마이크로서비스 설계: 서비스 경계 나누기, API Gateway, 서비스 간 통신, Circuit Breaker, Saga
이 글의 핵심
마이크로서비스를 어떤 기준으로 나눌지, API Gateway와 서비스 간 통신 방식, Consul 기반 서비스 디스커버리, 장애 전파를 막는 Circuit Breaker, Saga로 분산 트랜잭션을 처리하는 방법을 다룹니다.
이 글의 핵심
마이크로서비스는 교과서가 말하는 이상형으로만 접근하면 실패하기 쉽습니다. 필자는 예전에 한 팀에서, 이미 잘 돌아가던 모놀리스를 “쪼개야 진짜 아키텍처다”라는 분위기에 휩쓸려 나누었다가, 오히려 배포·디버깅·데이터 정합성만 더 복잡해진 경험이 있습니다. 반대로 팀이 실제로 나뉘고 도메인 경계가 뚜렷해지는 단계에서는 서비스를 분리하는 쪽이 운영 만족도를 확실히 높여 주었습니다. 이 글은 교과서적인 체크리스트와 실무에서 겪은 경험을 함께 정리한 것입니다. 결론을 먼저 말하면 모놀리스가 나은 상황도 상당히 많습니다. 그다음 문제는, 정말 나눠야 한다면 어떻게 나눌 것인가입니다.
모놀리스에서 마이크로서비스로: 흔한 전환 시나리오
전환 과정은 대개 비슷한 패턴을 따릅니다. 처음에는 하나의 저장소, 하나의 배포 단위로 빠르게 개발합니다. 그런데 팀 규모가 커지면서 결제·재고·알림 기능이 한 프로세스 안에서 얽히기 시작하면, 온콜 대응 시 “이게 어느 팀 책임 영역인가요?”라는 질문이 자주 나오게 됩니다. PM은 “서비스를 나누자”고 요청하고, 엔지니어는 “먼저 DDD(도메인 주도 설계)로 경계를 그어 보자”고 제안하는 경우가 많습니다. 실제 전환은 하루아침에 이루어지지 않습니다. BFF(Backend for Frontend)로 진입점을 먼저 정리하거나, 읽기/쓰기 부하 패턴이 다른 기능부터 HTTP나 메시지 큐로 분리해 내보내는 식으로 점진적으로 진행되는 경우가 대부분입니다. 데이터베이스를 당장 나눌 수 없다면 프로세스 경계부터 먼저 잡는 팀도 많습니다. 배포 속도가 빨라진 팀이 있는 반면, 분산 트랜잭션과 로그 추적 부담 때문에 오히려 더 느려진 팀도 있습니다. 마이크로서비스는 만능 해법이 아니라, 그에 상응하는 운영 비용을 감당할 수 있을 때 선택하는 도구에 가깝습니다.
실무에서 흔히 겪는 어려움
“한 줄짜리 수정에도 전체 배포가 필요하다”는 모놀리스의 흔한 불만입니다. 다만 서비스만 나눈다고 이 문제가 해결되는 것은 아닙니다. 빌드·테스트·배포 파이프라인 수가 늘어나는 만큼, 이를 감당할 문화와 도구가 없으면 오히려 더 큰 부담이 됩니다. “한 곳이 터지면 전체가 멈춘다”는 모놀리스에서 흔한 문제입니다. 서비스를 나누어도 의존성이 동기 HTTP 호출로 강하게 얽혀 있으면 이른바 분산 모놀리스가 되고, 장애가 여전히 연쇄적으로 퍼집니다. 뒤에서 다룰 서킷 브레이커, 타임아웃, 모니터링이 필요한 이유가 바로 여기에 있습니다. “팀끼리 서로 기다린다”는 문제도 자주 나타납니다. 서비스는 나누었지만 스키마와 릴리스 일정을 팀 간에 명확히 약속하지 못하면, 예전과 다를 바 없이 “당신 팀 배포가 끝나면 우리 쪽을 켜겠다”는 식의 대기가 반복됩니다. 여기서 얻을 수 있는 교훈은, 서비스를 나누기 전에 팀과 제품의 경계를 먼저 정리해야 한다는 것입니다. 기술적인 패턴(게이트웨이, 이벤트, 사가)은 그다음 문제입니다.
마이크로서비스란 무엇인가
마이크로서비스는 작고 독립적으로 배포되는 서비스들의 조합입니다. 정의는 간단하지만, 실제로 운영해 보면 네트워크 지연, 부분 실패, 최종 일관성(eventual consistency)이 기본 전제가 된다는 점을 체감하게 됩니다.
일반적으로 마이크로서비스는 다음과 같은 특징을 갖습니다. 각 서비스는 한 가지 책임에 집중하고(단일 책임 원칙), 독립적으로 배포될 수 있으며, 데이터베이스는 가능하면 서비스마다 분리합니다(당장 어렵다면 우선 공유 DB로 시작하고 이후 분리하는 팀도 현실적으로 많습니다). 통신은 HTTP·gRPC·메시지 큐를 사용하고, 한 서비스에 장애가 발생해도 전체 시스템이 함께 멈추지 않도록 설계하는 것을 목표로 합니다(물론 완벽하게 달성하기는 어렵습니다).
팀 규모가 작고 제품 방향이 아직 정해지지 않았다면, 모놀리스가 학습 속도와 출시 속도 모두에서 유리한 경우가 많습니다. “모놀리스가 나은 상황도 많다”는 말은 변명이 아니라 실질적인 제약 조건에 가깝습니다. 나중에 도메인 경계가 명확해졌을 때 나누어도 늦지 않습니다.
서비스는 어떻게 나눌 것인가 (DDD 개요)
아래 도식은 “하나의 덩어리 vs 나뉜 구조”를 시각적으로 보여주기 위한 것입니다. 실제로 중요한 기준은 이벤트나 유스케이스가 팀 A와 팀 B에서 서로 다르게 변경되는가를 살펴보는 것이며, 이 기준으로 경계를 잡을수록 나중에 후회할 가능성이 줄어듭니다.
모놀리식:
┌─────────────────────────────┐
│ Single Application │
│ - Users │
│ - Products │
│ - Orders │
│ - Payments │
│ - Notifications │
└─────────────────────────────┘
마이크로서비스:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │ │ Product │ │ Order │
│ Service │ │ Service │ │ Service │
└──────────┘ └──────────┘ └──────────┘
│ │ │
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Payment │ │ Notif. │ │ Shipping │
│ Service │ │ Service │ │ Service │
└──────────┘ └──────────┘ └──────────┘
API Gateway
아래는 Express 기반으로 구현한 API Gateway 예시입니다. 클라이언트의 모든 요청을 한곳으로 모아 라우팅부터 먼저 정리하는 패턴입니다. “우리 API는 이 주소로 들어온다”는 규칙 하나만 통일해도 클라이언트 연동과 온콜 대응이 훨씬 수월해집니다.
// gateway.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// User Service
app.use('/api/users', createProxyMiddleware({
target: 'http://user-service:3001',
changeOrigin: true,
}));
// Product Service
app.use('/api/products', createProxyMiddleware({
target: 'http://product-service:3002',
changeOrigin: true,
}));
// Order Service
app.use('/api/orders', createProxyMiddleware({
target: 'http://order-service:3003',
changeOrigin: true,
}));
app.listen(3000, () => console.log('API Gateway running on :3000'));
위 코드는 http-proxy-middleware를 사용해 경로별로 각 서비스로 요청을 전달하는 최소 구성입니다. /api/users로 들어온 요청은 user-service로, /api/orders로 들어온 요청은 order-service로 그대로 프록시됩니다. 실제 프로덕션 환경에서는 여기에 인증 토큰 검증, 레이트 리밋, 요청/응답 로깅, 서비스별 타임아웃 설정이 추가로 필요합니다. 게이트웨이는 진입점을 단순화해 주지만, 그 자체가 단일 장애점(SPOF)이 될 수 있으므로 다중 인스턴스와 헬스체크를 함께 구성하는 것이 중요합니다.
서비스 간 통신
동기 통신 (HTTP/gRPC)
동기 호출은 구현이 직관적이라는 장점이 있습니다. 다만 호출 체인이 길어질수록 지연 시간과 실패 확률이 곱셈으로 증가한다는 점을 염두에 두어야 합니다. 단순히 “한 번에 순서대로 호출한다”는 구조로 짜기보다는, 처음부터 타임아웃·재시도·서킷 브레이커를 고려해 설계하는 것이 좋습니다.
// order-service.ts
import axios from 'axios';
async function createOrder(userId: number, productId: number) {
// User Service 호출
const user = await axios.get(`http://user-service:3001/users/${userId}`);
// Product Service 호출
const product = await axios.get(`http://product-service:3002/products/${productId}`);
// 주문 생성
const order = await db.order.create({
data: {
userId,
productId,
amount: product.data.price,
},
});
return order;
}
이 코드는 주문 생성 시 User Service와 Product Service를 순차적으로 호출한 뒤 주문을 저장하는 예시입니다. 각 axios.get 호출에는 실제로는 타임아웃 설정과 재시도 로직이 반드시 필요하며, 그렇지 않으면 하나의 의존 서비스가 느려질 때 전체 요청이 함께 지연됩니다. 호출이 두 단계 이상 이어지는 구조라면, 각 단계의 실패를 어떻게 처리할지(부분 실패 시 롤백할지, 그대로 진행할지)를 미리 정의해 두어야 합니다.
비동기 통신 (메시지 큐)
서비스 간 결합을 느슨하게 유지하고 싶을 때는 메시지 큐를 활용합니다. 이때 발생하는 최종 일관성은 어쩔 수 없이 감수하는 부작용이 아니라, 처음부터 의도적으로 설계에 반영해야 하는 요소입니다. “주문은 생성되었는데 결제 완료 이벤트가 늦게 도착한” 상황을 로그만으로 추적하려 하면 원인 파악이 매우 어려워지므로, 이벤트 흐름을 시각적으로 확인할 수 있는 대시보드나 트레이싱 도구를 함께 구축하는 것이 좋습니다.
// order-service.ts
import { Kafka } from 'kafkajs';
const kafka = new Kafka({
clientId: 'order-service',
brokers: ['localhost:9092'],
});
const producer = kafka.producer();
async function createOrder(order: any) {
// 주문 생성
const newOrder = await db.order.create({ data: order });
// 이벤트 발행
await producer.send({
topic: 'order-events',
messages: [
{
value: JSON.stringify({
type: 'order_created',
order: newOrder,
}),
},
],
});
return newOrder;
}
// payment-service.ts
const consumer = kafka.consumer({ groupId: 'payment-service' });
await consumer.subscribe({ topic: 'order-events' });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString());
if (event.type === 'order_created') {
await processPayment(event.order);
}
},
});
Order Service는 주문을 생성한 뒤 order-events 토픽에 이벤트를 발행하고, Payment Service는 이를 구독해 결제를 처리합니다. 두 서비스는 서로의 존재를 직접 알 필요 없이 카프카 토픽만 공유하면 되므로 결합도가 낮아집니다. 다만 이 구조에서는 메시지 유실, 중복 처리, 순서 보장 문제를 반드시 고려해야 합니다. 컨슈머 로직은 같은 이벤트가 두 번 들어와도 결과가 달라지지 않도록 멱등하게 작성하는 것이 안전합니다.
Service Discovery (Consul)
서비스 인스턴스 수가 늘어나면 “어떤 주소로 요청을 보내야 하는가”가 문제가 됩니다. Consul과 같은 서비스 디스커버리 도구에 등록과 헬스체크 기능까지 함께 구성해 두면, “지금 살아 있는 인스턴스가 어디인가”를 매번 수동으로 확인할 필요가 줄어듭니다.
// service-registry.ts
import Consul from 'consul';
const consul = new Consul();
// 서비스 등록
await consul.agent.service.register({
id: 'user-service-1',
name: 'user-service',
address: 'localhost',
port: 3001,
check: {
http: 'http://localhost:3001/health',
interval: '10s',
},
});
// 서비스 발견
const services = await consul.health.service('user-service');
const healthyServices = services.filter(s => s.Checks.every(c => c.Status === 'passing'));
이 예시에서는 user-service 인스턴스를 Consul에 등록하면서 10초 간격으로 /health 엔드포인트를 확인하도록 헬스체크를 설정합니다. 이후 다른 서비스는 Consul에 “user-service가 어디에 있는가”를 물어보고, 헬스체크를 통과한 인스턴스 목록만 필터링해 사용합니다. 이렇게 하면 죽은 인스턴스로 트래픽이 계속 흘러가는 상황을 줄일 수 있습니다. 쿠버네티스 환경에서는 Service와 Endpoints 리소스가 이 역할을 대신하는 경우가 많으므로, Consul을 직접 도입할지 여부는 실행 환경에 따라 판단하면 됩니다.
Circuit Breaker
이미 장애가 발생한 의존 서비스에 계속 요청을 보내면, 그 실패가 호출자 쪽까지 전파되어 연쇄 장애로 이어질 수 있습니다. 실패가 반복되는 시점에 빠르게 실패시키는 방식이, 장애 범위를 줄이는 데 실질적으로 효과가 있습니다.
// circuit-breaker.ts
import axios from 'axios';
class CircuitBreaker {
private failureCount = 0;
private lastFailureTime = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(
private threshold: number = 5,
private timeout: number = 60000
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
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';
}
}
}
// 사용
const breaker = new CircuitBreaker();
try {
const user = await breaker.call(() =>
axios.get('http://user-service:3001/users/123')
);
} catch (error) {
console.error('Service unavailable');
}
이 CircuitBreaker 클래스는 CLOSED(정상), OPEN(차단), HALF_OPEN(시험적 재개) 세 가지 상태를 오가며 동작합니다. 실패 횟수가 임계값(threshold)을 넘어서면 상태를 OPEN으로 바꾸어 이후 요청을 즉시 실패시키고, 일정 시간(timeout)이 지나면 HALF_OPEN 상태로 전환해 요청을 한 번 시도해 봅니다. 이 방식은 이미 죽어 있는 서비스에 트래픽을 계속 흘려보내며 자원을 낭비하는 상황을 막아 줍니다. 실무에서는 이런 로직을 직접 구현하기보다 opossum이나 서비스 메시(Istio, Linkerd) 같은 기존 도구를 활용하는 경우가 많지만, 동작 원리를 이해해 두면 장애 시 상태를 해석하기가 훨씬 쉬워집니다.
분산 트랜잭션 (Saga 패턴)
하나의 데이터베이스 트랜잭션으로 처리를 끝낼 수 있다면 이상적이겠지만, 서비스를 나누고 나면 대개 사가(Saga) 패턴과 이벤트로 이를 대체합니다. 보상 트랜잭션(취소 처리)까지 함께 설계해 두지 않으면, “결제는 완료되었는데 주문 데이터가 없다”와 같이 정합성이 깨진 상태가 계속 누적될 수 있습니다.
// order-service.ts
async function createOrder(order: any) {
const newOrder = await db.order.create({ data: order });
await kafka.send({
topic: 'order-events',
messages: [{ value: JSON.stringify({ type: 'order_created', order: newOrder }) }],
});
}
// payment-service.ts
consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString());
if (event.type === 'order_created') {
try {
await processPayment(event.order);
await kafka.send({
topic: 'payment-events',
messages: [{ value: JSON.stringify({ type: 'payment_completed', orderId: event.order.id }) }],
});
} catch (error) {
await kafka.send({
topic: 'payment-events',
messages: [{ value: JSON.stringify({ type: 'payment_failed', orderId: event.order.id }) }],
});
}
}
},
});
// order-service.ts (보상 트랜잭션)
consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString());
if (event.type === 'payment_failed') {
await db.order.update({
where: { id: event.orderId },
data: { status: 'cancelled' },
});
}
},
});
이 예시는 주문·결제 사이의 사가를 이벤트로 구현한 것입니다. Order Service는 주문을 생성하고 order_created 이벤트를 발행합니다. Payment Service는 이를 구독해 결제를 시도하고, 성공하면 payment_completed를, 실패하면 payment_failed 이벤트를 다시 발행합니다. Order Service는 payment_failed 이벤트를 구독해 주문 상태를 취소로 되돌리는 보상 트랜잭션을 수행합니다. 이런 구조에서는 각 단계가 멱등하게 동작하는지, 그리고 이벤트가 중간에 유실되었을 때 어떻게 감지하고 재처리할지를 사전에 설계해 두는 것이 중요합니다. 아웃박스 패턴(outbox pattern)을 함께 적용하면 DB 트랜잭션과 이벤트 발행 사이의 불일치를 줄이는 데 도움이 됩니다.
정리: 실무에서 활용하는 체크리스트
- 실제로 팀과 배포 단위가 나뉘는가? — 그렇지 않다면 모놀리스에 모듈 경계를 두는 것만으로 충분할 수 있습니다. 모놀리스가 나은 상황도 여전히 많습니다.
- API Gateway: URL·인증·레이트 리밋을 한곳에서 일관되게 처리하도록 먼저 통일합니다.
- 서비스 디스커버리와 헬스체크: 살아 있는 인스턴스에만 트래픽이 가도록 구성합니다.
- 서킷 브레이커·타임아웃·멱등성: 분산 환경에서 무분별한 재시도는 오히려 장애를 키울 수 있습니다.
- 이벤트와 사가: “결국은 맞춰진다”는 가정을 관측 없이 방치하면, 실제로는 맞춰지지 않는 경우가 많습니다.
체크리스트로 정리하면 다음과 같습니다.
- 팀/도메인 경계를 가장 먼저 정리했는가
- API Gateway
- 서비스 간 통신(동기/비동기)과 실패 시나리오
- Service Discovery
- Circuit Breaker
- 모니터링·로그 상관 ID
- 배포·롤백
같이 보면 좋은 글
- Kubernetes 핵심 오브젝트: Pod, Deployment, Service, ConfigMap·Secret, Ingress, 헬스체크, HPA
- Kafka 입문
- RabbitMQ로 메시지 큐 구축하기
자주 묻는 질문 (FAQ)
Q. 마이크로서비스는 언제 도입하는 것이 적절한가요?
A. 팀과 조직, 릴리스 일정이 실질적으로 서로 다르게 움직이고 있으며, 그에 필요한 운영 역량(옵스, 온콜, 대시보드 등)을 감당할 수 있을 때 도입하는 것이 적절합니다. 단순히 유행을 따라 서비스를 나누면 운영 비용이 먼저 발생합니다. 규모가 작을 때는 모놀리스가 대체로 더 저렴하고 빠릅니다. 모놀리스가 더 나은 선택인 경우는 실제로 상당히 많습니다.
Q. 서비스를 얼마나 잘게 나누어야 하나요?
A. “피자 두 판 팀” 같은 경험 법칙도 있지만, 실무적으로는 “한 팀이 온전히 책임지는 서비스” 단위로 나누는 것을 기준으로 삼는 것이 좋습니다. 너무 잘게 나누면 “다시 합쳐야 하는가”라는 논의가 반복됩니다. 복잡도가 서비스 개수에 비례해서 늘어난다는 점만 기억해도 절반은 해결됩니다.
Q. 서비스 간 데이터 정합성은 어떻게 맞추나요?
A. 모든 데이터를 강한 일관성으로 묶기는 현실적으로 어렵습니다. 사가, 이벤트 소싱, 아웃박스 패턴 등을 활용해 최종 일관성을 확보하는 것이 일반적이며, 비즈니스 측에서 이 지연을 허용할 수 있다고 판단할 때 전환을 진행하는 것이 안전합니다. 그렇지 않다면 아직 서비스를 나눌 시점이 아닐 수 있습니다.
Q. 마이크로서비스를 프로덕션에 도입해도 괜찮은가요?
A. 대규모 조직에서도 널리 사용하는 방식입니다. 다만 “다른 회사는 되는데 우리는 왜 안 되는가”를 따져 보면, 관측성·런북·전담 팀 같은 뒷받침이 부족한 경우가 대부분입니다. 마이크로서비스는 하나의 도구일 뿐이며, 실제 성공 여부를 좌우하는 것은 운영 역량입니다.
심화 부록: 구현·운영 관점
이 부록에서는 앞서 다룬 내용을 실제 운영 관점에서 다시 정리합니다. 실무에서는 장애를 “입력 → 검증 → 핵심 로직 → 부작용 → 관측”의 다섯 단계로 나누어 살펴보면 원인을 좁혀 나가기가 한결 수월해집니다.
내부 동작 흐름
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건을 문장으로 명시해 두면 디버깅 속도가 빨라집니다(버퍼 크기, 격리 수준, FD 상한 등).
- 시간·네트워크가 섞인 계층과 순수 로직에 가까운 계층을 분리하면, 테스트 작성과 장애 분석이 모두 쉬워집니다.
- 백프레셔를 어디에 둘지 합의해 두지 않으면, 큐를 두어도 결국 병목이 다른 곳에서 터집니다.
프로덕션에서 자주 확인해야 할 항목
관측성 — 요청마다 상관 ID(correlation ID)가 부여되고, p95·p99 지연과 의존성 타임아웃/재시도 현황이 대시보드에 노출되고 있는가? 보안 — 인증과 감사 로그가 모든 경로에서 일관되게 적용되는가? 신뢰성 — 재시도는 멱등한 작업에만 적용되는가, 서킷 브레이커와 DLQ(Dead Letter Queue)는 구성되어 있는가? 성능 — 캐시, 커넥션 풀, 인덱스, N+1 쿼리는 점검했는가? 배포 — 롤백 절차, 카나리 배포, 마이그레이션 순서, 피처 플래그 문서는 준비되어 있는가? 용량 — 트래픽 피크 시 FD·스레드·디스크 상한은 충분한가? 스테이징 환경의 데이터 양과 네트워크 지연(RTT)을 실제 프로덕션과 최대한 비슷하게 맞춰 두면 장애 재현율이 크게 높아집니다. 스테이징 환경이 지나치게 가벼우면, 같은 장애가 프로덕션에서 반복해서 발생하는 경우가 많습니다.
엔드투엔드 관점으로 확장하기
- 입력 계약 고정(스키마, 버전, 최대 페이로드, 타임아웃, 에러 코드)
- 핵심 경로에 ID·지연·외부 결과 코드 꽂기
- 고장을 스테이징에서 재현(타임아웃, 5xx, 부분 데이터)
- 호환·롤백 — 설정/마이그/클라 버전
- 부하 후 p95, 에러율, 알림
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
트러블슈팅 체크리스트
- 간헐적 실패 — 레이스 컨디션, 타임아웃, DNS 문제, 외부 서비스 장애가 원인인 경우가 많습니다. 최소 재현 조건을 찾은 뒤, 트레이스와 로그를 상관 ID로 연결하고, 재시도·서킷 브레이커 설정값을 확인합니다.
- 응답 지연 — N+1 쿼리, 락 경합, 직렬화 비용, 캐시 미스가 주요 원인입니다. 프로파일러나 APM으로 하나씩 좁혀 나갑니다.
- 메모리 증가 — 무한정 커지는 캐시, 이벤트 리스너 누수, 반환되지 않은 커넥션이 흔한 원인입니다. 상한을 설정하고 스냅샷을 비교합니다.
- 빌드 실패 — 환경 변수, lockfile 불일치, 이미지 버전 차이를 CI와 로컬 환경에서 비교합니다.
- 설정 불일치 — 프로필, 시크릿, 기본값이 검증된 단일 소스에서 관리되고 있는지 확인합니다.
- 데이터 정합성 문제 — 비멱등 재시도, 캐시 무효화 누락이 원인인 경우가 많으므로, 멱등 키와 아웃박스 패턴을 다시 점검합니다.
문제 해결 순서는 다음과 같이 진행하는 것이 효율적입니다. 최소 재현 조건 확보 → 최근 변경 사항 범위 좁히기 → 환경 차이 확인 → 가설을 세워 관측 지표로 검증 → 수정 후 부하 테스트와 회귀 테스트.