본문으로 건너뛰기
DEVKBS
개발 노트
아키텍처읽는 데 약 5

Notion을 CMS로 쓰기, 그리고 두 번째 프로젝트에서 알게 된 것

관리자 시스템을 만들지 않고 기업 사이트를 넘기는 방법. 첫 프로젝트는 잘 됐고, 두 번째 프로젝트에서 무엇이 부족했는지 드러났습니다.

#Notion API#CMS#Next.js#어댑터

소규모 기업 사이트를 만들면 반드시 겪는 흐름이 있습니다. 사이트를 넘기고 나면 "이 문구 좀 바꿔 주세요", "공지 하나 올려 주세요" 같은 요청이 계속 들어옵니다. 그렇다고 이 규모에 관리자 시스템을 붙이면 배보다 배꼽이 큽니다. 관리자 화면을 만드는 비용, 인증을 붙이는 비용, 그리고 그 시스템 자체를 유지보수하는 비용이 사이트보다 커집니다.

만들지 않기로 한 결정

루와 기업 사이트를 리뉴얼할 때 이 문제를 다르게 풀어 보기로 했습니다. 관리자 시스템을 만들지 않고, 클라이언트가 이미 매일 쓰고 있는 Notion을 콘텐츠 원본으로 삼는 것입니다.

이 선택의 이점은 명확했습니다.

  • 관리자 화면 개발 비용이 0이 됩니다. 이미 잘 만들어진 에디터가 있습니다.
  • 클라이언트가 새 도구를 배울 필요가 없습니다. 늘 쓰던 화면에서 문서를 고치면 됩니다.
  • 권한 관리, 이미지 업로드, 협업 편집 같은 것을 전부 Notion이 해 줍니다.
  • 백엔드가 필요 없습니다. Next.js에서 API를 호출해 읽기만 하면 됩니다.

매 요청마다 호출하지 않는다

가장 먼저 부딪힌 것은 속도였습니다. Notion API는 빠르지 않고, 한 페이지를 그리는 데 여러 번 호출해야 하는 경우도 많습니다. 매 요청마다 부르면 사이트가 눈에 띄게 느려지고 호출 한도에도 걸립니다.

그래서 페이지를 정적으로 생성하고 주기적으로 다시 굽는 방식을 썼습니다. 사용자는 정적 페이지 속도로 보고, 클라이언트의 수정은 정해진 주기 안에 반영됩니다.

ts성격에 따라 주기를 다르게 준다
// 회사 소개처럼 거의 안 바뀌는 페이지
export const revalidate = 86400; // 하루

// 공지처럼 자주 바뀌는 목록
export const revalidate = 300;   // 5분

더 빠른 반영이 필요하면 Notion에서 저장했을 때 웹훅으로 revalidatePath를 부르는 방식도 쓸 수 있습니다. 다만 Notion에는 편집 웹훅이 없어서, 별도의 자동화 도구를 끼우거나 관리자용 재검증 버튼을 두는 식으로 우회하게 됩니다.

여기까지는 잘 됐습니다. 문제는 두 번째 사이트였다

첫 사이트가 잘 돌아가는 걸 보고, 계열사 사이트에도 같은 방식을 쓰기로 했습니다. 기간을 3주로 잡았고, 그 근거는 "앞 프로젝트 코드를 그대로 가져오면 된다"였습니다.

그런데 막상 옮겨 보니 재사용이 생각만큼 자동으로 되지 않았습니다. 문제는 이런 코드가 곳곳에 있었다는 것입니다.

tsx화면 컴포넌트 안에 노션 스키마가 박혀 있다
// 컴포넌트가 Notion 응답 구조를 그대로 알고 있다
const NoticeItem = ({ page }: { page: PageObjectResponse }) => {
  const title = page.properties["제목"].title[0]?.plain_text;
  const date = page.properties["작성일"].date?.start;
  const category = page.properties["분류"].select?.name;

  return <li>{title} · {category} · {date}</li>;
};

계열사의 Notion 데이터베이스는 속성 이름이 조금 달랐습니다. 제목타이틀이고, 분류가 없는 대신 태그가 있었습니다. 이 차이 하나 때문에 화면 컴포넌트를 여기저기 헤집어야 했습니다. 앞 프로젝트에서 잘 만들었다고 생각한 컴포넌트가 두 번째 프로젝트에서 가장 손이 많이 가는 부분이 됐습니다.

'재사용 가능한 코드'라는 말은, 실제로 다른 프로젝트에 옮겨 보기 전까지 검증되지 않습니다.

고친 방식: 어댑터 층을 세운다

핵심은 외부 서비스의 스키마가 우리 코드 깊숙이 들어오지 못하게 막는 것입니다. 그래서 Notion 응답을 우리 도메인 타입으로 변환하는 단계를 하나 두고, 화면 컴포넌트는 그 도메인 타입만 알게 했습니다.

tslib/notion/adapter.ts — 노션 속성 이름은 여기에만 존재한다
import type { PageObjectResponse } from "@notionhq/client";

/** 우리 도메인 타입 — 노션을 모른다 */
export interface Notice {
  id: string;
  title: string;
  category: string | null;
  publishedAt: string | null;
}

/** 사이트마다 이 매핑만 바꾸면 된다 */
const FIELD = {
  title: "제목",
  category: "분류",
  publishedAt: "작성일",
} as const;

export const toNotice = (page: PageObjectResponse): Notice => {
  const p = page.properties;
  return {
    id: page.id,
    title: readTitle(p[FIELD.title]) ?? "(제목 없음)",
    category: readSelect(p[FIELD.category]),
    publishedAt: readDate(p[FIELD.publishedAt]),
  };
};
tsx화면은 도메인 타입만 본다 — 노션이 바뀌어도 그대로다
const NoticeItem = ({ notice }: { notice: Notice }) => (
  <li>
    {notice.title}
    {notice.category && <span>{notice.category}</span>}
  </li>
);

이렇게 나누고 나니 세 번째 사이트를 만든다면 FIELD 매핑과 변환 함수만 손보면 되는 상태가 됐습니다. 화면 컴포넌트는 그대로 옮겨집니다.

부수 효과: 결측에 강해졌다

어댑터를 두면서 예상하지 못한 이득이 하나 있었습니다. 클라이언트가 Notion에서 제목을 비워 두거나 속성을 지워도 사이트가 죽지 않게 됐다는 것입니다.

화면 컴포넌트가 직접 properties["제목"].title[0].plain_text를 읽던 때는, 클라이언트가 실수로 행 하나를 비워 두면 그 페이지 전체가 렌더링에 실패했습니다. 어댑터에서 기본값을 주도록 바꾸니 그런 일이 사라졌습니다.

정리

  • 만들지 않는 것도 설계입니다. 관리자 시스템을 만들지 않기로 한 결정이 그 프로젝트에서 가장 큰 기술적 판단이었습니다.
  • 클라이언트가 이미 쓰는 도구에서 출발하면 학습 비용이 0이 됩니다.
  • 외부 API 응답은 반드시 경계에서 우리 타입으로 변환합니다. 그 경계가 없으면 재사용은 말뿐입니다.
  • 매 요청마다 외부 API를 부르지 않습니다. 콘텐츠 성격에 맞는 재검증 주기를 줍니다.
  • 재사용 가능성은 두 번째 프로젝트에서 검증됩니다. 첫 번째에서 아무리 잘 만들어도 알 수 없습니다.

이 글이 나온 작업