RAG 개인화 리뷰... 튜터님의...?

2026. 6. 1. 21:12·IL/TIL

미션 RAG 개인화 기능, 코드 잘 봤어요. 솔직히 말하면 부트캠프 팀프로젝트에서 "장애 격리"와 "트랜잭션 경계"를 이 정도로 진지하게 고민한 코드를 보기가 쉽지 않아요. 외부 AI 호출을 사용자 트랜잭션 밖으로 빼낸 것, RAG가 실패해도 미션 생성은 살리도록 일관되게 설계한 것, 심지어 프롬프트 인젝션까지 의식하고 방어 문구를 넣어둔 것 — 이건 실무 백엔드가 똑같이 고민하는 지점들이거든요. 그 위에서, 운영 단계로 넘어갈 때 닫아두면 좋을 포인트들을 짚어볼게요.


0. 여러분이 만든 구조부터 한 번 같이 보면

[미션 완료/피드백]
   └─ MissionMemoryRecorder → UserMemoryEmbeddingQueue.enqueue()
        └─ user_memory_embeddings에 PENDING row만 적재 (외부호출 X, 짧은 tx)   ← 적재
                                  │
   [Scheduler @fixedDelay] ──────┘
   └─ UserMemoryEmbeddingDispatcher.dispatchDue()
        ├─ claim(tx): canClaim → FOR UPDATE → markProcessing(가시성 타임아웃)
        ├─ gRPC: AiTextEmbeddingClient → ai모듈 → Spring AI(Gemini)  ← tx 밖에서 호출
        └─ markSucceeded: 정규화 벡터를 pgvector 컬럼에 저장               ← 임베딩

[미션 생성 시]
   └─ MissionRagContextService.enrich()
        ├─ 쿼리 텍스트 임베딩(gRPC, 동기)
        ├─ searchSimilar(): `<=>` 코사인거리 + 유사도 임계 필터
        └─ recentMissionContext JSON에 ragMemories 병합 (referenceOnly 표시)   ← 검색·주입

적재 → 임베딩 → 검색·주입의 세 단계가 모듈 경계와 트랜잭션 경계를 따라 깔끔하게 갈려 있어요. 아래 리뷰는 이 흐름 위에서 *"운영에 나갔을 때 어디가 먼저 삐걱댈까"*를 짚는 거예요.


1. 짚고 가면 좋을 포인트 (우선순위 순)

① 벡터 컬럼에 ANN 인덱스가 아직 없어요 ★★★

무엇이 문제이거나 모호한가V9__create_user_memory_embeddings.sql을 보면 인덱스가 (status, next_attempt_at, id)와 (user_id, status) 두 개인데요. searchSimilar에서 쓰는 e.embedding <=> CAST(? AS vector)(코사인 거리) 연산을 받쳐줄 벡터 인덱스(HNSW / IVFFlat)는 아직 없어요. pgvector는 이 인덱스가 없으면 해당 연산을 후보 전체에 대해 순차 계산하고 정렬하는 brute-force로 처리하거든요.

어디가 문제로 번질 수 있는가 지금은 WHERE e.user_id = ?로 한 사용자 행만 보니까 MVP 규모에선 멀쩡해요. 다만 이번 트랙의 목표가 "대규모 트래픽에 견디는 백엔드"잖아요. 한 사용자의 누적 기억이 수천 건으로 늘고 미션 생성마다 이 쿼리가 돌면, 생성 지연이 기억 건수에 비례해 늘어요. 발표 때 "왜 인덱스가 없나, 언제 걸 건가" 질문이 들어오면 그때 답을 만들기보다 미리 닫아두는 게 편해요.

어떻게 개선하면 좋을까 인덱스를 당장 추가하라는 게 아니에요. ADR에 "현재는 per-user 필터로 후보가 작아 brute-force를 허용하고, 사용자당 기억이 N건 또는 검색 지연이 M ms를 넘으면 HNSW를 도입한다"는 임계선 한 줄을 적어두면 이 질문이 닫혀요. HNSW를 쓸 때 코사인 거리와 정합한 opclass(vector_cosine_ops)가 무엇인지도 같이 메모해두면 좋고요.

② 배포 직후 임베딩이 한꺼번에 몰릴 수 있어요 ★★★

무엇이 문제이거나 모호한가V9 마지막의 INSERT ... SELECT ... FROM user_memories 백필이 기존 모든 기억을 PENDING으로 한 번에 적재해요. 배포 직후 스케줄러가 이 백로그 전체를 Gemini 임베딩으로 밀어내기 시작하는데, 이 비용·쿼터 폭증을 막는 장치는 마이그레이션 자체엔 없어요.

어디가 문제로 번질 수 있는가 batchSize와 백오프로 속도는 제한되지만 총량(기존 기억 전체 × 임베딩 단가)은 그대로예요. 하필 AI 기능을 시연하는 자리 직전에 배포가 들어가서 Gemini rate limit이나 비용 한도를 건드리면, RAG가 통째로 degrade된 상태로 데모를 하게 될 수 있어요.

어떻게 개선하면 좋을까 백필을 막을 필요는 없어요. "백필 N건 × 단가 = 예상 비용/쿼터, 그리고 다 소진되는 데 걸리는 시간"을 한 번 추산해서 운영 노트나 ADR에 적어두는 것이 가장 가치 있어요. 데모 환경에서는 백필 INSERT를 분리하거나 mission.memory-embedding.enabled로 시작 시점을 통제하는 선택지도 같이 적어두면, 그게 곧 의사결정 기록이 돼요.

③ 인스턴스를 여러 개 띄우면 같은 임베딩을 중복 호출할 수 있어요 ★★

무엇이 문제이거나 모호한가UserMemoryEmbeddingDispatcher.claim()이 canClaim(락 없는 조회) → findJobForUpdate(FOR UPDATE OF e) → markProcessing 순인데요. canClaim 시점과 락을 잡는 시점 사이에 다른 인스턴스가 먼저 claim할 수 있고, 락을 잡은 뒤 상태를 다시 확인하지는 않아요. findDispatchableIds에도 SKIP LOCKED가 없어서 두 인스턴스가 같은 id 목록을 받아 경쟁하게 돼요.

어디가 문제로 번질 수 있는가 앱이 단일 인스턴스면 @Scheduled가 직렬로 돌아 문제가 안 생겨요. 그런데 수평 확장(replica가 2개 이상)하는 순간 같은 row를 둘이 임베딩해서 AI 호출이 중복돼요. markSucceeded가 덮어쓰기라 데이터가 깨지진 않지만, 쿼터·비용이 새고 락 대기 경합이 생겨요. "대규모 트래픽이면 인스턴스를 늘릴 텐데?"라는 가정과 바로 맞닿는 지점이에요.

어떻게 개선하면 좋을까 무엇보다 먼저 "이 서비스는 단일 인스턴스 가정인가, 수평 확장 가정인가"를 ADR에 명시해보세요. 가정이 정해져야 이 포인트의 무게가 정해지거든요. 수평 확장을 전제한다면, findDispatchableIds에 FOR UPDATE SKIP LOCKED를 얹는 방향과 락 획득 후 canClaim을 한 번 더 검증하는 방향 중 무엇을 택할지를 두 분기까지 같이 그려보면 좋아요.

④ 미션 생성 경로에서 임베딩을 동기로 호출하고 있어요 ★★

무엇이 문제이거나 모호한가MissionRagContextService.enrich는 미션 생성 도중에 쿼리 임베딩 gRPC를 동기로 호출해요. 예외는 잘 삼켜서 fallback하지만, 이 레이어에는 타임아웃이 드러나 있지 않아요 — gRPC 클라이언트 deadline 설정에 전적으로 기대고 있어요.

어디가 문제로 번질 수 있는가 AI 모듈이 느려지거나 매달리면, deadline이 없을 경우 미션 생성 요청이 그만큼 끌려가요. "RAG는 보조 기능이라 실패해도 본 경로는 살린다"는 여러분의 설계 의도와 정확히 모순되는 지점이에요. 보조 기능이 본 경로의 응답 시간을 잡고 있으면 곤란하니까요.

어떻게 개선하면 좋을까@GrpcClient("ai") 채널에 deadline이 걸려 있는지 application.yaml에서 먼저 확인해보세요. 이미 있다면 그 값을 ADR로 끌어올려 "RAG 임베딩 호출은 X ms deadline, 초과 시 fallback"이라고 못 박으면 끝이에요. 없다면 그 한 줄을 정하는 게 이번 액션이고요.


2. 스스로 답을 채워보면 좋을 질문들

이 트랙이 가장 무게를 두는 건 "했다/안 했다"가 아니라 *"왜 그렇게 했는가"*예요. 아래는 지적이 아니라, 답을 적어두면 그대로 발표 자료가 되는 질문들이에요.

  1. 유사도 임계 0.72의 근거 — MissionRagProperties의 기본값 0.72는 어디서 나온 값인가요? 샘플 쿼리로 distance 분포를 본 경험값인지, 일단 잡아둔 값인지. 임의값이어도 괜찮아요 — "추후 튜닝 예정"이라고 적혀 있으면 그것 자체가 정직한 의사결정 기록이에요.
  2. 정규화 + 코사인의 의도 — EmbeddingVectorUtils.normalize로 단위벡터를 만든 뒤 <=>(코사인)을 쓰는데, 코사인은 벡터 크기에 영향을 안 받아서 사실상 중복 연산이에요. 혹시 추후 <#>(내적) 연산으로 전환하려고 미리 정규화해둔 거라면, 그건 훌륭한 forward-looking 결정이에요. 그 의도였는지 한 번 짚어보세요.
  3. 768 차원을 고른 이유 — gemini-embedding-001은 기본 3072차원인데 768로 줄였고, DB CHECK 제약까지 768로 고정해뒀어요. 저장·검색 비용과 표현력 사이의 트레이드오프를 의도하고 자른 거라면 좋은 근거가 돼요.
  4. model·dimension이 3곳에 흩어진 결합 — MissionRagProperties 주석이 "ai 설정·mission 설정·DB vector 컬럼이 모두 같아야 한다"고 이미 인지하고 있는데요. 이 셋 중 하나만 어긋나면 임베딩이 조용히 FAILED로 쌓여요. 이 정합성을 어떻게 깨지지 않게 보장할지 답을 적어두면 운영 감각이 드러나요.

3. 정리하며

다시 강조하면, 이 RAG는 데이터 정합성이 무너지는 치명결함은 보이지 않는, 방어적으로 잘 짠 코드예요. 비동기 큐 분리, 실패 graceful degradation, 프롬프트 인젝션 인지까지 — 이미 단단한 자산을 만들어 두셨어요. 지금 남은 일은 코드를 더 쓰는 쪽이 아니라, "인덱스는 언제, 인스턴스는 몇 개, deadline은 몇 ms"라는 운영 가정을 ADR로 고정하는 쪽에 가까워요.

외부 호출을 트랜잭션 밖으로 빼고 RAG 실패를 본 경로와 격리한 그 설계, 여기에 운영 가정 몇 줄만 ADR로 박으면 — 이 기능은 포트폴리오에서 "대규모 트래픽을 의식하고 만들었다"는 가장 강력한 증거가 돼요. 이미 어려운 절반은 끝내두셨으니, 남은 절반은 문장으로 닫는 즐거운 작업이에요.

저작자표시 비영리 (새창열림)

'IL > TIL' 카테고리의 다른 글

별친구 대화에 멀티턴 맥락과 기억 검색 붙이기  (0) 2026.06.04
알림 및 장애 대응 Runbook  (1) 2026.06.02
AI 외부 Provider 호출 보호와 Fallback 설계  (0) 2026.05.29
최종프로젝트 인프라 모니터링 설계  (0) 2026.05.28
사용자/제품 로그 설계  (0) 2026.05.27
'IL/TIL' 카테고리의 다른 글
  • 별친구 대화에 멀티턴 맥락과 기억 검색 붙이기
  • 알림 및 장애 대응 Runbook
  • AI 외부 Provider 호출 보호와 Fallback 설계
  • 최종프로젝트 인프라 모니터링 설계
견지
견지
개발로 개발하는지 새발로 개발하는지 내가 개인 건지 새인 건지 사람인 건지
  • 견지
    개발새발
    견지
  • 전체
    오늘
    어제
    • 분류 전체보기 (33)
      • ... (0)
      • IL (33)
        • TIL (29)
        • WIL (4)
        • MIL (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    git
    DB
    HTML
    oracle
    java
    JavaScript
    JSP
    CSS
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
견지
RAG 개인화 리뷰... 튜터님의...?
상단으로

티스토리툴바