diff --git a/README.md b/README.md
index 5b8d272..4126471 100644
--- a/README.md
+++ b/README.md
@@ -1,129 +1,495 @@
-# Spring Cloud + Kubernetes 기반 MSA 실습 프로젝트
+# Spring Cloud + Kubernetes 기반 E-Commerce MSA 프로젝트
## 1. 프로젝트 개요
-- 모놀리식 구조를 User/Order 서비스로 분리하여 MSA 환경 구성
+- 모놀리식 구조를 8개의 마이크로서비스로 분리한 E-Commerce 시스템 구현
+- Saga 패턴을 통한 분산 트랜잭션 처리 및 보상 트랜잭션 구현
+- Redis 분산 락을 활용한 동시성 제어 및 재고 관리
+- Apache Kafka 기반 Event-Driven Architecture로 서비스 간 비동기 통신
- Spring Cloud Gateway를 사용하여 서비스 라우팅 및 API Gateway 패턴 구현
-- Resilience4j를 통한 Circuit Breaker/Fallback 적용으로 장애 대응 경험
+- Resilience4j를 통한 Circuit Breaker/Fallback 적용으로 장애 대응
- Micrometer Tracing + Zipkin으로 분산 추적 환경 구축, 서비스 간 호출 흐름 시각화
- OpenFeign을 통한 마이크로서비스 간 동기 통신 구현
-- Docker 컨테이너화 후 Kubernetes에 배포하여 클라우드 네이티브 환경 이해
-- Spring Cloud와 Kubernetes 기능 비교 및 차이점 학습
+- Docker 컨테이너화 및 Docker Compose를 통한 로컬 개발 환경 구축
+- Kubernetes 배포를 위한 클라우드 네이티브 아키텍처 설계
## 2. 아키텍처 다이어그램
-
-- User 서비스 ↔ Order 서비스 간 REST 통신 (OpenFeign)
-- Gateway에서 서비스 라우팅
-- Circuit Breaker 적용 시 Order 서비스 장애 시 fallback 동작
-- Micrometer Tracing + Zipkin으로 요청 추적 (동일 Trace ID로 서비스 간 호출 연결)
+### 서비스 구성
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ API Gateway (8080) │
+│ (Spring Cloud Gateway) │
+└────────────────────────┬────────────────────────────────────────┘
+ │
+ ┌────────────────┼────────────────┐
+ │ │ │
+ ▼ ▼ ▼
+┌──────────────┐ ┌──────────────┐ ┌──────────────┐
+│User Service │ │Order Service │ │Product Svc │
+│ (8081) │◄─│ (8082) │◄─│ (8087) │
+└──────────────┘ └──────┬───────┘ └──────────────┘
+ │
+ ┌────────────────┼────────────────┐
+ │ │ │
+ ▼ ▼ ▼
+┌──────────────┐ ┌──────────────┐ ┌──────────────┐
+│Inventory Svc │ │Payment Svc │ │Delivery Svc │
+│ (8084) │ │ (8085) │ │ (8086) │
+└──────────────┘ └──────────────┘ └──────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │Notification │
+ │Service (8088)│
+ └──────────────┘
+
+ ┌────────────────────────────────┐
+ │ Apache Kafka (9092) │
+ │ - order-created │
+ │ - inventory-reserved │
+ │ - payment-completed │
+ │ - delivery-started │
+ └────────────────────────────────┘
+
+ ┌────────────────────────────────┐
+ │ Redis (6379) │
+ │ - 분산 락 (재고 동시성 제어) │
+ └────────────────────────────────┘
+```
+
+### 주요 특징
+- **동기 통신 (OpenFeign)**: Order → User, Order → Product (가격 검증)
+- **비동기 통신 (Kafka)**: Saga 패턴 기반 이벤트 체인
+- **분산 락 (Redis)**: 재고 차감 시 동시성 제어
+- **Circuit Breaker**: User Service 장애 시 fallback 동작
+- **분산 추적 (Zipkin)**: 서비스 간 호출 흐름 시각화
## 3. 기술 스택
### Backend
- **Java 21**, **Spring Boot 3.1.5**
-- **Spring Cloud 2022.0.4**: Gateway, Config, OpenFeign, Eureka
+- **Spring Cloud 2022.0.4**: Gateway, OpenFeign
+- **Apache Kafka**: 비동기 메시징 (Event-Driven Architecture)
+- **Redis**: 분산 락 (Redisson)
- **Resilience4j**: Circuit Breaker, Fallback
- **Micrometer Tracing + Brave**: 분산 추적
- **Zipkin**: 트레이싱 서버
-- **MySQL**: Database per Service 패턴
+- **H2 / MySQL**: Database per Service 패턴
### Infra
- **Docker**: 컨테이너화
- **Docker Compose**: 로컬 개발 환경
- **Kubernetes**: 프로덕션 배포 (예정)
-## 4. 주요 기능 / 구현 내용
+## 4. 마이크로서비스 구성
+
+### 4.1 User Service (8081)
+- 사용자 정보 관리
+- 사용자 검증 API 제공
+- H2 in-memory database
+
+### 4.2 Order Service (8082)
+- 주문 생성 및 관리
+- Saga 오케스트레이터 역할
+- User/Product Service 호출 (OpenFeign)
+- Kafka 이벤트 발행: order-created
+
+### 4.3 Product Service (8087) ⭐ 신규
+- 상품 정보 관리 (카탈로그)
+- 가격 정보 제공 (서버 측 가격 검증)
+- 카테고리별 상품 조회
+- 주문 시점 스냅샷 데이터 제공
+
+### 4.4 Inventory Service (8084)
+- 재고 관리 및 확보
+- Redis 분산 락 기반 동시성 제어
+- Saga 보상 트랜잭션 구현 (재고 복구)
+- Kafka 이벤트 처리: order-created → inventory-reserved
+
+### 4.5 Payment Service (8085)
+- 결제 처리 (시뮬레이션)
+- Kafka 이벤트 처리: inventory-reserved → payment-completed
+
+### 4.6 Delivery Service (8086)
+- 배송 시작 처리
+- Kafka 이벤트 처리: payment-completed → delivery-started
+
+### 4.7 Notification Service (8088)
+- 알림 발송 (이메일, SMS 시뮬레이션)
+- Kafka 이벤트 구독: order-created, delivery-started, delivery-completed
+
+### 4.8 API Gateway (8080)
+- 단일 진입점 (Single Entry Point)
+- 라우팅 및 로드 밸런싱
+- 인증/인가 (예정)
+
+## 5. 주요 기능 및 패턴 구현
+
+### 1) Saga 패턴 (분산 트랜잭션) ⭐ 핵심
+E-Commerce의 주문 프로세스를 Choreography 기반 Saga로 구현
+
+**정상 플로우:**
+```
+Order Created → Inventory Reserved → Payment Completed → Delivery Started → Completed
+```
+
+**보상 트랜잭션 플로우 (결제 실패 시):**
+```
+Order Created → Inventory Reserved → Payment Failed
+ ↓ ↓
+ 재고 복구 (Release) Order Cancelled
+```
+
+**구현 상세:**
+- Kafka 이벤트 체인으로 각 단계 연결
+- `PaymentFailedEvent`에 `productId`, `quantity` 포함하여 재고 복구 가능
+- Inventory Service가 `payment-failed` 토픽을 구독하여 자동 롤백
-### 1) 마이크로서비스 아키텍처
-- User Service, Order Service 분리
-- Database per Service (각 서비스 독립 DB)
-- RESTful API 설계 (쿼리 파라미터 활용)
+### 2) Redis 분산 락 (동시성 제어) ⭐ 핵심
+재고 차감 시 Race Condition 방지
-### 2) API Gateway (Spring Cloud Gateway)
+**문제 시나리오:**
+- 100명이 마지막 1개 재고 동시 주문 시 음수 재고 발생
+
+**해결 방법:**
+- Redisson 기반 분산 락 구현
+- `@DistributedLock` 커스텀 어노테이션 + AOP
+- SpEL 표현식으로 상품별 동적 락 키 생성 (`inventory:lock:#productId`)
+- Lock 타임아웃: 5초, Lease Time: 3초 (데드락 방지)
+
+**효과:**
+- 동시 요청 시 1개만 성공, 나머지는 "재고 부족" 응답
+- 재고 음수 발생 방지
+
+### 3) 서버 측 가격 검증 (보안) ⭐ 핵심
+클라이언트가 가격을 조작할 수 없도록 서버에서 가격 계산
+
+**Before (취약점):**
+```json
+POST /orders
+{
+ "userId": 1,
+ "productName": "MacBook Pro",
+ "price": 1, // 클라이언트가 1원으로 조작 가능!
+ "quantity": 1
+}
+```
+
+**After (보안 강화):**
+```json
+POST /orders
+{
+ "userId": 1,
+ "productId": 1, // 상품 ID만 전달
+ "quantity": 1
+}
+// 서버가 Product Service에서 실제 가격(3,500,000원)을 조회하여 계산
+```
+
+### 4) 마이크로서비스 아키텍처
+- 8개 독립 서비스로 분리 (User, Order, Product, Inventory, Payment, Delivery, Notification, Gateway)
+- Database per Service 패턴 (각 서비스 독립 DB)
+- RESTful API 설계
+
+### 5) API Gateway (Spring Cloud Gateway)
- 단일 진입점을 통한 라우팅
-- 인증/인가 처리
+- 서비스별 로드 밸런싱
+- 인증/인가 처리 (예정)
-### 3) 서비스 간 통신
-- **OpenFeign**: 선언적 HTTP Client로 동기 통신
-- Service Discovery (Eureka)를 통한 동적 라우팅
+### 6) 서비스 간 통신
+- **동기 통신 (OpenFeign)**: 즉시 응답 필요 (사용자 검증, 가격 조회)
+- **비동기 통신 (Kafka)**: 이벤트 기반 처리 (알림, Saga 플로우)
+- **Service Discovery**: Kubernetes Service DNS (운영), URL 직접 지정 (로컬)
-### 4) 장애 대응 (Resilience4j)
+### 7) 장애 대응 (Resilience4j)
- **Circuit Breaker**: 장애 전파 차단
- **Fallback**: User Service 장애 시 기본값 반환
- 설정: 10번 중 50% 실패 시 Circuit Open
-### 5) 분산 추적 (Micrometer + Zipkin) ⭐ 최근 구현
+### 8) 분산 추적 (Micrometer + Zipkin)
- **Trace ID/Span ID**: 서비스 간 요청 흐름 추적
- **B3 Propagation**: OpenFeign 호출 시 trace context 자동 전파
-- **Zipkin UI**: 서비스 의존성 그래프 및 성능 병목 지점 시각화
-- 트러블슈팅: Spring Boot 3.x + OpenFeign 통합 이슈 해결 (`feign-micrometer`)
+- **Zipkin UI**: 서비스 의존성 그래프 및 성능 시각화
-### 6) 설정 관리 (Config Server)
-- 중앙화된 설정 관리
-- 환경별 설정 분리 (dev, prod)
+### 9) 가격 스냅샷 패턴
+주문 시점의 가격을 저장하여 이후 가격 변동에 영향받지 않도록 처리
-## 5. 최근 개선사항
+**Order Entity:**
+- `productId`: Product Service 참조 (현재 정보 조회 가능)
+- `productName`: 주문 시점 스냅샷 (상품명 변경 시에도 유지)
+- `unitPrice`: 주문 시점 단가 (BigDecimal)
+- `totalPrice`: 서버 계산 총액 (단가 × 수량)
-### Order-User 서비스 간 통신 구현 (2025.10.12)
-**배경**: 기존에는 각 서비스가 독립적으로 동작했으나, 실제 비즈니스 시나리오에서는 서비스 간 데이터 통합이 필요
+## 6. 개발 타임라인
-**구현 내용**:
-- Order Service에서 User Service 호출하여 주문+사용자 정보 통합 응답
-- RESTful API 개선: `GET /orders?userId=1` (쿼리 파라미터 활용)
-- Circuit Breaker로 User Service 장애 시 fallback 처리
+### Phase 1: 기본 MSA 구성
+- User/Order 서비스 분리
+- Spring Cloud Gateway 구축
+- Circuit Breaker 적용
+- Micrometer + Zipkin 분산 추적
-**기술적 도전 과제**:
-1. **Trace Context 전파 이슈**: Spring Boot 3.x + OpenFeign 조합에서 trace context가 자동 전파되지 않는 문제 발견
- - 해결: `feign-micrometer` 의존성 추가 + B3 propagation 설정
- - 결과: Zipkin에서 Order → User 호출이 동일 Trace ID로 연결되어 추적 가능
+### Phase 2: E-Commerce 확장 (현재)
+- **Product Service 신규 구축**: 서버 측 가격 검증 보안 강화
+- **Inventory Service 리팩토링**: productName → productId 기반으로 전환
+- **Payment/Delivery Service 추가**: 전체 주문 프로세스 구현
+- **Saga 패턴 구현**: Choreography 방식의 분산 트랜잭션
+- **Redis 분산 락**: 동시성 제어 및 재고 무결성 보장
+- **Notification Service**: 비동기 알림 발송
-2. **Docker 빌드 캐싱 이슈**: 코드 변경사항이 컨테이너에 반영되지 않는 문제
- - 해결: 빌드 아티팩트 삭제 후 재빌드 (`rm -rf build && docker-compose up --build`)
+### Phase 3: 프로덕션 준비 (예정)
+- Kubernetes 배포
+- Outbox Pattern (트랜잭셔널 메시징)
+- 멱등성 처리 (Idempotency)
+- API 인증/인가 (JWT, OAuth2)
+- 모니터링 (Prometheus, Grafana)
+- CI/CD 파이프라인
-**성과**:
-- Zipkin UI에서 3개 span으로 서비스 간 호출 시각화 (order-service 2개, user-service 1개)
-- 평균 응답 시간: 19ms (Order), 3ms (User Service)
+## 7. 실행 방법
-## 6. 실행 방법
+### 7.1 로컬 개발 환경 (Docker Compose)
-### Docker Compose로 전체 시스템 실행
```bash
-# 전체 빌드 및 실행
+# 1. 전체 빌드
+./gradlew clean build -x test
+
+# 2. Docker Compose로 전체 시스템 시작
docker-compose up -d --build
-# 특정 서비스만 재빌드
-docker-compose up -d --build order-service
+# 3. 서비스 상태 확인
+docker-compose ps
-# 로그 확인
+# 4. 특정 서비스 로그 확인
docker-compose logs -f order-service
+docker-compose logs -f inventory-service
-# 종료
+# 5. 종료
docker-compose down
```
-### 접속 URL
-- **Eureka Dashboard**: http://localhost:8761
-- **Zipkin UI**: http://localhost:9411
-- **Order Service**: http://localhost:8082/orders?userId=1
-- **User Service**: http://localhost:8081/api/users/1
+### 7.2 접속 URL
- **API Gateway**: http://localhost:8080
+- **User Service**: http://localhost:8081
+- **Order Service**: http://localhost:8082
+- **Inventory Service**: http://localhost:8084
+- **Payment Service**: http://localhost:8085
+- **Delivery Service**: http://localhost:8086
+- **Product Service**: http://localhost:8087
+- **Notification Service**: http://localhost:8088
+- **Zipkin UI**: http://localhost:9411
+- **Kafka UI**: http://localhost:9000 (Kafdrop)
+
+### 7.3 E-Commerce 주문 플로우 테스트
+
+```bash
+# 1. 상품 조회
+curl http://localhost:8087/products | jq
+
+# 2. 특정 상품 상세 정보
+curl http://localhost:8087/products/1 | jq
+# Response: {"id":1,"name":"MacBook Pro 16","price":3500000,...}
+
+# 3. 주문 생성 (productId만 전달, 가격은 서버에서 계산!)
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{
+ "userId": 1,
+ "productId": 1,
+ "quantity": 1
+ }' | jq
+
+# 4. 사용자별 주문 조회
+curl "http://localhost:8082/orders?userId=1" | jq
+
+# 5. 재고 확인
+curl http://localhost:8084/inventory/1 | jq
+```
+
+### 7.4 Saga 플로우 확인 (터미널 여러 개 사용)
+
+```bash
+# Terminal 1: Order Service 로그
+docker-compose logs -f order-service
+
+# Terminal 2: Inventory Service 로그
+docker-compose logs -f inventory-service
+
+# Terminal 3: Payment Service 로그
+docker-compose logs -f payment-service
+
+# Terminal 4: Delivery Service 로그
+docker-compose logs -f delivery-service
+
+# Terminal 5: Notification Service 로그
+docker-compose logs -f notification-service
+
+# Terminal 6: 주문 생성
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId": 1, "productId": 1, "quantity": 1}'
+
+# 로그에서 Saga 이벤트 체인 확인:
+# Order → order-created 발행
+# Inventory → 재고 확보 → inventory-reserved 발행
+# Payment → 결제 처리 → payment-completed 발행
+# Delivery → 배송 시작 → delivery-started 발행
+# Notification → 각 단계마다 알림 발송
+```
+
+### 7.5 동시성 테스트 (분산 락 검증)
+
+```bash
+# 시나리오: 100명이 마지막 1개 재고 동시 주문
+
+# 1. 재고 확인
+curl http://localhost:8084/inventory/1 | jq
+# {"productId":1,"quantity":1}
+
+# 2. 100개 동시 요청 발생
+for i in {1..100}; do
+ curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId":1,"productId":1,"quantity":1}' &
+done
+wait
+
+# 3. 결과 확인
+# - 1개 주문만 성공 (200 OK)
+# - 99개 주문 실패 (재고 부족)
+# - 재고: 0개 (음수 아님!)
+
+# 4. Redis 락 상태 확인
+docker exec -it redis redis-cli
+> KEYS inventory:lock:*
+> TTL inventory:lock:1 # 락이 자동으로 해제되었는지 확인
+```
+
+### 7.6 보상 트랜잭션 테스트
-### API 테스트 예시
```bash
-# 주문 생성
+# 시나리오: 재고 부족으로 주문 실패 시 정상 롤백
+
+# 1. 재고 확인
+curl http://localhost:8084/inventory/1 | jq
+# {"productId":1,"quantity":5}
+
+# 2. 재고보다 많은 수량 주문
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId":1,"productId":1,"quantity":999}' | jq
+
+# 3. 로그 확인
+docker-compose logs inventory-service | grep "재고 부족"
+# ⚠️ 재고 부족 - productId: 1, 요청: 999, 현재: 5
+# 📤 inventory-failed 이벤트 발행
+
+docker-compose logs order-service | grep "주문 취소"
+# ❌ 주문 취소 처리 - orderId: X
+
+# 4. 재고 재확인 (변동 없어야 함)
+curl http://localhost:8084/inventory/1 | jq
+# {"productId":1,"quantity":5} ← 원상태 유지 ✅
+```
+
+### 7.7 Zipkin 분산 추적 확인
+
+```bash
+# 1. 주문 생성
curl -X POST http://localhost:8082/orders \
-H "Content-Type: application/json" \
- -d '{"userId": 1, "productName": "Laptop", "quantity": 2, "price": 1500000}'
+ -d '{"userId":1,"productId":1,"quantity":1}'
-# 주문+사용자 정보 조회 (분산 추적 확인 가능)
-curl http://localhost:8082/orders?userId=1 | jq
+# 2. Zipkin UI 접속
+open http://localhost:9411
-# Zipkin에서 Trace 확인
-curl http://localhost:9411/api/v2/traces?serviceName=order-service | jq
+# 3. 확인 사항:
+# - Service Name: order-service 선택
+# - Find Traces 클릭
+# - 서비스 간 호출 흐름: Order → User → Product → Inventory
+# - 각 서비스의 응답 시간 확인
+# - 동일 Trace ID로 연결된 Span 확인
```
-## 7. 관련 문서
+## 8. 학습 포인트 및 기술적 도전
+
+### 8.1 실무 수준의 보안 구현
+- **문제**: 클라이언트가 가격을 조작하여 1원에 상품 구매 가능
+- **해결**: Product Service 신규 구축 + 서버 측 가격 검증
+- **학습**: 금액 관련 데이터는 절대 클라이언트 입력을 믿으면 안 됨
+
+### 8.2 분산 시스템의 동시성 제어
+- **문제**: 100명이 동시 주문 시 재고 음수 발생
+- **해결**: Redis 분산 락 (Redisson) + AOP 기반 커스텀 어노테이션
+- **학습**: 단일 서버 락(@Synchronized)은 MSA에서 무용지물, 분산 락 필수
+
+### 8.3 Saga 패턴의 보상 트랜잭션
+- **문제**: 결제 실패 시 이미 차감된 재고 복구 방법 부재
+- **해결**: Kafka 이벤트에 productId/quantity 포함 + Compensating Transaction
+- **학습**: 분산 트랜잭션은 2PC가 아닌 Saga 패턴으로 해결
+
+### 8.4 데이터 일관성 보장
+- **문제**: 문자열 기반 productName으로 오타 발생 (재고 조회 실패)
+- **해결**: productId 기반 아키텍처 전환 (Product Service와 1:1 매칭)
+- **학습**: 외래키 대신 ID 참조로 서비스 간 느슨한 결합 유지
+
+### 8.5 Spring Boot 3.x 분산 추적 이슈
+- **문제**: OpenFeign 호출 시 trace context가 자동 전파되지 않음
+- **해결**: `feign-micrometer` 의존성 추가 + B3 Propagation 설정
+- **학습**: Spring Boot 3.x는 Sleuth가 제거되어 Micrometer Tracing으로 전환
+
+### 8.6 Database per Service 패턴
+- **장점**: 서비스 독립성, 기술 스택 자유도
+- **단점**: JOIN 불가, 데이터 중복 (스냅샷 패턴으로 해결)
+- **학습**: 주문 시점의 상품명/가격을 Order 테이블에 저장 (가격 변동 영향 없음)
+
+## 9. 관련 문서
+- [E-Commerce 실무 수준 아키텍처 개선 가이드](docs/E-Commerce-Production-Improvements.md) ⭐ 필독
- [Circuit Breaker 가이드](docs/resilience4j-patterns.md)
- [Circuit Breaker Q&A](docs/Circuit-Breaker-QNA.md)
- [Zipkin 분산 추적 가이드](docs/Zipkin-Distributed-Tracing.md)
-- [Eureka Service Discovery](docs/EUREKA.md)
+
+## 10. 프로젝트 구조
+```
+MSA-SpringCloud-Kubernetes/
+├── common/ # 공통 이벤트 클래스 (Kafka)
+│ └── src/main/java/com/example/common/event/
+│ ├── OrderCreatedEvent.java
+│ ├── InventoryReservedEvent.java
+│ ├── PaymentCompletedEvent.java
+│ └── DeliveryStartedEvent.java
+├── user-service/ # 사용자 관리 (8081)
+├── order-service/ # 주문 관리 (8082)
+│ ├── client/
+│ │ ├── UserClient.java # OpenFeign
+│ │ └── ProductClient.java # OpenFeign
+│ └── kafka/
+│ └── SagaEventConsumer.java
+├── product-service/ # 상품 관리 (8087) ⭐ 신규
+│ ├── entity/Product.java
+│ ├── dto/ProductResponse.java
+│ └── controller/ProductController.java
+├── inventory-service/ # 재고 관리 (8084)
+│ ├── annotation/
+│ │ └── DistributedLock.java # 커스텀 어노테이션
+│ ├── aop/
+│ │ └── DistributedLockAop.java
+│ ├── config/
+│ │ └── RedisConfig.java
+│ └── service/
+│ └── InventoryService.java
+├── payment-service/ # 결제 처리 (8085)
+├── delivery-service/ # 배송 관리 (8086)
+├── notification-service/ # 알림 발송 (8088)
+├── gateway/ # API Gateway (8080)
+├── docs/ # 프로젝트 문서
+│ ├── E-Commerce-Production-Improvements.md
+│ ├── resilience4j-patterns.md
+│ ├── Circuit-Breaker-QNA.md
+│ └── Zipkin-Distributed-Tracing.md
+├── docker-compose.yml # 로컬 개발 환경
+└── README.md
+```
+
+## 11. 기여 및 피드백
+- 이슈 제기: GitHub Issues
+- 개선 제안: Pull Request
+- 질문: Discussions
diff --git a/build.gradle b/build.gradle
index 5cdf713..f3223ed 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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
diff --git a/common/src/main/java/com/example/common/config/KafkaConsumerConfig.java b/common/src/main/java/com/example/common/config/KafkaConsumerConfig.java
new file mode 100644
index 0000000..56843aa
--- /dev/null
+++ b/common/src/main/java/com/example/common/config/KafkaConsumerConfig.java
@@ -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 consumerFactory() {
+ Map 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");
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
+ props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 1000);
+
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() {
+ ConcurrentKafkaListenerContainerFactory 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;
+ }
+}
diff --git a/common/src/main/java/com/example/common/config/KafkaProducerConfig.java b/common/src/main/java/com/example/common/config/KafkaProducerConfig.java
new file mode 100644
index 0000000..06f19b3
--- /dev/null
+++ b/common/src/main/java/com/example/common/config/KafkaProducerConfig.java
@@ -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 producerFactory() {
+ Map 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 kafkaTemplate() {
+ return new KafkaTemplate<>(producerFactory());
+ }
+}
diff --git a/common/src/main/java/com/example/common/event/DeliveryCompletedEvent.java b/common/src/main/java/com/example/common/event/DeliveryCompletedEvent.java
new file mode 100644
index 0000000..b15f9be
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/DeliveryCompletedEvent.java
@@ -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;
+}
diff --git a/common/src/main/java/com/example/common/event/DeliveryFailedEvent.java b/common/src/main/java/com/example/common/event/DeliveryFailedEvent.java
new file mode 100644
index 0000000..200369b
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/DeliveryFailedEvent.java
@@ -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;
+}
diff --git a/common/src/main/java/com/example/common/event/DeliveryStartedEvent.java b/common/src/main/java/com/example/common/event/DeliveryStartedEvent.java
new file mode 100644
index 0000000..3ed6509
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/DeliveryStartedEvent.java
@@ -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;
+
+ /**
+ * 택배사
+ */
+ private String carrier;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime startedAt;
+}
diff --git a/common/src/main/java/com/example/common/event/InventoryReservationFailedEvent.java b/common/src/main/java/com/example/common/event/InventoryReservationFailedEvent.java
new file mode 100644
index 0000000..3b0702b
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/InventoryReservationFailedEvent.java
@@ -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;
+
+ /**
+ * 실패 사유
+ */
+ private String reason;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime failedAt;
+}
diff --git a/common/src/main/java/com/example/common/event/InventoryReservedEvent.java b/common/src/main/java/com/example/common/event/InventoryReservedEvent.java
new file mode 100644
index 0000000..5ff997e
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/InventoryReservedEvent.java
@@ -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;
+}
diff --git a/common/src/main/java/com/example/common/event/OrderCancelledEvent.java b/common/src/main/java/com/example/common/event/OrderCancelledEvent.java
new file mode 100644
index 0000000..29631d9
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/OrderCancelledEvent.java
@@ -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;
+
+/**
+ * 주문 취소 이벤트
+ * - Order Service에서 Saga 실패 시 발행
+ * - Notification Service 등에서 구독하여 취소 알림 발송
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderCancelledEvent implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 주문 ID
+ */
+ private Long orderId;
+
+ /**
+ * 사용자 ID
+ */
+ private Long userId;
+
+ /**
+ * 취소 사유
+ */
+ private String reason;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime cancelledAt;
+}
diff --git a/common/src/main/java/com/example/common/event/OrderCompletedEvent.java b/common/src/main/java/com/example/common/event/OrderCompletedEvent.java
new file mode 100644
index 0000000..5b8127b
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/OrderCompletedEvent.java
@@ -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;
+
+/**
+ * 주문 완료 이벤트
+ * - Order Service에서 전체 Saga가 성공적으로 완료되었을 때 발행
+ * - Notification Service 등에서 구독하여 완료 알림 발송
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderCompletedEvent implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 주문 ID
+ */
+ private Long orderId;
+
+ /**
+ * 사용자 ID
+ */
+ private Long userId;
+
+ /**
+ * 상품명
+ */
+ private String productName;
+
+ /**
+ * 수량
+ */
+ private Integer quantity;
+
+ /**
+ * 결제 ID
+ */
+ private String paymentId;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime completedAt;
+}
diff --git a/common/src/main/java/com/example/common/event/OrderCreatedEvent.java b/common/src/main/java/com/example/common/event/OrderCreatedEvent.java
new file mode 100644
index 0000000..c2ac05f
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/OrderCreatedEvent.java
@@ -0,0 +1,65 @@
+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;
+
+/**
+ * 주문 생성 이벤트
+ * - Order Service에서 주문 생성 시 Kafka로 발행
+ * - Notification Service 등 다른 서비스에서 구독
+ * - 불변 객체 (Immutable): setter 없음, 생성 후 변경 불가
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderCreatedEvent implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 주문 ID
+ */
+ private Long orderId;
+
+ /**
+ * 사용자 ID
+ */
+ private Long userId;
+
+ /**
+ * 상품 ID
+ */
+ private Long productId;
+
+ /**
+ * 상품명
+ */
+ private String productName;
+
+ /**
+ * 수량
+ */
+ private Integer quantity;
+
+ /**
+ * 가격 (단가)
+ */
+ private BigDecimal price;
+
+ /**
+ * 총 금액 (단가 × 수량)
+ */
+ private BigDecimal totalPrice;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime createdAt;
+}
diff --git a/common/src/main/java/com/example/common/event/PaymentCompletedEvent.java b/common/src/main/java/com/example/common/event/PaymentCompletedEvent.java
new file mode 100644
index 0000000..ca3f294
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/PaymentCompletedEvent.java
@@ -0,0 +1,49 @@
+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;
+
+/**
+ * 결제 완료 이벤트
+ * - Payment Service에서 결제 성공 시 발행
+ * - Order Service가 구독하여 주문 완료 처리
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PaymentCompletedEvent implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 주문 ID
+ */
+ private Long orderId;
+
+ /**
+ * 결제 ID (외부 PG사 트랜잭션 ID)
+ */
+ private String paymentId;
+
+ /**
+ * 결제 금액
+ */
+ private BigDecimal amount;
+
+ /**
+ * 결제 수단 (CARD, BANK_TRANSFER 등)
+ */
+ private String paymentMethod;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime completedAt;
+}
diff --git a/common/src/main/java/com/example/common/event/PaymentFailedEvent.java b/common/src/main/java/com/example/common/event/PaymentFailedEvent.java
new file mode 100644
index 0000000..cb1eeb3
--- /dev/null
+++ b/common/src/main/java/com/example/common/event/PaymentFailedEvent.java
@@ -0,0 +1,49 @@
+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;
+
+/**
+ * 결제 실패 이벤트
+ * - Payment Service에서 결제 실패 시 발행
+ * - Inventory Service가 구독하여 재고 복구 (보상 트랜잭션)
+ * - Order Service가 구독하여 주문 취소 처리
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PaymentFailedEvent implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 주문 ID
+ */
+ private Long orderId;
+
+ /**
+ * 상품 ID (재고 복구용)
+ */
+ private Long productId;
+
+ /**
+ * 수량 (재고 복구용)
+ */
+ private Integer quantity;
+
+ /**
+ * 실패 사유
+ */
+ private String reason;
+
+ /**
+ * 이벤트 발생 시각
+ */
+ private LocalDateTime failedAt;
+}
diff --git a/common/src/main/java/com/example/common/exception/BusinessException.java b/common/src/main/java/com/example/common/exception/BusinessException.java
new file mode 100644
index 0000000..041c92c
--- /dev/null
+++ b/common/src/main/java/com/example/common/exception/BusinessException.java
@@ -0,0 +1,28 @@
+package com.example.common.exception;
+
+import lombok.Getter;
+
+/**
+ * 비즈니스 로직 예외의 최상위 클래스
+ * 모든 커스텀 예외는 이 클래스를 상속받음
+ */
+@Getter
+public class BusinessException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ public BusinessException(ErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+
+ public BusinessException(ErrorCode errorCode, String message) {
+ super(message);
+ this.errorCode = errorCode;
+ }
+
+ public BusinessException(ErrorCode errorCode, Throwable cause) {
+ super(errorCode.getMessage(), cause);
+ this.errorCode = errorCode;
+ }
+}
diff --git a/common/src/main/java/com/example/common/exception/EntityNotFoundException.java b/common/src/main/java/com/example/common/exception/EntityNotFoundException.java
new file mode 100644
index 0000000..c355ad6
--- /dev/null
+++ b/common/src/main/java/com/example/common/exception/EntityNotFoundException.java
@@ -0,0 +1,16 @@
+package com.example.common.exception;
+
+/**
+ * Entity를 찾을 수 없을 때 발생하는 예외
+ * 404 Not Found 응답
+ */
+public class EntityNotFoundException extends BusinessException {
+
+ public EntityNotFoundException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public EntityNotFoundException(ErrorCode errorCode, String message) {
+ super(errorCode, message);
+ }
+}
diff --git a/common/src/main/java/com/example/common/exception/ErrorCode.java b/common/src/main/java/com/example/common/exception/ErrorCode.java
new file mode 100644
index 0000000..275cbbb
--- /dev/null
+++ b/common/src/main/java/com/example/common/exception/ErrorCode.java
@@ -0,0 +1,62 @@
+package com.example.common.exception;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+/**
+ * 에러 코드 정의
+ * 각 서비스에서 발생할 수 있는 모든 에러를 중앙 집중식으로 관리
+ */
+@Getter
+@RequiredArgsConstructor
+public enum ErrorCode {
+
+ // ===== Common (1000번대) =====
+ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C001", "서버 내부 오류가 발생했습니다."),
+ INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C002", "잘못된 입력값입니다."),
+ METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C003", "허용되지 않은 HTTP 메서드입니다."),
+ INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "C004", "잘못된 타입입니다."),
+ ACCESS_DENIED(HttpStatus.FORBIDDEN, "C005", "접근 권한이 없습니다."),
+
+ // ===== Product (2000번대) =====
+ PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "상품을 찾을 수 없습니다."),
+ PRODUCT_ALREADY_DEACTIVATED(HttpStatus.BAD_REQUEST, "P002", "이미 비활성화된 상품입니다."),
+ PRODUCT_STOCK_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "P003", "재고가 부족합니다."),
+ INVALID_PRODUCT_PRICE(HttpStatus.BAD_REQUEST, "P004", "유효하지 않은 상품 가격입니다."),
+
+ // ===== Order (3000번대) =====
+ ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없습니다."),
+ ORDER_ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "O002", "이미 취소된 주문입니다."),
+ ORDER_CANNOT_CANCEL(HttpStatus.BAD_REQUEST, "O003", "취소할 수 없는 주문 상태입니다."),
+ ORDER_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "O004", "주문 항목을 찾을 수 없습니다."),
+
+ // ===== Payment (4000번대) =====
+ PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAY001", "결제 정보를 찾을 수 없습니다."),
+ PAYMENT_FAILED(HttpStatus.BAD_REQUEST, "PAY002", "결제 처리에 실패했습니다."),
+ PAYMENT_ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "PAY003", "이미 취소된 결제입니다."),
+ PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "PAY004", "결제 금액이 일치하지 않습니다."),
+ UNSUPPORTED_PAYMENT_GATEWAY(HttpStatus.BAD_REQUEST, "PAY005", "지원하지 않는 PG사입니다."),
+
+ // ===== Inventory (5000번대) =====
+ INVENTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "I001", "재고 정보를 찾을 수 없습니다."),
+ INVENTORY_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "I002", "재고가 부족합니다."),
+ INVENTORY_ALREADY_RESERVED(HttpStatus.CONFLICT, "I003", "이미 예약된 재고입니다."),
+
+ // ===== Delivery (6000번대) =====
+ DELIVERY_NOT_FOUND(HttpStatus.NOT_FOUND, "D001", "배송 정보를 찾을 수 없습니다."),
+ DELIVERY_ALREADY_STARTED(HttpStatus.BAD_REQUEST, "D002", "이미 배송이 시작되었습니다."),
+ DELIVERY_CANNOT_CANCEL(HttpStatus.BAD_REQUEST, "D003", "취소할 수 없는 배송 상태입니다."),
+
+ // ===== Notification (7000번대) =====
+ NOTIFICATION_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "N001", "알림 전송에 실패했습니다."),
+
+ // ===== Kafka/Event (8000번대) =====
+ EVENT_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "이벤트 발행에 실패했습니다."),
+ EVENT_CONSUME_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E002", "이벤트 처리에 실패했습니다."),
+ ;
+
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+}
diff --git a/common/src/main/java/com/example/common/exception/ErrorResponse.java b/common/src/main/java/com/example/common/exception/ErrorResponse.java
new file mode 100644
index 0000000..8e5a3a3
--- /dev/null
+++ b/common/src/main/java/com/example/common/exception/ErrorResponse.java
@@ -0,0 +1,100 @@
+package com.example.common.exception;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.springframework.validation.BindingResult;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * API 에러 응답 포맷
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ErrorResponse {
+
+ private String code; // 에러 코드 (예: P001, O001)
+ private String message; // 에러 메시지
+ private int status; // HTTP 상태 코드
+ private LocalDateTime timestamp;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private List errors; // 필드 에러 목록 (validation 용)
+
+ @Builder
+ public ErrorResponse(String code, String message, int status, List errors) {
+ this.code = code;
+ this.message = message;
+ this.status = status;
+ this.timestamp = LocalDateTime.now();
+ this.errors = errors != null ? errors : new ArrayList<>();
+ }
+
+ /**
+ * ErrorCode 기반으로 ErrorResponse 생성
+ */
+ public static ErrorResponse of(ErrorCode errorCode) {
+ return ErrorResponse.builder()
+ .code(errorCode.getCode())
+ .message(errorCode.getMessage())
+ .status(errorCode.getHttpStatus().value())
+ .build();
+ }
+
+ /**
+ * ErrorCode + 커스텀 메시지로 ErrorResponse 생성
+ */
+ public static ErrorResponse of(ErrorCode errorCode, String customMessage) {
+ return ErrorResponse.builder()
+ .code(errorCode.getCode())
+ .message(customMessage)
+ .status(errorCode.getHttpStatus().value())
+ .build();
+ }
+
+ /**
+ * Validation 에러를 위한 ErrorResponse 생성
+ */
+ public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult) {
+ return ErrorResponse.builder()
+ .code(errorCode.getCode())
+ .message(errorCode.getMessage())
+ .status(errorCode.getHttpStatus().value())
+ .errors(FieldError.of(bindingResult))
+ .build();
+ }
+
+ /**
+ * 필드 에러 정보
+ */
+ @Getter
+ @NoArgsConstructor(access = AccessLevel.PROTECTED)
+ public static class FieldError {
+ private String field;
+ private String value;
+ private String reason;
+
+ @Builder
+ public FieldError(String field, String value, String reason) {
+ this.field = field;
+ this.value = value;
+ this.reason = reason;
+ }
+
+ public static List of(BindingResult bindingResult) {
+ return bindingResult.getFieldErrors().stream()
+ .map(error -> FieldError.builder()
+ .field(error.getField())
+ .value(error.getRejectedValue() == null ? "" : error.getRejectedValue().toString())
+ .reason(error.getDefaultMessage())
+ .build())
+ .collect(Collectors.toList());
+ }
+ }
+}
diff --git a/common/src/main/java/com/example/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/example/common/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..fc1ea66
--- /dev/null
+++ b/common/src/main/java/com/example/common/exception/GlobalExceptionHandler.java
@@ -0,0 +1,97 @@
+package com.example.common.exception;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+/**
+ * 전역 예외 처리 핸들러
+ * 모든 마이크로서비스에서 공통으로 사용
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ /**
+ * 비즈니스 로직 예외 처리
+ * - EntityNotFoundException
+ * - InvalidValueException
+ * - BusinessException
+ */
+ @ExceptionHandler(BusinessException.class)
+ protected ResponseEntity handleBusinessException(BusinessException e) {
+ log.error("BusinessException: code={}, message={}", e.getErrorCode().getCode(), e.getMessage());
+
+ ErrorCode errorCode = e.getErrorCode();
+ ErrorResponse response = ErrorResponse.of(errorCode, e.getMessage());
+
+ return new ResponseEntity<>(response, errorCode.getHttpStatus());
+ }
+
+ /**
+ * javax.validation 검증 실패
+ * @Valid 어노테이션으로 binding error 발생 시
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ log.error("MethodArgumentNotValidException: {}", e.getMessage());
+
+ ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
+
+ return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
+ }
+
+ /**
+ * @ModelAttribute binding error 발생 시
+ */
+ @ExceptionHandler(BindException.class)
+ protected ResponseEntity handleBindException(BindException e) {
+ log.error("BindException: {}", e.getMessage());
+
+ ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
+
+ return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
+ }
+
+ /**
+ * enum type 불일치로 binding 실패 시
+ */
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ protected ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
+ log.error("MethodArgumentTypeMismatchException: {}", e.getMessage());
+
+ ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_TYPE_VALUE);
+
+ return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
+ }
+
+ /**
+ * 지원하지 않는 HTTP method 호출 시
+ */
+ @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+ protected ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
+ log.error("HttpRequestMethodNotSupportedException: {}", e.getMessage());
+
+ ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
+
+ return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
+ }
+
+ /**
+ * 처리하지 못한 모든 예외
+ */
+ @ExceptionHandler(Exception.class)
+ protected ResponseEntity handleException(Exception e) {
+ log.error("Unhandled Exception: ", e);
+
+ ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
+
+ return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+}
diff --git a/common/src/main/java/com/example/common/exception/InvalidValueException.java b/common/src/main/java/com/example/common/exception/InvalidValueException.java
new file mode 100644
index 0000000..99ef4e0
--- /dev/null
+++ b/common/src/main/java/com/example/common/exception/InvalidValueException.java
@@ -0,0 +1,16 @@
+package com.example.common.exception;
+
+/**
+ * 잘못된 값이 입력되었을 때 발생하는 예외
+ * 400 Bad Request 응답
+ */
+public class InvalidValueException extends BusinessException {
+
+ public InvalidValueException(ErrorCode errorCode) {
+ super(errorCode);
+ }
+
+ public InvalidValueException(ErrorCode errorCode, String message) {
+ super(errorCode, message);
+ }
+}
diff --git a/config-server/Dockerfile b/config-server/Dockerfile
deleted file mode 100644
index 4276147..0000000
--- a/config-server/Dockerfile
+++ /dev/null
@@ -1,33 +0,0 @@
-# Multi-stage build for optimization
-FROM gradle:8.5-jdk21 AS builder
-WORKDIR /app
-
-# Copy Gradle files
-COPY build.gradle settings.gradle ./
-COPY gradle ./gradle
-
-# Copy source code
-COPY config-server ./config-server
-
-# Build the application
-RUN gradle :config-server:bootJar --no-daemon
-
-# Runtime stage
-FROM eclipse-temurin:21-jre
-WORKDIR /app
-
-# Install curl for healthcheck
-RUN apt-get update && \
- apt-get install -y curl && \
- apt-get clean && \
- rm -rf /var/lib/apt/lists/*
-
-# Create non-root user for security
-RUN addgroup --system spring && adduser --system --ingroup spring spring
-USER spring:spring
-
-# Copy JAR from builder
-COPY --from=builder /app/config-server/build/libs/*.jar app.jar
-
-EXPOSE 8888
-ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/config-server/build.gradle b/config-server/build.gradle
deleted file mode 100644
index 63d8d8f..0000000
--- a/config-server/build.gradle
+++ /dev/null
@@ -1,9 +0,0 @@
-plugins {
- id 'org.springframework.boot' version '3.1.5'
-}
-
-dependencies {
- implementation 'org.springframework.cloud:spring-cloud-config-server'
- implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
- implementation 'org.springframework.boot:spring-boot-starter-actuator'
-}
diff --git a/config-server/src/main/java/com/example/configserver/ConfigServerApplication.java b/config-server/src/main/java/com/example/configserver/ConfigServerApplication.java
deleted file mode 100644
index d3fde37..0000000
--- a/config-server/src/main/java/com/example/configserver/ConfigServerApplication.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.example.configserver;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.cloud.config.server.EnableConfigServer;
-
-@SpringBootApplication
-@EnableConfigServer // Config Server 활성화
-public class ConfigServerApplication {
- public static void main(String[] args) {
- SpringApplication.run(ConfigServerApplication.class, args);
- }
-}
diff --git a/config-server/src/main/resources/application-docker.yml b/config-server/src/main/resources/application-docker.yml
deleted file mode 100644
index b15026b..0000000
--- a/config-server/src/main/resources/application-docker.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-server:
- port: 8888
-
-spring:
- application:
- name: config-server
- cloud:
- config:
- server:
- native:
- search-locations: classpath:/config
-
-management:
- endpoints:
- web:
- exposure:
- include: health,info
- endpoint:
- health:
- show-details: always
-
-eureka:
- client:
- service-url:
- defaultZone: http://eureka-server:8761/eureka/ # Docker 환경용
- instance:
- prefer-ip-address: true
-
-logging:
- level:
- org.springframework.cloud.config: DEBUG
diff --git a/config-server/src/main/resources/application.yml b/config-server/src/main/resources/application.yml
deleted file mode 100644
index eea4a9f..0000000
--- a/config-server/src/main/resources/application.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-server:
- port: 8888 # Config Server 포트
-
-spring:
- application:
- name: config-server
- profiles:
- active: native # 파일 시스템 기반 설정 사용 (Git도 가능)
- cloud:
- config:
- server:
- native:
- search-locations: classpath:/config # 설정 파일 위치
-
-management:
- endpoints:
- web:
- exposure:
- include: health,info
- endpoint:
- health:
- show-details: always
-
-eureka:
- client:
- service-url:
- defaultZone: http://localhost:8761/eureka/
- instance:
- prefer-ip-address: true
-
-logging:
- level:
- org.springframework.cloud.config: DEBUG
diff --git a/config-server/src/main/resources/config/api-gateway.yml b/config-server/src/main/resources/config/api-gateway.yml
deleted file mode 100644
index ad4372b..0000000
--- a/config-server/src/main/resources/config/api-gateway.yml
+++ /dev/null
@@ -1,82 +0,0 @@
-server:
- port: 8080
-
-spring:
- application:
- name: api-gateway
- cloud:
- gateway:
- routes:
- # User Service 라우팅
- - id: user-service
- uri: lb://user-service # lb = load balanced (Eureka로 찾기)
- predicates:
- - Path=/api/users/**
- filters:
- - name: CircuitBreaker
- args:
- name: userServiceCircuitBreaker
- fallbackUri: forward:/fallback/user-service
- - RewritePath=/api/users/(?.*), /$\{segment}
-
- # Order Service 라우팅
- - id: order-service
- uri: lb://order-service
- predicates:
- - Path=/api/orders/**
- filters:
- - name: CircuitBreaker
- args:
- name: orderServiceCircuitBreaker
- fallbackUri: forward:/fallback/order-service
- - RewritePath=/api/orders/(?.*), /$\{segment}
-
- discovery:
- locator:
- enabled: true
- lower-case-service-id: true
-
-eureka:
- client:
- service-url:
- defaultZone: http://localhost:8761/eureka/
- instance:
- prefer-ip-address: true
-
-# Resilience4j Circuit Breaker 설정
-resilience4j:
- circuitbreaker:
- configs:
- default:
- registerHealthIndicator: true
- slidingWindowSize: 10 # 최근 10개 요청 기준으로 판단
- minimumNumberOfCalls: 5 # 최소 5번 호출 후 동작
- failureRateThreshold: 50 # 실패율 50% 이상이면 OPEN
- waitDurationInOpenState: 10000 # OPEN 상태에서 10초 대기
- permittedNumberOfCallsInHalfOpenState: 3 # HALF_OPEN에서 3번 테스트
- slowCallDurationThreshold: 2000 # 2초 이상이면 느린 호출
- slowCallRateThreshold: 50 # 느린 호출 50% 이상이면 OPEN
- instances:
- userServiceCircuitBreaker:
- baseConfig: default
- orderServiceCircuitBreaker:
- baseConfig: default
-
- timelimiter:
- configs:
- default:
- timeoutDuration: 3s # 3초 타임아웃
-
-logging:
- level:
- org.springframework.cloud.gateway: DEBUG
- io.github.resilience4j: DEBUG
-
-# Distributed Tracing - Zipkin
-management:
- tracing:
- sampling:
- probability: 1.0 # 100% 샘플링 (개발 환경, 운영에서는 0.1 권장)
- zipkin:
- tracing:
- endpoint: http://zipkin:9411/api/v2/spans
diff --git a/config-server/src/main/resources/config/order-service.yml b/config-server/src/main/resources/config/order-service.yml
deleted file mode 100644
index 8095bbc..0000000
--- a/config-server/src/main/resources/config/order-service.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-server:
- port: 8082
-
-spring:
- application:
- name: order-service
- datasource:
- url: jdbc:mysql://mysql-order:3306/orderdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
- driverClassName: com.mysql.cj.jdbc.Driver
- username: user
- password: password
-
- jpa:
- hibernate:
- ddl-auto: update
- show-sql: true
- properties:
- hibernate:
- dialect: org.hibernate.dialect.MySQLDialect
- format_sql: true
-
-eureka:
- client:
- service-url:
- defaultZone: http://localhost:8761/eureka/
- instance:
- prefer-ip-address: true
-
-logging:
- level:
- com.example: DEBUG
- com.example.order.client: DEBUG
- feign: DEBUG
-
-# 추가 설정: 비즈니스 로직 관련
-app:
- order:
- max-order-amount: 1000000
- delivery-fee: 3000
-
-# Distributed Tracing - Zipkin
-management:
- tracing:
- sampling:
- probability: 1.0 # 100% 샘플링 (개발 환경, 운영에서는 0.1 권장)
- propagation:
- type: b3 # Zipkin B3 propagation format
- zipkin:
- tracing:
- endpoint: http://zipkin:9411/api/v2/spans
-
-# Resilience4j Circuit Breaker 설정 (서비스 간 호출용)
-resilience4j:
- circuitbreaker:
- configs:
- default:
- registerHealthIndicator: true
- slidingWindowSize: 10
- minimumNumberOfCalls: 5
- failureRateThreshold: 50
- waitDurationInOpenState: 10000
- permittedNumberOfCallsInHalfOpenState: 3
- instances:
- userClient: # Feign Client 이름
- baseConfig: default
-
- timelimiter:
- configs:
- default:
- timeoutDuration: 3s
- instances:
- userClient:
- baseConfig: default
diff --git a/config-server/src/main/resources/config/user-service.yml b/config-server/src/main/resources/config/user-service.yml
deleted file mode 100644
index 8880a5c..0000000
--- a/config-server/src/main/resources/config/user-service.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-server:
- port: 8081
-
-spring:
- application:
- name: user-service
- datasource:
- url: jdbc:mysql://mysql-user:3306/userdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
- driverClassName: com.mysql.cj.jdbc.Driver
- username: user
- password: password
-
- jpa:
- hibernate:
- ddl-auto: update
- show-sql: true
- properties:
- hibernate:
- dialect: org.hibernate.dialect.MySQLDialect
- format_sql: true
-
-eureka:
- client:
- service-url:
- defaultZone: http://localhost:8761/eureka/
- instance:
- prefer-ip-address: true
-
-logging:
- level:
- com.example: DEBUG
-
-# 추가 설정: 비즈니스 로직 관련
-app:
- user:
- default-role: USER
- max-login-attempts: 5
-
-# Distributed Tracing - Zipkin
-management:
- tracing:
- sampling:
- probability: 1.0 # 100% 샘플링 (개발 환경, 운영에서는 0.1 권장)
- propagation:
- type: b3 # Zipkin B3 propagation format
- zipkin:
- tracing:
- endpoint: http://zipkin:9411/api/v2/spans
diff --git a/delivery-service/build.gradle b/delivery-service/build.gradle
new file mode 100644
index 0000000..2b3f413
--- /dev/null
+++ b/delivery-service/build.gradle
@@ -0,0 +1,48 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.1.5'
+ id 'io.spring.dependency-management' version '1.1.3'
+}
+
+group = 'com.example'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '21'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ // Spring Boot
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+
+ // Common Module (이벤트 모델 공유)
+ implementation project(':common')
+
+ // Kafka
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // Database
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok:1.18.34'
+ annotationProcessor 'org.projectlombok:lombok:1.18.34'
+
+ // Micrometer + Zipkin (분산 추적)
+ implementation 'io.micrometer:micrometer-tracing-bridge-brave'
+ implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
+
+ // Test
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.kafka:spring-kafka-test'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/DeliveryServiceApplication.java b/delivery-service/src/main/java/com/example/delivery/DeliveryServiceApplication.java
new file mode 100644
index 0000000..7dd39fb
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/DeliveryServiceApplication.java
@@ -0,0 +1,14 @@
+package com.example.delivery;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.kafka.annotation.EnableKafka;
+
+@SpringBootApplication
+@EnableKafka
+public class DeliveryServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DeliveryServiceApplication.class, args);
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/config/AsyncConfig.java b/delivery-service/src/main/java/com/example/delivery/config/AsyncConfig.java
new file mode 100644
index 0000000..fb454df
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/config/AsyncConfig.java
@@ -0,0 +1,28 @@
+package com.example.delivery.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.context.annotation.Bean;
+
+import java.util.concurrent.Executor;
+
+/**
+ * 비동기 처리 설정
+ * @Async 어노테이션 활성화
+ */
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean(name = "taskExecutor")
+ public Executor taskExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(5);
+ executor.setMaxPoolSize(10);
+ executor.setQueueCapacity(100);
+ executor.setThreadNamePrefix("delivery-async-");
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/entity/Delivery.java b/delivery-service/src/main/java/com/example/delivery/entity/Delivery.java
new file mode 100644
index 0000000..350deb5
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/entity/Delivery.java
@@ -0,0 +1,111 @@
+package com.example.delivery.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 배송 엔티티
+ */
+@Entity
+@Table(name = "delivery")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class Delivery {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * 주문 ID (연관관계)
+ */
+ @Column(nullable = false)
+ private Long orderId;
+
+ /**
+ * 배송 ID (추적 번호)
+ */
+ @Column(nullable = false, unique = true)
+ private String deliveryId;
+
+ /**
+ * 배송지 주소
+ */
+ @Column(nullable = false)
+ private String address;
+
+ /**
+ * 택배사
+ */
+ @Column(nullable = false)
+ private String carrier;
+
+ /**
+ * 배송 상태
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private DeliveryStatus status;
+
+ /**
+ * 배송 시작 시각
+ */
+ @Column
+ private LocalDateTime startedAt;
+
+ /**
+ * 배송 완료 시각
+ */
+ @Column
+ private LocalDateTime completedAt;
+
+ /**
+ * 실패 사유
+ */
+ @Column
+ private String failureReason;
+
+ public Delivery(Long orderId, String deliveryId, String address, String carrier) {
+ this.orderId = orderId;
+ this.deliveryId = deliveryId;
+ this.address = address;
+ this.carrier = carrier;
+ this.status = DeliveryStatus.PREPARING;
+ }
+
+ public enum DeliveryStatus {
+ PREPARING, // 배송 준비
+ IN_TRANSIT, // 배송 중
+ DELIVERED, // 배송 완료
+ FAILED // 배송 실패
+ }
+
+ /**
+ * 배송 시작
+ */
+ public void start() {
+ this.status = DeliveryStatus.IN_TRANSIT;
+ this.startedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 배송 완료
+ */
+ public void complete() {
+ this.status = DeliveryStatus.DELIVERED;
+ this.completedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 배송 실패
+ */
+ public void fail(String reason) {
+ this.status = DeliveryStatus.FAILED;
+ this.failureReason = reason;
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/exception/DeliveryAlreadyStartedException.java b/delivery-service/src/main/java/com/example/delivery/exception/DeliveryAlreadyStartedException.java
new file mode 100644
index 0000000..fbd3881
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/exception/DeliveryAlreadyStartedException.java
@@ -0,0 +1,15 @@
+package com.example.delivery.exception;
+
+import com.example.common.exception.BusinessException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 이미 배송이 시작된 경우 발생하는 예외
+ */
+public class DeliveryAlreadyStartedException extends BusinessException {
+
+ public DeliveryAlreadyStartedException(String deliveryId) {
+ super(ErrorCode.DELIVERY_ALREADY_STARTED,
+ "이미 배송이 시작되었습니다. 배송 ID: " + deliveryId);
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/exception/DeliveryNotFoundException.java b/delivery-service/src/main/java/com/example/delivery/exception/DeliveryNotFoundException.java
new file mode 100644
index 0000000..4d79b76
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/exception/DeliveryNotFoundException.java
@@ -0,0 +1,18 @@
+package com.example.delivery.exception;
+
+import com.example.common.exception.EntityNotFoundException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 배송 정보를 찾을 수 없을 때 발생하는 예외
+ */
+public class DeliveryNotFoundException extends EntityNotFoundException {
+
+ public DeliveryNotFoundException(Long deliveryId) {
+ super(ErrorCode.DELIVERY_NOT_FOUND, "배송 정보를 찾을 수 없습니다. 배송 ID: " + deliveryId);
+ }
+
+ public DeliveryNotFoundException(String message) {
+ super(ErrorCode.DELIVERY_NOT_FOUND, message);
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/kafka/DeliveryEventProducer.java b/delivery-service/src/main/java/com/example/delivery/kafka/DeliveryEventProducer.java
new file mode 100644
index 0000000..0c369ad
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/kafka/DeliveryEventProducer.java
@@ -0,0 +1,104 @@
+package com.example.delivery.kafka;
+
+import com.example.common.event.DeliveryCompletedEvent;
+import com.example.common.event.DeliveryFailedEvent;
+import com.example.common.event.DeliveryStartedEvent;
+import com.example.delivery.entity.Delivery;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Delivery 이벤트 발행
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeliveryEventProducer {
+
+ private final KafkaTemplate kafkaTemplate;
+ private static final String TOPIC = "delivery-events";
+
+ /**
+ * 배송 준비 완료 이벤트 발행
+ * (prepareDelivery 완료 후 즉시 발행)
+ */
+ public void publishDeliveryPrepared(Delivery delivery) {
+ DeliveryStartedEvent event = DeliveryStartedEvent.builder()
+ .orderId(delivery.getOrderId())
+ .deliveryId(delivery.getDeliveryId())
+ .address(delivery.getAddress())
+ .carrier(delivery.getCarrier())
+ .startedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 배송 준비 완료 이벤트 발행 - orderId: {}", delivery.getOrderId());
+ sendEvent(event);
+ }
+
+ /**
+ * 배송 시작 이벤트 발행
+ * (실제 물류센터 출고 후 발행)
+ */
+ public void publishDeliveryStarted(Delivery delivery) {
+ DeliveryStartedEvent event = DeliveryStartedEvent.builder()
+ .orderId(delivery.getOrderId())
+ .deliveryId(delivery.getDeliveryId())
+ .address(delivery.getAddress())
+ .carrier(delivery.getCarrier())
+ .startedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 배송 시작 이벤트 발행 (물류센터 출고) - orderId: {}", delivery.getOrderId());
+ sendEvent(event);
+ }
+
+ /**
+ * 배송 완료 이벤트 발행
+ */
+ public void publishDeliveryCompleted(Delivery delivery) {
+ DeliveryCompletedEvent event = DeliveryCompletedEvent.builder()
+ .orderId(delivery.getOrderId())
+ .deliveryId(delivery.getDeliveryId())
+ .completedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 배송 완료 이벤트 발행 - orderId: {}", delivery.getOrderId());
+ sendEvent(event);
+ }
+
+ /**
+ * 배송 실패 이벤트 발행
+ */
+ public void publishDeliveryFailed(Delivery delivery) {
+ DeliveryFailedEvent event = DeliveryFailedEvent.builder()
+ .orderId(delivery.getOrderId())
+ .deliveryId(delivery.getDeliveryId())
+ .reason(delivery.getFailureReason())
+ .failedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 배송 실패 이벤트 발행 - orderId: {}", delivery.getOrderId());
+ sendEvent(event);
+ }
+
+ /**
+ * Kafka 이벤트 전송 공통 로직
+ */
+ private void sendEvent(Object event) {
+ CompletableFuture> future =
+ kafkaTemplate.send(TOPIC, event.toString(), event);
+
+ future.whenComplete((result, ex) -> {
+ if (ex == null) {
+ log.info("✅ [Kafka Producer] 이벤트 발행 성공");
+ } else {
+ log.error("❌ [Kafka Producer] 이벤트 발행 실패", ex);
+ }
+ });
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/kafka/PaymentEventConsumer.java b/delivery-service/src/main/java/com/example/delivery/kafka/PaymentEventConsumer.java
new file mode 100644
index 0000000..daa0717
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/kafka/PaymentEventConsumer.java
@@ -0,0 +1,44 @@
+package com.example.delivery.kafka;
+
+import com.example.common.event.PaymentCompletedEvent;
+import com.example.delivery.entity.Delivery;
+import com.example.delivery.service.DeliveryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * Payment 이벤트 구독
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentEventConsumer {
+
+ private final DeliveryService deliveryService;
+ private final DeliveryEventProducer deliveryEventProducer;
+
+ /**
+ * 결제 완료 이벤트 수신 → 배송 준비 시작
+ *
+ * 예외 처리 개선:
+ * - try-catch 제거: 재시도 가능한 예외는 자동 재시도 (CommonErrorHandler)
+ * - 재시도 후에도 실패 시 DLQ(Dead Letter Queue)로 이동
+ */
+ @KafkaListener(
+ topics = "payment-events",
+ groupId = "delivery-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void handlePaymentCompleted(PaymentCompletedEvent event) {
+ log.info("📩 [Kafka Consumer] 결제 완료 이벤트 수신 - orderId: {}, 배송 준비 시작",
+ event.getOrderId());
+
+ // 예외 발생 시 자동 재시도 (KafkaConsumerConfig의 ErrorHandler)
+ Delivery delivery = deliveryService.prepareDelivery(event.getOrderId());
+
+ // 이벤트 발행은 트랜잭션 커밋 후 (TransactionalEventListener 사용 권장)
+ deliveryEventProducer.publishDeliveryPrepared(delivery);
+ }
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/repository/DeliveryRepository.java b/delivery-service/src/main/java/com/example/delivery/repository/DeliveryRepository.java
new file mode 100644
index 0000000..5de8bb6
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/repository/DeliveryRepository.java
@@ -0,0 +1,16 @@
+package com.example.delivery.repository;
+
+import com.example.delivery.entity.Delivery;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface DeliveryRepository extends JpaRepository {
+
+ /**
+ * 주문 ID로 배송 조회
+ */
+ Optional findByOrderId(Long orderId);
+}
diff --git a/delivery-service/src/main/java/com/example/delivery/service/DeliveryService.java b/delivery-service/src/main/java/com/example/delivery/service/DeliveryService.java
new file mode 100644
index 0000000..aac58a1
--- /dev/null
+++ b/delivery-service/src/main/java/com/example/delivery/service/DeliveryService.java
@@ -0,0 +1,129 @@
+package com.example.delivery.service;
+
+import com.example.delivery.entity.Delivery;
+import com.example.delivery.exception.DeliveryNotFoundException;
+import com.example.delivery.kafka.DeliveryEventProducer;
+import com.example.delivery.repository.DeliveryRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DeliveryService {
+
+ private final DeliveryRepository deliveryRepository;
+ private final DeliveryEventProducer deliveryEventProducer;
+
+ /**
+ * 배송 준비 (결제 완료 시 호출)
+ * 동기 처리 - DB 저장 완료 후 리턴
+ */
+ @Transactional
+ public Delivery prepareDelivery(Long orderId) {
+ log.info("[Delivery Service] 배송 준비 - orderId: {}", orderId);
+
+ String deliveryId = "DEL-" + UUID.randomUUID().toString().substring(0, 8);
+ String carrier = selectCarrier(); // 택배사 선택 (CJ대한통운, 한진택배 등)
+ String address = "서울시 강남구 테헤란로 123"; // 실제로는 Order/User Service에서 조회
+
+ Delivery delivery = new Delivery(orderId, deliveryId, address, carrier);
+ deliveryRepository.save(delivery);
+
+ log.info("✅ [Delivery Service] 배송 준비 완료 - deliveryId: {}, carrier: {}",
+ deliveryId, carrier);
+
+ // 배송 시작 스케줄링 (비동기 - 물류센터 출고 시뮬레이션)
+ startDeliveryAsync(delivery.getId());
+
+ return delivery;
+ }
+
+ /**
+ * 배송 시작 (비동기 - 3초 후 자동 시작)
+ * @Async를 사용하여 별도 스레드에서 실행
+ */
+ @Async
+ @Transactional
+ public void startDeliveryAsync(Long deliveryId) {
+ try {
+ Thread.sleep(3000); // 3초 대기 (물류센터 출고 시뮬레이션)
+
+ Delivery delivery = deliveryRepository.findById(deliveryId)
+ .orElseThrow(() -> new DeliveryNotFoundException(deliveryId));
+
+ delivery.start();
+ deliveryRepository.save(delivery);
+
+ log.info("🚚 [Delivery Service] 배송 시작 (물류센터 출고) - deliveryId: {}", delivery.getDeliveryId());
+
+ // 트랜잭션 커밋 후 이벤트 발행
+ deliveryEventProducer.publishDeliveryStarted(delivery);
+
+ // 배송 완료 (비동기 - 5초 후 자동 완료)
+ completeDeliveryAsync(deliveryId);
+
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.error("❌ [Delivery Service] 배송 시작 중단됨", e);
+ throw new RuntimeException("배송 시작 실패", e);
+ } catch (Exception e) {
+ log.error("❌ [Delivery Service] 배송 시작 실패", e);
+ throw e; // 예외를 다시 던져서 트랜잭션 롤백
+ }
+ }
+
+ /**
+ * 배송 완료 (비동기 - 5초 후 자동 완료)
+ */
+ @Async
+ @Transactional
+ public void completeDeliveryAsync(Long deliveryId) {
+ try {
+ Thread.sleep(5000); // 5초 대기 (배송 중 시뮬레이션)
+
+ Delivery delivery = deliveryRepository.findById(deliveryId)
+ .orElseThrow(() -> new DeliveryNotFoundException(deliveryId));
+
+ // 5% 확률로 배송 실패 (수령 거부, 주소 오류 등)
+ if (Math.random() < 0.05) {
+ delivery.fail("수령 거부");
+ deliveryRepository.save(delivery);
+ log.warn("❌ [Delivery Service] 배송 실패 - deliveryId: {}, reason: 수령 거부",
+ delivery.getDeliveryId());
+
+ // 트랜잭션 커밋 후 실패 이벤트 발행
+ deliveryEventProducer.publishDeliveryFailed(delivery);
+ } else {
+ delivery.complete();
+ deliveryRepository.save(delivery);
+ log.info("✅ [Delivery Service] 배송 완료 - deliveryId: {}",
+ delivery.getDeliveryId());
+
+ // 트랜잭션 커밋 후 완료 이벤트 발행
+ deliveryEventProducer.publishDeliveryCompleted(delivery);
+ }
+
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.error("❌ [Delivery Service] 배송 완료 처리 중단됨", e);
+ throw new RuntimeException("배송 완료 실패", e);
+ } catch (Exception e) {
+ log.error("❌ [Delivery Service] 배송 완료 처리 실패", e);
+ throw e; // 예외를 다시 던져서 트랜잭션 롤백
+ }
+ }
+
+ /**
+ * 택배사 선택 (랜덤)
+ */
+ private String selectCarrier() {
+ String[] carriers = {"CJ대한통운", "한진택배", "로젠택배", "우체국택배"};
+ return carriers[(int) (Math.random() * carriers.length)];
+ }
+}
diff --git a/delivery-service/src/main/resources/application.yml b/delivery-service/src/main/resources/application.yml
new file mode 100644
index 0000000..148e947
--- /dev/null
+++ b/delivery-service/src/main/resources/application.yml
@@ -0,0 +1,56 @@
+spring:
+ application:
+ name: delivery-service
+
+ # Database (H2 로컬 개발용)
+ datasource:
+ url: jdbc:h2:mem:deliverydb
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+
+ # Kafka
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: delivery-service-group
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
+ properties:
+ spring.json.trusted.packages: "*"
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+
+# Server
+server:
+ port: 8086
+
+# Zipkin (분산 추적)
+management:
+ tracing:
+ sampling:
+ probability: 1.0
+ zipkin:
+ tracing:
+ endpoint: ${ZIPKIN_URL:http://localhost:9411/api/v2/spans}
+
+# Logging
+logging:
+ level:
+ com.example.delivery: DEBUG
+ org.springframework.kafka: INFO
diff --git a/docker-compose.yml b/docker-compose.yml
index f9c51bb..e0804e8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,90 +1,79 @@
version: '3.8'
-
services:
- mysql-user:
- image: mysql:8.0
- container_name: mysql-user
- env_file:
- - .env
- environment:
- MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE: userdb
- MYSQL_USER: user
- MYSQL_PASSWORD: ${MYSQL_USER_PASSWORD}
+ zipkin:
+ image: openzipkin/zipkin:latest
+ container_name: zipkin
ports:
- - "3306:3306"
- volumes:
- - mysql-user-data:/var/lib/mysql
+ - "9411:9411"
networks:
- msa-network
- healthcheck:
- test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
- interval: 10s
- timeout: 5s
- retries: 5
+ environment:
+ - STORAGE_TYPE=mem
- mysql-order:
- image: mysql:8.0
- container_name: mysql-order
- env_file:
- - .env
+ # Kafka 관련 서비스
+ zookeeper:
+ image: confluentinc/cp-zookeeper:7.5.0
+ container_name: zookeeper
environment:
- MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE: orderdb
- MYSQL_USER: user
- MYSQL_PASSWORD: ${MYSQL_USER_PASSWORD}
- ports:
- - "3307:3306"
- volumes:
- - mysql-order-data:/var/lib/mysql
+ ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
networks:
- msa-network
healthcheck:
- test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+ test: ["CMD", "nc", "-z", "localhost", "2181"]
interval: 10s
timeout: 5s
retries: 5
- zipkin:
- image: openzipkin/zipkin:latest
- container_name: zipkin
+ kafka:
+ image: confluentinc/cp-kafka:7.5.0
+ container_name: kafka
ports:
- - "9411:9411"
- networks:
- - msa-network
+ - "9092:9092"
environment:
- - STORAGE_TYPE=mem
-
- eureka-server:
- build:
- context: .
- dockerfile: eureka-server/Dockerfile
- ports:
- - "8761:8761"
- environment:
- - SPRING_PROFILES_ACTIVE=docker
+ KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
+ depends_on:
+ zookeeper:
+ condition: service_healthy
networks:
- msa-network
+ healthcheck:
+ test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"]
+ interval: 10s
+ timeout: 10s
+ retries: 5
- config-server:
- build:
- context: .
- dockerfile: config-server/Dockerfile
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: kafka-ui
ports:
- - "8888:8888"
+ - "8090:8080"
environment:
- - SPRING_PROFILES_ACTIVE=docker,native
- - EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-server:8761/eureka/
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
+ KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
depends_on:
- - eureka-server
+ kafka:
+ condition: service_healthy
networks:
- msa-network
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:8888/actuator/health"]
- interval: 30s
- timeout: 5s
- retries: 3
- start_period: 60s
+
+# eureka-server:
+# build:
+# context: .
+# dockerfile: eureka-server/Dockerfile
+# ports:
+# - "8761:8761"
+# environment:
+# - SPRING_PROFILES_ACTIVE=docker
+# networks:
+# - msa-network
user-service:
build:
@@ -132,6 +121,7 @@ services:
- SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver
- SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT=org.hibernate.dialect.MySQLDialect
- SPRING_AUTOCONFIGURE_EXCLUDE=org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration
+ - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092
depends_on:
mysql-order:
condition: service_healthy
@@ -141,6 +131,31 @@ services:
condition: service_healthy
user-service:
condition: service_started
+
+ kafka:
+ condition: service_healthy
+ networks:
+ - msa-network
+
+ notification-service:
+ build:
+ context: .
+ dockerfile: notification-service/Dockerfile
+ ports:
+ - "8083:8083"
+ environment:
+ - SPRING_PROFILES_ACTIVE=docker
+ - SPRING_CONFIG_IMPORT=optional:configserver:http://config-server:8888
+ - EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-server:8761/eureka/
+ - SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092
+ - ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
+ depends_on:
+ eureka-server:
+ condition: service_started
+ config-server:
+ condition: service_healthy
+ kafka:
+ condition: service_healthy
networks:
- msa-network
diff --git a/docs/E-Commerce-Production-Improvements.md b/docs/E-Commerce-Production-Improvements.md
new file mode 100644
index 0000000..a664d90
--- /dev/null
+++ b/docs/E-Commerce-Production-Improvements.md
@@ -0,0 +1,1170 @@
+# E-Commerce 실무 수준 아키텍처 개선 가이드
+
+> 구현 가이드 문서 | 학습용 프로토타입 → 프로덕션 레벨 시스템 전환
+
+## 목차
+1. [개선 배경](#1-개선-배경)
+2. [Product Service 구축](#2-product-service-구축)
+3. [재고 관리 리팩토링](#3-재고-관리-리팩토링)
+4. [Saga 보상 트랜잭션](#4-saga-보상-트랜잭션)
+5. [Redis 분산 락](#5-redis-분산-락)
+6. [테스트 가이드](#6-테스트-가이드)
+7. [아키텍처 비교](#7-아키텍처-비교)
+
+---
+
+## 1. 개선 배경
+
+### 기존 시스템의 문제점
+
+#### 1.1 보안 취약점 (Critical)
+```json
+// ❌ 기존: 클라이언트가 가격을 직접 입력
+POST /orders
+{
+ "userId": 1,
+ "productName": "MacBook Pro",
+ "price": 1, // 악의적인 사용자가 1원으로 조작!
+ "quantity": 1
+}
+```
+
+**문제:**
+- 클라이언트가 가격을 조작할 수 있음
+- 3,500,000원짜리 맥북을 1원에 구매 가능
+- 실제 서비스에서는 절대 있어서는 안 되는 구조
+
+#### 1.2 데이터 일관성 문제
+```java
+// ❌ 기존: 문자열 기반 상품명
+@Entity
+public class Inventory {
+ private String productName; // "맥북 프로" vs "MacBook Pro" 오타 발생
+ private Integer quantity;
+}
+
+// 문제 발생 시나리오
+Order: productName = "MacBook Pro"
+Inventory: productName = "맥북프로" // 띄어쓰기 차이로 재고 조회 실패!
+```
+
+#### 1.3 동시성 제어 부재
+```
+100명이 동시에 마지막 1개 재고 주문 시:
+
+Thread 1: 재고 조회(1) → 차감 → 저장(0) ✅
+Thread 2: 재고 조회(1) → 차감 → 저장(0) ✅
+Thread 3: 재고 조회(1) → 차감 → 저장(0) ✅
+...
+Thread 100: 재고 조회(1) → 차감 → 저장(0) ✅
+
+결과: 100개 주문 모두 성공, 재고 -99개 ❌
+```
+
+#### 1.4 불완전한 Saga 보상
+```
+주문 생성 → 재고 확보 → 결제 실패
+ ↓ ↓ ↓
+ PENDING RESERVED PaymentFailedEvent
+ ↓
+ 재고 복구 방법이 없음! ❌
+ (productId, quantity 정보 부재)
+```
+
+---
+
+## 2. Product Service 구축
+
+### 2.1 아키텍처 구조
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Client (Web/App) │
+└────────────────────────┬────────────────────────────────┘
+ │ POST /orders
+ │ {userId: 1, productId: 1, quantity: 1}
+ ↓
+┌─────────────────────────────────────────────────────────┐
+│ Order Service │
+├─────────────────────────────────────────────────────────┤
+│ 1. User 검증 (OpenFeign) │
+│ └─> UserClient.getUserById(userId) │
+│ │
+│ 2. Product 정보 조회 및 가격 검증 (OpenFeign) ⭐ │
+│ └─> ProductClient.getProductById(productId) │
+│ └─> 가격: 3,500,000원 (서버에서 가져옴) │
+│ │
+│ 3. 서버 측 총액 계산 ⭐ │
+│ totalPrice = price × quantity │
+│ = 3,500,000 × 1 = 3,500,000원 │
+│ │
+│ 4. 주문 생성 (가격 스냅샷 저장) ⭐ │
+│ Order { │
+│ productId: 1, │
+│ productName: "MacBook Pro 16", // 스냅샷 │
+│ unitPrice: 3,500,000, // 스냅샷 │
+│ totalPrice: 3,500,000 // 서버 계산 │
+│ } │
+└────────────────┬────────────────────────┬───────────────┘
+ │ │
+ ↓ ↓
+┌────────────────────────┐ ┌───────────────────────────┐
+│ User Service │ │ Product Service ⭐ NEW │
+├────────────────────────┤ ├───────────────────────────┤
+│ GET /api/users/{id} │ │ GET /products/{id} │
+│ │ │ │
+│ UserResponse { │ │ ProductResponse { │
+│ id: 1, │ │ id: 1, │
+│ name: "홍길동", │ │ name: "MacBook Pro 16", │
+│ email: "hong@..." │ │ price: 3500000, │
+│ } │ │ category: "ELECTRONICS",│
+│ │ │ brand: "Apple" │
+│ │ │ } │
+└────────────────────────┘ └───────────────────────────┘
+```
+
+### 2.2 Entity 설계
+
+**Product Entity**
+```java
+@Entity
+@Table(name = "products")
+@Getter
+@Setter
+@NoArgsConstructor
+public class Product extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 200)
+ private String name;
+
+ @Column(length = 1000)
+ private String description;
+
+ // 가격은 BigDecimal 사용 (정확한 금액 계산)
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal price;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 50)
+ private Category category;
+
+ @Column(length = 100)
+ private String brand;
+
+ @Column(length = 500)
+ private String imageUrl;
+
+ @Column(nullable = false)
+ private Boolean active = true; // 논리 삭제
+}
+```
+
+**Category Enum**
+```java
+public enum Category {
+ ELECTRONICS, // 전자기기
+ FASHION, // 패션
+ FOOD, // 식품
+ BOOKS, // 도서
+ SPORTS, // 스포츠
+ HOME_LIVING, // 홈/리빙
+ BEAUTY, // 뷰티
+ OTHERS // 기타
+}
+```
+
+### 2.3 Order Entity 개선
+
+**Before vs After**
+
+```java
+// ❌ Before
+@Entity
+public class Order {
+ private Long userId;
+ private String productName; // 문자열
+ private Integer quantity;
+ private Integer price; // 클라이언트 입력
+}
+
+// ✅ After
+@Entity
+public class Order {
+ private Long userId;
+
+ // Product Service 참조
+ @Column(nullable = false)
+ private Long productId;
+
+ // 주문 시점 스냅샷 (가격 변경 영향 없음)
+ @Column(nullable = false)
+ private String productName;
+
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal unitPrice; // 주문 시점 단가
+
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal totalPrice; // 서버 계산 총액
+
+ @Column(nullable = false)
+ private Integer quantity;
+}
+```
+
+### 2.4 서비스 로직 개선
+
+**OrderService.java**
+```java
+@Service
+@RequiredArgsConstructor
+public class OrderService {
+
+ private final UserClient userClient;
+ private final ProductClient productClient; // ⭐ 추가
+ private final OrderRepository orderRepository;
+ private final KafkaTemplate kafkaTemplate;
+
+ @Transactional
+ public Order createOrder(CreateOrderRequest request) {
+ // 1. 사용자 검증
+ UserResponse user = userClient.getUserById(request.getUserId());
+ log.info("✅ 사용자 검증 완료: {}", user.getName());
+
+ // 2. 상품 정보 조회 및 가격 검증 ⭐ 핵심 보안 로직
+ ProductResponse product = productClient.getProductById(request.getProductId());
+ log.info("✅ 상품 정보 조회: {} - {}원", product.getName(), product.getPrice());
+
+ // 3. 서버 측 가격 계산 (클라이언트 입력 무시!)
+ BigDecimal totalPrice = product.getPrice()
+ .multiply(new BigDecimal(request.getQuantity()));
+
+ // 4. 주문 생성 (가격 스냅샷 저장)
+ Order order = new Order(
+ request.getUserId(),
+ product.getId(),
+ product.getName(), // 주문 시점 상품명
+ request.getQuantity(),
+ product.getPrice(), // 주문 시점 단가
+ totalPrice // 서버 계산 총액
+ );
+
+ orderRepository.save(order);
+
+ // 5. Kafka 이벤트 발행
+ OrderCreatedEvent event = OrderCreatedEvent.builder()
+ .orderId(order.getId())
+ .userId(order.getUserId())
+ .productId(order.getProductId()) // ⭐ productId 추가
+ .productName(order.getProductName())
+ .quantity(order.getQuantity())
+ .unitPrice(order.getUnitPrice().intValue())
+ .totalPrice(order.getTotalPrice().intValue())
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ kafkaTemplate.send("order-created", event);
+
+ return order;
+ }
+}
+```
+
+### 2.5 OpenFeign Client
+
+**ProductClient.java**
+```java
+@FeignClient(
+ name = "product-service",
+ url = "${product.service.url:http://localhost:8087}"
+)
+public interface ProductClient {
+
+ @GetMapping("/products/{id}")
+ ProductResponse getProductById(@PathVariable("id") Long id);
+
+ @GetMapping("/products")
+ List getAllProducts();
+
+ @GetMapping("/products")
+ List getProductsByCategory(
+ @RequestParam("category") String category
+ );
+}
+```
+
+### 2.6 DTO 패턴 적용
+
+**왜 DTO를 사용하는가?**
+- Entity 직접 노출 방지 (JPA 지연 로딩 이슈 회피)
+- API 응답 형식 제어
+- 민감 정보 제외 가능
+
+```java
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+@Builder
+public class ProductResponse {
+ private Long id;
+ private String name;
+ private String description;
+ private BigDecimal price;
+ private Category category;
+ private String brand;
+ private String imageUrl;
+
+ // Entity → DTO 변환
+ public static ProductResponse from(Product product) {
+ return ProductResponse.builder()
+ .id(product.getId())
+ .name(product.getName())
+ .description(product.getDescription())
+ .price(product.getPrice())
+ .category(product.category())
+ .brand(product.getBrand())
+ .imageUrl(product.getImageUrl())
+ .build();
+ }
+}
+```
+
+---
+
+## 3. 재고 관리 리팩토링
+
+### 3.1 문제 상황
+
+```
+Order Service: productName = "MacBook Pro"
+Inventory Service: productName = "맥북프로"
+
+재고 조회 시도:
+SELECT * FROM inventory WHERE product_name = 'MacBook Pro'
+→ 결과 없음! ❌
+
+실제로는 재고가 있지만 문자열 불일치로 "재고 부족" 에러 발생
+```
+
+### 3.2 해결책: productId 기반 아키텍처
+
+**Inventory Entity 리팩토링**
+
+```java
+// ❌ Before
+@Entity
+public class Inventory {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String productName; // 문자열 기반
+
+ private Integer quantity;
+}
+
+// ✅ After
+@Entity
+public class Inventory {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, unique = true)
+ private Long productId; // Product Service의 ID와 1:1 매칭
+
+ @Column(nullable = false)
+ private Integer quantity;
+
+ public Inventory(Long productId, Integer quantity) {
+ this.productId = productId;
+ this.quantity = quantity;
+ }
+
+ // 재고 확보 (비즈니스 로직)
+ public boolean reserve(Integer quantity) {
+ if (this.quantity < quantity) {
+ return false; // 재고 부족
+ }
+ this.quantity -= quantity;
+ return true;
+ }
+
+ // 재고 복구 (보상 트랜잭션)
+ public void release(Integer quantity) {
+ this.quantity += quantity;
+ }
+}
+```
+
+**Repository 변경**
+
+```java
+public interface InventoryRepository extends JpaRepository {
+ // ❌ Before
+ // Optional findByProductName(String productName);
+
+ // ✅ After
+ Optional findByProductId(Long productId);
+}
+```
+
+### 3.3 이벤트 클래스 업데이트
+
+**모든 Kafka 이벤트에 productId 추가**
+
+```java
+// OrderCreatedEvent
+@Getter
+@Builder
+public class OrderCreatedEvent implements Serializable {
+ private Long orderId;
+ private Long userId;
+ private Long productId; // ⭐ 추가
+ private String productName; // 스냅샷 (표시용)
+ private Integer quantity;
+ private Integer unitPrice; // ⭐ 추가
+ private Integer totalPrice; // ⭐ 추가
+ private LocalDateTime createdAt;
+}
+
+// InventoryReservedEvent
+@Getter
+@Builder
+public class InventoryReservedEvent implements Serializable {
+ private Long orderId;
+ private Long productId; // ⭐ 추가
+ private String productName; // 스냅샷
+ private Integer quantity;
+ private LocalDateTime reservedAt;
+}
+
+// PaymentFailedEvent
+@Getter
+@Builder
+public class PaymentFailedEvent implements Serializable {
+ private Long orderId;
+ private Long productId; // ⭐ 재고 복구용
+ private Integer quantity; // ⭐ 재고 복구용
+ private String reason;
+ private LocalDateTime failedAt;
+}
+```
+
+---
+
+## 4. Saga 보상 트랜잭션
+
+### 4.1 Saga 패턴 플로우
+
+```
+정상 플로우:
+┌──────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐
+│ Order │───>│ Inventory │───>│ Payment │───>│ Delivery │
+│ Created │ │ Reserved │ │ Approved │ │ Started │
+└──────────┘ └───────────┘ └──────────┘ └──────────┘
+ PENDING RESERVED CONFIRMED SHIPPED
+
+보상 트랜잭션 플로우 (결제 실패):
+┌──────────┐ ┌───────────┐ ┌──────────┐
+│ Order │───>│ Inventory │───>│ Payment │
+│ Created │ │ Reserved │ │ Failed │
+└──────────┘ └───────────┘ └─────┬────┘
+ PENDING RESERVED │
+ ↑ ↑ │ PaymentFailedEvent
+ │ │ │ {productId: 1, quantity: 1}
+ │ │ ↓
+ │ ┌───────────────────────┐
+ │ │ Inventory Service가 │
+ │ │ 재고 복구 (+1) │
+ │ └───────────────────────┘
+ │ │
+ └────────────────────┘
+ OrderCancelledEvent
+```
+
+### 4.2 보상 트랜잭션 구현
+
+**InventoryEventConsumer.java**
+
+```java
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class InventoryEventConsumer {
+
+ private final InventoryService inventoryService;
+ private final KafkaTemplate kafkaTemplate;
+
+ // 주문 생성 이벤트 처리
+ @KafkaListener(topics = "order-created", groupId = "inventory-service-group")
+ public void handleOrderCreated(OrderCreatedEvent event) {
+ log.info("📦 [Kafka Consumer] 주문 생성 이벤트 수신 - orderId: {}, productId: {}",
+ event.getOrderId(), event.getProductId());
+
+ // 재고 확보 시도
+ boolean success = inventoryService.reserveInventory(
+ event.getProductId(),
+ event.getQuantity()
+ );
+
+ if (success) {
+ // 재고 확보 성공 → Payment Service로 전달
+ InventoryReservedEvent reservedEvent = InventoryReservedEvent.builder()
+ .orderId(event.getOrderId())
+ .productId(event.getProductId())
+ .productName(event.getProductName())
+ .quantity(event.getQuantity())
+ .reservedAt(LocalDateTime.now())
+ .build();
+
+ kafkaTemplate.send("inventory-reserved", reservedEvent);
+ } else {
+ // 재고 부족 → Order Service로 실패 알림
+ InventoryFailedEvent failedEvent = InventoryFailedEvent.builder()
+ .orderId(event.getOrderId())
+ .productId(event.getProductId())
+ .reason("재고 부족")
+ .failedAt(LocalDateTime.now())
+ .build();
+
+ kafkaTemplate.send("inventory-failed", failedEvent);
+ }
+ }
+
+ // ⭐ 결제 실패 이벤트 처리 (보상 트랜잭션)
+ @KafkaListener(topics = "payment-failed", groupId = "inventory-service-group")
+ public void handlePaymentFailed(PaymentFailedEvent event) {
+ log.warn("🔄 [Kafka Consumer] 결제 실패 이벤트 수신 - orderId: {}", event.getOrderId());
+ log.info("재고 복구 시작 - productId: {}, quantity: {}",
+ event.getProductId(), event.getQuantity());
+
+ // 재고 복구 (보상 트랜잭션)
+ inventoryService.releaseInventory(event.getProductId(), event.getQuantity());
+
+ log.info("✅ 재고 복구 완료 - productId: {}", event.getProductId());
+ }
+}
+```
+
+### 4.3 보상 트랜잭션 테스트
+
+**시나리오: 결제 실패 시 재고 자동 복구**
+
+```bash
+# 1. 초기 재고 확인
+curl http://localhost:8084/inventory/1
+# 응답: {"productId": 1, "quantity": 10}
+
+# 2. 주문 생성
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId": 1, "productId": 1, "quantity": 1}'
+
+# 3. 로그 확인
+docker-compose logs -f inventory-service
+# 📦 주문 생성 이벤트 수신 - productId: 1
+# ✅ 재고 확보 성공 - 남은 재고: 9
+# 🔄 결제 실패 이벤트 수신 (시뮬레이션)
+# ✅ 재고 복구 완료 - 현재 재고: 10
+
+# 4. 재고 재확인
+curl http://localhost:8084/inventory/1
+# 응답: {"productId": 1, "quantity": 10} ← 원상복구!
+```
+
+---
+
+## 5. Redis 분산 락
+
+### 5.1 동시성 문제 재현
+
+**문제 상황 시뮬레이션**
+
+```bash
+# 재고 1개 상태에서 100명이 동시 주문
+for i in {1..100}; do
+ curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId":1,"productId":1,"quantity":1}' &
+done
+
+# ❌ 분산 락 없을 때:
+# - 100개 주문 모두 성공
+# - 재고: -99개 (음수!)
+
+# ✅ 분산 락 적용 후:
+# - 1개 주문만 성공
+# - 99개 주문 실패 ("재고 부족")
+# - 재고: 0개 (정상)
+```
+
+### 5.2 Redis 분산 락 아키텍처
+
+```
+Client 1 Client 2 Client 3
+ │ │ │
+ ├─ POST /orders ───────┼─ POST /orders ────────┼─ POST /orders
+ ↓ ↓ ↓
+┌───────────────────────────────────────────────────────────┐
+│ Inventory Service (3 instances) │
+├───────────────────────────────────────────────────────────┤
+│ │
+│ @DistributedLock(key = "inventory:lock:#productId") │
+│ public boolean reserveInventory(Long productId, ...) │
+│ │
+└────────────────────────┬──────────────────────────────────┘
+ │
+ ↓ tryLock()
+ ┌─────────────────────────┐
+ │ Redis Cluster │
+ ├─────────────────────────┤
+ │ inventory:lock:1 = UUID │ ← 분산 락
+ │ TTL: 3초 │
+ └─────────────────────────┘
+
+실행 순서:
+1. Client 1 → Lock 획득 성공 ✅ → 재고 차감 진행
+2. Client 2 → Lock 획득 대기 (최대 5초)
+3. Client 3 → Lock 획득 대기 (최대 5초)
+4. Client 1 → 작업 완료 → Lock 해제
+5. Client 2 → Lock 획득 성공 ✅ → 재고 부족으로 실패
+6. Client 3 → Lock 획득 성공 ✅ → 재고 부족으로 실패
+```
+
+### 5.3 커스텀 어노테이션 구현
+
+**@DistributedLock.java**
+
+```java
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface DistributedLock {
+
+ /**
+ * 락 키 (SpEL 지원)
+ * 예: "inventory:lock:#productId"
+ */
+ String key();
+
+ /**
+ * 락 획득 대기 시간 (초)
+ * 이 시간 동안 락 획득 시도
+ */
+ long waitTime() default 5L;
+
+ /**
+ * 락 점유 시간 (초)
+ * 이 시간이 지나면 자동으로 락 해제
+ */
+ long leaseTime() default 3L;
+
+ TimeUnit timeUnit() default TimeUnit.SECONDS;
+}
+```
+
+### 5.4 AOP 구현
+
+**DistributedLockAop.java**
+
+```java
+@Slf4j
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class DistributedLockAop {
+
+ private final RedissonClient redissonClient;
+ private final ExpressionParser parser = new SpelExpressionParser();
+
+ @Around("@annotation(com.example.inventory.annotation.DistributedLock)")
+ public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ Method method = signature.getMethod();
+ DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
+
+ // SpEL 표현식으로 동적 락 키 생성
+ String lockKey = generateKey(distributedLock.key(), method, joinPoint.getArgs());
+ RLock lock = redissonClient.getLock(lockKey);
+
+ log.debug("🔒 [Lock] 락 획득 시도: {}", lockKey);
+
+ // 락 획득 시도
+ boolean acquired = false;
+ try {
+ acquired = lock.tryLock(
+ distributedLock.waitTime(),
+ distributedLock.leaseTime(),
+ distributedLock.timeUnit()
+ );
+
+ if (!acquired) {
+ log.warn("⚠️ [Lock] 락 획득 실패 (타임아웃): {}", lockKey);
+ throw new IllegalStateException(
+ "락 획득 실패: 다른 요청이 처리 중입니다. 잠시 후 다시 시도해주세요."
+ );
+ }
+
+ log.debug("✅ [Lock] 락 획득 성공: {}", lockKey);
+
+ // 실제 비즈니스 로직 실행
+ return joinPoint.proceed();
+
+ } finally {
+ // 락 해제
+ if (acquired && lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ log.debug("🔓 [Lock] 락 해제: {}", lockKey);
+ }
+ }
+ }
+
+ /**
+ * SpEL 표현식을 파싱하여 실제 락 키 생성
+ *
+ * 예: "inventory:lock:#productId" → "inventory:lock:1"
+ */
+ private String generateKey(String keyExpression, Method method, Object[] args) {
+ if (!keyExpression.contains("#")) {
+ return keyExpression; // SpEL 없으면 그대로 반환
+ }
+
+ StandardEvaluationContext context = new StandardEvaluationContext();
+
+ // 메서드 파라미터를 SpEL 변수로 등록
+ String[] paramNames = new DefaultParameterNameDiscoverer()
+ .getParameterNames(method);
+
+ if (paramNames != null) {
+ for (int i = 0; i < paramNames.length; i++) {
+ context.setVariable(paramNames[i], args[i]);
+ }
+ }
+
+ // SpEL 표현식 평가
+ Expression expression = parser.parseExpression(keyExpression);
+ return expression.getValue(context, String.class);
+ }
+}
+```
+
+### 5.5 서비스 적용
+
+**InventoryService.java**
+
+```java
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class InventoryService {
+
+ private final InventoryRepository inventoryRepository;
+
+ /**
+ * 재고 확보 (차감)
+ * - Redis 분산 락 적용으로 동시성 제어
+ *
+ * @param productId 상품 ID
+ * @param quantity 차감할 수량
+ * @return 성공 여부
+ */
+ @DistributedLock(
+ key = "inventory:lock:#productId", // 상품별로 다른 락
+ waitTime = 5, // 5초 동안 락 획득 시도
+ leaseTime = 3 // 3초 후 자동 해제 (데드락 방지)
+ )
+ @Transactional
+ public boolean reserveInventory(Long productId, Integer quantity) {
+ log.info("[Inventory Service] 재고 확보 요청 - productId: {}, quantity: {}",
+ productId, quantity);
+
+ Inventory inventory = inventoryRepository.findByProductId(productId)
+ .orElseThrow(() -> new IllegalArgumentException(
+ "상품 재고를 찾을 수 없습니다: " + productId
+ ));
+
+ // 재고 확보 시도 (Entity의 비즈니스 로직)
+ boolean success = inventory.reserve(quantity);
+
+ if (success) {
+ inventoryRepository.save(inventory);
+ log.info("✅ 재고 확보 성공 - productId: {}, 남은 재고: {}",
+ productId, inventory.getQuantity());
+ } else {
+ log.warn("⚠️ 재고 부족 - productId: {}, 요청: {}, 현재: {}",
+ productId, quantity, inventory.getQuantity());
+ }
+
+ return success;
+ }
+
+ /**
+ * 재고 복구 (보상 트랜잭션)
+ */
+ @Transactional
+ public void releaseInventory(Long productId, Integer quantity) {
+ log.info("🔄 재고 복구 (보상 트랜잭션) - productId: {}, quantity: {}",
+ productId, quantity);
+
+ Inventory inventory = inventoryRepository.findByProductId(productId)
+ .orElseThrow(() -> new IllegalArgumentException(
+ "상품 재고를 찾을 수 없습니다: " + productId
+ ));
+
+ inventory.release(quantity);
+ inventoryRepository.save(inventory);
+
+ log.info("✅ 재고 복구 완료 - productId: {}, 현재 재고: {}",
+ productId, inventory.getQuantity());
+ }
+}
+```
+
+### 5.6 Redis 설정
+
+**RedisConfig.java**
+
+```java
+@Configuration
+public class RedisConfig {
+
+ @Value("${spring.data.redis.host:localhost}")
+ private String redisHost;
+
+ @Value("${spring.data.redis.port:6379}")
+ private int redisPort;
+
+ @Bean
+ public RedissonClient redissonClient() {
+ Config config = new Config();
+ config.useSingleServer()
+ .setAddress("redis://" + redisHost + ":" + redisPort)
+ .setConnectionPoolSize(50)
+ .setConnectionMinimumIdleSize(10)
+ .setConnectTimeout(3000)
+ .setTimeout(3000);
+
+ return Redisson.create(config);
+ }
+}
+```
+
+**application.yml**
+
+```yaml
+spring:
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+```
+
+**docker-compose.yml**
+
+```yaml
+redis:
+ image: redis:7-alpine
+ container_name: redis
+ ports:
+ - "6379:6379"
+ networks:
+ - msa-network
+ command: redis-server --appendonly yes
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+```
+
+---
+
+## 6. 테스트 가이드
+
+### 6.1 전체 시스템 시작
+
+```bash
+# 1. 프로젝트 빌드
+./gradlew clean build -x test
+
+# 2. Docker Compose로 전체 시스템 시작
+docker-compose up -d --build
+
+# 3. 서비스 상태 확인
+docker-compose ps
+
+# 4. 로그 확인
+docker-compose logs -f order-service
+docker-compose logs -f inventory-service
+docker-compose logs -f product-service
+```
+
+### 6.2 Product Service 테스트
+
+```bash
+# 전체 상품 조회
+curl http://localhost:8087/products | jq
+
+# 특정 상품 조회
+curl http://localhost:8087/products/1 | jq
+
+# 응답 예시:
+# {
+# "id": 1,
+# "name": "MacBook Pro 16",
+# "description": "Apple M3 Max 칩 탑재",
+# "price": 3500000,
+# "category": "ELECTRONICS",
+# "brand": "Apple",
+# "imageUrl": "https://..."
+# }
+
+# 카테고리별 조회
+curl "http://localhost:8087/products?category=ELECTRONICS" | jq
+```
+
+### 6.3 실무 주문 플로우 테스트
+
+```bash
+# 주문 생성 (productId만 전달, 가격은 서버에서 계산!)
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{
+ "userId": 1,
+ "productId": 1,
+ "quantity": 1
+ }' | jq
+
+# 응답 예시:
+# {
+# "id": 1,
+# "userId": 1,
+# "productId": 1,
+# "productName": "MacBook Pro 16", # 주문 시점 스냅샷
+# "quantity": 1,
+# "unitPrice": 3500000, # 주문 시점 단가
+# "totalPrice": 3500000, # 서버 계산 총액
+# "status": "PENDING"
+# }
+
+# 사용자별 주문 조회
+curl "http://localhost:8082/orders?userId=1" | jq
+```
+
+### 6.4 Saga 플로우 확인
+
+```bash
+# 1. 주문 생성
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId": 1, "productId": 2, "quantity": 1}'
+
+# 2. 각 서비스 로그 확인 (별도 터미널에서)
+docker-compose logs -f order-service # 주문 생성
+docker-compose logs -f inventory-service # 재고 확보
+docker-compose logs -f payment-service # 결제 처리
+docker-compose logs -f delivery-service # 배송 준비
+docker-compose logs -f notification-service # 알림 발송
+
+# 3. Zipkin에서 분산 추적 확인
+open http://localhost:9411
+# → 서비스 간 호출 흐름 시각화
+```
+
+### 6.5 동시성 테스트
+
+```bash
+# 시나리오: 100명이 동시에 마지막 1개 재고 주문
+
+# 1. 재고 확인
+curl http://localhost:8084/inventory/1 | jq
+# {"productId": 1, "quantity": 1}
+
+# 2. 100개 동시 요청
+for i in {1..100}; do
+ curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId":1,"productId":1,"quantity":1}' &
+done
+
+# 3. 결과 확인
+# - 1개 주문만 성공 ✅
+# - 99개 주문 "재고 부족" 응답
+# - 재고: 0개 (정상)
+
+# 4. Redis 락 상태 확인
+docker exec -it redis redis-cli
+> KEYS inventory:lock:*
+# 락이 정상적으로 해제되었는지 확인
+```
+
+### 6.6 보상 트랜잭션 테스트
+
+```bash
+# 시나리오: 재고 부족으로 주문 취소
+
+# 1. 초기 재고 확인
+curl http://localhost:8084/inventory/1 | jq
+# {"productId": 1, "quantity": 5}
+
+# 2. 재고보다 많은 수량 주문
+curl -X POST http://localhost:8082/orders \
+ -H "Content-Type: application/json" \
+ -d '{"userId": 1, "productId": 1, "quantity": 999}' | jq
+
+# 3. 로그 확인
+docker-compose logs inventory-service | tail -20
+# ⚠️ 재고 부족 - productId: 1, 요청: 999, 현재: 5
+# 📤 Kafka 발행: inventory-failed
+
+docker-compose logs order-service | tail -20
+# 📥 Kafka 수신: inventory-failed
+# ❌ 주문 취소 처리 - orderId: X
+
+# 4. 재고 재확인 (변동 없어야 함)
+curl http://localhost:8084/inventory/1 | jq
+# {"productId": 1, "quantity": 5} ← 원상태 유지
+```
+
+---
+
+## 7. 아키텍처 비교
+
+### 7.1 Before vs After
+
+| 항목 | Before (학습용) | After (프로덕션) |
+|------|----------------|-----------------|
+| **보안** | 클라이언트가 가격 입력 ❌ | 서버 측 가격 검증 ✅ |
+| **상품 관리** | 없음 | Product Service 신규 구축 ✅ |
+| **재고 관리** | 문자열 기반 (productName) | ID 기반 (productId) ✅ |
+| **동시성 제어** | 없음 (음수 재고 발생) | Redis 분산 락 ✅ |
+| **Saga 보상** | 불완전 (재고 복구 안 됨) | 완전한 보상 트랜잭션 ✅ |
+| **가격 변동** | 과거 주문에 영향 | 스냅샷으로 보호 ✅ |
+| **이벤트 구조** | productName만 전달 | productId + 가격 정보 ✅ |
+| **데이터 일관성** | 오타 발생 가능 | Product Service와 1:1 매칭 ✅ |
+
+### 7.2 기술 스택 변화
+
+**Before:**
+```
+- Spring Boot 3.1.5
+- Spring Cloud Gateway
+- Apache Kafka
+- Resilience4j
+- Micrometer + Zipkin
+- H2 Database
+- OpenFeign
+```
+
+**After (추가된 기술):**
+```
+- Redis 7-alpine ← 분산 락
+- Redisson 3.x ← Redis 클라이언트
+- Spring AOP ← 횡단 관심사
+- SpEL ← 동적 락 키
+- BigDecimal ← 정확한 금액 계산
+- DTO Pattern ← Entity 노출 방지
+```
+
+### 7.3 서비스 아키텍처 변화
+
+**Before (7개 서비스):**
+```
+User Service
+Order Service
+Inventory Service
+Payment Service
+Delivery Service
+Notification Service
+API Gateway
+```
+
+**After (8개 서비스):**
+```
+User Service
+Order Service
+Product Service ← ⭐ 신규 추가
+Inventory Service
+Payment Service
+Delivery Service
+Notification Service
+API Gateway
+```
+
+### 7.4 데이터베이스 스키마 변화
+
+**Order 테이블**
+
+```sql
+-- Before
+CREATE TABLE orders (
+ id BIGINT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ product_name VARCHAR(255), -- 문자열
+ quantity INT NOT NULL,
+ price INT NOT NULL, -- 클라이언트 입력
+ status VARCHAR(50)
+);
+
+-- After
+CREATE TABLE orders (
+ id BIGINT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ product_id BIGINT NOT NULL, -- Product Service 참조
+ product_name VARCHAR(255), -- 스냅샷
+ quantity INT NOT NULL,
+ unit_price DECIMAL(10,2), -- 스냅샷
+ total_price DECIMAL(10,2), -- 서버 계산
+ status VARCHAR(50)
+);
+```
+
+**Inventory 테이블**
+
+```sql
+-- Before
+CREATE TABLE inventory (
+ id BIGINT PRIMARY KEY,
+ product_name VARCHAR(255), -- 문자열
+ quantity INT NOT NULL
+);
+
+-- After
+CREATE TABLE inventory (
+ id BIGINT PRIMARY KEY,
+ product_id BIGINT NOT NULL UNIQUE, -- Product Service 참조
+ quantity INT NOT NULL
+);
+```
+
+**Product 테이블 (신규)**
+
+```sql
+CREATE TABLE products (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(200) NOT NULL,
+ description VARCHAR(1000),
+ price DECIMAL(10,2) NOT NULL,
+ category VARCHAR(50) NOT NULL,
+ brand VARCHAR(100),
+ image_url VARCHAR(500),
+ active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP,
+ updated_at TIMESTAMP
+);
+```
+
+---
+
+## 8. 참고 문서
+
+- **[Circuit-Breaker-QNA.md](./Circuit-Breaker-QNA.md)** - Circuit Breaker 면접 대비 Q&A
+- **[resilience4j-patterns.md](./resilience4j-patterns.md)** - Resilience4j 패턴 가이드
+- **[Zipkin-Distributed-Tracing.md](./Zipkin-Distributed-Tracing.md)** - Zipkin 분산 추적 가이드
+- [Spring Cloud OpenFeign](https://spring.io/projects/spring-cloud-openfeign)
+- [Redisson Documentation](https://github.com/redisson/redisson/wiki)
+- [Apache Kafka](https://kafka.apache.org/documentation/)
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1af9e09..9355b41 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/inventory-service/build.gradle b/inventory-service/build.gradle
new file mode 100644
index 0000000..4ec411f
--- /dev/null
+++ b/inventory-service/build.gradle
@@ -0,0 +1,55 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.1.5'
+ id 'io.spring.dependency-management' version '1.1.3'
+}
+
+group = 'com.example'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '21'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ // Spring Boot
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+
+ // Common Module (이벤트 모델 공유)
+ implementation project(':common')
+
+ // Kafka
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // Redis + Redisson (분산 락)
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
+
+ // Database
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok:1.18.34'
+ annotationProcessor 'org.projectlombok:lombok:1.18.34'
+
+ // Micrometer + Zipkin (분산 추적)
+ implementation 'io.micrometer:micrometer-tracing-bridge-brave'
+ implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
+
+ // AOP (분산 락 AOP용)
+ implementation 'org.springframework.boot:spring-boot-starter-aop'
+
+ // Test
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.kafka:spring-kafka-test'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/InventoryServiceApplication.java b/inventory-service/src/main/java/com/example/inventory/InventoryServiceApplication.java
new file mode 100644
index 0000000..7af2265
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/InventoryServiceApplication.java
@@ -0,0 +1,14 @@
+package com.example.inventory;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.kafka.annotation.EnableKafka;
+
+@SpringBootApplication
+@EnableKafka
+public class InventoryServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(InventoryServiceApplication.class, args);
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/annotation/DistributedLock.java b/inventory-service/src/main/java/com/example/inventory/annotation/DistributedLock.java
new file mode 100644
index 0000000..409f775
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/annotation/DistributedLock.java
@@ -0,0 +1,41 @@
+package com.example.inventory.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 분산 락 어노테이션
+ * - Redis 기반 분산 락을 AOP로 적용
+ *
+ * 사용 예시:
+ * @DistributedLock(key = "inventory:lock:#productId")
+ * public boolean reserveInventory(Long productId, Integer quantity) { ... }
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface DistributedLock {
+
+ /**
+ * 락 키 (SpEL 지원)
+ * 예: "inventory:lock:#productId"
+ */
+ String key();
+
+ /**
+ * 락 대기 시간 (기본: 5초)
+ */
+ long waitTime() default 5L;
+
+ /**
+ * 락 점유 시간 (기본: 3초)
+ */
+ long leaseTime() default 3L;
+
+ /**
+ * 시간 단위 (기본: 초)
+ */
+ TimeUnit timeUnit() default TimeUnit.SECONDS;
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/aop/DistributedLockAop.java b/inventory-service/src/main/java/com/example/inventory/aop/DistributedLockAop.java
new file mode 100644
index 0000000..049bf00
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/aop/DistributedLockAop.java
@@ -0,0 +1,91 @@
+package com.example.inventory.aop;
+
+import com.example.inventory.annotation.DistributedLock;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.core.DefaultParameterNameDiscoverer;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+
+/**
+ * 분산 락 AOP
+ * - @DistributedLock 어노테이션이 붙은 메서드에 Redis 분산 락 적용
+ */
+@Slf4j
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class DistributedLockAop {
+
+ private final RedissonClient redissonClient;
+ private final ExpressionParser parser = new SpelExpressionParser();
+ private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
+
+ @Around("@annotation(com.example.inventory.annotation.DistributedLock)")
+ public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ Method method = signature.getMethod();
+ DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
+
+ // SpEL 표현식으로 락 키 생성
+ String lockKey = generateKey(distributedLock.key(), method, joinPoint.getArgs());
+ RLock lock = redissonClient.getLock(lockKey);
+
+ log.info("🔒 [Distributed Lock] 락 획득 시도 - key: {}", lockKey);
+
+ boolean acquired = false;
+ try {
+ // 락 획득 시도 (waitTime, leaseTime, timeUnit)
+ acquired = lock.tryLock(
+ distributedLock.waitTime(),
+ distributedLock.leaseTime(),
+ distributedLock.timeUnit()
+ );
+
+ if (!acquired) {
+ log.warn("⏰ [Distributed Lock] 락 획득 실패 (타임아웃) - key: {}", lockKey);
+ throw new IllegalStateException("락 획득 실패: 다른 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.");
+ }
+
+ log.info("✅ [Distributed Lock] 락 획득 성공 - key: {}", lockKey);
+ return joinPoint.proceed();
+
+ } finally {
+ if (acquired && lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ log.info("🔓 [Distributed Lock] 락 해제 완료 - key: {}", lockKey);
+ }
+ }
+ }
+
+ /**
+ * SpEL 표현식 파싱하여 락 키 생성
+ * 예: "inventory:lock:#productId" → "inventory:lock:1"
+ */
+ private String generateKey(String keyExpression, Method method, Object[] args) {
+ String[] parameterNames = nameDiscoverer.getParameterNames(method);
+ if (parameterNames == null) {
+ return keyExpression;
+ }
+
+ EvaluationContext context = new StandardEvaluationContext();
+ for (int i = 0; i < parameterNames.length; i++) {
+ context.setVariable(parameterNames[i], args[i]);
+ }
+
+ Expression expression = parser.parseExpression(keyExpression);
+ return expression.getValue(context, String.class);
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/config/DataInitializer.java b/inventory-service/src/main/java/com/example/inventory/config/DataInitializer.java
new file mode 100644
index 0000000..ca4ede9
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/config/DataInitializer.java
@@ -0,0 +1,43 @@
+package com.example.inventory.config;
+
+import com.example.inventory.entity.Inventory;
+import com.example.inventory.repository.InventoryRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+/**
+ * 초기 재고 데이터 생성
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DataInitializer implements CommandLineRunner {
+
+ private final InventoryRepository inventoryRepository;
+
+ @Override
+ public void run(String... args) {
+ if (inventoryRepository.count() > 0) {
+ log.info("재고 데이터가 이미 존재합니다. 초기화 스킵");
+ return;
+ }
+
+ log.info("📦 초기 재고 데이터 생성 중...");
+
+ // Product Service와 매칭되는 productId 사용
+ inventoryRepository.save(new Inventory(1L, 10)); // MacBook Pro 16
+ inventoryRepository.save(new Inventory(2L, 50)); // iPhone 15 Pro
+ inventoryRepository.save(new Inventory(3L, 30)); // Galaxy S24 Ultra
+ inventoryRepository.save(new Inventory(4L, 100)); // 나이키 에어맥스
+ inventoryRepository.save(new Inventory(5L, 80)); // 리바이스 501 진
+ inventoryRepository.save(new Inventory(6L, 40)); // 설화수 자음생 에센스
+ inventoryRepository.save(new Inventory(7L, 150)); // 클린 코드
+ inventoryRepository.save(new Inventory(8L, 120)); // 이펙티브 자바
+ inventoryRepository.save(new Inventory(9L, 25)); // 윌슨 테니스 라켓
+ inventoryRepository.save(new Inventory(10L, 15)); // 다이슨 무선 청소기
+
+ log.info("✅ 초기 재고 데이터 생성 완료 (Product Service와 매칭)");
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/config/RedisConfig.java b/inventory-service/src/main/java/com/example/inventory/config/RedisConfig.java
new file mode 100644
index 0000000..8ef9bd2
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/config/RedisConfig.java
@@ -0,0 +1,41 @@
+package com.example.inventory.config;
+
+import org.redisson.Redisson;
+import org.redisson.api.RedissonClient;
+import org.redisson.config.Config;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Redis + Redisson 설정
+ * - Redisson: Redis 기반 분산 락 구현체
+ */
+@Configuration
+public class RedisConfig {
+
+ @Value("${spring.data.redis.host:localhost}")
+ private String redisHost;
+
+ @Value("${spring.data.redis.port:6379}")
+ private int redisPort;
+
+ /**
+ * Redisson 클라이언트 Bean 생성
+ */
+ @Bean
+ public RedissonClient redissonClient() {
+ Config config = new Config();
+ config.useSingleServer()
+ .setAddress("redis://" + redisHost + ":" + redisPort)
+ .setConnectionMinimumIdleSize(10)
+ .setConnectionPoolSize(20)
+ .setIdleConnectionTimeout(10000)
+ .setConnectTimeout(10000)
+ .setTimeout(3000)
+ .setRetryAttempts(3)
+ .setRetryInterval(1500);
+
+ return Redisson.create(config);
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/entity/Inventory.java b/inventory-service/src/main/java/com/example/inventory/entity/Inventory.java
new file mode 100644
index 0000000..7ab64f6
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/entity/Inventory.java
@@ -0,0 +1,59 @@
+package com.example.inventory.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 재고 엔티티
+ */
+@Entity
+@Table(name = "inventory")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class Inventory {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * 상품 ID (Product Service 참조, unique)
+ */
+ @Column(nullable = false, unique = true)
+ private Long productId;
+
+ /**
+ * 재고 수량
+ */
+ @Column(nullable = false)
+ private Integer quantity;
+
+ public Inventory(Long productId, Integer quantity) {
+ this.productId = productId;
+ this.quantity = quantity;
+ }
+
+ /**
+ * 재고 차감
+ * @param amount 차감할 수량
+ * @return 성공 여부
+ */
+ public boolean reserve(Integer amount) {
+ if (this.quantity >= amount) {
+ this.quantity -= amount;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 재고 복구 (보상 트랜잭션)
+ * @param amount 복구할 수량
+ */
+ public void release(Integer amount) {
+ this.quantity += amount;
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/exception/InsufficientInventoryException.java b/inventory-service/src/main/java/com/example/inventory/exception/InsufficientInventoryException.java
new file mode 100644
index 0000000..1125b00
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/exception/InsufficientInventoryException.java
@@ -0,0 +1,20 @@
+package com.example.inventory.exception;
+
+import com.example.common.exception.BusinessException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 재고가 부족할 때 발생하는 예외
+ */
+public class InsufficientInventoryException extends BusinessException {
+
+ public InsufficientInventoryException(Long productId, Integer requested, Integer available) {
+ super(ErrorCode.INVENTORY_NOT_ENOUGH,
+ String.format("재고가 부족합니다. 상품 ID: %d, 요청: %d, 현재: %d",
+ productId, requested, available));
+ }
+
+ public InsufficientInventoryException(String message) {
+ super(ErrorCode.INVENTORY_NOT_ENOUGH, message);
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/exception/InventoryNotFoundException.java b/inventory-service/src/main/java/com/example/inventory/exception/InventoryNotFoundException.java
new file mode 100644
index 0000000..256f605
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/exception/InventoryNotFoundException.java
@@ -0,0 +1,18 @@
+package com.example.inventory.exception;
+
+import com.example.common.exception.EntityNotFoundException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 재고 정보를 찾을 수 없을 때 발생하는 예외
+ */
+public class InventoryNotFoundException extends EntityNotFoundException {
+
+ public InventoryNotFoundException(Long productId) {
+ super(ErrorCode.INVENTORY_NOT_FOUND, "상품 재고를 찾을 수 없습니다. 상품 ID: " + productId);
+ }
+
+ public InventoryNotFoundException(String message) {
+ super(ErrorCode.INVENTORY_NOT_FOUND, message);
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/kafka/InventoryEventProducer.java b/inventory-service/src/main/java/com/example/inventory/kafka/InventoryEventProducer.java
new file mode 100644
index 0000000..9b062d3
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/kafka/InventoryEventProducer.java
@@ -0,0 +1,81 @@
+package com.example.inventory.kafka;
+
+import com.example.common.event.InventoryReservationFailedEvent;
+import com.example.common.event.InventoryReservedEvent;
+import com.example.common.event.OrderCreatedEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Inventory 이벤트 발행
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class InventoryEventProducer {
+
+ private final KafkaTemplate kafkaTemplate;
+ private static final String TOPIC = "inventory-events";
+
+ /**
+ * 재고 확보 성공 이벤트 발행
+ */
+ public void publishInventoryReserved(OrderCreatedEvent orderEvent) {
+ InventoryReservedEvent event = InventoryReservedEvent.builder()
+ .orderId(orderEvent.getOrderId())
+ .productId(orderEvent.getProductId())
+ .productName(orderEvent.getProductName())
+ .quantity(orderEvent.getQuantity())
+ .totalPrice(orderEvent.getTotalPrice())
+ .reservedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 재고 확보 성공 이벤트 발행 - orderId: {}, productId: {}, topic: {}",
+ event.getOrderId(), event.getProductId(), TOPIC);
+
+ CompletableFuture> future =
+ kafkaTemplate.send(TOPIC, event.getOrderId().toString(), event);
+
+ future.whenComplete((result, ex) -> {
+ if (ex == null) {
+ log.info("✅ [Kafka Producer] 재고 확보 성공 이벤트 발행 완료 - orderId: {}", event.getOrderId());
+ } else {
+ log.error("❌ [Kafka Producer] 이벤트 발행 실패 - orderId: {}", event.getOrderId(), ex);
+ }
+ });
+ }
+
+ /**
+ * 재고 확보 실패 이벤트 발행
+ */
+ public void publishInventoryReservationFailed(Long orderId, Long productId,
+ Integer requestedQuantity, Integer availableQuantity) {
+ InventoryReservationFailedEvent event = InventoryReservationFailedEvent.builder()
+ .orderId(orderId)
+ .productId(productId)
+ .requestedQuantity(requestedQuantity)
+ .availableQuantity(availableQuantity)
+ .reason("재고 부족")
+ .failedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 재고 확보 실패 이벤트 발행 - orderId: {}, productId: {}, topic: {}",
+ event.getOrderId(), event.getProductId(), TOPIC);
+
+ CompletableFuture> future =
+ kafkaTemplate.send(TOPIC, event.getOrderId().toString(), event);
+
+ future.whenComplete((result, ex) -> {
+ if (ex == null) {
+ log.info("✅ [Kafka Producer] 재고 확보 실패 이벤트 발행 완료 - orderId: {}", event.getOrderId());
+ } else {
+ log.error("❌ [Kafka Producer] 이벤트 발행 실패 - orderId: {}", event.getOrderId(), ex);
+ }
+ });
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/kafka/OrderEventConsumer.java b/inventory-service/src/main/java/com/example/inventory/kafka/OrderEventConsumer.java
new file mode 100644
index 0000000..a94d3f5
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/kafka/OrderEventConsumer.java
@@ -0,0 +1,81 @@
+package com.example.inventory.kafka;
+
+import com.example.common.event.OrderCreatedEvent;
+import com.example.common.event.PaymentFailedEvent;
+import com.example.inventory.service.InventoryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * Order/Payment 이벤트 구독
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderEventConsumer {
+
+ private final InventoryService inventoryService;
+ private final InventoryEventProducer inventoryEventProducer;
+
+ /**
+ * 주문 생성 이벤트 수신 → 재고 확보 시도
+ *
+ * 개선사항:
+ * - DB/Redis 연결 실패 시 자동 재시도 (ErrorHandler)
+ * - 비즈니스 실패(재고 부족)는 명시적 처리
+ */
+ @KafkaListener(
+ topics = "order-events",
+ groupId = "inventory-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void handleOrderCreated(OrderCreatedEvent event) {
+ log.info("📩 [Kafka Consumer] 주문 생성 이벤트 수신 - orderId: {}, productId: {}, quantity: {}",
+ event.getOrderId(), event.getProductId(), event.getQuantity());
+
+ // 분산 락 획득 실패나 DB 연결 실패 시 자동 재시도
+ boolean success = inventoryService.reserveInventory(
+ event.getProductId(),
+ event.getQuantity()
+ );
+
+ if (success) {
+ // 재고 확보 성공 → 트랜잭션 커밋 후 이벤트 발행
+ inventoryEventProducer.publishInventoryReserved(event);
+ } else {
+ // 재고 부족 (비즈니스 실패) → 실패 이벤트 발행
+ int availableQuantity = inventoryService.getAvailableQuantity(event.getProductId());
+ inventoryEventProducer.publishInventoryReservationFailed(
+ event.getOrderId(),
+ event.getProductId(),
+ event.getQuantity(),
+ availableQuantity
+ );
+ }
+ }
+
+ /**
+ * 결제 실패 이벤트 수신 → 재고 복구 (보상 트랜잭션)
+ *
+ * 보상 트랜잭션은 반드시 성공해야 하므로 자동 재시도 적용
+ */
+ @KafkaListener(
+ topics = "payment-events",
+ groupId = "inventory-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void handlePaymentFailed(PaymentFailedEvent event) {
+ log.info("📩 [Kafka Consumer] 결제 실패 이벤트 수신 - orderId: {}, 재고 복구 시작",
+ event.getOrderId());
+
+ // 재고 복구 실패 시 자동 재시도 (보상 트랜잭션은 반드시 성공해야 함)
+ inventoryService.releaseInventory(
+ event.getProductId(),
+ event.getQuantity()
+ );
+
+ log.info("✅ [보상 트랜잭션] 재고 복구 완료 - orderId: {}", event.getOrderId());
+ }
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/repository/InventoryRepository.java b/inventory-service/src/main/java/com/example/inventory/repository/InventoryRepository.java
new file mode 100644
index 0000000..c409112
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/repository/InventoryRepository.java
@@ -0,0 +1,16 @@
+package com.example.inventory.repository;
+
+import com.example.inventory.entity.Inventory;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface InventoryRepository extends JpaRepository {
+
+ /**
+ * 상품 ID로 재고 조회
+ */
+ Optional findByProductId(Long productId);
+}
diff --git a/inventory-service/src/main/java/com/example/inventory/service/InventoryService.java b/inventory-service/src/main/java/com/example/inventory/service/InventoryService.java
new file mode 100644
index 0000000..6e29046
--- /dev/null
+++ b/inventory-service/src/main/java/com/example/inventory/service/InventoryService.java
@@ -0,0 +1,74 @@
+package com.example.inventory.service;
+
+import com.example.inventory.annotation.DistributedLock;
+import com.example.inventory.exception.InventoryNotFoundException;
+import com.example.inventory.entity.Inventory;
+import com.example.inventory.repository.InventoryRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class InventoryService {
+
+ private final InventoryRepository inventoryRepository;
+
+ /**
+ * 재고 확보 (차감)
+ * - Redis 분산 락 적용으로 동시성 제어
+ * @return 성공 여부
+ */
+ @DistributedLock(key = "inventory:lock:#productId", waitTime = 5, leaseTime = 3)
+ @Transactional
+ public boolean reserveInventory(Long productId, Integer quantity) {
+ log.info("[Inventory Service] 재고 확보 요청 - productId: {}, quantity: {}",
+ productId, quantity);
+
+ Inventory inventory = inventoryRepository.findByProductId(productId)
+ .orElseThrow(() -> new InventoryNotFoundException(productId));
+
+ boolean success = inventory.reserve(quantity);
+
+ if (success) {
+ inventoryRepository.save(inventory);
+ log.info("✅ [Inventory Service] 재고 확보 성공 - productId: {}, 남은 재고: {}",
+ productId, inventory.getQuantity());
+ } else {
+ log.warn("⚠️ [Inventory Service] 재고 부족 - productId: {}, 요청: {}, 현재: {}",
+ productId, quantity, inventory.getQuantity());
+ }
+
+ return success;
+ }
+
+ /**
+ * 재고 복구 (보상 트랜잭션)
+ */
+ @Transactional
+ public void releaseInventory(Long productId, Integer quantity) {
+ log.info("🔄 [Inventory Service] 재고 복구 (보상 트랜잭션) - productId: {}, quantity: {}",
+ productId, quantity);
+
+ Inventory inventory = inventoryRepository.findByProductId(productId)
+ .orElseThrow(() -> new InventoryNotFoundException(productId));
+
+ inventory.release(quantity);
+ inventoryRepository.save(inventory);
+
+ log.info("✅ [Inventory Service] 재고 복구 완료 - productId: {}, 현재 재고: {}",
+ productId, inventory.getQuantity());
+ }
+
+ /**
+ * 재고 조회
+ */
+ @Transactional(readOnly = true)
+ public Integer getAvailableQuantity(Long productId) {
+ return inventoryRepository.findByProductId(productId)
+ .map(Inventory::getQuantity)
+ .orElse(0);
+ }
+}
diff --git a/inventory-service/src/main/resources/application.yml b/inventory-service/src/main/resources/application.yml
new file mode 100644
index 0000000..9d79ec1
--- /dev/null
+++ b/inventory-service/src/main/resources/application.yml
@@ -0,0 +1,62 @@
+spring:
+ application:
+ name: inventory-service
+
+ # Database (H2 로컬 개발용)
+ datasource:
+ url: jdbc:h2:mem:inventorydb
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+
+ # Redis (분산 락)
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+
+ # Kafka
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: inventory-service-group
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
+ properties:
+ spring.json.trusted.packages: "*"
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+
+# Server
+server:
+ port: 8084
+
+# Zipkin (분산 추적)
+management:
+ tracing:
+ sampling:
+ probability: 1.0
+ zipkin:
+ tracing:
+ endpoint: ${ZIPKIN_URL:http://localhost:9411/api/v2/spans}
+
+# Logging
+logging:
+ level:
+ com.example.inventory: DEBUG
+ org.springframework.kafka: INFO
diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile
new file mode 100644
index 0000000..1b18f93
--- /dev/null
+++ b/notification-service/Dockerfile
@@ -0,0 +1,12 @@
+# Multi-stage build
+FROM eclipse-temurin:21-jdk AS build
+WORKDIR /app
+COPY . .
+RUN chmod +x gradlew
+RUN ./gradlew :notification-service:build -x test --no-daemon
+
+FROM eclipse-temurin:21-jre
+WORKDIR /app
+COPY --from=build /app/notification-service/build/libs/*.jar app.jar
+EXPOSE 8083
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/notification-service/build.gradle b/notification-service/build.gradle
new file mode 100644
index 0000000..2a48c75
--- /dev/null
+++ b/notification-service/build.gradle
@@ -0,0 +1,28 @@
+plugins {
+ id 'org.springframework.boot' version '3.1.5'
+}
+
+dependencies {
+ implementation project(':common')
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+
+ // Kafka
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // Eureka Client (Service Discovery)
+ implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
+
+ // Config Client
+ implementation 'org.springframework.cloud:spring-cloud-starter-config'
+
+ // Actuator (Health check & Metrics)
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+
+ // Micrometer Tracing (Zipkin 연동)
+ implementation 'io.micrometer:micrometer-tracing-bridge-brave'
+ implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
+
+ // Database
+ runtimeOnly 'com.h2database:h2' // 로컬 개발용
+}
diff --git a/notification-service/src/main/java/com/example/notification/NotificationServiceApplication.java b/notification-service/src/main/java/com/example/notification/NotificationServiceApplication.java
new file mode 100644
index 0000000..ca68d0d
--- /dev/null
+++ b/notification-service/src/main/java/com/example/notification/NotificationServiceApplication.java
@@ -0,0 +1,21 @@
+package com.example.notification;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.kafka.annotation.EnableKafka;
+
+/**
+ * Notification Service
+ * - Kafka에서 주문 이벤트를 구독하여 알림 발송
+ * - DB 없음 (stateless service)
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@EnableKafka
+public class NotificationServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(NotificationServiceApplication.class, args);
+ }
+
+}
diff --git a/notification-service/src/main/java/com/example/notification/kafka/DeliveryEventConsumer.java b/notification-service/src/main/java/com/example/notification/kafka/DeliveryEventConsumer.java
new file mode 100644
index 0000000..172621d
--- /dev/null
+++ b/notification-service/src/main/java/com/example/notification/kafka/DeliveryEventConsumer.java
@@ -0,0 +1,53 @@
+package com.example.notification.kafka;
+
+import com.example.common.event.*;
+import com.example.notification.service.NotificationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 배송 이벤트 Kafka Consumer
+ * - delivery-events 토픽을 구독
+ * - 배송 시작/완료/실패 이벤트 처리
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeliveryEventConsumer {
+
+ private final NotificationService notificationService;
+
+ /**
+ * 배송 이벤트 처리 (시작/완료/실패)
+ */
+ @KafkaListener(
+ topics = "delivery-events",
+ groupId = "notification-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void handleDeliveryEvent(Object event) {
+ try {
+ if (event instanceof DeliveryStartedEvent startedEvent) {
+ log.info("📩 [Kafka Consumer] 배송 시작 이벤트 수신 - orderId: {}, deliveryId: {}",
+ startedEvent.getOrderId(), startedEvent.getDeliveryId());
+ notificationService.sendDeliveryStartedNotification(startedEvent);
+
+ } else if (event instanceof DeliveryCompletedEvent completedEvent) {
+ log.info("📩 [Kafka Consumer] 배송 완료 이벤트 수신 - orderId: {}",
+ completedEvent.getOrderId());
+ notificationService.sendDeliveryCompletedNotification(completedEvent);
+
+ } else if (event instanceof DeliveryFailedEvent failedEvent) {
+ log.info("📩 [Kafka Consumer] 배송 실패 이벤트 수신 - orderId: {}",
+ failedEvent.getOrderId());
+ notificationService.sendDeliveryFailedNotification(failedEvent);
+ }
+
+ log.info("✅ [Kafka Consumer] 알림 발송 완료");
+ } catch (Exception e) {
+ log.error("❌ [Kafka Consumer] 알림 발송 실패", e);
+ }
+ }
+}
diff --git a/notification-service/src/main/java/com/example/notification/kafka/OrderEventConsumer.java b/notification-service/src/main/java/com/example/notification/kafka/OrderEventConsumer.java
new file mode 100644
index 0000000..542e16c
--- /dev/null
+++ b/notification-service/src/main/java/com/example/notification/kafka/OrderEventConsumer.java
@@ -0,0 +1,46 @@
+package com.example.notification.kafka;
+
+import com.example.common.event.OrderCreatedEvent;
+import com.example.notification.service.NotificationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 주문 이벤트 Kafka Consumer
+ * - order-events 토픽을 구독
+ * - 주문 생성 이벤트 수신 시 NotificationService 호출
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderEventConsumer {
+
+ private final NotificationService notificationService;
+
+ /**
+ * 주문 생성 이벤트 처리
+ * @param event 주문 생성 이벤트
+ */
+ @KafkaListener(
+ topics = "order-events",
+ groupId = "notification-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void handleOrderCreated(OrderCreatedEvent event) {
+ log.info("📩 [Kafka Consumer] 주문 이벤트 수신: orderId={}, userId={}, product={}",
+ event.getOrderId(), event.getUserId(), event.getProductName());
+
+ try {
+ // 알림 발송
+ notificationService.sendOrderNotification(event);
+ log.info("✅ [Kafka Consumer] 알림 발송 완료: orderId={}", event.getOrderId());
+ } catch (Exception e) {
+ log.error("❌ [Kafka Consumer] 알림 발송 실패: orderId={}, error={}",
+ event.getOrderId(), e.getMessage(), e);
+ // Phase 3에서 DLQ 처리 추가 예정
+ throw e; // Kafka Retry 트리거
+ }
+ }
+}
diff --git a/notification-service/src/main/java/com/example/notification/service/NotificationService.java b/notification-service/src/main/java/com/example/notification/service/NotificationService.java
new file mode 100644
index 0000000..f2d5b96
--- /dev/null
+++ b/notification-service/src/main/java/com/example/notification/service/NotificationService.java
@@ -0,0 +1,37 @@
+package com.example.notification.service;
+
+import com.example.common.event.OrderCreatedEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+/**
+ * 알림 발송 서비스
+ * - Phase 1: 로그 출력 (시뮬레이션)
+ * - 향후 확장: 이메일/SMS 발송, Push 알림 등
+ */
+@Slf4j
+@Service
+public class NotificationService {
+
+ /**
+ * 주문 생성 알림 발송
+ * @param event 주문 생성 이벤트
+ */
+ public void sendOrderNotification(OrderCreatedEvent event) {
+ log.info("📧 ========== 알림 발송 시작 ==========");
+ log.info("📧 [알림] 주문이 생성되었습니다!");
+ log.info("📧 주문 ID: {}", event.getOrderId());
+ log.info("📧 사용자 ID: {}", event.getUserId());
+ log.info("📧 상품명: {}", event.getProductName());
+ log.info("📧 수량: {}", event.getQuantity());
+ log.info("📧 가격: {}원", event.getPrice());
+ log.info("📧 주문 시각: {}", event.getCreatedAt());
+ log.info("📧 ========== 알림 발송 완료 ==========");
+
+ // TODO: Phase 2+ 확장 사항
+ // - 이메일 발송: emailService.send(...)
+ // - SMS 발송: smsService.send(...)
+ // - Push 알림: pushService.send(...)
+ // - DB에 알림 이력 저장 (선택)
+ }
+}
diff --git a/notification-service/src/main/resources/application.yml b/notification-service/src/main/resources/application.yml
new file mode 100644
index 0000000..dcc1c1c
--- /dev/null
+++ b/notification-service/src/main/resources/application.yml
@@ -0,0 +1,67 @@
+spring:
+ application:
+ name: notification-service
+ config:
+ import: optional:configserver:http://localhost:8888
+
+ datasource:
+ url: jdbc:h2:mem:notificationdb
+ driverClassName: org.h2.Driver
+ username: sa
+ password:
+
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
+
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: notification-service-group
+ auto-offset-reset: earliest # 처음부터 읽기 (개발용)
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
+ properties:
+ spring.json.trusted.packages: "*" # 모든 패키지 역직렬화 허용 (개발용)
+ spring.json.type.mapping: orderCreatedEvent:com.example.common.event.OrderCreatedEvent
+
+server:
+ port: 8083
+
+# Eureka Client
+eureka:
+ client:
+ enabled: false # 로컬 개발 시 Eureka 비활성화
+ service-url:
+ defaultZone: ${EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE:http://localhost:8761/eureka/}
+ fetch-registry: true
+ register-with-eureka: true
+ instance:
+ prefer-ip-address: true
+
+# Actuator (Health check)
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health, info, metrics, prometheus
+ endpoint:
+ health:
+ show-details: always
+ tracing:
+ sampling:
+ probability: 1.0 # Zipkin 샘플링 100% (개발용)
+ zipkin:
+ tracing:
+ endpoint: ${ZIPKIN_ENDPOINT:http://localhost:9411/api/v2/spans}
+
+logging:
+ level:
+ com.example.notification: DEBUG
+ org.springframework.kafka: INFO
diff --git a/order-service/build.gradle b/order-service/build.gradle
index a707edb..33549b3 100644
--- a/order-service/build.gradle
+++ b/order-service/build.gradle
@@ -14,5 +14,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator' // Health check
implementation 'io.github.openfeign:feign-micrometer' // OpenFeign + Micrometer Tracing 통합
- runtimeOnly 'com.mysql:mysql-connector-j'
+ // Kafka Producer
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // Database
+ runtimeOnly 'com.h2database:h2' // 로컬 개발용
+ runtimeOnly 'com.mysql:mysql-connector-j' // Docker/운영용
}
\ No newline at end of file
diff --git a/order-service/src/main/java/com/example/order/client/ProductClient.java b/order-service/src/main/java/com/example/order/client/ProductClient.java
new file mode 100644
index 0000000..41a5fed
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/client/ProductClient.java
@@ -0,0 +1,20 @@
+package com.example.order.client;
+
+import com.example.order.dto.ProductResponse;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+
+/**
+ * Product Service Feign Client
+ */
+@FeignClient(name = "product-service", url = "${product.service.url:http://localhost:8087}")
+public interface ProductClient {
+
+ /**
+ * 상품 ID로 조회
+ */
+ @GetMapping("/products/{id}")
+ ProductResponse getProductById(@PathVariable Long id);
+}
diff --git a/order-service/src/main/java/com/example/order/client/UserClient.java b/order-service/src/main/java/com/example/order/client/UserClient.java
index 7309919..5764aa7 100644
--- a/order-service/src/main/java/com/example/order/client/UserClient.java
+++ b/order-service/src/main/java/com/example/order/client/UserClient.java
@@ -5,7 +5,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
-@FeignClient(name = "user-service")
+@FeignClient(name = "user-service", url = "${user-service.url:http://localhost:8081}")
public interface UserClient {
@GetMapping("/api/users/{id}")
diff --git a/order-service/src/main/java/com/example/order/controller/OrderController.java b/order-service/src/main/java/com/example/order/controller/OrderController.java
index 1a57df6..99a2459 100644
--- a/order-service/src/main/java/com/example/order/controller/OrderController.java
+++ b/order-service/src/main/java/com/example/order/controller/OrderController.java
@@ -27,17 +27,21 @@ public ResponseEntity createOrder(@RequestBody CreateOrderRequest request
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
+ /**
+ * 주문 조회 API
+ * - userId 없으면: 전체 주문 조회
+ * - userId 있으면: 해당 사용자의 주문 + 사용자 정보 조회 (OpenFeign + Kafka)
+ */
@GetMapping
- public ResponseEntity> getAllOrders() {
- log.info("[Order Controller] 전체 주문 조회");
- List orders = orderService.getAllOrders();
- return ResponseEntity.ok(orders);
- }
-
- @GetMapping
- public ResponseEntity> getOrdersByUserId( Long userId) {
- log.info("[Order Controller] userId로 주문 조회 (User 정보 포함) - userId: {}", userId);
- List ordersWithUser = orderService.getOrdersWithUserInfo(userId);
- return ResponseEntity.ok(ordersWithUser);
+ public ResponseEntity> getOrders(@RequestParam(required = false) Long userId) {
+ if (userId == null) {
+ log.info("[Order Controller] 전체 주문 조회");
+ List orders = orderService.getAllOrders();
+ return ResponseEntity.ok(orders);
+ } else {
+ log.info("[Order Controller] userId로 주문 조회 (User 정보 포함) - userId: {}", userId);
+ List ordersWithUser = orderService.getOrdersWithUserInfo(userId);
+ return ResponseEntity.ok(ordersWithUser);
+ }
}
}
\ No newline at end of file
diff --git a/order-service/src/main/java/com/example/order/dto/ProductResponse.java b/order-service/src/main/java/com/example/order/dto/ProductResponse.java
new file mode 100644
index 0000000..aa48129
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/dto/ProductResponse.java
@@ -0,0 +1,25 @@
+package com.example.order.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * Product Service 응답 DTO
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProductResponse {
+ private Long id;
+ private String name;
+ private String description;
+ private BigDecimal price;
+ private String category;
+ private String categoryDisplayName;
+ private String brand;
+ private String imageUrl;
+ private Boolean active;
+}
diff --git a/order-service/src/main/java/com/example/order/entity/Order.java b/order-service/src/main/java/com/example/order/entity/Order.java
index 642e6e2..da2a3d6 100644
--- a/order-service/src/main/java/com/example/order/entity/Order.java
+++ b/order-service/src/main/java/com/example/order/entity/Order.java
@@ -19,6 +19,9 @@ public class Order extends BaseEntity {
@Column(nullable = false)
private Long userId;
+ @Column(nullable = false)
+ private Long productId;
+
@Column(nullable = false)
private String productName;
@@ -32,8 +35,9 @@ public class Order extends BaseEntity {
@Column(nullable = false)
private OrderStatus status = OrderStatus.PENDING;
- public Order(Long userId, String productName, Integer quantity, BigDecimal price) {
+ public Order(Long userId, Long productId, String productName, Integer quantity, BigDecimal price) {
this.userId = userId;
+ this.productId = productId;
this.productName = productName;
this.quantity = quantity;
this.price = price;
diff --git a/order-service/src/main/java/com/example/order/exception/OrderAlreadyCancelledException.java b/order-service/src/main/java/com/example/order/exception/OrderAlreadyCancelledException.java
new file mode 100644
index 0000000..52f7787
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/exception/OrderAlreadyCancelledException.java
@@ -0,0 +1,15 @@
+package com.example.order.exception;
+
+import com.example.common.exception.BusinessException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 이미 취소된 주문을 다시 취소하려 할 때 발생하는 예외
+ */
+public class OrderAlreadyCancelledException extends BusinessException {
+
+ public OrderAlreadyCancelledException(Long orderId) {
+ super(ErrorCode.ORDER_ALREADY_CANCELLED,
+ "이미 취소된 주문입니다: " + orderId);
+ }
+}
diff --git a/order-service/src/main/java/com/example/order/exception/OrderCannotCancelException.java b/order-service/src/main/java/com/example/order/exception/OrderCannotCancelException.java
new file mode 100644
index 0000000..976b9a4
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/exception/OrderCannotCancelException.java
@@ -0,0 +1,20 @@
+package com.example.order.exception;
+
+import com.example.common.exception.BusinessException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 취소할 수 없는 주문 상태일 때 발생하는 예외
+ * (예: 이미 배송이 시작된 경우)
+ */
+public class OrderCannotCancelException extends BusinessException {
+
+ public OrderCannotCancelException(Long orderId, String currentStatus) {
+ super(ErrorCode.ORDER_CANNOT_CANCEL,
+ "취소할 수 없는 주문 상태입니다. 주문 ID: " + orderId + ", 현재 상태: " + currentStatus);
+ }
+
+ public OrderCannotCancelException(String message) {
+ super(ErrorCode.ORDER_CANNOT_CANCEL, message);
+ }
+}
diff --git a/order-service/src/main/java/com/example/order/exception/OrderNotFoundException.java b/order-service/src/main/java/com/example/order/exception/OrderNotFoundException.java
new file mode 100644
index 0000000..65ca8c9
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/exception/OrderNotFoundException.java
@@ -0,0 +1,18 @@
+package com.example.order.exception;
+
+import com.example.common.exception.EntityNotFoundException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 주문을 찾을 수 없을 때 발생하는 예외
+ */
+public class OrderNotFoundException extends EntityNotFoundException {
+
+ public OrderNotFoundException(Long orderId) {
+ super(ErrorCode.ORDER_NOT_FOUND, "주문을 찾을 수 없습니다: " + orderId);
+ }
+
+ public OrderNotFoundException(String message) {
+ super(ErrorCode.ORDER_NOT_FOUND, message);
+ }
+}
diff --git a/order-service/src/main/java/com/example/order/kafka/OrderEventProducer.java b/order-service/src/main/java/com/example/order/kafka/OrderEventProducer.java
new file mode 100644
index 0000000..332467f
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/kafka/OrderEventProducer.java
@@ -0,0 +1,69 @@
+package com.example.order.kafka;
+
+import com.example.common.event.OrderCreatedEvent;
+import com.example.order.entity.Order;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.support.SendResult;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * 주문 이벤트 Kafka Producer
+ * - 주문 생성 시 order-events 토픽으로 이벤트 발행
+ * - Phase 1: 단순 발행 (비동기)
+ * - Phase 2: Outbox 패턴 적용 예정
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderEventProducer {
+
+ private final KafkaTemplate kafkaTemplate;
+ private static final String TOPIC = "order-events";
+
+ /**
+ * 주문 생성 이벤트 발행
+ * @param order 생성된 주문
+ */
+ public void publishOrderCreated(Order order) {
+ BigDecimal totalPrice = order.getPrice().multiply(BigDecimal.valueOf(order.getQuantity()));
+
+ OrderCreatedEvent event = OrderCreatedEvent.builder()
+ .orderId(order.getId())
+ .userId(order.getUserId())
+ .productId(order.getProductId())
+ .productName(order.getProductName())
+ .quantity(order.getQuantity())
+ .price(order.getPrice())
+ .totalPrice(totalPrice)
+ .createdAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 주문 이벤트 발행 시작 - orderId: {}, topic: {}",
+ order.getId(), TOPIC);
+
+ // 비동기 전송 (CompletableFuture)
+ CompletableFuture> future =
+ kafkaTemplate.send(TOPIC, event.getOrderId().toString(), event);
+
+ // 콜백으로 성공/실패 로깅
+ future.whenComplete((result, ex) -> {
+ if (ex == null) {
+ log.info("✅ [Kafka Producer] 이벤트 발행 성공 - orderId: {}, partition: {}, offset: {}",
+ event.getOrderId(),
+ result.getRecordMetadata().partition(),
+ result.getRecordMetadata().offset());
+ } else {
+ log.error("❌ [Kafka Producer] 이벤트 발행 실패 - orderId: {}, error: {}",
+ event.getOrderId(), ex.getMessage(), ex);
+ // Phase 1: 실패 시 로그만 남김
+ // Phase 2: Outbox 패턴으로 실패 처리
+ }
+ });
+ }
+}
diff --git a/order-service/src/main/java/com/example/order/kafka/SagaEventConsumer.java b/order-service/src/main/java/com/example/order/kafka/SagaEventConsumer.java
new file mode 100644
index 0000000..cccea20
--- /dev/null
+++ b/order-service/src/main/java/com/example/order/kafka/SagaEventConsumer.java
@@ -0,0 +1,141 @@
+package com.example.order.kafka;
+
+import com.example.common.event.*;
+import com.example.order.entity.Order;
+import com.example.order.exception.OrderNotFoundException;
+import com.example.order.repository.OrderRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Saga 이벤트 구독 (Inventory, Payment, Delivery 이벤트)
+ * Order Service는 Saga Orchestrator 역할
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SagaEventConsumer {
+
+ private final OrderRepository orderRepository;
+ private final OrderEventProducer orderEventProducer;
+
+ /**
+ * 재고 확보 실패 이벤트 수신 → 주문 취소
+ */
+ @KafkaListener(
+ topics = "inventory-events",
+ groupId = "order-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ @Transactional
+ public void handleInventoryEvent(Object event) {
+ if (event instanceof InventoryReservationFailedEvent failedEvent) {
+ log.info("📩 [Kafka Consumer] 재고 확보 실패 이벤트 수신 - orderId: {}",
+ failedEvent.getOrderId());
+
+ Order order = orderRepository.findById(failedEvent.getOrderId())
+ .orElseThrow(() -> new OrderNotFoundException(failedEvent.getOrderId()));
+
+ order.cancel(failedEvent.getReason());
+ orderRepository.save(order);
+
+ log.info("❌ [Saga Failed] 주문 취소 완료 - orderId: {}, reason: {}",
+ failedEvent.getOrderId(), failedEvent.getReason());
+
+ // 주문 취소 이벤트 발행 (Notification Service로)
+ orderEventProducer.publishOrderCancelled(order);
+ }
+ }
+
+ /**
+ * 결제 완료/실패 이벤트 수신
+ */
+ @KafkaListener(
+ topics = "payment-events",
+ groupId = "order-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ @Transactional
+ public void handlePaymentEvent(Object event) {
+ if (event instanceof PaymentCompletedEvent completedEvent) {
+ log.info("📩 [Kafka Consumer] 결제 완료 이벤트 수신 - orderId: {}, paymentId: {}",
+ completedEvent.getOrderId(), completedEvent.getPaymentId());
+
+ Order order = orderRepository.findById(completedEvent.getOrderId())
+ .orElseThrow(() -> new OrderNotFoundException(completedEvent.getOrderId()));
+
+ order.markPaymentCompleted(completedEvent.getPaymentId());
+ orderRepository.save(order);
+
+ log.info("✅ [결제 완료] orderId: {}, 다음: 배송 시작 대기", completedEvent.getOrderId());
+
+ } else if (event instanceof PaymentFailedEvent failedEvent) {
+ log.info("📩 [Kafka Consumer] 결제 실패 이벤트 수신 - orderId: {}",
+ failedEvent.getOrderId());
+
+ Order order = orderRepository.findById(failedEvent.getOrderId())
+ .orElseThrow(() -> new OrderNotFoundException(failedEvent.getOrderId()));
+
+ order.cancel(failedEvent.getReason());
+ orderRepository.save(order);
+
+ log.info("❌ [Saga Failed] 주문 취소 완료 - orderId: {}, reason: {}",
+ failedEvent.getOrderId(), failedEvent.getReason());
+
+ // 주문 취소 이벤트 발행 (Notification Service로)
+ orderEventProducer.publishOrderCancelled(order);
+ }
+ }
+
+ /**
+ * 배송 시작/완료/실패 이벤트 수신
+ */
+ @KafkaListener(
+ topics = "delivery-events",
+ groupId = "order-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ @Transactional
+ public void handleDeliveryEvent(Object event) {
+ if (event instanceof DeliveryStartedEvent startedEvent) {
+ log.info("📩 [Kafka Consumer] 배송 시작 이벤트 수신 - orderId: {}, deliveryId: {}",
+ startedEvent.getOrderId(), startedEvent.getDeliveryId());
+
+ Order order = orderRepository.findById(startedEvent.getOrderId())
+ .orElseThrow(() -> new OrderNotFoundException("주문을 찾을 수 없습니다"));
+
+ order.markDeliveryStarted(startedEvent.getDeliveryId());
+ orderRepository.save(order);
+
+ log.info("🚚 [배송 시작] orderId: {}, deliveryId: {}",
+ startedEvent.getOrderId(), startedEvent.getDeliveryId());
+
+ } else if (event instanceof DeliveryCompletedEvent completedEvent) {
+ log.info("📩 [Kafka Consumer] 배송 완료 이벤트 수신 - orderId: {}",
+ completedEvent.getOrderId());
+
+ Order order = orderRepository.findById(completedEvent.getOrderId())
+ .orElseThrow(() -> new OrderNotFoundException("주문을 찾을 수 없습니다"));
+
+ order.markDelivered();
+ order.complete(); // 최종 완료
+ orderRepository.save(order);
+
+ log.info("✅ [Saga Success] 주문 최종 완료 - orderId: {}", completedEvent.getOrderId());
+
+ // 주문 완료 이벤트 발행 (Notification Service로)
+ orderEventProducer.publishOrderCompleted(order);
+
+ } else if (event instanceof DeliveryFailedEvent failedEvent) {
+ log.info("📩 [Kafka Consumer] 배송 실패 이벤트 수신 - orderId: {}",
+ failedEvent.getOrderId());
+
+ // 배송 실패는 고객센터 처리 (주문은 유지)
+ log.warn("⚠️ [배송 실패] orderId: {}, reason: {} - 고객센터 처리 필요",
+ failedEvent.getOrderId(), failedEvent.getReason());
+ }
+ }
+}
diff --git a/order-service/src/main/java/com/example/order/service/OrderService.java b/order-service/src/main/java/com/example/order/service/OrderService.java
index 8b132b2..4157c8b 100644
--- a/order-service/src/main/java/com/example/order/service/OrderService.java
+++ b/order-service/src/main/java/com/example/order/service/OrderService.java
@@ -4,6 +4,7 @@
import com.example.order.dto.OrderWithUserResponse;
import com.example.order.dto.UserResponse;
import com.example.order.entity.Order;
+import com.example.order.kafka.OrderEventProducer;
import com.example.order.repository.OrderRepository;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
@@ -24,6 +25,7 @@ public class OrderService {
private final OrderRepository orderRepository;
private final UserClient userClient;
+ private final OrderEventProducer orderEventProducer; // Kafka Producer 추가
@CircuitBreaker(name = "userClient", fallbackMethod = "createOrderFallback")
@TimeLimiter(name = "userClient")
@@ -31,11 +33,13 @@ public Order createOrder(CreateOrderRequest request) {
// 분산 추적 테스트: User Service 호출하여 사용자 검증
log.info("주문 생성 요청 - userId: {}, productName: {}", request.getUserId(), request.getProductName());
+ // [동기] User Service 호출로 사용자 검증
UserResponse user = userClient.getUserById(request.getUserId());
log.info("사용자 검증 완료 - userId: {}, userName: {}", user.getId(), user.getName());
Order order = new Order(
request.getUserId(),
+ request.getProductId(),
request.getProductName(),
request.getQuantity(),
request.getPrice()
@@ -43,6 +47,9 @@ public Order createOrder(CreateOrderRequest request) {
Order savedOrder = orderRepository.save(order);
log.info("주문 생성 완료 - orderId: {}", savedOrder.getId());
+ // Kafka 이벤트 발행
+ orderEventProducer.publishOrderCreated(savedOrder);
+
return savedOrder;
}
@@ -56,11 +63,17 @@ private Order createOrderFallback(CreateOrderRequest request, Exception ex) {
Order order = new Order(
request.getUserId(),
+ request.getProductId(),
request.getProductName(),
request.getQuantity(),
request.getPrice()
);
- return orderRepository.save(order);
+ Order savedOrder = orderRepository.save(order);
+
+ // Kafka 이벤트 발행 (Fallback에서도 발행)
+ orderEventProducer.publishOrderCreated(savedOrder);
+
+ return savedOrder;
}
public List getAllOrders() {
@@ -111,12 +124,14 @@ private List getOrdersWithUserFallback(Long userId, Excep
@NoArgsConstructor
public static class CreateOrderRequest {
private Long userId;
+ private Long productId;
private String productName;
private Integer quantity;
private BigDecimal price;
- public CreateOrderRequest(Long userId, String productName, Integer quantity, BigDecimal price) {
+ public CreateOrderRequest(Long userId, Long productId, String productName, Integer quantity, BigDecimal price) {
this.userId = userId;
+ this.productId = productId;
this.productName = productName;
this.quantity = quantity;
this.price = price;
diff --git a/order-service/src/main/resources/application-docker.yml b/order-service/src/main/resources/application-docker.yml
index dcf2d8f..8712c58 100644
--- a/order-service/src/main/resources/application-docker.yml
+++ b/order-service/src/main/resources/application-docker.yml
@@ -9,8 +9,13 @@ spring:
server:
port: 8082
+# Service Discovery 설정 (K8s/Docker Compose 환경)
+user-service:
+ url: http://user-service:8081 # Kubernetes Service DNS 또는 Docker Compose service name
+
eureka:
client:
+ enabled: false # K8s 환경에서는 Eureka 불필요
service-url:
defaultZone: http://eureka-server:8761/eureka/
instance:
diff --git a/order-service/src/main/resources/application.yml b/order-service/src/main/resources/application.yml
index 62fde40..2b57f84 100644
--- a/order-service/src/main/resources/application.yml
+++ b/order-service/src/main/resources/application.yml
@@ -20,11 +20,24 @@ spring:
ddl-auto: create-drop
show-sql: true
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+ acks: 1
+ retries: 3
+
server:
port: 8082
+# Service Discovery 설정 (환경별로 변경)
+user-service:
+ url: http://localhost:8081 # 로컬 개발용
+
eureka:
client:
+ enabled: false # 로컬 개발 시 Eureka 비활성화
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
diff --git a/payment-service/build.gradle b/payment-service/build.gradle
new file mode 100644
index 0000000..2b3f413
--- /dev/null
+++ b/payment-service/build.gradle
@@ -0,0 +1,48 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.1.5'
+ id 'io.spring.dependency-management' version '1.1.3'
+}
+
+group = 'com.example'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '21'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ // Spring Boot
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+
+ // Common Module (이벤트 모델 공유)
+ implementation project(':common')
+
+ // Kafka
+ implementation 'org.springframework.kafka:spring-kafka'
+
+ // Database
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'com.mysql:mysql-connector-j'
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok:1.18.34'
+ annotationProcessor 'org.projectlombok:lombok:1.18.34'
+
+ // Micrometer + Zipkin (분산 추적)
+ implementation 'io.micrometer:micrometer-tracing-bridge-brave'
+ implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
+
+ // Test
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.kafka:spring-kafka-test'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/payment-service/src/main/java/com/example/payment/PaymentServiceApplication.java b/payment-service/src/main/java/com/example/payment/PaymentServiceApplication.java
new file mode 100644
index 0000000..a0666d2
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/PaymentServiceApplication.java
@@ -0,0 +1,14 @@
+package com.example.payment;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.kafka.annotation.EnableKafka;
+
+@SpringBootApplication
+@EnableKafka
+public class PaymentServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(PaymentServiceApplication.class, args);
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/config/PaymentGatewayConfig.java b/payment-service/src/main/java/com/example/payment/config/PaymentGatewayConfig.java
new file mode 100644
index 0000000..89ebfc5
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/config/PaymentGatewayConfig.java
@@ -0,0 +1,43 @@
+package com.example.payment.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * PG 설정을 관리하는 Configuration 클래스
+ */
+@Getter
+@Setter
+@Configuration
+@ConfigurationProperties(prefix = "payment.gateway")
+public class PaymentGatewayConfig {
+
+ private String defaultGateway;
+ private TossConfig toss;
+ private KakaoConfig kakao;
+ private NaverConfig naver;
+
+ @Getter
+ @Setter
+ public static class TossConfig {
+ private String secretKey;
+ private String apiUrl;
+ }
+
+ @Getter
+ @Setter
+ public static class KakaoConfig {
+ private String adminKey;
+ private String apiUrl;
+ }
+
+ @Getter
+ @Setter
+ public static class NaverConfig {
+ private String clientId;
+ private String clientSecret;
+ private String apiUrl;
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/controller/PaymentController.java b/payment-service/src/main/java/com/example/payment/controller/PaymentController.java
new file mode 100644
index 0000000..4a0d043
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/controller/PaymentController.java
@@ -0,0 +1,94 @@
+package com.example.payment.controller;
+
+import com.example.payment.entity.Payment;
+import com.example.payment.service.PaymentService;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 결제 API 컨트롤러
+ * 테스트를 위한 REST API 제공
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/payments")
+@RequiredArgsConstructor
+public class PaymentController {
+
+ private final PaymentService paymentService;
+
+ /**
+ * 결제 처리 (기본 PG사 사용)
+ */
+ @PostMapping
+ public ResponseEntity processPayment(@RequestBody PaymentProcessRequest request) {
+ log.info("결제 요청 API 호출 - orderId: {}, amount: {}", request.getOrderId(), request.getAmount());
+
+ Payment payment = paymentService.processPayment(request.getOrderId(), request.getAmount());
+
+ if (payment != null) {
+ return ResponseEntity.ok(new PaymentResult(true, payment.getPaymentId(), "결제 성공", null));
+ } else {
+ return ResponseEntity.ok(new PaymentResult(false, null, "결제 실패", null));
+ }
+ }
+
+ /**
+ * 결제 처리 (PG사 선택)
+ */
+ @PostMapping("/with-pg")
+ public ResponseEntity processPaymentWithPg(@RequestBody PaymentWithPgRequest request) {
+ log.info("결제 요청 API 호출 - orderId: {}, amount: {}, pgType: {}",
+ request.getOrderId(), request.getAmount(), request.getPgType());
+
+ Payment payment = paymentService.processPayment(
+ request.getOrderId(),
+ request.getAmount(),
+ request.getPgType()
+ );
+
+ if (payment != null) {
+ return ResponseEntity.ok(new PaymentResult(true, payment.getPaymentId(), "결제 성공", request.getPgType()));
+ } else {
+ return ResponseEntity.ok(new PaymentResult(false, null, "결제 실패", request.getPgType()));
+ }
+ }
+
+ /**
+ * 결제 취소
+ */
+ @PostMapping("/{orderId}/cancel")
+ public ResponseEntity cancelPayment(@PathVariable Long orderId) {
+ log.info("결제 취소 API 호출 - orderId: {}", orderId);
+ paymentService.cancelPayment(orderId);
+ return ResponseEntity.ok("결제 취소 요청이 처리되었습니다.");
+ }
+
+ @Getter
+ @AllArgsConstructor
+ public static class PaymentProcessRequest {
+ private Long orderId;
+ private Integer amount;
+ }
+
+ @Getter
+ @AllArgsConstructor
+ public static class PaymentWithPgRequest {
+ private Long orderId;
+ private Integer amount;
+ private String pgType; // TOSS_PAYMENTS, KAKAO_PAY, NAVER_PAY
+ }
+
+ @Getter
+ @AllArgsConstructor
+ public static class PaymentResult {
+ private boolean success;
+ private String paymentId;
+ private String message;
+ private String pgType;
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/dto/PaymentRequest.java b/payment-service/src/main/java/com/example/payment/dto/PaymentRequest.java
new file mode 100644
index 0000000..1db92ff
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/dto/PaymentRequest.java
@@ -0,0 +1,21 @@
+package com.example.payment.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * PG 결제 요청 DTO
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class PaymentRequest {
+ private Long orderId;
+ private BigDecimal amount;
+ private String paymentMethod;
+ private String customerName;
+ private String customerEmail;
+}
diff --git a/payment-service/src/main/java/com/example/payment/dto/PaymentResponse.java b/payment-service/src/main/java/com/example/payment/dto/PaymentResponse.java
new file mode 100644
index 0000000..d1df62e
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/dto/PaymentResponse.java
@@ -0,0 +1,21 @@
+package com.example.payment.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * PG 결제 응답 DTO
+ */
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class PaymentResponse {
+ private boolean success;
+ private String paymentId;
+ private String pgTransactionId;
+ private String message;
+ private String pgType;
+}
diff --git a/payment-service/src/main/java/com/example/payment/entity/Payment.java b/payment-service/src/main/java/com/example/payment/entity/Payment.java
new file mode 100644
index 0000000..7686566
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/entity/Payment.java
@@ -0,0 +1,82 @@
+package com.example.payment.entity;
+
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 결제 엔티티
+ */
+@Entity
+@Table(name = "payment")
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class Payment {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * 주문 ID (연관관계)
+ */
+ @Column(nullable = false)
+ private Long orderId;
+
+ /**
+ * 결제 ID (외부 PG사 트랜잭션 ID)
+ */
+ @Column(nullable = false, unique = true)
+ private String paymentId;
+
+ /**
+ * 결제 금액
+ */
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal amount;
+
+ /**
+ * 결제 수단
+ */
+ @Column(nullable = false)
+ private String paymentMethod;
+
+ /**
+ * 결제 상태
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private PaymentStatus status;
+
+ /**
+ * 결제 시각
+ */
+ @Column(nullable = false)
+ private LocalDateTime paymentAt;
+
+ public Payment(Long orderId, String paymentId, BigDecimal amount, String paymentMethod) {
+ this.orderId = orderId;
+ this.paymentId = paymentId;
+ this.amount = amount;
+ this.paymentMethod = paymentMethod;
+ this.status = PaymentStatus.COMPLETED;
+ this.paymentAt = LocalDateTime.now();
+ }
+
+ public enum PaymentStatus {
+ COMPLETED, // 결제 완료
+ CANCELLED // 결제 취소 (보상 트랜잭션)
+ }
+
+ /**
+ * 결제 취소 (보상 트랜잭션)
+ */
+ public void cancel() {
+ this.status = PaymentStatus.CANCELLED;
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/exception/PaymentFailedException.java b/payment-service/src/main/java/com/example/payment/exception/PaymentFailedException.java
new file mode 100644
index 0000000..5d69108
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/exception/PaymentFailedException.java
@@ -0,0 +1,19 @@
+package com.example.payment.exception;
+
+import com.example.common.exception.BusinessException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 결제 처리에 실패했을 때 발생하는 예외
+ */
+public class PaymentFailedException extends BusinessException {
+
+ public PaymentFailedException(String message) {
+ super(ErrorCode.PAYMENT_FAILED, message);
+ }
+
+ public PaymentFailedException(Long orderId, String reason) {
+ super(ErrorCode.PAYMENT_FAILED,
+ "결제 처리에 실패했습니다. 주문 ID: " + orderId + ", 사유: " + reason);
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/exception/PaymentNotFoundException.java b/payment-service/src/main/java/com/example/payment/exception/PaymentNotFoundException.java
new file mode 100644
index 0000000..0c3061e
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/exception/PaymentNotFoundException.java
@@ -0,0 +1,18 @@
+package com.example.payment.exception;
+
+import com.example.common.exception.EntityNotFoundException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 결제 정보를 찾을 수 없을 때 발생하는 예외
+ */
+public class PaymentNotFoundException extends EntityNotFoundException {
+
+ public PaymentNotFoundException(Long orderId) {
+ super(ErrorCode.PAYMENT_NOT_FOUND, "결제 정보를 찾을 수 없습니다. 주문 ID: " + orderId);
+ }
+
+ public PaymentNotFoundException(String message) {
+ super(ErrorCode.PAYMENT_NOT_FOUND, message);
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/exception/UnsupportedPaymentGatewayException.java b/payment-service/src/main/java/com/example/payment/exception/UnsupportedPaymentGatewayException.java
new file mode 100644
index 0000000..cd1233e
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/exception/UnsupportedPaymentGatewayException.java
@@ -0,0 +1,15 @@
+package com.example.payment.exception;
+
+import com.example.common.exception.ErrorCode;
+import com.example.common.exception.InvalidValueException;
+
+/**
+ * 지원하지 않는 PG사를 요청했을 때 발생하는 예외
+ */
+public class UnsupportedPaymentGatewayException extends InvalidValueException {
+
+ public UnsupportedPaymentGatewayException(String gatewayType) {
+ super(ErrorCode.UNSUPPORTED_PAYMENT_GATEWAY,
+ "지원하지 않는 PG 타입입니다: " + gatewayType);
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/factory/PaymentGatewayFactory.java b/payment-service/src/main/java/com/example/payment/factory/PaymentGatewayFactory.java
new file mode 100644
index 0000000..1313c49
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/factory/PaymentGatewayFactory.java
@@ -0,0 +1,53 @@
+package com.example.payment.factory;
+
+import com.example.payment.exception.UnsupportedPaymentGatewayException;
+import com.example.payment.strategy.PaymentGatewayStrategy;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * PG 전략을 선택하는 Factory 클래스
+ * 런타임에 동적으로 PG사를 선택 가능
+ */
+@Component
+@RequiredArgsConstructor
+public class PaymentGatewayFactory {
+
+ private final List strategies;
+ private final Map strategyMap = new HashMap<>();
+
+ /**
+ * 생성자 주입 시 모든 전략을 Map으로 관리
+ */
+ @PostConstruct
+ public void init() {
+ for (PaymentGatewayStrategy strategy : strategies) {
+ strategyMap.put(strategy.getGatewayType(), strategy);
+ }
+ }
+
+ /**
+ * PG 타입에 맞는 전략 반환
+ * @param gatewayType PG 타입 (TOSS_PAYMENTS, KAKAO_PAY, NAVER_PAY)
+ * @return 해당하는 전략 구현체
+ */
+ public PaymentGatewayStrategy getStrategy(String gatewayType) {
+ PaymentGatewayStrategy strategy = strategyMap.get(gatewayType);
+ if (strategy == null) {
+ throw new UnsupportedPaymentGatewayException(gatewayType);
+ }
+ return strategy;
+ }
+
+ /**
+ * 기본 전략 반환 (Toss Payments)
+ */
+ public PaymentGatewayStrategy getDefaultStrategy() {
+ return getStrategy("TOSS_PAYMENTS");
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/kafka/InventoryEventConsumer.java b/payment-service/src/main/java/com/example/payment/kafka/InventoryEventConsumer.java
new file mode 100644
index 0000000..409b324
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/kafka/InventoryEventConsumer.java
@@ -0,0 +1,52 @@
+package com.example.payment.kafka;
+
+import com.example.common.event.InventoryReservedEvent;
+import com.example.payment.entity.Payment;
+import com.example.payment.service.PaymentService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * Inventory 이벤트 구독
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class InventoryEventConsumer {
+
+ private final PaymentService paymentService;
+ private final PaymentEventProducer paymentEventProducer;
+
+ /**
+ * 재고 확보 성공 이벤트 수신 → 결제 처리
+ *
+ * 개선사항:
+ * - try-catch 제거: DB 연결 실패 등은 자동 재시도
+ * - 비즈니스 실패(잔액 부족)는 명시적 처리
+ */
+ @KafkaListener(
+ topics = "inventory-events",
+ groupId = "payment-service-group",
+ containerFactory = "kafkaListenerContainerFactory"
+ )
+ public void handleInventoryReserved(InventoryReservedEvent event) {
+ log.info("📩 [Kafka Consumer] 재고 확보 성공 이벤트 수신 - orderId: {}, 결제 처리 시작",
+ event.getOrderId());
+
+ // DB 예외 발생 시 자동 재시도 (CommonErrorHandler)
+ Payment payment = paymentService.processPayment(
+ event.getOrderId(),
+ event.getTotalPrice()
+ );
+
+ if (payment != null) {
+ // 결제 성공 → 트랜잭션 커밋 후 이벤트 발행
+ paymentEventProducer.publishPaymentCompleted(event, payment);
+ } else {
+ // 결제 실패 (비즈니스 로직) → 보상 트랜잭션 이벤트 발행
+ paymentEventProducer.publishPaymentFailed(event);
+ }
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/kafka/PaymentEventProducer.java b/payment-service/src/main/java/com/example/payment/kafka/PaymentEventProducer.java
new file mode 100644
index 0000000..8c1c246
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/kafka/PaymentEventProducer.java
@@ -0,0 +1,79 @@
+package com.example.payment.kafka;
+
+import com.example.common.event.InventoryReservedEvent;
+import com.example.common.event.PaymentCompletedEvent;
+import com.example.common.event.PaymentFailedEvent;
+import com.example.payment.entity.Payment;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Payment 이벤트 발행
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentEventProducer {
+
+ private final KafkaTemplate kafkaTemplate;
+ private static final String TOPIC = "payment-events";
+
+ /**
+ * 결제 완료 이벤트 발행
+ */
+ public void publishPaymentCompleted(InventoryReservedEvent inventoryEvent, Payment payment) {
+ PaymentCompletedEvent event = PaymentCompletedEvent.builder()
+ .orderId(inventoryEvent.getOrderId())
+ .paymentId(payment.getPaymentId())
+ .amount(payment.getAmount())
+ .paymentMethod(payment.getPaymentMethod())
+ .completedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 결제 완료 이벤트 발행 - orderId: {}, topic: {}",
+ event.getOrderId(), TOPIC);
+
+ CompletableFuture> future =
+ kafkaTemplate.send(TOPIC, event.getOrderId().toString(), event);
+
+ future.whenComplete((result, ex) -> {
+ if (ex == null) {
+ log.info("✅ [Kafka Producer] 결제 완료 이벤트 발행 완료 - orderId: {}", event.getOrderId());
+ } else {
+ log.error("❌ [Kafka Producer] 이벤트 발행 실패 - orderId: {}", event.getOrderId(), ex);
+ }
+ });
+ }
+
+ /**
+ * 결제 실패 이벤트 발행
+ */
+ public void publishPaymentFailed(InventoryReservedEvent inventoryEvent) {
+ PaymentFailedEvent event = PaymentFailedEvent.builder()
+ .orderId(inventoryEvent.getOrderId())
+ .productId(inventoryEvent.getProductId())
+ .quantity(inventoryEvent.getQuantity())
+ .reason("결제 실패: 잔액 부족")
+ .failedAt(LocalDateTime.now())
+ .build();
+
+ log.info("📤 [Kafka Producer] 결제 실패 이벤트 발행 - orderId: {}, productId: {}, topic: {}",
+ event.getOrderId(), event.getProductId(), TOPIC);
+
+ CompletableFuture> future =
+ kafkaTemplate.send(TOPIC, event.getOrderId().toString(), event);
+
+ future.whenComplete((result, ex) -> {
+ if (ex == null) {
+ log.info("✅ [Kafka Producer] 결제 실패 이벤트 발행 완료 - orderId: {}", event.getOrderId());
+ } else {
+ log.error("❌ [Kafka Producer] 이벤트 발행 실패 - orderId: {}", event.getOrderId(), ex);
+ }
+ });
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/repository/PaymentRepository.java b/payment-service/src/main/java/com/example/payment/repository/PaymentRepository.java
new file mode 100644
index 0000000..d4fd761
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/repository/PaymentRepository.java
@@ -0,0 +1,16 @@
+package com.example.payment.repository;
+
+import com.example.payment.entity.Payment;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface PaymentRepository extends JpaRepository {
+
+ /**
+ * 주문 ID로 결제 조회
+ */
+ Optional findByOrderId(Long orderId);
+}
diff --git a/payment-service/src/main/java/com/example/payment/service/PaymentService.java b/payment-service/src/main/java/com/example/payment/service/PaymentService.java
new file mode 100644
index 0000000..de55026
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/service/PaymentService.java
@@ -0,0 +1,96 @@
+package com.example.payment.service;
+
+import com.example.payment.config.PaymentGatewayConfig;
+import com.example.payment.exception.PaymentNotFoundException;
+import com.example.payment.dto.PaymentRequest;
+import com.example.payment.dto.PaymentResponse;
+import com.example.payment.entity.Payment;
+import com.example.payment.factory.PaymentGatewayFactory;
+import com.example.payment.repository.PaymentRepository;
+import com.example.payment.strategy.PaymentGatewayStrategy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PaymentService {
+
+ private final PaymentRepository paymentRepository;
+ private final PaymentGatewayFactory gatewayFactory;
+ private final PaymentGatewayConfig gatewayConfig;
+
+ /**
+ * 결제 처리 (전략 패턴 적용)
+ * @return 결제 성공 시 Payment 객체, 실패 시 null
+ */
+ @Transactional
+ public Payment processPayment(Long orderId, BigDecimal amount) {
+ return processPayment(orderId, amount, null);
+ }
+
+ /**
+ * 결제 처리 (PG사 선택 가능)
+ * @param orderId 주문 ID
+ * @param amount 결제 금액
+ * @param pgType PG사 타입 (null이면 기본 PG사 사용)
+ * @return 결제 성공 시 Payment 객체, 실패 시 null
+ */
+ @Transactional
+ public Payment processPayment(Long orderId, BigDecimal amount, String pgType) {
+ log.info("[Payment Service] 결제 처리 요청 - orderId: {}, amount: {}, pgType: {}",
+ orderId, amount, pgType);
+
+ // PG 전략 선택
+ PaymentGatewayStrategy strategy = (pgType != null)
+ ? gatewayFactory.getStrategy(pgType)
+ : gatewayFactory.getStrategy(gatewayConfig.getDefaultGateway());
+
+ // PG를 통한 결제 처리
+ PaymentRequest request = new PaymentRequest(orderId, amount, "CARD", "Customer", "customer@example.com");
+ PaymentResponse response = strategy.processPayment(request);
+
+ if (response.isSuccess()) {
+ Payment payment = new Payment(orderId, response.getPaymentId(), amount, "CARD");
+ paymentRepository.save(payment);
+
+ log.info("✅ [Payment Service] 결제 성공 - orderId: {}, paymentId: {}, PG: {}",
+ orderId, response.getPaymentId(), response.getPgType());
+ return payment;
+ } else {
+ log.warn("⚠️ [Payment Service] 결제 실패 - orderId: {}, PG: {}, message: {}",
+ orderId, response.getPgType(), response.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 결제 취소 (보상 트랜잭션)
+ */
+ @Transactional
+ public void cancelPayment(Long orderId) {
+ log.info("🔄 [Payment Service] 결제 취소 (보상 트랜잭션) - orderId: {}", orderId);
+
+ Payment payment = paymentRepository.findByOrderId(orderId)
+ .orElseThrow(() -> new PaymentNotFoundException(orderId));
+
+ // PG사에 취소 요청
+ PaymentGatewayStrategy strategy = gatewayFactory.getStrategy(gatewayConfig.getDefaultGateway());
+ PaymentResponse response = strategy.cancelPayment(payment.getPaymentId());
+
+ if (response.isSuccess()) {
+ payment.cancel();
+ paymentRepository.save(payment);
+
+ log.info("✅ [Payment Service] 결제 취소 완료 - orderId: {}, paymentId: {}, PG: {}",
+ orderId, payment.getPaymentId(), response.getPgType());
+ } else {
+ log.error("❌ [Payment Service] 결제 취소 실패 - orderId: {}, message: {}",
+ orderId, response.getMessage());
+ }
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/strategy/PaymentGatewayStrategy.java b/payment-service/src/main/java/com/example/payment/strategy/PaymentGatewayStrategy.java
new file mode 100644
index 0000000..9fb2478
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/strategy/PaymentGatewayStrategy.java
@@ -0,0 +1,31 @@
+package com.example.payment.strategy;
+
+import com.example.payment.dto.PaymentRequest;
+import com.example.payment.dto.PaymentResponse;
+
+/**
+ * PG 전략 인터페이스
+ * 다양한 PG사(토스페이먼츠, 카카오페이, 네이버페이 등)를 추상화
+ */
+public interface PaymentGatewayStrategy {
+
+ /**
+ * 결제 처리
+ */
+ PaymentResponse processPayment(PaymentRequest request);
+
+ /**
+ * 결제 취소
+ */
+ PaymentResponse cancelPayment(String paymentId);
+
+ /**
+ * PG사 타입 반환
+ */
+ String getGatewayType();
+
+ /**
+ * 결제 상태 조회
+ */
+ PaymentResponse getPaymentStatus(String paymentId);
+}
diff --git a/payment-service/src/main/java/com/example/payment/strategy/impl/KakaoPayStrategy.java b/payment-service/src/main/java/com/example/payment/strategy/impl/KakaoPayStrategy.java
new file mode 100644
index 0000000..1ad96a0
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/strategy/impl/KakaoPayStrategy.java
@@ -0,0 +1,93 @@
+package com.example.payment.strategy.impl;
+
+import com.example.payment.dto.PaymentRequest;
+import com.example.payment.dto.PaymentResponse;
+import com.example.payment.strategy.PaymentGatewayStrategy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+/**
+ * 카카오페이 PG 전략 구현체
+ * 실제로는 카카오페이 API를 호출하지만, 현재는 시뮬레이션
+ */
+@Slf4j
+@Component
+public class KakaoPayStrategy implements PaymentGatewayStrategy {
+
+ private static final String GATEWAY_TYPE = "KAKAO_PAY";
+
+ @Override
+ public PaymentResponse processPayment(PaymentRequest request) {
+ log.info("[{}] 결제 요청 - orderId: {}, amount: {}",
+ GATEWAY_TYPE, request.getOrderId(), request.getAmount());
+
+ // 실제로는 KakaoPay API 호출
+ // POST https://kapi.kakao.com/v1/payment/ready
+ // Authorization: KakaoAK {ADMIN_KEY}
+
+ boolean success = simulatePayment();
+
+ if (success) {
+ String pgTransactionId = "kakao_" + UUID.randomUUID().toString();
+ log.info("[{}] 결제 성공 - transactionId: {}", GATEWAY_TYPE, pgTransactionId);
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId("PAY-" + UUID.randomUUID().toString().substring(0, 8))
+ .pgTransactionId(pgTransactionId)
+ .message("카카오페이 결제 성공")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ } else {
+ log.warn("[{}] 결제 실패", GATEWAY_TYPE);
+ return PaymentResponse.builder()
+ .success(false)
+ .message("카카오페이 결제 실패")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+ }
+
+ @Override
+ public PaymentResponse cancelPayment(String paymentId) {
+ log.info("[{}] 결제 취소 요청 - paymentId: {}", GATEWAY_TYPE, paymentId);
+
+ // 실제로는 KakaoPay 취소 API 호출
+ // POST https://kapi.kakao.com/v1/payment/cancel
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId(paymentId)
+ .message("카카오페이 결제 취소 완료")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+
+ @Override
+ public String getGatewayType() {
+ return GATEWAY_TYPE;
+ }
+
+ @Override
+ public PaymentResponse getPaymentStatus(String paymentId) {
+ log.info("[{}] 결제 상태 조회 - paymentId: {}", GATEWAY_TYPE, paymentId);
+
+ // 실제로는 KakaoPay 조회 API 호출
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId(paymentId)
+ .message("결제 완료")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+
+ /**
+ * 결제 시뮬레이션 (85% 성공률)
+ */
+ private boolean simulatePayment() {
+ return Math.random() > 0.15;
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/strategy/impl/NaverPayStrategy.java b/payment-service/src/main/java/com/example/payment/strategy/impl/NaverPayStrategy.java
new file mode 100644
index 0000000..7ca5ac3
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/strategy/impl/NaverPayStrategy.java
@@ -0,0 +1,91 @@
+package com.example.payment.strategy.impl;
+
+import com.example.payment.dto.PaymentRequest;
+import com.example.payment.dto.PaymentResponse;
+import com.example.payment.strategy.PaymentGatewayStrategy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+/**
+ * 네이버페이 PG 전략 구현체
+ * 실제로는 네이버페이 API를 호출하지만, 현재는 시뮬레이션
+ */
+@Slf4j
+@Component
+public class NaverPayStrategy implements PaymentGatewayStrategy {
+
+ private static final String GATEWAY_TYPE = "NAVER_PAY";
+
+ @Override
+ public PaymentResponse processPayment(PaymentRequest request) {
+ log.info("[{}] 결제 요청 - orderId: {}, amount: {}",
+ GATEWAY_TYPE, request.getOrderId(), request.getAmount());
+
+ // 실제로는 Naver Pay API 호출
+ // POST https://dev.apis.naver.com/naverpay-partner/naverpay/payments/v2.2/apply/payment
+
+ boolean success = simulatePayment();
+
+ if (success) {
+ String pgTransactionId = "naver_" + UUID.randomUUID().toString();
+ log.info("[{}] 결제 성공 - transactionId: {}", GATEWAY_TYPE, pgTransactionId);
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId("PAY-" + UUID.randomUUID().toString().substring(0, 8))
+ .pgTransactionId(pgTransactionId)
+ .message("네이버페이 결제 성공")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ } else {
+ log.warn("[{}] 결제 실패", GATEWAY_TYPE);
+ return PaymentResponse.builder()
+ .success(false)
+ .message("네이버페이 결제 실패")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+ }
+
+ @Override
+ public PaymentResponse cancelPayment(String paymentId) {
+ log.info("[{}] 결제 취소 요청 - paymentId: {}", GATEWAY_TYPE, paymentId);
+
+ // 실제로는 Naver Pay 취소 API 호출
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId(paymentId)
+ .message("네이버페이 결제 취소 완료")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+
+ @Override
+ public String getGatewayType() {
+ return GATEWAY_TYPE;
+ }
+
+ @Override
+ public PaymentResponse getPaymentStatus(String paymentId) {
+ log.info("[{}] 결제 상태 조회 - paymentId: {}", GATEWAY_TYPE, paymentId);
+
+ // 실제로는 Naver Pay 조회 API 호출
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId(paymentId)
+ .message("결제 완료")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+
+ /**
+ * 결제 시뮬레이션 (88% 성공률)
+ */
+ private boolean simulatePayment() {
+ return Math.random() > 0.12;
+ }
+}
diff --git a/payment-service/src/main/java/com/example/payment/strategy/impl/TossPaymentsStrategy.java b/payment-service/src/main/java/com/example/payment/strategy/impl/TossPaymentsStrategy.java
new file mode 100644
index 0000000..c963447
--- /dev/null
+++ b/payment-service/src/main/java/com/example/payment/strategy/impl/TossPaymentsStrategy.java
@@ -0,0 +1,94 @@
+package com.example.payment.strategy.impl;
+
+import com.example.payment.dto.PaymentRequest;
+import com.example.payment.dto.PaymentResponse;
+import com.example.payment.strategy.PaymentGatewayStrategy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+/**
+ * 토스페이먼츠 PG 전략 구현체
+ * 실제로는 토스페이먼츠 API를 호출하지만, 현재는 시뮬레이션
+ */
+@Slf4j
+@Component
+public class TossPaymentsStrategy implements PaymentGatewayStrategy {
+
+ private static final String GATEWAY_TYPE = "TOSS_PAYMENTS";
+
+ @Override
+ public PaymentResponse processPayment(PaymentRequest request) {
+ log.info("[{}] 결제 요청 - orderId: {}, amount: {}",
+ GATEWAY_TYPE, request.getOrderId(), request.getAmount());
+
+ // 실제로는 Toss Payments API 호출
+ // POST https://api.tosspayments.com/v1/payments
+ // Authorization: Basic {SecretKey}
+
+ boolean success = simulatePayment();
+
+ if (success) {
+ String pgTransactionId = "toss_" + UUID.randomUUID().toString();
+ log.info("[{}] 결제 성공 - transactionId: {}", GATEWAY_TYPE, pgTransactionId);
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId("PAY-" + UUID.randomUUID().toString().substring(0, 8))
+ .pgTransactionId(pgTransactionId)
+ .message("토스페이먼츠 결제 성공")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ } else {
+ log.warn("[{}] 결제 실패", GATEWAY_TYPE);
+ return PaymentResponse.builder()
+ .success(false)
+ .message("토스페이먼츠 결제 실패 - 잔액 부족")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+ }
+
+ @Override
+ public PaymentResponse cancelPayment(String paymentId) {
+ log.info("[{}] 결제 취소 요청 - paymentId: {}", GATEWAY_TYPE, paymentId);
+
+ // 실제로는 Toss Payments 취소 API 호출
+ // POST https://api.tosspayments.com/v1/payments/{paymentKey}/cancel
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId(paymentId)
+ .message("토스페이먼츠 결제 취소 완료")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+
+ @Override
+ public String getGatewayType() {
+ return GATEWAY_TYPE;
+ }
+
+ @Override
+ public PaymentResponse getPaymentStatus(String paymentId) {
+ log.info("[{}] 결제 상태 조회 - paymentId: {}", GATEWAY_TYPE, paymentId);
+
+ // 실제로는 Toss Payments 조회 API 호출
+ // GET https://api.tosspayments.com/v1/payments/{paymentKey}
+
+ return PaymentResponse.builder()
+ .success(true)
+ .paymentId(paymentId)
+ .message("결제 완료")
+ .pgType(GATEWAY_TYPE)
+ .build();
+ }
+
+ /**
+ * 결제 시뮬레이션 (90% 성공률)
+ */
+ private boolean simulatePayment() {
+ return Math.random() > 0.1;
+ }
+}
diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml
new file mode 100644
index 0000000..5128be8
--- /dev/null
+++ b/payment-service/src/main/resources/application.yml
@@ -0,0 +1,71 @@
+spring:
+ application:
+ name: payment-service
+
+ # Database (H2 로컬 개발용)
+ datasource:
+ url: jdbc:h2:mem:paymentdb
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+
+ # Kafka
+ kafka:
+ bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ consumer:
+ group-id: payment-service-group
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
+ properties:
+ spring.json.trusted.packages: "*"
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+
+# Server
+server:
+ port: 8085
+
+# Zipkin (분산 추적)
+management:
+ tracing:
+ sampling:
+ probability: 1.0
+ zipkin:
+ tracing:
+ endpoint: ${ZIPKIN_URL:http://localhost:9411/api/v2/spans}
+
+# PG 설정
+payment:
+ gateway:
+ default: TOSS_PAYMENTS # 기본 PG사
+ toss:
+ secret-key: ${TOSS_SECRET_KEY:test_sk_toss}
+ api-url: https://api.tosspayments.com
+ kakao:
+ admin-key: ${KAKAO_ADMIN_KEY:test_admin_key}
+ api-url: https://kapi.kakao.com
+ naver:
+ client-id: ${NAVER_CLIENT_ID:test_client_id}
+ client-secret: ${NAVER_CLIENT_SECRET:test_client_secret}
+ api-url: https://dev.apis.naver.com
+
+# Logging
+logging:
+ level:
+ com.example.payment: DEBUG
+ org.springframework.kafka: INFO
diff --git a/product-service/build.gradle b/product-service/build.gradle
new file mode 100644
index 0000000..e92fe72
--- /dev/null
+++ b/product-service/build.gradle
@@ -0,0 +1,17 @@
+plugins {
+ id 'org.springframework.boot' version '3.1.5'
+}
+
+dependencies {
+ implementation project(':common')
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
+ implementation 'org.springframework.cloud:spring-cloud-starter-config'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+
+ // Database
+ runtimeOnly 'com.h2database:h2' // 로컬 개발용
+ runtimeOnly 'com.mysql:mysql-connector-j' // Docker/운영용
+}
diff --git a/product-service/src/main/java/com/example/product/ProductServiceApplication.java b/product-service/src/main/java/com/example/product/ProductServiceApplication.java
new file mode 100644
index 0000000..3355550
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/ProductServiceApplication.java
@@ -0,0 +1,18 @@
+package com.example.product;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+@SpringBootApplication
+@EnableDiscoveryClient
+@EnableJpaAuditing
+@EntityScan(basePackages = {"com.example.product.entity", "com.example.common"})
+public class ProductServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ProductServiceApplication.class, args);
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/config/DataInitializer.java b/product-service/src/main/java/com/example/product/config/DataInitializer.java
new file mode 100644
index 0000000..2c14bee
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/config/DataInitializer.java
@@ -0,0 +1,130 @@
+package com.example.product.config;
+
+import com.example.product.entity.Category;
+import com.example.product.entity.Product;
+import com.example.product.repository.ProductRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+
+/**
+ * 초기 데이터 생성
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DataInitializer implements CommandLineRunner {
+
+ private final ProductRepository productRepository;
+
+ @Override
+ public void run(String... args) {
+ if (productRepository.count() > 0) {
+ log.info("상품 데이터가 이미 존재합니다. 초기화 스킵");
+ return;
+ }
+
+ log.info("===== 테스트 상품 데이터 생성 시작 =====");
+
+ // 전자제품
+ productRepository.save(new Product(
+ "MacBook Pro 16",
+ "M3 Max 칩 탑재, 16인치 Liquid Retina XDR 디스플레이",
+ new BigDecimal("3500000"),
+ Category.ELECTRONICS,
+ "Apple",
+ "https://example.com/macbook-pro.jpg"
+ ));
+
+ productRepository.save(new Product(
+ "iPhone 15 Pro",
+ "A17 Pro 칩, 티타늄 디자인, 48MP 카메라",
+ new BigDecimal("1550000"),
+ Category.ELECTRONICS,
+ "Apple",
+ "https://example.com/iphone-15-pro.jpg"
+ ));
+
+ productRepository.save(new Product(
+ "Galaxy S24 Ultra",
+ "AI 기능 탑재, 200MP 카메라, S펜 지원",
+ new BigDecimal("1690000"),
+ Category.ELECTRONICS,
+ "Samsung",
+ "https://example.com/galaxy-s24.jpg"
+ ));
+
+ // 패션
+ productRepository.save(new Product(
+ "나이키 에어맥스",
+ "최고의 쿠셔닝과 편안함을 제공하는 러닝화",
+ new BigDecimal("159000"),
+ Category.FASHION,
+ "Nike",
+ "https://example.com/airmax.jpg"
+ ));
+
+ productRepository.save(new Product(
+ "리바이스 501 진",
+ "클래식 스트레이트 핏 데님",
+ new BigDecimal("129000"),
+ Category.FASHION,
+ "Levi's",
+ "https://example.com/levis-501.jpg"
+ ));
+
+ // 뷰티
+ productRepository.save(new Product(
+ "설화수 자음생 에센스",
+ "한방 발효 에센스, 피부 탄력 개선",
+ new BigDecimal("230000"),
+ Category.BEAUTY,
+ "설화수",
+ "https://example.com/sulwhasoo.jpg"
+ ));
+
+ // 도서
+ productRepository.save(new Product(
+ "클린 코드",
+ "애자일 소프트웨어 장인 정신 - 로버트 C. 마틴",
+ new BigDecimal("33000"),
+ Category.BOOK,
+ "인사이트",
+ "https://example.com/clean-code.jpg"
+ ));
+
+ productRepository.save(new Product(
+ "이펙티브 자바",
+ "자바 프로그래밍의 바이블 - 조슈아 블로크",
+ new BigDecimal("36000"),
+ Category.BOOK,
+ "인사이트",
+ "https://example.com/effective-java.jpg"
+ ));
+
+ // 스포츠
+ productRepository.save(new Product(
+ "윌슨 테니스 라켓",
+ "프로 스태프 시리즈, 카본 프레임",
+ new BigDecimal("250000"),
+ Category.SPORTS,
+ "Wilson",
+ "https://example.com/tennis-racket.jpg"
+ ));
+
+ // 홈/리빙
+ productRepository.save(new Product(
+ "다이슨 무선 청소기",
+ "V15 Detect, 레이저 먼지 감지",
+ new BigDecimal("899000"),
+ Category.HOME,
+ "Dyson",
+ "https://example.com/dyson-v15.jpg"
+ ));
+
+ log.info("===== 테스트 상품 데이터 생성 완료 (총 10개) =====");
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/controller/ProductController.java b/product-service/src/main/java/com/example/product/controller/ProductController.java
new file mode 100644
index 0000000..b00009c
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/controller/ProductController.java
@@ -0,0 +1,86 @@
+package com.example.product.controller;
+
+import com.example.product.dto.ProductResponse;
+import com.example.product.entity.Category;
+import com.example.product.entity.Product;
+import com.example.product.service.ProductService;
+import com.example.product.service.ProductService.CreateProductRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RestController
+@RequestMapping("/products")
+@RequiredArgsConstructor
+public class ProductController {
+
+ private final ProductService productService;
+
+ /**
+ * 상품 생성
+ */
+ @PostMapping
+ public ResponseEntity createProduct(@RequestBody CreateProductRequest request) {
+ log.info("[Product Controller] 상품 생성 API 호출");
+ Product product = productService.createProduct(request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(ProductResponse.from(product));
+ }
+
+ /**
+ * 전체 상품 조회
+ */
+ @GetMapping
+ public ResponseEntity> getAllProducts(
+ @RequestParam(required = false) Category category,
+ @RequestParam(required = false) String brand,
+ @RequestParam(required = false) String search) {
+
+ List products;
+
+ if (category != null) {
+ log.info("[Product Controller] 카테고리별 상품 조회 - category: {}", category);
+ products = productService.getProductsByCategory(category);
+ } else if (brand != null) {
+ log.info("[Product Controller] 브랜드별 상품 조회 - brand: {}", brand);
+ products = productService.getProductsByBrand(brand);
+ } else if (search != null) {
+ log.info("[Product Controller] 상품명 검색 - search: {}", search);
+ products = productService.searchProductsByName(search);
+ } else {
+ log.info("[Product Controller] 전체 상품 조회");
+ products = productService.getAllActiveProducts();
+ }
+
+ List response = products.stream()
+ .map(ProductResponse::from)
+ .collect(Collectors.toList());
+
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 상품 ID로 조회
+ */
+ @GetMapping("/{id}")
+ public ResponseEntity getProductById(@PathVariable Long id) {
+ log.info("[Product Controller] 상품 조회 - productId: {}", id);
+ Product product = productService.getProductById(id);
+ return ResponseEntity.ok(ProductResponse.from(product));
+ }
+
+ /**
+ * 상품 비활성화 (판매 중단)
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deactivateProduct(@PathVariable Long id) {
+ log.info("[Product Controller] 상품 비활성화 - productId: {}", id);
+ productService.deactivateProduct(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/dto/ProductResponse.java b/product-service/src/main/java/com/example/product/dto/ProductResponse.java
new file mode 100644
index 0000000..6da190d
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/dto/ProductResponse.java
@@ -0,0 +1,51 @@
+package com.example.product.dto;
+
+import com.example.product.entity.Category;
+import com.example.product.entity.Product;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 상품 응답 DTO
+ * - Entity를 직접 노출하지 않고 DTO로 변환하여 반환
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProductResponse {
+
+ private Long id;
+ private String name;
+ private String description;
+ private BigDecimal price;
+ private String category;
+ private String categoryDisplayName;
+ private String brand;
+ private String imageUrl;
+ private Boolean active;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ /**
+ * Entity → DTO 변환
+ */
+ public static ProductResponse from(Product product) {
+ return new ProductResponse(
+ product.getId(),
+ product.getName(),
+ product.getDescription(),
+ product.getPrice(),
+ product.getCategory().name(),
+ product.getCategory().getDisplayName(),
+ product.getBrand(),
+ product.getImageUrl(),
+ product.getActive(),
+ product.getCreatedAt(),
+ product.getUpdatedAt()
+ );
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/entity/Category.java b/product-service/src/main/java/com/example/product/entity/Category.java
new file mode 100644
index 0000000..ff4a072
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/entity/Category.java
@@ -0,0 +1,26 @@
+package com.example.product.entity;
+
+/**
+ * 상품 카테고리
+ */
+public enum Category {
+ ELECTRONICS("전자제품"),
+ FASHION("패션"),
+ FOOD("식품"),
+ BEAUTY("뷰티"),
+ SPORTS("스포츠"),
+ BOOK("도서"),
+ HOME("홈/리빙"),
+ TOY("장난감"),
+ ETC("기타");
+
+ private final String displayName;
+
+ Category(String displayName) {
+ this.displayName = displayName;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/entity/Product.java b/product-service/src/main/java/com/example/product/entity/Product.java
new file mode 100644
index 0000000..984f0dd
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/entity/Product.java
@@ -0,0 +1,106 @@
+package com.example.product.entity;
+
+import com.example.common.BaseEntity;
+import com.example.product.exception.InvalidProductPriceException;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+
+/**
+ * 상품 엔티티
+ */
+@Entity
+@Table(name = "products",
+ indexes = {
+ @Index(name = "idx_category", columnList = "category"),
+ @Index(name = "idx_brand", columnList = "brand")
+ })
+@Getter
+@Setter
+@NoArgsConstructor
+public class Product extends BaseEntity {
+
+ /**
+ * 상품명
+ */
+ @Column(nullable = false, length = 200)
+ private String name;
+
+ /**
+ * 상품 설명
+ */
+ @Column(length = 2000)
+ private String description;
+
+ /**
+ * 가격
+ */
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal price;
+
+ /**
+ * 카테고리
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 50)
+ private Category category;
+
+ /**
+ * 브랜드
+ */
+ @Column(length = 100)
+ private String brand;
+
+ /**
+ * 상품 이미지 URL (대표 이미지)
+ */
+ @Column(length = 500)
+ private String imageUrl;
+
+ /**
+ * 상품 활성화 여부 (판매 중단 시 false)
+ */
+ @Column(nullable = false)
+ private Boolean active = true;
+
+ /**
+ * 생성자
+ */
+ public Product(String name, String description, BigDecimal price,
+ Category category, String brand, String imageUrl) {
+ this.name = name;
+ this.description = description;
+ this.price = price;
+ this.category = category;
+ this.brand = brand;
+ this.imageUrl = imageUrl;
+ this.active = true;
+ }
+
+ /**
+ * 상품 비활성화 (판매 중단)
+ */
+ public void deactivate() {
+ this.active = false;
+ }
+
+ /**
+ * 상품 활성화 (판매 재개)
+ */
+ public void activate() {
+ this.active = true;
+ }
+
+ /**
+ * 가격 변경
+ */
+ public void updatePrice(BigDecimal newPrice) {
+ if (newPrice.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new InvalidProductPriceException(newPrice);
+ }
+ this.price = newPrice;
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/exception/InvalidProductPriceException.java b/product-service/src/main/java/com/example/product/exception/InvalidProductPriceException.java
new file mode 100644
index 0000000..2ed7076
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/exception/InvalidProductPriceException.java
@@ -0,0 +1,21 @@
+package com.example.product.exception;
+
+import com.example.common.exception.ErrorCode;
+import com.example.common.exception.InvalidValueException;
+
+import java.math.BigDecimal;
+
+/**
+ * 유효하지 않은 상품 가격일 때 발생하는 예외
+ */
+public class InvalidProductPriceException extends InvalidValueException {
+
+ public InvalidProductPriceException(BigDecimal price) {
+ super(ErrorCode.INVALID_PRODUCT_PRICE,
+ "가격은 0보다 커야 합니다. 입력된 가격: " + price);
+ }
+
+ public InvalidProductPriceException(String message) {
+ super(ErrorCode.INVALID_PRODUCT_PRICE, message);
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/exception/ProductAlreadyDeactivatedException.java b/product-service/src/main/java/com/example/product/exception/ProductAlreadyDeactivatedException.java
new file mode 100644
index 0000000..49bd569
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/exception/ProductAlreadyDeactivatedException.java
@@ -0,0 +1,15 @@
+package com.example.product.exception;
+
+import com.example.common.exception.BusinessException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 이미 비활성화된 상품을 다시 비활성화하려 할 때 발생하는 예외
+ */
+public class ProductAlreadyDeactivatedException extends BusinessException {
+
+ public ProductAlreadyDeactivatedException(Long productId) {
+ super(ErrorCode.PRODUCT_ALREADY_DEACTIVATED,
+ "이미 비활성화된 상품입니다: " + productId);
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/exception/ProductNotFoundException.java b/product-service/src/main/java/com/example/product/exception/ProductNotFoundException.java
new file mode 100644
index 0000000..df61383
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/exception/ProductNotFoundException.java
@@ -0,0 +1,18 @@
+package com.example.product.exception;
+
+import com.example.common.exception.EntityNotFoundException;
+import com.example.common.exception.ErrorCode;
+
+/**
+ * 상품을 찾을 수 없을 때 발생하는 예외
+ */
+public class ProductNotFoundException extends EntityNotFoundException {
+
+ public ProductNotFoundException(Long productId) {
+ super(ErrorCode.PRODUCT_NOT_FOUND, "상품을 찾을 수 없습니다: " + productId);
+ }
+
+ public ProductNotFoundException(String message) {
+ super(ErrorCode.PRODUCT_NOT_FOUND, message);
+ }
+}
diff --git a/product-service/src/main/java/com/example/product/repository/ProductRepository.java b/product-service/src/main/java/com/example/product/repository/ProductRepository.java
new file mode 100644
index 0000000..b8d8118
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/repository/ProductRepository.java
@@ -0,0 +1,38 @@
+package com.example.product.repository;
+
+import com.example.product.entity.Category;
+import com.example.product.entity.Product;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface ProductRepository extends JpaRepository {
+
+ /**
+ * 활성화된 상품 목록 조회
+ */
+ List findByActiveTrue();
+
+ /**
+ * 카테고리별 상품 조회
+ */
+ List findByCategoryAndActiveTrue(Category category);
+
+ /**
+ * 브랜드별 상품 조회
+ */
+ List findByBrandAndActiveTrue(String brand);
+
+ /**
+ * 상품명으로 검색 (LIKE)
+ */
+ List findByNameContainingAndActiveTrue(String name);
+
+ /**
+ * 활성화된 상품 조회 (ID)
+ */
+ Optional findByIdAndActiveTrue(Long id);
+}
diff --git a/product-service/src/main/java/com/example/product/service/ProductService.java b/product-service/src/main/java/com/example/product/service/ProductService.java
new file mode 100644
index 0000000..e18019c
--- /dev/null
+++ b/product-service/src/main/java/com/example/product/service/ProductService.java
@@ -0,0 +1,140 @@
+package com.example.product.service;
+
+import com.example.product.entity.Category;
+import com.example.product.entity.Product;
+import com.example.product.exception.ProductNotFoundException;
+import com.example.product.repository.ProductRepository;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ProductService {
+
+ private final ProductRepository productRepository;
+
+ /**
+ * 상품 생성
+ */
+ @Transactional
+ public Product createProduct(CreateProductRequest request) {
+ log.info("[Product Service] 상품 생성 - name: {}, price: {}",
+ request.getName(), request.getPrice());
+
+ Product product = new Product(
+ request.getName(),
+ request.getDescription(),
+ request.getPrice(),
+ request.getCategory(),
+ request.getBrand(),
+ request.getImageUrl()
+ );
+
+ Product savedProduct = productRepository.save(product);
+ log.info("[Product Service] 상품 생성 완료 - productId: {}", savedProduct.getId());
+
+ return savedProduct;
+ }
+
+ /**
+ * 전체 상품 조회 (활성화된 것만)
+ */
+ @Transactional(readOnly = true)
+ public List getAllActiveProducts() {
+ return productRepository.findByActiveTrue();
+ }
+
+ /**
+ * 상품 ID로 조회
+ */
+ @Transactional(readOnly = true)
+ public Product getProductById(Long id) {
+ return productRepository.findByIdAndActiveTrue(id)
+ .orElseThrow(() -> new ProductNotFoundException(id));
+ }
+
+ /**
+ * 카테고리별 상품 조회
+ */
+ @Transactional(readOnly = true)
+ public List getProductsByCategory(Category category) {
+ return productRepository.findByCategoryAndActiveTrue(category);
+ }
+
+ /**
+ * 브랜드별 상품 조회
+ */
+ @Transactional(readOnly = true)
+ public List getProductsByBrand(String brand) {
+ return productRepository.findByBrandAndActiveTrue(brand);
+ }
+
+ /**
+ * 상품명 검색
+ */
+ @Transactional(readOnly = true)
+ public List searchProductsByName(String name) {
+ return productRepository.findByNameContainingAndActiveTrue(name);
+ }
+
+ /**
+ * 상품 가격 변경
+ */
+ @Transactional
+ public Product updatePrice(Long id, BigDecimal newPrice) {
+ log.info("[Product Service] 가격 변경 - productId: {}, newPrice: {}", id, newPrice);
+
+ Product product = getProductById(id);
+ product.updatePrice(newPrice);
+
+ return productRepository.save(product);
+ }
+
+ /**
+ * 상품 비활성화 (판매 중단)
+ */
+ @Transactional
+ public void deactivateProduct(Long id) {
+ log.info("[Product Service] 상품 비활성화 - productId: {}", id);
+
+ Product product = productRepository.findById(id)
+ .orElseThrow(() -> new ProductNotFoundException(id));
+
+ product.deactivate();
+ productRepository.save(product);
+ }
+
+ /**
+ * 상품 생성 요청 DTO
+ */
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ public static class CreateProductRequest {
+ private String name;
+ private String description;
+ private BigDecimal price;
+ private Category category;
+ private String brand;
+ private String imageUrl;
+
+ public CreateProductRequest(String name, String description, BigDecimal price,
+ Category category, String brand, String imageUrl) {
+ this.name = name;
+ this.description = description;
+ this.price = price;
+ this.category = category;
+ this.brand = brand;
+ this.imageUrl = imageUrl;
+ }
+ }
+}
diff --git a/product-service/src/main/resources/application.yml b/product-service/src/main/resources/application.yml
new file mode 100644
index 0000000..1fd6773
--- /dev/null
+++ b/product-service/src/main/resources/application.yml
@@ -0,0 +1,38 @@
+spring:
+ application:
+ name: product-service
+ config:
+ import: optional:configserver:http://localhost:8888
+ datasource:
+ url: jdbc:h2:mem:productdb
+ username: sa
+ password:
+ driver-class-name: org.h2.Driver
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+ jpa:
+ hibernate:
+ ddl-auto: create
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+ dialect: org.hibernate.dialect.H2Dialect
+
+server:
+ port: 8087
+
+eureka:
+ client:
+ service-url:
+ defaultZone: http://localhost:8761/eureka/
+ instance:
+ prefer-ip-address: true
+
+logging:
+ level:
+ com.example.product: DEBUG
+ org.hibernate.SQL: DEBUG
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
diff --git a/settings.gradle b/settings.gradle
index e0fdd4d..ee6c8bb 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -3,6 +3,7 @@ rootProject.name = 'msa-spring-cloud-kubernetes'
include 'common'
include 'user-service'
include 'order-service'
+include 'notification-service'
include 'api-gateway'
include 'eureka-server'
include 'config-server'
\ No newline at end of file
diff --git a/user-service/build.gradle b/user-service/build.gradle
index 46c28be..45057cf 100644
--- a/user-service/build.gradle
+++ b/user-service/build.gradle
@@ -11,5 +11,7 @@ dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-config' // Config Client
implementation 'org.springframework.boot:spring-boot-starter-actuator' // Health check & Metrics
- runtimeOnly 'com.mysql:mysql-connector-j' // MySQL Driver
+ // Database
+ runtimeOnly 'com.h2database:h2' // 로컬 개발용
+ runtimeOnly 'com.mysql:mysql-connector-j' // Docker/운영용
}
\ No newline at end of file
diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml
index 1ee1d69..ab69518 100644
--- a/user-service/src/main/resources/application.yml
+++ b/user-service/src/main/resources/application.yml
@@ -28,6 +28,7 @@ server:
eureka:
client:
+ enabled: false # 로컬 개발 시 Eureka 비활성화
service-url:
defaultZone: http://localhost:8761/eureka/
instance: