Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e01d163
feat: Kafka 인프라 추가 - 비동기 메시징 기반 구축
yesrin Oct 21, 2025
bcac04a
feat: OrderCreatedEvent 이벤트 모델 생성
yesrin Oct 21, 2025
7e2fa08
feat: Order Service Kafka Producer 구현 - 주문 생성 시 이벤트 발행
yesrin Oct 21, 2025
f47003f
feat: Notification Service 생성 - Kafka Consumer 구현
yesrin Oct 21, 2025
00ceb0b
refactor: 로컬 개발 환경을 위한 H2 데이터베이스 설정 및 컨트롤러 개선
yesrin Oct 21, 2025
c679811
refactor: Service Discovery 전략 변경 - Eureka 대신 직접 URL 사용
yesrin Oct 21, 2025
86079a4
build: Gradle 8.10 및 Lombok 1.18.34로 업그레이드
yesrin Oct 21, 2025
0f3178b
refactor: Config Server 제거 - 로컬 개발 환경 단순화
yesrin Oct 21, 2025
2c8187f
docs: README 업데이트 - Kafka 및 아키텍처 개선사항 문서화
yesrin Oct 21, 2025
023bdfe
refactor: 환경별 Service Discovery URL 설정 외부화
yesrin Oct 21, 2025
cc5acb6
feat: Notification Service Kafka 설정 완료
yesrin Oct 23, 2025
4f018ae
docs: E-Commerce 시스템으로 확장된 README 전면 개편
yesrin Oct 28, 2025
f407fee
feat: PG 전략 패턴 기반 구조 추가 - DTO 및 인터페이스
yesrin Oct 28, 2025
b290d07
feat: PG 전략 구현체 추가 - Toss, Kakao, Naver
yesrin Oct 28, 2025
f177dfc
feat: PG Factory 및 설정 관리 추가
yesrin Oct 28, 2025
0bc9f0b
feat: PaymentService 및 Controller에 전략 패턴 적용
yesrin Oct 28, 2025
c45b76e
feat: Common 모듈에 Saga 이벤트 클래스 추가
yesrin Oct 28, 2025
46a5cb7
feat: Order Service에 Product 연동 및 Saga 이벤트 처리 추가
yesrin Oct 28, 2025
14ced57
feat: Inventory Service 추가 - 재고 관리 마이크로서비스
yesrin Oct 28, 2025
ebb2467
feat: Delivery Service 추가 - 배송 관리 마이크로서비스
yesrin Oct 28, 2025
7ccd2b0
feat: Product Service 추가 - 상품 관리 마이크로서비스
yesrin Oct 28, 2025
c59435b
feat: Notification Service에 배송 이벤트 알림 추가
yesrin Oct 28, 2025
1b46f81
feat: Payment Service 나머지 파일 및 개선사항 문서 추가
yesrin Oct 28, 2025
d4f410d
refactor: Kafka 설정을 Common 모듈로 통합
yesrin Oct 28, 2025
e83d771
refactor: Kafka Consumer 예외 처리 및 이벤트 발행 타이밍 개선
yesrin Oct 28, 2025
83855d7
refactor: 도메인별 커스텀 예외 클래스로 리팩토링
yesrin Nov 1, 2025
216da31
refactor: 금액 관련 필드를 Integer에서 BigDecimal로 변경
yesrin Nov 11, 2025
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
498 changes: 432 additions & 66 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ subprojects {

// 공통 의존성
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32' // Java 21 호환 버전
annotationProcessor 'org.projectlombok:lombok:1.18.32'
compileOnly 'org.projectlombok:lombok:1.18.34' // Java 21 최신 호환 버전
annotationProcessor 'org.projectlombok:lombok:1.18.34'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// Distributed Tracing - Micrometer + Zipkin
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.example.common.config;

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.support.serializer.JsonDeserializer;

import java.util.HashMap;
import java.util.Map;

/**
* 공통 Kafka Consumer 설정
* 모든 마이크로서비스에서 재사용 가능
*/
@Configuration
@EnableKafka
public class KafkaConsumerConfig {

@Value("${spring.kafka.bootstrap-servers:localhost:9092}")
private String bootstrapServers;

@Value("${spring.kafka.consumer.group-id:default-group}")
private String groupId;

@Bean
public ConsumerFactory<String, Object> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
Comment on lines +38 to +39
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

보안 위험: JsonDeserializer TRUSTED_PACKAGES="*" 사용 금지

와일드카드 신뢰 패키지는 폴리모픽 역직렬화 공격 표면을 넓힙니다. 공용 이벤트 패키지로 제한하세요(예: com.example.common.event) 및 타입 매핑을 명시하세요.

- props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
+ props.put(JsonDeserializer.TRUSTED_PACKAGES, "com.example.common.event");
+ props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, true);
+ // 선택: props.put(JsonDeserializer.TYPE_MAPPINGS,
+ //   "PaymentCompletedEvent:com.example.common.event.PaymentCompletedEvent,..." );

코딩 가이드라인 기준.

🤖 Prompt for AI Agents
In order-service/src/main/java/com/example/order/config/KafkaConsumerConfig.java
around lines 35-36, do not use JsonDeserializer.TRUSTED_PACKAGES="*"; instead
restrict trusted packages to your public event package (e.g.
"com.example.common.event") and remove the wildcard. Additionally, explicitly
configure type mappings or a default value type so the deserializer cannot
instantiate arbitrary classes (e.g. set JsonDeserializer.TYPE_MAPPINGS or
JsonDeserializer.VALUE_DEFAULT_TYPE to your known event types), and ensure the
consumer uses the JsonDeserializer class (or ErrorHandlingDeserializer wrapping
it) with these constrained settings to prevent polymorphic deserialization
attacks.

props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 1000);
Comment on lines +40 to +41
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Saga 보상 트랜잭션에서 자동 커밋은 위험

현재 자동 커밋 활성화 시 메시지 처리 중 예외 발생 시에도 오프셋이 커밋되어 보상 트랜잭션이 누락될 수 있습니다.

문제 시나리오:

  1. PaymentFailedEvent 수신 후 오프셋 자동 커밋
  2. 보상 로직 처리 중 서비스 크래시
  3. 재시작 시 이미 커밋된 오프셋 이후부터 읽어서 보상 트랜잭션 누락

수동 커밋 또는 컨테이너 ACK 모드로 변경하세요:

-props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
-props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 1000);
+props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

그리고 리스너 컨테이너 팩토리에서 ACK 모드를 명시:

@Bean
public ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, Object> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);  // 레코드 단위 ACK
    return factory;
}
🤖 Prompt for AI Agents
In common/src/main/java/com/example/common/config/KafkaConsumerConfig.java
around lines 40-41, the consumer currently enables auto-commit which risks
skipping compensation (saga rollback) when processing fails; change props to
disable automatic commits (set ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false
and remove/ignore AUTO_COMMIT_INTERVAL_MS_CONFIG), and ensure the listener
container factory is configured for manual ACK by setting
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD)
(or another appropriate manual ack mode) and handle commits explicitly (or use
container ACK) in your listeners.


return new DefaultKafkaConsumerFactory<>(props);
}

@Bean
public ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, Object> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());

// 에러 핸들러 설정 - 재시도 가능한 예외와 불가능한 예외 구분
factory.setCommonErrorHandler(new org.springframework.kafka.listener.DefaultErrorHandler(
new org.springframework.util.backoff.FixedBackOff(1000L, 3L) // 1초 간격으로 3회 재시도
));

return factory;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.example.common.config;

import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.support.serializer.JsonSerializer;

import java.util.HashMap;
import java.util.Map;

/**
* 공통 Kafka Producer 설정
* 모든 마이크로서비스에서 재사용 가능
*/
@Configuration
public class KafkaProducerConfig {

@Value("${spring.kafka.bootstrap-servers:localhost:9092}")
private String bootstrapServers;

@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

// Producer 성능 및 신뢰성 설정
config.put(ProducerConfig.ACKS_CONFIG, "1"); // 리더 파티션 ACK (성능과 신뢰성 균형)
config.put(ProducerConfig.RETRIES_CONFIG, 3); // 전송 실패 시 재시도
config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); // 압축
config.put(ProducerConfig.LINGER_MS_CONFIG, 10); // 배치 대기 시간
config.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 배치 크기

return new DefaultKafkaProducerFactory<>(config);
}

@Bean
public KafkaTemplate<String, Object> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.example.common.event;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* 배송 완료 이벤트
* - Delivery Service에서 배송 완료 시 발행
* - Order Service가 구독하여 주문 상태를 DELIVERED로 업데이트
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeliveryCompletedEvent implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 주문 ID
*/
private Long orderId;

/**
* 배송 ID
*/
private String deliveryId;

/**
* 이벤트 발생 시각
*/
private LocalDateTime completedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.example.common.event;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* 배송 실패 이벤트
* - Delivery Service에서 배송 실패 시 발행 (수령 거부, 주소 오류 등)
* - Order Service가 구독하여 고객센터 처리
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeliveryFailedEvent implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 주문 ID
*/
private Long orderId;

/**
* 배송 ID
*/
private String deliveryId;

/**
* 실패 사유
*/
private String reason;

/**
* 이벤트 발생 시각
*/
private LocalDateTime failedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.common.event;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* 배송 시작 이벤트
* - Delivery Service에서 배송 시작 시 발행
* - Order Service가 구독하여 주문 상태 업데이트
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeliveryStartedEvent implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 주문 ID
*/
private Long orderId;

/**
* 배송 ID (추적 번호)
*/
private String deliveryId;

/**
* 배송지 주소
*/
private String address;

Comment on lines +34 to +38
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

주소 PII 최소화 필요

공용 이벤트에 전체 address를 실어 나르면 서비스/로그 전파로 PII 확산 위험이 큽니다. addressId/maskedAddress로 대체하거나 별도 조회(권한 통제)로 분리하세요. 최소 보관/전파 원칙을 적용해 주세요.

/**
* 택배사
*/
private String carrier;

/**
* 이벤트 발생 시각
*/
private LocalDateTime startedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.example.common.event;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* 재고 확보 실패 이벤트
* - Inventory Service에서 재고 부족 시 발행
* - Order Service가 구독하여 주문 취소 처리
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InventoryReservationFailedEvent implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 주문 ID
*/
private Long orderId;

/**
* 상품 ID
*/
private Long productId;

/**
* 요청 수량
*/
private Integer requestedQuantity;

/**
* 현재 재고 수량
*/
private Integer availableQuantity;

Comment on lines +35 to +43
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

수량 유효성 보장

requestedQuantity > 0, availableQuantity >= 0 제약이 필요합니다. 스키마/유효성(Bean Validation)으로 강제하세요.

🤖 Prompt for AI Agents
common/src/main/java/com/example/common/event/InventoryReservationFailedEvent.java
around lines 35 to 43, the requestedQuantity and availableQuantity fields lack
Bean Validation constraints: add javax.validation annotations to enforce
requestedQuantity > 0 and availableQuantity >= 0 (e.g., annotate
requestedQuantity with @NotNull and @Min(1), and availableQuantity with @NotNull
and @Min(0)), add the necessary imports (javax.validation.constraints.Min and
javax.validation.constraints.NotNull), and ensure any constructors/getters
remain compatible so validation will be applied when the object is validated.

/**
* 실패 사유
*/
private String reason;

/**
* 이벤트 발생 시각
*/
private LocalDateTime failedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.example.common.event;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
* 재고 확보 성공 이벤트
* - Inventory Service에서 재고 차감 성공 시 발행
* - Payment Service가 구독하여 결제 처리 시작
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InventoryReservedEvent implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 주문 ID
*/
private Long orderId;

/**
* 상품 ID
*/
private Long productId;

/**
* 상품명 (스냅샷)
*/
private String productName;

/**
* 확보된 수량
*/
private Integer quantity;

/**
* 결제할 금액
*/
private BigDecimal totalPrice;

/**
* 이벤트 발생 시각
*/
private LocalDateTime reservedAt;
}
Loading