관리자 콘솔의 RAG 데이터베이스는 pgvector가 필요해서 전용 PostgreSQL 컨테이너를 따로 띄우고 있었다. Mattermost도 자기 PostgreSQL을 쓴다. 인스턴스가 둘이다. "하나로 합쳐도 되는 상황인가"에서 시작해 실제로 합치기까지의 기록이다.

1. 인스턴스가 둘이었던 이유

전용 컨테이너를 띄운 근거는 명확했다. Mattermost는 공식 postgres:18-alpine 이미지를 쓰는데 그 이미지에는 pgvector가 없다. 호스트에 이미 깔린 PostgreSQL은 pgvector를 지원하지 않는 버전이었다. 그래서 포트를 나눠 pgvector 전용 인스턴스를 나란히 띄웠다.

그런데 규모를 재 보면 인스턴스를 둘 유지할 근거가 약하다.

DB 크기
채팅 17 MB
RAG 66 MB (청크 1,331개)

합쳐서 83MB다. 인스턴스 하나를 줄여 아끼는 것은 PostgreSQL 상주 메모리 정도다.

2. 첫 답은 "권장하지 않음"이었다

검토 결과는 부정적이었다. 근거가 두 개 있었다.

확장이 아예 없다. 채팅 DB에서 사용 가능한 확장 목록을 조회하면 vector가 나오지 않는다. PostgreSQL 확장은 DB 안의 객체이기 이전에 서버 프로세스가 dlopen하는 공유 라이브러리다. 그래서 SQL 권한이나 스키마 설계로는 절대 우회할 수 없다. 이미지를 바꿔야 한다.

이미지를 바꾸면 정렬 규칙이 바뀐다. pgvector 공식 이미지에는 Alpine 태그가 없다. Debian 계열만 있다. 현재 클러스터는 musl(Alpine)로 초기화되어 있어서 glibc 이미지로 옮기면 문자열 정렬 규칙이 달라진다. B-tree 인덱스가 잘못 정렬된 채로 남아 조회가 행을 못 찾거나 UNIQUE 제약이 중복을 통과시키는, 조용한 손상으로 이어진다.

더 나쁜 건 이 클러스터의 datcollversion이 비어 있다는 점이었다. musl은 collation 버전을 보고하지 않는다. PostgreSQL이 경고조차 해주지 못한다는 뜻이다.

여기까지가 1차 결론이었다. 통합의 대가에 비해 얻는 게 없다.

3. 판단을 뒤집은 것 — Alpine 저장소의 패키지 하나

"다른 버전 이미지를 안 띄우고 지금 인스턴스 그대로 가능한가"를 다시 파고들다가 Alpine 저장소에 pgvector 패키지가 있는 것을 발견했다.

postgresql-pgvector-0.8.1-r0 depends on: postgresql18

의존이 Alpine 자체 postgresql18이라 파일이 /usr/lib/postgresql18/에 깔린다. 반면 공식 도커 이미지의 PostgreSQL은 소스 빌드라 /usr/local/lib/postgresql을 본다. 경로가 어긋날 뿐이고, 둘 다 같은 마이너 버전 / 같은 libc / 같은 아키텍처라 ABI는 맞는다. 이어 주면 된다.

ARG POSTGRES_IMAGE_TAG=18-alpine
FROM postgres:${POSTGRES_IMAGE_TAG}
RUN set -eux; \
    apk add --no-cache postgresql-pgvector; \
    maj="$(pg_config --version | sed -E 's/[^0-9]*([0-9]+).*/\1/')"; \
    ln -s "/usr/lib/postgresql${maj}/vector.so" "$(pg_config --pkglibdir)/vector.so"; \
    cp /usr/share/postgresql${maj}/extension/vector* "$(pg_config --sharedir)/extension/"; \
    test -e "$(pg_config --pkglibdir)/vector.so"; \
    test -e "$(pg_config --sharedir)/extension/vector.control"

핵심은 Alpine을 유지한다는 것이다. libc가 그대로니 정렬 규칙 문제가 통째로 사라지고 REINDEX도 필요 없다. 메이저 버전을 pg_config에서 뽑아 쓰므로 나중에 태그를 올려도 경로가 따라간다. 마지막 test 두 줄은 상류 패키지 레이아웃이 바뀌었을 때 런타임이 아니라 빌드에서 실패시키려고 넣었다.

4. 이미지를 안 바꾸는 방법도 있었다

"이미지를 새로 만드는 것 자체가 부담"인 경우를 위해 다른 방법도 검증해 봤다. 확장 파일 3개 (vector.so, vector.control, vector--0.8.1.sql, 합쳐 217KB)만 공식 이미지에 마운트하는 방식이다.

volumes:
  - ${POSTGRES_DATA_PATH}:/var/lib/postgresql
  - ./pgvector/vector.so:/usr/local/lib/postgresql/vector.so:ro
  - ./pgvector/vector.control:/usr/local/share/postgresql/extension/vector.control:ro
  - ./pgvector/vector--0.8.1.sql:/usr/local/share/postgresql/extension/vector--0.8.1.sql:ro

이것도 동작한다. 다만 디렉터리를 통째로 마운트하면 pg_trgm 같은 기존 확장이 가려지므로 반드시 파일 단위로 걸어야 한다.

커스텀 이미지 쪽을 골랐다. 확장이 이미지에 박혀 있어 유실될 수 없기 때문이다. 마운트 방식은 호스트에 파일 3개를 계속 관리해야 하고, 그게 사라지면 서버는 정상으로 뜨는데 확장만 깨진다. 증상이 조용해서 나쁘다.

5. 스키마를 나누려다 데이터베이스를 나눈 이유

원래 요청은 "채팅 DB 안에 스키마 하나 추가"였다. 그런데 ORM의 접속 코드를 보다가 문제를 찾았다. 런타임이 접속 문자열을 드라이버에 그대로 넘긴다. 실측해 보면 이렇다.

?schema=rag_probe                  -> current_schema=public     ← 무시됨
options=-c search_path=rag_probe   -> current_schema=rag_probe  ← 적용됨

드라이버는 ?schema=라는 파라미터를 모른다. 반면 마이그레이션 CLI는 그 값을 따른다. 결과는 마이그레이션은 지정한 스키마에, 런타임은 public에 붙는 불일치다. 에러도 나지 않는다. "테이블이 없다"는 런타임 오류로만 나타난다.

최근 ORM들이 드라이버 어댑터 구조로 옮겨가면서 생긴 함정이다. 마이그레이션 경로와 런타임 경로의 커넥션 파서가 서로 다르다. 예전 방식(내장 엔진)에서 통하던 관용구가 여기서 조용히 깨진다.

확장 연산자도 스키마에 묶인다. 전용 스키마에 확장을 넣고 search_path 없이 질의하면 이렇게 된다.

ERROR: operator does not exist: rag.vector <=> rag.vector

벡터를 raw SQL로 다루는 코드 전부가 search_path에 의존하게 된다는 뜻이다.

같은 클러스터 안의 별도 데이터베이스로 가면 이 문제군이 통째로 사라진다. 확장은 DB 단위로 설치되고 public이 기본이라 URL 트릭이 필요 없다. 잃는 것은 채팅 테이블과의 SQL JOIN인데, 지금 어디서도 하지 않는다.

6. 백업이 아니라 "복원되는 것"을 확인했다

실사용 중인 채팅 데이터를 건드리는 작업이다. 백업을 떠 놓는 것만으로는 부족하다고 보고, 3중으로 뜬 뒤 그중 하나를 새 이미지로 실제 부팅해서 데이터가 살아 있는지 먼저 확인했다.

# 1. 논리 백업 (무중단). 컨테이너가 read_only라 stdout으로 받는다
docker exec <pg-container> pg_dumpall -U mmuser | gzip > ~/backup/dumpall.sql.gz

# 2. 물리 백업 (무중단)
docker exec <pg-container> pg_basebackup -U mmuser -h /var/run/postgresql \
  -D /tmp/bb -X stream -c fast
docker exec <pg-container> tar cf - -C /tmp/bb . > ~/backup/basebackup.tar

# 3. 콜드 백업 (교체 직전 정지 창에서)
docker compose stop postgres && tar czf ~/backup/volumes-db-cold.tar.gz volumes/db

리허설은 물리 백업을 도커 볼륨에 풀고 새 이미지로 띄운 뒤 행 수를 대조하는 방식이다. 하드닝 설정(read_only, no-new-privileges, tmpfs)도 그대로 재현했다.

시점 users / posts / channels / teams
작업 전 기준값 10 / 194 / 13 / 1
백업 복원 리허설 10 / 194 / 13 / 1
컨테이너 교체 후 10 / 194 / 13 / 1

부팅 로그에 collation 경고는 0건이었다. 이 리허설이 통과한 뒤에야 실물을 건드렸다.

7. 실행

# 커스텀 이미지 빌드
docker compose build postgres

# 옛 전용 인스턴스 정지 (볼륨은 남긴다 — -v 를 붙이지 않는다)
docker compose down

# 콜드 백업 후 교체
docker compose stop postgres
tar czf ~/backup/volumes-db-cold.tar.gz volumes/db
docker compose up -d postgres

# DB·롤·확장. 확장은 반드시 슈퍼유저로 선설치한다
docker exec -i <pg-container> psql -U mmuser -d postgres <<'SQL'
CREATE ROLE admin_app LOGIN PASSWORD '<강한 값>';
CREATE DATABASE admin_db OWNER admin_app;
SQL
docker exec -i <pg-container> psql -U mmuser -d admin_db <<'SQL'
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
SQL
docker exec -i <pg-container> psql -U mmuser -d postgres <<'SQL'
REVOKE CONNECT ON DATABASE mattermost FROM PUBLIC;
REVOKE CONNECT ON DATABASE admin_db   FROM PUBLIC;
GRANT  CONNECT ON DATABASE admin_db   TO admin_app;
SQL

기존 데이터 디렉터리를 그대로 열기 때문에 재초기화도 REINDEX도 일어나지 않는다. 중단은 컨테이너 재생성 몇 초가 전부였다.

관리자 콘솔은 호스트 프로세스라 접근 경로가 필요해서 포트를 하나 열었다. 루프백에만 묶는다.

ports:
  - "127.0.0.1:5433:5432"

8. 실측으로만 드러난 함정 네 가지

# 함정 대응
1 CREATE EXTENSION vector슈퍼유저 전용이다. 마이그레이션이 앱 롤로 돌아 42501 Must be superuser로 죽는다 슈퍼유저가 DB별로 선설치. IF NOT EXISTS라 뒤이은 마이그레이션은 그냥 지나간다
2 드라이버가 ?schema=를 무시한다 스키마가 아니라 데이터베이스를 나눈다
3 PUBLIC은 기본적으로 모든 DB에 CONNECT 권한을 갖는다. DB를 나눠도 RAG 롤이 채팅 DB에 붙는다 REVOKE CONNECT ... FROM PUBLIC 양방향
4 docker cp가 tmpfs 마운트에서 동작하지 않는다(No such container:path). 하드닝된 컨테이너의 /tmp가 tmpfs다 tar cf - | tar xf - 스트림으로 받는다

3번은 격리 전후를 실제로 확인했다. REVOKE 전에는 RAG 롤이 채팅 DB에 접속까지는 됐다. 테이블 권한에서만 막혔을 뿐이다. REVOKE 후에는 접속 자체가 거부된다.

FATAL: permission denied for database "mattermost"
DETAIL: User does not have CONNECT privilege.

9. 결과

<pg-container>  postgres-pgvector:18-alpine  127.0.0.1:5433->5432
  ├─ mattermost (17 MB)   ← 그대로
  └─ admin_db   (8.7 MB)  ← 신규, vector 0.8.1 + pg_trgm 1.6

PostgreSQL 컨테이너는 하나로 줄었다. 검증은 전 항목 통과했다.

  • Mattermost: HTTP 200, DB 쓰기 정상, 데이터 기준값 유지
  • 마이그레이션 6개 적용, 시드 완료
  • 검색 MCP 서버 2개 healthy, DB 인증 실패 0건
  • 격리: RAG 롤 → 채팅 DB 접속 거부

런타임 경로도 실제 코드 경로(드라이버 어댑터)로 따로 확인했다. 이번 설계의 핵심 근거였던 지점이라 짚고 넘어갔다.

접속 DB: admin_db | schema: public
벡터 연산자: 0.00853986601633272

10. 곁가지로 정리한 것

시크릿이 저장소에 평문으로 있었다. 검색 MCP 서버의 compose에 DB 비밀번호가 그대로 적혀 있었다. compose가 같은 디렉터리의 .env를 읽게 바꾸고 .gitignore에 넣었다. 여기서 한 가지 장치를 더 걸었다.

DATABASE_URL: "${DATABASE_URL:?.env에 DATABASE_URL이 필요하다 (.env.example 참고)}"

:?가 없으면 .env를 빠뜨렸을 때 빈 접속 문자열로 조용히 기동한다. 붙여 두면 compose가 즉시 실패한다. 실제로 .env를 치워 보고 확인했다.

Mattermost compose 디렉터리가 별도 git 저장소였다. 상위 저장소의 .gitignore가 그 디렉터리를 통째로 제외하고 있어서, 바깥에서 git status를 봐도 변경이 보이지 않았다. 업스트림 클론이라 compose를 고치면 git pull 때 충돌한다. 전용 브랜치로 옮기고 main은 깨끗하게 뒀다.

벡터 인덱스가 없는 건 의도된 것이었다. 청크 테이블에 HNSW 인덱스가 없어서 처음엔 문서와 실제가 어긋난 줄 알았는데, 과거 마이그레이션이 이유를 적어 두고 DROP한 것이었다. ORM이 hnsw를 표현하지 못해 매 마이그레이션마다 DROP을 만들어 내고, 필터와 함께 쓰면 post-filter로 동작해 상위 k개가 다 걸러지면 결과가 비는 문제가 있다. 청크가 10만 건을 넘으면 iterative scan과 함께 다시 넣기로 되어 있었다.

11. 통합의 대가

기술적 결함이 아니라 통합의 정의다. 실행 전에 미리 적어 두고 들어갔다.

  • 자원: 메모리 상한을 채팅과 RAG가 공유한다. 인제스트 시 임베딩 대량 쓰기가 채팅 지연으로 번질 수 있다.
  • 백업 단위: 논리 백업은 DB별로 뜰 수 있지만 PITR과 볼륨 스냅샷은 클러스터 단위다.
  • 장애 반경: DB가 죽으면 채팅과 RAG가 함께 멈춘다. 이전에는 RAG DB가 죽어도 채팅은 살았다.
  • 업그레이드 일정: Mattermost가 요구하는 PostgreSQL 버전과 pgvector 지원 버전이 서로를 묶는다.

되돌리기는 쉽다. compose에서 커스텀 빌드를 공식 이미지로 되돌리고 재시작하면 채팅은 원상 복구된다. RAG 데이터는 이전하지 않고 처음부터 쌓았으므로 잃을 것이 없다.

12. 남은 것

서버 적용은 아직이다. Linux에서만 걸리는 것들이 따로 있어서 별도로 정리하고 있다. host.docker.internal이 Docker Desktop 전용 이름이라 Linux에는 없고, Ollama는 기본적으로 루프백에만 바인딩해서 컨테이너에서 도달하지 않으며, 프로덕션 서버는 TLS 종료 지점이 하나 더 필요하다. 이건 적용하고 나서 따로 쓴다.

관련 문서