Skip to content

자연어 기반 밴드 데이터 조회 기능 - #203

Merged
njm1250 merged 56 commits into
devfrom
feat/assistant-nl-query
Oct 17, 2026
Merged

njm1250 merged 56 commits into
devfrom
feat/assistant-nl-query

Conversation

@njm1250

@njm1250 njm1250 commented Sep 7, 2026 •

Copy link
Copy Markdown
Member

⏱ 소요 시간

  • 리뷰 예상 시간: 60분

📌 작업 요약

밴드 홈에서 일정·참석·곡·팀을 자연어로 물어보면, AI가 만든 조회 SQL을 서버가 검증한 뒤 실행해 답하는 기능


📝 작업 내용

BE

  1. LLM 호출 모듈 (modules/ai): JSON Schema 구조화 응답, Gemini·OpenAI 호환 provider, API 키·provider 순환, 오류 종류별 재시도와 timeout
  2. Text-to-SQL 생성 (modules/assistant/sql): 허용 카탈로그(테이블·컬럼·JOIN), 업무 용어, 질문 종류별 관계 규칙으로 프롬프트 구성
  3. SQL 검증기: PostgreSQL AST로 검사
    • SELECT 한 문장, 허용 테이블·컬럼·함수만 허용. 문자열 값은 파라미터로 분리
    • bands.id = $1 밴드 범위와 삭제 조건을 최상위 AND로 강제하고, 모든 테이블이 허용 JOIN으로 밴드에 연결되는지 확인
    • $1에는 서버가 멤버십을 확인한 bandId만 바인딩
  4. 실행: 읽기 전용 트랜잭션 + statement timeout, 최대 51행을 읽어 50행과 hasMore 반환
    • 목록(LIST)의 최상위 LIMIT은 서버가 관리
    • 상위 N(TOP_N)과 단일 집계(AGGREGATE)의 LIMIT은 의미가 있어 유지
  5. 재생성: 의미 규칙 위반을 한 번에 모두 알리고, 모든 시도의 위반과 직전 SQL을 함께 줘 최대 2회 재생성. 끝내 실패하면 503이 아니라 kind: REPHRASE 응답
  6. 사람 단위 실행: 참여·팀 편성 테이블은 검증 후 (일정, 멤버)·(팀, 멤버)마다 대표 행 하나만 남기는 조건을 붙여 실행
  7. 응답 계약
    • kind(ANSWER / UNSUPPORTED / CLARIFICATION / REPHRASE)
    • 결과 표(columns, rows, hasMore, resultMode, conditions, title), 이름 후보(clarification)
    • 요약은 모델이 아니라 서버가 조회 결과로 만든 결론 한 줄
    • 조건은 한글 값·한국 시간·"지금"·기간으로 바꾼 칩
  8. 아티스트 이름 확인: 밴드 곡 목록과 대조. 정확히 일치하거나 후보가 하나면 보정하고, 모호하면 후보와 "누르면 보낼 질문"을 함께 반환
  9. 추천 질문: 모델 없이 고정 SQL을 같은 검증기로 확인해 실행(usedLlm: false). 추천 질문마다 이어서 물어볼 질문 제공
  10. 사용자별 호출 제한(기본 5분 10회, 추천 질문 제외)과 질문·SQL·결과 건수를 남기는 감사 로그. 질문의 이메일·전화번호는 가리고, DB 오류는 원문 대신 이름·코드만 남김
  11. 평가 도구 (scripts/assistant-evaluation)
    • 겸업 멤버의 다중 세션을 포함한 합성 DB 시드
    • 개발용 50문항, 수정에 쓰지 않는 별도 평가 20문항, 직전 턴을 고정한 후속 질문 7문항
    • 컬럼 의미·행 순서·중복 개수까지 비교하는 채점기
    • 결과는 저장소 밖 경로에만 저장
  12. 후속 질문 맥락: 답마다 context(의도·원래 SQL·실행 파라미터·결과 건수)를 돌려주고, 다음 요청의 history(최대 3턴)로 받음
    • 받은 SQL은 실행 SQL과 같은 검증을 다시 통과해야 하고, 파싱 후 다시 만든 문장만 프롬프트에 넣음(주석에 숨긴 지시문 제거). 통과하지 못한 턴은 버림
    • 결과 행과 답 문장은 보내지 않음
    • 짧은 후속 질문은 현재 질문으로 인원 단위가 정해지지 않을 때만 바로 앞 질문과 이어 붙여 판별
    • turnIndex(최대 5)를 DTO에서 막고 로그에 남김
  13. 검증기 범위 판정 수정 (3차 리뷰 🔴)
    • 범위 조건을 쿼리 전체에서 모으지 않고 SELECT마다 판정. 범위 루트는 각 SELECT의 WHERE 최상위 AND에서만 인정하고, 서브쿼리는 바깥에서 이미 범위 안인 테이블만 참조 가능
    • LEFT JOIN ON 조건으로는 NULL이 채워지는 쪽만 범위 안에 넣음(RIGHT JOIN은 반대)
    • 별칭 없는 컬럼은 같은 SELECT의 ORDER BY 항목일 때만 출력 별칭으로 인정
    • 빈 문자열·공백 presetId로 호출 제한을 건너뛰던 문제 수정
  14. 전용 DB 역할로 모델 SQL 실행 (마이그레이션 20261013120000_add_assistant_reader_role)
    • 실행 직전 set_config('app.band_id', …, true) + SET LOCAL ROLE assistant_reader, 실행 후 RESET ROLE
    • assistant_reader는 카탈로그 허용 컬럼에만 SELECT 권한(컬럼 단위 GRANT). 카탈로그 spec이 GRANT와 대조
    • 18개 테이블에 이 역할 전용 RLS. 밴드 ID가 없는 테이블은 부모를 거쳐 판단, app.band_id가 없으면 0행
    • 앱 계정이 테이블 소유자가 아니면 RLS를 켜는 순간 앱 조회가 막히므로 마이그레이션이 실패하도록 함
    • 사람 단위 변환의 SELECT *는 카탈로그 컬럼 목록으로 변경
  15. 요청당 생성 시간 상한: SQL 생성·재생성 전체 9초(화면 timeout 10초). LlmService가 마감을 넘기면 재시도·다음 호출을 멈춤. LLM_MAX_RETRIES=0 허용, OpenAI 호환 거절 응답(content: null) 처리

FE

  1. 홈 카드: 검색바에서 바로 입력(입력을 시작하면 질문 버튼 표시), 짧은 이름의 추천 칩
  2. 대화 화면 (AssistantScreen)
    • 보내거나 추천 칩을 누르면 아래에서 올라와 화면 전체를 덮음
    • 질문은 말풍선, 답은 아래로 쌓이고 입력창은 하단 고정
    • 닫고 다시 물으면 새 대화로 시작
  3. 답 표시 (AssistantTurn)
    • 입력 중 표시(최소 0.7초) 뒤 결론 → 조건·결과 → 이어서 물어보기 순서로 등장
    • 0건·1건·상위 N개는 결과 제목을 헤드라인으로 써서 요약과 결과가 같은 말을 반복하지 않게 함
  4. 결과 유형별 렌더러 (AssistantResult)
    • 단일 숫자는 크게, 1건은 카드, 이름 목록은 칩, 여러 열은 순위·열 이름이 붙은 세로 목록
    • 10건이 넘으면 미리보기 후 펼치기, 50건 초과는 안내
  5. 다음 행동
    • 추천 질문별 이어서 물어보기
    • 답할 수 없거나 질문을 바꿔야 할 때 예시 질문
    • 이름 후보 칩
    • 실패하면 질문을 입력창에 되돌리고 다시 시도 버튼(429는 별도 문구)
  6. 응답 계약 반영: zod 응답 스키마와 MSW 목업을 새 계약에 맞춤. kind가 없는 응답도 answerable로 분기
  7. 후속 질문: 이어서 묻는 질문에만 직전 답의 context(최대 3턴)와 turnIndex를 붙여 보냄. 첫 질문의 요청 본문은 그대로. 질문 5개가 쌓이면 입력창 대신 새 대화 안내
  8. 리뷰 반영: 닫거나 새 대화로 바뀐 뒤 늦게 실패한 질문을 입력창에 되돌리지 않음, 시트를 열면 시트 자체에 포커스(키보드는 올리지 않음)·닫을 때 body로 가면 헤더 버튼으로, 계정이 바뀌면 대화 Provider 초기화, 결과 행 상한을 schema에 고정하지 않음

기타

  • 로컬 개발: S3 설정 없이도 서버가 뜨도록 스토리지 설정을 선택값으로 변경. NODE_ENV가 development·test일 때만 누락을 허용하고 그 밖(값 없음·staging 포함)은 운영처럼 필수. 개발 환경 변수 받기 스크립트(다른 환경 secret을 받지 않도록 형태·NODE_ENV 검사), MSW 끄기 옵션
  • dev 병합(어드민 콘솔 어드민 콘솔 백엔드와 서비스 운영 API 추가 #251)과 충돌 해결: 어드민 사용량 집계의 listAllObjects도 같은 스토리지 설정 확인을 거침
  • 밴드 목록·프로필 응답 파싱 수정

검증

측정 조건: gemini-3.1-flash-lite, temperature 0, 고정 시각 2026-09-01T00:00:00.000Z, 요청 동시성 1, 문항 간격 4.5초, 재생성 최대 2회. 합성 평가 DB(v3, 겸업 멤버의 다중 세션 포함)에서 Service·Repository 경로를 직접 호출한다. HTTP와 화면은 포함하지 않는다. 설정마다 3회 반복했고 세 회차의 문항별 결과가 모두 같았다.

평가 세트

세트 문항 만든 시점 쓰임
dev 50 초기 실패를 보며 고치는 데 직접 사용
holdout 20 구조 수정 전에 고정 수정에 쓰지 않고 설정 비교에만 사용
FINAL50 50 구조 개선 후, 실행 전에 정답 고정 최종 확인. 이후 실패 2건은 수정에 사용
followup 7 이번 PR 직전 턴을 정답 SQL로 고정한 후속 질문(조건 이어받기 4, 대상 이어받기 2, 대조 1)

구조 비교 (2026-10-06, 설정별 3회)

설정 dev holdout dev 생성 실패/회 재생성 복구율 dev / holdout 질문당 모델 호출(dev) 평균 입력 토큰 dev / holdout dev p95
C0 구조 개선 전 42/50 10/20 4 16.7% / 14.3% 1.20 4,478 / 6,097 4.5~5.7초
C1 누적 위반·직전 SQL로 재생성 46/50 14/20 0 83.3% / 71.4% 1.12 4,198 / 5,217 3.6~3.8초
C2 + 사람 단위 실행 50/50 17/20 0 – 1.12 4,198 / 5,217 3.4~3.7초

C1 → C2에서 holdout p95 평균은 4,346ms → 4,501ms(+3.6%)로, 미리 정한 허용치 20% 안이었다.

최종 확인 (FINAL50, 2026-10-06, 3회): 3회 모두 48/50. 첫 검증 통과 41/50, 재생성 복구 8/9. 실패는 LIMIT 계약 불일치(생성 실패) 1건, 아티스트 이름 해석(결과 불일치) 1건이다. 두 유형을 고친 뒤 관련 10문항 1회에서 조회 9/9, 후보 안내 1/1을 확인했다. 수정 후 전체 50문항은 다시 돌리지 않았다.

현재 HEAD 회귀 (fb1f0a7, 2026-10-11, 세트별 3회)

세트 정답 첫 검증 통과 재생성 복구 생성 실패 질문당 모델 호출 평균 입력 / 출력 토큰 p95 지연 다른 밴드 유출
dev 50/50 43/50 7/7 0 1.16 4,360 / 260 3.3~3.7초 0
holdout 17/20 13/20 6/7 0 1.35 5,217 / 438 3.7~4.2초 0
followup 6/7 7/7 – 0 1.00 4,232 / 382 2.1~2.7초 0
  • 후속 질문 맥락을 넣은 뒤에도 dev·holdout은 C2와 같은 점수다. 단일 질문 경로의 회귀는 없었다.
  • holdout 실패 3건은 10/6과 같은 문항이다. H04는 5명 동률 중 1명만 반환("출석 응답"의 뜻도 모호), H11은 요청하지 않은 시작 시각 컬럼 추가, H19는 개수 불일치다.
  • followup 실패 F03은 조건을 맞게 이어받았지만(같은 41곡) 요청하지 않은 BPM 컬럼을 덧붙여 오답으로 채점됐다. 이 세트로 프롬프트를 고치지 않았다.
  • followup 7문항 모두 직전 SQL이 서버 재검증을 통과해 이력으로 들어갔다.
  • 단위 테스트: BE 951개, FE 504개 통과.

10/13 리뷰 반영 검증 (모델 재호출 없음)

확인 방법 결과
새 범위 판정이 정상 SQL을 막는지 10/11 dev·holdout·followup 각 3회의 모델 응답 276건(재생성 포함)을 옛·새 검증기에 넣어 비교 통과·거부 변화 0건
전용 역할이 결과를 바꾸는지 평가 DB에 마이그레이션 적용 후, 실행까지 간 SQL 231건을 전용 역할·앱 계정으로 각각 실행 결과 동일(ORDER BY 없는 1문항은 행 순서만 다름), 권한 오류 0, 추천 질문 4종 정상
리뷰 우회 SQL 검증기 없이 전용 역할로 직접 실행 LEFT JOIN 우회는 현재 밴드 곡 80개만(앱 계정 160개), NOT EXISTS 우회 0행, users.email 권한 오류
DB 실행 시간 같은 SQL 77개 × 3회 p95 4.6ms → 13.4ms (역할 전환만 따로 재면 p95 0.9ms)
9초 마감 10/11 평가의 생성 시간 최대 4.7초라 걸리는 문항 없음

dev 병합 후 전체: 백엔드 verify.sh 통과(테스트 1,287), 프론트 tsc·테스트 534·eslint 통과.

읽는 법

  • 모든 숫자는 같은 조건에서의 변경 전후 비교다. "실사용 정확도"로 읽지 않는다.
  • 문항 수가 작아 95% 신뢰구간(Wilson)이 넓다. holdout 17/20은 6495%, followup 6/7은 4997%, FINAL50 48/50은 8799%, dev 50/50은 93100%다.
  • 3회 반복은 같은 질문의 재현성 확인이라 구간을 좁히지 않는다.
  • 모든 세트는 개발자가 만든 합성 질문이며 실제 사용자 질문 분포가 아니다. dev는 개선에 직접 썼다.
  • followup은 직전 턴이 맞았다는 전제에서 지시어 해석만 본다. 첫 턴이 틀린 채 이어지는 경우는 재지 않았다.

🚨 주요 고민 및 해결 과정

문제 1. 정해진 질문만 답하는 방식과 자유 SQL

처음에는 LLM이 JSON 조회 계획을 만들고 서버가 Prisma로 옮기는 방식이었는데, 지원하는 질문이 4종으로 묶여 미리 정의하지 않은 질문에 답할 수 없었다.

해결 과정

자유 SELECT를 허용하되 실행 전후에 서로 독립된 안전장치를 겹쳤다.

  • 밴드 범위는 모델이 바꿀 수 없도록 서버가 $1에 바인딩한다.
  • 허용 목록 밖의 테이블·컬럼·함수, 쓰기, 다중 문장은 AST에서 거부한다.
  • 보안·구문·허용 목록 위반은 첫 위반에서 검사를 멈추고, 의미 규칙 위반은 모두 모아 알린다. 두 경우 모두 DB에 보내지 않고 재생성 대상이 된다.
  • 모델 SQL은 전용 DB 역할로 실행해, 검증기가 놓쳐도 컬럼 권한과 RLS가 범위 밖 데이터를 막는다(문제 7).

문제 2. 겸업 멤버의 중복 집계

보컬·기타 겸업이면 같은 합주에 참여 행이 2개라 인원이 중복으로 세지고 명단에도 같은 사람이 두 번 나왔다. 프롬프트로 DISTINCT를 지시하고 질문 문장으로 인원 질문을 판별해 검사했지만 한계가 있었다.

  • 모델이 DISTINCT를 자주 빠뜨렸다.
  • "불참한다고 답한" 같은 표현은 판별을 빠져나갔다.
  • 1인 1행 테스트 데이터에서는 이 오류가 정답으로 채점됐다.

해결 과정

모델에게 허용한 참여·팀 편성 컬럼이 모두 사람 단위라는 점을 이용해, 검증이 끝난 뒤 서버가 대표 행 조건을 붙여 실행하도록 했다.

  • 질문 표현이나 DISTINCT 유무와 무관하게 사람당 한 행으로 실행된다.
  • 대표 행은 일정을 회의로 바꿀 때 멤버당 id 오름차순 첫 행을 남기는 기존 규칙과 같다.
  • (일정, 멤버, 세션) 인덱스를 써서 테이블 전체를 집계하지 않는다.
  • 평가 데이터에 다중 세션 행을 넣고 다시 측정했다.

문제 3. 재생성이 이미 고친 규칙을 다시 어김

JOIN을 고치면 DISTINCT를 빠뜨리고, DISTINCT를 고치면 JOIN을 다시 빠뜨리면서 재생성 2회를 다 썼다. 검증기는 첫 위반에서 멈췄고, 재생성에는 직전 실패 하나만 전달했기 때문이다.

해결 과정

의미 규칙 위반은 모두 모아서 알리고, 재생성할 때 모든 시도의 위반과 직전 SQL을 함께 줘 위반한 부분만 고치게 했다. 이 유형의 생성 실패가 사라졌다.

문제 4. 추천 질문의 답 형태가 매번 다름

가장 많이 눌리는 추천 질문인데도 모델이 매번 다른 컬럼을 골랐다. 예를 들어 "다음 합주 일정이 언제야?"에 일정 이름이 빠지는 경우가 있었다.

해결 과정

추천 질문은 고정 SQL로 실행하되, 자유 질문과 같은 검증기를 통과하게 했다. 답 형태가 고정되고 응답 시간이 약 2초에서 수십 ms로 줄었으며, 모델 호출 비용도 들지 않는다.

문제 5. 답을 보여주는 화면

처음에는 홈 카드 안에 답을 펼쳤다. 그러자 답이 길어지면 입력창이 하단 탭바 아래로 밀렸고, 숫자 하나도 표로 나왔으며, 조건이 PRACTICE·UTC 시각 같은 내부 값으로 보였다.

해결 과정

  • 답은 하단 입력창이 고정된 대화 화면에서 보여주고, 결과 유형별로 다르게 그린다.
  • 조건 문구는 서버가 사람 말로 만들어 보낸다.
  • 카드 안에서 펼치는 방식도 시도했지만, 한 곳에서 입력·답·이어서 묻기를 모두 처리하니 흐름이 어색해서 대화 화면으로 정했다.
  • 대화처럼 보이지만 처음에는 질문마다 따로 조회해 앞 답을 이어받지 못했다(문제 6).

문제 6. "그중에"를 해석할 수 없음

조건 칩은 사람이 읽는 요약이라, "그중에 기타 치는 사람은?"이 가리키는 앞 질문의 WHERE 절을 복원하지 못한다. 이런 짧은 질문에는 "참석" 같은 단서도 없어서 인원 검사의 질문 판별을 빠져나갔다.

해결 과정

  • 직전 턴의 검증된 SQL 구조를 맥락으로 준다. SQL은 밴드 데이터가 아니라 구조라 노출 부담이 작다. 결과 행과 답 문장은 보내지 않는다.
  • 클라이언트가 SQL을 고쳐 보낼 수 있어서 서버가 다시 검증하고, 주석을 지운 문장만 쓴다. 이 검증에도 구멍이 있었고(문제 7), 실행은 전용 DB 역할의 RLS 아래에서 이뤄진다.
  • 인원 판별은 후속 질문일 때만 앞 질문과 이어 붙여 다시 본다. 사람 단위 변환은 한 일정 안의 중복만 없애므로, 여러 일정에 걸친 인원 검사는 그대로 둔다.
  • 턴 상한 5는 근거 없이 정한 값이라 turnIndex별 실패율을 로그로 보고 조정한다.

문제 7. 검증기를 통과한 SQL이 다른 밴드·민감 컬럼을 읽음 (@DevJunz 3차 리뷰)

보안 경계가 앱 레벨 AST 검증기 하나뿐이었다. 리뷰에서 재현된 우회:

  • LEFT JOIN bands b ON so.band_id = b.id AND b.id = $1 + WHERE b.deleted_at IS NULL: ON 절의 범위 조건을 인정했고, 매칭 안 된 행은 b.*가 NULL이라 다른 밴드 곡이 남음
  • WHERE NOT EXISTS (SELECT 1 FROM bands b WHERE b.id = $1 AND so.band_id = b.id): 서브쿼리 안의 범위 조건이 바깥 테이블의 범위로 인정돼 다른 밴드 곡만 반환
  • SELECT u.id AS email, email AS leaked …: 출력 별칭과 같은 맨 이름이 컬럼 허용 목록을 건너뜀. PostgreSQL은 이 이름을 실제 users.email로 읽음. LEFT JOIN과 합치면 전체 사용자의 email·password_hash가 응답에 실림

해결 과정

  • 앞의 두 건은 조건을 쿼리 전체에서 모아 판단한 탓이라 범위 판정 단위를 SELECT로 바꿨다(BE 13). 결과로는 안전한 일부 SQL(FROM songs LEFT JOIN bands … WHERE b.id = $1)도 거부해 추가 생성 비용이 들 수 있다.
  • 별칭은 ORDER BY 항목일 때만 인정한다.
  • 검증기가 다시 틀려도 새지 않도록 모델 SQL을 전용 역할로 실행한다(BE 14). 별도 로그인 계정 대신 SET LOCAL ROLE을 써서 기존 계정·커넥션 풀을 그대로 쓴다. 뷰 대신 컬럼 GRANT를 쓴 이유는 모델 SQL이 실제 테이블명을 쓰기 때문이다. 하위 테이블에 band_id를 추가하는 대신 부모를 거치는 정책을 써 스키마는 바꾸지 않았고, 그 비용이 위 DB p95 증가다.
  • 검증기는 실행 전에 위반을 모델에 알려 재생성을 유도하고, 컬럼 권한·RLS는 실행 단계에서 읽을 수 있는 데이터를 제한한다.
  • 반영하지 않은 것: 구조화 플랜 방식(첫 구현이 그 방식이었고 질문 4종에 갇힘), 재작성 불변식 테스트(대표 행 변환 결과를 지금 검증기로 재검사할 수 없음. 대신 실행이 RLS 아래에서 이뤄짐), 전역 pending·reasoning 계열 파라미터(현재 경로 아님).

🚀 배포 시 확인 (main 릴리즈)

  • 마이그레이션: main 대비 새 마이그레이션은 20261013120000_add_assistant_reader_role 하나. 배포 워크플로의 prisma migrate deploy가 적용하고, 실패하면 set -e로 멈춰 기존 컨테이너가 유지된다.
    • 전제: 마이그레이션 계정(= 앱 DATABASE_URL 계정)이 18개 테이블의 소유자이고 역할을 만들 수 있어야 한다(CREATEROLE 또는 superuser). 아니면 마이그레이션이 실패한다.
    • 롤백: 이전 이미지로 되돌려도 안전하다. 정책은 assistant_reader에만 걸리고 소유자인 앱 계정은 RLS를 건너뛴다.
  • 환경 변수: GEMINI_API_KEY(필수, 배포 secret 검사 항목에는 없음). LLM_*, ASSISTANT_RATE_LIMIT_*는 기본값이 있어 선택.
  • 배포 후 확인: assistant_reader 역할과 18개 테이블의 RLS가 켜졌는지, 자유 질문·추천 질문이 각각 답하는지.
  • PR #258은 이 PR 머지 후 리베이스 필요(turnIndex 충돌).

Summary by CodeRabbit

  • 새 기능

    • 밴드 화면에서 추천 질문을 선택하거나 자연어로 질문해 밴드 일정, 멤버, 곡, 참여 현황 등을 조회할 수 있습니다.
    • 답변을 표와 요약으로 확인하고, 후속 질문·추천 질문·후보 선택으로 대화를 이어갈 수 있습니다.
    • 지원하지 않는 질문이나 추가 확인이 필요한 경우 안내를 제공합니다.
  • 개선 사항

    • 조회 결과는 최대 50행까지 표시하며, 조건과 결과 요약을 함께 확인할 수 있습니다.
    • 개발·운영 환경에 따라 스토리지 설정이 누락된 경우 적절한 오류 안내를 제공합니다.
  • 문서 및 테스트

    • assistant API와 자연어 조회 평가 절차에 관한 문서를 추가하고 관련 검증을 보강했습니다.

@njm1250 njm1250 self-assigned this Sep 7, 2026
@vercel

vercel Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
band-co Ready Ready Preview Oct 17, 2026 5:49am UTC

@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Walkthrough

자연어 밴드 조회를 위한 백엔드 처리, LLM 제공자, 프런트엔드 대화 화면 및 평가 도구를 추가합니다. 별도로 스토리지 설정 처리, 개발 환경 스크립트 검증, 밴드 목록 mock 응답을 변경합니다용.

Changes

자연어 밴드 조회

Layer / File(s) Summary
LLM 제공자 및 회전 설정
backend/src/modules/ai/*, backend/.env.example, backend/package.json
Gemini와 OpenAI 호환 제공자, 구조화 응답 타입, 오류 구분, 키·제공자 순환 및 재시도를 추가합니다용.
SQL 생성 계약 및 검증
backend/src/modules/assistant/sql/*, backend/scripts/assistant-evaluation/evaluate-rows.ts, backend/package.json
허용 SQL 카탈로그와 생성 지시를 정의합니다. SQL의 파라미터, 밴드 범위, 허용 관계, 집계 및 결과 모드를 검사하고, 검증된 쿼리의 관계 단위를 정규화합니다용.
조회 실행 및 결과 구성
backend/src/modules/assistant/{assistant.service.ts,execution/*,query-plan/*,repositories/*,rendering/*,types/*}
밴드 멤버십을 확인하고 추천 질문 또는 생성 SQL을 처리합니다. 조회는 읽기 전용 트랜잭션에서 실행하며, 최대 50행의 결과와 요약을 구성합니다용.
API 계약과 프런트엔드 연결
backend/src/modules/assistant/{assistant.controller.ts,assistant.module.ts,dto/*,rate-limit/*}, frontend/src/entities/assistant/*, backend/docs/backend/api-docs/*, backend/src/app.module.ts
인증된 프리셋·질의 API와 사용자별 요청 제한을 연결합니다. 프런트엔드는 요청과 응답을 검증하고, API 문서와 테스트 실행 항목을 추가합니다용.
어시스턴트 대화 화면
frontend/src/features/assistant-query/*, frontend/src/widgets/band-main/ui/BandMain.tsx, frontend/src/mocks/assistant/*, frontend/src/mocks/handlers.ts, frontend/src/main.tsx, frontend/pnpm-workspace.yaml
밴드 화면에 대화 패널을 추가합니다. 자유 질문, 추천 질문, 후속 질문, 재시도 및 결과 표시를 지원하고 MSW 응답을 연결합니다용.
평가 코퍼스와 실행 도구
backend/scripts/assistant-evaluation/*, backend/scripts/verify.sh, backend/docs/backend/testing.md
개발·holdout 질의와 SQL 결과 비교기를 추가합니다. 격리된 평가 DB의 자연어 평가 및 고정 SQL replay 실행 결과를 파일로 기록합니다용.

스토리지 설정 처리

Layer / File(s) Summary
스토리지 설정과 오류 응답
backend/src/storage/storage.service.ts, backend/src/storage/storage.service.spec.ts, backend/src/storage/storage.controller.ts
운영 환경은 생성 시 스토리지 설정을 요구합니다. 설정이 없는 비운영 환경은 초기화할 수 있지만 URL 생성 요청은 503 오류를 반환합니다용.

개발 환경 내려받기 스크립트

Layer / File(s) Summary
응답 검증 제거
backend/scripts/pull-development-env.sh
환경 파일 생성 전에 실행하던 Secrets Manager 응답 형식, 개발 환경, 필수 키 및 키 이름 검증을 제거합니다용.

밴드 목록 mock 응답

Layer / File(s) Summary
페이지네이션 응답 메타데이터
frontend/src/mocks/band/handlers.ts, frontend/src/widgets/band-list/ui/BandList.test.tsx
밴드 목록 mock과 빈 목록 테스트에서 totalCount 대신 meta.count, take, cursor, next를 사용합니다용.

Priority: ➖ Normal

Estimated code review effort: 5 (Critical) | ~120 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Client as AssistantPanel
  participant API as AssistantController
  participant Service as AssistantService
  participant LLM as LlmService
  participant Validator as SqlQueryValidator
  participant Repository as AssistantPrismaRepository
  Client->>API: 밴드 질문 전송
  API->>Service: 인증 사용자와 질문 전달
  Service->>Repository: 밴드 멤버십 확인
  Service->>LLM: 구조화 SQL 생성 요청
  LLM-->>Service: SQL 생성 결과
  Service->>Validator: 결과 SQL 검증
  Validator-->>Service: 검증된 SQL 반환
  Service->>Repository: 읽기 전용 조회 실행
  Repository-->>Service: 조회 결과 페이지 반환
  Service-->>API: 요약 및 표 결과 반환
  API-->>Client: assistant 답변 반환
Loading
Merge Risk: 🟠 High · up to 59378

The natural-language query feature validates model-generated SQL as its security boundary, but crafted queries can currently escape the band scope and read data from other bands, or select credential columns that the catalog intentionally excludes. Free-form questions can also bypass the per-user rate limit with an empty presetId. These issues should be fixed before merge; the remaining items are smaller correctness and tooling fixes.

Pre-merge checks: Passed 4 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Docstring Coverage Inconclusive Docstring coverage is 49.40% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 83 functions across 50 files. (38 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed 제목이 자연어 기반 밴드 데이터 조회 기능이라는 핵심 변경을 정확하고 간결하게 설명하므로 통과용
Description check Passed 작업 요약, 상세 변경 내용, 주요 고민과 해결 과정, 검증 결과, 배포 확인 사항을 충분히 포함하므로 통과용
Full details: Docstring Coverage

Explanation

Docstring coverage is 49.40% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 83 functions across 50 files. (38 skipped: 9 unsupported, 29 over the file limit.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@njm1250 njm1250 changed the title 자연어 기반 밴드 데이터 조회 MVP 자연어 기반 밴드 데이터 조회 기능 Oct 6, 2026
@njm1250
njm1250 marked this pull request as ready for review October 6, 2026 11:23
njm1250 and others added 20 commits October 7, 2026 00:44
인증만 mock이고 일부 요청만 실제 서버로 보내면, mock이 발급한 토큰을
서버가 검증하지 못해 401이 난다. 그래서 부분 활성화를 막고
VITE_ENABLE_MSW=false 로 한 번에 끄는 길만 남긴다.

원본 커밋(2c1e26e)에는 인증 응답 파싱·로그인 상태 동기화 수정이 함께
있었으나, 그 부분은 dev 가 자체적으로 고쳐 최종 결과가 동일해졌다.
낡은 구현을 되살리지 않기 위해 의도적으로 제외했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
밴드 목록 mock이 실제 응답과 모양이 달라 목록 테스트가 실제 계약을
검증하지 못했다.

원본 커밋(a3a2fde)에는 밴드·프로필 스키마와 profile-api 파싱 수정이
함께 있었으나, dev 가 #254(GET /users 응답에 대표 세션 추가)에서 같은
파일들을 다시 썼다. 그쪽이 더 새로운 계약이므로 의도적으로 제외하고
mock·테스트 두 파일만 남긴다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
다크 테마에서 --color-ring이 secondary(#1a1971)를 가리켜 버튼 포커스 표시가 배경에 묻혔다.
- 말풍선·결과 카드 바탕을 bg-surface-3로 바꿔 그라데이션 배경에서 구분되게 함
- 홈 카드 테두리, 추천 칩 구분선, 결과 행 구분선을 없애고 간격으로 나눔
- 머리·바닥을 다른 전체 화면 모달처럼 header-glow/footer-glow로 나눔
- 입력창은 공용 Input 기본값을 쓰고, 질문 칩은 일정 필터 칩 모양으로 통일
- 다시 시도 버튼을 기존 outline 버튼 색 조합으로 맞춤
스크롤바가 자리를 차지하는 PC 환경에서 긴 페이지(밴드 홈)와 짧은 페이지(아카이브)를 오가면 가운데 정렬된 화면 전체가 좌우로 밀렸다. html에 scrollbar-gutter: stable을 두어 스크롤바 자리를 항상 비워 둔다.
- 대화 상태를 앱 레이아웃의 Provider로 올려 화면을 닫고 밴드 안 다른 페이지로 옮겨도 대화가 남게 함
- 밴드 홈·아카이브·라이브러리 헤더에 물어보기 아이콘 추가
- 대화 화면을 닫으면 하단 탭바 위에 이어서 보기 바를 띄우고, X로 대화를 끝낼 수 있게 함
- 대화 화면 머리에 새 대화 버튼 추가, 경로가 바뀌면 화면만 내리고 대화는 유지
- 밴드 홈 카드에서 묻는 질문은 항상 새 대화로 시작
- 대화가 밴드를 바꿀 수 있어 질문 API 훅이 bandId를 호출마다 받도록 변경
바가 앱 폭(648) 컨테이너 안에 있어 PC에서 화면 가운데에만 떠 보였다. 바는 화면 폭을 다 채우고 안의 내용만 앱 폭으로 가운데 둔다.
스크롤을 잠글 때 Radix가 body에 스크롤바 폭만큼 margin-right를 주는데, scrollbar-gutter: stable로 비워 둔 자리까지 남아 두 번 보정됐다. 잠긴 동안에는 자리를 비우지 않게 한다.

@DevJunz DevJunz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아직 리뷰 진행 중입니다만 내용이 너무 많아서 핵심적으로 궁금한 부분 먼저 남기겠습니다.

전체 흐름과 설계까지 같이 봤습니다. 보안 관련 항목은 로컬 PostgreSQL 16에서 검증기 통과 → 실제 실행까지 재현해 봤어요.

서비스 흐름 요약

FE 홈 입력/헤더/추천 칩 → AssistantConversationProvider(RootLayout 전역)
→ POST /bands/:bandId/assistant/query
→ AccessTokenGuard → AssistantRateLimitGuard(추천 질문이면 skip) → DTO
→ 밴드 멤버 확인(LLM 호출 전) → 추천 질문: 고정 SQL / 자유 질문: LLM이 SQL JSON 생성
→ SqlQueryValidator(AST) ─ 실패 시 위반 모아 최대 2회 재생성, 소진 시 REPHRASE
→ 아티스트 이름 확인(모호하면 CLARIFICATION) → normalizeRelationGrain(사람 단위 대표 행)
→ READ ONLY tx + statement_timeout 3s, $1=bandId, 바깥 LIMIT 51
→ mapSqlResult + AnswerRenderer → ANSWER / UNSUPPORTED / CLARIFICATION / REPHRASE

좋았던 점

  • bandId 서버 바인딩, 문자열 리터럴 금지, 함수·cast 허용 목록, read-only tx, timeout, 행 제한까지 여러 겹으로 쌓은 구조
  • 권한 확인을 LLM 호출보다 먼저 해서 비멤버 요청에 비용이 안 드는 점
  • 추천 질문을 고정 SQL + 같은 검증기로 돌린 점, 위반을 모아서 재생성 피드백 주는 점, 평가 도구까지 갖춘 점

설계 측면에서 가장 걱정되는 점

보안 경계가 앱 레벨 AST 검증기 하나뿐입니다. DB 입장에서는 앱 계정 권한 그대로라 모든 테이블·컬럼이 열려 있고, 검증기는 "직접 AND 조건의 등호 그래프로 bands에 닿으면 범위 안"으로 판정하는데 이 규칙이 LEFT JOIN·NOT EXISTS의 의미를 보지 못합니다. 아래 인라인 🔴 3건이 모두 여기서 나왔고, 앞으로도 같은 종류의 구멍이 계속 나올 수 있는 구조라고 생각해요.

제안:

  1. DB 레벨 방어 추가 — 어시스턴트 전용 read-only role + 카탈로그 컬럼만 노출한 assistant 스키마 view, 밴드 범위는 SET LOCAL app.band_id + RLS(또는 security_barrier view)로 강제. 그러면 검증기는 품질/UX 계층이 되고, 구멍이 있어도 데이터가 새지 않습니다.
  2. (대안) 지원 범위가 일정/참석/곡/팀으로 한정돼 있으니 LLM은 구조화 플랜만 만들고 SQL은 서버가 조립하는 방식도 한 번 논의해 보면 좋겠어요.

그 밖에:

  • 검증한 SQL과 실행 SQL이 다릅니다(LIMIT 제거, 대표 행 치환, deparse). "검증 후 재작성은 범위를 좁히기만 한다"는 불변식을 테스트로 고정해 두면 좋겠습니다.
  • 요청 1건의 LLM 호출 수에 상한/전체 deadline이 없습니다(재생성 3 × 재시도 3 × key × provider + 잘림 재시도). rate limit은 HTTP 요청 단위라 비용 상한이 사실상 없어요.
  • PR이 1만 줄이고 본문 검증 섹션이 "추가 예정"이라, 정확도 수치를 채우고 가능하면 AI 모듈 / 검증·실행 / FE로 나눠 보면 리뷰가 훨씬 수월할 것 같아요.

우선순위

  • 🔴 머지 전 필수: LEFT JOIN 범위 우회, NOT EXISTS 범위 우회, 출력 별칭으로 컬럼 허용 목록 우회, 빈 presetId로 rate limit 우회
    • 특히 LEFT JOIN + 별칭 우회를 합치면 질문 한 번으로 서비스 전체 사용자의 email·password_hash가 응답에 실립니다(프롬프트 인젝션으로 모델을 유도하는 경로).
  • 🟠 중요 / 🟡 경미: 인라인 참고

재리뷰 (10/10 커밋 2개)

  • a787d18 스크롤 잠금 시 scrollbar-gutter: auto로 되돌려 Radix margin-right와의 이중 보정을 없앤 건 맞는 수정으로 보입니다. 제가 보려던 "모달 열 때 화면 밀림" 지적은 이걸로 해결된 것 같아요 👍
  • 543a1b5 머리·바닥 바 전체 폭 처리도 문제없어 보입니다. 다만 같은 파일의 포커스 이슈는 그대로라 인라인에 남겼어요.

FE tsc·assistant vitest 27개, BE ai/rate-limit/storage jest 31개는 통과했는데, 위 🔴 경로는 테스트에서 다루지 않습니다.

collectConditions(conditions, analysis);
}

for (const joinNode of analysis.joinNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 LEFT JOIN으로 밴드 범위 우회 (재현 확인)

JOIN ON 절의 b.id = $1::uuid도 범위 루트로 인정되는데, LEFT JOIN에서 매칭 안 된 행은 b.*가 전부 NULL이라 b.deleted_at IS NULL이 참이 됩니다.

SELECT so.title FROM songs so
LEFT JOIN bands b ON so.band_id = b.id AND b.id = $1::uuid
WHERE b.deleted_at IS NULL

→ 검증 통과, 실행하면 다른 밴드 곡까지 반환됩니다.

아래 별칭 우회(509줄)와 합치면 이것도 통과합니다:

SELECT u.id AS email, u.status AS password_hash, email AS e, password_hash AS p
FROM band_members bm LEFT JOIN bands b ON bm.band_id = b.id AND b.id = $1::uuid
JOIN users u ON u.id = bm.user_id
WHERE b.deleted_at IS NULL AND u.deleted_at IS NULL

→ 전체 사용자 email·password_hash.

제안: 범위 루트는 최상위 SELECT의 WHERE 직접 AND 조건에서만 인정하고, 범위 경로는 INNER JOIN만 허용(LEFT JOIN은 places 같은 말단 테이블에만).


/** 밴드 범위와 soft-delete 조건을 찾고 허용 관계로 연결된 별칭 그래프를 만든다. */
function collectScopeAndRelations(analysis: AstAnalysis): void {
for (const selectNode of analysis.selectNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 NOT EXISTS 서브쿼리로 밴드 범위 우회 (재현 확인, CodeRabbit 지적과 동일)

selectNodes에 서브쿼리 SELECT도 들어가서, 서브쿼리 안의 b.id = $1이 바깥 쿼리의 범위로 인정됩니다.

SELECT so.title FROM songs so
WHERE NOT EXISTS (SELECT 1 FROM bands b WHERE b.id = $1::uuid AND b.deleted_at IS NULL AND so.band_id = b.id)

→ 내 밴드 곡을 제외한 다른 밴드 곡만 반환됩니다.

제안: SELECT마다 별칭 스코프를 분리해서, 각 SELECT가 자기 FROM에서 범위 루트(또는 바깥 범위에 상관 조건으로 연결된 별칭)에 닿는지 따로 검사. 위 SQL과 LEFT JOIN 케이스를 spec 회귀 테스트로 추가해 주세요.

throw new InvalidSqlQueryError('SELECT_STAR_BLOCKED', 'SELECT *는 사용할 수 없습니다.');
}

if (fields.length === 1 && outputAliases.has(fields[0])) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 출력 별칭과 같은 이름이면 컬럼 허용 목록을 건너뜀 (재현 확인, CodeRabbit 지적과 동일)

PostgreSQL은 SELECT 목록·WHERE에서 별칭 없는 이름을 출력 별칭이 아니라 실제 테이블 컬럼으로 해석합니다.

SELECT b.name AS password_hash, password_hash AS leaked, email AS leaked_email
FROM bands b JOIN band_members bm ON bm.band_id = b.id JOIN users u ON u.id = bm.user_id
WHERE b.id = $1::uuid AND b.deleted_at IS NULL AND u.deleted_at IS NULL

→ 실제 해시값과 이메일이 나오고, mapSqlResult가 결과 열을 그대로 응답에 실어서 클라이언트까지 갑니다.

제안: 단일 이름은 같은 SELECT의 ORDER BY(sortClause) 안에서만 출력 별칭으로 인정하고, targetList·where·group에서는 항상 alias.column 요구.

*/
export function isPresetRequest(request: QueryRequest): boolean {
const body = request.body;
return typeof body === 'object' && body !== null && 'presetId' in body && body.presetId !== undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 빈 presetId로 rate limit 우회 (확인)

Guard는 ValidationPipe보다 먼저 돌아서 원본 body를 봅니다. ""/" "도 !== undefined라 skip되고, 이후 DTO가 빈 문자열을 undefined로 정규화해서 서비스는 자유 질문으로 LLM을 호출합니다.

{"presetId": "", "question": "다음 합주 언제야?"} → 제한 없이 LLM 호출.

const id = typeof body?.presetId === 'string' ? body.presetId.trim() : '';
return id !== '' && findPresetById(id) !== undefined;

spec에 "", 공백 케이스도 추가해 주세요.

Comment thread backend/src/modules/ai/ai.config.ts Outdated
keyRotationEnabled: parseBoolean(environment.LLM_KEY_ROTATION_ENABLED),
providerRotationEnabled: parseBoolean(environment.LLM_PROVIDER_ROTATION_ENABLED),
requestTimeoutMs: parsePositiveInteger(environment.LLM_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
maxRetriesPerProvider: parsePositiveInteger(environment.LLM_MAX_RETRIES, DEFAULT_MAX_RETRIES),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 LLM_MAX_RETRIES=0이 parsePositiveInteger에서 거부돼 조용히 기본값 2로 돌아갑니다. 운영에서 재시도를 끌 수 없어요. 0 이상 허용하는 파서로 분리하고 spec에 0 케이스 추가를 제안합니다.


const content = choice?.message?.content;

if (content === undefined || content.trim() === '') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 refusal/tool_call 응답은 content: null이라 null.trim() TypeError가 납니다. 결과(다음 대상 전환)는 같지만 로그 원인이 틀리게 남아요. typeof content !== 'string'으로 보고 message.refusal도 남겨 주세요.
또 106-107줄 temperature: 0, max_tokens는 OpenAI reasoning 계열(o-series, gpt-5 등)에서 400이라 max_completion_tokens + temperature 선택 전송을 고려해 주세요.

)
.max(50),
hasMore: z.boolean(),
maxRows: z.literal(50),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 maxRows: z.literal(50), rows.max(50)이 BE MAX_ASSISTANT_RESULT_ROWS를 하드코딩하고 있어서 BE가 값을 바꾸면 모든 표 응답이 파싱 실패 → "답을 가져오지 못했어요"가 됩니다. z.number().int().positive() + refine(rows.length <= maxRows)를 제안해요.

Comment thread frontend/src/app/layouts/RootLayout.tsx Outdated
<div className="mx-auto flex min-h-dvh max-w-[648px] flex-col">
{header ? <RouteHeader header={header} params={currentParams} /> : null}
// 물어보기 대화는 헤더 아이콘·밴드 홈 카드·이어서 보기 바가 함께 쓰므로 레이아웃 전체에 둔다.
<AssistantConversationProvider>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 대화 Provider가 사용자에 묶여 있지 않아 로그아웃 후 같은 기기에서 다른 계정으로 로그인하면 이전 사용자의 대화·입력 중 질문이 보입니다. key={user?.id}를 주거나 로그아웃 시 end() 호출을 제안해요. 또 pending이 전역 하나라 밴드 A 질문 대기 중에 밴드 B 입력창까지 막히고, 반대로 추천 칩은 대기 중에도 눌립니다.

Comment thread backend/src/storage/storage.service.ts Outdated
this.s3 = new S3Client({
region: this.region,
});
if (configService.get<string>('NODE_ENV') === 'production' && (this.region === null || this.bucket === null)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 NODE_ENV === 'production'일 때만 설정 누락을 막아서 NODE_ENV가 없거나 prod/staging이면 AWS 설정 없이 부팅되고 업로드만 503이 납니다(지금 Dockerfile은 production이라 당장 문제는 없음). ['development','test'].includes(NODE_ENV)일 때만 누락 허용하는 allowlist 방식을 제안해요.

--query SecretString \
--output text)

jq -e '

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 jq 검증 블록이 통째로 빠지면서 AWS 필수 키 검사 외에 type == "object", .NODE_ENV == "development", 키 이름 정규식 검사도 같이 사라졌습니다. secret ID를 잘못 지정하면 운영 값이 .env.development에 그대로 써질 수 있어요. AWS nonempty 조건만 빼고 나머지는 남기는 걸 제안합니다.

"그중에"처럼 앞 결과를 가리키는 질문은 조건 칩만으로 WHERE 절을 복원하지 못한다. 답마다 원래 SQL·실행 파라미터·결과 건수를 context로 돌려주고, 다음 요청의 history(최대 3턴)로 받는다. 클라이언트가 보낸 SQL은 실행 SQL과 같은 검증을 다시 통과해야 하며 파싱 후 다시 만든 문장만 프롬프트에 넣어 주석에 숨긴 지시문을 지운다. 결과 행과 답 문장은 보내지 않는다.

짧은 후속 질문은 현재 질문으로 인원 단위가 정해지지 않을 때만 바로 앞 질문과 이어 붙여 판별한다. 사람 단위 변환은 한 일정 안의 중복만 없애므로 여러 일정에 걸친 인원 검사는 유지한다. 대화 턴 상한(5)을 DTO에서 막고 turnIndex를 로그에 남긴다.
이어서 묻는 질문에만 직전 답의 조회 구조(최대 3턴)와 몇 번째 질문인지를 붙인다. 첫 질문의 요청 본문은 그대로다. 질문이 5개 쌓이면 입력창 대신 새 대화 안내를 보여주고 이어서 묻기 버튼을 숨긴다.
조건 이어받기 4문항, 생략된 대상 이어받기 2문항, 이력을 무시해야 하는 대조 1문항이다. 직전 턴은 정답 SQL로 고정해 첫 질문 정답 여부와 무관하게 지시어 해석만 보고, 제품과 같은 재검증을 거쳐 이력으로 넣는다.
범위 루트를 쿼리 전체에서 모으던 방식을 SELECT마다 따로 검사하도록 바꾼다. 범위 루트는 각 SELECT의 WHERE 최상위 AND 조건에서만 인정하고, 서브쿼리는 바깥에서 이미 범위 안인 별칭에만 기댈 수 있다. LEFT JOIN은 오른쪽, RIGHT JOIN은 왼쪽만 ON 조건으로 범위 안에 넣는다.

- LEFT JOIN bands ON ... AND b.id = $1 은 매칭되지 않은 행의 b.deleted_at이 NULL이라 다른 밴드 행이 남았다.
- NOT EXISTS 안의 b.id = $1 이 바깥 테이블의 범위로 인정됐다.
- SELECT 목록의 맨 이름은 PostgreSQL이 실제 컬럼으로 해석하는데, 같은 이름의 출력 별칭이 있으면 허용 목록 검사를 건너뛰었다. 이제 같은 SELECT의 ORDER BY 항목일 때만 출력 별칭으로 인정한다.

10/11 평가의 모델 출력 276건을 새 검증기로 다시 검사해 통과·거부 결과가 모두 같음을 확인했다.
Guard는 DTO 변환 전 원본 body를 보므로 빈 문자열·공백 presetId도 추천 질문으로 보고 제한을 건너뛰었다. DTO는 이를 undefined로 바꿔 자유 질문으로 모델을 호출한다. 앞뒤 공백을 지운 값이 실제 추천 질문 ID일 때만 제한에서 뺀다.
재생성 3회와 provider 재시도, 출력 잘림 재시도가 곱해져 한 요청이 2분을 넘길 수 있었다. 화면 요청은 10초에 끊기므로 SQL 생성 전체에 9초 마감을 두고, LlmService는 마감을 넘기면 재시도와 다음 호출을 멈추고 남은 시간만큼만 기다린다. 10/11 평가에서 가장 오래 걸린 생성은 4.7초였다.

감사 로그는 userId·bandId와 함께 남으므로 질문의 이메일·전화번호를 가리고, DB 오류는 바인딩 값이 섞일 수 있는 원문 대신 오류 이름과 코드만 남긴다.
LLM_MAX_RETRIES=0이 양수 파서에서 거부돼 기본값 2로 돌아갔다. OpenAI 호환 provider는 거절·도구 호출 응답의 content가 null이라 null.trim()에서 실패했다. 순환 토글을 끄면 첫 provider·첫 key만 쓰는 현재 동작에 맞게 설정 주석도 고친다.
NODE_ENV가 없거나 staging이면 AWS 설정 없이 부팅되던 것을 운영처럼 막는다. 개발 환경변수 동기화는 필수 키 검사 없이도 객체 형태, NODE_ENV=development, 키 이름 형식을 확인해 다른 환경 secret이 개발 파일에 써지지 않게 한다.
늦게 실패한 질문은 그 턴이 지금 대화에 있고 입력창이 비어 있을 때만 되돌린다. 계정이 바뀌면 대화 Provider를 새로 만든다. 시트를 열면 입력창 대신 시트 자체에 포커스를 둬 키보드를 올리지 않으면서 Tab이 시트 밖으로 나가지 않게 하고, 닫을 때 포커스가 body로 가면 헤더의 물어보기 버튼으로 보낸다.
maxRows를 50으로 고정해 서버 상한이 바뀌면 모든 표 응답이 파싱 실패로 보였다. 값은 양의 정수로만 보고 행 수가 maxRows 이하인지만 확인한다.
보안 경계가 앱 레벨 AST 검증기 하나뿐이라 검증기에 구멍이 생기면 앱 계정 권한 그대로 모든 테이블·컬럼이 열렸다. 모델 SQL을 실행할 때만 읽기 전용 트랜잭션 안에서 SET LOCAL ROLE assistant_reader로 바꾸고 set_config('app.band_id', ..., true)를 건다.

- assistant_reader는 카탈로그 허용 컬럼에만 SELECT 권한이 있다. users.email 같은 컬럼은 권한 오류가 난다.
- 18개 테이블에 assistant_reader 전용 RLS 정책을 둬 app.band_id 밴드의 행만 보인다. 설정이 없으면 아무 행도 보이지 않는다. 테이블 소유자인 앱 계정의 다른 조회에는 영향이 없다.
- 앱 계정이 테이블 소유자가 아니면 RLS를 켜는 순간 앱 조회가 막히므로 그 경우 마이그레이션을 실패시킨다.
- 사람 단위 변환의 SELECT *는 카탈로그 컬럼 목록으로 바꿨다. 컬럼 권한과 카탈로그가 어긋나지 않게 카탈로그 테스트에서 마이그레이션의 GRANT와 대조한다.

평가 DB에서 10/11 평가의 실행 SQL 231건을 전용 역할과 앱 계정으로 각각 실행해 결과가 모두 같음을 확인했다(1건은 ORDER BY가 없어 행 순서만 다름). 리뷰의 LEFT JOIN 우회는 자기 밴드 행만, NOT EXISTS 우회는 0행을 돌려준다. DB 실행 p95는 4.6ms에서 13.4ms가 됐다.
app.module은 자연어 조회와 어드민·신고·서비스 상태 모듈을 모두 등록한다. 스토리지는 로컬·테스트에서만 AWS 설정 누락을 허용하는 구조를 유지하고, 어드민 사용량 집계의 listAllObjects도 같은 설정 확인을 거친다. index.css는 dev의 배경 주석과 스크롤 잠금 시 scrollbar-gutter 규칙을 함께 둔다.
njm1250 added a commit that referenced this pull request Oct 17, 2026
#203의 후속 질문·검증기 수정·전용 DB 역할과 dev(어드민 콘솔)를 병합하고 충돌을 정리한다.

- turnIndex는 #203 정의로 통일한다. 1부터 시작하고 최대 5이며 후속 질문에만 보낸다. 서버는 값이 없으면 1로 기록한다. 0부터 세던 측정용 정의와 상한 깎기(MAX_TURN_INDEX)는 없앤다.
- sessionId는 모든 질문에 붙인다. 요청 본문에 담아 두므로 다시 시도해도 같은 세션·같은 턴으로 기록된다.
- 측정 기록에도 후속 질문 수(historyTurns)를 남기고, 질문의 이메일·전화번호를 가린다. 오류 메시지는 원문 대신 이름·코드만 남긴다.
- SQL 생성 9초 마감으로 재생성을 일찍 멈추면 실제 실패한 시도 수를 validationFailures로 남긴다.
- 측정 테이블 마이그레이션을 전용 DB 역할(20261013120000) 뒤인 20261017120000으로 옮긴다. #203이 먼저 배포되면 이전 날짜의 마이그레이션이 뒤늦게 적용되는 순서 꼬임을 피한다.
@njm1250
njm1250 merged commit c26f5a2 into dev Oct 17, 2026
3 checks passed
@njm1250
njm1250 deleted the feat/assistant-nl-query branch October 17, 2026 06:14
njm1250 added a commit that referenced this pull request Oct 17, 2026
dev의 #203 squash 커밋은 이 브랜치가 이미 담고 있는 #203 최종본과 내용이 같다(트리 동일). 같은 변경이 커밋 단위와 squash 단위로 두 번 들어와 생긴 충돌이라 이 브랜치의 내용을 유지한다.
njm1250 added a commit that referenced this pull request Oct 17, 2026
<!-- release-prs:start -->
## 포함된 PR

- ✨ feat: 자연어 조회 품질 측정과 실패 신호 (#258)
- 자연어 기반 밴드 데이터 조회 기능 (#203)
<!-- release-prs:end -->

This branch was successfully deployed

1 active deployment
Preview — 1702f549 Deployed Oct 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BE 서버, API, 비즈니스 로직 등 서버 사이드 작업 FE 사용자 인터페이스, 화면 구성, 스타일링 등 클라이언트 사이드 작업 feat 새로운 기능 추가나 기존 기능 확장, 사용자 요구사항 반영

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants