Conversation
- User: OAuth2 소셜 계정 정보 (provider, provider_id, email, nickname, status) - ChatRoom: 사용자 대화 세션 단위 - ChatMessage: 메시지 단위 저장 (USER/ASSISTANT role) - UserStatus / ChatMessageRole enum으로 타입 안전성 확보 - ErrorCode에 UNAUTHORIZED / FORBIDDEN / INVALID_TOKEN / EXPIRED_TOKEN / INVALID_REFRESH_TOKEN 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- JwtProvider: HS256 Access Token(15분) / Refresh Token(7일) 생성·검증
- JwtAuthenticationFilter: Bearer 토큰 파싱 + SecurityContext 설정
- OAuth2LoginSuccessHandler: 소셜 로그인 후 users upsert + JWT 발급 + Redis(RT:{userId}) 저장
- AuthService: Token Rotation — 갱신 시 구 토큰 즉시 폐기 후 신 토큰 발급
- POST /auth/token/refresh, POST /auth/logout 엔드포인트 추가
- SecurityConfig: /admin/** 인증 필수, STATELESS 세션, 401/403 JSON 응답
- GlobalExceptionHandler: OnSeoulApiException → JSON 응답
- SUSPENDED/DELETED 계정 로그인·갱신 차단
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SecurityConfig: SessionCreationPolicy STATELESS → IF_REQUIRED OAuth2 Code Flow의 state 검증을 위해 세션 필요 - SecurityConfig: /oauth2/authorization/**, /login/oauth2/code/** permitAll 추가 - OAuth2LoginSuccessHandler: providerId 추출 시 char[] ClassCastException 수정 (String.valueOf → Object.toString() 방어 처리) - OAuth2LoginSuccessHandler: Kakao 중첩 속성(kakao_account/properties) 파싱 추가 - application.yml: Google user-name-attribute id → sub (OIDC openid 스코프 호환) - application.yml: Google/Kakao registration 및 provider 블록 추가 - application-test.yml: Kakao provider stub 추가 - OAuth2LoginSuccessHandlerIntegrationTest: Spring 컨텍스트 없이 핸들러 직접 검증 (Google 신규/재로그인, SUSPENDED 차단, Kakao 속성 파싱, email null 허용) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## 모듈 구조 변경
- collector 모듈 제거 (settings.gradle에서 제외)
- application 모듈 신설 — 유스케이스 구현체 (spring-tx만 의존)
- adapter 모듈 신설 — 어댑터 단일 모듈 (Spring/JPA/Redis/WebClient)
- domain 모듈 정리 — 순수 POJO + port 인터페이스 (프레임워크 무의존)
- app 모듈 — OnSeoulApiApplication.java + yml만 유지
## domain (프레임워크 무의존)
- model/: User, ChatRoom, ChatMessage, PublicServiceReservation 등 순수 POJO
- port/in/: SocialLoginUseCase, RefreshTokenUseCase, LogoutUseCase, CollectDatasetUseCase
- port/out/: LoadUserPort, SaveUserPort, TokenIssuerPort, RefreshTokenStorePort,
SeoulDatasetFetchPort, GeocodingPort 등
## application (spring-tx만 의존)
- SocialLoginService, RefreshTokenService, LogoutService
- CollectDatasetService, GeocodingService, UpsertService
## adapter/in/
- security/: JjwtTokenIssuer(TokenIssuerPort 구현), JwtAuthenticationFilter, SecurityConfig,
OAuth2LoginSuccessHandler (UseCase 호출로 슬림화)
- web/: AuthController, CollectionController, GlobalExceptionHandler
- scheduler/: CollectionScheduler
## adapter/out/
- persistence/: UserJpaEntity+Adapter+Mapper, 동일 패턴으로 ChatRoom/ChatMessage/
PublicServiceReservation/ApiSourceCatalog/CollectionHistory/ServiceChangeLog
- redis/: RefreshTokenRedisAdapter
- seoulapi/: SeoulOpenApiAdapter
- kakao/: KakaoGeocodingAdapter
## 품질 게이트
- HexagonalArchTest: domain→Spring/JPA 금지, application→adapter 금지 (ArchUnit)
- ./gradlew build, test 전체 통과
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- on-seoul-api/README.md: 모듈 구조를 5모듈 헥사고날(common/domain/application/adapter/app)로 갱신, collector 제거, 의존 방향 다이어그램 추가 - on-seoul-api/app/README.md: app 모듈이 부트스트랩만 담당하도록 재작성. 주요 컴포넌트 위치(adapter/in/security, adapter/in/web, application/service), 토큰 정책, SecurityConfig 규칙, 예외 코드표, 인증 흐름 시퀀스 다이어그램 반영 - docs/architecture.md: API Service 섹션을 헥사고날 구조로 교체. collector 모듈 제거, 포트/어댑터 설명 추가, 의존 방향 다이어그램 추가 - on-seoul-api/docs/hexagonal-architecture.md: 전환 완료로 삭제 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
바운디드 컨텍스트 4개(인증/수집/채팅/AI에이전트)와 각 도메인의 애그리거트 경계·관계를 Mermaid 다이어그램으로 문서화 - 바운디드 컨텍스트 개요 (graph LR) - 인증 도메인: User 애그리거트 (classDiagram) - 수집 도메인: ApiSourceCatalog / CollectionHistory / PublicServiceReservation 3개 애그리거트 + ServiceChangeLog 엔티티 (classDiagram) - 채팅 도메인: ChatRoom 루트 + ChatMessage 엔티티 (classDiagram) - AI 에이전트 도메인: 외부 BC, 포트 인터페이스 계약 (graph LR) - 크로스 도메인 ID 참조 요약 테이블 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ChatAgentTrace (on_ai DB): LangGraph 실행 메타 저장 애그리거트 - messageId: on_data.chat_messages 논리 참조 (DB 다름, 물리 FK 없음) - trace: JSONB (node 경로, tool call 결과, 소요 시간) - ASSISTANT 메시지에만 생성, 생성 후 불변 - 바운디드 컨텍스트 개요 다이어그램에 ChatAgentTrace 노드 추가 - 크로스 도메인 참조 테이블에 messageId 논리 참조 항목 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 알림 bounded context 신규 추가 (NotificationSubscription, DeviceToken, NotificationTemplate, NotificationDispatch) - 크로스 도메인 참조 테이블에 알림 도메인 4개 참조 항목 추가 - 섹션 2~6 classDiagram에서 핵심 필드 외 불필요한 타임스탬프·중복 필드 제거 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SendQueryUseCase, SendQueryCommand, SaveChatRoomPort, LoadChatRoomPort, SaveChatMessagePort 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- prepare(): 새 방 생성 또는 기존 방 재사용 후 USER 메시지 저장 - saveAnswer(): 스트림 완료 후 ASSISTANT 메시지 저장 (nextSeq 채번) - ErrorCode: AI_SERVICE_ERROR(502), CHAT_ROOM_NOT_FOUND(404) 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ChatPersistenceAdapter: SaveChatRoomPort, LoadChatRoomPort, SaveChatMessagePort 구현 - ChatMessageJpaRepository: native query로 chat_message_seq 채번 - AiServiceAdapter: ServerSentEvent<String> 타입 파라미터로 한글 토큰 올바르게 디코딩 - AiServicePort: Flux 타입이 domain에 반응형 의존성을 끌지 않도록 adapter 내부 인터페이스로 선언 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ChatController: SseEmitter로 AI 서비스 스트림 릴레이, onError 시 SSE error 이벤트 전송 - QueryRequest: roomId(nullable) + question(@notblank) - application.yml: ai.service.url, stream-timeout-seconds 설정 추가 - AiServiceAdapterTest: 정상/오류/keep-alive 필터링 4개 케이스 - ChatControllerTest: 성공·에러·미인증·빈질의 5개 케이스 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
모든 코드가 domain/application/adapter 모듈로 이식 완료된 상태였음. 중복 코드 18개 소스 파일 + 9개 테스트 파일 삭제. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CollectorApplication: @SpringBootApplication + @EnableScheduling (배치 전용 부트) - CollectionScheduler: adapter/in/scheduler → collector 패키지로 이동 - collector/application.yml: DB/Seoul API/Kakao 설정 (Web/Security 의존 없음) - collector/build.gradle: adapter·application 의존 + bootJar → collector.jar - OnSeoulApiApplication: @EnableScheduling 제거 (스케줄링은 collector만 담당) app.jar(Web API) / collector.jar(배치) 두 산출물로 분리됨 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
application(유스케이스 레이어)과의 명칭 혼동 해소. bootstrap은 Web API 전용 부트스트랩 역할을 명확히 표현. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- architecture.md: adapter/in/scheduler 제거, bootstrap+collector 이중 부트 구조 반영 - on-seoul-api/README.md: 모듈 구조·의존 관계·실행 명령어 업데이트 - bootstrap/README.md: app→bootstrap 리네임, ChatController·SendQueryService 항목 추가, ai.service 설정 추가 - collector/README.md: 옛 pre-헥사고날 내용 전면 재작성 — 배치 부트 역할·구조·설정으로 교체 - docs/collector-module-cleanup-plan.md: 작업 완료로 삭제 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
f-lab-ted
left a comment
There was a problem hiding this comment.
헥사고날 전환의 핵심을 중점적으로 리뷰:
- 모듈 분리: common → domain → application → adapter 의존 방향 단방향 강제, bootstrap/collector를 별도 부트스트랩으로 분리
- 포트/어댑터 구현: domain.port.in(유스케이스 인터페이스), domain.port.out(외부 의존 인터페이스), adapter(구현체) 분리
- ArchUnit 검증: domain의 프레임워크 의존 금지, application→adapter 의존 금지 룰 설정
- SSE 릴레이 흐름: ChatController → AiServiceAdapter(WebClient Flux) → SseEmitter 릴레이 → SendQueryService로 이력 저장
잘한 점
헥사고날 아키텍처 전환이 교과서적으로 잘 구현되었습니다. domain 모듈의 순수 POJO 유지, ArchUnit을 활용한 의존성 경계 자동 검증, bootstrap/collector 부트스트랩 분리를 통한 Web API와 배치의 독립 기동 등 설계 의도가 명확하고 일관성이 있습니다. SendQueryServiceTest의 테스트 커버리지도 양호합니다.
보완할 점
- ChatController의 아키텍처 위반: Controller가
AiServicePort(어댑터 계층 인터페이스)를 직접 의존하고 있어, SSE 릴레이 로직이 컨트롤러에 누출되었습니다. 이는 헥사고날 아키텍처의 핵심 원칙인 "adapter→application→domain" 단방향 의존을 위반합니다. - SSE 스트림의 saveAnswer() 호출이 트랜잭션 밖에서 실패할 경우: Reactive 콜백 내에서 예외 발생 시 사용자 메시지는 저장되었지만 ASSISTANT 응답이 유실될 수 있습니다.
- SessionCreationPolicy.IF_REQUIRED: JWT 기반 stateless 인증을 사용하면서 세션 생성 정책이 IF_REQUIRED로 설정되어 있어 불필요한 HttpSession이 생성될 수 있습니다.
결론
Request Changes
- ChatController가 adapter 계층의 AiServicePort를 직접 의존하는 구조적 문제와 SSE 콜백에서의 saveAnswer() 에러 처리 미비는 반드시 수정 바람.
- 나머지 리뷰 코멘트에 대해서는 검토 후 반영 여부 결정.
| @@ -0,0 +1,67 @@ | |||
| package dev.jazzybyte.onseoul.adapter.in.web; | |||
|
|
|||
| import dev.jazzybyte.onseoul.adapter.out.aiservice.AiServicePort; | |||
There was a problem hiding this comment.
[Critical] 헥사고날 아키텍처 위반: Controller가 adapter 계층의 AiServicePort를 직접 의존
ChatController(inbound adapter)가 AiServicePort(outbound adapter 패키지에 위치한 인터페이스)를 직접 import하고 있습니다. 이는 이 PR의 핵심 설계 원칙인 adapter → application → domain 단방향 의존을 위반합니다.
현재 구조:
ChatController (adapter.in) → AiServicePort (adapter.out) ← 위반
ChatController (adapter.in) → SendQueryUseCase (domain.port.in) ← 정상
SSE 스트리밍 + 토큰 릴레이 + 이력 저장이라는 오케스트레이션 로직이 컨트롤러에 누출되었습니다. 해결 방안:
AiServicePort를domain.port.out으로 이동하여 도메인 포트로 승격시키거나,- SSE 릴레이 로직 전체를 application 서비스로 이동하여 컨트롤러는 유스케이스 호출만 담당하도록 변경
예를 들어 SendQueryUseCase에 Flux<String> streamAndSave(SendQueryCommand) 같은 메서드를 두고, application 서비스가 AI 호출 + 이력 저장을 모두 오케스트레이션하면 컨트롤러는 SseEmitter 변환만 담당하게 됩니다.
There was a problem hiding this comment.
넵 반영후 ArchTest 통해 확인했습니다!
static final ArchRule inbound_adapter_must_not_depend_on_outbound_adapter
| } | ||
| emitter.completeWithError(error); | ||
| }, | ||
| () -> { |
There was a problem hiding this comment.
[Critical] SSE 완료 콜백에서 saveAnswer() 실패 시 에러 처리 부재
onComplete 콜백 내의 saveAnswer()에서 예외가 발생하면(DB 장애, 트랜잭션 실패 등):
emitter.complete()가 호출되지 않아 클라이언트가 120초 타임아웃까지 대기- 사용자 메시지(USER)는
prepare()에서 이미 커밋되었지만, AI 응답(ASSISTANT)은 유실됨 - 예외가 어디에도 로깅되지 않아 장애 추적 불가
() -> {
try {
sendQueryUseCase.saveAnswer(roomId, answer.toString());
} catch (Exception e) {
log.error("ASSISTANT 응답 저장 실패: roomId={}", roomId, e);
} finally {
emitter.complete();
}
}최소한 위처럼 try-catch-finally로 감싸서 emitter.complete()는 항상 호출되도록 하고, 실패 시 로깅을 추가해야 합니다.
|
|
||
| StringBuilder answer = new StringBuilder(); | ||
|
|
||
| aiServicePort.stream(request.question(), roomId) |
There was a problem hiding this comment.
[Major] Reactive subscribe()를 Servlet 스레드에서 사용 시 Thread-Safety 문제
StringBuilder answer가 Reactive 스트림의 onNext 콜백에서 사용되고, onComplete에서 읽힙니다. WebClient의 Flux는 Reactor의 이벤트 루프(Netty) 스레드에서 실행되므로, SseEmitter.send()와 StringBuilder.append()가 Servlet 컨테이너 스레드가 아닌 Reactor 스레드에서 호출됩니다.
SseEmitter는 Spring MVC에서 비동기 응답을 지원하지만, Reactor 스레드에서 직접 send()를 호출하는 것은 Servlet 스펙과의 스레딩 계약에 주의가 필요합니다. StringBuilder는 thread-safe하지 않으므로, 만약 스케줄러 전환이 발생하면 데이터 손상 가능성이 있습니다.
고려사항:
publishOn(Schedulers.boundedElastic())으로 콜백 스레드를 명시적으로 지정하거나StringBuffer나 동기화된 대안을 사용하는 것을 권장합니다.
There was a problem hiding this comment.
publishOn(Schedulers.boundedElastic()) 으로 직렬화를 보장하도록 보완했습니다.
| @@ -0,0 +1,11 @@ | |||
| package dev.jazzybyte.onseoul.adapter.out.aiservice; | |||
There was a problem hiding this comment.
[Major] AiServicePort가 adapter 패키지에 위치 — 포트 인터페이스의 위치 재고 필요
헥사고날 아키텍처에서 포트 인터페이스는 domain.port.out 패키지에 위치해야 외부 시스템(AI 서비스)에 대한 의존을 도메인 계층에서 추상화할 수 있습니다. 현재 adapter.out.aiservice 패키지에 있어서:
- application 서비스가 이 포트를 사용하려면 adapter에 의존해야 하는 구조가 됨 (ArchUnit 룰 위반)
- 다른 outbound 포트(
LoadUserPort,SaveChatRoomPort등)는 모두domain.port.out에 있어 일관성이 깨짐
domain.port.out.AiAgentStreamPort 등의 이름으로 도메인 포트 패키지로 이동하는 것을 권장합니다. 구현체(AiServiceAdapter)만 adapter 패키지에 유지하면 됩니다.
There was a problem hiding this comment.
넵 리패키징 반영하면서 클래스명 AiServiceStreamPort로 변경했습니다!
| http | ||
| .csrf(AbstractHttpConfigurer::disable) | ||
| .sessionManagement(session -> | ||
| session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) |
There was a problem hiding this comment.
[Major] SessionCreationPolicy.IF_REQUIRED는 JWT stateless 인증과 불일치
JWT 기반 stateless 인증을 사용하고 있으므로 SessionCreationPolicy.STATELESS가 적합합니다. 현재 IF_REQUIRED로 설정되어 있어:
- 매 요청마다 불필요한 HttpSession이 생성될 수 있음 (메모리 낭비)
- CSRF를 비활성화한 것과 세션 사용 가능 상태가 보안적으로 일관되지 않음
OAuth2 로그인 플로우에서 authorization_request를 세션에 저장해야 하므로 IF_REQUIRED를 선택한 것으로 보이지만, OAuth2 콜백 완료 후에는 세션이 필요 없습니다. OAuth2 로그인 경로만 세션을 허용하고 나머지는 STATELESS로 처리하는 방법을 고려해보세요.
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))OAuth2 플로우의 state 파라미터 보관은 HttpSessionOAuth2AuthorizationRequestRepository 대신 쿠키 기반 구현(Cookie-based AuthorizationRequestRepository)으로 대체하면 완전 stateless가 가능합니다.
There was a problem hiding this comment.
피드백 감사합니다! 현재 Spring Security 프레임워크를 기반으로 동작을 검토하여 의견을 공유드립니다.
JWT 인증 요청 — 세션 미생성 (확인됨)
Spring Security 6.x에서 SecurityContextPersistenceFilter는 deprecated되고 SecurityContextHolderFilter로 교체됐습니다. 이 필터는 read-only라서 요청 종료 시 SecurityContext를 세션에 저장하지 않고있습니다. JwtAuthenticationFilter가 인증을 설정해도 세션이 생성되지 않아, 피드백주신 "매 요청마다 세션 생성" 문제는 현재 환경(Spring Boot 3.5 / Spring Security 6.5)에서는 발생하지 않아 성능이 개선됐다고 볼 수 있을 것 같았습니다.
위 내용과 별개로, ExceptionTranslationFilter는 401 응답 직전에 HttpSessionRequestCache.saveRequest()를 호출하여 인증 실패 경로에서 불필요한 세션이 생성되는 문제를 확인할 수 있었습니다. STATELESS 설정이면 NullRequestCache로 자동 대체되지만 IF_REQUIRED는 그렇지 않았습니다.
JWT + 쿠키 기반의 현재 인증방식에서는 SavedRequest는 사용되지 않으므로 NullRequestCache를 requestCache로 설정해 수정했습니다.
.requestCache(cache -> cache.requestCache(new NullRequestCache()))결론
세션생성 정책을 STATELESS로 전환하게 되면 이미 프레임워크 측에서 개선된 부분을 위해 따로 구현 비용이 발생할 것으로 보여 IF_REQUIRED 유지하는게 어떨까 합니다.
There was a problem hiding this comment.
Spring Security 6.x에서 SecurityContextHolderFilter가 read-only로 동작하면서 세션 생성을 자제하는 부분이나, NullRequestCache로 401/403 누수를 막은 건 정확한 대응입니다. 제가 놓쳤던 부분이네요.
그럼에도 STATELESS 설정과 쿠키 기반 저장소를 말씀드리는 건 서비스의 확장성, 즉 스케일 아웃(Scale-out) 상황 때문입니다.
현재 설정에서도 일반 API 요청은 괜찮겠지만, OAuth2 로그인을 처리하는 그 짧은 과정에서는 여전히 서버 세션이 만들어집니다. 기본 저장소인 HttpSessionOAuth2AuthorizationRequestRepository가 동작하기 때문인데요.
이게 서버가 한 대일 때는 상관없지만, 나중에 서버를 여러 대로 늘렸을 때 문제가 됩니다. 사용자가 1번 서버에서 로그인을 시작했는데 인증 콜백이 2번 서버로 들어오면 세션이 없어서 로그인이 실패하게 됩니다. 이걸 해결하려고 나중에 Redis 같은 공유 세션 저장소를 붙이거나 로드밸런서 설정을 만지는 건 생각보다 번거로운 일이 됩니다.
그래서 로그인을 시도할 때의 임시 상태값까지 쿠키에 담아 클라이언트로 넘기자고 제안드리는 것입니다. 그렇게 하면 서버는 어떤 순간에도 상태를 갖지 않는 완전한 무상태(Pure Stateless)가 되고, 서버를 아무리 늘려도 인프라 걱정 없이 유연하게 대응할 수 있습니다.
초기에 쿠키 기반 저장소를 구현하는 공수가 조금 들더라도, 나중에 서버 확장 시 발생할 구조적 결함을 미리 방지한다는 점에서 장기적으로는 훨씬 남는 장사라고 생각합니다.
혹시 IF_REQUIRED를 유지해야만 하는 별도의 이유가 있을까요?
There was a problem hiding this comment.
찾아보니 멘토님이 피드백해주신 말씀이 맞겠네요. HttpSessionOAuth2AuthorizationRequestRepository가 인증 과정의 state를 서버 세션에 저장하기 때문에, 분산 환경에서 콜백이 다른 서버로 라우팅되면 state 검증 과정에서 인증이 실패할 수 있겠네요. 확장성 측면에서는 고려하지 못했습니다.
쿠키 기반 저장소를 구현하면 인증 설계에서 Stateless를 달성할 수 있고, Redis 세션 공유 같은 인프라 의존도 제거할 수 있겠네요. 가이드해주신 내용대로 수정해보겠습니다!
|
|
||
| import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; | ||
|
|
||
| @AnalyzeClasses(packages = "dev.jazzybyte.onseoul") |
There was a problem hiding this comment.
[Suggestion] ArchUnit 검증 범위 확장 제안
현재 두 가지 룰(domain의 프레임워크 무의존, application의 adapter 무의존)로 핵심 경계를 잘 검증하고 있습니다. 추가로 다음 룰을 보완하면 아키텍처 경계가 더 견고해집니다:
- adapter.in → adapter.out 직접 의존 금지: 현재 ChatController가 AiServicePort(adapter.out)를 직접 의존하는 것을 잡아낼 수 있습니다.
@ArchTest
static final ArchRule inbound_adapter_must_not_depend_on_outbound_adapter = noClasses()
.that().resideInAPackage("..adapter.in..")
.should().dependOnClassesThat().resideInAPackage("..adapter.out..");- collector/bootstrap 모듈의 domain.model 직접 참조 금지: 현재
collector와bootstrap은 조합(composition root) 역할만 담당하므로, 도메인 모델을 직접 참조하지 않고 반드시domain.port(유스케이스 인터페이스)를 통해서만 접근해야 합니다. 실제로 현재CollectionScheduler는CollectDatasetUseCase포트만 의존하고 있어 올바른 구조입니다. 하지만 이 경계를 명시적으로 검증하지 않으면, 향후 개발 시 scheduler에서CollectionHistory나ApiSourceCatalog같은 도메인 모델을 직접 조작하는 코드가 유입될 수 있습니다.
@ArchTest
static final ArchRule collector_must_not_depend_on_domain_model = noClasses()
.that().resideInAPackage("..collector..")
.should().dependOnClassesThat().resideInAPackage("..domain.model..");
@ArchTest
static final ArchRule bootstrap_must_not_depend_on_domain_model = noClasses()
.that().resideInAPackage("..bootstrap..")
.should().dependOnClassesThat().resideInAPackage("..domain.model..");이 룰의 핵심은 composition root(collector/bootstrap)가 "어떤 유스케이스를 실행할지"만 알고, "도메인 객체를 어떻게 다루는지"는 모르게 하는 것입니다. 도메인 모델 조작은 application 레이어의 서비스가 전담하고, collector/bootstrap은 포트 인터페이스(CollectDatasetUseCase, SendQueryUseCase 등)만 호출하는 구조를 CI 단계에서 강제할 수 있습니다.
There was a problem hiding this comment.
넵 반영후 통과 확인했습니다!
buildToken()에 tokenType 파라미터를 추가해 Access/Refresh Token이 동일한 서명 구조를 가지는 문제를 해결함. extractUserIdSafely()는 access 타입만, extractUserIdFromRefreshToken()은 refresh 타입만 허용하도록 검증 추가. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuthService.refresh()에서 validateToken + extractUserId 조합 대신 extractUserIdFromRefreshToken()을 사용해 Refresh Token 타입을 명시적으로 검증함. Access Token이 Refresh 자리에 전달되면 Redis 조회 없이 INVALID_TOKEN 예외 발생. JwtProviderTest, AuthServiceTest에 타입 오용 방지 케이스 추가: - extractUserIdSafely: Refresh Token 전달 시 empty 반환 (필터 오용 방지) - extractUserIdFromRefreshToken: Access Token 전달 시 예외 발생 - AuthService.refresh: Access Token 전달 시 Redis 조회 전 예외 발생 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Authorization Code Flow에서 브라우저 콜백은 full-page navigation이므로 JSON body 응답을 SPA가 수신할 수 없는 문제 해결. 변경 내용: - OAuth2LoginSuccessHandler: JSON 응답 제거 → access/refresh 토큰을 HttpOnly; Secure; SameSite=Strict 쿠키로 발급 후 프론트엔드 콜백 URL로 리다이렉트 (SUSPENDED 계정도 에러 파라미터와 함께 리다이렉트) - JwtAuthenticationFilter: Authorization 헤더 없을 때 access_token 쿠키 폴백 추가 (브라우저 SPA ↔ API/모바일 클라이언트 모두 지원) - AuthController.refresh(): @RequestBody → @CookieValue, 새 토큰을 Set-Cookie로 반환 - AuthController.logout(): 쿠키 만료(maxAge=0) 처리 추가 (userId 없어도 쿠키 정리) - application.yml: app.frontend-base-url, app.cookie-secure 환경변수 추가 테스트: 쿠키 설정·리다이렉트 검증, 헤더 vs 쿠키 우선순위, 만료 사용자 에러 리다이렉트 등 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
쿠키 기반 전환 이유(Authorization Code Flow 설명), 보안 속성(HttpOnly/Secure/SameSite), 각 메서드의 동작과 주의사항(maxAge=0 삭제 조건, path 제한 목적 등) 문서화. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[TOCTOU 수정] Redis get + delete 두 번 호출 → getAndDelete 원자적 연산으로 교체. 동시 요청이 같은 Refresh Token으로 둘 다 유효 판정을 받을 수 있던 경합 조건(Time-of-Check to Time-of-Use) 해소. [TTL 단일 소스] AuthService / OAuth2LoginSuccessHandler에 중복 정의된 REFRESH_TOKEN_TTL_DAYS 상수를 제거하고 JwtProvider.getRefreshTokenMinutes()에서 파생하도록 통합. jwt.refresh-token-minutes 값 하나만 변경하면 JWT 만료 · Redis TTL · 쿠키 maxAge가 모두 자동으로 동기화됨. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- JjwtTokenIssuer: TokenIssuerPort 구현 유지, ACCESS/REFRESH 타입 클레임 상수 추가 - JwtAuthenticationFilter: TokenIssuerPort import 유지, OAuth2LoginSuccessHandler 쿠키 상수 참조 - OAuth2LoginSuccessHandler: ACCESS_TOKEN_COOKIE/REFRESH_TOKEN_COOKIE 공개 상수 추가 - SecurityConfig: /auth/logout permitAll 추가 (토큰 만료 시에도 로그아웃 허용) - JjwtTokenIssuerTest/JwtAuthenticationFilterTest: jwtProvider → tokenIssuer/tokenIssuerPort 수정 - AuthControllerTest: 헥사고날 UseCase 기반 테스트로 정리, authService 잔존 참조 제거 - app 모듈 파일 삭제: AuthController, AuthService, OAuth2LoginSuccessHandler, 관련 테스트, UserStatus Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Redis GET + DELETE 두 번 호출을 GETDEL 원자적 단일 명령으로 교체. 동시 요청이 같은 RT로 둘 다 유효 판정을 받을 수 있던 경합 조건 해소. - RefreshTokenStorePort: find() 제거 → getAndDelete() 추가, save()에 ttlMinutes 파라미터 추가 - RefreshTokenRedisAdapter: opsForValue().getAndDelete(key) 사용 - RefreshTokenService: find() + delete() 2-call → getAndDelete() 1-call - TokenIssuerPort: getRefreshTokenMinutes() 추가 (TTL 동적 조회)
Flux<String> 반환 타입 때문에 adapter 내부에 두었던 AiServicePort를 AiServiceStreamPort로 이름 변경 후 domain.port.out으로 이동. domain/build.gradle에 reactor-core 의존성 추가. ArchUnit HexagonalArchTest: bootstrap/collector 패키지 absent 오류 allowEmptyShould(true)로 해소.
…구조 정리 - JjwtTokenIssuer: 구현 라이브러리명 노출 제거 → JwtTokenIssuer로 리네임 - OAuth2LoginSuccessHandler: 생성자 인수 정리 및 응답 구조 개선 - SocialLoginService: save() 시그니처 변경(ttlMinutes 파라미터) 반영 - 관련 테스트 일괄 업데이트
- refresh(): @RequestBody → @CookieValue(refresh_token), 응답을 Set-Cookie로 전환 쿠키 없으면 400, 유효하지 않은 토큰이면 401 반환 - logout(): expireAccessCookie/expireRefreshCookie 호출로 maxAge=0 Set-Cookie 추가 userId 없어도(토큰 만료 상태) 쿠키 만료 응답 보장 - RefreshRequest DTO 삭제 (쿠키 기반 전환으로 불필요) - AuthControllerTest: JSON 기반 → 쿠키 기반 시나리오로 전면 재작성
- TokenIssuerPort: getAccessTokenMinutes() 추가 (단일 소스 완성) - JwtTokenIssuer: getAccessTokenMinutes() @OverRide 구현 - OAuth2LoginSuccessHandler: - ACCESS_TOKEN_MAX_AGE_SECONDS 하드코딩 제거 → accessTokenMinutes 필드로 단일 소스화 - providerId null 체크 추가 → server_error 리다이렉트 - catch(OnSeoulApiException) 분기 세분화: FORBIDDEN → forbidden, 그 외 → server_error - SecurityConfig: IF_REQUIRED 세션 정책 선택 근거 주석 추가
adapter.in(ChatController)이 domain.port.out(AiServiceStreamPort)을 직접 의존하고 오케스트레이션 로직이 컨트롤러에 누출되던 문제를 해소. - QueryAndStreamUseCase(domain.port.in) 신규 정의 - ChatStreamService(application): prepare → AI 스트림 → saveAnswer 오케스트레이션 - ChatController: QueryAndStreamUseCase 단일 의존, SseEmitter 변환만 담당 - application/build.gradle: reactor-core 명시 추가
- OAuth2LoginSuccessHandlerIntegrationTest: access/refresh 쿠키 HttpOnly·Path·MaxAge·SameSite 속성 검증, 에러 시 쿠키 미발급 검증, buildAccessCookie/expireAccessCookie 단위 테스트 추가 - SecurityConfigTest: access_token 쿠키 폴백 인증, 헤더 우선순위, 만료 쿠키 401 검증 추가
doOnComplete 내 saveAnswer() 예외가 onError로 변환되어 AI 응답이 정상 스트리밍됐음에도 에러 이벤트가 전송되던 문제 수정. - try-catch로 예외를 로컬에서 처리해 onComplete 신호를 정상 전파 - 저장 실패는 ERROR 로그로 기록 (roomId 포함) - 클라이언트는 항상 정상 종료(emitter.complete)를 수신
IF_REQUIRED 세션 정책에서 ExceptionTranslationFilter가 401 응답 전 HttpSessionRequestCache.saveRequest()를 호출해 세션을 생성하는 문제 수정. JWT + 쿠키 기반 인증은 SavedRequest를 사용하지 않으므로 NullRequestCache로 대체. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| http | ||
| .csrf(AbstractHttpConfigurer::disable) | ||
| .sessionManagement(session -> | ||
| session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) |
There was a problem hiding this comment.
Spring Security 6.x에서 SecurityContextHolderFilter가 read-only로 동작하면서 세션 생성을 자제하는 부분이나, NullRequestCache로 401/403 누수를 막은 건 정확한 대응입니다. 제가 놓쳤던 부분이네요.
그럼에도 STATELESS 설정과 쿠키 기반 저장소를 말씀드리는 건 서비스의 확장성, 즉 스케일 아웃(Scale-out) 상황 때문입니다.
현재 설정에서도 일반 API 요청은 괜찮겠지만, OAuth2 로그인을 처리하는 그 짧은 과정에서는 여전히 서버 세션이 만들어집니다. 기본 저장소인 HttpSessionOAuth2AuthorizationRequestRepository가 동작하기 때문인데요.
이게 서버가 한 대일 때는 상관없지만, 나중에 서버를 여러 대로 늘렸을 때 문제가 됩니다. 사용자가 1번 서버에서 로그인을 시작했는데 인증 콜백이 2번 서버로 들어오면 세션이 없어서 로그인이 실패하게 됩니다. 이걸 해결하려고 나중에 Redis 같은 공유 세션 저장소를 붙이거나 로드밸런서 설정을 만지는 건 생각보다 번거로운 일이 됩니다.
그래서 로그인을 시도할 때의 임시 상태값까지 쿠키에 담아 클라이언트로 넘기자고 제안드리는 것입니다. 그렇게 하면 서버는 어떤 순간에도 상태를 갖지 않는 완전한 무상태(Pure Stateless)가 되고, 서버를 아무리 늘려도 인프라 걱정 없이 유연하게 대응할 수 있습니다.
초기에 쿠키 기반 저장소를 구현하는 공수가 조금 들더라도, 나중에 서버 확장 시 발생할 구조적 결함을 미리 방지한다는 점에서 장기적으로는 훨씬 남는 장사라고 생각합니다.
혹시 IF_REQUIRED를 유지해야만 하는 별도의 이유가 있을까요?
…ate 쿠키화, STATELESS 전환 - CookieOAuth2AuthorizationRequestRepository 구현: OAuth2 state를 서버 세션 대신 HttpOnly 쿠키(oauth2_auth_request, SameSite=Lax, 600s)에 JSON+Base64url 직렬화해 저장 → 분산 환경 state 검증 문제 해결 - SameSite=Lax 선택 이유: OAuth2 콜백은 Google/Kakao에서의 cross-site GET 리다이렉트이므로 Strict는 쿠키를 차단, Lax는 허용 - SecurityConfig: IF_REQUIRED → STATELESS, NullRequestCache 제거 (쿠키 기반으로 OAuth2 플로우 전체가 세션 불필요 구조가 됨) - authorizationEndpoint에만 repository 주입 (Spring Security가 OAuth2LoginAuthenticationFilter에도 동일 인스턴스를 전파함) - SecurityConfig.redirectionEndpoint().authorizationRequestRepository() 는 존재하지 않는 API임을 확인하고 제거 - CookieOAuth2AuthorizationRequestRepositoryTest: 8개 케이스 (round-trip, null/tampered cookie, remove, expire, secure mode) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
M-1: HMAC-SHA256 서명 추가
- 쿠키 값을 {Base64urlPayload}.{Base64urlHMAC} 형태로 저장
- loadAuthorizationRequest 시 MessageDigest.isEqual()로 constant-time 서명 검증
- 검증 실패 시 WARN 로그 + null 반환 (timing attack 차단)
- app.cookie-signing-key(COOKIE_SIGNING_KEY 환경변수) 주입
M-2: 예외 유형별 WARN 로그 추가(@slf4j)
- Base64 디코딩 / JSON 역직렬화 / 서명 검증 실패를 구분해 WARN 출력
M-3: removeAuthorizationRequest — 쿠키 없을 때 expireCookie 호출 생략
- loadAuthorizationRequest 반환값이 null이면 Set-Cookie 헤더 미발급
S-1: deserialize() responseType dead code 제거
- 지원하지 않는 responseType이면 IllegalStateException throw
S-2: toMap()에서 사용하지 않는 grantType 필드 제거
S-4: attributes에서 code_verifier 명시적 제외(PKCE 노출 방지)
S-5: jakarta.servlet.http.Cookie import 추가
테스트: 서명 위변조 / 서명 없는 쿠키 / remove no-cookie 케이스 추가
설정: application.yml에 app.cookie-signing-key 추가,
application-test.yml에 테스트용 32-byte Base64 키 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ate 쿠키화, STATELESS 전환 - CookieOAuth2AuthorizationRequestRepository 구현: OAuth2 state를 서버 세션 대신 HttpOnly 쿠키(oauth2_auth_request, SameSite=Lax, 600s)에 JSON+Base64url 직렬화해 저장 → 분산 환경 state 검증 문제 해결 - SameSite=Lax 선택 이유: OAuth2 콜백은 Google/Kakao에서의 cross-site GET 리다이렉트이므로 Strict는 쿠키를 차단, Lax는 허용 - SecurityConfig: IF_REQUIRED → STATELESS, NullRequestCache 제거 (쿠키 기반으로 OAuth2 플로우 전체가 세션 불필요 구조가 됨) - authorizationEndpoint에만 repository 주입 (Spring Security가 OAuth2LoginAuthenticationFilter에도 동일 인스턴스를 전파함) - SecurityConfig.redirectionEndpoint().authorizationRequestRepository() 는 존재하지 않는 API임을 확인하고 제거 - CookieOAuth2AuthorizationRequestRepositoryTest: 8개 케이스 (round-trip, null/tampered cookie, remove, expire, secure mode)
M-1: HMAC-SHA256 서명 추가
- 쿠키 값을 {Base64urlPayload}.{Base64urlHMAC} 형태로 저장
- loadAuthorizationRequest 시 MessageDigest.isEqual()로 constant-time 서명 검증
- 검증 실패 시 WARN 로그 + null 반환 (timing attack 차단)
- app.cookie-signing-key(COOKIE_SIGNING_KEY 환경변수) 주입
M-2: 예외 유형별 WARN 로그 추가(@slf4j)
- Base64 디코딩 / JSON 역직렬화 / 서명 검증 실패를 구분해 WARN 출력
M-3: removeAuthorizationRequest — 쿠키 없을 때 expireCookie 호출 생략
- loadAuthorizationRequest 반환값이 null이면 Set-Cookie 헤더 미발급
S-1: deserialize() responseType dead code 제거
- 지원하지 않는 responseType이면 IllegalStateException throw
S-2: toMap()에서 사용하지 않는 grantType 필드 제거
S-4: attributes에서 code_verifier 명시적 제외(PKCE 노출 방지)
S-5: jakarta.servlet.http.Cookie import 추가
테스트: 서명 위변조 / 서명 없는 쿠키 / remove no-cookie 케이스 추가
설정: application.yml에 app.cookie-signing-key 추가,
application-test.yml에 테스트용 32-byte Base64 키 추가
ChatStreamServiceTest (application):
- 청크 그대로 발행, 완료 시 전체 답변으로 saveAnswer 호출
- prepare() → roomId가 stream()에 전달되는지 검증
- saveAnswer 예외 발생 시 Flux가 정상 complete(onError 미전파)
- 빈 스트림 → saveAnswer("") 호출 확인
- reactor-test 의존성 추가 (application 모듈 testImplementation)
CollectionHistoryTest (domain):
- 초기 status=FAILED, durationMs=null
- complete/fail/partial 각 상태 전환 및 필드값 검증
- 이미 완료된 이력에 재호출 시 IllegalStateException 3케이스
- 전체 arg 생성자 재구성 시 모든 필드 보존 확인
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
개요
헥사고날 아키텍처 전환 및 질의입력 및 SSE 릴레이를 구현했습니다.
구현 항목
헥사고날 아키텍처 전환
adapter → application → domain질의입력 엔드포인트 및 SSE 릴레이 구현