Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
35db47e
feat/116 :: Order 도메인 Aggregate, VO, 이벤트, 예외, 글로벌 예외 계층
lian2945 May 18, 2026
f79c8da
feat/116 :: Order UseCase, Port, Service, DTO 구현
lian2945 May 18, 2026
fb32475
feat/116 :: Order 어댑터 (Web/Kafka/gRPC/Persistence/Security/Scheduler)
lian2945 May 18, 2026
c43e703
feat/116 :: Order application-*.yml, Dockerfile, build.gradle 설정
lian2945 May 18, 2026
503d3c3
feat/116 :: SonarCloud 경고 해결 (빈 테스트 메서드 주석, Dockerfile non-root 유저)
lian2945 May 18, 2026
59650b6
feat/116 :: order application-dev.yml trusted.ips 수정
lian2945 May 19, 2026
7452d15
feat/116 :: order Dockerfile 경로 및 줄바꿈 오류 수정
lian2945 May 19, 2026
e92585c
feat/116 :: order application-prod.yml 수정 (server.port/TRUSTED_IPS 제거…
lian2945 May 19, 2026
6f90fdb
feat/116 :: subscribe/creator application-dev/prod.yml server.port 제거
lian2945 May 20, 2026
613a3e6
fix(grpc): GrpcAdapter withDeadlineAfter(2s) 설정 추가
lian2945 May 20, 2026
613fefc
fix(inbox): @DltHandler 추가로 DLT 전환 시 Inbox DEAD_LETTERED 상태 추적
lian2945 May 20, 2026
fff6513
fix(user/kafka): 전체 리스너에 @RetryableTopic + @DltHandler 추가
lian2945 May 20, 2026
12d1716
refactor(inbox): InboxEvent에 occurredAt() 추가, IdempotentAspect 리플렉션 제거
lian2945 May 20, 2026
a35f211
refactor(inbox): InboxEvent occurredAt() 계약 추가, IdempotentAspect 리플렉션 제거
lian2945 May 20, 2026
7bfbddd
fix(inbox): InboxEntity에 occurredAt 필드 추가
lian2945 May 20, 2026
947d50a
ci: trigger build
lian2945 May 20, 2026
92bede8
fix(inbox): InboxProperties 누락 및 inbox 설정 추가
lian2945 May 20, 2026
08ae806
fix(inbox): SubscribeInboxStatus/Repository, GeneralGoodsInboxStatus/…
lian2945 May 20, 2026
4109f1f
fix(creator): CreatorDomainEvent → CreatorOutbox 리네이밍 및 테이블명 creator_…
lian2945 May 20, 2026
df2ab7a
fix(creator): 서비스 레이어 CreatorDomainEventRepositoryPort → CreatorOutbo…
lian2945 May 20, 2026
dada21f
fix(creator): 도메인 이벤트 occurredAt 필드 추가 및 Creator.createBuilder() 복구
lian2945 May 20, 2026
3e82824
fix(kafka): creator/subscribe retry 설정 spring.kafka.retry.topic 형식으로 수정
lian2945 May 20, 2026
dbafa83
fix(creator): refactor/115 기준으로 creator 서비스 전체 동기화
lian2945 May 20, 2026
be3e263
fix(creator): creator.proto에 GetCreatorIdByUserId rpc 추가
lian2945 May 20, 2026
19f1287
fix(docker): Dockerfile appuser 보안 패턴 및 JAR_FILE 경로 통일
lian2945 May 20, 2026
525a95a
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
95d0e35
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
ef6480a
fix(inbox): 만료 메시지 조용한 폐기 → DEAD_LETTERED 영속화로 변경
lian2945 May 20, 2026
36b339a
feat(order): Resilience4j CircuitBreaker/TimeLimiter 설정 추가 (releaseSe…
lian2945 May 20, 2026
26bc10a
refactor(order): @Transactional 범위 내 gRPC 호출 제거
lian2945 May 21, 2026
62aaa64
refactor(order): AutoConfirm 스케줄러 청크 처리 + 분산 락 + 설정 외부화
lian2945 May 21, 2026
7c6f82a
fix(kafka): IdempotentAspect catch(Throwable) 수정 및 @RetryableTopic ex…
lian2945 May 22, 2026
5bb879c
fix(order): ConfirmOrderLineService 이벤트 중복 발행 방지 가드 추가
lian2945 May 22, 2026
0d66382
fix(order): confirmOrderLine 도메인에서 PREPARING 상태만 허용하도록 수정
lian2945 May 22, 2026
a72677b
fix(kafka): @RetryableTopic에 dltStrategy, dltTopicSuffix 명시
lian2945 May 22, 2026
e7bf5c8
fix(kafka): IdempotentAspect catch(Exception) → catch(Throwable) 컴파일 …
lian2945 May 22, 2026
5c87ab7
Merge remote-tracking branch 'origin/main' into feat/116
lian2945 May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions services/order/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu
ARG JAR_FILE=build/libs/*.jar
WORKDIR /app
ARG JAR_FILE=services/order/build/libs/*.jar
RUN groupadd -r appuser && useradd -r -g appuser appuserWORKDIR /app

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] 라인 3, 6에 개행이 누락되어 Dockerfile이 빌드 실패합니다.

  • 현재 RUN groupadd -r appuser && useradd -r -g appuser appuserWORKDIR /appappuserWORKDIR라는 사용자명이 만들어지고 /app 인자가 useradd에 전달되어 실패하며, WORKDIR 지시자가 인식되지 않습니다.
  • USER appuserEXPOSE 8080 또한 동일 문제로 appuserEXPOSE라는 잘못된 유저 지정.

수정안:

FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu
ARG JAR_FILE=services/order/build/libs/*.jar
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY ${JAR_FILE} app.jar
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

빌드/배포 차단 이슈이므로 머지 전 반드시 수정 필요합니다.

COPY ${JAR_FILE} app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
RUN chown -R appuser:appuser /app
USER appuserEXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
44 changes: 43 additions & 1 deletion services/order/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
plugins {
id 'com.google.protobuf' version '0.9.6'
}

ext {
springGrpcVersion = "1.0.2"
springCloudVersion = "2025.1.0"
}
version = '0.0.1'
description = 'order'

dependencies {
}
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework:spring-aspects'
implementation 'org.aspectj:aspectjweaver'
implementation 'org.springframework.boot:spring-boot-starter-kafka'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.grpc:spring-grpc-client-spring-boot-starter'
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
runtimeOnly 'com.mysql:mysql-connector-j'

testImplementation 'org.springframework.kafka:spring-kafka-test'
}

dependencyManagement {
imports {
mavenBom "org.springframework.grpc:spring-grpc-dependencies:$springGrpcVersion"
mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion"
}
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.34.0"
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.79.0'
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kr.magicbox.order.adapter.in.kafka;

import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent;
import kr.magicbox.order.adapter.in.kafka.event.DeliveryCompletedEvent;
import kr.magicbox.order.adapter.in.kafka.event.DeliveryStartedEvent;
import kr.magicbox.order.application.port.in.HandleDeliveryCompletedUseCase;
import kr.magicbox.order.application.port.in.HandleDeliveryStartedUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class DeliveryEventKafkaListener {

private final HandleDeliveryStartedUseCase handleDeliveryStartedUseCase;
private final HandleDeliveryCompletedUseCase handleDeliveryCompletedUseCase;

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.delivery-started", groupId = "order-service")
public void handleDeliveryStarted(ConsumerRecord<String, DeliveryStartedEvent> consumerRecord) {
log.info("[Inbox] delivery.started 이벤트 수신. eventId={}", consumerRecord.key());
DeliveryStartedEvent event = consumerRecord.value();
handleDeliveryStartedUseCase.handleDeliveryStarted(event.orderId(), event.orderLineId());
}

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.delivery-completed", groupId = "order-service")
public void handleDeliveryCompleted(ConsumerRecord<String, DeliveryCompletedEvent> consumerRecord) {
log.info("[Inbox] delivery.completed 이벤트 수신. eventId={}", consumerRecord.key());
DeliveryCompletedEvent event = consumerRecord.value();
handleDeliveryCompletedUseCase.handleDeliveryCompleted(event.orderId(), event.orderLineId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.magicbox.order.adapter.in.kafka;

import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafkaRetryTopic;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@EnableKafkaRetryTopic
@Configuration
@EnableConfigurationProperties(InboxProperties.class)
public class KafkaConfiguration {

@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.setThreadNamePrefix("kafka-retry-");
return scheduler;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.magicbox.order.adapter.in.kafka;

import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent;
import kr.magicbox.order.adapter.in.kafka.event.OrderPrepareEventDto;
import kr.magicbox.order.application.port.in.HandleOrderPrepareUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderStateKafkaListener {

private final HandleOrderPrepareUseCase handleOrderPrepareUseCase;

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.order-prepare", groupId = "order-service")
public void handleOrderPrepare(ConsumerRecord<String, OrderPrepareEventDto> consumerRecord) {
log.info("[Inbox] order.prepare 이벤트 수신. eventId={}", consumerRecord.key());
handleOrderPrepareUseCase.handleOrderPrepare(consumerRecord.value().orderId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package kr.magicbox.order.adapter.in.kafka;

import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent;
import kr.magicbox.order.adapter.in.kafka.event.PaymentCancelFailedEvent;
import kr.magicbox.order.adapter.in.kafka.event.PaymentCancelSucceededEvent;
import kr.magicbox.order.adapter.in.kafka.event.PaymentFailedEvent;
import kr.magicbox.order.adapter.in.kafka.event.PaymentSucceededEvent;
import kr.magicbox.order.application.port.in.HandlePaymentCancelFailedUseCase;
import kr.magicbox.order.application.port.in.HandlePaymentCancelSucceededUseCase;
import kr.magicbox.order.application.port.in.HandlePaymentFailedUseCase;
import kr.magicbox.order.application.port.in.HandlePaymentSucceededUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentEventKafkaListener {

private final HandlePaymentSucceededUseCase handlePaymentSucceededUseCase;
private final HandlePaymentFailedUseCase handlePaymentFailedUseCase;
private final HandlePaymentCancelSucceededUseCase handlePaymentCancelSucceededUseCase;
private final HandlePaymentCancelFailedUseCase handlePaymentCancelFailedUseCase;

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.payment-succeeded", groupId = "order-service")
public void handlePaymentSucceeded(ConsumerRecord<String, PaymentSucceededEvent> consumerRecord) {
log.info("[Inbox] payment.succeeded 이벤트 수신. eventId={}", consumerRecord.key());
handlePaymentSucceededUseCase.handlePaymentSucceeded(consumerRecord.value().orderId());
}

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.payment-failed", groupId = "order-service")
public void handlePaymentFailed(ConsumerRecord<String, PaymentFailedEvent> consumerRecord) {
log.info("[Inbox] payment.failed 이벤트 수신. eventId={}", consumerRecord.key());
handlePaymentFailedUseCase.handlePaymentFailed(consumerRecord.value().orderId());
}

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.payment-cancel-succeeded", groupId = "order-service")
public void handlePaymentCancelSucceeded(ConsumerRecord<String, PaymentCancelSucceededEvent> consumerRecord) {
log.info("[Inbox] payment.cancel.succeeded 이벤트 수신. eventId={}", consumerRecord.key());
handlePaymentCancelSucceededUseCase.handlePaymentCancelSucceeded(consumerRecord.value().orderId());
}

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.payment-cancel-failed", groupId = "order-service")
public void handlePaymentCancelFailed(ConsumerRecord<String, PaymentCancelFailedEvent> consumerRecord) {
log.info("[Inbox] payment.cancel.failed 이벤트 수신. eventId={}", consumerRecord.key());
handlePaymentCancelFailedUseCase.handlePaymentCancelFailed(consumerRecord.value().orderId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.magicbox.order.adapter.in.kafka;

import kr.magicbox.order.adapter.in.kafka.annotation.Idempotent;
import kr.magicbox.order.adapter.in.kafka.event.StockReserveFailedEvent;
import kr.magicbox.order.adapter.in.kafka.event.StockReserveSucceededEvent;
import kr.magicbox.order.application.port.in.HandleStockReserveFailedUseCase;
import kr.magicbox.order.application.port.in.HandleStockReserveSucceededUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class StockEventKafkaListener {

private final HandleStockReserveSucceededUseCase handleStockReserveSucceededUseCase;
private final HandleStockReserveFailedUseCase handleStockReserveFailedUseCase;

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.stock-reserve-succeeded", groupId = "order-service")
public void handleStockReserveSucceeded(ConsumerRecord<String, StockReserveSucceededEvent> consumerRecord) {
log.info("[Inbox] stock.reserve.succeeded 이벤트 수신. eventId={}", consumerRecord.key());
handleStockReserveSucceededUseCase.handleStockReserveSucceeded(consumerRecord.value().orderId());
}

@Idempotent
@RetryableTopic
@KafkaListener(topics = "outbox.event.stock-reserve-failed", groupId = "order-service")
public void handleStockReserveFailed(ConsumerRecord<String, StockReserveFailedEvent> consumerRecord) {
log.info("[Inbox] stock.reserve.failed 이벤트 수신. eventId={}", consumerRecord.key());
handleStockReserveFailedUseCase.handleStockReserveFailed(consumerRecord.value().orderId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.magicbox.order.adapter.in.kafka.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package kr.magicbox.order.adapter.in.kafka.aop;

import kr.magicbox.order.adapter.in.kafka.properties.InboxProperties;
import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxEntity;
import kr.magicbox.order.adapter.out.persistence.entity.OrderInboxStatus;
import kr.magicbox.order.adapter.out.persistence.repository.OrderInboxJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;

import java.lang.reflect.RecordComponent;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

private final OrderInboxJpaRepository orderInboxJpaRepository;
private final TransactionTemplate transactionTemplate;
private final InboxProperties inboxProperties;

@Around("@annotation(kr.magicbox.order.adapter.in.kafka.annotation.Idempotent)")
public Object around(ProceedingJoinPoint pjp) {
ConsumerRecord<String, ?> consumerRecord = extractRecord(pjp);
Long eventId = Long.parseLong(consumerRecord.key());
Instant occurredAt = extractOccurredAt(consumerRecord.value());

if (isTooOld(occurredAt)) {
log.warn("[Inbox] 만료된 메시지 폐기. eventId={}, occurredAt={}", eventId, occurredAt);
return null;
}

return transactionTemplate.execute(status -> {
if (orderInboxJpaRepository.existsByEventId(eventId)) {
log.warn("[Inbox] 중복 메시지 폐기. eventId={}", eventId);
return null;
}
OrderInboxEntity inbox = orderInboxJpaRepository.save(OrderInboxEntity.builder()
.eventId(eventId)
.topic(consumerRecord.topic())
.partition(consumerRecord.partition())
.offset(consumerRecord.offset())
.status(OrderInboxStatus.PENDING)
.occurredAt(occurredAt)
.build());
try {
pjp.proceed();
} catch (Throwable e) {
status.setRollbackOnly();
throw new RuntimeException(e);

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] Throwable을 그대로 RuntimeException으로 감싸면 재시도 정책이 무력화됩니다.

} catch (Throwable e) {
    status.setRollbackOnly();
    throw new RuntimeException(e);
}

@RetryableTopic은 예외 타입으로 retryable/non-retryable을 구분하는데, 여기서 모든 예외가 RuntimeException으로 균일화되어 도메인 예외(InvalidPurchaseTokenException 등 재시도해도 같은 결과)도 무한 retry 후 DLT로 들어갑니다. 또한 root cause 스택트레이스만 보이고 카테고리가 사라져 운영 분석이 어렵습니다.

권장:

  • catch (RuntimeException e) { ... throw e; } + catch (Throwable e) { ... throw new IllegalStateException(e); } 식으로 RuntimeException은 그대로 전파.
  • 추가로 @RetryableTopic(include=..., exclude=...)로 비재시도 예외 명시.

또한 pjp.proceed()Throwable을 던지므로 호출부에서 Error까지 잡아 wrap하는 것은 위험합니다. Error는 다시 던지도록 분리 권장.

}
inbox.markProcessed();
return null;
});
}

private boolean isTooOld(Instant occurredAt) {
return occurredAt.isBefore(Instant.now().minus(inboxProperties.getMaxEventAgeMinutes(), ChronoUnit.MINUTES));
}

private Instant extractOccurredAt(Object payload) {
if (payload == null) {
return Instant.now();
}
try {
for (RecordComponent component : payload.getClass().getRecordComponents()) {
if (component.getName().equals("occurredAt")) {
Object value = component.getAccessor().invoke(payload);
if (value instanceof Instant instant) {
return instant;
}
}
}
} catch (Exception e) {
log.warn("[Inbox] occurredAt 추출 실패, 현재 시각으로 대체. payload={}", payload.getClass().getSimpleName());
}
return Instant.now();
}

@SuppressWarnings("unchecked")
private ConsumerRecord<String, ?> extractRecord(ProceedingJoinPoint pjp) {
return Arrays.stream(pjp.getArgs())
.filter(ConsumerRecord.class::isInstance)
.map(arg -> (ConsumerRecord<String, ?>) arg)
.findFirst()
.orElseThrow(() -> new IllegalStateException("@Idempotent 메서드에 ConsumerRecord 파라미터가 없습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.magicbox.order.adapter.in.kafka.event;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;
import java.util.List;

public record DeliveryCompletedEvent(
@JsonProperty("order_id") Long orderId,
@JsonProperty("order_line_id") Long orderLineId,
@JsonProperty("customer_id") Long customerId,
@JsonProperty("delivery_id") Long deliveryId,
@JsonProperty("tracking_number") String trackingNumber,
@JsonProperty("delivered_at") Instant deliveredAt,
@JsonProperty("items") List<ItemPayload> items,
@JsonProperty("occurred_at") Instant occurredAt
) {
public record ItemPayload(
@JsonProperty("product_id") Long productId,
@JsonProperty("quantity") int quantity
) {}
}
Loading
Loading