[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 API 연동·배포 단계에서 확장 요구를 확인한 뒤 도입 여부를 결정한다.

  • Spring은 60초마다 여러 스팟의 변경된 게시글 묶음 현재 상태를 보낸다. AI는 전체 신호 ID로 신규·기존·삭제를 구분하고, 원글·신규 신호·직전 댓글 일부를 문맥으로 신규 신호만 판정한다. 이후 기존 판정 재사용과 삭제·만료를 반영해 같은 요청의 응답으로 스팟별 혼잡도를 돌려준다.

  • 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
│   ├── 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 요청을 Spring 서버로부터 받는다.
  • 기능 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는 게시글 묶음 아래 신호별 판정을 보관하고 만료된 항목을 정리하며, 요청의 신호 ID와 대조할 기존 상태를 제공한다.
  • preprocessor.py는 요청과 캐시의 신호 ID를 비교해 신규·기존·삭제로 분류한다. 상태 대조에는 현재 댓글 전체를 사용하고, 모델 입력은 원글·신규 신호·각 신규 댓글 직전의 최근 댓글 최대 N개로 구성한다. extractor.py는 신규 신호의 의미를 판정하고 aggregator.py는 최종 스팟 결과와 대표 원인을 계산한다.
  • AI는 원문을 보관하지 않고 신호별 판정만 보관한다. 신호별 cause_code와 원인 문구는 모델이 생성하고, Spring에 보내는 cause_code와 40자 이내의 cause_summary는 aggregator.py가 대표 신호에서 고른다. cause_code는 ACCIDENT, DELAY, QUEUE, EVENT, TRAFFIC 중 하나이며 코드는 문장을 새로 짓지 않는다.
  • 각 기능이 독립적이고, 의존하지 않는 구조이기 때문에, 담당하는 기능 단위로 책임을 분리하였다.
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]
    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 --> P3
  P3 --> E3
  E3 -->|"원글·신규 신호·직전 댓글 문맥으로 판정"| LLM
  LLM --> E3
  E3 --> A3
  A3 -->|"스팟 임시 결과"| S3
  S3 <-->|"판정 재사용·조회"| C3
  S3 -->|"200: 스팟 결과 / 409·503"| SP
  S1 --> CL
  S2 --> CL

기능 3 파일별 역할

파일 역할
router.py /congestion-analyses 요청 검증과 응답 반환
models.py 1단계의 요청·응답 모델과 상태별 값 검증. 외부 JSON 계약의 정의 위치
service.py 판정 조회, 신규 신호 분석, 스팟별 임시 변경 상태 구성, 스팟 단위 캐시 반영·집계·응답 구성의 흐름 조정
cache.py 게시글 묶음 아래 신호별 관련성·혼잡 단계·원인 코드·원인 문구 보관, 만료 정리, 기존 신호 상태 조회
preprocessor.py 현재 댓글 전체와 캐시의 ID를 비교해 신규·기존·삭제로 분류. 댓글을 시간순으로 정렬하고 원글·신규 신호·각 신규 댓글 직전의 최근 댓글 최대 N개를 모델 입력으로 구성
extractor.py 모델 호출과 신호별 판정 검증. ID 누락·중복·허용되지 않은 값 확인
aggregator.py 작성자당 최신 유효 제보 한 건을 사용해 스팟 등급·제보 수·근거 시각을 계산하고 대표 신호의 cause_code·cause_summary를 선택. 최다 단계가 동률이거나 제보가 없으면 UNKNOWN
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) 키로 게시글 묶음 아래 묶어서 보관하며 신호 종류·작성자·최초 작성 시각·관련성·혼잡 단계·cause_code·cause_summary를 저장한다. 원문 자체는 보관하지 않는다
보관 기간 게시글 묶음의 마지막 활동으로부터 25분
집계 유효 시간 신호 작성 시각으로부터 20분. 보관 기간과 별개다
판정 재사용 키 원글 판정은 post_id, 댓글 판정은 (post_id, comment_id). 게시글·댓글은 ID 체계가 분리돼 있어 comment_id 단독으로는 유일하지 않다
규모 예상 신호 수를 기준으로 Python 객체 오버헤드를 포함한 실제 메모리 사용량을 측정하고 캐시 상한을 정한다

AI는 신호별 판정 결과만 보관하고 원문은 보관하지 않는다. 판정 결과는 스팟 집계에 쓴다. 신호별 관련성·혼잡 단계·원인을 저장해 두고 작성된 지 20분 이내인 유효 제보를 골라 스팟 등급을 계산한다. 새 댓글을 해석할 원문은 매 요청에서 받는다. Spring이 보낸 현재 댓글 전체는 ID 대조에 사용하고, LLM에는 원글·신규 신호·직전 댓글 최대 N개만 넣는다. LLM 입력에는 캐시에 있는 이전 판정 결과를 넣지 않는다.

20분이 지난 게시글이라도 새 댓글이 달리면 Spring이 그 원글 본문을 문맥으로 함께 보내므로 AI는 이를 새 댓글 해석에 사용한다. 20분은 현재 혼잡도 집계에 반영할 제보의 유효 시간이고, 25분은 판정 결과를 캐시에 남겨 재사용하는 보관 기간이다. 두 시간은 서로 다른 목적이며, 25분 중 20분을 넘는 5분은 요청 처리 지연을 흡수하는 여유분으로 운영 중 실측을 보고 조정할 예정이다.

판정을 완료한 신호는 같은 ID가 다시 들어오면 본문이 달라도 기존 판정을 재사용한다. 요청에 새로 나타난 ID만 신규 신호로 판정하고, 캐시에 있지만 게시글 묶음의 현재 댓글 목록에 없는 ID는 삭제한다. 신규 신호가 없고 시간만 지난 경우에는 모델 호출이 0이다. 다만 분석 상태 자체를 잃으면 일부만 복구하지 않고 STATE_RESET으로 전체를 다시 분석한다.

분석 상태를 잃었을 때

프로세스를 재시작하거나 재배포하면 메모리의 캐시가 사라진다. 이 경우 일부 게시글 묶음만 선택적으로 복구하지 않고 전체 상태를 한 번에 재동기화한다.

  1. 상태가 유실된 AI는 남은 분석 상태도 초기화하고 복구 대기 상태로 전환한다. 다음 요청을 적용하지 않고 HTTP 409, code="STATE_RESET"을 반환한다.
  2. Spring은 STATE_RESET을 받으면 일반 변경 전송을 멈추고, 최근 20분의 현재 게시글·댓글과 댓글 해석에 필요한 원글을 모든 스팟에서 조회해 다음 요청으로 보낸다.
  3. AI는 이 복구 요청의 전체 분석에 성공해야 판정을 저장하고 정상 처리로 돌아간다. 하나라도 실패하면 빈 상태와 복구 대기를 유지하고 HTTP 503을 반환하며, Spring은 다음 주기에 최신 유효 자료 전체를 다시 조회해 재전송한다.
  4. 현재 구현은 단일 인스턴스·단일 워커를 전제로 한다.

운영 제약

현재 구현 범위는 FastAPI 단일 인스턴스·단일 워커와 프로세스 메모리 캐시다. Redis 연결, 분산 락, 메모리·Redis 공통 추상화는 구현하지 않는다. Spring API 연동·배포 단계에서 다중 워커·인스턴스가 필요하거나 재배포 후 전체 복구 비용이 허용 범위를 넘으면 Redis 도입을 검토한다.

모델 서버의 입력 계산 재사용(GPU 계산 절감)과 여기서 말하는 분석 캐시(판정 보관)는 서로 다른 기능이다.

동시 실행

Spring 요청은 순차 처리한다. 한 요청 안에서는 서로 다른 게시글 묶음의 모델 호출을 정해진 동시 호출 수 안에서 병렬 실행한다. 신규 판정과 삭제는 스팟별 임시 상태에 모으고, 같은 스팟의 처리가 모두 성공하면 캐시에 한 번에 반영한다. 하나라도 실패하면 해당 스팟의 임시 상태를 폐기한다.

구현 전 남은 상세 결정

  • 캐시 자료 구조, 만료 항목 정리 주기, 메모리 상한 초과 시 동작.
  • STATE_RESET 복구 시 예상 전체 유효 자료 크기가 수신 상한과 시간 예산(AI 50초·Spring 55초) 안에 들어오는지 검증.
  • 스팟 단위 원자적 캐시 반영과 요청 내부 동시 호출 수 제한의 구현.
  • 2단계 측정 결과에 따른 CONTEXT_COMMENT_LIMIT 값 확정.
  • Spring API 연동·배포 단계에서 Redis 검토 조건 충족 여부 확인.

집계 규칙과 모델 출력 JSON 스키마는 4단계 문서를 따른다. 우선 집계는 작성자당 최신 제보 한 건을 반영하는 다수결 집계를 기준으로 한다. 글의 최근성을 집계 규칙에 적용하는 것은 추후에 진행할 예정.

모듈화로 기대되는 효과와 장점

  • 기능 1,2,3이 서로 독립적이고 import 하지 않기 때문에, 각 기능을 담당하는 책임을 명확하게 분리할 수 있다.
  • 각 모듈에 적용된 LLM의 개선이 있는 경우, 해당 모듈만 수정하면 되므로 유지보수가 용이해진다.

모듈화된 설계가 팀의 서비스 시나리오에 부합하는 이유

  • Spring은 변경된 게시글 묶음 전달과 최종 결과 표시에 집중하고 AI는 분석과 집계를 담당한다.
  • 이미 판정한 신호를 캐시에서 재사용하므로 같은 글을 매 주기 다시 분석하지 않는다.
  • AI가 보관하는 판정은 복구 가능한 사본이므로 별도 DB 없이 분석 캐시를 운영한다. 캐시가 유지되는 동안에는 같은 신호의 중복 분석을 줄일 수 있다.
  • 신규 신호가 없는 주기에도 보관 중인 판정으로 재집계하며 모델 호출은 0이다. Spring은 마지막 제보 시각을 기준으로 오래된 결과의 표시를 종료한다.