벡터 검색은 권한을 모른다: RAG 필터를 서버에 둔 프론트엔드 계약
RAG 검색 화면에서 user_id를 클라이언트가 보내면 안 되는 이유와, 프론트엔드가 query만 보내도록 요청 계약을 바꾼 과정을 적었어요.
검색 결과 리스트에 남의 기록이 한 줄 섞여 있다고 상상해 보세요. 화면은 멀쩡하고 로딩도 잘 끝났는데 내용이 틀린 거예요. 벡터 검색을 쓰는 화면에서 이 사고가 유난히 쉽게 나는 이유가 있어요. 임베딩은 문장이 얼마나 비슷한지만 알지, 그 문장이 누구 것인지는 몰라요. 권한은 검색 엔진 밖에서 누군가 붙여 줘야 해요.
프론트가 user_id를 보내는 순간 생기는 일
처음엔 편하게 갔어요. 검색 컴포넌트가 { query, userId }를 서버로 보내고, 서버는 받은 userId로 필터를 걸었죠. 테스트해 보면 이 구조가 얼마나 얇은지 금방 보여요. 브라우저 개발자 도구에서 요청을 복사해 userId 값 하나만 바꿔 다시 보내는 데 30초면 충분해요. 화면이 아무리 정직해도 화면 밖에서 오는 요청까지 정직하다고 믿을 근거는 없어요.
그래서 계약을 뒤집었어요. 클라이언트가 보내는 필드는 query 1개뿐이고, 사용자 식별은 서버가 세션 쿠키에서 꺼내요. 프론트엔드 입장에선 보낼 게 줄어드니 코드도 짧아졌어요.
// client: only the question crosses the wire
await fetch("/api/search", {
method: "POST",
credentials: "include",
body: JSON.stringify({ query }),
});화면에서 다시 거르지 않기
여기서 한 번 헷갈렸어요. 응답에 소유자 필드가 들어 있으니 화면에서도 한 번 더 걸러 두면 안전하지 않을까 싶었죠. 직접 만들어 보니 오히려 반대였어요. 프론트에서 걸러 버리면 서버 필터가 새는 버그가 조용히 묻혀요. 그래서 응답은 zod 스키마로 형태만 검증하고, 내 것이 아닌 항목이 섞여 오면 렌더하지 않는 대신 에러로 올려요. 화면은 서버를 감싸 주는 쪽이 아니라 서버 버그를 드러내는 쪽이어야 해요.
검색창 자체도 손봤어요. 입력은 300ms 디바운스로 요청 횟수를 줄이고, 길이는 500자에서 잘라요. 결과는 상위 5건만 받아 리스트로 그려요. 이 숫자들은 서버가 정한 값이고 화면은 그대로 따르기만 해요.
빈 결과와 권한 오류는 다른 화면이에요
돌려 보니 UX에서 제일 자주 놓치는 지점이 상태 구분이었어요. 결과가 0건인 것과 세션이 만료된 것, 권한이 없는 것은 전부 다른 상황인데 처음엔 셋 다 "결과가 없어요" 한 줄로 퉁쳤거든요. 지금은 세 상태를 나눠요. 0건이면 "관련된 기록이 아직 없어요"라고 보여 주고, 401이면 로그인 화면으로 보내고, 403이면 "이 검색은 현재 계정으로 볼 수 없어요"처럼 상황과 원인, 다음 행동을 같이 적어요.
0건 응답 · 401 세션 만료 · 403 권한 없음, 세 상태의 화면 처리 비교
화면은 물어보기만, 판단은 서버가
라이프케어로그 플랜 시리즈를 만들면서 이 원칙이 습관이 됐어요. 플랜엘의 월 3건 무료 한도도 화면에서 세면 의미가 없고 서버가 세야 지켜지는 것과 같은 이치예요. 검색 컴포넌트는 질문을 받아 넘기고 상태를 정직하게 보여 주는 일까지만 맡고, 누구의 데이터를 보여 줄지는 서버가 정해요. 적용해 봤더니 컴포넌트 코드는 줄고 걱정거리도 같이 줄었어요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.