SQLite란? 서버 없이 쓰는 파일 기반 데이터베이스의 장점, 한계, 실무 사용법

요약

SQLite는 서버를 따로 띄우지 않고 애플리케이션 프로세스 안에서 동작하는 파일 기반 관계형 데이터베이스입니다. “가볍다”는 말 때문에 단순 캐시 정도로 오해하기 쉽지만, 트랜잭션·인덱스·조인·WAL 같은 기능을 갖춘 실제 SQL 데이터베이스이며 모바일 앱, 데스크톱 앱, 임베디드 장비, 테스트 환경, 소규모 웹 서비스에서 널리 쓰입니다. 다만 동시 쓰기 처리, 타입 규칙, 외래 키 설정, 네트워크 파일시스템 같은 부분은 서버형 DB와 다르게 접근해야 합니다.

목차

SQLite가 무엇인지 먼저 정리하기

SQLite는 sqlite3 라이브러리가 애플리케이션 안에 직접 포함되어 동작하는 임베디드 SQL 데이터베이스 엔진입니다. MySQL, PostgreSQL처럼 별도의 DB 서버 프로세스에 TCP로 접속하는 구조가 아니라, 애플리케이션이 같은 프로세스 안에서 SQLite 라이브러리를 호출하고 데이터는 하나의 데이터베이스 파일에 저장됩니다.

공식 문서는 SQLite를 “self-contained, serverless, zero-configuration, transactional SQL database engine”으로 설명합니다. 이 표현을 실무 관점으로 풀면 다음과 같습니다.

특징 의미 실무에서의 장점
Serverless 별도 DB 서버가 없음 배포·설정·운영 부담이 작음
Self-contained 기능 대부분이 작은 라이브러리 안에 포함 모바일/데스크톱/CLI에 넣기 쉬움
Zero-configuration 설치 후 서버 설정이 거의 없음 테스트, 로컬 개발, 샘플 앱에 적합
Transactional ACID 트랜잭션 지원 파일 DB지만 원자적 변경을 기대할 수 있음
Public domain 퍼블릭 도메인 라이선스 상용/개인 프로젝트에서 사용 부담이 작음

이전에 정리한 WatermelonDB 글에서도 SQLite가 내부 저장소로 자주 등장합니다. WatermelonDB, Android Room, iOS Core Data의 SQLite 백엔드처럼 “사용자는 라이브러리를 쓰지만 실제 저장은 SQLite 파일에 맡기는” 구조도 많습니다.

SQLite가 잘 맞는 상황과 피해야 할 상황

SQLite를 고를 때 가장 중요한 기준은 “데이터가 한 애플리케이션 인스턴스 또는 한 장치 중심으로 움직이는가?”입니다. SQLite는 파일 하나로 관리되는 로컬 데이터베이스에 강하고, 여러 서버가 동시에 높은 쓰기 부하를 처리하는 중앙 DB 역할에는 맞지 않습니다.

SQLite가 잘 맞는 경우

  • 모바일 앱의 로컬 저장소
  • 데스크톱 앱, Electron 앱, CLI 도구의 설정/상태 저장
  • IoT·임베디드 장비의 로컬 데이터 기록
  • 테스트용 DB, 데모 앱, 로컬 개발 환경
  • 읽기 위주 또는 쓰기 경쟁이 낮은 소규모 서비스
  • 애플리케이션 파일 포맷처럼 데이터를 하나의 파일로 묶어 이동해야 하는 경우

특히 앱 내부 데이터는 네트워크 연결이 끊겨도 계속 읽고 쓸 수 있어야 하는 경우가 많습니다. 이때 SQLite는 서버 의존성이 없고 파일 백업도 비교적 단순하기 때문에 오프라인 우선 구조와 잘 맞습니다.

SQLite를 신중하게 봐야 하는 경우

  • 여러 서버 인스턴스가 같은 DB 파일에 동시에 쓰려는 구조
  • 초당 많은 쓰기 트랜잭션이 지속적으로 발생하는 중앙 서비스
  • DB 서버의 접근 제어, 사용자 권한, 네트워크 접속 관리가 필요한 환경
  • 데이터베이스 파일을 NFS/SMB 같은 네트워크 파일시스템 위에 두려는 환경
  • 복잡한 운영 모니터링, 복제, 장애 조치가 중요한 서비스

SQLite가 “작아서 약한 DB”라는 뜻은 아닙니다. 문제는 역할입니다. 로컬 파일 DB로 쓰면 강력하지만, PostgreSQL이나 MySQL이 담당하는 중앙 서버 DB 역할까지 그대로 대체하려고 하면 락, 백업, 운영 권한, 동시성에서 한계가 빨리 드러납니다.

핵심 동작 방식: 파일, 트랜잭션, 타입, WAL

하나의 데이터베이스 파일

SQLite 데이터베이스는 보통 .sqlite, .db 같은 하나의 파일로 저장됩니다. 이 파일 안에 테이블, 인덱스, 트리거, 뷰가 함께 들어갑니다. 그래서 백업은 단순히 파일 복사처럼 보이지만, 실행 중인 DB 파일을 복사할 때는 트랜잭션 상태와 WAL 파일까지 고려해야 합니다.

운영 중 안전한 백업이 필요하다면 SQLite CLI의 .backup 명령이나 온라인 백업 API를 사용하는 편이 좋습니다.

sqlite3 app.db ".backup 'backup-$(date +%Y%m%d).db'"

트랜잭션과 동시성

SQLite는 트랜잭션을 지원합니다. 여러 SQL 문을 하나의 작업 단위로 묶으면 중간에 실패했을 때 롤백할 수 있습니다.

BEGIN IMMEDIATE;

UPDATE accounts
SET balance = balance - 10000
WHERE id = 1;

UPDATE accounts
SET balance = balance + 10000
WHERE id = 2;

COMMIT;

동시성에서 중요한 점은 동시에 여러 읽기는 가능하지만, 같은 DB 파일에 대한 쓰기는 기본적으로 한 번에 하나의 writer 중심으로 조정된다는 점입니다. WAL 모드를 사용하면 읽기와 쓰기의 충돌을 줄일 수 있지만, 쓰기 경쟁이 매우 높은 워크로드를 무한히 해결해 주는 것은 아닙니다.

WAL 모드

WAL(Write-Ahead Logging)은 변경 내용을 별도의 WAL 파일에 먼저 기록하는 방식입니다. 공식 문서는 WAL이 대부분의 시나리오에서 더 빠를 수 있고, reader가 writer를 막지 않고 writer가 reader를 막지 않는 방식으로 더 나은 동시성을 제공한다고 설명합니다.

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;

위 설정은 앱 개발에서 자주 쓰이지만, 모든 환경에서 무조건 정답은 아닙니다. WAL은 데이터베이스 파일 옆에 app.db-wal, app.db-shm 파일을 만들 수 있고, 네트워크 파일시스템이나 여러 프로세스 배포 구조에서는 주의가 필요합니다.

유연한 타입 시스템과 STRICT 테이블

SQLite는 PostgreSQL이나 MySQL처럼 컬럼 타입이 값을 강하게 제한하는 방식과 다릅니다. 공식 문서 표현대로 SQLite는 값 자체에 타입이 붙는 동적 타입 시스템을 사용합니다. 예를 들어 INTEGER로 선언한 컬럼에도 상황에 따라 다른 타입 값이 들어갈 수 있습니다.

이 유연성은 편할 때도 있지만, 실무에서는 “왜 문자열이 숫자 컬럼에 들어갔지?” 같은 버그를 만들 수 있습니다. 더 엄격한 타입 검사가 필요하면 SQLite 3.37.0부터 도입된 STRICT 테이블을 고려할 수 있습니다.

CREATE TABLE users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  age INTEGER CHECK (age >= 0),
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;

외래 키는 연결마다 켜는 습관이 안전하다

SQLite는 외래 키 제약을 지원하지만, 공식 문서 기준으로 과거 호환성 때문에 기본값을 가정하지 말고 연결마다 PRAGMA foreign_keys = ON;을 명시하는 습관이 안전합니다.

PRAGMA foreign_keys = ON;

CREATE TABLE authors (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE posts (
  id INTEGER PRIMARY KEY,
  author_id INTEGER NOT NULL,
  title TEXT NOT NULL,
  FOREIGN KEY (author_id) REFERENCES authors(id)
);

Node.js, Python, React Native, Android 등에서 SQLite 연결을 새로 만들 때마다 이 설정이 적용되는지 확인해야 합니다. ORM이나 래퍼 라이브러리를 쓰더라도 실제 연결 옵션을 확인하지 않으면 외래 키가 기대와 다르게 동작할 수 있습니다.

바로 실행해 보는 SQLite 기본 예제

macOS와 많은 Linux 배포판에는 sqlite3 CLI가 이미 포함되어 있거나 패키지 매니저로 쉽게 설치할 수 있습니다.

sqlite3 blog.db

간단한 테이블을 만들고 데이터를 넣어 보겠습니다.

PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;

CREATE TABLE IF NOT EXISTS notes (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;

INSERT INTO notes (title, body)
VALUES ('SQLite 테스트', '서버 없이 파일 하나로 저장되는 로컬 DB 예제입니다.');

SELECT id, title, created_at
FROM notes
ORDER BY id DESC;

CLI에서 한 번에 실행하려면 다음처럼 사용할 수 있습니다.

sqlite3 blog.db <<'SQL'
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;

CREATE TABLE IF NOT EXISTS notes (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;

INSERT INTO notes (title, body)
VALUES ('SQLite 테스트', '서버 없이 파일 하나로 저장되는 로컬 DB 예제입니다.');

SELECT id, title, created_at
FROM notes
ORDER BY id DESC;
SQL

Python에서는 표준 라이브러리 sqlite3만으로도 사용할 수 있습니다.

import sqlite3

with sqlite3.connect("blog.db") as conn:
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA busy_timeout = 5000")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS notes (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            body TEXT NOT NULL,
            created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        ) STRICT
    """)
    conn.execute(
        "INSERT INTO notes (title, body) VALUES (?, ?)",
        ("SQLite 테스트", "파라미터 바인딩으로 SQL 인젝션을 피합니다.")
    )
    rows = conn.execute("SELECT id, title FROM notes ORDER BY id DESC").fetchall()

print(rows)

여기서 중요한 부분은 문자열 포매팅으로 SQL을 직접 조립하지 않고 ? 파라미터를 사용했다는 점입니다. SQLite가 로컬 DB라고 해서 SQL 인젝션 위험이 사라지는 것은 아닙니다.

실무 모범 사례

1. 연결 초기화 SQL을 명확히 둔다

SQLite는 연결 단위 설정이 많습니다. 앱 시작 시 또는 커넥션 생성 시점에 초기화 SQL을 한 곳에 모아 두면 환경 차이로 인한 버그를 줄일 수 있습니다.

PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;

단, journal_mode는 DB 파일에 저장되는 설정이고 실행 환경에 따라 결과가 달라질 수 있으므로 실제 반환값도 확인하는 편이 좋습니다.

PRAGMA journal_mode;

2. 쓰기 트랜잭션은 짧게 유지한다

SQLite에서 긴 쓰기 트랜잭션은 다른 쓰기 작업을 기다리게 만들 수 있습니다. 파일 다운로드, 네트워크 호출, 이미지 처리 같은 느린 작업을 트랜잭션 안에 넣지 말고, DB 변경에 필요한 최소 구간만 트랜잭션으로 묶는 것이 좋습니다.

3. 인덱스는 쿼리 패턴 기준으로 만든다

SQLite도 인덱스 설계가 중요합니다. 자주 검색하거나 정렬하는 컬럼에는 인덱스를 고려하고, EXPLAIN QUERY PLAN으로 실제 사용 여부를 확인합니다.

CREATE INDEX IF NOT EXISTS idx_notes_created_at
ON notes (created_at DESC);

EXPLAIN QUERY PLAN
SELECT id, title
FROM notes
WHERE created_at >= '2026-01-01'
ORDER BY created_at DESC;

4. 날짜/시간 저장 형식을 팀에서 정한다

SQLite에는 별도의 전용 날짜 타입이 없습니다. 보통 ISO-8601 문자열, Unix time 정수, Julian day 숫자 중 하나를 선택합니다. 애플리케이션에서 타임존 규칙까지 함께 정하지 않으면 정렬, 비교, 표시에서 혼란이 생깁니다.

실무에서는 UTC 기준 ISO-8601 문자열을 저장하고 화면 표시 시 사용자 타임존으로 변환하는 방식이 이해하기 쉽습니다.

5. 백업은 파일 복사보다 SQLite 도구를 우선한다

앱이 DB를 사용 중일 때 단순 파일 복사를 하면 WAL 파일이나 체크포인트 상태를 놓칠 수 있습니다. 운영 중 백업은 .backup 또는 SQLite 온라인 백업 API를 우선 검토하세요.

sqlite3 app.db ".backup backup.db"
sqlite3 backup.db "PRAGMA integrity_check;"

흔한 오해와 실수

“SQLite는 테스트용 장난감 DB다”

아닙니다. SQLite는 브라우저, 모바일 OS, 앱, 임베디드 장비 등 매우 넓은 범위에서 사용됩니다. 다만 서버형 DB와 목적이 다릅니다. 로컬·임베디드·읽기 위주·단일 파일 중심의 문제에 강합니다.

“WAL을 켜면 동시 쓰기 문제가 모두 해결된다”

WAL은 reader와 writer의 충돌을 줄이는 데 도움이 됩니다. 하지만 같은 DB 파일에 대해 쓰기 경쟁이 높은 구조에서는 여전히 한계가 있습니다. 쓰기 작업을 큐로 직렬화하거나, 트랜잭션을 짧게 만들거나, 정말 중앙 DB가 필요한 구조인지 다시 판단해야 합니다.

“INTEGER PRIMARY KEY에는 AUTOINCREMENT를 항상 붙여야 한다”

SQLite 공식 문서는 AUTOINCREMENT가 추가 CPU, 메모리, 디스크, I/O 오버헤드를 만들며 엄격히 필요하지 않다면 피하라고 설명합니다. 대부분은 INTEGER PRIMARY KEY만으로 충분합니다. AUTOINCREMENT는 이전에 사용한 ROWID를 재사용하지 않아야 하는 특수 요구가 있을 때만 검토하세요.

-- 대부분의 경우 충분합니다.
CREATE TABLE items (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);

-- 이전 ROWID 재사용 방지 요구가 있을 때만 신중히 검토합니다.
CREATE TABLE audit_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  message TEXT NOT NULL
);

“외래 키를 선언했으니 자동으로 항상 검사된다”

외래 키 선언과 실제 검사는 별개로 봐야 합니다. 연결마다 PRAGMA foreign_keys = ON;을 적용하고, 테스트에서도 제약 위반이 실제로 막히는지 확인해야 합니다.

실패 사례와 해결

사례 1. database is locked 오류가 반복된다

증상: 앱에서 간헐적으로 database is locked, SQLITE_BUSY 오류가 발생합니다. 특히 여러 스레드나 여러 프로세스가 동시에 쓰기 작업을 할 때 자주 보입니다.

주요 원인

  • 긴 쓰기 트랜잭션이 락을 오래 잡고 있음
  • 연결별 busy_timeout이 너무 짧거나 없음
  • 같은 DB 파일에 여러 writer가 동시에 접근
  • 트랜잭션 안에서 네트워크 호출 같은 느린 작업 수행
  • 네트워크 파일시스템 위에서 SQLite 파일 사용

확인 방법

PRAGMA journal_mode;
PRAGMA busy_timeout;
# macOS/Linux에서 DB 파일을 잡고 있는 프로세스 확인 예시
lsof app.db

해결 방향

PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
  • 쓰기 트랜잭션을 짧게 유지합니다.
  • 한 앱 안에서는 쓰기 큐를 두고 순차 처리하는 방식을 고려합니다.
  • SQLite 파일을 네트워크 공유 폴더에 두지 않습니다.
  • 서버 여러 대가 같은 DB 파일을 공유해야 한다면 SQLite가 아니라 PostgreSQL/MySQL 같은 서버형 DB를 검토합니다.

사례 2. 외래 키가 걸려 있는데 잘못된 데이터가 들어간다

증상: FOREIGN KEY를 선언했는데 부모 row가 없는 데이터가 insert됩니다.

원인: SQLite 연결에서 외래 키 검사가 켜져 있지 않을 가능성이 큽니다. 공식 문서 기준으로 외래 키는 PRAGMA foreign_keys = ON;을 통해 런타임에 활성화해야 하며, 연결마다 적용해야 합니다.

확인 명령

PRAGMA foreign_keys;
PRAGMA foreign_key_check;

해결 방법

PRAGMA foreign_keys = ON;

애플리케이션 코드에서는 커넥션을 만들 때마다 초기화 루틴을 실행하세요. ORM을 사용한다면 ORM의 SQLite 연결 옵션이나 이벤트 훅에서 이 PRAGMA가 적용되는지 확인해야 합니다.

사례 3. 숫자 컬럼에 문자열이 들어가서 나중에 정렬이 이상하다

증상: INTEGER로 선언한 컬럼인데 문자열 값이 들어갔고, 정렬이나 비교 결과가 기대와 다릅니다.

원인: SQLite의 기본 타입 시스템은 유연합니다. 컬럼 선언만으로 모든 타입 오류가 강하게 막힌다고 생각하면 안 됩니다.

확인 명령

SELECT id, age, typeof(age)
FROM users
WHERE typeof(age) <> 'integer';

해결 방법

  • 새 테이블은 STRICT 테이블을 검토합니다.
  • 입력 단계에서 애플리케이션 검증을 추가합니다.
  • 기존 데이터는 마이그레이션 전 typeof()로 점검합니다.
CREATE TABLE users_new (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  age INTEGER CHECK (age >= 0)
) STRICT;

사례 4. 백업 파일을 복사했는데 복원 후 데이터가 일부 빠져 있다

증상: 운영 중 .db 파일만 복사했는데 복원한 DB에 최근 데이터가 보이지 않습니다.

원인: WAL 모드에서는 변경 내용이 -wal 파일에 남아 있을 수 있습니다. 실행 중인 DB 파일만 단순 복사하면 일관된 백업이 아닐 수 있습니다.

해결 방법

sqlite3 app.db ".backup backup.db"
sqlite3 backup.db "PRAGMA integrity_check;"

또는 애플리케이션에서 SQLite 온라인 백업 API를 사용합니다. 파일 복사가 꼭 필요하다면 DB 연결을 정리하고 체크포인트 상태를 이해한 뒤 진행해야 합니다.

결론

SQLite는 “서버 없이 파일 하나로 쓰는 가벼운 DB”라는 설명보다 훨씬 실용적인 데이터베이스입니다. 로컬 앱, 모바일 앱, 임베디드 장비, 테스트 환경처럼 데이터가 애플리케이션 가까이에 있어야 하는 상황에서는 설치와 운영 부담을 크게 줄여 줍니다.

반대로 여러 서버가 동시에 많은 쓰기를 처리하는 중앙 DB가 필요하다면 SQLite를 억지로 확장하기보다 PostgreSQL이나 MySQL 같은 서버형 DB를 선택하는 편이 안전합니다. SQLite를 잘 쓰는 핵심은 역할을 정확히 정하고, 연결 초기화 PRAGMA, 짧은 트랜잭션, WAL 이해, 외래 키 활성화, 백업 방식을 초기에 정리하는 것입니다.

참고 자료