평화로울 뻔했던 배포날 문제가 터졌다. 개발 환경에서는 보지 못했던 에러가 상용 배포 후에 발견된 것이다.

로컬에서 빌드한 뒤 띄워도 재현되지 않았고, 오직 실제 배포 환경에서만 발생했다.
- #425 — Text content does not match server-rendered HTML
- #418 — Hydration failed because the initial UI does not match what was rendered on the server
파악해 본 결과 하이드레이션 에러였다.
콘솔 에러와 함께 페이지가 뜨는 순간 CSS가 적용되지 않은 쌩UI가 잠깐 보였다가 사라지는 FOUC 현상도 포착할 수 있었다.
📌 접근1 : CSS 로드 상태 확인
FOUC 현상을 보자마자 가장 먼저 떠오른 건 CSS다.
CSS 파일이 늦게 로드되거나 하이드레이션 타이밍이 어긋나서 발생한 문제가 아닐까 싶었다.
Network 탭을 열어 CSS 요청의 타이밍과 응답을 확인했으나, CSS 로드 자체엔 문제가 없었다.
상태코드도 모두 200에 요청이 실패한 흔적도 없었다.
📌 접근2 : 모든 브라우저가 동일하게 동작하는지 확인
이상한 점은 개발 중 개발 환경 배포를 해봤을 때도, 직접 Docker로 개발 서버 올려 봤을 때도 문제가 없었다는 것이었다.
놓친 부분이 있을까 싶어 다시 개발 환경으로 돌아와 디버깅을 시도해 보았다.
브라우저별로 하나씩 테스트해보니, 웨일 브라우저에서만 FOUC 현상이 나타나는 것을 확인했다.
DOM을 열어보니 <html> 아래에 작성한 적 없는 <whale-quicksearch>라는 요소가 추가되어 있었다.
서버 HTML에서는 브라우저를 구분할 수 없으니 클라이언트에서 추가되는 요소라고 추측했고,
그러면 서버와 클라이언트 HTML이 달라지니까 하이드레이션 에러가 발생하는 게 아닐까?라고 생각했다
이건 웨일이 퀵서치 기능을 위해 자체적으로 넣는 요소라고 하여 클라이언트 진입 파일에서 이 요소를 제거하는 방어코드를 넣고 다시 배포해 보았다.
// entry.client.tsx (hydrateRoot() 호출 전에 실행되어야 함)
document.querySelectorAll('whale-quicksearch, [data-whale-ext]').forEach((el) => el.remove());
솔직히 특정 브라우저에서만 문제가 되는 이 이유가 원인은 아닐 것 같았고, 정말 아니었다ㅎㅎ;
여전히 상용에서는 동일한 이슈가 발생하고 있었다. 완전 헛다리 짚은 것
📌 접근 3 : 번역 파일 상태 확인
순식간에 지나가는 FOUC 현상을 자세히 들여다봤고, 놓치고 있던 큰 문제를 발견했다.
label_login 같은 번역 키 문자열이 화면에 그대로 찍혔다가 '로그인'으로 바뀌고 있었다. 진짜 0.1초의 순간...

번역 키가 그대로 보인다는 건?
서버가 렌더링 하는 시점에 번역 파일을 제대로 읽지 못한 상태라는 뜻이었다.
그러면 결과적으로 FOUC는 아래와 같은 순서를 거쳐 발생했을 것이다.
서버가 번역 파일을 읽지 못함
→ 서버 HTML에 번역 키가 그대로 렌더링됨
→ 클라이언트가 기대하는 HTML과 불일치 (#425)
→ 하이드레이션 실패 (#418)
→ React가 서버 DOM을 버리고 클라이언트에서 전체 리렌더링
→ 리렌더링 과정에서 CSS도 다시 요청/적용됨
→ 두 번째 CSS 반영이 끝나기 전에 DOM은 이미 그려짐
→ 스타일 없는 화면이 잠깐 노출 (FOUC)
여기서 한 가지 의문이 생겨났다.
그러면 클라이언트 렌더링에서는 어떻게 정상적으로 번역 파일을 찾아 가져올 수 있었을까?
찾아보니 클라이언트는 파일 시스템 경로가 아니라 nginx에 HTTP 요청을 보내 정적 파일로 서빙되는 번역 파일을 직접 받아오기 때문이라고 한다.
서버 프로세스의 작업 디렉토리와 무관한 경로라, 클라이언트 렌더링에는 영향이 없었던 것이다.
📌 해결 과정
번역 파일 자체는 정상적으로 존재했고, 로컬에서도 아무런 문제가 없었기에 배포 환경 별로 번역 파일을 찾아내지 못하는 게 원인일 거라고 바로 알 수 있었다.
기존엔 번역 파일 경로가 다음과 같이 되어 있었다.
resolve('./public/locales/{{lng}}/{{ns}}.json');
Remix는 빌드 시 public 폴더의 내용을 build/client로 옮긴다.
그래서 소스 상태로 실행하는 로컬, 개발 환경에서는 .public/locales가 그대로 존재했지만 경로가 달라진다.
- 로컬 -> public/locales가 루트에 그대로 존재
- 개발 (온프레미스) → public 폴더를 마운트해서 ./public/locales가 존재
- 상용(AWS) → 빌드할 경우 public이 build/client로 흡수돼 번역파일이 ./client/locales에 존재
( 현재 회사에서는 인프라 비용 문제로 개발 환경은 온프레미스 Docker를 사용하고, 상용은 AWS를 사용하고 있는데 두 배포 파이프라인이 달랐던 걸 간과했다..)
그래서 번역 파일 경로를 하나로 고정하는 대신 런타임에 실제 번역 파일이 어디에 있는지 분기 처리되도록 했다.
// server.ts / entry.server.tsx 공통
// 번역 파일 경로 자동 감지
// 1. 로컬 dev: ./public/locales
// 2. 개발 서버(Docker): ./public/locales (볼륨 마운트)
// 3. 상용 서버: ./client/locales (빌드 폴더가 루트)
const getLocalesPath = () => {
if (existsSync('./public/locales')) {
return resolve('./public/locales/{{lng}}/{{ns}}.json');
}
return resolve('./client/locales/{{lng}}/{{ns}}.json');
};
const localesPath = getLocalesPath();
재배포 후 확인하니 정상동작!
글로 정리하니 생각보다 쉽게 원인 찾고 해결한 것 같지만 꼬박 하루를 써버린 경험 ..ㅎㅎ..
FOUC 현상을 보니 CSS 문제라는 생각에 꽂혀 괜한 삽질만 엄청 했었는데, 알고보니 배포 파이프라인이 달라 생긴 문제라는 것에 약간 허탈하기도 했다.
그래도 이번 기회에 하이드레이션 에러가 어떤 상태에서 발생할 수 있는지 더 깊게 찾아볼 수 있어 좋았다.