핵심 한 줄
GET /owners에서 던진 NPE의 top frameOwnerController.processFindForm은 결함 지점과 정확히 일치한다 — 동기·in-process throw이기 때문. 이 클래스만 스택트레이스가 곧 원인이고 자동 패치가 가능하다. 편은 이 판별 신호와 함께 시리즈의 관측 토대(Prometheus·Loki·Grafana·Slack)를 세운다.
시리즈 — 장애를 '구별 가능'하게 만든 카오스 벤치
1. 배경 — 이 시리즈의 첫 번째 결함을 심다
0.서문에서 이야기한 목표는 하나다 — 각 장애를 다른 원인은 못 내는 단 하나의 판별 신호로 가르는 것. 그 첫 번째 실험이 W1이다.
W1이 특별한 이유가 있다. 다른 회차들이 “같은 증상, 다른 신호”를 가르는 작업이라면, W1은 그 모든 실험이 올라탈 관측 파이프라인 자체를 세우는 회차이기도 하다. 결함을 심고, 그 결함이 파이프라인을 타고 Slack 알림으로 도달하는 전 과정을 한 번에 검증한다.
2. 무엇을 망가뜨렸나 — NPE seam과 ActiveChaosFaults
결함의 이름은 Class A NPE다. chaos 프로파일에서 ChaosFaults seam을 타고 OwnerController.processFindForm까지 흘러든다.
seam 구조는 간단하다. 프로덕션에서는 NoOpChaosFaults가 살아나 null을 빈 문자열로 정규화한다. chaos 프로파일에서는 ActiveChaosFaults로 교체되고, 시나리오 OWNER_SEARCH_NPE가 arm되면 null guard 없이 .trim()을 호출해 NPE를 던진다.
// ActiveChaosFaults.java — normalizeLastName (lines 164–169)
public String normalizeLastName(String lastName) {
if (this.state.isArmed(OWNER_SEARCH_NPE)) {
// Seeded defect (Class A): null guard absent → NPE on null search term
return lastName.trim();
}
return lastName == null ? "" : lastName;
}OwnerController.processFindForm은 line 122에서 this.chaosFaults.normalizeLastName(owner.getLastName())을 호출한다. lastName이 null인 채 GET /owners?page=1이 들어오면 — arm 상태에서 — 이 한 줄이 NPE를 동기적으로 던진다. seam은 @Profile("chaos")로 게이트되어 있으므로 기본 빌드는 byte-for-byte 변화 없다.
3. 증상 — 500과 스택트레이스, 그리고 파이프라인으로의 표면화
arm 후 POST /chaos/ownerSearchNpe/arm을 거쳐 GET /owners?page=1을 쏘면 즉각 HTTP 500이 돌아온다. 스택트레이스의 위쪽 두 프레임:
java.lang.NullPointerException
at o.s.s.p.chaos.ActiveChaosFaults.normalizeLastName(ActiveChaosFaults.java:168)
at o.s.s.p.owner.OwnerController.processFindForm(OwnerController.java:122)
여기서 이 시리즈의 핵심 관찰이 시작된다. top frame이 processFindForm이고, 그 줄이 실제 결함 지점과 정확히 일치한다. 이 사실이 왜 중요한지는 §4에서.
이 스택트레이스가 파이프라인을 탄다. 앱은 chaos 프로파일에서 application-chaos.properties에 의해 logs/petclinic.json에 logstash-format JSON을 기록한다. Alloy가 이 파일을 tail해 Loki로 밀어 넣는다. Grafana의 ClassAOwnerSearchNPE 규칙이 30초 주기로 Loki 쿼리를 평가하고, 알림이 Slack slack-oncall contact point에 닿는다. scripts/smoke/classA-to-slack.sh가 이 전 과정을 자동화한다 — arm, 트리거, Grafana alert state를 최대 90초 polling하며 ALERT FIRING을 확인한다.
4. 판별 신호 — 왜 이 클래스만 top frame = 원인인가
이 시리즈가 12개의 결함 클래스를 구별하는 이유는 — 같은 신호가 다른 원인을 의미할 수 있기 때문이다. 스택트레이스는 그 대표적인 예다.
스택트레이스의 top frame이 결함 지점을 직접 가리키는 것은, 다음 두 조건이 동시에 충족될 때뿐이다.
- 동기(synchronous) — 예외가 요청 스레드 안에서 던져진다.
- in-process — throw 지점과 catch 지점이 같은 JVM 프로세스 안에 있다.
Class A NPE는 이 두 조건을 모두 만족한다. normalizeLastName이 호출되는 순간, 같은 요청 스레드, 같은 JVM에서 예외가 올라온다. top frame은 throw 지점 그 자체다. 실제 app 프레임(OwnerController)이 top 근처에 있고, 그 줄을 건드린 최근 커밋을 찾는 것으로 자동 패치 루프가 성립한다.
반면 4.top frame은 표면이지 원인이 아니다에서 다룰 Class B~D는 이 조건 중 하나 이상을 위반한다 — 비동기 전파, 원격 호출, 삼킨 예외. 그 경우 top frame은 원인이 표면화된 지점일 뿐, 결함이 심어진 지점이 아니다.
| 클래스 | 동기? | in-process? | top frame = 원인? | 자동 패치? |
|---|---|---|---|---|
| Class A (이 편) | 예 | 예 | 예 | 가능 |
| Class B~D | 아니오/혼합 | 아니오/혼합 | 아니오 | 불가 |
Class A가 ‘자동 패치 가능’한 이유는 여기 있다. top frame = 원인이 보장되면, 해당 파일:줄을 건드린 최근 커밋을 찾고 패치를 생성하는 루프가 구조적으로 성립한다.
5. 관측 토대 — Prometheus·Loki·Grafana·Slack
이 편에서 세운 파이프라인은 이후 모든 회차가 공유한다. 각 레이어를 한 줄씩:
- Prometheus —
micrometer-registry-prometheus를 추가해/actuator/prometheus를 열고 5s 간격으로 스크레이프. 이후 회차의 지연·에러율·쿼리 수 메트릭이 모두 이 위에 쌓인다. - Loki + Alloy —
logging.structured.format.file=logstash로logs/petclinic.json기록. Alloy(observability/alloy/config.alloy)가 tail해{service="petclinic"}레이블로 Loki에 적재한다. - Grafana —
observability/grafana/provisioning/alerting/rules.yml의ClassAOwnerSearchNPE규칙. Loki 쿼리count_over_time({service="petclinic"} |= "NullPointerException" [5m]) > 0으로 평가. - Slack —
slack-oncallcontact point.SLACK_BOT_TOKEN과ONCALL_SLACK_CHANNEL은.env에서만 주입되고 커밋된 파일에는 절대 없다.
프로파일 게이트를 놓치면 기본 빌드가 깨진다
JSON 파일 로깅과 chaos seam은
chaos프로파일에서만 활성화된다.NoOpChaosFaults(@Profile("!chaos"))가 프로덕션 동작을 보장하고,ChaosProfileGatingTests가 기본 프로파일에서ActiveChaosFaults와ChaosController가 부재함을 단언한다../gradlew test가 항상 green이어야 한다는 AC A1의 계약이 이 게이트 위에 서 있다.
6. 교훈
"스택트레이스 = 원인"은 이 클래스에서만 참
top frame이 결함 지점을 직접 가리키는 것은 동기·in-process throw의 특성이다. 이 조건을 벗어난 순간 — 비동기 전파, 원격 예외, 삼킨 오류 — top frame은 표면이지 원인이 아니다. 자동화 루트(top frame → 파일:줄 → 커밋 → 패치)는 Class A에서만 성립한다. 나머지 클래스에서 같은 루트를 시도하면 반드시 오진한다.
관련 글
- 0.서문 — 이 ‘판별 신호’ 논제의 전체 그림
- 4.top frame은 표면이지 원인이 아니다 — Class A의 대조: top frame이 원인이 아닌 클래스
- 2.장애 도구가 장애에 죽지 않게 — 이 판별 신호를 소비하는 자동 진단
- 근거: spring-petclinic
ActiveChaosFaults(OWNER_SEARCH_NPE),OwnerController.processFindFormline 122,scripts/smoke/classA-to-slack.sh