없는 글과 읽지 못한 글은 다릅니다
Markdown 블로그에서 존재하지 않는 글을 요청하면 404를 반환해야 합니다.
하지만 파일은 존재하는데 YAML 문법이 잘못되었거나, 서버에 파일 읽기 권한이 없다면 어떨까요?
이때도 404를 반환하면 독자에게는 없는 글처럼 보입니다. 운영하는 개발자도 응답만 보고 원인을 구분하기 어렵습니다.
Fresh Notes의 기존 코드에 바로 이 문제가 있었습니다.
5편에서 만든 Markdown 콘텐츠 로더는 글을 가져오지 못하면 null을 반환하고, 상세 페이지는 이를 404로 처리했습니다. 처음에는 간단했지만, 오류를 구분하지 않는 catch 때문에 서버 문제까지 같은 결과로 바뀌었습니다.
이번에는 오류 페이지를 만들면서 다음 세 가지를 함께 정리하겠습니다.
- 존재하지 않는 글과 초안은 404로 처리합니다.
- 파싱 오류와 읽기 권한 오류는 서버 오류로 전달합니다.
- 독자에게 보여줄 안내와 서버에 남길 오류 정보를 분리합니다.
예제는 프로젝트에서 사용하는 Fresh 2.3.3과 @fresh/plugin-vite 1.1.2를 기준으로 확인했습니다. 실제 프로젝트의 임시 복사본에서 코드를 수정하고 빌드한 뒤, 서버 핸들러가 반환한 상태 코드와 HTML을 검사했습니다.
기존 코드는 모든 실패를 404로 바꾸고 있었습니다
기존 getPost()의 구조를 줄이면 다음과 같습니다.
export async function getPost(
slug: string,
): Promise<Post | null> {
try {
const source = await Deno.readTextFile(
`${POSTS_DIR}/${slug}.md`,
);
const { attrs, body } = extractYaml<PostMeta>(
source,
);
return {
slug,
meta: attrs,
html: render(body),
readingTime: estimateReadingTime(body),
};
} catch {
return null;
}
}여기에는 서로 다른 작업이 하나의 try 안에 들어 있습니다.
파일 읽기
→ Front Matter 파싱
→ Markdown 렌더링
→ 결과 반환어느 단계에서 예외가 발생하든 결과는 null입니다.
상세 페이지에서는 다음처럼 처리합니다.
if (!post) {
return new Response("Post not found", {
status: 404,
});
}따라서 아래 상황이 모두 404가 됩니다.
| 상황 | 기존 처리 |
|---|---|
| 해당 Markdown 파일이 없음 | 404 |
| YAML 문법이 잘못됨 | 404 |
| 파일 읽기 권한이 없음 | 404 |
| 렌더링 중 예외 발생 | 404 |
파일이 없을 때는 맞는 응답입니다. 하지만 나머지는 서버가 콘텐츠를 처리하지 못한 상황입니다.
10편에서 살펴본 Deno 권한 문제도 이런 코드에서는 없는 글로 보일 수 있습니다. 권한을 수정해야 하는데 글 파일부터 찾게 되는 것입니다.
Fresh 2에서는 _error.tsx를 사용합니다
Fresh 1.x 예제에서는 _404.tsx, _500.tsx라는 파일을 볼 수 있습니다.
Fresh 2에서는 두 파일이 _error.tsx로 통합되었습니다. 오류 페이지에서 전달받은 error를 확인해 화면을 나눕니다. 이는 Fresh 2에서 사용할 수 있는 기능이며, 향후 로드맵에 있는 기능을 전제로 하지 않습니다. Fresh 마이그레이션 가이드
이번 프로젝트에는 다음 파일을 추가합니다.
routes/
├── _app.tsx
├── _error.tsx
├── index.tsx
└── posts/
└── [slug].tsx라우트나 핸들러에서 특정 HTTP 오류를 발생시키려면 HttpError를 사용할 수 있습니다.
import { HttpError } from "fresh";
throw new HttpError(404);Fresh는 발생한 HTTP 오류를 오류 처리 흐름으로 전달합니다. 일반 Error와 달리 HttpError에는 HTTP 상태 코드가 들어 있습니다. Fresh 오류 처리 문서
공통 오류 페이지를 만듭니다
routes/_error.tsx를 다음과 같이 작성합니다.
import { HttpError } from "fresh";
import { SITE } from "../config.ts";
import { define } from "../utils.ts";
export default define.page(function ErrorPage({
error,
url,
}) {
const status = error instanceof HttpError
? error.status
: 500;
const isNotFound = status === 404;
const title = isNotFound
? "페이지를 찾을 수 없습니다"
: "요청을 처리하지 못했습니다";
if (status >= 500) {
console.error(
"[request-error]",
url.pathname,
error,
);
}
return (
<>
<head>
<title>
{status} · {title} | {SITE.name}
</title>
<meta
name="robots"
content="noindex"
/>
</head>
<main class="mx-auto max-w-3xl px-7 py-16 sm:px-8 md:py-24">
<p class="font-mono text-sm text-primary">
{status}
</p>
<h1 class="mt-4 text-3xl font-semibold">
{title}
</h1>
<p class="mt-6 leading-8 text-base-content/70">
{isNotFound
? "주소가 잘못되었거나 공개되지 않은 글입니다."
: "잠시 후 다시 시도해 주세요. 문제가 계속되면 문의 페이지로 알려주세요."}
</p>
<nav
aria-label="오류 페이지 안내"
class="mt-8 flex flex-wrap gap-4"
>
<a
href="/"
class="btn btn-primary"
>
홈으로
</a>
<a
href="/search"
class="btn btn-outline"
>
글 검색
</a>
{!isNotFound && (
<a
href="/contact"
class="btn btn-ghost"
>
문의하기
</a>
)}
</nav>
</main>
</>
);
});상태 코드 판단은 다음 부분에서 합니다.
const status = error instanceof HttpError
? error.status
: 500;HttpError(404)라면 404 안내를 보여줍니다. 일반 예외라면 500으로 표시합니다.
다른 HTTP 오류가 들어와도 상태 코드는 유지합니다. 예를 들어 HttpError(403)을 발생시키면 화면에 403을 표시하고, 공통 안내 문구를 사용합니다.
여기서 화면에 숫자를 출력하는 코드가 HTTP 응답 상태를 설정하는 것은 아닙니다. HTTP 상태는 Fresh의 오류 처리 흐름에서 정해지며, 이 컴포넌트는 그 오류에 맞는 내용을 렌더링합니다.
따라서 구현 뒤에는 본문에 보이는 숫자와 실제 응답 상태가 일치하는지 함께 확인해야 합니다.
오류 내용은 서버 로그에 남깁니다
오류 객체를 그대로 화면에 출력하지 않았습니다.
다음과 같은 코드는 작성하지 않습니다.
<pre>{String(error)}</pre>파일 경로나 내부 처리 내용은 문제를 해결하는 개발자에게 필요합니다. 독자에게는 다음에 무엇을 할 수 있는지가 더 중요합니다.
그래서 서버 오류는 로그로 남깁니다.
console.error(
"[request-error]",
url.pathname,
error,
);독자에게는 홈, 검색, 문의 링크를 제공합니다.
오류 페이지 자체에서는 글 목록이나 추천 글을 추가로 읽지 않습니다. 콘텐츠를 읽다가 실패했는데 오류 화면을 만들기 위해 다시 같은 콘텐츠를 읽으면, 안내 화면까지 실패할 수 있기 때문입니다.
이번 페이지는 제목과 고정 링크만으로 구성했습니다. 새로운 Island도 필요하지 않습니다.
다만 프로젝트의 공통 _app.tsx와 헤더는 그대로 사용합니다. 이 코드만 보고 사이트 전체에 클라이언트 JavaScript가 없다고 판단할 수는 없습니다.
파일이 없을 때만 null을 반환합니다
이제 utils/posts.ts의 getPost()를 수정합니다.
기존 import와 다른 함수는 유지하고, getPost()만 다음 코드로 교체합니다.
export async function getPost(
slug: string,
): Promise<Post | null> {
let source: string;
try {
source = await Deno.readTextFile(
`${POSTS_DIR}/${slug}.md`,
);
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return null;
}
throw error;
}
const { attrs, body } = extractYaml<PostMeta>(
source,
);
if (attrs.draft) {
return null;
}
return {
slug,
meta: attrs,
html: render(body),
readingTime: estimateReadingTime(body),
};
}핵심은 try의 범위를 파일 읽기로 좁힌 것입니다.
파일을 찾지 못함
→ null
그 밖의 읽기 오류
→ 예외 전달
YAML 파싱 또는 Markdown 렌더링 오류
→ 예외 전달파일 읽기 실패도 종류를 확인합니다.
if (error instanceof Deno.errors.NotFound) {
return null;
}
throw error;예를 들어 읽기 권한이 없는 상황은 NotFound로 취급하지 않습니다. 발생한 오류를 그대로 전달해 서버 오류로 처리합니다.
초안도 상세 페이지에서는 없는 글과 같은 결과를 반환하도록 정했습니다.
if (attrs.draft) {
return null;
}기존 목록 코드에는 초안을 제외하는 조건이 있었지만, 상세 페이지에서 파일을 직접 읽는 경로에도 같은 정책이 필요합니다.
메타데이터 검증은 별도입니다. 11편의 Front Matter 검증은 잘못된 필드 값을 발행 전에 찾는 작업이고, 이번 변경은 요청 중 발생한 실패를 올바른 응답으로 전달하는 작업입니다.
extractYaml<PostMeta>()라는 타입 지정만으로 메타데이터 검증까지 끝나는 것은 아닙니다.
상세 페이지에서 HttpError를 발생시킵니다
routes/posts/[slug].tsx에 import를 추가합니다.
import { HttpError } from "fresh";그리고 기존의 new Response() 부분을 바꿉니다.
export const handler = define.handlers({
async GET(ctx) {
const post = await getPost(
ctx.params.slug,
);
if (!post) {
throw new HttpError(404);
}
return {
data: {
...post,
seriesPosts: post.meta.order === undefined
? []
: await getSeriesPosts(),
},
slug: ctx.params.slug,
};
},
});상세 페이지의 기본 컴포넌트는 그대로 유지합니다.
이제 처리 흐름은 다음과 같습니다.
상세 페이지 요청
→ getPost()
→ 파일 없음 또는 초안
→ null
→ HttpError(404)
→ 공통 오류 페이지
→ 읽기·파싱·렌더링 오류
→ 예외 전달
→ 공통 오류 페이지
→ 정상 글
→ 기존 상세 페이지존재하지 않는 글도 헤더와 푸터를 포함한 사이트 화면 안에서 안내할 수 있습니다.
404 Response를 반환하는 것과는 차이가 있습니다
다음 두 코드는 HTTP 상태가 같아도 처리 흐름이 다릅니다.
return new Response("Post not found", {
status: 404,
});throw new HttpError(404);이번에 확인한 Fresh 2.3.3에서는 직접 반환한 404 Response의 본문이 그대로 전달되었습니다. _error.tsx가 그 본문을 공통 오류 화면으로 바꾸지는 않았습니다.
반면 HttpError(404)를 발생시키면 공통 오류 페이지로 연결되었습니다.
| 작성 방식 | 확인한 응답 |
|---|---|
404 Response 직접 반환 |
직접 작성한 본문 |
HttpError(404) 발생 |
_error.tsx의 안내 화면 |
따라서 _error.tsx를 추가하는 것만으로 기존의 모든 404 응답이 같은 화면으로 바뀐다고 생각하면 안 됩니다.
HTML 페이지에서 공통 오류 화면을 사용하려는 지점은 HttpError로 연결합니다. API처럼 별도의 JSON 오류 응답이 필요한 곳에서는 직접 Response를 반환하는 방식도 사용할 수 있습니다.
빌드된 서버에서 상태 코드와 HTML을 확인합니다
오류 화면은 개발 서버에서 보이는 모습만으로 검증하지 않았습니다.
임시 복사본에 예제 코드를 적용하고 프로덕션 빌드를 만든 뒤, 생성된 서버의 fetch() 핸들러를 직접 호출했습니다.
확인 방식은 다음과 같습니다.
import server from "./_fresh/server.js";
const response = await server.fetch(
new Request(
"http://localhost:8000/posts/does-not-exist",
),
);
console.log(response.status);
const html = await response.text();
console.log(
html.includes("페이지를 찾을 수 없습니다"),
);이 코드는 서버 핸들러에 Request 객체를 전달합니다. 해당 주소로 실제 HTTP 요청을 보내거나, 브라우저에서 페이지를 여는 방식은 아닙니다.
반환된 HTML을 문자열로 읽으므로 HTML 안의 외부 스크립트도 실행하지 않습니다. 이번 빌드와 응답 확인은 네트워크 접근을 차단한 상태에서 진행했습니다.
검증 결과는 다음과 같습니다.
| 확인한 상황 | HTTP 상태 | 응답 내용 |
|---|---|---|
| 매칭되는 라우트가 없는 주소 | 404 | 공통 404 안내 |
| 존재하지 않는 글 | 404 | 공통 404 안내 |
draft: true인 글 |
404 | 공통 404 안내 |
| YAML 문법이 깨진 글 | 500 | 공통 오류 안내 |
일반 Error를 발생시키는 핸들러 |
500 | 공통 오류 안내 |
| 콘텐츠 읽기 권한을 제외한 실행 | 500 | 공통 오류 안내 |
404 Response를 직접 반환하는 핸들러 |
404 | 직접 작성한 본문 |
| 정상 글 | 200 | 기존 글 상세 화면 |
권한 오류는 글 파일을 삭제해서 흉내 내지 않았습니다. 별도 실행에서 콘텐츠 디렉터리 읽기 권한을 제외해 확인했습니다.
일반 예외 테스트에서는 다음과 같은 구분 가능한 문구를 사용했습니다.
throw new Error(
"TEST_ONLY_INTERNAL_DETAIL",
);프로덕션 응답 HTML에는 이 문구가 없고, 서버 로그에는 오류가 남는 것을 확인했습니다.
오류를 발생시키는 핸들러와 잘못된 Markdown 파일은 임시 실험용입니다. 운영 프로젝트에 추가할 파일은 아닙니다.
이번 검증은 HTTP 응답과 HTML 내용에 대한 확인입니다. 브라우저에서의 시각적인 배치까지 확인한 결과는 아닙니다.
오류 문구만 바꾸고 200을 반환하면 안 됩니다
오류 페이지를 만들 때 다음과 같은 결과가 나오지 않는지도 확인해야 합니다.
HTTP 상태: 200
본문: 페이지를 찾을 수 없습니다독자는 오류 화면을 보지만, 응답 상태는 성공입니다.
Google은 성공 상태로 응답한 페이지라도 내용이 오류나 빈 페이지라면 soft 404로 판단할 수 있다고 설명합니다. 오류 화면의 문구와 실제 HTTP 상태를 함께 확인해야 하는 이유입니다. Google의 HTTP 상태 코드 처리 안내
이번 코드의 noindex 메타 태그도 올바른 HTTP 상태 코드를 대신하지 않습니다.
없는 페이지는 404로, 처리에 실패한 요청은 500으로 응답하는 것이 먼저입니다.
또한 서버 장애를 404로 숨기면 일시적으로 글을 처리하지 못한 상황과 콘텐츠가 없는 상황을 구분하기 어려워집니다. 검색 노출을 조정하려고 상태 코드를 선택하기보다, 실제 요청 결과를 정확하게 표현해야 합니다.
catch에서 무엇을 복구할 수 있는지 정합니다
이번 변경에서 화면보다 중요한 부분은 catch의 역할입니다.
파일이 없는 상황은 블로그가 예상할 수 있는 결과입니다. null을 반환하고 상세 페이지에서 404로 연결합니다.
하지만 파일을 읽을 수 없거나 파싱에 실패했다면 콘텐츠 로더 안에서 정상적인 결과를 만들 수 없습니다. 이 오류는 다시 전달해 요청을 실패로 처리하고, 서버 로그에서 원인을 확인할 수 있게 합니다.
오류 페이지는 그 실패를 독자가 이해할 수 있는 안내로 바꿉니다.
이렇게 구분하면 없는 글에는 검색으로 이동할 길을 제공하고, 서버 문제에는 문제를 해결할 수 있는 기록을 남길 수 있습니다.