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. 아키텍처 다이어그램 -image -- 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: