알림도 안 울린 장애, 범인은 로그만 찍는 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) — 예상한 것만 잡는다

✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.

로그 대신 숫자로 남기기

삼키는 건 괜찮다. 세지 않는 게 문제다.

부수적 실패를 삼키는 판단은 옳을 때가 많다. 다만 삼킬 때마다 카운터를 올려야 한다. 그래야 빈도가 보이고, 임계값에 알림을 걸 수 있고, 추세도 읽힌다. 로그 한 줄과 카운터 한 줄. 차이가 정말 크다.

그다음은 실패를 정상으로 둔갑시키지 않는 것. 조회 실패와 빈 결과는 다른 상태다. 부른 쪽이 둘을 구별할 수 있게 표현해야 한다. 예외를 그대로 올리든, 성공과 실패를 함께 담는 결과 타입을 쓰든.

무엇을 잡을지 좁히는 것도 있다. 모든 예외를 잡으면 예상 못 한 것까지 삼킨다. 널 참조나 타입 오류 같은 진짜 버그가 "일시적 실패"로 처리된 채 몇 달씩 숨어 있을 수 있다. 예상한 종류만 잡고 나머지는 위로 올려 보내는 게 맞다.

로그 수준을 의미대로 쓰는 것도 빠질 수 없다. 정말 무시해도 되는 건 정보, 조사가 필요한 건 경고, 사람이 봐야 하는 건 오류. 전부 경고로 찍어 버리면 아무것도 구별이 안 된다.

조용한 실패를 찾는 법은 의외로 단순하다. 예외를 잡고 아무것도 안 하는 블록을 코드에서 검색하면 된다. 정적 분석 도구 대부분이 이걸 잡아 준다.

반대쪽 함정도 있다. 모든 실패를 알림으로 만들면 이번엔 아무도 알림을 안 본다. 알림 피로라는 문제인데, 결국 같은 자리에 도착한다. 중요한 신호를 놓친다.

다음 회고 전에 손볼 것

  1. 예외를 삼키는 곳마다 카운터를 올린다. 필요한 건 로그 한 줄이 아니라 지표 한 줄이다.
  2. 그 카운터에는 임계값 알림이 붙어 있어야 한다. 빈도가 갑자기 뛰면 알아야 하니까.
  3. 빈 목록과 조회 실패는 다른 상태다. 실패를 기본값으로 덮으면 안 된다.
  4. 전부 잡으면 진짜 버그까지 삼킨다. 잡는 예외의 범위는 좁게.
  5. 전부 경고로 찍으면 아무것도 구별이 안 된다. 로그 수준은 의미대로 쓴다.
  6. 예외를 잡고 아무것도 안 하는 블록은 정적 분석으로 찾을 수 있다.
  7. 부수적 기능의 실패율도 대시보드에 올려 두자. 안 보이면 없는 거나 마찬가지다.
  8. 장애 회고에서 "왜 늦게 알았나"는 꼭 물어야 한다. 답은 대개 지금 이 이야기다.
  9. 부분 실패는 따로 센다. 열 개 중 세 개가 실패해도 요청 전체가 성공으로 끝나면 성공률 지표에 안 잡힌다.
  10. 재시도 끝에 성공하면 최종 결과는 성공이다. 하지만 첫 시도가 실패했다는 건 중요한 신호라서, 재시도 횟수도 따로 세는 게 좋다.

우리 코드의 조용한 catch 찾기

조용한 실패를 찾는다:

코드
· 코드에서 삼키는 곳을 찾는다
 예외를 잡고 아무것도 안 하는 블록
 잡아서 로그만 찍는 블록
 정적 분석 도구가 대부분 잡아 준다

· 각 지점에 카운터가 있는가
 로그만 있고 지표가 없으면 아무도 모른다

· 지표는 있는데 알림이 없는 것
 대시보드에만 있는 지표는 야간에 무력하다

· 부분 실패가 집계되는가
 열 개 중 세 개 실패인데 요청은 성공으로 끝나면
 성공률 지표에 안 잡힌다

· 재시도로 가려진 실패
 재시도 횟수를 따로 세면 첫 시도 실패가 보인다

핵심 한 줄시스템이 조용한 것과 문제가 없는 것은 다르다. 삼킬 거면 세라.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

배포·관측 글 16편 모두 보기 →