Tech
2026.08.30

블로그 프로젝트 | 네트워크가 끊기면 글이 안 열린다 — 외부 DB 의존성 점검

#nextjs#postgresql#neon#ssr#resilience

1. 증상

네트워크가 끊긴 상태에서 로컬 개발 서버를 띄우자 블로그 전 페이지가 500 을 반환했습니다.

NeonDbError: Error connecting to database: TypeError: fetch failed

이상한 점은, 글 본문은 DB가 아니라 content/posts/*.mdx 파일에 있다는 것이었습니다. 읽을 데이터가 전부 디스크에 있는데 왜 네트워크가 필요한지가 출발점이었습니다.

2. 원인

2-1. 콘텐츠와 오버레이의 분리

이 사이트는 글 본문과 노출 여부를 서로 다른 곳에 둡니다.

데이터저장소네트워크
글 본문·메타데이터content/posts/*.mdx (git)불필요
숨김 여부post_visibility 테이블 (Neon PostgreSQL)필수

의도 자체는 합리적입니다. 콘텐츠는 git으로 버전 관리하고, 관리자가 토글하는 노출 여부만 DB에 얹는 구조입니다. 문제는 그 얇은 오버레이가 렌더를 막을 권한까지 갖고 있었다는 점입니다.

2-2. 예외가 그대로 올라오는 경로

lib/content/posts.ts

export async function getVisiblePosts(): Promise<PostMeta[]> {
  const hidden = await repo.getHiddenSlugs(); // ← 여기서 throw
  return getAllPosts().filter((p) => !hidden.has(p.slug));
}

repo.getHiddenSlugs()에는 try/catch가 없어 NeonDbError가 서버 컴포넌트까지 그대로 전파되고, Next.js가 이를 렌더 실패로 처리해 500을 냅니다.

2-3. 영향 범위

숨김 목록은 홈·목록·상세가 모두 조회하므로 블로그 영역 전체가 함께 죽습니다.

경로DB 호출본문 위치
/getVisiblePosts파일시스템
/postsgetVisiblePosts파일시스템
/posts/[slug]getHiddenSlugs파일시스템
/projects/[slug]getHiddenSlugs파일시스템
/music/*listAlbums 등DB (원본)

/music 계열은 데이터 원본이 DB라 오프라인에서 동작하지 않는 것이 당연하지만, 블로그 영역은 파일만으로 충분히 렌더할 수 있는데도 함께 막혀 있었습니다.

3. 외부 의존성 전수 점검

같은 문제가 다른 곳에도 있는지 렌더 경로의 외부 호출을 전부 확인했습니다.

대상호출 시점실패 시 동작판정
Neon DB (getHiddenSlugs)서버 렌더예외 전파 → 500❌
rehypeImageSize 원격 이미지MDX 컴파일catch → null, <img> 폴백✅
ogFetch (LinkPreview)서버 렌더catch → null + 5초 타임아웃, 폴백 카드✅
Spotify · Deezer APIAPI 라우트페이지 렌더 경로 아님✅
giscus 댓글클라이언트스크립트 미로드✅
Spotify 임베드클라이언트iframe 미로드✅

흥미로운 것은 이미 같은 원칙이 코드에 적혀 있었다는 점입니다. lib/content/ogFetch.ts 주석:

페이지 하나가 죽은 외부 링크 때문에 통째로 500 나면 안 되므로 여기서 예외를 삼킨다.

외부 이미지와 외부 링크에는 이 원칙이 적용돼 있었지만, DB만 예외였습니다. "우리 인프라"라는 이유로 외부 의존성 취급을 받지 못한 셈입니다.

4. 조치

repo 계층은 그대로 두고, 공개 렌더 경로에만 폴백을 두는 래퍼를 추가했습니다.

export async function getHiddenSlugsSafe(): Promise<Set<string>> {
  try {
    return await repo.getHiddenSlugs();
  } catch (err) {
    console.warn(
      "[posts] 숨김 목록 조회 실패 — 숨김 오버레이 없이 렌더합니다.",
      err,
    );
    return new Set();
  }
}

적용 지점은 세 곳입니다.

  • lib/content/posts.ts → getVisiblePosts()
  • app/posts/[slug]/page.tsx
  • app/projects/[slug]/page.tsx

repo에 직접 try/catch를 넣지 않은 이유

repo.getHiddenSlugs() 자체를 관대하게 만들면 관리자 화면도 함께 속게 됩니다. app/admin/posts는 숨김 상태를 보여주고 토글하는 화면인데, DB 장애 시 "숨긴 글이 없음"으로 표시되면 관리자가 잘못된 상태를 보고 판단하게 됩니다.

저장소 계층은 사실대로 던지게 두고, 폴백이 안전한 자리에서만 명시적으로 삼키는 편이 어디서 무엇을 포기하는지가 코드에 드러납니다.

트레이드오프

DB 장애 중에는 숨김 처리가 일시적으로 풀립니다. 숨긴 글이 노출되는 것과 사이트 전체가 열리지 않는 것 중 후자가 더 큰 손실이라고 판단했습니다. 다만 비공개가 반드시 지켜져야 하는 초안이 있다면 이 선택은 바뀔 수 있고, 그 경우 폴백을 "전부 숨김"으로 뒤집는 편이 맞습니다.

5. 결과

경로조치 전조치 후
/ · /posts · /posts/[slug] · /projects/[slug]500정상 렌더
/music/*500500 (데이터 원본이 DB라 정상 동작)
/admin/posts500500 (의도적으로 유지)

재현 및 확인 방법

# Wi-Fi를 끈 상태에서
npm run dev
# → /posts, /posts/[slug] 정상 렌더, 서버 콘솔에 경고 1줄만 기록

6. 배운 것

"우리 서비스의 DB"도 네트워크 너머에 있으면 외부 의존성입니다. Neon 같은 서버리스 PostgreSQL은 HTTP로 호출되므로 실패 특성이 외부 API와 다르지 않은데, 심리적으로는 내부 자원처럼 취급하기 쉽습니다.

그리고 의존성의 무게와 권한을 맞춰야 합니다. 숨김 여부는 부가 정보인데 본문 렌더를 막을 권한을 갖고 있었습니다. 조회 하나를 추가할 때 "이게 실패하면 무엇까지 못 보여줘야 하는가"를 같이 정해두면, 이런 불균형이 처음부터 생기지 않습니다.

댓글