모델 API 설계 - 100-hours-a-week/KTB4-4th-wiki GitHub Wiki
니쥬는 AI와의 대화로 취향을 분석한다. 그 결과로 두 종류의 상품 리스트를 만든다. 리스트를 둘로 나누는 것이 이 API의 전제다.
| 리스트 | 보는 사람 | 화면 |
|---|---|---|
self — 내게 필요한 것 |
본인 | 메인 (NU-03) |
gift — 받고 싶어할 것 |
친구 | 친구 상세·선물 추천 (NU-06·07) |
1. AI 서버 상태 확인 — 시퀀스 다이어그램과 설계 이유
Spring Backend, 컨테이너 플랫폼 또는 모니터링 시스템이 AI 서버의 기본 실행 상태를 확인한다. 헬스체크에서는 비용과 지연이 발생하는 LLM 및 임베딩 API를 호출하지 않는다.
sequenceDiagram
participant C as Spring 또는 모니터링 시스템
participant A as AI Server
participant P as FastAPI Application
participant D as 카탈로그 DB
C->>A: GET /health
A->>P: 애플리케이션 실행 상태 확인
P->>D: 커넥션 확인
D-->>P: 응답
alt 요청 처리 가능
P-->>A: 정상 상태
A-->>C: 200 OK
else 필수 초기화 미완료
P-->>A: 비정상 상태
A-->>C: 503 SERVICE_UNAVAILABLE
end
- AI 서버가 HTTP 요청을 받을 수 있는지 빠르게 확인한다.
- 반복되는 헬스체크에서 외부 유료 API를 호출하지 않는다.
- LLM 제공자의 일시적인 장애를 AI 서버 프로세스 장애와 구분한다.
- 카탈로그 DB는 모든 추천 경로의 필수 의존성이므로 커넥션만 확인한다. 벡터 검색은 실행하지 않는다.
- 컨테이너 재시작과 배포 상태 판단에 사용할 수 있도록 응답을 단순하게 유지한다.
- 외부 모델 의존성 점검이 필요하면
/health에 넣지 않고 별도의 readiness(준비 상태) 엔드포인트를 검토한다.
2. 취향 대화 — 시퀀스 다이어그램과 설계 이유
취향을 폼이 아니라 대화로 수집한다. AI 서버는 세션을 만들고 첫 발화를 반환한다. 이후 대화 사이클마다 응답을 스트리밍하고, 그 사용자 입력에서 파악한 취향 항목을 세션 상태에 누적한다.
이 문서의 1개 대화 사이클은 사용자 입력 1회와 AI 응답 1회의 왕복이다. 상한 20개 대화 사이클은 사용자 20회 + AI 20회인 총 40개 발화다. 한 사이클은 모델 호출 두 번으로 처리한다. 8B 응답 모델은 사용자에게 보일 답변을 쓰고, 1.3B 추출 모델은 그 입력에서 파악한 변경분을 JSON으로 뽑는다. 둘은 다른 시스템 프롬프트를 쓴다. 사용자가 기다리는 것은 응답 모델뿐이다.
무엇을 물을지는 모델이 정하지 않는다. 서버가 세션 상태의 항목 수를 세어 이번 턴의 목표를 정한다. 모델에는 지시문만 전달한다.
sequenceDiagram
participant U as 사용자
participant F as 프론트엔드
participant S as Spring Backend
participant A as AI Server
participant R as Redis 세션 상태
participant P as 목표 판정 (서버 로직)
participant L as 응답 모델
participant X as 추출 모델
U->>F: 대화 시작 (NU-04)
F->>S: 세션 생성 요청
S->>A: POST /v1/chat/sessions
A->>P: 첫 턴 목표 판정 (OPENING)
A->>L: 첫 발화 생성
A->>R: 세션 생성 (이력·상태 저장)
A-->>S: 201 sessionId, greeting
S-->>F: 첫 발화 표시
loop 최대 20개 대화 사이클
U->>F: 발화 입력
F->>S: 턴 전송
S->>A: POST /v1/chat/sessions/{id}/messages
A->>R: 세션 상태 로드
A->>P: 이번 턴 목표 판정 (LLM 미사용)
P-->>A: 목표 + 지시문
A->>L: 시스템(불변) + 누적 이력 + 지시문 + 이번 발화
L-->>A: 응답 스트림
A-->>S: event: token
S-->>F: SSE 릴레이
A->>A: 지시문 누출 절단 · 질문 1개 강제
A->>X: [STATE] + 직전 AI 발화 + 이번 발화 (구조화 출력)
X-->>A: 델타 JSON (items·axes·drop)
A->>A: 병합 · 정정 · 상한 초과분 방출
A->>R: 세션 상태 저장
A-->>S: event: done (turn, canClose)
end
Redis에서 세션 상태 로드
→ 서버가 이번 턴 목표를 정한다 (개수 기반 규칙, LLM 미사용)
→ 응답 모델 호출: [시스템(불변)] + [누적 이력] + [지시문 + 이번 발화]
→ 토큰을 SSE로 흘려보낸다
→ 응답 후처리: 지시문 누출 절단, 물음표 2개 이상이면 첫 질문까지만
→ 추출 모델 호출: [STATE] 블록 + 직전 AI 발화 + 이번 발화 → 델타 JSON
→ 병합·정정·방출 후 Redis 저장
→ done 이벤트로 턴 수와 종료 가능 여부 전달
응답과 추출은 분리된 두 호출이다. 하나의 응답을 구분자로 잘라 쓰지 않는다.
- 폼으로 물으면 응답률과 응답 품질이 낮다. 대화로 수집한다.
- 한 턴은 응답 호출과 추출 호출 두 번이다. 하나의 응답을 구분자로 잘라 쓰지 않는다. 대화와 JSON 정리는 성격이 다른 일이라 한 모델에 같이 시키지 않는다.
- 추출은 응답 스트림이 끝난 뒤에 돌기 때문에 사용자가 기다리지 않는다. 실패해도 그 턴만 빈 델타로 넘어가고 대화는 끊기지 않는다. 부분 실패가 계약 전체를 실패로 만들지 않는다.
- 델타 형식은
response_format: json_schema(strict)로 강제한다. 구분자 뒤에 JSON을 붙이는 방식은 형식 준수율이 낮다. 그 실패가 사용자에게 보이는 스트림 안에서 발생한다. - 취향 항목은 대화가 끝난 뒤 한 번에 뽑지 않고 턴마다 즉시 누적한다. 갱신이 늦으면 이미 들은 것을 다시 묻는다.
- 전체 생성을 기다리면 사용자가 응답 없음으로 판단한다. 토큰 단위로 전송한다. 프론트엔드는 백엔드가 릴레이한 SSE(Server-Sent Events)만 받는다.
- 지시문은 사용자 발화에 넣지 않는다. 별도 system 메시지로 뒤에 붙인다. 발화에 섞으면 모델이 지시문을 사용자 말로 처리해 그대로 출력한다.
- 대화 종료 판정은 서버가 한다. 모델이 종료를 선언하면 취향이 충분히 모이기 전에 대화를 끝내기 때문이다.
프롬프트를 어떤 순서로 조립하고 두 호출을 어떤 모델로 처리할지는 지연 문제라 모델 추론 성능 최적화에서 정한다.
모델이 맥락을 판단하게 하지 않는다. 세션 상태의 항목 수를 세는 결정적(deterministic) 규칙으로 목표를 정한다.
| 목표 | 나오는 조건 |
|---|---|
OPENING |
첫 턴 |
INTEREST |
검색용(query 역할) 항목 3개 미만 |
INTEREST_VIA_ROUTINE |
직접 질문이 두 번 무성과 → 일과 이야기에서 캔다 |
DISLIKE |
필터 항목(기피·제약) 0개이고 3턴 경과 |
GEAR |
검색용 1개 이상인데 보유·선호가 2개 미만 |
DEEPEN |
확신 0.6 이상 항목 2개 미만 |
WRAP |
20개 대화 사이클 상한 도달 · 종료 조건 충족(12번째 사이클 이후) · 이탈 신호 |
CORRECT |
확인 화면에서 정정을 요청해 재개된 세션의 첫 턴 |
- 같은 목표가 반복되면 지시문을 시도 횟수마다 바꾼다. 같은 것을 다른 각도로 묻기 위해서다.
- 세 번 물어도 항목이 나오지 않으면 그 목표를 중단하고 다음으로 넘어간다. 이 규칙이 없으면 같은 질문이 반복된다.
- 선물·상품 질문은 목표에 두지 않는다. 사용자가 선물을 받기 위한 대화로 인식하면 응답이 왜곡된다.
wants와unaffordable은 대화 중 언급되면 수집만 한다. - 이탈은 발화 길이로 본다. 최근 3턴이 계속 짧아지고 마지막이 12자 미만이면 더 묻지 않고 마무리로 넘어간다.
close 이후 확인 화면(NU-04A)에서 사용자가 정정을 요청하면 같은 세션을 재개한다. 이 경로의 첫 턴 목표가 CORRECT다.
| 항목 | 값 |
|---|---|
| 허용 횟수 | 세션당 1회 |
| 대화 사이클 예산 | 기존 최대 20개 사이클과 별도로 배정한다. 상한값은 미확정 |
| 지시문에 넣는 것 | 확인 화면에 표시된 키워드와 요약문 |
CORRECT는 다른 목표와 입력이 다르다. 나머지 목표는 비어 있는 항목을 채우는 것이고, CORRECT는 이미 확정된 항목 중 무엇이 틀렸는지를 좁히는 것이다. 그래서 지시문에 현재 프로필의 표시 항목을 함께 넣는다.
정정 대화를 마치면 close를 다시 호출한다. 별도의 재요청 엔드포인트를 두지 않는다.
linkRole(검색·배제·가중), visibility(공개 범위), intentType 기본값은 field에서 결정적으로 파생된다. 매 턴 모델에게 분류시키면 토큰이 늘고 판단이 흔들리므로 서버가 규칙으로 채운다.
항목 수도 서버가 관리한다. 세션당 상한은 items 12개, axes 3개다. 상한을 넘으면 확신도, 최초 언급 시점, 갱신 경과로 점수를 매겨 낮은 것부터 버린다. dislikes, constraints, 보류 신호(deferralReason)가 붙은 항목은 방출하지 않는다. 개수가 적고, 하나가 빠지면 배제 조건이 누락되기 때문이다.
응답 규칙도 프롬프트에만 맡기지 않는다. 지시문이 답변에 섞이면 그 지점부터 잘라낸다. 물음표가 둘 이상이면 첫 질문까지만 남긴다. "한 턴에 질문 하나"는 프롬프트와 코드 양쪽에서 강제한다.
3. 프로필 확정과 리스트 생성 — 시퀀스 다이어그램과 설계 이유
대화가 끝나면 처리가 두 구간으로 나뉜다. 앞 구간은 세션 상태를 취향 프로필로 확정하고 요약문과 표시 키워드를 만든다. 사용자가 확인 화면에서 결과를 승인하면 뒤 구간이 시작되어 self·gift 두 리스트를 만든다.
sequenceDiagram
participant U as 사용자
participant F as 프론트엔드
participant S as Spring Backend
participant A as AI Server
participant J as 리스트 생성 잡 큐
participant L as LLM 및 임베딩
participant D as 카탈로그 DB
F->>S: 대화 종료
S->>A: POST /v1/chat/sessions/{id}/close
A->>A: 프로필 확정 (서버 규칙)
A->>L: 요약문 생성
A->>A: 표시 키워드 선별 (서버 규칙)
A-->>S: 200 profile, summary, keywords
S-->>F: 확인 화면 표시 (NU-04A)
alt 정정 요청
U->>F: 다릅니다
F->>S: 세션 재개
S->>A: POST /v1/chat/sessions/{id}/messages
Note over S,A: 목표 CORRECT로 정정 대화, 이후 close 재호출
else 확정
U->>F: 맞습니다
F->>S: 확정 전송
S->>S: 프로필·요약문·키워드 저장
S->>A: POST /v1/recommendations/jobs
A->>J: 잡 등록
A->>A: 세션 원문 삭제 (병렬)
A-->>S: 202 jobId, pollAfterMs
S-->>F: 메인 화면으로 이동
J->>L: 검색 문장 재작성 · 임베딩
J->>D: 벡터 검색 및 하드필터
J->>J: 스코어 · 다양성 선별
J->>L: 추천 이유 생성
loop 완료까지 폴링
S->>A: GET /v1/recommendations/jobs/{jobId}
alt 처리 중
A-->>S: 200 running, progress
else 완료
A-->>S: 200 succeeded, lists
end
end
S->>S: 두 리스트 저장
S-->>F: SSE로 리스트 전달
end
앞 구간은 close 요청 안에서 동기로 처리한다.
세션 상태를 프로필로 확정
→ 요약문 생성
→ 표시 키워드 선별
→ 프로필 + 요약문 + 키워드 반환
뒤 구간은 확정 요청으로 등록되는 잡이다.
잡 등록 · 세션 원문 삭제 (병렬)
→ 검색 문장 재작성 → 임베딩 → 벡터 검색 → 스코어 · 선별 → 이유 생성
→ self·gift 두 리스트 반환
잡 안에서 모델 호출과 상품 검색이 여러 번 이어진다. 그 안을 어떤 단계로 쪼개고 무엇으로 검색할지는 이 문서의 범위가 아니다. 계약이 정하는 것은 확정 요청으로 시작해 잡 조회로 결과를 받는다는 것까지다.
- 구간을 나눈 이유는 사용자가 프로필을 확인하고 정정할 수 있어야 하기 때문이다. 검색과 이유 생성은 확정된 프로필로 돌아야 한다.
- 앞 구간은 규칙 계산 두 번과 모델 호출 한 번이라 지연 편차가 작다. 동기 응답으로 처리하고 별도 로딩 화면을 두지 않는다. 요약문 생성이 실패하면 요약 없이 프로필과 키워드만 반환한다.
- 검색 문장 재작성은 뒤 구간에 둔다. 정정 대화로 프로필이 바뀔 수 있으므로 확정 전에 쿼리를 만들면 버려진다.
- 뒤 구간은 지연이 길고 편차가 크다. 동기로 두면 타임아웃 값을 정할 수 없어 잡 등록 후 폴링으로 나눈다.
- 두 리스트를 한 잡에서 함께 만든다. 검색과 하드필터, 다양성 선별을 공유하므로 모드별로 나누면 임베딩과 벡터 검색이 중복된다.
- 사용자는 확정 직후 메인 화면으로 이동한다. 리스트 생성을 기다리는 동안 대화 화면에 머무를 필요가 없다. 백엔드가 폴링으로 결과를 받아 프론트엔드에 SSE로 전달한다. AI Server는 프론트엔드와 직접 연결하지 않는다.
- 세션 원문은
/close직후가 아니라 사용자가 확인 화면에서 프로필을 승인하고 리스트 잡을 등록하는 시점에 삭제한다. 삭제는 잡과 병렬로 실행하며 리스트 생성이 삭제 완료를 기다리지 않는다. - 잡 결과는 완료 후 10분간만 보관한다. 백엔드가 받아 저장하면 AI Server에 남길 이유가 없다.
- 요약문에는
lifestyle을 넣지 않는다. 친구가 읽는 글이다.visibility가friends인 관심사, 선호, 기피만 쓴다. - 부분 실패를 전체 실패로 만들지 않는다. 추천 이유 생성이 실패해도 기본 문구로 대체하고 추천 자체는 반환한다.
| 화면 | AI Server 호출 | 대기 |
|---|---|---|
| NU-04 대화 |
messages (SSE 릴레이) |
있음 |
| NU-04A 확인 |
close (동기) |
있음. 화면 전환으로 덮인다 |
| NU-03 메인, 리스트 준비 중 | 없음. 백엔드가 폴링하고 SSE로 전달 | 화면을 벗어나도 된다 |
| NU-03 메인 | 없음. 저장된 self 리스트 조회 |
없음 |
| NU-06 친구 상세 | 없음. 저장된 요약·키워드·리스트 조회 | 없음 |
| NU-07 선물 추천 | 없음. 예산 조절바는 클라이언트 필터 | 없음 |
대화를 빼면 사용자가 화면 앞에서 완료를 기다리는 구간이 없다.
4. 추천 리스트 재생성 — 시퀀스 다이어그램과 설계 이유
이미 저장된 프로필로 리스트를 다시 만든다. 화면 진입 시 호출하는 엔드포인트가 아니며, 프로필·카탈로그·피드백이 바뀌었을 때 백엔드가 부른다.
sequenceDiagram
participant T as 재생성 트리거
participant S as Spring Backend
participant A as AI Server
participant L as LLM 및 임베딩
participant D as 카탈로그 DB
T->>S: 프로필 변경 · 주간 배치 · 피드백 누적 · TTL 만료
S->>A: POST /v1/recommendations (mode, profile, feedbackSummary)
A->>D: 조건에 맞는 상품 조회
D-->>A: 후보 300~500
alt 최소 점수를 넘는 후보 있음
A->>A: 모드별 순위 계산과 선별
A->>L: 추천 이유 생성
A-->>S: 200 items, funnel
else 전량 미달
A-->>S: 200 items 빈 배열, emptyReason
end
S->>S: 리스트 교체 저장
프로필 수신 및 검증
→ 조건에 맞는 상품 후보 조회 (제외 목록 · constraints 반영)
→ 모드별 순위 계산과 선별
→ 추천 이유 생성
→ 응답 검증 및 정제
후보를 어떻게 찾고 어떤 식으로 순위를 매기는지는 이 문서에서 정하지 않는다. 계약이 정하는 것은 요청에 무엇을 넣고 응답에서 무엇을 받는지까지다.
- 리스트를 미리 만들어 백엔드가 저장한다. 친구 목록에서 한 명을 열 때마다 추천 이유 생성을 기다리지 않는다.
-
limit기본값은 화면 노출 개수가 아니라 저장할 개수다. NU-07 예산 조절바는 저장된 목록 위에서 도는 클라이언트 필터다. 적게 저장하면 예산을 좁혔을 때 결과가 0이 된다. 한 모드당 20개다. 한 번의 취향 추출에서 나오는 상품은self와gift를 합쳐 최대 40개다. 두 리스트는 같은 후보 풀에서 뽑으므로 상품이 겹칠 수 있다. 실제 고유 상품 수는 40개보다 적다. - 요청에 예산, 관계, 상황 입력이 없다. 리스트는 프로필에만 의존한다. 친구 관계를 표현하는 스키마가 없다. 그래서 모든 친구가 같은 요약 프로필과 같은
gift리스트를 본다. - 최소 점수 컷을 둔다. 벡터 검색은 맞는 상품이 없어도 가장 가까운 것을 반환한다. 기준이 없으면 관련 없는 상품이 목록에 들어간다.
- 결과 0건은 장애가 아니므로
200과 빈items를 반환하고emptyReason으로 원인을 구분한다. -
funnel을 함께 반환한다. 결과가 이상할 때 어느 단계에서 후보가 줄었는지 확인한다.
| 트리거 | 이유 |
|---|---|
| 새 대화로 취향/관심사가 업데이트 되어 프로필이 변경될때 | 필수. 보통은 추출 응답이 두 리스트를 함께 돌려주므로 별도 호출이 불필요하다 |
| 카탈로그 주간 배치 | 신상품 유입 반영 |
| 피드백 누적 | 순위 보정값이 바뀜 |
| TTL 7일 경과 | 카탈로그 스냅샷 노후화 |
5. 피드백 수집 — 시퀀스 다이어그램과 설계 이유
사용자가 추천 상품에 남긴 반응을 받아, 백엔드가 프로필과 제외 목록에 무엇을 반영해야 하는지 알려준다.
sequenceDiagram
participant U as 사용자
participant F as 프론트엔드
participant S as Spring Backend
participant A as AI Server
U->>F: 좋아요 · 싫어요 · 이미 있음 (NU-08)
F->>S: 피드백 전송
S->>A: POST /v1/recommendations/{id}/feedback
A->>A: 귀인 대상 판정 (상품 · 취향 항목 · 매핑)
A-->>S: 200 accepted, effects
S->>S: effects대로 프로필과 제외 목록 갱신
Note over S,A: 다음 리스트 재생성 요청에 feedbackSummary로 반영
- 프로필 저장 주체는 백엔드이므로 AI 서버는 값을 직접 바꾸지 않고 무엇을 바꿔야 하는지만
effects로 알려준다. - 같은
dislike라도 귀인 대상이 다르다. 특정 상품 한 건인지, 취향 항목 자체인지, 취향 노드와 카테고리의 매핑 문제인지를 구분한다. 구분해야 다음 추천이 개선된다. -
already_have는 취향이 틀렸다는 신호가 아니라 맞았다는 신호다. 상품은 제외하되confidence는 낮추지 않는다. - 피드백은 즉시 리스트를 다시 만들지 않는다. 누적된 뒤 재생성 트리거에서 한 번에 반영한다.
6. 카탈로그 운영 — 설계 이유
운영용 엔드포인트다. 취향 대비 상품이 부족한 구간을 반환한다. 화면에서 호출하지 않으므로 계약이 단순하다.
무엇을 커버리지로 셀지, 부족한 구간을 어떻게 채울지는 이 문서에서 정하지 않는다. 지식 소스 설계에 속하는 문제다.
엔드포인트 10개는 7개 기능으로 묶인다.
| # | 기능 | 하는 일 | 엔드포인트 | 화면 | 사용자 대기 |
|---|---|---|---|---|---|
| 1 | AI 서버 상태 확인 | 프로세스와 필수 의존성이 살아 있는지 확인 | 1개 | 없음 (운영) | 없음 |
| 2 | 취향 대화 | 대화로 취향을 수집하고 턴마다 세션 상태에 누적 | 3개 | NU-04 | 있음 (SSE 스트리밍) |
| 3 | 프로필 확정 | 세션 상태를 프로필로 확정하고 요약문과 키워드를 생성 | 1개 | NU-04A | 있음 (동기 응답) |
| 4 | 리스트 생성 | 확정된 프로필로 self·gift 두 리스트를 생성 |
2개 | NU-03 | 없음 (백엔드가 폴링) |
| 5 | 추천 리스트 재생성 | 저장된 프로필로 한 모드의 리스트를 갱신 | 1개 | NU-03·06·07 | 없음 (백엔드 트리거) |
| 6 | 피드백 수집 | 사용자 반응을 받아 백엔드가 반영할 변경을 반환 | 1개 | NU-08 | 없음 |
| 7 | 카탈로그 운영 | 취향 노드 대비 수집이 필요한 구간 집계 | 1개 | 운영 | 없음 |
사용자가 화면 앞에서 완료를 기다리는 기능은 2와 3이다. 기능 4는 사용자가 메인 화면으로 이동한 뒤에 돌고, 결과가 준비되면 백엔드가 SSE로 알린다. 5~7은 백엔드나 배치가 부르므로 응답 시간이 화면 체감에 직접 걸리지 않는다.
| Request Method | URL | 기능 설명 |
|---|---|---|
| GET | /health |
서버 상태 확인 |
| POST | /v1/chat/sessions |
대화 세션 생성 |
| POST | /v1/chat/sessions/{sessionId}/messages |
대화 턴 전송 및 응답 스트리밍 |
| DELETE | /v1/chat/sessions/{sessionId} |
대화 세션 폐기 (응답중 사용자 이탈) |
| POST | /v1/chat/sessions/{sessionId}/close |
대화 종료 및 프로필 확정 |
| POST | /v1/recommendations/jobs |
프로필 확정 승인 및 리스트 생성 잡 등록 |
| GET | /v1/recommendations/jobs/{jobId} |
리스트 생성 잡 상태 및 결과 조회 |
| POST | /v1/recommendations |
추천 리스트 재생성 |
| POST | /v1/recommendations/{recommendationId}/feedback |
추천 피드백 수집 |
| GET | /v1/catalog/coverage |
카탈로그 커버리지 조회 |
행 순서는 아래 「API별 요청·응답 계약」의 순서와 같다.
같은 기능 안에서도 처리 방식이 갈린다. 백엔드가 호출 코드를 짤 때 이 표를 먼저 본다.
| 처리 방식 | API | 이유 |
|---|---|---|
| 동기 |
/healthPOST /v1/chat/sessionsDELETE /v1/chat/sessions/{sessionId}POST /v1/recommendations/{recommendationId}/feedbackGET /v1/catalog/coverage
|
응답이 짧고 지연 편차가 작다 |
| 동기 · 모델 호출 1회 | POST /v1/chat/sessions/{sessionId}/close |
규칙 계산 두 번과 요약문 생성 한 번이다. 타임아웃을 짧게 두고 요약문 실패는 부분 실패로 처리한다 |
| 동기 · 무거움 | POST /v1/recommendations |
벡터 검색과 이유 생성이 들어가 수 초가 걸린다. 사용자 대기 경로가 아니라 유지한다 |
| 스트리밍 (SSE) | POST /v1/chat/sessions/{sessionId}/messages |
응답이 길어 전체 생성을 기다리면 화면이 멈춘 것처럼 보인다 |
| 비동기 (202 + 폴링) |
POST /v1/recommendations/jobsGET /v1/recommendations/jobs/{jobId}
|
검색과 이유 생성이 이어서 돌아 지연 편차가 크다 |
| 구분 | API | 상태 |
|---|---|---|
| 상태 있음 | 기능 2·3 | Redis 세션 (유휴 30분, 최대 2시간) |
| 상태 있음 | 기능 4 | 리스트 생성 잡 (완료 후 10분) |
| 상태 없음 | 기능 1·5·6·7 | 요청마다 독립. sessionId를 사용하지 않는다 |
NU-03~NU-12는 서비스 화면 번호이고, 엔드포인트의 /v1은 API 계약 버전이다. 상세 설계 문서가 v3까지 개정되었지만 계약 자체는 아직 첫 버전이므로 /v3 경로를 새로 파지 않는다.
대화(기능 2·3)를 빼면 사용자가 AI 서버를 기다리는 화면이 없다. 나머지 화면은 백엔드가 저장한 리스트를 조회하므로, recommendations는 화면 진입 API가 아니라 갱신 API다.
| 구분 | 설계 원칙 |
|---|---|
| 호출 방향 | 프론트엔드는 AI Server를 직접 호출하지 않으며, 모든 요청은 Frontend → Spring Backend → AI Server 순서로 전달한다. 대화 스트리밍도 백엔드가 받아 프론트엔드로 릴레이한다. |
| 인증·인가 | 사용자 인증과 친구 관계 권한 검사는 Spring Backend가 담당한다. AI Server는 Authorization: Bearer {SERVICE_TOKEN}으로 백엔드만 인증하고, 요청 본문의 userId를 식별자로 신뢰한다. |
| AI Server 책임 | 대화 진행, 취향 추출, 벡터 검색, 랭킹, 추천 이유 생성, 응답 검증과 정제를 담당한다. |
| 데이터 소유 | AI Server는 사용자 데이터를 저장하지 않는다. 취향 프로필과 추천 리스트는 백엔드가 소유하고, AI Server는 요청마다 받아 계산하고 돌려준다. 상품 카탈로그만 예외다. 소유를 이렇게 나눈 근거는 서비스 아키텍처 모듈화에서 다룬다. |
| 휘발성 상태 | 대화 원문과 누적 구조화 State는 Redis 세션에 함께 두고, 리스트 생성 잡 결과는 완료 후 10분만 보관한다. 세션은 사용자가 확인 화면에서 프로필을 승인하고 잡을 등록할 때 삭제하며, 미승인 상태로 TTL이 만료되면 그때 삭제된다. 장기 프로필은 백엔드 DB가 소유한다. |
| 대화 맥락 | 대화는 sessionId로 구분한다. 추천과 피드백은 대화 맥락을 사용하지 않는 독립 요청이다. |
| 모드 분기 |
self와 gift는 검색·하드필터·다양성 선별을 공유하고, 스코어 함수와 이유 생성 프롬프트에서만 갈라진다. 엔드포인트를 나누지 않는다. |
| 사전 계산 | 추천 리스트는 화면 진입 때 계산하지 않고 미리 만들어 백엔드가 저장한다. 리스트가 프로필 주인만의 함수이기 때문이다. |
| 외부 결과 검증 | LLM 출력은 신뢰하지 않는다. 상품은 반드시 카탈로그에 존재하는 것만 반환하고, 응답 스키마의 필수 필드와 형식을 검증한다. |
| 실패 처리 | 요청 오류, 세션 오류, 모델 장애, 카탈로그 장애를 구분한다. 부분 실패는 전체 실패로 만들지 않는다. 추천 결과가 없는 경우는 장애가 아니므로 200과 items: []를 반환한다. |
| 프라이버시 |
gift 응답에서는 matchedSignals[].value를 null로 두고 label만 반환한다. 원문 표현이 그대로 나가면 친구가 대화 내용을 역추론할 수 있다. |
| 버전 | 상세 설계 문서 버전(v1·v2·v3)과 API 계약 버전 /v1은 서로 다른 개념이다. |
필드 표는 필드 | 타입 | 설명 3열로 통일한다. 아무 표기가 없으면 필수이며, 그렇지 않은 것만 설명 앞에 표기한다.
| 표기 | 의미 |
|---|---|
| (표기 없음) | 필수. 요청에는 반드시 포함하고, 성공 응답에는 항상 반환한다 |
선택. |
요청에서 생략할 수 있다. 값이 제공되면 타입과 제약을 지켜야 한다 |
조건부 필수. |
특정 조건이 성립할 때만 포함하거나 반환한다 |
또는 null |
타입 열에 표기한다. 필드는 항상 반환하지만 값이 없으면 null이다 |
공통 헤더는 다음과 같다.
| 헤더 | 값 | 적용 |
|---|---|---|
Authorization |
Bearer {SERVICE_TOKEN} |
모든 엔드포인트. /health 제외 |
Content-Type |
application/json |
요청 본문이 있는 모든 엔드포인트 |
Accept |
text/event-stream |
턴 전송에만 적용 |
공통 경로 파라미터 타입은 다음과 같다. 경로 표기는 요청·응답 JSON과 같은 camelCase로 통일한다.
| 파라미터 | 타입 | 설명 |
|---|---|---|
sessionId |
string | 대화 세션 식별자 |
jobId |
string | 리스트 생성 잡 식별자 |
recommendationId |
string | 추천 리스트 식별자 |
취향 프로필은 백엔드가 저장하고 AI Server가 요청마다 받는다. 각 배열은 항상 존재하며 값이 없으면 빈 배열을 사용한다. 세션 전체에서 10개 배열의 ProfileItem 합계는 최대 12개다.
| 필드 | 타입 | 설명 |
|---|---|---|
schemaVersion |
string | 스키마 버전. 현재 3.0
|
userId |
string | 프로필 소유 사용자 식별자 |
summary |
string 또는 null | 친구에게 보이는 요약문. 생성 실패 시 null
|
interests |
array<ProfileItem> | 좋아하는 대상 |
hobbies |
array<ProfileItem> | 직접 하는 활동 |
preferences |
array<ProfileItem> | 상품 속성 선호 |
lifestyle |
array<ProfileItem> | 생활 맥락 |
wants |
array<ProfileItem> | 갖고 싶다고 말한 것 |
unaffordable |
array<ProfileItem> | 갖고 싶지만 사지 않은 것 |
consumables |
array<ProfileItem> | 떨어지면 다시 사는 것 |
owned |
array<ProfileItem> | 이미 갖고 있는 것 |
dislikes |
array<ProfileItem> | 싫어하는 것 |
constraints |
array<ProfileItem> | 알레르기·사이즈 등 불가 조건 |
axes |
array<string> | 사용자가 직접 밝힌 판단 기준. 최대 3개 |
POST /v1/chat/sessions/{sessionId}/close의 최상위 summary는 profile.summary와 같은 값이다. 두 위치에 모두 두어야 하는지는 아직 확정되지 않았으며, 현재 계약에서는 하위 호환을 위해 둘 다 반환한다.
10개 취향 배열의 의미와 추천 역할은 다음과 같다.
| 배열 | 정의 | 역할 |
|---|---|---|
interests |
좋아하는 대상 | 검색어 |
hobbies |
직접 하는 활동 | 검색어 |
preferences |
상품의 속성 선호 (색·소재·무게·브랜드) | 순위 조정 |
lifestyle |
생활 맥락 (1인 가구·재택) | 순위 조정 |
wants |
갖고 싶다고 말한 것 | 검색어 · want 신호 |
unaffordable |
갖고 싶은데 사지 않은 것 | 검색어 · 가장 강한 선물 신호 |
consumables |
떨어지면 다시 사는 것 | 검색어 · need 신호 |
owned |
이미 갖고 있는 것 | 배제 |
dislikes |
싫어하는 것 | 배제 |
constraints |
알레르기·사이즈 등 불가 조건 | 배제 |
| 필드 | 타입 | 설명 |
|---|---|---|
value |
string | 사용자 어휘 그대로 저장한 취향 값. 최대 20자 |
confidence |
number | 0.0~1.0 |
linkRole |
string |
query(검색) · filter(배제) · weight(가중) |
visibility |
string |
private · friends · public
|
intentType |
string 또는 null |
need · want · both. dislikes·owned·constraints처럼 의도 구분이 없는 항목은 null
|
deferralSignal |
boolean | 보류된 욕구인지 여부 |
deferralReason |
string | 조건부 필수. deferralSignal이 참일 때만 포함하며 justification · price · timing 중 하나 |
evidence |
string | 발화에서 잘라낸 근거 조각. 최대 40자 |
taxonomyPath |
array<string> 또는 null | 택소노미 매핑 결과. 매핑 실패는 오류가 아니며 null로 둔다 |
firstSeenAt |
string(ISO8601) | 최초 관측 시각 |
updatedAt |
string(ISO8601) | 최종 갱신 시각 |
deferralReason에 따라 선물 신호의 강도가 다르다.
deferralReason |
예 | 가중치 |
|---|---|---|
justification |
"나한테 사주긴 좀 그래" | 1.5 — 돈이 있어도 스스로 해결되지 않음 |
price |
"비싸서 못 샀어" | 1.4 |
timing |
"언젠가 사고 싶어" | 1.15 |
value를 카테고리로 정규화하지 않는다. "캠핑장에서 핸드드립"을 "아웃도어 > 캠핑"으로 바꾸면, 캠핑을 실제로 하는 사람과 캠핑 영상만 보는 사람이 같은 값이 된다. 구체성이 떨어지면 추천 정확도가 떨어진다.
확인 화면(NU-04A)과 친구 상세(NU-06)에 표시하는 대표 키워드다. 프로필 항목에서 서버 규칙으로 선별하며 모델을 호출하지 않는다.
| 필드 | 타입 | 설명 |
|---|---|---|
taste |
array<string> | 지속적인 성향. 최대 3개 |
interest |
array<string> | 현재 원하는 것. 최대 3개 |
선별 규칙은 다음과 같다.
| 구분 | 대상 field
|
조건 |
|---|---|---|
taste |
hobbies, preferences
|
visibility가 friends 이상, 확신도 내림차순 최대 3개 |
interest |
interests, wants, unaffordable, consumables
|
같은 조건, 최대 3개 |
-
lifestyle은 넣지 않는다. 요약문에서 제외하는 규칙과 같은 이유다. 생활 맥락은 친구가 보는 화면에 드러나면 안 된다. - 3개 고정이 아니라 최대 3개다. 대화가 짧게 끝나면 조건을 만족하는 항목이 3개에 미치지 못할 수 있다. 개수를 채우려고 확신도가 낮은 항목을 올리지 않는다.
- 키워드는 사용자에게 보이는 값이고, 검색에 쓰는 항목과 범위가 다르다. 검색은
linkRole이query인 항목 전체를 쓰며visibility와 무관하다.
추출 결과 조회와 추천 리스트 재생성은 동일한 RecommendationResult 구조를 사용한다.
| 필드 | 타입 | 설명 |
|---|---|---|
recommendationId |
string | 피드백 수집 시 참조하는 리스트 식별자 |
generatedAt |
string(ISO8601) | 생성 시각 |
mode |
string |
self 또는 gift
|
priceRange |
PriceRange 또는 null | 목록의 가격 분포. 결과가 없으면 null
|
items |
array<RecommendedItem> | 한 모드의 추천 목록. 최소 0개, 기본 최대 20개 |
emptyReason |
string 또는 null |
items가 비었을 때의 원인 |
suggestion |
string | 조건부 필수. items가 비고 안내할 행동이 있을 때만 반환 |
funnel |
RecommendationFunnel | 단계별 잔존 후보 수 |
| 필드 | 타입 | 설명 |
|---|---|---|
min |
integer | 목록의 최저 가격(원) |
max |
integer | 목록의 최고 가격(원) |
currency |
string | 현재 KRW 고정 |
| 필드 | 타입 | 설명 |
|---|---|---|
productId |
string | 카탈로그 상품 식별자 |
title |
string | 상품명 |
price |
integer | 원화 가격 |
imageUrl |
string | HTTPS 상품 이미지 URL |
productUrl |
string | 상품 상세 페이지 HTTPS URL |
category |
string | 카탈로그 카테고리 |
priceBand |
integer | 가격대 구간. 다양성 선별과 조절바에 사용 |
rank |
integer | 0부터 시작하는 순위 |
score |
number | 모드별 최종 스코어 |
reason |
string | 이 사람과 이 상품이 어울리는 이유. 상품마다 새로 쓰지 않고, 같은 검색 쿼리에 걸린 상품은 같은 문장을 공유한다 |
matchedSignals |
array<MatchedSignal> | 이 상품이 걸린 취향 신호 |
ranking |
RankingBreakdown | 스코어 인자와 랭커 버전 |
| 필드 | 타입 | 설명 |
|---|---|---|
field |
string |
TasteProfile의 10개 취향 배열 이름 중 하나 |
value |
string 또는 null | 원본 취향 값. gift 모드에서는 항상 null
|
label |
string 또는 null | 친구에게 공개할 택소노미 라벨. gift 모드에서 value를 대신한다 |
tasteNodeId |
string 또는 null | 매핑된 취향 노드 식별자 |
via |
string |
vector_content · vector_usage · vector_gift · taxonomy · feedback
|
similarity |
number 또는 null | 벡터 검색이면 코사인 유사도, 벡터 검색이 아니면 null
|
contribution |
number | 최종 점수 기여도 |
| 필드 | 타입 | 설명 |
|---|---|---|
mode |
string |
self 또는 gift
|
factors |
object<string, number> | 스코어 인자 이름과 값. self는 needSignal·practicality·cycleFit, gift는 wantSignal·deferralWeight·giftability를 사용 |
rankerVersion |
string | 랭커 규칙·가중치 버전 |
| 필드 | 타입 | 설명 |
|---|---|---|
retrieved |
integer | 최초 검색 후보 수 |
afterHardFilter |
integer | 제외 조건과 constraints 적용 후 후보 수 |
afterScoreFloor |
integer | 최소 점수 적용 후 후보 수 |
returned |
integer | 최종 반환 상품 수 |
{
"recommendationId": "r_5f6g",
"generatedAt": "2026-09-02T14:45:00Z",
"mode": "gift",
"priceRange": { "min": 12000, "max": 185000, "currency": "KRW" },
"items": [
{
"productId": "p_1234",
"title": "티타늄 더블월 머그컵 350ml",
"price": 148000,
"imageUrl": "https://cdn.example.com/p1234.jpg",
"productUrl": "https://example.com/products/1234",
"category": "캠핑식기",
"priceBand": 3,
"rank": 0,
"score": 0.809,
"reason": "직접 사기엔 조금 망설여지는 물건이에요. 캠핑에서 커피 내려 드시는 걸 좋아해서 오래 쓸 수 있는 컵을 골랐어요.",
"matchedSignals": [
{
"field": "unaffordable",
"value": null,
"label": "캠핑 장비",
"tasteNodeId": "outdoor.camping.camping_dish",
"via": "vector_content",
"similarity": 0.87,
"contribution": 0.42
}
],
"ranking": {
"mode": "gift",
"factors": { "wantSignal": 0.74, "deferralWeight": 1.5, "giftability": 0.81 },
"rankerVersion": "gift-1.0.0"
}
}
],
"emptyReason": null,
"funnel": { "retrieved": 412, "afterHardFilter": 268, "afterScoreFloor": 91, "returned": 20 }
}추천 가능한 상품이 없으면 오류 대신 다음과 같이 반환한다.
{
"recommendationId": "r_5f6h",
"generatedAt": "2026-09-02T14:46:00Z",
"mode": "gift",
"priceRange": null,
"items": [],
"emptyReason": "NO_RELEVANT_CANDIDATE",
"suggestion": "MORE_CONVERSATION",
"funnel": { "retrieved": 180, "afterHardFilter": 132, "afterScoreFloor": 0, "returned": 0 }
}emptyReason |
의미 | 화면 대응 |
|---|---|---|
NO_PROFILE_SIGNAL |
프로필에 query 역할 항목이 없어 검색을 시작할 수 없음 |
대화로 유도 |
ALL_EXCLUDED |
후보가 제외 조건과 constraints에 전부 걸림 |
제외 조건 조정 안내 |
NO_RELEVANT_CANDIDATE |
후보는 있었지만 최소 점수를 넘는 것이 없음 | 대화로 유도 또는 카탈로그 확충 |
CATALOG_GAP |
해당 취향 노드에 상품이 부족함 | 대화로 유도. 운영 신호로도 사용 |
| 필드 | 타입 | 설명 |
|---|---|---|
code |
string | 백엔드 분기용 상위 오류 코드 |
message |
string | 사용자에게 전달할 수 있는 오류 안내 문구 |
retryable |
boolean | 동일 요청 재시도로 회복 가능한지 여부 |
requestId |
string | 로그 추적용 요청 식별자 |
details |
object 또는 null | 세부 원인과 제한값. 추가 정보가 없으면 null
|
details의 내부 필드는 오류 코드마다 달라지는 확장 영역이다. 현재 공통 타입은 JSON object로만 정해졌고 오류 코드별 하위 스키마는 미확정이다.
{
"code": "LLM_UNAVAILABLE",
"message": "일시적인 오류입니다. 잠시 후 다시 시도해 주세요.",
"retryable": true,
"requestId": "req_8f2a",
"details": null
}전체 오류 코드는 다음과 같다.
| HTTP | code |
의미 | retryable |
|---|---|---|---|
400 |
INVALID_REQUEST |
스키마 위반, 필수 필드 누락 | false |
401 |
UNAUTHORIZED |
서비스 토큰 없음 또는 불일치 | false |
404 |
SESSION_NOT_FOUND |
세션 없음 또는 만료 | false |
404 |
JOB_NOT_FOUND |
리스트 생성 잡 ID 없음 또는 보관 기간 경과 | false |
404 |
RECOMMENDATION_NOT_FOUND |
피드백 대상 리스트 없음 | false |
409 |
SESSION_CLOSED |
이미 종료된 세션에 메시지 전송 | false |
422 |
PROFILE_TOO_SPARSE |
최대 사이클 전 수동 종료 등, 입력 계약상 최소 신호가 반드시 필요한 경로에서만 사용. MAX_CYCLES close와 리스트 잡 등록에는 사용하지 않음 |
false |
429 |
RATE_LIMITED |
호출 한도 초과 | true |
500 |
INTERNAL_ERROR |
그 외 서버 오류 | true |
502 |
LLM_UNAVAILABLE |
LLM API 호출 실패 | true |
502 |
EMBEDDING_UNAVAILABLE |
임베딩 API 호출 실패 | true |
503 |
CATALOG_UNAVAILABLE |
카탈로그 DB 접근 실패 | true |
504 |
SUMMARY_TIMEOUT |
요약문 생성 타임아웃. 부분 실패 정책을 적용하지 못한 예외 경로 | false |
RECOMMENDATION_FAILED는 HTTP 오류 코드가 아니다. 리스트 생성 잡의 결과 상태이므로 GET /v1/recommendations/jobs/{jobId}의 200 응답 안에 status: "failed"와 함께 담긴다.
모델을 쓰는 API라 일반 서버와 다른 실패가 있다. 부분 실패를 전체 실패로 만들지 않는 것이 원칙이다.
| 상황 | 대응 |
|---|---|
| JSON 스키마 위반 | 최대 2회 재시도. 그래도 실패하면 그 턴은 빈 델타로 넘어간다 |
| 빈 응답 | 스키마 위반과 동일하게 처리 |
| 응답 지연 | 타임아웃 20초, 재시도 1회 |
| 턴 델타 파싱 실패 | 그 턴만 건너뛴다. 답변은 이미 전달됐고 같은 내용이 다음 턴에 다시 나온다 |
| 추천 이유 생성 실패 | 신호 나열 기본 문구로 대체한다. 추천 자체는 실패시키지 않는다 |
| 임베딩 실패 | 해당 항목만 건너뛰고 나머지로 검색한다. 전부 실패하면 EMBEDDING_UNAVAILABLE
|
오류가 아닌 것은 다음과 같다.
| 상황 | 처리 |
|---|---|
| 추천 결과 0건 |
200 + items: [] + emptyReason
|
| 대화 중 취향이 모이지 않음 |
done 이벤트의 canClose: false
|
| 택소노미 매핑 실패 |
taxonomyPath: null로 두고 로그에 쌓는다 |
순서는 위 기능 분류를 따른다. 각 엔드포인트 줄에 소속 기능을 함께 표기한다.
GET /health · 기능 1 AI 서버 상태 확인
Spring Backend, 배포 플랫폼 또는 모니터링 시스템이 FastAPI 프로세스와 필수 초기화 상태를 확인한다. 반복 호출에 비용과 지연이 생기지 않도록 LLM과 임베딩 API는 호출하지 않는다. 이 엔드포인트만 서비스 토큰을 요구하지 않는다.
요청 본문과 필수 파라미터가 없다.
| HTTP | 응답 | 의미 |
|---|---|---|
200 |
status: "ok" |
HTTP 요청을 처리할 수 있음 |
503 |
공통 오류 응답, code: CATALOG_UNAVAILABLE
|
필수 의존성 초기화가 완료되지 않음 |
{
"status": "ok",
"version": "1.0.0",
"dependencies": { "catalogDb": "ok" },
"catalog": { "totalProducts": 8420, "lastBatchAt": "2026-09-02T06:00:00Z" }
}| 필드 | 타입 | 설명 |
|---|---|---|
status |
string | 정상 상태에서는 ok
|
version |
string | 배포된 AI 서버 버전 |
dependencies |
object | 필수 의존성별 상태. 현재는 카탈로그 DB만 포함 |
catalog |
object | 카탈로그 규모와 마지막 배치 시각 |
dependencies와 catalog의 하위 타입은 다음과 같다.
| 경로 | 타입 | 설명 |
|---|---|---|
dependencies.catalogDb |
string | 정상 상태에서는 ok
|
catalog.totalProducts |
integer | 현재 검색 가능한 카탈로그 상품 수 |
catalog.lastBatchAt |
string(ISO8601) | 마지막 카탈로그 배치 완료 시각 |
POST /v1/chat/sessions · 기능 2 취향 대화
취향 수집 대화를 시작하고 첫 발화를 반환한다. 온보딩에서 받은 값은 취향 항목이 아니라 첫 질문의 시드로만 쓴다.
| 필드 | 타입 | 설명 |
|---|---|---|
userId |
string | 백엔드가 발급한 사용자 식별자. AI Server는 이 값을 검증하지 않는다 |
existingProfile |
TasteProfile 또는 null | 재대화면 기존 프로필을 주입한다. 첫 대화면 null
|
onboarding |
OnboardingInput 또는 null |
seedCategories, excludeCategories, constraints를 포함한다 |
OnboardingInput의 하위 필드는 모두 필수이며 값이 없으면 빈 배열을 보낸다.
| 필드 | 타입 | 설명 |
|---|---|---|
seedCategories |
array<string> | 첫 질문 선택에 사용할 관심 카테고리 시드 |
excludeCategories |
array<string> | 추천에서 제외할 카테고리 |
constraints |
array<string> | 알레르기·사이즈 등 불가 조건 |
{
"userId": "u_10293",
"existingProfile": null,
"onboarding": {
"seedCategories": ["아웃도어", "커피"],
"excludeCategories": ["주류"],
"constraints": ["견과류 알레르기"]
}
}201 Created
| 필드 | 타입 | 설명 |
|---|---|---|
sessionId |
string | 이후 턴 전송과 종료에 사용하는 세션 식별자 |
greeting |
string | 화면에 먼저 표시할 첫 발화 |
maxTurns |
integer | 호환성을 위해 유지하는 필드명. 사용자 입력 기준 최대 대화 사이클 수이며 현재 20 |
{
"sessionId": "s_7a2b",
"greeting": "안녕하세요! 요즘 어떻게 지내시는지 궁금해요.",
"maxTurns": 20
}| 항목 | 값 |
|---|---|
| 저장소 | Redis (key = sessionId) |
| 유휴 TTL | 30분 |
| 최대 수명 | 2시간 |
| 최대 대화 사이클 | 20개 = 사용자 20회 + AI 20회, 총 40개 발화 |
| 유지하는 원문 | 최대 20개 대화 사이클 전량 누적. 창을 잘라내지 않는다 |
| 정정 대화 | 세션당 1회. 별도 턴 예산을 사용한다 |
| 원문 삭제 시점 | 확인 화면에서 사용자가 프로필을 승인하고 리스트 잡을 등록한 시점. 미승인 세션은 TTL 삭제 |
| HTTP | code |
발생 조건 |
|---|---|---|
400 |
INVALID_REQUEST |
필수 필드 누락 또는 프로필 스키마 위반 |
401 |
UNAUTHORIZED |
서비스 토큰 없음 또는 불일치 |
429 |
RATE_LIMITED |
userId 기준 대화 호출 한도 초과 |
502 |
LLM_UNAVAILABLE |
첫 발화 생성을 위한 모델 호출 실패 |
POST /v1/chat/sessions/{sessionId}/messages · 기능 2 취향 대화
사용자의 한 턴을 전달하고 모델 응답을 토큰 단위로 스트리밍한다. 응답 본문은 text/event-stream이며, 백엔드는 이 스트림을 프론트엔드로 릴레이한다.
| 필드 | 타입 | 설명 |
|---|---|---|
message |
string, 1~500자 | 사용자가 전송을 확정한 발화 |
{ "message": "주말마다 캠핑 가요" }200 OK, Content-Type: text/event-stream
| 이벤트 | 페이로드 | 내용 |
|---|---|---|
token |
{"text": "..."} |
사용자에게 보일 텍스트 조각 |
done |
{"turn": 3, "maxTurns": 20, "canClose": false, "itemCount": 4} |
사이클 종료. turn은 처리한 사용자 입력 수이며 canClose로 종료 가능 여부 전달 |
error |
공통 오류 응답 | 스트림 도중 실패 |
이벤트 페이로드 타입은 다음과 같다.
| 이벤트 | 필드 | 타입 | 설명 |
|---|---|---|---|
token |
text |
string | 사용자에게 보일 텍스트 조각 |
done |
turn |
integer | 처리 완료한 사용자 입력 수 |
done |
maxTurns |
integer | 최대 대화 사이클 수. 현재 20 |
done |
canClose |
boolean | 현재 상태로 프로필 확정이 가능한지 |
done |
itemCount |
integer | 누적된 ProfileItem 수 |
done |
inputLocked |
boolean | 조건부. 최대 사이클 도달로 추가 입력이 잠겼는지 |
done |
completionReason |
string | 조건부. SUFFICIENT · USER_EXIT · MAX_CYCLES
|
done |
profileCompleteness |
string | 조건부. sufficient 또는 partial
|
done |
lastTurnExtractionFailed |
boolean | 조건부. 마지막 턴 추출이 실패했는지 |
error |
전체 페이로드 | ErrorResponse | 공통 오류 응답 객체 |
event: token
data: {"text":"캠핑 좋죠. "}
event: token
data: {"text":"주로 어디로 다니세요?"}
event: done
data: {"turn":3,"maxTurns":20,"canClose":false,"itemCount":4}
대화 사이클 하나는 모델 호출 두 번으로 처리된다. 스트림에 실리는 것은 앞의 하나뿐이다.
| 호출 | 출력 | 사용자 대기 |
|---|---|---|
| 응답 모델 | 사용자에게 보일 텍스트 (스트리밍) | 있음 |
| 추출 모델 | 델타 JSON (구조화 출력) | 없음 |
추출 호출의 입력은 대화 전체가 아니라 세 조각이다.
[지금까지 파악한 것] ← [STATE] 블록 (items ≤ 12 · axes ≤ 3)
[직전 AI 발화]
[이번 사용자 발화]
출력은 response_format: json_schema(strict: true)로 형식을 강제한다.
{
"items": [
{ "value": "핸드드립", "field": "hobbies", "confidence": 0.85,
"evidence": "가서 커피 직접 내려 마시는 게 제일 좋아요",
"intent": "none", "defer": "none", "urgency": "none" }
],
"axes": [],
"drop": []
}델타 최상위 타입은 다음과 같다.
| 키 | 타입 | 규칙 |
|---|---|---|
items |
array<ExtractionItem> | 이번 턴에서 새로 파악하거나 갱신한 항목. 최대 3개 |
axes |
array<string> | 사용자가 스스로 밝힌 판단 기준 |
drop |
array<DropInstruction> | 사용자가 정정해 무효 또는 약화한 기존 항목 |
ExtractionItem 타입은 다음과 같다.
| 필드 | 타입 | 규칙 |
|---|---|---|
value |
string | 사용자 어휘 그대로. 물건 이름을 쪼개지 않는다 |
field |
string |
TasteProfile의 10개 배열 중 하나 |
confidence |
number | 0.0~1.0. 분명히 말했으면 0.8 이상 |
evidence |
string | 발화에서 잘라낸 조각. 최대 40자 |
intent |
string |
need · want · both · none
|
defer |
string |
price · justification · timing · none
|
urgency |
string |
soon · later · none
|
DropInstruction 타입은 다음과 같다.
| 필드 | 타입 | 규칙 |
|---|---|---|
value |
string | 정정할 기존 항목 값 |
field |
string | 해당 항목이 속한 TasteProfile 배열 이름 |
action |
string |
remove 또는 lower. lower는 항목을 남기고 확신도만 내린다 |
현재 프로토타입 chat/schema.py의 추출 스키마는 drop을 array<string>으로 구현했지만, 문자열만으로는 remove와 lower를 구분할 수 없다. 이 문서는 array<DropInstruction>을 최종 계약으로 정하며 구현 단계에서 프로토타입을 맞춰야 한다.
drop의 lower는 정정 대화에서 주로 쓴다. 사용자가 "틀렸다"고 말한 것과 "그 정도는 아니다"라고 말한 것을 같게 처리하면 항목이 과하게 지워진다. 확신도 조정 폭은 피드백 수집의 confidenceChange와 같은 값을 쓴다.
drop으로 처리된 항목은 items 상한 12개를 넘어 방출할 때 우선 대상이 된다. 사용자가 방금 아니라고 말한 항목보다 오래된 항목이 먼저 밀려나면 정정이 반영되지 않는다.
한 대화 사이클에 items 3개를 넘기지 않는다. 델타는 서버가 병합·정정한 뒤 Redis에 반영하고 원본은 버린다. 추출 호출이 실패하면 그 사이클은 빈 델타로 넘어가고 대화는 끊기지 않는다. 델타는 SSE에 실리지 않으므로 프론트엔드는 파싱 책임을 지지 않는다.
20번째 입력은 받지 않고 끊는 것이 아니다. 응답 스트림과 추출·병합을 모두 완료한 뒤 다음 done 이벤트를 보낸다.
event: done
data: {"turn":20,"maxTurns":20,"canClose":true,"inputLocked":true,
"completionReason":"MAX_CYCLES","profileCompleteness":"partial",
"lastTurnExtractionFailed":false}
- 프론트엔드는 입력창을 잠그고
분석이 완료됐어요를 표시한 뒤/close를 호출한다. -
profileCompleteness=partial은 수집 스키마가 덜 채워졌다는 뜻이지 처리 실패가 아니다. UI에서 “모든 취향을 충분히 파악했다”고 표현하지 않는다. - 마지막 추출이 실패하면 기존 누적 State로 확정하고
lastTurnExtractionFailed=true를 남긴다. - 20번째 처리 후의 21번째 메시지는
409 SESSION_CLOSED로 거절한다. - 최대 사이클 도달에 의한
/close는 신호 부족을 이유로422를 반환하지 않고 현재까지의 값으로 프로필을 만든다.
| HTTP | code |
발생 조건 |
|---|---|---|
400 |
INVALID_REQUEST |
메시지 길이 위반 |
404 |
SESSION_NOT_FOUND |
세션 없음 또는 TTL 만료 |
409 |
SESSION_CLOSED |
이미 종료된 세션에 전송 |
429 |
RATE_LIMITED |
대화 60회/분 초과 |
502 |
LLM_UNAVAILABLE |
모델 호출 실패. 스트림이 시작된 뒤라면 error 이벤트로 전달 |
DELETE /v1/chat/sessions/{sessionId} · 기능 2 취향 대화
사용자가 대화 중 이탈하거나 취소했을 때 호출한다. 추출을 실행하지 않고 세션 원문과 상태를 즉시 삭제한다.
| HTTP | 응답 | 의미 |
|---|---|---|
204 |
본문 없음 | 삭제 완료 |
404 |
공통 오류 응답, code: SESSION_NOT_FOUND
|
세션 없음 또는 이미 만료 |
POST /v1/chat/sessions/{sessionId}/close · 기능 3 프로필 확정
대화를 종료하고 세션 상태를 취향 프로필로 확정한다. 요약문과 표시 키워드를 함께 생성해 동기로 반환한다. 이 시점에 세션 원문을 삭제하지 않는다. 확인 화면에서 정정 대화로 돌아갈 수 있기 때문이다.
요청 본문이 없다.
200 OK
| 필드 | 타입 | 설명 |
|---|---|---|
profile |
TasteProfile | 확정된 취향 프로필 |
summary |
string 또는 null | 친구에게 보이는 요약문. 생성에 실패하면 null
|
keywords |
ProfileKeywords | 확인 화면에 표시할 대표 키워드 |
correctionAvailable |
boolean | 정정 대화를 아직 쓸 수 있는지 여부 |
completionReason |
string |
SUFFICIENT, USER_EXIT, MAX_CYCLES 중 종료 사유 |
profileCompleteness |
string |
sufficient 또는 partial
|
missingSignals |
array<string> | 선택. 부족한 신호 종류이며 빈 배열일 수 있음 |
{
"profile": { "...": "TasteProfile" },
"summary": "캠핑을 다니면서 직접 커피를 내려 마시는 걸 즐깁니다. 가벼운 장비를 선호하고, 향이 강한 제품은 좋아하지 않습니다.",
"keywords": {
"taste": ["핸드드립", "가벼운 장비"],
"interest": ["캠핑", "티타늄 머그컵"]
},
"correctionAvailable": true,
"completionReason": "MAX_CYCLES",
"profileCompleteness": "partial",
"missingSignals": ["dislikes"]
}요약문 생성이 실패해도 200을 반환하고 summary를 null로 둔다. 프로필과 키워드는 모델을 호출하지 않는 서버 규칙의 산출물이므로 함께 실패하지 않는다.
correctionAvailable이 false이면 이미 정정 대화를 한 번 사용한 세션이다. 프론트엔드는 이 값으로 정정 버튼의 노출 여부를 정한다.
| HTTP | code |
발생 조건 |
|---|---|---|
404 |
SESSION_NOT_FOUND |
세션 없음 또는 만료 |
409 |
SESSION_CLOSED |
이미 확정 요청이 처리된 세션 |
422 |
PROFILE_TOO_SPARSE |
20개 대화 사이클 전 사용자가 수동 종료를 요청했고 프로필 항목이 0개인 경우만 사용. MAX_CYCLES 종료에는 반환하지 않음 |
504 |
SUMMARY_TIMEOUT |
요약문 생성이 타임아웃. 재시도 대상이 아니며 200 + summary: null로 처리하는 것이 기본이다 |
POST /v1/recommendations/jobs · 기능 4 리스트 생성
사용자가 확인 화면에서 프로필을 승인하면 백엔드가 호출한다. self·gift 두 리스트 생성을 하나의 잡으로 등록하고, 같은 시점에 세션 원문 삭제를 병렬로 실행한다. 삭제 완료를 기다리지 않고 202를 반환한다.
| 필드 | 타입 | 설명 |
|---|---|---|
userId |
string | 백엔드가 발급한 사용자 식별자 |
sessionId |
string 또는 null | 확정 대상 세션. 원문 삭제에 사용한다. 이미 만료된 세션이면 null
|
profile |
TasteProfile | 사용자가 승인한 프로필 |
feedbackSummary |
FeedbackSummary 또는 null | 선택. 누적된 피드백 요약 |
{
"userId": "u_10293",
"sessionId": "s_7a2b",
"profile": { "...": "TasteProfile" },
"feedbackSummary": null
}202 Accepted
| 필드 | 타입 | 설명 |
|---|---|---|
jobId |
string | 결과 조회에 사용하는 잡 식별자 |
status |
string | 등록 직후에는 pending
|
pollAfterMs |
integer | 다음 폴링까지 대기할 시간 |
estimatedMs |
integer 또는 null | 예상 총 소요 시간. 아직 측정값이 없으면 null
|
{ "jobId": "j_9c4d", "status": "pending", "pollAfterMs": 1000, "estimatedMs": null }원문 삭제가 실패해도 잡 등록은 진행한다. 삭제 실패는 로그로 남기며, 세션 TTL이 최종 삭제를 보장한다.
| HTTP | code |
발생 조건 |
|---|---|---|
400 |
INVALID_REQUEST |
프로필 스키마 위반 |
검색용 항목이 없어도 불완전 프로필을 422로 되돌리지 않고 잡을 등록한다. worker는 빈 추천 결과와 emptyReason: NO_PROFILE_SIGNAL로 정상 완료한다.
GET /v1/recommendations/jobs/{jobId} · 기능 4 리스트 생성
잡의 진행 상태와 결과를 조회한다. 완료 응답에 self·gift 두 리스트가 함께 담긴다. 결과는 완료 후 10분간 보관하고 지운다.
요청 본문과 쿼리 파라미터가 없다.
200 OK. status에 따라 본문이 달라진다.
status |
함께 반환하는 필드 |
|---|---|
pending |
pollAfterMs |
running |
progress, pollAfterMs
|
succeeded |
lists |
failed |
error |
공통 필드와 상태별 필드 타입은 다음과 같다.
| 필드 | 타입 | 반환 조건 | 설명 |
|---|---|---|---|
jobId |
string | 항상 | 조회한 잡 식별자 |
status |
string | 항상 |
pending · running · succeeded · failed
|
pollAfterMs |
integer |
pending, running
|
다음 폴링까지 대기할 시간 |
progress |
number | running |
0.0~1.0 진행률 |
lists |
RecommendationLists | succeeded |
self와 gift 추천 결과 |
error |
JobError | failed |
잡 처리 실패 정보 |
RecommendationLists는 키가 고정된 객체다.
| 필드 | 타입 | 설명 |
|---|---|---|
self |
RecommendationResult | 본인용 추천 공통 응답 |
gift |
RecommendationResult | 친구용 추천 공통 응답 |
JobError 타입은 다음과 같다.
| 필드 | 타입 | 설명 |
|---|---|---|
code |
string | 현재 RECOMMENDATION_FAILED
|
message |
string | 실패 설명 |
retryable |
boolean | 같은 프로필로 잡을 다시 등록할 수 있는지 |
{
"jobId": "j_9c4d",
"status": "succeeded",
"lists": {
"self": { "...": "추천 공통 응답 (mode: self)" },
"gift": { "...": "추천 공통 응답 (mode: gift)" }
}
}{ "jobId": "j_9c4d", "status": "running", "progress": 0.6, "pollAfterMs": 1000 }{
"jobId": "j_9c4d",
"status": "failed",
"error": { "code": "RECOMMENDATION_FAILED", "message": "추천 리스트 생성에 실패했습니다", "retryable": true }
}잡 자체의 실패는 HTTP 오류가 아니라 200 응답의 status: "failed"로 전달한다. 폴링 경로에서 조회 실패와 잡 실패를 구분하기 위해서다. retryable이 참이면 백엔드가 같은 프로필로 POST /v1/recommendations/jobs를 다시 호출할 수 있다. 프로필은 이미 백엔드에 저장되어 있으므로 대화를 다시 하지 않아도 된다.
프로필은 이 응답에 담기지 않는다. 확정 시점에 백엔드가 이미 저장했기 때문이다.
| HTTP | code |
발생 조건 |
|---|---|---|
404 |
JOB_NOT_FOUND |
잡 ID 없음 또는 보관 기간 10분 경과 |
POST /v1/recommendations · 기능 4 추천 리스트 재생성
저장된 프로필로 한 모드의 리스트를 다시 만든다. 화면 진입 시 호출하는 엔드포인트가 아니다.
| 필드 | 타입 | 설명 |
|---|---|---|
userId |
string | 레이트 리밋과 로그 기준 |
mode |
string |
self(NU-03) 또는 gift(NU-06·07) |
profile |
TasteProfile | 백엔드가 저장한 프로필 전체 |
excludeCategories |
array<string> | 선택. 사용자가 설정한 제외 카테고리 |
excludeProductIds |
array<string> | 선택. 피드백으로 누적된 제외 상품 |
feedbackSummary |
FeedbackSummary | 선택. 누적된 피드백 요약 |
limit |
integer | 선택. 기본 20. 화면 노출 개수가 아니라 저장할 개수 |
FeedbackSummary가 포함되면 아래 다섯 배열을 모두 보내며 값이 없으면 빈 배열을 사용한다.
| 필드 | 타입 | 설명 |
|---|---|---|
likedProductIds |
array<string> | 좋아요를 받은 상품 ID |
dislikedProductIds |
array<string> | 싫어요를 받은 상품 ID |
alreadyHaveProductIds |
array<string> | 이미 보유한 상품 ID |
hiddenProductIds |
array<string> | 숨김 처리한 상품 ID |
dislikedSignals |
array<string> | 부정 피드백이 누적된 취향 항목 값 |
{
"userId": "u_10293",
"mode": "gift",
"profile": { "...": "TasteProfile 전체" },
"excludeCategories": ["주류"],
"excludeProductIds": ["p_881"],
"feedbackSummary": {
"likedProductIds": ["p_402"],
"dislikedProductIds": ["p_119"],
"alreadyHaveProductIds": ["p_555"],
"hiddenProductIds": [],
"dislikedSignals": ["캠핑 조명"]
},
"limit": 20
}200 OK와 추천 공통 응답을 반환한다. items가 빈 배열이어도 성공 응답이며 emptyReason으로 원인을 구분한다.
| HTTP | code |
발생 조건 |
|---|---|---|
400 |
INVALID_REQUEST |
mode 값 위반, 프로필 스키마 위반 |
422 |
PROFILE_TOO_SPARSE |
query 역할 항목이 없어 검색을 시작할 수 없음 |
429 |
RATE_LIMITED |
추천 10회/분 초과 |
502 |
EMBEDDING_UNAVAILABLE |
취향 항목 임베딩 전량 실패 |
503 |
CATALOG_UNAVAILABLE |
카탈로그 DB 접근 실패 |
LLM_UNAVAILABLE은 이 엔드포인트에서 발생하지 않는다. 추천 이유 생성이 실패하면 기본 문구로 대체하고 추천은 그대로 반환한다.
POST /v1/recommendations/{recommendationId}/feedback · 기능 5 피드백 수집
사용자가 추천 상품에 남긴 반응을 받아, 백엔드가 프로필과 제외 목록에 반영할 변경을 알려준다.
| 필드 | 타입 | 설명 |
|---|---|---|
productId |
string | 반응 대상 상품 |
action |
string |
like · dislike · already_have · hidden · purchased · gift_satisfied · gift_unsatisfied
|
matchedSignals |
array<FeedbackMatchedSignal> | 선택. 귀인 대상을 좁히기 위한 신호. 생략하면 상품 단위로만 반영 |
FeedbackMatchedSignal은 추천 응답의 MatchedSignal 전체가 아니라 귀인에 필요한 최소 필드만 받는다.
| 필드 | 타입 | 설명 |
|---|---|---|
field |
string |
TasteProfile의 10개 취향 배열 이름 중 하나 |
value |
string | 귀인 대상인 원본 취향 값 |
tasteNodeId |
string 또는 null | 매핑된 취향 노드 식별자 |
{
"productId": "p_1234",
"action": "already_have",
"matchedSignals": [
{ "field": "interests", "value": "캠핑장에서 핸드드립", "tasteNodeId": "outdoor.camping.camping_coffee" }
]
}| 필드 | 타입 | 설명 |
|---|---|---|
accepted |
boolean | 피드백 수용 여부 |
appliedToRanking |
boolean | 다음 리스트 생성의 순위 보정에 반영되는지 |
effects |
array<FeedbackEffect> | 백엔드가 프로필과 제외 목록에 적용할 변경 |
FeedbackEffect는 target에 따라 필드가 달라지는 tagged union이다.
| 필드 | 타입 | 반환 조건 | 설명 |
|---|---|---|---|
target |
string | 항상 |
product 또는 profile_item
|
productId |
string | target=product |
변경할 상품 ID |
value |
string | target=profile_item |
변경할 취향 항목 값 |
change |
string | 항상 | 적용할 변경 명령 |
confidenceChange |
number |
target=profile_item에서 필요할 때 |
확신도 증감량 |
현재 확정된 change 값은 exclude와 move_to_owned뿐이다. 반복 dislike처럼 확신도를 낮추는 경우의 change 열거형과 confidenceChange 조정 폭은 아직 미확정이다.
{
"accepted": true,
"appliedToRanking": true,
"effects": [
{ "target": "product", "productId": "p_1234", "change": "exclude" },
{ "target": "profile_item", "value": "캠핑장에서 핸드드립", "change": "move_to_owned", "confidenceChange": 0.0 }
]
}| 피드백 | 귀인 대상 | 반영 |
|---|---|---|
특정 상품에만 dislike
|
상품 |
excludeProductIds 누적 |
already_have |
상품 + 취향 확인 | 상품 제외, 항목을 owned로 이동. confidence는 낮추지 않는다 |
같은 항목에 dislike 반복 |
취향 항목 |
confidence 하향 |
같은 카테고리에 dislike 반복 |
취향 노드-카테고리 매핑 | 매핑 가중치 하향 |
gift_satisfied |
조합 전체 | 같은 노드의 유사 상품 가점 |
| HTTP | code |
발생 조건 |
|---|---|---|
400 |
INVALID_REQUEST |
action 값이 열거형에 없음 |
404 |
RECOMMENDATION_NOT_FOUND |
recommendationId에 해당하는 리스트 없음 |
GET /v1/catalog/coverage · 기능 6 카탈로그 운영
내부 운영용 엔드포인트다. 취향 노드 대비 상품이 부족한 구간을 알려준다.
| 필드 | 타입 | 설명 |
|---|---|---|
generatedAt |
string(ISO8601) | 집계 시각 |
totalProducts |
integer | 카탈로그 상품 수 |
totalNodes |
integer | 취향 노드 수 |
nodeCoverage |
number | 상품이 충분한 노드의 비율 |
gaps |
array<CoverageGap> | 보강이 필요한 노드 |
CoverageGap 타입은 다음과 같다.
| 필드 | 타입 | 설명 |
|---|---|---|
nodeId |
string | 취향 노드 식별자 |
productCount |
integer | 노드에 연결된 상품 수 |
bandsCovered |
integer | 상품이 존재하는 가격대 구간 수 |
priority |
string | 수집 우선순위. 예시에 high, medium이 있으나 전체 허용값은 아직 미확정 |
{
"generatedAt": "2026-09-02T06:00:00Z",
"totalProducts": 8420,
"totalNodes": 312,
"nodeCoverage": 0.71,
"gaps": [
{ "nodeId": "hobby.craft.leather", "productCount": 8, "bandsCovered": 1, "priority": "high" },
{ "nodeId": "pet.cat.toy", "productCount": 21, "bandsCovered": 2, "priority": "medium" }
]
}상품 개수만 세면 이미 많은 카테고리를 계속 더 수집하게 된다. gaps는 노드별 가격대 분포(bandsCovered)를 함께 보고 다음 수집 대상을 정한다.
아직 백엔드와 연결하지 않아 실제 호출 테스트는 진행하지 않았다. 아래 응답은 스키마 확인을 위해 작성한 예시이며 실행 결과가 아니다.
# 1) 세션 생성
curl -X POST https://ai.needu.internal/v1/chat/sessions \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId":"u_10293","existingProfile":null,
"onboarding":{"seedCategories":["아웃도어"],"excludeCategories":[],"constraints":[]}}'
# → 201 { "sessionId": "s_7a2b", "greeting": "...", "maxTurns": 20 }
# 2) 턴 전송 (SSE)
curl -N -X POST https://ai.needu.internal/v1/chat/sessions/s_7a2b/messages \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":"주말마다 캠핑 가요"}'
# → event: token / event: done
# 3) 종료 및 프로필 확정
curl -X POST https://ai.needu.internal/v1/chat/sessions/s_7a2b/close \
-H "Authorization: Bearer $SERVICE_TOKEN"
# → 200 { "profile": {...}, "summary": "...", "keywords": {...}, "correctionAvailable": true }
# 4) 사용자 승인 후 리스트 생성 등록
curl -X POST https://ai.needu.internal/v1/recommendations/jobs \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId":"u_10293","sessionId":"s_7a2b","profile":{...}}'
# → 202 { "jobId": "j_9c4d", "status": "pending", "pollAfterMs": 1000 }
# 5) 리스트 생성 결과 폴링
curl https://ai.needu.internal/v1/recommendations/jobs/j_9c4d \
-H "Authorization: Bearer $SERVICE_TOKEN"
# → 200 { "status": "succeeded", "lists": {...} }
# 6) 리스트 재생성
curl -X POST https://ai.needu.internal/v1/recommendations \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d @request.json
# → 200 { "recommendationId": "r_5f6g", "items": [...], "funnel": {...} }
# 6) 피드백
curl -X POST https://ai.needu.internal/v1/recommendations/r_5f6g/feedback \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"productId":"p_1234","action":"already_have"}'프론트엔드에서 AI 서버로 가는 경로가 없으므로 AI 서버는 백엔드 서버와만 연결된다.
| 계층 | 방식 |
|---|---|
| 네트워크 | 프라이빗 서브넷. 외부 인바운드 차단 |
| 인증 |
Authorization: Bearer {SERVICE_TOKEN} 공유 토큰 |
| 사용자 식별 | 요청 본문의 userId. AI 서버는 사용자 인증을 하지 않는다 |
| 레이트 리밋 |
userId 기준. 대화 60회/분, 추천 10회/분 |
| 시크릿 | LLM·임베딩 API 키, 서비스 토큰, DB 접속 정보는 시크릿 매니저에서 주입한다 |
| 로그 | 요청 본문 전체를 남기지 않는다. 대화 원문이 로그로 새면 원문 미저장 원칙이 무의미해진다 |
클라우드 담당에게 필요한 것은 다음과 같다.
- AI 서버용 프라이빗 서브넷과 백엔드로부터의 인바운드 규칙
- PostgreSQL(pgvector 확장 포함) 인스턴스와 Redis
- 카탈로그 배치용 스케줄러 (수집·속성추출·임베딩)
- 시크릿 매니저
- SSE를 지원하는 로드밸런서 설정 (응답 버퍼링 비활성화)
| 항목 | 확정 시점 |
|---|---|
TasteProfile.summary와 close.summary 중 최종 단일 위치 |
백엔드 DTO 확정 시. 현재는 같은 값을 두 위치에 모두 반환 |
ErrorResponse.details의 오류 코드별 하위 스키마 |
각 오류의 제한값·검증 정보가 정해질 때 |
결과 없음 suggestion의 전체 열거형 |
결과 없음 화면 행동 정의 시. 현재 예시는 MORE_CONVERSATION뿐 |
FeedbackEffect.change의 전체 열거형 |
피드백 반영 명령 설계 시. 현재 exclude, move_to_owned만 확정 |
반복 dislike의 confidenceChange 조정 폭 |
피드백 누적 후 |
CoverageGap.priority의 전체 열거형 |
카탈로그 수집 우선순위 정책 확정 시. 현재 예시는 high, medium
|
| LLM·임베딩 모델 선정 | 단계 2 추론 성능 최적화 |
| 취향 택소노미 사전 작성 범위 | 카탈로그 초기 수집과 함께 |
| 카탈로그 초기 수집 규모와 신선도 주기 | 카탈로그 배치 설계 |
| 최소 점수 컷 임계값 | 실측 데이터 확보 후 튜닝 |
already_have의 confidence 조정 폭 |
피드백 누적 후 |
| 정정 대화 턴 예산 | 정정 대화 프롬프트 작성과 함께 |
CORRECT 목표의 지시문 구성 |
프롬프트 작성 단계 |
| 확인 화면 이탈 시 처리 | 기획 협의. 프로필만 있고 리스트가 없는 상태를 자동 확정할지, 메인에서 확인을 유도할지 |
| 확인 대기 중 세션 TTL 연장 여부 | 백엔드 협의. 유휴 30분이 지나면 정정 대화가 불가능해진다 |
| 리스트 생성 실패 시 화면 처리 | 기획 협의 |
두 구간의 estimatedMs와 progress 배분 |
단계별 소요 시간 측정 후 |
이 문서는 계약만 정한다. 계약을 구현하는 방법은 다음 단계에서 하나씩 정한다.
| 넘기는 것 | 왜 여기서 정하지 않는가 | 어디서 |
|---|---|---|
| 모델 선정, 자체 GPU 서빙, 지연·처리량 최적화 | 지금은 상용 LLM API 전제다. 서빙 방식이 정해져야 잴 수 있다 | 2단계 |
| AI 서버 내부의 모듈·배포 단위, 데이터 소유 근거 | 계약이 고정돼야 내부를 자유롭게 나눌 수 있다 | 3단계 |
| 리스트 생성 잡 안쪽의 단계 구성 | 계약은 확정 요청 → 잡 조회까지만 본다 | 4단계 |
| 검색 문장 재작성, 임베딩, 벡터 검색, 카탈로그 수집 | 컨텍스트 보강 설계에 속한다 | 5단계 |
| 인스턴스 수, 오토스케일링, 모니터링 | 배포 환경 설계에 속한다 | 7단계 |
계약을 먼저 정한 이유는, 위 다섯 가지가 전부 바뀌어도 백엔드가 부르는 엔드포인트와 주고받는 스키마가 그대로이기 때문이다.