Repository navigation
자연어 기반 밴드 데이터 조회 기능 - #203
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Walkthrough자연어 밴드 조회를 위한 백엔드 처리, LLM 제공자, 프런트엔드 대화 화면 및 평가 도구를 추가합니다. 별도로 스토리지 설정 처리, 개발 환경 스크립트 검증, 밴드 목록 mock 응답을 변경합니다용. Changes자연어 밴드 조회
스토리지 설정 처리
개발 환경 내려받기 스크립트
밴드 목록 mock 응답
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 답변 반환
Merge Risk: 🟠 High · up to
|
인증만 mock이고 일부 요청만 실제 서버로 보내면, mock이 발급한 토큰을 서버가 검증하지 못해 401이 난다. 그래서 부분 활성화를 막고 VITE_ENABLE_MSW=false 로 한 번에 끄는 길만 남긴다. 원본 커밋(2c1e26e)에는 인증 응답 파싱·로그인 상태 동기화 수정이 함께 있었으나, 그 부분은 dev 가 자체적으로 고쳐 최종 결과가 동일해졌다. 낡은 구현을 되살리지 않기 위해 의도적으로 제외했다. 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
left a comment
There was a problem hiding this comment.
아직 리뷰 진행 중입니다만 내용이 너무 많아서 핵심적으로 궁금한 부분 먼저 남기겠습니다.
전체 흐름과 설계까지 같이 봤습니다. 보안 관련 항목은 로컬 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건이 모두 여기서 나왔고, 앞으로도 같은 종류의 구멍이 계속 나올 수 있는 구조라고 생각해요.
제안:
- DB 레벨 방어 추가 — 어시스턴트 전용 read-only role + 카탈로그 컬럼만 노출한
assistant스키마 view, 밴드 범위는SET LOCAL app.band_id+ RLS(또는 security_barrier view)로 강제. 그러면 검증기는 품질/UX 계층이 되고, 구멍이 있어도 데이터가 새지 않습니다. - (대안) 지원 범위가 일정/참석/곡/팀으로 한정돼 있으니 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) { |
There was a problem hiding this comment.
🔴 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) { |
There was a problem hiding this comment.
🔴 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])) { |
There was a problem hiding this comment.
🔴 출력 별칭과 같은 이름이면 컬럼 허용 목록을 건너뜀 (재현 확인, 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; |
There was a problem hiding this comment.
🔴 빈 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에 "", 공백 케이스도 추가해 주세요.
| 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), |
There was a problem hiding this comment.
🟠 LLM_MAX_RETRIES=0이 parsePositiveInteger에서 거부돼 조용히 기본값 2로 돌아갑니다. 운영에서 재시도를 끌 수 없어요. 0 이상 허용하는 파서로 분리하고 spec에 0 케이스 추가를 제안합니다.
|
|
||
| const content = choice?.message?.content; | ||
|
|
||
| if (content === undefined || content.trim() === '') { |
There was a problem hiding this comment.
🟡 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), |
There was a problem hiding this comment.
🟡 maxRows: z.literal(50), rows.max(50)이 BE MAX_ASSISTANT_RESULT_ROWS를 하드코딩하고 있어서 BE가 값을 바꾸면 모든 표 응답이 파싱 실패 → "답을 가져오지 못했어요"가 됩니다. z.number().int().positive() + refine(rows.length <= maxRows)를 제안해요.
| <div className="mx-auto flex min-h-dvh max-w-[648px] flex-col"> | ||
| {header ? <RouteHeader header={header} params={currentParams} /> : null} | ||
| // 물어보기 대화는 헤더 아이콘·밴드 홈 카드·이어서 보기 바가 함께 쓰므로 레이아웃 전체에 둔다. | ||
| <AssistantConversationProvider> |
There was a problem hiding this comment.
🟡 대화 Provider가 사용자에 묶여 있지 않아 로그아웃 후 같은 기기에서 다른 계정으로 로그인하면 이전 사용자의 대화·입력 중 질문이 보입니다. key={user?.id}를 주거나 로그아웃 시 end() 호출을 제안해요. 또 pending이 전역 하나라 밴드 A 질문 대기 중에 밴드 B 입력창까지 막히고, 반대로 추천 칩은 대기 중에도 눌립니다.
| this.s3 = new S3Client({ | ||
| region: this.region, | ||
| }); | ||
| if (configService.get<string>('NODE_ENV') === 'production' && (this.region === null || this.bucket === null)) { |
There was a problem hiding this comment.
🟡 NODE_ENV === 'production'일 때만 설정 누락을 막아서 NODE_ENV가 없거나 prod/staging이면 AWS 설정 없이 부팅되고 업로드만 503이 납니다(지금 Dockerfile은 production이라 당장 문제는 없음). ['development','test'].includes(NODE_ENV)일 때만 누락 허용하는 allowlist 방식을 제안해요.
| --query SecretString \ | ||
| --output text) | ||
|
|
||
| jq -e ' |
There was a problem hiding this comment.
🟠 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 규칙을 함께 둔다.
#203의 후속 질문·검증기 수정·전용 DB 역할과 dev(어드민 콘솔)를 병합하고 충돌을 정리한다. - turnIndex는 #203 정의로 통일한다. 1부터 시작하고 최대 5이며 후속 질문에만 보낸다. 서버는 값이 없으면 1로 기록한다. 0부터 세던 측정용 정의와 상한 깎기(MAX_TURN_INDEX)는 없앤다. - sessionId는 모든 질문에 붙인다. 요청 본문에 담아 두므로 다시 시도해도 같은 세션·같은 턴으로 기록된다. - 측정 기록에도 후속 질문 수(historyTurns)를 남기고, 질문의 이메일·전화번호를 가린다. 오류 메시지는 원문 대신 이름·코드만 남긴다. - SQL 생성 9초 마감으로 재생성을 일찍 멈추면 실제 실패한 시도 수를 validationFailures로 남긴다. - 측정 테이블 마이그레이션을 전용 DB 역할(20261013120000) 뒤인 20261017120000으로 옮긴다. #203이 먼저 배포되면 이전 날짜의 마이그레이션이 뒤늦게 적용되는 순서 꼬임을 피한다.
⏱ 소요 시간
📌 작업 요약
밴드 홈에서 일정·참석·곡·팀을 자연어로 물어보면, AI가 만든 조회 SQL을 서버가 검증한 뒤 실행해 답하는 기능
📝 작업 내용
BE
modules/ai): JSON Schema 구조화 응답, Gemini·OpenAI 호환 provider, API 키·provider 순환, 오류 종류별 재시도와 timeoutmodules/assistant/sql): 허용 카탈로그(테이블·컬럼·JOIN), 업무 용어, 질문 종류별 관계 규칙으로 프롬프트 구성bands.id = $1밴드 범위와 삭제 조건을 최상위 AND로 강제하고, 모든 테이블이 허용 JOIN으로 밴드에 연결되는지 확인$1에는 서버가 멤버십을 확인한 bandId만 바인딩hasMore반환kind: REPHRASE응답kind(ANSWER / UNSUPPORTED / CLARIFICATION / REPHRASE)columns,rows,hasMore,resultMode,conditions,title), 이름 후보(clarification)usedLlm: false). 추천 질문마다 이어서 물어볼 질문 제공scripts/assistant-evaluation)context(의도·원래 SQL·실행 파라미터·결과 건수)를 돌려주고, 다음 요청의history(최대 3턴)로 받음turnIndex(최대 5)를 DTO에서 막고 로그에 남김presetId로 호출 제한을 건너뛰던 문제 수정20261013120000_add_assistant_reader_role)set_config('app.band_id', …, true)+SET LOCAL ROLE assistant_reader, 실행 후RESET ROLEassistant_reader는 카탈로그 허용 컬럼에만 SELECT 권한(컬럼 단위 GRANT). 카탈로그 spec이 GRANT와 대조app.band_id가 없으면 0행SELECT *는 카탈로그 컬럼 목록으로 변경LlmService가 마감을 넘기면 재시도·다음 호출을 멈춤.LLM_MAX_RETRIES=0허용, OpenAI 호환 거절 응답(content: null) 처리FE
AssistantScreen)AssistantTurn)AssistantResult)kind가 없는 응답도answerable로 분기context(최대 3턴)와turnIndex를 붙여 보냄. 첫 질문의 요청 본문은 그대로. 질문 5개가 쌓이면 입력창 대신 새 대화 안내기타
NODE_ENV가 development·test일 때만 누락을 허용하고 그 밖(값 없음·staging 포함)은 운영처럼 필수. 개발 환경 변수 받기 스크립트(다른 환경 secret을 받지 않도록 형태·NODE_ENV검사), MSW 끄기 옵션listAllObjects도 같은 스토리지 설정 확인을 거침검증
측정 조건:
gemini-3.1-flash-lite, temperature 0, 고정 시각2026-09-01T00:00:00.000Z, 요청 동시성 1, 문항 간격 4.5초, 재생성 최대 2회. 합성 평가 DB(v3, 겸업 멤버의 다중 세션 포함)에서 Service·Repository 경로를 직접 호출한다. HTTP와 화면은 포함하지 않는다. 설정마다 3회 반복했고 세 회차의 문항별 결과가 모두 같았다.평가 세트
구조 비교 (2026-10-06, 설정별 3회)
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회)10/13 리뷰 반영 검증 (모델 재호출 없음)
users.email권한 오류dev 병합 후 전체: 백엔드
verify.sh통과(테스트 1,287), 프론트 tsc·테스트 534·eslint 통과.읽는 법
95%, followup 6/7은 4997%, FINAL50 48/50은 8799%, dev 50/50은 93100%다.🚨 주요 고민 및 해결 과정
문제 1. 정해진 질문만 답하는 방식과 자유 SQL
처음에는 LLM이 JSON 조회 계획을 만들고 서버가 Prisma로 옮기는 방식이었는데, 지원하는 질문이 4종으로 묶여 미리 정의하지 않은 질문에 답할 수 없었다.
해결 과정
자유 SELECT를 허용하되 실행 전후에 서로 독립된 안전장치를 겹쳤다.
$1에 바인딩한다.문제 2. 겸업 멤버의 중복 집계
보컬·기타 겸업이면 같은 합주에 참여 행이 2개라 인원이 중복으로 세지고 명단에도 같은 사람이 두 번 나왔다. 프롬프트로 DISTINCT를 지시하고 질문 문장으로 인원 질문을 판별해 검사했지만 한계가 있었다.
해결 과정
모델에게 허용한 참여·팀 편성 컬럼이 모두 사람 단위라는 점을 이용해, 검증이 끝난 뒤 서버가 대표 행 조건을 붙여 실행하도록 했다.
문제 3. 재생성이 이미 고친 규칙을 다시 어김
JOIN을 고치면 DISTINCT를 빠뜨리고, DISTINCT를 고치면 JOIN을 다시 빠뜨리면서 재생성 2회를 다 썼다. 검증기는 첫 위반에서 멈췄고, 재생성에는 직전 실패 하나만 전달했기 때문이다.
해결 과정
의미 규칙 위반은 모두 모아서 알리고, 재생성할 때 모든 시도의 위반과 직전 SQL을 함께 줘 위반한 부분만 고치게 했다. 이 유형의 생성 실패가 사라졌다.
문제 4. 추천 질문의 답 형태가 매번 다름
가장 많이 눌리는 추천 질문인데도 모델이 매번 다른 컬럼을 골랐다. 예를 들어 "다음 합주 일정이 언제야?"에 일정 이름이 빠지는 경우가 있었다.
해결 과정
추천 질문은 고정 SQL로 실행하되, 자유 질문과 같은 검증기를 통과하게 했다. 답 형태가 고정되고 응답 시간이 약 2초에서 수십 ms로 줄었으며, 모델 호출 비용도 들지 않는다.
문제 5. 답을 보여주는 화면
처음에는 홈 카드 안에 답을 펼쳤다. 그러자 답이 길어지면 입력창이 하단 탭바 아래로 밀렸고, 숫자 하나도 표로 나왔으며, 조건이 PRACTICE·UTC 시각 같은 내부 값으로 보였다.
해결 과정
문제 6. "그중에"를 해석할 수 없음
조건 칩은 사람이 읽는 요약이라, "그중에 기타 치는 사람은?"이 가리키는 앞 질문의 WHERE 절을 복원하지 못한다. 이런 짧은 질문에는 "참석" 같은 단서도 없어서 인원 검사의 질문 판별을 빠져나갔다.
해결 과정
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가 응답에 실림해결 과정
FROM songs LEFT JOIN bands … WHERE b.id = $1)도 거부해 추가 생성 비용이 들 수 있다.SET LOCAL ROLE을 써서 기존 계정·커넥션 풀을 그대로 쓴다. 뷰 대신 컬럼 GRANT를 쓴 이유는 모델 SQL이 실제 테이블명을 쓰기 때문이다. 하위 테이블에band_id를 추가하는 대신 부모를 거치는 정책을 써 스키마는 바꾸지 않았고, 그 비용이 위 DB p95 증가다.🚀 배포 시 확인 (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가 켜졌는지, 자유 질문·추천 질문이 각각 답하는지.turnIndex충돌).Summary by CodeRabbit
새 기능
개선 사항
문서 및 테스트