December 8, 20256 minutes
안녕하세요, 여러분.
오늘은 최근 개발자들 사이에서 가장 뜨거운 감자인 Next.js 16의 새로운 기능들과 이를 활용한 아키텍처 패턴에 대해 아주 깊이 있는 이야기를 나눠보려고 하는데요.
최근 Next.js Conf에서 노르웨이의 웹 개발자 Aurora가 발표한 ‘Composition, Caching, and Architecture’라는 강연이 제 눈길을 사로잡았습니다.
단순히 새로운 기능을 소개하는 것을 넘어, 우리가 그동안 프론트엔드 개발을 하면서 겪었던 고질적인 문제들을 아주 우아한 패턴으로 해결하는 과정을 보여주었기 때문입니다.
그래서 오늘은 이 강연 내용을 바탕으로, Next.js 16이 제시하는 새로운 렌더링의 패러다임과 실무에서 바로 적용할 수 있는 핵심 패턴들을 씹고 뜯고 맛보고 즐겨보도록 하겠습니다.
우리는 그동안 Next.js를 사용하면서 항상 선택의 기로에 서야만 했는데요.
바로 ‘정적 렌더링(Static Rendering)‘이냐 ‘동적 렌더링(Dynamic Rendering)‘이냐 하는 문제였습니다.
정적 렌더링은 빌드 시점에 미리 HTML을 만들어두기 때문에 속도가 빠르고 서버 부하도 적으며 SEO에도 유리하다는 장점이 있습니다.
반면에 동적 렌더링은 실시간 데이터나 사용자 맞춤형 정보를 보여주기에 적합하지만, 요청 때마다 서버가 일해야 하므로 비용이 들고 속도가 느려질 수 있다는 단점이 있죠.
그런데 문제는 실제 우리가 만드는 웹페이지는 이 둘 중 하나로 딱 잘라 말하기 어렵다는 점입니다.
예를 들어 이커머스 사이트의 상품 상세 페이지를 상상해 보면 이해가 쉬운데요.
상품 설명이나 이미지는 거의 변하지 않으니 정적으로 보여줘도 되지만, 장바구니에 담긴 개수나 ‘나를 위한 추천 상품’ 같은 섹션은 사용자마다 달라야 하니 동적으로 렌더링해야 합니다.
이전 버전의 Next.js에서는 페이지에 동적인 요소가 하나라도 들어가면 페이지 전체가 동적 렌더링으로 전환되어버리는 비효율이 발생했습니다.
물론 ‘Suspense’를 사용해 스트리밍 할 수 있었지만, 이 역시 근본적인 해결책이라기보다는 사용자 경험을 개선하는 미봉책에 가까웠습니다.
하지만 Next.js 16부터는 이야기가 완전히 달라집니다.
강연에서는 먼저 흔히 발생하는 ‘프롭 드릴링(Prop Drilling)’ 문제를 지적하며 리팩토링을 시작하는데요.
예를 들어 로그인 여부를 확인하는 isLoggedIn 같은 데이터를 최상위 페이지에서 가져와서, 저 깊숙한 곳에 있는 하위 컴포넌트까지 계속 전달해 주는 상황을 떠올려 보세요.
이건 유지보수를 지옥으로 만드는 주범입니다.
Aurora는 이 문제를 해결하기 위해 데이터 페칭 로직을 해당 데이터가 필요한 컴포넌트로 직접 밀어 넣는 방식을 제안합니다.
서버 컴포넌트 환경에서는 fetch나 React.cache를 사용하면 동일한 요청을 자동으로 중복 제거(deduplicate) 해주기 때문에, 여러 컴포넌트에서 같은 데이터를 호출해도 성능 문제가 발생하지 않습니다.
즉, 부모가 데이터를 가져와서 자식에게 뿌려주는 것이 아니라, 자식이 필요한 데이터를 스스로 가져오게 함으로써 컴포넌트 간의 의존성을 끊어내고 재사용성을 극대화하는 것입니다.
하지만 클라이언트 컴포넌트라면 이야기가 조금 달라지는데요.
클라이언트 컴포넌트에서는 비동기 서버 함수를 직접 호출할 수 없기 때문입니다.
이때 등장하는 것이 바로 ‘Context Provider’와 React의 use 훅을 활용한 패턴입니다.
최상위 레이아웃에서 데이터를 비동기로 가져온 뒤, 이를 Promise 상태로 Context Provider에 주입합니다.
그리고 하위 클라이언트 컴포넌트에서는 use 훅을 통해 이 Promise를 받아 처리하는 방식이죠.
이렇게 하면 클라이언트 컴포넌트 내부에서도 마치 await를 쓰는 것처럼 데이터를 기다렸다가 사용할 수 있게 되며, 복잡한 프롭 드릴링 없이도 필요한 곳에서 바로 데이터에 접근할 수 있게 됩니다.
다음으로 다룰 내용은 불필요한 클라이언트 사이드 자바스크립트를 줄이는 ‘도넛 패턴’입니다.
우리는 종종 상호작용이 필요한 아주 작은 부분 때문에 거대한 컴포넌트 전체를 'use client'로 선언하곤 하는데요.
예를 들어, 닫기 버튼 하나 때문에 거대한 모달 전체를 클라이언트 컴포넌트로 만드는 경우가 있습니다.
이렇게 되면 그 안에 포함된 정적인 텍스트나 이미지 처리 로직까지 전부 클라이언트 번들에 포함되어 전송되므로 성능에 악영향을 줍니다.
이 문제를 해결하는 것이 바로 도넛 패턴입니다.
도넛의 구멍처럼, 상호작용이 필요한 껍데기(Wrapper)만 클라이언트 컴포넌트로 만들고, 그 안에 들어갈 알맹이(Contents)는 서버 컴포넌트로 유지한 채 children prop으로 넘겨주는 방식입니다.
코드로 보면 대략 이런 구조가 됩니다.
// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }) {
const [isOpen, setIsOpen] = useState(true);
if (!isOpen) return null;
return (
<div>
<button onClick={() => setIsOpen(false)}>닫기</button>
{children}
</div>
);
}
// ServerPage.tsx
import ClientWrapper from './ClientWrapper';
import HeavyServerComponent from './HeavyServerComponent';
export default function Page() {
return (
<ClientWrapper>
<HeavyServerComponent />
</ClientWrapper>
);
}이렇게 하면 HeavyServerComponent는 여전히 서버에서 렌더링 되고, 브라우저로는 HTML 결과물만 전송됩니다.
상호작용을 위한 로직과 데이터를 보여주는 로직이 깔끔하게 분리되는 것이죠.
이 패턴은 단순히 성능만 좋게 하는 것이 아니라, 컴포넌트의 결합도를 낮춰서 재사용성을 엄청나게 높여줍니다.
강연에서는 이 패턴을 배너, 모달, 그리고 ‘더 보기’ 버튼이 있는 리스트 등 다양한 곳에 적용하여 클라이언트 번들 사이즈를 획기적으로 줄이는 모습을 보여줍니다.
이제 오늘의 하이라이트인 Next.js 16의 'use cache' 지시어에 대해 알아볼 차례인데요.
앞서 말씀드렸듯이 기존에는 페이지가 정적이냐 동적이냐의 이분법에 갇혀 있었습니다.
하지만 Next.js 16부터는 기본적으로 모든 것을 동적(Dynamic)으로 간주하되, 우리가 원하는 부분만 명시적으로 캐싱할 수 있게 되었습니다.
이게 무슨 말이냐면, 이제는 Next.js가 “어? 이 페이지에 searchParams를 쓰네? 그럼 무조건 동적 렌더링이야!“라고 제멋대로 판단하지 않는다는 뜻입니다.
우리는 이제 'use cache'라는 지시어를 통해 페이지 전체가 아니라, 특정 컴포넌트나 함수 단위로 캐싱을 제어할 수 있습니다.
강연의 데모에서는 이커머스 메인 페이지를 예로 드는데요.
메인 페이지에는 사용자 개인화된 추천 상품(동적 데이터)과 일반적인 베스트 상품 목록(정적 데이터)이 섞여 있습니다.
기존에는 개인화 데이터 때문에 페이지 전체가 동적으로 돌면서, 베스트 상품 목록까지 매번 DB를 조회하는 낭비가 있었습니다.
하지만 이제는 베스트 상품 목록을 보여주는 컴포넌트 상단에 'use cache'만 딱 붙여주면 됩니다.
그러면 이 컴포넌트는 빌드 시점이나 혹은 최초 요청 시점에 실행되어 결과가 캐시 되고, 이후 요청부터는 캐시 된 HTML 조각을 재사용하게 됩니다.
심지어 이 캐시 된 부분은 ‘PPR(Partial Prerendering)‘과 결합되어 초기 정적 쉘(Static Shell)에 포함될 수 있습니다.
결과적으로 사용자는 페이지에 들어오자마자 정적인 베스트 상품 목록을 즉시 보게 되고, 개인화된 추천 상품만 스트리밍으로 쓱 들어오는 환상적인 경험을 하게 되는 것입니다.
여기서 한 발 더 나아가서, 캐시 된 컴포넌트 안에 동적인 요소가 들어가야 한다면 어떻게 해야 할까요?
예를 들어, 상품 상세 설명은 캐싱하고 싶지만, 그 안에 있는 ‘찜하기(Like)’ 버튼은 사용자마다 상태가 다르니 동적이어야 합니다.
이럴 때 우리는 앞서 배웠던 ‘도넛 패턴’을 응용할 수 있습니다.
캐시가 적용된 부모 컴포넌트(상품 설명)가 동적인 자식 컴포넌트(찜하기 버튼)를 children으로 받거나 슬롯 형태로 포함하는 구조를 만드는 것입니다.
Next.js의 새로운 아키텍처는 놀랍게도 캐시 된 정적 콘텐츠 사이에 동적인 구멍을 뚫는 것을 허용합니다.
강연자는 이를 통해 “정적 vs 동적"이라는 낡은 패러다임을 완전히 부수고, “얼마나 정적으로 만들 것인가"라는 스펙트럼의 관점에서 접근해야 한다고 강조합니다.
이러한 패턴을 적용하면 우리는 더 이상 복잡한 generateStaticParams의 조합이나 난해한 캐싱 전략을 짜느라 머리를 쥐어뜯지 않아도 됩니다.
그저 기본적으로 동적으로 개발하되, 변경 빈도가 낮고 무거운 연산이 필요한 부분에 'use cache'를 붙여나가는 식의 ‘점진적 최적화’가 가능해지는 것입니다.
이는 개발자 경험(DX) 측면에서도 엄청난 진보라고 할 수 있습니다.
오늘 소개한 내용을 요약하자면, Next.js 16의 새로운 기능들은 단순히 ‘속도’만을 위한 것이 아닙니다.
데이터 페칭을 컴포넌트 내부로 이동시켜 결합도를 낮추는 아키텍처, 도넛 패턴을 통한 책임의 분리, 그리고 'use cache'를 통한 선언적인 최적화까지.
이 모든 것은 결국 ‘지속 가능한 애플리케이션’을 만들기 위한 퍼즐 조각들입니다.
Aurora가 보여준 데모 앱의 Lighthouse 점수가 100점을 기록하는 장면은 정말 인상적이었는데요.
복잡한 설정 파일 없이 코드 레벨에서 직관적인 패턴만으로 이런 성능을 낼 수 있다는 것이 Next.js가 나아가는 방향성을 명확히 보여줍니다.
이제 한국의 개발자분들도 이 새로운 패턴들을 적극적으로 도입해서, 더 빠르고 유지보수하기 쉬운 웹 애플리케이션을 만들어보시길 강력하게 추천합니다.
새로운 기술이 나올 때마다 두려워하기보다는, 내 코드를 더 우아하게 만들어줄 도구로 받아들이는 자세가 중요하니까요.