배포환경에서 정적으로 빌드된 페이지에 접근할 때 다음과 같은 오류가 발생했다.
Uncaught Error: Minified React error #425;
React 앱에서 발생하는 오류 중 하나입니다. 이 오류는 주로 React 앱이 프로덕션 모드로 빌드된 후 발생합니다.
리액트 앱이 프로덕션 환경에서 빠르고 경량화된 코드 생성을 위해 코드 압축과 최적화를 수행하는데 이 때 발생하는 에러라고 한다.
여러 버전의 Minifed Error
가 있는 듯 하고 친절하게 콘솔에 어떤 에러인지도 코드와 함께 알려주고 있으니 발생된 순서대로 확인해보기로 헀다.
#425
Text content does not match server-rendered HTML.
컨텐츠가 서버에서 렌더링한 HTML과 일치하지 않음
#418
Hydration failed because the initial UI does not match what was rendered on the server.
기본 UI가 서버에서 렌더링한 것과 일치하지 않아 직렬화된 캐시데이터 복원에 실패함
#423
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
역시 hydrate
실패 오류
서버사이드에서 빌드할 때 만든 페이지 정보와 실제 보여지는 부분이 뭔가 달라서 발생하는 에러로 보였다.
딱 특정 페이지에서만 해당 에러가 발생했고 어떻게 디버깅해보려했지만 쉽지 않아보였고 직접 찾는게 더 빠를 것 같아 의심가는 후보들을 추려가며 직접 찾았다.
Quill Editor 문제??
export const QuillNoSSRReader = ({ content }: { content: string }) => {
const Result = dynamic(
async () => {
const { default: QuillComponent } = await import('react-quill');
return () => <QuillComponent theme="bubble" readOnly value={content} />;
},
{
loading: () => (
<div className="quill">
<div className="ql-container ql-bubble ql-disabled">
<div className="ql-editor" data-gramm="false" dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
),
ssr: false,
},
);
return Result;
};
해당 페이지에는 에디터로 등록한 내용을 보여주는 부분이 있어 읽기 전용 에디터 컴포넌트를 사용했다.
dynamic import
를 적용해 렌더링 되기 전에는 HTML 태그기반으로 보여주고 브라우저가 떴을 때 의도된 컴포넌트를 보여주게 했다.
에디터 자체적으로 이렇게 사용하면 문제가 된다던가? 아니면 내부 내용물을 보여주는 방식이 둘이 다른가? 싶었지만 마음을 가다듬고 다시 생각해보았을 때 해당부분은 전혀 문제가 없다는 것을 알게 되었다.
애초에 서버 측에서 생성하는 내용물은 계속 같을 것이고 브라우저가 뜬 뒤에야 react-quill
을 import하고 컴포넌트로 보여주기 때문에 데이터 불일치 문제와 관련 없을 것이라고 생각했다.
오히려 위와 같은 동적 임포트는 문제가 발생한 지점에서 클라이언트 사이드 렌더링을 시켜 문제를 해결할 수 있는 방안이지 않나 싶었고 실제로 에디터를 없애고 빌드했을 때도 계속 문제가 발생하여 원인이 아님을 알 수 있었다.
정적 페이지에서 유일하게 빤짝거리는 놈을 찾았다
어제도 리전이 문제였는데 위의 문제도 마찬가지이다!.
실제로는 리전이 문제라기보다 빌드하는 시점의 타임존과 날짜 처리가 문제였다.
위의 내용은 테스트하느라 수정한 것이고 원래 등록한 날짜와 현재 날짜를 비교해 X분 전, X시간 전으로 보여주게끔 구현했었는데
현재날짜를 기준으로 무언가 처리해서 UI에 보여지게하는 이 동작이 문제라고 확신했다.
어쩐지 정적 빌드 페이지인데 17시간전 => 8시간전
이렇게 계속 깜빡거리더라
SSG페이지를 빌드하는 것은 serverless function
의 리전과 상관없이 빌드하는 시점의 서버 타임존을 기준으로 할 것이기 때문에 페이지가 빌드되었을 때 기준 타임존과 날짜로 페이지가 생성되었을 것이고 요청하는 쪽의 타임존은 다르고 현재 시간도 다르기 때문에 위와 같은 문제가 발생한 것이다!
실제로 해당 UI부분을 지우고 빌드하니 오류가 사라졌다.
로컬에서 빌드했을 때는 왜 못 봤을까?
빌드할 때와 실행할 때 타임존이 같아서라고 생각한다.
로컬환경에서 문제를 재현해보기 위해 아래처럼 타임존을 바꿔서 빌드했더니 오류가 식별되었다.
빌드 후 생성된 정적 HTML이나 네트워크 탭을 확인해보면 이렇게 초기에 생성될 때는 런던이라고 되어있다.
확실하게 확인해보고 싶을 겸 빌드시점과 요청 시점의 UI가 너무나도 명확하게 달라지게 만들어 테스트해보았다.
const Abc = () => (
<div>
<h1>{Math.random()}</h1>
</div>
);
역시나 똑같은 에러코드를 보여준다.
productionBrowserSourceMaps
를 true
로 설정하고 개발자 도구에서 어떤 지점에서 오류가 발생하는 건지 디버깅해서 찾아보려고 했는데 쉽지는 않은 것 같다.
Minifed React Error
를 또 접하게 되면 상태 코드 메시지 보고 잘 추적을 해야할 것 같다.
어라 왜 날짜만 깜빡이지?
라는 경험을 생각해보니 꽤 많이 해봤다.
한창 nextjs
처음 접하고 공부하면서 여러 사이트들을 돌아다니며 '아 여기는 어떤 방식으로 렌더링했군` 같은거 확인하고 그랬었는데 말이다.
콘솔에는 나타나지 않지만 본인과 같은 상황인 것 같다.
해당 UI가 있는 페이지는 내가 쓴 글 목록 같은게 있고 이는 모두가 볼 수 있는 페이지인데 페이지를 만든 시점에는 UTC 시간으로 되어있다.
기억은 안나지만 어떤 사이트에서는 초기 날짜가 아예 UI에 보여줄 포맷에 맞춰 1953년으로 되어있거나 비어있었던 경우를 본 것 같다.
export const getRelativeTimeByDateTime = (time: string) => {
const today = new Date();
const timeValue = new Date(time);
const betweenTime = Math.floor((today.getTime() - timeValue.getTime()) / 1000 / 60);
if (betweenTime < 1) return '방금전';
if (betweenTime < 60) {
return `${betweenTime}분전`;
}
const betweenTimeHour = Math.floor(betweenTime / 60);
if (betweenTimeHour < 24) {
return `${betweenTimeHour}시간전`;
}
const betweenTimeDay = Math.floor(betweenTime / 60 / 24);
if (betweenTimeDay < 3) {
return `${betweenTimeDay}일전`;
}
// ...some result
}
서버에서 준 createdAt
정보를 그대로 썼다면 사실 이 문제는 발생하지 않았을 것이다.
위와 같이 현재 시간 기준으로 처리해서 보여주다보니 발생한 문제였는데 new Date()
를 빌드 시점에 UTC
로 가져오지만 페이지를 요청했을 때는 Asia/Seoul
로 가져와서 발생하는 문제라고 보았다.
위와 같은 날짜처리를 해야한다면 이 문제는 근본적으로 깜빡거림을 없앨 수는 없을 것 같다.
해당 날짜 처리 함수를 동적 임포트로 로드해 처리하거나 useEffect
를 사용해 마운트 된 후 날짜 변환 처리를 해주면 해결될 것 같다.
export const RelativeDateText = ({ date }: { date: string }) => {
const RelativeDateTextComponent = dynamic(
async () => {
const { getRelativeTimeByDateTime } = await import('@/utils/common');
return () => <>{getRelativeTimeByDateTime(date)}</>;
},
{
loading: () => <>{getYYMMDD(date)}</>,
},
);
return RelativeDateTextComponent;
};
X분 전
같은 상대날짜를 해당부분에만 사용할 것 같지 않고 굳이 useEffect
를 써서 한번 더 리렌더링 시키기는 좀 그래서
상대날짜 처리 동적 컴포넌트를 따로 만들어 사용하는 쪽에서는 페이지 렌더링 방식을 신경쓰지 않아도 되게 구성해 보았다.
서버에서 페이지를 만들 때도 X년 X월 X일
포맷으로는 처리하게끔 해두어 오래된 게시글에서는 레이아웃 시프트가 발생하지 않을 것이다.
적용 전
적용 후
문제를 숨긴 것이 아니라 문제의 근본을 파악해 개선한 것 같아 뭔가 뿌듯하다!