가수면

Next SSR에서 ReferenceError 등의 오류 본문

일지

Next SSR에서 ReferenceError 등의 오류

니비앙 2024. 4. 23. 01:40

초기 상태값을 설정하는 과정에서 'next referenceerror: localstorage is not defined'와 같은 오류가 발생하는 경우가 있다.

 

단순 해결 방법이야 간단하다.

상태값의 업데이트를 useEffect 안에서 수행하거나, typeof window === 'undefined'와 같은 타입 가드를 사용하면 오류를 해결할 수 있다.

 

그러나 만약 '로그인 한 상태에서 새로고침 시 로딩이나 로그인 버튼 노출 없이 바로 닉네임이 노출되도록 하는 기능'구현한다고 한다면, 위 방법으로는 해결할 수 없다.

 

내가 바로 이 경우였는데, 오류 해결 이후 원하는 기능을 구현하기 위해 나는 '왜 이런 오류가 발생한 것일까?', '구조적으로 이게 진짜 한계인 건가?'라는 의문을 품게 되었다.

기능 구현 시도 과정

0. 목표

닉네임 전역 상태값이 존재한다면 닉네임을, 값이 없다면 로그인 버튼을 띄우는 로그인 컴포넌트 로그인 상태에서 새로고침 시 어떠한 로딩도 없이 바로 닉네임을 띄우는 것을 목표로 설정했다.

1. 오류 발생

기능 구현 과정에서 발생한 오류 상황을 시퀀스 다이어그램으로 표현한 이미지다.

 

새로고침 시 로그인 뷰가 바로 보이도록 초기 전역 상태값을 Local/Session Storage.getItem으로 설정했더니 ReferenceError가 발생했다.

 

라우저 상으로는 원하는 기능이 정확히 구현되었으나, 이 오류로 인해 빌드가 실패하게 되는 문제가 발생했다.

 

구글링을 통해 해결 방법을 찾았고, 나는 typeof window를 사용해 오류 해결을 시도하였다.

2.초기 전역 상태값에 타입 가드 설정

초기 전역 상태값으로 typeof window === "undefined"를 사용해 타입 가드를 적용했다.

typeof window === "undefined" ? null : localStorage.getItem("nickname")

 

그 결과 두 가지 문제가 발생했다.

1. 새로고침 시 로그인 버튼에서 닉네임으로 바뀌는 것이 눈으로 보이는 문제

2. 브라우저에 다음 오류가 발생하는 문제

Warning: Expected server HTML to contain a matching <div> in <div>.

Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.

app-index.js:33 Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.

Uncaught Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

 

우선 바뀌는 것이 눈으로 보이는 이상 이 방법은 실패했지만, 나는 두 번째 오류가 발생한 것에 집중했다.

오류의 내용인 즉슨, 서버에서 렌더링한 것과 Hydration 과정에서 브라우저 초기 렌더링 값이 불일치하기 때문에 Hydration에 실패하여 클라이언트 측에서 전체 페이지를 다시 렌더링했다는 것이다.

 

이후 개발자 도구를 열어 현재 요소와 네트워크 탭에서 받아온 document를 비교해보았다.

그리고 이 시점에서 내가 아주 중요한 사실을 오해하고 있었다는 것을 알게되었다.

바로 "use client"가 설정된 클라이언트 컴포넌트도 서버에서 프리렌더링된다는 사실이다.

 

사실 그 동안 개발하면서 클라이언트 컴포넌트는 CSR인 줄 알고 있었다...

그래서 dynamic import에 대해 공부할 때 왜 굳이 이걸...?이라는 의문이 있었는데 이 오해를 바로잡고 나니 그제야 여태까지의 모든 퍼즐들이 딱딱 들어맞으며 풀리기 시작했다.

 

최초 Local/Session Storage.getItem를 사용했을 때 오류가 발생한 원인은 서버 사이드에서 클라이언트에서만 사용하는 API를 호출하려고 했기 때문에 참조 오류가 발생했던 것이었다.

그렇기 때문에 typeof window === "undefined"로 조건문을 걸어 서버 사이드에서 호출되지 않도록 한 것이고.

 

그리고 이어서 나는 '그럼 쿠키는 어떨까?'라는 호기심이 떠올랐다.

3. 초기 전역 상태값에 쿠키 조회값 설정

먼저 예상대로 쿠키 역시 Hydration 오류가 발생했다.

그리고 흥미로운 것은 서버에서 쿠키를 읽을 때 undefined로 읽는다는 것이었다.

 

나는 그 이유를 js-cookie는 클라이언트에 저장된 쿠키를 읽는 방식이기 때문이라고 이해할 수 있었다.

참조 오류가 발생하지 않는 이유는 서버에서도 쿠키를 다룰 수 있다는 것인데, 이 또한 Spring Boot로 개발하며 쿠키를 다룬 경험이 있었던 부분이라 쉽게 이해할 수 있었다.

 

즉, 서버 사이드에서도 쿠키를 다룰 수 있기에 참조 오류는 발생하지 않았으나, 클라이언트의 cookie를 가져오려고 했기 때문에 쿠키 값으로 undefined가 할당 된 것이라 할 수 있겠다.

4. useEffect 내부에서 Dispatch로 상태값 업데이트

여기까지 이해한 이상 useEffect 내부에서 닉네임을 업데이트하는 것이 기능 구현을 완성시킬 방법이 아니라는 것은 쉽게 예측할 수 있었다.

예상했던 것처럼 로그인 버튼에서 닉네임으로 바뀌는 것이 눈으로 아주 잘 보였다.

 

Hydration이 발생하고, 그 과정에서 구조적으로 useEffect안의 비동기 처리가 완료될 때까지 프리렌더링 된 html의 값이 노출될 수밖에 없는 것이다.

5. 로그인 컴포넌트의 렌더링 방식을 CSR로 변경

나는 이 단계에서 목표로 했던 기능이 구현될 것으로 예상했다.

로그인 컴포넌트를 dynamic import해서 ssr옵션을 false로 설정해 CSR로 동작시키도록 변경하면,

초기 전역 상태값으로 localStorage.getItem을 설정했을 때 마운트되는 즉시 로딩 없이 닉네임이 노출될 것이며,

클라이언트에서만 렌더링할 테니 Hydration 불일치 오류가 발생하지 않을 것이다.

그러나 예상치 못한 결과가 나왔다.

 

다 렌더링을 마치고 웹 페이지가 이미 로딩되었는데, 로그인 컴포넌트만 아직 마운트가 되지 않아 마운트될 때까지 공간이 덩그러니 비어있는 문제가 발생한 것이다.

 

프리렌더링 된 HTML을 받아오는 SSR 속도가 CSR 보다 당연히 빠르다는 것을 놓치고 있었다.

생각해보니 초기 렌더링이 느리다는 것은 CSR의 손꼽는 단점 중에 하나이다...

기능 구현 완료

우선 로딩 없이 바로 닉네임을 노출시키는 것에 대한 제한 사항을 정리해보니 다음과 같았다.

1. CSR은 로딩을 보여줘야하니 SSR로 동작시키는 환경에서 해결해야 한다.

2. 초기 전역 상태값으로 닉네임을 설정하되, Local/Session Storage와 같은 클라이언트 API는 사용할 수 없다.

3. 로딩을 보여줘야하니 비동기 처리는 안 된다.

 

정리하고 나니 서버에서 아예 프리렌더링할 때 닉네임을 박는 것 말고는 구조적으로 방법이 없다는 결론을 내렸다.

 

서버에서 닉네임을 얻기 위해 첫 번째로 떠올린 방법은 서버사이드에서 닉네임을 응답값으로 받는 API 요청을 날리고 그 결과로 분기 처리하는 것이었다.

그러나 이 기능 하나 때문에 요청을 한 번 더 날린다는 게 너무 비효율적인 것 같아 차라리 로딩스피너를 띄우는 것이 더 좋겠다는 판단으로 기각했다.

 

두 번째는 쿠키로 설정했었을 때 참조 오류 없이 undefined가 할당되었던 것에서 아이디어를 얻을 수 있었다.

Spring Boot에서 쿠키에 보안 설정을 한 뒤 응답 헤더에 세팅한 경험이 있다.

그럼 당연히 node 환경에서도 요청이나 응답 헤더의 쿠키를 조작할 수 있지 않을까?

 

나는 Next가 풀스택 프레임워크였기 때문에 해당 기능이 있지 않을까 먼저 Next 공식문서에 cookie를 키워드로 검색했다.

그리고 요청 헤더의 쿠키를 조회하는 API를 찾을 수 있었다!

서버 사이드에서 요청 헤더 조회

해결방법은 간단했다.

서버 컴포넌트인 사이드바에서 요청 헤더의 쿠키를 조회하여 닉네임을 얻은 후, 로그인 컴포넌트에서 해당 닉네임으로 분기 처리하는 방식으로 해결했다.

마치며...

위 방법들을 전부 시도해 보며 Next의 렌더링 방식과 서버/클라이언트 컴포넌트의 동작을 꽤나 깊게 이해할 수 있는 제법 알찬 경험을 한 것 같다.

 

만일 서버에서 렌더링한 것과 Hydration 과정에서 브라우저 초기 렌더링 값이 불일치하기 때문에 Hydration 과정에서 오류가 생기는 것과 같이 Javascript가 기대하는 DOM 구조와 실제 DOM 구조가 달라서 Hydration이 발생하는 경우라면, 클라이언트 사이드에서 기대하는 값으로 수정하지 말고, 서버 사이드 단계에서 기대하는 값을 적용해 프리렌더링 시키는 것으로 DOM을 일치시키는 것이 바람직한 방식일 것이다.

 

 

 

 

 

Comments