폴더 구조
/src
/assets # 이미지, 폰트 등
/components # UI 컴포넌트
/constants # 상수
/hooks # 커스텀 훅
/models # 유틸리티 함수
/services # API 호출
/utils # 유틸리티 함수
이러한 구조는 처음 시작할 때 매우 자연스럽고 명확합니다. "이건 UI 컴포넌트니까 components에, 이건 API 통신이니까 services에" 하는 식으로 직관적으로 파일을 분류할 수 있습니다. 그래서 대부분의 튜토리얼이나 스타터 키트들도 이런 구조를 채택하고 있고 입문자에게도 아주 친숙합니다.
작은 프로젝트나 시작 단계에서는 이 구조가 특히나 효과적입니다. 파일 수가 적고, 각 폴더의 역할이 명확하며, 특정 종류의 코드를 찾기 쉽기 때문입니다. 개발을 시작할 때 정신적 부담 없이 코드를 쉽게 나눌 수 있는 구조라고 할 수 있겠습니다.
/components
/atoms # 기본 요소
Button.tsx
Input.tsx
Label.tsx
/molecules # 간단한 조합
FormField.tsx
SearchBar.tsx
/organisms # 복잡한 조합
ProductCard.tsx
NavigationBar.tsx
/templates # 페이지 템플릿
ProductDetailTemplate.tsx
/pages # 전체 페이지
ProductDetailPage.tsx
실제 프로젝트의 폴더구조에 Atomic Design을 적용해보면 유용함과 한계점을 모두 마주하게 됩니다. 분명 디자인 시스템을 직관적으로 보여주는데, 어디서 문제가 되는 걸까요? 한번 ProductCard 같은 컴포넌트를 생각해봅시다. ProductCard는 어디에 두어야 할까요? 내부적으로 Card와 Button을 사용하니 atom은 당연히 아닌 것 확실한데... 그래서 간단한 원자들로 조립했으니 molecules일까요? 아니면 비즈니스 로직을 포함하고 복잡하니 organisms?
문제은 이 뿐만이 아닙니다. 이렇게 원자 - 분자 - 유기체 이렇게 3개의 폴더 구조만으로는 점점 더 많아지는 컴포넌트들을 감당하기가 어려워 집니다.
왜 이런 문제가 생기는 걸까요? 그 이유는 Atomic Design Pattern이 간과한 중요한 점이 있었습니다. 바로 모든 컴포넌트를 단순히 계층만으로 분류할 수 없다는 것이었죠. Atomic Design Pattern은 디자인 시스템에는 적합했지만, 실제 프로젝트의 컴포넌트들은 디자인 시스템에서 말하는 UI 컴포넌트로만 되어 있는 것이 아니었기 때문입니다.
프론트엔드 개발에는 근본적으로 두 가지 다른 종류의 컴포넌트가 존재합니다. 디자인 시스템을 구성하는 순수한 UI 컴포넌트가 있다면, 그 컴포넌트들을 활용해서 비즈니스 로직을 구동하는 도메인 컴포넌트도 존재합니다.
Atomic Design Pattern은 디자인 시스템의 계층(atoms, molecules, organisms)을 표현하기에는 유용했지만, 또 다른 중요한 축인 도메인(product, user, order)을 다루는 컴포넌트들을 제대로 분류하지 못한다는 한계가 있었습니다.
도메인이란 무엇일까요? 도메인이란 비즈니스에서 다루는 개념적 영역을 말합니다. 쇼핑몰을 예로 들면 '제품', '장바구니', '사용자', '주문' 등이 주요 도메인이 됩니다. 그리고 제품목록, 주문내역, 사용자 다이얼로그 등이 바로 도메인정보를 다루는 컴포넌트, 즉 도메인 컴포넌트가 되겠습니다.
도메인 정보를 다루는 컴포넌트들은 기존의 버튼, 드롭다운, 모달 같은 순수한 화면 요소를 다루는 것에서 확장되면서 어떤 차이점을 가지고 있는지 한번 자세히 알아보겠습니다.
웹 개발 방식이 컴포넌트 기반 개발이 주류가 되면서, "컴포넌트는 재사용 가능해야 한다"는 원칙이 프론트엔드 개발의 핵심 철학으로 자리잡았습니다. 개발자들은 반복되는 버튼, 카드, 입력필드등에서의 중복 코드를 줄이고 일관된 UI를 구현하고 싶어했습니다. 그러면서도 컴포넌트를 최대한 독립적으로 만들려고 노력했고, 공통 코드는 모으되 props를 통해 외부에서 모든 것을 주입받도록 설계하는 것이 베스트 프랙티스로 여겨졌습니다.
function Button({ children, onClick, variant, size, disabled }) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
Button 같은 순수 UI 컴포넌트에서 이 접근법은 주효했습니다. 어디서든 사용할 수 있었고, 로그인 버튼이든, 장바구니 추가 버튼이든, 삭제 버튼이든 상관없이 일관된 동작을 보여주었습니다. 필요에 따라서 적절히 props를 통해서 공통 코드를 재사용하면서도 커스텀도 가능해지면서 컴포넌트를 "재사용성"이라는 원칙을 지키도록 작성하는 것은 중요한 덕목이 되었습니다.
이런 경험을 바탕으로 개발자들은 자연스럽게 다른 컴포넌트에도 같은 원칙을 적용하기 시작했습니다. 가령 상품 목록 리스트 항목을 그리는 ProductItem 같은 도메인 컴포넌트가 검색결과나 카탈로그, 찜 목록 등에 사용되니 다음과 같이 만들어 props를 통해 적절히 원하는대로 커스텀이 가능하도록 만들어 보았습니다.
function ProductItem({ product, onAddToCart, onToggleFavorite, showPrice, showRating }) {
return (
<div className="product-item">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
{showPrice && <span>{product.price}</span>}
{showRating && <Rating value={product.rating} />}
<Button onClick={() => onAddToCart(product)}>장바구니</Button>
<Button onClick={() => onToggleFavorite(product)}>♡</Button>
</div>
);
}
이러한 방식은 초기에는 잘 작동했습니다. 두 화면 모두에서 ProductItem을 재사용할 수 있었고, 옵션에 따라 원하는 필드의 노출이나 기능을 달리 할 수 있었죠. Button에서처럼 컴포넌트의 재사용이라는 목표를 달성하는 것 같았습니다.
하지만 프로젝트가 복잡해지면서 컴포넌트의 재사용성에 대한 의문이 생기기 시작했습니다. 가령 장바구니에서는 수량 조절이, 위시리스트에서는 다른 UI가 필요해지니 아래와 같이 재사용을 하기 시작했습니다.
// 처음엔 간단했는데...
<ProductItem product={product} />
// 장바구니용 기능이 필요해지니...
<ProductItem product={product} showQuantity={true} onQuantityChange={handleQuantity} />
// 위시리스트용도 추가하니...
<ProductItem
product={product}
variant="wishlist"
showDate={true}
showPriceAlert={true}
onRemove={handleRemove}
/>
각각의 새로운 요구사항마다 props를 추가하는 것이 재사용이라는 측면에서는 당연한 조치였지만 예상과는 달리 코드는 점점 더 복잡해져갔습니다. props가 늘어날수록 컴포넌트를 사용하기가 더 어려워졌고, 내부 로직은 조건문으로 가득해졌습니다. 또한 새로운 기능을 추가할 때마다 다른 variant에서는 문제가 없는지 계속 확인해야 했죠.
나중에야 알게 된 사실이지만 이런 유형의 컴포넌트들은 전통적인 방식으로는 재사용리 잘 되지 않습니다. 카탈로그의 ProductItem과 장바구니의 ProductItem은 겉보기에 같은 컴포넌트였지만 실제로는 다른 목적과 동작을 가지고 있었습니다. 그럼에도 하나의 컴포넌트로 억지로 묶어두다 보니 둘 다 최적화되지 못한 어정쩡한 결과물이 나온 것입니다.
구체적으로 잘못된 재사용을 하는 경우란 데이터가 다른 데도 같은 UI를 사용한다는 이유로 하나의 컴포넌트로 추상화를 하는 방식입니다.
가령 게시판를 만든다고 생각해 봅시다. 공지사항, 문의게시판, 방명록 등이 프로젝트 전체에서 유사한 기능과 화면을 가지고 있으니 이를 하나의 컴포넌트로 만들어 API나 일부 화면만 분기하는 방식으로 재사용을 하려고 합니다.
그래서 다음과 같이 BoardList와 같은 컴포넌트를 만들고 API와 타입 등을 받아서 다른 데이터들을 일부 분기되는 처리와 옵션을 달리 하는 방식으로 만들어 보았습니다.
function BoardList({
boardType,
apiEndpoint,
showAuthor = true,
showCategory = false,
allowReply = false
}) {
const { data: posts } = useFetch(apiEndpoint);
return (
<div className="board-list">
{posts?.map(post => (
<div key={post.id} className="board-item">
<h3>{post.title}</h3>
{showAuthor && <span>작성자: {post.author}</span>}
{showCategory && <span>분류: {post.category}</span>}
<span>{post.createdAt}</span>
{allowReply && <ReplyButton postId={post.id} />}
</div>
))}
</div>
);
}
// 사용 예시
<BoardList boardType="notice" apiEndpoint="/api/notices" showCategory={true} />
<BoardList boardType="qna" apiEndpoint="/api/qna" allowReply={true} />
<BoardList boardType="guestbook" apiEndpoint="/api/guestbook" showAuthor={false} />
이러한 방식은 얼핏 괜찮은 방식처럼 보입니다. 실제로 더 이상 프로젝트가 변하지 않거나 이 요구사항으로만 모든 기획을 처리해야할때 까지는 맞는 이야기입니다. 이 하나의 컴포넌트로 여러 종류의 게시판을 처리할 수 있었고, 코드 중복도 줄일 수 있었죠. 하지만 문제는 서비스가 잘되면 프로젝트와 요구사항은 언제나 변하고 커진다는 데에 있습니다.
요구사항은 화면이 아니라 도메인에 따라 방향성이 달라집니다. 공지사항은 '중요' 배지 기능을 요구했고, 문의 게시판은 답변 상태나 비밀글 기능이 필요했으며, 방명록은 본문 미리보기도 같이 보여달라는 등 처음에는 같은 화면이었지만 점점 요구사항이 특수성에 따라 달라지는 것이고 이는 도메인의 특성에 기인하는 것이죠.
만일 현명하게 이를 대처하고자 했다면 BoardList는 데이터와 무관하게 화면과 기능을 확장할 수 있도록 하고 props가 아니라 다양한 컴포넌트를 조립할 수 있도록 만들어야 합니다. 그리고 각자 도메인별로는 코드의 중복을 인정하고 BoardList를 활용해서 각자의 방식으로 독립적으로 발전 할 수 있는 구조로 만들어야 하죠.
순수 UI 컴포넌트:
도메인 컴포넌트:
우리가 얻은 교훈은 요구사항은 도메인을 중심으로 발전한다는 것이었습니다. 그렇기에 같은 화면을 가진 컴포넌트라도 코드가 중복되더라도 도메인에 따라 분리하는 것이 장기적으로 더 유지보수하기 좋은 방향이 되는만큼, 도메인을 다루는 컴포넌트와 UI를 다루는 컴포넌트는 서로 다른 관점에서 접근하고 이를 분리해서 관리하는 방향으로 진화하게 되었습니다.
문제의 핵심은 도메인 컴포넌트에 비즈니스 로직이 섞여 있다는 점이었습니다. 이를 해결하기 위해 컨테이너-프레젠터 패턴이 등장했습니다.
이 패턴의 핵심은 로직을 담당하는 컨테이너 컴포넌트와 UI만 담당하는 프레젠터 컴포넌트를 분리하는 것이었습니다.
// 프레젠터: 순수하게 UI만 담당
function ProductItemView({
product,
isInCart,
isFavorite,
onAddToCart,
onToggleFavorite
}) {
return (
<div className="product-item">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<span>{product.price}</span>
<Button
onClick={onAddToCart}
disabled={isInCart}
>
{isInCart ? '이미 담김' : '장바구니'}
</Button>
<Button
onClick={onToggleFavorite}
variant={isFavorite ? 'filled' : 'outline'}
>
♡
</Button>
</div>
);
}
// 컨테이너: 로직을 담당
function CatalogProductItem({ product }) {
const { addToCart, isInCart } = useCart();
const { toggleFavorite, isFavorite } = useFavorites();
const handleAddToCart = () => {
addToCart(product);
// 카탈로그 특화 로직: 추천 시스템에 데이터 전송
analytics.track('product_added_from_catalog', { productId: product.id });
};
const handleToggleFavorite = () => {
toggleFavorite(product);
// 카탈로그 특화 로직
analytics.track('product_favorited_from_catalog', { productId: product.id });
};
return (
<ProductItemView
product={product}
isInCart={isInCart(product.id)}
isFavorite={isFavorite(product.id)}
onAddToCart={handleAddToCart}
onToggleFavorite={handleToggleFavorite}
/>
);
}
// 장바구니용 컨테이너는 완전히 다른 로직
function CartProductItem({ item }) {
const { updateQuantity, removeItem } = useCart();
const handleQuantityChange = (newQuantity) => {
updateQuantity(item.id, newQuantity);
analytics.track('cart_quantity_updated', {
productId: item.product.id,
quantity: newQuantity
});
};
// 장바구니에는 ProductItemView가 아닌 완전히 다른 UI 사용
return (
<div className="cart-item">
<img src={item.product.image} alt={item.product.name} />
<div className="item-details">
<h4>{item.product.name}</h4>
<span>{item.product.price} × {item.quantity}</span>
</div>
<QuantitySelector
value={item.quantity}
onChange={handleQuantityChange}
/>
<Button onClick={() => removeItem(item.id)}>삭제</Button>
</div>
);
}
이런 접근법은 각 컨테이너가 자신의 맥락에 최적화된 로직을 가질 수 있게 해주었습니다. 카탈로그에서는 "구매 유도"에, 장바구니에서는 "수량 관리"에 집중할 수 있었죠.
하지만 컨테이너-프레젠터 패턴으로도 해결되지 않는 문제가 있었습니다. 바로 Props Drilling이었습니다. 독립성을 위해 필요한 데이터와 함수들을 계속 props로 전달해야 했고, 중간 계층 컴포넌트들은 실제로는 데이터 전달만 하면서도 모든 props를 받아서 다시 전달해야 했습니다.
function ProductSection({ products, onAddToCart, onToggleFavorite }) {
return (
<ProductList
products={products}
onAddToCart={onAddToCart}
onToggleFavorite={onToggleFavorite}
/>
);
}
function ProductList({ products, onAddToCart, onToggleFavorite }) {
return (
<div>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onAddToCart={onAddToCart}
onToggleFavorite={onToggleFavorite}
/>
))}
</div>
);
}
중간 계층의 컴포넌트들은 실제로는 데이터를 전달하는 역할만 하면서도, 독립성을 위해 모든 props를 받아서 다시 전달해야 했습니다. 재사용은 되지 않으면서 복잡도만 증가하는 상황이 벌어진 것입니다. 이는 UI 컴포넌트를 만들때에는 발생하지 않던 개념이었습니다.
이런 문제들을 해결하기 위해 프론트엔드 커뮤니티에서는 여러 패턴들이 자연스럽게 발전했습니다. Container-Presenter 패턴은 로직과 UI를 명확히 분리하려는 시도였고, 커스텀 훅의 부상은 비즈니스 로직을 컴포넌트에서 분리하여 재사용 가능한 형태로 만드는 패턴이었습니다. Context API와 전역 상태 관리는 Props drilling 문제를 해결하고 도메인 상태를 효율적으로 관리하기 위한 패턴들이었습니다.
// 전역 상태를 활용한 도메인 컴포넌트
function CatalogProductItem({ product }) {
// props drilling 없이 직접 필요한 로직에 접근
const { addToCart, isInCart } = useCart();
const { toggleFavorite, isFavorite } = useFavorites();
return (
<div className="product-item">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<span>{product.price}</span>
<Button onClick={() => addToCart(product)}>
{isInCart(product.id) ? '이미 담김' : '장바구니'}
</Button>
<Button onClick={() => toggleFavorite(product.id)}>
{isFavorite(product.id) ? '❤️' : '♡'}
</Button>
</div>
);
}
이런 패턴들이 등장한 배경에는 공통된 인식이 있었습니다. 도메인 컴포넌트는 props drilling 없이 필요한 도메인 로직에 직접 접근할 수 있어야 한다는 것이었습니다. 이는 기존의 "모든 것을 props로"라는 패러다임에서 "필요한 것을 직접 가져오기"라는 패러다임으로의 전환을 의미했습니다.
컴포넌트를 통해 발견한 "순수 UI"와 "도메인"의 구분은 사실 컴포넌트만의 이야기가 아니었습니다. 프로젝트가 성장하면서 이런 구분이 프론트엔드 개발의 모든 영역에서 나타나기 시작했죠.
훅(Hooks)에서도 마찬가지였습니다. useToggle()
, useDebounce()
, useClickOutside()
같은 훅들은 어떤 도메인 지식도 필요 없는 순수한 유틸리티 훅이었습니다. 반면 useCart()
, useAuth()
, useProductSearch()
같은 훅들은 특정 비즈니스 로직과 밀접하게 연관되어 있었죠.
// 순수 유틸리티 훅 - 어디서든 사용 가능
function useDebounce(value: string, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// 도메인 훅 - 장바구니 비즈니스 로직 포함
function useCart() {
const dispatch = useDispatch();
const items = useSelector(selectCartItems);
const addToCart = (product: Product) => {
dispatch(cartActions.add(product));
analytics.track('cart.item_added', { productId: product.id });
};
return { items, addToCart, totalPrice: calculateTotal(items) };
}
API 레이어에서도 동일한 패턴이 보였습니다. httpClient.get()
, httpClient.post()
같은 기본 HTTP 통신 유틸리티와 productApi.getAll()
, cartApi.checkout()
같은 도메인 특화 API는 성격이 완전히 달랐습니다. 전자는 어떤 프로젝트에서도 그대로 사용할 수 있지만, 후자는 해당 도메인의 비즈니스 규칙을 반영하고 있었죠.
심지어 유틸리티 함수들도 마찬가지였습니다. formatDate()
, debounce()
, deepClone()
같은 범용 유틸리티와 calculateShippingFee()
, validateProductCode()
, formatProductPrice()
같은 도메인 유틸리티는 근본적으로 다른 성격을 가지고 있었습니다.
이런 발견은 자연스럽게 폴더 구조에도 반영되기 시작했습니다. 처음에는 단순히 /components
, /hooks
, /services
, /utils
로 나누었지만, 각 폴더 내부에서는 도메인을 기준으로 세분화가 일어났죠.
/hooks
/ui # 순수 UI 관련 훅
useModal.ts
useDebounce.ts
useToggle.ts
/product # 제품 도메인 훅
useProducts.ts
useProductDetail.ts
/cart # 장바구니 도메인 훅
useCart.ts
useCheckout.ts
/services
/common # 기본 API 클라이언트
client.ts
interceptors.ts
/product # 제품 도메인 API
productApi.ts
/cart # 장바구니 도메인 API
cartApi.ts
컴포넌트는 도메인 중심의 세분화가 정착되면서 폴더 구조는 한층 체계적이 되었습니다. 하지만 여전히 해결되지 않은 불편함이 하나 있었죠. 바로 페이지 컴포넌트의 애매한 위치였습니다.
React로 처음 프로젝트를 시작했던 시절에는 모든 컴포넌트를 단순히 /components
폴더 안에 넣었습니다. HomePage, ProductListPage 같은 페이지 컴포넌트도, Button이나 Modal 같은 일반 컴포넌트도 모두 한 곳에 있었죠. 이름에 'Page'를 붙여서 구분하려 했지만, 프로젝트가 커질수록 이런 네이밍 컨벤션만으로는 부족했습니다.
/components
Button.tsx
Modal.tsx
Header.tsx
HomePage.tsx # 페이지? 컴포넌트?
LoginPage.tsx
ProductCard.tsx
ProductListPage.tsx # 이름으로만 구분
ProductDetailPage.tsx
CartIcon.tsx
CartPage.tsx
UserProfile.tsx
UserSettingsPage.tsx
더 큰 문제는 라우팅 설정과 실제 컴포넌트 사이의 거리였습니다. React Router 설정은 보통 App.js나 별도의 routes.js 파일에 있었고, 실제 페이지 컴포넌트는 components 폴더 깊숙한 곳에 있었죠. 새로운 개발자가 "이 URL은 어떤 컴포넌트를 렌더링하지?"라는 질문을 받으면, 라우팅 설정 파일을 열어보고, 거기서 import 경로를 따라가서, 해당 컴포넌트를 찾아야 했습니다. 단순해 보이지만 은근히 번거로운 과정이었죠.
페이지 컴포넌트는 분명 다른 컴포넌트들과는 다른 역할을 했습니다. URL과 직접 연결되고, 데이터 페칭의 시작점이 되며, SEO와 관련된 메타데이터를 설정하는 곳이었죠. 하지만 폴더 구조상으로는 이런 특별함이 전혀 드러나지 않았습니다. Button 컴포넌트 옆에 HomePage 컴포넌트가 나란히 있는 것은 뭔가 어색했습니다.
이런 불편함을 해소한 것이 바로 파일 기반 라우팅이었습니다. 파일 시스템의 구조가 곧 URL 구조가 된다는 간단한 아이디어였지만, 그 영향력은 대단했습니다. 이제 /pages/products/[id].tsx
파일을 만들면 자동으로 /products/:id
라우트가 생성되었습니다.
이와 같은 방법은 라우팅 설정을 별도로 관리할 필요가 없었고, URL만 보면 어떤 파일을 찾아가야 하는지 즉시 알 수 있었죠. 이는 단순히 편의성의 문제가 아니었습니다. 페이지라는 개념이 다른 컴포넌트들과는 본질적으로 다른 역할을 한다는 것을 구조적으로 개념화 한것이죠.
페이지는 애플리케이션의 진입점입니다. 사용자가 URL을 통해 직접 접근하는 곳이고, SEO가 중요한 곳이며, 데이터 페칭이 시작되는 곳이죠. 이런 특별한 역할을 하는 컴포넌트들을 일반 컴포넌트와 같은 폴더에 두는 것은 그 중요성을 제대로 반영하지 못한 것이었습니다
이렇게 역할별 분류, 도메인별 세분화, 그리고 페이지의 독립이라는 세 가지 진화가 만나면서 현대 프론트엔드 프로젝트의 일반적인 구조가 형성되었습니다. 대부분의 스타터 키트나 보일러플레이트를 열어보면 비슷한 패턴을 발견할 수 있죠.
/src
/pages (또는 /app) # 라우팅 진입점
index.tsx
about.tsx
/products
index.tsx
[id].tsx
/cart
index.tsx
checkout.tsx
/auth
login.tsx
register.tsx
/components # UI 컴포넌트
/ui # 순수 UI (도메인 무관)
Button.tsx
Modal.tsx
Card.tsx
Input.tsx
/product # 제품 도메인
ProductCard.tsx
ProductList.tsx
/cart # 장바구니 도메인
CartItem.tsx
CartSummary.tsx
/layout # 레이아웃 컴포넌트
Header.tsx
Footer.tsx
Sidebar.tsx
/hooks # 커스텀 훅
/ui
useModal.ts
useDebounce.ts
/product
useProducts.ts
useProductDetail.ts
/cart
useCart.ts
/auth
useAuth.ts
usePermissions.ts
/services # API 레이어
/api
client.ts
interceptors.ts
/product
productApi.ts
/cart
cartApi.ts
/auth
authApi.ts
/store # 상태 관리
/slices
productSlice.ts
cartSlice.ts
authSlice.ts
store.ts
/utils # 유틸리티
/common
formatters.ts
validators.ts
constants.ts
/product
priceCalculator.ts
/date
dateHelpers.ts
/types # TypeScript 타입 정의
/models
product.ts
user.ts
cart.ts
/api
responses.ts
requests.ts
/styles # 스타일 파일
/globals
reset.css
variables.css
/components
button.module.css
/assets # 정적 리소스
/images
/fonts
/icons
최상위에는 여전히 components, hooks, services 같은 역할별 폴더들이 있습니다. 하지만 이제 각 폴더 내부를 들여다보면 ui, product, cart 같은 도메인별 하위 폴더들이 있죠. 그리고 pages나 app 폴더는 독립적으로 존재하며 라우팅 구조를 담당합니다.
이런 구조에서 개발자의 사고 흐름은 대체로 이렇습니다. "장바구니에 상품을 추가하는 훅이 필요해. 그러면 hooks 폴더로 가서, cart 하위 폴더를 찾아보자." 또는 "제품 상세 페이지를 수정해야 해. pages 폴더의 products 아래에 있겠군." 역할이라는 큰 분류 아래 도메인이라는 세부 분류가 있는 이중 구조는 직관적이면서도 체계적이었습니다.
특히 이 구조가 인기를 얻은 이유는 점진적인 확장이 가능했기 때문입니다. 처음에는 단순히 components와 pages만으로 시작할 수 있습니다. 프로젝트가 커지면서 hooks 폴더가 추가되고, API 호출이 복잡해지면 services가 생기고, 상태 관리가 필요하면 store가 추가되는 식이죠. 각 단계에서 기존 구조를 크게 바꾸지 않고도 새로운 관심사를 수용할 수 있었습니다.
또한 각 폴더 내부에서도 필요에 따라 도메인별 분류를 추가할 수 있었습니다. 처음에는 모든 컴포넌트가 components 폴더에 평평하게 있다가, 파일이 많아지면 ui와 도메인별 폴더로 나누는 식이죠. 이런 유연성 덕분에 팀의 규모나 프로젝트의 복잡도에 맞춰 구조를 조정할 수 있었습니다.