SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-07

DuckDB vs 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 열만 읽고 파일의 나머지 모든 바이트는 건너뜁니다. 그리고 값의 배치(batch) 단위로 벡터화된 코드를 통해 합계를 계산합니다. 다른 열은 디스크에서 전혀 읽지 않으며, 바로 여기서 속도 차이가 발생합니다.

이제 각 엔진을 서로의 작업 부하에 적용해 봅니다. 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 모드에서는 쓰기 작업이 모든 읽기 작업을 차단합니다. WAL 모드에서는 쓰기 작업이 데이터를 추가하는 동안에도 읽기 작업은 마지막으로 커밋된 상태를 계속 읽을 수 있으므로, 느린 보고서 작업이 뒤따르는 웹 요청을 지연시키지 않습니다.

행이 정상적으로 생성되었는지 확인하십시오:

sqlite3 /srv/app/app.db "SELECT * FROM orders WHERE customer = 'ana';"

1|ana|2026-07-30T09:14:00Z|4200가 출력됩니다. 데이터베이스 옆에 app.db-walapp.db-shm이라는 두 개의 파일이 추가로 생성되었으며, 이들은 모두 해당 데이터베이스에 속합니다. 애플리케이션이 실행 중일 때 app.db만 복사하면 데이터가 손상된 백업이 생성되는데, 이에 대해서는 아래에서 자세히 다룹니다.

SQLite는 여전히 한 번에 하나의 쓰기 작업만 허용합니다. 이 제한은 큐가 아닌 잠금 방식이므로, 너무 오래 대기하는 두 번째 쓰기 작업은 무한정 차단되는 대신 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();"

쿼리할 현실적인 파일을 생성합니다. 다음 명령은 500만 건의 주문 데이터를 zstd로 압축하여 Parquet 파일로 기록합니다:

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 푸터를 읽어 쿼리에 필요한 컬럼 청크를 파악하고 해당 부분만 읽어 들였습니다. 디렉터리 전체도 FROM '/srv/data/orders-*.parquet'와 같은 글로브 패턴을 사용하여 동일하게 처리할 수 있으며, 이를 통해 일별로 내보낸 한 달 치 데이터를 하나의 쿼리로 분석할 수 있습니다.

이 모든 작업의 성능은 디스크 속도가 결정하며, 컬럼 스캔은 긴 순차 읽기 작업입니다. 따라서 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 서비스 및 타이머 쌍이 이 작업에 적합합니다. 하나는 COPY을 실행하는 유닛이고, 다른 하나는 이를 매일 밤 실행하는 타이머입니다.

하나의 VPS에서 두 엔진 모두 실행하기

이 구성에서는 컨테이너나 포트가 필요하지 않습니다. 두 엔진 모두 라이브러리 형태이므로 패키지 설치와 파일 경로 설정만으로 충분합니다. 스택의 나머지 부분이 이미 동일한 VPS에서 Docker Compose로 실행 중이라면, 데이터베이스 서비스를 추가하는 대신 필요한 컨테이너에 데이터 디렉터리를 마운트하십시오. 추가할 서비스 자체가 없기 때문입니다.

이 구성을 문제없이 유지하려면 두 가지 규칙을 따라야 합니다.

각 엔진에 별도의 디렉터리를 할당하십시오. 애플리케이션이 기록하는 SQLite 파일은 /srv/app에, 분석 엔진이 읽는 Parquet 파일은 /srv/data에 둡니다. 디렉터리를 공유하면 한쪽을 스냅샷으로 백업하는 작업이 다른 쪽과 충돌할 수 있습니다.

두 프로세스가 하나의 DuckDB 데이터베이스 파일을 읽기-쓰기 모드로 동시에 참조하게 하지 마십시오. DuckDB 파일은 오직 하나의 프로세스만 쓰기 권한을 가질 수 있으며, 두 번째 프로세스는 파일을 열지 못하고 실패합니다. 모든 프로세스가 access_mode = 'READ_ONLY'을 설정한다면 여러 읽기 프로세스가 동시에 접근하는 것은 가능합니다. 이는 여러 프로세스가 일상적으로 파일을 공유하는 SQLite에 익숙한 사용자들에게는 생소한 부분입니다. 분석 작업이 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 백업을 사용하여 두 경로를 모두 서버 외부로 전송하면, 전체 데이터 계층을 하나의 백업 작업으로 두 개의 디렉터리에 저장할 수 있습니다.

실패 유형과 확인 가능한 정확한 문자열

SQLite에서 발생하는 Error: database is locked는 다른 연결이 타임아웃 설정보다 더 오래 쓰기 잠금을 유지하고 있음을 의미합니다. 데이터베이스 손상이 아닙니다. 모든 연결에 PRAGMA busy_timeout를 설정한 뒤, 여러 개의 짧은 트랜잭션으로 나누어야 할 긴 트랜잭션이 있는지 확인하십시오.

권한 변경 후 발생하는 Error: unable to open database file은 일반적으로 프로세스가 파일에는 쓸 수 있지만 해당 디렉터리에는 쓸 수 없음을 의미합니다. SQLite는 데이터베이스 옆에 app.db-walapp.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로 옮기는 것입니다. 보고서가 느린 이유는 저장 방식 때문이므로, 해결책은 쓰기 경로를 재작성하는 것이 아니라 데이터를 내보내는(export) 것입니다.

FAQ

DuckDB가 제 애플리케이션 데이터베이스를 위해 SQLite를 대체할 수 있습니까?

쓰기 작업이 빈번한 경우에는 적합하지 않습니다. DuckDB는 전체 데이터베이스 파일에 대해 쓰기 잠금을 수행하며 한 번에 하나의 읽기-쓰기 프로세스만 허용합니다. 또한 단일 행 삽입보다는 대량 변경 작업에 최적화되어 있습니다. 트랜잭션 상태는 SQLite에 유지하고, 보고서 생성이 필요할 때 ATTACH '/srv/app/app.db' AS app (TYPE sqlite, READ_ONLY);을 사용하여 DuckDB가 이를 읽도록 구성하십시오.

분석 작업에서 DuckDB가 정말로 SQLite보다 빠릅니까?

대규모 테이블에 대한 스캔 및 집계 작업에서는 그렇습니다. 그 이유는 튜닝 기법이 아니라 저장 방식의 차이 때문입니다. DuckDB는 쿼리에 명시된 열만 읽고 값을 배치 단위로 처리하는 반면, SQLite는 특정 필드에 도달하기 위해 전체 행을 훑어야 합니다. 기본 키를 통해 단일 행을 가져오는 경우에는 상황이 역전됩니다. SQLite는 두 페이지만 참조하면 되지만, DuckDB는 모든 열의 저장소를 참조해야 하기 때문입니다.

VPS에서 DuckDB를 실행하려면 많은 RAM이 필요합니까?

아니요, 하지만 제한 설정과 디스크 공간을 할당해야 합니다. :memory: 대신 데이터베이스 파일을 열어 DuckDB가 중간 결과를 디스크로 내보낼 수 있게 하고, SET memory_limit = '2GB';을 VPS에서 할당 가능한 값으로 설정하십시오. 제한을 두지 않으면 대규모 GROUP BY 작업 하나가 Out of Memory Error을 발생시키거나 다른 서비스를 RAM에서 밀어낼 수 있습니다.

SQLite 데이터를 Parquet으로 어떻게 가져옵니까?

DuckDB에서 SQLite 파일을 연결한 뒤 COPY (SELECT * FROM app.orders) TO '/srv/data/orders.parquet' (FORMAT parquet, COMPRESSION zstd);을 사용하여 쿼리 결과를 바로 복사하십시오. 지난달 데이터와 같이 마감된 기간에 대해 주기적으로 실행하고, 애플리케이션이 계속해서 데이터를 기록하는 최근 행은 SQLite에 남겨두십시오.

무엇을 어떻게 백업해야 합니까?

둘 다 다른 방식으로 백업해야 합니다. 실행 중인 데이터베이스는 -wal이자 -shm 파일이기도 하므로 단순 복사 시 데이터가 손상될 수 있습니다. 따라서 cp 대신 sqlite3 app.db ".backup '/srv/backup/app.db'"를 사용하여 SQLite 스냅샷을 생성하십시오. Parquet 파일은 한 번 작성되면 변경되지 않으므로 디렉터리를 복사하는 것만으로 충분합니다.