RAG 엔진은 지금까지 별도의 관리 콘솔 안에서 살았다. 문서 업로드, 청킹, 임베딩, 검색 실험, 평가까지 콘솔 하나가 다 들고 있었고, 그 옆에는 조직의 정식 어드민 대시보드가 따로 있었다. 관리 화면이 두 개면 계정도 두 벌, 권한 체계도 두 벌이다. 콘솔을 폐기하고 RAG 섹션을 대시보드로 흡수하기로 했다.
말은 "코드를 옮긴다"지만 실제로 옮겨야 했던 것은 세 가지였다. 코드(엔진 2,100줄 + 서비스 1,600줄 + API 라우트 + 화면 3,400줄), DB 스키마의 관리 주체(콘솔이 사라지면 마이그레이션 소유자가 없어진다), 그리고 정책(대시보드의 아키텍처 결정 기록이 RAG 소유를 명시적으로 금지하고 있었다). 이 글은 그 셋을 하루에 옮기면서 결정한 것과 증명한 것, 그리고 밟은 함정의 기록이다.
1. 첫 번째 관문은 코드가 아니라 ADR이었다
대시보드 저장소의 ADR 0001은 경계가 명확했다. "이 대시보드는 RAG ingestion, embedding, retrieval, reranking, vector storage를 소유하지 않는다. PostgreSQL에 임베딩을 저장하지 않는다." 심지어 저장소의 에이전트 지침(AGENTS.md)은 "그 구현을 이 저장소에 추가하지 말라"고 못 박고 있었다. 이 상태에서 엔진 코드를 먼저 커밋하면, 커밋 하나하나가 그 시점의 저장소 정책 위반이 된다.
그래서 첫 커밋은 문서였다. 새 ADR 0004가 세 가지를 했다.
- supersede 관례를 신설했다. 이 저장소에는 ADR을 개정하는 관례 자체가 없었다. ADR 0004가 "개정하는 ADR은 대체하는 조항을 정확히 명시하고, 대체되는 ADR에는 Superseded-by 헤더를 단다"는 규칙을 만들면서 그 첫 사례가 됐다.
- 전면 폐기가 아니라 조항 단위로 개정했다. ADR 0001의 다른 경계(에이전트 런타임, MCP 서버, 채팅 시스템 설치를 소유하지 않는다)는 여전히 유효하다. 문서 전체를 Superseded로 바꾸면 살아 있는 규칙까지 죽은 것으로 읽힌다. RAG 관련 세 조항만 콕 집어 대체했다.
- 금지를 승인으로 바꿀 조건을 채웠다. 지침에는 "보존·리댁션·역할 접근 정책이 명시적으로 승인되기 전까지 질의 내용을 영속화하지 말라"는 조항이 있었고, 엔진의 검색 실험 로그와 평가 스냅샷이 정면으로 걸렸다. ADR에 보존 기간(질의 실행 180일 후 삭제, 평가·벤치마크는 품질 기준선으로 무기한)과 접근 권한을 명시해 그 "명시적 승인"을 만들었다.
같은 경계 문구가 README, 기여 가이드, 계약 문서, 운영 문서 등 열한 곳에 반복되고 있어서 전부 같은 커밋에서 고쳤다. 흩어진 사본 중 하나라도 남으면 다음 사람이 어느 쪽을 믿어야 할지 모르게 된다.
문서 규칙에서 나온 지시가 하나 더 있었다. 폐기될 콘솔의 프로젝트명을 문서에 남기지 않는다. 곧 사라질 이름이 살아남는 문서에 박제되면 몇 달 뒤 읽는 사람에게 죽은 참조만 남는다. 문서는 "legacy console"류의 범용 표현으로 통일했고, 이 원칙이 다음 절의 DB rename으로 이어졌다.
2. DB는 그대로 두고 이름만 바꿨다, 그리고 그 rename이 잠금장치였다
RAG 데이터(문서 원본, 추출 텍스트, 청크, 1024차원 임베딩)는 채팅 스택의 pgvector PostgreSQL에
얹힌 admin_db에 있었다. 데이터는 옮기지 않는다. 대시보드가 두 번째 datasource로 같은 DB에
붙는다. 다만 DB 이름이 콘솔의 이름을 따고 있어서, 콘솔 폐기와 함께 rag_db로 바꾸기로 했다.
처음에는 이름 정리 정도로 생각했는데, 실행해 보니 rename이 컷오버의 핵심 장치였다.
ALTER DATABASE "admin_db" RENAME TO "rag_db";
ALTER ROLE "admin" RENAME TO "rag";
rename이 실행되는 순간 레거시 콘솔의 접속 문자열이 무효가 된다. 콘솔이 실수로 다시 기동돼도 존재하지 않는 DB 이름을 찾다 죽는다. "이관 후 콘솔이 몰래 쓰는" 이중 기록(dual-writer) 사고가 정책 문서가 아니라 물리적으로 차단된 것이다.
역할 rename에는 조건이 하나 있다. PostgreSQL의 구식 MD5 암호는 해시에 사용자명이 솔트로 섞여
있어 역할 이름을 바꾸면 암호가 소실된다. 이 서버는 기본값이 SCRAM-SHA-256이라(사용자명
독립적) 무중단으로 넘어갔다. rename 전에 pg_authid에서 해시 방식을 확인하는 것이 순서다.
컷오버 절차는 이랬다. 접속 중인 클라이언트를 먼저 확인하니(pg_stat_activity) 검색 전용 MCP
서버 두 대와 관리자의 DataGrip이 붙어 있었다. 셋을 정리하고 rename한 뒤, MCP 서버들의 접속
URL을 갱신해 재기동했고(헬스체크 200 확인), 같은 DB를 쓰는 인증 게이트웨이의 설정 파일도 함께
갱신했다. 이관 전후로 청크 365개, 문서 9개가 그대로임을 확인했다.
3. Prisma 프로젝트 두 벌 — baseline은 바이트 동일성이 전부다
대시보드는 이제 Prisma 프로젝트를 두 개 가진다. 기존 컨트롤플레인 DB용과 신규 rag_db용.
Prisma 7의 --config 플래그로 설정 파일 두 벌이 공존하고, 각자 자기 마이그레이션 디렉터리를
소유한다.
핵심은 기존 마이그레이션 이력의 인수였다. 콘솔이 만든 마이그레이션 7개를 새 저장소로 가져와야
하는데, Prisma의 _prisma_migrations 테이블은 마이그레이션 파일의 체크섬을 기록한다. 파일을
바이트 동일하게 복사하면(diff -r로 확인했다) 새 저장소에서 돌려도 "이미 적용됨"으로 인식된다.
migrate resolve 같은 수동 개입 없이 baseline이 성립했고, 이후 상태 점검은 늘 두 명령이었다.
npx prisma migrate status --config prisma.rag.config.ts # up to date 인지
npx prisma migrate diff --from-config-datasource --to-schema ... # drift 0 인지
복사할 때 포맷터가 공백 하나라도 바꾸면 체크섬이 깨져 전부 "수정된 마이그레이션"이 된다.
그래서 복사 직후의 diff -r 검증이 이 방식의 전부다.
또 하나, 이 스키마에는 지울 수 없는 선언이 있다. 임베딩 컬럼은 Prisma가 이해하지 못하는
vector(1024) 타입이라 Unsupported로 선언돼 있고, 읽기와 쓰기는 전부 raw SQL이 한다.
생성된 클라이언트에는 이 필드가 아예 없다. 그렇다고 스키마에서 선언을 빼면 Prisma가 drift로
판정해 다음 마이그레이션에서 컬럼을 DROP한다. 임베딩 전체가 날아가는 경로다. 선언 유지 여부를
게이트(drift 드라이런 0건)로 잠갔다.
4. cross-DB가 강제한 설계 두 가지
DB가 둘이 되면 그 사이에 걸칠 수 없는 것이 두 가지 생긴다. 외래키와 트랜잭션이다.
외래키 절단. RAG 실험 로그와 평가에는 "누가 남겼나"가 있고, 원래는 콘솔의 계정 테이블로 FK가 걸려 있었다. 이제 행위자는 다른 DB의 대시보드 관리자 계정이다. cross-DB FK는 불가능하므로 FK 세 개를 끊고, 행위자 id를 평문 문자열로 두면서 이메일 스냅샷 컬럼을 추가했다. 화면 표시는 스냅샷이 담당하고, "1인 1대상 1평가" 유니크 제약은 id 문자열 기준으로 그대로 성립한다. 마침 세 테이블 모두 0행이라 과거 데이터의 이중 정체성 문제는 없었다.
best-effort 감사. 대시보드의 기존 규율은 "권한 있는 뮤테이션은 도메인 변경과 감사 기록을
한 트랜잭션으로" 였다. 그런데 RAG 쓰기는 rag_db에, 감사 로그는 컨트롤플레인 DB에 남는다.
묶을 수 없다. 그래서 순서를 규칙으로 만들었다. RAG DB에 먼저 쓰고(필요하면 그 DB 안에서만
트랜잭션), 성공하면 감사를 기록하되 감사 실패는 로그만 남기고 RAG 쓰기를 롤백하지 않는다.
업로드처럼 202로 수락하고 큐가 뒤에서 처리하는 작업은 수락 시점에 1건만 감사한다. 내부 상태
전이는 문서의 상태 컬럼이 이력이다. 이 결정 자체를 ADR에 명문화했다.
5. 이식이 검색을 바꾸지 않았다는 증명 — rank parity
엔진 코드 이식은 기계적이었다. 경로 별칭을 상대경로로 치환하고 DB 클라이언트 import를 바꾸는 스크립트 한 번에 33개 파일이 넘어왔고, 한국어 조문 청킹의 golden 픽스처 테스트가 무수정으로 통과했다. 시작 전에 "golden을 고쳐야 통과한다면 그건 이식 오류다"를 기준으로 정해 뒀었다.
그래도 청킹 테스트는 청킹만 증명한다. 검색 전체(키워드 + 벡터 + RRF 융합 + ONNX 리랭킹)가 같은지는 별도 증명이 필요했다. 방법은 대조 실행이었다. 레거시 저장소의 엔진 코드를 같은 DB에 직접 연결해 돌린 결과와, 새로 이식된 API의 결과를 같은 질의로 비교했다.
OLD | 7903d3da1d0279e7 | 제17조 (경조휴가) ...
OLD | 7081844bc2c85621 | [별표1] 경조휴가 및 경조금 지급기준 ...
NEW | 7903d3da1d0279e7 | 제17조 (경조휴가) ...
NEW | 7081844bc2c85621 | [별표1] 경조휴가 및 경조금 지급기준 ...
청크 식별자까지 동일한 순서. int8로 양자화된 리랭커는 배치 구성에 따라 로짓이 흔들리므로 점수 비교는 애초에 무의미하고, 순위와 포함 여부가 같으면 충분하다. 이식이 검색 품질을 건드리지 않았다는 가장 강한 증거를 여기서 얻었다.
참고로 엔진을 CLI에서 직접 돌릴 때는 함정이 하나 있다. 엔진 파일들이 server-only 마커를
import하고 있어서 일반 실행은 즉사한다. npx tsx --conditions=react-server로 번들러 조건을
맞춰야 한다.
6. 인증 치환은 두 줄 규칙으로 끝났다
콘솔은 자체 세션(관리자/뷰어 2역할)을, 대시보드는 HMAC 서명 세션에 4역할 × 11권한 체계를 쓴다. 30여 곳의 인증 호출을 옮겨야 했는데, 규칙은 두 줄이면 됐다.
| 콘솔 | 대시보드 |
|---|---|
requireAdmin() |
authorize(request, "rag:write") |
requireSession() |
authorize(request, "rag:test") |
신규 권한은 0개다. 대시보드의 권한 어휘에 이미 rag:write와 rag:test가 있었고(예전 프록시
화면의 유산), 의미가 정확히 들어맞았다. 운영자(OPERATOR) 역할의 유일한 쓰기가 "평가 저장"인
것도 콘솔 시절 뷰어의 설계 의도 그대로다. 치환 후 4역할 × 주요 라우트로 401/403 매트릭스를
실제 호출로 검증했다. 익명은 401, 감사자는 전부 403, 운영자는 읽기 200에 쓰기 403, 평가만 201.
7. UI 재작성 비용의 실체는 디자인 시스템이 아니라 i18n이었다
화면 3,400줄은 복사가 아니라 재작성이었다. 걱정했던 디자인 시스템 메이저 버전 갭(0.1 → 0.4)은 의외로 작았다. 컴포넌트 규약이 하위 호환이라 대부분 그대로 옮겨졌다. 진짜 비용은 다국어였다. 콘솔은 한국어 하드코딩이었고 대시보드는 3개 로케일(en/ko/vi)을 정합성 테스트로 강제한다. 결국 번역 키 200여 개를 새로 설계해 세 벌을 채웠다.
전부 번역하지는 않았고, 예외 두 가지를 ADR에 명시했다. 엔진이 만들어내는 동적 문구("OCR 12/37쪽" 같은 진행 표시)는 UI 카피가 아니라 데이터라 원문 그대로 보여주고, 운영 가이드 화면은 검증된 한국어 원문을 유지하되 비한국어 로케일에는 안내 배너만 로케일로 띄운다.
이관 후 사용자 피드백으로 내비게이션도 손봤다. 사이드바의 RAG 항목 아래에 프로젝트들이 접을 수 있는 하위 메뉴로 들어갔고(프로젝트 목록은 루트 레이아웃이 best-effort로 읽어서 RAG DB가 죽어도 셸은 산다), 프로젝트 하위 화면 이동은 밋밋한 링크 나열 대신 활성 표시가 있는 링크 탭으로 바꿨다.
8. Docker — standalone을 포기하고 얻은 것과 치른 것
대시보드의 기존 프로덕션 이미지는 Next.js standalone 출력 기반의 날씬한 이미지였다. 엔진이
들어오면서 이 전제가 깨졌다. 리랭커(ONNX)와 OCR 워커가 파일 추적에서 숨겨진 경로로 자산을
로드하기 때문에, standalone으로 빌드하면 빌드는 성공하고 이미지도 뜨는데 리랭킹이 런타임에
죽는다. 조용히 반쪽이 되는 최악의 실패 유형이다. 전체 node_modules를 싣고 next start로
도는 2GB 이미지로 전환했다.
compose에는 요구사항이 줄줄이 붙었다. 채팅 스택의 외부 네트워크에 조인해 rag_db에 닿고,
300MB 리랭커 가중치는 읽기 전용 바인드 마운트, 한국어 OCR 데이터는 named volume으로 재사용,
임베딩 서버(호스트 프로세스)는 host-gateway 매핑으로 접근. 그리고 주석 한 줄이 제일 중요하다.
절대 replica를 2로 올리지 말 것. 인제스트 큐가 인프로세스라 인스턴스가 둘이면 큐도 둘이다.
npm 쪽 함정도 이식했다. npm 11.17부터 install 스크립트가 기본 차단되는데, 이 프로젝트는 그
스크립트로 네이티브 바이너리를 받는다. 허용 목록 .npmrc가 없으면 역시 "빌드는 성공, 리랭크와
마이그레이션은 런타임 사망" 패턴이 된다.
검증은 격리 스택 스크립트를 확장해서 했다. 일회용 네트워크와 pgvector 스탠드인 컨테이너를 만들어 RAG 마이그레이션 8건과 vector/pg_trgm 확장 설치까지 단언하고, 이미지 빌드부터 기동, 보안 하드닝, 백업/복원까지 전 항목을 통과시킨 뒤에야 실제 스택을 교체했다. 교체 후 컨테이너 안에서 실검색까지 확인했다(임베딩 2.7초 + 리랭커 첫 로드 8.3초, 이후는 캐시).
운영에서 하나 배운 것: 마이그레이션은 이미지에 구워진다. 나중에 테이블 정리 마이그레이션을 추가했을 때, 배포용 마이그레이션 서비스가 "적용할 것 없음"이라고 답했다. 이미지가 새 파일을 모르는 것이다. 마이그레이션을 추가하면 operations 이미지 재빌드가 세트다.
9. 실패를 판정하려면 대조군이 필요하다 — e2e 두 건의 무죄 증명
이관 후 접근성 e2e(3로케일 × 3뷰포트 axe 매트릭스)에서 두 건이 떨어졌다. 반사적으로 고치려 들기 전에 물었다. 이게 이관 때문인가?
무수정 develop을 git worktree로 꺼내 같은 환경에서 돌렸다. 동일하게 실패했다. 원인은 이관과 무관한 환경 차이였다. macOS의 폰트 메트릭에서만 특정 화면이 모바일 뷰포트를 몇십 픽셀 넘쳐 스크롤 영역이 되고, 포커스 가능한 콘텐츠가 없는 스크롤 영역이라는 WCAG 위반이 뜬다. CI(리눅스)에서는 넘치지 않아 통과한다. 회귀 판정은 항상 대조군과 함께 해야 한다. 고치지 않은 것이 이 절의 결론이다.
10. 이번 세션에서 실제로 밟은 함정들
- brew의 자동 cleanup이 옆 프로그램을 죽인다. Node 22를 keg-only로 설치했더니 Homebrew가 공유 라이브러리(simdjson)를 올리면서 구버전 dylib을 지웠고, 그 라이브러리에 링크된 기존 Node 25가 dyld 에러로 즉사했다. 재설치로 복구했지만, brew install 한 번이 다른 도구를 깨는 경로는 기억해 둘 만하다.
- 로그인이 안 되는 두 가지 방식. 하나는 검증용으로 무작위 생성한 시드 비밀번호(사람이 알 수
없다). 다른 하나는 더 교묘했다. 공개 URL 설정이
127.0.0.1인데 사용자가localhost로 접속하면, 로그인 쿠키는 localhost에 심기고 성공 리다이렉트는 127.0.0.1로 간다. 브라우저 입장에서 다른 origin이라 쿠키가 없고, 로그인 화면으로 되돌아온다. 성공했는데 실패로 보인다. - DataGrip의 증분 인트로스펙션. 서버 쪽에서 DB를 rename하고 세션을 강제 종료한 뒤에는, 일반 새로고침이 캐시 이후 변경된 테이블만 다시 읽는다. 14개 중 rename 이후 스키마가 바뀐 3개만 보이는 기묘한 화면이 됐다. Force Refresh(캐시 폐기)가 답이다.
11. 마지막 정리 — 지운 것과 남긴 것
콘솔 폐기가 확정되자 테이블 정리 요청이 왔다. 여기서 중요한 건 지우지 않은 쪽이다.
- 지웠다: 콘솔의 계정 테이블(2행). FK는 이미 끊겼고 귀속은 이메일 스냅샷이 대신하므로 참조 무결성 영향이 없다. 정식 마이그레이션으로 제거했고 drift 0을 확인했다.
- 남겼다: 같은 DB에 얹혀 있던 외부 서비스 연동 테이블 3종. 이건 콘솔 것이 아니라 별도로 살아 있는 인증 게이트웨이가 소유한 라이브 데이터(OAuth 연결 5건, 암호화된 토큰)였다. "불필요한 테이블"을 지우기 전에 소유자를 먼저 확인하는 것, 이관 내내 반복된 원칙이다.
남은 것은 셋이다. 사용자 최종 테스트 후의 develop 머지(테스트 완료 전 머지 금지가 이번 작업의 운영 규율이었다), 레거시 콘솔 저장소의 실제 폐기, 운영 가이드의 영어/베트남어 번역.
관련 문서
- 하루 만에 설계 원칙을 뒤집다 — RAG 엔진을 콘솔 안으로 들이기 — 이번에 옮긴 엔진이 애초에 콘솔에 들어가게 된 경위(전편)
- 채팅 DB에 pgvector를 얹다 — PostgreSQL 인스턴스 두 개를 하나로 —
rag_db가 채팅 스택의 postgres에 얹혀 있게 된 배경 - 빌드는 통과했는데 이미지는 반쪽이었다 — 네이티브 애드온을 쓰는 Next.js 앱 도커화 — standalone 포기와 npmrc 허용 목록의 원 조사
- 채널마다 다른 규정을 보게 하기 — 멀티 프로젝트 RAG 결선 — 컷오버 때 재기동해야 했던 검색 MCP 서버의 결선 구조
- 모델은 빈칸을 상상으로 채운다 — 사용자별 인증 게이트웨이 개발서버 배포 — 끝까지 지우지 않은 연동 테이블의 실소유 서비스
- 운영 가이드를 화면으로 만들다 — 문서를 쓰다 제품의 결함 세 개를 찾았다 — 이번에 한국어 원문 그대로 이관한 가이드 화면의 탄생기