벡터 검색을 앞두고 SQLite에서 PostgreSQL + pgvector로 옮긴 이유

@givemethatsewon· July 15, 2026· 6 min read

별도 벡터 DB와 PostgreSQL pgvector의 데이터 경계 비교
별도 벡터 DB와 PostgreSQL pgvector의 데이터 경계 비교

일기 서비스에 “비슷한 기록 보여주기”를 넣으려 했다. 같은 단어가 있는 글이 아니라 문맥과 감정이 닮은 과거 일기를 찾는 기능이다. 텍스트를 임베딩으로 바꾸고, 벡터를 저장한 뒤 가까운 기록을 찾는 흐름이 필요했다.

처음에는 SQLite는 그대로 두고 벡터 데이터베이스만 따로 붙이는 방법을 검토했다. 그런데 원문은 한 저장소, 임베딩은 다른 저장소에 두면 삭제·백업·복구·재시도가 모두 두 시스템에 걸친 일이 된다. 기능 하나를 넣으면서 운영할 곳을 하나 더 만드는 셈이었다.

그래서 PostgreSQL과 pgvector로 옮겼다. 전용 벡터 DB가 맞는 상황도 분명 있다. 다만 지금 서비스에서는 원문과 임베딩을 PostgreSQL 하나에서 같이 관리하는 편이 훨씬 덜 복잡했다. 이 글에는 그 판단과 실제 컷오버 과정을 남겼다.

요약

  • 유사 일기 검색에는 벡터 컬럼과 근접 검색이 필요했고, pgvector가 그 기능을 PostgreSQL 안에 추가해준다.
  • 원문과 임베딩의 삭제·권한·백업 경계를 하나로 유지하기 위해 PostgreSQL + pgvector를 선택했다.
  • API는 공용 프록시 네트워크와 서비스 내부 네트워크에, PostgreSQL은 내부 네트워크에만 연결했다.
  • 컷오버는 “API 중지 → SQLite 최종 snapshot → 빈 PostgreSQL 이관 → 검증 → API 재기동”의 짧은 쓰기 중단 구간으로 만들었다.
  • PostgreSQL이 새 쓰기를 받기 전과 후에는 되돌리는 방법이 달라진다. 이 경계를 미리 정해둬야 새 데이터를 잃지 않는다.

결정의 기준: 검색 엔진보다 데이터 생명주기

유사 기록 검색은 다음 흐름이다.

일기 작성 → 임베딩 생성 → 벡터 저장 → 비슷한 과거 기록 검색

일기 서비스라서 더 까다로운 조건도 있다. 검색 후보는 늘 현재 사용자의 기록으로 제한해야 한다. 일기를 지우면 임베딩도 함께 지워져야 하고, 백업을 복구했을 때 둘은 같은 시점으로 돌아와야 한다.

SQLite와 별도 벡터 DB를 함께 쓰면 이 규칙이 나뉜다. 한쪽 저장이 실패했을 때 어떻게 다시 맞출지, 두 백업의 시점이 어긋나면 어떻게 할지, 삭제가 한쪽에만 반영되면 어떻게 찾을지까지 일일이 정해야 한다. 대규모 검색 서비스라면 그 비용을 감수할 이유가 생긴다. 하지만 내가 풀 문제는 “한 사용자의 과거 기록에서 비슷한 것을 찾기”였다.

선택지 장점 운영에서 늘어나는 일
SQLite + 별도 벡터 DB 기존 DB를 유지할 수 있다 원문·임베딩·삭제·백업의 source of truth가 둘이 된다
PostgreSQL + pgvector 관계형 조건과 벡터 검색을 같은 DB에서 다룬다 한 번의 DB 이관이 필요하다
전용 벡터 DB 대규모 ANN과 복잡한 검색에 유리할 수 있다 별도 클러스터, 권한, 동기화, 백업, 관측이 필요하다

pgvector는 PostgreSQL에 벡터 타입과 거리 연산, HNSW 같은 인덱스를 더하는 확장이다. 처음에는 정확도 검색으로 시작하고, 데이터량과 지연 시간을 본 뒤 근사 최근접 이웃 인덱스를 붙이면 된다. pgvector 공식 문서

결국 비교한 것은 제품 기능표가 아니라 운영비였다. 지금은 데이터를 두 곳에 나누는 비용보다 DB를 한 번 옮기는 비용이 작았다.

네트워크 이름도 인프라 계약이다

홈서버에는 이미 공용 reverse proxy 네트워크가 있었다. API는 이 네트워크에 있어야 외부 HTTP 요청을 받을 수 있지만, PostgreSQL이 여기에 있을 이유는 없다.

처음에는 튜토리얼에서 흔히 쓰는 app-net 같은 이름을 그대로 쓸 뻔했다. 하지만 운영 서버는 서비스마다 <서비스>-<역할> 형태의 명시적 이름을 쓰고 있었다. 예제 이름을 하드코딩하면 컨테이너가 어떤 경계를 공유하는지 나중에 한눈에 보이지 않는다.

그래서 API는 proxy-netdear-crush-internal에 연결하고, PostgreSQL은 dear-crush-internal에만 연결했다.

API는 proxy-net과 서비스 내부 네트워크에 연결하고 PostgreSQL은 내부 네트워크에만 두는 구조
API는 proxy-net과 서비스 내부 네트워크에 연결하고 PostgreSQL은 내부 네트워크에만 두는 구조

PostgreSQL에는 host port를 열지 않았다. DB를 프록시 네트워크에서 빼고 내부 네트워크에만 두면 웹에서 직접 닿을 수 없는 계층이라는 점도 바로 드러난다.

이 구성은 Compose 파일에만 남기지 않았다. 다음 조건을 배포 계약 테스트로 고정했다.

  • PostgreSQL은 pgvector 이미지를 사용한다.
  • API는 PostgreSQL health check가 통과한 뒤 시작한다.
  • API는 두 네트워크에, PostgreSQL은 서비스 내부 네트워크에만 속한다.
  • PostgreSQL은 host port를 publish하지 않는다.

Compose는 설정 파일이지만, 잘못 바뀌면 배포에서 바로 터진다. 그래서 이 조건도 테스트로 봤다.

예상보다 까다로웠던 것은 PostgreSQL 자체보다 SQLite 전제였다

DB만 바꾸면 SQL만 손보면 될 거라고 생각하기 쉽다. 실제로는 코드와 테스트 곳곳에 “현재 DB는 SQLite”라는 전제가 숨어 있었다.

가장 먼저 발목을 잡은 건 Alembic head를 문자열로 박아둔 테스트였다. pgvector용 revision을 추가하자 실제 체인은 맞는데도 예전 head를 기대하던 테스트가 깨졌다. 이관 전에 테스트의 기대값부터 마이그레이션 체인과 맞춰야 했다.

또 하나는 보호 장치였다. 이관 테스트가 운영 PostgreSQL을 잘못 초기화하지 않도록 테스트 대상은 _test suffix가 붙은 DB만 허용했다. 번거롭더라도 이관 코드에서는 “운영 데이터를 실수로 건드리지 않는다”가 먼저다.

백업 도구의 역할도 분리했다. pg_dump는 SQLite 파일을 PostgreSQL로 바꾸는 도구가 아니다. SQLite는 Backup API로 일관된 snapshot을 만들고, PostgreSQL로 전환한 뒤에야 pg_dump로 새 운영 DB를 백업한다. 이 구분을 놓치면 WAL이 남은 SQLite 파일을 단순 복사하거나 엉뚱한 시점의 백업을 믿게 된다.

컷오버: 이관 스크립트보다 전환 순서가 중요했다

컷오버(cutover)는 실제 쓰기를 받는 기준 DB를 바꾸는 작업이다. 여기서는 SQLite에서 PostgreSQL로 운영 source of truth를 넘기는 순간을 뜻한다.

무중단을 목표로 두 DB를 동기화하는 방법도 있다. 대신 이 서비스에서는 짧은 쓰기 중단을 허용하고, 절차를 단순하게 만들었다. 중간 상태가 적을수록 검증도 쉬웠다.

SQLite에서 PostgreSQL로 운영 기준 DB를 넘기는 컷오버와 롤백 창
SQLite에서 PostgreSQL로 운영 기준 DB를 넘기는 컷오버와 롤백 창

실행 명령은 서비스별 경로와 비밀값을 제거하면 아래 정도로 단순하다.

# 1. SQLite에 새 쓰기가 들어오지 않게 한다.
docker compose stop api

# 2. SQLite Backup API를 사용한 최종 snapshot을 만든다.
./backup-sqlite.sh

# 3. PostgreSQL을 준비하고 일회성 이관 컨테이너를 실행한다.
docker compose up -d --wait postgres
docker compose -f docker-compose.yml -f docker-compose.cutover.yml run --rm api

# 4. 검증 후 정상 API로 전환한다.
docker compose -f docker-compose.yml up -d api

docker-compose.cutover.yml은 API 이미지를 새로 만들려는 파일이 아니다. 같은 이미지를 이관 명령으로 한 번만 실행하는 override다. 이관이 끝나면 빼고 정상 API 모드로 다시 띄운다. 덕분에 빈 PostgreSQL을 바라보는 API가 먼저 떠서 쓰기를 받는 중간 상태를 막을 수 있다.

이관 컨테이너는 다음 조건을 통과할 때만 성공하도록 만들었다.

  1. 원본은 읽기 전용 SQLite snapshot이고 대상 PostgreSQL은 비어 있다.
  2. Alembic revision과 vector extension이 준비되어 있다.
  3. 외래 키 순서에 맞춰 데이터를 복사하고, 실패하면 PostgreSQL transaction을 rollback한다.
  4. 모든 테이블의 row count와 핵심 데이터 값을 source와 비교한다.

컨테이너가 exit code 0으로 끝났다고 이관이 끝난 건 아니다. 스키마와 데이터는 물론이고, API와 네트워크 경계까지 따로 확인했다.

스키마: Alembic revision, vector extension
데이터: 테이블별 row count, 시간값·원문 등 핵심 값 표본
애플리케이션: 로그인, 작성, 조회, 삭제
운영: DB의 내부 네트워크 격리, API health check

롤백 창은 새 쓰기 전에 끝난다

이관에서 제일 위험한 생각은 “문제가 생기면 SQLite로 돌아가면 된다”는 말이다. 그 말이 맞는 건 PostgreSQL이 새 데이터를 받기 전까지다.

시점 대응
PostgreSQL 검증 전 API를 멈춘 채 이관을 다시 실행한다
PostgreSQL API를 열기 전 이전 Compose 설정과 SQLite snapshot으로 되돌릴 수 있다
PostgreSQL이 새 쓰기를 받은 후 SQLite로 단순 복귀하면 새 데이터가 사라진다. PostgreSQL backup/restore를 기준으로 복구해야 한다

그래서 API 전환 직후 PostgreSQL 첫 백업을 만들고 복원 목록까지 확인했다.

pg_dump --format=custom --no-owner --no-privileges \
  --username="$POSTGRES_USER" \
  --dbname="$POSTGRES_DB" > ./backups/post-cutover.dump

pg_restore --list ./backups/post-cutover.dump > /dev/null

이 시점부터는 기존 SQLite 백업 cron도 PostgreSQL용으로 바꿔야 한다. DB만 바꾸고 백업 대상을 그대로 두면 복구 계획은 무용지물이 된다.

마치며

다 끝나고 보니 DB 엔진만 바뀐 게 아니었다. 어느 DB를 기준 데이터로 볼지 정한 작업이었다.

PostgreSQL + pgvector를 선택하니 원문, 임베딩, 권한, 삭제, 백업을 한 DB 경계 안에서 관리할 수 있었다. 컷오버 때도 새 source of truth가 바뀌는 시점과 되돌릴 수 있는 범위를 분명히 정할 수 있었다.

다음에도 비슷한 이관을 한다면, 성능 수치보다 먼저 적을 건 롤백 창이다. 실패했을 때 원본을 잃지 않고 어디까지 되돌릴 수 있는지. 그 답이 있어야 새 DB를 열 수 있다.

참고

givemethatsewon profile
@givemethatsewon
프로젝트를 만들고 운영하면서 배운 개발, 제품, 디버깅 기록을 남깁니다.