모차르트 작품 카탈로그의 작품 상세 페이지에 "이 작품의 명반" 섹션을 붙였습니다. 작품마다 추천 음반을 보여주고, 교보문고·예스24에서 실제로 구매할 수 있게 제휴 링크를 연결하는 기능입니다. 링크는 링크프라이스 딥링크를 씁니다.

요구사항 하나가 처음부터 분명했습니다. 딥링크를 화면에 그대로 노출하지 말 것. 클릭하면 새 창으로 열리되, 페이지 소스 어디에도 실제 제휴 URL이 박히지 않아야 했습니다. 이 단순해 보이는 제약 하나가 설계의 방향을 정했고, 뒤이어 "그러면 정작 제휴 쿠키는 심기는가?"라는 더 본질적인 질문으로 이어졌습니다.

발단링크를 숨기라는 제약

제휴 딥링크는 보통 이렇게 생겼습니다.

https://click.linkprice.com/click.php?m=<머천트>&a=<제휴ID>&l=...&tu=<인코딩된 목적지>

여기엔 제휴 ID가 그대로 들어 있습니다. 이걸 <a href="...">에 넣거나 클라이언트 컴포넌트로 내려보내면, 우클릭·소스 보기·네트워크 탭에서 누구나 링크 전체를 긁어갈 수 있습니다. 제휴 ID를 떼어내고 목적지로만 가버리거나, 링크를 그대로 복사해 자기 ID로 바꿔치기하는 일도 가능합니다.

그래서 "링크를 화면에 두지 않는다"가 출발점이 됐습니다. 페이지는 음반의 id만 알고, 실제 딥링크는 서버만 안다. 클릭하는 순간 서버가 id로 딥링크를 찾아 대신 보내준다. 전형적인 리다이렉트 우회 패턴입니다.

설계id만 내려보내고, 리다이렉트로 우회한다

세 군데에 손을 댔습니다.

1. 공개 조회 쿼리에서 딥링크 자체를 제외했습니다. 관리자 화면에서는 딥링크를 편집해야 하니 전체 필드를 읽지만, 공개 상세 페이지용 조회는 딥링크 컬럼을 아예 select 하지 않도록 별도 include를 뒀습니다.

// 공개(상세 페이지)용 — 딥링크(linkUrl)는 절대 select 하지 않는다.
export const workIncludePublic = {
  ...workInclude,
  recommendedAlbums: {
    orderBy: { order: 'asc' as const },
    select: {
      id: true,
      channel: true,
      coverImageUrl: true,
      label: true,
      performers: true,
      releaseYear: true,
      order: true,
      // linkUrl 없음 — 데이터가 컴포넌트로 흐르기 전에 차단
    },
  },
};

이 결정이 중요했습니다. 처음엔 표시용 컴포넌트에서 딥링크를 빼는 식으로 막았는데, 작품 객체를 통째로 받는 다른 클라이언트 컴포넌트가 있으면 그쪽 RSC 페이로드로 딥링크가 새어 나갔습니다. 민감 필드를 숨기는 건 표현 계층이 아니라 데이터 계층에서 해야 안전합니다. 쿼리에서 빼버리면 그 뒤로는 어떤 컴포넌트도 만질 수 없습니다.

2. id로만 역참조하는 리다이렉트 라우트를 만들었습니다.

// GET /api/albums/[id]/go
export async function GET(_req, { params }) {
  const { id } = await params;
  const album = await getAlbumRedirectTarget(id); // 서비스 계층 경유
  if (!album) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  // 오픈 리다이렉트 방지: http(s) 절대 URL만 허용
  if (!/^https?:\/\//i.test(album.linkUrl)) {
    return NextResponse.json({ error: 'Invalid link' }, { status: 400 });
  }
  return NextResponse.redirect(album.linkUrl, 302);
}

3. 클릭은 앵커가 아니라 스크립트로 새 창을 엽니다.

function openAlbum(id: string) {
  window.open(`/api/albums/${id}/go`, '_blank', 'noopener,noreferrer');
}

이제 페이지 HTML에는 음반 id와 /api/albums/{id}/go 경로만 남습니다. 실제 딥링크는 서버 메모리 밖으로 나가지 않습니다. 빌드 후 소스를 grep 해봐도 제휴 URL은 0건이었습니다.

핵심 질문302로 보내면 쿠키가 심기는가

여기서 멈췄다면 큰일 날 뻔했습니다. 제휴 마케팅의 수익 귀속은 쿠키로 이뤄집니다. 사용자가 딥링크를 거치면 제휴 네트워크가 브라우저에 추적 쿠키를 심고, 이후 구매가 그 쿠키로 누구의 추천이었는지 가려집니다. 그런데 우리는 딥링크를 직접 클릭시키지 않고 302 리다이렉트로 한 번 우회합니다.

이 우회가 쿠키 어트리뷰션을 깨뜨리면, 링크를 숨긴 대가로 수익 추적을 통째로 잃습니다. 기능이 동작하는 것처럼 보여도 정산은 0원이 되는, 가장 조용하고 치명적인 버그입니다.

핵심은 누가 리다이렉트를 따라가느냐입니다.

  • 서버가 딥링크를 fetch로 직접 따라가면, 쿠키는 서버에 심깁니다. 사용자 브라우저는 아무것도 받지 못하고 어트리뷰션은 깨집니다.
  • 반면 서버가 302를 브라우저에 돌려주고 브라우저가 그걸 따라가면, 쿠키는 사용자 브라우저에 심깁니다. 사용자가 raw 링크를 직접 클릭한 것과 동일합니다.

우리 라우트는 후자입니다. NextResponse.redirect로 302만 돌려주고 끝냅니다. 그 뒤 체인은 전부 브라우저가 수행합니다. 이론은 맞는데, 돈이 걸린 문제라 실측하기로 했습니다.

실제 브라우저로 쿠키 체인 측정

헤드리스 브라우저로 실제 딥링크를 따라가게 한 뒤, 최종 도착지와 브라우저에 저장된 쿠키를 덤프했습니다. 리다이렉트 체인은 이렇게 흘렀습니다.

click.linkprice.com/click.php?...
   → 302 (Set-Cookie: LPUID=...; domain=.linkprice.com)
order.kyobobook.co.kr/link/linkprice?lpinfo=<제휴ID>|...
   → 200 (제휴 정보를 머천트 쿠키로 심음)
hottracks.kyobobook.co.kr/media/music/detail/<상품>?LINK=PLP

그리고 브라우저에 실제로 남은 쿠키 중 결정적인 건 이것이었습니다.

쿠키 도메인 의미
LPUID .linkprice.com 링크프라이스 사용자 추적 (3년)
DUP_CHECK click.linkprice.com 중복 클릭 방지 (1일)
LPINFO .kyobobook.co.kr 제휴 ID가 머천트 도메인 쿠키로 저장 — 구매 귀속의 근거

가장 중요한 LPINFO가 머천트(.kyobobook.co.kr) 도메인에 제휴 ID를 달고 저장됐습니다. 이게 교보가 구매를 우리 추천으로 가려내는 실제 근거입니다. 302로 우회해도 어트리뷰션은 멀쩡했습니다.

오히려 이 방식이 더 견고합니다. iframe·픽셀·백그라운드 요청이 아니라 실제 풀 네비게이션이라, 브라우저가 링크프라이스를 first-party로 방문합니다. 사파리의 추적 차단(ITP) 같은 환경에서도 first-party 방문 쿠키는 잘 살아남습니다.

곁다리 1캐러셀 인디케이터를 다섯 번 갈아엎다

추천 음반은 가로 캐러셀로 보여줍니다. "전체 몇 개 중 지금 어디"를 표시하려다가 한참 헤맸습니다.

  • 점(dot): 한 화면에 카드가 한 장만 보이는 슬라이드쇼 전제입니다. 그런데 우리는 2~3장이 동시에 보여서, 활성 점 하나가 무엇을 가리키는지 모호했습니다.
  • 진행 바: 위치는 보여주는데 정작 "개수"가 사라졌습니다. 선으로 뭉개버린 느낌.
  • 세그먼트(카드당 막대): 개수는 명확해졌지만 산만했습니다.

세 번을 실패하고서야 진단이 분명해졌습니다. 개수와 위치를 작은 공간에 욱여넣으려는 시도 자체가 "여러 장이 동시에 보이는 추천 쉘프"라는 콘텐츠 유형과 싸우고 있었습니다. 애플 뮤직이나 넷플릭스 같은 추천 쉘프는 카운트 인디케이터를 의도적으로 뺍니다. 대신 헤더의 좌우 화살표(양 끝에서 비활성), 다음 카드가 살짝 보이는 peek, 그리고 가장자리 그라데이션 페이드로 "더 있음"을 암시합니다. 이쪽으로 가니 깔끔하게 정리됐습니다.

곁다리 2줄바꿈은 케이스가 아니라 규칙으로

연주자명이 길면 카드 안에서 들쭉날쭉 끊겼습니다. Symphonie-Orchester 같은 하이픈 단어가 어정쩡하게 갈라지고, 짧은 줄이 생겨 우측에 빈 공간이 남았습니다. 폰트 크기를 줄여보고, text-wrap: balance도 대보고, 케이스마다 손대려다 멈췄습니다. 케이스별 수정은 다음 데이터에서 또 깨집니다.

근본 해결은 한 줄이었습니다.

word-break: break-all; /* Tailwind: break-all */

단어 경계를 무시하고 글자 단위로 끊어 모든 줄을 끝까지 채웁니다. 이름 길이와 무관하게 항상 줄이 가득 차니, 케이스별 분기가 사라졌습니다. 타이포그래피 교과서적으론 과격하지만, 좁은 카드에서 빈 공간을 없애는 가장 결정적인 규칙이었습니다.

함정마이그레이션이 gitignore된 저장소

스키마에 새 테이블과 enum을 추가하고 마이그레이션을 돌리려는데, 로컬 DB가 리셋(데이터 전체 삭제)을 요구했습니다. 살펴보니 이 저장소는 prisma/migrations를 통째로 gitignore하고 있었고, 추적되는 마이그레이션 파일이 한 개도 없었습니다. 즉 마이그레이션 파일이 아니라 prisma db push로 스키마를 반영하는 운영 방식이었습니다. 로컬에 드리프트가 쌓여 있던 이유도 그거였습니다.

방식을 알았으니 로컬은 db push로 추가 전용 변경만 반영하고 끝냈습니다. 문제는 프로덕션이었습니다. db push는 스키마와 DB가 어긋나 있으면 파괴적으로 "고치려" 들 수 있습니다. 운영 DB에 무턱대고 돌릴 수는 없습니다.

그래서 실행 전에 무엇이 적용될지 먼저 떴습니다. Prisma 7에서는 migrate diff로 현재 DB와 스키마의 차이를 SQL로 뽑을 수 있고, 이건 DB를 건드리지 않는 읽기 전용 작업입니다.

prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script

출력은 깔끔했습니다.

CREATE TYPE "AlbumChannel" AS ENUM ('KYOBO', 'YES24');
CREATE TABLE "RecommendedAlbum" ( ... );
CREATE INDEX ...;
ALTER TABLE "RecommendedAlbum" ADD CONSTRAINT ... FOREIGN KEY ...;

DROP도, 기존 테이블 ALTER도, 컬럼 삭제도 없었습니다. 전부 추가(CREATE)뿐. 이는 두 가지를 보장합니다. 프로덕션이 스키마와 이미 동기 상태라는 것(드리프트 없음), 그리고 db push가 데이터 손실 없이 신규 객체만 만든다는 것. 운영 변경은 이렇게 미리 떠보고 들어가야 마음이 놓입니다.

마무리의심스러우면 측정한다

이번 작업에서 가장 값졌던 건 코드가 아니라 두 번의 측정이었습니다. 302 우회가 쿠키를 깨뜨릴지 모른다는 의심을 실제 브라우저로 확인했고, 운영 db push가 안전한지 실행 전에 diff로 떠봤습니다. 둘 다 "아마 괜찮을 것"으로 넘어갈 수 있던 지점이었고, 둘 다 넘어갔다면 조용히 사고가 났을 지점이었습니다.

전체 흐름은 Claude Code로 진행했습니다. 먼저 plan mode로 코드베이스를 탐색해 기존 패턴(작품 자식 리스트, 관리자 CRUD, 이미지 업로드)을 파악하고 계획을 세운 뒤, 구현하고, 헤드리스 브라우저로 스크린샷을 찍어 눈으로 확인하는 루프를 반복했습니다. 캐러셀 인디케이터를 다섯 번 갈아엎는 동안 매번 화면을 캡처해 비교한 게, 말로만 주고받는 것보다 훨씬 빨랐습니다. 디자인은 글이 아니라 그림으로 합의되니까요.

기능 하나를 붙이는 일이었지만, 정작 기억에 남는 건 "정말 그런가?"를 멈춰 서서 재본 두 순간이었습니다.