이미지 로딩 중...
AI Generated
2025. 11. 5. · 7 Views
Microservices 트러블슈팅 완벽 가이드
실무에서 자주 발생하는 마이크로서비스 장애 상황을 단계별로 해결하는 방법을 배웁니다. 서비스 간 통신 오류, 데이터 불일치, 성능 저하 등 실제 트러블슈팅 시나리오와 해결 전략을 다룹니다.
목차
- 서비스 간 통신 장애 해결 - Circuit Breaker 패턴으로 연쇄 장애 방지하기
- 분산 트랜잭션 불일치 해결 - Saga 패턴으로 데이터 일관성 유지하기
- API Gateway 타임아웃 최적화 - 요청 병렬화로 응답 시간 단축하기
- 서비스 디스커버리 장애 대응 - 클라이언트 사이드 로드밸런싱과 헬스체크
- 분산 로깅과 추적 - Correlation ID로 요청 흐름 추적하기
- 데이터베이스 연결 고갈 해결 - Connection Pool 최적화와 모니터링
- 카오스 엔지니어링 - 장애 주입으로 시스템 복원력 테스트하기
- API 버전 관리 - 하위 호환성 유지하며 안전하게 업데이트하기
1. 서비스 간 통신 장애 해결 - Circuit Breaker 패턴으로 연쇄 장애 방지하기
시작하며
여러분이 주문 서비스를 운영하는데 갑자기 결제 서비스가 느려지거나 응답이 없어서 전체 시스템이 멈춰버린 경험이 있나요? 한 서비스의 문제가 다른 모든 서비스로 번져나가면서 도미노처럼 무너지는 상황은 마이크로서비스 환경에서 가장 치명적인 문제 중 하나입니다.
이런 연쇄 장애(Cascade Failure)는 서비스 간 의존성이 많은 분산 시스템에서 특히 자주 발생합니다. 한 서비스가 느려지면 그것을 호출하는 서비스들이 타임아웃을 기다리며 스레드와 리소스를 소진하게 되고, 결국 전체 시스템이 마비됩니다.
바로 이럴 때 필요한 것이 Circuit Breaker 패턴입니다. 전기 회로의 차단기처럼 문제가 있는 서비스로의 호출을 자동으로 차단하여 시스템을 보호하고, 빠른 실패(Fail Fast)를 통해 리소스를 절약할 수 있습니다.
개요
간단히 말해서, Circuit Breaker는 외부 서비스 호출을 모니터링하고 실패율이 임계값을 넘으면 자동으로 호출을 차단하는 안전장치입니다. 마이크로서비스 아키텍처에서는 네트워크를 통한 원격 호출이 필수적이지만, 네트워크는 언제든 실패할 수 있습니다.
Circuit Breaker는 이러한 불안정한 외부 의존성으로부터 시스템을 보호합니다. 예를 들어, 결제 서비스가 다운되었을 때 주문 서비스가 계속해서 결제 API를 호출하며 리소스를 낭비하는 대신, 즉시 에러를 반환하고 대체 로직을 실행할 수 있습니다.
기존에는 단순히 재시도 로직이나 타임아웃 설정만으로 대응했다면, 이제는 Circuit Breaker를 통해 지능적으로 서비스 상태를 추적하고 자동으로 회복을 시도할 수 있습니다. Circuit Breaker는 세 가지 상태를 가집니다: Closed(정상), Open(차단), Half-Open(회복 시도).
이러한 상태 전환을 통해 장애가 있는 서비스에 부하를 주지 않으면서도 자동으로 회복을 감지할 수 있습니다.
코드 예제
import axios from 'axios';
class CircuitBreaker {
private failureCount = 0;
private lastFailureTime: number | null = null;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(
private threshold: number = 5, // 연속 실패 임계값
private timeout: number = 60000, // Open 상태 유지 시간 (1분)
private retryTimeout: number = 30000 // Half-Open 시도 간격
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
// Open 상태: 즉시 실패 반환
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime! > this.timeout) {
this.state = 'HALF_OPEN'; // 회복 시도
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await fn();
this.onSuccess(); // 성공 시 카운터 리셋
return result;
} catch (error) {
this.onFailure(); // 실패 카운트 증가
throw error;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.threshold) {
this.state = 'OPEN'; // 임계값 초과 시 차단
console.log('Circuit breaker opened due to failures');
}
}
}
// 사용 예시
const paymentServiceBreaker = new CircuitBreaker(5, 60000);
async function processPayment(orderId: string) {
try {
return await paymentServiceBreaker.call(async () => {
return await axios.post('http://payment-service/pay', { orderId });
});
} catch (error) {
// Fallback 로직: 결제 대기 큐에 추가
await addToPaymentQueue(orderId);
return { status: 'PENDING', message: 'Payment will be processed later' };
}
}
설명
이것이 하는 일: Circuit Breaker는 외부 서비스 호출을 감싸서(Wrap) 실패를 추적하고, 일정 임계값을 넘으면 더 이상 호출을 시도하지 않고 즉시 에러를 반환합니다. 이를 통해 느린 서비스나 다운된 서비스로 인한 리소스 낭비를 방지합니다.
첫 번째로, Circuit Breaker는 세 가지 상태를 관리합니다. CLOSED 상태는 정상 작동 중이며 모든 호출을 허용합니다.
이 상태에서 호출이 실패하면 failureCount가 증가하고, 성공하면 카운터가 리셋됩니다. 이렇게 하는 이유는 일시적인 네트워크 오류는 허용하되, 지속적인 문제만 감지하기 위함입니다.
그 다음으로, failureCount가 threshold(기본값 5)를 넘으면 상태가 OPEN으로 전환됩니다. OPEN 상태에서는 실제 서비스를 호출하지 않고 즉시 에러를 던집니다.
이것이 핵심인데, 문제가 있는 서비스에 계속 요청을 보내며 타임아웃을 기다리는 대신, 즉시 실패하여 리소스(스레드, 메모리, 커넥션)를 절약하고 빠르게 대체 로직을 실행할 수 있습니다. 마지막으로, OPEN 상태가 일정 시간(timeout, 기본값 1분) 지속되면 자동으로 HALF_OPEN 상태로 전환하여 회복을 시도합니다.
이 상태에서 한 번의 호출을 허용하여 서비스가 복구되었는지 확인하고, 성공하면 CLOSED로 돌아가고 실패하면 다시 OPEN으로 돌아갑니다. 여러분이 이 코드를 사용하면 서비스 장애 시 전체 시스템의 안정성을 크게 향상시킬 수 있습니다.
특히 결제 서비스, 외부 API 호출, 데이터베이스 연결 등 중요하지만 실패할 수 있는 의존성을 다룰 때 필수적입니다. 또한 Fallback 로직과 결합하면 사용자에게 더 나은 경험을 제공할 수 있습니다.
실제 프로덕션 환경에서는 각 외부 서비스마다 별도의 Circuit Breaker 인스턴스를 생성하고, 서비스의 특성에 맞게 threshold와 timeout 값을 조정해야 합니다. 또한 Circuit Breaker의 상태 변경을 로깅하고 모니터링하여 시스템의 건강 상태를 추적할 수 있습니다.
실전 팁
💡 각 외부 서비스마다 별도의 Circuit Breaker 인스턴스를 사용하세요. 하나의 서비스 장애가 다른 서비스 호출까지 차단하는 것을 방지할 수 있습니다. 💡 Circuit Breaker가 OPEN될 때 반드시 로깅과 알림을 설정하세요. 이는 중요한 장애 신호이며, 즉각적인 대응이 필요한 상황입니다. 💡 Fallback 로직을 항상 준비하세요. Circuit Breaker는 실패를 빠르게 감지할 뿐, 비즈니스 로직의 대안을 제공하지는 않습니다. 캐시된 데이터 사용, 큐에 작업 추가, 기본값 반환 등의 전략을 고려하세요. 💡 임계값과 타임아웃은 서비스의 특성에 맞게 조정하세요. 중요한 서비스는 낮은 임계값(3-5)을, 일시적으로 불안정한 서비스는 높은 임계값(10-20)을 사용할 수 있습니다. 💡 Netflix의 Hystrix나 resilience4j 같은 검증된 라이브러리 사용을 고려하세요. 프로덕션에서는 더 정교한 메트릭 수집, 대시보드, 동적 설정 변경 등이 필요합니다.
2. 분산 트랜잭션 불일치 해결 - Saga 패턴으로 데이터 일관성 유지하기
시작하며
여러분이 전자상거래 서비스를 운영하는데, 주문은 생성되었지만 재고는 차감되지 않았거나, 결제는 완료되었지만 배송이 시작되지 않은 적이 있나요? 마이크로서비스에서는 각 서비스가 독립적인 데이터베이스를 가지기 때문에 전통적인 ACID 트랜잭션으로 일관성을 보장할 수 없습니다.
이런 데이터 불일치 문제는 분산 시스템의 가장 어려운 도전 과제입니다. 네트워크 장애, 서비스 다운, 부분적인 실패 등으로 인해 일부 작업은 성공하고 일부는 실패하는 상황이 발생합니다.
이를 방치하면 재고와 주문 데이터가 맞지 않아 비즈니스 손실로 이어집니다. 바로 이럴 때 필요한 것이 Saga 패턴입니다.
분산 트랜잭션을 여러 개의 로컬 트랜잭션으로 나누고, 각 단계마다 보상 트랜잭션(Compensation)을 정의하여 실패 시 이전 상태로 롤백할 수 있습니다.
개요
간단히 말해서, Saga는 분산 환경에서 여러 서비스에 걸친 긴 트랜잭션을 관리하는 패턴으로, 각 단계가 로컬 트랜잭션으로 실행되고 실패 시 보상 로직으로 되돌립니다. 마이크로서비스에서는 각 서비스가 자신의 데이터베이스를 가지므로 분산 트랜잭션이 필수적입니다.
하지만 2PC(Two-Phase Commit) 같은 전통적인 방식은 성능이 느리고 가용성이 떨어집니다. Saga 패턴은 결과적 일관성(Eventual Consistency)을 수용하면서도 비즈니스 일관성을 보장합니다.
예를 들어, 주문 생성 → 재고 차감 → 결제 처리 → 배송 시작의 각 단계를 독립적으로 실행하고, 중간에 실패하면 역순으로 보상 작업을 실행합니다. 기존에는 분산 락이나 2PC로 강한 일관성을 유지하려 했다면, 이제는 Saga를 통해 각 서비스의 자율성을 유지하면서도 비즈니스 일관성을 달성할 수 있습니다.
Saga는 두 가지 구현 방식이 있습니다: Choreography(이벤트 기반 협업)와 Orchestration(중앙 조정자). Orchestration 방식은 흐름을 명확하게 파악하고 제어할 수 있어 복잡한 비즈니스 프로세스에 적합합니다.
코드 예제
// Saga Orchestrator 구현
interface SagaStep {
name: string;
execute: () => Promise<any>;
compensate: () => Promise<void>;
}
class SagaOrchestrator {
private steps: SagaStep[] = [];
private completedSteps: SagaStep[] = [];
addStep(step: SagaStep) {
this.steps.push(step);
return this;
}
async execute(): Promise<{ success: boolean; error?: Error }> {
try {
// 각 단계를 순차적으로 실행
for (const step of this.steps) {
console.log(`Executing step: ${step.name}`);
await step.execute();
this.completedSteps.push(step); // 성공한 단계 기록
}
return { success: true };
} catch (error) {
console.error(`Step failed, starting compensation`);
await this.compensate(); // 실패 시 보상 실행
return { success: false, error: error as Error };
}
}
private async compensate() {
// 완료된 단계를 역순으로 보상
for (const step of this.completedSteps.reverse()) {
try {
console.log(`Compensating step: ${step.name}`);
await step.compensate();
} catch (error) {
console.error(`Compensation failed for ${step.name}:`, error);
// 보상 실패는 별도로 처리 (알림, 수동 개입 등)
}
}
}
}
// 주문 처리 Saga 예시
async function createOrderSaga(orderData: any) {
const saga = new SagaOrchestrator();
saga
.addStep({
name: 'Create Order',
execute: async () => {
const order = await axios.post('http://order-service/orders', orderData);
return order.data;
},
compensate: async () => {
await axios.delete(`http://order-service/orders/${orderData.orderId}`);
}
})
.addStep({
name: 'Reserve Inventory',
execute: async () => {
await axios.post('http://inventory-service/reserve', {
productId: orderData.productId,
quantity: orderData.quantity
});
},
compensate: async () => {
await axios.post('http://inventory-service/release', {
productId: orderData.productId,
quantity: orderData.quantity
});
}
})
.addStep({
name: 'Process Payment',
execute: async () => {
await axios.post('http://payment-service/charge', {
orderId: orderData.orderId,
amount: orderData.amount
});
},
compensate: async () => {
await axios.post('http://payment-service/refund', {
orderId: orderData.orderId
});
}
});
return await saga.execute();
}
설명
이것이 하는 일: Saga Orchestrator는 여러 서비스에 걸친 복잡한 비즈니스 트랜잭션을 단계별로 실행하고, 중간에 실패가 발생하면 이미 완료된 단계들을 역순으로 되돌려 데이터 일관성을 유지합니다. 각 단계는 execute(실행)와 compensate(보상) 두 개의 함수를 가집니다.
첫 번째로, addStep 메서드로 Saga에 단계를 추가합니다. 각 단계는 이름, 실행 로직, 보상 로직을 포함합니다.
주문 생성 예시에서는 세 단계로 구성되는데, 주문 생성, 재고 예약, 결제 처리입니다. 중요한 점은 각 단계가 독립적으로 실행되며, 다른 서비스의 데이터베이스에 직접 접근하지 않고 API를 통해 통신한다는 것입니다.
그 다음으로, execute 메서드가 호출되면 등록된 단계들을 순차적으로 실행합니다. 각 단계가 성공하면 completedSteps 배열에 추가하여 추적합니다.
이 추적이 매우 중요한데, 나중에 어떤 단계를 보상해야 하는지 알 수 있기 때문입니다. 만약 세 번째 단계인 결제 처리에서 실패하면, 이미 완료된 첫 번째와 두 번째 단계만 보상하면 됩니다.
마지막으로, 어떤 단계에서든 에러가 발생하면 catch 블록으로 들어가 compensate 메서드를 호출합니다. 보상 메서드는 completedSteps를 역순으로 순회하며 각 단계의 compensate 함수를 실행합니다.
예를 들어, 결제가 실패했다면 재고 예약을 해제하고(release) 주문을 삭제합니다. 이렇게 하면 시스템이 트랜잭션 시작 전 상태로 돌아갑니다.
여러분이 이 코드를 사용하면 복잡한 분산 트랜잭션을 안전하게 관리할 수 있습니다. 각 서비스는 독립적으로 작동하면서도 전체 비즈니스 프로세스의 일관성이 보장됩니다.
또한 각 단계를 명확하게 정의하고 테스트할 수 있어 유지보수가 쉬워집니다. 실무에서는 Saga의 진행 상태를 데이터베이스에 저장하여 서버가 재시작되어도 계속 진행하거나 보상할 수 있도록 해야 합니다.
또한 멱등성(Idempotency)을 보장하여 같은 작업이 여러 번 실행되어도 안전하도록 설계해야 합니다.
실전 팁
💡 모든 Saga 단계와 보상 작업은 멱등성을 보장해야 합니다. 네트워크 재시도나 중복 메시지로 인해 같은 작업이 여러 번 실행될 수 있으므로, 중복 실행 검사 로직을 추가하세요. 💡 Saga의 상태를 데이터베이스에 영속화하세요. 서버가 재시작되거나 크래시되어도 진행 중이던 Saga를 복구하고 계속 실행하거나 보상할 수 있어야 합니다. 💡 보상 작업이 실패할 수 있음을 고려하세요. 보상 실패 시 재시도 로직을 구현하거나, 최종적으로 실패하면 수동 개입을 위한 알림과 대시보드를 준비해야 합니다. 💡 타임아웃을 설정하여 무한정 대기하지 않도록 하세요. 각 단계마다 적절한 타임아웃을 설정하고, 타임아웃 발생 시에도 보상 로직이 실행되도록 해야 합니다. 💡 Saga의 진행 상황을 모니터링하고 시각화하세요. 각 단계의 성공/실패율, 평균 실행 시간, 보상 실행 빈도 등을 추적하여 병목 지점을 파악하고 개선할 수 있습니다.
3. API Gateway 타임아웃 최적화 - 요청 병렬화로 응답 시간 단축하기
시작하며
여러분의 API가 여러 마이크로서비스에서 데이터를 가져와 조합해야 하는데, 각 서비스를 순차적으로 호출하다 보니 응답 시간이 너무 느려서 사용자가 이탈하는 경험을 해본 적 있나요? 예를 들어, 사용자 프로필 페이지를 보여주기 위해 사용자 정보(500ms), 주문 내역(700ms), 추천 상품(600ms)을 가져온다면 총 1.8초가 걸립니다.
이런 누적 지연(Cumulative Latency) 문제는 마이크로서비스 아키텍처의 흔한 성능 병목입니다. 각 서비스는 개별적으로 빠르더라도, 순차적으로 호출하면 전체 응답 시간이 선형으로 증가합니다.
특히 모바일 환경이나 느린 네트워크에서는 사용자 경험이 크게 저하됩니다. 바로 이럴 때 필요한 것이 요청 병렬화(Request Parallelization)입니다.
서로 독립적인 API 호출을 동시에 실행하여 전체 응답 시간을 가장 느린 서비스의 응답 시간으로 줄일 수 있습니다.
개요
간단히 말해서, 요청 병렬화는 여러 독립적인 API 호출을 동시에 실행하여 전체 응답 시간을 최소화하는 최적화 기법입니다. API Gateway나 BFF(Backend for Frontend) 레이어에서 여러 마이크로서비스의 데이터를 조합해야 하는 경우가 많습니다.
이때 순차적으로 호출하면 각 서비스의 지연 시간이 누적되어 전체 응답이 느려집니다. 병렬화를 사용하면 동시에 여러 요청을 보내고, 모든 응답이 도착할 때까지만 기다리면 됩니다.
예를 들어, 위 예시에서 세 서비스를 병렬로 호출하면 총 시간이 가장 느린 700ms로 줄어들어 60% 이상 빠르게 응답할 수 있습니다. 기존에는 async/await를 순차적으로 사용했다면, 이제는 Promise.all()을 활용하여 여러 비동기 작업을 동시에 실행할 수 있습니다.
이 기법의 핵심은 의존성 분석입니다. 어떤 요청이 다른 요청의 결과에 의존하는지 파악하여, 독립적인 요청만 병렬화해야 합니다.
또한 부분적인 실패를 처리하여, 일부 서비스가 실패해도 나머지 데이터는 반환할 수 있도록 설계해야 합니다.
코드 예제
// 병렬 요청 처리기
interface ServiceCall<T> {
name: string;
call: () => Promise<T>;
required: boolean; // 필수 여부
timeout?: number; // 개별 타임아웃
}
async function parallelFetch<T extends Record<string, any>>(
calls: ServiceCall<any>[]
): Promise<{ data: Partial<T>; errors: Record<string, Error> }> {
// 각 호출을 Promise로 래핑 (타임아웃 포함)
const promises = calls.map(async (serviceCall) => {
const timeoutMs = serviceCall.timeout || 5000;
try {
// Promise.race로 타임아웃 구현
const result = await Promise.race([
serviceCall.call(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
]);
return { name: serviceCall.name, data: result, error: null };
} catch (error) {
return {
name: serviceCall.name,
data: null,
error: error as Error,
required: serviceCall.required
};
}
});
// 모든 요청을 병렬로 실행
const results = await Promise.all(promises);
const data: Partial<T> = {};
const errors: Record<string, Error> = {};
// 결과 집계
for (const result of results) {
if (result.error) {
errors[result.name] = result.error;
// 필수 서비스가 실패하면 전체 실패
if (result.required) {
throw new Error(`Required service ${result.name} failed`);
}
} else {
data[result.name as keyof T] = result.data;
}
}
return { data, errors };
}
// 사용 예시: 사용자 대시보드 API
async function getUserDashboard(userId: string) {
const result = await parallelFetch([
{
name: 'userInfo',
call: () => axios.get(`http://user-service/users/${userId}`).then(r => r.data),
required: true,
timeout: 3000
},
{
name: 'orders',
call: () => axios.get(`http://order-service/users/${userId}/orders`).then(r => r.data),
required: false,
timeout: 5000
},
{
name: 'recommendations',
call: () => axios.get(`http://recommendation-service/users/${userId}`).then(r => r.data),
required: false,
timeout: 4000
},
{
name: 'notifications',
call: () => axios.get(`http://notification-service/users/${userId}`).then(r => r.data),
required: false,
timeout: 2000
}
]);
return {
...result.data,
_partialFailures: Object.keys(result.errors).length > 0,
_errors: result.errors
};
}
설명
이것이 하는 일: parallelFetch 함수는 여러 서비스 호출을 동시에 실행하고, 각 호출마다 개별 타임아웃을 적용하며, 부분적인 실패를 처리하여 가능한 데이터는 반환합니다. 이를 통해 전체 응답 시간을 최소화하면서도 일부 서비스 장애에 대한 복원력을 제공합니다.
첫 번째로, 각 서비스 호출을 ServiceCall 인터페이스로 정의합니다. 여기서 중요한 것은 required 플래그인데, 이는 해당 서비스가 필수인지 선택적인지를 나타냅니다.
사용자 정보는 필수이지만 추천 상품은 선택적일 수 있습니다. 또한 각 서비스마다 다른 타임아웃을 설정할 수 있어, 빠르게 응답해야 하는 서비스(알림 2초)와 느려도 되는 서비스(주문 내역 5초)를 차별화할 수 있습니다.
그 다음으로, Promise.race를 사용하여 각 호출에 타임아웃을 적용합니다. 실제 API 호출과 타임아웃 Promise를 경쟁시켜, 먼저 완료되는 것의 결과를 사용합니다.
이렇게 하면 느린 서비스가 전체 응답을 지연시키는 것을 방지할 수 있습니다. Promise.all을 사용하여 모든 요청을 동시에 시작하고, 모든 결과(성공 또는 실패)가 도착할 때까지 기다립니다.
마지막으로, 결과를 집계하면서 에러를 분리하여 처리합니다. 필수 서비스가 실패하면 전체 요청을 실패로 처리하고, 선택적 서비스가 실패하면 에러를 기록하되 나머지 데이터는 반환합니다.
클라이언트는 _partialFailures 플래그를 확인하여 일부 데이터가 누락되었음을 알 수 있고, UI에서 적절히 처리할 수 있습니다. 여러분이 이 코드를 사용하면 API 응답 시간을 크게 개선할 수 있습니다.
실제로 4개의 서비스를 순차 호출하면 2-3초 걸리던 것이 병렬 호출로 500-700ms로 줄어들 수 있습니다. 또한 일부 서비스가 느리거나 실패해도 전체 응답이 차단되지 않아 사용자 경험이 향상됩니다.
실무에서는 각 서비스의 평균 응답 시간과 P99 레이턴시를 분석하여 적절한 타임아웃을 설정해야 합니다. 또한 병렬 요청이 백엔드 서비스에 부하를 주지 않도록 Rate Limiting과 Circuit Breaker를 함께 사용하는 것이 좋습니다.
실전 팁
💡 의존성이 있는 요청은 병렬화하지 마세요. 예를 들어, 사용자 ID를 먼저 조회한 후 그 ID로 주문을 조회해야 한다면 순차적으로 실행해야 합니다. 의존성 그래프를 그려서 병렬화 가능한 부분을 식별하세요. 💡 타임아웃은 서비스의 P95 또는 P99 레이턴시보다 약간 높게 설정하세요. 너무 낮으면 정상적인 요청도 실패하고, 너무 높으면 장애 시 응답이 지연됩니다. 모니터링 데이터를 기반으로 조정하세요. 💡 부분 실패 시 클라이언트에게 명확히 알리세요. _partialFailures 플래그나 HTTP 206 Partial Content 상태 코드를 사용하여, 일부 데이터가 누락되었음을 전달하고 재시도 옵션을 제공하세요. 💡 캐싱을 함께 활용하세요. 자주 조회되는 데이터(사용자 정보, 추천 상품)는 Redis 같은 캐시에 저장하여, 백엔드 서비스 호출 자체를 줄일 수 있습니다. 캐시 히트 시 응답 시간이 100ms 이하로 줄어듭니다. 💡 병렬 요청 수를 제한하세요. 너무 많은 서비스를 동시에 호출하면 네트워크와 메모리 리소스를 소진할 수 있습니다. 보통 3-5개 정도가 적절하며, 더 많다면 UI를 점진적으로 로딩하는 것을 고려하세요.
4. 서비스 디스커버리 장애 대응 - 클라이언트 사이드 로드밸런싱과 헬스체크
시작하며
여러분의 마이크로서비스가 다른 서비스를 호출하려 할 때, 서비스 디스커버리(Consul, Eureka)가 다운되어 서비스 주소를 찾을 수 없어서 전체 시스템이 마비된 경험이 있나요? 또는 로드밸런서가 헬스 체크 없이 다운된 인스턴스로 트래픽을 보내서 요청이 실패하는 상황을 겪어본 적이 있나요?
이런 서비스 디스커버리 의존성 문제는 분산 시스템의 단일 장애점(Single Point of Failure)이 될 수 있습니다. 중앙화된 디스커버리 서비스가 장애를 겪으면 모든 서비스 간 통신이 중단됩니다.
또한 인스턴스 목록이 오래되어 이미 종료된 서비스로 요청이 라우팅되는 문제도 발생합니다. 바로 이럴 때 필요한 것이 클라이언트 사이드 로드밸런싱과 로컬 캐싱입니다.
각 클라이언트가 서비스 인스턴스 목록을 로컬에 캐싱하고, 자체적으로 헬스 체크와 로드밸런싱을 수행하여 디스커버리 서비스에 대한 의존성을 줄일 수 있습니다.
개요
간단히 말해서, 클라이언트 사이드 로드밸런싱은 각 서비스가 대상 서비스의 인스턴스 목록을 로컬에 캐싱하고, 자체적으로 헬스 체크를 수행하여 건강한 인스턴스로만 요청을 분산합니다. 전통적인 서버 사이드 로드밸런싱(Nginx, HAProxy)은 중앙화된 로드밸런서를 통해 트래픽을 분산합니다.
하지만 마이크로서비스 환경에서는 이것이 병목이 되고 단일 장애점이 될 수 있습니다. 클라이언트 사이드 로드밸런싱은 각 서비스가 스마트 클라이언트 역할을 하여, 디스커버리 서비스에서 인스턴스 목록을 가져와 로컬에 캐싱하고, 주기적으로 헬스 체크를 수행하여 자율적으로 로드밸런싱합니다.
기존에는 모든 요청마다 디스커버리 서비스에 질의했다면, 이제는 로컬 캐시를 사용하여 빠르게 응답하고, 디스커버리 서비스가 일시적으로 다운되어도 마지막으로 알려진 인스턴스 목록으로 계속 작동할 수 있습니다. 이 패턴의 핵심 요소는 세 가지입니다: 인스턴스 목록의 주기적 갱신, 실시간 헬스 체크, 그리고 지능적인 로드밸런싱 알고리즘(라운드 로빈, 최소 응답 시간 등)입니다.
이를 통해 높은 가용성과 낮은 레이턴시를 동시에 달성할 수 있습니다.
코드 예제
import axios from 'axios';
interface ServiceInstance {
id: string;
host: string;
port: number;
healthy: boolean;
lastCheck: number;
failureCount: number;
}
class ClientSideLoadBalancer {
private instances: ServiceInstance[] = [];
private currentIndex = 0;
private healthCheckInterval: NodeJS.Timeout | null = null;
constructor(
private serviceName: string,
private discoveryUrl: string,
private healthCheckPath: string = '/health',
private refreshIntervalMs: number = 30000
) {
this.initialize();
}
private async initialize() {
// 초기 인스턴스 목록 가져오기
await this.refreshInstances();
// 주기적으로 디스커버리 서비스에서 인스턴스 갱신
setInterval(() => this.refreshInstances(), this.refreshIntervalMs);
// 헬스 체크를 더 자주 수행 (10초마다)
this.healthCheckInterval = setInterval(
() => this.performHealthChecks(),
10000
);
}
private async refreshInstances() {
try {
const response = await axios.get(
`${this.discoveryUrl}/services/${this.serviceName}`,
{ timeout: 5000 }
);
// 새로운 인스턴스 병합 (기존 헬스 상태 유지)
const newInstances = response.data.instances;
this.instances = newInstances.map((inst: any) => {
const existing = this.instances.find(i => i.id === inst.id);
return {
id: inst.id,
host: inst.host,
port: inst.port,
healthy: existing?.healthy ?? true,
lastCheck: existing?.lastCheck ?? Date.now(),
failureCount: existing?.failureCount ?? 0
};
});
console.log(`Refreshed ${this.instances.length} instances for ${this.serviceName}`);
} catch (error) {
console.error('Failed to refresh instances, using cached list', error);
// 디스커버리 서비스 장애 시 캐시된 목록 계속 사용
}
}
private async performHealthChecks() {
const checks = this.instances.map(async (instance) => {
try {
await axios.get(
`http://${instance.host}:${instance.port}${this.healthCheckPath}`,
{ timeout: 2000 }
);
// 성공 시 healthy로 마킹하고 실패 카운트 리셋
instance.healthy = true;
instance.failureCount = 0;
instance.lastCheck = Date.now();
} catch (error) {
// 실패 시 카운트 증가, 3번 연속 실패하면 unhealthy
instance.failureCount++;
if (instance.failureCount >= 3) {
instance.healthy = false;
}
instance.lastCheck = Date.now();
}
});
await Promise.all(checks);
}
// 라운드 로빈 방식으로 건강한 인스턴스 선택
getNextInstance(): ServiceInstance | null {
const healthyInstances = this.instances.filter(i => i.healthy);
if (healthyInstances.length === 0) {
console.error('No healthy instances available');
return null;
}
// 라운드 로빈
const instance = healthyInstances[this.currentIndex % healthyInstances.length];
this.currentIndex++;
return instance;
}
async call<T>(path: string, options: any = {}): Promise<T> {
const instance = this.getNextInstance();
if (!instance) {
throw new Error(`No healthy instances for ${this.serviceName}`);
}
const url = `http://${instance.host}:${instance.port}${path}`;
try {
const response = await axios({ ...options, url });
return response.data;
} catch (error) {
// 실패 시 인스턴스를 일시적으로 unhealthy로 마킹
instance.failureCount++;
if (instance.failureCount >= 2) {
instance.healthy = false;
}
throw error;
}
}
destroy() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
}
}
// 사용 예시
const orderServiceLB = new ClientSideLoadBalancer(
'order-service',
'http://consul:8500',
'/health'
);
async function createOrder(orderData: any) {
return await orderServiceLB.call('/orders', {
method: 'POST',
data: orderData
});
}
설명
이것이 하는 일: ClientSideLoadBalancer는 디스커버리 서비스에서 가져온 서비스 인스턴스 목록을 로컬에 캐싱하고, 주기적으로 각 인스턴스의 건강 상태를 체크하여, 요청 시 건강한 인스턴스로만 트래픽을 라우팅합니다. 이를 통해 디스커버리 서비스에 대한 의존성을 줄이고 더 빠르고 안정적인 서비스 간 통신을 제공합니다.
첫 번째로, initialize 메서드에서 두 가지 주기적 작업을 설정합니다. refreshInstances는 30초마다 디스커버리 서비스에서 최신 인스턴스 목록을 가져와 로컬 캐시를 갱신합니다.
중요한 점은 새로운 목록을 가져올 때 기존 인스턴스의 헬스 상태와 실패 카운트를 보존한다는 것입니다. 이렇게 하면 디스커버리 서비스가 알려주지 않는 실시간 헬스 정보를 유지할 수 있습니다.
또한 디스커버리 서비스 호출이 실패해도 try-catch로 감싸서 캐시된 목록을 계속 사용합니다. 그 다음으로, performHealthChecks는 10초마다 모든 인스턴스에 헬스 체크 요청을 보냅니다.
각 인스턴스의 /health 엔드포인트를 호출하여 실제로 작동 중인지 확인합니다. 3번 연속 실패하면 해당 인스턴스를 unhealthy로 마킹하여 더 이상 트래픽을 보내지 않습니다.
반대로 성공하면 즉시 healthy로 복구하여 빠르게 트래픽을 받을 수 있도록 합니다. Promise.all을 사용하여 모든 헬스 체크를 병렬로 수행하므로 전체 시간이 단일 체크 시간으로 제한됩니다.
마지막으로, getNextInstance 메서드는 라운드 로빈 알고리즘으로 건강한 인스턴스를 순환하며 선택합니다. 먼저 healthy 플래그가 true인 인스턴스만 필터링하고, currentIndex를 사용하여 매 요청마다 다른 인스턴스를 반환합니다.
이렇게 하면 부하가 균등하게 분산되고, 특정 인스턴스에 트래픽이 몰리는 것을 방지합니다. call 메서드에서는 실제 요청 실패 시에도 failureCount를 증가시켜 실시간으로 문제 있는 인스턴스를 감지합니다.
여러분이 이 코드를 사용하면 디스커버리 서비스가 일시적으로 장애를 겪어도 서비스가 계속 작동할 수 있습니다. 또한 로드밸런서나 프록시를 거치지 않고 직접 통신하므로 네트워크 홉이 줄어들어 레이턴시가 감소합니다.
실시간 헬스 체크로 다운된 인스턴스를 빠르게 감지하여 요청 실패율을 크게 낮출 수 있습니다. 실무에서는 라운드 로빈 외에도 최소 응답 시간(Least Response Time) 알고리즘을 구현하여 더 빠른 인스턴스로 트래픽을 보낼 수 있습니다.
또한 인스턴스별 메트릭(응답 시간, 에러율)을 수집하여 Prometheus로 내보내면 모니터링과 알림을 설정할 수 있습니다.
실전 팁
💡 헬스 체크 엔드포인트는 가볍고 빠르게 응답해야 합니다. 데이터베이스 쿼리나 외부 API 호출을 포함하면 헬스 체크 자체가 부하가 되므로, 단순히 서버가 살아있는지만 확인하세요. 💡 인스턴스 목록 갱신과 헬스 체크 주기를 적절히 조정하세요. 갱신은 덜 자주(30-60초), 헬스 체크는 더 자주(10-15초) 수행하는 것이 좋습니다. 너무 자주 갱신하면 디스커버리 서비스에 부하를 줍니다. 💡 실패 임계값을 환경에 맞게 조정하세요. 안정적인 환경에서는 3번 실패 시 unhealthy로 마킹하고, 불안정한 환경에서는 5-10번으로 높여서 일시적 네트워크 오류로 인한 오탐을 줄이세요. 💡 Weighted Round Robin이나 Least Connections 같은 고급 로드밸런싱 알고리즘도 고려하세요. 인스턴스마다 처리 능력이 다르다면 가중치를 부여하거나, 현재 진행 중인 요청 수를 추적하여 부하가 적은 인스턴스로 라우팅할 수 있습니다. 💡 서비스 메시(Istio, Linkerd)를 사용하는 것도 고려하세요. 이 패턴을 직접 구현하는 대신, 서비스 메시가 제공하는 검증된 클라이언트 사이드 로드밸런싱과 헬스 체크를 활용할 수 있습니다.
5. 분산 로깅과 추적 - Correlation ID로 요청 흐름 추적하기
시작하며
여러분이 사용자로부터 "주문이 실패했다"는 버그 리포트를 받았는데, 주문 서비스, 재고 서비스, 결제 서비스, 배송 서비스를 거치는 복잡한 흐름에서 어디서 문제가 발생했는지 찾느라 몇 시간을 허비한 경험이 있나요? 각 서비스의 로그를 일일이 뒤지며 타임스탬프를 맞춰보는 것은 매우 비효율적입니다.
이런 분산 추적(Distributed Tracing) 문제는 마이크로서비스의 가장 큰 운영 과제 중 하나입니다. 모놀리식 애플리케이션에서는 단일 로그 파일에서 요청을 추적하면 되지만, 마이크로서비스에서는 하나의 사용자 요청이 수십 개의 서비스와 수백 개의 로그 라인으로 흩어집니다.
문제를 디버깅하려면 이 흩어진 로그를 연결해야 합니다. 바로 이럴 때 필요한 것이 Correlation ID(또는 Trace ID)입니다.
각 요청에 고유한 ID를 부여하고, 모든 서비스가 이 ID를 로그에 기록하면 전체 요청 흐름을 쉽게 추적하고 시각화할 수 있습니다.
개요
간단히 말해서, Correlation ID는 분산 시스템에서 단일 사용자 요청의 전체 생명주기를 추적하기 위해 모든 서비스에서 공유하는 고유 식별자입니다. 마이크로서비스 환경에서는 하나의 HTTP 요청이 여러 서비스를 거치며 연쇄적인 호출을 발생시킵니다.
Correlation ID를 사용하면 첫 진입점에서 ID를 생성하고, 모든 후속 서비스 호출에 이 ID를 헤더로 전달합니다. 각 서비스는 받은 ID를 모든 로그 메시지에 포함시켜 기록합니다.
예를 들어, API Gateway에서 생성된 corr-id-12345가 주문 서비스, 결제 서비스, 배송 서비스의 모든 로그에 나타나므로, 이 ID로 검색하면 전체 요청 흐름을 한눈에 볼 수 있습니다. 기존에는 타임스탬프나 사용자 ID로 로그를 연결하려 했다면, 이제는 Correlation ID를 통해 정확하고 빠르게 관련 로그를 찾을 수 있습니다.
이 패턴의 핵심은 일관성입니다. 모든 서비스가 동일한 헤더 이름(예: X-Correlation-ID)을 사용하고, 모든 로그에 이 ID를 포함시켜야 합니다.
또한 비동기 메시징(Kafka, RabbitMQ)을 사용할 때도 메시지 메타데이터에 ID를 포함시켜 추적을 이어나가야 합니다.
코드 예제
import { v4 as uuidv4 } from 'uuid';
import express from 'express';
import axios from 'axios';
import winston from 'winston';
// Correlation ID 미들웨어
function correlationIdMiddleware(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
// 기존 Correlation ID가 있으면 사용, 없으면 생성
const correlationId = req.headers['x-correlation-id'] as string || uuidv4();
// 요청 객체에 저장 (이후 로깅에서 사용)
req.correlationId = correlationId;
// 응답 헤더에도 포함 (클라이언트가 디버깅에 활용)
res.setHeader('X-Correlation-ID', correlationId);
next();
}
// Winston 로거 설정 (Correlation ID 자동 포함)
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'app.log' })
]
});
// Correlation ID를 포함한 로깅 헬퍼
function logWithCorrelation(
correlationId: string,
level: string,
message: string,
meta: any = {}
) {
logger.log(level, message, {
correlationId,
...meta
});
}
// Axios 인터셉터: 외부 서비스 호출 시 Correlation ID 전달
axios.interceptors.request.use((config) => {
// 현재 요청 컨텍스트에서 Correlation ID 가져오기
const correlationId = (config as any).correlationId;
if (correlationId) {
config.headers['X-Correlation-ID'] = correlationId;
}
return config;
});
// Express 앱 설정
const app = express();
app.use(express.json());
app.use(correlationIdMiddleware);
// 예시 엔드포인트: 주문 생성
app.post('/orders', async (req, res) => {
const { correlationId } = req;
logWithCorrelation(correlationId!, 'info', 'Order creation started', {
userId: req.body.userId,
productId: req.body.productId
});
try {
// 재고 확인 (Correlation ID 전달)
logWithCorrelation(correlationId!, 'info', 'Checking inventory');
const inventoryResponse = await axios.get(
'http://inventory-service/check',
{
params: { productId: req.body.productId },
correlationId // 커스텀 속성으로 전달
} as any
);
// 결제 처리 (Correlation ID 전달)
logWithCorrelation(correlationId!, 'info', 'Processing payment');
const paymentResponse = await axios.post(
'http://payment-service/charge',
{ amount: req.body.amount },
{ correlationId } as any
);
logWithCorrelation(correlationId!, 'info', 'Order created successfully', {
orderId: 'ORDER-12345'
});
res.json({ success: true, orderId: 'ORDER-12345' });
} catch (error: any) {
logWithCorrelation(correlationId!, 'error', 'Order creation failed', {
error: error.message,
stack: error.stack
});
res.status(500).json({ error: 'Order creation failed' });
}
});
// 타입 확장 (TypeScript)
declare global {
namespace Express {
interface Request {
correlationId?: string;
}
}
}
app.listen(3000, () => {
console.log('Order service listening on port 3000');
});
설명
이것이 하는 일: Correlation ID 미들웨어는 모든 들어오는 HTTP 요청에 고유한 ID를 부여하거나 기존 ID를 유지하고, 이 ID를 모든 로그와 후속 서비스 호출에 전달하여 분산된 로그를 하나로 연결합니다. 이를 통해 복잡한 마이크로서비스 환경에서도 특정 요청의 전체 실행 경로를 쉽게 추적할 수 있습니다.
첫 번째로, correlationIdMiddleware가 모든 요청을 가로채서 Correlation ID를 확인합니다. 만약 클라이언트나 상위 서비스가 X-Correlation-ID 헤더를 이미 제공했다면 그것을 사용하고, 없다면 uuid를 사용하여 새로운 ID를 생성합니다.
이 ID를 요청 객체에 저장하여 요청 처리 과정 전체에서 접근할 수 있게 하고, 응답 헤더에도 포함시켜 클라이언트가 문제 리포트 시 이 ID를 함께 제공할 수 있도록 합니다. 그 다음으로, logWithCorrelation 헬퍼 함수를 통해 모든 로그에 자동으로 Correlation ID를 포함시킵니다.
Winston 같은 구조화된 로깅 라이브러리를 사용하면 JSON 형식으로 로그가 저장되어, 나중에 Elasticsearch나 Splunk 같은 로그 분석 도구에서 correlationId 필드로 쉽게 검색할 수 있습니다. 예를 들어, correlationId: "abc-123"으로 검색하면 해당 요청과 관련된 모든 서비스의 모든 로그를 시간 순서대로 볼 수 있습니다.
마지막으로, Axios 인터셉터를 사용하여 다른 서비스를 호출할 때 자동으로 Correlation ID를 헤더에 추가합니다. 주문 서비스가 재고 서비스를 호출할 때 동일한 ID를 전달하므로, 재고 서비스의 로그에도 같은 ID가 기록됩니다.
이렇게 하면 요청이 여러 서비스를 거치더라도 전체 체인을 추적할 수 있습니다. 만약 결제 서비스에서 에러가 발생했다면, 해당 Correlation ID로 검색하여 주문 생성부터 결제 실패까지의 전체 컨텍스트를 확인할 수 있습니다.
여러분이 이 코드를 사용하면 디버깅 시간을 몇 시간에서 몇 분으로 단축할 수 있습니다. 사용자가 "주문이 실패했다"고 리포트할 때 Correlation ID를 함께 제공하면, 즉시 해당 요청의 모든 로그를 찾아 문제의 근본 원인을 파악할 수 있습니다.
또한 성능 분석에도 유용한데, 특정 요청이 각 서비스에서 얼마나 시간을 소비했는지 추적할 수 있습니다. 실무에서는 OpenTelemetry나 Jaeger 같은 분산 추적 시스템을 사용하여 더 풍부한 정보를 수집할 수 있습니다.
이들은 Correlation ID뿐만 아니라 Span(각 서비스의 처리 시간), Tags(메타데이터), Baggage(컨텍스트 정보)를 자동으로 수집하고 시각화합니다.
실전 팁
💡 Correlation ID는 가능한 한 가장 앞단(API Gateway, Load Balancer)에서 생성하세요. 이렇게 하면 전체 요청 흐름을 완전히 추적할 수 있습니다. 각 서비스에서 개별적으로 생성하면 연결이 끊깁니다. 💡 비동기 메시징(Kafka, RabbitMQ) 사용 시에도 Correlation ID를 메시지 헤더나 메타데이터에 포함시키세요. 이벤트 기반 아키텍처에서도 추적을 이어나갈 수 있습니다. 💡 Correlation ID를 고객 지원 팀에게도 노출하세요. API 응답 헤더나 에러 메시지에 포함시켜, 사용자가 문제를 리포트할 때 이 ID를 제공하면 즉시 관련 로그를 찾을 수 있습니다. 💡 로그 수집 스택(ELK, Loki)에서 Correlation ID로 인덱싱하세요. 빠른 검색을 위해 이 필드를 인덱스로 설정하고, 대시보드에서 필터로 사용할 수 있도록 구성하세요. 💡 OpenTelemetry를 사용하여 더 풍부한 추적 정보를 수집하세요. Correlation ID뿐만 아니라 각 서비스의 처리 시간, 함수 호출 스택, 데이터베이스 쿼리 등을 자동으로 추적하고 Jaeger나 Zipkin으로 시각화할 수 있습니다.
6. 데이터베이스 연결 고갈 해결 - Connection Pool 최적화와 모니터링
시작하며
여러분의 서비스가 갑자기 "Too many connections" 에러를 내며 모든 요청이 실패하고, 데이터베이스는 정상이지만 애플리케이션이 더 이상 연결을 얻지 못하는 상황을 겪어본 적 있나요? 특히 트래픽이 급증하거나 느린 쿼리가 연결을 오래 점유할 때 이런 문제가 발생합니다.
이런 연결 고갈(Connection Exhaustion) 문제는 마이크로서비스 환경에서 각 서비스가 독립적으로 데이터베이스 연결 풀을 관리할 때 특히 심각합니다. 연결 풀 설정이 잘못되었거나, 연결 누수(Connection Leak)가 있거나, 동시 요청이 풀 크기를 초과하면 새로운 요청이 연결을 기다리다가 타임아웃됩니다.
바로 이럴 때 필요한 것이 적절한 Connection Pool 설정과 모니터링입니다. 연결 풀의 크기를 최적화하고, 유휴 연결을 정리하고, 연결 누수를 탐지하여 안정적인 데이터베이스 액세스를 보장할 수 있습니다.
개요
간단히 말해서, Connection Pool은 데이터베이스 연결을 미리 생성하여 재사용함으로써 연결 생성 오버헤드를 줄이고, 동시 연결 수를 제한하여 데이터베이스를 보호하는 메커니즘입니다. 데이터베이스 연결을 매번 새로 생성하는 것은 비용이 큽니다.
TCP 핸드셰이크, 인증, 세션 초기화 등이 필요하므로 수십~수백 밀리초가 걸립니다. Connection Pool은 미리 정해진 수의 연결을 생성하고 유지하여, 요청이 들어오면 즉시 사용 가능한 연결을 제공합니다.
예를 들어, 풀 크기를 10으로 설정하면 최대 10개의 동시 쿼리를 실행할 수 있고, 11번째 요청은 기존 연결이 반환될 때까지 대기합니다. 기존에는 연결을 무한정 생성하거나 고정된 설정을 사용했다면, 이제는 트래픽 패턴과 데이터베이스 용량에 맞게 동적으로 조정하고 모니터링할 수 있습니다.
Connection Pool의 핵심 설정은 세 가지입니다: 최소 연결 수(min), 최대 연결 수(max), 유휴 연결 타임아웃(idle timeout). 또한 연결 누수를 탐지하기 위한 획득 타임아웃(acquire timeout)과 연결 검증(validation)도 중요합니다.
코드 예제
import { Pool, PoolClient } from 'pg';
import { EventEmitter } from 'events';
interface PoolMetrics {
totalConnections: number;
idleConnections: number;
waitingRequests: number;
totalAcquired: number;
totalReleased: number;
acquireTimeMs: number[];
}
class MonitoredConnectionPool extends EventEmitter {
private pool: Pool;
private metrics: PoolMetrics = {
totalConnections: 0,
idleConnections: 0,
waitingRequests: 0,
totalAcquired: 0,
totalReleased: 0,
acquireTimeMs: []
};
constructor() {
super();
this.pool = new Pool({
host: 'localhost',
database: 'mydb',
user: 'user',
password: 'password',
// Connection Pool 설정
min: 2, // 최소 유지 연결 수
max: 10, // 최대 연결 수
idleTimeoutMillis: 30000, // 유휴 연결 30초 후 제거
connectionTimeoutMillis: 5000, // 연결 획득 타임아웃
// 연결 검증 (연결이 아직 유효한지 확인)
allowExitOnIdle: true
});
this.setupMonitoring();
}
private setupMonitoring() {
// 연결 획득 이벤트
this.pool.on('acquire', () => {
this.metrics.totalAcquired++;
this.emit('metrics', this.getMetrics());
});
// 연결 반환 이벤트
this.pool.on('release', () => {
this.metrics.totalReleased++;
this.emit('metrics', this.getMetrics());
});
// 에러 이벤트
this.pool.on('error', (err) => {
console.error('Unexpected pool error:', err);
this.emit('error', err);
});
// 주기적 메트릭 수집 (10초마다)
setInterval(() => {
this.emit('metrics', this.getMetrics());
}, 10000);
}
getMetrics() {
return {
...this.metrics,
totalConnections: this.pool.totalCount,
idleConnections: this.pool.idleCount,
waitingRequests: this.pool.waitingCount,
avgAcquireTimeMs: this.metrics.acquireTimeMs.length > 0
? this.metrics.acquireTimeMs.reduce((a, b) => a + b, 0) / this.metrics.acquireTimeMs.length
: 0
};
}
// 연결 획득 (타임아웃 및 메트릭 수집)
async acquire(): Promise<PoolClient> {
const startTime = Date.now();
try {
const client = await this.pool.connect();
const acquireTime = Date.now() - startTime;
// 획득 시간 기록 (최근 100개만 유지)
this.metrics.acquireTimeMs.push(acquireTime);
if (this.metrics.acquireTimeMs.length > 100) {
this.metrics.acquireTimeMs.shift();
}
// 느린 획득 경고 (500ms 이상)
if (acquireTime > 500) {
console.warn(`Slow connection acquire: ${acquireTime}ms`);
this.emit('slow-acquire', acquireTime);
}
return client;
} catch (error) {
const acquireTime = Date.now() - startTime;
console.error(`Failed to acquire connection after ${acquireTime}ms:`, error);
throw error;
}
}
// 쿼리 헬퍼 (자동 연결 반환)
async query<T = any>(text: string, params?: any[]): Promise<T[]> {
const client = await this.acquire();
try {
const result = await client.query(text, params);
return result.rows;
} finally {
// 반드시 연결 반환 (연결 누수 방지)
client.release();
}
}
// 트랜잭션 헬퍼
async transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.acquire();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release(); // 반드시 반환
}
}
// 연결 풀 종료
async close() {
await this.pool.end();
}
}
// 사용 예시
const dbPool = new MonitoredConnectionPool();
// 메트릭 모니터링
dbPool.on('metrics', (metrics) => {
console.log('Pool metrics:', metrics);
// 경고: 활성 연결이 최대치에 근접
if (metrics.totalConnections >= 8) {
console.warn('Connection pool near capacity!');
}
// 경고: 대기 중인 요청이 많음
if (metrics.waitingRequests > 5) {
console.warn('Many requests waiting for connections!');
}
});
// 쿼리 실행
async function getUsers() {
return await dbPool.query('SELECT * FROM users WHERE active = $1', [true]);
}
// 트랜잭션 실행
async function createOrder(userId: string, items: any[]) {
return await dbPool.transaction(async (client) => {
const order = await client.query(
'INSERT INTO orders (user_id) VALUES ($1) RETURNING id',
[userId]
);
for (const item of items) {
await client.query(
'INSERT INTO order_items (order_id, product_id, quantity) VALUES ($1, $2, $3)',
[order.rows[0].id, item.productId, item.quantity]
);
}
return order.rows[0];
});
}
설명
이것이 하는 일: MonitoredConnectionPool은 PostgreSQL 연결 풀을 래핑하여 연결 획득, 반환, 타임아웃을 모니터링하고, 메트릭을 수집하여 연결 풀의 건강 상태를 실시간으로 추적합니다. 또한 자동 연결 반환을 보장하여 연결 누수를 방지하고, 느린 연결 획득을 경고하여 병목을 조기에 발견합니다.
첫 번째로, Pool 객체를 생성할 때 핵심 설정을 지정합니다. min(최소 연결 수)을 2로 설정하면 항상 2개의 연결이 유지되어 초기 요청에 빠르게 응답할 수 있습니다.
max(최대 연결 수)를 10으로 설정하면 동시에 최대 10개의 쿼리를 실행할 수 있으며, 데이터베이스가 과부하되는 것을 방지합니다. idleTimeoutMillis를 30초로 설정하면 사용되지 않는 연결을 자동으로 정리하여 리소스를 절약합니다.
connectionTimeoutMillis는 연결을 기다리는 최대 시간으로, 5초 이상 대기하면 에러를 던져 무한정 블로킹되는 것을 방지합니다. 그 다음으로, setupMonitoring 메서드에서 연결 풀의 이벤트를 리스닝하여 메트릭을 수집합니다.
acquire와 release 이벤트를 추적하여 획득된 연결과 반환된 연결의 수를 기록합니다. 만약 totalAcquired가 totalReleased보다 계속 많다면 연결 누수가 발생하고 있다는 신호입니다.
또한 연결 획득 시간을 측정하여 평균을 계산하고, 500ms 이상 걸리면 경고를 발생시켜 풀이 고갈되고 있음을 알립니다. 마지막으로, query와 transaction 헬퍼 메서드는 자동으로 연결을 획득하고 finally 블록에서 반드시 반환합니다.
이것이 매우 중요한데, 연결을 수동으로 관리하면 예외 발생 시 연결을 반환하지 않는 버그가 쉽게 발생합니다. try-finally 패턴을 사용하면 어떤 상황에서도 연결이 반환되어 연결 누수를 방지할 수 있습니다.
transaction 메서드는 추가로 BEGIN, COMMIT, ROLLBACK을 자동으로 처리하여 트랜잭션 관리를 단순화합니다. 여러분이 이 코드를 사용하면 데이터베이스 연결 문제를 조기에 발견하고 해결할 수 있습니다.
메트릭을 Prometheus로 내보내면 Grafana 대시보드에서 실시간으로 모니터링하고, 연결 풀이 고갈되기 전에 알림을 받을 수 있습니다. 또한 연결 획득 시간이 증가하는 추세를 관찰하여 풀 크기를 늘리거나 느린 쿼리를 최적화할 수 있습니다.
실무에서는 연결 풀 크기를 계산하는 공식을 사용할 수 있습니다: 풀 크기 = (코어 수 * 2) + 디스크 수. 예를 들어, 4코어 CPU와 1개 디스크를 가진 서버라면 적정 풀 크기는 9입니다.
하지만 이것은 시작점일 뿐이며, 실제 부하 테스트를 통해 최적값을 찾아야 합니다.
실전 팁
💡 연결 풀 크기는 무조건 크다고 좋은 것이 아닙니다. 데이터베이스 서버의 max_connections 설정과 모든 애플리케이션 인스턴스의 풀 크기 합을 고려하세요. 예를 들어, DB max_connections가 100이고 10개의 앱 인스턴스가 있다면 각 인스턴스의 풀 크기는 10 이하로 설정해야 합니다.
💡 연결 검증(validation query)을 설정하여 죽은 연결을 자동으로 제거하세요. PostgreSQL에서는 간단한 SELECT 1 쿼리를 주기적으로 실행하여 연결이 아직 유효한지 확인할 수 있습니다.
💡 느린 쿼리를 모니터링하고 최적화하세요. 연결을 오래 점유하는 쿼리가 있으면 풀이 빠르게 고갈됩니다. PostgreSQL의 pg_stat_statements나 slow query log를 사용하여 병목을 찾으세요.
💡 Read Replica를 사용하여 읽기 부하를 분산하세요. 쓰기 쿼리는 Primary DB로, 읽기 쿼리는 Replica로 라우팅하면 Primary의 연결 풀 압박을 줄이고 전체 처리량을 높일 수 있습니다.
💡 메트릭을 시계열 데이터베이스(Prometheus, InfluxDB)에 저장하고 대시보드로 시각화하세요. 연결 풀 사용률, 평균 획득 시간, 대기 중인 요청 수를 그래프로 보면 문제를 빠르게 파악할 수 있습니다.
7. 카오스 엔지니어링 - 장애 주입으로 시스템 복원력 테스트하기
시작하며
여러분의 마이크로서비스가 프로덕션에서는 완벽하게 작동하다가, 하나의 서비스가 다운되거나 네트워크가 지연되는 순간 전체 시스템이 연쇄적으로 무너지는 것을 경험한 적 있나요? 개발 환경에서는 모든 것이 정상이기 때문에 이런 장애 시나리오를 테스트하기 어렵습니다.
이런 예상치 못한 장애 상황에 대한 준비 부족은 프로덕션 장애의 주요 원인입니다. Circuit Breaker, Retry, Fallback 같은 복원력 패턴을 구현했더라도, 실제로 제대로 작동하는지 검증하지 않으면 막상 장애가 발생했을 때 무용지물이 됩니다.
Netflix는 이런 문제를 해결하기 위해 카오스 엔지니어링을 도입했고, 이를 통해 시스템의 약점을 사전에 발견하고 개선할 수 있었습니다. 바로 이럴 때 필요한 것이 카오스 엔지니어링(Chaos Engineering)입니다.
의도적으로 장애를 주입하여 시스템이 어떻게 반응하는지 관찰하고, 복원력을 검증하며, 숨어있는 약점을 찾아낼 수 있습니다.
개요
간단히 말해서, 카오스 엔지니어링은 시스템에 의도적으로 장애를 주입하여 실제 장애 상황을 시뮬레이션하고, 시스템의 복원력과 약점을 발견하는 실험적 접근 방식입니다. 카오스 엔지니어링의 핵심 아이디어는 "장애는 언제든 발생할 수 있으므로, 사전에 통제된 환경에서 장애를 발생시켜 시스템의 반응을 관찰하고 개선하자"는 것입니다.
주입할 수 있는 장애 유형은 다양합니다: 서비스 다운, 네트워크 지연, 패킷 손실, CPU/메모리 고갈, 디스크 장애 등. 예를 들어, 결제 서비스가 갑자기 500 에러를 반환하도록 만들어, 주문 서비스의 Circuit Breaker가 제대로 작동하고 Fallback이 실행되는지 확인할 수 있습니다.
기존에는 장애가 발생하기를 기다리거나 수동으로 서비스를 종료하며 테스트했다면, 이제는 자동화된 카오스 실험을 통해 정기적으로 시스템의 복원력을 검증할 수 있습니다. 카오스 엔지니어링은 네 단계로 진행됩니다: 1) 정상 상태 정의(SLI, SLO), 2) 가설 수립("결제 서비스가 다운되어도 주문이 큐에 저장되어야 함"), 3) 장애 주입, 4) 가설 검증.
만약 가설이 거짓으로 판명되면, 시스템을 개선하고 다시 테스트합니다.
코드 예제
import axios, { AxiosRequestConfig } from 'axios';
// 카오스 설정 인터페이스
interface ChaosConfig {
enabled: boolean;
failureRate: number; // 실패율 (0.0 ~ 1.0)
latencyMs?: number; // 인위적 지연
errorCode?: number; // 반환할 에러 코드
errorMessage?: string; // 에러 메시지
}
// 카오스 미들웨어
class ChaosMiddleware {
private configs: Map<string, ChaosConfig> = new Map();
// 특정 서비스에 대한 카오스 설정
setConfig(serviceName: string, config: ChaosConfig) {
this.configs.set(serviceName, config);
}
// 카오스 활성화/비활성화
enableChaos(serviceName: string, enable: boolean) {
const config = this.configs.get(serviceName);
if (config) {
config.enabled = enable;
}
}
// 요청 전에 카오스 주입
async injectChaos(serviceName: string): Promise<void> {
const config = this.configs.get(serviceName);
if (!config || !config.enabled) {
return; // 카오스 비활성화 시 정상 동작
}
// 지연 주입
if (config.latencyMs && config.latencyMs > 0) {
console.log(`[Chaos] Injecting ${config.latencyMs}ms latency to ${serviceName}`);
await new Promise(resolve => setTimeout(resolve, config.latencyMs));
}
// 실패 주입 (확률적)
if (Math.random() < config.failureRate) {
const error = new Error(config.errorMessage || 'Chaos-induced failure');
(error as any).response = {
status: config.errorCode || 500,
data: { error: config.errorMessage || 'Service unavailable' }
};
console.log(`[Chaos] Injecting failure to ${serviceName}: ${config.errorCode}`);
throw error;
}
}
}
// 글로벌 카오스 미들웨어 인스턴스
const chaosMiddleware = new ChaosMiddleware();
// Axios 인터셉터에 카오스 미들웨어 연결
axios.interceptors.request.use(
async (config: AxiosRequestConfig) => {
// URL에서 서비스 이름 추출 (예: http://payment-service/... -> payment-service)
const serviceName = config.url?.match(/\/\/([^\/]+)/)?.[1] || 'unknown';
try {
await chaosMiddleware.injectChaos(serviceName);
} catch (error) {
// 카오스로 인한 에러를 던짐
return Promise.reject(error);
}
return config;
},
error => Promise.reject(error)
);
// 카오스 실험 실행기
class ChaosExperiment {
constructor(
private name: string,
private hypothesis: string,
private chaosAction: () => void,
private validationFn: () => Promise<boolean>
) {}
async run(): Promise<{ success: boolean; details: string }> {
console.log(`\n=== Running Chaos Experiment: ${this.name} ===`);
console.log(`Hypothesis: ${this.hypothesis}`);
try {
// 1. 정상 상태 확인
console.log('Step 1: Validating steady state...');
const beforeState = await this.validationFn();
if (!beforeState) {
return {
success: false,
details: 'System not in steady state before experiment'
};
}
// 2. 카오스 주입
console.log('Step 2: Injecting chaos...');
this.chaosAction();
// 3. 시스템 관찰 (10초간)
console.log('Step 3: Observing system behavior...');
await new Promise(resolve => setTimeout(resolve, 10000));
// 4. 가설 검증
console.log('Step 4: Validating hypothesis...');
const afterState = await this.validationFn();
// 5. 카오스 제거
console.log('Step 5: Removing chaos...');
this.chaosAction(); // 토글로 구현된 경우
if (afterState) {
console.log('✓ Hypothesis validated: System is resilient');
return { success: true, details: 'System handled chaos gracefully' };
} else {
console.log('✗ Hypothesis failed: System is NOT resilient');
return { success: false, details: 'System did not meet resilience requirements' };
}
} catch (error: any) {
return {
success: false,
details: `Experiment failed: ${error.message}`
};
}
}
}
// 실험 예시: 결제 서비스 장애 시 주문 시스템 복원력 테스트
async function runPaymentServiceChaosExperiment() {
// 카오스 설정: 결제 서비스 50% 실패율, 500 에러
chaosMiddleware.setConfig('payment-service', {
enabled: false, // 초기에는 비활성화
failureRate: 0.5,
errorCode: 503,
errorMessage: 'Payment service temporarily unavailable'
});
const experiment = new ChaosExperiment(
'Payment Service Failure',
'When payment service fails, orders should be queued and processed later',
// 카오스 액션: 결제 서비스 장애 활성화/비활성화
() => {
const config = chaosMiddleware['configs'].get('payment-service');
if (config) {
config.enabled = !config.enabled;
}
},
// 검증 함수: 주문이 여전히 생성되고 큐에 저장되는지 확인
async () => {
try {
const response = await axios.post('http://order-service/orders', {
userId: 'test-user',
productId: 'test-product',
amount: 100
});
// 주문이 PENDING 상태로 생성되었는지 확인
return response.data.status === 'PENDING' || response.data.status === 'COMPLETED';
} catch (error) {
console.error('Order creation failed:', error);
return false;
}
}
);
const result = await experiment.run();
console.log('\nExperiment Result:', result);
}
// 실행
// runPaymentServiceChaosExperiment();
설명
이것이 하는 일: 카오스 미들웨어는 특정 서비스로의 HTTP 요청을 가로채서 인위적인 지연이나 실패를 주입하고, 카오스 실험 프레임워크는 이를 통해 시스템의 복원력을 체계적으로 테스트합니다. 이를 통해 실제 장애가 발생하기 전에 시스템의 약점을 찾아내고 개선할 수 있습니다.
첫 번째로, ChaosMiddleware 클래스는 각 서비스별로 카오스 설정을 관리합니다. failureRate를 0.5로 설정하면 해당 서비스로의 요청 중 50%가 실패하도록 만듭니다.
latencyMs를 설정하면 인위적인 지연을 추가하여 네트워크가 느린 상황을 시뮬레이션합니다. 중요한 점은 카오스를 활성화/비활성화할 수 있다는 것인데, 실험이 끝나면 즉시 비활성화하여 시스템을 정상 상태로 되돌릴 수 있습니다.
그 다음으로, Axios 인터셉터에 카오스 미들웨어를 연결하여 모든 HTTP 요청에 자동으로 적용합니다. URL에서 서비스 이름을 추출하여 해당 서비스의 카오스 설정을 찾고, injectChaos 메서드를 호출합니다.
Math.random()으로 확률적 실패를 구현하여 실제 간헐적 장애 상황을 재현합니다. 만약 카오스가 실패를 주입하기로 결정하면, 가짜 에러 객체를 생성하여 던지므로 애플리케이션은 실제 서비스 장애처럼 인식합니다.
마지막으로, ChaosExperiment 클래스는 과학적인 실험 프로세스를 자동화합니다. 먼저 정상 상태를 확인하여 실험을 시작하기 전에 시스템이 건강한지 검증합니다.
그 다음 카오스를 주입하고 일정 시간 동안 시스템을 관찰합니다. 마지막으로 validationFn을 실행하여 가설이 참인지 검증합니다.
예를 들어, "결제 서비스가 실패해도 주문은 PENDING 상태로 생성되어야 한다"는 가설을 테스트하고, 실제로 그렇게 작동하는지 확인합니다. 여러분이 이 코드를 사용하면 프로덕션에 배포하기 전에 시스템의 복원력을 검증할 수 있습니다.
예를 들어, Circuit Breaker를 구현했다면 실제로 작동하는지 테스트할 수 있고, Fallback 로직이 올바른 데이터를 반환하는지 확인할 수 있습니다. 또한 정기적으로 카오스 실험을 실행하여 코드 변경이 복원력을 저하시키지 않았는지 회귀 테스트할 수 있습니다.
실무에서는 스테이징 환경에서 먼저 카오스 실험을 수행하고, 충분히 검증된 후 프로덕션에서도 점진적으로 실행합니다. Netflix의 Chaos Monkey는 프로덕션에서 무작위로 인스턴스를 종료하여 시스템이 항상 복원력을 유지하도록 강제합니다.
실전 팁
💡 작은 실험부터 시작하세요. 처음에는 개발 환경에서 단일 서비스에 낮은 실패율(10-20%)로 시작하고, 점차 범위와 강도를 높여가세요. 갑자기 프로덕션에서 높은 실패율로 시작하면 실제 장애를 유발할 수 있습니다. 💡 카오스 실험을 CI/CD 파이프라인에 통합하세요. 배포 전에 자동으로 카오스 실험을 실행하여 새로운 코드가 시스템의 복원력을 저하시키지 않았는지 확인할 수 있습니다. 💡 다양한 장애 유형을 테스트하세요. 서비스 다운뿐만 아니라 네트워크 지연(latency), 패킷 손실, CPU 스파이크, 메모리 누수 등 다양한 시나리오를 시뮬레이션하세요. 각 장애 유형마다 시스템의 반응이 다를 수 있습니다. 💡 실험 결과를 문서화하고 공유하세요. 실패한 가설은 시스템의 약점을 나타내므로, 이를 기록하고 개선 계획을 세우세요. 성공한 가설도 기록하여 시스템의 복원력을 증명할 수 있습니다. 💡 Chaos Mesh, Gremlin, LitmusChaos 같은 전문 도구를 고려하세요. 이들은 Kubernetes 환경에서 더 정교한 카오스 실험(Pod 종료, 네트워크 파티션, 시간 스큐 등)을 쉽게 수행할 수 있는 기능을 제공합니다.
8. API 버전 관리 - 하위 호환성 유지하며 안전하게 업데이트하기
시작하며
여러분이 API를 개선하려고 응답 형식을 변경했는데, 기존 클라이언트들이 갑자기 에러를 내며 작동을 멈추는 경험을 해본 적 있나요? 또는 새로운 기능을 추가하고 싶지만 기존 클라이언트를 망가뜨릴까 봐 두려워서 변경을 망설인 적이 있나요?
이런 API 호환성 문제는 마이크로서비스 환경에서 특히 심각합니다. 여러 팀이 독립적으로 서비스를 개발하고, 다양한 버전의 클라이언트(모바일 앱, 웹 앱, 다른 마이크로서비스)가 동시에 API를 사용하기 때문입니다.
모바일 앱은 사용자가 업데이트하지 않으면 오래된 버전이 계속 사용되므로, API 변경이 기존 버전을 망가뜨리면 큰 문제가 됩니다. 바로 이럴 때 필요한 것이 API 버전 관리(API Versioning)입니다.
여러 버전의 API를 동시에 제공하고, 클라이언트가 원하는 버전을 선택할 수 있게 하여, 기존 클라이언트를 망가뜨리지 않으면서도 새로운 기능을 추가할 수 있습니다.
개요
간단히 말해서, API 버전 관리는 동일한 API의 여러 버전을 동시에 제공하여 기존 클라이언트의 하위 호환성을 유지하면서도 새로운 기능을 안전하게 추가하고 개선할 수 있게 하는 전략입니다. API는 시간이 지나면서 진화해야 합니다.
새로운 기능 추가, 버그 수정, 성능 개선, 보안 강화 등이 필요합니다. 하지만 Breaking Change(기존 클라이언트를 망가뜨리는 변경)를 도입하면 모든 클라이언트가 동시에 업데이트해야 하는데, 이는 현실적으로 불가능합니다.
API 버전 관리는 이 문제를 해결합니다. 예를 들어, /v1/users는 기존 형식을 유지하고, /v2/users는 새로운 형식을 제공하여, 클라이언트가 준비되었을 때 점진적으로 v2로 마이그레이션할 수 있습니다.
기존에는 API를 변경할 때마다 모든 클라이언트를 업데이트하거나 복잡한 조건문으로 처리했다면, 이제는 명시적인 버전 관리를 통해 깔끔하게 분리하고 각 버전을 독립적으로 유지보수할 수 있습니다. API 버전 관리 방식은 크게 네 가지입니다: URL 경로(/v1/users), Query Parameter(/users?version=1), Header(Accept: application/vnd.api+json;version=1), Content Negotiation.
이 중 URL 경로 방식이 가장 명확하고 캐싱에 유리하여 가장 널리 사용됩니다.
코드 예제
import express, { Request, Response, NextFunction } from 'express';
// 버전별 핸들러 타입
type VersionedHandler = {
[version: string]: express.RequestHandler;
};
// API 버전 관리 미들웨어
class ApiVersionManager {
private app: express.Application;
private currentVersion: string = 'v2'; // 최신 버전
private deprecatedVersions: Set<string> = new Set(['v1']); // 사용 중단 예정
constructor(app: express.Application) {
this.app = app;
this.setupDeprecationWarning();
}
// 사용 중단 경고 미들웨어
private setupDeprecationWarning() {
this.app.use((req: Request, res: Response, next: NextFunction) => {
const version = this.extractVersion(req.path);
if (version && this.deprecatedVersions.has(version)) {
res.setHeader('X-API-Warn', `Version ${version} is deprecated. Please migrate to ${this.currentVersion}`);
res.setHeader('X-API-Current-Version', this.currentVersion);
console.warn(`Deprecated API version used: ${version} at ${req.path}`);
}
next();
});
}
private extractVersion(path: string): string | null {
const match = path.match(/^\/(v\d+)\//);
return match ? match[1] : null;
}
// 버전별 라우터 등록
registerVersioned(
basePath: string,
handlers: VersionedHandler
) {
Object.keys(handlers).forEach(version => {
const versionedPath = `/${version}${basePath}`;
this.app.use(versionedPath, handlers[version]);
console.log(`Registered ${versionedPath}`);
});
}
}
// 사용자 데이터 모델 (v1 형식)
interface UserV1 {
id: string;
name: string;
email: string;
}
// 사용자 데이터 모델 (v2 형식 - 더 풍부한 정보)
interface UserV2 {
id: string;
profile: {
firstName: string;
lastName: string;
email: string;
};
metadata: {
createdAt: string;
lastLogin: string;
};
}
// 데이터 변환 함수 (v2 -> v1 변환으로 하위 호환성 유지)
function transformV2toV1(userV2: UserV2): UserV1 {
return {
id: userV2.id,
name: `${userV2.profile.firstName} ${userV2.profile.lastName}`,
email: userV2.profile.email
};
}
// v1 라우터 (레거시)
const userRouterV1 = express.Router();
userRouterV1.get('/:id', async (req: Request, res: Response) => {
// 실제로는 v2 데이터를 가져와 v1 형식으로 변환
const userV2: UserV2 = {
id: req.params.id,
profile: {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com'
},
metadata: {
createdAt: '2023-01-01',
lastLogin: '2025-01-01'
}
};
// v1 형식으로 변환하여 반환
const userV1 = transformV2toV1(userV2);
res.json(userV1);
});
// v2 라우터 (현재)
const userRouterV2 = express.Router();
userRouterV2.get('/:id', async (req: Request, res: Response) => {
// 최신 형식으로 반환
const userV2: UserV2 = {
id: req.params.id,
profile: {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com'
},
metadata: {
createdAt: '2023-01-01T00:00:00Z',
lastLogin: '2025-01-01T12:00:00Z'
}
};
res.json(userV2);
});
// Express 앱 설정
const app = express();
const versionManager = new ApiVersionManager(app);
// 버전별 라우터 등록
versionManager.registerVersioned('/users', {
v1: userRouterV1,
v2: userRouterV2
});
// 버전 없는 요청은 최신 버전으로 리다이렉트
app.get('/users/:id', (req: Request, res: Response) => {
res.redirect(301, `/v2/users/${req.params.id}`);
});
// API 문서 엔드포인트
app.get('/api-versions', (req: Request, res: Response) => {
res.json({
current: 'v2',
supported: ['v1', 'v2'],
deprecated: ['v1'],
sunset: {
v1: '2026-12-31' // v1 종료 예정일
}
});
});
app.listen(3000, () => {
console.log('API server listening on port 3000');
console.log('Available endpoints:');
console.log(' GET /v1/users/:id (deprecated)');
console.log(' GET /v2/users/:id (current)');
console.log(' GET /api-versions');
});
설명
이것이 하는 일: ApiVersionManager는 URL 경로 기반 버전 관리를 구현하여, 동일한 API의 여러 버전을 동시에 제공하고, 사용 중단 예정 버전에 대한 경고를 자동으로 추가하며, 클라이언트가 점진적으로 최신 버전으로 마이그레이션할 수 있도록 돕습니다. 첫 번째로, setupDeprecationWarning 미들웨어는 모든 요청을 검사하여 사용 중단 예정 버전(v1)이 사용되면 응답 헤더에 경고를 추가합니다.
X-API-Warn 헤더는 클라이언트에게 "이 버전은 곧 사용 중단될 예정이니 최신 버전으로 업데이트하세요"라고 알려줍니다. 또한 X-API-Current-Version 헤더로 현재 권장 버전을 알려줍니다.
이렇게 하면 클라이언트 개발자가 API 응답 헤더를 확인하여 마이그레이션이 필요함을 알 수 있습니다. 그 다음으로, registerVersioned 메서드는 버전별 라우터를 깔끔하게 등록합니다.
각 버전은 완전히 독립적인 라우터를 가지므로, v1과 v2의 로직을 분리하여 유지보수할 수 있습니다. 예를 들어, v2에 새로운 기능을 추가하거나 버그를 수정해도 v1에는 영향을 주지 않습니다.
URL 경로 방식(/v1/users, /v2/users)을 사용하면 버전이 명확하고, CDN이나 브라우저 캐싱도 제대로 작동합니다. 마지막으로, transformV2toV1 함수는 하위 호환성을 유지하는 핵심 기법입니다.
실제 데이터베이스에는 v2 형식으로 저장하고, v1 API가 호출되면 데이터를 v1 형식으로 변환하여 반환합니다. 이렇게 하면 데이터 모델을 중복으로 유지할 필요 없이, 단일 소스에서 여러 형식을 제공할 수 있습니다.
예를 들어, v2는 profile 객체로 구조화된 데이터를 반환하지만, v1은 평탄한 구조의 name 필드로 변환하여 기존 클라이언트와 호환됩니다. 여러분이 이 코드를 사용하면 API를 자신 있게 발전시킬 수 있습니다.
Breaking Change가 필요한 경우 새로운 버전을 만들고, 기존 버전은 일정 기간(예: 1-2년) 유지하면서 클라이언트에게 마이그레이션 시간을 제공할 수 있습니다. /api-versions 엔드포인트를 통해 지원되는 버전과 종료 예정일을 공개하여, 클라이언트 개발자가 계획을 세울 수 있습니다.
실무에서는 버전별 사용량을 모니터링하여 v1 사용자가 충분히 줄어들면 종료할 수 있습니다. 또한 각 버전의 API 문서를 별도로 제공하고(Swagger, Redoc), 마이그레이션 가이드를 작성하여 클라이언트 개발자를 돕는 것이 중요합니다.
실전 팁
💡 Semantic Versioning을 따르세요. Major 버전(v1, v2)은 Breaking Change가 있을 때만 올리고, Minor/Patch 변경(새 필드 추가, 버그 수정)은 같은 버전 내에서 처리하세요. 예를 들어, v2에 선택적 필드를 추가하는 것은 Breaking Change가 아닙니다.
💡 새 필드를 추가할 때는 선택적(optional)으로 만들어 하위 호환성을 유지하세요. 기존 클라이언트는 새 필드를 무시하고, 새 클라이언트만 활용할 수 있습니다. 필드를 제거하거나 타입을 변경하는 것은 Breaking Change이므로 새 버전이 필요합니다.
💡 버전 종료(Sunset) 정책을 명확히 하고 사전에 공지하세요. 최소 6개월~1년 전에 사용 중단을 경고하고, 종료일을 Sunset HTTP 헤더로 알려주세요. 갑작스러운 종료는 클라이언트에게 큰 피해를 줍니다.
💡 버전별 사용량을 추적하세요. 각 버전으로 들어오는 요청 수를 로깅하고 대시보드로 시각화하여, 구버전 사용자가 얼마나 남았는지 파악할 수 있습니다. 사용량이 5% 이하로 떨어지면 종료를 고려할 수 있습니다.
💡 GraphQL을 고려하세요. REST API 버전 관리의 복잡성을 피하고 싶다면, GraphQL을 사용하면 클라이언트가 필요한 필드만 요청하므로 스키마 진화가 더 유연합니다. 새 필드를 추가해도 기존 쿼리는 영향받지 않습니다.