서비스 아키텍처 모듈화 - 100-hours-a-week/KTB4-4th-wiki GitHub Wiki

서비스 아키텍처 모듈화

1단계에서 백엔드와의 계약을 정하고, 2단계에서 그 계약을 자체 GPU 서버에서 어떻게 서빙할지를 정했다. 3단계는 그것을 어떤 단위로 묶어 담을지를 정한다.

앞 단계가 이 문서에 숙제를 남겼다.

앞 단계에서 넘어온 것 이 문서에서 답하는 곳
O4 세션 고정 라우팅을 실제로 성립시킬 배포 구조 경계 결정 2
모델 서버와 앱의 배포 주기가 다르다 경계 결정 4
O3 리스트 생성 잡을 어디서 돌릴 것인가 경계 결정 2
1단계가 미룬 데이터 소유 근거 경계 결정 1

인스턴스 수와 오토스케일링, 모니터링은 여기서 정하지 않는다. 배포 단위를 어떻게 나눌지가 먼저이고, 그 단위를 몇 개씩 어떻게 띄울지는 7단계(인프라 확장성과 모니터링)의 일이다. 이 문서는 나눌 때 지켜야 할 고려사항까지만 쓴다.

과제가 상정하는 출발점은 "단일 애플리케이션에 모델 추론 코드가 섞여 있다"이다. 니쥬는 그 상태가 아니다. 백엔드가 Java/Spring이고 AI가 Python이라 프로세스 분리는 선택지가 아니라 전제였고, 그 경계는 1단계에서 REST 계약으로 이미 고정됐다.

그래서 이 문서는 이미 갈라진 것을 다시 가르지 않고 세 가지를 한다.

① 기존 경계(백엔드 ↔ AI 서버)가 왜 거기인지를 책임과 데이터 소유권으로 확정한다
② AI 서버 안에서 실제로 갈라야 하는 것만 가른다. 근거는 2단계에서 나온 병목이다
③ 가르지 않기로 한 것과 그 이유를 남긴다

③이 없으면 모듈화 문서는 "가능한 모든 것을 마이크로서비스로"가 된다. 분할은 공짜가 아니라 네트워크 홉과 스키마 동기화, 배포 파이프라인을 비용으로 낸다. 팀 규모(AI 담당 1명)와 초기 트래픽을 넘어서는 분할은 유지보수성을 오히려 떨어뜨린다. 갈라야 할 근거를 대는 것과 같은 무게로 가르지 않을 근거를 댄다.

경계 결정 프리뷰

1. 백엔드 ↔ AI 서버 — 다이어그램과 설계 이유

경계의 실체는 인터페이스가 아니라 소유권이다

누가 무엇을 갖는지 정하면 인터페이스는 따라 나온다.

flowchart LR
    subgraph BE["Backend · Java/Spring"]
        U["사용자 · 친구 · 알림"]
        P["취향 프로필"]
        L["추천 리스트 self · gift"]
        F["피드백 원장"]
    end

    subgraph AI["AI Server · Python"]
        S["대화 세션 상태<br/>Redis · TTL 30분"]
        C["상품 카탈로그 + 벡터<br/>PostgreSQL + pgvector"]
    end

    P -->|요청마다 주입| AI
    F -->|요약본만 주입| AI
    AI -->|계산 결과 반환| P
    AI -->|계산 결과 반환| L

    style AI fill:#f0f7ff
    style C fill:#fff4e6
Loading
데이터 소유 근거
사용자 계정 · 친구 · 알림 백엔드 AI와 무관한 서비스 도메인
취향 프로필 백엔드 사용자 자산이다. 조회·수정·삭제(탈퇴)의 책임이 사용자 데이터를 가진 쪽에 있어야 한다
추천 리스트 백엔드 화면이 직접 읽는다. AI 호출 없이 조회되어야 한다
대화 세션 상태 AI 서버 계산 중간 상태다. 유실되면 그 대화만 잃는다
상품 카탈로그 + 벡터 AI 서버 상품 벡터가 임베딩 모델 버전에 묶여 있다. 소유가 나뉘면 모델 교체에 두 팀 배포가 묶인다
피드백 원장 백엔드 사용자 행동 기록. AI는 재생성 시 요약본만 받는다

설계 이유

  • POST /v1/recommendations가 profile 전체를 요청 본문으로 받는 근거가 이 표다. AI 서버가 스테이트리스라서가 아니라, 프로필을 소유하지 않기로 했기 때문이다.
  • "AI 서버는 저장하지 않는다"는 원칙은 사용자 데이터에만 적용된다. 카탈로그는 명시적인 예외다.
  • 경계를 여기 그은 근거는 네 가지다. 언어가 다르고(Java와 Python이라 한 프로세스에 담을 방법이 없다), 배포 주기가 다르며(프롬프트 수정이 주 단위로 일어난다), 교체 가능성이 있고(2단계가 상용 API를 자체 서빙으로 바꿨는데 백엔드는 한 줄도 바뀌지 않았다), 실패가 격리된다. 니쥬 화면 대부분은 AI를 호출하지 않으므로(모델 API 설계의 「사용자 대기가 발생하는 구간」), AI 서버가 502를 내도 서비스는 "새 대화를 시작할 수 없다"로 축소될 뿐이다. 이 성질은 경계를 이렇게 그었기 때문에 생긴 것이다.
2. api / worker 분리 — 다이어그램과 설계 이유

라우팅 정책이 서로 모순된다

2단계 O4는 KV 캐시 재사용을 위해 같은 세션을 같은 인스턴스로 보내라고 요구한다. 반대로 리스트 생성 잡은 아무 인스턴스나 집어야 처리량이 난다. 한 프로세스에 두면 둘 중 하나를 포기해야 한다.

flowchart LR
    BE["Backend"]

    subgraph API["api — 세션 고정 라우팅"]
        A1["인스턴스 #1<br/>세션 s_7a2b"]
        A2["인스턴스 #2<br/>세션 s_3f1c"]
    end

    Q[("Redis<br/>잡 큐")]

    subgraph W["worker — 아무나 집기"]
        W1["인스턴스 #1"]
        W2["인스턴스 #2"]
    end

    BE -->|sessionId 해시| A1
    BE -->|sessionId 해시| A2
    A1 -->|잡 등록| Q
    A2 -->|잡 등록| Q
    Q --> W1
    Q --> W2

    style API fill:#f0f7ff
    style W fill:#f0f7ff
Loading

설계 이유

  • 스케일 축이 다르다. api는 동시 세션 수로, worker는 큐 대기 길이로 늘린다. 한 단위면 둘 중 하나 기준으로만 늘어난다.
  • 리스트 생성이 도는 동안 SSE 스트림이 같은 이벤트 루프에서 밀린다. 사용자가 기다리는 유일한 경로가 기다리지 않는 작업에 막히는 구조다.
  • 잡 큐는 새로운 계약이 아니라 기존 계약의 구현이다. 1단계가 리스트 생성을 202 + jobId + 폴링으로 정해뒀기 때문에, 뒤를 큐로 구현해도 백엔드와 재협의할 것이 없다.
  • 프로필 확정은 잡이 아니다. 모델 호출이 요약문 하나뿐이라 close 요청 안에서 동기로 끝난다. api에 남고 큐를 타지 않는다. 큐를 타는 것은 사용자 확인 이후의 리스트 생성뿐이다.
  • 잡 트리거가 하나에서 둘로 늘었다. 사용자 확정과 백엔드의 재생성 트리거가 같은 잡을 등록한다. 진입점이 둘이어도 등록되는 잡의 종류는 하나라 worker의 책임은 달라지지 않는다.
3. batch 분리 — 다이어그램과 설계 이유

임베딩 모델을 바꾸면 상품 벡터가 전부 무효가 된다

카탈로그 수집과 속성 추출, 상품 임베딩, 가격 재고 갱신은 오프라인 작업이다. 이것이 서빙과 같은 프로세스면 성능 목표가 배치 스케줄에 의존하게 된다.

무중단 재색인은 batch가 분리돼 있어야 성립한다.

flowchart TD
    S1["① batch가 새 벡터 컬럼(v2)을 채운다"] --> S2["② 커버리지 100% 확인"]
    S2 --> S3["③ worker의 읽기 대상을 v2로 전환<br/>배포 1회"]
    S3 --> S4["④ 안정화 후 v1 컬럼 제거"]
    S1 -.그동안.-> R["worker는 v1으로 계속 검색<br/>서빙 무중단"]
    S3 -.실패하면.-> B["v1으로 되돌린다<br/>배포 1회"]

    style R fill:#e6f0ff
Loading

설계 이유

  • TTFT p95 1초 목표가 배치가 안 돌 때만 성립한다면 목표로서 의미가 없다. 상품 벡터 재계산이 같은 GPU를 잡으면 대화 턴이 그만큼 밀린다.
  • 임베딩 버전 불일치는 조용히 실패하는 유일한 계약이다. 다른 계약은 에러를 내지만 이것은 검색 결과가 이상해질 뿐이라 알아채기 어렵다. 그래서 상품 벡터 컬럼에 모델 버전을 박고, worker가 검색 전에 자기 임베딩 버전과 대조한다.
  • 취향 벡터와 상품 벡터는 같은 벡터 공간이어야 한다. 이 제약이 batch와 worker를 데이터로 묶으므로, 코드로도 묶지 않으려면 공유 지점을 DB 컬럼 하나로 좁혀야 한다.
  • 모듈화하지 않았다면 재색인은 새벽에 서비스를 내리고 하는 작업이 된다. 지금 설계에서는 배포 한 번이고, 되돌리는 것도 배포 한 번이다.
4. model-runtime 분리 — 다이어그램과 설계 이유

배포 주기가 두 자릿수 배 차이 난다

프롬프트 문구 수정은 주 단위로 일어나고 초 단위로 끝나야 한다. 8B BF16 모델 로드는 분 단위가 될 수 있다.

flowchart LR
    subgraph APP["앱 — 초 단위 배포 · 주 단위 변경"]
        A["api"]
        W["worker"]
        B["batch"]
    end

    subgraph RT["model-runtime — 분 단위 로드 · 드물게 변경"]
        M1["응답·요약·검색문 모델<br/>kanana-1.5-8b BF16"]
        M2["턴 델타 추출 모델<br/>kanana-2-1.3b BF16"]
        M3["외부 임베딩 API"]
    end

    A -->|OpenAI 호환 HTTP| RT
    W --> RT
    B --> RT

    style RT fill:#f3e8ff
Loading

설계 이유

  • 앱과 모델이 한 단위면 프롬프트 한 줄을 고칠 때마다 모델을 다시 로드하게 된다. 배포 시간이 초에서 분으로 늘고, 변경 빈도가 가장 높은 작업이 가장 느린 작업에 묶인다.
  • 과제문이 말하는 "모델 서빙 부분의 분리"가 니쥬에서는 이 항목이다. 프로토타입이 이미 OpenAI 호환 HTTP로 부르고 있으므로(chat/llm.py), 구조를 바꾸는 작업이 아니라 배포 단위로 못 박는 작업이다.
  • 2단계 O5의 역할별 모델 분리(응답·재작성 8B, 추출 1.3B)가 이 경계 안에 들어온다. 앱은 어느 모델인지가 아니라 어느 포트인지만 알고 부르므로, 모델을 바꿔도 호출부가 바뀌지 않는다.
  • 표준 인터페이스(OpenAI 호환)를 쓰는 덕에 vLLM과 SGLang, 상용 API가 같은 클라이언트로 교체된다. 2단계에서 상용 API를 로컬 서빙으로 바꿔도 백엔드가 바뀌지 않는다고 한 것을, 이 경계가 AI 서버 내부에서도 성립시킨다.
5. 가르지 않는 것 — 설계 이유

분할하지 않기로 한 다섯 가지

대안 선택 이유
대화 · 추출 · 추천을 각각 마이크로서비스로 하지 않는다. 패키지 경계로 둔다 셋 다 TasteProfile 스키마 하나를 공유한다. 서비스로 나누면 필드 하나 추가에 세 서비스 배포가 묶여서, 결합도는 그대로인데 배포 비용만 3배가 된다
self / gift를 별도 서비스로 하지 않는다 분기가 스코어 함수와 이유 생성 입력 두 곳뿐이다. 검색과 하드필터, 다양성 선별은 완전히 공유한다
전용 벡터 DB 도입 하지 않는다. pgvector 유지 상품 1만 개 × 벡터 3종 = 3만 벡터는 인덱스 없이도 수십 ms다. 속성 필터와 벡터 검색이 한 쿼리에 들어가야 한다는 요구도 pgvector가 그대로 만족한다. 100만 개에서 재검토
카탈로그 크롤러를 독립 서비스로 하지 않는다. batch 안의 잡 타입 크롤링과 속성추출, 임베딩은 항상 같이 돌고 같은 테이블에 쓴다. 나눠도 같이 배포된다
AI 서버도 상시 다중 인스턴스 api만 그렇게 한다. worker는 큐 기반, batch는 스케줄 기반 상시 GPU 인스턴스가 비용에서 가장 큰 항목이다

설계 이유

  • 공유하는 것이 스키마 하나뿐이면 패키지 경계로 충분하고, 여러 개면 서비스로 나눠야 한다. 니쥬는 전자에 해당한다.
  • 분할의 이득은 인스턴스 수가 아니라 배포와 실패가 독립된다는 데서 나온다. 초기에는 같은 노드의 별도 프로세스로 시작해도 이득이 실현되고, 인스턴스를 늘릴 때 코드를 고칠 필요가 없다.

모듈화 후 아키텍처

flowchart TB
    FE["Frontend<br/>NU-03 ~ NU-12"]
    BE["Backend · Java/Spring<br/>사용자·친구·알림<br/>취향 프로필 · 추천 리스트 저장"]

    subgraph AI["AI 서버 (Python)"]
        API["api<br/>HTTP · SSE<br/>대화 턴 오케스트레이션"]
        WORK["worker<br/>추출 · 리스트 생성 · 이유"]
        BATCH["batch<br/>카탈로그 수집 · 임베딩<br/>가격·재고 갱신"]
    end

    RUNTIME["model-runtime<br/>vLLM<br/>응답 · 추출 · 임베딩 모델"]
    Q[("Redis<br/>세션 상태 · 잡 큐")]
    CAT[("PostgreSQL + pgvector<br/>상품 카탈로그")]
    SHOP["외부 커머스 소스"]

    FE -->|HTTPS| BE
    BE -->|프라이빗 서브넷<br/>Bearer 서비스 토큰| API
    API --> Q
    Q -->|잡 수신| WORK
    WORK --> CAT
    BATCH --> CAT
    SHOP --> BATCH
    API -->|OpenAI 호환 HTTP| RUNTIME
    WORK --> RUNTIME
    BATCH --> RUNTIME

    style API fill:#f0f7ff
    style WORK fill:#f0f7ff
    style BATCH fill:#fff4e6
    style RUNTIME fill:#f3e8ff
Loading

백엔드에서 보면 아무것도 바뀌지 않았다. 1단계의 엔드포인트 계약이 그대로다. 내부를 넷으로 나눴지만 백엔드가 알아야 할 것은 늘지 않았다.

배포 단위

자르는 축은 도메인이 아니라 실행 특성이다. 2단계가 이미 경로별로 최적화 목표를 다르게 잡았다. 목표가 다르면 스케일 축이 다르고, 스케일 축이 다르면 배포 단위도 달라야 한다.

도메인(대화 / 추천 / 카탈로그)으로 자르면 "추천" 하나 안에 사용자 대기 경로와 오프라인 배치가 같이 들어가서, 조각은 늘었는데 독립 스케일링은 여전히 안 되는 상태가 된다.

단위 담당 사용자 대기 스케일 축 특이 요구 죽으면
api HTTP/SSE 수신, 대화 턴 오케스트레이션, 프로필 확정, 잡 등록 있음 동시 세션 수 세션 고정 라우팅(O4) 새 대화 불가. 저장된 리스트 조회는 정상
worker 검색문 재작성, 임베딩, 검색, 랭킹, 이유 생성 없음 큐 대기 길이 무상태 메인 화면의 리스트 준비가 길어짐. 대화와 확인 화면은 정상
batch 카탈로그 크롤링·속성추출·임베딩, 가격·재고 갱신 없음 스케줄 상시 기동 불필요 카탈로그가 낡음. 서빙은 정상
model-runtime 응답·추출·임베딩 추론 (vLLM) 간접 GPU 메모리·동시 요청 가장 비싼 자원 대화·추출 불가. 조회 화면 정상

스케일 축을 여기 적어두는 것은 인스턴스를 몇 개 띄울지 정하려는 것이 아니라, 축이 서로 다르다는 사실 자체가 단위를 나눈 근거이기 때문이다. 실제 인스턴스 수와 오토스케일링 정책, GPU 스펙 배치는 7단계에서 정한다.

분할의 이득은 인스턴스 수가 아니라 배포와 실패가 독립된다는 데서 나온다. 한 노드 안의 별도 프로세스로 시작해도 그 이득은 실현되고, 나중에 인스턴스를 늘릴 때 코드는 바뀌지 않는다.

모듈 목록과 책임

배포 단위 안쪽의 코드 모듈이다. 프로토타입 chat/이 이미 역할별로 갈라져 있어 그대로 승격한다.

모듈 책임 현재 코드 배포 단위 접근하는 상태
conversation 이번 턴 목표 결정, 프롬프트 조립, 응답 스트리밍 chat/policy.py · chat/prompts.py api 세션(Redis)
profile 취향 스키마, 델타 병합·방출·정정 chat/schema.py 공유 없음(순수)
extraction 델타 추출, 프로필 확정, 요약문 생성, 표시 키워드 선별 chat/finalize.py api 없음
rewriting 확정된 프로필에서 파생 쿼리 생성 chat/finalize.py worker 없음
retrieval 취향 임베딩, 벡터 검색, 하드 필터 미구현 worker 카탈로그(읽기)
ranking 모드별 스코어, 점수 컷, 다양성 선별 미구현 worker 없음(순수)
reasoning 쿼리 단위 이유 생성(O1) 미구현 worker 없음
activity 메인 화면 행동 카드 chat/activity.py worker 없음
catalog 크롤링, 속성 추출, 상품 임베딩, 신선도 갱신 미구현 batch 카탈로그(쓰기)
gateway 모델 호출, 재시도·타임아웃·구조화 출력 강제 chat/llm.py 공유 없음

profile이 공유 커널이다

여러 배포 단위가 같이 쓰는 모듈은 profile 하나뿐이다. 이 모듈은 의존성이 없는 순수 파이썬이어야 한다. FastAPI도 Redis도 DB도 import 하지 않는다. 그래야 api와 worker에 같은 코드가 들어가도 서로의 인프라를 끌고 오지 않는다.

모듈 간 인터페이스

의존 방향

flowchart LR
    IN["inbound adapter<br/>FastAPI · SSE · 큐 컨슈머"]
    APP["application<br/>유스케이스 조립"]
    DOM["domain<br/>profile · ranking · policy<br/>(순수 로직)"]
    PORT["ports<br/>인터페이스 정의"]
    OUT["outbound adapter<br/>vLLM · pgvector · Redis"]

    IN --> APP --> DOM
    APP --> PORT
    DOM --> PORT
    OUT -.구현.-> PORT

    style DOM fill:#e6f0ff
    style PORT fill:#fff4e6
Loading

규칙은 하나다. domain은 바깥을 모르고, 화살표가 안쪽으로만 향한다.

이 규칙의 실질적인 의미는 ranking과 profile 단위 테스트에 GPU도 DB도 필요 없다는 것이다. 스코어 가중치를 조정할 때마다 모델을 띄워야 한다면 조정 주기가 배포 주기에 묶인다.

통신 방식

방식 쓰는 곳 이유
인프로세스 함수 호출 api 안(policy → prompts → gateway), worker 안(retrieval → ranking → reasoning) 순차 실행이고 항상 같이 배포된다. 네트워크를 낄 이유가 없다
잡 큐 (Redis) api → worker 호출자가 결과를 기다리지 않는다. 202 + jobId 계약이 그대로 큐 시맨틱이다
공유 DB (pgvector) batch → worker 배치 산출물은 대량이고 저빈도다. 쓰기는 batch만, 읽기는 worker만
HTTP (OpenAI 호환) 앱 → model-runtime 표준 인터페이스라 서빙 스택과 상용 API가 같은 클라이언트로 교체된다

잡 큐 계약

{
  "jobType": "extraction",
  "jobId": "j_9c4d",
  "payloadVersion": 1,
  "userId": "u_10293",
  "sessionSnapshot": { "items": [], "axes": [], "turns": 12 },
  "options": { "generateLists": true, "limit": 20 },
  "enqueuedAt": "2026-09-08T14:40:00Z"
}
항목 규칙
상태 전이 pending → running → succeeded / failed. GET /v1/recommendations/jobs/{jobId} 응답과 1:1로 대응한다
멱등성 jobId가 멱등 키. 재시도해도 덮어써질 뿐 중복 생성되지 않는다
결과 저장 Redis에 완료 후 10분간 둔다. 백엔드가 폴링으로 가져가면 역할이 끝나므로 AI 서버에 남기지 않는다
부분 실패 이유 생성이 실패해도 리스트는 반환한다. 프로필은 잡에 들어오기 전에 백엔드가 저장했으므로 잡이 통째로 실패해도 잃지 않는다
진행률 worker가 단계마다 progress를 갱신한다. 응답의 progress 필드가 그것이다

포트 인터페이스

class ChatModel(Protocol):
    def stream(self, messages: list[Message], *, temperature: float) -> Iterator[str]: ...

class StructuredExtractor(Protocol):
    def extract(self, messages: list[Message], *, schema: dict) -> dict: ...

class Embedder(Protocol):
    def embed_batch(self, texts: list[str]) -> list[Vector]: ...   # 1회 100건까지
    @property
    def version(self) -> str: ...          # 상품 벡터 컬럼에 박히는 값

class CatalogRepository(Protocol):
    def search(self, vector: Vector, filters: HardFilter, k: int) -> list[Candidate]: ...

class SessionStore(Protocol):
    def load(self, session_id: str) -> State | None: ...
    def save(self, session_id: str, state: State, ttl_s: int) -> None: ...
포트 현재 어댑터 예정 어댑터
ChatModel · StructuredExtractor LM Studio (chat/llm.py) vLLM (2단계 O6)
Embedder 미구현 카탈로그 초기 수집과 함께 확정
CatalogRepository 미구현 pgvector
SessionStore 인메모리 dict Redis

이 표에서 가장 중요한 줄은 Embedder가 version을 노출한다는 부분이다. batch가 쓴 상품 벡터와 worker가 만든 취향 벡터가 같은 공간인지 런타임에 확인할 수 있다.

버전과 호환성

모듈을 나누면 버전이 어긋날 수 있는 지점이 생긴다. 이것이 모듈화의 대가이고, 다음 표가 그 관리 계획이다.

계약 지금 값 어긋나면 관리 방법
REST API /v1 백엔드 연동 실패 필드 추가는 하위 호환. 제거와 의미 변경은 사전 합의
TasteProfile.schemaVersion 3.0 저장된 프로필을 재대화에서 못 읽음 백엔드가 값 그대로 저장하고 반환한다. api가 로드 시 마이그레이션
rankerVersion gift-1.0.0 리스트 간 점수 비교 불가 응답에 실어 보낸다. 값이 바뀌면 재생성 트리거
임베딩 버전 solar-embedding-2 취향 벡터와 상품 벡터가 다른 공간이 되어 검색이 조용히 망가진다 상품 벡터 컬럼에 버전을 박고 검색 전 대조. 불일치면 CATALOG_UNAVAILABLE
잡 페이로드 payloadVersion: 1 api와 worker의 배포 시차에 잡이 유실된다 컨슈머가 모르는 버전은 큐에 되돌리고 경보

배포 순서는 계약이 바뀌는 변경일수록 읽는 쪽을 먼저 올린다. 잡이 api(프로듀서)에서 worker(컨슈머)로 흐르므로 worker를 먼저 배포한다.

기대 효과

측면 효과
개발 랭킹과 스키마 로직이 순수 함수라 GPU 없이 테스트된다. 프롬프트 실험이 배포 파이프라인을 타지 않는다
협업 백엔드는 REST 계약만 본다. 내부를 넷으로 나눴지만 백엔드가 알아야 할 것은 늘지 않았다
운영 배치가 서빙 지연을 건드리지 않는다. 실패가 배포 단위 안에서 멈춘다
비용 GPU를 쓰는 단위가 model-runtime 하나로 모인다. 나머지는 GPU가 필요 없는 단위로 남는다
성능 세션 고정 라우팅(api)과 큐 기반 분산(worker)을 동시에 쓸 수 있다. 한 프로세스면 하나를 포기해야 한다
교체 모델과 서빙 스택, 임베딩 모델 교체가 어댑터 한 파일로 끝난다

변경 영향 범위

변경 고치는 모듈 재배포 영향 없는 것
대화 말투·질문 문구 수정 conversation api worker · batch · 백엔드 · 프론트
취향 필드 추가 (10개 → 11개) profile api · worker batch · 프론트. 백엔드는 스키마 버전만
랭킹 가중치 조정 ranking worker api · batch · 백엔드
이유 생성을 쿼리 단위로 (2단계 O1) reasoning worker api · batch · 백엔드 · 프론트
추출 모델 1.3B로 교체 (2단계 O5) 설정만 model-runtime 앱 코드 전체
상용 API → 로컬 서빙 전환 gateway 어댑터 api · worker · model-runtime 도메인 로직 · 백엔드 · 프론트
커머스 소스 추가 catalog batch api · worker · 백엔드 · 프론트
임베딩 모델 교체 gateway · catalog batch → worker (순서 준수) api · 백엔드 · 프론트
대화 트래픽 급증 없음 api 증설 나머지 전부

마지막 열이 모듈화의 산출물이다. 어떤 변경이든 재배포 대상이 한두 개로 끝난다.

서비스 시나리오와의 정합

니쥬는 대화 한 번에 결과가 나오고 그 뒤로는 조회만 하는 서비스다. 사용자가 AI를 기다리는 구간이 전체 흐름에서 앞쪽 한 번뿐이라, 무거운 계산을 대기 경로 밖으로 밀어내는 것이 설계의 중심이 된다. api와 worker를 나눈 것은 그 구조를 배포 단위로 옮긴 것이다.

1단계가 확정과 리스트 생성을 두 구간으로 나누면서 이 경향이 더 뚜렷해졌다. 사용자가 화면 앞에서 기다리는 것은 대화와 확인 화면까지이고, 리스트 생성은 사용자가 메인 화면으로 이동한 뒤에 돈다. 대기 경로에 남은 것이 전부 api이고 밀어낸 것이 전부 worker다.

반대로 실시간 대화형 추천 서비스였다면 이 분리는 근거가 약했을 것이다. 같은 분할이 어디서나 옳은 것은 아니고, 니쥬의 흐름이 이 분할을 요구하는 것이다.

다음 단계로 넘기는 것

경계를 그었으니, 각 모듈 안을 채우는 일이 남는다.

넘기는 것 어느 모듈의 안쪽인가 어디서
프로필 확정과 리스트 생성의 단계 구성, 각 단계 인터페이스 api · worker 4단계
검색 문장 재작성, 임베딩, 벡터 검색, 카탈로그 수집 전략 retrieval · catalog 5단계
외부 도구·상용 API 호출을 어떤 규약으로 감쌀지 gateway 6단계
인스턴스 수, 오토스케일링, 컨테이너, 모니터링 지표 배포 단위 전체 7단계

이 순서가 가능한 이유가 모듈화다. 4·5·6단계가 각각 다른 모듈 안쪽을 건드리므로, 한 단계의 설계가 다른 단계의 설계를 되돌리지 않는다.

미확정 항목

항목 확정 시점
큐 구현 (Redis Streams vs Celery) 잡 종류가 3개뿐이라 Streams로 충분할 가능성이 높다. 구현 착수 시
api·worker 노드 배치 초기에는 같은 노드의 별도 프로세스. 분리 시점은 트래픽을 보고
model-runtime 분리 수준 응답 모델과 추출 모델을 한 인스턴스에 둘지 나눌지. 2단계 E5·E7 결과
컨테이너 · 오케스트레이션 · 인스턴스 수 · 모니터링 7단계. 이 문서는 배포 단위만 정하고 배포 방법은 정하지 않는다
온디맨드 폴백의 소속 지금은 worker. 지연이 크면 별도 큐로 뺀다
세션 고정 라우팅 구현 인프라 담당과 합의 필요 (2단계 O4에서 이미 올린 항목)
⚠️ **GitHub.com Fallback** ⚠️