3P by GN⁺ | ★ favorite | 댓글 2개
  • SQLite는 로컬 저장소와 임베디드 프로젝트에 널리 쓰이는 자체 완결형 RDBMS지만, 하위 호환성 때문에 안전성과 성능에 불리한 기본값을 유지하고 있음
  • 기본적으로 외래 키 제약을 강제하지 않고 ROWID를 재사용할 수 있어, 삭제된 사용자의 게시물이 같은 ID를 받은 다른 사용자에게 연결되는 데이터 무결성 문제가 생길 수 있음
  • 일반 테이블은 선언된 열과 다른 자료형도 저장하며, STRICT 테이블은 이를 막지만 테이블마다 strict를 지정해야 하고 사용자 정의 타입 이름을 활용하던 방식과 충돌함
  • 동시 쓰기에서 즉시 발생하는 SQLITE_BUSY, 비활성화된 WAL, 보수적인 동기화 설정은 각각 busy_timeout, journal_mode, synchronous 프라그마로 개선할 수 있음
  • PRAGMA edition = 2026 같은 연도 기반 에디션으로 안전한 기본값 묶음을 선택하게 하면, 기존 동작을 깨지 않고 SQLite의 기본 설정을 계속 발전시킬 수 있음

SQLite의 강점과 낡은 기본값

  • SQLite는 임베디드 프로젝트의 데이터베이스이자 로컬 데이터 저장의 업계 표준으로 부를 만하며, 일부 서버 소프트웨어인 lobste.rs도 SQLite에서 실행되고 있음
  • 별도 프로세스로 실행되는 전통적인 RDBMS와 달리 라이브러리 형태의 RDBMS여서 소프트웨어를 자체 완결형으로 유지할 수 있음
  • 일반 파일 형식처럼 사용자 정의 직렬화기와 파서를 작성할 필요가 없어, RDBMS와 파일 기반 저장 방식의 장점을 함께 제공함
  • 그러나 데이터 무결성, 동시성, 성능에 영향을 주는 여러 기본값이 부적절하다는 문제가 남아 있음

외래 키를 강제하지 않아 생기는 무결성 문제

  • 외래 키는 데이터베이스의 일관성을 유지하고 끊어진 참조를 방지하는 핵심 수단이지만, SQLite는 기본적으로 이를 강제하지 않음
  • posts.user_idusers.id를 참조하도록 선언해도 기본 설정에서는 다음 동작을 막지 못함
    • 유효한 사용자 ID가 없는 게시물 생성
    • 해당 사용자의 게시물을 남겨둔 채 사용자 삭제
  • INTEGER PRIMARY KEY는 테이블의 ROWID 별칭이 되며, SQLite의 ROWID 할당 알고리듬은 일부 상황에서 ID를 재사용함
    • Bob이 ID 1로 게시물을 작성한 뒤 계정만 삭제해도 외래 키 오류가 발생하지 않음
    • 이후 Alice가 재사용된 ID 1을 받으면 Bob의 기존 게시물이 Alice에게 연결됨
    • 조회 자체는 정상적으로 성공하므로 단순한 끊어진 참조보다 문제를 발견하기 어려움
  • 연결마다 다음 프라그마를 활성화하면 외래 키를 강제할 수 있음
PRAGMA foreign_keys = ON;
  • 이를 처음부터 적용했다면 게시물을 남긴 사용자 삭제는 FOREIGN KEY constraint failed 오류로 중단됨

선언과 다른 자료형을 허용하는 일반 테이블

  • SQLite 값은 NULL, INTEGER, REAL, TEXT, BLOB 가운데 하나지만, 일반 테이블의 열 선언은 저장 가능한 자료형을 엄격히 제한하지 않고 타입 친화도(type affinity) 를 결정함
  • INTEGER 친화도 열에는 다음 저장 규칙이 적용됨
    • 정수로 해석할 수 있는 TEXTINTEGER로 변환함
    • 실수로 해석할 수 있는 TEXT는 배정밀도 부동소수점인 REAL로 변환함
    • 그 밖의 값은 원래 자료형 그대로 저장함
  • 다른 친화도에도 별도 변환 규칙이 있음
    • BLOB 친화도는 값을 그대로 저장함
    • TEXT 친화도는 BLOB, TEXT, NULL을 그대로 두고 숫자를 TEXT로 변환함
    • REAL 친화도는 INTEGER 친화도처럼 동작하지만 정수를 REAL로 변환함
  • 이 때문에 duration_sec INTEGER 열에도 숫자가 아닌 'Way too long, I mean come on' 같은 문자열을 저장할 수 있음
  • 실제 프로젝트에서는 불리언용 숫자 1, 0 대신 문자열 '1', '0'이 기록되어 데이터를 정리하고 디버깅해야 했던 사례도 있었음

STRICT 테이블과 사용자 정의 타입의 충돌

  • STRICT 테이블은 열에 잘못된 자료형을 넣으면 cannot store TEXT value in INTEGER column 같은 타입 오류를 발생시킴
CREATE TABLE music (
  id INTEGER PRIMARY KEY,
  name TEXT,
  duration_sec INTEGER
) strict;
  • 모든 테이블을 전역적으로 엄격하게 만드는 프라그마는 없으므로, 각 CREATE TABLE 문에 strict를 빠짐없이 추가해야 함

  • SQLite는 유연한 타입이 유용하다는 입장이지만, STRICT 테이블에서도 ANY 자료형을 명시하면 모든 종류의 값을 저장하는 열을 만들 수 있음

    • 임의 자료형을 허용할 열과 특정 자료형을 강제할 열을 명시적으로 구분할 수 있음
  • 더 현실적인 호환성 문제는 STRICT 테이블이 자료형을 강제하는 데 그치지 않고 타입 지정자 해석 규칙까지 바꾼다는 점임

  • 일반 테이블이 타입 이름을 해석하는 방식

    • 일반 SQLite 테이블은 선언된 타입 문자열에 따라 친화도를 정함
    • "INT"가 포함되면 INTEGER로 처리함
    • "CHAR", "CLOB", "TEXT" 중 하나가 포함되면 TEXT가 됨
    • "BLOB"이 포함되거나 타입을 생략하면 BLOB으로 처리함
    • "REAL", "FLOA", "DOUB" 중 하나가 포함되면 REAL이 됨
    • 어느 조건에도 해당하지 않으면 NUMERIC으로 처리함
    • 느슨한 타입과 이 규칙을 결합하면 DATETIME, KEY_VALUE_SET, COLOR 같은 사용자 정의 타입 이름을 열에 붙일 수 있음
    • 데이터베이스 커넥터나 래퍼는 타입 이름을 보고 자동으로 직렬화·역직렬화할 수 있음
    • 별도 처리 기능이 없더라도 타입 이름 자체가 열의 예상 데이터를 문서화함
    • STRICT를 그대로 기본값으로 바꾸면 이 활용법을 포기해야 하므로, 명시적인 사용자 정의 타입 별칭이 필요함
    CREATE TYPE KEY_VALUE_SET = TEXT;
    
    • 엄격한 테이블에서 KEY_VALUE_SET 같은 별칭을 사용할 수 있다면, 애플리케이션에서 파싱해야 하는 TEXT 열의 예상 데이터 패턴도 스키마에 기록할 수 있음
    • 사용자 정의 타입에 CHECK 제약을 연결하는 기능도 유용함
    • SQL:1999 표준에는 제약까지 지원하는 타입 별칭 문법인 CREATE DOMAIN 이 이미 있으므로, SQLite가 이 표준 문장을 지원하는 방식으로 구현할 수 있음

동시 쓰기와 SQLITE_BUSY

  • SQLite는 여러 동시 읽기를 허용하지만, 쓰기는 한 번에 하나만 처리함
  • 기본 설정에서는 두 프로세스가 동시에 쓰기 잠금을 얻으려 할 때 하나가 기다리지 않고 즉시 SQLITE_BUSY 오류를 받음
    • 디스크 I/O를 수행하는 쓰기는 원래 느려질 수 있으므로, 일정 시간 잠금 해제를 기다리는 편이 더 자연스러움
    • 실제 시스템이 간헐적으로 중단되어 직접 재시도 루프를 작성해야 했던 사례도 있음
  • 다음 설정은 최대 5초 동안 잠금 획득을 재시도한 뒤에만 SQLITE_BUSY를 반환함
PRAGMA busy_timeout = 5000;
  • 정상 운영에서는 모든 쓰기를 단일 프로세스, 가능하면 단일 스레드에 모으는 편이 적절하며 동시 쓰기가 빠를 수는 없음
  • 다만 동시 쓰기가 필요한 정상적인 운영 사례도 있음
    • sqlite3 명령줄 도구로 데이터베이스를 대화형으로 정리
    • 별도 프런트엔드가 없는 드문 관리 작업용 스크립트 실행
  • 이런 작업은 기본 설정 때문에 예상하지 못한 SQLITE_BUSY를 일으켜 실행 중인 소프트웨어를 중단시킬 수 있음

WAL과 성능 기본값

  • SQLite는 올바르게 설정하면 PostgreSQL이나 MySQL 같은 대형 서버가 맡던 역할도 일부 수행할 만큼 빠를 수 있지만, 기본 성능은 좋지 않음
  • 서버 환경의 세부 튜닝은 Optimizing SQLite for servers에서 더 확인할 수 있음
  • 가장 중요한 기본값 문제는 Write-Ahead Log, 즉 WAL이 비활성화되어 있다는 점임
PRAGMA journal_mode = WAL;
  • WAL은 대부분의 상황에서 쓰기 속도를 크게 높임
  • 다음 설정을 함께 사용하면 데이터 손상 위험 없이 디스크 동기화 횟수를 크게 줄일 수 있음
PRAGMA synchronous = NORMAL;

연도 기반 SQLite 에디션

  • 기존 기본값을 지금 직접 변경하면 오래된 소프트웨어가 깨지고, 사용자가 향후 SQLite 업그레이드를 두려워하게 될 수 있음
  • 하위 호환성을 유지하면서 개선된 기본값을 선택하도록 하나의 상위 프라그마(super pragma) 를 추가할 수 있음
PRAGMA edition = 2026;
  • 2026 에디션은 최소한 다음 설정의 별칭으로 동작함
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
  • 이 에디션에서는 새 테이블의 strict 모드도 기본값으로 적용함
  • Rust editions처럼 기존 코드의 동작을 보존하면서 새로운 기본 동작을 명시적으로 선택하는 방식임
  • JavaScript의 "use strict"; 같은 단일 모드와 달리, 연도 기반 에디션은 시대에 따라 합리적인 기본값 묶음을 바꿀 수 있음
  • 예를 들어 Hctree의 WAL2가 2034년에 메인 브랜치에 들어간다면, 미래의 PRAGMA edition = 2034journal_mode = WAL2를 선택하도록 구성할 수 있음
  • 에디션 체계는 기존 프로그램의 기본 동작을 깨지 않으면서도 SQLite가 과거 설정에 묶이지 않고 안전성과 성능 기본값을 발전시키는 절충안이 될 수 있음

댓글과 토론

이론은 좋아 보이지만 호환성 충돌 및 오버헤드를 생각하면 손해일 것 같아요.
SQLite 는 이지스 같은 임베디드 환경에서 동작하기 위해 탄생한 DBMS 인데요,
void** 뻥뻥 때리고 weak 선언후에 함수 덮어씌우기 하는게 임베디드 세상이죠. 그런세상에서 저걸 굳이? 싶은 생각이 드네요

Lobste.rs 의견들
  • 어제 lobste.rs가 SQLite로 전환했다는 소식(https://lobste.rs/s/ko1ji1/lobste_rs_is_now_running_on_sqlite)을 보고 이 글을 썼음
    당시 대화를 읽으며 SQLite 기본값의 사소한 불편들이 떠올라 블로그 글로 정리함

    • 나도 그 대화를 읽고 같은 결론에 도달함
      여력이 있다면 이 글을 SQLite 포럼에도 공유할 만함 : https://sqlite.org/forum/forum
  • SQL에서 CREATE TYPE 은 복합 타입을 만들 때 사용하며, Postgres는 여기에 범위, 열거, 배열, 상당히 복잡한 기반 타입까지 지원함
    원하는 기능은 CREATE DOMAIN 에 가깝다. 기반 구체 타입으로 새타입(newtype)을 만들고 기본값과 제약 조건도 지정할 수 있어, 여러 테이블에서 같은 CHECK 제약 조건을 반복하는 스키마가 훨씬 간결해짐. 예를 들면 create domain mything as text; 또는 create domain mything as integer not null check (VALUE < 5);처럼 쓸 수 있음

    • 기존 타입 별칭 기능이 있는지 먼저 확인했어야 했음. 글에 관련 내용을 추가하겠음
  • busy_timeout만으로는 즉시 발생하는 SQLITE_BUSY 오류를 막을 수 없다는 꽤 위험한 함정도 다룰 필요가 있음
    읽기 전용 쿼리로 트랜잭션을 시작한 뒤 쓰기 쿼리를 실행해 보유한 잠금을 승격하려 할 때 다른 쓰기 작업이 있으면, busy_timeout 설정과 무관하게 즉시 실패함. 해결하려면 begin immediate 를 사용해야 함

    • 실제 버그가 아니라는 건 알지만, 솔직히 거의 버그처럼 느껴지는 동작
  • 제안한 에디션에 묶인 설정 중 일부는 SQLite 모델에서 연결 속성이고, 나머지는 데이터베이스 파일이나 개별 테이블의 속성이라는 점을 고려해야 함
    PRAGMA journal_mode = WAL은 데이터베이스 파일에 계속 유지되지만 PRAGMA foreign_keys = ON은 연결마다 매번 설정해야 함. STRICT는 개별 테이블의 속성이어서 한 데이터베이스 안에 엄격한 테이블과 그렇지 않은 테이블을 섞을 수도 있음. 따라서 이 통합 PRAGMA를 데이터베이스 파일과 연결 중 어디의 속성으로 정의할지 정해야 함. 구버전 라이브러리가 자신이 모르는 기능이 포함된 파일을 수정해 망가뜨리지 않도록 경고하는 내장 장치가 있는지도 궁금

  • 에디션 방식은 기본적으로 하위 호환성을 유지하려는 SQLite 개발진의 목표와, 최신 권장 설정을 원하는 사용자를 위한 덜 혼란스러운 기본값 사이의 좋은 절충안으로 보임

  • 최근 RocksDB에 푹 빠졌음. SQLite처럼 내장형 데이터베이스 라이브러리지만 SQL이 없다는 점이 오히려 큰 장점이라고 봄
    모든 작업을 SQL로 표현해야 하는 제약은 데이터베이스를 다루는 데 방해만 된다고 느낌

    • RocksDB는 키-값 데이터베이스이고 SQLite는 관계형 데이터베이스라서, 서로 다른 목적을 위해 만들어진 별개의 개념이라고 좀
      lobste.rs처럼 SQL을 사용하는 시스템을 RocksDB나 다른 키-값 데이터베이스로 어떻게 옮길지 상상하기 어려움
  • busy_timeout을 설정하는 것이 해결책이라는 데 동의하지 않음. SQLite는 단일 쓰기 스레드를 두고 서로 무관한 논리적 트랜잭션을 일괄 처리하면 성능이 매우 뛰어남
    구체적인 방식은 https://andersmurphy.com/2025/12/… 에 설명돼 있음

    • 일반적인 작업 부하에서는 쓰기 작업을 하나로 제한하고, 여러 쓰기 작업이 상시 경쟁하는 구조를 만들지 않는 편이 맞음
      하지만 정리 작업을 위해 데이터베이스를 직접 수정하거나, 별도 관리 화면까지 만들 필요는 없는 간헐적인 관리 작업을 스크립트로 실행하는 일도 흔함. 이때 운 나쁘게 실행 시점이 겹쳤다는 이유로 서버나 애플리케이션이 무작위로 중단될 수 있다는 건 좋지 않음