서버에서 DuckDB와 SQLite를 함께 써야 하는 이유
SQLite는 트랜잭션 상태를 저장하고 DuckDB는 Parquet와 CSV를 분석합니다. 한 VPS에서 두 엔진을 함께 운영하는 이유와 각 엔진의 실제 사용 예를 설명합니다.
서버에서 DuckDB와 SQLite 비교: 한 문장으로 답하기
SQLite는 OLTP 엔진(온라인 트랜잭션 처리)입니다. 데이터를 행 단위로 저장하며, 한 번에 적은 수의 행을 안전하고 빠르게 읽고 쓰도록 설계되었습니다. DuckDB는 OLAP 엔진(온라인 분석 처리)입니다. 데이터를 열 단위로 저장하며, 수백만 개의 행을 스캔하고 하나의 집계 결과를 반환하도록 설계되었습니다. 둘 다 임베디드 라이브러리이며, 일반 파일을 열어 사용합니다. 또한 관리자가 계속 관리해야 하는 서버 프로세스를 실행하지 않습니다.
따라서 "어느 것을 사용해야 하는가"라는 질문에 대한 정직한 답은 거의 항상 "같은 VPS에서 둘 다 사용한다"입니다. 애플리케이션은 실시간 상태를 SQLite에 저장합니다. 보고 작업은 DuckDB로 Parquet 및 CSV 파일을 읽습니다. 두 제품은 같은 작업을 수행하지 않으므로 서로 경쟁하지 않습니다.
행 저장소와 열 저장소가 답을 바꾸는 이유
SQLite는 행을 페이지의 하나의 연속된 조각으로 기록합니다. 기본 키로 주문 하나를 가져오면 인덱스 페이지 하나와 데이터 페이지 하나에 접근하므로 2번 읽기가 발생합니다. 애플리케이션은 초당 수천 번 정확히 이러한 작업을 수행합니다. 사용자를 읽고, 세션을 업데이트하고, 주문을 삽입합니다.
DuckDB는 각 열을 별도로 기록하고 압축합니다. 500만 개 행에 대해 amount_cents을 합산하면 amount_cents 열만 읽고, 파일의 다른 모든 바이트를 건너뛰며, 값 배치에 대해 벡터화된 코드로 합계를 계산합니다. 다른 열은 디스크에서 전혀 읽지 않습니다. 이것이 속도가 나오는 이유입니다.
이제 각 엔진에서 상대 엔진의 작업 부하를 실행해 보겠습니다. SQLite에서 열을 합산하려면 모든 행을 순회하고 하나의 필드에 접근하기 위해 페이지에서 전체 행을 가져와야 합니다. 따라서 필요한 것보다 훨씬 많은 디스크를 읽습니다. DuckDB에서 주문 하나를 삽입하려면 단일 값을 기록하기 위해 모든 열의 저장소에 접근해야 하며, 이 작업을 수행하는 동안 전체 데이터베이스 파일에 쓰기 잠금을 설정합니다. 어느 엔진도 문제가 있는 것이 아닙니다. 각 엔진이 적합하도록 설계되지 않은 질문에 답하고 있을 뿐입니다.
SQLite가 강점을 보이는 경우: 트랜잭션 애플리케이션 상태
쓰기 작업이 작고 빈번하며 손실되어서는 안 되는 경우 SQLite를 사용합니다. 세션, 주문, 큐 행, 설정 등 웹 요청으로 생성되는 모든 데이터가 해당합니다.
sudo apt update && sudo apt install -y sqlite3
sudo install -d -o "$USER" -g "$USER" /srv/app같은 단계에서 테이블을 만들고 write-ahead logging을 활성화합니다.
sqlite3 /srv/app/app.db <<'SQL'
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
placed_at TEXT NOT NULL,
amount_cents INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS orders_customer ON orders (customer);
INSERT INTO orders (customer, placed_at, amount_cents)
VALUES ('ana', '2026-07-30T09:14:00Z', 4200);
SQL출력의 첫 번째 줄은 wal입니다. 이는 모드가 전환되었음을 보고하는 PRAGMA이며, 서버에서 가장 유용한 설정입니다. 기본 rollback journal 모드에서는 writer가 모든 reader를 차단합니다. WAL 모드에서는 writer 하나가 데이터를 추가하는 동안 reader가 마지막으로 커밋된 상태를 계속 읽습니다. 따라서 느린 보고서 때문에 해당 웹 요청이 더 이상 지연되지 않습니다.
행이 반환되었는지 확인합니다.
sqlite3 /srv/app/app.db "SELECT * FROM orders WHERE customer = 'ana';"1|ana|2026-07-30T09:14:00Z|4200가 출력됩니다. 데이터베이스 옆에 app.db-wal와 app.db-shm이라는 파일 2개가 추가로 생성되며, 두 파일 모두 해당 데이터베이스에 속합니다. 애플리케이션이 실행 중일 때 app.db만 복사하면 일관성이 깨진 백업이 생성됩니다. 이 내용은 아래에서 자세히 설명합니다.
SQLite에서는 한 번에 하나의 writer만 허용됩니다. 이 제한은 큐가 아니라 잠금입니다. 따라서 두 번째 writer가 너무 오래 기다리면 영원히 차단되지 않고 database is locked 오류로 실패합니다. 애플리케이션이 여는 모든 연결에 PRAGMA busy_timeout = 5000;를 설정하여 대기 시간을 늘립니다. 일반적인 웹 작업 부하에서는 5초 동안 기다리도록 설정하면 이러한 오류 대부분이 사라집니다.
DuckDB가 강점을 보이는 경우: 이미 보유한 파일에 대한 분석
질문이 "몇 개인가", "얼마나 많은가" 또는 "상위 10개는 무엇인가"로 시작하고 입력이 여러 CSV 또는 Parquet 파일이라면 DuckDB를 선택합니다. 명령줄 클라이언트를 설치합니다. 2026년 7월 기준 버전은 1.5.5입니다.
curl https://install.duckdb.org | sh이 스크립트는 ~/.duckdb/cli/latest/duckdb 아래에 바이너리를 설치하고, 이를 PATH에 추가하는 줄을 출력합니다. 실행되는지 확인합니다.
~/.duckdb/cli/latest/duckdb :memory: "SELECT version();"쿼리할 현실적인 파일을 생성합니다. 이 명령은 zstd로 압축된 Parquet 파일에 주문 데이터 5000000개 행을 기록합니다.
mkdir -p /srv/data
duckdb :memory: "
COPY (
SELECT i AS id,
'cust_' || (i % 5000) AS customer,
TIMESTAMP '2026-01-01 00:00:00' + INTERVAL (i) MINUTE AS placed_at,
(i * 37) % 20000 AS amount_cents
FROM range(5000000) t(i)
) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);"이제 분석 질문을 실행합니다. 셸을 열고 타이머를 켠 다음, 가져오기 단계 없이 파일을 직접 쿼리합니다.
.timer on
SELECT customer,
count(*) AS orders,
sum(amount_cents)/100.0 AS revenue
FROM '/srv/data/orders.parquet'
GROUP BY customer
ORDER BY revenue DESC
LIMIT 5;게시된 수치를 그대로 신뢰하지 말고 .timer에서 직접 수치를 확인합니다. 결과는 디스크와 코어 수에 따라 달라지기 때문입니다. 중요한 것은 결과의 형태입니다. CREATE TABLE도, INSERT도, 로드 단계도 없었습니다. DuckDB는 Parquet footer를 읽고 쿼리에 필요한 column chunk를 확인한 다음 해당 데이터만 읽었습니다. glob인 FROM '/srv/data/orders-*.parquet'를 사용하면 전체 디렉터리에서도 같은 방식으로 동작합니다. 따라서 한 달치 일일 내보내기 파일을 하나의 쿼리로 처리할 수 있습니다.
디스크 속도가 이 모든 작업의 하한이며 column scan은 긴 순차 읽기입니다. 따라서 VPS에서 NVMe와 구형 SATA 스토리지의 차이는 SQLite의 소규모 임의 읽기보다 이 작업에서 더 분명하게 나타납니다.
DuckDB에서 SQLite 데이터베이스 읽기
두 엔진은 DuckDB의 sqlite 확장을 통해 연결됩니다. 분석 쿼리가 운영 상태에 절대 기록하지 못하도록 애플리케이션 데이터베이스를 읽기 전용으로 연결합니다.
INSTALL sqlite;
LOAD sqlite;
ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY);
SELECT customer, count(*) AS orders
FROM app.orders
GROUP BY customer
ORDER BY orders DESC;이 명령은 복사하지 않고 쿼리 실행 시 SQLite 파일에서 행을 읽습니다. 편리하지만 빠르지는 않습니다. 디스크의 데이터가 여전히 행 저장 형식이고 DuckDB가 전체 데이터를 순회해야 하기 때문입니다. 내보내기에 사용하고, 30초마다 다시 로드하는 대시보드에는 사용하지 않습니다.
COPY (SELECT * FROM app.orders)
TO '/srv/data/orders-2026-07.parquet' (FORMAT parquet, COMPRESSION zstd);이 한 문장이 전체 패턴입니다. SQLite는 최근 운영 행을 관리합니다. 예약된 내보내기 작업이 마감된 기간의 데이터를 Parquet으로 변환합니다. DuckDB는 여러 달에 걸친 모든 질문에 답하고, 애플리케이션 데이터베이스는 작게 유지되므로 쓰기 작업이 빠르게 처리됩니다.
내보내기 작업은 수동으로 실행하지 말고 일정에 따라 실행합니다. systemd service 및 timer 쌍이 이 작업에 적합합니다. 하나의 unit이 COPY을 실행하고, 하나의 timer가 이를 매일 밤 실행합니다.
하나의 VPS에서 둘 다 실행하기
여기에는 컨테이너가 필요하지 않으며 포트도 필요하지 않습니다. 두 엔진 모두 라이브러리이므로 설치 대상은 패키지와 파일 경로입니다. 나머지 스택이 이미 동일한 VPS의 Docker Compose에서 실행 중이라면 데이터 디렉터리를 필요한 컨테이너에 마운트해야 합니다. 데이터베이스 서비스를 추가하면 안 됩니다. 추가할 서비스 자체가 없기 때문입니다.
이 구성을 문제없이 운영하려면 다음 2가지 규칙을 지켜야 합니다.
각 엔진에 고유한 디렉터리를 할당합니다. /srv/app은 애플리케이션이 쓰는 SQLite 파일용으로 사용하고, /srv/data는 analytics가 읽는 Parquet 파일용으로 사용합니다. 두 엔진이 디렉터리를 공유하면 한쪽을 스냅샷하는 백업 작업이 다른 쪽과 충돌하게 됩니다.
두 프로세스가 하나의 DuckDB 데이터베이스 파일을 read-write 모드로 가리키게 해서는 안 됩니다. 하나의 프로세스만 DuckDB 파일을 쓰기 위해 열 수 있으며, 두 번째 프로세스는 해당 파일을 전혀 열지 못합니다. 모든 프로세스가 access_mode = 'READ_ONLY'을 설정하면 여러 읽기 프로세스를 사용할 수 있습니다. SQLite에서 온 사용자에게는 이 동작이 낯설 수 있습니다. SQLite에서는 여러 프로세스가 하나의 파일을 일반적으로 공유하기 때문입니다. analytics가 Parquet 파일만 읽는다면 이 문제가 발생하지 않습니다. 따라서 영구 상태를 SQLite에 보관해야 할 이유가 하나 더 생깁니다.
백업은 서로 다르며, 그 차이가 문제를 일으킵니다
실행 중인 SQLite 데이터베이스는 3개의 파일로 구성됩니다. cp을 쓰기 작업 중간에 사용해 이 파일들을 복사하면, 열리기는 하지만 내용이 잘못된 파일이 생성됩니다. 애플리케이션이 계속 쓰기 작업을 수행하는 동안 일관된 스냅샷을 생성하려면 데이터베이스 엔진 자체의 백업 명령을 사용합니다.
sqlite3 /srv/app/app.db ".backup '/srv/backup/app-$(date -u +%Y%m%dT%H%M%SZ).db'"
sqlite3 /srv/backup/app-20260730T091400Z.db "PRAGMA integrity_check;"정상적으로 복사되면 integrity_check는 ok을 출력합니다. 다른 결과가 나오면 해당 스냅샷을 폐기하고 다시 생성합니다.
Parquet 파일은 기록된 후 변경되지 않으므로 특별한 처리가 필요하지 않습니다. 디렉터리를 백업하면 됩니다. VPS에서 restic으로 백업을 사용해 두 경로를 모두 서버 외부로 전송하면 전체 데이터 계층을 하나의 백업 작업에서 2개의 디렉터리로 처리할 수 있습니다.
실패 모드와 표시되는 정확한 문자열
SQLite의 Error: database is locked는 다른 연결이 시간 제한보다 오래 쓰기 잠금을 보유했다는 뜻입니다. 데이터가 손상된 것은 아닙니다. 모든 연결에서 PRAGMA busy_timeout를 설정한 다음, 여러 개의 짧은 트랜잭션으로 처리했어야 할 작업이 장시간 트랜잭션으로 실행되고 있지 않은지 확인합니다.
권한을 변경한 후 Error: unable to open database file이 표시되면 일반적으로 프로세스가 파일에는 쓸 수 있지만 해당 파일이 있는 디렉터리에는 쓸 수 없다는 뜻입니다. SQLite는 데이터베이스 옆에 app.db-wal과 app.db-shm을 생성하므로 .db 파일뿐 아니라 디렉터리 자체에도 쓰기 권한이 있어야 합니다.
DuckDB의 IO Error: Could not set lock on file은 일반적으로 다른 프로세스가 이미 해당 데이터베이스를 쓰기 모드로 열고 있다는 뜻입니다. 다른 셸을 닫거나 현재 연결을 읽기 전용으로 엽니다.
소형 VPS에서 DuckDB의 Out of Memory Error이 표시되면 쿼리에 할당된 것보다 더 많은 작업 메모리가 필요했다는 뜻입니다. DuckDB는 가능한 경우 디스크로 데이터를 임시 저장하므로, :memory: 대신 디스크의 데이터베이스 파일을 열어 임시 저장 공간을 제공합니다. 또한 SET memory_limit = '2GB';으로 메모리 사용량을 제한합니다. 다른 서비스를 실행하는 서버에서는 이 제한이 임시 쿼리 때문에 애플리케이션이 RAM을 모두 사용하지 않도록 합니다.
Parquet을 쿼리할 때 Binder Error: Referenced column "amount" not found이 표시되면 거의 항상 파일의 스키마가 기억하고 있는 것과 다르다는 뜻입니다. DESCRIBE SELECT * FROM '/srv/data/orders.parquet';을 실행하고 실제 열 이름을 확인합니다.
실제로 선택하는 방법
쓰기 패턴을 확인합니다. 전원이 차단되어도 보존되어야 하는 소규모 쓰기 작업이 많다면 SQLite가 적합합니다. 읽기 패턴도 확인합니다. 장기간의 이력을 대상으로 집계와 함께 전체 스캔을 수행한다면 DuckDB가 적합합니다. 실제 시스템에서는 두 질문에 모두 “예”라고 답하는 경우가 많습니다. 이때는 한 엔진이 다른 엔진의 역할까지 맡도록 강제하기보다, 각 엔진이 잘 처리하는 작업을 나누어 맡기는 것이 올바른 대응입니다.
피해야 할 마이그레이션은 보고서가 느리다는 이유로 실시간 애플리케이션 상태를 DuckDB로 옮기는 것입니다. 보고서가 느린 원인은 스토리지 레이아웃이므로, 쓰기 경로를 다시 작성하지 말고 데이터를 내보내야 합니다.
FAQ
DuckDB를 애플리케이션 데이터베이스로 SQLite 대신 사용할 수 있습니까?
쓰기 작업이 빈번한 데이터베이스에는 적합하지 않습니다. DuckDB는 전체 데이터베이스 파일에 쓰기 잠금을 설정하고, 한 번에 하나의 읽기-쓰기 프로세스만 허용합니다. 또한 단일 행 삽입보다 대량 변경에 맞게 설계되었습니다. 트랜잭션 상태는 SQLite에 유지하고, 보고서가 필요할 때 DuckDB가 ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY);을(를) 사용해 해당 데이터를 읽도록 하십시오.
분석 작업에서 DuckDB가 SQLite보다 실제로 더 빠릅니까?
대규모 테이블을 스캔하고 집계하는 작업에서는 그렇습니다. 그 이유는 튜닝 기법이 아니라 저장소 레이아웃에 있습니다. DuckDB는 쿼리에서 지정한 열만 읽고 값을 일괄 처리합니다. 반면 SQLite는 한 필드에 접근하려면 전체 행을 순회해야 합니다. 기본 키로 단일 행을 가져오는 작업에서는 결과가 반대입니다. SQLite는 2개의 페이지에 접근하지만 DuckDB는 모든 열의 저장소에 접근하기 때문입니다.
VPS에서 DuckDB를 실행하려면 RAM이 많이 필요합니까?
아닙니다. 다만 메모리 제한과 디스크를 설정해야 합니다. :memory: 대신 데이터베이스 파일을 열어 DuckDB가 중간 결과를 디스크로 넘길 수 있게 하십시오. 그런 다음 SET memory_limit = '2GB';을(를) VPS에서 할당할 수 있는 값으로 설정하십시오. 제한이 없으면 대규모 GROUP BY 하나가 Out of Memory Error을(를) 증가시키거나 다른 서비스의 RAM을 고갈시킬 수 있습니다.
SQLite 데이터를 Parquet으로 가져오려면 어떻게 해야 합니까?
DuckDB에서 SQLite 파일을 attach하고 COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);을(를) 사용해 쿼리 결과를 바로 복사하십시오. 지난달 행과 같이 종료된 기간의 데이터를 대상으로 일정에 따라 실행하십시오. 애플리케이션이 계속 쓰는 최신 행은 SQLite에 유지하십시오.
어느 데이터를 백업해야 하며, 어떻게 백업해야 합니까?
둘 다 백업해야 하지만 방식은 다릅니다. cp 대신 sqlite3 app.db ".backup '/srv/backup/app.db'"을(를) 사용해 SQLite 스냅샷을 생성하십시오. 실행 중인 데이터베이스는 -wal이면서 -shm 파일이기도 하므로 단순히 복사하면 파일이 손상될 수 있습니다. Parquet 파일은 한 번 작성된 후 변경되지 않으므로 디렉터리를 복사하는 것으로 충분합니다.