안녕하세요. 올리브영에서 프론트엔드 개발을 담당하고 있는 그냥 잇츠테드입니다.
저희 팀은 올리브영의 새로운 성장 동력인 리테일 미디어 사업 확대를 위해, 광고 파트너 오피스 시스템을 기존 플랫폼과 매끄럽게 통합하는 프로젝트를 맡았습니다.
목표는 명확했습니다. 부모 애플리케이션에서 자식 애플리케이션을 iframe으로 임베드하되, 사용자가 두 번 로그인하지 않도록 인증을 공유하는 것이었죠.
자식 애플리케이션에는 자체 인증 시스템이 없었기 때문에, 부모 애플리케이션의 인증 토큰을 안전하게 공유하는 방법이 필요했습니다.
간단해 보였던 이 과제는 생각보다 복잡했습니다.
동시에 쏟아지는 토큰 요청, 401 에러의 연쇄 폭발, 무한 재시도 루프까지. 이를 하나씩 해결하며 안정적인 인증 시스템을 만들어낸 과정을 공유합니다.
이런 분들께 도움이 될 거예요.
- iframe을 사용한 마이크로프론트엔드 아키텍처를 구현하는 개발자
- 크로스 오리진 환경에서 안전한 토큰 관리가 필요한 분
- 401 에러가 동시 다발적으로 발생하는 문제를 겪고 계신 분
- postMessage API의 실전 활용 사례가 궁금한 분
postMessage API를 활용해 이 문제를 어떻게 해결했는지, 그리고 예상치 못했던 4가지 난관과 해결 과정을 공유합니다.
TL;DR
iframe 환경에서 postMessage를 활용해 안전한 토큰 기반 인증 시스템을 구현했습니다.
핵심 포인트:
- ✅ 동시 10개 요청 → 1개로 최적화 (Request Queue)
- ✅ Promise 공유로 401 에러 동시 처리
- ✅ 쿨다운 메커니즘으로 무한 루프 방지
- ✅ Origin 검증 + Rate Limiting으로 보안 강화
핵심 코드 (Promise 공유 패턴):
let handling401Promise: Promise<string | null> | null = null;
if (handling401Promise) {
// 이미 갱신 중이면 같은 Promise 기다림
const newToken = await handling401Promise;
} else {
// 첫 401만 실제 갱신 수행
handling401Promise = refreshTokensDirect();
}문제 상황
부모 애플리케이션에서 자식 애플리케이션을 iframe으로 임베드하는 프로젝트가 시작되었습니다. 자식 애플리케이션은 자체 인증 시스템이 없었기 때문에 부모 애플리케이션의 인증 시스템을 활용해야 했고, 저는 이 인증 통신 시스템을 구현하게 되었습니다.
iframe으로 통합하면서 가장 먼저 마주한 질문은 "어떻게 안전하게 인증 정보를 공유할 것인가?" 였습니다. 서로 다른 도메인에서 실행되는 두 애플리케이션 사이에서 토큰을 주고받아야 하는데, 보안을 해치지 않으면서도 안정적으로 작동해야 했습니다.
초기 고민들
처음에는 간단하게 생각했습니다.
- "그냥 URL 파라미터로 토큰 넘기면 되지 않을까?"
- "
localStorage는 같은 도메인에서만 공유되니까 안 되겠구나..." - "Cookie는? 🤔
SameSite정책 때문에 복잡해질 것 같은데..."
내부 검토 및 기술 논의를 거쳐, 위 문제들을 가장 안전하고 유연하게 해결할 수 있는 postMessage API를 사용하기로 결정했습니다.
왜 postMessage인가?
postMessage는 서로 다른 출처(origin) 간에 안전하게 메시지를 주고받을 수 있는 Web API입니다.
💡 Origin(출처)이란? 웹사이트의 프로토콜(https), 도메인(example.com), 포트번호를 합친 것입니다. 예를 들어
https://parent.com과https://child.com은 도메인이 다르므로 서로 다른 origin입니다. 보안상 브라우저는 기본적으로 다른 origin 간의 데이터 접근을 차단합니다.
제가 postMessage를 선택한 이유는 다음과 같습니다.
- 보안성: Origin 검증을 통해 신뢰할 수 있는 출처만 통신 가능
- 유연성: 복잡한 데이터 구조(객체, 배열 등)도 전달 가능
- 표준 API: 별도 라이브러리 설치 없이 바로 사용 가능
- 양방향 통신: 부모↔자식 양쪽 모두 메시지 송수신 가능
URL 파라미터로 토큰을 넘기는 방법은 브라우저 히스토리에 노출되어 보안상 위험하고, Cookie는 SameSite 정책 때문에 크로스 오리진에서 제약이 많습니다. postMessage는 이런 문제 없이 안전하고 유연하게 통신할 수 있는 최적의 선택이었습니다.
기본 구조 설계
먼저 간단한 구조부터 만들어보았습니다. 자식 iframe이 부모에게 토큰을 요청하면, 부모가 자신의 토큰을 전달하는 방식입니다.
부모 뷰
// hooks/useIframeAuth.ts
export const useIframeAuth = (iframeRef: RefObject<HTMLIFrameElement>) => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Origin 검증
if (event.origin !== CHILD_ORIGIN) return;
const { type } = event.data;
if (type === 'GET_PARENT_TOKEN') {
const token = getToken('token');
const refreshToken = getToken('refresh_token');
// 토큰 전송
iframeRef.current?.contentWindow?.postMessage(
{
type: 'PARENT_TOKEN',
token,
refreshToken,
},
event.origin
);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [iframeRef]);
};자식 뷰 (iframe)
// 토큰 요청
const requestToken = (): Promise<TokenResponse> => {
return new Promise((resolve, reject) => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'PARENT_TOKEN') {
window.removeEventListener('message', handleMessage);
resolve({
token: event.data.token,
refreshToken: event.data.refreshToken,
});
}
};
window.addEventListener('message', handleMessage);
window.parent.postMessage({ type: 'GET_PARENT_TOKEN' }, '*');
});
};여기까지는 순조로웠습니다. 기본적인 토큰 전달은 잘 작동했습니다. 하지만 실제 서비스에 적용하려고 하자 예상치 못한 문제들이 하나씩 나타나기 시작했습니다.
첫 번째 난관: 동시 다발적인 API 호출
컴포넌트가 마운트되면서 여러 API가 동시에 호출되는 상황이 발생했습니다. 문제는 각 API 요청마다 부모에게 토큰을 요청하면서 불필요한 중복 요청이 발생한다는 것이었습니다. 10개의 API가 동시에 호출되면 부모에게도 10번의 토큰 요청이 날아갔고, 같은 토큰을 10번 반복해서 받아오는 비효율이 발생했습니다.
해결 방법. Request Queueing
첫 번째 요청만 실제로 처리하고, 나머지는 큐에 넣어서 결과를 공유하도록 했습니다.
const isHandlingTokenRequest = useRef(false);
const pendingTokenRequests = useRef<PendingTokenRequest[]>([]);
const handleTokenRequest = async (messageType: string) => {
// 이미 처리 중이면 큐에 추가
if (isHandlingTokenRequest.current) {
return new Promise((resolve, reject) => {
pendingTokenRequests.current.push({ resolve, reject, messageType });
});
}
isHandlingTokenRequest.current = true;
try {
// 토큰 획득
const token = await getValidToken();
// 원본 요청 응답
sendTokenResponse(token);
// 큐에 대기 중인 요청들도 같은 토큰으로 응답
const queuedRequests = [...pendingTokenRequests.current];
pendingTokenRequests.current.length = 0;
queuedRequests.forEach(({ resolve }) => {
sendTokenResponse(token);
resolve(token);
});
} finally {
isHandlingTokenRequest.current = false;
}
};이렇게 하니 10개의 요청이 동시에 와도 실제로는 1번만 토큰을 갱신하게 되었습니다!
📊 개선 효과
- 동시 API 호출 시 불필요한 토큰 요청 95% 감소
- 네트워크 부하 감소로 평균 응답 속도 향상
- 서버 토큰 발급 API 호출 횟수 대폭 절감
두 번째 난관: 401 에러 폭탄
첫 번째 문제를 해결하고 나니 더 심각한 문제가 드러났습니다. 토큰이 만료되어 여러 API가 동시에 401 에러를 받으면서 각자 토큰 갱신을 시도하는 상황이었죠. 5개의 API가 동시에 실패하면 5번의 토큰 갱신 요청이 발생했고, 이는 서버 부하는 물론 경합 상태(race condition)를 유발했습니다.
Request A → 401 ─┐
Request B → 401 ─┼→ 동시에 토큰 갱신 시도? 😱
Request C → 401 ─┘시행착오 1: 단순 플래그로 제어
처음에는 간단하게 플래그 하나로 제어하려고 했습니다.
let isRefreshing = false;
if (error.response?.status === 401) {
if (isRefreshing) return; // 이미 갱신 중이면 무시
isRefreshing = true;
await refreshToken();
isRefreshing = false;
}하지만 이 방법은 갱신이 완료되기 전에 들어온 요청들은 그냥 실패하게 됩니다.
해결 방법: Promise 공유
모든 401 에러가 하나의 갱신 작업을 공유하도록 수정했습니다. 첫 번째 401 에러만 실제 토큰 갱신을 수행하고, 나머지 요청들은 그 갱신 작업이 완료될 때까지 기다립니다. 갱신이 완료되면 대기 중이던 모든 요청이 새 토큰을 받아 자동으로 재시도됩니다.
let handling401Promise: Promise<string | null> | null = null;
// Response Interceptor
interceptor.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const originalRequest = error.config;
// 이미 갱신 중이면 그 Promise를 기다림
if (handling401Promise) {
const newToken = await handling401Promise;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return axios(originalRequest);
}
// 첫 번째 401만 실제 갱신 작업 수행
handling401Promise = (async () => {
try {
const result = await refreshTokensDirect();
return result?.token || null;
} finally {
handling401Promise = null;
}
})();
const newToken = await handling401Promise;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return axios(originalRequest);
}
return Promise.reject(error);
}
);이제 여러 요청이 동시에 401을 받아도 하나의 갱신 작업만 수행됩니다. 대기 중이던 요청들은 갱신이 완료되면 새 토큰을 헤더에 담아 원래 요청을 자동으로 재시도합니다. 사용자는 에러를 전혀 느끼지 못하고, 서버 부하도 최소화됩니다.
세 번째 난관: Refresh Token도 만료되면?
토큰 갱신 로직을 구현하면서 또 다른 케이스를 발견했습니다. Refresh Token마저 만료된 경우입니다. 이 경우 재로그인이 필요한데, 이걸 어떻게 처리할까요?
에러 코드 분리
- ACCESS_TOKEN_EXPIRED: Access Token 만료 → 갱신 시도
- REFRESH_TOKEN_EXPIRED: Refresh Token 만료 → 재로그인 필요
if (error.response?.status === 401) {
const errorCode = error.response?.data?.code;
// Refresh Token 만료
if (errorCode === 'REFRESH_TOKEN_EXPIRED') {
clearToken('token');
clearToken('refresh_token');
// 부모에게 인증 실패 알림
if (environmentState?.isIframe) {
window.parent.postMessage({ type: 'AUTHENTICATION_FAILED' }, '*');
}
return Promise.reject(error);
}
// Access Token 만료 - 갱신 시도
if (errorCode === 'ACCESS_TOKEN_EXPIRED') {
// ... 토큰 갱신 로직
}
}부모 뷰에서는 이 메시지를 받으면 전체 페이지를 새로고침하여 재로그인 플로우로 진입합니다.
// 부모 뷰
if (type === 'AUTHENTICATION_FAILED') {
console.error('[PARENT] Authentication failed, clearing tokens');
clearToken('token');
clearToken('refresh_token');
window.location.reload();
}네 번째 난관: 무한 재시도 방지
토큰 갱신이 실패했을 때 무한히 재시도하면 서버에 부하를 줄 수 있습니다. 이를 방지하기 위해 쿨다운 메커니즘을 도입했습니다.
const tokenRefreshState = {
failedAttempts: 0,
lastFailureTime: 0,
maxRetries: 3,
cooldownMs: 5000, // 5초
};
// 갱신 시도 전 쿨다운 체크
const timeSinceLastFailure = Date.now() - tokenRefreshState.lastFailureTime;
if (
tokenRefreshState.failedAttempts >= tokenRefreshState.maxRetries &&
timeSinceLastFailure < tokenRefreshState.cooldownMs
) {
console.warn('[AXIOS] Token refresh in cooldown period');
return Promise.reject(error);
}
// 갱신 시도
try {
await refreshToken();
tokenRefreshState.failedAttempts = 0; // 성공 시 리셋
} catch (error) {
tokenRefreshState.failedAttempts++;
tokenRefreshState.lastFailureTime = Date.now();
}3번 연속 실패하면 5초 동안 재시도를 막습니다. 서버도 보호하고 불필요한 요청도 줄일 수 있었습니다.
전체 통신 흐름
최종적으로 완성된 통신 흐름은 다음과 같습니다.
- iframe 로드: 부모로부터 토큰 요청
- API 호출: 토큰을 헤더에 담아 전송
- 401 발생: 동시성 제어 후 토큰 갱신
- 갱신 완료: 부모와 동기화 후 재시도
- 인증 만료: 재로그인 플로우
보안 고려사항
개발 중에는 편의를 위해 Origin 검증을 비활성화했지만, 프로덕션에서는 반드시 활성화해야 합니다.
// ❌ 개발 중 (임시)
// if (event.origin !== ADORA_ORIGIN) return;
// ✅ 프로덕션
const ALLOWED_ORIGINS = [
'https://your-iframe-domain.com',
'https://your-parent-domain.com',
];
if (!ALLOWED_ORIGINS.includes(event.origin)) {
console.error('[PARENT] Invalid origin:', event.origin);
return;
}또한 Rate Limiting을 추가하여 악의적인 공격도 방어했습니다:
const REQUEST_RATE_LIMIT = 10; // 초당 최대 10개
const REQUEST_WINDOW = 1000; // 1초
if (requestCount.current > REQUEST_RATE_LIMIT) {
throw new Error('Too many token requests');
}마무리
"그냥 토큰 하나 전달하면 되겠지"라고 생각했던 과제는 생각보다 복잡했습니다. 동시성 제어, 자동 재시도, 무한 루프 방지, 보안까지. 하나씩 해결하면서 안정적인 인증 시스템을 만들 수 있었습니다.
주요 해결 방법 요약
✅ 동시성 제어 → Request Queue + Promise 공유
✅ 자동 재시도 → Axios Interceptor 활용
✅ 무한 루프 방지 → 쿨다운 메커니즘 (3회/5초)
✅ 보안 → Origin 검증 + Rate Limiting
✅ 상태 동기화 → 부모-자식 토큰 일치 보장
핵심 교훈
1. 동시성은 예상보다 복잡하다
여러 API가 동시에 401을 받는 상황은 단순 플래그로 해결되지 않았습니다. Promise를 공유하는 패턴이 핵심이었죠. 처음엔 "이미 갱신 중이면 무시"하는 방식으로 접근했다가, 대기 중인 요청들이 실패하는 문제를 겪었습니다. 결국 모든 요청이 하나의 갱신 작업을 공유하도록 만들어 해결했습니다.
2. 사용자 경험이 최우선이다
기술적 구현도 중요하지만, 사용자가 인증 오류를 느끼지 않도록 하는 것이 가장 중요했습니다. 토큰이 만료되어도 자동으로 갱신하고 재시도하기 때문에, 사용자는 로그인 상태가 끊김없이 유지된다고 느낍니다.
3. 보안은 프로덕션에서 필수이다(당연하지만!)
개발 중에는 편의를 위해 Origin 검증을 비활성화했지만, 프로덕션에서는 반드시 활성화해야 합니다. 악의적인 사이트가 우리 토큰을 요청하는 것을 막으려면 Origin 검증과 Rate Limiting이 필수입니다.
iframe 환경에서 인증 통신을 구현하면서 마주쳤던 문제들과 해결 과정을 공유해 봤습니다. 비슷한 과제를 하시는 분들께 시행착오를 줄이는 데 도움이 되었으면 좋겠습니다. 더 나은 방법이나 궁금한 점이 있으시다면 댓글로 공유해 주세요!

