AI 서비스 초기 구성 #4
Conversation
루트에서 Application 제거하고 common/collector/app 3개 서브모듈로 분리한다. - common: 공유 엔티티, DTO, 예외 정의 - collector: 서울시 Open API 수집 파이프라인 - app: Spring Boot 진입점, 컨트롤러, 시큐리티 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DDL 스키마 기준으로 4개 엔티티(PublicServiceReservation, DataSourceCatalog, CollectionHistory, ServiceChangeLog)와 Repository를 common 모듈에 추가한다. CollectionHistory는 생성 시 FAILED로 초기화하여 파이프라인 중단 시에도 이력이 남도록 한다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DDL 스키마 기준으로 4개 엔티티(PublicServiceReservation, DataSourceCatalog, CollectionHistory, ServiceChangeLog)와 Repository를 common 모듈에 추가한다. CollectionHistory는 생성 시 FAILED로 초기화하여 파이프라인 중단 시에도 이력이 남도록 한다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
페이지네이션 전체 수집, 5xx 재시도(지수 백오프), 4xx 즉시 실패, 응답 파싱(JsonNode → DTO) 로직을 구현한다. 재시도 대상은 SeoulApiServerException(5xx)으로 한정하여 4xx 인증 오류가 재시도되는 문제를 방지한다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
드라이버는 런타임 접속 주체인 app 모듈의 책임이며 common은 JPA 엔티티 정의만 담당하므로 DB 드라이버를 알 필요 없음 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
샘플 응답 기준으로 V_MIN/V_MAX(이용시간)를 확정하고 USETMINFO 제거. PublicServiceRowMapper에서 날짜(yyyy-MM-dd HH:mm:ss.S), 이용시간(HH:mm), 좌표(nullable String→BigDecimal), 취소기준일(String→Short) 파싱을 처리한다. 파싱 실패 시 예외 없이 null 반환 후 warn 로그를 남긴다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
common 모듈에 OnSeoulApiException + ErrorCode 추가. SeoulApiException/SeoulApiServerException이 ErrorCode를 통해 HTTP 상태 코드와 에러 식별자를 보유하도록 리팩터링. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
webmvc → web, webclient → webflux, security-oauth2-* → oauth2-* 로 올바른 아티팩트명으로 변경 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
수집 파이프라인 흐름(Mermaid), 주요 컴포넌트, 예외 계층, 설정 항목 문서화. Spring Boot 버전 4.0.x → 3.5.x 오기 수정. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
서울시 API는 HTTP 200이면서 RESULT.CODE가 INFO-200인 경우 "해당하는 데이터가 없음"을 의미한다. 기존 코드는 이를 예외로 던졌으나 빈 목록 반환으로 수정. 테스트 케이스 및 README 추가. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SVCID/SVCNM 누락 시 Optional.empty() 반환으로 Upsert 키 오류 및 NOT NULL 위반 방지. Phase 6 예정 클래스(CollectionService, ChangeLogService) 포함한 모듈 책임 범위를 README·다이어그램에 반영. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
common 모듈이 공유 엔티티까지 담는 God 모듈이 되는 문제를 해결하기 위해 모듈 구조를 재편했다. - domain 모듈 신설: 공용 JPA 엔티티(PublicServiceReservation)와 Repository를 담당 - collector 모듈: 수집 파이프라인 전용 엔티티(CollectionHistory, ServiceChangeLog, DataSourceCatalog)와 관련 Repository/Enums를 내부로 이동 - common 모듈: 전역 예외(ErrorCode, OnSeoulApiException)만 남기고 JPA 의존 제거 - 의존 방향: app → domain, collector, common / collector → domain, common / domain → common - 문서(collector/README.md, on-seoul-api/README.md, docs/architecture.md) 구조도 동기화
네트워크 지연 시 스레드가 무한정 블로킹되는 문제를 방지한다. - SeoulApiProperties에 connectTimeoutMs(10s), responseTimeoutSeconds(30s) 추가 - CollectorConfig의 HttpClient에 connect timeout, response timeout 적용 - getBlockTimeout()으로 전체 재시도 허용 시간을 외부에서 계산 가능하게 노출
block() 무한 대기 및 빈 body로 인한 NPE 가능성을 제거한다. - switchIfEmpty로 빈 body 즉시 PARSE_ERROR 예외 처리 - .timeout()으로 시도당 응답 타임아웃 적용, TimeoutException → COLLECT_API_TIMEOUT 변환 - .block(getBlockTimeout())으로 전체 재시도 포함 block 타임아웃 안전망 추가 - 빈 응답 케이스 테스트 추가
complete/fail/partial 결과 기록 후 재호출 시 IllegalStateException을 발생시켜 의도치 않은 상태 덮어쓰기를 방지한다. - validateNotFinished()로 durationMs 기준 완료 여부 판별 - CollectionStatus 각 값에 Javadoc 추가 - 중복 상태 전이 케이스 4종 테스트 추가
engine, AsyncSessionLocal, Base, get_db를 정의하여 FastAPI 의존성 주입 기반의 비동기 DB 세션을 구성한다.
…L 추가 LangGraph 노드 간 공유 상태(AgentState), 채팅 요청/응답 모델(ChatRequest, ChatResponse), SSE 스트리밍 이벤트 타입(EventType, SSEEvent)을 정의하고, 대화 이력 및 시설 임베딩 벡터 테이블 DDL을 추가한다.
텍스트 임베딩을 위한 Embedder 클래스와 LLM 프롬프트 응답 생성을 위한 Generator 클래스를 구현하고 mock 기반 단위 테스트 4건을 추가했습니다. pytest asyncio_mode=auto 설정 및 테스트용 conftest 환경변수 설정 포함.
- schemas/state.py에 IntentType(str, Enum) 추가 (SQL_SEARCH, VECTOR_SEARCH, MAP, FALLBACK) - AgentState.intent 및 ChatResponse.intent 타입을 str에서 IntentType | None으로 변경 - core/logging.py에서 os.getenv 대신 settings.log_level을 사용하도록 변경
- langchain-anthropic 제거, langchain-google-genai 추가 - llm_provider 기본값 openai → gemini - get_chat_model() 에 provider 파라미터 추가 (gemini | openai) - 임베딩은 DDL vector(1536) 기준으로 OpenAI 고정
- ChatRequest: user_id/session_id → room_id/message_id - ChatResponse: session_id → message_id - SSEEvent: session_id → message_id - AgentState: room_id/message_id 기반, title_needed 플래그·trace 필드 추가 - schemas/trace.py 신규: AgentTrace (chat_agent_traces JSONB 페이로드 모델)
- ddl.sql 제거 (구 설계: conversations + facility_embeddings) - ddl_chat_entities.sql: on_ai DB 전용 (service_embeddings + chat_agent_traces) - HNSW 인덱스 주석 처리로 적재 후 추가 가능하도록 안내
- get_embeddings(): OpenAIEmbeddings → GoogleGenerativeAIEmbeddings - embedding_model 기본값: text-embedding-3-small → models/gemini-embedding-2-preview - output_dimensionality=1536으로 DDL vector(1536) 유지 - _GeminiEmbeddings 래퍼: aembed_documents가 배치를 단일 호출로 합치는 버그를 asyncio.gather + aembed_query 병렬 호출로 우회
- pytest marker: integration → external_api - addopts: 기본 실행 시 external_api 자동 제외 - test_llm_integration.py → test_llm_external_api.py 재작성 - conftest.py: load_dotenv() 추가, 더미 키 정리
- core/exceptions.py: OnSeoulAgentException 기반의 계층형 예외 정의 (LLMException, DatabaseException, WorkflowException, ConfigurationException) - llm/client.py: 설정 오류 시 ConfigurationException 발생 - llm/generator.py & embedder.py: LLM 호출 실패 시 LLMException으로 래핑 - core/database.py: DB 세션 및 연결 오류 시 DatabaseException 발생
f-lab-ted
left a comment
There was a problem hiding this comment.
docs, on-seoul-agent (python) 파트에 대한 리뷰입니다.
| │ └── scheduler/ | ||
| │ └── CollectionScheduler.java # 일 1회 수집 트리거 → CollectionService → ChangeLogService → NotificationService | ||
| │ └── security/ | ||
| │ ├── SecurityConfig.java # Spring Security 설정 (세션 기반) |
There was a problem hiding this comment.
[Major] architecture.md에서 SecurityConfig.java를 "Spring Security 설정 (세션 기반)"으로, SessionAuthFilter.java를 "요청마다 세션 검증"으로 기술하고 있으나, docs/schemas/auth-design.md에서는 JWT 기반 인증(Access Token은 클라이언트 저장, 서버 상태 없음)으로 설계되어 있습니다.
두 문서의 인증 전략이 상충합니다:
- architecture.md → 세션 기반
- auth-design.md → JWT 기반 (Stateless Access Token + Redis Refresh Token)
JWT 설계를 채택한 것이라면 architecture.md의 SessionAuthFilter → JwtAuthFilter, "세션 기반" → "JWT 기반"으로 업데이트가 필요합니다. 설계 문서 간 불일치는 이후 구현 시 혼란을 야기할 수 있습니다.
| class AgentTrace(BaseModel): | ||
| message_id: int | ||
| trace: dict[str, Any] | ||
| created_at: datetime = Field(default_factory=datetime.now) |
There was a problem hiding this comment.
[Major] datetime.now()는 timezone-naive한 datetime을 생성하지만, DDL(ddl_chat_entities.sql)에서 created_at을 TIMESTAMPTZ(timezone-aware)로 정의하고 있습니다. timezone 불일치는 시간 기반 조회/정렬에서 예측 불가능한 결과를 초래할 수 있습니다.
datetime.now(tz=timezone.utc) 또는 datetime.now(tz=UTC)를 사용하세요:
from datetime import datetime, timezone
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))There was a problem hiding this comment.
해당 피드백 반영해두었습니다.
별개로 API 서비스에서는 TIMESTAMP 타입을 사용하여, 타입이 혼재된 상태로 있어 통일하는 작업해두겠습니다!
|
|
||
| def setup_logging() -> None: | ||
| log_level = getattr(logging, settings.log_level.upper(), logging.INFO) | ||
| logging.basicConfig( |
There was a problem hiding this comment.
[Minor] logging.basicConfig는 root logger를 직접 설정하므로, uvicorn·SQLAlchemy·httpx 등 서드파티 라이브러리의 로그 레벨까지 함께 변경됩니다. PR 설명에서 언급한 "정형 로깅"과도 거리가 있습니다.
서비스 전용 logger를 설정하는 것이 더 적합합니다:
def setup_logging() -> None:
logger = logging.getLogger("on_seoul_agent")
logger.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
)
logger.addHandler(handler)| @@ -0,0 +1,34 @@ | |||
| from typing import Any, Optional | |||
There was a problem hiding this comment.
[Minor] from typing import Any, Optional 후 Optional[Any]를 사용하고 있으나, 같은 프로젝트의 다른 파일들(config.py, state.py, chat.py 등)은 모두 str | None 형태의 modern union syntax를 사용합니다. 코드베이스 일관성을 위해 Optional[Any] → Any | None으로 통일하세요.
def __init__(self, message: str, detail: Any | None = None):|
|
||
| google_api_key: str | None = None | ||
| gemini_model: str = "gemini-2.0-flash" | ||
|
|
There was a problem hiding this comment.
[Suggestion] google_api_key와 openai_api_key가 None이어도 앱이 정상 기동되지만, 실제 LLM 호출 시(get_chat_model)에 None이 그대로 API 클라이언트에 전달되어 모호한 에러가 발생합니다.
get_chat_model() 내부에서 선택된 provider의 키가 없으면 ConfigurationException을 명시적으로 발생시키는 가드를 추가하면 디버깅이 수월해집니다:
if selected_provider in ("gemini", "google"):
if not settings.google_api_key:
raise ConfigurationException("GOOGLE_API_KEY is required for Gemini provider")
...There was a problem hiding this comment.
해당 피드백 반영하였습니다. 그 외 다른 설정값 누락에도 적용하여 테스트코드 추가하였습니다.
|
|
||
| @CreationTimestamp | ||
| @Column(name = "created_at", nullable = false, updatable = false) | ||
| private LocalDateTime createdAt; |
There was a problem hiding this comment.
[Bug] @Builder 생성자에 apiServicePath 파라미터가 누락되어 있습니다.
apiServicePath는 @Column(nullable = false)로 선언되어 있으므로, Builder를 통해 엔티티를 생성하면 해당 필드가 null인 채로 persist되어 DB 제약조건 위반(NOT NULL constraint)이 발생합니다.
@Builder
public ApiSourceCatalog(String datasetId, String datasetName, String datasetUrl,
String apiServicePath, boolean active, String tags) {
this.datasetId = datasetId;
this.datasetName = datasetName;
this.datasetUrl = datasetUrl;
this.apiServicePath = apiServicePath;
this.active = active;
this.tags = tags;
}| private String key; | ||
| private String baseUrl = "http://openapi.seoul.go.kr:8088"; | ||
| private int pageSize = 200; | ||
| private int maxRetries = 3; |
There was a problem hiding this comment.
[Operational] key 필드에 유효성 검증이 없습니다.
API 키가 설정되지 않으면(null) WebClient가 경로에 null을 포함한 요청을 보내게 되어, 의미 없는 4xx 응답과 함께 디버깅이 어려운 오류가 발생합니다.
@jakarta.validation.constraints.NotBlank 어노테이션을 추가하고, @Validated와 함께 사용하면 애플리케이션 기동 시점에 설정 누락을 조기 감지할 수 있습니다.
@NotBlank(message = "seoul.api.key 설정이 필요합니다")
private String key;| if (totalCount == 0 || firstPage.getRows().isEmpty()) { | ||
| log.info("서울시 Open API 수집 결과 없으므로 수집 생략. serviceName={}", serviceName); | ||
| return result; | ||
| } |
There was a problem hiding this comment.
[Suggestion] fetchAll() 내부에서 result가 불필요하게 두 번 생성됩니다.
List<PublicServiceRow> result = new ArrayList<>(); // 첫 번째 생성
if (totalCount == 0 || firstPage.getRows().isEmpty()) {
return result;
}
result = new ArrayList<>(totalCount); // 두 번째 생성 (첫 번째는 즉시 GC 대상)result 선언을 early return 이후로 옮기면 불필요한 객체 생성을 피할 수 있습니다.
if (totalCount == 0 || firstPage.getRows().isEmpty()) {
return List.of();
}
List<PublicServiceRow> result = new ArrayList<>(totalCount);
result.addAll(firstPage.getRows());
개요
AI 서비스의 기반이 되는 외부 LLM 연동(Gemini/GPT) 환경과 공통 예외 체계를 구축했습니다.
구현 항목
프로젝트 초기 구성 및 인프라
pydantic-settings기반 환경 변수 관리데이터 스키마 및 DB 설계 (Phase 2)
room_id/message_id기반 요청/응답 모델 정의AgentState정의(LangChain에서는 미사용)chat_agent_tracesJSONB 모델링on_aiDB 전용 테이블(service_embeddings,chat_agent_traces) 및 pgvector 확장 적용LLM 클라이언트 및 유틸리티 (Phase 3)
llm_provider설정에 따라 Gemini(Google)와 GPT(OpenAI)를 동적으로 전환하는get_chat_model구현LLMException: API 호출 및 쿼터 제한 오류 처리DatabaseException: DB 세션 및 쿼리 오류 처리ConfigurationException: 설정 누락 및 공급자 미지원 오류 처리테스트 및 검증
pytest를 사용하여 실제 LLM API 호출 검증