Microservices 완벽 마스터
Microservices의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Spring Cloud 마이크로서비스 구축 완벽 가이드
Spring Cloud를 활용한 마이크로서비스 아키텍처 구축 방법을 초급자도 이해할 수 있도록 상세하게 설명합니다. 서비스 디스커버리, API Gateway, Config Server 등 핵심 컴포넌트를 실무 중심으로 다룹니다.
목차
- 서비스 디스커버리(Eureka Server) - 마이크로서비스의 전화번호부
- Eureka Client 설정 - 서비스를 레지스트리에 등록하기
- API Gateway(Spring Cloud Gateway) - 단일 진입점 구축
- Config Server - 중앙 집중식 설정 관리
- Resilience4j Circuit Breaker - 장애 전파 차단
- Distributed Tracing(Sleuth + Zipkin) - 분산 추적
- Message Queue(RabbitMQ/Kafka) - 비동기 통신
- Centralized Logging(ELK Stack) - 통합 로그 관리
1. 서비스 디스커버리(Eureka Server) - 마이크로서비스의 전화번호부
시작하며
여러분이 대규모 쇼핑몰 애플리케이션을 만들 때를 상상해보세요. 주문 서비스, 결제 서비스, 상품 서비스 등 수십 개의 마이크로서비스가 각기 다른 서버에서 실행되고 있습니다.
이때 주문 서비스가 결제 서비스를 호출하려면 어떻게 해야 할까요? IP 주소를 하드코딩할까요?
이런 방식은 서버가 추가되거나 IP가 변경될 때마다 코드를 수정해야 하는 악몽 같은 상황을 만듭니다. 특히 클라우드 환경에서는 서버 인스턴스가 동적으로 생성되고 삭제되기 때문에 IP를 관리하는 것이 거의 불가능합니다.
바로 이럴 때 필요한 것이 Eureka Server입니다. 모든 마이크로서비스가 자신의 위치를 등록하고, 다른 서비스를 찾을 수 있는 중앙 레지스트리 역할을 합니다.
마치 전화번호부처럼 서비스 이름만으로 위치를 찾아주는 똑똑한 시스템입니다.
개요
간단히 말해서, Eureka Server는 마이크로서비스 아키텍처에서 서비스들의 위치 정보를 관리하는 중앙 레지스트리입니다. Netflix에서 개발한 이 도구는 Spring Cloud의 핵심 컴포넌트로 자리잡았습니다.
실무에서는 서버 인스턴스가 자동으로 늘어나고 줄어드는 오토스케일링 환경이 일반적입니다. 예를 들어, 블랙프라이데이 같은 대규모 트래픽 이벤트 시 주문 서비스가 10개에서 50개로 늘어날 수 있습니다.
Eureka는 이런 동적 환경에서 각 서비스 인스턴스를 자동으로 추적합니다. 기존에는 로드밸런서 설정 파일을 수동으로 관리하고 배포했다면, 이제는 서비스가 시작되면 자동으로 Eureka에 등록되고 종료되면 자동으로 제거됩니다.
개발자는 서비스 이름만 알면 됩니다. Eureka의 핵심 특징은 첫째, 자가 보존 모드(Self Preservation)로 네트워크 장애 시에도 안정적으로 동작하고, 둘째, 하트비트 메커니즘으로 서비스 상태를 실시간 체크하며, 셋째, 클라이언트 사이드 로드밸런싱을 지원합니다.
이러한 특징들이 고가용성 시스템 구축에 필수적입니다.
코드 예제
// build.gradle - Eureka Server 의존성 추가
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
}
// EurekaServerApplication.java - Eureka Server 설정
@SpringBootApplication
@EnableEurekaServer // 이 어노테이션으로 Eureka Server 활성화
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
// application.yml - Eureka Server 설정
server:
port: 8761 // Eureka Server의 기본 포트
eureka:
client:
register-with-eureka: false // 자기 자신을 등록하지 않음
fetch-registry: false // 레지스트리 정보를 가져오지 않음
server:
enable-self-preservation: true // 자가 보존 모드 활성화
설명
이것이 하는 일: Eureka Server는 마이크로서비스 환경에서 서비스 인스턴스의 위치 정보를 중앙에서 관리하고, 각 서비스가 다른 서비스를 동적으로 찾을 수 있게 해주는 서비스 디스커버리 서버입니다. 첫 번째로, @EnableEurekaServer 어노테이션이 Spring Boot 애플리케이션을 Eureka Server로 전환합니다.
이 한 줄의 코드로 복잡한 서비스 레지스트리 시스템이 구축됩니다. Spring Cloud가 자동으로 필요한 모든 빈과 엔드포인트를 설정하기 때문입니다.
그 다음으로, application.yml의 설정이 실행되면서 Eureka Server의 동작 방식을 정의합니다. register-with-eureka와 fetch-registry를 false로 설정하는 이유는 Eureka Server 자신은 클라이언트가 아니기 때문입니다.
만약 이 설정을 빼먹으면 Eureka Server가 자기 자신에게 등록하려고 시도하면서 불필요한 로그와 에러가 발생합니다. 마지막으로, enable-self-preservation을 true로 설정하면 네트워크 장애가 발생했을 때 Eureka가 서비스 등록 정보를 급하게 삭제하지 않습니다.
실무에서는 일시적인 네트워크 문제로 하트비트가 끊길 수 있는데, 이때 모든 서비스를 제거하면 시스템 전체가 마비됩니다. 자가 보존 모드는 이런 상황을 방지합니다.
여러분이 이 코드를 사용하면 8761 포트에서 Eureka Dashboard에 접속할 수 있고, 등록된 모든 마이크로서비스를 시각적으로 확인할 수 있습니다. 실무에서는 서비스의 상태, 인스턴스 개수, 헬스체크 결과 등을 실시간으로 모니터링하면서 시스템의 건강 상태를 파악할 수 있습니다.
또한 서비스 간 통신이 안정적으로 이루어지고, 장애가 발생한 인스턴스는 자동으로 제외됩니다.
실전 팁
💡 운영 환경에서는 Eureka Server를 최소 2개 이상의 클러스터로 구성하세요. 단일 Eureka Server가 다운되면 새로운 서비스 등록이 불가능해집니다. peer-to-peer 복제로 고가용성을 확보할 수 있습니다.
💡 eureka.instance.lease-renewal-interval-in-seconds를 30초(기본값)보다 짧게 설정하면 장애 감지가 빨라지지만, 네트워크 트래픽이 증가합니다. 실무에서는 10-30초 사이에서 트래픽과 감지 속도의 균형을 맞추세요.
💡 Eureka Dashboard의 "EMERGENCY! EUREKA MAY BE INCORRECTLY CLAIMING INSTANCES ARE UP WHEN THEY'RE NOT" 경고는 자가 보존 모드가 활성화되었다는 의미입니다. 개발 환경에서는 enable-self-preservation을 false로 설정해서 빠른 테스트를 할 수 있습니다.
💡 Eureka Client가 등록된 서비스 목록을 캐싱하기 때문에, 서비스가 종료되어도 최대 30초간 다른 서비스에서 호출을 시도할 수 있습니다. 이를 고려해서 Graceful Shutdown을 구현하세요.
💡 보안을 위해 Spring Security를 추가하고 Eureka Server에 인증을 적용하세요. 운영 환경에서 누구나 Eureka Dashboard에 접근하면 시스템 구조가 노출됩니다.
2. Eureka Client 설정 - 서비스를 레지스트리에 등록하기
시작하며
Eureka Server를 구축했다면, 이제 실제 마이크로서비스들을 등록해야 합니다. 여러분이 주문 처리 서비스를 만들었다고 가정해봅시다.
이 서비스를 다른 서비스들이 찾을 수 있게 하려면 어떻게 해야 할까요? 과거에는 각 서비스의 IP와 포트를 설정 파일에 하드코딩하고, 변경 사항이 있을 때마다 모든 서비스를 재배포해야 했습니다.
이는 배포 시간을 늘리고 운영 복잡도를 높이는 주요 원인이었습니다. Eureka Client로 서비스를 등록하면 이 모든 과정이 자동화됩니다.
서비스가 시작되면 자동으로 Eureka Server에 등록되고, 30초마다 하트비트를 보내며, 종료되면 자동으로 제거됩니다.
개요
간단히 말해서, Eureka Client는 마이크로서비스를 Eureka Server에 등록하고 다른 서비스를 찾을 수 있게 해주는 라이브러리입니다. Spring Cloud Netflix의 일부로 제공됩니다.
실무에서는 수십 개의 마이크로서비스가 동시에 실행됩니다. 예를 들어, 이커머스 플랫폼에서 주문 서비스는 결제 서비스, 재고 서비스, 배송 서비스 등 여러 서비스와 통신해야 합니다.
Eureka Client를 사용하면 서비스 이름만으로 이 모든 통신을 처리할 수 있습니다. 기존에는 RestTemplate에 IP 주소를 직접 입력했다면, 이제는 서비스 이름을 사용합니다.
"http://payment-service/api/payments"처럼 논리적인 이름으로 호출하면 Eureka가 실제 인스턴스를 찾아줍니다. Eureka Client의 핵심 특징은 첫째, 자동 등록/해제로 수동 작업을 제거하고, 둘째, 클라이언트 사이드 로드밸런싱으로 서버 부하를 분산하며, 셋째, 로컬 캐싱으로 Eureka Server 장애 시에도 서비스 호출이 가능합니다.
이는 시스템의 복원력을 크게 향상시킵니다.
코드 예제
// build.gradle - Eureka Client 의존성 추가
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
// OrderServiceApplication.java - 주문 서비스
@SpringBootApplication
@EnableDiscoveryClient // Eureka Client 활성화 (생략 가능, 자동 감지됨)
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
// application.yml - Eureka Client 설정
spring:
application:
name: order-service // Eureka에 등록될 서비스 이름 (매우 중요!)
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/ // Eureka Server 주소
instance:
prefer-ip-address: true // IP 주소로 등록 (hostname 대신)
instance-id: ${spring.application.name}:${random.value} // 고유 인스턴스 ID
server:
port: 8081 // 이 서비스의 포트
설명
이것이 하는 일: Eureka Client 설정은 마이크로서비스가 시작될 때 Eureka Server에 자동으로 등록되고, 정기적으로 상태를 보고하며, 다른 서비스의 위치 정보를 조회할 수 있게 만듭니다. 첫 번째로, spring.application.name 설정이 가장 중요합니다.
이 이름이 Eureka Server에 등록되고 다른 서비스들이 이 이름으로 호출합니다. "order-service"로 등록하면 다른 서비스에서 "http://order-service/api/orders"처럼 호출할 수 있습니다.
이 이름은 절대 중복되어서는 안 되며, 한번 정하면 변경하기 어렵기 때문에 신중하게 선택해야 합니다. 그 다음으로, eureka.client.service-url.defaultZone이 Eureka Server의 위치를 지정합니다.
실무에서는 여러 Eureka Server를 쉼표로 구분해서 등록합니다. 예를 들어 "http://eureka1:8761/eureka/,http://eureka2:8761/eureka/"처럼 설정하면 하나가 다운되어도 다른 서버로 등록됩니다.
이는 단일 장애점을 제거하는 중요한 설정입니다. 마지막으로, instance-id를 random.value로 설정하면 같은 서비스의 여러 인스턴스를 실행할 때 각각 고유한 ID를 갖습니다.
Docker나 Kubernetes 환경에서 같은 서비스를 스케일아웃할 때 필수적입니다. prefer-ip-address를 true로 설정하면 hostname 대신 IP 주소로 등록되어 클라우드 환경에서 DNS 문제를 방지합니다.
여러분이 이 코드를 사용하면 서비스가 시작되자마자 Eureka Dashboard에 나타나고, 30초마다 하트비트를 보내며 살아있음을 알립니다. 실무에서는 여러 인스턴스를 실행하면 Eureka가 자동으로 로드밸런싱해주고, 하나의 인스턴스가 죽으면 다른 인스턴스로 트래픽을 전환합니다.
또한 Eureka Server가 일시적으로 다운되어도 로컬 캐시를 사용해서 서비스 간 통신이 계속 가능합니다.
실전 팁
💡 spring.application.name은 반드시 소문자와 하이픈만 사용하세요. 대문자나 언더스코어를 사용하면 DNS 호환성 문제가 발생할 수 있습니다. "OrderService" 대신 "order-service"로 작성하세요.
💡 로컬 개발 환경에서 여러 인스턴스를 테스트하려면 server.port=0으로 설정하세요. Spring Boot가 자동으로 사용 가능한 랜덤 포트를 할당해서 포트 충돌을 방지합니다.
💡 eureka.instance.lease-expiration-duration-in-seconds를 90초(기본값)보다 짧게 설정하면 장애 서비스가 빨리 제거되지만, 네트워크 지연으로 정상 서비스도 제거될 위험이 있습니다. 운영 환경에서는 기본값을 유지하는 것이 안전합니다.
💡 @EnableDiscoveryClient 어노테이션은 Spring Cloud 최신 버전에서는 생략해도 됩니다. eureka-client 의존성만 있으면 자동으로 활성화되지만, 명시적으로 표기하면 코드 가독성이 좋아집니다.
💡 컨테이너 환경(Docker, Kubernetes)에서는 eureka.instance.hostname을 명시적으로 설정하세요. 자동으로 설정된 hostname이 외부에서 접근 불가능한 내부 이름일 수 있습니다.
3. API Gateway(Spring Cloud Gateway) - 단일 진입점 구축
시작하며
여러분의 마이크로서비스 시스템에 주문, 결제, 상품, 회원 서비스가 있다고 가정해봅시다. 프론트엔드 개발자가 각 서비스의 서로 다른 URL을 모두 관리해야 한다면 얼마나 복잡할까요?
CORS 설정도 각 서비스마다 따로 해야 하고, 인증 로직도 중복됩니다. 이런 문제는 시스템이 커질수록 더 심각해집니다.
서비스가 10개, 20개로 늘어나면 프론트엔드 코드는 수많은 엔드포인트로 가득 차고, 보안 정책 변경 시 모든 서비스를 수정해야 합니다. 바로 이럴 때 필요한 것이 API Gateway입니다.
모든 클라이언트 요청을 받는 단일 진입점 역할을 하며, 라우팅, 인증, 로깅, 속도 제한 등을 중앙에서 관리합니다.
개요
간단히 말해서, Spring Cloud Gateway는 모든 마이크로서비스 앞에 위치하여 트래픽을 제어하고 라우팅하는 리버스 프록시입니다. Netflix Zuul의 후속 솔루션으로 Spring WebFlux 기반의 비동기 처리를 지원합니다.
실무에서는 보안, 모니터링, 속도 제한 등 공통 기능을 Gateway에서 처리합니다. 예를 들어, JWT 토큰 검증을 각 마이크로서비스에서 중복 구현하는 대신 Gateway에서 한 번만 처리하면 됩니다.
이는 코드 중복을 줄이고 보안 정책을 일관되게 적용할 수 있게 합니다. 기존에는 클라이언트가 각 서비스에 직접 접근했다면, 이제는 Gateway를 통해서만 접근합니다.
"https://api.mycompany.com/orders", "https://api.mycompany.com/payments"처럼 하나의 도메인으로 모든 서비스를 제공합니다. Gateway의 핵심 특징은 첫째, Route 설정으로 URL 패턴에 따라 적절한 서비스로 라우팅하고, 둘째, Filter를 통해 요청/응답을 가공하며, 셋째, Predicate로 조건부 라우팅을 수행합니다.
이러한 기능들이 복잡한 트래픽 관리를 단순화합니다.
코드 예제
// build.gradle - Spring Cloud Gateway 의존성
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}
// ApiGatewayApplication.java
@SpringBootApplication
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
// application.yml - Gateway 라우팅 설정
spring:
application:
name: api-gateway
cloud:
gateway:
routes:
- id: order-service # 라우트 ID (고유해야 함)
uri: lb://order-service # lb://는 로드밸런싱을 의미
predicates:
- Path=/api/orders/** # 이 패턴의 요청을 order-service로
filters:
- RewritePath=/api/orders/(?<segment>.*), /$\{segment} # /api/orders 제거
- id: payment-service
uri: lb://payment-service
predicates:
- Path=/api/payments/**
filters:
- RewritePath=/api/payments/(?<segment>.*), /$\{segment}
server:
port: 8080
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
설명
이것이 하는 일: Spring Cloud Gateway는 클라이언트의 모든 요청을 받아서 URL 패턴을 분석하고, 적절한 마이크로서비스로 라우팅하며, 그 과정에서 필요한 전처리와 후처리를 수행하는 스마트한 라우터입니다. 첫 번째로, routes 설정에서 각 마이크로서비스로의 경로를 정의합니다.
uri: lb://order-service의 "lb://"는 Load Balancer의 약자로, Eureka에서 찾은 여러 인스턴스 중 하나를 자동으로 선택합니다. 만약 order-service가 3개 실행 중이면 라운드로빈 방식으로 분산됩니다.
이는 별도의 로드밸런서 없이도 부하 분산을 실현합니다. 그 다음으로, predicates는 어떤 요청을 이 라우트로 보낼지 결정하는 조건입니다.
Path=/api/orders/**는 "/api/orders"로 시작하는 모든 요청을 매칭합니다. 실무에서는 Header, Method, Query 등 다양한 조건을 조합해서 복잡한 라우팅 로직을 구현할 수 있습니다.
예를 들어 특정 헤더가 있는 요청만 베타 서버로 보내는 카나리 배포가 가능합니다. 마지막으로, filters의 RewritePath는 URL을 변환합니다.
클라이언트는 "/api/orders/123"으로 요청하지만 실제 order-service는 "/123"으로 받습니다. 이렇게 하면 각 마이크로서비스는 "/api/orders" 같은 prefix를 신경 쓰지 않아도 됩니다.
또한 Gateway에서 AddRequestHeader, CircuitBreaker, RateLimiter 같은 다양한 필터를 적용해서 횡단 관심사를 처리할 수 있습니다. 여러분이 이 코드를 사용하면 클라이언트는 8080 포트의 Gateway만 알면 됩니다.
실무에서는 "https://api.company.com/api/orders", "https://api.company.com/api/payments"처럼 하나의 도메인으로 모든 서비스를 제공하고, SSL 인증서도 Gateway에만 적용하면 됩니다. 또한 Gateway에서 JWT 검증, API 키 확인, 요청 로깅을 수행하면 각 마이크로서비스는 비즈니스 로직에만 집중할 수 있습니다.
서비스가 추가되어도 routes만 추가하면 되므로 확장성이 뛰어납니다.
실전 팁
💡 운영 환경에서는 Gateway를 여러 인스턴스로 실행하고 앞단에 로드밸런서(AWS ALB, Nginx)를 두세요. Gateway 자체가 단일 장애점이 되면 전체 시스템이 마비됩니다.
💡 GlobalFilter를 구현해서 모든 요청에 공통 로직을 적용하세요. 예를 들어 요청/응답 로깅, 상관관계 ID(Correlation ID) 추가, 응답 시간 측정 등을 전역 필터로 처리하면 일관성이 유지됩니다.
💡 actuator를 추가하고 /actuator/gateway/routes 엔드포인트로 현재 라우팅 설정을 확인하세요. 설정이 제대로 적용되었는지 실시간으로 검증할 수 있어 디버깅이 쉬워집니다.
💡 Resilience4j의 CircuitBreaker 필터를 적용해서 장애 전파를 막으세요. 특정 마이크로서비스가 응답하지 않을 때 빠르게 실패하고 fallback 응답을 반환하면 전체 시스템의 안정성이 높아집니다.
💡 spring.cloud.gateway.httpclient.connect-timeout과 response-timeout을 반드시 설정하세요. 기본값은 무제한이라 느린 서비스가 Gateway의 모든 스레드를 점유할 수 있습니다. 실무에서는 5-10초가 적절합니다.
4. Config Server - 중앙 집중식 설정 관리
시작하며
여러분이 10개의 마이크로서비스를 운영하는데, 데이터베이스 URL을 변경해야 한다면 어떻게 하시겠습니까? 각 서비스의 application.yml을 수정하고 10번 재배포해야 할까요?
개발, 스테이징, 운영 환경별로 다른 설정을 관리하는 것도 악몽입니다. 이런 방식은 설정 변경 한 번에 몇 시간씩 걸리고, 실수로 잘못된 설정을 배포할 위험도 높습니다.
특히 보안과 관련된 설정(API 키, DB 비밀번호)을 코드 저장소에 커밋하는 것은 심각한 보안 위협입니다. 바로 이럴 때 필요한 것이 Config Server입니다.
모든 마이크로서비스의 설정을 Git 저장소 같은 중앙 위치에서 관리하고, 서비스 재시작 없이 설정을 갱신할 수 있습니다.
개요
간단히 말해서, Spring Cloud Config Server는 분산 시스템의 설정을 외부화하고 중앙에서 관리하는 서버입니다. Git, SVN, 파일시스템 등 다양한 백엔드를 지원합니다.
실무에서는 환경별(dev, staging, prod) 설정과 서비스별 설정을 계층적으로 관리합니다. 예를 들어, application.yml에 공통 설정을 두고, application-prod.yml에 운영 환경만의 설정을 추가하는 방식입니다.
Config Server는 이를 자동으로 병합해서 제공합니다. 기존에는 각 서비스의 resources 폴더에 설정 파일을 넣고 재배포했다면, 이제는 Git에 push하고 /actuator/refresh를 호출하거나 Spring Cloud Bus로 전체 서비스에 변경을 전파합니다.
재배포 없이 설정이 갱신됩니다. Config Server의 핵심 특징은 첫째, 버전 관리로 설정 변경 이력을 추적하고, 둘째, 환경별 프로파일로 설정을 분리하며, 셋째, 암호화를 통해 민감한 정보를 보호합니다.
이는 운영 안정성과 보안을 동시에 향상시킵니다.
코드 예제
// build.gradle - Config Server 의존성
dependencies {
implementation 'org.springframework.cloud:spring-cloud-config-server'
}
// ConfigServerApplication.java
@SpringBootApplication
@EnableConfigServer // Config Server 활성화
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
// application.yml - Config Server 설정
server:
port: 8888 # Config Server 포트
spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://github.com/your-org/config-repo # Git 저장소 URL
default-label: main # 기본 브랜치
search-paths: configs # 설정 파일이 있는 디렉토리
clone-on-start: true # 시작 시 저장소 복제
# encrypt:
# key: my-secret-key # 설정 암호화 키 (운영에서는 필수!)
설명
이것이 하는 일: Spring Cloud Config Server는 Git 저장소에서 설정 파일을 읽어와 마이크로서비스들에게 제공하고, 설정 변경 시 자동으로 감지하여 서비스에 전파하는 중앙 설정 관리 시스템입니다. 첫 번째로, @EnableConfigServer 어노테이션이 이 애플리케이션을 Config Server로 전환합니다.
Spring Boot가 자동으로 REST API 엔드포인트들을 생성해서 "/{application}/{profile}/{label}" 형식으로 설정을 제공합니다. 예를 들어 "/order-service/prod/main"을 호출하면 order-service의 운영 환경 설정을 받습니다.
그 다음으로, git.uri가 설정 파일들이 저장된 Git 저장소를 가리킵니다. 실무에서는 GitHub, GitLab, Bitbucket의 private repository를 사용하고, username과 password로 인증합니다.
search-paths는 저장소 내에서 설정 파일을 찾을 위치를 지정합니다. 만약 "configs/{application}" 같은 패턴을 사용하면 서비스별로 디렉토리를 분리할 수 있습니다.
마지막으로, clone-on-start를 true로 설정하면 Config Server가 시작될 때 Git 저장소를 미리 복제합니다. 이렇게 하면 첫 요청이 빠르고, Git 저장소가 일시적으로 다운되어도 로컬 복제본으로 서비스할 수 있습니다.
encrypt.key는 데이터베이스 비밀번호, API 키 같은 민감한 정보를 암호화하는 데 사용됩니다. 운영 환경에서는 반드시 설정해야 합니다.
여러분이 이 코드를 사용하면 Git에 application.yml, order-service.yml, payment-service-prod.yml 같은 파일을 커밋하고, 각 마이크로서비스는 8888 포트의 Config Server에서 자신의 설정을 가져옵니다. 실무에서는 설정을 변경하고 Git에 push하면 /actuator/refresh를 호출해서 재배포 없이 새 설정을 적용할 수 있습니다.
또한 Git의 브랜치와 태그를 활용해서 설정 버전을 관리하고, 문제가 생기면 이전 버전으로 롤백할 수 있습니다.
실전 팁
💡 민감한 정보는 반드시 암호화하세요. spring cloud config encrypt CLI 도구로 값을 암호화하고 {cipher}로 시작하는 문자열로 저장하면 Config Server가 자동으로 복호화합니다. 예: password: '{cipher}AQA3F...'
💡 Config Client(마이크로서비스)의 bootstrap.yml에 spring.cloud.config.fail-fast=true를 설정하세요. Config Server에 연결할 수 없으면 서비스 시작을 중단해서 잘못된 설정으로 실행되는 것을 방지합니다.
💡 Spring Cloud Bus와 RabbitMQ를 조합하면 한 번의 /bus-refresh 호출로 모든 마이크로서비스에 설정 변경을 전파할 수 있습니다. 각 서비스마다 refresh를 호출할 필요가 없어집니다.
💡 Git 저장소에 .gitignore를 잘 설정하세요. 암호화되지 않은 비밀번호나 API 키가 실수로 커밋되면 보안 사고로 이어집니다. pre-commit hook으로 검증하는 것도 좋습니다.
💡 Config Server 자체도 고가용성을 위해 여러 인스턴스로 실행하고, 각 Config Client에 여러 Config Server URL을 설정하세요. 예: spring.cloud.config.uri=http://config1:8888,http://config2:8888
5. Resilience4j Circuit Breaker - 장애 전파 차단
시작하며
여러분의 주문 서비스가 결제 서비스를 호출하는데, 결제 서비스가 응답하지 않는다면 어떻게 될까요? 주문 서비스도 멈춰버리고, 결국 전체 시스템이 마비됩니다.
이를 "장애 전파(Cascading Failure)"라고 합니다. 실무에서는 한 서비스의 장애가 연쇄적으로 다른 서비스로 퍼지면서 시스템 전체가 다운되는 경우가 빈번합니다.
특히 블랙프라이데이 같은 트래픽 급증 상황에서 한 서비스가 과부하되면 도미노처럼 무너집니다. 바로 이럴 때 필요한 것이 Circuit Breaker입니다.
문제가 있는 서비스로의 호출을 자동으로 차단하고, 빠르게 실패하거나 대체 응답(fallback)을 반환해서 시스템을 보호합니다.
개요
간단히 말해서, Resilience4j Circuit Breaker는 장애가 발생한 서비스로의 요청을 자동으로 차단하고, 일정 시간 후 복구를 시도하는 패턴을 구현한 라이브러리입니다. Netflix Hystrix의 후속으로 더 가볍고 Java 8 친화적입니다.
실무에서는 외부 API 호출, 데이터베이스 쿼리, 다른 마이크로서비스 호출 등 실패 가능성이 있는 모든 곳에 적용합니다. 예를 들어, 결제 서비스가 다운되었을 때 주문 서비스는 "결제가 일시적으로 불가능합니다"라는 메시지를 즉시 반환하고, 주문 정보는 큐에 저장해서 나중에 처리할 수 있습니다.
기존에는 타임아웃만 설정해서 30초를 기다렸다가 실패했다면, 이제는 Circuit Breaker가 열려있으면 즉시 실패해서 응답 시간이 밀리초 단위로 줄어듭니다. 사용자 경험이 극적으로 개선됩니다.
Circuit Breaker의 핵심 특징은 첫째, CLOSED(정상) → OPEN(차단) → HALF_OPEN(복구 시도) 세 가지 상태를 자동으로 전환하고, 둘째, 실패율 기반으로 차단 여부를 결정하며, 셋째, fallback 메서드로 대체 응답을 제공합니다. 이는 시스템의 복원력(Resilience)을 극대화합니다.
코드 예제
// build.gradle - Resilience4j 의존성
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
// application.yml - Circuit Breaker 설정
resilience4j:
circuitbreaker:
instances:
paymentService: # Circuit Breaker 이름
register-health-indicator: true
sliding-window-size: 10 # 최근 10개 호출을 기준으로 판단
failure-rate-threshold: 50 # 실패율 50% 이상이면 OPEN
wait-duration-in-open-state: 10000 # OPEN 상태 10초 유지
permitted-number-of-calls-in-half-open-state: 3 # HALF_OPEN에서 3번 시도
automatic-transition-from-open-to-half-open-enabled: true
// OrderService.java - Circuit Breaker 사용
@Service
public class OrderService {
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResponse processPayment(PaymentRequest request) {
// 결제 서비스 호출 - 여기서 예외가 발생하면 fallback 실행
return restTemplate.postForObject(
"http://payment-service/api/payments",
request,
PaymentResponse.class
);
}
// fallback 메서드 - 동일한 시그니처 + Throwable 파라미터
private PaymentResponse paymentFallback(PaymentRequest request, Throwable t) {
// 대체 응답 반환 또는 큐에 저장
return PaymentResponse.builder()
.status("PENDING")
.message("결제가 지연되고 있습니다. 잠시 후 다시 시도해주세요.")
.build();
}
}
설명
이것이 하는 일: Resilience4j Circuit Breaker는 마이크로서비스 호출을 모니터링하다가 실패율이 높아지면 자동으로 호출을 차단하고 fallback 응답을 반환하며, 일정 시간 후 서비스 복구를 확인하는 자동화된 장애 관리 메커니즘입니다. 첫 번째로, @CircuitBreaker 어노테이션이 메서드 호출을 감싸서 예외를 감지합니다.
sliding-window-size가 10이면 최근 10번의 호출 결과를 슬라이딩 윈도우로 추적합니다. 이 중 50%(failure-rate-threshold) 이상이 실패하면 Circuit Breaker가 CLOSED에서 OPEN으로 전환됩니다.
이는 지속적인 장애를 빠르게 감지하는 메커니즘입니다. 그 다음으로, OPEN 상태에서는 processPayment 메서드가 실행되지 않고 즉시 paymentFallback이 호출됩니다.
10초(wait-duration-in-open-state) 동안 모든 요청이 fallback으로 처리되어 장애 서비스에 부하를 주지 않습니다. 사용자는 30초를 기다리지 않고 즉시 "결제가 지연되고 있습니다" 메시지를 받아 더 나은 경험을 합니다.
마지막으로, 10초가 지나면 HALF_OPEN 상태로 전환되고 3번(permitted-number-of-calls-in-half-open-state)의 시험 호출을 허용합니다. 이 3번이 모두 성공하면 CLOSED로 돌아가 정상 동작하고, 하나라도 실패하면 다시 OPEN으로 돌아갑니다.
이는 서비스가 복구되었는지 자동으로 확인하는 지능적인 메커니즘입니다. 여러분이 이 코드를 사용하면 결제 서비스가 다운되어도 주문 서비스는 계속 실행됩니다.
실무에서는 fallback에서 메시지 큐(RabbitMQ, Kafka)에 주문을 저장하고, 결제 서비스가 복구되면 배치로 처리할 수 있습니다. 또한 /actuator/health에서 Circuit Breaker 상태를 모니터링하고, OPEN 상태가 되면 알림을 받아 신속하게 대응할 수 있습니다.
전체 시스템의 가용성이 크게 향상됩니다.
실전 팁
💡 fallback 메서드의 시그니처를 정확히 맞추세요. 원본 메서드와 동일한 파라미터 + Throwable을 받아야 합니다. 시그니처가 다르면 런타임에 "fallback method not found" 에러가 발생합니다.
💡 sliding-window-type을 COUNT_BASED(기본)에서 TIME_BASED로 변경하면 최근 N초간의 호출을 기준으로 판단합니다. 트래픽이 일정하지 않은 서비스에서는 TIME_BASED가 더 정확합니다.
💡 slow-call-rate-threshold를 설정해서 느린 응답도 실패로 간주하세요. 예를 들어 응답이 5초 이상 걸리는 호출이 50% 이상이면 Circuit Breaker를 열 수 있습니다. 타임아웃 전에 차단해서 리소스를 보호합니다.
💡 여러 Circuit Breaker 인스턴스를 만들어서 서비스별로 다른 설정을 적용하세요. 중요한 결제 서비스는 엄격하게(failure-rate-threshold: 30), 부차적인 추천 서비스는 느슨하게(failure-rate-threshold: 70) 설정할 수 있습니다.
💡 Micrometer와 연동해서 Circuit Breaker 메트릭을 Prometheus로 전송하고 Grafana로 시각화하세요. OPEN 상태 전환 횟수, 평균 실패율, fallback 호출 횟수를 실시간으로 모니터링하면 장애 대응이 빨라집니다.
6. Distributed Tracing(Sleuth + Zipkin) - 분산 추적
시작하며
여러분의 시스템에서 사용자가 "주문이 느려요"라고 불만을 제기했습니다. 주문 서비스는 결제 서비스, 재고 서비스, 배송 서비스를 순차적으로 호출하는데, 어느 서비스가 느린지 어떻게 알 수 있을까요?
각 서비스의 로그를 일일이 찾아보며 연관시켜야 할까요? 마이크로서비스 환경에서는 하나의 요청이 여러 서비스를 거치기 때문에 문제 지점을 파악하기 매우 어렵습니다.
로그가 각 서비스에 분산되어 있고, 요청 ID도 없어서 추적이 불가능합니다. 바로 이럴 때 필요한 것이 Distributed Tracing입니다.
각 요청에 고유 ID를 부여하고 모든 서비스를 거치며 추적해서, 어느 구간에서 얼마나 시간이 걸렸는지 시각화합니다.
개요
간단히 말해서, Spring Cloud Sleuth는 분산 환경에서 요청을 추적하기 위해 Trace ID와 Span ID를 자동으로 생성하고 전파하는 라이브러리이고, Zipkin은 이 추적 데이터를 수집해서 시각화하는 서버입니다. 실무에서는 성능 병목 지점 파악, 장애 원인 분석, 서비스 간 의존성 파악에 필수적입니다.
예를 들어, 전체 응답 시간이 5초인데 Zipkin을 보니 재고 서비스에서 4.5초가 걸린 것을 발견하고 해당 서비스의 데이터베이스 쿼리를 최적화할 수 있습니다. 기존에는 각 서비스의 로그를 시간 순으로 정렬하고 수동으로 연결했다면, 이제는 Zipkin UI에서 요청의 전체 흐름을 타임라인으로 볼 수 있습니다.
클릭 한 번으로 어느 서비스가 느린지 즉시 파악됩니다. Sleuth와 Zipkin의 핵심 특징은 첫째, 코드 변경 없이 자동으로 추적 정보를 생성하고, 둘째, HTTP 헤더와 메시지 큐를 통해 Trace ID를 전파하며, 셋째, 샘플링으로 모든 요청을 기록하지 않아 오버헤드를 최소화합니다.
이는 운영 환경에서도 안전하게 사용할 수 있게 합니다.
코드 예제
// build.gradle - Sleuth와 Zipkin 의존성
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-sleuth'
implementation 'org.springframework.cloud:spring-cloud-sleuth-zipkin'
}
// application.yml - Sleuth와 Zipkin 설정
spring:
application:
name: order-service
sleuth:
sampler:
probability: 1.0 # 100% 샘플링 (개발용), 운영에서는 0.1 (10%) 권장
zipkin:
base-url: http://localhost:9411 # Zipkin 서버 URL
sender:
type: web # HTTP로 전송 (kafka도 가능)
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
// OrderService.java - 자동으로 추적됨 (코드 변경 불필요)
@Service
public class OrderService {
@Autowired
private RestTemplate restTemplate; // Sleuth가 자동으로 instrumentation
public Order createOrder(OrderRequest request) {
// 이 메서드 호출이 자동으로 Span으로 기록됨
PaymentResponse payment = restTemplate.postForObject(
"http://payment-service/api/payments",
request,
PaymentResponse.class
); // HTTP 호출에 Trace ID가 자동으로 헤더에 추가됨
// Trace ID와 Span ID가 로그에 자동으로 포함됨
log.info("Payment processed for order: {}", request.getOrderId());
return Order.builder()
.orderId(request.getOrderId())
.paymentStatus(payment.getStatus())
.build();
}
}
설명
이것이 하는 일: Spring Cloud Sleuth는 마이크로서비스로 들어오는 모든 요청에 고유한 Trace ID를 부여하고, 각 서비스 호출마다 Span ID를 생성하여, 요청이 여러 서비스를 거치는 전체 경로와 소요 시간을 추적 가능하게 만듭니다. 첫 번째로, Sleuth가 자동으로 모든 HTTP 요청, RestTemplate 호출, FeignClient 호출에 Trace ID와 Span ID를 추가합니다.
예를 들어 사용자가 주문하면 Gateway에서 Trace ID "abc123"이 생성되고, order-service로 전파되며, order-service가 payment-service를 호출할 때도 동일한 Trace ID가 HTTP 헤더(X-B3-TraceId)에 포함됩니다. 이렇게 전체 요청 체인이 하나의 ID로 연결됩니다.
그 다음으로, 로그에 Trace ID와 Span ID가 자동으로 포함되어 "[order-service,abc123,def456]" 형식으로 출력됩니다. 이제 여러 서비스의 로그 파일에서 "abc123"을 검색하면 하나의 요청과 관련된 모든 로그를 찾을 수 있습니다.
ELK 스택(Elasticsearch, Logstash, Kibana)과 조합하면 더욱 강력합니다. 마지막으로, Zipkin 서버가 각 서비스에서 전송한 Span 데이터를 수집하고 타임라인으로 시각화합니다.
sampler.probability가 1.0이면 모든 요청을, 0.1이면 10%만 샘플링합니다. 운영 환경에서는 모든 요청을 추적하면 오버헤드가 크기 때문에 10-20% 샘플링이 적절합니다.
샘플링되지 않은 요청도 로그에는 Trace ID가 포함되므로 필요 시 검색 가능합니다. 여러분이 이 코드를 사용하면 Zipkin UI(http://localhost:9411)에서 각 요청의 전체 흐름을 볼 수 있습니다.
실무에서는 특정 요청이 느린 이유를 즉시 파악하고, 서비스 간 의존성을 시각화하며, 특정 서비스의 평균 응답 시간을 분석할 수 있습니다. 예를 들어 "주문 완료까지 총 3초 중 결제 서비스에서 2.5초 소요"라는 것을 한눈에 파악하고, 결제 서비스의 병목을 집중적으로 개선할 수 있습니다.
장애 발생 시에도 Trace ID로 전체 호출 체인을 추적해서 근본 원인을 빠르게 찾을 수 있습니다.
실전 팁
💡 운영 환경에서는 sleuth.sampler.probability를 0.1 이하로 설정하세요. 100% 샘플링은 대규모 트래픽 환경에서 Zipkin 서버와 네트워크에 큰 부하를 줍니다. 10%만 샘플링해도 패턴 파악에는 충분합니다.
💡 Zipkin sender.type을 kafka나 rabbitmq로 변경하면 비동기 전송으로 애플리케이션 성능 영향을 최소화할 수 있습니다. HTTP 방식은 간단하지만 Zipkin 서버가 다운되면 요청이 블로킹될 수 있습니다.
💡 @NewSpan 어노테이션으로 특정 메서드를 별도 Span으로 만들 수 있습니다. 예를 들어 복잡한 비즈니스 로직 메서드를 추적하면 메서드 내부의 성능 병목을 찾을 수 있습니다: @NewSpan("complex-calculation")
💡 Zipkin의 보관 기간(storage)을 설정하세요. 기본은 메모리 저장이라 재시작하면 데이터가 사라집니다. 운영에서는 Elasticsearch나 MySQL을 백엔드로 사용해서 장기간 추적 데이터를 보관하고 분석하세요.
💡 Trace ID를 API 응답 헤더에 포함시켜 클라이언트에 반환하세요. 사용자가 문제를 보고할 때 Trace ID를 함께 제공하면 해당 요청의 전체 흐름을 즉시 조회할 수 있어 고객 지원이 빨라집니다.
7. Message Queue(RabbitMQ/Kafka) - 비동기 통신
시작하며
여러분이 이메일 발송 기능을 구현한다고 가정해봅시다. 주문이 완료되면 확인 이메일을 보내야 하는데, 이메일 서버가 느리거나 일시적으로 다운되면 주문 API도 느려지거나 실패할까요?
사용자는 주문이 완료되었는지 궁금해하며 기다립니다. 이런 동기 방식은 서비스 간 강한 결합을 만들고, 한 서비스의 장애가 다른 서비스에 즉시 영향을 미칩니다.
특히 실시간성이 필요 없는 작업(이메일, 로그, 통계)까지 즉시 처리하려다 시스템 전체가 느려집니다. 바로 이럴 때 필요한 것이 Message Queue입니다.
주문 서비스는 "이메일을 보내라"는 메시지를 큐에 넣고 즉시 응답하고, 이메일 서비스는 자기 속도로 큐에서 메시지를 가져와 처리합니다.
개요
간단히 말해서, Message Queue는 서비스 간에 메시지를 비동기로 전달하는 중간 매개체입니다. RabbitMQ는 AMQP 프로토콜 기반의 전통적인 메시지 브로커이고, Kafka는 고성능 분산 이벤트 스트리밍 플랫폼입니다.
실무에서는 서비스 간 느슨한 결합, 부하 평준화, 이벤트 기반 아키텍처 구현에 사용됩니다. 예를 들어, 주문 생성 이벤트를 발행하면 이메일 서비스, 재고 서비스, 분석 서비스가 각각 구독해서 필요한 작업을 수행합니다.
서비스를 추가해도 기존 서비스는 변경되지 않습니다. 기존에는 주문 서비스가 각 서비스를 직접 HTTP로 호출했다면, 이제는 메시지를 발행하기만 하면 됩니다.
이메일 서비스가 다운되어도 주문은 정상 처리되고, 메시지는 큐에 안전하게 보관됩니다. Message Queue의 핵심 특징은 첫째, 비동기 처리로 응답 시간을 단축하고, 둘째, 메시지 영속성으로 서비스 장애 시에도 데이터 손실을 방지하며, 셋째, 부하 평준화로 트래픽 급증에 대응합니다.
이는 시스템의 확장성과 안정성을 동시에 향상시킵니다.
코드 예제
// build.gradle - RabbitMQ 의존성 (또는 Kafka)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-amqp'
}
// application.yml - RabbitMQ 설정
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
// RabbitMQConfig.java - Queue와 Exchange 설정
@Configuration
public class RabbitMQConfig {
public static final String ORDER_QUEUE = "order.created";
public static final String ORDER_EXCHANGE = "order.exchange";
public static final String ORDER_ROUTING_KEY = "order.created";
@Bean
public Queue orderQueue() {
return new Queue(ORDER_QUEUE, true); // durable: true (영속성)
}
@Bean
public TopicExchange orderExchange() {
return new TopicExchange(ORDER_EXCHANGE);
}
@Bean
public Binding binding() {
return BindingBuilder
.bind(orderQueue())
.to(orderExchange())
.with(ORDER_ROUTING_KEY);
}
}
// OrderService.java - 메시지 발행 (Producer)
@Service
public class OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
public Order createOrder(OrderRequest request) {
Order order = Order.builder()
.orderId(UUID.randomUUID().toString())
.customerId(request.getCustomerId())
.build();
// 주문을 DB에 저장
orderRepository.save(order);
// 비동기로 이벤트 발행 - 여기서 블로킹되지 않음!
OrderCreatedEvent event = new OrderCreatedEvent(order);
rabbitTemplate.convertAndSend(
RabbitMQConfig.ORDER_EXCHANGE,
RabbitMQConfig.ORDER_ROUTING_KEY,
event
);
return order; // 즉시 응답 반환
}
}
// EmailService.java - 메시지 소비 (Consumer)
@Service
public class EmailService {
@RabbitListener(queues = RabbitMQConfig.ORDER_QUEUE)
public void handleOrderCreated(OrderCreatedEvent event) {
// 이메일 서비스는 자기 속도로 메시지를 처리
sendOrderConfirmationEmail(event.getOrder());
log.info("Email sent for order: {}", event.getOrder().getOrderId());
}
private void sendOrderConfirmationEmail(Order order) {
// 실제 이메일 전송 로직
// 여기서 예외가 발생하면 메시지가 재처리됨 (DLQ 설정 시)
}
}
설명
이것이 하는 일: Message Queue는 서비스 간의 직접적인 HTTP 호출을 메시지 기반의 비동기 통신으로 전환하여, 서비스들이 독립적으로 동작하고 장애가 전파되지 않으며, 트래픽 급증 시에도 안정적으로 처리할 수 있게 만듭니다. 첫 번째로, Queue, Exchange, Binding을 설정해서 메시지 라우팅 구조를 만듭니다.
TopicExchange는 라우팅 키 패턴으로 메시지를 여러 큐에 분배할 수 있습니다. 예를 들어 "order.*" 패턴으로 "order.created", "order.cancelled" 이벤트를 각각 다른 큐로 보낼 수 있습니다.
durable: true는 RabbitMQ 서버가 재시작해도 큐가 사라지지 않게 합니다. 그 다음으로, OrderService가 rabbitTemplate.convertAndSend()로 메시지를 발행합니다.
이 메서드는 즉시 반환되어 주문 API의 응답 시간에 영향을 주지 않습니다. 만약 이메일 발송에 5초가 걸려도 사용자는 1초 안에 "주문 완료" 응답을 받습니다.
메시지는 RabbitMQ에 안전하게 저장되고, 이메일 서비스가 다운되어 있어도 나중에 처리됩니다. 마지막으로, EmailService의 @RabbitListener가 큐를 지속적으로 폴링하며 메시지를 가져와 처리합니다.
메시지 처리 중 예외가 발생하면 기본적으로 재시도되고, Dead Letter Queue(DLQ)를 설정하면 실패한 메시지를 별도로 보관해서 나중에 분석할 수 있습니다. prefetch count를 조절하면 Consumer가 한 번에 가져올 메시지 개수를 제어해서 과부하를 방지합니다.
여러분이 이 코드를 사용하면 주문 서비스는 이메일 서비스의 상태와 무관하게 동작합니다. 실무에서는 블랙프라이데이 같은 트래픽 급증 시 주문이 초당 1000건 발생해도 즉시 응답하고, 이메일 서비스는 초당 100건씩 천천히 처리할 수 있습니다.
큐가 버퍼 역할을 해서 부하를 평준화합니다. 또한 새로운 서비스(SMS 서비스, 푸시 알림 서비스)를 추가할 때 order.created 큐를 구독하기만 하면 되므로 기존 코드를 수정하지 않아도 됩니다.
이것이 이벤트 기반 아키텍처의 핵심 장점입니다.
실전 팁
💡 메시지에 idempotency key를 포함시켜 중복 처리를 방지하세요. 네트워크 문제로 같은 메시지가 두 번 전달될 수 있는데, 이메일이 두 번 발송되면 사용자 경험이 나빠집니다. Redis에 처리한 메시지 ID를 저장하고 확인하세요.
💡 Dead Letter Queue(DLQ)를 반드시 설정하세요. 처리 실패한 메시지가 무한 재시도되면 큐가 막히고 새 메시지가 처리되지 않습니다. DLQ로 실패 메시지를 분리하고 별도로 분석해서 근본 원인을 해결하세요.
💡 메시지에 타임스탬프와 TTL(Time To Live)을 설정하세요. 1시간 전에 발행된 "할인 쿠폰 발송" 메시지는 처리해도 의미가 없을 수 있습니다. TTL이 지난 메시지는 자동으로 폐기되어 리소스를 절약합니다.
💡 Consumer의 prefetch count를 적절히 설정하세요. 기본값이 너무 크면 한 Consumer가 메시지를 독점하고, 너무 작으면 네트워크 오버헤드가 증가합니다. 보통 10-50 사이가 적절합니다.
💡 Kafka를 사용할 때는 파티션 개수를 Consumer 개수의 배수로 설정하세요. 파티션이 3개인데 Consumer가 5개면 2개는 놀게 됩니다. 확장 계획을 고려해서 처음부터 충분한 파티션을 만드세요(Kafka는 파티션 개수를 줄일 수 없습니다).
8. Centralized Logging(ELK Stack) - 통합 로그 관리
시작하며
여러분이 10개의 마이크로서비스를 운영하는데, 특정 사용자의 주문이 실패했다는 보고를 받았습니다. 문제를 찾으려면 Gateway, 주문 서비스, 결제 서비스, 재고 서비스의 로그 파일을 각각 열어서 시간과 Trace ID로 검색해야 합니다.
서버가 여러 대라면 더욱 복잡합니다. 이런 방식은 장애 대응 시간을 크게 늘리고, 로그가 각 서버에 흩어져 있어 서버가 종료되면 로그도 사라집니다.
특히 컨테이너 환경에서는 Pod가 재시작되면 이전 로그를 볼 수 없습니다. 바로 이럴 때 필요한 것이 Centralized Logging입니다.
모든 마이크로서비스의 로그를 중앙으로 모아서 통합 검색하고, 시각화하며, 알림을 설정할 수 있습니다.
개요
간단히 말해서, ELK Stack은 Elasticsearch(검색/저장), Logstash(수집/변환), Kibana(시각화)의 조합으로, 분산된 로그를 중앙에서 관리하는 오픈소스 솔루션입니다. 최근에는 Filebeat나 Fluentd가 Logstash를 대체하기도 합니다.
실무에서는 실시간 로그 모니터링, 에러 알림, 성능 분석, 보안 감사에 필수적입니다. 예를 들어, Kibana에서 "최근 1시간 동안 ERROR 레벨 로그"를 검색하면 모든 서비스의 에러를 한 화면에서 볼 수 있고, 특정 패턴이 반복되면 알림을 받을 수 있습니다.
기존에는 SSH로 각 서버에 접속해서 tail -f로 로그를 봤다면, 이제는 브라우저에서 Kibana로 모든 로그를 실시간 검색합니다. "user_id:12345 AND level:ERROR"처럼 복잡한 쿼리도 즉시 실행됩니다.
ELK Stack의 핵심 특징은 첫째, 분산 로그를 중앙에서 통합 관리하고, 둘째, 강력한 전문 검색(Full-text Search)으로 빠르게 검색하며, 셋째, 대시보드로 로그를 시각화해서 패턴을 발견합니다. 이는 운영 효율성을 극적으로 향상시킵니다.
코드 예제
// build.gradle - Logstash 인코더 의존성
dependencies {
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
}
// logback-spring.xml - Logstash로 전송하는 로그 설정
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 콘솔 출력 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Logstash로 전송 -->
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>localhost:5000</destination> <!-- Logstash 주소 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<!-- 추가 필드 포함 -->
<customFields>{"app_name":"${spring.application.name}"}</customFields>
<includeMdcKeyNames>traceId,spanId,userId</includeMdcKeyNames> <!-- MDC 필드 포함 -->
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="LOGSTASH"/>
</root>
</configuration>
// OrderService.java - 구조화된 로그 작성
@Service
@Slf4j
public class OrderService {
public Order createOrder(OrderRequest request) {
// MDC에 컨텍스트 정보 추가 (ELK에서 검색 가능)
MDC.put("userId", request.getUserId());
MDC.put("orderId", UUID.randomUUID().toString());
try {
// 구조화된 로그 - JSON 필드로 전송됨
log.info("Order creation started",
kv("userId", request.getUserId()),
kv("totalAmount", request.getTotalAmount()));
Order order = processOrder(request);
log.info("Order created successfully",
kv("orderId", order.getOrderId()),
kv("status", order.getStatus()));
return order;
} catch (Exception e) {
// 에러 로그 - Kibana에서 알림 설정 가능
log.error("Order creation failed",
kv("userId", request.getUserId()),
kv("errorMessage", e.getMessage()),
e);
throw e;
} finally {
MDC.clear(); // 메모리 누수 방지
}
}
}
// docker-compose.yml - ELK Stack 간단 구성 (개발용)
version: '3'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
environment:
- discovery.type=single-node
ports:
- "9200:9200"
logstash:
image: docker.elastic.co/logstash/logstash:8.11.0
ports:
- "5000:5000"
volumes:
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
kibana:
image: docker.elastic.co/kibana/kibana:8.11.0
ports:
- "5601:5601"
설명
이것이 하는 일: ELK Stack은 각 마이크로서비스에서 발생하는 로그를 실시간으로 수집하여 중앙의 Elasticsearch에 저장하고, Kibana 웹 인터페이스로 검색, 필터링, 시각화할 수 있게 만드는 통합 로그 관리 시스템입니다. 첫 번째로, LogstashTcpSocketAppender가 애플리케이션의 모든 로그를 JSON 형식으로 변환해서 Logstash로 전송합니다.
customFields에 app_name을 추가하면 Kibana에서 "app_name:order-service"로 특정 서비스의 로그만 필터링할 수 있습니다. includeMdcKeyNames로 Sleuth의 traceId와 spanId를 포함시키면 분산 추적과 로그를 연결할 수 있습니다.
그 다음으로, Logstash가 수신한 로그를 파싱하고 필터링한 후 Elasticsearch에 저장합니다. Logstash pipeline 설정에서 불필요한 필드를 제거하거나, grok 패턴으로 비구조화된 로그를 구조화하거나, geoip로 IP 주소를 지역 정보로 변환할 수 있습니다.
실무에서는 민감한 정보(비밀번호, 카드번호)를 자동으로 마스킹하는 필터를 추가합니다. 마지막으로, Kibana의 Discover 화면에서 "level:ERROR AND userId:12345"처럼 쿼리하면 특정 사용자의 모든 에러 로그를 즉시 찾을 수 있습니다.
Visualize로 "시간대별 에러 발생 건수", "서비스별 로그 분포", "가장 많이 발생한 예외 Top 10" 같은 차트를 만들고, Dashboard에 모아서 실시간 모니터링합니다. Alerting 기능으로 "5분간 ERROR 로그 100건 이상"이면 Slack 알림을 보낼 수 있습니다.
여러분이 이 코드를 사용하면 로그를 찾기 위해 여러 서버를 돌아다니지 않아도 됩니다. 실무에서는 장애 발생 시 Kibana에서 Trace ID로 검색하면 Gateway부터 마지막 마이크로서비스까지 전체 로그를 시간 순으로 볼 수 있습니다.
또한 "최근 24시간 동안 가장 많이 발생한 에러"를 분석해서 우선순위 높은 버그를 파악하고, "특정 API의 평균 응답 시간 추이"를 차트로 보면서 성능 저하를 조기에 발견할 수 있습니다. 컨테이너가 재시작되어도 로그는 Elasticsearch에 안전하게 보관되어 언제든 조회 가능합니다.
실전 팁
💡 Elasticsearch 인덱스에 날짜별 롤오버(Rollover)를 설정하세요. "logs-2024-01-15" 형식으로 매일 새 인덱스를 만들면 오래된 로그를 삭제하기 쉽고 검색 성능도 향상됩니다. ILM(Index Lifecycle Management)으로 자동화하세요.
💡 로그 레벨을 적절히 사용하세요. 모든 로그를 INFO로 찍으면 중요한 로그가 묻히고, 너무 많이 찍으면 Elasticsearch 용량이 부족해집니다. DEBUG는 개발 환경에만, ERROR는 진짜 에러에만 사용하세요.
💡 Kibana의 Saved Search를 활용하세요. "최근 1시간 결제 실패 로그" 같은 자주 사용하는 쿼리를 저장하면 클릭 한 번으로 실행할 수 있습니다. 팀원들과 공유하면 일관된 모니터링이 가능합니다.
💡 Filebeat를 사용하면 Logstash보다 가볍고 리소스 효율적입니다. 특히 컨테이너 환경에서는 Filebeat를 사이드카 컨테이너로 배포하거나 DaemonSet으로 실행해서 로그를 수집하세요.
💡 민감한 정보를 로그에 남기지 마세요. 만약 불가피하다면 Logstash 필터로 자동 마스킹하거나, Elasticsearch의 필드 레벨 보안으로 특정 사용자만 볼 수 있게 제한하세요. GDPR 같은 규정 준수도 고려해야 합니다.