모델 API 설계 - 100-hours-a-week/KTB4-4th-wiki GitHub Wiki

모델 API 설계

니쥬는 AI와의 대화로 취향을 분석한다. 그 결과로 두 종류의 상품 리스트를 만든다. 리스트를 둘로 나누는 것이 이 API의 전제다.

리스트 보는 사람 화면
self — 내게 필요한 것 본인 메인 (NU-03)
gift — 받고 싶어할 것 친구 친구 상세·선물 추천 (NU-06·07)

기능별 처리 흐름

1. AI 서버 상태 확인 — 시퀀스 다이어그램과 설계 이유

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
Loading

설계 이유

  • 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
Loading

내부 처리 순서

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
Loading

내부 처리 순서

앞 구간은 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: 리스트 교체 저장
Loading

내부 처리 순서

프로필 수신 및 검증
→ 조건에 맞는 상품 후보 조회 (제외 목록 · constraints 반영)
→ 모드별 순위 계산과 선별
→ 추천 이유 생성
→ 응답 검증 및 정제

후보를 어떻게 찾고 어떤 식으로 순위를 매기는지는 이 문서에서 정하지 않는다. 계약이 정하는 것은 요청에 무엇을 넣고 응답에서 무엇을 받는지까지다.

설계 이유

  • 리스트를 미리 만들어 백엔드가 저장한다. 친구 목록에서 한 명을 열 때마다 추천 이유 생성을 기다리지 않는다.
  • limit 기본값은 화면 노출 개수가 아니라 저장할 개수다. NU-07 예산 조절바는 저장된 목록 위에서 도는 클라이언트 필터다. 적게 저장하면 예산을 좁혔을 때 결과가 0이 된다. 한 모드당 20개다. 한 번의 취향 추출에서 나오는 상품은 self와 gift를 합쳐 최대 40개다. 두 리스트는 같은 후보 풀에서 뽑으므로 상품이 겹칠 수 있다. 실제 고유 상품 수는 40개보다 적다.
  • 요청에 예산, 관계, 상황 입력이 없다. 리스트는 프로필에만 의존한다. 친구 관계를 표현하는 스키마가 없다. 그래서 모든 친구가 같은 요약 프로필과 같은 gift 리스트를 본다.
  • 최소 점수 컷을 둔다. 벡터 검색은 맞는 상품이 없어도 가장 가까운 것을 반환한다. 기준이 없으면 관련 없는 상품이 목록에 들어간다.
  • 결과 0건은 장애가 아니므로 200과 빈 items를 반환하고 emptyReason으로 원인을 구분한다.
  • funnel을 함께 반환한다. 결과가 이상할 때 어느 단계에서 후보가 줄었는지 확인한다.

리스트를 다시 만드는 시점

트리거 이유
새 대화로 취향/관심사가 업데이트 되어 프로필이 변경될때 필수. 보통은 추출 응답이 두 리스트를 함께 돌려주므로 별도 호출이 불필요하다
카탈로그 주간 배치 신상품 유입 반영
피드백 누적 순위 보정값이 바뀜
TTL 7일 경과 카탈로그 스냅샷 노후화
5. 피드백 수집 — 시퀀스 다이어그램과 설계 이유

피드백 수집 - v3이후 고도화 내용

사용자가 추천 상품에 남긴 반응을 받아, 백엔드가 프로필과 제외 목록에 무엇을 반영해야 하는지 알려준다.

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로 반영
Loading

설계 이유

  • 프로필 저장 주체는 백엔드이므로 AI 서버는 값을 직접 바꾸지 않고 무엇을 바꿔야 하는지만 effects로 알려준다.
  • 같은 dislike라도 귀인 대상이 다르다. 특정 상품 한 건인지, 취향 항목 자체인지, 취향 노드와 카테고리의 매핑 문제인지를 구분한다. 구분해야 다음 추천이 개선된다.
  • already_have는 취향이 틀렸다는 신호가 아니라 맞았다는 신호다. 상품은 제외하되 confidence는 낮추지 않는다.
  • 피드백은 즉시 리스트를 다시 만들지 않는다. 누적된 뒤 재생성 트리거에서 한 번에 반영한다.
6. 카탈로그 운영 — 설계 이유

카탈로그 커버리지

운영용 엔드포인트다. 취향 대비 상품이 부족한 구간을 반환한다. 화면에서 호출하지 않으므로 계약이 단순하다.

무엇을 커버리지로 셀지, 부족한 구간을 어떻게 채울지는 이 문서에서 정하지 않는다. 지식 소스 설계에 속하는 문제다.

API 계약 한눈에 보기

기능 분류

엔드포인트 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 이유
동기 /health
POST /v1/chat/sessions
DELETE /v1/chat/sessions/{sessionId}
POST /v1/recommendations/{recommendationId}/feedback
GET /v1/catalog/coverage
응답이 짧고 지연 편차가 작다
동기 · 모델 호출 1회 POST /v1/chat/sessions/{sessionId}/close 규칙 계산 두 번과 요약문 생성 한 번이다. 타임아웃을 짧게 두고 요약문 실패는 부분 실패로 처리한다
동기 · 무거움 POST /v1/recommendations 벡터 검색과 이유 생성이 들어가 수 초가 걸린다. 사용자 대기 경로가 아니라 유지한다
스트리밍 (SSE) POST /v1/chat/sessions/{sessionId}/messages 응답이 길어 전체 생성을 기다리면 화면이 멈춘 것처럼 보인다
비동기 (202 + 폴링) POST /v1/recommendations/jobs
GET /v1/recommendations/jobs/{jobId}
검색과 이유 생성이 이어서 돌아 지연 편차가 크다

상태를 가지는 API

구분 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다.


핵심 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 추천 리스트 식별자

TasteProfile 객체

취향 프로필은 백엔드가 저장하고 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 알레르기·사이즈 등 불가 조건 배제

ProfileItem 객체

필드 타입 설명
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를 카테고리로 정규화하지 않는다. "캠핑장에서 핸드드립"을 "아웃도어 > 캠핑"으로 바꾸면, 캠핑을 실제로 하는 사람과 캠핑 영상만 보는 사람이 같은 값이 된다. 구체성이 떨어지면 추천 정확도가 떨어진다.

ProfileKeywords 객체

확인 화면(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 객체 (추천 공통 응답)

추출 결과 조회와 추천 리스트 재생성은 동일한 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 단계별 잔존 후보 수

PriceRange 객체

필드 타입 설명
min integer 목록의 최저 가격(원)
max integer 목록의 최고 가격(원)
currency string 현재 KRW 고정

RecommendedItem 객체

필드 타입 설명
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 스코어 인자와 랭커 버전

MatchedSignal 객체

필드 타입 설명
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 최종 점수 기여도

RankingBreakdown 객체

필드 타입 설명
mode string self 또는 gift
factors object<string, number> 스코어 인자 이름과 값. self는 needSignal·practicality·cycleFit, gift는 wantSignal·deferralWeight·giftability를 사용
rankerVersion string 랭커 규칙·가중치 버전

RecommendationFunnel 객체

필드 타입 설명
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로 두고 로그에 쌓는다

API별 요청·응답 계약

순서는 위 기능 분류를 따른다. 각 엔드포인트 줄에 소속 기능을 함께 표기한다.

1. 헬스 체크

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) 마지막 카탈로그 배치 완료 시각

2. 대화 세션 생성

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 첫 발화 생성을 위한 모델 호출 실패

3. 턴 전송

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번째 사용자 입력의 강제 종료

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 이벤트로 전달

4. 세션 폐기

DELETE /v1/chat/sessions/{sessionId} · 기능 2 취향 대화

사용자가 대화 중 이탈하거나 취소했을 때 호출한다. 추출을 실행하지 않고 세션 원문과 상태를 즉시 삭제한다.

응답

HTTP 응답 의미
204 본문 없음 삭제 완료
404 공통 오류 응답, code: SESSION_NOT_FOUND 세션 없음 또는 이미 만료

5. 세션 종료 및 프로필 확정

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로 처리하는 것이 기본이다

6. 프로필 확정 승인 및 리스트 생성 등록

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로 정상 완료한다.

6-1. 리스트 생성 결과 조회

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분 경과

7. 추천 리스트 재생성

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은 이 엔드포인트에서 발생하지 않는다. 추천 이유 생성이 실패하면 기본 문구로 대체하고 추천은 그대로 반환한다.

8. 피드백 수집

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에 해당하는 리스트 없음

9. 카탈로그 커버리지

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단계

계약을 먼저 정한 이유는, 위 다섯 가지가 전부 바뀌어도 백엔드가 부르는 엔드포인트와 주고받는 스키마가 그대로이기 때문이다.


⚠️ **GitHub.com Fallback** ⚠️