새 창으로 폼 데이터 전달하기 — base64 querystring vs postMessage 비교 일지
광고 미리보기, 임시 결과 미리보기처럼 현재 폼 상태를 다른 도메인의 새 창에 그대로 보여주고 싶을 때 어떤 방식이 가장 깔끔한지 직접 두 방식을 다 시도해봤다.
- 방식 A: 폼 상태를 직렬화 → base64 → URL querystring으로 전달
- 방식 B: 새 창을 먼저 열고
window.postMessage로 데이터 전달
각 방식의 동작, 트레이드오프, 그리고 "이미지가 포함된 폼"에서 실제로 부딪힌 한계를 적었다.
시나리오
광고주가 광고 등록 폼을 작성하다가 "미리보기" 버튼을 누르면 마케터(광고를 노출하는 쪽)에게 보일 화면이 새 창으로 떠야 했다. 두 페이지는 다른 도메인(adv.example.com ↔ marketer.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. 리스너 등록 순서
송신 측: addEventListener → window.open (READY 못 받을까봐 먼저 등록).
수신 측: addEventListener → opener.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.open이null반환
수신 측 — 더 깊이 들어가서 부딪힌 문제들
송신 코드는 핸드셰이크가 핵심이라 비교적 단순했다. 실제로 골치 아팠던 건 수신 측이었다. 새 창에 띄워지는 페이지가 마케터 도메인에 있으니, 마케터의 라우팅·미들웨어·레이아웃·전역 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 LayoutApp 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 방식과 비교하니 수신 측 코드가 단순해지는 폭이 컸다.
| 단계 | querystring | postMessage |
|---|---|---|
| URL 디코드 | decodeURIComponent | (필요 없음) |
| base64 → bytes | atob + padding 보정 | (필요 없음) |
| bytes → string | TextDecoder 또는 escape | (필요 없음) |
| string → object | JSON.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=
↑ 이 = 가 다음 키-밸류 구분자로 오해될 수 있음방어 수단은 두 가지를 함께 써야 했다.
- 송신 측:
encodeURIComponent(base64)로 항상 URL 인코딩 - 수신 측: 디코더에서 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로 정착했다. 이유는 다음과 같다.
- Provider는 root에 있다 —
<QueryClientProvider>가 root layout에 있어 미리보기에서도 동작했다. 수신 페이지가 별도 Provider 트리를 만들 필요가 없었다. - 유저 액션은 일어나지 않는다 — 광고주가 미리보기를 보는 동안 "참여하기" 버튼을 누르진 않았다. 누르더라도 비로그인이라 mutation은 401로 실패하고, 그게 자연스러웠다.
- 유지보수 비용 — 컴포넌트 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를 걸고 싶은 충동이 있었지만 의식적으로 피했다.
- devtools 디버깅 — 개발 중엔 직접 URL을 열어 마크업/스타일을 확인하는 경우가 많았다. redirect되면 작업이 어려워졌다.
- 명시적 안내 — 갑자기 다른 페이지로 튀면 사용자가 혼란스러워했다. "광고주 페이지의 미리보기 버튼으로 열어주세요" 같은 메시지가 친절했다.
- 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 querystring | postMessage |
|---|---|---|
| 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=CPA4바이트 - 이미지 dataURL은 메모리에서 메모리로 복사 (수 ms)
- access log엔 깔끔한 URL만 남음
- 새로고침하면 데이터가 사라져 미리보기 의도와도 자연스럽게 맞음
코드는 약 50줄 더 늘었지만, 한 번 작성하니 모든 페이로드 크기에서 동일하게 동작했다.
핵심 정리
| querystring | postMessage | |
|---|---|---|
| 한 줄 평 | "URL이 곧 데이터" | "URL은 라우팅, 데이터는 메모리" |
| 적합한 상황 | 작은 페이로드, URL 공유가 가치 있는 경우 | 큰 페이로드, 일회성 데이터, 보안 민감 |
| 안 맞는 상황 | 이미지 포함, 보안 민감, 큰 폼 | URL 공유 필요, 다른 세션에서 재현 필요 |
미리보기처럼 일회성·대용량·다른 도메인 시나리오에서는 postMessage가 거의 모든 면에서 우월했다. 핸드셰이크 코드 50줄이 추가되는 비용은 작았고, URL 한계·로그 누출·새로고침 잔존 문제를 한 번에 해결했다.
작은 페이로드, 단순 데모, URL 공유 자체가 기능인 케이스(jsfiddle 같은)에선 querystring이 여전히 빠르고 충분했다.
상황을 보고 골라 쓰되, 이미지나 긴 텍스트가 들어간다면 postMessage를 기본값으로 두는 게 안전하다는 게 이번 작업에서 얻은 결론이었다.