F&B 디지털 채널 백엔드는 일반 커머스보다 운영 변수가 더 많다. 매장 POS, 배달 외부 채널, PG, 멤버십, 쿠폰, 재고, 알림이 한 트랜잭션 안에서 같이 움직이고, 점심·저녁 피크타임이 하루에 두 번 분명하게 찍힌다. 이 시간대에 한 쪽이 어긋나면 주문은 받았는데 매장에 안 떨어졌거나, 결제는 됐는데 쿠폰이 안 빠졌거나, 재고가 0인데 주문이 들어...
F&B 디지털 채널 백엔드는 일반 커머스보다 운영 변수가 더 많다. 매장 POS, 배달 외부 채널, PG, 멤버십, 쿠폰, 재고, 알림이 한 트랜잭션 안에서 같이 움직이고, 점심·저녁 피크타임이 하루에 두 번 분명하게 찍힌다. 이 시간대에 한 쪽이 어긋나면 주문은 받았는데 매장에 안 떨어졌거나, 결제는 됐는데 쿠폰이 안 빠졌거나, 재고가 0인데 주문이 들어가 있는 식의 운영 사고가 즉시 CS로 전환된다. 그래서 F&B 백엔드의 실력은 새 기능을 만드는 능력보다 "장애 났을 때 5분 안에 무엇을 보고, 무엇을 끄고, 무엇을 복구하느냐"에서 더 분명하게 갈린다.
면접 관점에서도 마찬가지다. CJ푸드빌처럼 빕스·뚜레쥬르·계절밥상 같은 다채널 브랜드를 운영하는 조직은 "안정 운영"을 직무 기술서의 앞쪽에 둔다. 코드 스타일이나 알고리즘보다 "피크타임에 503 어떻게 줄였나", "PG 장애 때 어떻게 대응했나", "재고 동기화 깨졌을 때 어떻게 회복했나" 같은 운영형 질문이 시니어 백엔드 평가의 본진이 된다. 이 글은 그 본진을 정면으로 다룬다.
운영 장애를 다룰 때 가장 먼저 정렬해야 할 개념은 "관측은 3층 구조"라는 사실이다.
traceId로 묶인다. "왜 느린지", "어디서 죽었는지"를 가장 빠르게 답해준다.운영 사고를 잘 다루는 백엔드 엔지니어는 이 3층을 항상 같은 순서로 쓴다. 메트릭으로 "어디가 아픈가"를 잡고, 트레이스로 "어떤 흐름이 아픈가"를 좁히고, 로그로 "왜 아픈가"를 본다. 신입 시절 흔히 하는 실수가 알람 받자마자 로그부터 grep 하는 것인데, 트래픽이 큰 시스템에서는 로그 양이 너무 많아 인과를 잃는다.
여기에 운영 백엔드가 추가로 들고 있어야 할 개념이 두 개 더 있다.
평일 11:30–13:00, 17:30–20:00이 거의 모든 F&B 디지털 채널의 1차 피크다. 평소 RPS의 3~6배까지 뛴다. 이 시간대에 가장 먼저 깨지는 자원 순서는 대체로 정해져 있다.
피크타임 대비의 핵심은 "용량을 늘리는 것"이 아니라 "느려졌을 때 죽지 않게 하는 것"이다. 즉, 타임아웃, 서킷 브레이커, 백프레셔, 큐잉이 갖춰져야 한다. 단순 스케일 아웃만 해서 피크를 넘기려는 설계는 비용도 비싸고, 외부 의존성이 느려지는 순간 그대로 무너진다.
쿠폰이나 한정 메뉴 오픈은 시간 0초 기준으로 트래픽이 수직 상승한다. 이 패턴은 일반 피크타임과 달리 "공정성" 요구가 추가된다. 같은 1초 안에 들어온 사용자 중 누구에게 쿠폰을 줄지가 비즈니스 이슈가 된다.
운영에서 자주 터지는 실패 패턴:
UPDATE coupon SET remain = remain - 1로 처리해 row lock 경합 폭주.DECR로 옮겼지만 발급 후 영속화 실패 시 보상 트랜잭션이 없어 "발급은 됐는데 DB엔 없음" 상태.대응 원칙은 셋이다. Redis로 카운터를 빠르게 깎고, 발급 이력은 idempotency key로 멱등하게 적재하고, 영속화 실패는 비동기 보상 큐로 흘린다.
F&B 디지털 채널 특유의 사고 유형이다. 사용자 입장에선 결제까지 끝났는데 매장 화면에는 주문이 안 떠 있다. 원인은 보통 다음 셋 중 하나다.
설계 원칙은 "주문 확정과 매장 송신을 같은 트랜잭션에 묶지 않는다"이다. 주문 확정 → outbox에 매장 송신 메시지 적재 → 별도 publisher가 Kafka로 송신 → 매장 단말 consumer가 ack. 실패 시 재시도와 사람이 볼 수 있는 운영 대시보드(미송신 주문 카운트)가 필수다.
PG는 우리가 통제할 수 없고, 1년에 몇 번은 반드시 흔들린다. 백엔드 관점의 방어선은 다음과 같다.
PG 장애 시 가장 빠른 완화는 보통 "결제 수단 일부 차단"이다. 카드사 A만 문제라면 카드사 A 결제만 막고 나머지로 우회시키는 게 전체 서비스 다운보다 낫다. 이게 가능하려면 PG/카드사 단위로 feature flag가 분리돼 있어야 한다.
주문 완료/배달 출발/픽업 준비 완료 같은 알림이 안 가면 CS가 즉시 폭증한다. 알림은 본질적으로 비동기여서 모니터링 사각지대가 되기 쉽다. 운영용 지표로 반드시 갖춰야 할 것:
자주 하는 실수는 알림 실패를 단순 WARN 로그만 남기고 retry queue를 안 두는 것이다. 푸시 provider는 5xx가 흔하다. retry/backoff/DLQ가 기본 세트다.
F&B는 재고 단위가 일반 커머스와 다르다. "원두 떨어짐"이 SKU 단위 품절로 즉시 환산되지 않고 매장 운영자가 수동 토글하는 경우가 많다. 이 토글이 카탈로그 캐시에 늦게 반영되면 "주문은 들어왔지만 매장에서 못 만든다"는 운영 사고가 난다.
대응은 두 갈래다.
캐시는 성능을 위해 도입하지만, 정합성이 깨졌을 때 디버깅이 가장 어렵다. F&B에서 자주 보는 패턴은 다음과 같다.
원칙은 세 가지다. TTL을 너무 길게 두지 않는다, 무효화는 단일 이벤트로 묶는다, 마지막 보루로 짧은 주기의 강제 리프레시 잡을 둔다. "캐시 정합성 문제는 절대 안 일어나게 만든다"는 비현실적이고, "일어나도 N분 안에 자동 수렴한다"가 현실적인 목표다.
정산, 통계, 데이터 동기화, 외부 채널 메뉴 업로드 같은 배치는 새벽에 돈다. 실패하면 다음 날 오전이 돼야 인지된다. 운영에서 필요한 최소 장치:
CS 인입량은 보통 사고의 후행 지표지만, 알람보다 빠르게 움직이는 경우도 있다. 알람 임계치가 보수적이거나, 사용자 체감 영역(예: 앱 특정 화면 렌더링 오류)이 서버 메트릭에 안 잡히는 경우다. 그래서 운영팀에서 들어오는 CS 키워드(예: "결제 실패", "쿠폰 안 들어옴")를 시간 단위로 집계해 대시보드에 띄우는 게 의외로 효과적이다.
| 도메인 | SLI 예시 | SLO 예시 |
|---|---|---|
| 주문 API | 성공률, p95 latency | 99.9% / 500ms |
| 결제 콜백 | 성공률 | 99.95% |
| 매장 송신 | 30초 내 도달률 | 99.5% |
| 카탈로그 캐시 | hit rate | 95% 이상 |
| 알림 발송 | 성공률 (provider별) | 99% |
| 배치 | 마지막 성공 이후 경과 시간 | < 26h |
운영형 백엔드에서 traceId는 사실상 1순위 시민이다. 원칙:
traceId 필드를 고정 위치로 둔다.지원자의 traceId 재전송 경험은 정확히 이 지점에 닿는다. "동기 호출에서만 traceId가 살아 있고 Kafka로 넘어가는 순간 끊겼다 → 메시지 헤더로 명시적으로 실어 보내 비동기 구간까지 같은 traceId로 추적 가능하게 만들었다" 같은 식의 서술이 면접에서 강한 운영 신호가 된다.
로그 레벨 운용도 같이 정리해 둔다.
알람이 울렸을 때 행동 순서를 머리에 박혀 있게 만든다.
지원자가 이미 갖고 있는 "graceful shutdown 503 대응" 경험은 이 루프의 1~2번에 닿는다. 배포 중 503 비율이 튀었을 때 "직전 변경이 원인" → "graceful shutdown으로 in-flight 요청 종료 후 종료" → "503 비율 회복" 흐름은 첫 5분 루프를 정확히 따른 좋은 케이스다.
// bad
@Transactional
public Order placeOrder(OrderCommand cmd) {
Order o = orderRepo.save(cmd.toEntity());
pgClient.charge(o.getId(), cmd.getAmount()); // 외부 호출이 트랜잭션 안
sendKakaoAlert(o); // 외부 호출이 트랜잭션 안
posClient.dispatch(o); // 외부 호출이 트랜잭션 안
return o;
}문제는 셋이다. 외부 호출이 길어지면 DB 커넥션이 점유돼 풀이 마른다. 외부 실패 시 DB만 롤백되고 외부 부작용은 남는다. 이중 결제 위험이 높다.
// improved
@Transactional
public Order placeOrder(OrderCommand cmd) {
Order o = orderRepo.save(cmd.toEntity());
outboxRepo.save(OutboxEvent.posDispatch(o));
outboxRepo.save(OutboxEvent.alertOrderPlaced(o));
return o; // 짧고 결정적
}
// 결제는 별도 단계 / idempotency key 필수
public PaymentResult pay(OrderId id, IdempotencyKey key, Amount amount) {
return pgClient.charge(id, key, amount, timeout(5_000));
}핵심은 외부 호출을 트랜잭션 밖으로 빼고, outbox로 묶고, 결제는 멱등 키로 보호한다는 점이다.
// bad
try {
push.send(token, message);
} catch (Exception e) {
log.warn("push failed", e); // 흔적만 남고 사라짐
}// improved
notificationQueue.enqueue(NotificationJob.of(token, message)); // DLQ + retry 보유// bad: 가격만 갱신, 옵션 캐시는 그대로
cache.put(menuPriceKey(id), price);// improved: 무효화 이벤트 한 번으로 묶음
events.publish(new MenuChanged(id));
// listener: cache.invalidate(menuPriceKey(id)); cache.invalidate(menuOptionKey(id));운영 시뮬레이션은 한 노트북에서 충분히 만들 수 있다. docker compose로 다음 스택을 띄운다.
services:
app:
image: openjdk:17
# spring boot fat jar 마운트
mysql:
image: mysql:8.0
environment: [MYSQL_ROOT_PASSWORD=root]
redis:
image: redis:7
kafka:
image: bitnami/kafka:3.6
prometheus:
image: prom/prometheus
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"]
grafana:
image: grafana/grafana
loki:
image: grafana/loki
tempo:
image: grafana/tempoSpring Boot 쪽에는 micrometer-registry-prometheus, spring-cloud-sleuth(또는 micrometer tracing), logback-json 설정만 들어가 있으면 메트릭/트레이스/로그 3층이 다 잡힌다.
k6 run --vus 200 --duration 3m order_script.jsmaximum-pool-size를 10 → 30으로 올렸을 때 DB CPU와 latency 변화 관찰.PG mock 서버에 50% 확률 5s 지연을 넣고, 서킷 브레이커 on/off 비교.
resilience4j:
circuitbreaker:
instances:
pg:
failureRateThreshold: 50
waitDurationInOpenState: 10s
slidingWindowSize: 20가격 변경 이벤트를 일부러 일부 인스턴스에만 전달하고 30초 안에 자동 수렴하는지 확인. TTL 30초/60초/300초로 바꿔보면서 사용자 영향 시간 측정.
주문 컨트롤러 → Kafka produce → consumer → DB 까지 같은 traceId가 찍히는지 Tempo에서 확인. 헤더 전파 빠뜨렸을 때 어디서 끊기는지 직접 본다.
장애 후 24~72시간 안에 다음 형식으로 정리한다. 형식 자체가 재발 방지에 큰 영향을 준다.
좋은 포스트모템의 가장 큰 특징은 "사람을 탓하지 않는다"는 점이다. "A가 실수했다"가 아니라 "그 실수가 운영 단계까지 도달할 수 있는 구조였다"로 쓴다. 면접에서도 같은 톤이 시니어 신호로 읽힌다.
한 사고에서 1~2개 레버까지만 손대는 게 현실적이다. 4개를 한 번에 하려고 하면 어느 것도 안 끝난다.
운영 장애 관련 질문에는 STAR + 숫자 + 트레이드오프로 답한다.
운영 깊이를 묻는 질문 — "캐시 정합성 어떻게 다뤘나", "traceId 비동기 구간 어떻게 살렸나", "PG 장애 때 어떻게 대응했나" — 에는 같은 프레임을 그대로 쓰면 된다.
Q. 피크타임에 가장 먼저 무너지는 자원이 뭐라고 보나요? A. 보통 WAS 스레드 풀과 DB 커넥션 풀이다. 외부 의존성이 느려질수록 우리 스레드가 묶이기 때문이다. 그래서 외부 호출에 timeout과 서킷 브레이커를 두는 게 단순 스케일 아웃보다 먼저다.
Q. PG 장애 시 어떻게 대응하나요? A. 먼저 영향 범위가 전체 PG인지 특정 카드사인지 확인한다. 부분 장애면 해당 결제수단만 feature flag로 차단해 블래스트 레이디어스를 줄인다. 이중 결제 방지를 위해 idempotency key 기반 재시도와 결과 폴링/webhook 이중 채널을 둔다.
Q. 캐시 정합성은 어떻게 보장하나요? A. "절대 안 깨지게"는 비현실적이고, "깨져도 N분 안에 수렴한다"를 목표로 한다. TTL을 짧게 잡고, 변경 이벤트로 명시적 invalidate, 최후 보루로 강제 리프레시 잡을 둔다. 본인이 다룬 정합성 이슈 사례와 어떤 레버로 수렴 시간을 줄였는지 같이 말한다.
Q. 운영 중 traceId가 끊기면 어떻게 하나요? A. 끊긴 구간을 먼저 찾는다. 대개 비동기 구간(Kafka, 스레드 풀, 배치) 진입 시점이다. 메시지 헤더 또는 컨텍스트 전파로 traceId를 명시적으로 실어 보내는 처리로 복구한다. 본인이 직접 수정해본 경험을 그대로 풀어 답하면 강하다.
Q. graceful shutdown은 왜 중요하고 어떻게 구현하나요?
A. 배포·스케일 다운 시 in-flight 요청이 강제 종료되면 5xx로 사용자에게 노출된다. 종료 hook에서 헬스체크를 unready로 먼저 바꿔 신규 트래픽을 차단하고, in-flight 요청이 끝날 때까지 일정 시간 대기 후 종료한다. Spring Boot의 server.shutdown=graceful과 spring.lifecycle.timeout-per-shutdown-phase로 기본은 구성되지만, LB unregister 타이밍과 맞물려야 실제 503이 사라진다.
Q. 새벽 배치가 실패하면 어떻게 알아내나요? A. 배치별 "마지막 성공 시각" 메트릭을 두고, 이 값이 예상 주기보다 길어지면 알람을 띄운다. 잡 안에서 try/catch로 묻히는 패턴이 가장 위험해서, 실패 시 명시적으로 메트릭에 카운트를 올리는 식으로 보조한다.