개요
저는 Notion 연동 블로그를 직접 개발하면서 나름의 검색 엔진에서의 실적을 올리고 있습니다. 그런데, 저는 이번에 다른 블로그들을 보면서, URL 가 포스트의 제목인 것을 보았습니다. 정말 깔끔한 것이, 제 블로그도 이렇게 변경해야겠다 마음 먹었죠. 기존의 제 블로그의 URL 는 페이지의 id 를 직접 노출한 형태였거든요. 그런데, 작업을 해보니 생각보다 고려해야할 것들이 좀 있었습니다.
미리 요약
본 포스트의 분량이 굉장히 길어졌기 때문에 중요한 점을 먼저 적어둡니다. 이에 대한 근거 및 실험이 아래 과정에 있다고 보시면 좋을 것 같습니다. 이미 서비스되어 검색엔진이 크롤링하고 로그를 쌓고 있는 페이지의 URL 을 변경 시 어떻게 하는 것이 좋았는지 정리합니다.
- 검색엔진은 반드시 정적인 리소스만 크롤링 하는 것이 아니라, 서버의 모든 응답을 크롤링 합니다.
- 그러므로, URL 이 변경될 때 서버는 리디렉션 응답을 명확하게 주고, 리디렉션된 리소스에 명확한 표시만 해둔다면 로그들을 유지하며 URL 을 변경 할 수 있습니다. (301 리디렉션 & canonical 설정)
- slug 형태의 URL 을 사용하더라도, 내부적으로는 id 형태로 리소스를 관리하는 것이 좋습니다.
- 영구적인 301 리디렉션의 경우 브라우저가 자동으로 캐시합니다. 물론, 시크릿 모드로 개발하신다면 신경 쓰지 않아도 됩니다.
목표
이번 목표는 다음과 같았습니다.
- 기존 운영 중에 있는 노션 블로그의 블로그 포스트의
/posts/{post-id}
형태의 URL 를/posts/{title}
형태로 제공한다.
- 지금까지 SEO 를 해두었기 때문에 이 실적을 유지한다.
- 현재 제공되고있는 서비스는 이용자들에게 변경이 없는 것 처럼 느껴지게 한다. (구글에서 검색된 링크를 타고 들어오면 변경한 URL 로 리디렉션)

그리고 이 변경을 통해 얻는 이점은 다음과 같았습니다.
- 포스트 관리 용이: GSC(Google Search Console) 에서나 Vercel 을 통해 통계를 모니터링 할 때, URL 을 직접 보여주곤 합니다. 하지만, 인기있는 게시글이나 이런 것을 확인 할 때는 post-id 만 적혀있으면 어떤 게시글이 인기가 있는지 알 수가 없습니다. 이를 개선 할 수 있을 것이라 생각 했습니다.
- SEO 점수 개선: 검색 엔진이 직접 URL 을 평가하지는 않지만, 구글 URL 설계 권장 사항에 사용자가 읽기 좋은 URL 이라면 공유할 때 더 신뢰가 가기 때문에 긍정적인 효과가 있다고 적혀있습니다. 이를 통해 간접적인 점수 개선이 일어날 것을 기대했습니다.
- 보안: 노션의 페이지 id 가 노출되는 것은 사실 그렇게 큰 문제가 될 일은 아니지만, 그래도 어떤 엔티티의 id 가 직접 노출되는 것은 바람직하지 않다고 생각했습니다. 결국 제 notion 정보를 탈취 당하면 제 페이지를 누군가 강제로 수정 할 수 있으니까요.
설계
이번에는 좀 걱정되는 것이 많았습니다. 지금까지의 실적을 잃고싶지는 않았거든요. 하지만, 한번 잘못되면 되돌리기도 어려우니 여러가지를 우선적으로 조사했습니다. 우선, 다음과 같은 방식으로 변경하는 것이 가장 좋다고 결론을 내렸습니다.
설계 목표
- SEO 관리 시 중복 URL로 판별이 난다면 SEO 점수에 직접적으로 악영향이 가기 때문에, 301 리디렉션으로 컨텐츠가 영구적으로 리디렉션 되었다는 것을 검색 엔진에게 알립니다.
- 기존 페이지는 현재 이미 배포되어 실적을 내고 있으므로 사용자는 중간에 검색엔진에서 제공받던 페이지를 끊김없이 받을 수 있어야합니다.
- 결국 post-id 로 페이지 상세 정보를 검색 해야하기 때문에, 브라우저에서
/posts/{slug}
형태로 호출 하더라도 내부적으로는 slug 를 post-id 로 변환해 관리하도록 합니다.
- slug 는 URL 친화적으로 변경해야합니다. 사실 slug 를 한글로 제공하려는 것 자체는 URL 친화적이지는 않지만, 그 외의 부분을 최대한 맞추려고 합니다.
설계안
설계 목표에 맞게 설계를 하기 위해 현황을 먼저 파악하고 빠르게 문제 해결을 볼 수 있는 방법을 찾고자 했습니다.
현재 제가 공개한 포스트는 많지 않습니다. (40개 정도) 그러므로, notion api 호출을 최대한 줄이면서 효율적으로 notion 에 작성한 포스트들의 메타데이터를 통해 slug-id 변환하는 방법은 서버 내 파일 형태로 slug-id mapper 를 만들어두고, ISR 주기에 맞추어 파일을 업데이트하고, 이 파일을 기준으로 매핑하면 좋겠다는 생각을 했습니다. 이는 제가 공개한 포스트 양이 많아지면 변경해야하지만, 현 시점에서는 가장 부하가 적다는 장점이 있다고 생각했습니다.
그래서 저는 우선 베스트 시나리오는 다음과 같이 생각했습니다.
- notion API 를 호출해서 slug-id mapper 를 서버 내에 파일 형태로 저장 + ISR 처럼 주기적으로 파일을 업데이트 할 수 있다면 최고
- 이 파일을 기준으로 ISR 형태로 포스트 페이지들을 빌드한다.
또한,posts/[slug]
형태 이외에 이전에posts/[postId]
도 미리 빌드해 검색엔진이 기존에 서비스하던 리소스를 잃지 않게 합니다.
- 이 파일을 fetch 해서 next.js 의 middleware 에서
posts/[param]
형태의 api 호출 시, id 인지 여부를 확인하고,posts/[postId]
면 permanent redirection 형태로posts/[slug]
redirect 해준다.
- SEO 를 위해
posts/[slug]
페이지에 필요한 메타데이터를 설정해둔다. (canonical 설정)
하지만, 실제 개발 중 두가지 문제가 생겼습니다.
첫번째 문제는 제가 생각한 베스트 시나리오였던 notion API 를 호출 후 slug-id mapper 를 json 형태로 public 에 파일로 저장하는 방식은 이를 로컬에서 빌드 후 테스트 할 때 까지는 middleware 를 통한 fetch 가 가능해 잘 동작 했지만, 실제 vercel 에 배포 할 때에는
middleware
를 빌드 할 때 문제가 생겼습니다. 이는 middleware
가 fetch
를 할 때 vercel
을 통해 호출 할 때는 edge
환경에서 호출하게 되는데, 이때 fetch
api 의 호스트 주소를 잘 파악하지 못하는 문제가 있었습니다. 그리고, 두번째 문제는 검색엔진이 특정 포스트들을 중복된 리소스라는 판단하에 기존에 있던
posts/[postId]
리소스를 남겨두고 posts/[slug]
형태의 리소스는 크롤링을 하지 않은 것입니다.
이 사유는 제가 canonical 설정으로 표준 URL 을 설정해두었으나, 대표로 지정한 표준 URL 페이지가 현재 페이지와 다른 경우 입니다. 저는 처음에는 페이지를 중복해서 만든 것이 문제인 줄 알았으나, 이는 제가 포스트 내용을 수정하며 달라진 내용이 있어서 크롤링 하지 못한 것 입니다. 이는 시간이 지나 다시 컨텐츠를 동일하게 한 후 크롤러에게 다시 검사해달라고 요청 할 수 있습니다.
그래서 저는 설계를 조금 변경해야겠다고 생각했습니다. 301 리디렉션이 필요한 포스트가 40개 정도로 적으므로, 리디렉션을 도와줄 slug-id mapper 가 반드시 파일형태로 존재할 필요는 없다는 생각이 들었습니다. 그래서 리디렉션 할 때는 그냥 리디렉션이 필요한 현재 post 들을 일반 객체로 사용하여 검사하도록 변경하였습니다. 그리고,
posts/[postId]
의 리소스들은 미리 빌드하지 않기로 했습니다.최종 반영된 설계 안
- slug-id mapper 는 서버 컴포넌트 내에서만 필요하므로, react 의 cache api 를 통해 최대한 캐시하여 사용
posts/[slug]
리소스만 미리 빌드하고, ISR 한다.posts/[postId]
형태의 리소스는 그냥 리디렉션 처리만 해두어도 괜찮다.
- middleware 에서
posts/[param]
형태의 api 호출 시, id 인지 여부를 확인하고,posts/[postId]
면 permanent redirection 형태로posts/[slug]
redirect 해준다. middleware 의 리디렉션이 필요한 포스트 개수가 적으므로 middleware 에서 사용할 mapper 는 하드코딩 한다.
- SEO 를 위해
posts/[slug]
페이지에 필요한 메타데이터를 설정해둔다. (canonical 설정)
개발
개발 현황 정리
설계에 따라서 변경해야할 부분을 파악하고, 변경할 부분을 정리 하고 변경하려고 합니다. 변경해야할 부분은 다음과 같습니다.
- ISR 부분
- 메타데이터 부분
- 리디렉션 부분
ISR 부분 현황 및 변경 개발
현재 저는 기존 포스트 페이지들을 ISR 형태로 (주기적으로 사용자에게 static site를 재빌드하여 페이지 제공) 제공하고 있었습니다. 그리고 ISR 형태로 제공할 때 next.js 의
generateStaticParam
api 를 통해 static site 의 리소스를 미리 빌드 타임에 빌드합니다. 이때, notion 에서 제공하는 JS API 라이브러리(notion를 사용하여 API 를 호출해 제가 만들어놓은 포스트들의 메타데이터를 가져와 그것을 기준으로
generateStaticParam
를 이용합니다. 현재는 id 를 기준으로 staticPath 를 만들지만 이제 title 형태로 변경해 staticPath 를 만들어야 합니다. 그래서 staticPath 를 만들 때는 변경할 것이 많지 않습니다.
변경 전
/** 생략... */ export const revalidate = 180; export async function generateStaticParams() { const posts = (await getNotionPosts()).map(Post.create); return posts.map(({ id }) => ( { postId: id, }) ); } /** 생략... */
변경 후
/** 생략... */ export const revalidate = 180; export async function generateStaticParams() { const posts = (await getNotionPosts()).map(Post.create); return posts.map(({ slugifiedTitle }) => ( { slug: slugifiedTitle, }) ); } /** 생략... */
변경될 페이지 부분 현황 및 변경 개발
변경될 페이지는 메타데이터 부분이 변경되어야 합니다. 가장 큰 것은 canonical 설정이 추가되어야 한다는 점이겠습니다.
변경 전
/** 생략... */ export async function generateMetadata({ params }: PostDetailPageProps) { const { title, content, tags } = await getNotionPostMetadata(params.postId); return { title, description: content, keywords: tags, }; } /** 생략... */
변경 후
/** 생략... */ export async function generateMetadata({ params }: PostDetailPageProps) { const postId = await slugToPostId(params.slug); const { title, content, tags } = await getNotionPostMetadata(postId); return { title, description: content, keywords: tags, alternates: { canonical: `${process.env.BLOG_URL}/posts/${slug(title)}`, }, }; } /** 생략... */
canonical 로 대표 URL 을 정하여 이 페이지가 리디렉션 URL 인 post-id 형태로 요청을 한다면, 대표 URL 은 이것이고, 301 리디렉션으로 페이지가 변경되었음을 검색엔진에게 알립니다.
리디렉션 부분
이는 next.js 의 middleware 를 통해 구현하였습니다. 기존에는 middleware 를 사용하지 않았으며 middleware 를 추가하였습니다.
변경 후
import { isNotionPageId } from "@/entities/posts/utils"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; // 미들웨어 전용 헬퍼 객체, 추가되는 포스트는 미들웨어를 통한 리디렉션 필요 없을 가능성 농후 const slugMapHelper = { "18주차-타입챌린지-스터디": "1e63c18b-cccb-8077-a44e-e0447ff198c7" /** ... 생략 */ }; const idMapHelper = Object.fromEntries( Object.entries(slugMapHelper).map(([slugifiedTitle, id]) => [ id, slugifiedTitle, ]), ); // This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const slugOrId = pathname.split("/posts/")[1]; if (!slugOrId) { return NextResponse.next(); } if (isNotionPageId(slugOrId)) { const slugifiedTitle = idMapHelper[slugOrId]; if (slugifiedTitle) { const url = request.nextUrl.clone(); url.pathname = `/posts/${slugifiedTitle}`; return NextResponse.redirect(url, 301); } } return NextResponse.next(); } export const config = { matcher: "/posts/:path*", };
isNotionPageId
는 path 파라미터로 들어온 값이 노션 페이지 ID 인지 확인하는 유틸함수 입니다. 그리고 redirect(url, 301)
처럼 301 만 넘겨주면 간단하게 처리 됩니다. 심지어 브라우저가 해당 URL 들을 캐시해 첫 리디렉션 이후엔 바로 캐시된 리소스를 제공합니다.개발중 새롭게 알게된 점
- 검색 엔진이 크롤링을 할 때는 정적 HTML 만을 크롤링 하는 것이 아닌, 서버 응답을 크롤링합니다.
- 영구적인 리디렉션의 경우 브라우저가 자동으로 캐시합니다. 이것 때문에 개발모드에서 리디렉션이 되지 않아야할 상황임에도 불구하고 정상적인 리디렉션이 되기도 했습니다.
적용 후
제가 원했던 방식이 잘 동작하게 되었습니다. 이제 기존에 있던
posts/[postId]
형태의 url 은 자동으로 리디렉션 되며, 검색엔진도 서서히 posts/[slug]
형태의 URL 을 통계에 잡기 시작합니다. 
결론
생각보다 효과는 미비하고, 사용자에게는 영향이 적었던 변경사항이지만, 개인적으로는 많은 학습이 되었던 기능 변경이었습니다. 한번 올렸던 기록들이 초기화될까 무서워서 신중히 접근했었지만, 역시나 미리 걱정 할 필요는 없던 것 같습니다. 제가 이 기능 변경을 통해서 배운 점을 위에 적어두긴 했지만, 내용을 추가해 다시 적어보자면 이렇습니다.
- 검색엔진은 반드시 정적인 리소스만 크롤링 하는 것이 아니라, 서버의 모든 응답을 크롤링 합니다.
- 그러므로, URL 을 변경할 것이라면, 이전 URL 을 미리 빌드해두어 정적 리소스 형태로 둘 필요가 없습니다.
- 그저, URL 변경을 명확하게 검색엔진에게 전해주면 됩니다. 이는 딱 두가지만 해주면 됩니다. 301 영구 리디렉션, 리소스 메타데이터에 canonical 설정
- slug 형태로 관리하면 여러 문제가 있기에 내부적으로는 id 형태로 돌려 사용하는 것이 필요합니다.
- 브라우저는 301 리디렉션 페이지를 만나면 자동으로 캐시 할 수 있습니다. 이는, 이러한 개발을 하며 디버깅 할 때 알아두면 좋을 것 같습니다.
마무리가 조금 중구난방이 된 것 같네요. 읽어주셔서 감사합니다.