diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 3fa761a..5a4eb8d 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -6,7 +6,7 @@ - + diff --git a/README.md b/README.md index b610e9c..5b8d272 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,126 @@ - 모놀리식 구조를 User/Order 서비스로 분리하여 MSA 환경 구성 - Spring Cloud Gateway를 사용하여 서비스 라우팅 및 API Gateway 패턴 구현 - Resilience4j를 통한 Circuit Breaker/Fallback 적용으로 장애 대응 경험 -- Sleuth + Zipkin으로 분산 추적 환경 구축, 요청 흐름 분석 +- Micrometer Tracing + Zipkin으로 분산 추적 환경 구축, 서비스 간 호출 흐름 시각화 +- OpenFeign을 통한 마이크로서비스 간 동기 통신 구현 - Docker 컨테이너화 후 Kubernetes에 배포하여 클라우드 네이티브 환경 이해 - Spring Cloud와 Kubernetes 기능 비교 및 차이점 학습 ## 2. 아키텍처 다이어그램 image -- User 서비스 ↔ Order 서비스 간 REST 통신 +- User 서비스 ↔ Order 서비스 간 REST 통신 (OpenFeign) - Gateway에서 서비스 라우팅 - Circuit Breaker 적용 시 Order 서비스 장애 시 fallback 동작 -- Sleuth + Zipkin으로 요청 추적 - -## 3. 주요 기능 / 실습 내용 -1. 서비스 분리: User, Order 서비스 -2. API Gateway 라우팅 및 인증 (Spring Cloud Gateway) -3. 장애 대응: Resilience4j Circuit Breaker + Fallback -4. 분산 추적: Sleuth + Zipkin -5. Docker 컨테이너화 -6. Kubernetes 배포: Deployment, Service, Ingress -7. Spring Cloud vs Kubernetes 기능 비교 학습 - -## 4. 실행 방법 -1. 각 서비스 빌드 +- Micrometer Tracing + Zipkin으로 요청 추적 (동일 Trace ID로 서비스 간 호출 연결) + +## 3. 기술 스택 + +### Backend +- **Java 21**, **Spring Boot 3.1.5** +- **Spring Cloud 2022.0.4**: Gateway, Config, OpenFeign, Eureka +- **Resilience4j**: Circuit Breaker, Fallback +- **Micrometer Tracing + Brave**: 분산 추적 +- **Zipkin**: 트레이싱 서버 +- **MySQL**: Database per Service 패턴 + +### Infra +- **Docker**: 컨테이너화 +- **Docker Compose**: 로컬 개발 환경 +- **Kubernetes**: 프로덕션 배포 (예정) + +## 4. 주요 기능 / 구현 내용 + +### 1) 마이크로서비스 아키텍처 +- User Service, Order Service 분리 +- Database per Service (각 서비스 독립 DB) +- RESTful API 설계 (쿼리 파라미터 활용) + +### 2) API Gateway (Spring Cloud Gateway) +- 단일 진입점을 통한 라우팅 +- 인증/인가 처리 + +### 3) 서비스 간 통신 +- **OpenFeign**: 선언적 HTTP Client로 동기 통신 +- Service Discovery (Eureka)를 통한 동적 라우팅 + +### 4) 장애 대응 (Resilience4j) +- **Circuit Breaker**: 장애 전파 차단 +- **Fallback**: User Service 장애 시 기본값 반환 +- 설정: 10번 중 50% 실패 시 Circuit Open + +### 5) 분산 추적 (Micrometer + Zipkin) ⭐ 최근 구현 +- **Trace ID/Span ID**: 서비스 간 요청 흐름 추적 +- **B3 Propagation**: OpenFeign 호출 시 trace context 자동 전파 +- **Zipkin UI**: 서비스 의존성 그래프 및 성능 병목 지점 시각화 +- 트러블슈팅: Spring Boot 3.x + OpenFeign 통합 이슈 해결 (`feign-micrometer`) + +### 6) 설정 관리 (Config Server) +- 중앙화된 설정 관리 +- 환경별 설정 분리 (dev, prod) + +## 5. 최근 개선사항 + +### Order-User 서비스 간 통신 구현 (2025.10.12) +**배경**: 기존에는 각 서비스가 독립적으로 동작했으나, 실제 비즈니스 시나리오에서는 서비스 간 데이터 통합이 필요 + +**구현 내용**: +- Order Service에서 User Service 호출하여 주문+사용자 정보 통합 응답 +- RESTful API 개선: `GET /orders?userId=1` (쿼리 파라미터 활용) +- Circuit Breaker로 User Service 장애 시 fallback 처리 + +**기술적 도전 과제**: +1. **Trace Context 전파 이슈**: Spring Boot 3.x + OpenFeign 조합에서 trace context가 자동 전파되지 않는 문제 발견 + - 해결: `feign-micrometer` 의존성 추가 + B3 propagation 설정 + - 결과: Zipkin에서 Order → User 호출이 동일 Trace ID로 연결되어 추적 가능 + +2. **Docker 빌드 캐싱 이슈**: 코드 변경사항이 컨테이너에 반영되지 않는 문제 + - 해결: 빌드 아티팩트 삭제 후 재빌드 (`rm -rf build && docker-compose up --build`) + +**성과**: +- Zipkin UI에서 3개 span으로 서비스 간 호출 시각화 (order-service 2개, user-service 1개) +- 평균 응답 시간: 19ms (Order), 3ms (User Service) + +## 6. 실행 방법 + +### Docker Compose로 전체 시스템 실행 ```bash -cd user-service && ./mvnw clean package -cd order-service && ./mvnw clean package +# 전체 빌드 및 실행 +docker-compose up -d --build + +# 특정 서비스만 재빌드 +docker-compose up -d --build order-service + +# 로그 확인 +docker-compose logs -f order-service + +# 종료 +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 +- **API Gateway**: http://localhost:8080 + +### API 테스트 예시 +```bash +# 주문 생성 +curl -X POST http://localhost:8082/orders \ + -H "Content-Type: application/json" \ + -d '{"userId": 1, "productName": "Laptop", "quantity": 2, "price": 1500000}' + +# 주문+사용자 정보 조회 (분산 추적 확인 가능) +curl http://localhost:8082/orders?userId=1 | jq + +# Zipkin에서 Trace 확인 +curl http://localhost:9411/api/v2/traces?serviceName=order-service | jq +``` + +## 7. 관련 문서 +- [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) diff --git a/build.gradle b/build.gradle index f8ba4e1..5cdf713 100644 --- a/build.gradle +++ b/build.gradle @@ -32,9 +32,13 @@ subprojects { // 공통 의존성 dependencies { - compileOnly 'org.projectlombok:lombok:1.18.30' // Java 21 호환 버전 - annotationProcessor 'org.projectlombok:lombok:1.18.30' + compileOnly 'org.projectlombok:lombok:1.18.32' // Java 21 호환 버전 + annotationProcessor 'org.projectlombok:lombok:1.18.32' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Distributed Tracing - Micrometer + Zipkin + implementation 'io.micrometer:micrometer-tracing-bridge-brave' + implementation 'io.zipkin.reporter2:zipkin-reporter-brave' } dependencyManagement { diff --git a/config-server/src/main/resources/config/api-gateway.yml b/config-server/src/main/resources/config/api-gateway.yml index e17bf37..ad4372b 100644 --- a/config-server/src/main/resources/config/api-gateway.yml +++ b/config-server/src/main/resources/config/api-gateway.yml @@ -2,6 +2,8 @@ server: port: 8080 spring: + application: + name: api-gateway cloud: gateway: routes: @@ -69,3 +71,12 @@ 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 index d12450f..8095bbc 100644 --- a/config-server/src/main/resources/config/order-service.yml +++ b/config-server/src/main/resources/config/order-service.yml @@ -2,6 +2,8 @@ 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 @@ -27,9 +29,45 @@ eureka: 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 index 1efb620..8880a5c 100644 --- a/config-server/src/main/resources/config/user-service.yml +++ b/config-server/src/main/resources/config/user-service.yml @@ -2,6 +2,8 @@ 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 @@ -33,3 +35,14 @@ 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/docker-compose.yml b/docker-compose.yml index 0c2ffc6..f9c51bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,16 @@ services: timeout: 5s retries: 5 + zipkin: + image: openzipkin/zipkin:latest + container_name: zipkin + ports: + - "9411:9411" + networks: + - msa-network + environment: + - STORAGE_TYPE=mem + eureka-server: build: context: . diff --git a/docs/Zipkin-Distributed-Tracing.md b/docs/Zipkin-Distributed-Tracing.md new file mode 100644 index 0000000..b5feca8 --- /dev/null +++ b/docs/Zipkin-Distributed-Tracing.md @@ -0,0 +1,343 @@ +# Zipkin 분산 추적 (Distributed Tracing) + +## 개요 + +### Zipkin이란? +마이크로서비스 간 **요청의 전체 흐름을 추적하고 시각화**하는 도구입니다. + +``` +User Request → Order Service → User Service + [Trace ID: ABC] [Trace ID: ABC] ✅ 동일한 Trace로 연결 +``` + +### 주요 기능 +- 서비스 간 호출 추적 +- 성능 병목 지점 파악 +- 에러 발생 위치 추적 +- 서비스 의존성 그래프 + +--- + +## 핵심 개념 + +### 1. Trace (추적) +하나의 사용자 요청 전체를 나타냅니다. + +``` +Trace ID: 68eb6934747c42b7 +├─ Span 1: Order Service (19ms) +├─ Span 2: Order → User (Feign 호출, 5ms) +└─ Span 3: User Service (3ms) +``` + +### 2. Span (구간) +Trace 내에서 하나의 작업 단위입니다. + +```json +{ + "traceId": "68eb6934747c42b7", + "spanId": "237d7d4de82a0276", + "parentSpanId": "68eb6934747c42b7", + "name": "GET /api/users/{id}", + "duration": 3576, + "localEndpoint": { + "serviceName": "user-service" + } +} +``` + +### 3. Context Propagation (컨텍스트 전파) +서비스 간 호출 시 **HTTP 헤더로 Trace 정보를 전달**합니다. + +```http +GET /api/users/1 HTTP/1.1 +X-B3-TraceId: 68eb6934747c42b7237d7d4de82a0276 +X-B3-SpanId: 237d7d4de82a0276 +X-B3-ParentSpanId: 68eb6934747c42b7 +``` + +--- + +## 구현 가이드 + +### 1. 의존성 추가 + +**root build.gradle** +```gradle +subprojects { + dependencies { + implementation 'io.micrometer:micrometer-tracing-bridge-brave' + implementation 'io.zipkin.reporter2:zipkin-reporter-brave' + } +} +``` + +**order-service/build.gradle** +```gradle +dependencies { + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'io.github.openfeign:feign-micrometer' // ⭐ 필수! + implementation 'org.springframework.boot:spring-boot-starter-actuator' +} +``` + +> ⚠️ **중요**: `feign-micrometer` 없으면 trace context가 전파되지 않습니다! + +### 2. Config Server 설정 + +```yaml +management: + tracing: + sampling: + probability: 1.0 # 100% 샘플링 (개발), 운영은 0.1 권장 + propagation: + type: b3 # ⭐ Zipkin B3 포맷 (필수) + zipkin: + tracing: + endpoint: http://zipkin:9411/api/v2/spans +``` + +### 3. Docker Compose + +```yaml +zipkin: + image: openzipkin/zipkin:latest + ports: + - "9411:9411" + environment: + - STORAGE_TYPE=mem # 개발용 +``` + +### 4. OpenFeign Client + +```java +@FeignClient(name = "user-service") +public interface UserClient { + @GetMapping("/api/users/{id}") + UserResponse getUserById(@PathVariable("id") Long id); +} +``` + +--- + +## 트러블슈팅 + +### 문제 1: Trace가 연결되지 않음 ⭐ 가장 흔함 + +**증상** +```bash +# Order Service와 User Service가 별도 Trace로 표시 +{"traceId": "ABC", "services": ["order-service"]} +{"traceId": "XYZ", "services": ["user-service"]} +``` + +**원인** +Spring Boot 3.x + OpenFeign에서 trace context 자동 전파 안 됨 + +**해결** +```gradle +// 1. 의존성 추가 +implementation 'io.github.openfeign:feign-micrometer' +``` + +```yaml +# 2. propagation 설정 +management: + tracing: + propagation: + type: b3 +``` + +```bash +# 3. 재빌드 +rm -rf order-service/build +docker-compose up -d --build order-service +``` + +**검증** +```bash +curl -s "http://localhost:9411/api/v2/traces?serviceName=order-service&limit=1" | \ + jq '.[0] | {traceId: .[0].traceId, services: [.[] | .localEndpoint.serviceName] | unique}' + +# 결과: {"traceId": "...", "services": ["order-service", "user-service"]} ✅ +``` + +### 문제 2: Docker 빌드 캐싱 + +**증상** +코드 수정 후 `docker-compose up -d --build` 해도 반영 안 됨 + +**해결** +```bash +rm -rf order-service/build && docker-compose up -d --build order-service +``` + +### 문제 3: Propagation Format 불일치 + +**해결** +모든 서비스에서 동일한 type 사용: +```yaml +management: + tracing: + propagation: + type: b3 # 모든 서비스 통일 +``` + +--- + +## 활용 + +### Zipkin UI +**접속**: http://localhost:9411 + +``` +1. Service Name: order-service 선택 +2. Trace 클릭 → Span 상세 확인 + ├─ order-service: 19ms + ├─ http get (Feign): 5ms + └─ user-service: 3ms +``` + +### API 조회 +```bash +# 최근 Trace 조회 +curl -s "http://localhost:9411/api/v2/traces?serviceName=order-service&limit=10" | jq + +# 특정 Trace ID 조회 +curl -s "http://localhost:9411/api/v2/trace/${TRACE_ID}" | jq +``` + +### 로그에서 Trace ID 확인 +``` +INFO [order-service,68eb6934747c42b7,237d7d4de82a0276] + └─ Trace ID ────┘ └─ Span ID ───┘ +``` + +--- + +## 면접 대비 Q&A + +### Q1. Zipkin vs Prometheus 차이는? + +**A:** +- **Zipkin (Tracing)**: "무슨 일이 일어났나?" → 요청 흐름 추적, 성능 병목 분석 +- **Prometheus (Metrics)**: "얼마나 일어났나?" → CPU/메모리/요청 수 모니터링 + +실전에서는 둘 다 사용합니다. + +### Q2. Spring Cloud Sleuth vs Micrometer Tracing? + +**A:** +- **Sleuth**: Spring Boot 2.x 시대의 분산 추적 +- **Micrometer Tracing**: Spring Boot 3.x 표준 + +Spring Boot 3.x로 마이그레이션 시: +- 의존성: `spring-cloud-starter-sleuth` → `micrometer-tracing-bridge-brave` +- OpenFeign 통합: **`feign-micrometer` 필수 추가** + +### Q3. B3 vs W3C Propagation? + +**A:** + +**B3 (Zipkin Propagation)** + +헤더 형식 - Multi 방식: +```http +X-B3-TraceId: 68eb6934747c42b7237d7d4de82a0276 +X-B3-SpanId: 237d7d4de82a0276 +X-B3-ParentSpanId: 68eb6934747c42b7 +X-B3-Sampled: 1 +``` + +헤더 형식 - Single 방식: +```http +b3: 68eb6934747c42b7237d7d4de82a0276-237d7d4de82a0276-1 + └─────────── TraceId ──────────────┘ └─── SpanId ────┘ │ + Sampled +``` + +- Zipkin 프로젝트 표준 +- 레거시 시스템 지원 +- Brave, Zipkin 완벽 호환 + +**W3C Trace Context** + +헤더 형식: +```http +traceparent: 00-68eb6934747c42b7237d7d4de82a0276-237d7d4de82a0276-01 + │ └─────────── TraceId ──────────────┘ └─── SpanId ────┘ │ + │ Sampled + Version +``` + +- W3C 국제 표준 (2020년) +- OpenTelemetry 기본 포맷 +- 최신 APM 도구 지원 + +**선택 기준:** +- 기존 Zipkin 사용 중 → B3 +- 새 프로젝트 → W3C +- 둘 다 지원: `type: b3,w3c` + +### Q4. OpenFeign에서 trace context 자동 전파 안 되는 이유? + +**A:** +Spring Boot 3.x에서는 Micrometer Tracing과 OpenFeign 통합이 자동으로 안 됩니다. + +**해결**: `feign-micrometer` 의존성 명시적 추가 필수 +```gradle +implementation 'io.github.openfeign:feign-micrometer' +``` + +이게 없으면 OpenFeign이 B3 헤더를 자동으로 추가하지 않습니다. + +### Q5. 모든 요청을 추적하면 성능 영향은? + +**A:** +- Trace ID 생성: 매우 낮음 (~1μs) +- HTTP 헤더 추가: 매우 낮음 +- Zipkin 전송: 낮음 (비동기) + +**Sampling 전략**: +```yaml +# 개발 +probability: 1.0 # 100% + +# 운영 (높은 트래픽) +probability: 0.1 # 10% +``` + +에러 발생 시 항상 100% 샘플링됩니다 (Brave 기본 동작). + +### Q6. 서비스 간 통신 패턴은? + +**A:** +``` +External Client + ↓ (인증 필요) +API Gateway (인증/인가/라우팅) + ↓ +Order Service + ↓ (내부 통신, Service Discovery) +User Service ✅ 직접 호출 (일반적) +``` + +**패턴 선택**: +- **외부 → 서비스**: API Gateway 필수 +- **서비스 → 서비스**: 직접 호출 (Netflix, Amazon, Uber 방식) + +API Gateway를 다시 경유하면 성능 오버헤드와 복잡도가 증가합니다. + +--- + +## 참고 자료 + +- [Zipkin 공식 사이트](https://zipkin.io/) +- [Micrometer Tracing 문서](https://micrometer.io/docs/tracing) +- [Spring Boot Actuator - Tracing](https://docs.spring.io/spring-boot/reference/actuator/tracing.html) +- [OpenFeign #812 - Micrometer Tracing 통합 이슈](https://github.com/spring-cloud/spring-cloud-openfeign/issues/812) + +--- + +> 📝 **작성**: 2025-10-12 +> 🏷️ **태그**: Zipkin, Distributed Tracing, Micrometer, OpenFeign, Spring Boot 3.x diff --git a/docs/zipkin_screenshot.png b/docs/zipkin_screenshot.png new file mode 100644 index 0000000..f66c1c0 Binary files /dev/null and b/docs/zipkin_screenshot.png differ diff --git a/order-service/build.gradle b/order-service/build.gradle index 0a39ff5..a707edb 100644 --- a/order-service/build.gradle +++ b/order-service/build.gradle @@ -10,6 +10,9 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.cloud:spring-cloud-starter-config' // Config Client + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' // Circuit Breaker + 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' } \ No newline at end of file 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 new file mode 100644 index 0000000..7309919 --- /dev/null +++ b/order-service/src/main/java/com/example/order/client/UserClient.java @@ -0,0 +1,13 @@ +package com.example.order.client; + +import com.example.order.dto.UserResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient(name = "user-service") +public interface UserClient { + + @GetMapping("/api/users/{id}") + UserResponse getUserById(@PathVariable("id") Long id); +} diff --git a/order-service/src/main/java/com/example/order/config/FeignConfig.java b/order-service/src/main/java/com/example/order/config/FeignConfig.java new file mode 100644 index 0000000..8bc1de2 --- /dev/null +++ b/order-service/src/main/java/com/example/order/config/FeignConfig.java @@ -0,0 +1,14 @@ +package com.example.order.config; + +import feign.Logger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FeignConfig { + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; // FULL: 모든 요청/응답 헤더와 바디 로깅 + } +} 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 f328661..1a57df6 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 @@ -1,16 +1,19 @@ package com.example.order.controller; +import com.example.order.dto.OrderWithUserResponse; import com.example.order.entity.Order; import com.example.order.service.OrderService; import com.example.order.service.OrderService.CreateOrderRequest; 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; +@Slf4j @RestController -@RequestMapping("/api/orders") +@RequestMapping("/orders") @RequiredArgsConstructor public class OrderController { @@ -18,19 +21,23 @@ public class OrderController { @PostMapping public ResponseEntity createOrder(@RequestBody CreateOrderRequest request) { + log.info("[Order Controller] 주문 생성 API 호출됨"); Order order = orderService.createOrder(request); + log.info("[Order Controller] 주문 생성 완료 - orderId: {}", order.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(order); } @GetMapping public ResponseEntity> getAllOrders() { + log.info("[Order Controller] 전체 주문 조회"); List orders = orderService.getAllOrders(); return ResponseEntity.ok(orders); } - @GetMapping("/user/{userId}") - public ResponseEntity> getOrdersByUserId(@PathVariable Long userId) { - List orders = orderService.getOrdersByUserId(userId); - 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); } } \ No newline at end of file diff --git a/order-service/src/main/java/com/example/order/dto/OrderWithUserResponse.java b/order-service/src/main/java/com/example/order/dto/OrderWithUserResponse.java new file mode 100644 index 0000000..c8a63db --- /dev/null +++ b/order-service/src/main/java/com/example/order/dto/OrderWithUserResponse.java @@ -0,0 +1,44 @@ +package com.example.order.dto; + +import com.example.order.entity.Order; +import com.example.order.entity.OrderStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class OrderWithUserResponse { + // Order 정보 + private Long orderId; + private String productName; + private Integer quantity; + private BigDecimal price; + private OrderStatus status; + private LocalDateTime createdAt; + + // User 정보 + private Long userId; + private String userName; + private String userEmail; + + public static OrderWithUserResponse of(Order order, UserResponse user) { + return new OrderWithUserResponse( + order.getId(), + order.getProductName(), + order.getQuantity(), + order.getPrice(), + order.getStatus(), + order.getCreatedAt(), + user.getId(), + user.getName(), + user.getEmail() + ); + } +} diff --git a/order-service/src/main/java/com/example/order/dto/UserResponse.java b/order-service/src/main/java/com/example/order/dto/UserResponse.java new file mode 100644 index 0000000..f39787c --- /dev/null +++ b/order-service/src/main/java/com/example/order/dto/UserResponse.java @@ -0,0 +1,14 @@ +package com.example.order.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserResponse { + private Long id; + private String name; + private String email; +} 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 84aae40..642e6e2 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 @@ -29,6 +29,7 @@ public class Order extends BaseEntity { private BigDecimal price; @Enumerated(EnumType.STRING) + @Column(nullable = false) private OrderStatus status = OrderStatus.PENDING; public Order(Long userId, String productName, Integer quantity, BigDecimal price) { @@ -37,8 +38,4 @@ public Order(Long userId, String productName, Integer quantity, BigDecimal price this.quantity = quantity; this.price = price; } -} - -enum OrderStatus { - PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED } \ No newline at end of file diff --git a/order-service/src/main/java/com/example/order/entity/OrderStatus.java b/order-service/src/main/java/com/example/order/entity/OrderStatus.java new file mode 100644 index 0000000..6e4e51c --- /dev/null +++ b/order-service/src/main/java/com/example/order/entity/OrderStatus.java @@ -0,0 +1,9 @@ +package com.example.order.entity; + +public enum OrderStatus { + PENDING, // 대기중 + CONFIRMED, // 확인됨 + SHIPPED, // 배송중 + DELIVERED, // 배송완료 + CANCELLED // 취소됨 +} 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 7e3dc52..8b132b2 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 @@ -1,22 +1,59 @@ package com.example.order.service; +import com.example.order.client.UserClient; +import com.example.order.dto.OrderWithUserResponse; +import com.example.order.dto.UserResponse; import com.example.order.entity.Order; import com.example.order.repository.OrderRepository; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.timelimiter.annotation.TimeLimiter; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.util.List; +import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; + private final UserClient userClient; + @CircuitBreaker(name = "userClient", fallbackMethod = "createOrderFallback") + @TimeLimiter(name = "userClient") public Order createOrder(CreateOrderRequest request) { + // 분산 추적 테스트: User Service 호출하여 사용자 검증 + log.info("주문 생성 요청 - userId: {}, productName: {}", request.getUserId(), request.getProductName()); + + UserResponse user = userClient.getUserById(request.getUserId()); + log.info("사용자 검증 완료 - userId: {}, userName: {}", user.getId(), user.getName()); + + Order order = new Order( + request.getUserId(), + request.getProductName(), + request.getQuantity(), + request.getPrice() + ); + Order savedOrder = orderRepository.save(order); + log.info("주문 생성 완료 - orderId: {}", savedOrder.getId()); + + return savedOrder; + } + + // Circuit Breaker Fallback 메서드 + private Order createOrderFallback(CreateOrderRequest request, Exception ex) { + log.error("User Service 호출 실패! Circuit Breaker 작동 - userId: {}, error: {}", + request.getUserId(), ex.getMessage()); + + // 사용자 검증 없이 주문 생성 (또는 예외를 던질 수도 있음) + log.warn("사용자 검증 없이 주문 생성 진행 - userId: {}", request.getUserId()); + Order order = new Order( request.getUserId(), request.getProductName(), @@ -34,6 +71,41 @@ public List getOrdersByUserId(Long userId) { return orderRepository.findByUserId(userId); } + @CircuitBreaker(name = "userClient", fallbackMethod = "getOrdersWithUserFallback") + @TimeLimiter(name = "userClient") + public List getOrdersWithUserInfo(Long userId) { + log.info("[Order Service] 사용자의 주문 목록 조회 시작 - userId: {}", userId); + + // 1. 해당 사용자의 주문 목록 조회 + List orders = orderRepository.findByUserId(userId); + log.info("[Order Service] 주문 목록 조회 완료 - userId: {}, orderCount: {}", userId, orders.size()); + + // 2. User Service 호출하여 사용자 정보 조회 + UserResponse user = userClient.getUserById(userId); + log.info("[Order Service] 사용자 정보 조회 완료 - userId: {}, userName: {}", user.getId(), user.getName()); + + // 3. Order + User 정보 합쳐서 반환 + List response = orders.stream() + .map(order -> OrderWithUserResponse.of(order, user)) + .collect(Collectors.toList()); + + log.info("[Order Service] Order + User 정보 반환 완료"); + return response; + } + + // Circuit Breaker Fallback 메서드 + private List getOrdersWithUserFallback(Long userId, Exception ex) { + log.error("User Service 호출 실패! Circuit Breaker 작동 - userId: {}, error: {}", + userId, ex.getMessage()); + + // User 정보 없이 Order만 반환 (기본값) + List orders = orderRepository.findByUserId(userId); + return orders.stream() + .map(order -> OrderWithUserResponse.of(order, + new UserResponse(userId, "Unknown User", "unknown@example.com"))) + .collect(Collectors.toList()); + } + @Getter @Setter @NoArgsConstructor diff --git a/user-service/build.gradle b/user-service/build.gradle index 47c88a7..46c28be 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -9,6 +9,7 @@ dependencies { 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' // Config Client + implementation 'org.springframework.boot:spring-boot-starter-actuator' // Health check & Metrics runtimeOnly 'com.mysql:mysql-connector-j' // MySQL Driver } \ No newline at end of file diff --git a/user-service/src/main/java/com/example/user/controller/UserController.java b/user-service/src/main/java/com/example/user/controller/UserController.java index 7bfbee6..f655133 100644 --- a/user-service/src/main/java/com/example/user/controller/UserController.java +++ b/user-service/src/main/java/com/example/user/controller/UserController.java @@ -4,6 +4,7 @@ import com.example.user.service.UserService; import com.example.user.service.UserService.CreateUserRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -11,6 +12,7 @@ import java.util.List; import java.util.Optional; +@Slf4j @RestController @RequestMapping("/api/users") @RequiredArgsConstructor @@ -32,7 +34,9 @@ public ResponseEntity> getAllUsers() { @GetMapping("/{id}") public ResponseEntity getUserById(@PathVariable Long id) { + log.info("[User Controller] 사용자 조회 API 호출됨 - userId: {}", id); User user = userService.getUserById(id); + log.info("[User Controller] 사용자 조회 완료 - userId: {}, userName: {}", user.getId(), user.getName()); return ResponseEntity.ok(user); }