[Volume-7] 이벤트 기반 아키텍처 구현 - #322
Conversation
Claude Code 로컬 skill 파일을 원격에 올리지 않도록 ignore.
Working Principles 5개 원칙 명시, Git Workflow (브랜치/커밋 컨벤션) 추가, 영역별 상세 룰은 외부 skill (/coding-style, /design, /refactor, /tdd) 로 분리.
수동 getter/protected 기본 생성자 제거 후 @Getter/@NoArgsConstructor/@builder 로 통일. 사용처(ProductService, 관련 테스트 3종)도 빌더 호출로 정렬.
POST /api/v1/users — 로그인 ID 중복/비밀번호 정책/이메일 형식 검증 후 BCrypt 인코딩하여 저장. domain/application/interfaces 레이어와 단위/통합/E2E 테스트 포함.
X-Loopers-LoginId/Pw 헤더 기반 인증 — Spring Security 필터 체인에 커스텀 LoopersAuthenticationFilter 를 추가하고, 인증 실패는 LoopersAuthenticationEntryPoint 가 401 UNAUTHORIZED ApiResponse 로 변환. UserRepository 에 단건 조회 메서드(findById, findByLoginId) 도 함께 추가. findByLoginId 는 필터가 사용하고, findById 는 후속 내 정보 조회 API 에서 사용.
GET /api/v1/users/me — 인증된 사용자의 회원 정보를 반환. 이름은 Response DTO 변환 시점에 마지막 한 글자만 * 로 마스킹 (응답 필드: loginId/name/birthDate/email). UserService.getById, UserFacade.getMyInfo, UserV1Dto.MyInfoResponse(마스킹), 컨트롤러 GET /me 핸들러까지 단위(마스킹)/통합(Service/Facade)/E2E(정상 + 인증 실패 3종) 테스트와 함께 추가.
- anyRequest() 를 denyAll 로 바꾸고 허용 경로(POST /api/v1/users, Swagger, /error) 만 명시적 permitAll - /api/** 를 일괄 authenticated 로 묶어 인증 누락 방지 - PasswordEncoder 빈을 SecurityConfig 로 통합 (PasswordEncoderConfig 제거) - 변경된 인증 정책에 맞춰 ExampleV1ApiE2ETest 에 헤더 인증 추가
- PUT /api/v1/users/me/password — 인증된 사용자 본인의 비밀번호 변경 - 현재 비밀번호 검증 실패 시 401, 정책 위반/동일 비밀번호 시 400 - 정책 검증은 기존 UserPasswordPolicy 재사용 (8~16자, 생년월일 미포함) - 도메인/서비스/E2E 레이어 테스트 추가
- UserV1ApiSpec 인터페이스 신규: @tag, 엔드포인트별 @operation(summary/description), 인증 헤더 @parameter, @ApiResponses 응답 코드 매핑 - 각 에러 코드에 @content + @ExampleObject 로 실제 응답 바디(ApiResponse.fail) 예시 노출 - UserV1Controller 가 UserV1ApiSpec 을 implements, 각 메서드에 @OverRide 추가
원본 com.querydsl 가 사실상 미관리 상태(마지막 릴리즈 2021)라 record 기반 @embeddable 등 Java 신기능 APT 처리가 막힘. io.github.openfeign.querydsl 6.10.1 (소스 호환, jakarta 네이티브) 로 교체하여 record VO 도입 기반을 마련.
LoginId/Email/EncodedPassword/PlainPassword/UserName/BirthDate 를 도메인 VO 로 분리해 invariant 를 한 군데로 모으고, JPA @embeddable 로 박아 UserModel 이 VO 타입을 그대로 보존하도록 한다.
비밀번호 정책 검증은 PlainPassword VO 의 record 생성자에서 처리하므로 별도 정책 클래스를 둘 필요가 없다.
application 측 SignUpCommand 는 controller 가 facade 로 넘기는 raw 입력, 도메인 측 SignUpUserCommand 는 VO 로 묶인 도메인 입력으로 layer 책임을 분리.
LoginId 파싱과 비밀번호 매칭 책임을 UserAuthenticator 로 빼고, LoopersAuthenticationFilter 는 헤더 추출과 SecurityContext 설정만 담당한다.
primitive 필드 + 자체 validation 을 걷어내고 도메인 VO 를 @Embedded 로 박는다. 회원 가입 사건이 빌더 체인에 묻히지 않도록 UserModel.signUp 정적 팩토리를 노출. JpaRepository.findByLoginId 시그니처도 LoginId 타입으로 맞춘다.
signUp 은 SignUpUserCommand 를 받아 EncodedPassword 까지 만든 뒤 UserModel.signUp 으로 위임하고, changePassword 는 현재 비번 매칭과 raw 동일 여부 검증을 직접 수행한 뒤 PlainPassword 로 정책만 검사. getUser 도 LoginId 가 아닌 PK 기반으로 표준화한다.
Facade 는 raw 입력을 VO 로 감싸 SignUpUserCommand 를 만들어 도메인에 넘기고, UserInfo 는 UserModel 의 VO 를 unwrap 해 외부에 노출.
controller 는 UserInfo 를 받아 V1Dto.UserResponse/MyInfoResponse 로만 매핑하고, SignUpRequest.toCommand 와 ChangePasswordRequest 시그니처를 facade 의 새 API 와 맞춰 ApiSpec 도 가독성 위주로 단순화.
|
Important Review skippedToo many files! This PR contains 471 files, which is 321 over the limit of 150. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (471)
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🧭 Context & Decision
문제 정의
POST /coupons/{id}/issue) 하나뿐이고,CouponTemplate에 수량 개념이 없어 "선착순 N명" 이벤트를 열 수 없다. 중복 발급 방지도user_coupon유니크 제약에만 의존한다.REQUESTED → ISSUED/SOLD_OUT/ALREADY_ISSUED/FAILED)선택지와 결정
flowchart LR U[사용자] --> G{"1차 · Redis Lua 게이트<br/>중복+재고 원자 판정"} G -- "중복/매진" --> X["409 즉시 거절<br/>(Kafka·DB 도달 안 함)"] G -- 통과 --> S["요청 저장 REQUESTED<br/>+ Kafka 발행 → 202"] S --> K[["2차 · coupon-issue-requests<br/>key=couponId (파티션 직렬화)"]] K --> D{"3차 · DB 조건부 UPDATE<br/>issued_count < total_quantity"} D -- 성공 --> I[✅ ISSUED] D -- "0 rows" --> O[⛔ SOLD_OUT]WHERE issued_count < total_quantity— 최종 진실고려한 대안:
최종 결정: C — "Redis는 문지기, 진실은 DB". Redis가 원자적이어도 재시작 시 카운터가 유실될 수 있으므로 성능 게이트로만 쓰고, over-issue 차단의 책임은 DB 조건부 UPDATE가 진다. 구현 순서도 DB 가드(기준선) → 게이트 순으로 쌓아 어느 층이 빠져도 정합성이 깨지지 않게 했다.
트레이드오프:
추후 개선 여지: API가 요청 저장 후 발행 직전에 죽으면
REQUESTED로 갇힌 행이 남는다(빈도 극저로 보고 수용) → 타임아웃 reaper로 보정 가능. DLT에 쌓인 실패 메시지 재처리 프로세스도 후속 과제.🤔 고민한 점 / 막혔던 부분
ALREADY_ISSUED상태 전이를 커밋할 수 없다(UnexpectedRollbackException). 그래서 선조회(findIssuedCoupon)로 분기하고, 유니크 제약은 선조회 직후 끼어드는 극히 드문 레이스의 안전망으로만 남겼다 — 이 경우 예외를 그대로 던져 롤백시키면 재전달 시 선조회가 기존 발급을 발견해 정상적으로ALREADY_ISSUED로 정리된다.🙋 기타
로컬 인프라(실 Kafka)로 전체 왕복을 실측 검증했다:
issued_count 3 / total_quantity 3, 초과 0건