Why NextWhy Next
목록으로
💻 개발6분 읽기

그냥 staging에서 끄면 되잖아 - 시끄러운 알림이 감춘 진짜 버그

staging에서 매일 뜨던 Google Play 404 알림. 소스를 끄면 조용해지지만, 그것과 실패 모드를 고치는 것은 다른 문제였다. 404는 재시도해도 소용없는 영구 오류인데 5xx와 한데 뭉쳐 재시도되고 있었다는 이야기.

핵심 요약

큐에 태워 재시도하는 잡에서 외부 API가 404를 던지면, 그 404는 재시도해도 좋아지지 않는 영구 조건인 경우가 많다. 그런데 클라이언트가 404를 5xx·429와 한 예외로 뭉쳐 던지면 재시도 예산을 태우고 알림을 채운다. 해법은 응답 코드를 가장 먼저 보는 클라이언트 경계에서 영구 오류(4xx)와 일시 오류(5xx·429·타임아웃)를 나누는 것이다. 그리고 시끄러운 알림 앞에서 '소스를 끄는 것'과 '실패 모드를 고치는 것'은 다른 문제다. 전자는 이 알림 하나를, 후자는 그 실패가 돌아올 모든 경우를 지운다.

목차

매일 같은 시각, 같은 알림이 왔다. staging에서 구독 대사(reconcile) 잡이 실패했다는 ERROR였다. 새벽 3시 17분에 크론이 돌고, 3분 뒤 알림이 뜬다. 스택 트레이스는 늘 한 줄로 시작했다. Google Play Voided Purchases API returned 404. 재시도를 세 번 하고, 세 번 다 같은 404로 죽고, 잡은 DLQ로 떨어지고, 우리 슬랙에는 빨간 알림이 하나 더 쌓였다.

이건 장애가 아니었다. 아무것도 망가지지 않았다. 그냥 시끄러웠다. 그리고 시끄러운 알림을 어떻게 다룰지 결정하는 과정에서, 나는 "끄는 것"과 "고치는 것"이 서로 다른 문제라는 걸 다시 배웠다.

왜 404였나

Voided Purchases API는 환불·취소된 구매 목록을 돌려주는 구글 플레이 엔드포인트다. 우리는 이걸로 몰래 환불받고 앱은 계속 쓰는 경우를 매일 대사해서 걸러낸다. 그런데 이 엔드포인트가 404를 던지는 건 "데이터가 없다"는 뜻이 아니다. voidedpurchases.list에서 404는 해당 애플리케이션(패키지)을 찾을 수 없거나, 서비스 계정이 그 패키지에 접근할 권한이 없다는 뜻이다.

staging 서비스 계정에는 프로덕션 패키지에 대한 플레이 콘솔 접근이 없다. 있을 이유도 없다. staging은 실제 스토어 환불 트래픽이 흐르는 환경이 아니다. 그러니 staging에서 이 API를 부르면 404가 난다. 오늘도, 내일도, 설정을 바꾸지 않는 한 영원히. 이건 잠깐 끊겼다 붙는 일시적 장애가 아니라 환경이 만들어내는 영구 조건이다.

여기서 핵심이 갈린다. 일시적 장애라면 재시도가 맞다. 네트워크가 깜빡이거나 상대 서버가 잠깐 5xx를 뱉으면, 몇 초 뒤 다시 부르면 된다. 하지만 영구 조건은 재시도로 좋아지지 않는다. staging 서비스 계정에 없는 권한이 3초 뒤에 생길 리 없다. 그러니 이 404를 세 번 재시도하는 건 순수한 낭비였고, 그 끝에 오는 ERROR 알림은 순수한 노이즈였다.

코드는 왜 재시도했나

원인은 클라이언트가 응답을 분류하는 방식에 있었다. listVoidedPurchases는 200이 아닌 모든 응답을 하나로 뭉쳐서 던지고 있었다.

if (!res.ok) {
  throw AppHttpException.serviceUnavailable(
    `Google Play Voided Purchases API returned ${res.status}`,
  );
}

serviceUnavailable은 우리 큐가 "재시도 대상"으로 읽는 에러다. 그러니 404든 500이든 429든, 여기서 나온 에러는 전부 세 번 재시도되고 전부 알림으로 끝난다. 문제는 이 세 상태의 의미가 전혀 다르다는 것이다. 500과 429는 "지금은 안 되지만 곧 될 수도 있다"이고, 404는 "이 조건에선 앞으로도 안 된다"이다. 하나의 예외로 뭉치는 순간, 재시도해봐야 소용없는 에러가 재시도 파이프라인에 올라탄다.

재미있는 건, 이미 정답이 같은 파일 안에 있었다는 점이다. 같은 클라이언트의 getSubscriptionPurchase는 진작에 404를 따로 분기하고 있었다.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "purchase not found");
}

한 메서드는 404의 의미를 존중하고, 다른 메서드는 뭉개고 있었다. 선례는 코드 안에 있었는데 새 메서드가 그걸 따르지 않았을 뿐이다. 이런 비대칭은 대개 메서드를 각각 다른 날, 다른 관심사로 짤 때 생긴다.

고치기: 경계에서 나눈다

수정은 두 층에서 이뤄졌고, 둘 다 이미 있던 패턴을 따랐다.

먼저 클라이언트 경계에서 404를 분리했다. 404는 "찾을 수 없음"이라는 뜻을 가진 GooglePlayApiError(404)로 던지고, 나머지 5xx·429·타임아웃은 그대로 serviceUnavailable로 남겼다. 진짜 재시도가 필요한 것만 재시도 대상으로 유지한 것이다.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx·429·타임아웃 = 진짜 재시도 대상 */);
}

그다음 대사 서비스가 이 404를 어떻게 받아들일지 정했다. 이미 서비스에는 "서비스 계정 키가 없으면 조용히 건너뛴다"는 흐름(isConfigured === false)이 있었다. 404도 본질적으로 같은 상황이다. 이 환경에선 부를 수 없는 API다. 그래서 같은 자리에 태웠다.

try {
  const voided = await client.listVoidedPurchases();
  // ... 대사 진행
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("voided purchases 404 - 이 환경에선 접근 불가, 건너뜀");
    return { skippedAppNotFound: true };
  }
  throw e; // 404가 아니면 그대로 재던져 정상 재시도
}

catch에서 404만 삼키고 warn 로그를 남긴 뒤 skip 통계를 돌려준다. 재던지지 않으니 재시도도, DLQ도, ERROR 알림도 없다. 반대로 404가 아닌 에러는 그대로 재던져서 원래의 재시도 로직이 정상 작동한다. 시끄러운 것만 조용해지고, 진짜 문제는 여전히 시끄럽게 남는다.

여기에 테스트를 붙였다. 클라이언트가 404에서 GooglePlayApiError를 던지는지, 대사가 404를 건너뛰는지, 그리고 404가 아닌 에러는 여전히 재던지는지. 마지막 케이스가 중요하다. 404를 조용히 만드느라 실수로 500까지 삼키면, 진짜 장애를 못 보게 된다.

"그냥 staging에서 끄면 되잖아?"

PR을 올리고 나서 좋은 질문을 받았다. staging에서 애초에 이 잡을 안 돌리면 되는 것 아닌가? 맞는 직관이다. staging엔 실제 환불 트래픽이 없으니, API를 부를 이유 자체가 없다. 그래서 확인해봤다.

끄는 건 생각보다 간단하지 않았다. staging에서 이 기능을 끄는 자연스러운 스위치는 서비스 계정 키의 유무(isConfigured)인데, 그 키는 이 잡 전용이 아니었다. 앱 결제 영수증 검증, 구글 실시간 알림(RTDN) 웹훅 처리, 구독 대사 프로세서. 구독 검증 전반이 같은 키에 물려 있었다. staging에서 결제·구독을 테스트하려면 그 키가 있어야 한다. 키를 빼서 잡을 끄면, 정작 테스트해야 할 것들이 같이 꺼진다.

그럼 이 잡만 끄는 전용 토글을 만들면 되지 않나. 그런 토글은 없었다. GOOGLE_VOIDED_RECONCILE_ENABLED 같은 스위치를 새로 넣어야 한다는 뜻이다. 즉 "staging만 끈다"도 결국 코드와 환경 변수를 건드리는 작업이다. 공짜로 끄는 방법은 처음부터 없었다.

끄는 것과 고치는 것은 다른 문제다

하지만 진짜 요점은 난이도가 아니었다. 토글을 만드는 게 쉬웠다 해도, 그건 404 수정을 대체하지 못한다. 두 접근이 서로 다른 문제를 풀기 때문이다.

  • 404를 재시도 대상에서 빼는 것은 근본 방어다. 어떤 환경이든, staging이든, 설정이 틀어진 프로덕션이든, 누군가 토글을 깜빡한 미래든, 재시도해도 소용없는 404가 알림 폭주로 번지지 않게 막는다.
  • staging에서 잡을 끄는 것은 운영 최적화다. "이 환경엔 부를 이유가 없으니 호출 자체를 생략한다"는, 그 위에 얹으면 좋은 선택이다.

순서가 중요하다. 404 방어를 두고 토글을 더하는 건 괜찮다. 그러나 토글만 넣고 404 방어를 빼면, 언젠가 프로덕션에서 설정이 틀어져 404가 나는 날 알림이 다시 터진다. 그때는 "staging이니까 무시"라고 넘길 수도 없다. 소스를 끄는 건 지금 보이는 알림 하나를 지우고, 실패 모드를 고치는 건 그 알림이 다시 나올 모든 경우를 지운다.

시끄러운 알림을 만나면 손이 먼저 소스로 간다. 이 잡을 끄자, 이 알림 규칙을 지우자, 이 환경에서 빼자. 자연스러운 충동이고, 때로는 그게 맞다. 하지만 끄기 전에 한 번 물어야 한다. 나는 지금 알림을 끄는 건가, 아니면 알림이 알려준 실패 모드를 고치는 건가. 전자는 이 알림을 조용하게 만들고, 후자는 이 실패가 다른 모습으로 돌아오는 걸 막는다.

경계에서 물어야 할 것

이 이야기는 구글 플레이 API만의 것이 아니다. 외부를 호출하고 그 실패를 큐에 태워 재시도하는 모든 코드가 같은 갈림길에 선다. 재시도 정책을 큐 설정에서 손보기 전에, 클라이언트 경계에서 먼저 물어야 한다.

이 에러는 재시도로 나아지는가? 5xx·429·타임아웃은 대개 그렇다. 지금은 안 되어도 곧 될 수 있다. 4xx는 대개 아니다. 404, 403, 400은 조건이 바뀌기 전엔 같은 답을 준다. 이 둘을 하나의 예외로 뭉치면, 재시도해봐야 소용없는 실패가 재시도 예산을 태우고 알림을 채운다. 나누는 자리는 큐가 아니라, 응답 코드를 가장 먼저 보는 클라이언트 경계다.

그리고 고치기 전에 코드베이스를 한 번 훑어보는 것도 잊지 말자. 우리 경우엔 같은 파일의 옆 메서드가 이미 404를 옳게 다루고 있었다. 새로 발명할 필요가 없었다. 답이 이미 코드 안에 있는데 새 코드가 그걸 따르지 않은 것뿐인 경우가, 생각보다 많다.

자주 묻는 질문

백그라운드 잡이 404로 계속 재시도하고 실패 알림이 반복돼요. 어떻게 하나요?

먼저 그 404가 일시적인지 영구적인지 구분해야 합니다. 리소스가 그 환경에 애초에 없거나 접근 권한이 없어서 나는 404라면 재시도해도 같은 결과입니다. 이런 경우는 클라이언트가 응답 코드를 처음 보는 지점에서 404를 별도 에러 타입으로 분기해, 잡이 재던지지 않고 warn 로그와 함께 건너뛰도록(graceful skip) 만드는 게 맞습니다. 그래야 재시도·DLQ·ERROR 알림이 발생하지 않습니다.

일시적 오류와 영구적 오류를 무엇으로 구분하나요?

대체로 5xx·429·타임아웃은 일시적이라 재시도 대상입니다. 지금은 안 되어도 곧 될 수 있기 때문입니다. 반면 404·403·400 같은 4xx는 요청이나 조건 자체가 바뀌기 전엔 같은 답을 주므로 재시도해도 소용없습니다. 이 구분은 큐의 재시도 정책이 아니라, 응답 코드를 가장 먼저 보는 클라이언트 경계에서 하는 것이 좋습니다.

관련 글