알림도 안 울린 장애, 범인은 로그만 찍는 catch
장애가 났는데 알림도 안 울리고 에러율 그래프도 평평했다. 몇 시간 뒤 사용자 문의로 알았다. 예외를 잡아 경고 로그만 남기는 코드 한 군데가 장애를 통째로 가리고 있었다.
장애가 났는데 알림이 안 울렸다. 지표도 정상이었고, 에러율 그래프는 평평했다.
사용자 문의로 알았다. 이미 몇 시간이 지난 뒤였다.
코드를 뒤져 보니 예외를 잡아서 로그만 찍고 넘어가는 곳이 있었다. 그것도 경고 수준으로. 아무도 안 보는 로그였고, 지표에는 한 줄도 안 잡혔다.
시스템이 조용하다고 정상인 건 아니다. 잘못을 말하지 못하게 만들어 두면, 잘못이 없는 상태와 구별이 안 된다.
그 구별 불가능은 우연이 아니다. 설계가 만들었다. 누군가 그 코드를 그렇게 쓰기로 했고, 그 결정 때문에 장애를 몇 시간 늦게 알았다.
좋은 의도로 넣은 catch 가 문제다
예외를 삼키는 코드는 대개 선의에서 나온다.
부수적인 기능 하나 실패했다고 전체가 실패하면 안 되니까. 통계 기록이 안 됐다고 주문을 취소할 순 없다. 그래서 잡아서 로그만 남기고 넘어간다.
여기까진 맞는 판단이다. 문제는 그다음.
그 실패가 어디에도 세어지지 않으면 얼마나 자주 나는지 아무도 모른다. 하루 한 번이면 무시해도 되지만 하루 만 번이면 큰일이다. 그런데 둘을 구별할 방법이 없다.
로그는 안 본다. 이게 현실이다. 정상일 때도 로그는 쏟아지고, 경고 수준 메시지는 그 안에 파묻힌다. "로그에 남겼으니 괜찮다"는 사실상 "아무도 모르게 처리했다"는 말이다.
더 나쁜 경우도 있다. 예외를 잡고 기본값을 돌려주는 코드. 조회에 실패했는데 빈 목록을 돌려주면, 부른 쪽은 "데이터가 없구나" 하고 받아들인다. 실패가 정상적인 빈 결과로 둔갑한다. 그 잘못된 데이터로 계산이 이어지고, 결과가 저장되기까지 한다.
트랜잭션과도 얽힌다. 예외를 삼키면 롤백도 안 일어나서 반쪽짜리 상태가 그대로 커밋된다.
삼킬 거면 한 줄 더
삼킬 거면 세어라:
try { sendStats(); }
catch (Exception e) { log.warn('실패', e); }
아무도 안 보는 로그. 지표에도 안 잡힌다
· 삼키더라도 반드시 센다
try { sendStats(); }
catch (Exception e) {
log.warn('실패', e);
metrics.counter('stats_send_failed').increment(); // 이 줄
}
→ 이 카운터에 알림을 걸 수 있다
· 기본값으로 덮으면 실패가 정상으로 둔갑한다
catch (Exception e) { return emptyList(); }
실패는 실패로 표현한다 — 빈 목록과 구별되게
· 무엇을 삼킬지는 의식적으로 정한다
catch (Exception e) — 전부 잡는다. 예상 못 한 것까지
catch (TimeoutException e) — 예상한 것만 잡는다✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.
로그 대신 숫자로 남기기
삼키는 건 괜찮다. 세지 않는 게 문제다.
부수적 실패를 삼키는 판단은 옳을 때가 많다. 다만 삼킬 때마다 카운터를 올려야 한다. 그래야 빈도가 보이고, 임계값에 알림을 걸 수 있고, 추세도 읽힌다. 로그 한 줄과 카운터 한 줄. 차이가 정말 크다.
그다음은 실패를 정상으로 둔갑시키지 않는 것. 조회 실패와 빈 결과는 다른 상태다. 부른 쪽이 둘을 구별할 수 있게 표현해야 한다. 예외를 그대로 올리든, 성공과 실패를 함께 담는 결과 타입을 쓰든.
무엇을 잡을지 좁히는 것도 있다. 모든 예외를 잡으면 예상 못 한 것까지 삼킨다. 널 참조나 타입 오류 같은 진짜 버그가 "일시적 실패"로 처리된 채 몇 달씩 숨어 있을 수 있다. 예상한 종류만 잡고 나머지는 위로 올려 보내는 게 맞다.
로그 수준을 의미대로 쓰는 것도 빠질 수 없다. 정말 무시해도 되는 건 정보, 조사가 필요한 건 경고, 사람이 봐야 하는 건 오류. 전부 경고로 찍어 버리면 아무것도 구별이 안 된다.
조용한 실패를 찾는 법은 의외로 단순하다. 예외를 잡고 아무것도 안 하는 블록을 코드에서 검색하면 된다. 정적 분석 도구 대부분이 이걸 잡아 준다.
반대쪽 함정도 있다. 모든 실패를 알림으로 만들면 이번엔 아무도 알림을 안 본다. 알림 피로라는 문제인데, 결국 같은 자리에 도착한다. 중요한 신호를 놓친다.
다음 회고 전에 손볼 것
- 예외를 삼키는 곳마다 카운터를 올린다. 필요한 건 로그 한 줄이 아니라 지표 한 줄이다.
- 그 카운터에는 임계값 알림이 붙어 있어야 한다. 빈도가 갑자기 뛰면 알아야 하니까.
- 빈 목록과 조회 실패는 다른 상태다. 실패를 기본값으로 덮으면 안 된다.
- 전부 잡으면 진짜 버그까지 삼킨다. 잡는 예외의 범위는 좁게.
- 전부 경고로 찍으면 아무것도 구별이 안 된다. 로그 수준은 의미대로 쓴다.
- 예외를 잡고 아무것도 안 하는 블록은 정적 분석으로 찾을 수 있다.
- 부수적 기능의 실패율도 대시보드에 올려 두자. 안 보이면 없는 거나 마찬가지다.
- 장애 회고에서 "왜 늦게 알았나"는 꼭 물어야 한다. 답은 대개 지금 이 이야기다.
- 부분 실패는 따로 센다. 열 개 중 세 개가 실패해도 요청 전체가 성공으로 끝나면 성공률 지표에 안 잡힌다.
- 재시도 끝에 성공하면 최종 결과는 성공이다. 하지만 첫 시도가 실패했다는 건 중요한 신호라서, 재시도 횟수도 따로 세는 게 좋다.
우리 코드의 조용한 catch 찾기
조용한 실패를 찾는다:
· 코드에서 삼키는 곳을 찾는다
예외를 잡고 아무것도 안 하는 블록
잡아서 로그만 찍는 블록
정적 분석 도구가 대부분 잡아 준다
· 각 지점에 카운터가 있는가
로그만 있고 지표가 없으면 아무도 모른다
· 지표는 있는데 알림이 없는 것
대시보드에만 있는 지표는 야간에 무력하다
· 부분 실패가 집계되는가
열 개 중 세 개 실패인데 요청은 성공으로 끝나면
성공률 지표에 안 잡힌다
· 재시도로 가려진 실패
재시도 횟수를 따로 세면 첫 시도 실패가 보인다핵심 한 줄시스템이 조용한 것과 문제가 없는 것은 다르다. 삼킬 거면 세라.