window.opener를 끊어버린 COOP 추적기1. 증상: 팝업 로그인은 열리는데, 마지막에 죽는다2. 추적 시작: 진짜 opener가 사라진 건지부터 확인했다3. 결정적 단서: COOP 관련 경고 메시지4. 원인 분석: 문제는 “내 코드”가 아니라 “리다이렉트 체인”에 있었다5. 왜 “간헐적”이었나: Report-Only는 힌트였고, 강제 COOP가 진짜 문제였다6. 해결 방향: window.opener를 단일 통신 채널로 믿지 않기로 했다7. 수정한 방식콜백 페이지부모 창8. 이번 이슈에서 배운 점1) “코드가 안 바뀌었는데 왜 깨졌지?”라는 질문은 위험하다2) window.opener는 생각보다 약한 의존성이다3) 디버깅은 결국 관찰 가능한 단서를 늘리는 일이다9. 마무리참고 자료
window.opener를 끊어버린 COOP 추적기
어제까지 멀쩡하던 MS 소셜 로그인이 어느 날부터 배포 환경에서 간헐적으로 실패하기 시작했다.
더 난감했던 건, 인증 로직을 바꾼 적이 없었다는 점이다. 백엔드 쪽 변경도 없었고, Git 로그를 뒤져봐도 로그인 플로우를 건드린 흔적은 없었다. 남은 단서는 콘솔에 찍힌 에러 로그뿐이었다.
Uncaught (in promise) TypeError: Cannot read properties of null (reading 'postMessage')
처음에는 단순한 프론트 버그라고 생각했다. 그런데 파고들수록 문제는 코드 한 줄이 아니라, 브라우저 보안 정책과 외부 인증 서비스의 리다이렉트 체인에 있었다.
이 글은
window.opener가 왜 갑자기 null이 되었는지 추적하면서 알게 된 Cross-Origin-Opener-Policy(COOP)의 동작과, 그 문제를 어떻게 우회했는지 정리한 기록이다.1. 증상: 팝업 로그인은 열리는데, 마지막에 죽는다
로그인 방식은 흔한 팝업 기반 OAuth 흐름이었다.
- 메인 창에서
window.open()으로 로그인 팝업을 연다.
- 팝업에서 Microsoft 로그인을 진행한다.
- 인증이 끝나면 우리 서비스의
/callback페이지로 돌아온다.
- 콜백 페이지에서
window.opener.postMessage()로 부모 창에 로그인 성공을 알리고, 팝업을 닫는다.
구조만 보면 단순하다.
문제는 4번에서 터졌다.
콜백 페이지가 부모 창에 메시지를 보내려는 순간, 어떤 경우에는
window.opener가 살아 있었고, 어떤 경우에는 null이었다. postMessage()는 다른 창과 안전하게 통신할 수 있게 해주지만, 전제 조건이 하나 있다. 대상 윈도우 객체 참조를 실제로 가지고 있어야 한다. window.opener가 사라진 순간, 이 통신 방식은 바로 무너진다.가장 이상했던 건 재현 패턴이었다.
- 로컬에서는 잘 된다.
- 배포 환경에서도 될 때가 있다.
- 그런데 어떤 사용자는 로그인되고, 어떤 사용자는 같은 흐름에서 실패한다.
이쯤 되면 “코드가 틀렸다”보다는 “환경에 따라 조건이 달라진다” 쪽이 더 맞다.
2. 추적 시작: 진짜 opener가 사라진 건지부터 확인했다
이럴 때 제일 위험한 건 에러 메시지만 보고 바로 결론 내리는 것이다. 그래서 먼저 확인한 건 딱 하나였다.
정말
window.opener가 null인가?
아니면 객체는 있는데, 접근만 제한된 상태인가?콜백 페이지에 로그를 심어 상태를 확인했다.
try { console.log("opener exists:",!!window.opener); console.log("opener closed:",window.opener?.closed); } catch (error) { console.error("failed to access opener:",error); }
이 로그 덕분에 상황이 조금 선명해졌다.
- 어떤 경우에는 정말
window.opener가null이었다.
- 또 어떤 경우에는 콘솔에 COOP 관련 경고가 보였다.
- 즉, 단순히 “팝업이 막혔다”가 아니라, 리다이렉트 도중 브라우저가 opener 관계를 끊는 경로가 존재한다는 쪽이 더 유력했다.
MDN 기준으로도
window.opener는 몇몇 경우에 null이 될 수 있는데, 그중 하나가 바로 응답에 Cross-Origin-Opener-Policy: same-origin이 적용된 경우다. 이 경우 새 문서는 opener와의 참조를 유지하지 못할 수 있다.3. 결정적 단서: COOP 관련 경고 메시지
콘솔을 자세히 보다가 익숙하지 않은 메시지가 눈에 들어왔다.
Cross-Origin-Opener-Policy policy would block the window.closed call
이 메시지가 중요했던 이유는, 문제의 방향을 완전히 바꿔줬기 때문이다.
이제 의심 대상은 애플리케이션 로직이 아니라 HTTP 응답 헤더, 그중에서도 COOP로 좁혀졌다.
COOP는 문서가 다른 창과 같은 browsing context group에 남을지, 아니면 새 그룹으로 격리될지를 제어하는 헤더다. 문서가 새 그룹으로 분리되면 opener와 popup 사이 참조가 끊길 수 있고, 그 결과
window.opener 기반 통신이 성립하지 않는다.4. 원인 분석: 문제는 “내 코드”가 아니라 “리다이렉트 체인”에 있었다
이제 DevTools의 Network 탭에서 로그인 리다이렉트 체인을 처음부터 끝까지 확인했다.
확인 결과는 이랬다.
- 내 서비스의 콜백 페이지에는 특별한 COOP 헤더가 없었다.
- 그런데 Microsoft 로그인 페이지와 중간 리다이렉트 응답들 중 일부에 COOP 관련 헤더가 있었다.
- 그리고 로그인 플로우는 같은 과정을 거쳐도 같은 응답을 항상 받지는 않았다.
즉, MS측의 바뀐 정책과 점진적 배포로 인해서인지, 매번 응답이 달라졌고, 어떨때는 opener 관계가 유지됐고, 어떤 경로에서는 중간 응답이 브라우징 컨텍스트를 분리해버린 것이다.
COOP의 핵심은 여기 있다.
문서를
Window.open()으로 열었더라도, 이후 탐색된 문서가 COOP 정책에 따라 새 browsing context group으로 이동하면 opener와의 참조가 끊어질 수 있다. 그래서 “팝업은 분명 내가 열었는데, 마지막 콜백에서는 opener가 없다” 같은 현상이 생긴다.5. 왜 “간헐적”이었나: Report-Only는 힌트였고, 강제 COOP가 진짜 문제였다
로그를 보다 보니 COOP 관련 헤더가 한 종류만 있는 게 아니었다.
Cross-Origin-Opener-Policy
Cross-Origin-Opener-Policy-Report-Only
여기서
Report-Only는 이름 그대로 정책 위반 가능성을 보고만 하고, 실제 차단은 강제하지 않는 모드다. 반면 실제 Cross-Origin-Opener-Policy는 브라우저 동작에 영향을 줄 수 있다. COOP는 report-only 모드를 지원해서, popup/opener 상호작용이 깨질 지점을 먼저 관찰할 수 있게 한다.그래서 현상은 이렇게 정리됐다.
- 어떤 경우에는 Report-Only만 걸려 있어서 콘솔 경고만 남기고 통신은 계속됐다.
- 어떤 경우에는 실제 COOP가 적용된 응답을 거쳐서 opener 관계가 끊어졌다.
- 그 결과 최종 콜백 페이지에서는
window.opener가null이 되었고,postMessage()가 실패했다.
결국 원인은 “MS 로그인 페이지가 이상하다”가 아니라, 더 정확히 말하면 외부 인증 서비스의 리다이렉트 체인 안에 있는 COOP 정책이 팝업-부모 창 연결을 보장하지 않는다는 점이었다.
6. 해결 방향: window.opener를 단일 통신 채널로 믿지 않기로 했다
여기서 중요한 사실이 하나 있다.
외부 서비스 응답 헤더는 내가 제어할 수 없다.
그렇다면 해결책은 원인을 제거하는 게 아니라,
window.opener가 끊겨도 로그인 완료를 전달할 수 있는 구조로 바꾸는 것이다.핵심 아이디어는 이거였다.
- 1차 시도:
postMessage()로 부모 창에 전달
- 실패 시 fallback: 같은 origin끼리 공유되는
localStorage를 이용해 신호 전달
localStorage는 same-origin 문서끼리 공유되고, 한 문서가 값을 바꾸면 다른 same-origin browsing context들에서 storage 이벤트가 발생한다. 중요한 점은, 이 이벤트는 값을 바꾼 자기 자신에게는 오지 않고 다른 창들에서만 발생한다는 것이다. 팝업과 부모 창이 같은 origin이라면 fallback 채널로 쓰기에 적당했다.7. 수정한 방식
콜백 페이지
const LOGIN_SIGNAL_KEY = "LOGIN_SIGNAL"; function notifyLoginSuccess() { const payload = { type: "LOGIN_SUCCESS", timestamp: Date.now(), }; let delivered = false; try { if (window.opener && !window.opener.closed) { window.opener.postMessage(payload, window.location.origin); delivered = true; } } catch (error) { console.warn("opener communication blocked by browser policy", error); } // opener 통신이 불가능하면 same-origin storage 이벤트로 fallback if (!delivered) { localStorage.setItem(LOGIN_SIGNAL_KEY, JSON.stringify(payload)); } window.close(); } notifyLoginSuccess();
부모 창
const LOGIN_SIGNAL_KEY = "LOGIN_SIGNAL"; useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; if (event.data?.type !== "LOGIN_SUCCESS") return; router.push("/home"); }; const handleStorage = (event: StorageEvent) => { if (event.key !== LOGIN_SIGNAL_KEY || !event.newValue) return; localStorage.removeItem(LOGIN_SIGNAL_KEY); router.push("/home"); }; window.addEventListener("message", handleMessage); window.addEventListener("storage", handleStorage); return () => { window.removeEventListener("message", handleMessage); window.removeEventListener("storage", handleStorage); }; }, [router]);
이렇게 바꾸면 이상적인 경우에는 기존처럼
postMessage()가 먼저 동작하고, opener가 끊긴 경우에만 storage 기반 fallback이 동작한다.8. 이번 이슈에서 배운 점
1) “코드가 안 바뀌었는데 왜 깨졌지?”라는 질문은 위험하다
웹에서는 코드만 시스템이 아니다. 브라우저 정책도 바뀌고, 외부 서비스의 응답 헤더도 바뀌고, 로그인 플로우의 리다이렉트 경로도 사용자 상태에 따라 달라진다.
즉, 내 코드가 그대로여도 실행 환경은 그대로가 아닐 수 있다.
2) window.opener는 생각보다 약한 의존성이다
팝업 로그인은 오랫동안 많이 써온 패턴이지만, 브라우저는 계속 더 보수적으로 바뀌고 있다.
noopener, COOP, 브라우징 컨텍스트 격리 같은 정책이 들어오면 opener 관계는 언제든 끊길 수 있다. COOP는 이런 popup/opener 관계를 제어하기 위해 설계된 헤더이고, 경우에 따라 참조를 아예 끊어버릴 수 있다.3) 디버깅은 결국 관찰 가능한 단서를 늘리는 일이다
처음부터 COOP를 의심한 건 아니다.
window.opener 상태를 로그로 찍고, 콘솔 경고를 보고, 네트워크 헤더를 따라가면서 원인을 좁혔다.이번 이슈도 결국 에러 메시지 하나를 믿지 않고, 상태를 직접 확인한 것이 해결의 시작이었다.
9. 마무리
이번 문제를 겪고 나서 팝업 로그인 코드를 보는 관점이 조금 달라졌다.
예전에는
window.opener.postMessage()를 당연한 통신 방법으로 생각했다. 지금은 다르게 본다. 그건 “되면 좋은 1차 채널”이지, 항상 존재한다고 가정해도 되는 기반은 아니다.외부 인증 서비스와 브라우저 보안 정책이 개입하는 순간, 팝업과 부모 창의 연결은 애플리케이션이 보장할 수 있는 영역이 아니다.
그래서 해결책도 단순했다.
opener에 의존하지 않는 fallback을 준비한다.그 한 가지 원칙으로, “가끔 실패하는 로그인”을 “정책이 바뀌어도 버티는 로그인”으로 바꿀 수 있었다.