icon

메티의 블로그


Next.js Notion 블로그에서 유저 친화적인 slug URL로 전환한 과정

Next.js Notion 블로그에서 유저 친화적인 slug URL로 전환한 과정

Next.js Notion 블로그에서 유저 친화적인 slug URL로 전환한 과정

Tags
SEO
Next.js
날짜
May 1, 2025
상태
공개

개요

저는 Notion 연동 블로그를 직접 개발하면서 나름의 검색 엔진에서의 실적을 올리고 있습니다. 그런데, 저는 이번에 다른 블로그들을 보면서, URL 가 포스트의 제목인 것을 보았습니다. 정말 깔끔한 것이, 제 블로그도 이렇게 변경해야겠다 마음 먹었죠. 기존의 제 블로그의 URL 는 페이지의 id 를 직접 노출한 형태였거든요. 그런데, 작업을 해보니 생각보다 고려해야할 것들이 좀 있었습니다.

미리 요약

본 포스트의 분량이 굉장히 길어졌기 때문에 중요한 점을 먼저 적어둡니다. 이에 대한 근거 및 실험이 아래 과정에 있다고 보시면 좋을 것 같습니다. 이미 서비스되어 검색엔진이 크롤링하고 로그를 쌓고 있는 페이지의 URL 을 변경 시 어떻게 하는 것이 좋았는지 정리합니다.
  1. 검색엔진은 반드시 정적인 리소스만 크롤링 하는 것이 아니라, 서버의 모든 응답을 크롤링 합니다.
  1. 그러므로, URL 이 변경될 때 서버는 리디렉션 응답을 명확하게 주고, 리디렉션된 리소스에 명확한 표시만 해둔다면 로그들을 유지하며 URL 을 변경 할 수 있습니다. (301 리디렉션 & canonical 설정)
  1. slug 형태의 URL 을 사용하더라도, 내부적으로는 id 형태로 리소스를 관리하는 것이 좋습니다.
  1. 영구적인 301 리디렉션의 경우 브라우저가 자동으로 캐시합니다. 물론, 시크릿 모드로 개발하신다면 신경 쓰지 않아도 됩니다.

목표

이번 목표는 다음과 같았습니다.
  1. 기존 운영 중에 있는 노션 블로그의 블로그 포스트의 /posts/{post-id} 형태의 URL 를 /posts/{title} 형태로 제공한다.
  1. 지금까지 SEO 를 해두었기 때문에 이 실적을 유지한다.
  1. 현재 제공되고있는 서비스는 이용자들에게 변경이 없는 것 처럼 느껴지게 한다. (구글에서 검색된 링크를 타고 들어오면 변경한 URL 로 리디렉션)
 
지금까지의 URL 은 다음과 같이 사람이 알아 볼 수 없는 형태였다면, 변경할 형태는 다음과 같습니다.
지금까지의 URL 은 다음과 같이 사람이 알아 볼 수 없는 형태였다면, 변경할 형태는 다음과 같습니다.
 
그리고 이 변경을 통해 얻는 이점은 다음과 같았습니다.
  1. 포스트 관리 용이: GSC(Google Search Console) 에서나 Vercel 을 통해 통계를 모니터링 할 때, URL 을 직접 보여주곤 합니다. 하지만, 인기있는 게시글이나 이런 것을 확인 할 때는 post-id 만 적혀있으면 어떤 게시글이 인기가 있는지 알 수가 없습니다. 이를 개선 할 수 있을 것이라 생각 했습니다.
  1. SEO 점수 개선: 검색 엔진이 직접 URL 을 평가하지는 않지만, 구글 URL 설계 권장 사항에 사용자가 읽기 좋은 URL 이라면 공유할 때 더 신뢰가 가기 때문에 긍정적인 효과가 있다고 적혀있습니다. 이를 통해 간접적인 점수 개선이 일어날 것을 기대했습니다.
  1. 보안: 노션의 페이지 id 가 노출되는 것은 사실 그렇게 큰 문제가 될 일은 아니지만, 그래도 어떤 엔티티의 id 가 직접 노출되는 것은 바람직하지 않다고 생각했습니다. 결국 제 notion 정보를 탈취 당하면 제 페이지를 누군가 강제로 수정 할 수 있으니까요.

설계

이번에는 좀 걱정되는 것이 많았습니다. 지금까지의 실적을 잃고싶지는 않았거든요. 하지만, 한번 잘못되면 되돌리기도 어려우니 여러가지를 우선적으로 조사했습니다. 우선, 다음과 같은 방식으로 변경하는 것이 가장 좋다고 결론을 내렸습니다.

설계 목표

  1. SEO 관리 시 중복 URL로 판별이 난다면 SEO 점수에 직접적으로 악영향이 가기 때문에, 301 리디렉션으로 컨텐츠가 영구적으로 리디렉션 되었다는 것을 검색 엔진에게 알립니다.
  1. 기존 페이지는 현재 이미 배포되어 실적을 내고 있으므로 사용자는 중간에 검색엔진에서 제공받던 페이지를 끊김없이 받을 수 있어야합니다.
  1. 결국 post-id 로 페이지 상세 정보를 검색 해야하기 때문에, 브라우저에서 /posts/{slug} 형태로 호출 하더라도 내부적으로는 slug 를 post-id 로 변환해 관리하도록 합니다.
  1. slug 는 URL 친화적으로 변경해야합니다. 사실 slug 를 한글로 제공하려는 것 자체는 URL 친화적이지는 않지만, 그 외의 부분을 최대한 맞추려고 합니다.

설계안

설계 목표에 맞게 설계를 하기 위해 현황을 먼저 파악하고 빠르게 문제 해결을 볼 수 있는 방법을 찾고자 했습니다.
 
현재 제가 공개한 포스트는 많지 않습니다. (40개 정도) 그러므로, notion api 호출을 최대한 줄이면서 효율적으로 notion 에 작성한 포스트들의 메타데이터를 통해 slug-id 변환하는 방법은 서버 내 파일 형태로 slug-id mapper 를 만들어두고, ISR 주기에 맞추어 파일을 업데이트하고, 이 파일을 기준으로 매핑하면 좋겠다는 생각을 했습니다. 이는 제가 공개한 포스트 양이 많아지면 변경해야하지만, 현 시점에서는 가장 부하가 적다는 장점이 있다고 생각했습니다.
 
그래서 저는 우선 베스트 시나리오는 다음과 같이 생각했습니다.
  1. notion API 를 호출해서 slug-id mapper 를 서버 내에 파일 형태로 저장 + ISR 처럼 주기적으로 파일을 업데이트 할 수 있다면 최고
  1. 이 파일을 기준으로 ISR 형태로 포스트 페이지들을 빌드한다. 또한, posts/[slug] 형태 이외에 이전에 posts/[postId] 도 미리 빌드해 검색엔진이 기존에 서비스하던 리소스를 잃지 않게 합니다.
  1. 이 파일을 fetch 해서 next.js 의 middleware 에서 posts/[param] 형태의 api 호출 시, id 인지 여부를 확인하고, posts/[postId] 면 permanent redirection 형태로 posts/[slug] redirect 해준다.
  1. SEO 를 위해 posts/[slug] 페이지에 필요한 메타데이터를 설정해둔다. (canonical 설정)
 
하지만, 실제 개발 중 두가지 문제가 생겼습니다.
 
첫번째 문제는 제가 생각한 베스트 시나리오였던 notion API 를 호출 후 slug-id mapper 를 json 형태로 public 에 파일로 저장하는 방식은 이를 로컬에서 빌드 후 테스트 할 때 까지는 middleware 를 통한 fetch 가 가능해 잘 동작 했지만, 실제 vercel 에 배포 할 때에는 middleware 를 빌드 할 때 문제가 생겼습니다. 이는 middlewarefetch 를 할 때 vercel 을 통해 호출 할 때는 edge 환경에서 호출하게 되는데, 이때 fetch api 의 호스트 주소를 잘 파악하지 못하는 문제가 있었습니다.
그리고, 두번째 문제는 검색엔진이 특정 포스트들을 중복된 리소스라는 판단하에 기존에 있던 posts/[postId] 리소스를 남겨두고 posts/[slug] 형태의 리소스는 크롤링을 하지 않은 것입니다.
일부 페이지가 중복페이지, Google 에서 사용자와 다른 표준을 선택함 이라는 사유로 크롤링 되지 않음
일부 페이지가 중복페이지, Google 에서 사용자와 다른 표준을 선택함 이라는 사유로 크롤링 되지 않음
이 사유는 제가 canonical 설정으로 표준 URL 을 설정해두었으나, 대표로 지정한 표준 URL 페이지가 현재 페이지와 다른 경우 입니다. 저는 처음에는 페이지를 중복해서 만든 것이 문제인 줄 알았으나, 이는 제가 포스트 내용을 수정하며 달라진 내용이 있어서 크롤링 하지 못한 것 입니다. 이는 시간이 지나 다시 컨텐츠를 동일하게 한 후 크롤러에게 다시 검사해달라고 요청 할 수 있습니다.
 
그래서 저는 설계를 조금 변경해야겠다고 생각했습니다. 301 리디렉션이 필요한 포스트가 40개 정도로 적으므로, 리디렉션을 도와줄 slug-id mapper 가 반드시 파일형태로 존재할 필요는 없다는 생각이 들었습니다. 그래서 리디렉션 할 때는 그냥 리디렉션이 필요한 현재 post 들을 일반 객체로 사용하여 검사하도록 변경하였습니다. 그리고, posts/[postId] 의 리소스들은 미리 빌드하지 않기로 했습니다.
 
최종 반영된 설계 안
  1. slug-id mapper 는 서버 컴포넌트 내에서만 필요하므로, react 의 cache api 를 통해 최대한 캐시하여 사용
  1. posts/[slug] 리소스만 미리 빌드하고, ISR 한다. posts/[postId] 형태의 리소스는 그냥 리디렉션 처리만 해두어도 괜찮다.
  1. middleware 에서 posts/[param] 형태의 api 호출 시, id 인지 여부를 확인하고, posts/[postId] 면 permanent redirection 형태로 posts/[slug] redirect 해준다. middleware 의 리디렉션이 필요한 포스트 개수가 적으므로 middleware 에서 사용할 mapper 는 하드코딩 한다.
  1. SEO 를 위해 posts/[slug] 페이지에 필요한 메타데이터를 설정해둔다. (canonical 설정)

개발

개발 현황 정리

설계에 따라서 변경해야할 부분을 파악하고, 변경할 부분을 정리 하고 변경하려고 합니다. 변경해야할 부분은 다음과 같습니다.
  1. ISR 부분
  1. 메타데이터 부분
  1. 리디렉션 부분

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, }) ); } /** 생략... */
변경 전 app/posts/[postId]/page.tsx 의 ISR 부분: 3분마다 static site 를 만들도록 하고, posts/[postId] 가 getNotionPosts() 에서 가져온 response 로 각 post 의 id 가 들어가 이 라우팅을 기준으로 페이지를 static 하게 만듭니다.

변경 후

/** 생략... */ export const revalidate = 180; export async function generateStaticParams() { const posts = (await getNotionPosts()).map(Post.create); return posts.map(({ slugifiedTitle }) => ( { slug: slugifiedTitle, }) ); } /** 생략... */
변경 후 app/posts/[slug]/page.tsx 의 ISR 부분: Post 클래스에서 title 을 slugifiedTitle 로 변경해주는 로직을 넣어두어 posts 객체는 이미 slugifiedTitle 꺼내서 쓸 수 있습니다.

변경될 페이지 부분 현황 및 변경 개발

변경될 페이지는 메타데이터 부분이 변경되어야 합니다. 가장 큰 것은 canonical 설정이 추가되어야 한다는 점이겠습니다.

변경 전

/** 생략... */ export async function generateMetadata({ params }: PostDetailPageProps) { const { title, content, tags } = await getNotionPostMetadata(params.postId); return { title, description: content, keywords: tags, }; } /** 생략... */
변경 전 app/posts/[postId]/page.tsx 의 메타데이터 부분

변경 후

/** 생략... */ 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)}`, }, }; } /** 생략... */
변경 후 app/posts/[slug]/page.tsx 의 메타데이터 부분
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*", };
middleware.ts 파일
isNotionPageId 는 path 파라미터로 들어온 값이 노션 페이지 ID 인지 확인하는 유틸함수 입니다. 그리고 redirect(url, 301) 처럼 301 만 넘겨주면 간단하게 처리 됩니다. 심지어 브라우저가 해당 URL 들을 캐시해 첫 리디렉션 이후엔 바로 캐시된 리소스를 제공합니다.

개발중 새롭게 알게된 점

  1. 검색 엔진이 크롤링을 할 때는 정적 HTML 만을 크롤링 하는 것이 아닌, 서버 응답을 크롤링합니다.
  1. 영구적인 리디렉션의 경우 브라우저가 자동으로 캐시합니다. 이것 때문에 개발모드에서 리디렉션이 되지 않아야할 상황임에도 불구하고 정상적인 리디렉션이 되기도 했습니다.

적용 후

제가 원했던 방식이 잘 동작하게 되었습니다. 이제 기존에 있던 posts/[postId] 형태의 url 은 자동으로 리디렉션 되며, 검색엔진도 서서히 posts/[slug] 형태의 URL 을 통계에 잡기 시작합니다.
아래 두개와 같은 형태의 URL 이 이제 슬슬 위의 URL 의 통계를 넘어서게 될 것입니다.
아래 두개와 같은 형태의 URL 이 이제 슬슬 위의 URL 의 통계를 넘어서게 될 것입니다.
 

결론

생각보다 효과는 미비하고, 사용자에게는 영향이 적었던 변경사항이지만, 개인적으로는 많은 학습이 되었던 기능 변경이었습니다. 한번 올렸던 기록들이 초기화될까 무서워서 신중히 접근했었지만, 역시나 미리 걱정 할 필요는 없던 것 같습니다. 제가 이 기능 변경을 통해서 배운 점을 위에 적어두긴 했지만, 내용을 추가해 다시 적어보자면 이렇습니다.
  1. 검색엔진은 반드시 정적인 리소스만 크롤링 하는 것이 아니라, 서버의 모든 응답을 크롤링 합니다.
  1. 그러므로, URL 을 변경할 것이라면, 이전 URL 을 미리 빌드해두어 정적 리소스 형태로 둘 필요가 없습니다.
  1. 그저, URL 변경을 명확하게 검색엔진에게 전해주면 됩니다. 이는 딱 두가지만 해주면 됩니다. 301 영구 리디렉션, 리소스 메타데이터에 canonical 설정
  1. slug 형태로 관리하면 여러 문제가 있기에 내부적으로는 id 형태로 돌려 사용하는 것이 필요합니다.
  1. 브라우저는 301 리디렉션 페이지를 만나면 자동으로 캐시 할 수 있습니다. 이는, 이러한 개발을 하며 디버깅 할 때 알아두면 좋을 것 같습니다.
 
마무리가 조금 중구난방이 된 것 같네요. 읽어주셔서 감사합니다.