Sangcho.log
dev

catch가 삼킨 것 — TypeError: fetch failed에서 ENOTFOUND까지

12 min read|

홈 화면 한쪽에 외부 커뮤니티의 최신 글 목록을 띄우는 섹션이 있습니다. 서버 컴포넌트가 커뮤니티 페이지를 fetch해서 하이드레이션 JSON을 긁어오고, 1시간 캐시로 재배포 없이 갱신되는 구조입니다.

로컬에서는 잘 됩니다. 개발 서버에 올리면 그 섹션만 빈 상태(fallback)로 뜹니다.

에러는 안 납니다. 페이지도 정상입니다. 그 섹션만 조용히 비어 있습니다.

1. 코드부터 의심하는 건 대체로 틀렸다 — 그래도 배제는 해야 한다

"배포에서만 다르다"는 신호를 받았을 때 제일 먼저 한 일은 코드 쪽 후보를 하나씩 실측으로 지우는 것이었습니다. 추측으로 지우면 나중에 다시 의심하게 되니까요.

파서 드리프트. 3rd-party 사이트의 내부 JSON 구조에 의존하는 정규식이라, 저쪽이 필드 하나만 끼워 넣어도 매치가 0건이 되고 조용히 실패합니다. 실제로 전에 한 번 겪은 실패 모드입니다. 라이브 HTML을 받아 정규식을 직접 돌려봤습니다.

TOTAL MATCHES: 19
1 a1b2c3d4e5f6g7h8i9j0 2026-07-21T06:45:30.000Z 🔗 새 기능이 추가되었어요
2 k1l2m3n4o5p6q7r8s9t0 2026-07-09T08:41:03.000Z 🗂️ 이번 달 업데이트 안내

정상입니다.

TLS 체인. 사내 네트워크에서 TLS를 재서명하는 장비를 쓰면 Node만 막히고 브라우저는 통과하는 일이 있습니다. 반대로 중간 인증서 체인이 빠져 있으면 브라우저는 AIA로 보완하지만 Node는 안 합니다. 확인해 봤습니다.

0 s:/CN=community.acme.io
  i:/C=US/O=Let's Encrypt/CN=YE2
...
Verify return code: 0 (ok)

정품이고 체인도 완결입니다.

프로덕션 런타임. 로컬 dev 서버는 next dev고 배포는 standalone 빌드에 Alpine Node입니다. 런타임이 다르니 이것도 후보였습니다. 배포와 같은 방식으로 이미지를 빌드해서 그 안에서 직접 돌렸습니다.

$ docker run --rm --entrypoint node app:local -e "fetch('https://community.acme.io/FEED', ...)"
OK status 200 bytes 351327 ms 845

됩니다. 845ms니 3초 타임아웃에도 여유가 있습니다.

빌드타임 프리렌더. 정적 프리렌더되면 빌드 시점 결과가 그대로 박히니, 빌드 머신에서 실패했다면 fallback이 영구히 박혀 있을 수 있습니다. 상위 레이아웃이 headers()를 쓰고 있었고 빌드 로그에서도 전 라우트가 ƒ (Dynamic)이었습니다. 요청마다 실행됩니다.

여기까지 오면 남는 건 하나입니다. 코드는 문제없고, 그 환경에서만 네트워크가 안 된다.

2. 그런데 어느 네트워크 문제인지 알 방법이 없었다

문제의 함수는 이렇게 생겼습니다.

export async function fetchCommunityNews(): Promise<NewsItem[] | null> {
  try {
    return await getCachedNews();
  } catch {
    return null;
  }
}

의도는 명확합니다. async 서버 컴포넌트가 throw했을 때 하위 client ErrorBoundary가 그걸 잡는다는 보장이 없어서, 서버 컴포넌트 레벨에서 확정적으로 처리하려는 것이었습니다. 그 판단 자체는 맞습니다.

문제는 catch {}가 에러 객체를 바인딩조차 안 한다는 것입니다. DNS가 안 풀린 건지, 방화벽이 막은 건지, 3초 타임아웃에 걸린 건지 — 그걸 가르는 정보가 이 줄에서 완전히 소멸합니다.

증상은 셋 다 똑같습니다. 3초 뒤 throw → catch → null → fallback 렌더. 커뮤니티 쪽 접근 로그에도 안 남으니 "호출이 아예 안 되는 것 같다"로 보입니다.

원인을 좁히기 전에 로깅부터 고쳐야 했습니다.

} catch (e) {
  console.log('[warn] community news fetch failed:', (e as Error)?.name, (e as Error)?.message);
  return null;
}

여기서 함정이 하나 더 있었습니다. 이 프로젝트의 next.config에는 이런 설정이 있습니다.

compiler: {
  removeConsole: {
    exclude: ['log'],
  },
},

console.warn도 console.error도 빌드에서 제거됩니다. 살아남는 건 console.log뿐입니다. 진단 로그를 습관대로 console.warn으로 넣었다면, 배포하고 로그를 한참 뒤진 다음에야 "아무것도 안 찍히는데?"를 만났을 겁니다. 레벨은 메시지 앞에 [warn]으로 붙여서 표현했습니다.

배포하고 로그를 받았습니다.

[warn] community news fetch failed: TypeError fetch failed

3. TypeError: fetch failed는 원인이 아니다

이 문구를 원인으로 읽으면 안 됩니다. undici가 연결 계층의 실패를 전부 이걸로 감쌉니다. DNS 실패도, 커넥션 거부도, 인증서 검증 실패도 전부 같은 문장으로 나옵니다.

진짜 코드는 error.cause에 들어 있습니다. 그리고 제 로그는 name과 message만 찍고 있었습니다. 배포를 한 번 더 해야 했습니다.

다만 이 한 줄에서도 하나는 확실히 걷어냈습니다. 타임아웃은 아닙니다.

AbortSignal.timeout()이 발동하면 name이 TimeoutError로 나옵니다. TypeError라는 건 시간이 초과된 게 아니라 연결 자체가 성립하지 않았다는 뜻입니다. 3초라는 타임아웃 값을 만지작거릴 뻔했는데, 그쪽은 완전히 헛다리였습니다.

} catch (e) {
  // undici 는 연결 실패를 전부 `TypeError: fetch failed` 로 감싸므로 cause 까지 봐야 구분된다
  const cause = (e as { cause?: { code?: string; message?: string } })?.cause;
  console.log('[warn] community news fetch failed:',
    (e as Error)?.name, (e as Error)?.message,
    '| cause:', cause?.code ?? '-', cause?.message ?? '-');
  return null;
}

처음부터 cause를 찍었으면 배포 한 번으로 끝났을 일입니다. 이게 이 글에서 제일 실용적인 교훈입니다.

4. 중간에 나온 제안 — "rewrite로 프록시해서 피하면 안 되나?"

cause를 기다리는 동안 이런 제안이 나왔습니다.

그냥 next.config.js에서 rewrite로 프록시 걸어서 피하면 안 될까?

안 됩니다. 그리고 왜 안 되는지가 이 문제의 성격을 정확히 설명합니다.

Next의 외부 rewrite는 브라우저가 대상 호스트에 직접 붙는 게 아닙니다. 소스를 열어보면 이렇습니다.

// next/dist/server/lib/router-utils/proxy-request.js
async function proxyRequest(req, res, parsedUrl, upgradeHead, reqBody, proxyTimeout) {
  const HttpProxy = require('next/dist/compiled/http-proxy');

Next 서버 프로세스가 http-proxy로 대신 연결합니다. 지금 실패하고 있는 게 정확히 그 hop입니다 — 컨테이너에서 외부 호스트로 나가는 연결. 클라이언트 라이브러리가 undici에서 http-proxy로 바뀔 뿐, DNS 조회도 egress 경로도 그대로입니다.

[의도한 그림]
브라우저 ──> 우리 서버 ──> community.acme.io     ← 이 화살표가 실패 중
                          (rewrite가 바꿔주길 기대)

[실제]
브라우저 ──> 우리 서버 ──> community.acme.io     ← 같은 화살표. 그대로 실패
                          (프록시 구현만 다름)

전화를 거는 사람이 안 바뀝니다. 같은 사람이 같은 전화번호부를 보고 있으니 결과도 같습니다.

그럼 브라우저가 직접 부르게 하면? 그건 화살표를 진짜로 옮기는 거라 논리적으로는 맞습니다. 두 가지가 막습니다.

$ curl -s -I -H "Origin: https://dev-portal.acme.io" https://community.acme.io/FEED
HTTP/1.1 200 OK

Access-Control-Allow-Origin이 없습니다. CORS로 차단됩니다. 그리고 설령 열려 있어도 388KB짜리 HTML을 사용자 브라우저가 받아 정규식으로 파싱해야 합니다. 홈 화면 초기 로딩에 그걸 얹을 수는 없습니다.

호스트명이 그대로인 한, 서버사이드 프록시로는 이 벽을 못 넘습니다.

5. cause가 도착했다

[warn] community news fetch failed: TypeError fetch failed
  | cause: ENOTFOUND getaddrinfo ENOTFOUND community.acme.io

getaddrinfo ENOTFOUND. 이름을 못 풉니다. 방화벽에 막힌 것도, 인증서가 문제인 것도 아니고, 그 앞 단계인 DNS 조회에서 끝났습니다.

여기서 각 호스트가 어떻게 해석되는지 나란히 놓아봤습니다.

호스트 해석 결과
dev-api.acme.io 10.42.7.11 (사설)
dev-portal.acme.io 10.42.7.12 (사설)
community.acme.io CNAME → customdomain.pagehost.example → 13.209.88.31 (외부 SaaS)

한눈에 보입니다. acme.io 아래 호스트들은 전부 내부 사설 IP를 가리키는데, community만 혼자 외부 SaaS를 가리킵니다. 커뮤니티는 우리가 만든 게 아니라 외부 서비스에 도메인만 연결해 쓰는 것이었거든요.

그리고 배포 환경의 VNet에는 acme.io private DNS zone이 링크돼 있었습니다.

Private DNS zone은 링크되는 순간 그 이름공간에 대해 authoritative가 됩니다. 존에 없는 레코드를 만나도 공용 DNS로 넘겨주지 않고, 그대로 NXDOMAIN을 돌려줍니다.

컨테이너: "community.acme.io 주소 좀"
        ↓
private zone: "acme.io는 내가 담당인데, community라는 이름은 내 목록에 없어."
        ↓
        NXDOMAIN  →  ENOTFOUND
        (바깥에 물어볼 기회 자체가 없음)

내부 호스트를 위해 존을 만들면서, 유일하게 바깥을 가리키는 서브도메인이 같이 삼켜진 겁니다. 다른 호스트들은 다 존에 등록돼 있으니 아무 문제 없이 동작했고, 그래서 acme.io가 통째로 안 되는 게 아니라 딱 하나만 안 되는 형태로 나타났습니다.

로컬에서 항상 성공한 이유도 같습니다. 개발 PC는 그 private zone을 안 보고 공용 DNS를 씁니다. 거기엔 CNAME이 정상적으로 등록돼 있습니다.

고치는 법

애플리케이션 코드에는 손댈 게 없습니다. private zone에 레코드 한 줄입니다.

community  CNAME  customdomain.pagehost.example.

A 레코드로 IP를 직접 박는 건 피하는 게 좋습니다. 저 SaaS의 공인 IP는 TTL이 60초입니다. 통보 없이 바뀝니다.

Node 쪽에서 dns.setServers()로 공용 DNS를 강제하거나 undici 디스패처에 커스텀 lookup을 끼우는 우회도 기술적으로는 가능합니다. 하지만 그건 인프라 설정 오류를 애플리케이션 코드로 덮는 것이고, 나중에 존이 고쳐져도 그 코드는 이유를 잃은 채 남습니다. 게다가 그 컨테이너가 외부 DNS 서버에 UDP로 나갈 수 있는지도 별도로 확인해야 합니다.

아직 검증 안 된 것

DNS가 풀린 뒤 443 아웃바운드가 열려 있는지는 모릅니다. ENOTFOUND에서 멈췄으니 그 다음 단계는 시험된 적이 없습니다. 이름을 해석하게 만들었더니 이번엔 ECONNREFUSED가 나오는 전개는 충분히 가능합니다.

그래서 인프라에 요청할 때 DNS 레코드와 아웃바운드 허용을 같이 얘기하는 게 맞습니다. 한 층을 뚫으면 다음 층이 나오는 게 네트워크 문제의 기본값입니다.

정리

  • catch {}는 에러를 바인딩조차 안 한다. 이게 있으면 원인 진단이 시작되지 않는다. 원인을 좁히기 전에 로깅부터 고쳐야 하는 상황이 실제로 있다.
  • TypeError: fetch failed는 원인이 아니라 undici의 래퍼다. DNS·커넥션·인증서가 전부 같은 문장으로 나온다. error.cause.code를 봐야 갈린다. 처음부터 찍었으면 배포 왕복이 절반이었다.
  • name만으로도 한 단계는 갈린다. TimeoutError면 타임아웃 초과, TypeError면 연결 계층. 타임아웃 값부터 만지지 말 것.
  • rewrite는 서버 프로세스가 대신 연결하는 것이다. 실패하는 hop과 같은 hop이라 우회가 안 된다. 프록시가 의미 있으려면 연결 주체가 실제로 바뀌어야 한다.
  • private DNS zone은 링크되면 그 이름공간에 authoritative다. 존에 없는 이름은 공용으로 fallback하지 않는다. 내부 도메인 아래에 외부 SaaS를 가리키는 서브도메인을 새로 붙이면, 배포 환경에서는 서버사이드로 안 보인다고 가정하고 시작하는 편이 낫다.
  • "배포에서만 실패"의 절반은 이름 해석 문제다. 로컬은 공용 DNS를 쓰기 때문에 이 차이가 영원히 안 드러난다.

돌아보면 이 문제에서 실제로 어려웠던 부분은 없습니다. 원인은 DNS 레코드 하나였고, 확인 방법도 dig 세 번이면 됐습니다. 시간을 잡아먹은 건 에러 메시지가 원인을 가리키지 않는 구간이었습니다. catch {}가 한 번, TypeError: fetch failed가 또 한 번.

에러 처리를 짤 때 우리는 대개 "실패했을 때 화면이 어떻게 보일까"를 생각합니다. "실패했을 때 내가 원인을 알 수 있을까"는 그만큼 자주 묻지 않습니다. 그리고 후자를 안 물어본 대가는, 항상 몇 달 뒤 다른 환경에서 청구됩니다.