커머스나 F&B 디지털 채널은 사용자 경험과 매출이 분 단위로 직결된다. 점심·저녁 피크에 주문 실패가 1%만 튀어도 가맹점·콜센터·SNS로 거의 동시에 신호가 들어오고, 30분이 지나면 일일 매출 지표에 흠집이 남는다. 이때 엔지니어가 가장 자주 실패하는 지점은 "장애가 뭔지 몰라서"가 아니라 첫 5분 동안 무엇을 보고 무엇을 결정해야 하는지 합의되어 있...
커머스나 F&B 디지털 채널은 사용자 경험과 매출이 분 단위로 직결된다. 점심·저녁 피크에 주문 실패가 1%만 튀어도 가맹점·콜센터·SNS로 거의 동시에 신호가 들어오고, 30분이 지나면 일일 매출 지표에 흠집이 남는다. 이때 엔지니어가 가장 자주 실패하는 지점은 "장애가 뭔지 몰라서"가 아니라 첫 5분 동안 무엇을 보고 무엇을 결정해야 하는지 합의되어 있지 않아서다.
이 문서는 다음을 목표로 한다.
대상 독자는 시니어 백엔드 엔지니어 면접을 준비 중인 사람, 그리고 F&B/커머스 도메인에서 SRE 협업이 필요한 백엔드다. 관련 인접 문서가 이미 있다면(예: task/ai-service-team/graceful-shutdown-503-fix.md) 사례 본문은 그쪽에 두고 이 문서는 첫 5분 운영 플레이북 + 관측성 기본기 hub 역할로 좁힌다.
장애 대응에서 흔한 실패 패턴은 다음 셋이다.
이 문제들을 막으려면 첫 5분의 행동이 사람에 의존하지 않고 대시보드에 의존해야 한다.
다음은 채널 장애 인지 직후 0–5분 동안 한 사람(온콜)이 따라가야 할 순서다. 가능하면 한 화면, 못해도 두 화면 안에 끝나도록 대시보드를 짠다.
가장 먼저 확인하는 것은 기술 지표가 아니라 비즈니스 표면 지표다. 기술 지표가 깨끗해 보여도 사용자가 결제를 못 하고 있으면 장애다.
봐야 할 1차 패널:
판단 기준:
이 단계에서 절대 하지 않는 것: 로그 본문 열기, 코드 의심하기, 누가 무엇을 배포했는지 추적하기. 아직은 표면만 본다.
비즈니스 지표로 "어디 영역인가"를 좁혔다면, 그 영역에 대해 golden signals를 본다. Google SRE 책의 latency / traffic / errors / saturation 4종을 커머스/F&B에 맞게 재정의한다.
이 시점에서 답이 나와야 하는 질문은 한 가지다.
"지금 실패가 우리 코드/리소스 문제인가, 외부 의존성 문제인가?"
p99 latency가 외부 호출(예: PG 호출) 구간에서만 튀고 우리 서버 saturation은 깨끗하다면 외부 의존성 쪽이다. 반대로 saturation이 함께 차오르면 우리 쪽이 받아내지 못하는 상태다.
지금까지의 정보를 가지고 의심 영역을 한 곳으로 좁힌다.
이 단계에서 비로소 trace를 연다. 실패한 주문 1건의 traceId를 잡아 전체 호출 그래프를 본다. 처음부터 trace를 열지 않는 이유는, 단일 요청이 전체 장애를 대표한다고 보장할 수 없기 때문이다. metric으로 "어떤 segment가 느린지"를 좁힌 뒤 trace로 "왜 느린지"를 본다.
5분이 끝나는 시점에는 다음 셋 중 하나를 결정한다.
판단이 안 서면 "관측성을 더 켜는 결정"을 한다 — 임시 로그 레벨 상승, 샘플링률 100% 상승. 단 이 결정 자체도 5분 안에 한다.
일반적인 골든 시그널 정의를 그대로 가져오면 도메인 특성이 빠진다. 커머스/F&B에서는 다음과 같이 다시 정의해 두는 것이 좋다.
OUT_OF_STOCK, COUPON_INVALID, PG_DECLINED, PG_TIMEOUT, IDEMPOTENCY_CONFLICT 등.세 신호를 따로따로 운영하면 첫 5분에 절대 답이 안 나온다. 최소한 다음 두 가지 규칙은 코드 베이스에 박혀 있어야 한다.
진입 단계(API Gateway 또는 첫 번째 서버)에서 traceId를 생성하거나 inbound header(traceparent, X-Request-ID)에서 받아 그대로 전파한다. 모든 외부 호출, 모든 로그, 모든 metric exemplar에 같은 traceId가 붙는다.
Spring Boot 기준 최소 구현 예:
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Request-ID";
private static final String MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String traceId = req.getHeader(HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(MDC_KEY, traceId);
res.setHeader(HEADER, traceId);
try {
chain.doFilter(req, res);
} finally {
MDC.remove(MDC_KEY);
}
}
}logback 패턴에 %X{traceId}를 박아 두면, 이후 어떤 로그를 검색해도 traceId 한 개로 한 사용자 요청 흐름을 복원할 수 있다.
장애 시 grep할 게 아니라, 검색 가능한 필드를 미리 박아 둔다.
log.info("order_attempt {} {} {} {} {}",
kv("order_id", orderId),
kv("user_id", userId),
kv("store_id", storeId),
kv("amount", amount),
kv("payment_method", method));JSON 로그로 쌓고 order_id, store_id, payment_method 같은 필드를 인덱싱하면, 첫 5분 안에 "강남점에서만 결제가 실패한다"는 사실을 한 줄 쿼리로 잡을 수 있다.
Prometheus / OpenTelemetry는 metric 데이터 포인트에 exemplar를 붙일 수 있다. p99 latency가 튄 시점의 exemplar를 클릭하면 바로 그 요청의 trace로 점프한다. metric → trace 점프가 1클릭으로 되면 첫 5분 분석 비용이 크게 떨어진다.
alert: order_api_avg_latency > 500ms평균은 long-tail을 가린다. 1만 요청 중 100건이 5초여도 평균은 별로 안 움직인다.
개선:
alert: histogram_quantile(0.99, order_api_latency_bucket) > 1s for 3m
alert: histogram_quantile(0.999, order_api_latency_bucket) > 3s for 3mfor 3m을 둬서 단발 스파이크에 깨우지 않는다.
심야에는 분당 10건만 들어와도 정상이고, 점심 피크에는 분당 5천 건이 정상이다. 같은 임계치를 쓰면 둘 다 잘못 운다.
개선: 동시간대 baseline 대비 편차를 본다.
alert: order_failure_rate
> 1.5 * avg_over_time(order_failure_rate[7d] @ same_time_of_day)또는 단순하게라도 시간대별 임계치 테이블(점심/저녁/심야)을 두 개 이상 둔다.
PG 응답이 200 OK + body status FAIL로 오는 경우를 놓친다. 결제 실패는 비즈니스 metric(payment_decline_rate)에 별도 알람을 건다.
플레이북이 위키에만 있고 알람 본문에 없으면, 새벽 3시에 깨어난 사람은 못 따라간다. 알람 메시지에 다음을 박는다.
후보자 프로필에 들어 있는 graceful shutdown 503 해결 경험은 첫 5분 플레이북과 직접 연결된다. 사례를 다음 흐름으로 재구성하면 인터뷰 답변이 자연스럽다.
5xx_rate가 분당 수십 건씩 튐. 트래픽이나 비즈니스 KPI는 정상. → 우리 쪽 문제.5xx_rate 스파이크가 사라짐. 이후 알람에 "배포 직후 30초 5xx 스파이크" 패턴을 별도 알람으로 추가.이 답변이 인터뷰에서 강한 이유는 "장애를 풀었다"가 아니라 "관측성·플레이북 자체를 영구적으로 개선했다"까지 들어가기 때문이다.
면접 직전에도 빠르게 손에 익히려면 다음 4개 컨테이너로 충분하다.
version: "3.9"
services:
app:
image: openjdk:21-slim
working_dir: /app
command: ["java", "-jar", "/app/order-app.jar"]
volumes:
- ./build:/app
ports: ["8080:8080"]
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel:4318
OTEL_SERVICE_NAME: order-app
otel:
image: otel/opentelemetry-collector:0.103.0
command: ["--config=/etc/otel.yaml"]
volumes:
- ./otel.yaml:/etc/otel.yaml
ports: ["4318:4318"]
prometheus:
image: prom/prometheus:v2.55.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports: ["9090:9090"]
grafana:
image: grafana/grafana:11.2.0
ports: ["3000:3000"]
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Adminprometheus.yml에서 app:8080/actuator/prometheus를 스크랩하고, Grafana에 Prometheus를 연결한 뒤 다음 패널을 만든다.
order_attempt_total rateorder_success_total rate1 - rate(order_success_total[1m]) / rate(order_attempt_total[1m]) (실패율)histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[1m])))hikaricp_connections_active)부하는 k6로 흘린다.
import http from "k6/http";
import { check } from "k6";
export const options = {
scenarios: {
lunch_peak: {
executor: "ramping-arrival-rate",
startRate: 50, timeUnit: "1s",
preAllocatedVUs: 200,
stages: [
{ target: 200, duration: "30s" },
{ target: 800, duration: "1m" },
{ target: 800, duration: "2m" },
{ target: 100, duration: "30s" },
],
},
},
};
export default function () {
const res = http.post("http://localhost:8080/orders",
JSON.stringify({ storeId: "S001", amount: 12000 }),
{ headers: { "Content-Type": "application/json" } });
check(res, { "ok": (r) => r.status === 200 });
}이 상태에서 의도적으로 실패를 주입한다. 예: 앱 안에 PG mock을 두고 30% 확률로 200ms~1.5s 지연 + PG_DECLINED 응답을 내린다. 그러면 Grafana에서 latency p99와 실패율이 어떻게 움직이는지, 평균 latency는 얼마나 둔감한지 직접 볼 수 있다.
@RestController
@RequestMapping("/orders")
public class OrderController {
private final Random rnd = new Random();
private final Counter attempt;
private final Counter success;
private final Counter declined;
public OrderController(MeterRegistry reg) {
this.attempt = reg.counter("order_attempt_total");
this.success = reg.counter("order_success_total");
this.declined = reg.counter("order_decline_total", "reason", "PG_DECLINED");
}
@PostMapping
public ResponseEntity<?> create(@RequestBody OrderReq req) throws InterruptedException {
attempt.increment();
// 의도적 지연
long sleep = rnd.nextInt(200);
if (rnd.nextDouble() < 0.05) sleep += 1200; // long-tail 5%
Thread.sleep(sleep);
if (rnd.nextDouble() < 0.30) {
declined.increment();
return ResponseEntity.ok(Map.of("status", "FAIL", "reason", "PG_DECLINED"));
}
success.increment();
return ResponseEntity.ok(Map.of("status", "OK"));
}
}이 단순한 코드만으로도 "200 OK 안에 status FAIL이 섞여 있을 때 5xx 알람만 보면 어떻게 되는지"를 즉시 체감할 수 있다.
첫 5분 운영을 잘하는 팀은 사후 회고 양식이 단순하다. 길지 않게 다음 항목만 채운다.
비난이 아니라 시스템 개선으로 끝나야 한다. "사람이 늦게 알아챘다"가 결론이면, 그건 알람이 잘못 잡혀 있다는 뜻이다.
면접에서 "장애 대응 경험을 말해 보세요" 같은 질문이 나오면 다음 4단 구조를 권한다.
graceful shutdown 503 사례는 위 4단에 정확히 매핑된다. 후보자가 답할 때는 "장애를 빨리 풀었다"보다 **"같은 종류의 장애가 다시 일어나지 않도록 관측성과 배포 sequence를 영구적으로 바꿨다"**를 강조한다.
다음과 같은 follow-up도 미리 준비한다.
운영 중인 서비스에 다음이 박혀 있는지 한 줄씩 체크한다.
%X{traceId}).이 리스트의 90% 이상이 yes면, 첫 5분에 사람이 아니라 대시보드가 답을 가르쳐 준다.