서비스 아키텍처 모듈화 - 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
| 데이터 | 소유 | 근거 |
|---|---|---|
| 사용자 계정 · 친구 · 알림 | 백엔드 | 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
- 스케일 축이 다르다.
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
- 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
- 앱과 모델이 한 단위면 프롬프트 한 줄을 고칠 때마다 모델을 다시 로드하게 된다. 배포 시간이 초에서 분으로 늘고, 변경 빈도가 가장 높은 작업이 가장 느린 작업에 묶인다.
- 과제문이 말하는 "모델 서빙 부분의 분리"가 니쥬에서는 이 항목이다. 프로토타입이 이미 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
백엔드에서 보면 아무것도 바뀌지 않았다. 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 하나뿐이다. 이 모듈은 의존성이 없는 순수 파이썬이어야 한다. 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
규칙은 하나다. 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에서 이미 올린 항목) |