이 문제를 마주한 상황을 풀어보면, 저는 Next.js의 app router를 쓰고 있으며 현재는 학습 플랫폼의 기능 중 하나인 동영상 플레이어를 구현 중이에요. 이때 대국민 비디오 라이브러리 video.js를 활용하여 비디오 플레이어를 띄우고 있었어요. (다른 녀석을 찾아볼까…?싶었지만 디자인 커스텀, 플레이 관련 옵션 기능이 많아서 시간이 촉박한 개발을 하고 있는 현재에는 가장 잘 어울리는 선택이라 판단)
근데 video.js 특성상 Client Side Rendering(CSR)만 가능한 상황이에요.
그런데 저는 현재 app router를 쓰고 있어 default가 SSR로 작동을 하고 있죠. 따라서 dynamic import(next/dynamic)를 사용해서 SSR 옵션을 false로 설정 해야했어요.
next/dynamic
⇒React.Lazy를 확장해서 만든 Next.js 동적 임포트 방식
(동적 임포트란 미리 불러오는게 아니라 직접적으로 컴포넌트가 필요한 상황에서 불러오는 것)
const VideoPlayerComponent = dynamic(
() => import('@/components/classroom/player/components').then((mod) => ({ default: mod.VideoPlayer })),
{
ssr: false,
},
);
위처럼 말이죠.
동적 임포팅을 통해 원하는 컴포넌트가 잘 나오고 있었어요. 그런데 한가지 아쉬운 점이 있던 것이 CSR방식을 활용하다보니 관련 js번들이 다운받아지는 동안 아무것도 보이지 않다 다운이 완료 되면 뿅!하고 튀어나오는 것이죠.
이를 개선하기 위해서 Suspense 태그를 써서 fallback 컴포넌트를 띄워 놓으려고 했어요.
next/dynamic도 React.Lazy를 확장하여 구현된 것이니 충분히 Suspense를 사용할 수 있겠다고 판단
// VideoPlayer를 동적으로 import
const VideoPlayerComponent = dynamic(
() => import('@/components/classroom/player/components').then((mod) => ({ default: mod.VideoPlayer })),
{
ssr: false,
},
);
export function VideoWrapper({ className = '', ...videoProps }: VideoWrapperProps) {
return (
<div className={`h-full w-full ${className}`}>
{/*Suspense태그를 사용해서 번들을 다운받는 동안 fallback 컴포넌트를 보여주자*/}
<Suspense
fallback={
<div className="flex h-full w-full items-center justify-center bg-black">
<div className="text-white">비디오 컴포넌트 로딩 중...</div>
</div>
}>
<VideoPlayerComponent {...videoProps} />
</Suspense>
</div>
);
}
위처럼 말이죠!
그런데 오잉?? fallback이 안 보이는 것이에요?!? 따로 에러가 나지도 않는데 이게 뭐지??하며 원인을 찾기 시작했어요.

이 원인은 문서에도 나와있었어요. (…머쓱)

[Invalid-dynamic-suspense] 챕터
위 에러가 발생하는 경우는 3가지가 있어요.
dynamic 임포트에서 { suspense: true } 옵션을 사용하면 이 오류가 발생할 수 있습니다.suspense: true + { ssr: false } 옵션과 함께 사용: dynamic 임포트에서 suspense: true와 ssr: false 옵션을 함께 사용하면 서버 컴포넌트에서 ssr: false가 무시되어 오류가 발생합니다. React 18 이상 버전은 서버에서 Suspense 경계를 해결하려고 시도하기 때문입니다. [!나의 상황!]suspense: true + { loading } 옵션과 함께 사용: dynamic 임포트에서 suspense: true와 loading 옵션을 함께 사용하면 오류가 발생합니다. dynamic 임포트에서 suspense: true를 설정하면 React가 가장 가까운 Suspense 경계의 fallback을 사용하게 되므로, loading 옵션은 제거해야 합니다.저의 경우에서는 2번과 동일한 상황이었어요.
해결법은 아주 간단했는데요.
suspense를 지우고 next/dynamic에서 제공하는 loading속성을 사용하면 되는 것이죠.
해결은 했지만, 왜…? 왜지….? 왜일까…..? 어째서…..? 라는 생각에 하나씩 짚어 나가봤어요.

그 전에 “나는 왜 next/dynamic에 ssr:false와 Suspense를 함께 쓰려고 했지?” 라는 질문으로 돌아가봤어요.
video.js를 사용해서 비디오를 만들 것임.video.js는 CSR로만 동작함.CSR로 동작하기 위해 dynamic import를 사용할 때 SSR에 false 속성을 주었음.CSR로 동작할 때 필요한 js를 다운받고 그리는 동안의 대체 페이지가 필요함.Suspense를 사용함.결론적으로 저는 “대체 페이지를 그리기 위함”이 가장 큰 목적이었고, 그 목적을 달성하는 방식의 내부 동작에서 무언가 충돌이 있다는 것을 눈치 챌 수 있었어요.
그렇다면 Suspense와 SSR:false가 어떻게 동작하기에 내부적으로 충돌이 생기는 것 일까요?
react에서 Suspense는 비동기 작업이 끝나기 까지 fallback 페이지를 보여주는 역할을 해요. 이런 Suspense는 Next.js에서 사용하게 되면 Next.js는 서버에서 Suspense로 감싼 컴포넌트가 로딩 될 때 가장 가까운 부모 <Suspense>의 fallback을 렌더링하도록 준비해요.
next/dynamic에 ssr:false는 Next.js에게 "이 컴포넌트를 서버에서 아예 렌더링하지 마"라고 명시적으로 지시하는 것이에요. 결론적으로 ssr:false을 쓰게 된다면 서버는 이 컴포넌트 영역을 비워둔 채로 클라이언트에게 HTML을 보내게 되는 것이죠.
두 사이에 모순이 있다는 것을 눈치 채셨을 것 같은데요…

"A 컴포넌트(
Suspense로 감싼 컴포넌트)를 서버에서 Suspense로 렌더링해줘 (suspense)"
"아니, A 컴포넌트를 서버에서 렌더링하지 마-영역 자체를 비워 둠 (ssr: false)"
아니!!!!~~~!!! 어느장단에춤쳐야하니!!@@##!@# 하고 무시 당해버리는 것인거죠.
한 마디로 정리해보면 서버는 부모에 있는 <Suspense> 태그를 정상적으로 인식했지만, 문제는 그 Suspense 태그가 동작해야 할 자식 컴포넌트가 ssr: false 때문에 서버 렌더링 과정에서 제외되면서, suspense라는 태그의 의미가 무효화되고 혼란을 만들어내요
해결법으로 제시한 loading은 뭔데 저걸 쓰면 해결이 되는걸까요?
아주 조금만 생각해보면 뭔가 next/dynamic에 특화된 녀석일 것 같은 냄새가 나긴 하죠.. (킁킁)
loading은 next/dynamic의 독자적인 로딩 UI 처리 방식으로 동적으로 가져오고 있는 컴포넌트가 렌더링 되기 전까지 보여주는 역할을 해요
그르니까 ssr:false와 Suspense사이의 모순이 있어서 무효화가 된 것과 그의 대안책이 명료하게 이해가 되죠.
추가적으로 역할을 좀 더 자세하게 보면
loading 속성은 컴포넌트의 번들(bundle)이 다운로드되는 동안의 로딩 상태를 처리.next/dynamic을 사용하면 해당 컴포넌트 코드가 별도의 JavaScript 파일(청크)로 분리loading 속성은 이 JavaScript 청크가 네트워크를 통해 다운로드되는 동안 보여줄 임시 UI(placeholder)를 지정하는 역할을 함loading과 ssr: false의 관계:ssr: false를 설정하면 서버는 해당 컴포넌트의 HTML을 렌더링하지 않고, 클라이언트에서 컴포넌트 코드를 다운로드하고 렌더링loading 속성에 정의된 UI를 표시!이렇게 next/dynamic이 동작하는 과정을 살펴보고 ssr의 속성(T/F)에 따라 어떻게 fallback page를 보여줘야하는지 감을 잡는 기회가 되었어요! (사실 너무 당연하고 간단한 것이겠지만요.)
react의 Lazy를 통해 코드 스필리팅을 하는 과정에서도 CSR과 SSR의 차이가 존재하고 그 안에서 Suspense의 상충과 대안으로의 loading을 알아볼 수 있었습니다. 하나하나 되짚어 가는 과정이 재미있었네요!
오늘도 재미있는 next…

[추가]
ㅇㅇ: 님아, 그러면 ssr:true로 next/dynamic를 하고는 Suspense를 쓰는게 나음???
ㄴ ㅇㅇㅇㅇㅇㅇ 자네 정확하게 이해했군..!
문서에서도 ssr:true 혹은 ssr속성을 따로 커스텀 하지 않고 defualt로 쓸 때(defualt가 true)는 loading보다는 suspense를 쓰라고 안내하고 있음.
https://nextjs.org/docs/messages/invalid-dynamic-suspense
그게 리엑트의 본질에 더 맞다~~ 이 말이야
참고로 loading은 그냥 next/dynamic를 한 요소의 chunk를 다운 받을 동안 쓰는 말그대로 loading의 영역인거임.. 추가적으로 데이터 패칭이나 그런걸 한다면 그때는 그에 맞는 Suspense를 사용하셔야합니다!!!!!
[추가]
dynamic과 lazy??: dynamic과 lazy가 많이 다른가??
ㄴ 잼미니(Gemini) said…
[next/dynamic과 React.lazy의 관계]
next/dynamic은 React.lazy와 근본적으로 다른 방식으로 동작하는 것이 아니라, React.lazy를 포함하여 Next.js에 맞게 확장된 기능입니다.
next/dynamic은 컴포넌트를 동적으로 불러와 코드 스플리팅을 하는 데 사용되며, 서버 사이드 렌더링(SSR) 환경에서 최적화되도록 설계되었습니다. 반면, React.lazy는 오직 client side rendering(CSR) 환경에서만 작동합니다.
next/dynamic은 서버와 클라이언트 환경을 모두 고려합니다. ssr: false 옵션을 사용하지 않는 한, next/dynamic은 서버에서 동적 컴포넌트를 미리 렌더링(pre-render)하여 초기 로딩 성능을 최적화합니다.React.lazy는 클라이언트 사이드에서만 작동하며, 컴포넌트가 렌더링될 때까지 해당 컴포넌트의 코드를 불러오는 작업을 지연시킵니다.Next.js는 suspense: true 옵션이 설정되면 내부적으로 React.lazy와 유사한 방식으로 동작하도록 컴포넌트를 처리합니다. 이로 인해 suspense와 loading 속성이 충돌하는 것입니다.
따라서 next/dynamic은 React.lazy의 기능을 포함하고, 추가로 서버 렌더링을 지원하는 등 Next.js 환경에 맞게 기능이 확장된 '고수준(Higher-level)' API라고 할 수 있습니다
[추가]
??: Suspense는 클라이언트 서버 둘 곳에서 모두 동작하는데 ssr:false속성이 있긴하지만 그래도 왜 서버에서 먼저 찾고 오잉?! 컴포넌트 없어!! 하고 단정 지어서 무시해버리는거야? 클라에서는 먼저 못찾아? 서버에서 찾게하는걸 무시할 순 없어?
ㄴ 그것이….Next.js니까…
Suspense는 서버와 클라이언트 모두에서 동작하지만, Next.js의 기본 철학이 서버 우선(server-first)이기 때문에 서버에서 먼저 렌더링을 시도하는 것임
그런데 React 18 이상에서는 서버 Suspense를 끌 수 없기 때문에 ssr: false와 suspense: true가 근본적으로 호환되지 않는 것…

친절하게 말해주고 있었습니다…. 우선적으로 서버에서 해결하려하는데 오잉?? 너 컴포넌트 없는데?? 하니까 걍 고장이 나버리는 것

[추가]
??: 근데 왜 client component도 ssr로 동작하지? ssr: true/false를 해야하는게 웃기지 않음?? ‘use client’붙이면 클라 컴포넌트고 그러면 클라에서 렌더링 되는거 아니야?
ㄴ 아 ㅋㅋㅠㅠ 선생님… 선생님은 이제
React Server Component(RSC)와React Client Component(RCC)라는 개념에 익숙해지셔야함ㅠㅠ
리터럴리 니가알던내가아냐!
선생님들께서 아시던 기존의. 기본의. 그냥. 그런. 컴포넌트는 React Client Component(RCC)로 치환 된거임 그리고 React Server Componet(RSC)라는 새로운 레이어를 추가한 것임.
근데? rsc는 서버에서만 실행되지만, rcc는 서버에서도 브라우저에서도 실행 되는 것임 (아니 클라이언트라며….ㅠ)
여기서부터 이제 뇌가 동공지진을 내기 시작함.
뇌: 잉 머라고???/?? 클라이언트라면서…. 클라이언트라면서!@!!!!!

민수가 민수가 아니고 민지가 민지가 아니게 되는 상황이 발생!
왜냐면, 기존 react server component가 없을 때를 생각해보면(react client component만 존재했던 상황) 컴포넌트는 브라우저에서도 서버에서도 렌더링이 되었었음.
빈 index.html(빈거)를 서버에서 내려주잖아? 또, SSR까지 생각해보면 이는 거의 완성된 html을 만들고 브라우저로 내려줬잖아. 그쵸? 이게 기존에 알던 react이고 component죠?
ㅈㅏ. 이것들 이것들 까지도 우리는 이제 React Client Component라고 부르기로 한거임…. 그러니까 네이밍은 client라면서 서버에서도 렌더링을 하는 민지가 민수고 민수가 민지고 니가 알던 내가 아니고 그니까 클라이언튼데 서버에서 실행되는 상황 발생…
React “Client” Component라는 이름에 클라이언트!!!에 종속되지 말아라가 가장 키 포인트임.. 내 생각엔 그냥 React Client Component 이름을 머리에서 지우고 "hydration이 필요한 컴포넌트”라고 하는게 맞는 개념일 것 같음! (Client/Server라는 명칭이 물리적 위치에 대한 개념이 절대 아님!)
추가적으로 ’use client” 붙였는데 그러면 client component아님? 왜 아직도 서버에서 렌더링 댐?이라는 질문을 이미 누군가. 했음ㅋㅋ 그리고 관련된 discussions이 있음
https://github.com/reactwg/server-components/discussions/4
이거 보고 리액트는 이름을 잘못 지은게 맞다!! 생각함. (나만 헷갈리는게 아니잖아!!!!)

공식문서 짱! discussions 짱!!
https://nextjs.org/docs/app/guides/lazy-loading