증상
언어 설정 화면에서 언어를 고르면 첫 클릭이 무시되는 경우가 있습니다. 잠시 뒤 다시 누르면 정상 동작합니다.
화면은 이미 다 그려져 있어서 사용자 입장에서는 "눌렀는데 아무 일도 안 일어난다"로 보입니다.
재현 조건
PR #147 검증 중 두 차례 재현했습니다.
- 언어를 전환하면
window.location.assign으로 전체 페이지 로드가 발생합니다.
- 로드 직후 곧바로 다른 언어를 누르면 무시됩니다.
- 특히
/settings/language로 진입해 서버가 /en/settings/language로 307 리다이렉트한 직후에 더 잘 재현됐습니다.
원인 (확인 완료 — 처음 적었던 것과 다릅니다)
처음에는 "HTML은 즉시 그려지지만 하이드레이션 전까지 <button onClick> 핸들러가 없다"로 봤습니다. 아닙니다.
프로덕션 /settings/language의 HTML을 직접 받아 확인했습니다.
{ "hasLanguageLabels": false, "buttonCount": 0, "anchorCount": 0,
"ariaHiddenSkeleton": true }
언어 목록이 서버 응답에 아예 없습니다. 버튼도 링크도 0개입니다. 목록은 스타일 프로브(useSettingsStyleReady)가 풀린 뒤에야 그려지는데, 그건 하이드레이션이 끝난 다음입니다. 즉 핸들러 없는 목록을 누를 수 있는 순간 자체가 존재하지 않습니다.
진짜 원인은 스켈레톤입니다.
SettingsLanguageSkeleton이 헤더와 행 자리를 그대로 흉내 내서 화면이 다 그려진 것처럼 보입니다.
- 그런데
pointer-events: none이라 클릭이 막힌 층에 걸려 사라집니다.
- 언어를 바꾸면 전체 페이지가 다시 로드되므로 그 직후에 이 구간을 다시 만납니다.
- 307이 한 번 더 끼면 스타일 청크가 더 늦게 도착해 구간이 길어집니다. → 재현율이 올라간 이유.
재현 조건 세 가지와 전부 맞아떨어집니다.
검증된 해법
아래 셋을 함께 적용하면 해소됩니다. 구현·검증까지 마쳤고 CI도 통과했으나, 진행 순서 조정으로 이번에는 반영하지 않습니다. 코드는 브랜치 fix/language-switch-before-hydration과 닫힌 PR #182에 남아 있습니다.
1. 언어 목록을 처음부터 낸다 (스켈레톤 뒤에 두지 않음)
이 화면의 인라인 폴백 스타일이 이미 클래스와 같은 배치를 담고 있어(스타일 청크가 늦을 때 쓰려고 만들어 둔 것), 스켈레톤 없이도 화면이 튀지 않습니다.
2. 행을 링크로 바꾼다
로케일은 이미 URL이 유일한 원천이라 언어 전환은 다른 주소로 가는 것 이상이 아닙니다. 접근성도 좋아집니다.
3. 선호 기록을 서버로 옮긴다
링크만으로는 스크립트가 돌지 않아 쿠키를 쓸 수 없습니다. /set-language/{로케일}{돌아갈 자리}로 들어온 요청만 사용자의 선택으로 보고 기록한 뒤 그 자리로 돌려보냅니다. 로케일 URL을 그냥 방문한 것은 여전히 선호가 아닙니다(#147 유지).
함정 1 — 쿼리 파라미터로는 안 됩니다
처음에 ?set-lang=ko 표시로 만들었더니 기본 로케일(한국어) 선택이 통째로 무시됐습니다.
/settings/language?set-lang=ja 200 OK <- 가드가 안 돔
/en/settings/language?set-lang=ko 307 <- 여긴 돔
프리렌더된 정적 파일이 서버 핸들러보다 먼저 응답하기 때문입니다. 이 레포는 vite.config.ts의 AUTH_DEPENDENT_PATHS 주석에서 이미 같은 함정을 경고하고 있습니다. 기본 로케일 자리야말로 프리렌더 대상이라 예외를 둘 수 없어, 전용 경로여야 합니다.
함정 2 — 가드 순서
선택을 가장 먼저 처리하지 않으면, 기본 로케일을 고른 경우 아직 살아 있는 낡은 쿠키가 사용자를 방금 고른 것과 다른 곳으로 끌고 갑니다.
함정 3 — normalizeLocale을 경로에 쓰면 안 됩니다
normalizeLocale은 Accept-Language의 en-US를 받으려고 접두사 일치를 씁니다. 경로 조각에 그대로 대면 english → en, javascript → ja, korean → ko로 통과합니다. 경로 조각 전용 파서가 따로 필요합니다.
검증 방법
E2E를 JS를 끈 브라우저로 돌리면 하이드레이션 전 상황이 그대로 재현됩니다. 스켈레톤 게이트를 되돌리면 3건 중 2건이 깨지는 것까지 확인했습니다.
영향 범위
apps/web/src/routes/settings.language.tsx
apps/web/src/features/settings/ui/LanguageSettingList.tsx
apps/web/src/shared/i18n/server-locale-guard.ts
apps/web/src/shared/store/language.ts
남은 별건
홈 헤더의 언어 드롭다운은 목록을 여는 것부터 스크립트가 필요해 같은 방식으로는 풀리지 않습니다(role="listbox" 안의 <a>는 접근성상으로도 어긋납니다). 홈도 프리렌더 대상이라 트리거 버튼의 첫 탭이 하이드레이션 전이면 같은 식으로 사라질 수 있습니다.
증상
언어 설정 화면에서 언어를 고르면 첫 클릭이 무시되는 경우가 있습니다. 잠시 뒤 다시 누르면 정상 동작합니다.
화면은 이미 다 그려져 있어서 사용자 입장에서는 "눌렀는데 아무 일도 안 일어난다"로 보입니다.
재현 조건
PR #147 검증 중 두 차례 재현했습니다.
window.location.assign으로 전체 페이지 로드가 발생합니다./settings/language로 진입해 서버가/en/settings/language로 307 리다이렉트한 직후에 더 잘 재현됐습니다.원인 (확인 완료 — 처음 적었던 것과 다릅니다)
처음에는 "HTML은 즉시 그려지지만 하이드레이션 전까지
<button onClick>핸들러가 없다"로 봤습니다. 아닙니다.프로덕션
/settings/language의 HTML을 직접 받아 확인했습니다.{ "hasLanguageLabels": false, "buttonCount": 0, "anchorCount": 0, "ariaHiddenSkeleton": true }언어 목록이 서버 응답에 아예 없습니다. 버튼도 링크도 0개입니다. 목록은 스타일 프로브(
useSettingsStyleReady)가 풀린 뒤에야 그려지는데, 그건 하이드레이션이 끝난 다음입니다. 즉 핸들러 없는 목록을 누를 수 있는 순간 자체가 존재하지 않습니다.진짜 원인은 스켈레톤입니다.
SettingsLanguageSkeleton이 헤더와 행 자리를 그대로 흉내 내서 화면이 다 그려진 것처럼 보입니다.pointer-events: none이라 클릭이 막힌 층에 걸려 사라집니다.재현 조건 세 가지와 전부 맞아떨어집니다.
검증된 해법
아래 셋을 함께 적용하면 해소됩니다. 구현·검증까지 마쳤고 CI도 통과했으나, 진행 순서 조정으로 이번에는 반영하지 않습니다. 코드는 브랜치
fix/language-switch-before-hydration과 닫힌 PR #182에 남아 있습니다.1. 언어 목록을 처음부터 낸다 (스켈레톤 뒤에 두지 않음)
이 화면의 인라인 폴백 스타일이 이미 클래스와 같은 배치를 담고 있어(스타일 청크가 늦을 때 쓰려고 만들어 둔 것), 스켈레톤 없이도 화면이 튀지 않습니다.
2. 행을 링크로 바꾼다
로케일은 이미 URL이 유일한 원천이라 언어 전환은 다른 주소로 가는 것 이상이 아닙니다. 접근성도 좋아집니다.
3. 선호 기록을 서버로 옮긴다
링크만으로는 스크립트가 돌지 않아 쿠키를 쓸 수 없습니다.
/set-language/{로케일}{돌아갈 자리}로 들어온 요청만 사용자의 선택으로 보고 기록한 뒤 그 자리로 돌려보냅니다. 로케일 URL을 그냥 방문한 것은 여전히 선호가 아닙니다(#147 유지).함정 1 — 쿼리 파라미터로는 안 됩니다
처음에
?set-lang=ko표시로 만들었더니 기본 로케일(한국어) 선택이 통째로 무시됐습니다.프리렌더된 정적 파일이 서버 핸들러보다 먼저 응답하기 때문입니다. 이 레포는
vite.config.ts의AUTH_DEPENDENT_PATHS주석에서 이미 같은 함정을 경고하고 있습니다. 기본 로케일 자리야말로 프리렌더 대상이라 예외를 둘 수 없어, 전용 경로여야 합니다.함정 2 — 가드 순서
선택을 가장 먼저 처리하지 않으면, 기본 로케일을 고른 경우 아직 살아 있는 낡은 쿠키가 사용자를 방금 고른 것과 다른 곳으로 끌고 갑니다.
함정 3 —
normalizeLocale을 경로에 쓰면 안 됩니다normalizeLocale은 Accept-Language의en-US를 받으려고 접두사 일치를 씁니다. 경로 조각에 그대로 대면english→en,javascript→ja,korean→ko로 통과합니다. 경로 조각 전용 파서가 따로 필요합니다.검증 방법
E2E를 JS를 끈 브라우저로 돌리면 하이드레이션 전 상황이 그대로 재현됩니다. 스켈레톤 게이트를 되돌리면 3건 중 2건이 깨지는 것까지 확인했습니다.
영향 범위
apps/web/src/routes/settings.language.tsxapps/web/src/features/settings/ui/LanguageSettingList.tsxapps/web/src/shared/i18n/server-locale-guard.tsapps/web/src/shared/store/language.ts남은 별건
홈 헤더의 언어 드롭다운은 목록을 여는 것부터 스크립트가 필요해 같은 방식으로는 풀리지 않습니다(
role="listbox"안의<a>는 접근성상으로도 어긋납니다). 홈도 프리렌더 대상이라 트리거 버튼의 첫 탭이 하이드레이션 전이면 같은 식으로 사라질 수 있습니다.