컨텐츠 미리보기 기능 구현 시 postMessage vs Base64 URL 방식 비교
폼에 입력한 값을 새 탭의 미리보기 페이지에 실시간으로 반영해야 했다. URL에 직렬화해서 보내는 방식과 window.postMessage로 보내는 방식 두 가지를 비교했고, 최종적으로 postMessage를 골랐다. 그 과정에서 마주친 트레이드오프와 구현 디테일을 정리했다.
요구사항부터 정리해봤다
어드민에서 콘텐츠를 생성할 때, 입력 중인 값(이미지 업로드, 이름·텍스트 필드, 대표 컬러, 식별 정보, 연락처 등)이 실제 배포 후 어떻게 보이는지 미리 확인할 수 있어야 했다.
핵심 제약 조건은 다음과 같았다.
| 요구사항 | 설명 |
|---|---|
| 실시간 반영 | 폼 값이 바뀌면 미리보기도 즉시 갱신되어야 함 |
| 새 탭(별도 창) | 모달이 아닌 별도 창에서 풀 레이아웃을 봐야 함 |
| 파일 업로드 미리보기 | 브랜드 로고·파비콘은 아직 서버에 업로드되지 않은 File 객체 |
| 같은 origin | 어드민 도메인 안에서만 동작 |
| 보안 | 외부 origin이 미리보기를 가로채면 안 됨 |
후보 1 — Base64(또는 JSON) 인코딩 후 URL 전달
가장 단순한 접근부터 봤다. 폼 값을 직렬화해서 쿼리스트링이나 URL 해시에 실어 보낸다.
window.open 호출 쪽에서 폼 값을 JSON으로 직렬화한 뒤 base64로 인코딩해 새 창을 연다.
const payload = JSON.stringify({
platformName,
primaryColor,
brandLogoBase64,
})
const encoded = btoa(unescape(encodeURIComponent(payload)))
window.open(`/preview?data=${encoded}`, 'platform-preview')새 창 쪽은 쿼리스트링에서 꺼내 다시 디코딩하면 끝이다.
const params = useSearchParams()
const raw = params.get('data')
const payload = JSON.parse(decodeURIComponent(escape(atob(raw))))장점
- 구현이 직관적이다. URL 하나로 모든 데이터가 전달된다.
- opener에 의존하지 않는다.
window.opener가 끊겨도 미리보기 페이지 자체로 완결된다. - 공유 가능하다. 링크를 복사해서 다른 탭·사람에게 그대로 보낼 수 있다.
- 직렬화·역직렬화가 JSON으로 일원화되어 디버깅이 쉽다.
단점
- 실시간 갱신이 불가능하다. 폼이 바뀔 때마다 새 URL을 만들고 창을 새로 열거나
location.replace로 리로드해야 한다. UX가 사용 불가능한 수준이다. File객체를 직접 보낼 수 없다.FileReader.readAsDataURL로 base64 인코딩이 필요하고, 큰 이미지면 URL 길이가 폭발한다.- URL 길이 제한이 있다. 브라우저별 한도가 크게 다르다(Chrome·Edge는 약 2KB, Firefox는 약 64KB, Safari는 약 80KB). 가장 좁은 Chrome 기준으로는 로고 한 장만 들어가도 쉽게 초과한다.
- 민감 정보가 브라우저 히스토리·서버 액세스 로그에 남는다. 사업자 등록번호, 이메일, 전화번호가 쿼리스트링에 노출된다.
- base64는 원본 대비 약 33% 더 크다. 인코딩·디코딩 비용도 있다.
- 메모리 부담도 있다. base64 문자열이 그대로 React state에 들어가면 GC가 어렵다.
"한 번 열어서 정적으로 본다"면 충분하지만, "입력 중 실시간 미리보기"라는 요구를 충족시키지 못한다. 이 한 가지로 후보에서 탈락했다.
후보 2 — window.postMessage
window.open()으로 같은 origin의 미리보기 페이지를 열고, opener와 새 창 사이를 postMessage로 양방향 통신한다. postMessage는 두 윈도우(또는 iframe) 사이에서 메시지를 주고받는 브라우저 API다.
핵심 동작 흐름
opener와 새 창의 핸드셰이크 순서를 시퀀스로 그리면 이렇게 된다.
opener 쪽 (Create 페이지)
window.open을 호출한 쪽에서는 새 창의 참조를 ref에 들고 있다가, 새 창에서 READY 신호가 오면 그때부터 postMessage로 PAYLOAD를 회신한다. react-hook-form의 watch() 구독으로 폼 값 변경을 자동 감지한다.
export const usePlatformPreviewSender = (methods) => {
const { getValues, watch } = methods
const previewWindowRef = useRef<Window | null>(null)
const isReadyRef = useRef(false)
const sendPayload = useCallback(() => {
const w = previewWindowRef.current
if (!w || w.closed || !isReadyRef.current) return
w.postMessage(
{ type: 'PLATFORM_PREVIEW:PAYLOAD', payload: buildPayload(getValues()) },
window.location.origin,
)
}, [getValues])
useEffect(() => {
const handle = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.source !== previewWindowRef.current) return
if (event.data?.type === 'PLATFORM_PREVIEW:READY') {
isReadyRef.current = true
sendPayload()
}
}
window.addEventListener('message', handle)
return () => window.removeEventListener('message', handle)
}, [sendPayload])
useEffect(() => {
const sub = watch(() => sendPayload())
return () => sub.unsubscribe()
}, [watch, sendPayload])
const openPreview = useCallback(() => {
if (previewWindowRef.current && !previewWindowRef.current.closed) {
previewWindowRef.current.focus()
sendPayload()
return
}
isReadyRef.current = false
previewWindowRef.current = window.open('/preview/...', 'platform-preview')
}, [sendPayload])
return { openPreview }
}window.open에 noopener를 주고 싶을 수 있는데, 이걸 주면 window.open()이 null을 반환해 새 창 참조 자체를 얻지 못한다. 새 창의 window.opener도 null이 되어 양방향 통신이 끊긴다. 그래서 일부러 빼야 한다.
새 창 쪽 (Preview 페이지)
새 창 쪽은 mount 직후 window.opener.postMessage로 READY를 던지고, PAYLOAD가 올 때마다 state를 갱신한다. 브랜드 로고는 File 객체로 들어오기 때문에 URL.createObjectURL로 미리보기용 URL을 만든다.
export const usePlatformPreviewReceiver = () => {
const [payload, setPayload] = useState<PlatformPreviewPayload | null>(null)
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const prevLogoUrlRef = useRef<string | null>(null)
const prevLogoFileRef = useRef<File | null>(null)
useEffect(() => {
const handle = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'PLATFORM_PREVIEW:PAYLOAD') return
const next = event.data.payload
setPayload(next)
if (next.brandLogoFile !== prevLogoFileRef.current) {
if (prevLogoUrlRef.current) URL.revokeObjectURL(prevLogoUrlRef.current)
const url = next.brandLogoFile instanceof File
? URL.createObjectURL(next.brandLogoFile)
: null
prevLogoUrlRef.current = url
setLogoUrl(url)
prevLogoFileRef.current = next.brandLogoFile
}
}
window.addEventListener('message', handle)
if (window.opener) {
window.opener.postMessage(
{ type: 'PLATFORM_PREVIEW:READY' },
window.location.origin,
)
}
return () => {
window.removeEventListener('message', handle)
if (prevLogoUrlRef.current) URL.revokeObjectURL(prevLogoUrlRef.current)
}
}, [])
return { payload, logoUrl }
}window.opener가 null이면(= 새 탭에 직접 URL을 친 경우) READY를 보내지 않고 빈 상태로 둔다.
장점
- 실시간 양방향 통신이 된다. 폼 입력 즉시 미리보기에 반영된다.
File객체를 그대로 전달할 수 있다.postMessage는 Structured Clone Algorithm을 사용하기 때문에File,Blob,ArrayBuffer까지 복제된다. base64 인코딩 단계가 통째로 사라진다.- URL이 깨끗하다. 미리보기 URL에 민감 정보가 들어가지 않는다.
- origin 검증으로 보안이 확보된다.
event.origin과event.source이중 체크가 가능하다. - 데이터 크기 제한이 사실상 없다. 메모리에 들어가는 한도까지 보낼 수 있다.
단점
- opener 양방향 핸들에 의존한다.
noopener옵션을 쓰면 양쪽이 끊기고, 새 탭에서 직접 URL을 치면window.opener가 null이라 빈 상태로 시작한다(공유 불가). - READY 핸드셰이크가 필요하다. 새 창 mount 타이밍과 opener의 전송 타이밍이 어긋나면 첫 PAYLOAD가 유실되므로, "새 창이 준비됐다"는 신호를 명시적으로 받아야 한다.
- 창 종료 감지가 폴링이다. 표준 이벤트가 없어
setInterval로window.closed를 1초마다 본다. - 테스트가 까다롭다. 두 개의 window context가 필요해서 단위 테스트로는 검증이 어렵고, E2E(Playwright 등)로 가야 한다.
- opener 누수 위험이 있다. 미리보기 페이지가
window.opener를 통해 opener의 전역에 접근할 수 있다는 점은 같은 origin이라도 신경 써야 한다(여기서는 같은 앱 내부라 허용).
의사결정 매트릭스
두 방식을 한 줄로 놓고 봤다.
| 항목 | Base64/URL | postMessage |
|---|---|---|
| 실시간 반영 | X 새로고침 필요 | O 즉시 |
File 객체 처리 | X base64 변환 필요 | O 구조화 복제로 그대로 |
| URL 노출·히스토리 안전성 | X 민감정보 노출 | O 깨끗 |
| 데이터 크기 한도 | X 브라우저별 2~80KB (Chrome ~2KB) | O 사실상 무제한 |
| 공유 가능한 링크 | O | X opener 없으면 빈 화면 |
| 핸드셰이크 복잡도 | O 없음 | X READY/PAYLOAD 필요 |
| 보안 모델 | URL 추측 가능성 | origin·source 이중 검증 |
| 테스트 난이도 | 쉬움 | E2E 필요 |
결정적인 차이는 "실시간 반영"과 "File 객체 그대로 전달" 두 항목이었다. 이 요구사항을 충족하지 못하는 base64는 후보에서 탈락했다.
postMessage 구현에서 챙긴 디테일
선택한 방식을 그대로 던지면 잔버그가 잔뜩 따라온다. 구현하면서 신경 쓴 포인트를 정리했다.
메시지 계약을 한 파일에 모았다
부모·자식이 각자 타입을 정의하면 반드시 불일치가 생긴다. 메시지 상수, payload 타입, 타입 가드까지 한 파일에 모았다.
export const PLATFORM_PREVIEW_MESSAGE = {
READY: 'PLATFORM_PREVIEW:READY',
PAYLOAD: 'PLATFORM_PREVIEW:PAYLOAD',
} as const
export interface PlatformPreviewPayload { /* ... */ }
export const isPlatformPreviewReadyMessage = (data: unknown): data is PlatformPreviewReadyMessage => { /* ... */ }
export const isPlatformPreviewPayloadMessage = (data: unknown): data is PlatformPreviewPayloadMessage => { /* ... */ }부모·자식이 같은 상수와 타입 가드를 import하므로 typo가 나면 컴파일 단계에서 막힌다.
origin은 부모·자식 양쪽에서 검증했다
postMessage의 두 번째 인자에 '*'를 쓰면 같은 창에 끼어든 다른 origin이 가로챌 수 있다. opener 쪽은 항상 명시적 origin을 박았다.
w.postMessage(message, window.location.origin)새 창 쪽은 origin뿐 아니라 source 윈도우까지 비교했다. 같은 origin 내의 다른 탭이 흉내내는 메시지도 차단된다.
if (event.origin !== window.location.origin) return
if (event.source !== previewWindowRef.current) returnREADY 핸드셰이크가 반드시 필요한 이유
window.open 직후에 opener 쪽이 곧장 postMessage를 던지면, 새 창의 React가 아직 mount되지 않아서 리스너가 없다. 첫 메시지가 그대로 유실된다.
새 창이 mount된 뒤 READY를 먼저 보내고, opener 쪽이 그걸 받았을 때 PAYLOAD를 회신하는 구조여야 안전하다. 이 핸드셰이크를 빼면 "처음 한 번은 빈 화면이 뜨다가 폼을 한 번 더 건드려야 나타나는" 재현 어려운 버그가 생긴다.
react-hook-form의 watch() 구독으로 변경 감지
useState였다면 모든 setter에 sendPayload를 끼워야 했겠지만, RHF의 watch()는 모든 필드 변경을 한곳에서 받을 수 있다.
useEffect(() => {
const sub = watch(() => sendPayload())
return () => sub.unsubscribe()
}, [watch, sendPayload])watch를 컴포넌트 본문에서 직접 호출하면 리렌더가 발생하지만, watch(cb) 구독 형태는 콜백만 호출하고 리렌더를 일으키지 않는다. opener 쪽 훅에서는 구독 형태를 써야 한다.
File에서 ObjectURL로 변환하면서 메모리 회수도 챙겼다
URL.createObjectURL은 명시적으로 revokeObjectURL을 호출해야 해제된다. 새 PAYLOAD가 들어올 때마다 이전 URL을 revoke하지 않으면 미리보기 사용 시간에 비례해 메모리가 누수된다.
if (nextFile !== prevLogoFileRef.current) {
if (prevLogoUrlRef.current) URL.revokeObjectURL(prevLogoUrlRef.current)
const url = nextFile instanceof File ? URL.createObjectURL(nextFile) : null
prevLogoUrlRef.current = url
setLogoUrl(url)
prevLogoFileRef.current = nextFile
}파일이 동일한 경우 ObjectURL을 재생성하지 않는 것도 중요했다. 매 PAYLOAD마다 새 URL을 만들면 <img> src가 매번 바뀌어 깜빡임이 생겼다. ref로 직전 파일을 들고 있다가 비교한다.
창 종료 감지
표준 이벤트가 없어 setInterval로 1초마다 previewWindowRef.current?.closed를 폴링하고, true면 ref와 ready 플래그를 모두 초기화했다. 다음에 다시 openPreview를 호출하면 새 READY 핸드셰이크부터 다시 시작된다.
직접 URL 접근 시 fallback
미리보기 페이지를 새 탭에 직접 URL로 치면 window.opener가 null이다. READY를 보낼 곳이 없으므로 빈 상태로 둔다. 에러를 던지지 않고 "데이터 없음" UI를 유지하는 것이 핵심이었다.
if (window.opener) {
window.opener.postMessage({ type: PLATFORM_PREVIEW_MESSAGE.READY }, window.location.origin)
}다른 대안들도 한 번씩 따져봤다
postMessage로 가기 전, 다른 옵션들도 매트릭스에 올려두고 비교했다.
| 대안 | 평가 |
|---|---|
| localStorage + storage 이벤트 | 같은 origin 내 다른 탭에 storage 이벤트가 발사된다. 동작은 하지만 File 객체를 저장할 수 없고, 모든 탭에 broadcast되어 다른 미리보기 탭이 열려 있으면 간섭한다. |
| BroadcastChannel API | localStorage보다 깔끔하다. File도 구조화 복제로 전달된다. 단, Safari 15.4 이전·IE 같은 구형 환경 미지원. 모던 브라우저 타깃이라 후보는 됐지만, 명시적 1:1 채널(opener ↔ 새창)이 더 직관적이라 postMessage를 골랐다. |
| 서버에 임시 저장 후 ID 전달 | 가장 무겁다. 미리보기 한 번 보려고 임시 업로드 API를 만드는 건 오버킬이다. 미리보기는 아직 저장 전 상태를 보는 게 핵심 가치라 빠졌다. |
| iframe + props drilling | 한 화면 안에서 보여줄 거라면 가장 단순하다. "별도 창에서 풀 화면"이 요구라 제외했다. |
BroadcastChannel은 진지하게 고려할 만한 후보였다. 다만 이번 케이스는 "이 창과 저 창" 1:1 통신이 명확했고, 모든 탭에 broadcast되는 시맨틱이 오히려 과했다.
이번 작업에서 가장 크게 남은 건 postMessage가 단순한 문자열 전달 채널이 아니라 구조화 복제 채널이라는 점이었다. File/Blob까지 그대로 보낼 수 있다는 사실이 base64 직렬화 단계를 통째로 지웠고, 그게 의사결정의 결정타였다. origin과 source 이중 검증, READY 핸드셰이크, ObjectURL의 revoke 짝 맞추기처럼 옵션처럼 보이는 디테일이 빠지면 재현 어려운 버그로 돌아온다는 것도 확인했다.