데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
iOS에서 인증 버튼을 누르면 실패 안내만 뜨고 인증 팝업이 열리지 않는 문제가 있었습니다. 데스크톱 Chrome에서는 아무리 눌러도 재현되지 않았습니다.
인증 흐름은 이랬습니다. 서버에서 인증에 필요한 값을 먼저 받아 오고, 인증 팝업을 열어 그 값을 넘깁니다.
const handleClick = async () => {
const authValue = await prepareAuth(); // 네트워크 왕복
const popUp = window.open(AUTH_POPUP_URL, '_blank'); // iOS에서는 여기서 null
if (!popUp) return showOpenFailed();
// ...
};
window.open이 null을 돌려주고 있었습니다. 팝업 차단기가 막은 것입니다. 사용자가 분명히 버튼을 눌렀는데 왜 차단됐을까요?
브라우저는 팝업처럼 사용자를 방해할 수 있는 동작을, 사용자가 방금 무언가를 눌렀을 때만 허용합니다. HTML 표준은 이를 transient activation이라고 부릅니다. 클릭하면 활성화 플래그가 켜지고, 일정 시간이 지나면 꺼집니다.
문제는 await였습니다. await 뒤의 window.open은 클릭 태스크가 아니라 응답이 온 뒤의 태스크에서 실행되고, 그때까지 활성화가 유효한지는 브라우저마다 다르게 판단합니다.
| 브라우저 | await 뒤의 window.open |
|---|---|
| Chrome | 활성화 유효 시간이 약 5초(Chromium 구현값). API가 빠르면 통과 |
| iOS Safari 18.6 | 응답이 300ms여도 항상 차단 |
| iOS Safari 26.5 | 응답이 10초 미만이면 허용 |
표의 iOS 수치는 시뮬레이터 두 버전에서 직접 잰 값입니다. 최신 시뮬레이터(26.5) 하나로만 보면 기존 코드가 정상으로 보이므로, 실패한 기기와 같은 버전대로 재현해야 했습니다.
Chrome에서 되던 건 API가 빨라서 응답이 유효 시간 안에 왔기 때문입니다. 반면 iOS 18.6에서는 서버 응답 속도와 상관없이 매번 실패했습니다. 간헐적인 버그가 아니라 코드 자체의 문제였습니다.
값을 페이지에 들어올 때 미리 받아 두면 클릭 시점에 기다릴 일이 없습니다. 하지만 이 값은 인증을 시작할 때마다 외부 인증 서비스가 새로 발급하는 1회용 값입니다. 미리 받아 두면 누르지도 않을 인증마다 발급 요청이 나가고, 재시도할 때는 어차피 새로 받아야 합니다.
대신 인증 팝업은 값을 나중에 넘겨받아도 되는 구조였습니다. 그렇다면 순서만 뒤집으면 됩니다. 팝업은 클릭의 동기 구간에서 먼저 열고, 서버에서 받은 값은 준비되는 대로 팝업에 넘깁니다.
// 구조만 남긴 코드입니다
const handleClick = () => {
const popUp = window.open(AUTH_POPUP_URL, '_blank'); // 클릭과 같은 태스크에서 먼저 연다
if (!popUp) return showOpenFailed();
prepareAuth() // 네트워크 왕복은 팝업을 연 뒤에
.then((authValue) => sendWhenReady(popUp, authValue))
.catch(() => popUp.close());
};
처음에는 window.open 한 줄을 위로 올리는 수정처럼 보였지만, 기존 코드는 "팝업이 열릴 때는 값이 이미 있다"는 순서에 기대고 있었습니다. 순서를 뒤집자 그 가정에 기대던 곳이 하나씩 깨졌습니다. 아래 코드는 구조만 남긴 것입니다.
팝업은 열리고 나서 값을 받을 준비가 되면 신호를 보냅니다. 예전에는 값을 받은 뒤에 팝업을 열었으니, 신호가 올 때 값은 항상 있었습니다. 순서를 뒤집자 값을 기다리는 사이 팝업 쪽 준비가 먼저 끝나는 경우가 생겼고, 그 신호를 놓쳤습니다.
그래서 팝업을 연 직후부터 신호를 기다리고, 신호와 값이 둘 다 도착했을 때만 보내게 했습니다. 어느 쪽이 먼저 와도 결과는 같습니다.
let payload = null
let isPopUpReady = false
const sendWhenBothArrived = () => {
if (!isPopUpReady || !payload) return
popUp.postMessage(payload, window.location.origin)
}
window.addEventListener('message', (event) => {
if (!isReadyFromPopup(event, popUp)) return
isPopUpReady = true
sendWhenBothArrived() // 값이 먼저 와 있었다면 여기서 보낸다
})
const sendAuthData = (value) => {
payload = value
sendWhenBothArrived() // 신호가 먼저 와 있었다면 여기서 보낸다
}
예전에는 값을 받아 와야 팝업을 열었으니, 실패하면 팝업 자체가 없었습니다. 이제는 값을 받기 전에 팝업이 열려 있으니, 값을 받아 오지 못하면 빈 팝업이 남습니다. 실패하면 열어 둔 팝업을 닫는 경로를 넣었습니다. 값을 기다리는 사이 사용자가 팝업을 먼저 닫았다면, 받은 값은 보내지 않고 끝냅니다.
const session = openAuthSession(() => {
activeSession.current = null // 팝업이 닫히면 세션을 비운다
})
if (!session) return showOpenFailed()
activeSession.current = session
const response = await prepareAuth()
if (!response.ok) {
session.abort() // 열어 둔 빈 팝업을 닫는다
return showError()
}
// 기다리는 사이 팝업이 닫혔다면 위 콜백이 세션을 비웠고,
// 다시 눌렀다면 새 세션으로 바뀌었다. 어느 쪽이든 이 값은 보내지 않는다
if (activeSession.current !== session) return
session.sendAuthData(response.data)
팝업이 닫혔는지는 타이머로 확인하고, 닫히면 메시지 리스너와 타이머를 정리합니다. 예전에는 팝업을 닫는 건 사용자뿐이었는데, 이제는 실패했을 때 코드가 직접 닫는 경로도 생겼습니다. 이 경로에서는 정리가 빠져 타이머가 남았습니다.
정리를 endSession 한 함수로 모으고, 팝업이 닫히는 모든 경로가 이 함수를 거치게 했습니다. 경로는 둘입니다.
popUp.closed를 보고 endSession을 부릅니다.abort를 부릅니다.abort는 팝업을 닫기 전에 endSession부터 부릅니다. 팝업을 닫기만 하고 정리를 타이머에 맡기면, 다음 확인 때까지 정리가 미뤄집니다. 코드가 닫는 경우에는 닫는 순간을 이미 알고 있으니 기다릴 이유가 없습니다. 그래서 정리를 먼저 끝내고 창을 닫습니다. 그 뒤에 타이머가 같은 닫힘을 한 번 더 알아채도, isEnded 덕분에 정리는 한 번만 실행됩니다.
let isEnded = false
const endSession = () => {
if (isEnded) return // 두 경로가 겹쳐도 한 번만
isEnded = true
clearInterval(closedPollingTimer)
window.removeEventListener('message', handlePopupMessage)
}
// 사용자가 닫는 경로: 닫힌 것을 알아채면 정리한다
const closedPollingTimer = setInterval(() => {
if (popUp.closed) endSession()
}, 500)
// 코드가 직접 닫는 경로: 정리를 먼저 끝내고 창을 닫는다
const abort = () => {
endSession()
popUp.close()
}
수정 후에는 iOS 18.6과 26.5 모두에서 팝업이 바로 열렸습니다.
서로 다른 화면 사이를 설계하는 일은 늘 어렵습니다. 한쪽 코드만 봐서는 상대가 언제 준비되고 언제 사라지는지 알 수 없고, 그 사이에는 팝업을 언제 허용할지 정하는 브라우저까지 끼어 있습니다.
이번에도 그랬습니다. 브라우저의 규칙에 맞추려고 호출 순서 한 줄을 바꾸자, 부모 창과 팝업이 서로의 상태를 어떻게 가정하고 있었는지가 드러났습니다. 어느 쪽이 먼저 준비될지 정하지 않고 둘 다 도착했을 때 움직이게 하고, 창이 닫히는 경로가 여럿이어도 정리는 한 곳에서 하게 만든 것이 수정의 핵심이었습니다.
브라우저의 규칙도 한결같지 않았습니다. 같은 코드가 iOS 버전에 따라 열리기도 하고 막히기도 했으니, 실패한 환경과 같은 버전으로 재현하지 않았다면 원인을 놓쳤을 겁니다.
화면 사이의 약속은 어느 한쪽 코드에도 온전히 적혀 있지 않습니다. 순서를 바꾸기 전에, 그 순서에 무엇이 기대고 있었는지부터 찾아야 한다는 것을 다시 배웠습니다.
배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
전역 Suspense로 감싸면 경고는 사라지지만 페이지 본문도 HTML에서 함께 사라집니다. 동기 훅이 왜 Suspense 경계를 요구하는지 소스코드로 따라갔습니다.
여러 Claude Code 세션의 상태를 한눈에 보는 위젯을 만들며, 믿기 어려운 신호를 다른 신호로 교차 검증한 과정을 정리했습니다.