들어가며
우리 프로젝트는 현재 모놀리식 구조로 개발 중이지만, 미래에 대규모 트래픽 상황에서 MSA로 빠르게 전환해야 할 상황을 대비해야 합니다. 이 글에서는 로드밸런서의 동작 원리를 자세히 알아보고, 언제 어떻게 MSA로 전환할지에 대한 실무적 가이드를 제공하겠습니다.
로드밸런서 완전 분석
로드밸런서의 정의
로드밸런서는 들어오는 네트워크 트래픽을 여러 서버에 분산시키는 네트워크 장비 또는 소프트웨어입니다. 단순히 요청을 나누어주는 것이 아니라, 시스템의 가용성과 성능을 보장하는 핵심 인프라입니다.
로드밸런서가 해결하는 문제들
1. 단일 서버의 한계
문제 상황: 쇼핑몰 블랙프라이데이
단일 서버:
🖥️ 서버1 (한계: 1000명)
↑
👥👥👥👥👥 10,000명 몰림
결과: 서버 다운, 서비스 중단
로드밸런서 + 다중 서버:
🔄 로드밸런서
↙ ↓ ↘
🖥️ 서버1 🖥️ 서버2 🖥️ 서버3
(3,333명) (3,333명) (3,334명)
결과: 정상 서비스
2. 단일 장애점(SPOF) 제거
로드밸런서 없이:
서버1 장애 → 전체 서비스 중단
로드밸런서 있으면:
서버1 장애 → 로드밸런서가 감지 → 서버2,3으로만 트래픽 전송
로드밸런서 동작 원리 상세 분석
1. 기본 동작 흐름
1. 클라이언트 요청
🌐 Client → HTTP Request → 🔄 Load Balancer
2. 서버 선택 알고리즘 실행
🔄 Load Balancer: "어느 서버가 가장 적절한가?"
- 서버1: CPU 70%, 연결 50개
- 서버2: CPU 30%, 연결 20개 ← 선택
- 서버3: CPU 90%, 연결 80개
3. 요청 전달
🔄 Load Balancer → HTTP Request → 🖥️ Server2
4. 응답 반환
🖥️ Server2 → HTTP Response → 🔄 Load Balancer → 🌐 Client
2. 헬스체크 메커니즘
// 로드밸런서의 헬스체크 구현 예시
public class HealthChecker {
@Scheduled(fixedRate = 5000) // 5초마다 실행
public void checkServerHealth() {
for (Server server : serverPool) {
try {
// HTTP GET /health 요청
ResponseEntity<String> response = restTemplate.getForEntity(
server.getUrl() + "/health",
String.class
);
if (response.getStatusCode() == HttpStatus.OK) {
server.markHealthy();
log.info("Server {} is healthy", server.getUrl());
} else {
server.markUnhealthy();
log.warn("Server {} returned non-200 status", server.getUrl());
}
} catch (Exception e) {
server.markUnhealthy();
log.error("Server {} is unhealthy: {}", server.getUrl(), e.getMessage());
}
}
}
}
3. 로드밸런싱 알고리즘 상세
Round Robin (라운드 로빈)
public class RoundRobinBalancer {
private List<Server> servers;
private AtomicInteger currentIndex = new AtomicInteger(0);
public Server selectServer() {
List<Server> healthyServers = getHealthyServers();
if (healthyServers.isEmpty()) {
throw new NoHealthyServerException();
}
int index = currentIndex.getAndIncrement() % healthyServers.size();
return healthyServers.get(index);
}
}
실행 예시:
요청1 → 서버1
요청2 → 서버2
요청3 → 서버3
요청4 → 서버1 (다시 처음부터)
Least Connections (최소 연결)
public class LeastConnectionsBalancer {
public Server selectServer() {
return getHealthyServers()
.stream()
.min(Comparator.comparingInt(Server::getActiveConnections))
.orElseThrow(NoHealthyServerException::new);
}
}
실행 예시:
서버1: 연결 15개
서버2: 연결 8개 ← 선택
서버3: 연결 12개
Weighted Round Robin (가중 라운드 로빈)
public class WeightedRoundRobinBalancer {
public Server selectServer() {
// 서버1: 가중치 3 (고성능 서버)
// 서버2: 가중치 2 (중성능 서버)
// 서버3: 가중치 1 (저성능 서버)
// 6개 요청 중 서버1이 3개, 서버2가 2개, 서버3이 1개 처리
}
}
IP Hash
public class IpHashBalancer {
public Server selectServer(String clientIp) {
int hash = clientIp.hashCode();
int serverIndex = Math.abs(hash) % getHealthyServers().size();
return getHealthyServers().get(serverIndex);
// 같은 IP는 항상 같은 서버로 연결 (세션 유지)
}
}
로드밸런서 종류와 동작 위치
1. Layer 4 로드밸런서 (Network Layer)
특징: IP + Port 기준으로 분산
동작 예시:
클라이언트 요청: 192.168.1.100:80
↓
로드밸런서: "80포트 요청이니까 웹서버들에게 분산"
↓
서버1: 192.168.1.10:8080
서버2: 192.168.1.11:8080
서버3: 192.168.1.12:8080
2. Layer 7 로드밸런서 (Application Layer)
특징: HTTP 내용을 분석해서 분산
동작 예시:
// URL 패턴 기반 라우팅
if (request.getPath().startsWith("/api/users")) {
return userServiceServers; // 사용자 서비스 서버들
} else if (request.getPath().startsWith("/api/orders")) {
return orderServiceServers; // 주문 서비스 서버들
}
// 헤더 기반 라우팅
if (request.getHeader("User-Agent").contains("Mobile")) {
return mobileServers; // 모바일 최적화 서버들
} else {
return webServers; // 웹 최적화 서버들
}
실제 로드밸런서 구현
Nginx를 이용한 구현
# /etc/nginx/nginx.conf
# 서버 그룹 정의
upstream backend_servers {
# 가중치를 이용한 로드밸런싱
server 192.168.1.10:8080 weight=3; # 고성능 서버
server 192.168.1.11:8080 weight=2; # 중성능 서버
server 192.168.1.12:8080 weight=1; # 저성능 서버
# 헬스체크 설정
keepalive 32;
}
server {
listen 80;
server_name api.momo.com;
# 로드밸런싱 적용
location / {
proxy_pass http://backend_servers;
# 헤더 전달 설정
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 타임아웃 설정
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
# 재시도 설정
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
# 헬스체크 엔드포인트
location /nginx-health {
access_log off;
return 200 "healthy\n";
}
}
Spring Cloud Gateway를 이용한 구현
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// 사용자 서비스 라우팅
.route("user-service", r -> r
.path("/api/v2/users/**")
.filters(f -> f
.retry(3) // 3번 재시도
.circuitBreaker(config -> config
.setName("user-service-cb")
.setFallbackUri("forward:/fallback/users")
)
)
.uri("lb://user-service") // 로드밸런싱
)
// 모임 서비스 라우팅
.route("meeting-service", r -> r
.path("/api/v2/meetings/**")
.filters(f -> f
.retry(3)
.circuitBreaker(config -> config
.setName("meeting-service-cb")
.setFallbackUri("forward:/fallback/meetings")
)
)
.uri("lb://meeting-service")
)
.build();
}
}
MSA 전환 시점과 전략
전환 시점 판단 기준
1. 트래픽 기준
경고 신호:
- 동시 접속자 5,000명 이상
- 응답 시간 3초 이상
- CPU 사용률 90% 지속
- 메모리 부족으로 인한 OOM 발생
모니터링 지표:
@Component
public class SystemMetrics {
@Scheduled(fixedRate = 60000) // 1분마다
public void checkSystemHealth() {
double cpuUsage = getCpuUsage();
long memoryUsage = getMemoryUsage();
int activeConnections = getActiveConnections();
double avgResponseTime = getAverageResponseTime();
if (cpuUsage > 80 && avgResponseTime > 2000) {
log.warn("시스템 부하 증가 - CPU: {}%, 응답시간: {}ms",
cpuUsage, avgResponseTime);
// 알림 발송
}
}
}
2. 개발팀 규모 기준
전환 고려 시점:
- 개발팀 10명 이상
- 도메인별 전담팀 구성 가능
- 독립적인 배포 주기 필요
현재 우리팀 (4~5명):
- 아직 MSA 전환 불필요
- 모듈형 모놀리식으로 충분
MSA 전환 단계별 전략
Phase 1: 준비 단계 (현재 우리가 해야 할 일)
1. 도메인 경계 명확화
// 현재 우리 구조 - 이미 잘 분리됨
com.example.momo
├── domain.user // 사용자 관리
├── domain.meeting // 모임 관리
├── domain.payment // 결제 처리
├── domain.notification // 알림 발송
├── domain.category // 카테고리 관리
└── domain.auth // 인증/인가
각 도메인의 독립성 확보:
- 도메인 간 직접 참조 금지
- WebClient를 통한 통신 (이미 구현됨)
- 각 도메인별 데이터베이스 분리 가능한 구조
2. 인터페이스 표준화
// API 응답 형식 통일 (이미 구현됨)
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private LocalDateTime timestamp;
}
// 에러 처리 통일
@RestControllerAdvice
public class GlobalExceptionHandler {
// 모든 도메인에서 동일한 에러 응답 형식
}
3. 설정 외부화
# application.yml - 환경별 설정 분리
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
---
spring:
profiles: local
datasource:
url: jdbc:mysql://localhost:3306/momo
---
spring:
profiles: prod
datasource:
url: jdbc:mysql://prod-db-cluster:3306/momo
Phase 2: 수직 분할 (Database per Service)
-- 현재: 하나의 데이터베이스
momo_db
├── users
├── meetings
├── payments
├── notifications
└── categories
-- MSA 전환 후: 서비스별 데이터베이스
user_db
├── users
└── user_categories
meeting_db
├── meetings
└── meeting_participants
payment_db
├── payments
notification_db
├── notifications
Phase 3: 수평 분할 (Microservice Deployment)
1. 서비스 분리 우선순위
1순위: 독립성이 높은 서비스
- Auth Service (다른 서비스들이 의존)
- Notification Service (단방향 통신)
2순위: 핵심 비즈니스 로직
- User Service
- Meeting Service
3순위: 보조 서비스
- Payment Service (외부 연동)
- Category Service (상대적으로 단순)
2. 점진적 전환 전략 (Strangler Fig Pattern)
// 1단계: API Gateway 도입
@RestController
public class GatewayController {
@GetMapping("/api/v2/users/**")
public ResponseEntity<?> handleUserRequest(HttpServletRequest request) {
if (isUserServiceMigrated()) {
// 새로운 User 마이크로서비스로 전달
return forwardToUserService(request);
} else {
// 기존 모놀리식 애플리케이션으로 전달
return forwardToMonolith(request);
}
}
}
// 2단계: 서비스별 순차 이관
// User Service 이관 → Meeting Service 이관 → Payment Service 이관
빠른 MSA 전환을 위한 기술 스택
1. 컨테이너화 (Docker)
# 각 서비스를 컨테이너로 패키징
FROM openjdk:17-jre-slim
# User Service
COPY user-service.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "/app.jar"]
# Meeting Service
COPY meeting-service.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "/app.jar"]
2. 오케스트레이션 (Kubernetes)
# user-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3 # 3개 인스턴스
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: momo/user-service:latest
ports:
- containerPort: 8081
env:
- name: DB_URL
value: "jdbc:mysql://user-db:3306/user_db"
---
apiVersion: v1
kind: Service
metadata:
name: user-service-lb
spec:
selector:
app: user-service
ports:
- port: 80
targetPort: 8081
type: LoadBalancer # 자동 로드밸런싱
3. 서비스 메시 (Istio)
# 고급 로드밸런싱과 트래픽 관리
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: user-service
spec:
hosts:
- user-service
http:
- match:
- headers:
version:
exact: v2
route:
- destination:
host: user-service
subset: v2
weight: 10 # 10% 트래픽
- route:
- destination:
host: user-service
subset: v1
weight: 90 # 90% 트래픽 (카나리 배포)
전환 시 주의사항과 해결방안
1. 분산 트랜잭션 문제
// 문제: 모임 참가 시나리오
// 기존 모놀리식 - 간단한 트랜잭션
@Transactional
public void joinMeeting(Long userId, Long meetingId) {
paymentService.processPayment(userId, meetingId); // 1. 결제
meetingService.addParticipant(userId, meetingId); // 2. 참가
notificationService.sendNotification(userId, meetingId); // 3. 알림
}
// MSA 해결방안 - Saga 패턴
@Component
public class MeetingJoinSaga {
public void joinMeeting(JoinMeetingCommand command) {
SagaTransaction saga = sagaManager.begin("join-meeting");
try {
// 1. 결제 처리
PaymentResult payment = paymentService.processPayment(command);
saga.addCompensation(() -> paymentService.cancelPayment(payment.getId()));
// 2. 모임 참가
MeetingResult meeting = meetingService.addParticipant(command);
saga.addCompensation(() -> meetingService.removeParticipant(command));
// 3. 알림 발송
notificationService.sendNotification(command);
saga.commit();
} catch (Exception e) {
saga.rollback(); // 보상 트랜잭션 실행
throw new MeetingJoinFailedException(e);
}
}
}
2. 서비스 간 통신 복잡도
// 서킷 브레이커 패턴 적용
@Component
public class UserClient {
@CircuitBreaker(name = "user-service", fallbackMethod = "fallbackGetUser")
@Retry(name = "user-service")
@TimeLimiter(name = "user-service")
public CompletableFuture<UserResponse> getUser(Long userId) {
return CompletableFuture.supplyAsync(() ->
webClient.get()
.uri("/users/{userId}", userId)
.retrieve()
.bodyToMono(UserResponse.class)
.block()
);
}
public CompletableFuture<UserResponse> fallbackGetUser(Long userId, Exception ex) {
log.warn("User service 호출 실패, 기본값 반환: {}", ex.getMessage());
return CompletableFuture.completedFuture(UserResponse.getDefault(userId));
}
}
3. 데이터 일관성 문제
// 이벤트 소싱 패턴
@Entity
public class MeetingEvent {
private String eventType; // MEETING_CREATED, PARTICIPANT_JOINED
private String aggregateId; // 모임 ID
private String eventData; // JSON 형태의 이벤트 데이터
private LocalDateTime timestamp;
}
@EventListener
public class UserEventHandler {
@Async
public void handleMeetingParticipantJoined(MeetingParticipantJoinedEvent event) {
// User 서비스에서 참가 모임 수 업데이트
userService.incrementMeetingCount(event.getUserId());
}
}
마치며
로드밸런서는 단순한 트래픽 분산 도구가 아니라 시스템의 가용성과 확장성을 보장하는 핵심 인프라입니다. MSA 전환은 기술적 도전이지만, 올바른 준비와 단계적 접근을 통해 성공적으로 수행할 수 있습니다.
현재 우리 프로젝트는 MSA 전환을 위한 기반이 잘 갖춰져 있습니다. 앞으로는 모니터링을 통한 전환 시점 판단과 점진적 전환 전략 수립에 집중하여, 필요한 순간에 빠르고 안전하게 MSA로 전환할 수 있도록 준비해야 합니다.
'Spring 7기 프로젝트 > 모임 플렛폼 프로젝트' 카테고리의 다른 글
| Elasticsearch를 활용한 모임 검색 서비스 설계 (0) | 2025.07.28 |
|---|---|
| ELK 스택을 활용한 애플리케이션 모니터링 시스템 구축 (1) | 2025.07.28 |
| WebFlux와 Spring MVC: 실무 관점에서의 차이점 이해하기 (0) | 2025.07.25 |
| Elasticsearch 완벽 가이드: 실무에서 알아야 할 모든 것 (1) | 2025.07.25 |
| 도메인 경계 분리를 통한 Auth와 User 도메인 리팩토링 (0) | 2025.07.24 |