내부 private repo 작업을 바탕으로 썼다. 외부 접근이 불가한 저장소라 링크 없이, 조직·시스템·사람을 식별할 수 있는 정보는 걷어내고 구조만 남긴다.

다른 조직이 폐기 예정으로 두었던 콘텐츠 관리 시스템(CMS) API 가 오전 11시 남짓에 닫혔고, 그 API 를 서버에서 직접 호출하던 우리 랜딩 페이지는 그 즉시 “현재 오류를 확인 중입니다” 화면이 됐다. 우리가 그걸 안 것은 오후 2시 직전, 사업 부서의 신고를 받고서였다. 두 시간 반 동안 우리 쪽 어떤 대시보드도, 어떤 알림 채널도 조용했다.

닫히기 전에 관측이 두 번 신호를 보냈는데 왜 사람이 그걸 놓쳤는지, 왜 우리 호출이 어느 소비자 목록에도 없었는지는 관측은 두 번 알려줬다, 틀린 건 번역이었다에 적었다. 그 글은 조직 사이에서 벌어진 일의 기록이고, 마지막에 규칙 하나를 남겼다. “실패를 삼키는 대체 화면에는 로그를 붙인다.” 이 글은 그 한 줄을 코드 쪽에서 파고든다. 왜 우리 코드에는 그 로그가 없었는가.

핫픽스 자체는 상수 하나를 새 주소로 바꾸는 것이었다. 리뷰에서 동료가 남긴 코멘트가 이 글의 출발점이다.

이번 장애가 두 시간 넘게 신고로만 드러난 이유가 CMS 조회 실패를 아무 데도 남기지 않는 구조인데, 실패 분기에서 logger.error 한 줄만 남겨두면 다음 전환 때는 알림으로 먼저 잡을 수 있지 않을까요.

맞는 말이었다. 그런데 그 한 줄이 왜 없었는지 따라가 보니, 누가 빠뜨려서가 아니라 있다고 믿을 만한 코드가 이미 충분히 있어서였다.

TL;DR

  • 실패를 처리하는 코드와 실패를 알리는 코드는 다르다. 친절한 fallback 화면은 사용자에게는 배려지만, 운영자에게는 침묵이다.
  • 실패를 분류한 타입이 있어도 그것을 소비하는 곳이 없으면 문서일 뿐이다. 이 페이지는 실패를 네 종류로 나눠 반환했고, 호출자는 그걸 버렸다.
  • HTTP 200 으로 내려가는 오류 화면은 상태 코드 기반 모니터링에 잡히지 않는다. 서버 로그를 남기거나, 응답 본문을 보는 검사가 있어야 한다.
  • PR 체크리스트의 “에러 로그 수집” 항목을 “기존 분기 그대로”로 통과시킨 건 나였다. 항목이 묻는 게 무엇인지 되물었어야 했다.

코드는 실패를 잘 다루고 있었다

페이지는 Next.js Pages Router 의 getServerSideProps 에서 외부 CMS 를 직접 호출했다. 호출 함수를 줄이면 이렇다.

type FetchFailure =
  | { kind: 'http'; httpStatus: number }
  | { kind: 'api_meta'; apiErrorCode?: string | null; apiMessage?: string | null }
  | { kind: 'invalid_payload' }
  | { kind: 'exception'; message: string };

type FetchResult =
  | { ok: true; data: CmsContent }
  | { ok: false; error: FetchFailure; requestUrl: string };

export const fetchCmsContent = async (contentId: string): Promise<FetchResult> => {
  const requestUrl = buildUrl(contentId);
  try {
    const res = await fetch(requestUrl, {
      cache: 'no-store',
      signal: AbortSignal.timeout(3_000),
    });
    if (!res.ok) return { ok: false, requestUrl, error: { kind: 'http', httpStatus: res.status } };
    // ... meta 검사, payload 검사
    return { ok: true, data };
  } catch (error) {
    return { ok: false, requestUrl, error: { kind: 'exception', message: String(error) } };
  }
};

리뷰에서 이 함수를 보면 흠잡을 데가 없다. 예외를 삼키지 않고 판별 union(discriminated union)으로 돌려준다. HTTP 실패, API 가 자기 규약으로 알리는 실패, 형태가 깨진 응답, 네트워크 예외를 구분한다. 무한 대기를 막는 타임아웃이 있고, 실패한 요청 URL 까지 함께 넘긴다. “예외 처리가 잘 되어 있는가”라는 질문에 이 함수는 예라고 답한다.

호출하는 쪽은 이렇다.

const cmsResult = await fetchCmsContent(contentId);

if (!cmsResult.ok) {
  return {
    props: {
      pageMeta: { title: FALLBACK_TITLE },
      cms: { ok: false as const },
    },
  };
}

여기서 모든 것이 사라진다. error.kind 도, httpStatus 도, requestUrlprops 로 내려가지 않는다. 클라이언트는 cms.ok === false 하나만 받아서 오류 화면을 그린다. 서버는 이 응답을 HTTP 200 으로 내려보낸다.

네 종류로 분류한 실패는 이 if 문 안에서 한 종류로 접혔고, 그 한 종류마저 어디에도 기록되지 않았다. 타입 정의는 정확했지만 그 타입을 읽는 코드가 없었다. 실패 분류는 컴파일러만 아는 사실이었다.

왜 아무 알림도 없었나

이 구조에서 외부 API 가 죽었을 때 무슨 일이 일어나는지 순서대로 보면 침묵의 이유가 드러난다.

  1. fetch 가 404 를 받는다. res.ok 가 false 이므로 { kind: 'http', httpStatus: 404 } 를 돌려준다. 예외가 던져지지 않는다.
  2. getServerSideProps 는 정상 종료한다. 오류 상태의 props 를 만들었을 뿐, 실패한 것이 아니다. Next.js 입장에서 이 렌더는 성공이다.
  3. 응답은 200 으로 나간다. 로드밸런서, 애플리케이션 성능 모니터링(APM), 상태 코드 기반 알림 규칙은 아무것도 이상하게 보지 않는다.
  4. 브라우저는 오류 화면을 그린다. 클라이언트 에러 추적 도구는 JS 예외가 없으므로 아무것도 보고하지 않는다.
  5. cache: 'no-store' 라 캐시된 정상 응답이 남아 있을 여지도 없다. 제거 배포 직후부터 모든 사용자가 즉시 오류 화면을 본다. 이건 의도한 설계였고 그 자체로는 맞다. 다만 “즉시 반영”이 “즉시 감지”를 뜻하지는 않았다.

각 단계는 다 정상이다. 예외를 삼킨 곳도 없고, 잘못된 fallback 도 없다. 그런데 정상 단계를 다섯 개 이으면 결과는 두 시간 반의 침묵이다. 실패는 처리됐고, 처리됐기 때문에 사라졌다.

Google SRE 책의 모니터링 장은 네 가지 황금 신호 중 “에러”를 설명하면서 이렇게 나눈다. 명시적 에러(HTTP 500), 암묵적 에러(HTTP 200 이지만 잘못된 내용), 정책상 에러(1초 넘긴 응답). 이번 건은 교과서적인 암묵적 에러다. 상태 코드만 보는 모니터링은 정의상 이걸 못 본다.

로그는 있었다, 다른 곳에

더 불편한 사실이 하나 있다. 같은 디렉터리에 logger.error 호출이 이미 있었다. 장애 바로 전날, 이 페이지의 동영상 모듈이 재생 실패로 예외를 흘리는 문제를 고치면서, 재생 불가는 error, 재생 중단은 warn 으로 나눠 남기도록 했다.

즉 이 페이지는 동영상 하나가 안 나올 때는 로그를 남기고, 페이지 전체가 안 나올 때는 남기지 않았다. 로깅이 없어서가 아니라, 로깅이 “그때 아팠던 곳”에만 붙어 있었다. 동영상 예외는 에러 추적 도구에 잡음을 만들어서 눈에 띄었고, 그래서 고쳤다. 데이터 조회 실패는 애초에 아무 잡음도 안 만들었기 때문에 고칠 계기가 없었다.

관측성이 사후에, 증상이 있었던 자리에만 덧붙는 방식으로 자라면 이런 구멍이 남는다. 잡음을 냈던 실패는 로그가 생기고, 조용했던 실패는 계속 조용하다. 조용했다는 것이 곧 안전했다는 뜻이 아니다. 이 페이지의 가장 중요한 실패 경로는 가장 조용한 경로였다.

체크리스트에 체크한 사람은 나였다

핫픽스 PR 템플릿에는 이런 항목이 있다.

예외 처리 및 에러 로그 수집이 잘 구현되어 있어요.

나는 체크하고 괄호 안에 “기존 실패 분기 그대로”라고 적었다. 거짓은 아니다. 기존 분기는 정말 그대로였고, 예외 처리는 위에서 본 대로 훌륭했다. 그런데 항목은 두 가지를 묻고 있었다. 예외 처리, 그리고 에러 로그 수집. 나는 앞 절반을 보고 뒷 절반까지 통과시켰다.

이건 체크리스트의 흔한 실패 방식이다. 항목이 하나의 문장으로 묶여 있으면 그중 눈에 띄는 절반만 확인하고 넘어가게 된다. 이번 장애의 원인이 바로 “에러 로그 수집 없음”이었는데, 그 장애의 핫픽스 PR 에서 “에러 로그 수집 잘 되어 있음”에 체크했다. 리뷰어가 그 모순을 봤다.

리뷰어가 본 것은 코드가 아니었다. logger.error 가 없다는 사실은 diff 어디에도 안 나온다. 리뷰어가 본 것은 장애 타임라인이었다. 11시에 깨졌고 2시에 알았다는 사실에서 거꾸로 “왜 두 시간 반 동안 아무도 몰랐는가”를 물었고, 그 답이 코드 구조에 있었다. diff 를 보는 리뷰와 사건을 보는 리뷰는 다른 것을 잡는다.

고친 것

앞 글에서 조직 쪽 대책으로 소비자 인벤토리를 등록제로 만들자고 했다. 필요한 일이지만 그건 상대가 다음에도 우리를 기억해 주는 것에 기대는 방어다. 코드 안에 두는 방어는 그렇지 않다. 실패 분기에 로그 한 줄이 있으면 상대가 우리를 잊어도 우리는 알고, 신고보다 두 시간 먼저 알면 그 두 시간 동안 우리가 먼저 연락할 수 있다. 외부 의존이 깨지는 것은 못 막아도, 깨졌을 때 누가 먼저 아는가는 우리가 정한다.

핫픽스는 상수 하나를 바꾸는 것으로 끝냈다. 장애 중에 범위를 넓히지 않았다. 로깅은 후속 티켓으로 분리했고, 내용은 리뷰어가 제안한 그대로다.

if (!cmsResult.ok) {
  logger.error('getServerSideProps', 'CMS 조회 실패 — fallback 화면으로 응답', {
    kind: cmsResult.error.kind,
    httpStatus: cmsResult.error.kind === 'http' ? cmsResult.error.httpStatus : undefined,
    requestUrl: cmsResult.requestUrl,
    contentId,
  });
  return { props: { /* 기존과 동일 */ } };
}

한 줄이라고 했지만 무엇을 남길지는 정해야 한다. 기준은 다음 장애 때 이 로그 하나로 첫 판단을 내릴 수 있는가였다.

  • kind — 네 종류 중 무엇인지. HTTP 실패와 형태 깨짐은 대응이 다르다.
  • httpStatus — 404 면 경로 문제, 5xx 면 상대 장애, 401/403 이면 인증. 이 숫자 하나로 연락할 곳이 갈린다.
  • requestUrl — 어느 호스트, 어느 경로를 쳤는지. 이번 장애라면 로그를 열자마자 “옛 경로를 치고 있다”가 보였을 것이다.
  • contentId — 특정 콘텐츠만 깨졌는지 전부인지.

이 메타는 이미 fetchCmsContent 가 만들어 주던 것이다. 새로 계산할 게 없다. 호출자가 버리던 것을 로그로 흘려보내기만 하면 된다. 실패 분류를 타입으로 정의한 사람의 의도가 장애를 한 번 겪고서야 소비처를 얻은 셈이다.

로그 기반 알림은 기존 규칙에 태운다. 이 로거는 이미 에러 추적 도구로 이어져 있다. 새 파이프라인을 만드는 게 아니라 있는 파이프라인에 신호를 넣는 것이고, 알림 규칙이 실제로 이 로그를 집는지는 배포 뒤 한 번 일부러 실패시켜 확인할 예정이다.

배운 것

처리와 관측은 별개의 요구사항이다. “실패하면 어떻게 보여줄 것인가”와 “실패했음을 누가 어떻게 알 것인가”는 다른 질문이고, 하나에 답했다고 다른 하나가 따라오지 않는다. 좋은 fallback 화면은 오히려 두 번째 질문을 잊게 만든다. 사용자가 불편하지 않으니 운영자도 급하지 않다.

분류한 실패에는 소비자가 있어야 한다. 판별 union 으로 실패를 네 종류로 나눴다면, 어디선가 switch (error.kind) 를 하거나 최소한 kind 를 기록해야 한다. 소비자 없는 분류는 타입 체커 안에서만 사는 문서다. 함수를 설계할 때 “이 실패 정보를 누가 읽는가”를 같이 정하면 호출자가 그냥 버리는 일이 줄어든다.

200 fallback 은 모니터링의 사각지대다. 오류 화면을 200 으로 내려보내기로 했다면, 그 순간 상태 코드 알림은 포기한 것이다. 대신 서버 로그, 응답 본문을 확인하는 합성 검사, 또는 오류 화면 렌더 시점의 클라이언트 이벤트 중 하나는 있어야 한다. 셋 다 없으면 사용자 신고가 모니터링이다.

관측성은 아팠던 자리에만 자라지 않게 한다. 잡음을 냈던 실패에 로그가 붙는 건 자연스럽다. 그래서 주기적으로 “이 페이지에서 가장 치명적인 실패 경로는 어디고, 거기 로그가 있는가”를 잡음과 무관하게 물어야 한다. 이번엔 가장 치명적인 경로가 가장 조용했다.

체크리스트 항목은 절반만 읽으면 통과된다. “예외 처리 및 에러 로그 수집”은 두 항목이었다. 앞으로 이런 항목에는 어느 절반을 확인했는지 적으려 한다. “예외 처리 O, 로그 수집 X, 후속 티켓” 처럼. 그러면 리뷰어가 모순을 찾을 필요 없이 내가 먼저 보게 된다.

타임라인을 보는 리뷰가 있다. diff 만으로는 “왜 두 시간 반 걸렸는가”를 물을 수 없다. 장애 핫픽스 리뷰에는 발생 시각과 인지 시각을 적어 두는 게 좋다. 그 간격 자체가 리뷰 대상이다.

관련 글

며칠 전 에러 메시지가 두 가지 일을 하고 있었다에서 “로그엔 원문, 화면엔 없어야” 한다고 썼다. 이번 건은 정확히 그 반대였다. 화면엔 있는데 로그엔 없었다. 두 글을 합치면 원칙은 하나다. 실패 정보는 로그에 먼저 도착해야 하고, 화면은 그 다음이다.

Feature Flag 장애가 흰 화면을 만들 때에서는 조회 실패를 “비활성”과 같은 상태로 취급해 화면이 사라진 사례를 다뤘다. 구조는 같다. 실패가 정상 상태 중 하나로 접히면 실패가 보이지 않는다. 그 글은 화면 쪽 대책이었고, 이 글은 로그 쪽 대책이다.

참고