AI·도구

Astro 블로그에서 카테고리를 URL에 넣지 않고 관리하기

글 주소는 /blog/글이름/ 으로 두고 분류는 글 머리의 값 하나로 나눴다. 주소에 주제를 넣지 않은 이유, 분류를 옮기고 지워도 주소가 안 깨진 경험, 글 없는 메뉴를 숨긴 방법을 적고, 직접 만들 사람을 위한 코드는 끝에 모았다.
이 글의 순서 (9)
  1. 주소에 주제를 넣는 방식은 버렸다
  2. 분류는 글 머리에 한 줄로 적는다
  3. 주소에 쓰는 값과 화면에 보이는 이름을 나눈다
  4. 글 세 편을 다른 분류로 옮겨도 글 주소는 그대로였다
  5. 분류를 지워도 깨지는 주소가 없었다
  6. 글이 없는 분류는 메뉴와 목록 페이지에서 숨긴다
  7. 아직 남은 문제
  8. 요청하는 법
  9. 직접 만들 사람을 위한 코드

이 사이트는 Astro 공식 blog 템플릿으로 시작했다. 템플릿에는 분류 기능이 없고, 모든 글이 /blog/ 아래에 있다.

글을 HR, 게임, 노트 세 주제로 나누기로 하면서 주소를 어떻게 할지부터 정해야 했다. 여기에는 그때 정한 방식과 실제로 분류를 바꿔 본 경험을 적는다. 직접 만들 사람을 위한 코드는 글 끝에 모아 두었다. 사이트를 처음 만든 과정은 구축기에 따로 있다.

주소에 주제를 넣는 방식은 버렸다

/hr/글이름/, /game/글이름/ 처럼 주제를 주소에 넣는 방식도 있다. 주소만 봐도 무슨 글인지 알 수 있다.

걸린 건 두 가지였다.

그래서 글 주소는 /blog/글이름/ 하나로 평평하게 두고, 분류는 글 안에 적는 값으로만 나눴다. 분류를 바꿔도 주소는 그대로다.

hr.heonote.com 같은 서브도메인(주소 앞에 붙는 별도 이름)으로 나누는 방법도 버렸다. 검색엔진은 서브도메인을 사실상 다른 사이트로 본다. 글이 적은 사이트에서 검색 유입이 세 곳으로 흩어지면 어느 쪽도 쌓이지 않는다.

분류는 글 머리에 한 줄로 적는다

마크다운 글 맨 위 --- 사이에는 제목, 날짜 같은 설정값을 적는다. 이 부분을 frontmatter(글 머리 설정)라고 한다. 여기에 category 한 줄을 추가했다.

---
title: '글 제목'
pubDate: 'Sep 15 2026'
category: 'hr'
---

쓸 수 있는 값은 설정 파일에 목록으로 정해 두었다. 목록에 없는 값을 적으면 사이트를 만드는 단계(빌드)에서 멈춘다. hr을 hrr로 잘못 적어도 그대로 배포되지 않는다. 글이 엉뚱한 분류로 들어가거나 어느 목록에도 안 보이는 일을 미리 막아 준다.

주소에 쓰는 값과 화면에 보이는 이름을 나눈다

분류마다 값을 두 개 둔다.

분류 주소에 쓰는 값 화면에 보이는 이름
인사·노무 hr HR
AI와 도구 ai AI·도구
기타 notes 노트

화면에 보이는 이름은 언제 바꿔도 되지만, 주소에 쓰는 값은 한번 정하면 바꾸지 않는다. 실제로 한 번 바꿨다. 노트 분류의 처음 이름은 ’잡다’였다. 보이는 이름만 ’노트’로 고쳤고, 주소 값은 notes 그대로라 주소는 하나도 바뀌지 않았다. 메뉴, 목록 페이지 제목, 설명이 한꺼번에 따라 바뀌었다.

글 세 편을 다른 분류로 옮겨도 글 주소는 그대로였다

주소에 주제를 넣지 않은 효과는 분류를 실제로 바꿔 보고 확인했다.

노트에 쌓인 글이 대부분 AI와 도구로 무언가를 만든 이야기여서, “AI·도구” 분류를 새로 만들고 노트 글 세 편을 옮겼다. 한 일은 이것뿐이다.

  1. 분류 목록에 ai 를 추가했다.
  2. 옮길 글 세 편의 category 값을 ai 로 바꿨다.
  3. 글이 없어진 노트 목록 주소(/notes/)로 들어온 사람을 AI·도구 목록(/ai/)으로 보내는 설정을 한 줄 넣었다.

글 주소는 /blog/글이름/ 그대로라 이미 공유된 링크나 검색 결과는 하나도 깨지지 않았다. 주소에 주제를 넣었다면 세 편의 주소가 모두 바뀌어 글마다 리디렉션을 걸어야 했다. 3번의 이동은 나중에 노트에 다시 글이 생길 수 있어서 영구 이동이 아닌 임시 이동으로 걸었다.

분류를 지워도 깨지는 주소가 없었다

분류를 빼는 것도 같았다. 게임 글은 별도 사이트에서 쓰기로 하면서 게임 분류를 없앴다. 글이 없던 분류라 지워도 깨지는 주소가 없었다. 분류 목록에서 game 을 지운 것으로 끝났다. 글이 없는 분류는 처음부터 목록 페이지를 만들지 않았으니(아래 절) 사라진 주소도, 걸어야 할 리디렉션도 없었다.

글이 없는 분류는 메뉴와 목록 페이지에서 숨긴다

/hr/, /ai/ 같은 목록 페이지는 파일 하나가 분류마다 만들어 낸다. 이때 글이 한 편도 없는 분류는 목록 페이지를 만들지 않는다. 메뉴에도 같은 조건을 걸어, 글이 있는 분류만 링크를 띄운다.

메뉴에 분류가 있는데 눌렀더니 빈 페이지면 만들다 만 사이트로 보인다. 지금은 노트 글을 모두 AI·도구로 옮겨서 메뉴에 노트가 없다. 노트에 글을 한 편 올리면 그 빌드에서 메뉴와 페이지가 같이 생긴다.

목록 페이지와 메뉴 두 곳 모두에 조건을 걸어야 한다. 한쪽만 걸면 메뉴에는 있는데 누르면 페이지가 없거나, 페이지는 있는데 메뉴에서 갈 수 없는 상태가 된다.

아직 남은 문제

분류 목록이 두 곳에 따로 있다. 쓸 수 있는 값을 정하는 목록과, 화면에 보이는 이름을 정하는 목록이다.

첫 번째 목록에만 새 분류를 넣으면 빌드는 통과하고 그 분류로 글도 쓸 수 있다. 그런데 목록 페이지와 메뉴는 두 번째 목록만 보기 때문에 아무것도 생기지 않는다. 에러는 없는데 그 글은 전체 글 목록에서만 보이고 분류로는 찾아갈 수 없다.

AI·도구 분류를 추가할 때도 두 곳을 같이 고쳐야 했다. 지금은 두 파일에 “함께 고칠 것”이라는 주석을 달아 두는 것으로 버티고 있다. 한 목록이 다른 목록을 읽어 오게 바꾸면 한 곳만 고치면 되는데, 아직 손대지 않았다.

요청하는 법

코드를 몰라도 이 구조는 Claude Code(내 PC의 파일을 직접 읽고 고치는 Claude의 작업 도구)에 이렇게 맡기면 된다.

글 주소에 분류 넣지 말고
글 머리에 category로 나눠줘.
분류별 목록 페이지 만들고,
글 없는 분류는 메뉴에서 빼줘.

만든 뒤에는 분류 값을 일부러 틀리게 적어 빌드가 멈추는지, 글이 없는 분류가 메뉴에서 빠지는지 한 번씩 확인하면 된다.

직접 만들 사람을 위한 코드

여기서부터는 Astro로 같은 구조를 직접 만들 사람을 위한 부분이다.

쓸 수 있는 분류 값은 src/content.config.ts 에서 정한다. z.enum 은 정해진 값 중 하나만 허용한다는 뜻이고, 적지 않으면 notes 로 들어간다.

category: z.enum(['hr', 'ai', 'notes']).default('notes'),

목록에 없는 값을 적으면 빌드가 이런 에러로 멈춘다.

[InvalidContentEntryDataError] blog → zz-enum-test data does not match collection schema.
  category: Invalid option: expected one of "hr"|"ai"|"notes"

주소 값과 보이는 이름은 src/consts.ts 에 둔다.

export const CATEGORIES = [
	{ slug: 'hr', name: 'HR', description: '인사·노무 실무에서 정리한 것들' },
	{ slug: 'ai', name: 'AI·도구', description: 'AI와 도구로 업무 프로그램과 사이트를 만들어 본 기록' },
	{ slug: 'notes', name: '노트', description: '읽고 정리하고 남겨두는 것들' },
] as const;

분류별 목록 페이지는 src/pages/[category]/index.astro 파일 하나가 만든다. 파일 이름의 대괄호는 그 자리에 여러 값이 들어간다는 표시다. 어떤 페이지를 만들지는 getStaticPaths 가 정한다. params 는 주소에 들어갈 값, props 는 그 페이지에 넘길 데이터다.

export async function getStaticPaths() {
	const posts = await getCollection('blog');

	return CATEGORIES
		// 글이 실제로 있는 카테고리만 페이지를 만든다
		.filter((cat) => posts.some((post) => post.data.category === cat.slug))
		.map((cat) => ({
			params: { category: cat.slug },
			props: {
				category: cat,
				posts: posts
					.filter((post) => post.data.category === cat.slug)
					.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
			},
		}));
}

메뉴는 src/components/Header.astro 에서 글이 있는 분류만 고른다.

const posts = await getCollection('blog');
const activeCategories = CATEGORIES.filter((cat) =>
	posts.some((post) => post.data.category === cat.slug),
);

리디렉션은 Cloudflare Pages가 읽는 public/_redirects 에 적는다. 302는 임시 이동이다. 끝에 / 가 붙은 주소와 안 붙은 주소를 둘 다 적었다.

/notes/ /ai/ 302
/notes /ai/ 302

2026년 9월, Astro 7.3 기준.

AI·도구 다른 글

틀린 내용이나 바뀐 법령을 발견하면 문의로 알려 주면 확인하고 고친다. 고친 글에는 수정 날짜를 적는다.