콘텐츠 대표 이미지 - ☁️ Spring Cloud로 분산 시스템 구축하기 🚀

☁️ Spring Cloud로 분산 시스템 구축하기 🚀

마이크로서비스 아키텍처의 세계로 떠나는 즐거운 여행 ✨

안녕, 친구! 😊 오늘은 정말 재미있는 주제를 가지고 왔어. 바로 Spring Cloud를 활용한 분산 시스템 구축이야. 혹시 "분산 시스템이 뭐야?"라고 생각할 수도 있는데, 걱정 마! 차근차근 친구처럼 설명해줄게.

요즘 IT 업계에서는 모놀리식(Monolithic) 아키텍처에서 마이크로서비스 아키텍처로 전환하는 게 대세야. 왜냐고? 🤔 간단해! 하나의 거대한 애플리케이션보다 작은 서비스들로 나눠서 관리하는 게 훨씬 효율적이거든. 마치 큰 피자 한 판을 혼자 먹는 것보다, 여러 조각으로 나눠서 친구들과 나눠 먹는 게 더 좋은 것처럼 말이야! 🍕

Spring Cloud는 이런 분산 시스템을 구축할 때 필요한 다양한 도구들을 제공하는 프레임워크야. Netflix, Amazon 같은 글로벌 기업들이 실제로 사용하고 있는 검증된 기술이지. 그럼 지금부터 본격적으로 Spring Cloud의 세계로 들어가볼까? 💪
Spring Cloud Gateway Service A User Service Service B Order Service Service C Payment Service Service D Notification

🎯 분산 시스템이란 무엇인가?

먼저 기본 개념부터 잡고 가자! 분산 시스템은 말 그대로 여러 개의 독립적인 컴퓨터들이 네트워크로 연결되어 하나의 시스템처럼 동작하는 구조를 말해. 🖥️🖥️🖥️

예를 들어볼게. 너가 온라인 쇼핑몰을 운영한다고 생각해봐. 전통적인 모놀리식 방식이라면 회원 관리, 상품 관리, 주문 처리, 결제, 배송 추적 등 모든 기능이 하나의 거대한 애플리케이션 안에 들어있어. 근데 이게 문제가 뭐냐면...

🚨 모놀리식 아키텍처의 문제점 1. 배포의 어려움: 작은 기능 하나만 수정해도 전체 시스템을 다시 배포해야 해
2. 확장성 제한: 특정 기능만 확장하고 싶어도 전체를 확장해야 하니까 비효율적이야
3. 장애 전파: 한 부분에 문제가 생기면 전체 시스템이 다운될 수 있어
4. 기술 스택 고정: 한 번 선택한 기술을 바꾸기가 정말 어려워
5. 팀 협업 어려움: 여러 팀이 동시에 작업하면 코드 충돌이 빈번해
반면에 마이크로서비스 아키텍처는 이런 문제들을 해결해줘! 각 기능을 독립적인 서비스로 분리하면, 각 서비스는 자신만의 데이터베이스를 가지고, 독립적으로 배포되고, 필요한 만큼만 확장할 수 있어. 정말 멋지지 않아? 😎

근데 여기서 새로운 문제가 생겨. 서비스들이 분리되면서 생기는 복잡성을 어떻게 관리할 거냐는 거지. 서비스들끼리 어떻게 통신하고, 어떻게 찾고, 설정은 어떻게 관리하고... 이런 문제들 말이야. 바로 이때 Spring Cloud가 등장하는 거야! 🎉

☁️ Spring Cloud 핵심 컴포넌트 소개

Spring Cloud는 분산 시스템을 구축할 때 필요한 여러 가지 패턴들을 구현한 도구 모음이야. 하나씩 살펴보자! 🔍

1. Service Discovery (서비스 디스커버리) 🔎

마이크로서비스 환경에서는 서비스 인스턴스들이 동적으로 생성되고 삭제돼. IP 주소나 포트 번호가 계속 바뀌는 거지. 그럼 서비스 A가 서비스 B를 호출하려면 어떻게 해야 할까? 🤷‍♂️

바로 Service Registry가 필요해! Spring Cloud에서는 주로 Eureka나 Consul을 사용해. 모든 서비스들이 시작할 때 자신의 정보를 레지스트리에 등록하고, 다른 서비스를 호출할 때는 레지스트리에서 위치를 조회하는 거야.

💡 실무 팁: Eureka는 Netflix에서 만든 서비스 디스커버리 도구야. 설정이 간단하고 Spring Boot와 통합이 잘 되어 있어서 많이 사용해. 재능넷 같은 플랫폼에서도 여러 서비스를 관리할 때 이런 패턴을 활용하면 정말 편리하지!
Eureka Server 설정 예제:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
}

// Application.java
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}

// application.yml
server:
  port: 8761

eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
  server:
    enable-self-preservation: false
Eureka Client 설정 예제:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}

// Application.java
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

// application.yml
spring:
  application:
    name: user-service

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
  instance:
    prefer-ip-address: true
이렇게 설정하면 user-service가 Eureka 서버에 자동으로 등록돼. 다른 서비스에서는 "user-service"라는 이름으로 이 서비스를 찾을 수 있어! 정말 편리하지? 😊

2. API Gateway (API 게이트웨이) 🚪

클라이언트가 여러 마이크로서비스를 직접 호출하면 복잡해져. 각 서비스의 주소를 다 알아야 하고, 인증도 각각 해야 하고... 이런 문제를 해결하는 게 API Gateway야!

Spring Cloud에서는 Spring Cloud Gateway를 제공해. 이전에는 Zuul을 많이 썼는데, 요즘은 Spring Cloud Gateway가 대세야. 비동기 방식으로 동작해서 성능이 훨씬 좋거든! 🚀

🎯 API Gateway의 주요 기능 • 라우팅: 클라이언트 요청을 적절한 마이크로서비스로 전달
• 인증/인가: 중앙화된 보안 처리
• 로드 밸런싱: 여러 인스턴스에 요청 분산
• 로깅/모니터링: 모든 요청을 한 곳에서 추적
• Rate Limiting: API 호출 횟수 제한
• 응답 캐싱: 자주 사용되는 데이터 캐싱
Spring Cloud Gateway 설정 예제:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}

// application.yml
spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/api/users/**
          filters:
            - RewritePath=/api/users/(?<segment>.*), /${segment}
            
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/api/orders/**
          filters:
            - RewritePath=/api/orders/(?<segment>.*), /${segment}
            - AddRequestHeader=X-Request-Source, gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

server:
  port: 8080
여기서 lb://USER-SERVICE의 'lb'는 Load Balancer를 의미해. Eureka에서 USER-SERVICE를 찾아서 자동으로 로드 밸런싱을 해주는 거야! 👏

커스텀 필터 만들기:

@Component
public class CustomGatewayFilter implements GlobalFilter, Ordered {
    
    private static final Logger logger = LoggerFactory.getLogger(CustomGatewayFilter.class);
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        logger.info("요청 경로: {}", exchange.getRequest().getPath());
        logger.info("요청 메서드: {}", exchange.getRequest().getMethod());
        
        // 요청 전처리
        ServerHttpRequest request = exchange.getRequest().mutate()
            .header("X-Gateway-Timestamp", String.valueOf(System.currentTimeMillis()))
            .build();
        
        return chain.filter(exchange.mutate().request(request).build())
            .then(Mono.fromRunnable(() -> {
                // 응답 후처리
                logger.info("응답 상태 코드: {}", 
                    exchange.getResponse().getStatusCode());
            }));
    }
    
    @Override
    public int getOrder() {
        return -1; // 필터 실행 순서
    }
}

3. Configuration Management (설정 관리) ⚙️

마이크로서비스가 많아지면 설정 파일 관리가 정말 골치 아파져. 각 서비스마다 application.yml이 있고, 환경별로(개발/스테이징/운영) 다른 설정이 필요하거든. 😰

Spring Cloud Config는 이런 설정들을 중앙에서 관리할 수 있게 해줘. Git 저장소에 설정 파일들을 올려두고, Config Server를 통해 각 서비스가 필요한 설정을 가져가는 방식이야.

Config Server 설정:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-config-server'
}

// Application.java
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}

// application.yml
server:
  port: 8888

spring:
  application:
    name: config-server
  cloud:
    config:
      server:
        git:
          uri: https://github.com/your-org/config-repo
          default-label: main
          search-paths: '{application}'
          username: your-username
          password: your-token
Config Client 설정:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

// bootstrap.yml (application.yml보다 먼저 로드됨)
spring:
  application:
    name: user-service
  cloud:
    config:
      uri: http://localhost:8888
      fail-fast: true
      retry:
        initial-interval: 1000
        max-attempts: 6

management:
  endpoints:
    web:
      exposure:
        include: refresh
Git 저장소의 설정 파일 구조는 이렇게 만들면 돼:

config-repo/
├── user-service.yml          # 기본 설정
├── user-service-dev.yml      # 개발 환경
├── user-service-prod.yml     # 운영 환경
├── order-service.yml
└── application.yml           # 모든 서비스 공통 설정
💡 실무 팁: 설정이 변경되었을 때 서비스를 재시작하지 않고 반영하려면 @RefreshScope 어노테이션을 사용하고, POST /actuator/refresh 엔드포인트를 호출하면 돼! Spring Cloud Bus를 사용하면 모든 서비스에 한 번에 변경사항을 전파할 수도 있어.

4. Circuit Breaker (서킷 브레이커) 🔌

분산 시스템에서 가장 무서운 게 뭔지 알아? 바로 연쇄 장애야! 😱 서비스 A가 서비스 B를 호출하는데, B가 느려지거나 다운되면 A도 같이 느려지고, A를 호출하는 다른 서비스들도 영향을 받아. 마치 도미노처럼 말이야.

Circuit Breaker는 이런 연쇄 장애를 방지하는 패턴이야. 전기 회로의 차단기처럼 동작해서, 문제가 있는 서비스로의 호출을 차단하고 대체 로직(fallback)을 실행하는 거지.

Spring Cloud에서는 Resilience4j를 주로 사용해. 예전에는 Hystrix를 많이 썼는데, Netflix가 유지보수를 중단해서 요즘은 Resilience4j가 표준이야.

상태 설명 동작
Closed 정상 상태 모든 요청이 정상적으로 전달됨
Open 장애 감지 모든 요청이 즉시 실패하고 fallback 실행
Half-Open 회복 시도 일부 요청만 허용하여 서비스 상태 확인
Resilience4j 설정 예제:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
}

// application.yml
resilience4j:
  circuitbreaker:
    instances:
      orderService:
        register-health-indicator: true
        sliding-window-size: 10
        minimum-number-of-calls: 5
        permitted-number-of-calls-in-half-open-state: 3
        wait-duration-in-open-state: 10s
        failure-rate-threshold: 50
        slow-call-rate-threshold: 100
        slow-call-duration-threshold: 3s
        
  retry:
    instances:
      orderService:
        max-attempts: 3
        wait-duration: 1s
        
  timelimiter:
    instances:
      orderService:
        timeout-duration: 2s

management:
  health:
    circuitbreakers:
      enabled: true
  endpoints:
    web:
      exposure:
        include: health
Circuit Breaker 사용 예제:

@Service
public class OrderService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @CircuitBreaker(name = "orderService", fallbackMethod = "getOrderFallback")
    @Retry(name = "orderService")
    @TimeLimiter(name = "orderService")
    public CompletableFuture<Order> getOrder(Long orderId) {
        return CompletableFuture.supplyAsync(() -> {
            String url = "http://ORDER-SERVICE/orders/" + orderId;
            return restTemplate.getForObject(url, Order.class);
        });
    }
    
    // Fallback 메서드
    private CompletableFuture<Order> getOrderFallback(Long orderId, Exception ex) {
        logger.error("주문 서비스 호출 실패, Fallback 실행: {}", ex.getMessage());
        
        // 캐시에서 가져오거나 기본값 반환
        Order defaultOrder = new Order();
        defaultOrder.setId(orderId);
        defaultOrder.setStatus("UNAVAILABLE");
        defaultOrder.setMessage("주문 정보를 일시적으로 가져올 수 없습니다.");
        
        return CompletableFuture.completedFuture(defaultOrder);
    }
}
이렇게 하면 order-service가 다운되어도 전체 시스템이 멈추지 않아! 사용자에게는 "일시적으로 정보를 가져올 수 없습니다"라는 메시지를 보여주고, 시스템은 계속 동작하는 거지. 정말 똑똑하지? 🧠

5. Load Balancing (로드 밸런싱) ⚖️

마이크로서비스는 보통 여러 인스턴스로 실행돼. 트래픽이 많을 때 확장하기 위해서지. 그럼 여러 인스턴스 중 어디로 요청을 보낼지 결정해야 하는데, 이게 바로 로드 밸런싱이야!

Spring Cloud에서는 Spring Cloud LoadBalancer를 사용해. 예전에는 Ribbon을 썼는데, 이것도 유지보수가 중단되어서 Spring Cloud LoadBalancer로 넘어왔어.

RestTemplate에 로드 밸런싱 적용:

@Configuration
public class RestTemplateConfig {
    
    @Bean
    @LoadBalanced  // 이 어노테이션이 핵심!
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

@Service
public class UserService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    public Order getOrder(Long orderId) {
        // 서비스 이름으로 호출하면 자동으로 로드 밸런싱됨
        String url = "http://ORDER-SERVICE/orders/" + orderId;
        return restTemplate.getForObject(url, Order.class);
    }
}
WebClient에 로드 밸런싱 적용 (비동기 방식):

@Configuration
public class WebClientConfig {
    
    @Bean
    @LoadBalanced
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }
}

@Service
public class UserService {
    
    @Autowired
    private WebClient.Builder webClientBuilder;
    
    public Mono<Order> getOrder(Long orderId) {
        return webClientBuilder.build()
            .get()
            .uri("http://ORDER-SERVICE/orders/{id}", orderId)
            .retrieve()
            .bodyToMono(Order.class);
    }
}
🎯 로드 밸런싱 알고리즘 • Round Robin (기본값): 순서대로 돌아가면서 요청 분배
• Random: 무작위로 인스턴스 선택
• Weighted Response Time: 응답 시간이 빠른 인스턴스에 더 많은 요청
• Zone Aware: 같은 가용 영역(Zone)의 인스턴스 우선 선택
커스텀 로드 밸런싱 전략:

@Configuration
public class LoadBalancerConfig {
    
    @Bean
    public ServiceInstanceListSupplier serviceInstanceListSupplier(
            ConfigurableApplicationContext context) {
        return ServiceInstanceListSupplier.builder()
            .withDiscoveryClient()
            .withHealthChecks()  // 헬스 체크 추가
            .withCaching()       // 캐싱 추가
            .build(context);
    }
}

6. Distributed Tracing (분산 추적) 🔍

마이크로서비스 환경에서 디버깅이 어려운 이유가 뭘까? 하나의 요청이 여러 서비스를 거쳐가기 때문이야! 사용자가 주문을 하면 User Service → Order Service → Payment Service → Notification Service 이렇게 여러 서비스를 거치는데, 중간에 문제가 생기면 어디서 발생했는지 찾기가 정말 힘들어. 😓

Spring Cloud Sleuth와 Zipkin을 사용하면 이 문제를 해결할 수 있어! Sleuth는 각 요청에 고유한 Trace ID를 부여하고, 모든 로그에 이 ID를 포함시켜. Zipkin은 이 정보들을 수집해서 시각화해주는 도구야.

Sleuth와 Zipkin 설정:

// build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-sleuth'
    implementation 'org.springframework.cloud:spring-cloud-sleuth-zipkin'
}

// application.yml
spring:
  application:
    name: user-service
  sleuth:
    sampler:
      probability: 1.0  # 100% 샘플링 (운영에서는 0.1 정도 권장)
  zipkin:
    base-url: http://localhost:9411
    sender:
      type: web

logging:
  level:
    org.springframework.cloud.sleuth: DEBUG
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
이렇게 설정하면 로그가 이렇게 찍혀:

INFO [user-service,a1b2c3d4e5f6g7h8,1234567890abcdef] 사용자 정보 조회: userId=123
INFO [order-service,a1b2c3d4e5f6g7h8,fedcba0987654321] 주문 생성: orderId=456
INFO [payment-service,a1b2c3d4e5f6g7h8,9876543210fedcba] 결제 처리: paymentId=789
같은 Trace ID(a1b2c3d4e5f6g7h8)를 가진 로그들을 추적하면 전체 흐름을 파악할 수 있어! Zipkin UI에서는 이걸 타임라인으로 예쁘게 보여줘. 각 서비스에서 얼마나 시간이 걸렸는지, 어디서 병목이 발생했는지 한눈에 알 수 있지! 👀

Zipkin 서버 실행 (Docker 사용):

docker run -d -p 9411:9411 openzipkin/zipkin
그리고 브라우저에서 http://localhost:9411 로 접속하면 멋진 UI를 볼 수 있어!

🏗️ 실전 프로젝트: 온라인 쇼핑몰 구축하기

이론만 배우면 재미없지! 실제로 간단한 온라인 쇼핑몰을 만들어보면서 Spring Cloud의 모든 컴포넌트를 활용해보자. 🛒✨

📋 프로젝트 구조
우리가 만들 시스템은 이렇게 구성될 거야:

🏢 마이크로서비스 구성 1. Eureka Server: 서비스 레지스트리
2. Config Server: 중앙 설정 관리
3. API Gateway: 단일 진입점
4. User Service: 회원 관리
5. Product Service: 상품 관리
6. Order Service: 주문 처리
7. Payment Service: 결제 처리
8. Notification Service: 알림 발송

Step 1: 프로젝트 초기 설정 🎬

먼저 멀티 모듈 프로젝트를 구성해보자. Gradle을 사용할 거야!

루트 프로젝트 settings.gradle:

rootProject.name = 'shopping-mall-microservices'

include 'eureka-server'
include 'config-server'
include 'api-gateway'
include 'user-service'
include 'product-service'
include 'order-service'
include 'payment-service'
include 'notification-service'
include 'common'
루트 프로젝트 build.gradle:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.5' apply false
    id 'io.spring.dependency-management' version '1.1.3' apply false
}

ext {
    set('springCloudVersion', "2022.0.4")
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'
    
    group = 'com.shoppingmall'
    version = '1.0.0'
    sourceCompatibility = '17'
    
    repositories {
        mavenCentral()
    }
    
    dependencyManagement {
        imports {
            mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
        }
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-actuator'
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
    }
    
    tasks.named('test') {
        useJUnitPlatform()
    }
}

Step 2: Common 모듈 만들기 📦

모든 서비스에서 공통으로 사용할 클래스들을 모아두자!

// common/src/main/java/com/shoppingmall/common/dto/ApiResponse.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private LocalDateTime timestamp;
    
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, "Success", data, LocalDateTime.now());
    }
    
    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(false, message, null, LocalDateTime.now());
    }
}

// common/src/main/java/com/shoppingmall/common/exception/GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleResourceNotFound(
            ResourceNotFoundException ex) {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(ApiResponse.error(ex.getMessage()));
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ApiResponse.error("서버 오류가 발생했습니다: " + ex.getMessage()));
    }
}

Step 3: User Service 구현 👤

회원 관리 서비스를 만들어보자!

// user-service/build.gradle
dependencies {
    implementation project(':common')
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
    runtimeOnly 'com.h2database:h2'
}

// User.java
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String name;
    
    private String phone;
    private String address;
    
    @Enumerated(EnumType.STRING)
    private UserStatus status;
    
    @CreatedDate
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
}

// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

// UserService.java
@Service
@Slf4j
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    public UserDto createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException("이미 존재하는 이메일입니다.");
        }
        
        User user = new User();
        user.setEmail(request.getEmail());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setName(request.getName());
        user.setPhone(request.getPhone());
        user.setAddress(request.getAddress());
        user.setStatus(UserStatus.ACTIVE);
        
        User savedUser = userRepository.save(user);
        log.info("새로운 사용자 생성: {}", savedUser.getEmail());
        
        return UserDto.from(savedUser);
    }
    
    public UserDto getUserById(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("사용자를 찾을 수 없습니다."));
        return UserDto.from(user);
    }
    
    public UserDto updateUser(Long userId, UpdateUserRequest request) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("사용자를 찾을 수 없습니다."));
        
        if (request.getName() != null) user.setName(request.getName());
        if (request.getPhone() != null) user.setPhone(request.getPhone());
        if (request.getAddress() != null) user.setAddress(request.getAddress());
        
        User updatedUser = userRepository.save(user);
        log.info("사용자 정보 업데이트: {}", updatedUser.getEmail());
        
        return UserDto.from(updatedUser);
    }
}

// UserController.java
@RestController
@RequestMapping("/users")
@Slf4j
public class UserController {
    
    @Autowired
    private UserService userService;
    
    @PostMapping
    public ResponseEntity<ApiResponse<UserDto>> createUser(
            @Valid @RequestBody CreateUserRequest request) {
        UserDto user = userService.createUser(request);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ApiResponse.success(user));
    }
    
    @GetMapping("/{userId}")
    public ResponseEntity<ApiResponse<UserDto>> getUser(@PathVariable Long userId) {
        UserDto user = userService.getUserById(userId);
        return ResponseEntity.ok(ApiResponse.success(user));
    }
    
    @PutMapping("/{userId}")
    public ResponseEntity<ApiResponse<UserDto>> updateUser(
            @PathVariable Long userId,
            @Valid @RequestBody UpdateUserRequest request) {
        UserDto user = userService.updateUser(userId, request);
        return ResponseEntity.ok(ApiResponse.success(user));
    }
}

Step 4: Order Service 구현 📦

주문 서비스는 다른 서비스들과 통신해야 해. User Service에서 사용자 정보를 가져오고, Product Service에서 상품 정보를 가져오고, Payment Service로 결제 요청을 보내야 하지!

// OrderService.java
@Service
@Slf4j
public class OrderService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private WebClient.Builder webClientBuilder;
    
    @CircuitBreaker(name = "orderService", fallbackMethod = "createOrderFallback")
    @Retry(name = "orderService")
    public Mono<OrderDto> createOrder(CreateOrderRequest request) {
        // 1. 사용자 정보 확인
        return webClientBuilder.build()
            .get()
            .uri("http://USER-SERVICE/users/{userId}", request.getUserId())
            .retrieve()
            .bodyToMono(new ParameterizedTypeReference<ApiResponse<UserDto>>() {})
            .flatMap(userResponse -> {
                if (!userResponse.isSuccess()) {
                    return Mono.error(new ResourceNotFoundException("사용자를 찾을 수 없습니다."));
                }
                
                // 2. 상품 정보 확인
                return webClientBuilder.build()
                    .get()
                    .uri("http://PRODUCT-SERVICE/products/{productId}", 
                        request.getProductId())
                    .retrieve()
                    .bodyToMono(new ParameterizedTypeReference<ApiResponse<ProductDto>>() {});
            })
            .flatMap(productResponse -> {
                if (!productResponse.isSuccess()) {
                    return Mono.error(new ResourceNotFoundException("상품을 찾을 수 없습니다."));
                }
                
                ProductDto product = productResponse.getData();
                
                // 3. 주문 생성
                Order order = new Order();
                order.setUserId(request.getUserId());
                order.setProductId(request.getProductId());
                order.setQuantity(request.getQuantity());
                order.setTotalAmount(product.getPrice() * request.getQuantity());
                order.setStatus(OrderStatus.PENDING);
                
                Order savedOrder = orderRepository.save(order);
                log.info("주문 생성: orderId={}, userId={}", 
                    savedOrder.getId(), savedOrder.getUserId());
                
                // 4. 결제 요청
                PaymentRequest paymentRequest = new PaymentRequest();
                paymentRequest.setOrderId(savedOrder.getId());
                paymentRequest.setAmount(savedOrder.getTotalAmount());
                paymentRequest.setPaymentMethod(request.getPaymentMethod());
                
                return webClientBuilder.build()
                    .post()
                    .uri("http://PAYMENT-SERVICE/payments")
                    .bodyValue(paymentRequest)
                    .retrieve()
                    .bodyToMono(new ParameterizedTypeReference<ApiResponse<PaymentDto>>() {})
                    .map(paymentResponse -> {
                        if (paymentResponse.isSuccess()) {
                            savedOrder.setStatus(OrderStatus.PAID);
                            orderRepository.save(savedOrder);
                            log.info("결제 완료: orderId={}", savedOrder.getId());
                        }
                        return OrderDto.from(savedOrder);
                    });
            });
    }
    
    // Fallback 메서드
    private Mono<OrderDto> createOrderFallback(CreateOrderRequest request, Exception ex) {
        log.error("주문 생성 실패, Fallback 실행: {}", ex.getMessage());
        
        // 임시 주문 정보 반환
        OrderDto fallbackOrder = new OrderDto();
        fallbackOrder.setStatus(OrderStatus.FAILED);
        fallbackOrder.setMessage("일시적으로 주문을 처리할 수 없습니다. 잠시 후 다시 시도해주세요.");
        
        return Mono.just(fallbackOrder);
    }
    
    public OrderDto getOrderById(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new ResourceNotFoundException("주문을 찾을 수 없습니다."));
        return OrderDto.from(order);
    }
    
    public List<OrderDto> getOrdersByUserId(Long userId) {
        List<Order> orders = orderRepository.findByUserId(userId);
        return orders.stream()
            .map(OrderDto::from)
            .collect(Collectors.toList());
    }
}

Step 5: API Gateway 라우팅 설정 🚪

이제 모든 서비스를 Gateway를 통해 접근할 수 있도록 설정하자!

// application.yml
spring:
  cloud:
    gateway:
      routes:
        # User Service
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/api/users/**
          filters:
            - RewritePath=/api/users/(?<segment>.*), /users/${segment}
            - name: CircuitBreaker
              args:
                name: userServiceCircuitBreaker
                fallbackUri: forward:/fallback/users
        
        # Product Service
        - id: product-service
          uri: lb://PRODUCT-SERVICE
          predicates:
            - Path=/api/products/**
          filters:
            - RewritePath=/api/products/(?<segment>.*), /products/${segment}
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
        
        # Order Service
        - id: order-service
          uri: lb://ORDER-SERVICE
          predicates:
            - Path=/api/orders/**
          filters:
            - RewritePath=/api/orders/(?<segment>.*), /orders/${segment}
            - AddRequestHeader=X-Request-Source, api-gateway
        
        # Payment Service
        - id: payment-service
          uri: lb://PAYMENT-SERVICE
          predicates:
            - Path=/api/payments/**
          filters:
            - RewritePath=/api/payments/(?<segment>.*), /payments/${segment}
      
      default-filters:
        - name: GlobalLoggingFilter
        - name: AuthenticationFilter

// FallbackController.java
@RestController
@RequestMapping("/fallback")
public class FallbackController {
    
    @GetMapping("/users")
    public ResponseEntity<ApiResponse<Void>> userServiceFallback() {
        return ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(ApiResponse.error("사용자 서비스가 일시적으로 사용 불가능합니다."));
    }
    
    @GetMapping("/products")
    public ResponseEntity<ApiResponse<Void>> productServiceFallback() {
        return ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(ApiResponse.error("상품 서비스가 일시적으로 사용 불가능합니다."));
    }
}

Step 6: 서비스 간 통신 최적화 ⚡

서비스 간 통신을 더 효율적으로 만들기 위해 몇 가지 최적화를 해보자!

1. 연결 풀 설정:

@Configuration
public class WebClientConfig {
    
    @Bean
    @LoadBalanced
    public WebClient.Builder webClientBuilder() {
        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
            .responseTimeout(Duration.ofSeconds(5))
            .doOnConnected(conn -> 
                conn.addHandlerLast(new ReadTimeoutHandler(5))
                    .addHandlerLast(new WriteTimeoutHandler(5)));
        
        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .filter(ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
                log.info("Request: {} {}", 
                    clientRequest.method(), clientRequest.url());
                return Mono.just(clientRequest);
            }))
            .filter(ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
                log.info("Response Status: {}", clientResponse.statusCode());
                return Mono.just(clientResponse);
            }));
    }
}
2. 캐싱 전략:

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(Arrays.asList(
            new ConcurrentMapCache("users"),
            new ConcurrentMapCache("products"),
            new ConcurrentMapCache("orders")
        ));
        return cacheManager;
    }
}

@Service
public class ProductService {
    
    @Cacheable(value = "products", key = "#productId")
    public ProductDto getProductById(Long productId) {
        // DB 조회
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ResourceNotFoundException("상품을 찾을 수 없습니다."));
        return ProductDto.from(product);
    }
    
    @CacheEvict(value = "products", key = "#productId")
    public void updateProduct(Long productId, UpdateProductRequest request) {
        // 상품 업데이트
    }
}
3. 비동기 처리:

@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

@Service
public class NotificationService {
    
    @Async("taskExecutor")
    public CompletableFuture<Void> sendOrderConfirmation(Long orderId) {
        log.info("주문 확인 이메일 발송 시작: orderId={}", orderId);
        
        // 이메일 발송 로직
        try {
            Thread.sleep(2000); // 시뮬레이션
            log.info("주문 확인 이메일 발송 완료: orderId={}", orderId);
        } catch (InterruptedException e) {
            log.error("이메일 발송 실패", e);
        }
        
        return CompletableFuture.completedFuture(null);
    }
}

🔒 보안 강화하기

분산 시스템에서 보안은 정말 중요해! 여러 서비스가 네트워크로 통신하다 보니 보안 취약점이 생기기 쉽거든. 하나씩 보안을 강화해보자! 🛡️

1. JWT 기반 인증 구현 🔐

// build.gradle
dependencies {
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

// JwtTokenProvider.java
@Component
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String secretKey;
    
    @Value("${jwt.expiration}")
    private long validityInMilliseconds;
    
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }
    
    public String createToken(String username, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(username);
        claims.put("roles", roles);
        
        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds);
        
        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(validity)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    }
    
    public String getUsername(String token) {
        return Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody()
            .getSubject();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

// AuthenticationFilter.java (Gateway)
@Component
public class AuthenticationFilter implements GatewayFilter {
    
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        
        // 인증이 필요 없는 경로
        if (isPublicPath(request.getPath().toString())) {
            return chain.filter(exchange);
        }
        
        // Authorization 헤더 확인
        if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
            return onError(exchange, "인증 토큰이 없습니다.", HttpStatus.UNAUTHORIZED);
        }
        
        String authHeader = request.getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return onError(exchange, "유효하지 않은 토큰 형식입니다.", 
                HttpStatus.UNAUTHORIZED);
        }
        
        String token = authHeader.substring(7);
        
        // 토큰 검증
        if (!jwtTokenProvider.validateToken(token)) {
            return onError(exchange, "유효하지 않은 토큰입니다.", 
                HttpStatus.UNAUTHORIZED);
        }
        
        // 사용자 정보를 헤더에 추가
        String username = jwtTokenProvider.getUsername(token);
        ServerHttpRequest modifiedRequest = request.mutate()
            .header("X-User-Id", username)
            .build();
        
        return chain.filter(exchange.mutate().request(modifiedRequest).build());
    }
    
    private Mono<Void> onError(ServerWebExchange exchange, 
                               String message, 
                               HttpStatus status) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(status);
        return response.setComplete();
    }
    
    private boolean isPublicPath(String path) {
        return path.contains("/auth/") || 
               path.contains("/public/") ||
               path.contains("/actuator/");
    }
}

2. HTTPS 통신 설정 🔒

// application.yml
server:
  port: 8443
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-password: changeit
    key-store-type: PKCS12
    key-alias: tomcat

// 개발 환경에서 자체 서명 인증서 생성
// keytool -genkeypair -alias tomcat -keyalg RSA -keysize 2048 \
//   -storetype PKCS12 -keystore keystore.p12 -validity 3650

3. Rate Limiting 구현 ⏱️

API 남용을 방지하기 위해 요청 횟수를 제한하자!

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
}

// application.yml
spring:
  redis:
    host: localhost
    port: 6379
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/api/users/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10  # 초당 10개 요청
                redis-rate-limiter.burstCapacity: 20  # 최대 20개 버스트
                redis-rate-limiter.requestedTokens: 1

// RateLimiterConfig.java
@Configuration
public class RateLimiterConfig {
    
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            // 사용자별로 Rate Limiting
            String userId = exchange.getRequest()
                .getHeaders()
                .getFirst("X-User-Id");
            return Mono.just(userId != null ? userId : "anonymous");
        };
    }
}
💡 보안 체크리스트: ✅ 모든 서비스 간 통신은 HTTPS 사용
✅ 민감한 정보는 암호화하여 저장
✅ API Gateway에서 중앙화된 인증/인가 처리
✅ Rate Limiting으로 DDoS 공격 방어
✅ 정기적인 보안 패치 및 업데이트
✅ 로그에 민감한 정보 기록 금지

📊 모니터링과 로깅

분산 시스템을 운영하려면 모니터링이 필수야! 각 서비스의 상태를 실시간으로 확인하고, 문제가 생기면 빠르게 대응해야 하거든. 🎯

1. Spring Boot Actuator 활용 📈

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus'
}

// application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,httptrace
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: ${spring.application.name}
    export:
      prometheus:
        enabled: true

info:
  app:
    name: ${spring.application.name}
    version: @project.version@
    description: Shopping Mall Microservice

2. Prometheus와 Grafana 연동 📊

Prometheus 설정 (prometheus.yml):

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-apps'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets:
        - 'user-service:8081'
        - 'order-service:8082'
        - 'product-service:8083'
        - 'payment-service:8084'
Docker Compose로 모니터링 스택 구성:

version: '3.8'

services:
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
  
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    depends_on:
      - prometheus
  
  zipkin:
    image: openzipkin/zipkin:latest
    ports:
      - "9411:9411"

3. 구조화된 로깅 📝

// build.gradle
dependencies {
    implementation 'net.logstash.logback:logstash-logback-encoder:7.3'
}

// logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    
    <springProperty scope="context" name="applicationName" 
                    source="spring.application.name"/>
    
    <!-- Console Appender -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${applicationName}"}</customFields>
        </encoder>
    </appender>
    
    <!-- File Appender -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/${applicationName}.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/${applicationName}-%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${applicationName}"}</customFields>
        </encoder>
    </appender>
    
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

4. 커스텀 메트릭 추가 📏

@Service
public class OrderService {
    
    private final Counter orderCounter;
    private final Timer orderProcessingTimer;
    
    public OrderService(MeterRegistry meterRegistry) {
        this.orderCounter = Counter.builder("orders.created")
            .description("주문 생성 횟수")
            .tag("service", "order-service")
            .register(meterRegistry);
        
        this.orderProcessingTimer = Timer.builder("orders.processing.time")
            .description("주문 처리 시간")
            .tag("service", "order-service")
            .register(meterRegistry);
    }
    
    public OrderDto createOrder(CreateOrderRequest request) {
        return orderProcessingTimer.record(() -> {
            // 주문 처리 로직
            OrderDto order = processOrder(request);
            orderCounter.increment();
            return order;
        });
    }
}

🚀 배포 전략

이제 우리가 만든 마이크로서비스를 실제로 배포해보자! 요즘은 컨테이너 기반 배포가 대세야. Docker와 Kubernetes를 활용해볼게! 🐳☸️

1. Docker 컨테이너화 🐋

각 서비스를 Docker 이미지로 만들어보자!

Dockerfile (각 서비스마다 작성):

# Multi-stage build
FROM gradle:7.6-jdk17 AS builder
WORKDIR /app
COPY . .
RUN gradle clean build -x test

FROM openjdk:17-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8080

ENV JAVA_OPTS="-Xms256m -Xmx512m"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
docker-compose.yml (로컬 개발용):

version: '3.8'

services:
  eureka-server:
    build: ./eureka-server
    ports:
      - "8761:8761"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
    networks:
      - microservices-network
  
  config-server:
    build: ./config-server
    ports:
      - "8888:8888"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/
    depends_on:
      - eureka-server
    networks:
      - microservices-network
  
  api-gateway:
    build: ./api-gateway
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/
      - SPRING_CLOUD_CONFIG_URI=http://config-server:8888
    depends_on:
      - eureka-server
      - config-server
    networks:
      - microservices-network
  
  user-service:
    build: ./user-service
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/
      - SPRING_CLOUD_CONFIG_URI=http://config-server:8888
    depends_on:
      - eureka-server
      - config-server
      - postgres
    networks:
      - microservices-network
  
  order-service:
    build: ./order-service
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/
      - SPRING_CLOUD_CONFIG_URI=http://config-server:8888
    depends_on:
      - eureka-server
      - config-server
      - postgres
    networks:
      - microservices-network
  
  postgres:
    image: postgres:15
    environment:
      - POSTGRES_DB=shopping_mall
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=admin123
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - microservices-network
  
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    networks:
      - microservices-network

networks:
  microservices-network:
    driver: bridge

volumes:
  postgres-data:

2. Kubernetes 배포 ☸️

프로덕션 환경에서는 Kubernetes를 사용하는 게 좋아! 자동 스케일링, 자가 치유, 롤링 업데이트 등 강력한 기능들을 제공하거든.

Deployment 예제 (user-service):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
  labels:
    app: user-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: user-service
  template:
    metadata:
      labels:
        app: user-service
    spec:
      containers:
      - name: user-service
        image: shopping-mall/user-service:1.0.0
        ports:
        - containerPort: 8080
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "kubernetes"
        - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE
          value: "http://eureka-server:8761/eureka/"
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: user-service
  ports:
  - protocol: TCP
    port: 8080
    targetPort: 8080
  type: ClusterIP
HorizontalPodAutoscaler 설정:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: user-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: user-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

3. CI/CD 파이프라인 구축 🔄

GitHub Actions를 사용한 자동 배포 파이프라인을 만들어보자!

.github/workflows/deploy.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
    
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    
    - name: Build with Gradle
      run: ./gradlew clean build
    
    - name: Run tests
      run: ./gradlew test
    
    - name: Upload test results
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: test-results
        path: '**/build/test-results/**/*.xml'
  
  build-docker-images:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    strategy:
      matrix:
        service: [user-service, order-service, product-service, payment-service]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    
    - name: Login to Docker Hub
      uses: docker/login-action@v2
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}
    
    - name: Build and push
      uses: docker/build-push-action@v4
      with:
        context: ./${{ matrix.service }}
        push: true
        tags: shopping-mall/${{ matrix.service }}:${{ github.sha }}
        cache-from: type=registry,ref=shopping-mall/${{ matrix.service }}:latest
        cache-to: type=inline
  
  deploy-to-kubernetes:
    needs: build-docker-images
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Configure kubectl
      uses: azure/k8s-set-context@v3
      with:
        method: kubeconfig
        kubeconfig: ${{ secrets.KUBE_CONFIG }}
    
    - name: Deploy to Kubernetes
      run: |
        kubectl set image deployment/user-service \
          user-service=shopping-mall/user-service:${{ github.sha }}
        kubectl set image deployment/order-service \
          order-service=shopping-mall/order-service:${{ github.sha }}
        kubectl rollout status deployment/user-service
        kubectl rollout status deployment/order-service
🎯 배포 전략 비교 Rolling Update (기본값):
• 점진적으로 새 버전으로 교체
• 무중단 배포 가능
• 롤백이 쉬움

Blue-Green Deployment:
• 새 버전을 완전히 별도로 배포
• 트래픽을 한 번에 전환
• 빠른 롤백 가능

Canary Deployment:
• 일부 사용자에게만 새 버전 제공
• 점진적으로 트래픽 증가
• 리스크 최소화

🎓 실무 팁과 베스트 프랙티스

이제 실전에서 정말 유용한 팁들을 공유할게! 내가 프로젝트하면서 배운 것들이야. 😊

1. 서비스 분리 기준 ✂️

💡 언제 서비스를 분리해야 할까? ✅ 분리하면 좋은 경우:
• 독립적으로 배포해야 하는 기능
• 확장 요구사항이 다른 기능
• 다른 팀이 담당하는 기능
• 기술 스택이 달라야 하는 기능

❌ 분리하지 않는 게 좋은 경우:
• 강하게 결합된 기능들
• 트랜잭션이 필수적인 기능
• 통신 오버헤드가 큰 경우
• 팀 규모가 작은 경우

2. 데이터 일관성 관리 🔄

분산 시스템에서 가장 어려운 문제 중 하나가 데이터 일관성이야. 여러 서비스가 각자의 데이터베이스를 가지고 있으니까 말이야.

Saga 패턴 구현:

@Service
public class OrderSagaOrchestrator {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private InventoryService inventoryService;
    
    @Autowired
    private NotificationService notificationService;
    
    @Transactional
    public OrderDto createOrderWithSaga(CreateOrderRequest request) {
        // 1. 주문 생성
        OrderDto order = orderService.createOrder(request);
        
        try {
            // 2. 재고 확인 및 예약
            inventoryService.reserveInventory(
                request.getProductId(), 
                request.getQuantity()
            );
            
            try {
                // 3. 결제 처리
                PaymentDto payment = paymentService.processPayment(
                    order.getId(), 
                    order.getTotalAmount()
                );
                
                // 4. 주문 확정
                order = orderService.confirmOrder(order.getId());
                
                // 5. 알림 발송 (비동기)
                notificationService.sendOrderConfirmation(order.getId());
                
                return order;
                
            } catch (PaymentException e) {
                // 결제 실패 시 재고 복구
                inventoryService.releaseInventory(
                    request.getProductId(), 
                    request.getQuantity()
                );
                throw e;
            }
            
        } catch (InventoryException e) {
            // 재고 부족 시 주문 취소
            orderService.cancelOrder(order.getId());
            throw e;
        }
    }
}
이벤트 기반 Saga (더 나은 방법):

// OrderService.java
@Service
public class OrderService {
    
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    public OrderDto createOrder(CreateOrderRequest request) {
        Order order = new Order();
        // ... 주문 생성 로직
        Order savedOrder = orderRepository.save(order);
        
        // 이벤트 발행
        eventPublisher.publishEvent(new OrderCreatedEvent(savedOrder));
        
        return OrderDto.from(savedOrder);
    }
}

// OrderCreatedEvent.java
public class OrderCreatedEvent {
    private final Order order;
    
    public OrderCreatedEvent(Order order) {
        this.order = order;
    }
    
    public Order getOrder() {
        return order;
    }
}

// InventoryEventListener.java
@Component
public class InventoryEventListener {
    
    @Autowired
    private InventoryService inventoryService;
    
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    @EventListener
    @Async
    public void handleOrderCreated(OrderCreatedEvent event) {
        try {
            inventoryService.reserveInventory(
                event.getOrder().getProductId(),
                event.getOrder().getQuantity()
            );
            
            // 재고 예약 성공 이벤트 발행
            eventPublisher.publishEvent(
                new InventoryReservedEvent(event.getOrder())
            );
            
        } catch (InventoryException e) {
            // 재고 예약 실패 이벤트 발행
            eventPublisher.publishEvent(
                new InventoryReservationFailedEvent(event.getOrder(), e)
            );
        }
    }
}

3. 성능 최적화 ⚡

🚀 성능 향상 체크리스트 1. 데이터베이스 최적화:
• 적절한 인덱스 설정
• N+1 쿼리 문제 해결 (Fetch Join 사용)
• 커넥션 풀 튜닝
• 읽기 전용 쿼리는 @Transactional(readOnly = true) 사용

2. 캐싱 전략:
• 자주 조회되는 데이터는 Redis에 캐싱
• Cache-Aside 패턴 활용
• TTL 적절히 설정
• 캐시 워밍업 구현

3. 비동기 처리:
• 시간이 오래 걸리는 작업은 비동기로
• 메시지 큐 활용 (RabbitMQ, Kafka)
• CompletableFuture 활용

4. API 최적화:
• 페이지네이션 구현
• 필요한 필드만 조회 (Projection)
• Batch API 제공
• GraphQL 고려
Redis 캐싱 예제:

@Configuration
@EnableRedisRepositories
public class RedisConfig {
    
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = 
            new RedisStandaloneConfiguration("localhost", 6379);
        return new LettuceConnectionFactory(config);
    }
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

@Service
public class ProductService {
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String CACHE_KEY_PREFIX = "product:";
    private static final long CACHE_TTL = 3600; // 1시간
    
    public ProductDto getProductById(Long productId) {
        String cacheKey = CACHE_KEY_PREFIX + productId;
        
        // 1. 캐시 확인
        ProductDto cachedProduct = (ProductDto) redisTemplate
            .opsForValue()
            .get(cacheKey);
        
        if (cachedProduct != null) {
            log.info("캐시에서 상품 조회: productId={}", productId);
            return cachedProduct;
        }
        
        // 2. DB 조회
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ResourceNotFoundException("상품을 찾을 수 없습니다."));
        
        ProductDto productDto = ProductDto.from(product);
        
        // 3. 캐시 저장
        redisTemplate.opsForValue().set(
            cacheKey, 
            productDto, 
            CACHE_TTL, 
            TimeUnit.SECONDS
        );
        
        log.info("DB에서 상품 조회 후 캐싱: productId={}", productId);
        return productDto;
    }
    
    public void updateProduct(Long productId, UpdateProductRequest request) {
        // 상품 업데이트 로직
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ResourceNotFoundException("상품을 찾을 수 없습니다."));
        
        // ... 업데이트 로직
        
        productRepository.save(product);
        
        // 캐시 무효화
        String cacheKey = CACHE_KEY_PREFIX + productId;
        redisTemplate.delete(cacheKey);
        
        log.info("상품 업데이트 및 캐시 무효화: productId={}", productId);
    }
}

4. 에러 처리 전략 🚨

// 공통 에러 응답 형식
@Data
@AllArgsConstructor
public class ErrorResponse {
    private String errorCode;
    private String message;
    private LocalDateTime timestamp;
    private String path;
    private Map<String, String> details;
}

// 전역 예외 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request) {
        
        ErrorResponse error = new ErrorResponse(
            "RESOURCE_NOT_FOUND",
            ex.getMessage(),
            LocalDateTime.now(),
            request.getRequestURI(),
            null
        );
        
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(error);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {
        
        Map<String, String> details = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error -> 
            details.put(error.getField(), error.getDefaultMessage())
        );
        
        ErrorResponse error = new ErrorResponse(
            "VALIDATION_ERROR",
            "입력값 검증에 실패했습니다.",
            LocalDateTime.now(),
            request.getRequestURI(),
            details
        );
        
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(error);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex,
            HttpServletRequest request) {
        
        log.error("예상치 못한 오류 발생", ex);
        
        ErrorResponse error = new ErrorResponse(
            "INTERNAL_SERVER_ERROR",
            "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
            LocalDateTime.now(),
            request.getRequestURI(),
            null
        );
        
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(error);
    }
}

5. 테스트 전략 🧪

마이크로서비스는 테스트가 정말 중요해! 각 서비스를 독립적으로 테스트하고, 통합 테스트도 해야 하거든.

단위 테스트:

@SpringBootTest
@AutoConfigureMockMvc
class UserServiceTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserRepository userRepository;
    
    @Test
    void 사용자_생성_성공() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("test@example.com");
        request.setPassword("password123");
        request.setName("홍길동");
        
        User user = new User();
        user.setId(1L);
        user.setEmail(request.getEmail());
        user.setName(request.getName());
        
        when(userRepository.existsByEmail(anyString())).thenReturn(false);
        when(userRepository.save(any(User.class))).thenReturn(user);
        
        // When & Then
        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.success").value(true))
            .andExpect(jsonPath("$.data.email").value("test@example.com"))
            .andExpect(jsonPath("$.data.name").value("홍길동"));
    }
    
    @Test
    void 중복_이메일_사용자_생성_실패() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("existing@example.com");
        request.setPassword("password123");
        request.setName("홍길동");
        
        when(userRepository.existsByEmail(anyString())).thenReturn(true);
        
        // When & Then
        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.message").value("이미 존재하는 이메일입니다."));
    }
}
통합 테스트 (Contract Testing):

// Spring Cloud Contract 사용
@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.shoppingmall:user-service:+:stubs:8081",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class OrderServiceIntegrationTest {
    
    @Autowired
    private OrderService orderService;
    
    @Test
    void 주문_생성_통합_테스트() {
        // Given
        CreateOrderRequest request = new CreateOrderRequest();
        request.setUserId(1L);
        request.setProductId(1L);
        request.setQuantity(2);
        
        // When
        OrderDto order = orderService.createOrder(request).block();
        
        // Then
        assertThat(order).isNotNull();
        assertThat(order.getUserId()).isEqualTo(1L);
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
    }
}

🌟 마무리하며

와! 여기까지 정말 긴 여정이었어! 🎉 우리는 Spring Cloud를 사용해서 완전한 분산 시스템을 구축하는 방법을 배웠어. Service Discovery, API Gateway, Config Server, Circuit Breaker, Distributed Tracing까지... 정말 많은 걸 다뤘지?

🎯 핵심 요약 1. 마이크로서비스 아키텍처: 큰 애플리케이션을 작은 서비스들로 분리
2. Service Discovery: Eureka로 서비스 위치 자동 관리
3. API Gateway: 단일 진입점으로 라우팅, 인증, 로드밸런싱
4. Config Server: 중앙화된 설정 관리
5. Circuit Breaker: 장애 전파 방지와 빠른 실패
6. Distributed Tracing: 요청 흐름 추적과 디버깅
7. 보안: JWT 인증, HTTPS, Rate Limiting
8. 모니터링: Prometheus, Grafana, Zipkin
9. 배포: Docker, Kubernetes, CI/CD
처음에는 복잡해 보일 수 있지만, 하나씩 차근차근 구현하다 보면 어느새 멋진 분산 시스템을 만들 수 있을 거야! 💪

실무에서 마이크로서비스를 도입할 때는 팀의 규모, 프로젝트의 복잡도, 비즈니스 요구사항을 잘 고려해야 해. 무조건 마이크로서비스가 정답은 아니거든. 작은 프로젝트라면 모놀리식으로 시작해서 필요할 때 점진적으로 분리하는 것도 좋은 전략이야.

혹시 Spring Cloud나 마이크로서비스 아키텍처에 대해 더 배우고 싶다면, 재능넷에서 관련 전문가들을 찾아볼 수 있어! 실무 경험이 풍부한 개발자들이 멘토링도 해주고, 프로젝트 컨설팅도 해주니까 정말 도움이 될 거야. 😊

💡 다음 단계 학습 추천: • Kafka를 활용한 이벤트 기반 아키텍처
• gRPC를 사용한 고성능 서비스 간 통신
• Service Mesh (Istio, Linkerd)
• Serverless 아키텍처와의 결합
• Domain-Driven Design (DDD) 적용
• CQRS와 Event Sourcing 패턴
마이크로서비스 아키텍처는 계속 발전하고 있어. 새로운 기술과 패턴들이 계속 나오니까, 꾸준히 공부하고 실험해보는 게 중요해! 그리고 무엇보다 실제 프로젝트에 적용해보면서 경험을 쌓는 게 최고의 학습 방법이야. 🚀

자, 이제 너도 Spring Cloud 마스터가 될 준비가 됐어! 멋진 분산 시스템을 만들어보자! 화이팅! 💪✨

궁금한 점이 있거나 도움이 필요하면 언제든지 재능넷 커뮤니티에서 질문해봐. 많은 개발자들이 기꺼이 도와줄 거야! 👨‍💻👩‍💻
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.