SQLite로 버티다 PostgreSQL로 옮겨야 하는 시점의 신호

sqlite로 버티다 PostgreSQL로

“동시 접속자가 늘어나면 SQLite를 버려야 한다”고 생각하는 분이 많은데, 정확한 신호는 접속자 수가 아니라 동시 쓰기 충돌과 잠금 대기 시간입니다. 읽기 위주 트래픽은 SQLite WAL 모드에서도 꽤 오래 버팁니다. 반대로 쓰기 요청이 겹치기 시작하면 접속자가 몇 명 안 되는 개인 프로젝트에서도 database is locked 에러가 뜹니다. 이 글에서는 그 신호가 코드 레벨에서 정확히 어떻게 나타나는지, 그리고 PostgreSQL로 옮겨야 하는 조건과 옮겨도 해결되지 않는 부분을 짚어보겠습니다.

SQLite 잠금 구조부터 정확히 짚어보겠습니다

SQLite는 기본적으로 데이터베이스 파일 하나에 대해 쓰기 트랜잭션을 한 번에 하나만 허용합니다. WAL(Write-Ahead Logging) 모드를 켜면 읽기와 쓰기가 서로를 막지 않아 동시성이 크게 개선되지만, 쓰기끼리는 여전히 직렬화됩니다. 즉 쓰기 요청이 동시에 5개 들어오면 4개는 큐에서 기다리거나 타임아웃으로 실패합니다.

이 구조는 SQLite 공식 문서에도 명시돼 있습니다. SQLite 공식 문서의 ‘Appropriate Uses For SQLite’ 항목을 보면, 클라이언트가 많고 쓰기 경쟁이 잦은 서버 환경보다는 임베디드·로컬 스토리지 용도로 SQLite를 권장한다는 내용이 나옵니다. 반면 PostgreSQL은 MVCC(다중 버전 동시성 제어) 방식으로 트랜잭션마다 스냅숏을 따로 유지해서, 서로 다른 행을 건드리는 쓰기끼리는 대기 없이 동시에 처리됩니다.

동시 쓰기 신호는 이런 모습으로 나타납니다

실제 운영 중인 서비스라면 아래 신호가 로그나 모니터링에 먼저 잡힙니다.

  • 애플리케이션 로그에 sqlite3.OperationalError: database is locked 또는 SQLITE_BUSY 에러가 산발적으로 찍힙니다.
  • 쓰기 요청의 응답 시간이 평소보다 튀는데, 원인을 보면 쿼리 자체가 아니라 잠금 대기(busy_timeout)에서 소요됩니다.
  • 여러 워커 프로세스나 컨테이너가 같은 SQLite 파일에 동시에 접근하는 구조(예: 백그라운드 작업 큐, 워크플로 자동화 도구의 실행 이력 기록)에서 특히 자주 발생합니다.
  • WAL 모드를 켰는데도 체크포인트가 실행되는 순간 일시적으로 쓰기가 몰리는 현상이 보입니다.

여러 프로세스가 동시에 접근해 잠금 대기가 발생하는 데이터베이스 서버

이 신호가 하루 몇 번 수준이면 설정 튜닝으로 버틸 수 있지만, 트래픽이 늘면서 빈도가 꾸준히 늘어난다면 구조적인 한계로 봐야 합니다.

SQLITE_BUSY 에러를 코드로 재현해 보겠습니다

말로만 설명하면 감이 안 잡히니 Python 3의 표준 sqlite3 모듈로 직접 재현해 보겠습니다. 스레드 5개가 같은 파일에 동시에 INSERT를 시도하는 코드입니다.

import sqlite3
import threading
import time

sqlite3.connect("test.db").execute(
    "CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, msg TEXT)"
)

def writer(n):
    conn = sqlite3.connect("test.db", timeout=1)  # 잠금 대기 최대 1초
    try:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute("INSERT INTO logs (msg) VALUES (?)", (f"writer-{n}",))
        time.sleep(0.5)  # 쓰기 트랜잭션을 일부러 길게 유지
        conn.commit()
        print(f"writer-{n} 성공")
    except sqlite3.OperationalError as e:
        print(f"writer-{n} 실패: {e}")
    finally:
        conn.close()

threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

이 코드를 실행하면 대략 아래와 같은 출력이 나옵니다. (타이밍에 따라 성공하는 스레드 번호는 달라질 수 있습니다.)

writer-0 성공
writer-1 실패: database is locked
writer-2 실패: database is locked
writer-3 실패: database is locked
writer-4 실패: database is locked

timeout 값을 늘리거나 PRAGMA busy_timeout = 5000;을 설정하면 실패 대신 대기 시간이 늘어나는 쪽으로 바뀝니다. 즉 에러가 안 보인다고 문제가 사라진 게 아니라, 사용자 체감 응답 속도로 옮겨간 것뿐입니다. 이 대기 시간이 서비스 SLA에 영향을 줄 정도가 되면 그게 바로 옮겨야 하는 시점입니다.

PostgreSQL 전환, 어떤 기준으로 판단해야 할까요?

체크리스트 형태로 정리하면 판단이 쉬워집니다.

SQLite에서 PostgreSQL로 전환하는 데이터베이스 마이그레이션 인프라

판단 기준 SQLite로 버텨도 되는 경우 PostgreSQL 전환을 검토할 시점
쓰기 주체 프로세스 1개가 순차적으로 씀 여러 프로세스·컨테이너·워커가 동시에 씀
잠금 에러 빈도 거의 없거나 재시도로 해결됨 하루 수십 건 이상, 재시도해도 반복됨
트랜잭션 길이 짧고 즉시 커밋 외부 API 호출 등으로 트랜잭션이 길어짐
배포 구조 단일 서버, 단일 파일 다중 서버, 수평 확장 필요
필요한 기능 기본 SQL이면 충분 행 단위 잠금, 복제, 확장 인덱스 등 필요

PostgreSQL의 동시성 제어 방식은 공식 문서에서 자세히 설명하고 있습니다. PostgreSQL 공식 문서의 동시성 제어(MVCC) 소개를 보면, 읽기 트랜잭션이 쓰기를 막지 않는 것은 물론 서로 다른 행을 수정하는 쓰기 트랜잭션끼리도 잠금 경쟁 없이 진행된다는 점을 확인할 수 있습니다. 이 차이가 바로 동시 쓰기가 잦은 구조에서 SQLite와 PostgreSQL의 체감 성능을 가르는 지점입니다.

전환해도 해결되지 않는 부분과 트레이드오프

PostgreSQL로 옮긴다고 모든 문제가 사라지지는 않습니다. 별도의 서버 프로세스를 띄우고 운영해야 하니 백업, 버전 업그레이드, 접속 풀 관리 같은 작업이 새로 생깁니다. 로컬 파일 하나로 끝나던 배포가 네트워크 왕복이 있는 클라이언트-서버 구조로 바뀌면서, 아주 작은 쿼리 하나도 SQLite보다 느리게 느껴질 수 있습니다.

동시 쓰기가 원인이 아니라 단순히 파일 I/O가 느린 디스크 때문이라면 PostgreSQL로 옮겨도 근본 원인은 그대로 남습니다. 이런 경우에는 SQLite를 유지하면서 쓰기를 애플리케이션 레벨에서 큐잉해 순차 처리하는 방식으로도 충분히 버틸 수 있습니다. 반대로 트래픽이 크지 않은데 미리 PostgreSQL로 옮기면, 관리 비용만 늘고 실질적인 이득은 거의 없는 경우도 흔합니다.

busy_timeout을 늘렸는데 왜 여전히 잠기나요?

busy_timeout은 잠금이 풀릴 때까지 기다리는 시간을 늘려줄 뿐, 애초에 쓰기가 몰리는 근본 원인은 없애지 못합니다. 대기 시간을 5초, 10초로 늘리면 에러 로그는 줄어들지만, 그만큼 사용자는 응답을 더 오래 기다리게 됩니다. 대기 큐가 계속 쌓이는 상태라면 타임아웃 값을 조정하는 대신 쓰기 구조 자체를 점검해야 할 시점입니다. 트랜잭션 안에서 외부 API를 호출하거나 무거운 연산을 하고 있다면, 그 부분을 트랜잭션 밖으로 빼는 것만으로 잠금 시간이 눈에 띄게 줄어드는 경우도 많습니다.

SQLite로 버티는 동안 나오는 잠금 신호는 무시할 경고가 아니라 구조를 점검하라는 신호입니다. 에러 로그에 database is locked가 반복해서 찍히고, 동시에 쓰는 프로세스가 늘어나는 추세라면 PostgreSQL 전환을 이번 분기 로드맵에 올려두는 편이 안전합니다. 반대로 신호가 드물다면 busy_timeout 조정과 트랜잭션 최소화만으로도 충분히 더 버틸 수 있으니, 먼저 위 코드로 직접 재현해 보고 판단해 보시기 바랍니다.

LLM 앱 로깅, 프롬프트와 응답을 어디까지 남겨야 할까

Leave a Comment