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

☁️ Spring Cloud로 분산 시스템 구축하기 🚀
마이크로서비스 아키텍처의 세계로 떠나는 즐거운 여행 ✨
요즘 IT 업계에서는 모놀리식(Monolithic) 아키텍처에서 마이크로서비스 아키텍처로 전환하는 게 대세야. 왜냐고? 🤔 간단해! 하나의 거대한 애플리케이션보다 작은 서비스들로 나눠서 관리하는 게 훨씬 효율적이거든. 마치 큰 피자 한 판을 혼자 먹는 것보다, 여러 조각으로 나눠서 친구들과 나눠 먹는 게 더 좋은 것처럼 말이야! 🍕
Spring Cloud는 이런 분산 시스템을 구축할 때 필요한 다양한 도구들을 제공하는 프레임워크야. Netflix, Amazon 같은 글로벌 기업들이 실제로 사용하고 있는 검증된 기술이지. 그럼 지금부터 본격적으로 Spring Cloud의 세계로 들어가볼까? 💪
🎯 분산 시스템이란 무엇인가?
예를 들어볼게. 너가 온라인 쇼핑몰을 운영한다고 생각해봐. 전통적인 모놀리식 방식이라면 회원 관리, 상품 관리, 주문 처리, 결제, 배송 추적 등 모든 기능이 하나의 거대한 애플리케이션 안에 들어있어. 근데 이게 문제가 뭐냐면...
2. 확장성 제한: 특정 기능만 확장하고 싶어도 전체를 확장해야 하니까 비효율적이야
3. 장애 전파: 한 부분에 문제가 생기면 전체 시스템이 다운될 수 있어
4. 기술 스택 고정: 한 번 선택한 기술을 바꾸기가 정말 어려워
5. 팀 협업 어려움: 여러 팀이 동시에 작업하면 코드 충돌이 빈번해
근데 여기서 새로운 문제가 생겨. 서비스들이 분리되면서 생기는 복잡성을 어떻게 관리할 거냐는 거지. 서비스들끼리 어떻게 통신하고, 어떻게 찾고, 설정은 어떻게 관리하고... 이런 문제들 말이야. 바로 이때 Spring Cloud가 등장하는 거야! 🎉
☁️ Spring Cloud 핵심 컴포넌트 소개
1. Service Discovery (서비스 디스커버리) 🔎
마이크로서비스 환경에서는 서비스 인스턴스들이 동적으로 생성되고 삭제돼. IP 주소나 포트 번호가 계속 바뀌는 거지. 그럼 서비스 A가 서비스 B를 호출하려면 어떻게 해야 할까? 🤷♂️바로 Service Registry가 필요해! Spring Cloud에서는 주로 Eureka나 Consul을 사용해. 모든 서비스들이 시작할 때 자신의 정보를 레지스트리에 등록하고, 다른 서비스를 호출할 때는 레지스트리에서 위치를 조회하는 거야.
// 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가 대세야. 비동기 방식으로 동작해서 성능이 훨씬 좋거든! 🚀
• 인증/인가: 중앙화된 보안 처리
• 로드 밸런싱: 여러 인스턴스에 요청 분산
• 로깅/모니터링: 모든 요청을 한 곳에서 추적
• Rate Limiting: API 호출 횟수 제한
• 응답 캐싱: 자주 사용되는 데이터 캐싱
// 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 # 모든 서비스 공통 설정
4. Circuit Breaker (서킷 브레이커) 🔌
분산 시스템에서 가장 무서운 게 뭔지 알아? 바로 연쇄 장애야! 😱 서비스 A가 서비스 B를 호출하는데, B가 느려지거나 다운되면 A도 같이 느려지고, A를 호출하는 다른 서비스들도 영향을 받아. 마치 도미노처럼 말이야.Circuit Breaker는 이런 연쇄 장애를 방지하는 패턴이야. 전기 회로의 차단기처럼 동작해서, 문제가 있는 서비스로의 호출을 차단하고 대체 로직(fallback)을 실행하는 거지.
Spring Cloud에서는 Resilience4j를 주로 사용해. 예전에는 Hystrix를 많이 썼는데, Netflix가 유지보수를 중단해서 요즘은 Resilience4j가 표준이야.
| 상태 | 설명 | 동작 |
|---|---|---|
| Closed | 정상 상태 | 모든 요청이 정상적으로 전달됨 |
| Open | 장애 감지 | 모든 요청이 즉시 실패하고 fallback 실행 |
| Half-Open | 회복 시도 | 일부 요청만 허용하여 서비스 상태 확인 |
// 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);
}
}
• 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를 볼 수 있어!
🏗️ 실전 프로젝트: 온라인 쇼핑몰 구축하기
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");
};
}
}
✅ 민감한 정보는 암호화하여 저장
✅ 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;
});
}
}
🚀 배포 전략
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
• 점진적으로 새 버전으로 교체
• 무중단 배포 가능
• 롤백이 쉬움
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. 성능 최적화 ⚡
• 적절한 인덱스 설정
• N+1 쿼리 문제 해결 (Fetch Join 사용)
• 커넥션 풀 튜닝
• 읽기 전용 쿼리는 @Transactional(readOnly = true) 사용
2. 캐싱 전략:
• 자주 조회되는 데이터는 Redis에 캐싱
• Cache-Aside 패턴 활용
• TTL 적절히 설정
• 캐시 워밍업 구현
3. 비동기 처리:
• 시간이 오래 걸리는 작업은 비동기로
• 메시지 큐 활용 (RabbitMQ, Kafka)
• CompletableFuture 활용
4. API 최적화:
• 페이지네이션 구현
• 필요한 필드만 조회 (Projection)
• Batch API 제공
• GraphQL 고려
@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);
}
}
🌟 마무리하며
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나 마이크로서비스 아키텍처에 대해 더 배우고 싶다면, 재능넷에서 관련 전문가들을 찾아볼 수 있어! 실무 경험이 풍부한 개발자들이 멘토링도 해주고, 프로젝트 컨설팅도 해주니까 정말 도움이 될 거야. 😊
• gRPC를 사용한 고성능 서비스 간 통신
• Service Mesh (Istio, Linkerd)
• Serverless 아키텍처와의 결합
• Domain-Driven Design (DDD) 적용
• CQRS와 Event Sourcing 패턴
자, 이제 너도 Spring Cloud 마스터가 될 준비가 됐어! 멋진 분산 시스템을 만들어보자! 화이팅! 💪✨
궁금한 점이 있거나 도움이 필요하면 언제든지 재능넷 커뮤니티에서 질문해봐. 많은 개발자들이 기꺼이 도와줄 거야! 👨💻👩💻
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.