Skip to content

feat/117 :: Release 릴리즈 서비스 구현#25

Open
lian2945 wants to merge 84 commits into
mainfrom
feat/117
Open

feat/117 :: Release 릴리즈 서비스 구현#25
lian2945 wants to merge 84 commits into
mainfrom
feat/117

Conversation

@lian2945

Copy link
Copy Markdown
Collaborator

📌 관련 이슈

  • close #117

📝 변경 사항 요약

작업 유형

  • ✨ feat: 새로운 기능 추가 (기능 단위 완료 후 PR)
  • 🐛 fix: 버그 수정 (버그 1개 = PR 1개)
  • ♻️ refactor: 코드 리팩토링 (작업 단위 완료 후 PR)
  • ✅ test: 테스트 코드 추가/수정 (작업 단위 완료 후 PR)

변경 내용

  • 도메인: Release Aggregate (ReleaseStatus, ReleaseLevel, 판매 시작/종료, 판매 수량 관리), VO 3종, 예외 3종
  • 애플리케이션: UseCase 8종 (등록/조회/판매시작·종료/자동판매/수량증가), Port, Service, DTO
  • 어댑터: REST Controller (Command/Query/Admin), gRPC 서버 (릴리즈 정보 제공), gRPC 클라이언트 (creator → creatorId 조회), JPA Persistence, Security, 스케줄러 (10분 단위 자동 판매 시작), 커스텀 Validation (@ScheduledAtMultipleOfTenMinutes)
  • 설정: application-local/dev.yml, build.gradle, Dockerfile, proto (creator/release)

변경 이유

  • 마술 작품 발매(릴리즈) 등록, 판매 상태 관리, gRPC 서버 제공이 필요하며 order/waiting/creator 서비스에서 릴리즈 정보를 조회하는 핵심 서비스

✅ 테스트 체크리스트

  • 단위 테스트 작성 및 통과
  • 통합 테스트 통과
  • 기존 기능 정상 동작 확인 (Regression)
  • API 응답값 확인
  • 예외 케이스 처리 확인
  • 로컬 환경에서 직접 테스트 완료

📸 스크린샷 / 로그

펼쳐보기

curl-test.sh 기반 릴리즈 API 테스트 완료


🔄 동작 플로우 (Mermaid)

flowchart TD
    A[릴리즈 등록 요청] --> B[ReleaseCommandController]
    B --> C[RegisterReleaseService]
    C --> D[gRPC → creator 서비스 creatorId 조회]
    D -->|NOT_FOUND| E[CreatorNotFoundException 404]
    D -->|성공| F[Release Aggregate 생성 저장]
    F --> G[판매 시작 시간 도래]
    G --> H[StartSaleScheduler 10분 주기]
    H --> I[ReleaseStatus → ON_SALE]
    I --> J[판매 종료 요청]
    J --> K[ReleaseStatus → SALE_ENDED]
Loading

💬 리뷰어에게

  • gRPC 서버(ReleaseGrpcService)가 order, waiting, creator 서비스에 릴리즈 정보를 제공합니다. proto 정의와 응답 매핑을 검토해주세요.
  • @ScheduledAtMultipleOfTenMinutes 커스텀 Validation이 10분 단위 판매 시작 시간을 강제합니다.
  • Resilience4j CircuitBreaker 설정(creator gRPC 클라이언트)을 확인해주세요.

lian2945 and others added 8 commits May 18, 2026 17:07
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 18, 2026 08:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

lian2945 and others added 19 commits May 18, 2026 18:04
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, grpc 설정 추가)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@RetryableTopic 전환 이후 DLT 발생 시 Inbox 상태가 업데이트되지 않던
문제를 해결합니다. 각 리스너에 @DltHandler를 추가하여 재시도 소진 후
DLT로 전환될 때 해당 Inbox 레코드를 DEAD_LETTERED로 마킹합니다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@RetryableTopic 전환 이후 DLT 발생 시 Inbox 상태가 업데이트되지 않던
문제를 해결합니다. 각 리스너에 @DltHandler를 추가하여 재시도 소진 후
DLT로 전환될 때 해당 Inbox 레코드를 DEAD_LETTERED로 마킹합니다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AuthEventKafkaListener: @RetryableTopic, @DltHandler 추가 및 UserInboxRepository 주입
- SseConnectedKafkaListener, SseDisconnectedKafkaListener: @RetryableTopic 추가
- KafkaConfiguration: @EnableKafkaRetryTopic, ThreadPoolTaskScheduler 빈 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AuthEventKafkaListener: @RetryableTopic, @DltHandler 추가 및 UserInboxRepository 주입
- SseConnectedKafkaListener, SseDisconnectedKafkaListener: @RetryableTopic 추가
- KafkaConfiguration: @EnableKafkaRetryTopic, ThreadPoolTaskScheduler 빈 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lian2945 and others added 4 commits May 21, 2026 22:01
- 토큰 검증(waiting gRPC)을 @transactional 이전에 호출하도록 분리
- increaseSoldQuantity gRPC 제거 → ReleaseSoldQuantityIncreaseEvent Outbox 비동기 발행으로 교체
- ReleaseGrpcAdapter, ReleaseIncreaseSoldQuantityPort, ReleaseServiceUnavailableException 삭제
- release-service gRPC 채널 설정 및 releaseService CircuitBreaker/TimeLimiter 설정 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- order 서비스가 발행하는 release-sold-quantity-increase Outbox 이벤트 수신
- Inbox 패턴 인프라 추가: @idempotent AOP, ReleaseInboxEntity/Repository, IdempotentAspect
- HandleReleaseSoldQuantityIncreaseService: 기존 IncreaseSoldQuantityService와 동일 도메인 로직 재사용
- build.gradle: spring-boot-starter-kafka, aspectj 의존성 추가
- application-dev/prod/local.yml: Kafka consumer 설정 및 inbox 설정 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AUTO_CONFIRM_DAYS 하드코딩 제거 → order.auto-confirm.days/chunk-size 설정 외부화
- findDeliveredBefore에 limit(Pageable) 추가로 전체 로드 방지
- 단일 트랜잭션 전체 배치 → AutoConfirmOrderChunkService.confirmOne()으로 분리
  self-invocation 문제 해결, 한 건 실패해도 다른 건 롤백 없이 계속 처리
- Redisson 분산 락 적용(@SchedulerLock): 멀티 인스턴스 중복 실행 방지
- spring.data.redis 설정 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@f-lab-ted f-lab-ted left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Release Aggregate(상태 SCHEDULED → ON_SALE → SOLD_OUT/ENDED, 한정 수량/판매 수량/판매 예정 시각 관리)와 헥사고널 구성(REST/gRPC/Kafka/JPA/Scheduler), creator 서비스로의 gRPC 호출 + Resilience4j CircuitBreaker, 10분 단위 판매 시작 시각 강제 Validation, 자동 판매 시작 스케줄러, release-sold-quantity-increase Kafka Inbox 처리를 함께 검토했습니다. 또한 다른 서비스(auth/creator)의 Inbox DEAD_LETTERED 만료 처리/@RetryableTopic 전환, URL prefix /api/creator → /creator//admin/creator 변경 영향도 함께 살펴봤습니다.

잘한 점

DDD/헥사고널 경계가 명확하게 분리되어 있고(core ↔ port ↔ adapter), Release Aggregate에 상태 전이 규칙과 검증을 집중시킨 점, gRPC ↔ Resilience4j ↔ deadline 조합으로 외부 호출 안정성을 챙긴 점은 좋습니다.

보완할 점 (Critical/Major 위주)

  1. Critical ReleaseGrpcService.toProtoRelease 가 proto의 created_at 필드에 scheduledAt 값을 매핑하고 있어, 호출 측(creator 등)에서 의미가 달라진 데이터를 받게 됩니다. proto 정의를 scheduled_at/status/limited_quantity/sold_quantity까지 포함하도록 보강하고 매핑을 일치시켜야 합니다.
  2. Critical StartSaleScheduler가 분산 락 없이 1분 주기로 돌고 있어 다중 인스턴스 환경에서 같은 SCHEDULED 릴리즈를 동시에 잡아 OptimisticLockingFailureException 또는 중복 startSale 시도가 발생합니다. 최근 order 서비스의 AutoConfirm 스케줄러에 적용된 분산 락 + 청크 처리 패턴을 동일하게 적용해 주세요. 또한 PR 설명의 "10분 주기"와 코드의 fixedDelay = 60000(1분) 불일치도 정정 필요합니다.
  3. Major IncreaseSoldQuantityUseCase 가 gRPC(ReleaseGrpcService#increaseSoldQuantity)와 Kafka(OrderEventKafkaListener) 두 경로 모두에서 호출됩니다. 동일 주문에 대해 양쪽이 모두 발화하면 이중 차감/oversell 위험이 있고, 책임 분리가 모호합니다. 비동기(Outbox→Inbox) 단일 경로로 통일하거나 gRPC 경로를 제거하는 것이 안전합니다.
  4. Major /admin/release/*SecurityConfigurationanyRequest().permitAll() 정책 하에 인증/관리자 권한 검사 없이 노출됩니다. 게이트웨이에서 prefix-기반 보호가 있더라도 서비스 자체에 방어선이 필요합니다.
  5. Major RegisterReleaseService#registerRelease 내부에서 creator gRPC 호출이 @Transactional 안에 포함되어 있어, 외부 호출(최대 2초 + 서킷브레이커 전환) 동안 DB 트랜잭션이 유지됩니다. gRPC 호출은 트랜잭션 밖에서 수행한 뒤 결과만 들고 트랜잭션을 시작하도록 분리해 주세요.

그 외 가이드에서 지적된 IdempotentAspect.isTooOld 중복 저장 위험, @DltHandler ↔ retry topic 이름 불일치, auth/creator 간 @RetryableTopic 비대칭, FRONTEND_URI→URL 환경변수 변경, creator gRPC 채널 일관성도 같은 맥락의 보완 대상입니다.

결론

Request Changes — 1번 proto 매핑(데이터 정합성), 2번 스케줄러 분산 락(다중 인스턴스 안전성), 3번 increaseSoldQuantity 이중 경로(이중 차감), 4번 admin 보호 부재 점에 대해서는 반드시 수정 바랍니다.

.setThumbnailUrl(result.thumbnailUrl() != null ? result.thumbnailUrl() : "")
.setLevel(toProtoLevel(result.level()))
.setPrice(result.price())
.setCreatedAt(Timestamp.newBuilder()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] proto created_at 필드에 scheduledAt 값을 매핑하고 있습니다.

.setCreatedAt(Timestamp.newBuilder()
        .setSeconds(scheduledAt.getEpochSecond())
        .setNanos(scheduledAt.getNano())
        .build())
  • 호출 측(creator 서비스의 ReleaseQueryGrpcAdapter 등)은 응답의 created_at을 실제 생성 시각으로 신뢰합니다. 여기에 판매 예정 시각이 들어가면 정렬/노출 로직이 모두 어긋납니다.
  • 그리고 getReleasesByCreatorId 응답에는 status, scheduled_at, limited_quantity, sold_quantity 같은 release 의 핵심 속성이 누락되어 있어 order/waiting 같은 호출 측에서 다시 확인 호출이 필요해질 가능성이 큽니다.

제안: proto 정의에 created_at/scheduled_at/status/limited_quantity/sold_quantity 를 추가하고, 매핑을 의미에 맞게 분리해 주세요. (특히 created_atresult.createdAt()을 써야 합니다.)


private final AutoStartSaleUseCase autoStartSaleUseCase;

@Scheduled(fixedDelay = 60000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] 분산 락 없이 1분 주기로 동작하여 다중 인스턴스에서 충돌 위험이 큽니다.

  • AutoStartSaleService#autoStartScheduledReleasesfindScheduledBefore(now) 로 모든 대상 릴리즈를 한 트랜잭션에서 가져와 release.startSale() + update()를 반복 호출합니다. 여러 pod 가 같은 SCHEDULED 행에 대해 동시에 진입하면 @Version 기반 낙관락 충돌로 일부 instance 가 예외와 함께 종료되고, 다음 주기까지 시작이 지연됩니다.
  • 최근 order 서비스의 AutoConfirm 스케줄러에 적용한 "분산 락 + 청크 처리" 패턴을 동일하게 도입하는 것이 자연스럽습니다 (ShedLock / Redisson 등).
  • PR 설명에는 "10분 단위 자동 판매 시작"이라고 적혀있지만 fixedDelay = 60000(1분)으로 설정되어 있어 설명과 코드가 불일치합니다. 의도가 1분이라면 PR 문서를 수정하고, 10분이 의도라면 fixedDelayString = "PT10M" 또는 cron 으로 명시해 주세요.
@Scheduled(fixedDelay = 60000) // 1분
public void autoStartScheduledReleases() { ... }

부가적으로, @ScheduledAtMultipleOfTenMinutes 로 등록 시각을 10분 단위로 강제했다면 스케줄러도 10분 주기(또는 cron 0 */10 * * * *)로 정렬하는 것이 의미 충돌이 적습니다.

@Override
public void increaseSoldQuantity(IncreaseSoldQuantityRequest request,
StreamObserver<IncreaseSoldQuantityResponse> responseObserver) {
increaseSoldQuantityUseCase.increaseSoldQuantity(ReleaseId.of(request.getReleaseId()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] increaseSoldQuantity 동기 gRPC 경로 / Kafka Inbox 경로가 중복됩니다.

같은 도메인 행위(Release#increaseSoldQuantity)가 두 진입점에서 호출됩니다.

  • 동기: ReleaseGrpcService#increaseSoldQuantity (RPC)
  • 비동기: OrderEventKafkaListener#handleReleaseSoldQuantityIncrease (Kafka Inbox)

위험 요소:

  1. 주문 서비스가 두 경로를 모두 사용하면 동일 주문에 대해 sold_quantity 가 2회 증가하여 oversell 됩니다.
  2. gRPC 경로에는 멱등성 보호(Inbox/eventId 검증)가 없어, 클라이언트가 재시도하면 그대로 또 증가합니다.
  3. 두 경로 모두 @Version 낙관락에 의존하기 때문에 단순 충돌 시 예외만 던지고 재시도 정책이 없습니다.

제안: 주문→릴리즈 sold quantity 증가는 Outbox→Inbox(Kafka) 단일 경로로 통일하고 gRPC increaseSoldQuantity 엔드포인트는 제거하거나, 반대로 동기 차감을 유지하려면 Inbox 리스너를 제거하고 멱등키(eventId)에 대한 명시적 중복 방지를 gRPC에 도입해 주세요.

.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new UserInfoExtractFilter(trustedIpProperties), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] /admin/release/* 가 인증/권한 없이 노출되어 있습니다.

.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())

AdminReleaseCommandController(start-sale / end-sale)는 운영자만 호출해야 하는데, 현재 설정에선 외부에서 누구나 호출 가능합니다. 게이트웨이가 보호한다 하더라도 서비스 자체적으로:

  • /admin/** 경로는 UserInfoExtractFilter 가 주입한 ROLE_ADMIN 권한 보유자만 통과하도록 requestMatchers("/admin/**").hasRole("ADMIN") 추가
  • 또는 컨트롤러 메서드에 @PreAuthorize("hasRole('ADMIN')") 적용

이 필요합니다. 외부 노출 위험이 큰 상태 전이(판매 시작/종료) 엔드포인트라 우선순위를 높여 봐주세요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것은 AuthorizationPolicy를 통해 해결할 예정입니다


@Override
@Transactional
public Long registerRelease(RegisterReleaseCommand command) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] gRPC 외부 호출이 @Transactional 경계 안에 포함되어 있습니다.

@Transactional
public Long registerRelease(RegisterReleaseCommand command) {
    CreatorId creatorId = creatorIdQueryPort.getCreatorId(command.userId()); // gRPC + CircuitBreaker
    Release release = Release.createBuilder()....build();
    return releaseRepositoryPort.save(release);
}
  • getCreatorId 는 최대 2초 deadline + Circuit half-open 상황까지 고려하면 응답이 늦어질 수 있는데, 그 동안 DB 커넥션이 점유됩니다.
  • creator 서비스가 NOT_FOUND 가 아닌 다른 원인으로 실패하면 CreatorServiceUnavailableException 까지 트랜잭션 안에서 처리됩니다(불필요한 트랜잭션 rollback 비용).

제안: creator 조회는 트랜잭션 밖에서 수행 후, 검증된 creatorId 만 들고 짧은 트랜잭션 안에서 저장하도록 분리하는 것을 권장합니다.

public Long registerRelease(RegisterReleaseCommand command) {
    CreatorId creatorId = creatorIdQueryPort.getCreatorId(command.userId());
    return saveNewRelease(creatorId, command); // 이 안에 @Transactional
}

@Override
@Transactional
public void autoStartScheduledReleases() {
List<Release> releases = releaseRepositoryPort.findScheduledBefore(Instant.now());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] 대상 릴리즈 전체를 한 트랜잭션에서 순차 처리합니다.

List<Release> releases = releaseRepositoryPort.findScheduledBefore(Instant.now());
releases.forEach(release -> {
    release.startSale();
    releaseRepositoryPort.update(release);
});
  • 누락된 시작 예정 건이 수십~수백 건 쌓이면 단일 트랜잭션이 길어지고 한 건 실패 시 모두 롤백됩니다.
  • release.startSale()SCHEDULED 가 아닐 때 ReleaseStatusConflictException 을 던지는데, 그 사이 다른 인스턴스/이벤트가 상태를 바꿔놓았다면 전체 작업이 무산됩니다.

제안: order 서비스의 AutoConfirm 청크 처리 패턴(분산 락 + 페이지 단위 + 개별 트랜잭션)을 적용해 부분 실패 격리를 확보해 주세요. 최소한 release.startSale() 을 try/catch 로 감싸 한 건 실패가 다른 건을 막지 않도록 해야 합니다.

Long eventId = Long.parseLong(consumerRecord.key());
InboxEvent event = (InboxEvent) consumerRecord.value();
Long eventId = event.eventId();
Instant occurredAt = event.occurredAt();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] 만료 메시지 DEAD_LETTERED 저장 시 existsByEventId 중복 체크가 누락되어 있습니다.

if (isTooOld(occurredAt)) {
    transactionTemplate.executeWithoutResult(status ->
        authInboxRepository.save(AuthInboxEntity.builder()
                .eventId(eventId)
                ...
                .status(AuthInboxStatus.DEAD_LETTERED)
                .build())
    );
    return null;
}

동일 eventId 가 (정상 처리된 후) 재전달되어 만료 판정을 받게 되면, 위 분기는 정상 처리 분기와 달리 중복 체크 없이 곧바로 save 를 호출하므로 event_id UNIQUE 제약 위반이 발생합니다. 또한 같은 메시지가 retry로 여러 번 들어와 모두 만료 처리되면 동일하게 위반됩니다.

제안:

if (isTooOld(occurredAt)) {
    if (authInboxRepository.existsByEventId(eventId)) {
        log.info("[Inbox] 이미 기록된 만료 이벤트. eventId={}", eventId);
        return null;
    }
    // save DEAD_LETTERED ...
}

creator 측 IdempotentAspect 도 동일 구조이므로 같이 보완해 주세요.

@DltHandler
public void handleDlt(ConsumerRecord<String, ?> consumerRecord) {
log.error("[Inbox] DLT 전환. topic={}, partition={}, offset={}", consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset());
creatorInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), consumerRecord.partition(), consumerRecord.offset())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] @DltHandler 안에서 consumerRecord.topic() 으로 Inbox 를 조회하면 원본 토픽이 아니라 retry 토픽을 받게 됩니다.

@RetryableTopic 사용 시 메시지는 outbox.event.user-withdrawnoutbox.event.user-withdrawn-retry-0...-retry-1 ... 식으로 토픽이 바뀌며 흐릅니다. 결국 DLT 핸들러에서는 retry 토픽의 이름이 들어오는데, IdempotentAspect 가 Inbox 에 저장할 때는 원본 토픽 이름으로 저장합니다. 따라서:

creatorInboxRepository.findByTopicAndPartitionAndOffset(consumerRecord.topic(), ...)

는 매칭되지 않아 markDeadLettered() 가 실행되지 않습니다.

제안: Inbox 저장 시 KafkaHeaders.ORIGINAL_TOPIC/ORIGINAL_PARTITION/ORIGINAL_OFFSET 헤더를 우선 사용하거나, eventId 기준으로 조회/마킹하도록 변경해 주세요. auth 서비스의 DLT 핸들러는 @RetryableTopic 없이 동작하므로 같은 이슈는 아니지만, 두 서비스의 정책(@EnableKafkaRetryTopic 적용 여부)이 비대칭이라 일관성 확인이 필요합니다.


ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.RELEASE.getHostName());
ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc.newBlockingStub(channel);
ReleaseServiceGrpc.ReleaseServiceBlockingStub stub = ReleaseServiceGrpc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] 같은 PR에 GrpcConfiguration 으로 releaseManagedChannel Bean 을 등록해두고도, 본 어댑터에서는 매 호출마다 grpcChannelFactory.createChannel(...) 로 새 채널을 만들고 있어 일관성이 깨졌습니다.

채널 생성은 비용이 큰 작업이고 keep-alive 등의 설정도 채널 단위로 관리되므로, 새로 추가된 releaseManagedChannel Bean 을 주입받아 재사용하도록 통일해 주세요. subscribeManagedChannel, reviewManagedChannel, userManagedChannel, shortformManagedChannel 사용처도 모두 동일 문제를 가집니다.

release 서비스의 CreatorGrpcAdapter 는 이미 creatorManagedChannel 을 주입받아 재사용하고 있어 좋은 레퍼런스가 됩니다.


@RestController
@RequestMapping("/api/creator")
@RequestMapping("/creator")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] URL prefix 일괄 변경(/api/creator/* → /creator/*, /admin/creator/*)은 Breaking Change 입니다.

다음 영역을 PR 머지 전에 반드시 동기화 확인해 주세요.

  • API Gateway 라우팅 규칙
  • 타 마이크로서비스의 REST 호출 위치(있다면)
  • tools/curl-test.sh 류 운영 스크립트
  • 프론트엔드 API base path
  • e2e/통합 테스트 픽스처

특히 /admin/creator/* 는 새 prefix 이므로 게이트웨이에서 일반 사용자 토큰으로 접근하지 못하도록 별도 라우트/필터를 두는 것이 안전합니다.

lian2945 and others added 24 commits May 22, 2026 11:58
…clude 추가

- IdempotentAspect: catch(Throwable→RuntimeException) wrap으로 인한 retry 정책 무력화 수정
  - Error는 즉시 rethrow
  - RuntimeException은 타입 보존하여 rethrow
  - checked Exception만 IllegalStateException으로 wrap
- 전 서비스 KafkaListener: @RetryableTopic(exclude = {BusinessException.class}) 추가
  - BusinessException(4xx)은 재시도 불필요하므로 즉시 DLT로 전송

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java
#	services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java
#	services/order/src/main/resources/application-dev.yml
#	services/order/src/main/resources/application-local.yml
#	services/order/src/main/resources/application-prod.yml
#	services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java
Order.confirmOrderLine()은 CONFIRMED 상태에서도 호출 가능(partial confirm 이후 재호출)하므로
이미 CONFIRMED인 상태에서 라인 확정 시 OrderConfirmedEvent가 중복 발행되는 버그 수정.
PREPARING → CONFIRMED 전이 시에만 이벤트를 발행하도록 previousStatus 가드 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CONFIRMED 상태에서 재확정을 도메인 레벨에서 차단하여 이벤트 중복 발행 방지.
서비스 레이어의 previousStatus 가드는 불필요해져 제거.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
기본값과 동일하나 운영 시 DLT 전략을 명확히 하기 위해 명시적으로 추가.
- dltStrategy = FAIL_ON_ERROR: DLT 전송 실패 시 컨슈머 중단(메시지 유실 방지)
- dltTopicSuffix = "-dlt": DLT 토픽명 명시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java
…에러 수정

pjp.proceed()가 throws Throwable로 선언되어 있어 catch(Exception)으로는
컴파일러가 Throwable 처리를 인정하지 않아 컴파일 에러 발생.
마지막 catch 블록을 Throwable로 변경하여 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	services/auth/src/main/java/kr/magicbox/auth/adapter/in/kafka/UserEventKafkaListener.java
#	services/auth/src/main/resources/application-dev.yml
#	services/auth/src/main/resources/application-prod.yml
#	services/creator/src/main/java/kr/magicbox/creator/adapter/in/kafka/UserEventKafkaListener.java
#	services/creator/src/main/resources/application-prod.yml
#	services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/CreatorEventKafkaListener.java
#	services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/in/kafka/aop/IdempotentAspect.java
#	services/general-goods/src/main/java/kr/magicbox/generalgoods/adapter/out/communication/grpc/CreatorGrpcAdapter.java
#	services/general-goods/src/main/resources/application-prod.yml
#	services/order/build.gradle
#	services/order/src/main/resources/application-local.yml
#	services/release/build.gradle
#	services/release/src/main/resources/application-local.yml
#	services/shopping-cart/Dockerfile
#	services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/CreatorEventKafkaListener.java
#	services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/UserEventKafkaListener.java
#	services/subscribe/src/main/java/kr/magicbox/subscribe/adapter/in/kafka/aop/IdempotentAspect.java
#	services/subscribe/src/main/resources/application-prod.yml
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/AuthEventKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseConnectedKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/in/kafka/SseDisconnectedKafkaListener.java
#	services/user/src/main/java/kr/magicbox/user/adapter/out/communication/grpc/ReviewQueryGrpcAdapter.java
#	services/user/src/main/resources/application-dev.yml
#	services/user/src/main/resources/application-prod.yml
- release.proto: ReleaseStatus enum 추가, Release 메시지에 scheduled_at/status/limited_quantity/sold_quantity 필드 추가
- ReleaseGrpcService: toProtoRelease()에서 createdAt을 scheduledAt으로 잘못 매핑하던 버그 수정, 누락 필드 매핑 추가
- creator/release.proto: release 서비스 proto와 동기화 (ReleaseStatus enum, 누락 필드 추가)
- ReleaseResult: soldQuantity/status/scheduledAt/createdAt 필드 추가
- ReleaseStatus: 신규 enum 추가 (SCHEDULED/ON_SALE/SOLD_OUT/ENDED)
- ReleaseQueryGrpcAdapter: 신규 필드 매핑 추가, import 정렬

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- StartSaleScheduler: fixedDelay=60000 → cron="0 */10 * * * *" (10분 주기, ScheduledAtMultipleOfTenMinutes와 정렬)
- StartSaleScheduler: RedissonClient.tryLock()으로 분산 락 적용 — 락 획득 실패 시 즉시 반환
- AutoStartSaleService: 단일 트랜잭션 전체 처리 → 청크 루프(100건 단위) + AutoStartSaleChunkService에서 건별 트랜잭션
- ReleaseRepositoryPort/ReleaseJpaAdapter/ReleaseJpaRepository: findScheduledBefore에 limit 파라미터 추가 (Pageable 기반)
- build.gradle: redisson-spring-boot-starter 의존성 추가
- application-*.yml: Redis 설정 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…로로 통일

sold_quantity 증가는 Outbox→Inbox(Kafka) 경로만 사용하므로 gRPC 중복 경로 제거.
- release.proto: rpc IncreaseSoldQuantity 및 관련 메시지 제거
- ReleaseGrpcService: increaseSoldQuantity 메서드 및 IncreaseSoldQuantityUseCase 의존성 제거
- IncreaseSoldQuantityUseCase, IncreaseSoldQuantityService 삭제

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
creatorIdQueryPort.getCreatorId() (gRPC + CircuitBreaker)가 @transactional 경계 안에 포함되어
DB 커넥션을 불필요하게 점유하는 문제 수정.

- RegisterReleaseService: @transactional 제거, gRPC 조회 후 RegisterReleaseTxService에 위임
- RegisterReleaseTxService: DB 저장만 담당하는 별도 서비스로 @transactional 격리
  (self-invocation으로 AOP 프록시가 무시되는 문제를 피하기 위해 별도 클래스로 분리)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ReleaseResult: isOnSale() 파생 메서드 추가 — 판매 중 판단 로직을 DTO에 집중
- ReleaseGrpcService: isReleaseOnSale에서 enum 직접 비교 → result.isOnSale() 위임, 불필요한 ReleaseStatus import 제거
- ScheduledAtMultipleOfTenMinutesValidator: atZone() 중복 호출 제거(ZonedDateTime 한 번만), 현재 시각+10분 이후만 허용하는 최소 예약 시간 검증 추가
- ReleaseQueryGrpcAdapter: 매 호출마다 새 채널 생성 → GrpcConfiguration의 releaseManagedChannel Bean 주입으로 채널 재사용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… 주입된 creatorManagedChannel 사용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ng 교체 (Maven Central 미존재)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GetMyProfileUseCase / GetMyProfileService 추가
- GetMyProfileQuery, GetMyProfileResult DTO 추가
- GetMyProfileResponse 추가 (id, nickname, profile, role)
- UserQueryController에 GET /user/me 엔드포인트 추가
- class-level @RequestMapping 제거, 각 메서드에 전체 경로 명시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- outbox.event.order-prepare 토픽 전용 debeziumKafkaListenerContainerFactory 등록
  (Debezium 메시지는 __TypeId__ 헤더가 없으므로 StringDeserializer로 수신)
- OrderStateKafkaListener를 ConsumerRecord<String, String>으로 변경,
  ObjectMapper로 직접 OrderPrepareEventDto 파싱
- IdempotentAspect: value가 InboxEvent가 아닌 경우(String)
  consumerRecord.key()를 eventId로 폴백 처리
- DLT 발행 실패 방지: debeziumKafkaListenerContainerFactory에
  ByteArraySerializer 기반 DLT ProducerFactory + DeadLetterPublishingRecoverer 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- OrderOutboxAdapter: save() 후 entity id를 payload의 event_id 필드로 주입
- OrderOutboxEntity: updatePayload() 메서드 추가
- orchestrator IdempotentAspect가 event_id(NOT NULL)를 정상 읽어
  order→orchestrator→payment 사가 흐름 완성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jun 1, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
9 Security Hotspots

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants