인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
앞 글에서 주요 페이지의 인증 분기를 서버에서 확정해 본문을 HTML에 싣도록 바꿨습니다. 그런데 그 변경을 PR로 올리자 CI의 e2e 테스트가 깨졌습니다.
실패한 건 로그인을 전제로 하는 테스트 중 모바일 프로젝트뿐이었습니다. 데스크톱 프로젝트는 전부 통과했고, 실패한 샤드는 평소보다 몇 배 오래 걸렸습니다. 원인을 찾기까지 가설을 두 번 세웠고, 두 번 다 틀렸습니다.
CI에서는 API를 목으로 대신합니다. 서버 컴포넌트에서 인증 API를 호출하게 됐으니, 목이 없는 그 요청이 응답을 기다리다 테스트를 멈춰 세운다고 봤습니다. 유난히 긴 소요 시간이 근거처럼 보였습니다.
반증: 인증 API 목을 빼고 로컬에서 CI 설정으로 로그인 테스트를 돌려도 전부 통과했습니다. 목 부재가 원인이면 여기서 실패했어야 합니다. 게다가 Next.js 테스트 모드는 목에 없는 요청을 바로 끊어 버려서 기다릴 일 자체가 없습니다.
요청 대기가 아니라면 모바일에만 있는 조건이 원인이어야 했습니다. 모바일은 햄버거 메뉴를 열어야 로그아웃 버튼이 보입니다. 대기 중인 모달이 오버레이 자리를 먼저 차지해서 메뉴가 열리지 못한다고 봤습니다.
반증: 오버레이 우선순위를 정의한 상수를 열어 보니 모바일 메뉴가 1순위, 대기 중인 모달이 2순위였습니다. 메뉴가 밀릴 수 없는 순서였습니다.
두 번 틀리고 나서 두 프로젝트의 테스트 코드를 비교했습니다. 데스크톱은 사용자 정보 메뉴가 보이는지만 확인하고, 모바일은 햄버거 메뉴를 클릭해 시트를 열어야 했습니다. 클릭하지 않는 데스크톱은 깨질 일이 없었습니다.
결정적 증거는 Playwright trace였습니다.
| 순서 | 동작 | 결과 |
|---|---|---|
| 1 | waitForURL | 통과. URL만 봄 |
| 2 | 햄버거 클릭 | 성공, err=False |
| 3 | 로그아웃 대기 | 제한 시간까지 기다렸으나 실패 |
클릭은 DOM에 성공했습니다. 그런데 hydration이 끝나지 않아 핸들러가 붙어 있지 않았습니다. Playwright는 클릭이 성공했으니 재시도할 이유가 없고, 그래서 조용히 실패합니다.
SSR이 이 틈을 넓혔다예전에는 로그인 상태를 클라이언트에서 확인한 뒤에야 헤더가 로그인 메뉴를 그렸습니다. 그 버튼이 보이는 순간에는 이미 hydration이 끝나 있었습니다. SSR로 본문을 먼저 그리자 "보이지만 아직 눌리지 않는" 구간이 길어졌습니다. 새 버그라기보다 원래 있던 레이스가 드러난 것입니다.
가설 1에서 근거로 삼았던 긴 소요 시간도 인과가 반대였습니다. 요청 대기가 테스트를 느리게 한 게 아니라, 클릭이 유실되고 케이스마다 타임아웃이 쌓여 샤드 전체가 느려진 것이었습니다.
실제 사용자도 이 "보이지만 아직 눌리지 않는" 구간을 지납니다. 이 구간은 SSR이라면 피할 수 없습니다. SSR은 화면이 보이는 시점을 앞당길 뿐, 클라이언트가 JavaScript를 받아 hydration을 마치는 시점까지 앞당기지는 않기 때문입니다. 그래서 고칠 대상은 앱이 아니라, 화면이 뜨자마자 누르는 테스트 쪽이었습니다.
해법의 방향은 분명했습니다. 클릭 전에 hydration이 끝나기를 기다리면 됩니다. 문제는 무엇을 기준으로 기다리느냐였습니다.
| 기준 | 쓰지 않은 이유 |
|---|---|
고정 대기, networkidle | hydration 완료를 보장하지 않음 |
Next.js 내부 신호(window.__NEXT_HYDRATED 같은) | 언제든 바뀔 수 있는 내부 데이터 |
toPass() 재시도 | 동작은 하지만 회귀 탐지력을 같이 잃음. 토글을 다시 눌러 닫을 위험이 있고, 첫 클릭이 늘 유실되는 버그도 "여러 번 누르면 됨"으로 통과함 |
Atomic Object가 소개한 SvelteKit 사례를 참고했습니다. 앱이 hydration을 마치면 표식(마커)을 남기고, 페이지 이동 헬퍼가 그 마커를 기다리게 하는 방식입니다. 마커는 테스트 모드에서만 붙도록 가드해 프로덕션 동작에는 영향을 주지 않게 했습니다.
// 이름은 설명을 위해 단순화했습니다. E2E 전용 hydration 완료 표식
export const HydratedFlag = () => {
useEffect(() => {
if (!IS_E2E) {
return
}
document.documentElement.dataset.hydrated = 'true'
}, [])
return null
}
이 컴포넌트는 루트 레이아웃에 둡니다. 그 useEffect가 도는 시점이면 "이제 클릭이 처리되는" 상태로 볼 수 있고, 테스트는 html[data-hydrated="true"]가 붙기를 기다립니다.
가설을 세우는 과정에 아쉬움이 남았습니다. 첫 번째 가설은 긴 소요 시간과는 맞았지만 "모바일에서만"이라는 근거와는 맞지 않았는데, 그걸 따져 보지 않고 검증부터 했습니다. 가설은 모든 근거와 맞아야 후보가 되고, 하나라도 어긋나면 검증하기 전에 버려야 합니다.
이제 CI는 통과했습니다. 그런데 크롤러가 받는 HTML에서 정산달력 페이지의 본문은 여전히 비어 있었습니다. 인증 분기 바로 아래의 뷰포트 분기가 같은 문제를 안고 있었기 때문입니다. 그 이야기는 다음 편에서 이어집니다.
인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
특정 요청도, 붐비는 시간대도 아닌데 502가 간헐적으로 떴고 다시 보내면 멀쩡했습니다. 무작위처럼 보이는 이 오류는 어디서 생긴 걸까요?
검색엔진은 페이지를 읽을 수는 있어도 그 안의 숫자가 무엇을 뜻하는지는 모릅니다. 메타 태그를 다 넣어도 검색 결과가 파란 링크 한 줄에 머무는 이유를 정리했습니다.
전역 Suspense로 감싸면 경고는 사라지지만 페이지 본문도 HTML에서 함께 사라집니다. 동기 훅이 왜 Suspense 경계를 요구하는지 소스코드로 따라갔습니다.
여러 Claude Code 세션의 상태를 한눈에 보는 위젯을 만들며, 믿기 어려운 신호를 다른 신호로 교차 검증한 과정을 정리했습니다.
문서는 Server Action이 큐에 쌓인다고만 말합니다. 그 큐가 어디까지 하나로 묶이는지는 직접 재 보기 전에는 알 수 없었습니다.