[1단계] 모델 API 설계 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
[1단계] 모델 API 설계
목차
1. API 엔드포인트 목록 및 기능
| Method | 엔드포인트 | 기능 설명 |
|---|---|---|
| POST | /taxi_meter |
미터기 |
| POST | /receipt |
영수증 |
| POST | /congestion-analyses |
혼잡도 분석 |
2. API 입/출력 명세
미터기 입력스키마
from pydantic import BaseModel
class TaxiMeterRequest(BaseModel):
image_URL: str # 미터기 사진 URL
ride_channel_id: str # 동승 채널 ID
영수증 입력스키마
from pydantic import BaseModel
class ReceiptRequest(BaseModel):
ride_channel_id: str # 동승 채널 ID
image_URL: str # 영수증 사진 URL
혼잡도 입력스키마
from typing import Annotated, Literal
from pydantic import AwareDatetime, BaseModel, Field, StringConstraints
NonEmptyText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
ContentText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=500)]
PositiveId = Annotated[int, Field(gt=0)]
class CongestionCommentInput(BaseModel):
comment_id: PositiveId # 댓글 ID
author_key: NonEmptyText # 작성자 구분 키
created_at: AwareDatetime # 최초 작성 시각
content: ContentText # 현재 본문 (최대 500자)
class CongestionPostBundleInput(BaseModel):
post_id: PositiveId # 원글 ID (게시글 묶음 식별에 사용)
author_key: NonEmptyText # 작성자 구분 키
created_at: AwareDatetime # 최초 작성 시각
content: ContentText # 현재 본문 (최대 500자)
comments: list[CongestionCommentInput] # 최근 20분의 현재 댓글 전체
class CongestionSpotInput(BaseModel):
spot_id: PositiveId # 스팟 ID
post_bundles: list[CongestionPostBundleInput] # 변경된 게시글 묶음 목록
deleted_post_ids: list[PositiveId] # 삭제된 원글 ID (해당 게시글 묶음 전체 제거)
class CongestionAnalysisRequest(BaseModel):
request_id: NonEmptyText # 요청·응답 추적 ID
as_of: AwareDatetime # 조회·집계 기준 시각
spots: list[CongestionSpotInput] # 변경을 전달할 스팟 목록
spots는 스팟 목록, 각 스팟의 post_bundles는 게시글 묶음 목록이다. 게시글 묶음 하나는 원글 한 개와 그 원글의 comments로 구성한다. CongestionPostBundleInput의 post_id, author_key, created_at, content는 원글의 정보이며 comments는 댓글 목록이다.
게시글 묶음은 원글과 일대일로 연결되므로 별도 post_bundle_id를 만들지 않고 원글의 post_id로 식별한다. deleted_post_ids 역시 실제 삭제된 원글 ID를 받으며 해당 게시글 묶음 전체를 제거한다. 댓글은 게시글 묶음 안에 포함되므로 kind, signal_id, 댓글의 별도 post_id는 받지 않는다.
미터기 출력스키마
from pydantic import BaseModel
class TaxiMeterResponse(BaseModel):
cost: int # 택시 금액
success : bool # 성공 여부
영수증 출력스키마
from pydantic import BaseModel
class ReceiptResponse(BaseModel):
amount_cost: int # 송금 금액
transfer_info: str # 이체완료
sender_name: str # 보낸사람 명
send_time: str # 보낸 날짜와 시각
success : bool # 성공 여부
혼잡도 출력스키마
class CongestionResult(BaseModel):
congestion_level: Literal["LOW", "MEDIUM", "HIGH", "UNKNOWN"] # 스팟의 최종 혼잡 단계
cause_code: Literal["ACCIDENT", "DELAY"] | None # 사고·지연 여부 (해당 없으면 null)
cause_summary: Annotated[str, StringConstraints(min_length=1, max_length=40)] | None # 원인 요약 (없으면 null)
report_count: int = Field(ge=0) # 집계에 반영한 작성자 수
last_reported_at: AwareDatetime | None # 마지막 유효 제보 시각 (없으면 null)
class CongestionSpotResult(BaseModel):
spot_id: PositiveId # 스팟 ID
analysis_status: Literal["SUCCESS", "FAILED"] # 스팟 단위 처리 성공 여부
result: CongestionResult | None # 스팟 분석 결과 (실패하면 null)
class CongestionAnalysisResponse(BaseModel):
request_id: NonEmptyText # 요청·응답 추적 ID
as_of: AwareDatetime # 조회·집계 기준 시각
spots: list[CongestionSpotResult] # 스팟별 분석 결과 목록
스키마는 필드 형식을 나타낸다. 아래의 중복 금지, 시간 범위, 상태별 필드 조건도 요청·응답 검증에 적용한다.
혼잡도 연동 개요
Spring은 60초마다 변경된 게시글 묶음의 현재 원문을 보내고, FastAPI는 해당 게시글 묶음을 다시 분석한 뒤 스팟별 혼잡도와 혼잡 원인을 응답한다.
AI는 게시글 묶음별 분석 결과를 보관한다. 변경되지 않은 게시글 묶음은 보관 중인 결과를 재사용하고, 최근 20분의 유효한 단계 제보를 코드로 집계한다. 원문은 요청에 포함되므로 이전 요약만으로 새 댓글을 해석하지 않는다. 변경이 없는 주기에는 LLM을 호출하지 않고 만료된 제보를 제외해 재집계한다.
용어 정리
- 스팟(spot): 혼잡도를 표시하는 지도상의 위치이자 해당 위치에 속한 게시글 묶음의 집합.
spot_id로 연결하며 백엔드congestion_spots의 행에 대응한다. - 신호(signal): 게시글 또는 댓글 한 건. 게시글은
post_id, 댓글은comment_id로 식별한다. - 게시글 묶음: 게시글 1개와 그 게시글에 달린 댓글의 묶음.
post_id로 식별하며 전달·재분석·교체의 단위다. - 단계 제보: 신호에서 현재 상황에 대한
LOW·MEDIUM·HIGH가 판정되어 스팟의 혼잡도 집계에 쓰이는 제보다. 질문·과거 회상·해석이 불확실한 내용은 단계 제보로 세지 않는다.
기능3 혼잡도 규칙
- 요청·응답은
Content-Type: application/json을 사용한다. - 모든 필드는 필수 키다.
| None필드만null을 허용하며 빈 목록은[]로 보낸다. - 시각은 시간대를 포함한 ISO 8601 형식이다.
as_of - 20분 < created_at <= as_of인 신호만 현재 집계에 사용한다. created_at은 최초 작성 시각이며 수정해도 바꾸지 않는다. 본문 수정만으로 제보의 유효 시간을 연장하지 않는다.spot_id,post_id,comment_id는 Spring의 양의 정수 ID다. 게시글과 댓글은 ID 체계가 별도이므로 게시글 100번에 댓글 100번이 달릴 수 있다.request_id,author_key,content는 공백만으로 구성될 수 없다.- 게시글·댓글의 생성·수정·삭제를 다룬다. 대댓글은 이번 계약의 범위에 포함하지 않는다.
A. 요청과 작성 규칙 — Spring
요청 필드
| 필드 | 의미 |
|---|---|
request_id |
요청·응답 추적 ID. 결과 재사용을 보장하는 키는 아니다. |
as_of |
이번 조회와 집계의 공통 기준 시각. 최근 20분은 이 시각으로 계산한다. |
spots |
변경을 전달할 스팟 목록. 변경이 없으면 []로 보내 기존 판정을 재집계한다. |
spots[].spot_id |
게시글 묶음이 속한 스팟 ID. |
spots[].post_bundles |
변경된 게시글 묶음 목록. 포함된 게시글 묶음만 교체하며, 포함되지 않은 게시글 묶음은 기존 판정을 유지한다. |
post_bundles[].post_id |
게시글 묶음을 식별하는 게시글 ID. |
post_bundles[].author_key |
원글 작성자 구분 키. 같은 작성자는 댓글·스팟·주기에 걸쳐 같은 값을 사용한다. |
post_bundles[].created_at |
원글의 최초 작성 시각. 수정 시각으로 바꾸지 않는다. |
post_bundles[].content |
원글의 현재 본문. 오래된 원글도 댓글 해석에 필요하면 포함한다. |
post_bundles[].comments |
해당 게시글 묶음에서 최근 20분 안에 작성됐고 현재 삭제되지 않은 댓글 전체. 신규 댓글만 보내지 않는다. |
comments[].comment_id |
댓글 ID. 부모 게시글은 이 댓글을 포함하는 post_bundles[] 항목으로 구분한다. |
comments[].author_key |
댓글 작성자 구분 키. 원글의 작성자 키와 같은 기준을 사용한다. |
comments[].created_at |
댓글의 최초 작성 시각. 수정 시각으로 바꾸지 않는다. |
comments[].content |
댓글의 현재 본문. |
spots[].deleted_post_ids |
원글이 삭제되어 게시글 묶음의 분석 결과 전체를 제거할 게시글 ID 목록. 삭제가 없으면 []. |
요청 작성 규칙
- 변경된 게시글 묶음만 전달한다. 신규 게시글, 원글 수정, 댓글 추가·수정·삭제가 발생한 게시글 묶음을 보낸다. 한 주기에 여러 변경이 있어도 게시글 묶음당 현재 상태 한 건만 보낸다. 이전 요청에서 처리가 확인되지 않은 변경도 포함한다.
- 게시글 묶음 단위로 교체한다.
post_bundles에 포함된 게시글 묶음은 원글과 최근 20분의 현재 댓글 전체를 담는다.comments=[]는 그 범위에 댓글이 없다는 뜻이다. 요청에 없는 게시글 묶음은 기존 판정을 유지하되 시간 만료는 계속 적용한다. - 원글을 문맥으로 함께 전달한다. 원글이 20분보다 오래됐어도 유효한 댓글이 있으면 포함한다. 오래된 원글은 댓글 해석에만 사용하고 단계 제보 수나 마지막 제보 시각에 반영하지 않는다. 범위 밖 댓글을 참조해야만 해석되는 내용은 추측하여 제보로 만들지 않는다.
- 삭제를 반영한다. 댓글 삭제는 현재
comments에서 제외하여 전달한다. 원글 삭제는deleted_post_ids로 전달하며 해당 게시글 묶음 전체를 집계에서 제거한다. 이미 제거된 ID를 다시 전달해도 성공으로 처리한다. - 스팟과 작성자를 일관되게 연결한다. 같은 게시글 묶음을 여러 스팟에 배정하지 않는다. 같은 작성자는 게시글·댓글·스팟·주기에 걸쳐 같은
author_key를 사용한다. 작성자별 사전 제거는 하지 않는다. 이번 계약에서는 게시글 묶음의 스팟 변경을 다루지 않는다. - 중복과 순서를 확인한다. 한 요청에서
spot_id, 게시글 ID, 댓글 ID는 각각 중복되지 않는다. 같은 게시글 ID를post_bundles와deleted_post_ids에 동시에 넣지 않는다. 댓글은created_at,comment_id오름차순으로 보낸다. - 변경이 없어도 호출한다.
spots=[]로 호출하면 보관 중인 판정을 시간 기준으로 재집계한다. 신규·수정 원문 분석이나 원인 요약을 위한 LLM 호출은 하지 않는다. 만료로 요약의 근거가 사라지면 해당 요약도 제거한다. - 요청을 겹쳐 보내지 않는다. 이전 요청의 처리가 종료된 후 다음 요청을 처리한다. AI도 상태 반영을 순서대로 처리하며 이전
as_of의 요청이 최신 상태를 덮어쓰지 않게 한다. 실패 시에는 다음 주기의 현재 원문으로 다시 요청한다. 재전송한 게시글 묶음은 추가 누적하지 않고 교체한다. - 본문과 게시글 묶음을 임의로 자르지 않는다. 본문은 최대 500자다. 원본 서비스의 상한과 일치하는지 연동 전에 확인한다. HTTP 바이트·게시글 묶음 수·댓글 수 상한도 연동 전에 확정한다. 일반 요청은 완전한 게시글 묶음 단위로 나누어 순차 전송할 수 있지만 하나의 게시글 묶음을 여러 요청으로 나누지 않는다.
요청에 게시글 묶음 A·B만 있고 C가 없다면 A·B만 교체한다. C의 기존 분석 상태는 유지하며, C의 신호 중 유효시간 안의 단계 제보는 계속 집계에 사용한다. 요청에서 빠졌다는 이유로 삭제하지 않는다.
B. 응답과 결과 적용
응답 필드
| 필드 | 의미 |
|---|---|
request_id |
처리한 요청의 값을 그대로 반환한다. |
as_of |
요청의 분석 기준 시각을 그대로 반환한다. 응답 생성 완료 시각이 아니다. |
spots |
이번 요청의 스팟과 보관 중인 유효 제보가 있는 스팟의 결과. 마지막 제보가 이번에 만료·삭제된 스팟도 포함한다. |
spots[].spot_id |
Spring이 결과를 저장할 스팟 ID. |
spots[].analysis_status |
스팟 단위 처리 결과. SUCCESS 또는 FAILED. |
spots[].result |
정상 결과 객체. 실패하면 null. |
result.congestion_level |
최종 혼잡 단계 LOW, MEDIUM, HIGH, UNKNOWN. |
result.cause_code |
원문에서 사고 또는 지연으로 판단되면 ACCIDENT 또는 DELAY. 그 외 원인이거나 근거가 없으면 null. |
result.cause_summary |
원문에 근거한 대표 원인 1개의 정형 문장. 공백·문장부호 포함 40자 이내. 원인 근거가 없거나 제보가 충돌하면 null. |
result.report_count |
집계에 반영한 서로 다른 작성자 수. 스팟 안에서 작성자별 최신 유효 단계 제보 한 건을 반영한다. |
result.last_reported_at |
집계에 사용한 단계 제보 중 가장 최근 최초 작성 시각. 유효 제보가 없으면 null. |
상태별 필드 조건과 Spring 처리
| 상태 | 필드 조건 | Spring 처리 |
|---|---|---|
SUCCESS + LOW/MEDIUM/HIGH |
result 있음, report_count >= 1, last_reported_at 있음. |
정상 결과를 저장한다. |
SUCCESS + UNKNOWN, 제보 있음 |
최다 단계 동률. report_count >= 2, last_reported_at 있음, cause_code=null, cause_summary=null. |
제보가 있지만 등급을 정하지 못한 상태로 저장한다. |
SUCCESS + UNKNOWN, 제보 없음 |
report_count=0, last_reported_at=null, cause_code=null, cause_summary=null. |
기존 혼잡도 표시를 내린다. 스팟 자체는 삭제하지 않는다. |
FAILED |
result=null. 스팟의 변경 적용 또는 결과 구성에 실패했다. |
해당 스팟에 보낸 변경 전체를 다음 주기에 현재 원문으로 다시 보낸다. |
UNKNOWN은 정상 처리 결과이며 기술적 실패가 아니다. PARTIAL은 사용하지 않는다. 같은 스팟의 변경은 모두 성공한 경우에만 반영하고, 실패한 스팟은 변경 적용 전 상태를 유지한다. 다른 스팟은 성공할 수 있다. Spring은 SUCCESS인 스팟에 대해서만 해당 요청에 실은 변경을 처리 완료로 기록하며, 요청 이후 발생한 변경은 다음 주기에 보낸다.
cause_code는 cause_summary가 없으면 항상 null이다. cause_summary가 있어도 원인이 사고·지연에 해당하지 않으면(혼잡·행사 등) cause_code는 null이며, 이 경우 cause_summary만으로 원인을 표시한다.
집계와 표시 만료
- AI는 게시글 묶음별로 신호의 작성자·최초 작성 시각·판정을 보관하여 만료된 제보를 개별적으로 제외한다. 스팟별 작성자당 최신 단계 제보 한 건을 고른 뒤 최다 단계를 사용한다. 최다 단계가 동률이거나 단계 제보가 없으면
UNKNOWN이다. - 정상 결과의
last_reported_at은as_of - 20분 < last_reported_at <= as_of를 만족한다. 단계 제보 한 건도 반영할 수 있지만 질문을 제보로 세거나 기존 제보의 시각을 연장하지 않는다. - Spring은 마지막 정상 결과의
last_reported_at + 20분에 도달하면 등급·요약 표시를 종료한다. AI 응답을 받지 못해도 종료한다. 응답을 받을 때 이미 만료된 결과는 표시하지 않는다. - 실패 시 기존 정상 결과를 만료 전까지 유지하고 갱신 지연으로 표시한다. 다만 수정·삭제된 신호를 포함할 가능성이 있는 해당 스팟의 기존 결과는 숨기고 확인 불가로 표시한다. 실패가 이전 결과의 유효 시간을 연장하지는 않는다.
- 새
SUCCESS를 받으면 결과를 교체하고 갱신 지연 표시를 해제한다. 같은 스팟이 응답에 두 번 있거나 성공·실패 필드 조건을 위반하면 응답 오류로 처리한다. - 만료·삭제 안내 응답이 유실될 수 있으므로 Spring의 자체 만료도 유지한다. 요청에 포함한 스팟은 빈 목록이어도 결과를 받는다. 정상 응답에 없는 스팟은 이번 갱신 대상이 아니다.
C. 분석 상태 복구
평상시에는 변경된 게시글 묶음만 보낸다. 최초 시작 또는 AI의 분석 상태가 유실된 경우에만 현재 유효한 게시글 묶음 전체를 같은 요청 스키마로 다시 보낸다.
- 상태가 유실된 AI는 남은 분석 상태도 초기화하고 복구 대기 상태로 전환한다. 해당 요청을 적용하지 않고 HTTP
409,code="STATE_RESET"을 반환한다. 빈 정상 결과로 상태 유실을 숨기지 않는다. - Spring은
STATE_RESET을 받으면 일반 변경 전송을 중단한다. 최근 20분의 현재 게시글·댓글과 댓글 해석에 필요한 원글을 모든 스팟에서 조회하여 다음 요청으로 보낸다.deleted_post_ids는 모두[]이며 유효 자료가 없으면spots=[]다. - AI는
STATE_RESET통보 후 다음 요청을 복구 요청으로 처리한다. 전체 분석에 성공해야 판정을 저장하고 정상 처리로 돌아간다. 하나라도 실패하면 빈 상태와 복구 대기를 유지하고 HTTP503을 반환한다. 재시작으로 복구 대기 정보까지 잃었다면 다시STATE_RESET을 반환한다. - Spring은 복구 성공 시 이번 응답에 없는 기존 혼잡도 결과도 해제한다. 이는 Spring이 복구 중임을 알고 수행하는 처리이며 일반 응답에는 적용하지 않는다. 복구 실패 시 다음 주기에 최신 자료 전체로 재시도한다.
- 초기 계약에서는 단일 Spring 호출 흐름이 요청을 순차 전송하고 전체 복구를 한 요청으로 완료한다. 복구 자료를 나누어 보내지 않는다. 예상 전체 유효 자료가 수신 상한과 시간 예산에 들어오는지 연동 전에 확인한다.
저장소 종류와 원문 보관 방식은 AI 내부에서 정한다. 정상 동작에 필요한 분석 상태를 잃으면 위 복구 절차를 따른다.
D. 표시와 오류 처리
지도 표시
| 상황 | 표시 기준 |
|---|---|
| 유효한 정상 등급 | 등급·원인 요약·마지막 제보 시각 표시 |
제보가 있는 UNKNOWN |
제보가 있지만 등급을 정하지 못한 상태 |
| 제보 없는 정상 결과 | 혼잡도 표시 종료 |
| 실패했지만 기존 정상 결과를 유지할 수 있음 | 기존 결과에 갱신 지연 표시 |
| 기존 결과가 만료됐거나 수정·삭제로 유효성을 확인할 수 없음 | 혼잡 등급 없이 확인 불가 표시 |
Spring→FE API의 실제 표시 필드명은 해당 API에서 확정한다.
시간 예산
기존의 AI 50초, Spring 응답 대기 55초를 초기 시간 예산으로 유지한다. 실제 게시글 묶음·댓글 수로 검증한 뒤 조정한다. AI는 예산 내 완료하지 못한 스팟을 FAILED로 반환한다. 네트워크 오류 등으로 응답 자체가 오지 않는 경우도 Spring이 처리한다. 실패한 스팟의 변경 전체를 다음 주기에 다시 보내며, HTTP 오류나 응답 유실이면 해당 요청 전체의 변경을 다시 보낸다. 재전송 시 원문과 as_of는 현재 기준으로 갱신한다.
HTTP 상태
| HTTP | 의미 |
|---|---|
200 |
요청 처리 완료. 일반 요청의 스팟별 성공·실패는 analysis_status로 구분 |
401 / 403 |
서버 간 인증·권한 오류 |
409 |
STATE_RESET: 전체 분석 상태 복구 필요. STALE_REQUEST: 최신 적용 상태보다 오래된 요청으로, 현재 자료로 다시 요청 |
413 |
합의한 수신 상한 초과. 일반 요청은 완전한 게시글 묶음 단위로 나눌 수 있음 |
422 |
필드·ID·시각·중복 등 계약 위반 |
429 |
처리량 한도 초과. 다음 허용 시점에 재전송 |
500 / 503 |
서버 오류 또는 전체 복구 실패. 현재 자료로 재시도 |
오류 본문은 code, message 두 문자열로 통일한다. 오류 시 재전송하더라도 게시글·댓글 ID는 유지한다.
3. API 역할 및 연동 관계
1. 미터기 (/taxi_meter)
- 주요 기능: 미터기 사진에서 택시 금액을 추출한다.
- 핵심 역할: 미터기 사진에서 택시 금액을 추출하는 역할
- 입출력:
- 입력:
image_URL,ride_channel_id - 출력:
cost,success
- 입력:
2. 영수증 (/receipt)
- 주요 기능: 영수증 사진에서 영수증 정보를 추출한다.
- 핵심 역할: 영수증 사진에서 송금 금액, 이체 완료 정보, 보낸사람 명, 보낸 날짜와 시각을 추출하는 역할
- 입출력:
- 입력:
ride_channel_id,image_URL - 출력:
amount_cost,transfer_info,sender_name,send_time,success
- 입력:
3. 혼잡도 분석 (/congestion-analyses)
- Spring 역할: 게시글·댓글 원본 저장(Source of Truth), 스팟 배정, 게시글 묶음 변경·삭제 전달, 최종 스팟 결과 저장·표시·만료, 실패 재전송과 전체 복구 요청.
- AI 역할: 전달된 게시글 묶음 재분석, 게시글 묶음별 판정 교체·보관, 삭제·시간 만료 반영, 스팟별 혼잡도와 원인 요약 계산.
- 연동 방향: Spring → AI 단방향. 결과는 같은 요청의 응답으로 돌아온다.
4. API 호출 및 응답 예시
/taxi_meter — POST
Request
{
"image_URL": "https://example.com/image.jpg",
"ride_channel_id": "1234567890"
}
Response
{
"success": true,
"cost": 10000
}
/receipt — POST
Request
{
"image_URL": "https://example.com/image.jpg",
"ride_channel_id": "1234567890"
}
Response
{
"success": true,
"amount_cost": 10000,
"transfer_info": "이체완료",
"sender_name": "카테부",
"send_time": "2026-09-07T08:15:00+09:00"
}
/congestion-analyses — POST, Spring → AI
아래 첫 예시(정상 흐름)는 현재 운영 중인 스팟 수(6개) 전체를 기준으로 한 실제 응답 형태를 보여준다. 이후 개별 규칙을 설명하는 예시들은 해당 규칙과 관련된 스팟만 표시하지만, 실제 응답에는 다른 스팟의 결과도 함께 spots 배열에 담긴다.
요청 — 일부 스팟만 변경된 정상 주기
이번 주기에 변경이 발생한 스팟은 102, 105, 106 세 곳뿐이다. 스팟 102는 게시글 100에 댓글 201이 추가되고 삭제된 게시글 90이 있다. 스팟 105는 신규 게시글 520이 올라왔다. 스팟 106은 게시글 610의 분석이 실패하는 경우를 보여준다. 변경 없는 스팟(101, 103, 104)은 spots에 담지 않는다.
{
"request_id": "req-20260922-110000",
"as_of": "2026-09-22T11:00:00+09:00",
"spots": [
{
"spot_id": 102,
"post_bundles": [
{
"post_id": 100,
"author_key": "a1",
"created_at": "2026-09-22T10:45:00+09:00",
"content": "공연이 끝나서 출구 앞에 사람이 몰렸어요.",
"comments": [
{
"comment_id": 201,
"author_key": "a2",
"created_at": "2026-09-22T10:59:40+09:00",
"content": "지금도 관람객이 많아서 줄 서서 움직여요."
}
]
}
],
"deleted_post_ids": [90]
},
{
"spot_id": 105,
"post_bundles": [
{
"post_id": 520,
"author_key": "b1",
"created_at": "2026-09-22T10:58:10+09:00",
"content": "사고가 나서 통행이 지연되고 있어요.",
"comments": []
}
],
"deleted_post_ids": []
},
{
"spot_id": 106,
"post_bundles": [
{
"post_id": 610,
"author_key": "c1",
"created_at": "2026-09-22T10:59:00+09:00",
"content": "여기도 사람이 많아지는 중이에요.",
"comments": []
}
],
"deleted_post_ids": []
}
]
}
응답 — 6개 스팟의 결과
요청에 없던 101, 103, 104는 보관 중인 유효 제보를 시간 기준으로 재집계한 결과이며, 102·105·106은 이번 요청으로 재분석한 결과다. 105는 원문이 사고로 판정되어 cause_code="ACCIDENT"가 채워졌고, 102는 원인이 혼잡(행사)이라 cause_summary만 있고 cause_code는 null이다. 106은 분석에 실패한 예시다.
{
"request_id": "req-20260922-110000",
"as_of": "2026-09-22T11:00:00+09:00",
"spots": [
{
"spot_id": 101,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "LOW",
"cause_code": null,
"cause_summary": null,
"report_count": 1,
"last_reported_at": "2026-09-22T10:52:10+09:00"
}
},
{
"spot_id": 102,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "HIGH",
"cause_code": null,
"cause_summary": "공연 종료로 인파가 몰리고 있어요.",
"report_count": 2,
"last_reported_at": "2026-09-22T10:59:40+09:00"
}
},
{
"spot_id": 103,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "MEDIUM",
"cause_code": null,
"cause_summary": null,
"report_count": 1,
"last_reported_at": "2026-09-22T10:47:30+09:00"
}
},
{
"spot_id": 104,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "UNKNOWN",
"cause_code": null,
"cause_summary": null,
"report_count": 2,
"last_reported_at": "2026-09-22T10:55:00+09:00"
}
},
{
"spot_id": 105,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "HIGH",
"cause_code": "ACCIDENT",
"cause_summary": "사고로 통행이 지연되고 있어요.",
"report_count": 1,
"last_reported_at": "2026-09-22T10:58:10+09:00"
}
},
{
"spot_id": 106,
"analysis_status": "FAILED",
"result": null
}
]
}
Spring은 106에 보냈던 변경 전체를 다음 주기에 현재 원문으로 다시 보낸다. 나머지 스팟은 각자의 last_reported_at + 20분에 도달하면 표시를 종료하며, 그 전에도 일부 제보가 만료되면 주기적 재집계 결과로 교체된다.
요청 — 원글 수정과 댓글 삭제
게시글 100의 본문이 수정되고 댓글 201이 삭제되었다. 원글의 최초 작성 시각은 유지하며 현재 댓글이 없으므로 comments=[]로 보낸다. AI는 이전 댓글 판정까지 포함한 게시글 묶음의 판정을 교체한다.
{
"request_id": "req-20260922-110100",
"as_of": "2026-09-22T11:01:00+09:00",
"spots": [
{
"spot_id": 102,
"post_bundles": [
{
"post_id": 100,
"author_key": "a1",
"created_at": "2026-09-22T10:45:00+09:00",
"content": "장소를 잘못 적었어요. 다른 출구 이야기입니다.",
"comments": []
}
],
"deleted_post_ids": []
}
]
}
응답 — 스팟 분석 실패
수정된 게시글 묶음을 분석하지 못한 예시다. Spring은 다음 주기에 해당 스팟에 보냈던 변경 전체를 최신 상태로 다시 보낸다. 이 경우 기존 결과는 수정·삭제된 신호에 의존하므로 표시를 숨긴다.
{
"request_id": "req-20260922-110100",
"as_of": "2026-09-22T11:01:00+09:00",
"spots": [
{"spot_id": 102, "analysis_status": "FAILED", "result": null}
]
}
요청 — 원글 삭제
원글이 삭제되면 게시글 묶음 전체의 분석 결과를 제거한다. 삭제만 있는 스팟도 응답에 포함한다.
{
"request_id": "req-20260922-110200",
"as_of": "2026-09-22T11:02:00+09:00",
"spots": [
{"spot_id": 102, "post_bundles": [], "deleted_post_ids": [100]}
]
}
요청·응답 — 변경 없이 제보가 만료되는 경우
앞선 예시와 별개로, 스팟 102의 마지막 제보가 10:59:40이고 11:19:00 집계까지 유효했다고 가정한다. 다음 요청에서는 제보가 모두 만료된다.
{
"request_id": "req-20260922-112000",
"as_of": "2026-09-22T11:20:00+09:00",
"spots": []
}
{
"request_id": "req-20260922-112000",
"as_of": "2026-09-22T11:20:00+09:00",
"spots": [
{
"spot_id": 102,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "UNKNOWN",
"cause_code": null,
"cause_summary": null,
"report_count": 0,
"last_reported_at": null
}
}
]
}
유효 제보가 없는 정상 결과이므로 Spring은 혼잡도 표시를 내린다. 삭제 또는 무관한 내용으로의 수정으로 제보가 없어졌을 때도 같은 결과 형식을 사용한다.
오류·복구 요청 — AI 분석 상태 유실
AI가 일반 요청에 HTTP 409로 응답한다.
{"code": "STATE_RESET", "message": "현재 유효한 게시글 묶음 전체의 재전송이 필요합니다."}
Spring은 현재 분석 대상 전체를 같은 요청 스키마로 보낸다. 아래는 전체 대상이 게시글 300과 댓글 301뿐인 경우다. 원글은 20분보다 오래됐으므로 문맥으로만 사용한다.
{
"request_id": "req-20260922-113000-recovery",
"as_of": "2026-09-22T11:30:00+09:00",
"spots": [
{
"spot_id": 103,
"post_bundles": [
{
"post_id": 300,
"author_key": "a3",
"created_at": "2026-09-22T10:00:00+09:00",
"content": "출구 앞이 붐비네요.",
"comments": [
{
"comment_id": 301,
"author_key": "a4",
"created_at": "2026-09-22T11:29:00+09:00",
"content": "지금은 사람이 별로 없어요."
}
]
}
],
"deleted_post_ids": []
}
]
}
{
"request_id": "req-20260922-113000-recovery",
"as_of": "2026-09-22T11:30:00+09:00",
"spots": [
{
"spot_id": 103,
"analysis_status": "SUCCESS",
"result": {
"congestion_level": "LOW",
"cause_code": null,
"cause_summary": null,
"report_count": 1,
"last_reported_at": "2026-09-22T11:29:00+09:00"
}
}
]
}
Spring은 복구 응답에 없는 기존 스팟 결과를 해제하고 이후에는 다시 변경된 게시글 묶음만 보낸다.
혼잡도 연동 흐름
sequenceDiagram
participant S as Spring
participant A as AI 서버
participant L as LLM
loop 60초마다, 요청은 순차 처리
S->>A: 변경된 게시글 묶음 원문, 삭제된 게시글 ID
alt 분석 상태 유실
A-->>S: 409 STATE_RESET
Note over A: 빈 분석 상태에서 복구 대기
S->>A: 현재 유효한 게시글 묶음 전체 (동일 스키마)
A->>L: 전달된 게시글 묶음 분석
L-->>A: 신호별 판정
A->>A: 전체 성공 시 상태 교체·스팟 집계
A-->>S: 200 스팟 결과 또는 503 복구 실패
else 정상 상태
opt 변경된 게시글 묶음이 있음
A->>L: 해당 게시글 묶음만 재분석
L-->>A: 신호별 판정
end
A->>A: 스팟별 변경 적용·삭제·만료 반영·집계
A-->>S: 200 스팟별 SUCCESS 또는 FAILED
end
Note over S: 정상 결과 저장·표시·만료, 실패한 변경 재전송
end