Next.js의 App router를 활용한 사이드 프로젝트 진행 중, 서버 컴포넌트 사이에 클라이언트 컴포넌트를 배치해야만 하는 구조를 만들어야 했다.
처음에는 잘못된 접근으로 삽질을 좀 하긴 했으나 ㅎ__ㅎ
이번 기회에 RSC, RCC의 렌더링 프로세스에 대해 더 많이 이해할 수 있었다!
- 768px 이상에서는 모바일 너비인 393px를 유지
- 768px 미만에서는 브라우저 뷰포트 너비를 가득 채우기
- 메인 영역의 스크롤 방향에 따른 하단 네비게이션 가시성 처리
- 스크롤 내릴 때 : 숨김
- 스크롤 올릴 때 : 보임

이에, 다음처럼 3가지 레이아웃을 만들었다.
app/layout.tsx:NextIntlClientProvider,TanstackQueryProvider등을 가지는ResponsiveLayout.tsx: 최상단 레이아웃와 모바일 영역을 유지하며 반응형 처리
MainLayout.tsx: 헤더/하단 네비게이션을 가지는 레이아웃
ResponsiveLayout과 MainLayout를 분리한 이유는, 헤더와 네비게이션을 가지지 않는 로그인 등 인증과 관련된 페이지들이 존재해서다.
gif가 CORS 오류로 업로드가 안된다..!!
최종적으로 잘 동작하는 것은 여기 사이트에서 확인 가능하다..
위에서 언급했듯이 상단 헤더와 하단 네비게이션을 MainLayout.tsx에 정의하여 그 하위의 페이지들에는 헤더/네비게이션이 렌더링 되도록 아래와 같은 구조를 생각했다.
MainLayout.tsx
import Header from '@/components/layout/Header';
import BottomNavigation from '@/components/layout/BottomNavigation';
import ModalPortal from '@/components/layout/ModalPortal';
import { Toaster } from '@/components/ui/toaster';
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children} // page.tsx 컴포넌트가 들어올 자리
<BottomNavigation />
<ModalPortal />
<Toaster />
</>
);
}
위와 같은 구조에서, BottomNavigation이 children 내부 요소의 스크롤을 감지해야 하는 상황인 것.
use client를 사용하면 쉽게 해결이 된다만, 나는 children이 될 page.tsx는 주요 콘텐츠에 대한 빠른 로딩과 SEO 처리를 위해 서버 컴포넌트로 렌더링되기를 원했다.
ScrollObserver로 children을 감싸더라도 클라이언트 컴포넌트의 자식 컴포넌트이니 서버 컴포넌트가 되지 못한다.
이에, 조금 다른 접근을 했다.
스크롤 영역이 감지되어야 하는 페이지에서 특정 아이디값을 부여하고, 그 아이디에 스크롤 이벤트를 달면 안되나? 하는..
클라이언트 컴포넌트의 자식 컴포넌트이니 서버 컴포넌트가 되지 못한다. 라는 생각은 매우 잘못된 생각이었다 ^__^..
그리고 실제로 페이지 별로 최상단 태그에 id값을 부여하여 해당 id 값으로 스크롤을 감지하는 코드로 기능을 구현했다.
아래는 나의 나름의 아이디어를 실현해본.. 코드..
useScrollDirection.tsx
특정 아이디를 가진 요소의 스크롤 이벤트 동작 감지, 방향과 현재 위치 계산
이벤트 연속 발생 방지를 위해 throttle 적용
import throttle from 'lodash/throttle';
import { useState, useEffect } from 'react';
import { usePathname } from '@/i18n/routing';
import { useSearchParams } from 'next/navigation';
import { DOM_IDS } from '@/constants/domIdentifiers';
const useScrollDirection = () => {
const [scrollDirection, setScrollDirection] = useState<'up' | 'down'>('up');
const [scrollPosition, setScrollPosition] = useState<'top' | 'bottom' | 'middle'>('middle');
const [prevScrollY, setPrevScrollY] = useState(0);
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const scrollContainer = document.querySelector(`#${DOM_IDS.CURRENT_SCROLL_PAGE}`);
if (!scrollContainer) return;
const handleScroll = throttle(() => {
if (!scrollContainer) return;
const currentScrollY = scrollContainer.scrollTop;
const scrollHeight = scrollContainer.scrollHeight;
const clientHeight = scrollContainer.clientHeight;
// 현재 스크롤 포지션 구하기
if (currentScrollY <= 0) { // 모바일에서 상단으로 추가 스크롤 되는 경우 때문에 === 가 아닌 <= 로 처리해야함.
setScrollPosition('top');
} else if (currentScrollY + clientHeight >= scrollHeight) {
setScrollPosition('bottom');
} else {
setScrollPosition('middle');
}
// 스크롤 방향 구하기
if (currentScrollY > prevScrollY) {
setScrollDirection('down');
} else if (currentScrollY < prevScrollY) {
setScrollDirection('up');
}
setPrevScrollY(currentScrollY);
}, 100); // 100ms 간격으로 스크롤 이벤트 처리
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [prevScrollY, pathname, searchParams]); // prevScrollY, routing이 변경될 때마다 effect 재실행
return { scrollDirection, scrollPosition };
};
export default useScrollDirection;
const scrollContainer = document.querySelector(`#${DOM_IDS.CURRENT_SCROLL_PAGE}`);
위와 같은 방법으로 요구사항은 만족 시켰으나, 마음 한켠 계속 찝찝하고 신경이 쓰였다.
태그에 아이디 값을 부여하여 직접 DOM을 조작하는 방식은, 리액트의 선언적이고 컴포넌트 기반의 접근 방식과 맞지 않기 때문이다.
이에, 주말에 참여하고 있는 모각코 스터디에서 이런 문제를 주제로 이야기를 나눠봤는데, 정말 감사하게도 스터디원께서 관련 레퍼런스를 전달 주시면서 서버 컴포넌트를 children으로 넘기면 RSC-RCC-RSC의 구조가 가능하다는 사실을 알려주셨다.(레코 감사함니다ฅ ʕ•ᴥ•ʔ)
그리고 해당 힌트로부터 아래와 같은 구조로 id 값을 제거하고 useRef를 사용하여 요구사항을 만족시킬 수 있었다!!!
레이아웃들
최상단에서 반응형 레이아웃을 가지고, 반응형 레이아웃은 헤더와 하단 네비게이션을 가지는 MainLayout을 갖는다. (반응형 레이아웃 하위에 헤더/네비게이션이 필요 없는 페이지가 있어서 분리)
BottomNavigation은 페이지들의 부모가 될 main 태그의 ref를 넘겨받는다.

useScrollDirection.tsx
넘겨받은 Ref의 스크롤 이벤트 동작 감지, 방향과 현재 위치 계산
이벤트 연속 발생 방지를 위해 throttle 적용
'use client';
import throttle from 'lodash/throttle';
import { useState, useEffect } from 'react';
export const useScrollDirection = (targetRef: React.RefObject<HTMLElement>) => {
const [scrollDirection, setScrollDirection] = useState<'up' | 'down'>('up');
const [scrollPosition, setScrollPosition] = useState<'top' | 'bottom' | 'middle'>('middle');
const [prevScrollY, setPrevScrollY] = useState(0);
useEffect(() => {
const scrollContainer = targetRef.current;
if (!scrollContainer) return;
const handleScroll = throttle(() => {
if (!scrollContainer) return;
const currentScrollY = scrollContainer.scrollTop;
const scrollHeight = scrollContainer.scrollHeight;
const clientHeight = scrollContainer.clientHeight;
// 현재 스크롤 포지션 구하기
if (currentScrollY <= 0) {
setScrollPosition('top');
} else if (currentScrollY + clientHeight >= scrollHeight) {
setScrollPosition('bottom');
} else {
setScrollPosition('middle');
}
// 스크롤 방향 구하기
if (currentScrollY > prevScrollY) {
setScrollDirection('down');
} else if (currentScrollY < prevScrollY) {
setScrollDirection('up');
}
setPrevScrollY(currentScrollY);
}, 100); // 100ms 간격으로 스크롤 이벤트 처리
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [prevScrollY, targetRef]);
return { scrollDirection, scrollPosition };
};
id 값으로 DOM에 접근할 때는, 페이지 간 이동 시 컴포넌트가 언마운트되고 다시 마운트 되는 과정에서 요소를 찾지 못해 스크롤 이벤트가 동작하지 않는 문제가 있었다.
이에 useEffect의 의존성으로 pathname을 추가하여 라우팅이 변경되면 useEffect를 다시 실행하여 요소를 다시 찾도록 했는데, 페이지 스크롤이 일어나는 부모 요소 하나에만 useRef를 연결하고 스크롤 감지 시에는 넘겨받은 ref로 이벤트를 처리를 하니 라우팅 감지도 필요가 없어져서 아주 깔끔해졌다.
https://nextjs.org/docs/app/api-reference/functions/use-pathname#do-something-in-response-to-a-route-change
https://velog.io/@changwook/nextjs-13-route-change
Next.js는 13 버전 이후로 서버 컴포넌트와 클라이언트 컴포넌트를 구분하여 각기 다른 방식으로 렌더링을 처리한다. 이 두 컴포넌트는 서로 다른 실행 환경을 기반으로 작동하며, 서버 컴포넌트(RSC)는 서버에서만 렌더링되고, 클라이언트 컴포넌트(RCC)는 브라우저에서 실행되는 JavaScript와 함께 렌더링된다.
처음에 나는 단순히 "클라이언트 컴포넌트의 자식은 무조건 클라이언트 컴포넌트가 된다" 라고 알고있었다.
그래서 "서버 컴포넌트의 부모는 반드시 서버 컴포넌트여야 한다"는 전제하에 문제에 접근하여 id를 부여하는 방법을 떠올렸으며, Next.js의 서버 컴포넌트는사용이 생각보다 제한적인가?라는 생각도 있었다.
Next.js의 RSC와 RCC 간 데이터 전송 메커니즘을 살펴보면 내 생각과 다르게 Next.js에서는 RSC -> RCC -> RSC 구조가 가능하다.
Next.js에서 이 구조가 가능해지는 이유는 아래와 같은 렌더링 프로세스 때문이다.
- 서버에서 먼저 전체 컴포넌트 트리를 렌더링
- 클라이언트 컴포넌트를 만나면, 그 부분을 플레이스홀더로 대체
- 클라이언트에서 JavaScript가 로드되면, 클라이언트 컴포넌트를 hydrate
- 이 때 클라이언트 컴포넌트의 children으로 전달된 서버 컴포넌트는 이미 서버에서 렌더링된 상태로, 단순히 삽입만 진행
즉, 클라이언트 컴포넌트의 children으로 서버 컴포넌트를 전달하면, 그 서버 컴포넌트는 여전히 서버에서 렌더링되어 클라이언트로 전달되기 때문에 이를 통해 클라이언트 컴포넌트 하위에 서버 컴포넌트를 포함시킬 수 있는 것이다.
이 구조를 바탕으로 내 프로젝트에서도 layout.tsx는 클라이언트 컴포넌트지만, layout.tsx에서 children으로 받게되는 page.tsx의 컴포넌트를 서버 컴포넌트로 유지했다.

그럼 여기서 궁금증이 생긴다.
즉, 위 그림에서 C가 A로부터 넘겨받은 children이 아닌, B에서 import해서 가져온 컴포넌트라면 RSC-RCC-RSC 구조가 가능한가?
당연하지만 불가능하다. 클라이언트 컴포넌트에서 import해온 컴포넌트는 자동으로 클라이언트 컴포넌트가 된다.
서버에서 렌더링되기 위해서는, 서버컴포넌트에서 import되어 렌더링이 완료가 되어야한다.
Next.js에서는 렌더 트리를 만들 때, props에 넘겨지는 데이터는 직렬화 가능한 데이터로 관리된다.
RSC -> RCC 간의 데이터 전송
function과 같이 직렬화 불가능한 객체를 prop으로 넘겨줄 수 없다.
RCC -> RCC 간의 데이터 전송
클라이언트 컴포넌트 간에는 자유롭게 함수나 상태를 전달할 수 있다. 클라이언트에서만 실행되기 때문에 직렬화나 데이터 전송에 제한이 없다.
RSC -> RSC 간의 데이터 전송
서버 컴포넌트 간에는 서버 측 데이터 및 함수를 자유롭게 전달할 수 있다. 이 역시 서버에서만 실행되기 때문에 직렬화에 제약을 받지 않는다.
Next.js에서 컴포넌트 트리가 어떻게 만들어지는지, 렌더링 시의 직렬화/역직렬화에 대한 이해와 서버컴포넌트 클라이언트 컴포넌트 사이의 관계에 대해서 이해를 하는 것이 중요했다.
나는 보통 새로운 기술을 공부할 때, 기초 강의만 듣고 바로 뭔갈 만들어보는데..
강의를 보고 단순히 따라 치는게 아니라 진짜 내가 스스로 생각을 하면서 구조에 대한 고민을 해볼 기회가 많아서이다.
하지만 이런 중요하고 기초적인 내용을 놓칠 수 있다는 점을 체감하고 이번 기회에 유데미에서 Next.js 강의를 하나 샀다 ㅎㅎ..
근데 또, 이런 문제 상황을 겪어보기 전에 강의만 들으면 진심으로 이해하거나 쉽게 받아들여지지 않았을 것 같기도 하다.. 이런 점을 고려하면 프로젝트를 하면서 강의를 듣는 것을 병행하는 것이 좋은 것 같다!
아, 그리고 이번 뿐만 아니라 모각코 스터디에서 이런 이슈에 대해 자유롭게 의견을 나누고 얻어가는 것이 정말 많아서 아주 좋습니당 :)