Skip to content

[FEATURE] Shipment 배송 이벤트 발행/구독 기능 구현 및 배송담당자 삭제 내부 API 구현 - #47

Merged
soo96 merged 20 commits into
developfrom
feature/#38-shipment-event-pub-sub
Apr 7, 2026
Merged

soo96 merged 20 commits into
developfrom
feature/#38-shipment-event-pub-sub

Conversation

@soo96

@soo96 soo96 commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

📌 PR 제목


✨ 작업 내용

  • 이벤트 처리

    • 발행
      • 배송 생성 완료: shipment.created
      • 배송 생성 실패: shipment.creation.failed
      • 배송 완료: shipment.completed
    • 구독
      • 주문 생성 성공: order.created -> 배송 생성 구현
      • 주문 취소: order.canceled -> 배송 취소 구현
  • 내부 API

    • 허브 삭제 시 업체 배송 담당자 삭제 요청 : DELETE /internal/shipment-managers/hubs/{hubId}
    • 회원 삭제시 배송담당자 상태변경: DELETE /internal/shipment-managers/users/{hubId}
    • 스케줄링을 통한 일괄 삭제
  • UserContext로 사용자 정보 매핑 추가


🔍 상세 내용

  • 배송 생성 완료 이벤트 발행
  • 배송 생성 실패 이벤트 발행
  • 배송 완료 이벤트 발행
  • 주문 생성 성공 이벤트 구독 -> 배송 생성 처리
  • 주문 취소 이벤트 구독 -> 배송 취소 처리
  • 허브 삭제 시 업체 배송 담당자 삭제 요청 내부 API
  • 회원 삭제시 배송담당자 상태변경 내부 API
  • 배송담당자 삭제 스케줄링

🔗 관련 이슈

Closes #38


⚠️ 리뷰 포인트 (선택)

리뷰 받고 싶은 포인트가 있으면 작성해주세요.


✅ 체크리스트

  • 코드가 정상적으로 동작합니다.
  • 테스트를 완료했습니다.
  • 코드 스타일을 준수했습니다.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 배송 생성/완료/취소 비즈니스 로직, 관련 이벤트/DTO/퍼블리셔·사가 이벤트 추가
    • 내부 배송 관리자 삭제(허브/유저)용 API 및 UserContext 인자 해석기·WebMvc 설정 추가
    • 캐시 추상화, Redis 클라이언트, 라운드로빈 할당, 스케줄러(정기 정리·삭제) 도입
    • idempotent 사가 처리기와 메시지 핸들러/큐·바인딩 확장
  • 버그 수정

    • 내부 호출용 API 경로 조정
  • 인프라 개선

    • RabbitMQ/Feign/Actuator/Redis 설정 및 중복 메시지 방지(영속화+캐시) 강화

soo96 added 16 commits April 6, 2026 21:58
- ShipmentRabbitConfig: order.created 큐 및 DLQ 설정
- OrderCreatedEvent, ShipmentCreatedEvent, ShipmentCreationFailedEvent 정의
- ProcessedSagaEventJpaEntity, Repository: DB 기반 멱등성 저장
- IdempotentSagaExecutor, IdempotentSagaHandler: Redis+DB 이중 멱등성 처리
- ProcessedSagaEventCleanupScheduler: 24시간 경과 이벤트 주기적 정리
- HubClient 인터페이스 및 HubFeignClient/HubClientImpl 구현
- CacheClient 인터페이스 및 RedisClient 구현
- @EnableFeignClients, @EnableScheduling 활성화
- ShipmentManagerRepository: findFirstAvailableByType, findAllByType 추가
- UserClient로 수신자 조회, Redis 커서 기반 허브 담당자 순번 배정
- 이벤트 수신 후 createShipment 호출
- domain/event에 ShipmentCreatedEvent, ShipmentCreationFailedEvent 레코드 정의
- ShipmentEventPublisher 인터페이스를 application 레이어에 추가
- ShipmentEventPublisherImpl: domain 레코드를 SagaEvent로 변환 후 발행
- POST /api/shipments/{shipmentId}/complete api 추가
- ShipmentCompletedEvent 발행
- Hub 삭제 시 업체 배송 담당자 일괄삭제
- User 삭제시 배송 담당자 삭제
- 매일 자정에 일괄 삭제 처리
- findFirstAvailableByType, findAllByType 쿼리에 pendingDeletion = false 조건 추가
@soo96 soo96 self-assigned this Apr 7, 2026
@soo96 soo96 added ✨ Feature 기능 추가 ✅ Test Test labels Apr 7, 2026
@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c05746b4-df5f-4626-88bb-df1687a997b8

📥 Commits

Reviewing files that changed from the base of the PR and between b534f5c and 84f4b08.

📒 Files selected for processing (1)
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java

📝 Walkthrough

Walkthrough

주요 변경사항은 shipment-service에 배송 생성/완료/실패 이벤트 발행 및 주문 생성/취소 이벤트 구독을 포함한 대규모 기능 추가와 연동입니다. Hub 경로 조회(Feign), Redis 기반 캐시/커서(CacheClient/RedisClient), 배송담당자 할당 로직, Shipment 도메인 상태 전이(markCompleted/markCanceled), idempotent 처리(ProcessedSagaEventJpaEntity, IdempotentSagaExecutor, IdempotentSagaHandler), 스케줄러(ProcessedSagaEventCleanupScheduler, ShipmentManagerCleanupScheduler), 내부 삭제 API(/internal/shipment-managers/hubs/{hubId}, /internal/shipment-managers/users/{userId}), 컨트롤러의 UserContext 인자 해석기 및 관련 DTO/리포지터리/테스트가 추가되었습니다.

Sequence Diagram(s)

sequenceDiagram
    participant OrderService as Order Service
    participant RabbitMQ as RabbitMQ
    participant OrderCreatedHandler as OrderCreatedHandler
    participant IdempotentExecutor as IdempotentSagaExecutor
    participant ShipmentService as Shipment Service
    participant HubFeign as HubFeignClient
    participant Cache as CacheClient/RedisClient
    participant EventPub as ShipmentEventPublisher
    participant DB as ProcessedSagaEventRepository

    OrderService->>RabbitMQ: Publish order.created
    RabbitMQ->>OrderCreatedHandler: Deliver OrderCreatedEvent
    OrderCreatedHandler->>IdempotentExecutor: handle(event)
    IdempotentExecutor->>Cache: hasProcessed(redisKey)
    alt not processed
        IdempotentExecutor->>ShipmentService: createShipment(command)
        ShipmentService->>HubFeign: getHubRoutes(depId, arrId)
        HubFeign-->>ShipmentService: List<HubRouteResult>
        ShipmentService->>Cache: increment(HUB_MANAGER_CURSOR_KEY)
        Cache-->>ShipmentService: cursorIndex
        ShipmentService->>DB: persist Shipment (with routes)
        ShipmentService->>EventPub: publishCreated(ShipmentCreatedEvent)
        EventPub->>RabbitMQ: Publish shipment.created
        IdempotentExecutor->>DB: save ProcessedSagaEventJpaEntity
        IdempotentExecutor->>Cache: set(redisKey, 24h)
    else already processed
        IdempotentExecutor-->>OrderCreatedHandler: skip processing
    end
Loading
sequenceDiagram
    participant Client as Client
    participant Controller as ShipmentController
    participant ShipmentService as Shipment Service
    participant Repo as ShipmentRepository/ShipmentJpaRepository
    participant EventPub as ShipmentEventPublisher
    participant RabbitMQ as RabbitMQ

    Client->>Controller: POST /api/shipments/{id}/complete
    Controller->>ShipmentService: completeShipment(shipmentId)
    ShipmentService->>Repo: findByOrderIdWithRoutes(orderId)
    Repo-->>ShipmentService: Shipment + routes
    ShipmentService->>ShipmentService: validate routes all ARRIVED_AT_HUB
    ShipmentService->>ShipmentService: markCompleted()
    ShipmentService->>Repo: save(shipment)
    ShipmentService->>EventPub: publishCompleted(ShipmentCompletedEvent)
    EventPub->>RabbitMQ: Publish shipment.completed
    ShipmentService-->>Controller: ShipmentCompleteResult
    Controller-->>Client: ShipmentCompleteResDto
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • afterrest
  • Jin4041
  • zlonce
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 변경사항(배송 이벤트 발행/구독 및 배송담당자 삭제 내부 API)을 명확하게 설명하고 있습니다.
Description check ✅ Passed PR 설명이 제공된 템플릿의 모든 필수 섹션(제목, 작업 내용, 상세 내용, 관련 이슈, 체크리스트)을 포함하고 있습니다.
Linked Issues check ✅ Passed PR의 모든 코드 변경사항이 #38 이슈의 요구사항을 충족합니다: 세 가지 이벤트 발행, 두 가지 이벤트 구독, 내부 API 구현, 스케줄링 처리.
Out of Scope Changes check ✅ Passed 모든 변경사항이 #38 이슈 범위 내에 있으며, 추가적인 범위 외 변경은 발견되지 않습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/#38-shipment-event-pub-sub

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 20

🧹 Nitpick comments (8)
shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java (1)

6-16: 커맨드 레코드에 최소 불변조건 검증을 추가하는 것을 권장합니다.

이 레코드가 외부 이벤트 입력을 받는 경로라면 quantity > 0 및 필수 UUID/null 체크를 compact constructor에서 선제 검증하면 실패 지점이 명확해집니다.

예시 diff
 public record CreateShipmentCommand(
 	UUID orderId,
 	UUID ordererId,
 	UUID productId,
 	int quantity,
 	UUID departureHubId,
 	UUID arrivalHubId,
 	LocalDateTime requestDeadline,
 	String requestNote,
 	String shipmentAddress
 ) {
+	public CreateShipmentCommand {
+		if (orderId == null || ordererId == null || productId == null
+			|| departureHubId == null || arrivalHubId == null) {
+			throw new IllegalArgumentException("필수 식별자는 null일 수 없습니다.");
+		}
+		if (quantity <= 0) {
+			throw new IllegalArgumentException("quantity는 1 이상이어야 합니다.");
+		}
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java`
around lines 6 - 16, Add defensive validation to the CreateShipmentCommand
record by implementing a compact constructor that checks non-null for all
UUID/string/LocalDateTime fields (orderId, ordererId, productId, departureHubId,
arrivalHubId, requestDeadline, shipmentAddress) and ensures quantity > 0; throw
IllegalArgumentException or NullPointerException with clear messages when
validation fails so creation fails fast and gives a clear error location.
shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java (1)

121-123: 상태 전환 검증 누락 고려

markCanceled()는 현재 상태 검증 없이 바로 CANCELED로 변경합니다. 다른 상태 전환 메서드들(markMovingToHub, markArrivedAtHub)은 유효한 상태 전환인지 검증하는 패턴을 따르고 있습니다.

이미 CANCELED 또는 ARRIVED_AT_HUB 상태인 경로를 다시 취소하는 것이 비즈니스적으로 허용되는지 확인이 필요합니다. 의도적인 설계라면 무시해도 됩니다.

♻️ 상태 검증 추가 제안
 public void markCanceled() {
+    if (this.status == ShipmentRouteStatus.CANCELED) {
+        return; // 이미 취소됨 - 멱등성 보장
+    }
+    if (this.status == ShipmentRouteStatus.ARRIVED_AT_HUB) {
+        throw new BusinessException(ShipmentErrorCode.INVALID_SHIPMENT_ROUTE_STATUS);
+    }
     this.status = ShipmentRouteStatus.CANCELED;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java`
around lines 121 - 123, The markCanceled() method currently sets status
unconditionally; follow the same state-transition validation pattern as
markMovingToHub/markArrivedAtHub by checking the current status
(ShipmentRoute#status) before assigning ShipmentRouteStatus.CANCELED and throw
an IllegalStateException for invalid transitions (e.g., when status is
ARRIVED_AT_HUB or already CANCELED) or make it idempotent by returning early if
status == CANCELED—update the markCanceled() method accordingly to perform this
validation instead of unconditional assignment.
shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java (1)

9-17: 테스트 편의성을 위해 @AllArgsConstructor 추가 고려

AI 요약에 따르면 테스트에서 리플렉션을 통해 인스턴스를 생성하고 있습니다. @AllArgsConstructor를 추가하면 테스트 코드가 간결해지고, Jackson 역직렬화도 명시적으로 지원됩니다.

♻️ 제안하는 수정
 import lombok.Getter;
 import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;

 `@Getter`
 `@NoArgsConstructor`
+@AllArgsConstructor
 public class HubRouteResult {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java`
around lines 9 - 17, Add a Lombok all-args constructor to the HubRouteResult DTO
by annotating the class with `@AllArgsConstructor` and importing
lombok.AllArgsConstructor so tests and Jackson can construct instances without
reflection; update the class declaration that currently has `@Getter` and
`@NoArgsConstructor` to include `@AllArgsConstructor` (retain existing annotations
and fields: sequence, departureHubId, arrivalHubId, estimatedDistance,
estimatedDuration).
shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java (1)

21-28: canceledAt 매핑 의미 확인 필요

canceledAtshipment.getUpdatedAt()을 사용하고 있습니다. 취소 시점의 updatedAt이 취소 시간을 나타내는 것이 맞다면 괜찮지만, Shipment 도메인에 명시적인 canceledAt 필드가 있다면 해당 필드를 사용하는 것이 더 명확합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java`
around lines 21 - 28, The mapping in ShipmentCanceledResult.fromEntity uses
shipment.getUpdatedAt() for canceledAt which may be incorrect; check the
Shipment domain for an explicit canceledAt accessor (e.g., getCanceledAt()) and
if it exists, change ShipmentCanceledResult.fromEntity to call
shipment.getCanceledAt() instead of shipment.getUpdatedAt(); if Shipment lacks a
canceledAt field, either add a proper canceledAt property to the Shipment
aggregate and expose getCanceledAt(), or add a clear comment/rename to indicate
that updatedAt is intentionally used as the cancellation timestamp so the
mapping is explicit and correct.
shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java (1)

7-9: 대량 삭제 시 성능 이슈 가능성

Spring Data JPA의 파생 쿼리 deleteBy* 메서드는 먼저 엔티티를 조회한 후 개별적으로 삭제합니다. 정리 스케줄러에서 많은 레코드를 삭제할 경우 N+1 문제가 발생할 수 있습니다.

♻️ 벌크 삭제를 위한 JPQL 쿼리 사용 제안
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
 public interface ProcessedSagaEventRepository extends JpaRepository<ProcessedSagaEventJpaEntity, String> {
-	void deleteByProcessedAtBefore(LocalDateTime cutoff);
+	`@Modifying`
+	`@Query`("DELETE FROM ProcessedSagaEventJpaEntity e WHERE e.processedAt < :cutoff")
+	int deleteByProcessedAtBefore(`@Param`("cutoff") LocalDateTime cutoff);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java`
around lines 7 - 9, The derived deleteByProcessedAtBefore method causes N+1
deletes because Spring Data loads entities before removing; replace it with a
bulk JPQL delete using a `@Modifying` `@Query` on the ProcessedSagaEventRepository
(e.g., a method like deleteBefore or deleteByProcessedAtBefore using "delete
from ProcessedSagaEventJpaEntity e where e.processedAt < :cutoff") and ensure
the method is executed in a `@Transactional` context and marked `@Modifying`
(consider setting clearAutomatically=true) so deletion is performed in one SQL
bulk operation rather than row-by-row.
shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java (2)

72-80: HubRouteResult 생성에 Reflection 사용은 적절하지만, Builder 또는 테스트 픽스처 고려

ReflectionTestUtils는 DTO에 setter가 없을 때 유용하지만, HubRouteResult에 테스트용 생성자나 Builder를 추가하거나 별도의 테스트 픽스처 클래스를 만드는 것이 유지보수에 더 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java`
around lines 72 - 80, The test currently uses ReflectionTestUtils inside
createHubRouteResult to set HubRouteResult internals; instead add a
test-friendly way to construct HubRouteResult (either a package-private
constructor, a static test Builder on HubRouteResult, or a separate test
fixture/factory) and update createHubRouteResult to call that
constructor/builder/factory instead of using ReflectionTestUtils.setField for
sequence, departureHubId, arrivalHubId, estimatedDistance and estimatedDuration;
ensure the new construction is accessible to ShipmentServiceTest and remove all
ReflectionTestUtils usage for HubRouteResult.

120-123: createShipment_success 테스트에서 더 상세한 검증 권장

현재 테스트는 예외가 발생하지 않는 것과 이벤트 발행만 검증합니다. ArgumentCaptor를 사용하여 저장된 Shipment의 필드 값이나 발행된 이벤트의 내용을 검증하면 테스트 신뢰도가 높아집니다.

💡 예시
// given
ArgumentCaptor<Shipment> shipmentCaptor = ArgumentCaptor.forClass(Shipment.class);

// when
shipmentService.createShipment(command);

// then
then(shipmentRepository).should().save(shipmentCaptor.capture());
Shipment captured = shipmentCaptor.getValue();
assertThat(captured.getOrderId()).isEqualTo(orderId);
assertThat(captured.getDepartureHubId()).isEqualTo(departureHubId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java`
around lines 120 - 123, Enhance the createShipment_success test to capture and
assert the actual Shipment saved and the published event contents: use
ArgumentCaptor.forClass(Shipment.class) to capture the argument passed to
shipmentRepository.save (and/or capture the event via
eventPublisher.publishCreated), call shipmentService.createShipment(command) as
before, then retrieve the captured Shipment and assert key fields like
getOrderId(), getDepartureHubId(), and any status/metadata; also assert the
published event's payload fields to match expected values so the test verifies
state and event content, not just no-exception and publish invocation.
shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java (1)

37-44: userContext 파라미터가 현재 사용되지 않습니다.

createShipmentManager에서 userContext가 주입되지만 사용되지 않습니다. TODO 주석이나 권한 처리 계획이 있다면 주석으로 명시하는 것이 좋습니다. 동일한 패턴이 getShipmentManager, searchShipmentManager에도 적용됩니다.

API Gateway 도입 계획에 따른 의도적인 구조임을 이해하지만, 향후 구현 예정임을 나타내는 TODO 주석 추가를 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java`
around lines 37 - 44, The UserContext parameter is injected into controller
methods but unused; add a clear TODO comment in ShipmentManagerController on the
methods createShipmentManager, getShipmentManager, and searchShipmentManager
stating that UserContext is intentionally kept for future API Gateway /
authorization integration and will be used for permission checks and user info
propagation, so retain the parameter for now; ensure the comment briefly
indicates expected future behavior (e.g., "TODO: use UserContext for auth/claims
from API Gateway") and keep the method signatures unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shipment-service/build.gradle`:
- Around line 51-52: Remove the duplicate Feign dependency declaration: delete
the second implementation entry for
'org.springframework.cloud:spring-cloud-starter-openfeign' in build.gradle so
only the single declaration remains (the earlier one on Line 33). Ensure no
other identical implementation lines exist to avoid redundant dependency
declarations.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCompleteResult.java`:
- Line 26: The DTO mapping uses shipment.getUpdatedAt() for completedAt, which
can be overwritten by subsequent updates; update the domain to expose a
dedicated completion timestamp or change the mapping to use that dedicated field
instead of getUpdatedAt() — add a completedAt field on the Shipment entity (and
set it when status transitions in updateStatus()), expose a getter (e.g.,
getCompletedAt()), and update ShipmentCompleteResult.completedAt(...) to use
shipment.getCompletedAt(); apply the same fix for
ShipmentCanceledResult.canceledAt using a dedicated canceledAt field and getter.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java`:
- Around line 120-123: markPendingDeletionByUserId currently throws
BusinessException(SHIPMENT_MANAGER_NOT_FOUND) when
shipmentManagerRepository.findByUserId(userId) is empty, making the operation
non-idempotent; change it to treat "not found" as a no-op success (e.g., return
immediately or log and return) instead of throwing, while preserving the
existing behavior of calling manager.markPendingDeletion() when a
ShipmentManager is present; update the code referencing
shipmentManagerRepository.findByUserId, ShipmentManager.markPendingDeletion, and
avoid throwing BusinessException with SHIPMENT_MANAGER_NOT_FOUND for missing
users.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentService.java`:
- Around line 56-104: The createShipment method currently publishes domain
events inside the `@Transactional` boundary causing possible DB/event
inconsistency; change createShipment to only persist and then publish an
internal Spring event (e.g., create and publish ShipmentCreatedInternalEvent and
ShipmentCreationFailedInternalEvent) via ApplicationEventPublisher from within
ShipmentService (replace eventPublisher.publishCreated(...) and
publishCreationFailed(...) calls with
applicationEventPublisher.publishEvent(...)), and implement a new
ShipmentEventListener with methods annotated `@TransactionalEventListener`(phase =
TransactionPhase.AFTER_COMMIT) that receive ShipmentCreatedInternalEvent and
ShipmentCreationFailedInternalEvent and call the external
eventPublisher.publishCreated(...) / publishCreationFailed(...); keep existing
symbols ShipmentService, eventPublisher,
eventPublisher.publishCreated/publishCreationFailed, and add
ShipmentCreatedInternalEvent/ShipmentCreationFailedInternalEvent and
ShipmentEventListener to ensure events are only sent after successful commit.
- Around line 155-168: completeShipment currently publishes the
ShipmentCompletedEvent inside the `@Transactional` method (via
eventPublisher.publishCompleted after shipment.markCompleted), which risks
transactional-event inconsistency if the transaction later fails; change the
flow so the domain change (shipment.markCompleted) is committed within the
transaction and the event is emitted only after commit—either by removing
publishCompleted from the `@Transactional` completeShipment and calling it from a
non-transactional caller, or by using a TransactionalEventListener or an outbox
pattern to defer publishing until after commit (refer to completeShipment,
shipment.markCompleted, eventPublisher.publishCompleted and the `@Transactional`
annotation to locate the code).
- Around line 99-103: The catch block in ShipmentService currently always calls
eventPublisher.publishCreationFailed and may mask the original exception if
publishing fails or incorrectly signal failure when shipmentRepository.save
already committed; modify the method so you track whether
shipmentRepository.save(...) completed successfully (e.g., a boolean persisted
or by checking the returned entity) and only call
eventPublisher.publishCreationFailed(command.orderId()) when the save did NOT
complete, and wrap the publishCreationFailed call in its own try/catch that logs
any publish error but rethrows the original exception (do not replace the
original exception if publish fails); reference shipmentRepository.save and
eventPublisher.publishCreationFailed in ShipmentService's catch handling and
ensure the original exception is always the one propagated.
- Around line 193-211: The startIndex calculation can overflow when cursor is
near Long.MAX_VALUE because it does (cursor - 1) before casting; instead take
cursor modulo the hubManagers size first. In buildRoutes, get int n =
hubManagers.size(), compute an int cursorMod = (int)(cursor % n) and then derive
startIndex safely (e.g. (cursorMod + n - 1) % n) so the subtraction never
happens on the full long; keep the rest of the mapping logic (managerIndex
calculation using route.getSequence()) unchanged.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/domain/Shipment.java`:
- Around line 118-154: Public updateStatus(ShipmentStatus) allows bypassing
transition rules; prevent this by making updateStatus private (or
package-private) and ensure all external status changes go through
markCompleted()/markCanceled() which call
validateCompletable()/validateCancelable() and then set the status;
alternatively, if updateStatus must stay public, have it enforce the same
validation logic (call validateCompletable() when setting COMPLETED and
validateCancelable() when setting CANCELLED, and throw BusinessException for
illegal transitions) so the aggregate invariants in
markCompleted()/markCanceled() cannot be bypassed.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClientImpl.java`:
- Around line 28-33: In HubClientImpl (around the hubFeignClient.getHubRoutes
call) add null checks for the ApiResponse itself before accessing getData(): if
response == null or response.getData() == null or empty, throw the domain
BusinessException using ShipmentErrorCode.HUB_ROUTE_NOT_FOUND so external null
responses are converted to the same domain error; update the logic around
ApiResponse<List<HubRouteResult>> response =
hubFeignClient.getHubRoutes(departureHubId, arrivalHubId) to explicitly handle a
null response and then the existing routes null/empty case.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubFeignClient.java`:
- Around line 17-18: The Feign client method in HubFeignClient uses implicit
parameter names for `@RequestParam` (departureHubId, arrivalHubId) which fails at
runtime on Spring Boot 3.5 + Java 21; fix it by either adding explicit names to
the annotations (e.g., change `@RequestParam` UUID departureHubId to
`@RequestParam`("departureHubId") UUID departureHubId and similarly for
arrivalHubId) or enable the compiler -parameters flag in your build (add the
JavaCompile tasks.withType configuration to build.gradle) so parameter name
metadata is retained.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/RedisClient.java`:
- Around line 19-22: The increment method in RedisClient currently masks a null
result from redisTemplate.opsForValue().increment(key) by returning 0L, which
can produce a negative/incorrect round-robin cursor in callers; instead detect a
null response and fail fast: change RedisClient.increment to throw a clear
unchecked exception (e.g., IllegalStateException) including the key and a short
context message when value == null so Redis connection/operation failures
surface immediately to callers performing (cursor - 1) % hubManagers.size();
keep the successful path returning the non-null Long value.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCanceledEvent.java`:
- Around line 12-18: OrderCanceledEvent's orderId remains null after Jackson
deserialization because the class only has `@NoArgsConstructor` and `@Getter`;
update OrderCanceledEvent to allow Jackson to set orderId by either adding an
explicit constructor public OrderCanceledEvent(UUID orderId) that assigns
this.orderId or by annotating the class with `@Setter` (or adding a setter for
orderId), so that OrderCanceledHandler's event.getOrderId() returns the expected
value.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaExecutor.java`:
- Line 22: 현재 IdempotentSagaExecutor의 REDIS_TTL(현재 Duration.ofHours(24))이
processed-event 정리 작업과 동일한 24시간으로 설정되어 있어, 브로커의 최대 재시도/재전달 창보다 짧을 경우 지연 재전달된 동일
eventId가 다시 처리될 수 있습니다; 변경 방법은 REDIS_TTL 상수를 하드코딩된 24시간에서 분리하고(예: 새 구성 키 또는
프로퍼티로) broker의 최대 재시도 창보다 길게 기본값을 설정하거나 완전히 구성 가능하게 만들어야 합니다 — 즉 클래스
IdempotentSagaExecutor에서 REDIS_TTL을 구성값으로 치환하고 processed-event 정리(job) 타임아웃과는
별도의 설정(예: idempotency.ttl)으로 관리하도록 수정하세요.
- Around line 35-48: The idempotency marker is written after doProcess in
executeWithIdempotency, allowing concurrent deliveries to both run side effects;
change the flow to atomically acquire a pre-check/lock before invoking doProcess
(e.g., attempt a Redis SETNX / cacheClient.setIfAbsent(redisKey, "1", REDIS_TTL)
or perform a unique insert into processedSagaEventRepository to reserve the
event) and return early if the acquire fails; if using DB unique insert, catch
the duplicate-key exception and skip processing, and still register
TransactionSynchronizationManager.registerSynchronization to set the cache
afterCommit and persist ProcessedSagaEventJpaEntity only once (or rely on the
successful unique insert as the persistent marker).

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ProcessedSagaEventCleanupScheduler.java`:
- Around line 24-25: 현재 ProcessedSagaEventCleanupScheduler에서 LocalDateTime
cutoff = LocalDateTime.now().minusHours(24)로 TTL이 하드코딩되어 있어 재전달 지연이 24시간을 초과할 경우
중복 처리 위험이 있습니다; 변경 방법은 ProcessedSagaEventCleanupScheduler에 환경설정으로부터 읽는 TTL 값을
주입(예: Duration 또는 long hours 프로퍼티)하고 LocalDateTime.now().minus(주입된 TTL)으로
cutoff를 계산한 뒤 processedSagaEventRepository.deleteByProcessedAtBefore(...)를 호출하도록
수정하세요 — 관련 심볼: ProcessedSagaEventCleanupScheduler, cutoff,
processedSagaEventRepository.deleteByProcessedAtBefore.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentJpaRepository.java`:
- Around line 31-37: The repository method findByOrderIdWithRoutes currently
returns Optional<Shipment> but orderId is not guaranteed unique, risking
NonUniqueResultException; fix by either (A) enforcing 1:1 semantics: add a
unique constraint on the orderId field in the Shipment entity (e.g.,
`@Column`(unique = true) and add a DB migration/constraint) and keep
findByOrderIdWithRoutes as Optional<Shipment>, or (B) supporting 1:many
semantics: change the repository method signature
findByOrderIdWithRoutes(`@Param`("orderId") UUID orderId) to return List<Shipment>
(and update all callers to handle a list) so multiple shipments for the same
order are handled safely.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerJpaRepository.java`:
- Around line 38-46: The custom JPQL using "limit 1" is non-standard; remove the
`@Query` block and replace the repository method with a Spring Data derived query
to get the first matching row. Rename/replace findFirstAvailableByType(...) with
a derived signature such as Optional<ShipmentManager>
findFirstByTypeAndDeletedAtIsNullAndPendingDeletionFalseOrderByShipmentSequenceAsc(ShipmentManagerType
type) (remove `@Param`), which yields the same semantics in a portable way.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContextArgumentResolver.java`:
- Line 43: Wrap the UUID parsing in UserContextArgumentResolver so that
UUID.fromString(userId) parsing failures are caught and converted into a
controlled 4xx error: catch IllegalArgumentException thrown by UUID.fromString
in the method that constructs the UserContext and throw a BusinessException (or
another existing client error type) with a clear message about invalid X-User-Id
format; alternatively, add an exception handler for IllegalArgumentException
that returns 400 Bad Request. Update the code paths that construct new
UserContext(UUID.fromString(userId), role) to use this guarded parsing to ensure
consistent 4xx responses.

In `@shipment-service/src/main/resources/application.yaml`:
- Line 27: Replace the hardcoded spring.rabbitmq.port value (currently set to
5672) in application.yaml with an environment-variable-backed placeholder so the
port can be overridden at deploy time; use the SPRING_RABBITMQ_PORT env var with
a default of 5672 (i.e., change the port entry for spring.rabbitmq.port to
reference SPRING_RABBITMQ_PORT with default 5672) to match other infra patterns
and enable flexibility across environments.
- Around line 28-29: Remove the fallback "guest" defaults so RabbitMQ
credentials must be injected: replace username: ${RABBITMQ_USERNAME:guest} and
password: ${RABBITMQ_PASSWORD:guest} with username: ${RABBITMQ_USERNAME} and
password: ${RABBITMQ_PASSWORD} in application.yaml to force runtime failure when
env vars are missing (or add explicit startup validation that
RABBITMQ_USERNAME/RABBITMQ_PASSWORD are present); update any startup/config
validation logic if needed to fail fast and document the required env vars.

---

Nitpick comments:
In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java`:
- Around line 9-17: Add a Lombok all-args constructor to the HubRouteResult DTO
by annotating the class with `@AllArgsConstructor` and importing
lombok.AllArgsConstructor so tests and Jackson can construct instances without
reflection; update the class declaration that currently has `@Getter` and
`@NoArgsConstructor` to include `@AllArgsConstructor` (retain existing annotations
and fields: sequence, departureHubId, arrivalHubId, estimatedDistance,
estimatedDuration).

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java`:
- Around line 6-16: Add defensive validation to the CreateShipmentCommand record
by implementing a compact constructor that checks non-null for all
UUID/string/LocalDateTime fields (orderId, ordererId, productId, departureHubId,
arrivalHubId, requestDeadline, shipmentAddress) and ensures quantity > 0; throw
IllegalArgumentException or NullPointerException with clear messages when
validation fails so creation fails fast and gives a clear error location.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java`:
- Around line 21-28: The mapping in ShipmentCanceledResult.fromEntity uses
shipment.getUpdatedAt() for canceledAt which may be incorrect; check the
Shipment domain for an explicit canceledAt accessor (e.g., getCanceledAt()) and
if it exists, change ShipmentCanceledResult.fromEntity to call
shipment.getCanceledAt() instead of shipment.getUpdatedAt(); if Shipment lacks a
canceledAt field, either add a proper canceledAt property to the Shipment
aggregate and expose getCanceledAt(), or add a clear comment/rename to indicate
that updatedAt is intentionally used as the cancellation timestamp so the
mapping is explicit and correct.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java`:
- Around line 121-123: The markCanceled() method currently sets status
unconditionally; follow the same state-transition validation pattern as
markMovingToHub/markArrivedAtHub by checking the current status
(ShipmentRoute#status) before assigning ShipmentRouteStatus.CANCELED and throw
an IllegalStateException for invalid transitions (e.g., when status is
ARRIVED_AT_HUB or already CANCELED) or make it idempotent by returning early if
status == CANCELED—update the markCanceled() method accordingly to perform this
validation instead of unconditional assignment.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java`:
- Around line 7-9: The derived deleteByProcessedAtBefore method causes N+1
deletes because Spring Data loads entities before removing; replace it with a
bulk JPQL delete using a `@Modifying` `@Query` on the ProcessedSagaEventRepository
(e.g., a method like deleteBefore or deleteByProcessedAtBefore using "delete
from ProcessedSagaEventJpaEntity e where e.processedAt < :cutoff") and ensure
the method is executed in a `@Transactional` context and marked `@Modifying`
(consider setting clearAutomatically=true) so deletion is performed in one SQL
bulk operation rather than row-by-row.

In
`@shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java`:
- Around line 37-44: The UserContext parameter is injected into controller
methods but unused; add a clear TODO comment in ShipmentManagerController on the
methods createShipmentManager, getShipmentManager, and searchShipmentManager
stating that UserContext is intentionally kept for future API Gateway /
authorization integration and will be used for permission checks and user info
propagation, so retain the parameter for now; ensure the comment briefly
indicates expected future behavior (e.g., "TODO: use UserContext for auth/claims
from API Gateway") and keep the method signatures unchanged.

In
`@shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java`:
- Around line 72-80: The test currently uses ReflectionTestUtils inside
createHubRouteResult to set HubRouteResult internals; instead add a
test-friendly way to construct HubRouteResult (either a package-private
constructor, a static test Builder on HubRouteResult, or a separate test
fixture/factory) and update createHubRouteResult to call that
constructor/builder/factory instead of using ReflectionTestUtils.setField for
sequence, departureHubId, arrivalHubId, estimatedDistance and estimatedDuration;
ensure the new construction is accessible to ShipmentServiceTest and remove all
ReflectionTestUtils usage for HubRouteResult.
- Around line 120-123: Enhance the createShipment_success test to capture and
assert the actual Shipment saved and the published event contents: use
ArgumentCaptor.forClass(Shipment.class) to capture the argument passed to
shipmentRepository.save (and/or capture the event via
eventPublisher.publishCreated), call shipmentService.createShipment(command) as
before, then retrieve the captured Shipment and assert key fields like
getOrderId(), getDepartureHubId(), and any status/metadata; also assert the
published event's payload fields to match expected values so the test verifies
state and event content, not just no-exception and publish invocation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3422109e-ce89-4ca6-be61-d6e89dcbd557

📥 Commits

Reviewing files that changed from the base of the PR and between ae3056d and ca72fa5.

📒 Files selected for processing (57)
  • hub-service/src/main/java/com/shipflow/hubservice/infrastructure/client/DeliveryClient.java
  • order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCompletedEvent.java
  • order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/consume/ShipmentCreatedEvent.java
  • order-service/src/main/java/com/shipflow/orderservice/infrastructure/messaging/event/publish/OrderCanceledEvent.java
  • shipment-service/build.gradle
  • shipment-service/src/main/java/com/shipflow/shipmentservice/ShipmentserviceApplication.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentEventPublisher.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentManagerService.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/ShipmentService.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/CacheClient.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/HubClient.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/client/dto/HubRouteResult.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/command/CreateShipmentCommand.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCanceledResult.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/application/dto/result/ShipmentCompleteResult.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/Shipment.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentManager.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRoute.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/ShipmentRouteStatus.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCompletedEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreatedEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/event/ShipmentCreationFailedEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/exception/ShipmentErrorCode.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentManagerRepository.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/domain/repository/ShipmentRepository.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClient.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClientImpl.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubFeignClient.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/RedisClient.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/ShipmentEventPublisherImpl.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/config/ShipmentRabbitConfig.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCanceledEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/consume/OrderCreatedEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCompletedSagaEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreatedSagaEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/event/publish/ShipmentCreationFailedSagaEvent.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaExecutor.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/IdempotentSagaHandler.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCanceledHandler.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/handler/OrderCreatedHandler.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ProcessedSagaEventCleanupScheduler.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/messaging/scheduler/ShipmentManagerCleanupScheduler.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventJpaEntity.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ProcessedSagaEventRepository.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentJpaRepository.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerJpaRepository.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentManagerRepositoryImpl.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/persistence/ShipmentRepositoryImpl.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentController.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerController.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/ShipmentManagerInternalController.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContext.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/UserContextArgumentResolver.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/WebMvcConfig.java
  • shipment-service/src/main/java/com/shipflow/shipmentservice/presentation/dto/response/ShipmentCompleteResDto.java
  • shipment-service/src/main/resources/application.yaml
  • shipment-service/src/test/java/com/shipflow/shipmentservice/application/ShipmentServiceTest.java
💤 Files with no reviewable changes (1)
  • shipment-service/src/main/java/com/shipflow/shipmentservice/infrastructure/client/HubClient.java

Comment thread shipment-service/build.gradle Outdated
Comment thread shipment-service/src/main/resources/application.yaml
Comment thread shipment-service/src/main/resources/application.yaml

@kim-jun-won kim-jun-won left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

고생하셨습니다!

@250ghghghgh 250ghghghgh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

고생하셨습니다~

@soo96
soo96 merged commit 09fc830 into develop Apr 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 기능 추가 ✅ Test Test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Shipment 배송 이벤트 발행/구독 기능 구현 및 배송담당자 삭제 내부 API 구현

3 participants