TypeScript는 Markdown 파일까지 검사하지 않습니다
Fresh Notes의 콘텐츠 로더에는 다음 코드가 있습니다.
const { attrs, body } = extractYaml<PostMeta>(
source,
);PostMeta에는 제목, 날짜, 태그, 공개 여부 같은 정보가 선언되어 있습니다.
export interface PostMeta {
title: string;
description: string;
date: string | Date;
order?: number;
category: string;
tags: string[];
draft: boolean;
featured?: boolean;
image?: string;
}에디터에서는 attrs.tags가 문자열 배열이고 attrs.draft는 boolean이라고 표시됩니다.
하지만 Markdown 파일에 다음처럼 작성하면 어떻게 될까요?
tags: Deno
draft: "false"tags는 배열이 아니라 문자열입니다. draft도 boolean이 아니라 문자열입니다.
extractYaml<PostMeta>()에 타입을 전달했다고 이런 값이 자동으로 변환되거나 거부되지는 않습니다. 이 함수는 Front Matter를 추출하고 파싱하며, 반환 데이터의 타입을 지정할 수 있게 합니다. 프로젝트에서 요구하는 필드 규칙의 검증은 별도 작업입니다. Deno Front Matter
이번에는 잘못된 글 파일을 요청이 들어온 뒤 발견하는 대신, 빌드 전에 검사하는 도구를 만들어보겠습니다.
실제 코드에서는 어떤 문제가 생길까요?
현재 검색 코드는 태그를 다음처럼 처리합니다.
const tags = post.meta.tags.map(normalize);그런데 Front Matter가 다음과 같다면:
tags: Deno실제 값은 문자열이므로 기대한 배열의 map()을 사용할 수 없습니다.
draft에서는 더 조용한 문제가 생깁니다.
draft: "false"현재 글 목록은 다음 조건으로 초안을 제외합니다.
if (attrs.draft) {
continue;
}문자열 "false"는 JavaScript에서 truthy입니다. 따라서 작성자는 공개 글이라고 생각했는데 목록에서는 제외될 수 있습니다.
날짜도 마찬가지입니다.
date: "2026-02-30"문자열이라는 조건만으로는 존재하지 않는 날짜를 걸러낼 수 없습니다.
이 문제들은 타입 선언이 틀려서 생긴 것이 아닙니다. 파일에서 읽은 실제 값이 타입 선언과 같은지 확인하는 단계가 없기 때문에 발생합니다.
검증할 규칙부터 정합니다
이번에는 현재 블로그에서 사용하는 메타데이터만 검사합니다.
| 필드 | 규칙 |
|---|---|
title |
공백만으로 이루어지지 않은 문자열 |
description |
공백만으로 이루어지지 않은 문자열 |
date |
실제로 존재하는 YYYY-MM-DD 문자열 |
category |
Fresh, Deno, Vite, Preact 중 하나 |
tags |
하나 이상의 비어 있지 않은 문자열을 가진 배열 |
draft |
boolean |
order |
생략 가능, 있다면 1 이상의 안전한 정수 |
featured |
생략 가능, 있다면 boolean |
image |
생략 가능, 있다면 비어 있지 않은 문자열 |
글 전체를 확인할 때는 order 중복도 검사합니다.
이 중 일부는 기술적인 제약이 아니라 블로그의 운영 규칙입니다. 예를 들어 태그가 없는 글을 허용할 수도 있지만, 현재는 최소 하나를 작성하도록 정했습니다.
5편에서는 YAML 날짜를 처리하기 위해 string | Date 타입을 사용했습니다. 기존 소비 코드와의 호환을 위해 그 타입은 유지하되, 앞으로 작성하는 파일은 따옴표로 감싼 날짜 문자열을 사용하도록 검사하겠습니다.
date: "2026-10-19"검증 함수는 unknown에서 시작합니다
파일에서 읽은 값을 처음부터 PostMeta라고 가정하지 않겠습니다.
const { attrs } = extractYaml<unknown>(source);이제 attrs의 필드에 바로 접근할 수 없습니다. 실제 값을 검사해 필요한 형태인지 확인해야 합니다.
TypeScript는 typeof, 배열 검사, 조건문 등을 통해 확인한 값의 타입을 좁힐 수 있습니다. 이번 검증도 이 방식으로 작성합니다. TypeScript Narrowing
utils/post-meta.ts를 만듭니다.
import type { PostMeta } from "./posts.ts";
function isRecord(
value: unknown,
): value is Record<string, unknown> {
return typeof value === "object" &&
value !== null &&
!Array.isArray(value);
}
function isNonEmptyString(
value: unknown,
): value is string {
return typeof value === "string" &&
value.trim().length > 0;
}
function isDateString(
value: unknown,
): value is string {
if (
typeof value !== "string" ||
!/^\d{4}-\d{2}-\d{2}$/.test(value)
) {
return false;
}
const date = new Date(
`${value}T00:00:00.000Z`,
);
return Number.isFinite(date.getTime()) &&
date.toISOString().slice(0, 10) === value;
}
export function getPostMetaIssues(
value: unknown,
): string[] {
if (!isRecord(value)) {
return ["Front Matter: 객체여야 합니다."];
}
const issues: string[] = [];
for (const field of ["title", "description"]) {
if (!isNonEmptyString(value[field])) {
issues.push(
`${field}: 비어 있지 않은 문자열이어야 합니다.`,
);
}
}
if (!isDateString(value.date)) {
issues.push(
"date: 유효한 YYYY-MM-DD 문자열이어야 합니다.",
);
}
if (
typeof value.category !== "string" ||
!["Fresh", "Deno", "Vite", "Preact"].includes(
value.category,
)
) {
issues.push(
"category: Fresh, Deno, Vite, Preact 중 하나여야 합니다.",
);
}
if (
!Array.isArray(value.tags) ||
value.tags.length === 0 ||
!value.tags.every(isNonEmptyString)
) {
issues.push(
"tags: 비어 있지 않은 문자열 배열이어야 합니다.",
);
}
if (typeof value.draft !== "boolean") {
issues.push(
"draft: true 또는 false여야 합니다.",
);
}
if (
value.order !== undefined &&
(
typeof value.order !== "number" ||
!Number.isSafeInteger(value.order) ||
value.order < 1
)
) {
issues.push(
"order: 1 이상의 안전한 정수여야 합니다.",
);
}
if (
value.featured !== undefined &&
typeof value.featured !== "boolean"
) {
issues.push(
"featured: true 또는 false여야 합니다.",
);
}
if (
value.image !== undefined &&
!isNonEmptyString(value.image)
) {
issues.push(
"image: 비어 있지 않은 문자열이어야 합니다.",
);
}
return issues;
}
export function assertPostMeta(
value: unknown,
source: string,
): asserts value is PostMeta {
const issues = getPostMetaIssues(value);
if (issues.length > 0) {
throw new Error(
issues
.map((issue) => `${source}: ${issue}`)
.join("\n"),
);
}
}PostMeta는 기존 utils/posts.ts의 타입을 재사용합니다. import type이므로 이 import를 위해 Markdown 렌더러를 런타임에 실행하지는 않습니다.
검증은 두 함수로 나눴습니다.
getPostMetaIssues()
→ 발견한 문제를 문자열 배열로 반환
assertPostMeta()
→ 문제가 있으면 파일 이름과 함께 예외 발생문제를 처음 하나 발견했을 때 바로 끝내지 않고, 한 파일에서 확인할 수 있는 오류를 모아 보여줍니다.
날짜는 형식과 실제 값을 함께 확인합니다
날짜 검사에서는 정규식만 사용하지 않았습니다.
/^\d{4}-\d{2}-\d{2}$/이 정규식은 다음 두 값을 모두 통과시킵니다.
2026-10-19
2026-02-30둘 다 네 자리 연도, 두 자리 월, 두 자리 일이라는 형식을 만족하기 때문입니다.
따라서 UTC 자정으로 변환한 뒤 다시 날짜 문자열로 바꾸어 원본과 비교합니다.
const date = new Date(
`${value}T00:00:00.000Z`,
);
return Number.isFinite(date.getTime()) &&
date.toISOString().slice(0, 10) === value;실험에서는 다음 결과를 확인했습니다.
| 입력 | 결과 |
|---|---|
"2026-10-19" |
통과 |
"2024-02-29" |
통과 |
"2026-02-29" |
실패 |
"2026-02-30" |
실패 |
시간과 시간대를 저장하는 구조로 확장한 것은 아닙니다. 기존에 정한 날짜만의 저장 형식이 실제 달력 날짜인지 확인하는 용도입니다.
따옴표 없는 YAML 날짜도 별도로 확인했습니다.
date: 2026-10-19검증에 사용한 @std/front-matter 1.0.9의 파싱 결과에서는 이 값이 날짜 문자열 규칙을 통과하지 않았습니다. 따옴표로 감싼 값은 통과했습니다.
5편에서 날짜 파싱 오류를 만난 뒤 정했던 작성 규칙을 이제 코드로 확인하는 것입니다.
전체 글을 확인하는 스크립트를 만듭니다
필드의 타입과 값은 글 하나만 보고 검사할 수 있습니다.
하지만 연재 번호의 중복은 여러 파일을 함께 읽어야 발견할 수 있습니다.
# first.md
order: 11# second.md
order: 11각 파일만 보면 유효한 양의 정수입니다. 전체 연재에서는 같은 번호를 사용하고 있습니다.
scripts/check-posts.ts를 만듭니다.
import { extractYaml } from "@std/front-matter";
import { assertPostMeta } from "../utils/post-meta.ts";
const postsDir = Deno.args[0] ??
"./content/posts";
const files: string[] = [];
for await (const entry of Deno.readDir(postsDir)) {
if (
entry.isFile &&
entry.name.endsWith(".md")
) {
files.push(entry.name);
}
}
files.sort();
const errors: string[] = [];
const orders = new Map<number, string>();
for (const file of files) {
const path = `${postsDir}/${file}`;
try {
const source = await Deno.readTextFile(path);
const { attrs } = extractYaml<unknown>(
source,
);
assertPostMeta(attrs, path);
if (attrs.order !== undefined) {
const previous = orders.get(attrs.order);
if (previous) {
errors.push(
`${path}: order 중복: ${attrs.order} (기존 파일: ${previous})`,
);
} else {
orders.set(attrs.order, path);
}
}
} catch (error) {
const message = error instanceof Error
? error.message
: String(error);
errors.push(
message.startsWith(`${path}:`)
? message
: `${path}: ${message}`,
);
}
}
if (errors.length > 0) {
console.error(errors.join("\n"));
Deno.exit(1);
}
console.log(
`콘텐츠 검증 통과: ${files.length}개 파일`,
);파일 이름을 정렬한 뒤 검사하므로 오류 출력 순서도 일정하게 유지됩니다.
YAML 문법 자체가 깨진 파일도 catch에서 파일 경로와 함께 기록합니다. 하나의 파일이 실패했다고 나머지 파일 검사를 중단하지 않습니다.
검사할 디렉터리를 인자로 받을 수 있으므로 실제 글과 실패 예제를 분리할 수도 있습니다.
초안도 검사 대상으로 둡니다
이 스크립트에는 다음 조건이 없습니다.
if (attrs.draft) {
continue;
}초안을 공개 목록에서 제외하는 것과, 저장소의 메타데이터를 검사하는 것은 목적이 다르기 때문입니다.
이번 규칙에서는 다음처럼 처리합니다.
draft: true
→ 공개 목록에서는 제외
→ 메타데이터 검증에는 포함따라서 본문이 미완성인 초안도 제목·설명·날짜 등 기본 메타데이터는 작성해야 합니다.
초안에 order를 지정했다면 그 번호도 예약한 것으로 보고 중복을 검사합니다. 번호를 아직 정하지 않았다면 생략할 수 있습니다.
현재 Fresh Notes는 하나의 연재 번호 체계를 사용하므로 전체 글에서 order를 비교합니다. 여러 시리즈가 생긴다면 나중에는 series와 order의 조합을 기준으로 바꾸어야 합니다.
실제로 실패 파일을 넣어봅니다
정상 파일만 통과하는 것으로는 검증이 충분하지 않습니다.
별도 실험 디렉터리에 다음 파일을 만들었습니다.
fixtures/
├── 01-valid.md
├── 02-duplicate.md
├── 03-invalid.md
└── 04-broken-yaml.md첫 번째 파일은 정상 메타데이터를 사용합니다.
두 번째 파일은 같은 order: 11을 가진 초안입니다.
세 번째 파일에는 여러 오류를 함께 넣었습니다.
---
title: " "
description: "메타데이터 검사"
date: "2026-02-30"
order: "11"
category: Deno
tags: Deno
draft: "false"
---네 번째 파일은 YAML 문법을 깨뜨립니다.
---
title: [broken
---검사할 디렉터리만 읽도록 허용해 실행합니다.
deno run \
--allow-read=./fixtures \
scripts/check-posts.ts \
./fixtures실제 출력은 다음과 같습니다. YAML 파서의 상세 오류 위치는 버전에 따라 달라질 수 있습니다.
./fixtures/02-duplicate.md: order 중복: 11 (기존 파일: ./fixtures/01-valid.md)
./fixtures/03-invalid.md: title: 비어 있지 않은 문자열이어야 합니다.
./fixtures/03-invalid.md: date: 유효한 YYYY-MM-DD 문자열이어야 합니다.
./fixtures/03-invalid.md: tags: 비어 있지 않은 문자열 배열이어야 합니다.
./fixtures/03-invalid.md: draft: true 또는 false여야 합니다.
./fixtures/03-invalid.md: order: 1 이상의 안전한 정수여야 합니다.
./fixtures/04-broken-yaml.md: Cannot read flow collection: unexpected end of the stream within a flow collection at line 2, column 1:프로세스는 종료 코드 1로 끝났습니다.
여기서 확인한 것은 오류 메시지의 모양만이 아닙니다.
잘못된 파일 발견
↓
파일 이름과 원인 출력
↓
실패 종료 코드 반환마지막 단계가 있어야 뒤에 연결한 빌드도 중단할 수 있습니다.
빌드 전에 검증하도록 연결합니다
deno.json에 콘텐츠 검사 task를 추가합니다.
{
"tasks": {
"check:content": "deno run --allow-read=./content/posts scripts/check-posts.ts",
"build": "deno task check:content && vite build"
}
}기존의 다른 task는 유지합니다.
이제 빌드 흐름은 다음과 같습니다.
deno task build
↓
Markdown 메타데이터 검사
↓
통과하면 Vite build콘텐츠 검사가 종료 코드 1을 반환하면 && 뒤의 빌드는 실행되지 않습니다.
10편에서 다룬 권한 규칙도 그대로 적용했습니다. 이 검사 도구는 글을 읽기만 하므로 콘텐츠 디렉터리의 읽기 권한만 지정했습니다.
별도로 실행할 수도 있습니다.
deno task check:contentdeno check와도 역할이 다릅니다.
deno check
→ TypeScript 코드의 타입 검사
check:content
→ Markdown 파일의 실제 데이터 검사코드가 타입 검사를 통과해도 콘텐츠 검사는 실패할 수 있습니다. 두 검사는 서로 다른 입력을 확인합니다.
콘텐츠 로더에서도 같은 함수를 사용할 수 있습니다
발행 전 검사가 우선이지만, 런타임 로더에서도 검증 함수를 재사용할 수 있습니다.
utils/posts.ts에 import를 추가합니다.
import { assertPostMeta } from "./post-meta.ts";getPosts()에서는 메타데이터를 파싱한 직후 검사합니다.
const { attrs } = extractYaml<unknown>(
source,
);
assertPostMeta(
attrs,
`${POSTS_DIR}/${entry.name}`,
);
if (attrs.draft) {
continue;
}검증을 먼저 해야 "false" 문자열을 초안 여부로 판단하는 문제도 막을 수 있습니다.
getPost()에서도 같은 경계를 둡니다.
const { attrs, body } = extractYaml<unknown>(
source,
);
assertPostMeta(
attrs,
`${POSTS_DIR}/${slug}.md`,
);다만 현재 getPost()의 catch가 모든 오류를 null로 바꾸고 있다면 검증 오류도 404로 숨겨질 수 있습니다.
파일이 없는 경우만 null로 처리하고 나머지 오류는 전달해야 검증 실패를 확인할 수 있습니다.
catch (error) {
if (error instanceof Deno.errors.NotFound) {
return null;
}
throw error;
}글 하나의 로더에서는 필드 규칙을 검사합니다. 연재 번호 중복처럼 전체 파일이 필요한 검사는 발행 전 스크립트에서 담당합니다.
이번에 확인한 결과와 범위
별도 실험에서 검증 코드의 타입 검사와 테스트 10개를 실행했습니다.
테스트에는 정상 메타데이터, 윤년 날짜, 잘못된 날짜, 문자열 draft, 잘못된 태그와 카테고리, 잘못된 연재 번호, YAML 날짜 파싱 차이를 포함했습니다. 모두 기대한 결과를 확인했습니다.
당시 저장소에 있던 글 파일 10개에도 검사 스크립트를 실행했습니다.
콘텐츠 검증 통과: 10개 파일실패 파일을 모은 별도 디렉터리에서는 중복 번호와 깨진 YAML까지 확인했고, 스크립트가 종료 코드 1을 반환했습니다.
이 검증은 메타데이터의 형식과 일부 운영 규칙을 대상으로 합니다.
다음 항목까지 검사한 것은 아닙니다.
image 경로의 실제 파일 존재 여부
본문 링크의 유효성
Markdown 내용의 정확성
발행 날짜가 의도한 날짜인지 여부유효한 날짜 문자열이라고 해서 작성자가 올바른 날짜를 입력했다는 뜻은 아닙니다. 이번 코드는 자동으로 판단할 수 있는 규칙을 확인합니다.
파일에서 데이터로 넘어오는 경계에 검사를 둡니다
Markdown은 직접 작성하는 파일이라 신뢰하기 쉽습니다.
하지만 제목을 빠뜨리거나, 다른 글을 복사하면서 연재 번호를 바꾸지 않거나, false에 따옴표를 붙이는 실수는 충분히 발생할 수 있습니다.
이번에는 그 실수를 다음 순서로 확인하도록 만들었습니다.
Markdown 파일
↓
YAML 파싱
↓
필드 타입과 값 검사
↓
전체 글의 연재 번호 검사
↓
빌드기존에는 콘텐츠 오류가 검색이나 글 목록 요청에서 드러날 수 있었습니다. 이제는 파일 이름과 필드 이름을 포함한 오류로 발행 전에 확인할 수 있습니다.
TypeScript의 PostMeta는 코드를 작성할 때 필요한 계약입니다. 검증 함수는 파일에서 읽은 값이 그 계약과 작성 규칙을 만족하는지 확인합니다.
다음 글에서는 이렇게 발견한 오류를 Fresh가 어떻게 응답해야 하는지 살펴보겠습니다. 없는 글과 읽지 못한 글, 잘못된 글을 구분해 404·500 응답과 사용자 화면, 서버 로그를 연결하겠습니다.