인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
1편에서 인증 분기를 서버에서 확정했고, 2편에서 그 변경으로 깨진 e2e까지 고쳤습니다. 그런데 정산달력 페이지만은 여전히 본문이 비어 있었습니다.
정산달력 페이지는 모바일과 데스크톱 레이아웃과 기능이 완전히 다르고, 그 분기를 matchMedia 기반 훅으로 처리하고 있었습니다. 서버는 뷰포트를 모르니, 인증 분기를 살려놓은 바로 아래에서 뷰포트 분기가 본문을 다시 비우고 있었던 것입니다.
// 단순화한 코드입니다
const Calendar = () => {
const { isMobile, isReady } = useViewport()
if (!isReady) return null // 서버에서는 항상 여기
return isMobile ? <MobileView /> : <DesktopView />
}
훅의 값은 클라이언트 마운트 이후에만 정해지기 때문에, 서버 렌더에서는 null이 나가고 캘린더 본문이 HTML에서 통째로 빠집니다.
서버가 뷰포트를 모른다면, 몰라도 되게 두 레이아웃을 다 보내면 됩니다. 가장 직관적인 해법입니다.
<div className="md:hidden"><MobileView /></div>
<div className="hidden md:block"><DesktopView /></div>
SSR은 해결됐습니다. 그리고 CI가 깨졌습니다. 두 트리가 동시에 DOM에 있으니 getByText가 같은 문구를 두 개 잡아 strict mode violation이 났습니다. .filter({ visible: true })로 CI는 통과시켰습니다.
CI는 넘겼지만, 테스트가 깨진 원인은 "두 트리가 동시에 마운트된다"는 설계 자체였습니다. 같은 성질이 테스트 밖에서도 무언가를 깨뜨리고 있지 않을까 싶어 전수 조사를 했고, 실제 문제가 세 가지 나왔습니다.
포털 누수. 비로그인 소개 화면의 상세 팝오버가 처음부터 열린 채로 그려지는데, Radix Popover는 document.body로 포털되어 hidden md:block 래퍼에 갇히지 않습니다. 모바일 화면 위에 데스크톱용 카드가 떠 있었고, 하필 크롤러용으로 고친 바로 그 경로였습니다.
중복 쿼리. 모바일에서만 쓰는 인접 달 조회가 데스크톱에서도 마운트돼 매번 불필요한 요청이 나갔습니다.
번들 크기. 두 레이아웃을 항상 함께 그리니 뷰포트에 맞는 쪽만 불러오도록 코드를 나눌 수 없고, 모든 사용자가 양쪽 레이아웃의 코드를 모두 받습니다.
CSS 숨김 설계의 검증 체크리스트
display:none기반 분기는 트리 전체에 대해 다음을 확인해야 합니다.
- 포털:
document.body로 나가는 컴포넌트는 래퍼에 갇히지 않음- 쿼리: 한쪽 전용 데이터 훅이 반대쪽에서도 마운트됨
- id와 label: 중복 id가 접근성 트리에 잠복
- 테스트 로케이터:
getByText는 hidden 요소도 잡음
체크리스트를 한 번 통과시키는 건 가능합니다. 문제는 새 컴포넌트가 추가될 때마다 네 항목을 다시 검토해야 한다는 것이고, 그 검토를 계속 지키기는 어렵습니다. 유지 비용이 이렇다면 이중 렌더라는 선택 자체를 다시 볼 차례였습니다.
되돌리기 전에, 다른 라이브러리들은 같은 문제를 어떻게 푸는지 공식 문서, 이슈, 소스로 조사했습니다.
| 주체 | 방식 |
|---|---|
| React 코어팀 (issue #23381) | "모든 변형을 렌더하고 effect가 다 돌게 두거나, hydration 전에 HTML을 잘라내라" |
| Artsy fresnel | 서버에서 둘 다 + 클라이언트 첫 렌더에서 비매칭 null |
| Next.js, Vercel | 미들웨어 userAgent().device.type으로 rewrite |
| MUI | useMediaQuery는 "두 번 렌더, 느림" 명시. SSR이면 UA 추정 권장 |
| Chakra | v2는 JS 분기 + "깜빡임" 명시. v3는 CSS 유틸로 회귀 |
갈래는 셋입니다. 둘 다 렌더 후 좁히기, CSS 한 트리, UA로 서버에서 판별하기. 첫 번째를 실제로 하는 건 fresnel 하나였고, React 코어팀 답변도 "effect가 다 돌게 두거나"라는 조건을 붙여 나이브한 이중 렌더를 권하지 않습니다. 제가 겪은 중복 쿼리가 정확히 그 지점입니다.
첫 번째 갈래에 해당하는 중간 안(hydration 전엔 두 벌, 후엔 한 벌)도 구현해 봤습니다. 불일치 경고 없이 동작했지만, 크롤러가 읽는 첫 HTML을 채운다는 이 작업의 목적에는 맞지 않았습니다. hydration 뒤에 한 벌을 걷어내도, 첫 HTML을 그대로 읽는 크롤러는 여전히 트리 두 벌을 받기 때문입니다.
남은 두 갈래 중 CSS 한 트리는 모바일과 데스크톱 레이아웃이 완전히 다른 이 페이지에는 맞지 않았습니다. 반면 UA 판별은 새로 드는 비용이 없었습니다. 인증 분기를 서버로 옮긴 페이지는 이미 인증 API 때문에 force-dynamic이라, headers()를 추가로 읽어도 달라지는 것이 없기 때문입니다.
export async function ServerAuthProvider({ children }) {
const auth = await getServerAuth()
const { device } = userAgentFromString((await headers()).get('user-agent') ?? '')
return (
<AuthContext.Provider value={auth}>
<ViewportHintContext.Provider value={toViewportHint(device.type)}>
{children}
</ViewportHintContext.Provider>
</AuthContext.Provider>
)
}
인증과 뷰포트 둘 다 요청 컨텍스트(쿠키, UA)에서 나오는 값이라 한 곳에서 확정합니다. 훅은 힌트를 초기값으로 쓰다가 matchMedia가 값을 잡으면 그쪽으로 넘어갑니다.
// 서버 렌더와 hydration 첫 렌더가 같은 힌트를 보므로 불일치 없음
if (!isReady && hint) {
return { ...hint, isReady: true }
}
UA는 추정이고 matchMedia가 확정입니다. 틀렸으면 마운트 직후 한 번 갈아탑니다. 예를 들어 iPad Safari는 데스크톱 UA를 보내 첫 HTML이 데스크톱 트리로 나갑니다. 추정 다음에 확정이 오는 구조가 이걸 흡수하고, 크롤러는 iPad UA로 오지 않으므로 목적에는 영향이 없습니다.
device.type을 힌트로 바꿀 때는 값이 undefined인 경우를 데스크톱으로 두는 게 중요합니다. 일반 PC 크롬은 device.type이 아예 없습니다.
서버가 정말 UA만으로 트리를 고르는지는 JavaScript를 끄고 확인했습니다. UA만 바꿔 같은 URL을 열면, 보이는 레이아웃은 전부 서버가 고른 트리입니다.
| 데스크톱 UA | Googlebot 스마트폰 UA |
|---|---|
마지막으로 세 단계가 첫 HTML에 실제로 무엇을 남기는지, 정산달력 페이지를 1편과 같은 스크립트로 쟀습니다. 프로덕션 행을 뺀 나머지는 각 단계의 커밋을 로컬 프로덕션 빌드로 띄운 값입니다.
| 단계 | script 제외 | 본문 텍스트 | <h1> |
|---|---|---|---|
| 프로덕션 (작업 전) | 13.6KB | 386자 | 0개 |
| 이중 렌더 (1차 시도) | 57.8KB | 985자 | 2개 |
| UA 힌트 (desktop) | 36.5KB | 614자 | 1개 |
| UA 힌트 (mobile) | 33.1KB | 757자 | 1개 |
UA 힌트의 HTML은 데스크톱 UA 기준으로 이중 렌더보다 37% 작습니다. 게다가 이중 렌더는 UA와 무관하게 같은 HTML을 내보내, 모바일 사용자도 데스크톱 트리까지 받습니다. 페이지 소스를 보면 <h1> 2개 중 첫 번째가 md:hidden 안에 있습니다. CSS로 숨겨도 HTML에는 두 벌이 그대로 실립니다.
같은 문제를 푸는 방법은 여러 가지입니다. 이중 렌더는 본문을 HTML에 싣는다는 목표를 가장 직관적으로 달성했지만, 새 컴포넌트가 생길 때마다 같은 검토를 반복해야 했고 포털처럼 래퍼 밖으로 나가는 컴포넌트는 예상하지 못한 곳에서 화면을 깨뜨렸습니다. 반대로 UA 힌트는 iPad처럼 추정이 틀리는 경우가 있었지만, 요구사항이 "크롤러가 받는 HTML에 본문이 있을 것"이었기에 받아들일 수 있었습니다. 방법을 고를 때는 요구사항이 정확히 무엇인지, 오래 유지해도 괜찮은지, 어떤 엣지 케이스가 있는지를 함께 따져야 합니다.
인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
특정 요청도, 붐비는 시간대도 아닌데 502가 간헐적으로 떴고 다시 보내면 멀쩡했습니다. 무작위처럼 보이는 이 오류는 어디서 생긴 걸까요?
검색엔진은 페이지를 읽을 수는 있어도 그 안의 숫자가 무엇을 뜻하는지는 모릅니다. 메타 태그를 다 넣어도 검색 결과가 파란 링크 한 줄에 머무는 이유를 정리했습니다.
전역 Suspense로 감싸면 경고는 사라지지만 페이지 본문도 HTML에서 함께 사라집니다. 동기 훅이 왜 Suspense 경계를 요구하는지 소스코드로 따라갔습니다.
여러 Claude Code 세션의 상태를 한눈에 보는 위젯을 만들며, 믿기 어려운 신호를 다른 신호로 교차 검증한 과정을 정리했습니다.
문서는 Server Action이 큐에 쌓인다고만 말합니다. 그 큐가 어디까지 하나로 묶이는지는 직접 재 보기 전에는 알 수 없었습니다.