[React] 좋은 추상화란 무엇인가(1) ? 유연한 컴포넌트 설계를 위한 제어의 역전
재사용을 위해 만든 컴포넌트가, 시간이 지날수록 오히려 재사용하기 어려워진 경험이 있으신가요? 저는 최근 프로젝트에서 SearchFilter 컴포넌트가 그러한데요
프로젝트에는 여러 목록 페이지가 존재했고, 페이지마다 검색 조건이 제각각이었습니다. 그래서 처음에는 Configuration 배열만 넘기면 알아서 그려주는 컴포넌트를 만들었습니다.
// ❌ 초기의 설정 기반 접근 (Configuration)
const filterFields = [
{ name: 'userName', label: '이름', type: 'text' },
{ name: 'deptCode', label: '부서', type: 'select', options: [...] },
];
<SearchFilter fields={filterFields} onSearch={handleSearch} />
하지만 이후에 요구 사항이 계속 달라졌습니다.
"이름 검색창과 아이디 검색창을 묶어서(Group) 배경색을 회색으로 칠해주고, 부서 선택은 그 옆에 따로 배치해 주세요."
기존 SearchFilter는 단순히 배열을 map으로 순회하며 그릴 뿐, 여러 필드를 그룹화(Grouping)하거나 레이아웃을 커스텀하는 기능이 없었습니다. 이를 구현하려면 설정 객체 구조를 복잡하게 변경해야 했고, 컴포넌트 내부에는 if (type === 'group')과 같은 분기 로직과 재귀 렌더링이 추가되어야 했습니다.
문제는 여기서 끝이 아니었습니다.
새로운 타입 추가: 체크박스, 라디오 버튼, 슬라이더 등 새로운 입력 방식이 필요할 때마다
SearchFilter내부 코드를 매번 수정해야 했습니다.복잡한 배치 요구: "예약 페이지에서는 기간 검색이 한 줄을 다 차지하고, 검색 버튼은 우측 하단에 둬주세요."와 같은 레이아웃 예외 처리가 계속 늘어났습니다.
결국 사용하기도, 유지보수하기도 힘든 코드가 되어버렸죠…ㅜㅜ
어디서부터 잘못되었을까.. "이게 과연 좋은 추상화일까?"라는 의문이 들었습니다.
이 물음에 대한 답을 찾던 중, Kent C. Dodds의 Inversion of Control(제어의 역전) 아티클을 접하게 되었습니다. 이 글을 통해 컴포넌트 설계에 대한 새로운 접근 방식을 고민해 볼 수 있었습니다.
오늘은 제가 학습하고 실무에 적용해 본 제어의 역전에 대해 공유하려 합니다.
제어의 역전이란?
보통 우리는 함수나 컴포넌트를 만들 때, "모든 경우의 수를 내가 다 처리해 놔야지!"라고 생각합니다.
하지만 제어의 역전은 "핵심 기능만 내가 하고, 세부 판단은 이걸 쓰는 사람한테 맡기자!"라고 생각을 뒤집는 것입니다.
일반적인 방식 (Control): "내가(함수) 다 알아서 할게. 넌 옵션(파라미터)만 골라."
제어 역전 (Inversion of Control): "틀은 내가 짤게. 알맹이(로직/화면/상태)는 네가(사용자) 채워."
왜 이렇게 해야 할까요? "미래를 예측할 수 없기 때문"입니다. 개발자는 신이 아닙니다. 오늘 만든 컴포넌트에 내일 기획자와 디자이너가 어떻게 수정 요구할지 모릅니다. 이 모든 미래의 요구사항을 미리 다 코딩해 넣는 것은 불가능합니다.
이제 이 개념을 로직, 렌더링, 상태 세 가지 단계로 나누어 살펴보겠습니다.
로직 제어권 역전 (Callback)
👉 "판단 로직은 네가 줘, 계산만 내가 할게."
지금은 "A 조건"만 필요해 보이지만, 내일 기획자가 "B 조건", 모레는 "C 조건인데 A랑 섞어서..."라고 요구할 수 있습니다. 이 모든 미래의 요구사항을 컴포넌트 하나에 미리 다 코딩해 넣을 수 있을까요? 불가능합니다. 그래서 "판단 로직"을 구멍(Slot)으로 남겨두고, 나중에 채워 넣게 만드는 것입니다.
언제 사용하나요?
"조건"이 계속 바뀔 것 같을 때
검색 필터 (이름으로 검색? 날짜로 검색? 가격으로 검색? -> 검색 로직을 역전!)
유효성 검사 (이메일 형식? 비밀번호 길이? 특수문자? -> 검증 로직을 역전!)
"결과물"을 다양하게 보여줘야 할 때
- 날짜 포맷 (2024-01-01? 2024년 1월 1일? 1/1? -> 포맷팅 로직을 역전!)
라이브러리나 공통 컴포넌트를 만들 때
- 우리 팀 모두가 쓰는 DataTable을 만드는데, 팀마다 요구사항이 다를 때.
사용하면 뭐가 좋은가요?
유지보수가 꿀이다
새로운 요구사항이 와도 공통 컴포넌트 코드를 건드릴 필요가 없습니다. (버그 발생 확률 0%에 수렴)
그냥 사용하는 쪽 파일에서 함수 하나만 새로 짜서 넣어주면 끝입니다.
재사용성이 폭발한다
이 컴포넌트는 어떤 상황에서도 쓸 수 있는 "만능열쇠"가 됩니다.
조건이 아무리 복잡해도 다 받아줄 수 있기 때문입니다.
클린 코드
if (type === 'A') ... else if (type === 'B') ...같은 지저분한 분기문이 싹 사라집니다.
실전 예시 1) 할인 가격 계산기
Step 1: 처음엔 단순하게 시작 (하드코딩)
기획자: "10% 할인 기능 만들어주세요."
function calculatePrice(price) {
return price * 0.9; // 10% 할인 고정
}
Step 2: 요구사항 추가 (Props로 해결 시도)
기획자: "어떤 건 20% 할인이고, 어떤 건 1000원 깎아주도록 해주세요"
개발자: "아... 옵션 추가해야겠네."
function calculatePrice(price, type, amount) {
if (type === 'percent') {
return price * (1 - amount / 100);
} else if (type === 'minus') {
return price - amount;
}
}
Step 3: 요구사항 폭발
기획자: "수요일엔 5% 더 깎아주고요, VIP는 1000원 깎고 나서 10% 또 깎아주세요."
개발자: 🤯
Step 4: 제어의 역전 (구원투수 등판)
개발자: "안 되겠다. '판단 로직'은 밖에서 받자! 난 계산기만 돌려줄게."
// 좋은 함수 (제어권 역전)
// discountLogic: 가격을 받아서 할인가를 뱉어주는 함수
function calculatePrice(price, discountLogic) {
const finalPrice = discountLogic(price);
return finalPrice < 0 ? 0 : finalPrice; // 마이너스 방지 같은 "핵심 규칙"만 내가 관리
}
// 상황 1: 그냥 10% 할인
calculatePrice(10000, (price) => price * 0.9);
// 상황 2: VIP 복합 할인 (컴포넌트 수정 없이 가능!)
calculatePrice(10000, (price) => {
const afterMinus = price - 1000;
return afterMinus * 0.9;
});
// 상황 3: 수요일 특가 (미래의 요구사항도 OK)
calculatePrice(10000, (price) => {
const isWednesday = new Date().getDay() === 3;
return isWednesday ? price * 0.5 : price;
});
calculatePrice 함수는 더 이상 수정될 필요가 없습니다. 이것이 바로 "제어의 역전이 주는 평화"입니다.
실전 예시 2) 필터링 기능이 있는 리스트
- 나쁜 예: 컴포넌트 안에서 if (item.active) 처럼 조건을 박아버림.
interface Todo {
id: number;
text: string;
isCompleted: boolean;
}
// ❌ 나쁜 예: 필터링 조건이 컴포넌트 안에 박혀있음
// "완료된 것만 보고 싶다"는 요구사항이 생기면? props로 showCompleted를 받아야 하고...
// "짝수 ID만 보고 싶다"는 요구사항이 생기면? 또 코드를 고쳐야 함.
function BadTodoList({ todos, filterType }: { todos: Todo[], filterType: 'all' | 'completed' | 'active' }) {
const filteredTodos = todos.filter(todo => {
if (filterType === 'completed') return todo.isCompleted;
if (filterType === 'active') return !todo.isCompleted;
return true; // 'all'
});
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredTodos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.isCompleted ? 'line-through' : 'none' }}>
{todo.text}
</li>
))}
</ul>
);
}
function App() {
const [todos] = useState<Todo[]>([
{ id: 1, text: '리액트 공부하기', isCompleted: true },
{ id: 2, text: '운동하기', isCompleted: false },
{ id: 3, text: '책 읽기', isCompleted: false },
{ id: 4, text: '친구 만나기', isCompleted: true },
]);
return (
<div style={{ padding: '50px' }}>
<BadTodoList todos={todos} filterType="completed" />
</div>
)
}
- 좋은 예: filterFn이라는 함수를 받아서, 조건을 밖에서 결정하게 함.
interface Todo {
id: number;
text: string;
isCompleted: boolean;
}
function GoodTodoList({ todos, filterFn }: { todos: Todo[], filterFn: (todo: Todo) => boolean }) {
const filteredTodos = todos.filter(filterFn);
return (
<ul style={{ listStyle: 'none', padding: 0, border: '1px solid green' }}>
{filteredTodos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.isCompleted ? 'line-through' : 'none' }}>
{todo.text}
</li>
))}
</ul>
);
}
function App() {
const [todos] = useState<Todo[]>([
{ id: 1, text: '리액트 공부하기', isCompleted: true },
{ id: 2, text: '운동하기', isCompleted: false },
{ id: 3, text: '책 읽기', isCompleted: false },
{ id: 4, text: '친구 만나기', isCompleted: true },
]);
return (
<div style={{ padding: '50px' }}>
<p>1. 완료된 것만 보기</p>
<GoodTodoList todos={todos} filterFn={(todo) => todo.isCompleted} />
<p>2. 안 끝난 것만 보기</p>
<GoodTodoList todos={todos} filterFn={(todo) => !todo.isCompleted} />
<p>3. 글자 수가 5자 이상인 것만 보기 (새로운 로직도 컴포넌트 수정 없이 가능!)</p>
<GoodTodoList todos={todos} filterFn={(todo) => todo.text.length >= 5} />
</div>
)
}
렌더링 제어권 역전 (Composition)
👉 "기능은 내가 줄게, 화면 구성은 네가 조립해."
보통 컴포넌트를 만들 때 "데이터(Props)만 줘, 화면은 내가(컴포넌트) 다 그려줄게"라고 합니다.
하지만 렌더링 제어권 역전은 "데이터랑 기능은 내가 줄게, 근데 화면은 네가(사용자) 알아서 그려!"라고 하는 것입니다.
일반적인 방식: "메뉴 리스트(배열)만 줘. 내가 <ul>로 예쁘게 그려줄게." (Props로 데이터 전달)
제어 역전 방식: "메뉴 리스트 줄게. 근데 <ul>로 그릴지 <div>로 그릴지는 네가 정해." (Render Prop 또는 합성 컴포넌트)
왜 사용하나요?
가장 큰 이유는 "디자인 요구사항이 계속 변할 수 있기 때문"입니다.
기능(예: 메뉴 열고 닫기)은 똑같은데, 디자인은 매번 다릅니다.
A 페이지: "메뉴 아이템 왼쪽에 아이콘 넣어주세요."
B 페이지: "메뉴 아이템 사이에 빨간 줄(Divider) 넣어주세요."
C 페이지: "메뉴 클릭하면 파란불 들어오게 해주세요."
이걸 하나의 Menu 컴포넌트에서 Props로 다 처리하려고 하면?<Menu showIcon={true} showDivider={true} activeColor="blue" ... />
→ Props 지옥에 빠져 컴포넌트가 터져 나갑니다.
그래서 "기능(열고 닫기)은 내가 할 테니, 생김새(UI)는 네가 알아서 조립해!"라고 넘겨버리는 것입니다.
사용하면 뭐가 좋은가요?
"Props 지옥" 탈출
isRed, hasIcon, showDivider 같은 잡다한 Props를 안 만들어도 됩니다.
그냥 사용하는 쪽에서 <span style={{color: 'red'}}> 넣으면 끝이니까요.
직관적인 코드 (HTML과 비슷함)
<select><option>...</option></select>처럼 구조가 한눈에 보입니다.
코드를 읽는 사람도 "아, 여기엔 이게 들어가는구나" 바로 알 수 있습니다.
유연성 끝판왕
갑자기 "메뉴 안에 검색창 넣어주세요"라고 해도 컴포넌트 수정 없이 가능합니다.
그냥 <Menu><input type="search" /></Menu> 넣으면 되니까요.
실전 예시: "드롭다운 메뉴" (Props vs 합성)
Step 1: 나쁜 예 (Props로 모든 걸 해결하려 함)
기획자: "메뉴 만들어주세요."
개발자: "네, 배열만 넘기면 되겠다."
// ❌
function BadMenu({ items }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>메뉴</button>
{isOpen && (
<ul>
{items.map(item => (
// 모든 디자인이 여기에 하드코딩 되어 있음
<li onClick={item.onClick}>{item.label}</li>
))}
</ul>
)}
</div>
);
}
// 사용할 때: 디자인 변경 불가능
<BadMenu items={[{ label: '로그인', onClick: login }]} />
Step 2: 요구사항 폭발
기획자/디자이너: "로그인 버튼 옆에 열쇠 아이콘 넣어주시고, 회원가입은 빨간색으로, 그리고 중간에 구분선도 넣어주세요."
개발자: "아... items 배열에 icon, color, isDivider 속성 추가해야겠네..." (고통 시작)
Step 3: 좋은 예 (합성 컴포넌트 - 렌더링 제어권 역전)
개발자: "안 되겠다. '화면 그리는 권한'을 사용자에게 주자! 난 열고 닫는 상태만 관리할게."
// ✅ 좋은 컴포넌트 (껍데기와 기능만 제공)
function Menu({ children }) {
const [isOpen, setIsOpen] = useState(false);
// Context로 'isOpen'과 'toggle' 기능을 자식들에게 공유
return (
<MenuContext.Provider value={{ isOpen, toggle }}>
<div className="menu">{children}</div>
</MenuContext.Provider>
);
}
// 사용할 때 (사용자가 UI를 직접 조립 = 제어권 역전)
<Menu>
<MenuButton>메뉴 열기 🔽</MenuButton>
<MenuList>
{/* 아이콘 넣기? OK */}
<MenuItem>🔑 로그인</MenuItem>
{/* 구분선 넣기? OK (그냥 태그 넣으면 됨) */}
<hr />
{/* 빨간색? OK */}
<MenuItem style={{ color: 'red' }}>회원가입</MenuItem>
{/* 검색창? OK */}
<li><input placeholder="검색..." /></li>
</MenuList>
</Menu>
결과: Menu 컴포넌트 코드는 수정할 필요가 없습니다. 기획자의 요구사항에 맞춰주기 편합니다.
이것이 바로 "렌더링 제어권 역전"의 힘입니다!
상태 제어권 역전 (State Reducer)
👉 "상태가 어떻게 변할지조차 네가 결정해."
보통 Dropdown 컴포넌트는 "클릭하면 -> 열린다(true)"라는 규칙을 내부에 가지고 있습니다.
하지만 State Reducer 패턴은 이 규칙마저 사용자가 덮어쓸 수 있게 해줍니다.
왜 사용하나요?
"기본 동작을 바꾸고 싶은 예외적인 케이스가 존재하기 때문"입니다.
예를 들어 Dropdown 메뉴를 생각해 봅시다.
기본 동작: 메뉴 아이템을 클릭하면 -> 메뉴가 닫힌다.
새로운 요구사항: "다중 선택(Multi-select) 기능이 필요해요. Shift 키를 누르고 클릭하면 메뉴가 안 닫히게 해주세요."
이걸 컴포넌트 내부에서 if (e.shiftKey) ... 이렇게 처리하기 시작하면 또 코드가 더러워집니다.
그래서 "상태 변경의 결정권"을 사용자에게 넘기는 것입니다.
언제 사용하나요?
복잡한 라이브러리를 만들 때 (React Table, Downshift 등 유명 라이브러리가 다 이걸 씁니다.)
사용자의 행동(Action)에 따른 결과(State)를 커스터마이징하고 싶을 때.
"이 버튼 누르면 원래는 꺼져야 하는데, 이번만 안 꺼지게 해줘."
"최대 3개까지만 선택되게 해줘." (3개 넘으면 상태 변경 거부)
사용하면 뭐가 좋은가요?
개발자가 모든 엣지 케이스를 예측할 필요가 없다.
- "Shift 누르면?", "Ctrl 누르면?", "더블 클릭하면?"... 이 모든 걸 컴포넌트에 코딩 안 해도 됩니다.
자유도가 높다.
- 컴포넌트의 기본 로직을 덮어쓸 수 있어서 자유도가 무한대가 됩니다.
실전 예시: Dropdown (토글 메뉴)
Step 1: 일반적인 Dropdown (규칙 고정)
"메뉴 아이템을 클릭하면 메뉴가 닫힌다"는 규칙이 컴포넌트 내부에 하드코딩 되어 있습니다.
function Dropdown({ items }) {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
return (
<div>
<button onClick={toggle}>메뉴 열기</button>
{isOpen && (
<ul>
{items.map(item => (
<li onClick={() => {
item.onSelect();
setIsOpen(false); // ❌ 클릭하면 무조건 닫힘 (규칙 고정)
}}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Step 2: 요구사항 발생
기획자: "다중 선택(Multi-select) 기능이 필요해요. 아이템을 클릭해도 메뉴가 안 닫히게 해주세요."
개발자: "아... closeOnSelect={false} props 추가해야겠네..."
기획자: "근데 Shift 키를 누르고 클릭할 때만 안 닫히게 하고, 그냥 클릭하면 닫히게 해주세요."
개발자: "음... keepOpenOnShift={true} 추가...?"
기획자: "또, 최대 3개까지만 선택되면 자동으로 닫히게..."
개발자: 🤯
Step 3: State Reducer 적용 (제어권 역전)
개발자: "안 되겠다. '상태가 어떻게 변할지(닫힐지 말지)'는 사용자가 정해라! 난 그냥 사건만 보고할게."
// 1. 컴포넌트는 "무슨 일이 일어났는지(Action)"만 알려줌
function Dropdown({ stateReducer }) {
const [isOpen, setIsOpen] = useState(false);
// 상태를 변경할 때마다 사용자(reducer)에게 물어봄
const dispatch = (action) => {
// "나 지금 아이템 클릭했어(ItemClick). 다음 상태(isOpen)는 뭐가 돼야 해?"
const nextState = stateReducer(isOpen, action);
setIsOpen(nextState);
};
return (
<div>
<button onClick={() => dispatch({ type: 'ToggleButtonClick' })}>메뉴</button>
{isOpen && (
<ul>
<li onClick={() => dispatch({ type: 'ItemClick' })}>메뉴 1</li>
</ul>
)}
</div>
);
}
// ------------------------------------------------
// 사용하는 곳 (사용자가 규칙을 창조)
// ------------------------------------------------
// 규칙: "Shift 키를 누르고 있으면 안 닫히는 메뉴"
function myReducer(state, action) {
switch (action.type) {
case 'ToggleButtonClick':
return !state; // 버튼 누르면 토글 (기본 동작)
case 'ItemClick':
// 핵심: Shift 키가 눌려있으면 상태 유지(안 닫힘), 아니면 닫힘
if (action.shiftKey) {
return state; // (현재 상태 유지 = 열려있음)
}
return false; // (닫힘)
default:
return state;
}
}
<Dropdown stateReducer={myReducer} />
'굳이 이렇게까지?' 싶을 수도 있지만 React Table, Downshift 같은 유명 라이브러리들은 이 패턴을 통해 개발자의 모든 변덕(Edge Case)을 수용하고 있습니다.
마치며
이번 공부를 통해 "좋은 추상화란, 모든 것을 내부에서 감추는 게 아니라, 적절한 제어권을 사용자에게 위임하는 것"임을 배웠습니다.
물론, 모든 컴포넌트에 제어 역전 패턴을 적용하는 것이 정답은 아닙니다. 재사용성이 낮거나 단순한 컴포넌트에 무리하게 패턴을 도입하는 것은 오히려 불필요한 복잡도를 높이는 오버엔지니어링이 될 수 있기 때문입니다.
다만 이제는새로운 요구사항이 들어왔을 때, 무조건 if 문을 추가하거나 무작정 패턴을 적용하기보다, "지금 이 복잡함이 정말 필요한가? 제어권을 넘기는 것이 장기적으로 이득인가?"를 한 번 더 고민하는 개발자가 되려 합니다.
다음 글에서는 제가 겪었던 시행착오를 바탕으로, 언제 추상화를 하면 좋은지에 대해 이야기해 보겠습니다.
Reference
https://kentcdodds.com/blog/inversion-of-control#enter-inversion-of-control