소규모 기업 사이트를 만들면 반드시 겪는 흐름이 있습니다. 사이트를 넘기고 나면 "이 문구 좀 바꿔 주세요", "공지 하나 올려 주세요" 같은 요청이 계속 들어옵니다. 그렇다고 이 규모에 관리자 시스템을 붙이면 배보다 배꼽이 큽니다. 관리자 화면을 만드는 비용, 인증을 붙이는 비용, 그리고 그 시스템 자체를 유지보수하는 비용이 사이트보다 커집니다.
만들지 않기로 한 결정
루와 기업 사이트를 리뉴얼할 때 이 문제를 다르게 풀어 보기로 했습니다. 관리자 시스템을 만들지 않고, 클라이언트가 이미 매일 쓰고 있는 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를 읽던 때는, 클라이언트가 실수로 행 하나를 비워 두면 그 페이지 전체가 렌더링에 실패했습니다. 어댑터에서 기본값을 주도록 바꾸니 그런 일이 사라졌습니다.
정리
- 만들지 않는 것도 설계입니다. 관리자 시스템을 만들지 않기로 한 결정이 그 프로젝트에서 가장 큰 기술적 판단이었습니다.
- 클라이언트가 이미 쓰는 도구에서 출발하면 학습 비용이 0이 됩니다.
- 외부 API 응답은 반드시 경계에서 우리 타입으로 변환합니다. 그 경계가 없으면 재사용은 말뿐입니다.
- 매 요청마다 외부 API를 부르지 않습니다. 콘텐츠 성격에 맞는 재검증 주기를 줍니다.
- 재사용 가능성은 두 번째 프로젝트에서 검증됩니다. 첫 번째에서 아무리 잘 만들어도 알 수 없습니다.