
단순한 네비게이션이라도 프로젝트 규모가 커지면 관리 포인트가 기하급수적으로 늘어납니다. 이번 프로젝트에서는 단순히 UI를 그리는 수준을 넘어, 어느 환경에서도 일관되게 동작하고 스스로 데이터를 관리하는 '독립적인 위젯(Widget)' 구조를 설계하는 데 집중했습니다.
기능을 세분화하여 각 파일이 하나의 책임만 갖게 했습니다. 이는 정규화된 스크립트가 파일을 인식하기 좋게 만들 뿐만 아니라, 유지보수 효율을 극대화합니다.
src/widgets/navigation/
├── lib/
│ └── useNavigation.js // 비즈니스 로직 (Data Engine)
├── ui/
│ ├── NavigationWidget.jsx // 엔트리 포인트 (조립 공장)
│ ├── NavigationContext.jsx // 데이터 공유 (중앙 방송국)
│ ├── Navigation.jsx // 배치 설계도 (Layout)
│ ├── NavigationLayouts.jsx // 레이아웃 디자인 패턴 (Pattern Components)
│ └── Brand.jsx, NavLinks.jsx... // 세부 UI 부품
└── index.js // 외부 노출 인터페이스
파일을 세분화할수록 최상단에서 구한 데이터를 하위 부품까지 전달하는 과정이 번거로워집니다. 중간 단계 컴포넌트가 배달부 역할을 하게 되면, UI 구조를 살짝만 바꿔도 모든 Props 경로를 수정해야 하는 비효율이 발생합니다.
이 결합도(Coupling) 문제를 해결하기 위해 Context API를 활용한 캡슐화 전략을 선택했습니다.
위젯 내부를 하나의 전용 컨텍스트로 감싸, 부품들이 중간 단계 없이 필요한 데이터를 직접 구독하도록 설계했습니다.
이어서 이 구조를 구현하기 위한 상세 코드와 함께, 서버 컴포넌트의 장점을 알고도 클라이언트 방식을 택한 '기술적 트레이드오프'를 공유하겠습니다.
위젯에 필요한 모든 데이터와 상태 로직을 UI와 분리하여 커스텀 훅으로 관리합니다. 상세한 계산 로직은 훅 내부로 숨기고, UI에는 오직 필요한 데이터와 함수만을 반환하는 깨끗한 인터페이스를 제공합니다.
// #1. 비즈니스 로직 인터페이스 (src/widgets/navigation/lib/useNavigation.js)
// 상세 로직은 캡슐화하고 UI에 필요한 데이터 구조만 정의합니다.
export const useNavigation = () => {
// ... 내부 계산 로직 (Github API 연동, URL 파라미터 파싱 등)은 생략
return {
username, // 표시될 유저 이름
avatarUrl, // 프로필 이미지 경로
customUsername,// 현재 커스텀 모드 여부
getHref, // 상태를 유지하며 경로를 생성하는 함수
pathname // 현재 경로 정보
};
};
일관성 있는 시스템을 위해 위젯 내부의 데이터 방송국을 구축했습니다. 이 컨텍스트는 내부 부품들이 외부 간섭 없이 데이터를 수급하는 통로가 됩니다.
// #2. 중앙 방송국 (src/widgets/navigation/ui/NavigationContext.jsx)
'use client';
import { createContext, useContext } from 'react';
const NavigationContext = createContext(null);
export const useNavContext = () => {
const context = useContext(NavigationContext);
if (!context) throw new Error('useNavContext는 NavigationProvider 안에서만 쓰세요!');
return context;
};
export const NavigationProvider = NavigationContext.Provider;
그다음, 비즈니스 로직과 UI를 결합하는 조립 공장(Widget)을 구축했습니다. 이 위젯은 외부에서 데이터를 주입받지 않고 스스로 로직을 수행하는 Self-contained 컴포넌트입니다.
// #3. 조립 공장 (src/widgets/navigation/ui/NavigationWidget.jsx)
'use client';
import { useNavigation } from '../lib/useNavigation';
import { NavigationProvider } from './NavigationContext';
import { Navigation } from './Navigation';
export const NavigationWidget = () => {
const navData = useNavigation();
return (
<NavigationProvider value={navData}>
<Navigation />
</NavigationProvider>
);
};
UI의 배치 로직(Navigation.jsx)과 실제 뼈대(NavigationLayouts.jsx)를 분리했습니다. 이를 통해 Navigation.jsx는 데이터 흐름이나 복잡한 스타일 코드 없이, 어떤 부품이 어디에 위치하는지 한눈에 보여주는 '설계도' 역할만 수행하게 됩니다.
// #4. 레이아웃 설계도 (src/widgets/navigation/ui/Navigation.jsx)
// 데이터를 받지 않고 오직 부품의 배치(Layout)만 정의합니다.
import * as Layout from './NavigationLayouts';
import { Brand } from './Brand';
import { NavLinks } from './NavLinks';
import { DarkModeToggle } from './DarkModeToggle';
import { TryYourself } from './TryYourself';
export const Navigation = () => {
return (
<Layout.Root>
<Layout.LeftSide>
<Brand />
<Layout.ShowOnMobile><DarkModeToggle /></Layout.ShowOnMobile>
</Layout.LeftSide>
<Layout.RightSide>
<TryYourself />
<Layout.MenuWrapper>
<NavLinks />
<Layout.ShowOnDesktop><DarkModeToggle /></Layout.ShowOnDesktop>
</Layout.MenuWrapper>
</Layout.RightSide>
</Layout.Root>
);
};
하위 부품들은 Props 배달을 기다리지 않습니다. 중앙 방송국에서 필요한 데이터만 직접 수령하는 구조를 통해 코드의 간결함을 유지합니다.
// #5. 데이터 직접 구독 부품 (src/widgets/navigation/ui/Brand.jsx)
import { useNavContext } from './NavigationContext';
export const Brand = ({ size = 32 }) => {
const { avatarUrl, username, getHref } = useNavContext();
return (
<Link href={getHref('/')}>
<Image src={avatarUrl} alt={username} width={size} height={size} />
<span>{username}</span>
</Link>
);
이 설계의 가장 큰 고민은 "순수 UI임에도 'use client'가 불가피한가?"였습니다. RSC(서버 컴포넌트)의 이점은 분명하지만, 저는 클라이언트 중심의 위젯 캡슐화를 선택했습니다.
RSC 방식을 유지하려면 데이터를 부모에서 구하거나 수동으로 넘겨야 하며, 이는 네비게이션을 레이아웃의 종속물로 만듭니다. 저는 어디에 꽂아도 즉시 돌아가는 독립 모듈로서의 가치를 우선했습니다.
프로젝트가 커질수록 Props 경로를 추적하는 비용은 커집니다. Context를 활용하면 구조가 깊어져도 하위 부품을 자유롭게 추가/삭제할 수 있는 유연한 아키텍처를 가질 수 있습니다.
use client를 통해 위젯을 캡슐화하면 외부와의 결합도는 낮아지고 내부 응집도는 높아집니다. 최종적으로 layout.jsx는 비즈니스 로직을 전혀 모른 채 아래와 같이 선언적인 형태를 유지하게 됩니다.
// #6. 최종 레이아웃 적용 (src/app/layout.jsx)
import { NavigationWidget } from '@/widgets';
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<LayoutContainer nav={<NavigationWidget />}>
{children}
</LayoutContainer>
</body>
</html>
);
}
모든 기술 선택에는 트레이드오프가 존재합니다. 이번 설계는 "서버 컴포넌트의 순수성"과 "위젯 아키텍처의 편리함" 사이의 균형점을 찾는 과정이었습니다.
설계에 정답은 없지만, 자신의 철학을 코드로 녹여내고 그 이유를 증명하는 과정이 시니어 개발자로 가는 핵심임을 다시 한번 체감했습니다.