운영 API는 2026년 5월 14일부터 ALB 뒤 서버 두 대를 한 대씩 재시작하는 롤링 배포를 해 왔다. 무중단 배포인 줄 알았다. 10월 1일 배포 중 health를 2초 간격으로 보다가, 노드마다 11~16초씩 503이 섞이는 창을 발견했다. 하루 전 운영에서 켠 미수신 자동취소 때문에, 그 창은 매장 주문의 잘못된 취소로 이어질 수 있었다
2026년 10월 6일 배포에 drain 단계를 넣었다. 그날 남은 «503 0건»은 health check 표본이었다. 2026년 10월 14일 롤링 배포 무중단 검증을 실제 업무 요청으로 다시 했다. 롤링 배포 중 읽기 API 7,799건과 후불 주문 264건은 한 건도 실패하지 않았다. ALB와 서버 두 대는 무중단의 조건일 뿐이었고, 무중단은 drain을 넣은 뒤에야 측정으로 확인됐다
같은 두 대 구성에 예고 없는 프로세스 장애도 넣었다. kill -9로 죽인 노드는 systemd가 26~28초 만에 되살려, ALB의 이탈 판정(30초 × 2회)에 닿기 전에 돌아왔다. 그동안 ALB는 그 노드에 계속 요청을 보냈고, 그 몫(약 절반)이 대부분 503으로 실패했다. 실사용자 요청도 3건 실패했다. 예상하지 못한 것도 하나 나왔다. 매장 접수앱은 주문 상세 조회에 끝내 실패해도 수신 확인(ACK)을 보내고 있었다
배포 실행과 취소, 관리 콘솔, 관리자 로그인은 내가 직접 했다. 조회 프로브, 브라우저 주문 루프, 집계, 교차검증은 AI 에이전트가 맡았다. 서버 명령은 AI 에이전트가 쓰고, 내가 확인한 뒤 SSH로 직접 실행했다. 결과는 작성에 참여하지 않은 별도 AI 에이전트가 원시 파일에서 다시 계산해 교차검증했다
서버 두 대와 롤링 순서만으로는 503 창이 남았다
API는 Spring Boot(Java 17) 단일 jar이고, NCP Application Load Balancer(ALB) 뒤에 두 대가 있다. ALB는 30초마다 GET /actuator/health를 불러, 연속 2회 실패하면 그 노드를 빼고 연속 2회 성공하면 다시 넣는다. 분배는 Round Robin이고 sticky session은 없다. 프로세스는 systemd가 관리하고, JVM 기동에 16~18초가 걸린다
drain이 들어오기까지의 흐름을 커밋 이력과 작업 문서로 다시 맞춰 보면 이렇다
| 날짜 | 일 |
|---|---|
| 5/14 | 현재 저장소 이력이 시작된 날. 두 노드를 동시에 배포하던 방식을 1번 → 2번 순서로 재시작하는 롤링으로 바꿨다 |
| 9/23 | 1번이 ALB에 다시 붙을 때까지 기다리는 150초 대기를 넣었다(9/21 실측 124~134초 기준) |
| 9/30 | 미수신 자동취소(ACK가 없으면 주문을 취소하고, 선불은 환불)를 운영에서 켰다 |
| 10/1 | 배포 중 ALB 경유 health를 2초 간격으로 보다가 503 창을 발견했다. 세 번의 배포에서 노드마다 약 11~16초였다 |
| 10/1 밤 | ACK 관련 배포마다 배치 자동취소를 껐다 켜야 하는 상황을 설계 문제로 보고, drain을 별건으로 등록했다 |
| 10/2 | 설계·구현, 개발 서버 시험 |
| 10/6 | 운영 반영. health 표본 503 0 |
| 10/14 | 실제 업무 요청으로 다시 측정 |
503이 난 원리는 이렇다. 배포는 노드를 ALB에서 빼지 않고 바로 재시작했다. graceful shutdown(30초)은 이미 받은 요청을 마치게 하지만, 새 요청은 받지 않는다. ALB가 계속 보내는 새 요청까지 막아 주지는 못한다. 재시작은 ALB의 이탈 판정(30초 × 2회)보다 짧게 끝났다. ALB는 그 노드를 내내 UP으로 보고, 내려가는 노드에 요청을 보냈다
2026년 9월 23일의 150초 대기는 «다시 붙는 쪽»만 기다렸다. «빠지는 쪽»은 다루지 않았다. 한 대씩 재시작해도 ALB가 빼기 전에 내려가면, 그 노드로 가는 몫은 실패한다. drain 직전 마지막 측정(10/6 첫 반영 롤링, 옛 jar)까지 합치면 노드마다 약 10~17초 503이 섞였다
이 503 창은 health 표본으로 본 것이다. 업무 요청은 따로 재지 않았다. 그래도 업무 요청이 같은 창에 걸렸다고 보는 근거는 있다. 2초 간격으로 본 health는 ALB의 자체 검사가 아니라, 밖에서 ALB를 거쳐 보낸 보통 요청이었다. ALB는 이 요청도 Round Robin으로 나눴다. 10월 6일 옛 jar 롤링에서 받은 503은 ALB가 만든 평문 «503 Service Unavailable»이었고, 그동안 ALB는 그 노드를 내내 UP으로 봤다. 같은 창에 들어온 업무 요청도 그 노드 몫은 같은 503을 받았을 것으로 본다
같은 구조에서 잰 값은 뒤에 나온다. 3회차는 «노드가 내려가 있고 ALB는 아직 빼지 않은» 모양을 kill -9로 만들었고, 그 노드 몫의 업무 요청 약 절반이 ALB 503으로 실패했다. 다만 drain 전 롤링은 graceful 재시작이고 3회차는 kill -9다. 원인은 같아도 창의 모양까지 같지는 않다. 그래서 3회차는 drain 전 창의 직접 증거가 아니라, 같은 구조에서의 측정으로 읽어야 한다
9월 30일에 켠 자동취소가 그 창을 위험하게 만들었다
503 창은 롤링 배포의 구조에서 나오니, 그 전부터 있었을 것이다. 위험해진 것은 자동취소를 켠 뒤다. 주문이 매장에 닿는 경로를 보면 이유가 보인다
손님 주문 ─▶ API 커밋 ─▶ 비동기 후처리: 주문 이벤트 행 저장(트랜잭션) ─▶ 커밋 뒤 Redis 발행
│
메시징 서버(별도 2대) ◀────────────────────┘
│ STOMP(WebSocket)
▼
매장 접수앱 ─▶ 주문 상세 조회(API) ─▶ ACK(API)
ACK 없음 → 마지막 발행 + 60초 + 배치 주기 10초 뒤 자동취소 (선불은 환불까지)
접수앱은 이벤트를 받으면 주문 상세를 조회하고 ACK를 보낸다. 서버는 ACK로 «수신»을 기록한다. ACK가 오지 않으면 배치가 그 주문을 «매장이 못 받은 주문»으로 보고 취소한다. 같은 매장의 뒤 이벤트가 ACK되었으면 취소 대신 «ACK 유실 의심» 알림만 낸다. ACK API는 실패해도 HTTP 200에 본문 success:false를 돌려준다. 그래서 수신 여부는 상태 코드가 아니라 본문으로 판정해야 한다
자동취소가 켜진 뒤로는, 503 창에서 ACK 요청이 죽은 노드로 가면 매장이 받은 주문도 취소될 수 있었다. 선불이면 환불까지 나간다. 그래서 ACK 경로를 건드리는 배포 때마다 배치 서버를 재시작해 자동취소를 껐다 켜는 수동 우회를 썼다
«매번 배치를 재시작해야 하나요? 설계가 잘못된 듯» 10월 1일 밤, AI 에이전트와 배포 작업을 하던 중 배치 서버를 직접 재시작하면서 내가 남긴 말이다. 배포 때마다 사람이 안전망을 껐다 켜는 절차를 설계 문제로 보고, 그날 23:39 drain을 별건으로 등록했다
drain은 노드가 먼저 빠지겠다고 알리게 했다
10월 2일에 두 안을 놓고 골랐다
| 안 | 방식 | 판단 |
|---|---|---|
| A(채택) | 노드가 health로 스스로 «빼 달라»고 답해 ALB가 빼게 한다 | ALB가 이미 /actuator/health를 보고 있어 ALB 설정을 바꿀 필요가 없다 |
| B | 배포가 클라우드 API로 대상 그룹에서 노드를 뺐다가 다시 넣는다 | 배포 자동화(GitHub Actions)에 로드밸런서 권한 키를 둬야 한다 |
A는 Spring Boot Actuator의 기본값을 이용한다. Actuator는 OUT_OF_SERVICE와 DOWN을 기본으로 503에 매핑한다. health에 «drain 표시» 기여자를 하나 넣었다
@Component
public class DrainHealthIndicator implements HealthIndicator {
// 배포가 중간에 끊겨 표시가 남아도 노드가 영영 빠져 있지 않게 한다 — 배포의 기다림(120초)보다 넉넉히 길다.
private static final Duration MARKER_TTL = Duration.ofMinutes(10);
@Override
public Health health() {
return isDraining() ? Health.outOfService().build() : Health.up().build();
}
// isDraining(): 표시 파일의 수정 시각이 TTL 안이면 true · 파일이 없으면 false
}
배포 워크플로는 노드 하나씩 다음 순서로 움직인다
1. 노드 작업 폴더에 표시 파일 생성 → 그 노드의 /actuator/health 가 OUT_OF_SERVICE(503) (업무 요청은 계속 처리한다) 2. ALB 가 비정상 2회로 그 노드를 뺄 때까지 120초 대기 3. 표시 삭제 → 재시작 (server.shutdown: graceful, 30초) 4. ALB 재합류까지 150초 대기 → UP 확인 → 다음 노드
핵심은 ALB가 노드를 먼저 빼고, 그다음에 재시작하는 순서다. 2026년 9월 23일에 넣은 재합류 대기(4번)에, 빠지는 쪽의 기다림(1·2번)을 더한 셈이다.
2번의 120초는 이렇게 잡았다. 이론상 최악은 약 60초다. 표시 직후 첫 검사까지 최대 30초, 그 30초 뒤 두 번째 실패다. 그런데 재합류 실측(124~134초)이 같은 방식으로 계산한 이론값의 약 2배였다. 빠짐도 그 비율로 늘어난다고 보고 넉넉히 잡았다. 2026년 10월 14일에 잰 이탈 시간은 42초·55초 이상(하한)으로 120초 안이었다
TTL을 둔 이유는 배포가 2번과 3번 사이에서 끊기는 경우다. 표시 파일이 남아도 10분 뒤에는 노드가 스스로 UP으로 돌아온다. 메시징 서버에도 같은 drain을 넣고, 그동안 없던 노드 사이 재합류 대기도 함께 넣었다. 메시징 서버는 이번 검증 대상이 아니다
10월 6일 운영에 반영한 뒤 배포 절차에서 «자동취소를 끌까요» 단계가 없어졌다
«503 0건»은 health 표본이었다
2026년 10월 14일, drain의 근거를 다시 조사했다. AI 에이전트 둘이 따로 조사해 서로 대조했다
| 이전 근거 | 실제 |
|---|---|
| 10/5 장애 전환 검증 계획 | 계획만 있었다. 실행하지 않았다 |
| drain 코드 | 컴파일만 확인했다. 테스트는 돌리지 않았다 |
| 개발 서버 drain 시험 | 개발 서버는 ALB 없는 1대다. «표시 → 503 → 재시작 → UP» 순서만 봤다 |
| 10/6 운영 «503 0건» | ALB 경유 health 폴링 표본(API 378 · 메시징 395)이었다. 세션 집계 출력만 남고 원시 파일은 사라져 재계산할 수 없다 |
| 이후 롤링 14건(10/7~10/14) | health, 문서 마커, 무인증 401 폴링뿐이었다 |
2026년 10월 6일 기록에는 «사용자에게 간 503 0»이라는 문장이 있었다. health 표본을 사용자 요청으로 넓혀 쓴 문장이다. health 엔드포인트가 503을 한 번도 안 냈다는 것과, 손님의 주문이 한 번도 실패하지 않았다는 것은 다른 주장이다. 그 차이를 일주일 동안 확인하지 않았다
업무 API 연속 호출, 요청별 처리 노드 식별, 재시작 순간의 진행 중 요청, 주문의 매장 전달은 한 번도 재지 않았다. 그래서 세 가지를 차례로 재기로 했다. 롤링 중 읽기, 롤링 중 주문, 그리고 계획되지 않은 중단이다
무엇을 성공으로 셀지 먼저 정했다
판정 원칙을 실행 전에 적었다
- 상태 코드만 보지 않는다. 본문
success와 식별값까지 맞아야 성공이다 - health 표본을 업무 무중단으로 넓히지 않는다
- ALB를 거친 요청만 센다. 서버를 직접 부른 성공은 ALB 전환을 증명하지 못한다
- 원시 파일을 남기고, 작성과 분리된 AI 에이전트가 원시에서 다시 계산한다. 2·3회차는 계획도 실행 전에 별도 에이전트의 적대적 검토를 거친다
측정 장치는 다섯 가지를 붙였다
| 장치 | 하는 일 |
|---|---|
| 조회 프로브(Python) | ALB 경유 읽기 API를 동시 1로, 재시도 없이, 요청마다 새 TLS 연결로 부른다. 요청마다 X-Request-Id: <run>-<seq>를 붙인다 |
| health 스레드 | ALB 경유 /actuator/health를 초당 1건 부른다. drain 503은 DRAIN으로 따로 센다 |
| ALB 대상 상태 폴러 | NCP CLI getTargetList를 5초마다 조회한다 |
| 서버 로그 귀속 | 두 노드 로그에서 «이 노드가 응답한 seq»를 비트맵으로 뽑아, 측정 PC 기록과 대조해 중복·누락·무응답을 센다 |
| ALB 접근 로그 쿼리 | NCP Cloud Log Analytics에서 message:(500 OR 502 OR 503 OR 504) 건수를 센다 |
X-Request-Id가 이 측정의 중심이다. 측정 PC가 본 성공·실패와, 서버가 실제로 처리한 노드를 요청 하나 단위로 맞출 수 있다. «성공했는데 서버 기록이 없는 요청»이나 «서버가 받고 응답하지 못한 요청»도 이 대조로만 셀 수 있다
롤링 배포 중 조회 7,799건, 실패 0
1회차는 15:25~15:52에 했다. 코드 변화 없이 절차만 보려고 같은 커밋을 수동 배포했다.
| 항목 | 값 |
|---|---|
| 업무 API | 매장 정보 조회 1종. 손님 웹이 실제로 부르는 읽기 API이고, 코드 경로에 저장 호출이 없음을 확인했다 |
| 성공 조건 | HTTP 200 · JSON · success=true · 매장 id·이름 일치 · 응답 키 100개 이상 |
| 부하 | 목표 초당 5건(실제 평균 4.92) · 소켓 연산당 5초 제한 |
| 중단 기준 | 업무 실패가 1건이라도 나면 배포 취소(재합류 대기 150초 안) |
| 시간 | 기준선 5분 + 배포 약 11분 + 이후 10분 |
결과는 업무 조회 7,799건, 실패 0이었다(1,585.8초). 지연은 p50 109 · p95 157 · p99 257 · 최대 5,255ms였다. health 1,586건은 UP 1,542 + DRAIN 44(1번 24 · 2번 20)였고, 업무 API 응답에 503은 한 건도 없었다
처리 노드도 전수 대조했다. API-1 3,862 + API-2 3,937 = 7,799. 두 노드에 모두 찍힌 요청, 성공했는데 서버 기록이 없는 요청, 서버가 받고 응답하지 못한 요청이 모두 0이었다. 서버 쪽 비200과 1초 이상 처리도 0이었다
이 «실패 0»은 1회차 조회 기준이고, 놓칠 수 있는 창이 있다. 노드별 요청 간격은 중앙값 0.4초 · p99 0.8초 · 최대 2.6초였다. 한 노드에만 걸린 1초 이상 실패 창을 놓칠 확률은 0.3~0.4%지만, 0.4초짜리 짧은 창은 약 16% 놓친다. 한 번 잰 결과다
단계별로 나누면 다음과 같다
| 단계 | 요청 | 실패 | p50 / p99 / 최대 ms | 처리 노드 |
|---|---|---|---|---|
| 기준선 | 1,570 | 0 | 107 / 206 / 1,981 | 1번 761 · 2번 809 |
| 빌드 | 740 | 0 | 104 / 157 / 387 | 1번 372 · 2번 368 |
| 1번 drain 신호 ~ 재시작 | 591 | 0 | 113 / 357 / 1,115 | 1번 101 · 2번 490 |
| 1번 재시작 ~ 기동 | 89 | 0 | 112 / 148 / 149 | 2번 89 |
| 1번 기동 ~ 2번 drain | 1,093 | 0 | 111 / 331 / 5,255 | 1번 421 · 2번 672 |
| 2번 drain 신호 ~ 재시작 | 563 | 0 | 92 / 195 / 335 | 1번 460 · 2번 103 |
| 2번 재시작 ~ 기동 | 97 | 0 | 91 / 232 / 274 | 1번 97 |
| 2번 기동 ~ 배포 완료 | 67 | 0 | 109 / 163 / 170 | 1번 67 |
| 배포 완료 뒤 10분 | 2,989 | 0 | 110 / 281 / 703 | 1번 1,583 · 2번 1,406 |
재시작 구간에서는 요청이 전부 남은 노드로 갔다. ALB가 재시작 전에 그 노드를 뺐다는 뜻이다. 노드별로 잰 시간은 이렇다
| 항목 | API-1 | API-2 |
|---|---|---|
| drain 표시 → 마지막 처리(ALB 이탈) | 42초 이상 | 55초 이상 |
| 마지막 처리 → 재시작 | 79.5초 | 64.7초 |
| 재시작 → 첫 ALB 실요청 | 69.4초(JVM 기동 뒤 51.6) | 55.6초(JVM 기동 뒤 36.2) |
| 한 대만 받은 구간 | 약 149초(2번 단독) | 약 120초(1번 단독) |
getTargetList 표시 지연 — 마지막 처리 → DOWN | +67초 | +61초 |
getTargetList 표시 지연 — 첫 실요청 → UP | +91초 | +107초 |
표를 읽다 보면 예상과 다른 점이 몇 개 있다
drain 첫 관측은 drain 시작이 아니다. 2번은 표시가 생긴 뒤에도 health 14건이 연달아 1번으로 가서, 첫 503을 7초 넘게 늦게 봤다. 그사이 2번은 업무 요청 21건을 정상 처리했다. Round Robin이어도 요청마다 노드가 번갈아 바뀌지 않는다. 같은 노드가 최대 9번 연속으로 받았다. 그래서 위 표의 이탈 시간은 두 노드 모두 하한이다
getTargetList는 1~2분 늦다. NCP CLI getTargetList가 보여 주는 대상 상태는 실제 이탈·복귀보다 61~107초 늦게 바뀌었다. 폴링 간격(약 5.5초)만큼 늦게 잡혔을 수도 있다. 이탈과 복귀 판정은 ALB 경유 health 본문과 서버 로그로 해야 한다
배포 성공 보고는 두 노드 복귀가 아니다. 2번의 첫 실요청은 GitHub Actions의 성공 보고보다 13~23초 늦었다. 배포가 끝났다고 바로 다음 배포를 이어 붙이면, 2번이 돌아오기 전에 1번을 빼는 순간이 생길 수 있다. 이 위험은 존재만 확인했고 재현하지 않았다
복귀 순서가 지켜졌다. 1번이 실요청을 받기 시작한 뒤에야 2번 drain이 시작됐다. 그사이 1번은 최대 421건을 처리했고, 여유는 최대 177초였다. 한 대만 받은 약 2~2.5분 동안 남은 노드가 요청을 전부 처리했다
재시작 때 진행 중인 요청은 없었다. 두 노드 모두 graceful 종료가 2ms에 끝났다. 다만 프로브는 재시작 65~80초 전부터 그 노드로 가지 않았다. 프로브만으로는 «graceful이 진행 중 요청을 지켰다»는 것까지 증명하지 못한다
프로브 밖의 요청도 봤다
시험 요청만 실패 0이면 반쪽이다. 같은 창의 다른 요청은 두 로그로 봤다
앱 응답 로그(15:00~15:59, 두 노드)에서 프로브 외 요청의 5xx·예외 응답·ERROR 줄은 0이었다. 노드별 프로브 합(3,862 · 3,937)이 비트맵 귀속과 같아서 집계 명령 자체도 검증됐다. 다만 «프로브 외»에는 다른 작업 세션과 도구의 호출도 섞여 있고, 낮은 트래픽 시간대였다
ALB 접근 로그(15:25~15:52)는 건수 쿼리로 셌다
| 쿼리 | 건수 | 판독 |
|---|---|---|
message:health | 1,586 | 프로브 health 1,586과 같다. ALB 로그가 이 창을 빠짐없이 담았다 |
message:( | 44 | 전부 프로브 health의 drain 503이다(1번 24 · 2번 20) |
배포 창 전체에서 drain 503 외의 5xx는 ALB와 앱 어느 로그에도 0건이었다. ALB가 스스로 낸 502/503도 0이다. 501, 505 이상의 코드와 HTTP 상태 없이 끊긴 연결은 보지 않았다. 이 콘솔의 «다운로드»는 화면의 20행만 내보내서, 전수는 건수 쿼리로만 셀 수 있었다
5초 정체 1건은 원인을 가르지 못했다
15:35:56에 업무 요청 하나가 5,255ms 걸렸다. 서버 처리는 1초 미만이었다. 별도 연결인 health도 같은 순간 4,276ms였다. ALB를 거치지 않는 NCP API 폴러도 같은 순간 10.9초(평소 약 5.5초)가 걸렸고, 배포 전 15:30:30에도 업무와 health가 함께 늦었다. 측정 PC나 로컬 네트워크 쪽 정황이다
반대 정황도 있다. 그 시각은 1번이 ALB에 막 다시 오르던 무렵이고, 2번이 유일한 노드였다. 다른 네트워크에 관측점을 하나 더 두었다면 갈렸을 것이다. 지금은 원인을 확정하지 못했다. 순차 프로브라 그 5초 동안은 다음 요청을 보내지 못했다는 점도 측정 공백으로 남는다
주문 264건은 매장 ACK까지 따라갔다
조회가 끊기지 않았다고 주문이 매장에 닿는다는 보장은 없다. 2회차(17:52~18:15)는 롤링 배포 동안 5초마다 후불 주문을 넣고, 매장 접수앱이 상세를 조회하고 ACK를 보내는지까지 따라갔다
쓰기 시험에는 전용 매장이 필요했다
1회차의 테스트 매장은 쓸 수 없었다. 다른 운영 용도로 쓰는 매장이라 결제대기로 남을 시험 주문(예상 약 115건)이 그 화면에 섞인다. 게다가 정지 상태였다. 다른 운영 테스트 매장은 선불이거나 폐점 표시이거나 메뉴가 없었다
전용 매장 T를 새로 만들었다. 조건은 테스트 브랜드, 후불 전용, 비회원 주문 허용, 영업 중, 외부 POS 연동 아님, 메뉴 1개(100원, 준비량 없음)다. 매장 회원도 하나 둬야 했다. 접수앱 로그인 계정이 없는 매장은 자동취소가 건너뛰기 때문이다
부작용은 코드로 먼저 따졌다. 후불 결제대기 이벤트는 알림톡 템플릿이 없고 FCM 대상도 아니라 생성 때 알림이 나가지 않는다. 정산 배치와 매출 통계에서도 빠진다. 위험은 ACK가 빠질 때다. 자동취소가 나면 손님 번호로 취소 알림톡이 나간다. 그래서 손님 번호 없이 주문했다. 고객정보 행이 생기지 않아 자동취소가 나도 알림톡은 0이다. 시험 주문은 끝나고도 취소하지 않고 결제대기로 남겼다. 취소할 때마다 알림이 나가고, 매출에서는 이미 빠져 있기 때문이다
검토는 «그대로는 안 된다»였다
실행 전 계획을 별도 AI 에이전트에 적대적으로 검토하게 했다. 판정은 «그대로는 안 됨»이었다. 주요 지적과 반영은 이렇다
| 지적 | 반영 |
|---|---|
| ACK가 빠져도 자동으로 멈추지 않는다 | 주문 루프를 접수앱 탭 안에서 돌린다. 1초마다 «생성 뒤 15초 안에 상세 조회 + ACK success»를 확인해 없으면 즉시 정지. 탭이 죽으면 주문도 멈춘다 |
| 재전송이 ACK 누락을 가린다 | 재전송 응답도 기록하고, 재전송 뒤 ACK는 «복구»로 따로 센다 |
| 탭이 뒤로 가면 실시간 수신이 달라질 수 있다 | 실행 중 다른 페이지 조작·창 최소화 금지, visibilitychange 기록 |
| 10초 간격은 위험 창을 거의 못 덮는다 | 5초로 줄였다(drain→이탈 구간 노드당 42~55초+ 에 8~11건). 조회 프로브를 같이 돌려 단계 경계를 잡는다 |
| 다른 배포가 끼어들 수 있다 | 실행 창 동안 API·배치 배포 보류를 다른 작업 세션에 알린다 |
주문 루프는 접수앱 탭 안에서 fetch로 주문을 넣는다. XHR 훅이 접수앱의 상세 조회, ACK, 재전송 응답을 기록하되 요청은 바꾸지 않는다. 서버 로그에 남지 않는, ALB에서 실패한 요청까지 보려는 구성이다
실행 직전에 두 가지 일이 있었다. 다른 작업 세션이 배치 서버를 재시작해서, 주문 루프는 그 뒤에 시작했다. 첫 시도 3건은 모두 HTTP 500이었다. 손님 주문 API의 입력 범위 검사 누락 때문이었다. 이 결함의 처리는 별도로 정하고, 입력값을 바꿔 다시 시작했다
결과
후불 주문 264건이 모두 생성 → 상세 조회 → ACK success:true를 지났다. 자동취소, 재전송, 실패 요청, 탭 가시성 변화가 모두 0이었다
| 단계 | 시각 | 주문 | 종단 성공 |
|---|---|---|---|
| 기준선 | 17:52:49~18:01:17 | 102 | 102 |
| 1번 drain 신호 | 18:01:17~18:01:53 | 7 | 7 |
| 1번 재시작·복귀 ~ 2번 drain 전 | 18:01:53~18:07:08 | 63 | 63 |
| 2번 drain 신호 | 18:07:08~18:08:06 | 12 | 12 |
| 2번 재시작 ~ 배포 완료 | 18:08:06~18:09:34 | 18 | 18 |
| 배포 완료 뒤 | 18:09:35~18:14:44 | 62 | 62 |
판정은 «자동취소 0»이 아니라 «ACK 빠진 주문 0»으로 했다. 같은 매장의 뒤 주문이 ACK되면 앞 주문의 미수신은 «보류» 알림으로 끝나고 취소가 나지 않는다. 자동취소 0만으로는 앞 주문의 누락을 놓친다
마지막 주문 + 5.5분 뒤 상태를 조회했다. 265건(본 실행 264 + 직전 시험 1) 모두 취소되지 않았다. 함께 돌린 조회 프로브는 2,822건 실패 0이었다. 지연은 브라우저 시계 기준으로 생성 → 상세 조회 시작 p50 74 · p99 154 · 최대 560ms였다
이 회차에서는 서버 로그를 뽑지 않았다. 이벤트 저장과 Redis 발행은 브라우저가 본 ACK success:true에서 추론했다. 접수앱은 브라우저 1대였고, 실기기와 FCM은 보지 않았다. 주문 하나가 시스템을 지나는 시간은 약 0.1~0.6초다. 5초 간격이라 약 5초보다 짧은 실패 창은 주문 사이 공백에 빠질 수 있다
kill -9는 유휴일 때만 쏘게 만들었다
1·2회차는 계획된 중단이다. 노드가 미리 알리고 ALB가 뺀 뒤에 재시작한다. 프로세스가 예고 없이 죽으면 어떻게 되는지는 잰 적이 없었다. 3회차(22:46~23:30)는 노드당 한 번씩 kill -9를 넣었다
서버 장애(클라우드 서버 강제 정지)는 넣지 않고 프로세스 장애만 넣었다. 복구는 systemd에 맡겼다. 서비스 설정은 Restart=on-failure · RestartSec=10이다. systemd.service 문서는 on-failure가 0이 아닌 종료 코드로 끝나거나 SIGHUP·SIGINT·SIGTERM·SIGPIPE를 뺀 시그널로 끝난 프로세스를 재시작한다고 적는다. SIGKILL은 재시작 대상이다
실행 전에 예상한 실패
코드와 설정으로 먼저 예상을 적었다
- 실패 창 — kill → 10초 뒤 재시작 → 기동 16~18초. 노드당 약 27~30초다. sticky가 없으니 그동안 요청 약 절반이 실패한다. 창이 ALB 이탈 판정(30초 × 2회)보다 짧아, ALB가 뺄 수 있는 시점이 오기 전에 노드가 돌아올 것으로 봤다. drain 도입 전 롤링과 같은 모양이다.
- 잘린 쓰기 — 가장 나쁜 결과다. 주문·결제 커밋 뒤의 비동기 후처리는 이벤트 저장·POS 전송·알림톡을 한 트랜잭션으로 하고, Redis 발행은 그 커밋 뒤에 한다. 그 사이에 kill되면 문제가 생긴다.
- 커밋 전이면 이벤트 행이 롤백된다. 실시간 전달도 자동취소도 재전송도 없다. 손님은 결제했는데 매장은 모르는 주문이 남는다.
- 커밋 뒤 발행 전이면 «미발송»으로 남는다. 자동취소는 발송된 것만 보니 취소도 안 된다.
- 결제 승인·취소에서 PG 호출 뒤 커밋 전에 kill되면 PG와 DB가 어긋난다.
- graceful은 동작하지 않는다 — SIGKILL은 잡거나 막거나 무시할 수 없다. graceful shutdown도, 비동기 실행기의 종료 대기도 건너뛴다.
- 접수앱 재시도 — 상세 조회와 ACK를 각각 최대 3회 재시도하지만 약 3~5초만 덮는다. 30초 창 안에서 재시도가 모두 죽은 노드로 가면 미수신이 될 것으로 봤다. 이 예상은 틀렸다. 뒤에서 다룬다.
잘린 쓰기는 피할 수 없으니 확률을 줄이기로 했다. kill은 노드가 유휴일 때만 하고, 1번 kill 뒤 잘린 쓰기 점검을 2번 kill의 관문으로 뒀다
유휴가 아니면 쏘지 않는 스크립트
AI 에이전트가 쓴 kill 스크립트의 요지다. 식별자는 바꿨다
EXPECT=api-1 # 이 노드 이름 — 다른 노드 창에 붙여도 안 쏜다
S=$(<환경 파일에서 actuator 보호 비밀값 읽기>)
v(){ curl -s -m 2 -H "<보호 헤더>: $S" "http://localhost:<port>/actuator/metrics/$1" \
| grep -o '"value": *[0-9.Ee+-]*' | head -1 | cut -d: -f2 | tr -d ' '; }
alb_ok(){ for k in 1 2 3 4 5 6 7 8 9 10; do
[ "$(curl -s -m 3 -o /dev/null -w '%{http_code}' https://api.example.com/actuator/health)" = 200 ] || return 1; done; }
P=$(systemctl show -p MainPID --value app.service)
if [ "$(hostname)" != "$EXPECT" ]; then echo "중단 안 함: 노드 이름 불일치"
elif ! [ "${P:-0}" -gt 1 ] || [ "$(ps -o comm= -p "$P")" != java ]; then echo "중단 안 함: MainPID=$P"
elif ! alb_ok; then echo "중단 안 함: ALB 경유 health 10회 중 200 아닌 것이 있다(상대 노드 미복귀 의심)"
else
for i in $(seq 1 30); do
a=$(v executor.active); h=$(v http.server.requests.active); ok=1
for n in $APP_EXECUTORS; do # 앱 비동기 실행기 4종(스케줄러 제외)
x=$(v "executor.queued?tag=name:$n"); [ "${x%.*}" = 0 ] || ok=0
done
if [ "${a%.*}" = 0 ] && [ "$ok" = 1 ] && [ -n "$h" ] && [ "${h%.*}" -le 1 ]; then
date '+%F %T.%3N'; kill -9 "$P"; date '+%F %T.%3N'; break # 두 date 줄이 kill 직전·직후 시각
fi
[ "$i" = 30 ] && echo "30초 동안 유휴가 아니라 중단 안 함"
sleep 1
done
fi
unset S
쏘지 않는 경우는 넷이다. 노드 이름이 다를 때, MainPID가 java가 아닐 때, ALB 경유 health 10회 중 하나라도 200이 아닐 때, 30초 안에 유휴가 오지 않을 때다
MainPID 가드는 빠지면 위험하다. 서비스가 이미 내려가 MainPID가 0이면 kill -9 0이 된다. kill(2)에서 pid 0은 «호출한 프로세스의 프로세스 그룹 전체»다. 지금 셸이 속한 그룹을 죽인다. ALB health 10회 확인은 두 대가 동시에 빠지는 것을 막는다. Round Robin이라 상대 노드가 아직 죽어 있으면 10회 안에 실패가 섞인다
유휴는 실행기 활성 0, 앱 실행기 넷의 대기열 0, 처리 중 HTTP 요청 1 이하(지표 요청 자신)로 정의했다. 확인과 kill 사이에 수 ms가 있어서 잘리는 주문·결제를 줄일 뿐 없애지는 못한다
검토가 «영영 쏘지 않는 스크립트»를 잡았다
이 스크립트는 처음 모양이 아니다. 실행 전 별도 에이전트 검토를 두 번 거쳤다. 1차 판정은 «고치면 가능»이었다. 잘린 쓰기를 잴 수단이 없다는 지적, 2번 관문이 «관찰 2~3분»뿐이라 두 대가 동시에 빠질 수 있다는 지적에서 위 가드와 관문이 나왔다
2차 검토에서는 유휴 판정이 영영 참이 되지 않는다는 지적이 나왔다. 처음에는 실행기 대기열을 합계로 봤다. 그 합계에 @Scheduled 작업 3개의 스케줄러 대기열이 들어간다. 쉬고 있어도 약 3이다. 그대로 돌렸다면 30초 동안 «유휴 아님»만 찍고 끝났을 것이다. 앱 실행기 넷만 이름별로 보게 고쳤다
고친 스크립트는 측정 PC에서 가짜 지표 서버와 가짜 로그로 일부러 실패시켜 봤다. 스케줄러 대기 3을 무시하고 쏘는가, 노드 이름이 다르거나 ALB 응답에 502가 섞이거나 지표 이름이 틀리면 안 쏘는가, kill 줄이 없으면 점검을 건너뛰는가. 모두 의도대로 동작했다
2번 kill의 관문은 다섯 개였다. 1번 새 JVM이 ALB 실요청을 받고 있는지, 프로브 최근 60초 실패가 0인지, 1번 첫 실요청 뒤 100초 이상 지났고 운영 알림 채널에 매장 T가 아닌 매장이 없는지, 잘린 쓰기 점검 결과가 우리 요청과 우리 주문뿐인지, 주문 루프가 살아 있는지다. 실매장 영향을 줄이려고 밤의 낮은 트래픽 시간대를 골랐다. 전날 같은 시간대 ALB 로그를 보니 POS 상태 폴링, 제휴 관리자 토큰 검증, 스캐너 404 정도였다
노드는 ALB의 이탈 판정보다 먼저 돌아왔다
조건은 운영, 두 노드 같은 jar, 프로세스 장애만, 유휴 확인 뒤 kill, 노드당 1회, 밤의 낮은 트래픽이다. 프로브는 초당 5건 + health 1건, 주문은 5초 간격, 접수앱은 브라우저 1대였다
| 항목 | API-1 | API-2 |
|---|---|---|
| kill(서버 시각) | 23:04:34.319 | 23:10:17.721 |
| systemd 재시작 예약 | 23:04:44.477(+10.2초) | 23:10:27.968(+10.2초) |
| 앱 기동 완료 | 23:05:01.391(재시작 뒤 16.9초) | 23:10:46.078(18.1초) |
| 새 JVM의 첫 ALB 경유 요청 처리 | 23:05:02.282(기동 0.9초 뒤) | 23:10:47.039(기동 1.0초 뒤) |
| 프로브 실패 창(측정 PC) | 23:04:34.599~23:05:00.839 · 26.2초 | 23:10:17.686~23:10:45.887 · 28.2초 |
| 실패 건수 | 68 — 전부 ALB 503 Service Unavailable | 70 — 503 69 · 502 1 |
| 창 안 요청 귀속 | 137 = 2번 처리 68 · 실패 68 · 1번 1(경계) | 144 = 1번 처리 74 · 실패 69 · 2번 1(경계) |
| ALB 대상 상태(5초 간격) | 내내 UP | 내내 UP |
창 안 요청의 약 절반, 즉 죽은 노드로 간 몫이 그대로 실패했다. 남은 노드는 자기 몫을 전부 처리했다. 연속 실패는 최장 4건(0.6초)이었고, 5초 내내 실패한 구간이나 타임아웃은 없었다. 2번의 502 한 건은 kill이 자른 처리 중 요청이다
복구는 전부 systemd 자동 재시작이었고 수동 start는 없었다. ALB는 그 노드를 한 번도 빼지 않았다. 고장이 아니다. 뺄 수 있는 시점이 오기 전에 노드가 먼저 살아났다. 근거는 넷이고, 강한 순서로 적는다
- 복귀 직후 바로 트래픽을 받았다. 측정값이다. 1번은 앱 기동 0.9초 뒤, 2번은 1.0초 뒤에 ALB를 거친 첫 요청을 처리했다. ALB가 이 노드를 뺐다면 다시 넣을 때 정상 판정 2회(30초 간격)가 필요하다. 기동 1초 만에 요청을 받았다면 처음부터 빠진 적이 없다.
- 실패가 창 끝까지 절반씩 이어졌다. 측정값이다. 1번 창 요청 137건은 실패 68, 남은 노드 처리 68, 경계 1이었다. ALB가 중간에 뺐다면 그 뒤로는 남은 노드가 전부 받아 실패가 멈췄어야 한다. 마지막 실패(23:05:00.839)는 앱 기동 0.55초 전이었다. 2번도 0.19초 전이었다. 측정 PC와 서버 시계의 차는 같은 요청의 송수신 로그로 −0.033~+0.022초였다.
- 설정상 뺄 수 없는 길이였다. 설정값에서 따진 추론이다. 이탈하려면 30초 간격 health check가 2회 연속 실패해야 하니, 첫 실패와 두 번째 실패 사이에 최소 30초가 걸린다. 실패 창은 26.2초·28.2초라 두 번째 실패 전에 노드가 돌아왔다.
getTargetList가 310회 내내 UP이었다. 측정값이지만 가장 약한 근거다. 이 표시는 실제보다 54초~1분 45초 늦게 바뀐다. 이것만으로는 증명이 안 되고, 위 셋과 맞는다는 보조 근거다.
이 결론은 재시작이 빠른 프로세스 장애에만 해당한다. 서버 정지처럼 재시작이 없으면 30~60초 뒤 ALB가 뺄 것으로 보지만, 시험하지 않았다
프로브 전체는 8,765건 = 성공 8,627 + 실패 138이었다. 실패 138건은 어느 노드에도 응답 기록이 없다. 대부분 ALB가 거절했고, 노드가 받고 응답하지 못한 것은 2번의 502 한 건뿐이다. 그래서 노드별 실패 창은 kill 시각과 앞뒤 귀속으로 추론한 값이다
잘린 쓰기 0, 단 유휴일 때 쐈기 때문이다
| 점검 | API-1 | API-2 |
|---|---|---|
| 받았는데 응답 로그 없음(kill 전 2분) | 0 | 1 — 우리 프로브(위 502와 같은 요청) |
| 이벤트 처리 시작 · 발행 없음 | 0 | 0 — 양성 대조: 처리 14 · 저장 14 · 발행 14 |
| 끝나지 않은 결제 | 결제 로그 0 | 결제 로그 0 |
점검 창은 journal의 status=9/KILL 줄에서 자동으로 뽑았다. 2번 점검에 건수 줄을 더해, 점검 패턴이 실제 로그에 맞는다는 것을 확인했다(처리 14 · 저장 14 · 발행 14). 그래서 1번의 «0»도 패턴이 틀려서 나온 빈 결과가 아니다
이 0은 유휴 확인 뒤에 kill했기 때문이다. 주문 처리 도중의 kill은 일부러 피했다. «kill해도 잘린 쓰기가 생기지 않는다»는 증거로 읽으면 안 된다
주문 346건과 실사용자 요청 3건
| 구간 | 주문 | 정상 | 재시도 끝에 정상 | 상세 실패 · ACK 성공 | 생성 실패 |
|---|---|---|---|---|---|
| 기준선(22:47~23:04) | 209 | 209 | 0 | 0 | 0 |
| 1번 kill 뒤 | 69 | 63 | 2 | 1 | 3 |
| 2번 kill 뒤 | 68 | 62 | 2 | 2 | 2 |
서버의 시험 테이블 주문 341건과 브라우저의 생성 성공 341건이 양쪽 차집합 0으로 맞았다. 유령 주문은 없다. 생성 실패 5건은 ALB가 거절해 주문 자체가 생기지 않았다. 자동취소, 재전송, 운영 알림도 0이었다. 접수앱 재시도가 4건을 살렸다. 한 주문은 상세 1회 실패 뒤 2회째 성공, ACK 2회 실패 뒤 3회째 성공이었다
ALB 접근 로그(22:46~23:23)의 5xx는 198건이었다. 측정 PC 몫 195건은 원시 기록과 2분 칸별로 맞았다. 나머지 3건이 실사용자 요청이고, 모두 ALB 503이었다
23:04:41.571 Windows POS 클라이언트의 상태 폴링 23:04:41.919 제휴 관리자 화면의 토큰 검증 23:10:25.114 Windows POS 클라이언트의 상태 폴링
주문·결제·접수 경로는 0건이었다. 밤의 낮은 트래픽 시간대에 노드당 한 번 쏜 결과이고, 낮 시간대의 영향은 재지 않았다.
상세 조회가 실패해도 접수앱은 ACK를 보냈다
표의 «상세 실패 · ACK 성공» 3건은 처음에 다른 칸에 있었다
실행 중 판정은 상세 조회 뒤 «첫 ACK»만 짝지었다. ACK 재시도가 성공한 주문을 미수신으로 셌고, 실행 중 «ACK 누락» 경고 4건이 모두 이 오탐이었다. 첫 판정기의 «ACK 실패» 3건도 짝짓기 오류였다. 결과 교차검증에서 주문별로 상세·ACK 시도를 따로 모으는 판정기로 다시 셌다. 그러자 상세 조회 3회가 전부 실패했는데 ACK는 성공한 주문 3건이 남았다
실행 전 예상은 «상세가 끝내 실패하면 ACK도 빠진다»였다. 그게 아니었다. 3회차 직후 운영 접수앱 번들을 판독해 클라이언트 동작을 확인했다(별도 에이전트 교차검증). 10월 1일 판에서는 상세가 실패하면 ACK가 빠졌다. 10월 14일 판에서는 상세가 끝내 실패해도 ACK를 보낸다. 그사이 언제 바뀌었는지는 확인하지 못했다
서버는 ACK를 받으면 그 주문을 «수신»으로 기록하고, 재전송·ACK 지연 푸시·자동취소 대상에서 모두 뺀다. 매장 화면에는 주문이 바로 뜨지 않는다. 목록을 다시 불러올 때 나타나고, 웹 알림음과 새 주문 표시는 없다
이번 3건이 «매장이 주문을 놓친 사례»를 재현한 것은 아니라고 본다. 시험 주문은 전부 후불 결제대기였다. 결제대기 이벤트는 상세가 성공해도 알림음과 보드 표시가 없고 즉시 푸시 대상도 아니다. 기록에 이벤트 종류 칸이 없어 직접 확인하지는 못했다
위험한 모양은 선불 결제완료 주문이 장애 창에서 상세 조회 3회를 모두 실패하는 경우다. 웹에는 아무것도 뜨지 않고 서버는 «수신»으로 본다. 남는 안전망은 ACK와 무관하게 나가는 결제완료 즉시 푸시 하나다. 실기기에서 그 푸시가 어떻게 울리는지는 보지 않았다. 서버 API 문서의 계약은 «ACK는 화면에 반영한 뒤»다. 이 동작은 그 계약과 어긋나서 앱 쪽 개선 후보로 남겼다
재전송과 자동취소는 «ACK가 왔다면 매장이 주문을 봤다»는 전제 위에 서 있다. ACK를 언제 보내는지는 클라이언트가 정하고, 앱 판이 바뀌면 서버 모르게 달라진다. 이번에는 장애 주입이 그 변화를 드러냈다
계획된 중단과 예고 없는 중단은 다른 문제였다
| 항목 | 롤링 배포(drain) · 1·2회차 | 갑작스러운 중단(kill -9) · 3회차 |
|---|---|---|
| ALB에서 빠지는가 | drain 표시 뒤 42초·55초 이상 걸려 빠진다 | 빠지기 전에 돌아온다(이탈 판정 전 복귀 · DOWN 0) |
| 시험 요청 실패 | 0(조회 7,799 · 주문 264) | 노드당 26~28초 동안 그 노드 몫 실패(조회 68·70 · 주문 생성 5) |
| 실사용자 5xx | 0(1회차 앱·ALB 로그, 2회차는 안 봄) | 3(POS 상태 폴링 2 · 제휴 관리자 토큰 검증 1) |
| 주문 → 매장 전달 | 끊김 0 | 재시도로 대부분 이어짐 · 상세 없이 ACK 3 |
| 복구 주체 | 배포 워크플로(재합류 대기 150초 + UP 확인) | systemd 자동 재시작(10초 + 기동 16~18초) |
drain 롤링에서 실패가 없었던 이유는 순서다. 노드가 health로 먼저 빠지겠다고 알리고, ALB가 뺀 뒤에 재시작한다. 각 1회 측정이고, 1회차는 0.4초 실패 창을 약 16% 놓친다는 조건이 붙는다
프로세스 장애에서는 «재시작 지연 10초 + JVM 기동»이 실패 창의 길이를 정했다. 재시작이 이탈 판정보다 빨라서, health check가 노드를 뺄 수 있는 시점이 오기 전에 노드가 돌아왔다. 그동안 ALB는 그 노드를 정상으로 보고 요청을 나눴다. 그 창은 클라이언트 재시도가 일부 덮었다. 접수앱 재시도는 주문 4건을 살렸고, 재시도 여부를 모르는 POS 상태 폴링과 토큰 검증은 실패로 남았다
그래서 롤링 배포는 무중단이었나
출발 질문은 «ALB 뒤 두 대로 한 롤링 배포가 실제로 무중단이었나»였다. 답은 시기별로 다르다
- drain 전(5/14~10/6) — 무중단이 아니었다. 배포마다 노드당 약 10~17초 503이 섞였다. ALB와 서버 두 대, 배포 순서, 재합류 대기로는 이 창을 막지 못했다.
- drain 후 — 이 조건에서는 무중단이었다. 조회 7,799건 실패 0, 후불 주문 264건은 접수앱 상세 조회와 ACK까지 끊김 0, 1회차 배포 창의 실사용자 요청에도 5xx가 없었다. 한 노드가 빠진 2~2.5분 동안 남은 노드가 전부 처리했고, 복귀한 노드가 실요청을 받은 뒤에야 다음 노드가 빠졌다.
drain 전의 503 창은 health 표본으로만 봤으니, 같은 지표로도 비교해 둔다. 1회차의 health 1,586건은 UP 1,542와 DRAIN 44뿐이었다. drain이 일부러 낸 503 말고는 503이 없었다
«무중단» 앞에는 조건이 붙는다
- 낮은 부하 시간대에, 회차당 한 번 쟀다
- 1회차는 0.4초짜리 짧은 실패 창을 약 16% 놓칠 수 있다
- 선불 결제, 실기기 접수앱, 메시징 서버 롤링은 업무 요청으로 재지 않았다
- 배포 성공 보고 시점에 2번 노드는 아직 ALB 밖이었다. 배포를 연달아 돌리면 위험할 수 있다
- 예고 없는 프로세스 장애에는 여전히 노드당 26~28초의 부분 실패 창이 있다
서버 정지와 처리 도중 kill은 재지 않기로 했다
3회차 뒤 남은 후보 셋은 10월 15일에 하지 않기로 정했다
| 후보 | 하지 않는 이유 |
|---|---|
| 처리 도중 kill | 틈(결제 커밋 뒤 이벤트 커밋 전)은 이미 코드로 확인했다. 시험해도 결론이 바뀌지 않는다 |
| 서버 정지 | ALB의 health check 이탈은 drain 롤링에서 이미 쟀고, 한 대 단독 운영은 롤링 배포마다 겪는다. 정작 모르는 «피크 때 한 대로 버티는가»는 밤 시험으로 잴 수 없다. 비용은 실사용자 최대 1분가량의 실패와 운영 서버 정지·기동이다 |
| health check 주기 단축 | 두 대뿐이라 오탐 이탈 위험이 커진다 |
서버 정지 대신 재부팅 자동 기동 설정을 확인했다. 재부팅은 하지 않았다. 두 노드 모두 서비스가 enabled이고 WantedBy=multi-user.target(기본 타깃), After/Wants=network-online.target, Restart=on-failure다. 프로세스 장애는 systemd 재시작이, 서버 재부팅은 부팅 시 자동 기동이 앱을 다시 띄운다
세 회차가 공통으로 덮지 못한 범위도 있다. 선불 승인·취소·환불과 후불 주문 외의 쓰기 API, 롤링·장애 때 메시징 서버의 STOMP 재연결, 운영 피크의 한 노드 처리 능력은 재지 않았다. 초당 5건과 5초 간격은 부하 시험이 아니다. keep-alive 연결 재사용과 수 초 걸리는 요청이 재시작과 겹치는 경우도 보지 않았다. 모든 회차가 1회씩이다
빠지는 쪽을 다룬 뒤에야 무중단이 확인됐다
ALB와 서버 두 대는 무중단의 필요조건이었다. 5월부터 쌓은 롤링 순서와 재합류 대기는 «다시 붙는 쪽»만 다뤘고, 노드마다 503 창이 남았다. 그 창을 위험하게 만든 것은 9월 30일에 켠 자동취소였고, 막은 것은 «빠지는 쪽»을 다룬 drain이었다
10월 6일의 «503 0건»은 틀린 숫자가 아니었다. health 엔드포인트가 503을 내지 않았다는 사실을, 사용자 요청이 실패하지 않았다는 주장으로 넓혀 읽은 것이 문제였다. 업무 요청에 ID를 붙여 서버 로그까지 따라가자, 이번 조건에서는 drain이 그 주장을 뒷받침했다. 같은 방식으로 예고 없는 중단도 재 보니, ALB의 이탈 판정보다 짧은 26~28초 실패 창과 클라이언트의 ACK 동작 변화가 드러났다
로드밸런서 뒤에 서버를 여러 대 두었다면, 배포가 노드를 ALB에서 뺀 뒤에 재시작하는지부터 확인하고, 무중단은 요청 ID로 서버 로그까지 귀속한 업무 요청으로 잰다
용어 정리
drain — 노드를 내리기 전에 로드밸런서가 그 노드로 새 요청을 보내지 않게 먼저 빼는 절차다. 여기서는 health 엔드포인트가 OUT_OF_SERVICE(503)를 답하게 해 ALB가 스스로 빼도록 했다
ALB health check — 로드밸런서가 대상 노드의 상태 URL을 주기적으로 불러, 정해진 횟수만큼 연속 실패하면 분배에서 빼고 연속 성공하면 다시 넣는 기능이다. 이 구성은 30초 간격, 정상·비정상 임계 각 2회다. 노드가 판정에 필요한 시간보다 빨리 되살아나면 분배에서 빠지지 않고, 그사이 그 노드로 간 요청이 실패한다
Round Robin과 sticky session — Round Robin은 요청을 대상 노드에 돌아가며 나누는 방식이다. sticky session은 같은 클라이언트를 같은 노드로 보내는 설정이다. sticky가 없으면 노드 하나가 죽었을 때 모든 클라이언트의 요청 일부가 실패한다
graceful shutdown — 종료 신호를 받으면 새 요청을 받지 않고 진행 중인 요청을 정해진 시간 안에 마친 뒤 내려가는 방식이다. kill -9(SIGKILL)는 프로세스가 잡을 수 없어 graceful shutdown이 동작하지 않는다
ACK — 매장 접수앱이 주문 이벤트를 받았다고 서버에 알리는 수신 확인이다. 이 서비스에서는 ACK가 없으면 일정 시간 뒤 주문을 자동취소한다
적대적 검토 — 작성자와 분리된 검토자가 계획이나 결과의 허점을 찾는 역할을 맡아 검토하는 방식이다. 여기서는 작성에 참여하지 않은 별도 AI 에이전트가 맡았다