일기 서비스에 “비슷한 기록 보여주기”를 넣으려 했다. 같은 단어가 있는 글이 아니라 문맥과 감정이 닮은 과거 일기를 찾는 기능이다. 텍스트를 임베딩으로 바꾸고, 벡터를 저장한 뒤 가까운 기록을 찾는 흐름이 필요했다.
처음에는 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-net과 dear-crush-internal에 연결하고, PostgreSQL은 dear-crush-internal에만 연결했다.
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를 동기화하는 방법도 있다. 대신 이 서비스에서는 짧은 쓰기 중단을 허용하고, 절차를 단순하게 만들었다. 중간 상태가 적을수록 검증도 쉬웠다.
실행 명령은 서비스별 경로와 비밀값을 제거하면 아래 정도로 단순하다.
# 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 apidocker-compose.cutover.yml은 API 이미지를 새로 만들려는 파일이 아니다. 같은 이미지를 이관 명령으로 한 번만 실행하는 override다. 이관이 끝나면 빼고 정상 API 모드로 다시 띄운다. 덕분에 빈 PostgreSQL을 바라보는 API가 먼저 떠서 쓰기를 받는 중간 상태를 막을 수 있다.
이관 컨테이너는 다음 조건을 통과할 때만 성공하도록 만들었다.
- 원본은 읽기 전용 SQLite snapshot이고 대상 PostgreSQL은 비어 있다.
- Alembic revision과
vectorextension이 준비되어 있다. - 외래 키 순서에 맞춰 데이터를 복사하고, 실패하면 PostgreSQL transaction을 rollback한다.
- 모든 테이블의 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를 열 수 있다.
