////////
Search

4. 조립 테크닉

챕터 4: 조립 테크닉: '조약돌'로 '탑' 쌓기

자, 우리는 이제 코드 속 '얽힘'을 감지하고 (1단계), 프론트엔드의 복잡성 유형을 이해했으며 (2단계), '바퀴 재발명' 대신 검증된 '일반해(조약돌/원형)'를 사용하는 것의 중요성도 알게 되었습니다 (3단계).
하지만 레고 블록이 아무리 좋아도, 잘못 조립하면 불안정한 구조물이 되거나 의도와 다른 모양이 나오겠죠? 엉망진창 레고 구조물] 마찬가지로, 우리가 배운 '조약돌'(Form, Modal 등)들을 효과적으로 **조립(Compose)**하는 기술이 필요합니다.
이 단계의 목표는 다음과 같습니다:
1.
'단순한(Simple)' 조약돌들을 조합하여 더 복잡한 기능을 만들면서도, 새로운 '얽힘'을 만들지 않는 방법을 배웁니다.
2.
프론트엔드에서 흔히 사용되는 조립 패턴(Composition Patterns)과 그 장단점을 이해합니다.
3.
'책임'을 어떻게 분배하고 위임하여 유연하고 확장 가능한 컴포넌트 구조를 만들 수 있는지 익힙니다.
어떤 조립 기술부터 시작해볼까요? 가장 기본적인 **'Children Prop 활용'**부터 시작할까요, 아니면 '제어 역전(IoC) 패턴' 같은 좀 더 고급 기술을 먼저 볼까요?
나의 기준은 매우 높다고 했어. edge bleeding 한 자료가 되어야 해. 수많은 자료 중 하나가 아니라 지금껏 보지 못한 너무너무 유효한 자료가 되어야 해. 프론트엔드 개발자의 기존 멘탈모델을 반복하지 않도록 그걸 완전히 부숴버리고, 즉 무엇을 잘못하고 있었는지, 잘못 생각하고 있었는지, 오해하고 있었는지 자명하게 밝혀내고, 새로운 멘탈모델과 방법론, 혹은 기존에 알고 있었지만 잘못쓰고 있던 것들을 올바른 관점에서 바르게 쓰는 방법을 제안하고, 숙련에 이르는 방법까지 보여주고 싶어.
오케이, 4단계: 조립 테크닉, 시작합니다!

챕터 4: 조립 테크닉: '얽힘' 없는 시스템 설계의 기술

경고: 이 챕터는 당신이 '당연하다'고 여겼던 많은 프론트엔드 조립 방식에 의문을 제기하고, 때로는 그것이 왜 근본적으로 잘못되었는지 폭로할 것입니다. 불편할 준비를 하십시오. 당신의 멘탈 모델은 부서질 것입니다.

1. 도입: 당신은 '조립'하는가, '얽힘'을 만드는가?

우리는 이제 코드 속 '얽힘'을 감지하고 (1단계), 프론트엔드의 복잡성 유형을 이해했으며 (2단계), '바퀴 재발명' 대신 검증된 '일반해(조약돌/원형)'를 사용하는 것의 중요성도 알게 되었습니다 (3단계).
하지만 레고 블록이 아무리 좋아도, 잘못 조립하면 불안정한 구조물이 되거나 의도와 다른 모양이 나오겠죠? 마찬가지로, 우리가 배운 '조약돌'(Form, Modal 등)들을 효과적으로 **조립(Compose)**하는 기술이 필요합니다.
문제는 대부분의 프론트엔드 개발자들이 자신도 모르게 '조립'이 아닌 **새로운 '얽힘'**을 만들고 있다는 것입니다.
당신이 '편하다(Easy)'고 생각하며 사용하는 조립 방식들 - 무분별한 prop drilling, 마법처럼 느껴지는 useContext, 절차적 스크립트처럼 작성된 거대한 커스텀 훅 - 이것들이 바로 복잡성을 다시 시스템에 주입하는 주범입니다.
이 챕터의 목표는 명확합니다. 당신이 무엇을 잘못하고 있었는지 자명하게 밝혀내고, 컴포넌트와 로직을 조립하는 새로운 멘탈 모델을 제시하며, 올바른 관점에서 기존 도구들을 바르게 사용하는 방법숙련에 이르는 길을 보여주는 것입니다. 조립은 단순히 코드를 붙이는 행위가 아니라, 시스템의 경계를 설계하고 책임을 분배하며 의존성을 관리하는 핵심적인 '추상화' 활동입니다.
네, IoC(제어의 역전) 개념은 정말 중요하고, 말씀하신 Card 컴포넌트 예시는 프론트엔드 개발자들이 매일 겪는 상황이라 완벽하게 와닿을 것 같습니다. "그냥 React.ReactNode 타입으로 prop 뚫으면 되는 거 아냐?"라고 피상적으로 생각했던 부분을 **'제어권의 이동'**이라는 IoC의 본질과 연결해서 설명하면, 기존 멘탈 모델에 강력한 충격을 줄 수 있습니다.

IoC(제어의 역전)를 와닿게 설명하기: 상품 카드 발코니 확장 공사

우리가 '조립 테크닉'에서 다루는 핵심 원리 중 하나가 바로 **IoC(Inversion of Control, 제어의 역전)**입니다. 이름이 거창해서 어렵게 느껴지지만, 본질은 간단합니다. "내가 할 일을 남(외부)에게 시키는 것", 즉 '제어권'을 넘겨주는 발상입니다.

흔한 문제: 만능 맥가이버칼이 된 상품 카드

쇼핑몰에서 흔히 보는 상품 카드 컴포넌트를 떠올려 봅시다. 처음엔 이미지, 제목, 가격만 보여주면 됐습니다.
// 초기 Card: 단순함 function ProductCard({ imageUrl, title, price }) { return ( <div className="card"> <img src={imageUrl} alt={title} /> <h3>{title}</h3> <p>{price}</p> </div> ); }
TypeScript
복사
그런데 요구사항이 추가됩니다. "좋아요 버튼 넣어주세요.", "장바구니 담기 버튼도요.", "할인율 표시 기능도 필요해요."
많은 개발자들은 '쉽게(Easy)' 이 요구사항들을 Card 컴포넌트에 직접 추가합니다.
// 점점 비대해지는 Card (Bad Example - No IoC) ❌ function ProductCard({ imageUrl, title, price, // 좋아요 관련 props likeCount, isLiked, onClickLike, // 장바구니 관련 props showCartButton, onAddToCart, isAddingToCart, // 할인 관련 props discountRate, originalPrice, // ... 또 다른 기능 추가 시 props는 계속 늘어남 ... }) { return ( <div className="card"> <img src={imageUrl} alt={title} /> <h3>{title}</h3> {discountRate && <p className="discount">{discountRate}% 할인</p>} <p>{price}{originalPrice && <s className="original-price">{originalPrice}</s>}</p> {/* --- 부가 기능 영역 --- */} <div className="card-addons"> <button onClick={onClickLike} disabled={isLiked}> ❤️ {likeCount} </button> {showCartButton && ( <button onClick={onAddToCart} disabled={isAddingToCart}> {isAddingToCart ? '담는중...' : '장바구니 담기'} </button> )} {/* ... 다른 버튼이나 정보들 ... */} </div> </div> ); }
TypeScript
복사
무엇이 문제일까요?
1.
책임 과다: ProductCard는 이제 상품 정보 표시뿐 아니라, 좋아요/장바구니/할인 등 다양한 부가 기능의 '구체적인 구현 방식'과 '상태'까지 알아야 합니다. SRP(단일 책임 원칙) 위반입니다.
2.
강한 결합 (얽힘): ProductCard는 좋아요 API, 장바구니 API, 할인 정책 등 외부 시스템의 세부사항과 강하게 결합됩니다. 좋아요 로직이 바뀌면 ProductCard도 수정해야 할 수 있습니다.
3.
낮은 재사용성: '장바구니 버튼 없는' 카드는 어떻게 만드나요? showCartButton={false} prop을 추가해야 합니다. '좋아요 대신 위시리스트 버튼'을 넣고 싶으면요? ProductCard 자체를 수정하거나 복잡한 분기 로직을 넣어야 합니다.
4.
예측 불가능한 인터페이스: Props가 계속 늘어나면서 컴포넌트 사용법을 파악하기 점점 어려워집니다.
이것이 바로 제어권이 ProductCard 내부에 집중되어 발생한 '얽힘'입니다. ProductCard가 너무 많은 것을 직접 '제어'하려 하고 있습니다.

해결책: 제어의 역전 (IoC) - "발코니만 내어줄게, 꾸미는 건 네가 해!"

IoC는 이 제어권을 **외부(부모 컴포넌트)**로 넘기는 것입니다. ProductCard는 부가 기능이 들어갈 **'자리'(슬롯)**만 마련해주고, 그 자리에 **'무엇을 넣을지', '어떻게 동작하게 할지'**는 외부에서 결정하도록 책임을 위임합니다.
가장 흔한 방법이 바로 children이나 특정 영역을 위한 React.ReactNode 타입의 prop을 사용하는 것입니다.
// 제어권을 역전시킨 Card (Good Example - With IoC) ✅ function ProductCard({ imageUrl, title, price, // 부가 기능이 들어갈 '자리'만 제공 bottomAddon // 또는 그냥 children을 활용해도 됨 }) { return ( <div className="card"> <img src={imageUrl} alt={title} /> <h3>{title}</h3> <p>{price}</p> {/* --- 부가 기능 영역 --- */} <div className="card-addons"> {/* 외부에서 무엇을 렌더링할지 제어권을 가짐 */} {bottomAddon} </div> </div> ); } // 사용처 (부모 컴포넌트) function ProductListPage() { // ... 좋아요, 장바구니 관련 상태와 핸들러는 여기서 관리 ... const { handleLikeClick, isLiked, likeCount } = useLikeFeature(productId); const { handleAddToCart, isAdding } = useCartFeature(productId); return ( <ProductCard imageUrl={product.imageUrl} title={product.title} price={product.price} // 'renderBottomAddon' 자리에 무엇을 넣을지 부모가 결정하고 제어! bottomAddOn={ <> <button onClick={handleLikeClick} disabled={isLiked}> ❤️ {likeCount} </button> <button onClick={handleAddToCart} disabled={isAdding}> {isAdding ? '담는중...' : '장바구니 담기'} </button> </> } /> ); }
TypeScript
복사
무엇이 달라졌나요?
1.
명확한 책임 분리: ProductCard는 이제 **'상품 정보 표시'와 '부가 기능 자리 제공'**이라는 단순한(Simple) 책임만 가집니다. 좋아요, 장바구니 로직은 ProductListPage(또는 해당 로직을 가진 커스텀 훅)가 책임집니다.
2.
약한 결합 (얽힘 해소): ProductCard는 이제 좋아요/장바구니 기능의 존재나 구현 방식을 전혀 모릅니다. 그저 bottomAddOn이라는 '계약'만 알 뿐입니다.
3.
높은 유연성 및 재사용성:
장바구니 버튼 없는 카드를 만들고 싶으면? bottomAddOn에 해당 버튼을 빼고 렌더링하면 됩니다.
위시리스트 버튼을 추가하고 싶으면? ProductCard 수정 없이 ProductListPage에서 위시리스트 버튼을 만들어 renderBottomAddon에 추가하면 됩니다. (OCP: 개방-폐쇄 원칙)
4.
예측 가능한 인터페이스: ProductCard의 인터페이스는 핵심 정보와 '확장 슬롯'만 제공하므로 훨씬 간결하고 예측 가능합니다.
이것이 바로 **IoC(제어의 역전)**입니다. ProductCard가 부가 기능 영역의 **'제어권'**을 외부(ProductListPage)에 **'역전'**시킨 것입니다. bottomAddOn prop은 그 제어권을 넘기기 위한 '경계 인터페이스' 역할을 합니다.
단순히 React.ReactNode 타입을 쓰는 것이 중요한 게 아니라, 이 방식을 통해 **'누가 무엇을 제어할 것인가?'**라는 책임을 명확히 분배하고 컴포넌트 간의 '얽힘'을 푸는 것이 IoC의 핵심 가치입니다. 이것이 바로 'Easy'해 보였던 첫 번째 방식보다 'Simple'하고 강력한 설계입니다.
네, 정확히 이해했습니다. FSD의 import 규칙이라는 **'형식'**에만 집착한 나머지, '왜' 그 규칙이 존재하는지(의존성 관리, 책임 분리)를 놓치고, 결국 레이어 구조만 복잡해지고 실제로는 더 '얽힌' 코드를 만들게 되는 상황을 말씀하시는군요. '엔티티 짬통'이나 '지하 n층' 비유가 아주 적절합니다.
IoC나 합성을 제대로 활용하지 못하고, 그저 '하위 레이어로 옮기기' 또는 '모든 걸 props로 뚫기'라는 기계적인 방식으로 규칙만 만족시키려 할 때 발생하는 문제를 구체적인 예시로 만들어 보겠습니다.

FSD 안티 패턴: 규칙 준수를 위한 '짬통 엔티티'와 '하드코딩'

FSD(Feature-Sliced Design)의 핵심 가치 중 하나는 '단방향 의존성' 규칙입니다. 상위 레이어(e.g., widgets)는 하위 레이어(e.g., features, entities)에 의존할 수 있지만, 그 반대는 안 됩니다. 이 규칙 자체는 훌륭합니다. 문제는 이 규칙을 '왜' 지켜야 하는지 이해하지 못한 채, 맹목적으로 따르려 할 때 발생합니다.
흔한 시나리오: 상품 목록에 '장바구니 담기' 버튼 추가하기
entities/product: 상품 데이터 타입 정의, 상품 정보를 보여주는 순수한 ProductCard 컴포넌트가 있습니다.
features/add-to-cart: 특정 상품 ID를 받아 장바구니에 추가하는 로직(useAddToCart 훅)과 버튼 UI(AddToCartButton)가 있습니다. 이 훅은 내부적으로 CartStore 같은 다른 상태에 의존할 수 있습니다.
widgets/product-list: 상품 목록 데이터를 받아 ProductCard들을 리스트 형태로 보여주는 ProductList 위젯이 있습니다.
pages/home: 홈페이지에서 ProductList 위젯을 사용합니다.
요구사항: ProductList 위젯 안의 각 ProductCard 옆에 해당 상품을 장바구니에 담는 AddToCartButton을 추가해야 합니다.
잘못된 접근 1: "엔티티는 피처를 import 못 하니까... 피처를 엔티티로 내리자!" (짬통 엔티티 시작)
개발자는 ProductCard(entities)가 AddToCartButton(features)을 직접 사용해야 한다고 생각합니다. 하지만 FSD 린트 규칙이 이를 막습니다.
"아, entities는 features를 import 못 하지... 그럼 AddToCartButton 로직을 그냥 entities/product 안으로 옮기면 되겠네!"
// entities/product/ui/ProductCardWithCart_BAD.tsx ❌ // "규칙만 지키면 돼!" - 잘못된 생각 import React from 'react'; import { Product } from '../types'; // 엔티티 타입 // import { useAddToCart } from '@/features/add-to-cart'; // <-- 원래 여기 있어야 할 훅 import { useCartStore } from '@/shared/stores/cart'; // <-- 어? 엔티티가 갑자기 카트 스토어를 아네? // 장바구니 로직이 엔티티 레이어로 침투! function useAddToCart_BAD(productId) { const addToCart = useCartStore((state) => state.addToCart); // ... 로딩, 에러 상태 등 장바구니 관련 로직 ... return { /* ... */ }; } interface Props { product: Product; // 장바구니 버튼 표시에 필요한 추가 Props... 점점 늘어날 예정 } // ProductCard가 장바구니 책임을 떠안음 export function ProductCardWithCart_BAD({ product }: Props) { const { /* ... */ } = useAddToCart_BAD(product.id); return ( <div className="product-card"> {/* 상품 정보 표시 */} <img src={product.imageUrl} /> <h3>{product.name}</h3> <p>{product.price}</p> {/* 장바구니 버튼 및 관련 로직 직접 포함 */} <button onClick={handleAddToCart} disabled={isLoading}> {isLoading ? '...' : 'Add to Cart'} </button> </div> ); }
TypeScript
복사
결과:
FSD 규칙 위반: 비록 import 경로 문제는 피했지만, entities 레이어가 features 레이어의 책임(장바구니 추가 로직)과 shared 레이어의 구체적인 구현(CartStore)까지 알아버렸습니다. 이는 FSD의 핵심 원칙인 관심사 분리를 정면으로 위반합니다. entities는 더 이상 순수한 '상품' 도메인 모델이 아닙니다.
짬통 엔티티: 다른 피처(e.g., '위시리스트 추가')가 필요해지면, ProductCardWithWishlist_BAD 같은 컴포넌트가 또 생기거나, ProductCard_Mega_BAD 안에 모든 피처 로직이 조건부로 들어가게 됩니다. entities는 온갖 피처 로직의 '짬통'이 됩니다.
재사용성 파괴: 이제 순수하게 상품 정보만 보여주는 ProductCard는 없습니다. 모든 ProductCard는 장바구니 로직과 얽혀있습니다.
잘못된 접근 2: "그럼 그냥 Props로 다 뚫어버리자!" (하드코딩된 인터페이스)
개발자는 장바구니 로직을 entities로 내리는 것이 잘못되었다는 것을 깨닫습니다. 대신, ProductCard가 장바구니 기능에 필요한 모든 데이터와 함수를 props로 받도록 수정합니다.
"좋아, ProductCard는 순수하게 유지하고, 필요한 건 위젯(ProductList)에서 props로 다 넘겨주면 되겠지!"
// entities/product/ui/ProductCard_StillBAD.tsx ❌ import React from 'react'; import { Product } from '../types'; interface Props { product: Product; // 장바구니 "피처"에 필요한 모든 것을 Props로 요구 showCartButton: boolean; onAddToCart: (productId: number) => void; isAddingToCart: boolean; // 위시리스트 기능이 추가되면? isWishlisted, onAddToWishlist, isAddingToWishlist... props 폭발! } // ProductCard가 여전히 장바구니 버튼의 '구현 방식'을 알고 있음 export function ProductCard_StillBAD({ product, showCartButton, onAddToCart, isAddingToCart, }: Props) { return ( <div className="product-card"> {/* 상품 정보 표시 */} <img src={product.imageUrl} /> <h3>{product.name}</h3> <p>{product.price}</p> {/* 장바구니 버튼 렌더링 로직을 직접 포함 */} {showCartButton && ( <button onClick={() => onAddToCart(product.id)} disabled={isAddingToCart}> {isAddingToCart ? '...' : 'Add to Cart'} </button> )} {/* 위시리스트 버튼이 필요하면 여기에 또 if문 추가? */} </div> ); } // widgets/product-list/ui/ProductList.tsx import { ProductCard_StillBAD } from '@/entities/product'; import { useAddToCart } from '@/features/add-to-cart'; // 피처 로직 가져옴 function ProductList({ products }) { const { mutate: addToCart, isPending } = useAddToCart(); return ( <div> {products.map((product) => ( <ProductCard_StillBAD key={product.id} product={product} // 위젯이 엔티티에게 피처의 세부 구현을 위한 props를 주입 showCartButton={true} onAddToCart={addToCart} isAddingToCart={isPending} /> ))} </div> ); }
TypeScript
복사
결과:
FSD 규칙 준수 (표면적): import 경로는 지켰습니다.
하드코딩된 인터페이스: ProductCard는 이제 showCartButton, onAddToCart, isAddingToCart라는 '장바구니 피처'에 특화된 props 계약에 묶였습니다. 다른 종류의 액션(e.g., 위시리스트, 공유하기)을 추가하려면 props를 계속 추가하고 내부 로직을 수정해야 합니다. 추상화되지 않았습니다.
여전히 강한 결합: ProductCard는 비록 features 레이어를 직접 import 하지는 않지만, 여전히 '장바구니 담기'라는 특정 피처의 존재와 동작 방식(클릭 시 onAddToCart 호출)을 알고 있어야 합니다.
재사용성 제한:ProductCard는 '장바구니 버튼을 표시할 수 있는' 카드일 뿐, 다른 종류의 인터랙션을 위한 확장성이 매우 낮습니다.
올바른 접근: IoC와 합성을 통한 '경계 설계'
FSD 규칙은 우리에게 "의존성을 고민하라"는 신호를 줍니다. entitiesfeatures를 직접 알면 안 된다는 것은, ProductCard는 '장바구니 담기'라는 구체적인 액션을 몰라야 한다는 뜻입니다. 대신, 액션이 들어갈 '자리'(슬롯)만 제공하고, 실제 액션 컴포넌트는 **상위 레이어(widgets 또는 pages)에서 조합(Composition)**해야 합니다. 이것이 바로 **IoC(제어의 역전)**입니다.
// entities/product/ui/ProductCard_GOOD.tsx ✅ import React from 'react'; import { Product } from '../types'; interface Props { product: Product; // '액션 슬롯' 제공: React 노드를 받음 (IoC) renderActions?: (product: Product) => React.ReactNode; } // ProductCard는 어떤 액션이 들어올지 전혀 모름! export function ProductCard_GOOD({ product, renderActions }: Props) { return ( <div className="product-card"> {/* 상품 정보 표시 */} <img src={product.imageUrl} /> <h3>{product.name}</h3> <p>{product.price}</p> {/* 액션 영역: 외부에서 주입된 것을 렌더링 */} {renderActions && ( <div className="card-actions"> {renderActions(product)} </div> )} </div> ); } // widgets/product-list/ui/ProductList.tsx ✅ import { ProductCard_GOOD } from '@/entities/product'; import { AddToCartButton } from '@/features/add-to-cart'; // 위젯이 피처를 알고 있음 // import { AddToWishlistButton } from '@/features/add-to-wishlist'; // 필요하면 다른 피처도 가져옴 function ProductList({ products }) { // 위젯은 필요한 피처들을 알고 있고, 어떻게 조합할지 결정 return ( <div> {products.map((product) => ( <ProductCard_GOOD key={product.id} product={product} // 위젯이 엔티티 카드와 피처 버튼을 '조합(Composition)' renderActions={(p) => ( <> <AddToCartButton productId={p.id} /> {/* <AddToWishlistButton productId={p.id} /> */} </> )} /> ))} </div> ); }
TypeScript
복사
결과:
FSD 규칙 및 원칙 준수: entities는 하위 레이어만 알고, widgetsentitiesfeatures를 조합하여 UI를 구성합니다. 각 레이어의 책임이 명확합니다.
느슨한 결합 & 높은 재사용성: ProductCard_GOOD는 이제 어떤 액션과도 얽히지 않았습니다. 장바구니 버튼이 없어도 되고, 위시리스트 버튼만 넣어도 되고, 아예 아무 액션도 넣지 않아도 됩니다. 완벽하게 재사용 가능한 '조약돌'이 되었습니다.
명확한 경계 설계: renderActions prop은 ProductCard와 그 위에 얹혀질 액션들 사이의 명확한 '경계'이자 '계약' 역할을 합니다. ProductCard는 '액션 슬롯을 제공한다'는 책임만 지고, 실제 액션의 구현 책임은 상위 레이어에 위임(IoC)했습니다.
핵심: FSD 규칙은 목적이 아니라, '의존성의 얽힘'을 방지하고 IoC와 합성을 통해 '단순하고(Simple)' 재사용 가능한 컴포넌트를 만들도록 유도하는 수단입니다. 규칙의 '형식'만 따르고 '본질'을 놓치면, FSD를 적용하고도 여전히 복잡하고 변경하기 어려운 코드를 만들게 됩니다.

2. 안티 패턴: 당신의 '쉬움(Easy)'이 만든 '얽힘' (멘탈 모델 파괴)

기존 멘탈 모델을 파괴하기 위해, 당신이 '괜찮다'고 생각했을 법한 흔한 조립 방식들이 왜 근본적으로 '얽힘'을 유발하는지 해부합니다.
a) Prop Drilling의 진짜 죄악: 불필요한 결합과 불투명성
잘못된 믿음: "Prop Drilling은 그냥 귀찮을 뿐, 성능 문제만 없으면 괜찮다."
진실 폭로: Prop Drilling은 데이터를 전달하는 중간 컴포넌트들에게 **불필요한 책임(데이터 전달)**을 부여하고, 서로 관련 없는 컴포넌트들을 강하게 결합시킵니다. 데이터의 출처와 사용처가 멀어져 흐름이 불투명해지고, 데이터 구조 변경 시 수정 범위가 예측 불가능하게 넓어집니다. ('책임', '결합', '변경 파급력' 관점에서 얽힘 발생)
// Bad: Theme prop을 5단계 드릴링 function App() { const theme = 'dark'; return <Layout theme={theme} />; // Level 1 } function Layout({ theme }) { return <Sidebar theme={theme} />; // Level 2 } function Sidebar({ theme }) { return <Profile theme={theme} />; // Level 3 } function Profile({ theme }) { return <Avatar theme={theme} />; // Level 4 } function Avatar({ theme }) { // Avatar만 theme이 필요했는데, Layout, Sidebar, Profile 모두 theme을 알아야 함 const style = { border: `2px solid ${theme === 'dark' ? 'white' : 'black'}` }; return <img src="..." style={style} />; // Level 5 }
TypeScript
복사
b) Context API의 저주: 마법이 아닌 암묵적 의존성 폭탄
잘못된 믿음: "Context는 Prop Drilling의 만병통치약이며, 전역 상태를 쉽게 공유하는 마법이다."
진실 폭로: Context는 **'암묵적 의존성'**을 만듭니다. 컴포넌트는 Context의 존재뿐 아니라 그 **내부 구현(값의 구조, 제공되는 함수)**에 강하게 결합됩니다 (CheckoutButton 예시 재강조). 이는 테스트를 극도로 어렵게 만들고 재사용성을 파괴합니다. Context는 '상태 관리' 도구가 아니라, '의존성 주입' 메커니즘이며, 잘못 사용하면 시스템 전체를 거미줄처럼 옭아맵니다.
// Bad: 거대한 AppContext 남용 const AppContext = React.createContext({}); function UserSettings() { // 설정만 필요하지만, AppContext 전체에 암묵적으로 의존 const { settings, updateSettings } = useContext(AppContext); // ... settings 사용 ... } function CartBadge() { // 장바구니 정보만 필요하지만, AppContext 전체에 암묵적으로 의존 const { cartItems } = useContext(AppContext); // ... cartItems 사용 ... } // AppContext의 auth 관련 구현이 바뀌면 UserSettings, CartBadge도 리렌더링 될 수 있음! // UserSettings 테스트 시 불필요하게 cartItems, auth 등도 모킹해야 함.
TypeScript
복사
c) '갓(God)' 커스텀 훅의 함정: 숨겨진 절차적 괴물
잘못된 믿음: "로직을 커스텀 훅으로 뽑으면 무조건 좋다. 재사용 가능하고 깔끔해진다."
진실 폭로: 수백 줄짜리 거대 커스텀 훅은 종종 상태 관리, 비동기 처리, 데이터 가공, 여러 부수 효과 등 서로 다른 책임을 가진 로직들이 순서에 의존하여 실행되는 절차적 스크립트와 같습니다. 이는 '분리'처럼 보이지만, 실제로는 테스트하기 어렵고 재사용하기 애매하며 내부적으로 강하게 얽힌 새로운 괴물을 만들 뿐입니다. (단일 책임 원칙 위배)
// Bad: 모든 것을 처리하는 거대 훅 function useUserProfileManager(userId) { const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const queryClient = useQueryClient(); // 의존성 1: QueryClient const toaster = useToaster(); // 의존성 2: Toaster useEffect(() => { /* ... userId로 프로필 fetch + 로딩/에러 상태 설정 ... */ }, [userId]); const updateProfile = async (newData) => { setIsLoading(true); try { await api.updateProfile(userId, newData); // API 호출 setProfile(newData); // 로컬 상태 업데이트 queryClient.invalidateQueries(['userProfile', userId]); // 캐시 무효화 toaster.success('Profile updated!'); // 성공 알림 } catch (e) { setError(e); toaster.error('Update failed!'); // 실패 알림 } finally { setIsLoading(false); } }; // 반환 값에 상태, 액션 함수, 로딩/에러 상태 등이 뒤섞여 있음 return { profile, isLoading, error, updateProfile }; } // 이 훅은 테스트하기 어렵고, 다른 종류의 데이터나 알림 방식을 사용하기 어렵다.
TypeScript
복사

3. 새로운 멘탈 모델: 조립은 '경계 설계(Boundary Design)'다

얽힘 없는 조립을 위한 새로운 관점은 **'경계 설계'**입니다. 컴포넌트/훅/모듈 내부 구현보다 외부와의 '경계'를 어떻게 정의하고 관리할 것인가에 집중합니다.
a) 명시적 계약(Explicit Contracts): 경계는 '약속'이다.
Props: 컴포넌트의 공개 API. 무엇을 받고(Input), 무엇을 알릴지(Output Events/Callbacks) 명확하고 최소한으로 정의해야 한다. ('보편적 언어' 활용)
Children: 내용 주입을 위한 표준 '슬롯'. 핵심 골격 외의 렌더링 책임을 외부(부모)에 위임하는 강력한 제어 역전 수단.
훅의 인터페이스: 인자와 반환 값(상태, 함수)이 훅의 '계약'. 예측 가능하고 안정적이어야 한다.
b) 책임의 명확한 할당(Responsibility Allocation): 경계는 '책임'을 나눈다.
상태 소유권 (State Ownership): 이 상태는 누가 관리하는 것이 가장 '단순(Simple)'한가? (Uncontrolled vs Controlled 결정)
로직 위치 (Logic Placement): 이 로직(계산, 액션)은 어디에 속하는 것이 가장 응집력 있는가? (컴포넌트 vs 훅 vs 유틸리티 함수)
렌더링 책임 (Rendering Responsibility): 이 UI 조각은 누가 그려야 하는가? (컴포넌트 자신 vs Children vs Render Prop)
'경계'를 긋는 행위는 이 질문들에 답하며 책임을 명확히 분배하는 과정이다.
c) 관리되는 의존성(Managed Dependencies): 경계는 '관계'를 정의한다.
명시성: 의존성은 숨기지 말고 드러내라 (암묵적 Context 최소화).
방향성: 의존성은 가능한 한 방향으로 흘러야 안정적이다 (단방향 의존성).
추상성: 구체적인 구현이 아닌, 안정적인 '계약(인터페이스)'에 의존하라 (의존성 역전 원칙).
'경계'는 모듈 간의 관계, 즉 의존성의 형태와 방향을 통제하는 방화벽이다.

4. 올바른 조립 테크닉: '경계 설계' 관점에서 재해석

이제 익숙하거나 새로운 조립 패턴들을 '경계 설계'라는 새로운 렌즈로 다시 살펴보고, 각 패턴이 어떤 '경계'를 어떻게 관리하는 데 유용한지 재정의합니다.
a) Children Prop (콘텐츠 경계):
재정의: 단순히 '태그 사이에 넣는 것'이 아니다. 컴포넌트의 핵심 구조적 책임(e.g., 레이아웃, 스타일)과 내부 콘텐츠 렌더링 책임 사이의 경계를 설정하는 가장 기본적인 IoC(제어 역전) 패턴이다.
Good Case: 범용 Card 컴포넌트는 테두리와 그림자만 책임지고, 내부는 children으로 받아 어떤 콘텐츠든 담을 수 있게 한다.
function Card({ children }) { return <div className="card-styles">{children}</div>; } // 사용: Card 내부에 무엇이 들어갈지는 Card가 몰라도 된다. <Card><UserProfile /></Card> <Card><ProductDetails /></Card>
TypeScript
복사
b) 합성 컴포넌트 (Compound Components - 협력 경계):
재정의: <Tabs><TabList><Tab/><TabPanel/></Tabs> 처럼, 강하게 연관된 컴포넌트 그룹이 외부에는 단일한 개념(Tabs)으로 보이면서 내부적으로 상태를 공유하고 협력하도록 경계를 설정하는 패턴. 내부 상태 공유를 위해 Context를 사용하지만, 이는 캡슐화된 구현 세부사항일 뿐 외부에는 노출되지 않는다. Prop Drilling 없이 응집력 있는 API 제공.
Good Case: Select 컴포넌트를 Select, Select.Trigger, Select.Option 등으로 구성하여 사용자가 구조를 명확히 이해하고 커스터마이즈하기 쉽게 만든다.
// 사용자는 Select 내부 상태 관리 방식을 몰라도 된다. <Select value={value} onChange={setValue}> <Select.Trigger placeholder="Choose..." /><Select.Options> <Select.Option value="apple">Apple</Select.Option> <Select.Option value="banana">Banana</Select.Option> </Select.Options> </Select>
TypeScript
복사
c) Render Props / Function-as-Child (렌더링 책임 경계):
재정의: 컴포넌트가 자신의 상태나 데이터를 기반으로 **'무엇(what)'**을 렌더링할지 결정하는 책임을 외부(부모)에 위임하는 강력한 IoC 패턴. 데이터 제공 컴포넌트와 UI 표현 컴포넌트 간의 경계를 명확히 하여 결합도를 낮춘다.
Good Case: MousePosition 컴포넌트는 마우스 좌표만 추적하고, 그 좌표를 어떻게 화면에 그릴지는 children 함수가 결정하도록 책임을 위임한다.
function MousePosition({ children }) { const [position, setPosition] = useState({ x: 0, y: 0 }); // ... 마우스 이벤트 리스너 설정 ... return children(position); // 렌더링 책임을 children 함수에 넘김 } // 사용: MousePosition은 아래 UI 구조를 몰라도 된다. <MousePosition> {({ x, y }) => ( <div>The mouse is at ({x}, {y})</div> )} </MousePosition>
TypeScript
복사
d) Hooks as State Machines/Services (상태 로직 경계):
재정의: 커스텀 훅을 절차적 스크립트가 아닌, 명확한 인터페이스(입력 인자, 반환 상태/함수)를 가진 캡슐화된 상태 저장소, 상태 기계, 또는 서비스로 설계한다. 복잡한 상태 관련 로직을 UI(컴포넌트)로부터 격리하여 경계를 만든다. 훅 내부에서도 '계산'과 '액션' 분리 원칙을 적용한다.
Good Case: useCounter 훅은 카운터 값과 증가/감소 함수만 노출하고, 내부 구현(e.g., useReducer 사용)은 숨긴다.
function useCounter(initialValue = 0) { const [count, dispatch] = useReducer(reducer, initialValue); const increment = () => dispatch({ type: 'INCREMENT' }); const decrement = () => dispatch({ type: 'DECREMENT' }); // 내부 reducer 구현은 외부에 노출되지 않음 return { count, increment, decrement }; // 명확한 계약 } // 사용: 컴포넌트는 카운터가 어떻게 동작하는지 몰라도 된다. function CounterComponent() { const { count, increment, decrement } = useCounter(); return (/* ... count와 함수들 사용 ... */); }
TypeScript
복사
e) Context API (현명한 사용 - 환경 의존성 경계):
재정의: Context는 '마법'이 아니다. 애플리케이션 전반에 걸쳐 거의 변하지 않는 진정한 '환경(Environment)' (e.g., 테마 객체, 인증 클라이언트 인스턴스, 라우터 객체, i18n 인스턴스)을 명시적으로 주입하기 위한 최후의 DI 수단으로 제한적으로 사용한다. '애플리케이션 환경'과 '컴포넌트 구현' 사이의 경계를 설정하는 용도이다. 변하기 쉬운 애플리케이션 상태 전달에는 부적합하다.
Good Case: ThemeProvider가 앱 최상단에서 테마 객체를 제공하고, 하위 컴포넌트들이 useTheme() 훅을 통해 테마 값에 접근한다. (테마 객체 자체는 자주 변하지 않음)

5. 숙련에 이르는 길: '의도적 수련'으로서의 조립

조립 기술은 단순히 아는 것을 넘어 '잘 사용하는' 숙련의 영역입니다.
a) 트레이드오프 분석 훈련: 어떤 조립 패턴이 항상 정답인 경우는 없습니다. 각 패턴의 장단점과 적용 맥락(상태 변경 빈도, 컴포넌트 관계 등)을 이해하고, 'Simple'함과 'Easy'함 사이에서 의식적인 트레이드오프를 내리는 연습. "6개월 후 이 설계는 변경하기 쉬울까?" 질문하기.
b) '단순함' 평가 훈련: 조립된 결과를 보고 '얽힘'이 해소되었는지 평가하는 눈 기르기 (책임 명확성? 결합도? 예측 가능성? 가독성?). 코드가 '글'처럼 읽히는가?
c) 의도적 수련 루프:
리팩토링 연습: 기존의 '얽힌' 코드를 오늘 배운 패턴들을 적용하여 '단순하게' 리팩토링하기. (See & Do)
설계 연습: 코딩 전에 컴포넌트/훅의 '경계'와 '계약'을 먼저 설계하고 동료와 토론하기. (Do)
피드백 루프: 코드 리뷰 시 조립 방식과 경계 설계의 적절성에 대해 적극적으로 질문하고 피드백 구하기. (Feedback)

6. 결론: 조립은 설계다, 당신의 코드는 건축물이다

컴포넌트를 조립하는 행위는 단순히 UI를 구성하는 것을 넘어, 시스템 전체의 구조와 품질, 미래의 변경 용이성을 결정하는 핵심적인 설계 활동임을 재강조. 'Easy'한 유혹을 뿌리치고 'Simple'함을 추구하며 '얽힘'을 푸는 조립 기술을 연마하여, 단순한 부품 나열이 아닌, **견고하고 우아한 '건축물'**을 만들 것을 촉구.