본문으로 건너뛰기

새 창으로 폼 데이터 전달하기 — base64 querystring vs postMessage 비교 일지

·32 min read

광고 미리보기, 임시 결과 미리보기처럼 현재 폼 상태를 다른 도메인의 새 창에 그대로 보여주고 싶을 때 어떤 방식이 가장 깔끔한지 직접 두 방식을 다 시도해봤다.

  • 방식 A: 폼 상태를 직렬화 → base64 → URL querystring으로 전달
  • 방식 B: 새 창을 먼저 열고 window.postMessage로 데이터 전달

각 방식의 동작, 트레이드오프, 그리고 "이미지가 포함된 폼"에서 실제로 부딪힌 한계를 적었다.


시나리오

광고주가 광고 등록 폼을 작성하다가 "미리보기" 버튼을 누르면 마케터(광고를 노출하는 쪽)에게 보일 화면이 새 창으로 떠야 했다. 두 페이지는 다른 도메인(adv.example.commarketer.example.com)이고, 폼은 아직 저장 전이라 백엔드에 데이터가 없는 상태였다.

┌──────────────────────────┐         ┌──────────────────────────┐
│  광고주 폼                │  click  │  새  (마케터 도메인)     │
- 제목                   │   →     │  미리보기 화면              │
- 본문                   │         │  - 폼 데이터로 렌더링       │
- 이미지 3 (~500KB)    │         │                            │
└──────────────────────────┘         └──────────────────────────┘

이 데이터를 어떻게 새 창에 전달할 것인가가 풀어야 할 문제였다.


방식 A — base64 querystring

처음엔 가장 직관적인 방법으로 시작했다. 폼 데이터를 JSON 문자열로 바꾸고, base64로 인코딩한 뒤, querystring에 붙여서 새 창을 여는 방식이다.

// 송신 측 (광고주)
const payload = mapFormToPreviewPayload(formValues)
const json = JSON.stringify(payload)
const b64 = btoa(unescape(encodeURIComponent(json))) // UTF-8 안전 base64
const url = `${MARKETER_URL}/preview?payload=${encodeURIComponent(b64)}`
window.open(url, '_blank')
// 수신 측 (마케터)
const params = useSearchParams()
const encoded = params.get('payload')
const json = decodeURIComponent(escape(atob(encoded)))
const payload = JSON.parse(json) as PreviewPayload
return <Preview data={payload} />

좋았던 점

  • 자기완결적 — URL 자체가 데이터를 포함하니 북마크·새로고침·공유에 강했다
  • stateless — 서버 저장소가 필요 없었다
  • 단순 — 송신/수신 로직이 짧았다

한계 — 직접 부딪힌 것들

1. URL 길이 한계 — 가장 치명적이었다

브라우저별 URL 길이 한계는 충분히 길지만(Chrome ~32MB), 실제 운영 환경의 다른 컴포넌트들이 훨씬 일찍 잘랐다.

위치한계
Chrome 주소창~32MB
Firefox 주소창~64KB
Cloudflare~16KB
Vercel Edge~14KB (request line)
AWS ALB~16KB
Nginx 기본값8KB

이미지 한 장(500KB) → dataURL 변환 시 ~670KB → base64로 또 +33%. 단일 이미지만으로도 프록시에서 잘렸다.

// 실제로 일어난 일
const url = `https://...preview?payload=${superLongBase64}`
window.open(url) // → 414 Request-URI Too Large
                 // → 또는 silent 잘림 → 디코딩 실패

2. 보안/프라이버시 누출

URL은 다음 위치에 그대로 기록된다.

  • 브라우저 history
  • 새 창의 주소창
  • 서버 access log
  • referer 헤더 (페이지에서 외부 리소스 fetch 시)
  • 분석 도구 (GA, Sentry 등이 URL을 수집)
  • 북마크

광고 본문, 이미지, 가격 같은 비공개 사전 데이터가 외부 시스템에 노출되는 게 마음에 걸렸다.

3. 새로고침 시 의도치 않은 잔존

미리보기는 본질적으로 일회성 데이터인데, URL에 데이터를 넣으니 다음 문제가 생겼다.

  • 새 창을 새로고침하면 같은 페이로드가 다시 디코드되어 보인다
  • 사용자가 URL을 복사해서 누군가에게 보내면 그대로 미리보기가 노출된다

4. 디버깅 어려움

URL 한 줄이 1MB가 되니 사람이 읽을 수 없었고, 브라우저 개발자 도구도 표시를 잘랐다.

어느 경우에 썼는지

querystring 방식이 잘 맞는 경우는 따로 있었다.

  • 페이로드가 작을 때 (수 KB 이하)
  • 공유 가능한 URL 자체가 가치 있을 때 (예: jsfiddle, codepen "share embed")
  • 서버 작업 없이 빠르게 만들고 싶을 때 (POC, 데모)
  • JWT처럼 페이로드가 본질적으로 짧고 stateless해야 할 때

방식 B — window.postMessage

querystring으로는 이미지가 들어간 폼을 도저히 감당할 수 없어서 postMessage로 갈아탔다. 새 창을 먼저 열고, 마운트되면 핸드셰이크 후 메모리상의 데이터를 직접 전달하는 방식이다.

핵심 아이디어

window.postMessage(data, targetOrigin)브라우저 내부 메모리로 데이터를 전달하는 API다.

  • URL을 거치지 않음
  • 네트워크를 거치지 않음
  • 같은 브라우저 내 두 창 사이에서만 동작
  • 데이터는 Structured Clone Algorithm으로 복제 — 객체/배열/Date/Blob/File 그대로 전달 가능, 직렬화 불필요

핸드셰이크가 왜 필요했는지

새 창은 언제 마운트될지 모른다. 송신 측에서 window.open 직후 바로 postMessage를 보냈더니, 수신 측 페이지가 아직 로드되지 않아 메시지가 사라졌다.

그래서 수신 측이 먼저 "준비됐다"고 알리고, 그 다음 송신 측이 페이로드를 보내는 패턴으로 바꿨다.

송신  (광고주)                          수신  (마케터)
   │                                          │
   │ ① 'message' 리스너 등록 (handshake용)

   │ ② window.open("/preview?type=CPA")
   │       ────────────────────────────────→  │
   │                                          │ 페이지 마운트
   │                                          │
   │                                          │ ③ 'message' 리스너 등록
   │                                          │
   │                                          │ ④ window.opener.postMessage(
   │                                          │      { type: READY }, '*'
   │                                          │    )
   │       ←──────────────────────────────────│
   │ ⑤ READY 수신 → 페이로드 송신
   │   w.postMessage(
   │     { type: PAYLOAD, payload },
MARKETER_ORIGIN
   │   )
   │       ────────────────────────────────→  │
   │                                          │ ⑥ PAYLOAD 수신 → setData
   │                                          │    → 화면 렌더

송신 측 코드

const PREVIEW_READY = 'CPA_PREVIEW_READY'
const PREVIEW_PAYLOAD = 'CPA_PREVIEW_PAYLOAD'
const HANDSHAKE_TIMEOUT_MS = 30_000
 
export const openPreviewWindow = (params: {
  marketerUrl: string
  payload: PreviewPayload
}): Promise<void> =>
  new Promise((resolve, reject) => {
    const { marketerUrl, payload } = params
    const targetOrigin = new URL(marketerUrl).origin
    const url = `${marketerUrl}/preview?type=${payload.kind}`
 
    let timer: ReturnType<typeof setTimeout> | null = null
    let cleaned = false
 
    const cleanup = () => {
      if (cleaned) return
      cleaned = true
      window.removeEventListener('message', handleMessage)
      if (timer !== null) clearTimeout(timer)
    }
 
    const handleMessage = (e: MessageEvent) => {
      if (e.origin !== targetOrigin) return // 보안: 출처 검증
      if (e.source !== w) return            // 우리가 연 창에서 온 것인지
      if (e.data?.type === PREVIEW_READY) {
        w?.postMessage({ type: PREVIEW_PAYLOAD, payload }, targetOrigin)
        cleanup()
        resolve()
      }
    }
 
    // 리스너를 window.open보다 **먼저** 등록 (race condition 방지)
    window.addEventListener('message', handleMessage)
 
    // `noopener` 미사용: 수신 측에서 window.opener 필요
    const w = window.open(url, '_blank')
    if (!w) {
      cleanup()
      reject(new Error('팝업이 차단되어 미리보기를 열 수 없습니다.'))
      return
    }
 
    timer = setTimeout(() => {
      cleanup()
      reject(new Error('미리보기 페이지 응답이 없습니다.'))
    }, HANDSHAKE_TIMEOUT_MS)
  })

수신 측 코드

'use client'
 
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
 
const PREVIEW_READY = 'CPA_PREVIEW_READY'
const PREVIEW_PAYLOAD = 'CPA_PREVIEW_PAYLOAD'
const ALLOWED_OPENER_ORIGIN = process.env.NEXT_PUBLIC_ADVERTISER_URL
 
export default function PreviewPage() {
  const params = useSearchParams()
  const type = params.get('type')
  const [data, setData] = useState<PreviewPayload | null>(null)
 
  useEffect(() => {
    const onMessage = (e: MessageEvent) => {
      // 보안: 알려진 origin에서 온 메시지만 수신
      if (ALLOWED_OPENER_ORIGIN) {
        const allowed = new URL(ALLOWED_OPENER_ORIGIN).origin
        if (e.origin !== allowed) return
      }
      if (e.data?.type === PREVIEW_PAYLOAD) {
        setData(e.data.payload)
      }
    }
 
    // 리스너를 READY 송신보다 **먼저** 등록
    window.addEventListener('message', onMessage)
 
    if (window.opener) {
      window.opener.postMessage({ type: PREVIEW_READY }, '*')
    }
 
    return () => window.removeEventListener('message', onMessage)
  }, [])
 
  if (!data) return <PreviewSkeleton />
  return <Preview data={data} type={type} />
}

작업하면서 챙긴 포인트들

1. 리스너 등록 순서

송신 측: addEventListenerwindow.open (READY 못 받을까봐 먼저 등록). 수신 측: addEventListeneropener.postMessage(READY) (PAYLOAD 못 받을까봐 먼저 등록).

이 순서를 안 지키면 race condition이 종종 터졌다.

2. noopener를 쓰지 않기

window.open(url, '_blank')              // 수신 측에서 window.opener 사용 가능
window.open(url, '_blank', 'noopener')  // window.opener가 null → READY 송신 불가

noopener 없이 열면 보안 우려(tab-nabbing)가 있긴 하지만, 송신/수신 모두 같은 조직의 도메인이라 신뢰 관계가 있다고 보고 허용했다.

3. origin 검증

postMessage는 기본적으로 모든 origin에 도달할 수 있다. 그래서 다음을 챙겼다.

  • 송신 시 두 번째 인자로 정확한 targetOrigin 명시 ('*' 금지)
  • 수신 시 e.origin을 알려진 origin과 비교

'*'를 쓰면 사용자가 그 사이 다른 사이트로 이동했을 때 페이로드가 누출될 수 있다는 함정에 한 번 빠질 뻔했다.

4. 타임아웃

핸드셰이크가 영원히 안 끝날 수 있었다(페이지 빌드 에러, 네트워크 끊김 등). setTimeout으로 정리하지 않으니 메모리 누수 + 사용자에게 피드백 없음 상태가 발생했다. 30초 타임아웃을 걸어 reject로 빠지게 했다.

전환하고 좋았던 점

  • URL 길이 무관 — 페이로드가 1MB여도 OK
  • 로그 미노출 — URL/history/access log 어디에도 안 남았다
  • 새로고침 시 데이터 사라짐 — 미리보기 의도와 일치했다
  • dataURL/Blob/File 직접 전달 가능 — 이미지를 base64로 두 번 인코딩할 필요가 없었다

단점/주의점

  • 같은 브라우저 세션 내에서만 동작 — 사용자가 URL을 복사해 다른 사람에게 보낼 수 없다 (그러나 미리보기 용도엔 OK)
  • 핸드셰이크 코드 필요 — 단순 URL보다 복잡
  • 팝업 차단 시 실패window.opennull 반환

수신 측 — 더 깊이 들어가서 부딪힌 문제들

송신 코드는 핸드셰이크가 핵심이라 비교적 단순했다. 실제로 골치 아팠던 건 수신 측이었다. 새 창에 띄워지는 페이지가 마케터 도메인에 있으니, 마케터의 라우팅·미들웨어·레이아웃·전역 Provider와 모두 충돌했다. 미리보기를 위해 이 환경을 어디까지 끊고 어디까지 재사용할지가 진짜 설계 포인트였다.

1. 라우팅 격리 — 부모 layout의 부작용 끊기

기존 상세페이지 (public)/cpa/participation/[id](public)/layout.tsx에서 다음을 자동으로 수행하고 있었다.

  • cookies()로 로그인 판별
  • prefetchSidebarQueries로 사용자/랭킹 데이터 prefetch
  • <AuthInitProvider>, <HydrationBoundary> 마운트
  • <LoginRequiredGuard>, <LoginDialogGuard> 자동 마운트 → 비로그인 시 모달 띄움

미리보기는 이 가드와 prefetch가 모두 방해됐다. 광고주가 자기 도메인에서 미리보기를 띄울 때 마케터 도메인의 로그인 모달이 화면을 가렸다.

해결: 미리보기 전용 route group (preview)로 격리하고, 그 layout은 빈 wrapper로 뒀다.

// app/(preview)/layout.tsx
const Layout: React.FC<React.PropsWithChildren> = ({ children }) => {
  return <>{children}</>
}
export default Layout

App Router에서 route group은 URL에 영향을 주지 않으므로 (preview)/cpa/preview/page.tsx/cpa/preview로 매핑되면서 (public)/layout.tsx의 부작용을 완전히 건너뛴다. 글로벌 Provider(QueryClientProvider, ThemeProvider 등)는 root layout에 있어 그대로 적용됐다.

URL 충돌 함정

같은 URL이 두 group에서 동시 매칭되면 Next.js가 빌드 시 에러를 던진다.

  • (가능) (public)/cpa/participation/[id] + (preview)/cpa/preview — URL 다름, 충돌 없음
  • (불가) (public)/cpa/participation/[id] + (preview)/cpa/participation/preview[id]=preview와 정적 preview가 같은 URL을 가리켜 충돌

미리보기 path를 정할 때 기존 dynamic 세그먼트와의 패턴 매칭을 함께 검토해야 했다.

2. 미들웨어 예외 — auth gate 우회

마케터의 middleware.ts/cpa/로 시작하는 path를 모두 인증 필수로 처리하고 있었다.

const PATHS = {
  AUTH: ['/cpa/', '/cps/', '/inquiry', '/my'],
}
 
if (!isLogin && matchingPath(PATHS.AUTH, pathname)) {
  return NextResponse.redirect('/?returnUrl=...')
}

미리보기 path /cpa/preview도 이 매칭에 걸려, 비로그인 광고주가 미리보기 창을 열면 마케터 메인으로 redirect됐다. PUBLIC_OVERRIDE 예외 한 줄로 풀었다.

const PATHS = {
  AUTH: ['/cpa/', '/cps/', '/inquiry', '/my'],
  PUBLIC_OVERRIDE: ['/cpa/preview'],
}
 
const isPublicOverride = matchingPath(PATHS.PUBLIC_OVERRIDE, pathname)
if (!isLogin && !isPublicOverride && matchingPath(PATHS.AUTH, pathname)) {
  // redirect
}

route group은 layout 격리, 미들웨어는 redirect 격리 — 두 곳 모두 손봐야 진짜 public이 됐다. 한쪽만 풀고 끝낸 줄 알았다가 빌드 후 비로그인 테스트에서 다시 막혔다.

3. 세 가지 상태 머신

수신 페이지는 본질적으로 다음 셋 중 하나를 보여주도록 설계했다.

SSR / 초기 마운트       →  Skeleton
window.opener 없음      →  Error ("미리보기 창은 광고주 페이지에서 열어주세요")
PAYLOAD 도착            →  실제 데이터 렌더
const { data, error } = useCpaPreviewReceiver()
 
if (error) return <CpaPreviewError message={error} />
if (!data) return <CpaPreviewSkeleton />
return <Sections data={data} />

이 분기는 SSR 흐름과 자연스럽게 맞았다.

  • SSR: useEffect 미실행 → data=null, error=null → Skeleton 렌더 → hydration 미스매치 없음
  • Hydration 직후: useEffect 실행 → opener 있으면 READY 송신 (skeleton 유지) / 없으면 setError (Error 화면)
  • PAYLOAD 수신: setData → 섹션 렌더

브라우저가 SSR 결과를 먼저 보여주고 → JS 로드 후 핸드셰이크 → 데이터로 교체되는 흐름이라 사용자에겐 "스켈레톤 잠시 → 본문" 한 번의 전환만 보였다.

4. URL의 type query는 왜 남겨뒀나

송신 payload에 kind: 'CPA' | 'CPA_FORM'이 이미 들어 있는데, 왜 URL에 ?type=CPA를 또 붙였는지 스스로도 잠깐 헷갈렸다.

이유는 PAYLOAD 도착 전 skeleton 단계에서도 타입을 알아야 더 정확한 placeholder를 그려서 layout shift를 줄일 수 있기 때문이었다. CPA_FORM이면 "외부 폼 사용 가능" 영역이 추가되는데, 핸드셰이크 시간 동안 미리 자리를 잡아두면 데이터 도착 후 점프가 작아졌다.

또한 일부 컴포넌트는 forceKind prop으로 데이터의 kind를 무시하고 URL 기준으로 강제 렌더할 수 있게 해뒀다. 데이터에 kind가 누락되거나 잘못 와도 URL만 보고 일관된 화면을 그릴 수 있도록 한 안전장치였다.

URL은 가볍게(?type=CPA 정도), 본 데이터는 postMessage로 — 역할 분리가 명확해졌다.

5. TypeScript 타입 가드 — postMessage data는 unknown

MessageEvent.data는 신뢰할 수 없는 입력이었다. 등록한 리스너는 origin이 같은 어떤 페이지의 메시지든 다 받았다(브라우저 확장, devtools eval, 같은 origin의 다른 iframe 등).

origin 검증만으로는 부족해서 타입 가드도 함께 걸었다.

const isPayloadMessage = (
  data: unknown,
): data is CpaPreviewPayloadMessage => {
  if (typeof data !== 'object' || data === null) return false
  const d = data as { type?: unknown; payload?: unknown }
  return (
    d.type === CPA_PREVIEW_MESSAGE.PAYLOAD &&
    typeof d.payload === 'object' &&
    d.payload !== null
  )
}
 
const onMessage = (e: MessageEvent) => {
  // 1단계: origin 화이트리스트
  if (ALLOWED_ORIGINS.length > 0 && !ALLOWED_ORIGINS.includes(e.origin)) return
  // 2단계: type 가드
  if (!isPayloadMessage(e.data)) return
  setData(e.data.payload)
}

origin 검증 + 타입 가드 둘 다 통과한 메시지만 setData에 도달하도록 막았다.

6. Structured Clone의 진짜 이점은 수신 측에 있었다

base64 querystring 방식과 비교하니 수신 측 코드가 단순해지는 폭이 컸다.

단계querystringpostMessage
URL 디코드decodeURIComponent(필요 없음)
base64 → bytesatob + padding 보정(필요 없음)
bytes → stringTextDecoder 또는 escape(필요 없음)
string → objectJSON.parse(필요 없음)
타입 검증parsed 결과 검증postMessage data 검증

postMessage는 Structured Clone Algorithm으로 객체가 그대로 도착하니 위 4단계가 통째로 사라졌다. 그뿐 아니라:

  • Date 객체 — querystring은 JSON.stringify 시 문자열이 되어 수신 측이 new Date()로 다시 파싱해야 했다. postMessage는 Date 그대로 왔다.
  • File / Blob — querystring으론 dataURL 변환 필수였는데, postMessage는 파일 객체 그대로 왔다 (Structured Clone이 지원).
  • 이미지 dataURL — 둘 다 가능하지만 postMessage는 base64 인코딩이 한 번뿐이었다. querystring은 base64를 또 base64로 감싸 덩치가 ~33% 더 커졌다.

수신 컴포넌트에서 <img src={data.mainImage}> 한 줄로 끝났다. dataURL이든 https URL이든 분기 없이.

7. 함정 — base64 + URL = 패딩

querystring 방식을 한동안 병행 지원했는데, base64의 = 패딩이 URL 파서에서 잘리는 케이스를 만났다.

?payload=eyJpZCI6OTk5OS...In0=
                            ↑ 이 = 가 다음 키-밸류 구분자로 오해될 수 있음

방어 수단은 두 가지를 함께 써야 했다.

  1. 송신 측: encodeURIComponent(base64)로 항상 URL 인코딩
  2. 수신 측: 디코더에서 padding을 자동 복원
const padded = received.replace(/-/g, '+').replace(/_/g, '/')
  .padEnd(Math.ceil(received.length / 4) * 4, '=')
const decoded = Buffer.from(padded, 'base64').toString('utf-8')

postMessage로 옮긴 뒤엔 이런 함정이 처음부터 사라졌다. URL 인코딩, padding, base64url 변형 — 신경 쓸 게 없었다.

8. 컴포넌트 재사용 전략 — 결국 B를 골랐다

미리보기가 보여줘야 할 UI는 실제 상세페이지와 90% 동일했다. 두 가지 갈림길이 있었다.

A. 미리보기 전용 컴포넌트 신규 작성

  • 장점: readonly 강제 쉬움 (액션 핸들러 자체 없음), 의존성 격리
  • 단점: 동일한 시각 결과를 두 번 작성 → 두 곳을 동기화 유지해야 함

B. 상세페이지 컴포넌트 재사용

  • 장점: 단일 진실 공급원(SSOT), 시각/스펙이 항상 동기
  • 단점: 의존성(useMe, useImageDownload, mutation 등)이 미리보기 환경에 그대로 들어옴

나는 B로 정착했다. 이유는 다음과 같다.

  1. Provider는 root에 있다<QueryClientProvider>가 root layout에 있어 미리보기에서도 동작했다. 수신 페이지가 별도 Provider 트리를 만들 필요가 없었다.
  2. 유저 액션은 일어나지 않는다 — 광고주가 미리보기를 보는 동안 "참여하기" 버튼을 누르진 않았다. 누르더라도 비로그인이라 mutation은 401로 실패하고, 그게 자연스러웠다.
  3. 유지보수 비용 — 컴포넌트 30여 개를 두 벌 유지하는 비용이 컸다. 디자인 변경이 발생할 때마다 양쪽 동기화가 누락되기 쉬웠다.

다만 readonly 보장이 강하게 필요한 도메인(예: 결제 미리보기, 권한 민감 화면)이라면 A가 안전했을 것이다. 미리보기 컨텍스트를 prop이 아니라 Context로 주입해서 자식 컴포넌트가 알아서 disabled로 동작하게 만드는 절충안도 후보로 검토했다.

<PreviewModeProvider value>
  <ProductCardBody cpa={data} />  {/* 내부에서 useContext로 readonly 분기 */}
</PreviewModeProvider>

9. 송신 측의 "비어있음" 약속을 신뢰하기

미리보기는 폼 작성 중간에 띄우니 필드가 비어있을 수 있었다. 송신 측은 다음 표현으로 통일했다.

{
  promotionalImageSet: [],         // 빈 배열
  dailyLimit: null,                // null
  category: { id: '', name: '' },  // 빈 객체 (스키마 강제)
}

수신 컴포넌트는 이 표현을 기준으로 분기했다. 기존 상세페이지 컴포넌트가 이미 이 패턴을 쓰고 있어서 미리보기에서 별도 작업 없이 동작했다.

// 빈 배열 → 자연스럽게 .map()이 0회 호출
{cpa.promotionalImageSet.map((img, i) => <ImageCard key={i} {...img} />)}
 
// null → 옵셔널 체이닝 + nullish 분기
{cpa.dailyLimit?.amount && <DailyLimitBadge amount={cpa.dailyLimit.amount} />}
 
// 빈 문자열 → filter(Boolean)
const parts = [cpa.category?.name, cpa.subCategory?.name].filter(Boolean)

이 컨벤션을 양쪽이 합의하지 않으니 한 번은 .map of undefined 류 런타임 에러가 핸드셰이크 직후에 터졌다. 비어있음 표현을 스키마 단계에서 못박아 두는 게 핵심이었다.

10. 직접 URL 접근을 redirect로 차단하지 않은 이유

window.opener가 없을 때 자동 redirect를 걸고 싶은 충동이 있었지만 의식적으로 피했다.

  1. devtools 디버깅 — 개발 중엔 직접 URL을 열어 마크업/스타일을 확인하는 경우가 많았다. redirect되면 작업이 어려워졌다.
  2. 명시적 안내 — 갑자기 다른 페이지로 튀면 사용자가 혼란스러워했다. "광고주 페이지의 미리보기 버튼으로 열어주세요" 같은 메시지가 친절했다.
  3. bot 대응 — 검색 봇이 직접 URL로 접근할 수 있었다. redirect 대신 에러 페이지를 보여주면 noindex 처리도 자연스러웠다.

수신 측은 단지 "데이터가 안 오는 상태"임을 정확히 표시하는 데 집중하도록 두었다.

11. 인터랙티브 테스트 — mock 송신자를 file:// 로

수신 코드는 단위 테스트가 까다로웠다. window.opener.postMessage가 송신자의 호출이라 모킹하려면 jsdom에 두 개의 가상 window를 만들어야 하는데, 비용이 크고 실제 브라우저 동작과도 달랐다.

가장 빠른 방법은 정적 HTML로 mock 송신자를 만들어 file://로 여는 것이었다.

<!-- /tmp/cpa-preview-tester.html -->
<button onclick="open('CPA')">참여형</button>
<button onclick="open('CPA_FORM')">커스텀 폼</button>
<script>
  const MARKETER = 'http://localhost:3002'
  let pending, popup
  function open(kind) {
    pending = { id: 1, kind, name: '테스트', /* ... */ }
    popup = window.open(`${MARKETER}/cpa/preview?type=${kind}`)
  }
  window.addEventListener('message', (e) => {
    if (e.origin !== MARKETER) return
    if (e.data?.type === 'CPA_PREVIEW_READY') {
      e.source.postMessage({ type: 'CPA_PREVIEW_PAYLOAD', payload: pending }, MARKETER)
    }
  })
</script>

dev 환경에선 origin 검증을 opt-in(NEXT_PUBLIC_ADVERTISER_URL 미설정 시 모두 허용)으로 두니 file:// 또는 임의 origin에서도 mock 송신이 가능했다. 운영 빌드에선 env가 설정돼 자동으로 화이트리스트로 잠겼다.

E2E 테스트로 정식화하려면 Playwright의 context.newPage()로 두 페이지를 같은 컨텍스트에서 열어 postMessage가 흐르도록 만들 수 있을 것이다.


비교표

항목base64 querystringpostMessage
URL 길이 한계한계 초과 (프록시에서 16KB)무관
이미지 dataURL한 장만으로 한계 초과여러 장 OK
URL/history/log 노출그대로 노출안 남음
새로고침 시 동작복원됨 (의도 무관)데이터 사라짐 (자연스러움)
코드 복잡도낮음중간 (핸드셰이크 필요)
공유 가능성URL 자체로 공유 가능같은 세션에서만
백엔드 의존성없음없음
다른 도메인 지원가능가능 (origin 검증 필요)
보안 검토URL에 사전 데이터 박힘 → 우려메모리 전달 → 깔끔

내가 선택한 기준

                  페이로드가 큰가?
                  (이미지/긴 텍스트 포함)

              ┌────────┴────────┐
              No                Yes
              │                  │
              ▼                  ▼
       URL 공유가             postMessage
       의미 있나?

   ┌───┴────┐
   Yes      No
   │        │
   ▼        ▼
base64   둘 다 OK
querystring  (postMessage 권장)

추가 옵션

URL도 postMessage도 부족할 때 검토한 대안들도 메모해 둔다.

1. 백엔드 draft API

POST /preview/drafts → { token: 'abc123', expiresAt: ... }
window.open(`/preview?token=abc123`)
GET /preview/drafts/abc123 → payload
  • URL이 매우 짧음
  • 만료 가능
  • 서버에서 권한 검증 가능
  • 백엔드 작업 필요, 네트워크 1회 추가

대형 광고 플랫폼(Naver GFA, Kakao Moment, Meta Ads Manager)이 이 방식을 쓰고 있었다.

2. sessionStorage + URL 키

const id = crypto.randomUUID()
sessionStorage.setItem(`preview-${id}`, JSON.stringify(payload))
window.open(`/preview?id=${id}`)
  • 같은 origin에서만 가능 (origin이 다르면 storage 격리)
  • 내 시나리오(다른 도메인)엔 부적합

3. BroadcastChannel

const channel = new BroadcastChannel('preview')
channel.postMessage(payload)
  • 같은 origin 여러 탭 간 통신에 적합
  • 다른 origin 안 됨

실전 경험 — 이미지 폼은 결국 postMessage

처음엔 base64 querystring으로 구현했다. 작은 페이로드에선 문제 없었지만, 광고 이미지(600×600, 1200×628, 800×1200) 세 장이 들어가는 순간 한계가 명확해졌다.

이미지 3 (각 ~500KB)
  → dataURL 변환 시 ~2MB
  → base64로 또 +33%~2.7MB
URL ~2.7MB
  → window.open 자체는 OK
  → 그러나 Vercel Edge에서 414 Request-URI Too Large
  → 또는 referer 헤더에 박혀서 외부 리소스 로드 시 함께 전송

postMessage로 전환하니 이렇게 바뀌었다.

  • URL은 ?type=CPA 4바이트
  • 이미지 dataURL은 메모리에서 메모리로 복사 (수 ms)
  • access log엔 깔끔한 URL만 남음
  • 새로고침하면 데이터가 사라져 미리보기 의도와도 자연스럽게 맞음

코드는 약 50줄 더 늘었지만, 한 번 작성하니 모든 페이로드 크기에서 동일하게 동작했다.


핵심 정리

querystringpostMessage
한 줄 평"URL이 곧 데이터""URL은 라우팅, 데이터는 메모리"
적합한 상황작은 페이로드, URL 공유가 가치 있는 경우큰 페이로드, 일회성 데이터, 보안 민감
안 맞는 상황이미지 포함, 보안 민감, 큰 폼URL 공유 필요, 다른 세션에서 재현 필요

미리보기처럼 일회성·대용량·다른 도메인 시나리오에서는 postMessage가 거의 모든 면에서 우월했다. 핸드셰이크 코드 50줄이 추가되는 비용은 작았고, URL 한계·로그 누출·새로고침 잔존 문제를 한 번에 해결했다.

작은 페이로드, 단순 데모, URL 공유 자체가 기능인 케이스(jsfiddle 같은)에선 querystring이 여전히 빠르고 충분했다.

상황을 보고 골라 쓰되, 이미지나 긴 텍스트가 들어간다면 postMessage를 기본값으로 두는 게 안전하다는 게 이번 작업에서 얻은 결론이었다.