소규모 기업 사이트는 넘기고 나면 꼭 "이 문구 좀 바꿔 주세요", "공지 하나 올려 주세요" 같은 요청이 들어옵니다. 그렇다고 이 규모에 관리자 시스템을 붙이면 배보다 배꼽이 큽니다. 관리자 화면을 만들고, 인증을 붙이고, 그 시스템을 유지보수하는 비용이 사이트보다 커집니다.
관리자 시스템을 만들지 않기로 했다
루와 기업 사이트를 리뉴얼할 때는 다르게 풀어 보기로 했습니다. 관리자 시스템을 만들지 않고, 클라이언트가 이미 매일 쓰는 Notion을 콘텐츠 원본으로 삼았습니다.
좋은 점은 분명했습니다.
- 관리자 화면 개발 비용이 0이 됩니다. 이미 잘 만들어진 에디터가 있습니다.
- 클라이언트가 새 도구를 배울 필요가 없습니다. 늘 쓰던 화면에서 문서를 고치면 됩니다.
- 권한 관리, 이미지 업로드, 협업 편집 같은 것을 전부 Notion이 해 줍니다.
- 백엔드가 필요 없습니다. Next.js에서 API를 호출해 읽기만 하면 됩니다.
매 요청마다 호출하지 않는다
가장 먼저 부딪힌 것은 속도였습니다. Notion API는 빠르지 않고, 한 페이지를 그리는 데 여러 번 호출해야 하는 경우도 많습니다. 매 요청마다 부르면 사이트가 눈에 띄게 느려지고 호출 한도에도 걸립니다.
그래서 페이지를 정적으로 생성하고 주기적으로 다시 굽는 방식을 썼습니다. 사용자는 정적 페이지 속도로 보고, 클라이언트의 수정은 정해진 주기 안에 반영됩니다.
// 회사 소개처럼 거의 안 바뀌는 페이지
export const revalidate = 86400; // 하루
// 공지처럼 자주 바뀌는 목록
export const revalidate = 300; // 5분더 빠른 반영이 필요하면 Notion에서 저장했을 때 웹훅으로 revalidatePath를 부르는 방식도 쓸 수 있습니다. 다만 Notion에는 편집 웹훅이 없어서, 별도의 자동화 도구를 끼우거나 관리자용 재검증 버튼을 두는 식으로 우회하게 됩니다.
문제는 두 번째 사이트였다
첫 사이트가 잘 돌아가는 걸 보고, 계열사 사이트에도 같은 방식을 쓰기로 했습니다. 기간을 3주로 잡았고, 그 근거는 "앞 프로젝트 코드를 그대로 가져오면 된다"였습니다.
그런데 막상 옮겨 보니 재사용이 생각처럼 되지 않았습니다. 이런 코드가 곳곳에 있었기 때문입니다.
// 컴포넌트가 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 응답을 우리 도메인 타입으로 바꿔 주는 단계를 하나 두고, 화면 컴포넌트는 그 도메인 타입만 알게 했습니다.
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]),
};
};const NoticeItem = ({ notice }: { notice: Notice }) => (
<li>
{notice.title}
{notice.category && <span>{notice.category}</span>}
</li>
);이렇게 나누고 나니 세 번째 사이트를 만든다면 FIELD 매핑과 변환 함수만 손보면 되는 상태가 됐습니다. 화면 컴포넌트는 그대로 옮겨집니다.
값이 비어 있어도 사이트가 죽지 않게 됐다
어댑터를 두면서 예상하지 못한 이득이 하나 있었습니다. 클라이언트가 Notion에서 제목을 비워 두거나 속성을 지워도 사이트가 죽지 않게 됐다는 것입니다.
화면 컴포넌트가 직접 properties["제목"].title[0].plain_text를 읽던 때는, 클라이언트가 실수로 행 하나를 비워 두면 그 페이지 전체가 렌더링에 실패했습니다. 어댑터에서 기본값을 주도록 바꾸니 그런 일이 사라졌습니다.