본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 26. 09:34

SQLite는 부족하고 Postgres는 과할 때 사용하는 DuckDB 패턴

요약

데이터베이스 선택 시 SQLite와 Postgres 사이의 간극을 메워주는 DuckDB의 활용 패턴을 소개합니다. 분석 쿼리 성능을 극대화하기 위해 행 기반의 SQLite와 컬럼형인 DuckDB를 병행 사용하는 전략을 제안합니다.

핵심 포인트

  • SQLite는 앱의 상태 관리(트랜잭션)에 적합함
  • DuckDB는 대규모 데이터 분석 및 집계 쿼리에 최적화됨
  • 컬럼형 저장 방식 덕분에 DuckDB는 분석 속도가 압도적으로 빠름
  • 서버 설치 없이 임베디드 방식으로 가볍게 사용 가능
  • 노트북에서 DuckDB는 1,000만 개의 행을 1초 미만으로 스캔합니다.
  • 트랜잭션에는 SQLite, 분석에는 DuckDB를 사용하며 서버는 필요하지 않습니다.
  • 가져오기(import) 단계 없이 CSV 및 Parquet 파일을 직접 쿼리합니다.
  • 실시간 쓰기(live writes)나 동시 사용자(concurrent users)를 위해 사용하지는 않습니다.

저는 4GB 크기의 주문 데이터 CSV 파일을 가지고 있었는데, SQLite는 집계 쿼리(aggregation query)를 수행하는 데 40초 동안 버벅거렸습니다. 동일한 파일을 DuckDB에 로드했더니 0.8초 만에 답을 얻었습니다. 그 순간 저는 데이터베이스 선택을 두 가지 옵션만 있는 문제로 취급하는 것을 그만두었습니다. 그 사이에는 빠져 있는 중간 단계가 있으며, 그것이 바로 DuckDB입니다.

아무도 말하지 않는 SQLite와 Postgres 사이의 간극

대부분의 1인 개발자들은 똑같은 사고방식을 따릅니다. 작은 프로젝트라면 SQLite를 사용하고, 앱이 커지면 Postgres로 옮깁니다. 이 두 데이터베이스가 모두에게 가장 잘 알려진 이름이기 때문에 이러한 도약은 자연스럽게 느껴집니다.

문제는 이동을 결정하는 계기가 대개 잘못되었다는 점입니다. 사람들은 앱이 느려지면 Postgres가 필요하다고 가정합니다. 하지만 '느리다'는 것은 종종 한 가지 특정한 상황을 의미합니다. 바로 행 기반 저장소(row-based store)에서 분석 쿼리(analytical query)가 실행되는 상황입니다. SQLite는 데이터를 행 단위로 저장합니다. 500만 개의 행을 스캔하여 하나의 컬럼을 합산하라고 요청하면, 단 하나의 필드만 필요함에도 불구하고 모든 행의 모든 바이트를 읽습니다. 이것이 병목 현상(bottleneck)이지, 엔진 자체가 약해서 발생하는 문제가 아닙니다.

Postgres는 이를 더 잘 처리하지만, 서버, 커넥션 풀(connection pool), 사용자 테이블, 백업 전략, 그리고 계속 유지해야 하는 프로세스를 동반합니다. 단일 장비에서 실행되는 1인용 앱에게, 본질적으로 리포팅 기능에 불과한 것을 위해 이러한 운영상의 부담(operational weight)을 짊어지는 것은 매우 큰 일입니다.

DuckDB는 그 간극을 메워줍니다. DuckDB는 컬럼형(columnar) 방식이므로 쿼리가 건드리는 컬럼만 읽습니다. SQLite처럼 임베디드(embedded) 방식이므로 실행할 서버가 없습니다. 단일 파일 또는 메모리 내에 존재합니다. 한 줄의 명령어로 설치할 수 있으며 일반적인 라이브러리처럼 가져올 수 있습니다. 데몬(daemon), 포트(port), 자격 증명(credentials)도 필요 없습니다.

다음은 제 작업에서 얻은 수치입니다. 800만 개의 이벤트 행을 일별로 그룹화하는 대시보드 쿼리를 SQLite에서 실행했을 때는 31초가 걸렸습니다. DuckDB에서 동일한 쿼리를 실행했을 때는 1.2초 만에 결과가 반환되었습니다. 저는 스키마(schema)를 변경하지 않았습니다. 인덱스(indexes)를 추가하지도 않았습니다. 그저 해당 질문의 형태에 맞춰 구축된 데이터베이스를 사용했을 뿐입니다.

제가 정립한 멘탈 모델(mental model)은 간단합니다. SQLite는 앱의 상태(state), 즉 주문, 사용자, 세션, 그리고 한 번에 한 줄씩 쓰는 행(rows)들을 위한 것입니다. DuckDB는 그 상태에 대해 던지는 질문, 즉 합계, 트렌드, 큰 테이블 간의 조인(joins), 내보내기(exports)를 위한 것입니다. 이 둘은 경쟁 관계가 아닙니다. 이들은 나란히 존재하며, 파일 형식 덕분에 함께 사용하기가 매우 쉽습니다.

행 기반 저장소(row stores)가 진정으로 충분한 시점에 대한 더 깊은 맥락을 알고 싶다면, SQLite Is Enough를 참조하세요. 저는 DuckDB가 등장하기 전부터 그 사고방식에 의존해 왔습니다.

파일을 직접 읽는 것이 핵심 기능(Killer Feature)이다

DuckDB가 저에게 각인된 이유는 단순히 원시 속도(raw speed) 때문이 아니었습니다. 파일을 먼저 임포트(import)하지 않고도 쿼리할 수 있다는 점 때문이었습니다.

SQLite에서 CSV를 다루려면 CREATE TABLE 문, 컬럼 타입(column type) 추측, 임포트 단계, 그리고 그 후에 쿼리를 실행해야 합니다. 무언가를 알아내기도 전에 세 가지 작업을 거쳐야 합니다. DuckDB에서는 단 한 줄만 작성하면 됩니다:

SELECT country, count(*) FROM 'orders.csv' GROUP BY country;

이 명령은 디스크에 있는 파일에 대해 직접 실행됩니다. 테이블도, 스키마도, 임포트도 필요 없습니다. DuckDB가 자동으로 타입을 감지(sniffs)하여 답을 알려줍니다. Parquet 파일의 경우도 동일한 패턴이며, Parquet는 이미 컬럼형(columnar)이고 압축된 형식이기에 속도가 말도 안 되게 빨라집니다.

저는 일회성 질문을 위해 이 기능을 끊임없이 사용합니다. 업체에서 200MB짜리 내보내기 파일을 보내주면, 저는 이를 위한 영구적인 테이블을 만들고 싶지 않습니다. 대신 누락된 필드가 있는 행이 몇 개인지, 날짜 범위는 어떻게 되는지, 두 파일이 동일한 ID를 공유하는지 알고 싶을 뿐입니다. DuckDB는 제가 임포트 스크립트를 다 작성하기도 전에 이 세 가지 질문에 모두 답해줍니다.

또한 여러 파일을 한 번에 읽을 수 있습니다. 'logs/*.parquet'와 같은 글로브(glob) 패턴에 쿼리를 지정하면 DuckDB는 폴더 전체를 하나의 테이블로 취급합니다. 저는 90개의 일일 내보내기(export) 파일이 있었는데, 이 모든 파일에 대해 단 한 번의 GROUP BY를 실행했습니다. 4초도 안 되어 모든 데이터를 읽어 들였습니다. 결합(concatenation) 단계도, 루프(loop)도, 임시 테이블(temp table)도 필요 없었습니다.

조인(join) 방식도 마찬가지입니다. 하나의 쿼리 안에서 Parquet 파일, CSV, 그리고 DuckDB 파일 내부의 테이블을 모두 조인할 수 있습니다. 최적화 도구(optimizer)가 힘든 작업을 대신 처리해 줍니다. 한 번은 1,200만 행의 이벤트 파일과 50,000 행의 조회용(lookup) CSV를 조인했는데, 2초 만에 결과를 얻었습니다. 딕셔너리(dictionary)를 사용하는 스크립팅 언어로 이 작업을 수행했다면, 두 데이터를 모두 메모리에 로드하고 병합 로직을 직접 작성해야 했을 것입니다.

이러한 파일 우선(file-first) 동작 방식은 저의 프로토타이핑 방식을 바꾸어 놓았습니다. 탐색 단계에서는 임포트(import) 단계를 완전히 건너뜁니다. 쿼리가 유용하다고 판단되면 그 결과를 Parquet 파일로 저장하고, 그것이 다음 단계의 입력값이 됩니다. 전체 파이프라인(pipeline)은 그저 파일과 SQL로만 이루어집니다.

이미지 및 에셋 메타데이터 작업에서 이 점은 예상보다 훨씬 중요합니다. Magnific를 사용하여 에셋 배치를 생성할 때, 실행 로그는 CSV로 저장되는데, 저는 디스크에서 직접 이 로그들을 쿼리하여 어떤 설정이 결과물(keepers)을 만들어냈는지 찾아냅니다.

내 스택에서 DuckDB가 자리를 잡은 곳

이제 저는 네 가지 상황에서 DuckDB를 사용하며, 이전에는 이 상황들이 느리거나 번거로웠습니다.

첫 번째는 대시보드입니다. 합계, 추세 또는 세부 내역을 보여주는 모든 내부 페이지는 DuckDB를 통해 실행됩니다. 라이브 앱 데이터는 SQLite에 유지하고, 정해진 일정에 따라 관련 테이블을 Parquet 파일로 덤프(dump)합니다. 대시보드 쿼리는 Parquet를 대상으로 실행됩니다. 예전에 6초가 걸리던 페이지 로딩이 이제는 1초 미만으로 단축되었습니다. 앱에 데이터를 쓰는 사용자들은 분석 경로(analytics path)를 전혀 건드리지 않으므로, 어느 한쪽이 다른 쪽을 차단(block)하는 일도 발생하지 않습니다.

두 번째는 데이터 클리닝 (data cleaning)입니다. 어떤 데이터가 영구 테이블 (permanent table)에 들어가기 전에, 저는 DuckDB를 통해 중복 (duplicates), Null 클러스터 (null clusters), 잘못된 날짜 형식 (bad date formats)을 확인합니다. 제가 필요로 하는 윈도우 함수 (window functions)와 샘플 (samples)이 모두 갖춰져 있습니다. 300만 행 파일에 대한 일반적인 검사는 2초 미만으로 완료되는데, 이는 제가 이 과정을 건너뛰는 대신 실제로 실행하게 만든다는 의미입니다.

세 번째는 내보내기 (exports)입니다. 누군가 CSV나 Excel 형식의 보고서가 필요할 때, 저는 DuckDB에서 쿼리 (query)를 작성하고 COPY를 사용하여 파일을 씁니다. 단 하나의 문장으로 출력이 생성됩니다. pandas도, 수동 작성 루프 (manual writing loop)도, 인코딩 문제 (encoding surprises)도 없습니다.

네 번째는 서로 다른 형식으로 존재하는 데이터를 조인 (joining)하는 것입니다. 저는 API 응답은 JSON으로, 로그는 Parquet로, 참조 데이터는 CSV로 유지합니다. DuckDB는 이 세 가지를 한꺼번에 쿼리할 수 있습니다. DuckDB는 중첩 구조 (nested structures)를 포함한 JSON 파일을 네이티브하게 읽을 수 있으므로, 파싱 (parsing) 단계 없이 세 단계 깊이의 필드를 가져올 수 있습니다.

이 모든 것을 하나로 묶어주는 점은, 이 중 어느 것도 쓰기 집약적인 (write-heavy) 데이터베이스를 필요로 하지 않는다는 것입니다. 이들은 모두 이미 존재하는 데이터에 대한 읽기 질문 (read questions)입니다. 그것이 바로 컬럼형 엔진 (columnar engines)이 구축된 정확한 워크로드 (workload)입니다.

운영 비용 (operational cost)에 대해서는 솔직해져야 하는데, 이는 거의 제로에 가깝습니다. DuckDB는 단일 의존성 (single dependency)입니다. 모니터링할 프로세스도, 보안을 유지해야 할 포트 (port)도, 두려워해야 할 업그레이드 마이그레이션 (upgrade migration)도 없습니다. 새로운 서버에 배포할 때 DuckDB는 서비스 (service)가 아니라 라이브러리 (library)이기 때문에 그냥 작동합니다. 덕분에 앱과 함께 두 번째 데이터베이스 서버를 운영할 필요가 없으므로, 저의 Shopify 백엔드 보고 작업이 단순하게 유지됩니다.

여기서의 원칙은 운영 환경 (production)에서 DuckDB를 읽기 전용 (read-only)으로 유지하는 것입니다. 저는 Parquet 파일을 라이브 상태 (live state)가 아닌, 새로 고쳐진 스냅샷 (refreshed snapshot)으로 취급합니다. 이 단 하나의 규칙이 사람들이 겪는 대부분의 문제를 방지해 줍니다.

사용하지 않는 경우와 그것이 중요한 이유

DuckDB는 범용 애플리케이션 데이터베이스 (general purpose application database)가 아니며, 그런 척하는 것은 해가 될 것입니다. 저는 한계점에 부딪히며 그 경계를 배웠습니다.

첫 번째 경계는 동시 쓰기(concurrent writes)입니다. DuckDB는 한 번에 하나의 작성자(one writer)만 처리하도록 설계되었습니다. 만약 사용자들이 끊임없이 행(row)을 작성하는 앱이라면, DuckDB는 잘못된 도구입니다. 동일한 파일에 쓰려고 시도하는 두 프로세스는 서로 충돌하게 되며, 패배한 쪽은 잠금(locked out) 상태가 됩니다. 이는 OLTP 데이터베이스가 설계된 목적과 정반대되는 상황입니다. 그러한 워크로드(workload)의 경우, 저는 많은 읽기 작업과 직렬화된 쓰기(serialized writes)를 깔끔하게 처리하는 SQLite를 유지하거나, 여러 머신에 걸쳐 진정으로 많은 동시 작성자가 필요한 경우에는 Postgres로 격상합니다.

두 번째 경계는 작고 빈번한 트랜잭션(transactions)입니다. 사용자가 무언가를 클릭할 때마다 앱이 한 행을 삽입한다면, DuckDB는 그러한 리듬에 맞춰 구축되지 않았습니다. 컬럼형 저장소(Columnar stores)는 단일 행의 점진적인 유입(single-row drips)이 아니라 대량 로드(bulk loads)를 원합니다. 저는 그러한 트래픽을 SQLite에 유지하고, 나중에 분석이 필요한 경우 DuckDB로 배치(batch) 처리하여 옮깁니다.

세 번째 경계는 여러 연결된 클라이언트 간에 엄격한 트랜잭션 보장(transactional guarantees)이 필요한 모든 것입니다. 주문 처리, 재고 잠금(inventory locking), 결제 상태 등이 이에 해당합니다. 이는 실제 트랜잭션 저장소(transactional store)에 속해야 합니다. 돈이 개입되는 상황에서 DuckDB는 당신이 원하는 격리 보장(isolation guarantees)을 제공하지 않을 것입니다.

네 번째 경계는 데이터가 진정으로 작을 때입니다. 전체 테이블이 20,000행 정도라면, SQLite는 모든 쿼리에 즉각적으로 응답하며 DuckDB는 당신에게 아무것도 제공하지 못합니다. 컬럼형의 이점은 규모(scale)가 커질 때만 나타납니다. 약 100만 행 미만에서는 그 차이가 미미합니다. 존재하지 않는 문제를 해결하기 위해 의존성(dependency)을 추가하지 마세요.

솔직한 요약은 DuckDB가 임베디드(embedded) 방식으로 구현된 분석 엔진이라는 점입니다. 당신의 요구사항이 "내 애플리케이션의 진실을 저장하고 많은 사람이 이를 안전하게 변경하게 하는 것"이 되는 순간, 당신은 DuckDB의 영역을 벗어난 것입니다. 반대로 당신의 요구사항이 "한 머신에 있는 대량의 데이터 더미에 대해 어려운 질문에 답하는 것"이 되는 순간, 당신은 정확히 DuckDB의 핵심 용도에 들어온 것입니다.

이것은 제가 모든 도구에 적용하는 것과 동일한 판단 기준입니다. 엔진을 프로젝트의 규모가 아니라 질문의 형태에 맞추십시오. 어떤 도구를 어디에 배치할지 결정하기 위해 제가 사용하는 프레임워크가 궁금하다면, Claude Blueprint에서 제가 어떻게 스스로를 궁지로 몰아넣지 않으면서 단일 스택(solo stack)을 작게 유지하는지 설명되어 있습니다.

결론 (Bottom Line)

DuckDB는 제가 문제인지조차 몰랐던 문제를 해결해 주었습니다. 저는 분석 속도가 느려지는 것을 Postgres로 옮겨야 한다는 신호로 받아들여 왔지만, 실제 신호는 제가 행 기반 저장소(row store)에 열 기반(columnar) 질문을 던지고 있었다는 것이었습니다. 해결책은 서버가 아니라 라이브러리였습니다.

이제 저의 규칙은 세 가지 작업에 세 가지 데이터베이스를 사용하는 것입니다. SQLite는 앱이 기록하는 라이브 상태(live state)를 보유합니다. DuckDB는 해당 상태의 스냅샷(snapshot)에 대해 분석적 질문에 답합니다. Postgres는 여러 머신에 걸쳐 동시 쓰기(concurrent writers)가 진정으로 필요할 때만 도입하는데, 이는 제가 이전에 가정했던 것보다 훨씬 드문 일입니다.

파일 우선(file-first) 설계야말로 DuckDB를 채택할 가치가 있게 만드는 요소입니다. CSV 또는 Parquet 파일을 직접 쿼리하면 탐색을 저해하는 임포트(import) 단계를 제거할 수 있습니다. 파일 폴더를 대상으로 실행하는 한 줄의 SQL은 언제나 로딩 스크립트보다 낫습니다.

작게 시작하십시오. 가장 느린 대시보드 쿼리를 가져와서 테이블을 Parquet로 덤프(dump)한 뒤, DuckDB에서 동일한 쿼리를 실행해 보세요. 만약 실행 시간이 초 단위에서 밀리초(milliseconds) 단위로 줄어든다면, 당신은 잃어버린 중간 단계(missing middle)를 찾은 것입니다. 만약 그렇지 않다면, 당신의 데이터는 이미 SQLite로도 충분할 만큼 작았다는 뜻이며, 결과적으로 의존성(dependency) 하나를 줄인 셈이 됩니다.

이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면 귀하에게 추가 비용 부담 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0