핵심 한 줄

GET /owners 에서 던진 NPE의 top frame OwnerController.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이 결함 지점을 직접 가리키는 것은, 다음 두 조건이 동시에 충족될 때뿐이다.

  1. 동기(synchronous) — 예외가 요청 스레드 안에서 던져진다.
  2. 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

이 편에서 세운 파이프라인은 이후 모든 회차가 공유한다. 각 레이어를 한 줄씩:

  • Prometheusmicrometer-registry-prometheus를 추가해 /actuator/prometheus를 열고 5s 간격으로 스크레이프. 이후 회차의 지연·에러율·쿼리 수 메트릭이 모두 이 위에 쌓인다.
  • Loki + Alloylogging.structured.format.file=logstashlogs/petclinic.json 기록. Alloy(observability/alloy/config.alloy)가 tail해 {service="petclinic"} 레이블로 Loki에 적재한다.
  • Grafanaobservability/grafana/provisioning/alerting/rules.ymlClassAOwnerSearchNPE 규칙. Loki 쿼리 count_over_time({service="petclinic"} |= "NullPointerException" [5m]) > 0으로 평가.
  • Slackslack-oncall contact point. SLACK_BOT_TOKENONCALL_SLACK_CHANNEL.env에서만 주입되고 커밋된 파일에는 절대 없다.

프로파일 게이트를 놓치면 기본 빌드가 깨진다

JSON 파일 로깅과 chaos seam은 chaos 프로파일에서만 활성화된다. NoOpChaosFaults(@Profile("!chaos"))가 프로덕션 동작을 보장하고, ChaosProfileGatingTests가 기본 프로파일에서 ActiveChaosFaultsChaosController가 부재함을 단언한다. ./gradlew test가 항상 green이어야 한다는 AC A1의 계약이 이 게이트 위에 서 있다.


6. 교훈

"스택트레이스 = 원인"은 이 클래스에서만 참

top frame이 결함 지점을 직접 가리키는 것은 동기·in-process throw의 특성이다. 이 조건을 벗어난 순간 — 비동기 전파, 원격 예외, 삼킨 오류 — top frame은 표면이지 원인이 아니다. 자동화 루트(top frame → 파일:줄 → 커밋 → 패치)는 Class A에서만 성립한다. 나머지 클래스에서 같은 루트를 시도하면 반드시 오진한다.


관련 글