희곡 낭독 매칭 서비스에서 작품은 지금까지 작가 본인과 관리자만 볼 수 있었습니다. 홈 화면은 데이터 조회가 전혀 없는 정적 랜딩이었고, 로그인하지 않은 방문자에게 보이는 작품 정보는 하나도 없었습니다. 이번 요구는 작가가 원하는 작품을 스스로 공개로 전환해서, 로그인 없이도 제목·작가명· 로그라인·시놉시스·추가정보를 볼 수 있게 하는 것이었습니다. 대본 파일, 페이지 수, 등장인물 구성, 이메일은 절대 나가면 안 됩니다.

이 글은 그 기능을 만들면서 정한 경계 세 가지와, 미리보기 배포에서 빌드가 깨진 이유, 그리고 QA 중에 요구가 계속 바뀌는 동안 설계가 어디까지 버텼는지에 대한 기록입니다.

전제: "공개"라는 개념이 라우트 단위로만 있었다

코드를 읽고 확인한 것들입니다.

  • 작품 모델에 공개 여부 필드가 없었습니다. "공개"는 프록시(Next.js 16의 미들웨어 상당물)의 화이트리스트에 경로를 넣는 것, 즉 라우트 단위로만 존재했습니다.
  • 작가용 작품 상세 페이지가 없었습니다. 목록 카드 전체가 수정 페이지로 가는 링크였습니다. 카드 안에 토글 스위치를 두면 클릭이 곧 내비게이션이 되므로 카드 구조를 바꿔야 했습니다.
  • 공개 페이지의 데이터 패턴은 FAQ 페이지 하나뿐이었고, export const revalidate로 빌드 시 Prisma를 직접 조회하고 있었습니다.
  • 미리보기(Preview) 배포는 운영 DB를 그대로 쓰고, 마이그레이션은 production 배포에서만 실행됩니다. 이 사실이 나중에 빌드를 깨뜨립니다.

기획 단계에서 네 가지를 물었고 답은 이랬습니다. 상세 페이지는 만든다, 토글은 작품 관리 카드와 수정 페이지 둘 다에 둔다, 작가 필터는 이름 검색으로 시작한다, 관리자 승인 없이 작가가 켜면 즉시 공개한다.

경계 1: 노출 필드는 select 상수 하나가 결정한다

비로그인 페이지에서 가장 무서운 건 "컬럼이 늘었는데 모르고 새어 나가는" 경우입니다. 그래서 공개 조회가 쓰는 select를 서비스 파일 하나에 상수로 두고, 목록·상세·홈 세 조회가 전부 이것만 쓰게 했습니다.

const PUBLIC_CREATOR_SELECT = {
    select: { id: true, activityName: true, name: true },
} satisfies Prisma.UserDefaultArgs;

/** 상세 페이지: 시놉시스·추가정보 포함 */
export const PUBLIC_WORK_SELECT = {
    id: true, title: true, logline: true, synopsis: true, extraInfo: true,
    publishedAt: true, creator: PUBLIC_CREATOR_SELECT,
} satisfies Prisma.WorkSelect;

/** 목록·홈 카드: 추가정보는 상세에서만 */
export const PUBLIC_WORK_CARD_SELECT = {
    id: true, title: true, logline: true, synopsis: true,
    publishedAt: true, creator: PUBLIC_CREATOR_SELECT,
} satisfies Prisma.WorkSelect;

그리고 테스트가 이 상수에 금지 필드가 없다는 것을 고정합니다.

const forbidden = ["scriptUrl", "scriptName", "pageCount", "maleCount",
                   "femaleCount", "anyCount", "averageScore", "createdAt"];
for (const key of forbidden) {
    expect(key in select, `${key} 가 노출된다`).toBe(false);
}
expect("email" in select.creator.select).toBe(false);

include: { work: true }처럼 전체를 가져와서 화면에서 골라 쓰는 방식은 여기서는 쓰지 않았습니다. 그 방식은 새 컬럼이 자동으로 응답에 실리는데, 로그인한 작가 본인에게 보내는 응답이라면 상관없지만 비로그인 HTML에 실리면 곤란합니다. 실제로 QA에서 비로그인 curl로 목록·상세 HTML을 받아 scriptProxyUrl, 이메일, pageCount 문자열이 0건인지 확인했습니다.

경계 2: 공개 API를 만들지 않는다

처음에는 GET /api/works/public 같은 걸 열고 페이지가 그걸 부르는 구조를 떠올렸습니다. 버렸습니다. 공개 페이지는 서버 컴포넌트이고 Prisma를 직접 부를 수 있으니, API를 하나 더 열면 노출 표면만 늘어납니다. 프록시 화이트리스트에는 페이지 경로 둘만 넣었습니다.

const PUBLIC_PAGE_PATHS = new Set<string>([
    "/", "/login", "/signup", /* ... */
    "/works", // 공개 작품 목록 — 페이지가 Prisma 를 직접 조회하므로 공개 API 는 없다
]);
const PUBLIC_PAGE_PATTERNS = [
    /^\/guide(?:\/|$)/,
    /^\/works\/[^/]+$/, // /works/[id] — 하위 세그먼트 1개만; /works/x/edit 는 비공개
];

패턴을 [^/]+$로 좁힌 이유는 이 저장소의 오래된 교훈 때문입니다. 프리픽스 매칭으로 공개 경로를 잡으면 /login-callback 같은 신규 라우트가 인증을 우회합니다. 그래서 테스트에 /works-admin, /worksheet, /works/a/b, /works/abc/edit가 공개가 아니라는 케이스를 함께 넣었습니다. 공개 전환 자체는 PATCH /api/works/[id]/visibility라는 로그인 필수 라우트가 맡고, 소유자인지 관리자인지는 서비스가 판정합니다.

경계 3: publishedAt은 비공개로 돌리면 null이 된다

공개 시각을 어떻게 다룰지 작은 결정이 하나 있었습니다. 비공개로 전환할 때 publishedAt을 남겨둘 것인가, 지울 것인가. 지우기로 했습니다.

  • 불변식 isPublic ⇒ publishedAt ≠ null이 성립하면 공개 목록의 orderBy: { publishedAt: "desc" }에 null 처리가 필요 없습니다.
  • 작가가 작품을 손봐서 다시 공개하면 목록 맨 위로 올라옵니다. 그게 기대 동작에 가깝습니다.
  • 같은 상태로 다시 요청하면 쓰기를 건너뜁니다. 재요청 때마다 publishedAt이 갱신되어 순서가 흔들리는 걸 막습니다.
if (work.isPublic === isPublic) {
    return { id: work.id, isPublic: work.isPublic, publishedAt: work.publishedAt };
}
return withTransaction((tx) =>
    tx.work.update({
        where: { id: workId },
        data: { isPublic, publishedAt: isPublic ? new Date() : null },
        select: { id: true, isPublic: true, publishedAt: true },
    })
);

"최초 공개일"이 나중에 필요해지면 firstPublishedAt을 따로 두면 됩니다. 지금 하나의 컬럼에 두 의미를 싣지 않았습니다.

캐시: 홈만 캐시하고, 목록과 상세는 하지 않는다

홈 화면의 "지금 공개된 작품" 섹션은 unstable_cache로 감싸고 60초 TTL을 뒀습니다. 필터도 페이지도 없는 불변 집계라 캐시 대상에 맞습니다. 공개 토글, 공개 작품의 수정·삭제 라우트는 커밋 후 revalidateTag(tag, { expire: 0 })를 호출해 즉시 비웁니다. 지난 포인트 작업에서 "max" 프로파일이 stale 표시만 하고 첫 읽기는 옛 값을 돌려준다는 걸 배운 뒤로, 이 저장소의 이벤트 무효화는 전부 { expire: 0 }입니다.

반대로 /works 목록과 상세는 검색어·페이지·id에 의존하므로 캐시하지 않습니다. 캐시 키에서 파라미터를 하나 빠뜨리면 다른 검색 결과가 섞여 나오는데, 그 버그는 테스트로 잡기 어렵습니다. 매 요청 2쿼리(findMany + count)면 충분합니다.

캐시 경계를 넘는 값은 primitive만 두는 규칙도 지켰습니다. 카드에 시놉시스 발췌를 넣자는 요구가 QA 중에 왔을 때, 시놉시스 전문을 캐시에 싣는 대신 서버에서 140자로 잘라 synopsisExcerpt 문자열만 넘겼습니다. 홈 캐시 항목 하나가 작품 6개 × 전문이 되는 걸 피한 것입니다.

함정: 미리보기 배포가 운영 DB를 쓰는데, 빌드가 새 컬럼을 읽는다

PR을 올리자 CI는 통과했는데 Vercel 미리보기 빌드가 실패했습니다. 원인은 앞에서 적은 전제 두 개가 만나서 생긴 것이었습니다.

  1. 홈 화면은 revalidate = 60으로 빌드 시 프리렌더되고, 이제 공개 작품을 조회합니다.
  2. 미리보기 배포는 운영 DB를 쓰는데, 마이그레이션은 production에서만 돌아갑니다.

즉 미리보기 빌드가 isPublic 컬럼이 아직 없는 운영 DB에 where: { isPublic: true }를 날린 것입니다. 지금까지 스키마를 바꾼 PR들이 이 문제를 겪지 않았던 이유는 새 컬럼을 동적 페이지에서만 썼기 때문입니다. 동적 페이지는 미리보기 런타임에서 500이 나지만 빌드는 통과합니다.

선택지는 홈을 동적으로 만들거나(정적 홈을 포기), 마이그레이션 정책을 바꾸거나(미리보기가 운영 DB를 건드리게 됨), 이 조회만 "컬럼 없음"을 견디게 하는 것이었습니다. 세 번째로 했습니다.

try {
    rows = await prisma.work.findMany({ where: { isPublic: true }, /* ... */ });
} catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2022") {
        console.warn("[stats-cache] Work 공개 컬럼이 아직 없어 홈 공개 작품 섹션을 건너뜁니다.");
        return [];
    }
    throw error;
}

P2022(column does not exist)만 잡고 나머지는 그대로 던집니다. DB 연결 실패나 다른 에러로 빌드가 조용히 통과하는 건 원하지 않았습니다. 홈 섹션은 원래 0건이면 렌더하지 않으므로 화면에는 아무 흔적이 없고, production 배포는 migrate deploynext build보다 먼저 실행되므로 정상 노출됩니다.

이걸 로컬에서 재현하려고 pg_dump로 DB 복제본을 만들고 컬럼 세 개를 DROP한 뒤 그 DB로 next build를 돌렸습니다. 경고 한 줄만 남기고 68페이지 프리렌더가 통과했습니다. 배포 문서에도 규칙을 한 줄 추가했습니다. 빌드 시 조회되는 정적 페이지가 새 컬럼을 쓰면 P2022를 흡수해야 한다고.

곁가지: React 19.2 개발 모드가 redirect() 페이지에서 내는 에러

QA 중에 /creator를 열 때 브라우저 콘솔에 이런 에러가 떴습니다.

Uncaught TypeError: Failed to execute 'measure' on 'Performance':
'CreatorPage' cannot have a negative time stamp.

/creator/actor는 렌더 즉시 redirect()를 던지는 리다이렉트 전용 페이지였습니다. React 19.2 개발 빌드는 컴포넌트마다 performance.measure로 렌더 시간을 기록하는데, 렌더가 중단된 컴포넌트는 종료 시각이 음수로 계산되어 브라우저가 호출을 거부합니다. React와 Next.js 저장소에 같은 증상이 보고되어 있고, 프로덕션 빌드에는 없는 코드입니다.

무시해도 되는 에러였지만 이 페이지들은 애초에 비용이 있었습니다. 매 로그인마다 /creator를 거치면서 레이아웃의 세션 조회와 포인트 요약 조회가 한 번 헛돌고 있었습니다. 별도 PR로 두 페이지를 지우고 next.configredirects()로 옮겼습니다.

async redirects() {
    return [
        { source: "/creator", destination: "/creator/works", permanent: false },
        { source: "/actor", destination: "/actor/schedules", permanent: false },
    ];
},

Next.js 파이프라인은 headers → redirects → 미들웨어 → rewrites 순이라 리다이렉트가 프록시보다 먼저 응답합니다. 인가는 목적지에서 프록시가 그대로 수행하니 보안 경계는 변하지 않습니다. 307을 쓴 이유는 308이 브라우저에 영구 캐시되어, 나중에 /creator에 진짜 대시보드를 만들어도 기존 사용자는 계속 /creator/works로 튀기 때문입니다.

QA에서 요구가 열 번 넘게 바뀌었다

기능이 화면에 뜨자 요구가 계속 왔습니다. 추가정보를 배우 화면에도, 작가의 일정 등록 화면에도, 관리자 화면에도 보여 달라. 시놉시스 입력란을 키워 달라. 상세 화면 섹션 제목에 포인트를 주되 작가 색인 녹색으로. 같은 작가의 다른 작품을 연결해 달라. 카드에 시놉시스 일부를 넣어 달라. 카드의 링크 영역을 넓혀 달라. 메뉴를 맨 앞으로.

이 중에서 설계와 부딪힌 것이 둘 있었습니다.

문구. 홈 섹션과 상세 하단에 "마음에 드는 작품의 리딩에 배우로 참여해 보세요"라고 써 두었는데, 공개된 작품에 리딩 일정이 없는 경우가 많다는 지적이 왔습니다. 고치면서 "일정이 열리면 안내됩니다" 로 쓰려다 확인해 보니 새 일정 알림 기능이 없었습니다. 알림 종류는 확정과 리뷰 요청뿐입니다. 결국 "리딩 일정은 작가가 별도로 등록합니다. 배우로 가입하면 열린 리딩 일정을 둘러보고 지원할 수 있습니다"로 썼습니다. 문구 하나도 코드가 실제로 하는 일을 넘어서 약속하지 않게.

작가 필터. 처음에는 카드의 작가명을 누르면 /works?query=작가명으로 이름 검색을 했습니다. 동명 작가가 섞이고, 이름이 없는 작가는 링크를 걸 수 없었습니다. ?author=<creatorId> 정확 필터로 바꿨는데, 여기서 열거 문제가 하나 생깁니다. 헤더에 "OO 작가의 작품"을 띄우려면 id로 이름을 조회해야 하는데, 아무 사용자 id나 URL에 넣으면 비공개 사용자의 이름이 나옵니다. 그래서 조회 조건을 공개 작품이 1건 이상인 사용자로 좁혔습니다.

export async function getPublicAuthor(id: string) {
    return prisma.user.findFirst({
        where: { id, createdWorks: { some: { isPublic: true } } },
        select: { id: true, activityName: true, name: true },
    });
}

배우 계정의 id를 넣어 이름이 0건인 걸 curl로 확인했습니다.

요구가 바뀔 때마다 고친 곳을 세어 보면, 노출 필드는 select 상수 한 곳, 검색 조건은 where 빌더 한 곳, 홈 데이터는 캐시 헬퍼 한 곳이었습니다. 카드 컴포넌트가 목록·홈·상세 하단 세 곳에서 같이 쓰이니 발췌를 넣거나 링크 영역을 넓히는 것도 한 번이면 됐습니다. 설계에 쓴 시간이 여기서 돌아왔습니다.

곁가지 둘: 검색 초기화를 눌러도 검색창에 글자가 남는다

공용 검색 입력 컴포넌트가 URL의 query를 마운트 때 한 번만 읽어 상태로 들고 있었습니다. "검색 초기화" 링크로 query가 사라져도 컴포넌트는 그대로 살아 있으니 입력값이 남습니다. URL을 진실 원천으로 두고 바뀔 때 동기화하도록 고쳤습니다.

const urlQuery = searchParams.get("query") || "";
const [searchTerm, setSearchTerm] = useState(urlQuery);
useEffect(() => {
    setSearchTerm(urlQuery);
}, [urlQuery]);

이 컴포넌트는 작가와 배우의 일정 목록에서도 쓰고 있었으니, 거기서도 같은 버그가 있었을 것입니다.

마무리

브랜치는 19커밋, 55파일, 테스트는 90파일 649건 통과로 develop에 머지했습니다. 마이그레이션은 컬럼 세 개와 인덱스 하나를 추가하는 것뿐이고 백필이 없어서, 배포 직후에는 아무 작품도 공개 상태가 아니고 홈 화면도 그대로입니다. 작가가 처음 스위치를 켜는 순간부터 보이기 시작합니다.

돌아보면 이번 작업의 값은 기능 자체보다 경계 세 개를 먼저 그은 데 있었습니다. 어디까지 나가는지 (select 상수), 어디로 들어오는지(공개 API 없음, 페이지 경로 둘), 언제부터인지(publishedAt 불변식). 그 뒤에 온 요구 변경은 대부분 그 경계 안에서 한 곳만 고치면 됐습니다. 미리보기 빌드가 깨진 건 경계 밖에서 온 문제였고, 그건 환경의 전제를 문서에 한 줄 더 적는 것으로 끝났습니다.