[3단계] 서비스 아키텍처 모듈화 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
[3단계] 서비스 아키텍처 모듈화
AI 서버 책임
-
AI 서버는 AI의 처리에 관한 책임만 가지고, db접근을 포함한 상호작용은 Spring 서버에서 담당하도록 하였다.
-
기능 1,2,3을 각각 별도의 모듈화를 통해 독립적인 구조와 하나의 기능을 담당하는 책임을 가지도록 하였다.
-
기능 3도 DB를 직접 다루지 않는다. 원본은 Spring이 관리하고 AI는 분석에 필요한 판정 결과만 캐시에 보관한다.
-
FastAPI가 단일 인스턴스, 단일 워커일 때 캐시는 프로세스 메모리에 보관하지만 이후 확장했을 때 Redis 도입을 검토한다.
-
Spring은 60초마다 변경된 게시글 묶음(신규·수정·삭제)의 현재 원문을 보낸다. AI는 보관 중인 판정과 새로 받은 원문을 함께 분석해 각 신호들의 혼잡도를 집계해서 같은 요청의 응답으로 스팟별 혼잡도를 돌려준다.
-
Spring은 최신 정상 결과의 저장·지도 표시·만료를 담당한다.
-
AI가 분석 상태를 잃으면 해당 요청을 적용하지 않고 HTTP
409 STATE_RESET을 반환한다. Spring은 일반 변경 전송을 멈추고 현재 유효한 게시글 묶음 전체를 같은 요청 스키마로 다시 보낸다. AI는 전체 분석에 성공해야 정상 처리로 돌아가며, 하나라도 실패하면 빈 상태와 복구 대기를 유지하고503을 반환한다.
flowchart LR
U[사용자]
FE[Frontend]
SP[Spring 도메인 서버]
DB[(DB)]
subgraph AI[FastAPI AI 서버]
TM[미터기 모듈]
RC[영수증 모듈]
CG[혼잡도 모듈]
MC["분석 캐시(메모리)"]
end
U --> FE
FE -->|"공개 REST / WS"| SP
SP <--> DB
SP -->|"POST /taxi_meter"| TM
SP -->|"POST /receipt"| RC
SP -->|"60초마다 POST /congestion-analyses"| CG
CG <--> MC
CG -->|"200: 스팟 결과 (409: STATE_RESET)"| SP
기능 3은 Spring의 DB를 직접 조회·수정하지 않으며 AI 전용 DB도 두지 않는다. 분석에 필요한 판정 결과만 임시로 보관하고 Spring과는 API로 원문과 결과를 주고받는다.
FastAPI 서버 구조
backend-fastapi/
├── app/
│ ├── main.py
│ ├── core/
│ │ ├── config.py
│ │ ├── logging.py
│ │ └── exceptions.py
│ ├── api/
│ │ ├── router.py
│ │ └── deps.py
│ ├── features/
│ │ ├── taxi_meter/
│ │ │ ├── router.py
│ │ │ ├── models.py
│ │ │ ├── main.py
│ │ │ ├── image.py
│ │ │ └── judge.py
│ │ ├── receipt/
│ │ │ ├── router.py
│ │ │ ├── models.py
│ │ │ └── service.py
│ │ └── congestion/
│ │ ├── router.py
│ │ ├── models.py
│ │ ├── service.py
│ │ ├── cache.py
│ │ ├── preprocessor.py
│ │ ├── extractor.py
│ │ ├── aggregator.py
│ │ ├── summarizer.py
│ │ └── policy.py
│ ├── llm/
│ │ ├── __init__.py
│ │ ├── router.py
│ │ ├── types.py
│ │ └── providers/
│ │ ├── openai.py
│ │ ├── slm.py
│ │ └── ocr.py
│ └── clients/
│ └── spring.py
├── tests/
│ ├── features/
│ └── llm/
├── .env
└── pyproject.toml
모듈화 후 서비스 아키텍처
- API 설계서에 따라 post 요청을 스프링 서버로 부터 받음.
- 기능 1,2,3을 별도 모듈화 하여 features 폴더 아래로 둔다.
- 기능 1,2,3은 서로를 import하지 않는다. api, core, llm, clients는 공용만 둔다.
- 기능 1 미터기, 기능 2 영수증은 이미지 입력이다. PP-OCRv6 medium으로 후보를 뽑고, LFM2.5-VL-1.6B native로 확정한다.
- 기능 3의
router.py는 요청을 검증하고service.py가 분석·집계·응답 구성을 조정한다. cache.py는 게시글 묶음별 신호 판정(혼잡 단계·혼잡 원인)과 원문 지문을 보관하고 만료된 항목을 정리하며 판정이 없는 게시글 묶음을 알려준다.preprocessor.py는 요청의 신호를 캐시의 원문 지문과 비교해 신규·수정·삭제·불변으로 분류하고, 신규·수정 신호와 그 게시글 묶음의 원문을 모델 입력으로 구성한다.extractor.py는 신호의 의미를 판정하고aggregator.py와summarizer.py는 최종 스팟 결과를 코드로 계산한다.- AI는 원문을 보관하지 않고 판정 결과만 보관하므로 내부 요약을 따로 만들지 않는다. 신호별 원인 문구(
cause.text)는 모델이 생성하고, Spring에 보내는 40자 이내의cause_summary와 사고·지연 여부를 나타내는cause_code는 그중 대표 신호를 코드가 골라 그대로 쓴다. 코드가 문장을 새로 짓지 않는다. - 각 기능이 독립적이고, 의존하지 않는 구조이기 때문에, 담당하는 기능 단위로 책임을 분리하였다.
flowchart TB
SP[Spring]
MAIN[main.py]
API[api/router.py]
subgraph F1[features/taxi_meter]
R1[router]
S1[service]
M1[models]
end
subgraph F2[features/receipt]
R2[router]
S2[service]
M2[models]
end
subgraph F3[features/congestion]
R3[router]
S3[service]
M3[models]
P3[preprocessor]
E3[extractor]
A3[aggregator]
T3[summarizer]
C3[cache]
end
LLM[llm]
CL[clients/spring.py]
SP -->|"Rest - JSON"| MAIN --> API
API --> R1
API --> R2
API --> R3
R1 --> S1 --> LLM
R2 --> S2 --> LLM
R3 -->|"요청 검증"| S3
S3 -->|"200: 스팟 결과 (409: STATE_RESET)"| SP
S3 --> P3
S3 --> E3
S3 --> A3
S3 --> T3
E3 -->|"신규·수정 신호 의미 추출"| LLM
S3 <-->|"판정 재사용·조회"| C3
S1 --> CL
S2 --> CL
기능 3 파일별 역할
| 파일 | 역할 |
|---|---|
router.py |
/congestion-analyses 요청 검증과 응답 반환 |
models.py |
1단계의 요청·응답 모델과 상태별 값 검증. 외부 JSON 계약의 정의 위치 |
service.py |
판정 조회, 신규 신호 분석, 스팟 집계, 응답 구성의 흐름 조정 |
cache.py |
게시글 묶음별 신호 판정(혼잡 단계·혼잡 원인)과 원문 지문 보관, 만료 정리, 판정 없는 게시글 묶음 확인 |
preprocessor.py |
요청의 신호를 캐시의 원문 지문과 비교해 신규·수정·삭제·불변으로 분류. 신규·수정 신호와 그 게시글 묶음의 원문을 모델 입력으로 구성 |
extractor.py |
모델 호출과 신호별 판정 검증. ID 누락·중복·허용되지 않은 값 확인 |
aggregator.py |
유효한 혼잡 단계 제보로 스팟 등급·제보 수·근거 시각 계산. 작성자당 최신 제보 한 건만 반영하며, 최다 단계가 동률이거나 제보가 없으면 UNKNOWN |
summarizer.py |
캐시에 보관된 신호별 원인 문구·코드 중 대표 신호를 골라 그대로 cause_summary·cause_code로 씀. 문장을 새로 짓지 않고 고르기만 한다. 원인 근거가 없거나 사고·지연이 아니면 cause_code는 null로 맞춘다 |
policy.py |
분석·집계 정책 버전과 입력·동시성 제한 등 기능 3 설정 관리 |
clients/spring.py |
기능 1·2가 사용하는 기존 Spring 호출. 기능 3은 사용하지 않는다 |
모델 제공자 호출은 기존 llm 계층을 사용한다. 기능 3은 Spring을 먼저 호출하지 않으므로 clients/spring.py에 기능 3용 메서드를 추가하지 않는다.
Spring과 AI의 데이터 책임
| 데이터·작업 | 소유 서버 | 책임 |
|---|---|---|
| 게시글·댓글 원본, 스팟 배정 | Spring | 원본을 저장·관리하고 변경된 게시글 묶음과 (상태 유실 시) 전체 유효 자료를 AI에 전달 |
| 게시글 묶음별 신호 판정 | AI | 집계에 쓸 판정을 보관(원문은 보관하지 않는다). 상태를 잃으면 STATE_RESET으로 복구한다 |
| 최근 처리 상태·마지막 정상 스팟 결과 | Spring | 실패 시 기존 결과 유지, 표시 만료 |
기능 3이 보관하는 판정은 복구할 수 있다. 상태를 잃으면 STATE_RESET으로 게시글 묶음 전체를 재전송받아 다시 분석하며, 선택적으로 일부 게시글 묶음만 재전송받는 방식은 두지 않는다. 별도의 영구 저장소는 두지 않되, 복구에 필요한 전체 재전송량과 처리 시간이 시간 예산(AI 50초·Spring 55초) 안에 들어오는지는 운영 시 확인한다.
분석 캐시
| 항목 | 값 |
|---|---|
| 보관 단위 | 신호(게시글 또는 댓글) 하나당 레코드 하나. 원글은 post_id, 댓글은 (post_id, comment_id) 키로 게시글 묶음 아래 묶어서 보관하며 작성자·최초 작성 시각·혼잡 단계·혼잡 원인·원문 지문(해시)을 저장한다. 원문 자체는 보관하지 않는다 |
| 보관 기간 | 게시글 묶음의 마지막 활동으로부터 25분 |
| 집계 유효 시간 | 신호 작성 시각으로부터 20분. 보관 기간과 별개다 |
| 판정 재사용 키 | 원글 판정은 post_id, 댓글 판정은 (post_id, comment_id). 게시글·댓글은 ID 체계가 분리돼 있어 comment_id 단독으로는 유일하지 않다 |
| 규모 | 판정 레코드 하나는 작성자 키·작성 시각·혼잡 단계·혼잡 원인·원문 지문으로 구성되며 레코드당 약 450바이트로 어림잡는다. 분당 신호 20건 기준 25분 보관 시 약 500건(약 220KB), 분당 100건 기준 약 2,500건(약 1.1MB)이 쌓인다. 실제 메모리 사용량은 자료 구조에 따라 측정하고 상한을 정한다 |
AI는 신호별 판정 결과만 보관하고 원문은 보관하지 않는다. 판정 결과는 스팟 집계에 쓴다. 신호별 관련성·혼잡 단계·원인을 저장해 두고 작성된 지 20분 이내인 유효 제보를 골라 스팟 등급을 계산한다. 새 댓글을 해석할 원문은 매 요청에서 받는다. 예를 들어 "지금도 그래요"라는 댓글은 부모 게시글의 본문을 함께 읽어야 의미를 알 수 있는데, Spring은 게시글 묶음에 변화가 있을 때마다 원글과 현재 유효한 댓글 전체를 요청에 담아 보내므로 AI가 원문을 따로 들고 있지 않아도 새 댓글을 해석할 수 있다. LLM 입력에는 캐시에 있는 이전 판정 결과를 넣지 않는다.
20분이 지난 게시글이라도 새 댓글이 달리면 Spring이 그 원글 본문을 문맥으로 함께 보내므로 AI는 이를 새 댓글 해석에 사용한다. 20분은 현재 혼잡도 집계에 반영할 제보의 유효 시간이고, 25분은 판정 결과를 캐시에 남겨 재사용하는 보관 기간이다. 두 시간은 서로 다른 목적이며, 25분 중 20분을 넘는 5분은 요청 처리 지연을 흡수하는 여유분으로 운영 중 실측을 보고 조정할 예정이다.
판정을 완료한 신호는 원문 지문이 같으면 재사용하고, 다르면 수정된 것으로 보고 다시 판정한다. 신규 신호가 없고 시간만 지난 경우에는 모델 호출이 0이다. 다만 분석 상태 자체를 잃으면 일부만 복구하지 않고 STATE_RESET으로 전체를 다시 분석한다.
분석 상태를 잃었을 때
프로세스를 재시작하거나 재배포하면 메모리의 캐시가 사라진다. 이 경우 일부 게시글 묶음만 선택적으로 복구하지 않고 전체 상태를 한 번에 재동기화한다.
- 상태가 유실된 AI는 남은 분석 상태도 초기화하고 복구 대기 상태로 전환한다. 다음 요청을 적용하지 않고 HTTP
409,code="STATE_RESET"을 반환한다. - Spring은
STATE_RESET을 받으면 일반 변경 전송을 멈추고, 최근 20분의 현재 게시글·댓글과 댓글 해석에 필요한 원글을 모든 스팟에서 조회해 다음 요청으로 보낸다. - AI는 이 복구 요청의 전체 분석에 성공해야 판정을 저장하고 정상 처리로 돌아간다. 하나라도 실패하면 빈 상태와 복구 대기를 유지하고 HTTP
503을 반환하며, Spring은 다음 주기에 같은 자료로 재시도한다. - 인스턴스를 2대 이상으로 늘리면 인스턴스마다 캐시가 따로 비어 있어, 요청이 다른 인스턴스로 갈 때마다 전체 복구가 반복될 수 있다. 초기에는 1대로 운영하고 늘릴 때 외부 저장소(Redis) 도입을 다시 판단한다.
운영 제약
캐시를 FastAPI 프로세스 메모리에 두는 동안에는 워커·인스턴스를 1개로 유지하고 상시 구동 상태로 배포한다. 워커·인스턴스가 여러 개이거나 프로세스가 재시작되면 캐시가 비거나 흩어져 판정이 없을 수 있고, 그만큼 재전송·재분석이 늘어난다. 이 제약은 Redis 등 외부 저장소로 캐시를 옮기면 사라진다.
모델 서버의 입력 계산 재사용(GPU 계산 절감)과 여기서 말하는 분석 캐시(판정 보관)는 서로 다른 기능이다.
동시 실행
같은 게시글 묶음에 대한 분석·캐시 갱신은 한 번에 하나씩 처리한다. 이처럼 처리 순서를 보장하는 방식을 게시글 묶음 단위 직렬화라고 한다. Spring이 55초에 응답을 끊어도 AI 쪽 처리는 계속될 수 있다. 다음 주기 요청이 겹칠 때를 대비해 진행 중인 분석을 확인한다. 서로 다른 게시글 묶음은 동시에 모델을 호출할 수 있다. 동시 호출 수는 2단계 검증 결과에 맞춰 제한한다.
구현 전 남은 상세 결정
- 캐시 자료 구조, 만료 항목 정리 주기, 메모리 상한 초과 시 동작.
STATE_RESET복구 시 예상 전체 유효 자료 크기가 수신 상한과 시간 예산(AI 50초·Spring 55초) 안에 들어오는지 검증.- 게시글 묶음 단위 직렬화와 동시 호출 수 제한의 구현.
집계 규칙과 모델 출력 JSON 스키마는 4단계 문서를 따른다. 우선 집계는 작성자당 최신 제보 한 건을 반영하는 다수결 집계를 기준으로 한다. 글의 최근성을 집계 규칙에 적용하는 것은 추후에 진행할 예정.
모듈화로 기대되는 효과와 장점
- 기능 1,2,3이 서로 독립적이고 import 하지 않기 때문에, 각 기능을 담당하는 책임을 명확하게 분리할 수 있다.
- 각 모듈에 적용된 LLM의 개선이 있는 경우, 해당 모듈만 수정하면 되므로 유지보수가 용이해진다.
모듈화된 설계가 팀의 서비스 시나리오에 부합하는 이유
- Spring은 변경된 게시글 묶음 전달과 최종 결과 표시에 집중하고 AI는 분석과 집계를 담당한다.
- 이미 판정한 신호를 캐시에서 재사용하므로 같은 글을 매 주기 다시 분석하지 않는다.
- AI가 보관하는 판정은 복구 가능한 사본이므로 별도 DB 없이 분석 캐시를 운영한다. 캐시가 유지되는 동안에는 같은 신호의 중복 분석을 줄일 수 있다.
- 신규 신호가 없는 주기에도 보관 중인 판정으로 재집계하며 모델 호출은 0이다. Spring은 마지막 제보 시각을 기준으로 오래된 결과의 표시를 종료한다.