Tech_News

lobste.rs는 왜 MariaDB를 버리고 SQLite로 갔나 — DB를 줄여서 얻은 것들

TeEm0 2026. 7. 15. 09:00
728x90

"DB 서버를 하나 더 붙이자"는 얘기는 많이 들어봤어도 "DB 서버를 아예 없애자"는 얘기는 드물다. lobste.rs가 정확히 후자를 했다. MariaDB VPS를 통째로 내리고 애플리케이션 서버 안의 SQLite 파일 하나로 옮겼다. 결과는 CPU·메모리 사용량 감소, 체감 응답속도 개선, 그리고 VPS 비용 절반.

이게 왜 재밌냐면, 우리가 인프라 최적화라고 하면 보통 "더 추가"하는 방향으로만 생각하기 때문이다. 캐시 레이어 추가, 리드 레플리카 추가, 커넥션 풀러 추가. lobste.rs는 반대로 "빼는" 결정으로 단순성과 비용을 동시에 잡았다. 이번 글에서 그 과정과, 우리가 실제로 따라 하려 할 때 밟게 될 지뢰를 정리한다.

1. 왜 MariaDB를 버렸나 — 동기와 배경

사실 이 마이그레이션은 갑자기 나온 게 아니다. 논의는 2019년 이슈 #539에서 시작됐다. 당시엔 MariaDB에서 벗어나자는 얘기였고, 대안으로 PostgreSQL이 유력했다. PostgreSQL은 대부분의 팀에서 "고민 없이 고르는 기본값"이니까 자연스러운 선택이었다.

그런데 2025년에 실제로 작업할 지원자가 나타났을 때, 그 사람이 SQLite로 가고 싶어 했다. 여기서 lobste.rs 팀의 판단이 흥미롭다. 원문 댓글에서 관리자가 직접 밝힌 이유는 이렇다.

PostgreSQL은 평소 기본 선택이지만 별도 서비스를 운영·조정·유지해야 하는 부담이 있음. 예상 수요보다 크고 복잡한 해법을 피하고 싶어 SQLite를 선택함.

이게 핵심이다. lobste.rs는 단일 서버로 충분히 돌아가는 규모의 커뮤니티 사이트다. 이런 서비스에 PostgreSQL을 붙인다는 건 곧 별도 프로세스, 별도 VPS, 커넥션 관리, 백업 파이프라인, 버전 업그레이드 운영을 짊어진다는 뜻이다. "우리 규모에서 그게 정말 필요한가?"라는 질문에 정직하게 아니라고 답한 결과가 SQLite였다.

실무에서 우리도 이 질문을 자주 회피한다. "일단 Postgres 깔자, 나중에 커지면 편하니까." 그런데 그 "나중"이 안 오는 서비스가 훨씬 많다. lobste.rs는 오지 않을 미래를 위해 지금의 운영 복잡도를 지불하기를 거부한 거다.

2. SQLite는 프로덕션에서 쓸 수 있나 — 오해와 실제

여기서 대부분의 엔지니어가 멈칫한다. "SQLite는 임베디드용 아냐? 동시성이 안 되잖아." 반은 맞고 반은 틀리다.

SQLite의 동시성 모델을 정확히 이해하는 게 중요하다. 원문 FAQ 논의를 정리하면 이렇다.

  • 읽기는 여러 프로세스에서 동시에 가능
  • 쓰기는 한 번에 하나만 가능 (파일 시스템 잠금으로 보장)
  • 쓰기가 겹치면 명시적 큐가 아니라 "잠깐 대기 후 재시도" 방식으로 처리된다

비유하자면 화장실이 하나인 카페다. 읽기(구경)는 여러 명이 동시에 해도 되지만, 쓰기(화장실)는 한 명씩 순서대로다. lobste.rs 같은 사이트는 읽기가 쓰기보다 압도적으로 많은 read-heavy 워크로드다. 게시물 하나 쓰면 수백 번 읽힌다. 이런 패턴에서는 "쓰기 하나만 가능"이 실질적 병목이 되지 않는다.

그리고 "SQLite는 단일 서버여야 한다"는 제약도 있다. NFS로 파일 공유하는 방식은 공식적으로 권장되지 않는다. 그래서 SQLite를 쓴다는 건 곧 "우리 서비스는 한 대로 돌린다"는 아키텍처 선언이기도 하다. 요즘 서버 한 대 스펙이면 상당한 규모를 감당하니, 생각보다 많은 서비스가 여기 해당한다.

실제 규모 감을 위해: lobste.rs의 SQLite 파일 크기는 원문 댓글 기준 약 3.8GB라고 언급됐다. 커뮤니티 사이트 전체 데이터가 4GB 미만이라는 얘기다. 이 정도면 파일 하나로 충분히 관리된다.

3. 마이그레이션 과정 — 스키마 변환과 첫 배포 실패

이번 사례에서 가장 배울 게 많은 부분이 여기다. 첫 배포는 실패했다.

첫 배포가 CPU 100% 찍고 롤백된 이유

2월 21일 첫 배포에서, 읽기 전용 트래픽만으로 모든 CPU가 100%에 도달했다. 사이트를 읽기 전용 상태로 열었는데도 서버가 못 버틴 거다. 원인을 못 찾아 롤백했다.

가장 뼈아팠던 조건은 이거다. 팀이 프로덕션 DB에 접근할 수 없었다. 실제 데이터로 성능을 미리 재현할 방법이 없었다는 뜻이다. 그래서 로컬에서 실제 데이터 절반 크기의 테스트 데이터를 생성했는데, 이걸 만드는 데만 일주일이 걸렸다.

원인은 결국 두 가지였다.

  1. 대형 테이블에서 두 개의 쿼리가 전체 테이블 스캔(full table scan)을 일으킴
  2. 별도의 N+1 문제

여기서 실무 교훈. MariaDB에서 잘 돌던 쿼리가 SQLite에서 똑같이 잘 돈다는 보장이 없다. 옵티마이저가 다르고, 인덱스 활용 방식이 다르다. MariaDB가 알아서 커버해주던 비효율 쿼리가 SQLite로 오면 그대로 풀스캔으로 터질 수 있다.

lobste.rs 팀이 회고에서 남긴 가장 실용적인 조언이 이거다. "테스트 중 전체 테이블 스캔이 발생하면 테스트가 실패하도록 설정할 수 있었다면 첫 배포 문제를 미리 잡았을 것." 이건 우리도 바로 적용할 수 있다. SQLite는 쿼리 플랜을 확인할 수 있다.

# 쿼리가 인덱스를 타는지 풀스캔하는지 확인
sqlite3 lobsters.db "EXPLAIN QUERY PLAN
  SELECT * FROM stories WHERE user_id = 42 ORDER BY created_at DESC;"

# 인덱스 잘 타는 경우 출력 예시
QUERY PLAN
`--SEARCH stories USING INDEX index_stories_on_user_id (user_id=?)

# 풀스캔 나는 경우 출력 예시 (이게 나오면 위험 신호)
QUERY PLAN
`--SCAN stories

출력에 SCAN이 뜨고 그 대상이 큰 테이블이면 인덱스를 검토해야 한다. SEARCH ... USING INDEX가 나오면 정상이다. 이걸 CI 테스트에 엮어서 "큰 테이블에 SCAN이 뜨면 빌드 실패"로 만들면 lobste.rs가 겪은 사고를 예방할 수 있다.

스키마 변환에서 걸린 호환성 문제

MariaDB와 SQLite는 타입 시스템과 함수가 다르다. lobste.rs가 실제로 부딪힌 것들:

  • unsigned bigint 미지원: MariaDB에서 일부 ID에 쓰던 unsigned bigint를 SQLite가 지원하지 않아 그냥 bigint로 변경했다.
  • 없는 함수 직접 구현: SQLite에 없던 regexp, if, stddev를 SQLite gem의 사용자 정의 함수(UDF)로 구현했다. (참고로 최신 SQLite에는 iif 함수가 이미 있어서, 이 부분은 버전 확인이 필요하다.)
  • 정렬/대소문자 규칙: MariaDB에서 utf8mb4_general_ci를 쓰던 걸 SQLite에서는 NOCASE로 바꿨는데, NOCASE는 ASCII 문자만 대소문자를 구분 없이 처리한다. 전체 UTF 대소문자 접기(case folding)는 안 된다.

이 마지막 항목은 한국어 서비스에 특히 중요하다. 한글은 대소문자 개념이 없어서 직접 영향은 적지만, 영문/유니코드가 섞인 검색·정렬에서 MariaDB와 다르게 동작할 수 있다. "왜 대문자로 검색하면 결과가 다르지?" 같은 버그가 여기서 나온다.

또 전문 검색(full-text search)은 SQLite FTS를 쓰는데, 순위 산정 알고리즘이 MariaDB와 달라서 검색 결과 순서가 바뀔 수 있다. lobste.rs 팀도 "검색 테스트는 있지만 순위 검증은 적어서 기능적 변화 가능성이 있다"고 인정했다. 마이그레이션 후 검색 결과가 미묘하게 달라지는 건 각오해야 한다.

4. 운영 결과 — 실제로 뭐가 좋아졌나

7월 11일 두 번째 배포는 성공했다. 결과를 원문 기준으로 정리하면:

  • 전환 후 사이트 정상 운영, CPU·메모리 사용량 안정
  • 월요일 트래픽 급증(주간 피크)에서도 문제 없음
  • CPU·메모리 사용량 감소, 체감 응답성 개선
  • MariaDB VPS 종료로 VPS 비용 절반으로 감소

여기서 짚어야 할 포인트. 관리자도 "놀랍게도 CPU와 RAM이 모두 감소했다"고 했다. 직관과 반대다. DB를 앱 서버 안으로 끌고 왔는데 왜 리소스가 줄었을까?

추측이지만 합리적인 설명은 이렇다. MariaDB 시절엔 별도 VPS와 앱 서버 사이에 네트워크 왕복, 커넥션 관리, 직렬화/역직렬화 오버헤드가 있었다. SQLite는 같은 프로세스 안에서 함수 호출로 데이터를 읽으니 이 오버헤드가 통째로 사라진다. 네트워크를 안 타는 게 생각보다 크다. (단, 이건 이번 사례에 대한 해석이고, 모든 워크로드에서 재현되는 일반 법칙은 아니다.)

주의할 점: 이 "비용 절반, 리소스 감소"는 lobste.rs 규모와 read-heavy 패턴에서 나온 결과다. 원문에 구체적 수치(전후 CPU %, 응답시간 ms)는 차트로만 언급됐고 텍스트로는 없다. 우리 서비스에 그대로 대입하지 말고 방향성만 참고하자.

5. SQLite 프로덕션 운영 시 주의할 점 — 흔한 함정

기본 설정으로 쓰면 안 된다

SQLite를 서버에서 제대로 쓰려면 PRAGMA 튜닝이 필수다. 관리자가 원문에 공개한 lobste.rs의 실제 설정이 좋은 출발점이다.

PRAGMA foreign_keys=ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 134217728;
PRAGMA journal_size_limit = 67108864;
PRAGMA cache_size = 2000;

핵심만 보면:

  • journal_mode = WAL: Write-Ahead Logging. 이게 없으면 읽기가 쓰기를 블록한다. 서버용이면 필수다.
  • busy_timeout = 5000: 쓰기가 겹쳐서 잠겼을 때 5초까지 재시도하고 기다린다. 이게 없으면 바로 에러가 터진다.
  • synchronous = NORMAL: WAL 모드와 조합하면 성능과 안정성의 균형점.

흔한 함정: SQLITE_BUSY

SQLite를 서버에서 처음 굴리면 거의 반드시 만나는 에러가 이거다.

SQLite3::BusyException: database is locked (SQLite3::BusyException)

또는 로그에 이렇게 뜬다.

Error: database is locked (5) (SQLITE_BUSY)

쓰기 트랜잭션이 겹쳤는데 대기 시간을 초과했을 때 발생한다. 원인은 보통 둘 중 하나다.

  1. busy_timeout이 설정 안 됐거나 너무 짧다
  2. 트랜잭션이 지연 모드(deferred)로 시작돼서 잠금 획득 시점이 꼬였다

관리자가 중요한 힌트를 남겼다. "busy_timeout이 제대로 작동하려면 즉시 트랜잭션 모드(immediate transaction)도 켜야 한다." 기본 deferred 트랜잭션은 첫 쓰기 순간에야 잠금을 잡으려 하는데, 이때 이미 다른 쓰기가 진행 중이면 busy_timeout이 기대대로 안 먹힐 수 있다. immediate 모드는 트랜잭션 시작 시점에 쓰기 잠금을 확보하러 가서 이 경합을 깔끔하게 처리한다.

Rails라면 database.yml에서 이렇게 걸 수 있다.

# config/database.yml
production:
  adapter: sqlite3
  database: storage/production.sqlite3
  timeout: 5000
  # 트랜잭션 기본 모드를 IMMEDIATE로
  default_transaction_mode: IMMEDIATE
  pragmas:
    journal_mode: WAL
    synchronous: NORMAL
    foreign_keys: true
    busy_timeout: 5000
    cache_size: 2000
    temp_store: MEMORY

흔한 함정: NVMe와 안 맞는 기본값

관리자가 희미하게 기억한다며 남긴 얘기가 하나 있다. SQLite의 디스크 쓰기 방식 일부가 HDD에는 유리하지만 NVMe에는 부적합했던 부분이 있었다는 거다. 요즘 서버는 대부분 NVMe SSD니까, 기본값을 무조건 신뢰하지 말고 워크로드로 벤치마크해보는 게 안전하다. (구체적으로 어떤 PRAGMA인지는 원문에 명시 안 됐다. 공식 문서 확인 필요.)

백업은 어떻게?

파일 하나라서 백업이 오히려 단순하다. lobste.rs는 매일 밤 실행되는 작업이 restic을 호출하는 방식을 쓴다. 다만 WAL 모드에서는 파일을 그냥 복사하면 WAL에 아직 반영 안 된 데이터가 누락될 수 있으니, 안전하게 하려면 SQLite의 백업 API나 온라인 백업 명령을 쓰는 게 좋다.

# WAL을 고려한 안전한 온라인 백업 (사이트 돌아가는 중에도 OK)
sqlite3 production.sqlite3 ".backup '/backups/production-$(date +%F).sqlite3'"

# 백업 파일 무결성 검증
sqlite3 /backups/production-2025-01-15.sqlite3 "PRAGMA integrity_check;"

# 정상이면 출력
ok

실시간 복제/PITR이 필요하면 원문 댓글에서도 언급된 litestream 같은 도구를 검토하면 된다. lobste.rs는 restic 야간 백업으로 충분하다고 판단한 케이스다.

6. 정리 — 언제 이 선택이 옳은가

한 줄 요약: read-heavy이고 단일 서버로 충분한 규모라면, SQLite로 옮기는 게 비용과 운영 복잡도를 동시에 줄이는 현실적 선택이 될 수 있다.

이 선택이 맞는 경우:

  • 읽기가 쓰기보다 압도적으로 많은 워크로드 (커뮤니티, 블로그, 문서 사이트, 대시보드)
  • 단일 서버 아키텍처로 감당되는 규모 (데이터 수 GB 수준, 앞으로도 수평 확장 계획 없음)
  • 별도 DB 서버 운영 부담과 비용을 줄이고 싶은 소규모 팀

이 선택이 위험한 경우:

  • 쓰기가 매우 많거나 여러 서버가 동시에 써야 하는 워크로드 → 쓰기 직렬화가 병목이 된다
  • 수평 확장이 전제인 서비스 → SQLite는 단일 서버 가정을 강제한다
  • MariaDB/MySQL의 특정 함수·타입·정렬 규칙에 깊게 의존하는 코드베이스 → 호환성 작업 비용이 크다

lobste.rs 사례의 진짜 교훈은 SQLite 자체보다 그 판단 과정에 있다고 본다. "기본값으로 PostgreSQL"이라는 관성을 의심하고, 우리 서비스의 실제 규모와 접근 패턴을 정직하게 본 다음, 오지 않을 미래를 위한 복잡도를 지불하지 않기로 한 결정. 그리고 프로덕션 DB 접근 없이 배포했다가 CPU 100%로 롤백한 실패 경험까지 투명하게 공개한 것. 이 두 가지가 이 글이 단순 릴리스 공지가 아닌 이유다.

덧붙여, 마이그레이션 전에 꼭 하고 넘어갈 것 하나만 고르라면 "실제 데이터 규모의 테스트 셋으로 EXPLAIN QUERY PLAN을 돌려 풀스캔을 사전에 잡는 것"이다. lobste.rs가 일주일 데이터 생성하고 CPU 100% 맞고 나서야 배운 걸, 우리는 미리 하면 된다.

참고 자료