아래 글을 봤으나 이해가 잘 안돼 제미나이에게 iOS 개발자를 중심으로 설명해달라고 함
https://velog.io/@khy226/%EB%B2%88%EC%97%AD-React-Design-Patterns
[번역] React Design Patterns
해당 포스트는 리액트 디자인 패턴에 대해서 다룬 React Design Patterns 아티클의 번역 문서입니다. 리액트 개발자는 디자인 패턴을 사용함으로써 시간과 노력을 절약할 수 있으며, 검증된 솔루션을
velog.io
1. 컨테이너와 프레젠테이션 패턴 (Container & Presentational)
오, iOS 개발자라니 반갑네! Swift랑 SwiftUI(혹은 UIKit) 하던 감각이 있어서 금방 적응할 거야.
iOS에서 **MVVM(Model - View - ViewModel)**을 쓰는 가장 큰 이유가 뭐야?
보통 "비즈니스 로직(ViewModel)과 UI(View)의 분리" 때문이잖아? ViewController가 뚱뚱해지는(Massive View Controller) 걸 막으려고 말이야.
React도 똑같은 고민을 하는데, 해결하는 **방식(패턴)**이 조금 달라. 요즘 리액트 씬에서 "국룰"로 불리는 패턴들을 iOS 개념이랑 엮어서 설명해줄게.
1) Custom Hooks 패턴 (사실상 React의 MVVM)
"ViewModel을 함수로 만든 것"
iOS에서 class MyViewModel: ObservableObject 만들어서 로직 때려 넣지? React에서는 이걸 Custom Hook이라는 걸로 처리해. 이게 요즘 제일 많이 쓰는 패턴이야.
- View (Component): UI만 그림.
- ViewModel (Custom Hook): 상태(useState), 로직, API 호출 등을 담당.
[비교해볼까?]
- iOS (SwiftUI): @StateObject var viewModel = LoginViewModel()
- React: const { login, isLoading } = useLogin();
[코드 예시]
// 📁 useCounter.js (이게 ViewModel 역할!)
// 이름이 'use'로 시작하는 함수를 Custom Hook이라고 해.
function useCounter() {
const [count, setCount] = useState(0); // State
const increment = () => { // Logic
setCount(prev => prev + 1);
};
return { count, increment }; // View에 필요한 것만 던져줌 (Output)
}
// 📁 CounterComponent.jsx (이게 View 역할!)
function CounterComponent() {
// ViewModel을 주입받는 느낌
const { count, increment } = useCounter();
return (
<button onClick={increment}>
현재 카운트: {count}
</button>
);
}
핵심: "로직이 복잡해? 그럼 use... 함수로 싹 빼버려!" 이게 React의 기본 철학이야.
2) Container - Presenter 패턴 (전통의 강자)
"똑똑한 놈(Container)과 멍청한 놈(Presenter) 나누기"
이건 MVVM이랑 거의 비슷한데, 컴포넌트 파일을 아예 물리적으로 두 개로 쪼개는 방식이야. 예전엔 필수였는데, 요즘은 1번(Hooks) 때문에 좀 덜 쓰긴 하지만 여전히 유효해.
- Container (똑똑한 놈):
- 로직 담당, API 호출, 상태 관리.
- HTML(JSX)이 거의 없고 로직만 가득해.
- iOS의 ViewController 역할이랑 비슷해.
- Presenter (멍청한 놈):
- 오직 props로 받은 데이터만 보여줌.
- 내부에 useState 같은 거 거의 안 씀.
- iOS의 UIView(XIB) 역할.
// 1. Container (로직)
function UserListContainer() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users').then(data => setUsers(data));
}, []);
// Presenter한테 데이터만 툭 던져줌
return <UserListPresenter users={users} />;
}
// 2. Presenter (UI)
function UserListPresenter({ users }) {
return (
<ul>
{users.map(user => <li>{user.name}</li>)}
</ul>
);
}
💡 iOS 개발자를 위한 한 줄 요약
"React에서는 ViewModel 클래스를 만드는 대신, Custom Hook (useSomething) 함수를 만들어서 로직을 분리한다!"
이것만 알면 MVVM 하던 감각 그대로 React 개발 쌉가능이야!
2. 훅(Hooks)을 사용한 컴포넌트 구성
**Hook(훅)**은 한마디로 **"장비(Item) 착용"**이야. 🛠️
네가 게임 캐릭터(함수형 컴포넌트)라고 쳐보자.
원래 함수형 컴포넌트는 그냥 "HTML만 뱉어내는 아주 단순한 녀석"이었어. 상태(State)도 못 가지고, 복잡한 기능도 못 썼지.
근데 Hook이 등장하면서 이 단순한 캐릭터가 아이언맨 수트를 입게 된 거야.
"야, 너 이제 기억력(State)도 갖고, 통신(Lifecycle)도 해!"
1) 이름이 왜 Hook(갈고리)이야?
React의 핵심 기능(LifeCycle, State 등)이 벽에 걸려 있다고 상상해봐.
함수 컴포넌트가 지나가다가 "갈고리(Hook)"를 딱 걸어서 그 기능을 끌어와서 쓰는 거야.
그래서 React의 모든 Hook은 **use**로 시작해.
- useState: "상태(State) 기능 좀 쓸게!"
- useEffect: "타이밍(Lifecycle) 기능 좀 쓸게!"
2) iOS 개발자를 위한 비교 (Class vs Function)
이걸 이해하려면 역사를 아주 살짝만 알면 돼.
[옛날: Class Component 시대]
- iOS의 UIViewController랑 똑같음.
- 무겁고, 상속받아야 하고, viewDidLoad, viewWillDisappear 같은 수명주기 함수를 따로따로 다 써줘야 했어.
- this 키워드 때문에 코드가 엄청 복잡했지.
[지금: Function Component + Hooks 시대]
- iOS의 SwiftUI View (Struct)랑 똑같음.
- 그냥 가벼운 함수 하나야.
- 필요한 기능이 있다? 그때그때 **Hook(use...)**을 가져와서 조립해.
3) 대표적인 Hook 3대장
네가 가장 많이 쓰게 될 "필수 장비" 3개만 알면 돼.
① useState: 메모장 장착 📝
컴포넌트가 뭔가 기억하게 만듦.
- 기능: 값이 바뀌면 화면을 다시 그림.
- iOS 비유: SwiftUI의 @State랑 100% 똑같음.
// "count라는 변수를 기억해줘!"
const [count, setCount] = useState(0);
② useEffect: 타이밍 센서 장착 ⏰
화면이 그려진 직후나, 뭔가 바뀔 때 특정 작업을 함.
- 기능: API 호출, 이벤트 등록 등.
- iOS 비유: viewDidLoad + viewDidAppear + deinit을 합친 것. 또는 SwiftUI의 .onAppear, .onChange.
useEffect(() => {
console.log("화면 켜졌다! API 부르자!"); // viewDidLoad
return () => {
console.log("화면 꺼진다! 청소하자!"); // deinit
};
}, []);
③ useCustomHook: 나만의 무기 제작 ⚔️
아까 말한 MVVM의 ViewModel을 만드는 기술이야.
- useState랑 useEffect를 잘 버무려서 useMyLogic 같은 함수를 만드는 거지.
요약
Hook은 멍청했던 함수형 컴포넌트한테 useState(기억력), useEffect(타이밍) 같은 "슈퍼 파워 장비"를 use라는 주문으로 채워주는 것이다!
3. 리듀서로 상태 관리
Reducer(리듀서)? 이름이 좀 수학적이라 딱딱하게 들리지? 겁먹지 마!
이거 사실 네가 iOS 개발하면서도 알게 모르게 썼던 개념이야.
한마디로 정의하면 "엄격한 장부 관리자" 또는 **"상태 자판기"**라고 생각하면 돼.
1) 비유로 이해하기: 엄격한 창고 관리자 👮♂️
네가 식당 매니저(Component)라고 쳐보자. 창고에 **사과(State)**가 10개 있어.
근데 네가 맘대로 창고 문 열고 사과를 가져가면 될까?
안 돼! 그러면 나중에 "누가 사과 먹었어!" 하고 싸움 나잖아. (이게 상태 불일치 버그야)
그래서 Reducer라는 **"엄격한 창고 관리자"**를 고용했어.
- 너 (Component): 직접 사과를 못 꺼내. 대신 **"쪽지(Action)"**를 적어서 관리자한테 줘.
- 쪽지 내용: "사과 1개 꺼내기", "사과 5개 채워넣기"
- Reducer (관리자): 네가 준 쪽지(Action)랑 현재 재고(Current State)를 확인해.
- 결과 (New State): 장부에 기록하고, 변경된 재고 수량을 딱 알려줘.
"야, 지금 10개 있는데(Current State), 네가 1개 달라고 했으니까(Action), 자 이제 9개 남았다(New State)!"
2) 공식으로 보기 (Swift랑 비슷해!)
Reducer는 결국 딱 하나의 함수야. 공식이 있어.
// Reducer 함수 예시
// state: 현재 상태 (사과 10개)
// action: 주문서 (type: 'EAT_APPLE')
function appleReducer(state, action) {
switch (action.type) {
case 'EAT_APPLE':
return state - 1; // 사과 하나 줄여서 반환
case 'BUY_APPLE':
return state + 1; // 사과 하나 늘려서 반환
default:
return state; // 아무것도 안 하면 그대로 반환
}
}
3) iOS 개발자를 위한 연결고리 🔗
너 Swift에서 배열 다룰 때 reduce 함수 써봤지?
// Swift 코드
let numbers = [1, 2, 3, 4]
let sum = numbers.reduce(0) { (currentSum, number) in
return currentSum + number
}
// 결과: 10
이거랑 원리가 100% 똑같아!
- Swift reduce: (현재까지 합친 값, 들어온 숫자) -> 새로운 합계
- React Reducer: (현재 상태, 들어온 액션) -> 새로운 상태
그래서 이름이 Reducer인 거야. 액션들을 하나하나 실행(reduce)하면서 상태를 만들어가니까.
그리고 iOS 아키텍처 중에 **TCA (The Composable Architecture)**나 ReSwift 들어봤어? 그게 이 Reducer 패턴을 iOS로 가져간 거야.
4) 실제 코드: useState vs useReducer
그럼 언제 이걸 써?
- useState: 단순히 껐다 켰다(Bool), 텍스트 입력(String) 같은 단순한 거.
- useReducer: 상태가 복잡하거나, 여러 값이 서로 얽혀있을 때.
[코드 예시: 쇼핑카트]
// 1. Reducer 정의 (로직을 밖으로 뺌)
function cartReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, action.product]; // 기존 거에 추가
case 'REMOVE':
return state.filter(item => item.id !== action.id); // 삭제
case 'CLEAR':
return []; // 싹 비우기
default:
return state;
}
}
// 2. 컴포넌트에서 사용
function ShoppingCart() {
// dispatch: 관리자한테 쪽지 보내는 우체통
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<div>
<button onClick={() => dispatch({ type: 'ADD', product: '맥북' })}>
맥북 담기 (쪽지 보내기)
</button>
<button onClick={() => dispatch({ type: 'CLEAR' })}>
카트 비우기
</button>
</div>
);
}
4. 프로바이더로 데이터 관리
**Prop Drilling(프롭 드릴링)**은 말 그대로 **"데이터로 땅 파고 내려가기"**야. 개발자들을 가장 피곤하게 만드는 '단순 노동(노가다)' 중 하나지.
이걸 해결해주는 **Provider(Context API)**는 "마법의 텔레포트" 장치고.
iOS 개발자니까 내비게이션 스택으로 비유하면 이해가 팍 올 거야.
1) Prop Drilling (고통의 시작 😫)
"야, 막내한테 이것 좀 전해줘. (x 10명)"
네가 App (최상위 뷰)에서 저~기 밑에 있는 ProfileButton (자손 뷰)한테 "로그인한 유저 이름"을 보여주고 싶어.
근데 React 데이터는 **위에서 아래로(단방향)**만 흐르잖아?
그럼 중간에 껴 있는 Header, Nav, Menu, MenuItem 등등은 유저 이름이 필요도 없는데, 오직 ProfileButton한테 전해주기 위해서 억지로 받아서 넘겨줘야 해.
App (유저 정보 있음)
⬇️ props 전달
Header (난 필요 없는데...)
⬇️ props 전달
Menu (나도 필요 없는데...)
⬇️ props 전달
ProfileButton (아싸 받았다!)
이게 마치 드릴로 땅을 파고 내려가는 것 같다고 해서 Prop Drilling이야.
[iOS로 치면?]
ViewController A에서 ViewController E로 데이터를 보내야 하는데, 중간에 있는 B, C, D한테 init이나 segue로 계속 데이터 넘겨주는 상황. 코드 더러워지고 B, C, D 수정할 때마다 화나지? 딱 그거야.
2) Provider와 Context API (구세주 🦸♂️)
"방송실에서 안내 방송 드립니다. 막내 들어라!"
이 문제를 해결하려고 React 팀이 Context API라는 걸 만들었어. 여기에 Provider라는 개념이 등장해.
- Context: 데이터가 저장되는 공중 부양 탱크.
- Provider: 그 탱크에 데이터를 쏴주는 방송국.
이제 App에서 Provider를 이용해서 데이터를 쏴주면(Broadcast), 중간에 있는 애들을 다 건너뛰고 데이터가 필요한 막내(ProfileButton)가 다이렉트로 꺼내 쓸 수 있어.
[코드 흐름]
- 방송국 개설 (createContext): UserContext라는 방송 채널을 만듦.
- 방송 송출 (Provider): 최상위 컴포넌트(App)를 <UserContext.Provider value={user}>로 감싸. 이제 이 안에 있는 모든 하위 컴포넌트는 방송을 들을 수 있어.
- 방송 수신 (useContext): 저 밑에 있는 ProfileButton에서 useContext(UserContext)를 쓰면 바로 user 정보가 튀어나와!
3) 코드로 비교해볼까?
[😫 Prop Drilling 방식]
function App() {
const user = { name: "Steve" };
return <Header user={user} />; // 1. 전달
}
function Header({ user }) {
return <Nav user={user} />; // 2. 전달 (난 쓰지도 않는데!)
}
function Nav({ user }) {
return <Profile user={user} />; // 3. 전달
}
function Profile({ user }) {
return <div>{user.name}</div>; // 4. 드디어 사용
}
[😎 Provider (Context) 방식]
// 1. 방송국(Context) 생성
const UserContext = React.createContext(null);
function App() {
const user = { name: "Steve" };
// 2. Provider로 감싸기 (여기서 쏘면 밑에 다 들림)
return (
<UserContext.Provider value={user}>
<Header />
</UserContext.Provider>
);
}
function Header() {
return <Nav />; // user props 안 넘겨도 됨! 깔끔!
}
function Nav() {
return <Profile />;
}
function Profile() {
// 3. 필요한 곳에서 바로 꺼내 쓰기 (텔레포트!)
const user = useContext(UserContext);
return <div>{user.name}</div>;
}
4) iOS 개발자를 위한 한 줄 요약 🍎
"Prop Drilling은 prepare(for segue:)로 데이터 계속 넘기는 노가다이고,
Provider는 SwiftUI의 @EnvironmentObject랑 똑같다!"
SwiftUI 해봤으면 @EnvironmentObject 알지? 뷰 계층 어디서든 데이터에 접근할 수 있게 해주는 거. React의 Context Provider가 바로 그 원조격이야!
5. HOC(Higher-Order Components)를 통한 컴포넌트 확장
HOC(Higher-Order Component), 한국말로는 **"고차 컴포넌트"**라고 해.
이름만 들으면 무슨 고차원 수학 같은데, 사실 개념은 "컴포넌트 튜닝 샵" 혹은 **"업그레이드 포장지"**야. 🎁
iOS 개발자니까 "상속(Inheritance)" 대신 "조합(Composition)"을 사용하는 패턴이라고 이해하면 제일 빨라.
1) 비유: 토니 스타크와 아이언맨 수트 🤖
- 일반 컴포넌트: 그냥 토니 스타크 (능력: 똑똑함).
- HOC: 아이언맨 수트 제작소.
- HOC를 거친 컴포넌트: 아이언맨 (능력: 똑똑함 + 하늘을 낢 + 미사일)
HOC는 컴포넌트를 받아서, 새로운 기능을 덧붙인(Wrapping) 새로운 컴포넌트를 뱉어내는 함수야.
2) 왜 써? (공통 기능 재사용)
네가 페이지를 100개 만들었는데, 그중 50개는 "로그인한 사람만 볼 수 있어야 해".
그럼 50개 파일마다 일일이 if (isLogin) ... 코드를 복붙할 거야? 끔찍하지?
이럴 때 **withAuth**라는 HOC(수트)를 하나 만들어서 50개 컴포넌트에 입혀버리는 거야.
[코드 예시]
// 1. HOC 정의 (튜닝 샵)
// 보통 이름이 'with'로 시작해.
function withAuth(Component) {
return function WrappedComponent(props) {
// 공통 로직: 로그인 안 했으면 쫓아냄
if (!localStorage.getItem('token')) {
return <div>로그인 하세요!</div>;
}
// 로그인 했으면 원래 컴포넌트 보여줌 (props도 그대로 전달)
return <Component {...props} />;
};
}
// 2. 원래 컴포넌트 (토니 스타크)
function MyPage() {
return <div>비밀스러운 내 정보 페이지</div>;
}
// 3. HOC 적용 (수트 입히기 -> 아이언맨 탄생)
const ProtectedPage = withAuth(MyPage);
export default ProtectedPage;
이제 <ProtectedPage />를 쓰면 자동으로 로그인 체크 기능이 포함돼서 나와.
3) iOS 개발자를 위한 비교 🍎
① BaseViewController (상속)
iOS에서는 보통 공통 기능을 넣으려고 BaseViewController를 만들고 다른 VC들이 이걸 상속받게(class MyVC: BaseVC) 하잖아?
React는 상속을 싫어해. 대신 HOC로 감싸서(Wrapping) 해결해.
② Swift의 고차 함수 (Higher-Order Function)
Swift의 map, filter 기억나?
- 함수를 파라미터로 받거나, 함수를 리턴하는 함수를 '고차 함수'라고 하지?
- HOC도 똑같아. 컴포넌트를 파라미터로 받아서 컴포넌트를 리턴하니까 '고차 컴포넌트'인 거야.
③ Decorator 패턴
디자인 패턴 중에 데코레이터 패턴 알지? 객체에 동적으로 책임을 추가하는 거. HOC가 딱 데코레이터 패턴의 구현체야.
4) 요즘 트렌드: "Hook에게 자리를 내어주는 중" 📉
사실 HOC는 **Hook이 나오기 전(옛날)**에 로직을 재사용하려고 썼던 주력 기술이야.
근데 Hook(useAuth)이 나오면서 HOC는 좀 덜 쓰이게 됐어. Hook이 훨씬 코드가 깔끔하거든.
- 로직 재사용: Hook이 짱 (useSomething)
- HOC는 언제 써?
- 어떤 라이브러리들은 아직 HOC를 제공함 (예: Redux의 connect, 스타일 라이브러리 등).
- "로직" 재사용보다는, 컴포넌트의 **"껍데기(UI 레이아웃)"**를 입힐 때 여전히 유용함.
요약
HOC는 컴포넌트를 인자로 받아서, 기능이 추가된 새 컴포넌트를 뱉어내는 함수다.
마치 'BaseViewController'를 만드는 것과 목적이 같지만, 상속이 아니라 '포장(Wrapping)'하는 방식이다!
6. 컴파운드 컴포넌트(Compound Components)
**Compound Components(컴파운드 컴포넌트)**는 이름 그대로 **"한 팀으로 움직이는 컴포넌트들"**이야. 🤝
가장 쉬운 비유는 HTML의 <select>와 <option> 태그야. 이 둘은 항상 같이 다니지?
<select>
<option value="apple">사과</option>
<option value="banana">바나나</option>
</select>
<select>가 대장이고 <option>들이 부하야. 사용자는 그냥 이렇게 선언만 하면, 클릭했을 때 목록이 열리고 닫히는 복잡한 로직은 얘네끼리 알아서 처리해.
React에서도 이렇게 **"부모와 자식이 암묵적으로 상태를 공유하면서 유기적으로 동작하는 패턴"**을 컴파운드 컴포넌트라고 해.
1) 왜 이게 필요해? (비교 체험)
네가 아코디언(Accordion) 메뉴를 만든다고 쳐보자.
😫 일반적인 방식 (Props 때려박기)
모든 걸 부모한테 다 넘겨줘야 해. 커스터마이징 하려면 옵션이 끝도 없이 늘어나.
<Accordion
title="제목"
content="내용입니다"
isOpen={isOpen}
onClick={handleClick}
titleColor="blue" // 디자인 바꾸려면 props 계속 추가됨...
/>
- 문제점: "제목을 내용 아래에 두고 싶어!" -> 불가능. 코드 수정해야 함. 유연성 빵점.
😎 컴파운드 컴포넌트 방식 (레고 조립)
내부 로직(열고 닫기)은 공유하되, 배치는 네 맘대로 할 수 있어.
<Accordion>
<Accordion.Header>제목 클릭해봐</Accordion.Header>
<Accordion.Body>
짜잔! 내용이 나왔습니다.
<button>버튼도 넣을 수 있지</button>
</Accordion.Body>
</Accordion>
- 장점:
- 유연함: Header랑 Body 순서를 바꿔도 되고, 중간에 다른 태그를 넣어도 됨.
- 깔끔함: isOpen이나 onClick 같은 상태를 일일이 넘겨줄 필요가 없음 (안에서 지들끼리 공유함).
2) 마법의 원리 🪄 (Context API의 재등장)
아까 배운 Context API(Provider) 기억나? 컴파운드 컴포넌트는 그걸 내부적으로 몰래 쓰고 있어.
- **부모(<Accordion>)**가 Context.Provider를 만들어서 isOpen, toggle 같은 상태를 방송해.
- **자식들(<Header>, <Body>)**은 useContext로 부모의 신호를 몰래 받아서 작동해.
- 사용자(개발자) 입장에서는 props를 안 넘겨도 되니까 마법처럼 보이지.
3) iOS 개발자를 위한 비유 🍎
이거 완전 SwiftUI 그 자체야! SwiftUI에서 Picker나 List 쓸 때 생각해봐.
// SwiftUI
Picker("과일 선택", selection: $selectedFruit) {
Text("사과").tag(0)
Text("바나나").tag(1)
}
여기서 Picker가 부모고 Text가 자식이지?
Text한테 "너는 클릭되면 Picker한테 0번이라고 알려줘"라고 델리게이트 연결하고 그러지 않잖아. 그냥 감싸기만 하면 알아서 동작하지?
이게 바로 컴파운드 컴포넌트 패턴이야.
- React: <Select><Option>...</Option></Select>
- SwiftUI: Picker { Text(...) }
[Q&A] 컴파운드 컴포넌트는 리액트에서 라이브러리 형태로 제공하는 거야? 아님 개발자가 직접 정의해서 사용해야해?
정답부터 말하자면 **후자(직접 정의)**야!
컴파운드 컴포넌트는 리액트가 설치하자마자 "자, 이거 써!" 하고 주는 기능(API)이 아니라, 우리가 **리액트의 기본 기능(Context API, Children)을 잘 조립해서 만드는 "디자인 패턴(설계 방식)"**이야.
마치 MVC 패턴이 iOS에서 제공하는 라이브러리가 아니라, 개발자가 코드를 짜는 방식인 것과 똑같아.
하지만 걱정 마! 요즘은 두 가지 방법으로 이 패턴을 만나게 될 거야.
1. 직접 만들기 (DIY 🛠️)
네가 회사에서 공통으로 쓸 **"우리 회사만의 멋진 탭 메뉴"**를 만들어야 한다면, 직접 이 패턴으로 코드를 짜야 해.
[어떻게 만드는데?]
아까 말한 Context API가 본드 역할을 해.
- Context 만들기: createContext로 데이터 통로를 뚫어.
- 부모 컴포넌트: Provider로 상태(State)를 공급해.
- 자식 컴포넌트: useContext로 부모 상태를 몰래 가져다 써.
- 묶어주기: Main.Item = Item 처럼 부모 객체에 자식을 달아줘서 점(.)으로 접근하게 해.
[간단한 코드 구조]
// 1. 본드(Context) 준비
const ToggleContext = React.createContext();
// 2. 부모 (Main)
function Toggle({ children }) {
const [on, setOn] = useState(false);
const toggle = () => setOn(!on);
// 자식들한테 on이랑 toggle 함수를 뿌려줌
return (
<ToggleContext.Provider value={{ on, toggle }}>
{children}
</ToggleContext.Provider>
);
}
// 3. 자식 (Sub) - 스위치 버튼
function ToggleOn({ children }) {
const { on } = useContext(ToggleContext); // 부모 상태 몰래 봄
return on ? children : null; // 켜졌을 때만 보여줌
}
function ToggleButton() {
const { toggle } = useContext(ToggleContext); // 부모 함수 몰래 가져옴
return <button onClick={toggle}>스위치</button>;
}
// 4. 합체! (이렇게 묶어주면 <Toggle.On> 처럼 쓸 수 있어)
Toggle.On = ToggleOn;
Toggle.Button = ToggleButton;
2. 남이 만든 거 쓰기 (라이브러리 📦)
사실 실무에서는 Headless UI, Radix UI, Chakra UI 같은 유명한 UI 라이브러리들이 이 패턴으로 컴포넌트를 만들어서 제공해.
너는 내부가 어떻게 생겼는지 몰라도 그냥 설명서 보고 조립만 하면 돼.
// Radix UI 라이브러리 예시 (이미 컴파운드 패턴으로 만들어져 있음)
import * as Accordion from '@radix-ui/react-accordion';
// 우린 그냥 가져다 쓰면 됨!
<Accordion.Root type="single">
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger>제목</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>내용</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
iOS 개발자를 위한 요약 🍎
- 컴파운드 컴포넌트: iOS의 Delegate Pattern이나 MVC처럼 개발자가 구현하는 **"설계 방식"**이다.
- Context API: 이 패턴을 구현하기 위해 사용하는 **도구(Tool)**다. (iOS의 Protocol이나 NotificationCenter 같은 도구)
- 실무:
- 커스텀 UI: 네가 직접 디자인 시스템을 구축해야 한다면 직접 구현해야 함.
- 일반 개발: 보통은 잘 만들어진 라이브러리를 가져다 쓰는데, 걔네가 이 패턴으로 되어 있음.
7. 프롭 결합
**Prop 결합 (Prop Combination)**은 이름만 들으면 거창해 보이지만, 사실 **"귀찮은 짐을 한 보따리로 싸서 한 번에 건네주는 기술"**이야. 🛍️
보통 "Prop Getters" 패턴이라고도 많이 불리는데, 반복되는 속성(Props)들을 미리 묶어두거나 합쳐서 코드를 획기적으로 줄이는 방법이지.
iOS 개발자라면 "공통 설정(Configuration) 적용하기" 정도로 생각하면 쉬워.
1) 문제 상황: 똑같은 거 계속 쓰기 귀찮아! 😫
네가 접근성(Accessibility)을 잘 지키는 토글 버튼을 만든다고 쳐보자.
이 버튼을 쓸 때마다 아래 속성들을 매번 적어야 한다면 어떨까?
// 😫 버튼 하나 만들 때마다 이거 다 적어야 함... (지옥)
<button
aria-pressed={isOn}
aria-controls="menu-list"
role="button"
tabIndex={0}
onClick={toggle}
className="btn-primary"
>
메뉴 열기
</button>
버튼이 10개면 저 긴 코드를 10번 복붙해야 해. 오타 나면 기능 고장 나고 난리 나겠지?
2) 해결책: Prop Combination (보따리 싸기) 🎁
이때 등장하는 게 Prop Combination 기술이야.
필요한 속성들을 객체(Object) 하나에 다 담아서(Combine), **스프레드 문법(...)**으로 한 방에 뿌리는 거지.
[1단계: 보따리 만드는 함수 만들기]
보통 이런 함수 이름은 get...Props로 짓는 게 국룰이야.
// 필요한 속성을 다 묶어서 주는 함수 (Prop Getter)
function getToggleButtonProps({ onClick, ...otherProps } = {}) {
return {
'aria-pressed': isOn, // 1. 필수 기능 (상태)
'role': 'button', // 2. 필수 접근성 속성
'tabIndex': 0,
onClick: (e) => { // 3. 기능 결합 (내 기능 + 니 기능)
console.log("기본 토글 동작 수행");
toggle(); // 라이브러리 내부 동작
if (onClick) onClick(e); // 사용자가 추가한 동작도 실행해줌!
},
...otherProps, // 4. 그 외 사용자가 넣은 스타일 등
};
}
[2단계: 사용하는 곳]
이제 사용자는 이렇게만 쓰면 돼.
// 😎 와! 그 길던 코드가 한 줄로 끝!
<button {...getToggleButtonProps({ className: "my-btn" })}>
메뉴 열기
</button>
저 ... (Spread Operator)가 보따리를 풀어서 button 태그 안에 속성들을 촤라락 펼쳐줘.
3) iOS 개발자를 위한 비유 🍎
이건 iOS에서 View를 설정하는 헬퍼 함수나 Modifier랑 비슷해.
[iOS (UIKit)]
매번 버튼 만들 때마다 setTitle, setTitleColor, layer.cornerRadius... 설정하기 귀찮으니까 이렇게 하잖아?
// iOS에서 공통 설정을 함수로 뺀 것과 비슷함
func configurePrimaryButton(_ button: UIButton) {
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 10
button.addTarget(self, action: #selector(tapped), for: .touchUpInside)
// ... 기타 등등 10줄 더
}
// 사용할 때
configurePrimaryButton(myButton)
React의 Prop Combination은 여기서 한 발 더 나아가서, **"이벤트(Action)까지 합쳐버린다"**는 특징이 있어.
"네가 onClick을 넣었어? 그럼 내 기본 클릭 동작(toggle) 먼저 하고, 그 다음에 네 클릭 동작(onClick)도 실행시켜 줄게!" (함수 체이닝)
[Q&A] Spread Operator 문법의 원리가 뭐야? 내부적으로 어떻게 동작해?
**Spread Operator (...)**가 그냥 "마법의 가루"처럼 보이지만, 내부적으로 까보면 철저한 **규칙(Protocol)**에 따라 돌아가는 녀석이야.
크게 1. 배열(Iterable)일 때와 2. 객체(Object)일 때 동작 원리가 조금 달라. iOS 개발자니까 Iterator Pattern과 메모리 복사 관점에서 설명해줄게.
1. 배열/문자열일 때: "Iterator Protocol의 승리"
배열([]), 문자열(""), Map, Set 같은 애들을 ...으로 펼칠 수 있는 이유는, 얘네가 **Iterable(순회 가능)**하기 때문이야.
JS 엔진 내부에서는 이렇게 동작해:
- "야, 너 순회 가능해?"
- 엔진은 대상 객체한테 [Symbol.iterator]라는 메서드가 있는지 확인해. (Swift로 치면 Sequence 프로토콜을 준수하는지 묻는 거야.)
- "그럼 하나씩 내놔봐." (Iterator 생성)
- iterator 객체를 만들고, next() 메서드를 계속 호출해.
- 반복 (Loop):
- next()가 { value: 값, done: false }를 리턴하면 값을 챙기고, { done: true }가 나올 때까지 반복해.
2. 객체일 때: "Shallow Copy (얕은 복사)"
객체({ key: value })는 순서가 없어서 Iterator가 없어. 그래서 방식이 좀 달라.
JS 엔진은 **"Own Enumerable Properties(직접 가지고 있고 열거 가능한 속성)"**만 찾아서 새 객체에 복사해.
- 새 빈 껍데기 생성: 메모리에 새로운 객체 공간({})을 하나 할당해.
- 속성 긁어모으기: 원본 객체의 키(Key)들을 하나씩 돌면서 값을 읽어와.
- 값 할당: 새 객체에 그 키와 값을 집어넣어.
3. 가장 중요한 함정: "얕은 복사 (Shallow Copy)" ⚠️
이게 제일 중요해. Spread Operator는 **깊은 복사(Deep Copy)**를 안 해줘.
- 원시값(Number, String): 값이 그대로 복사됨. (안전)
- 참조값(Object, Array): **메모리 주소(Reference)**만 복사됨. (위험!)
[메모리 관점]
const original = {
name: "Steve",
detail: { age: 20 } // 내부에 또 객체가 있음 (참조 타입)
};
const copy = { ...original };
// 1. name은 원시값이라 서로 영향 없음 (OK)
copy.name = "Job";
// 2. detail은 '주소'만 복사돼서, 둘이 같은 방을 쳐다봄 (공유됨!)
copy.detail.age = 99;
console.log(original.detail.age); // 😱 99 출력! (원본이 오염됨)
[iOS 비교 🍎]
Swift에서 클래스(Reference Type) 인스턴스를 변수에 대입하면 주소만 복사되는 거랑 같아.
만약 완벽하게 분리하고 싶으면 JS에선 structuredClone() 같은 깊은 복사 함수를 따로 써야 해.
요약: Spread Operator의 원리
- 배열: Iterator Protocol을 사용한다. next()를 계속 호출해서 값을 하나씩 꺼내 새 배열에 담는다. (시간 복잡도 $O(N)$)
- 객체: Key 순회를 사용한다. 속성을 하나씩 읽어서 새 객체에 옮겨 적는다.
- 주의: **얕은 복사(Shallow Copy)**다. 내부의 객체나 배열은 주소값만 복사되므로 원본과 연결이 끊기지 않는다.
즉, **"마법이 아니라 그냥 for문 돌려서 하나씩 옮겨 담는 노가다꾼"**이라고 생각하면 돼! 🧹
8. 리액트에서의 지연 로딩 컴포넌트
**지연 로딩(Lazy Loading)**은 이름 그대로 "게으르게 로딩하기", 다시 말해 **"필요할 때까지 최대한 미루다가 그때서야 가져오는 기술"**이야. 🛌
React 앱을 처음 켤 때, 사용자가 당장 보지도 않을 "설정 페이지"나 "무거운 차트 라이브러리"까지 다 다운로드하면 시간이 엄청 오래 걸리겠지?
그래서 "지금 당장 필요한 것만 먼저 받고, 나머지는 나중에(Lazy) 받자!" 하는 전략이야.
1) 비유: 넷플릭스와 뷔페 🍿
- 일반 로딩 (Eager Loading): 2시간짜리 영화 파일을 내 컴퓨터에 100% 다 다운로드한 뒤에야 재생 버튼이 눌리는 것. (기다리다 숨 넘어감 😱)
- 지연 로딩 (Lazy Loading): 넷플릭스처럼 **앞부분 1분만 먼저 다운로드(버퍼링)**해서 보여주고, 나머지는 보면서 천천히 받아오는 것.
React에서는 이걸 **코드 분할 (Code Splitting)**이라고 불러. 거대한 하나의 자바스크립트 파일(bundle.js)을 여러 조각(chunk.js)으로 쪼개는 거지.
2) 어떻게 쓰는데? (React.lazy & Suspense)
문법은 아주 간단해. import를 함수처럼 쓰면 돼.
[옛날 방식 (무조건 다 가져옴)]
import HeavyChart from './HeavyChart'; // 앱 켜지자마자 다운로드됨
function App() {
return <HeavyChart />;
}
[지연 로딩 방식 (필요할 때 가져옴)]
여기서 **Suspense**라는 애가 등장하는데, 얘가 아주 중요해.
네트워크로 코드를 받아오는 동안(로딩 중) 보여줄 **대타(Fallback UI)**를 지정해주는 녀석이야.
import React, { Suspense } from 'react';
// 1. lazy 함수로 감싸기 (이제 이건 나중에 필요할 때 네트워크 요청함)
const HeavyChart = React.lazy(() => import('./HeavyChart'));
function App() {
return (
<div>
<h1>차트 보러 가기</h1>
{/* 2. Suspense로 감싸기: "야, 차트 파일 받아오는 동안은 스피너 보여줘!" */}
<Suspense fallback={<div>로딩 중입니다... 🌀</div>}>
<HeavyChart />
</Suspense>
</div>
);
}
이제 사용자가 <HeavyChart />가 있는 화면에 진입하는 순간, 브라우저는 그때서야 HeavyChart.js 조각을 서버에 요청해서 받아와.
3) iOS 개발자를 위한 비유 🍎
이건 Swift의 **lazy var**랑 개념이 완전 똑같아!
[Swift]
// 이 뷰컨트롤러가 만들어질 때 complexView는 메모리에 안 올라감.
// 실제로 complexView에 접근(.view)하는 순간에 메모리에 할당됨.
lazy var complexView: ComplexView = {
return ComplexView()
}()
React의 lazy도 마찬가지야.
- 초기 로딩 속도 향상: iOS 앱 켤 때 AppDelegate에서 모든 뷰컨트롤러를 미리 다 초기화하지 않잖아? 메인 화면만 띄우지?
- 메모리/네트워크 절약: 사용자가 "설정" 탭을 누르지 않으면, 설정 화면 관련 코드는 평생 다운로드 안 받아도 됨.
9. 제어된 입력(Controlled Inputs)
제어 입력 (Controlled Inputs), 혹은 **제어 컴포넌트 (Controlled Components)**라고 부르는 건 리액트 폼(Form) 다룰 때 가장 기본이 되는 개념이야.
한마디로 정의하자면 **"입력창의 내용(Value)을 브라우저가 아니라, React의 State가 100% 통제하는 것"**이야.
마치 "꼭두각시 인형" 같아. 입력창(Input)은 스스로 아무것도 못 보여줘. 오직 주인이 시키는 대로만 움직여. 🎭
1) 원리: 데이터의 흐름 (단방향)
일반적인 HTML(비제어)과 React의 제어 입력은 작동 방식이 완전히 달라.
- 일반 HTML Input (방목형 🐑):
- 내가 'A'를 치면, 입력창이 스스로 'A'를 화면에 보여줌.
- 나중에 필요할 때 document.getElementById('input').value로 값을 가져옴.
- React Controlled Input (통제형 👮♂️):
- 내가 'A'를 치면, 화면에 바로 안 보여줘.
- **"나 'A'라고 치고 싶은데요?"**라고 React한테 보고(onChange)함.
- React가 State를 업데이트함 (setState('A')).
- React가 다시 렌더링하면서 Input한테 명령함. "자, 이제 'A'라고 표시해!" (value={state})
2) 코드 구조
코드를 보면 "아, 이래서 제어라고 하는구나" 싶을 거야.
function NameInput() {
const [name, setName] = useState(""); // 1. 진실의 원천 (Source of Truth)
const handleChange = (e) => {
// 3. 입력 요청이 들어오면 State를 업데이트
setName(e.target.value.toUpperCase()); // (소문자로 쳐도 대문자로 바꿔버릴 수 있음!)
};
return (
<input
value={name} // 2. 화면에 보여주는 값은 무조건 State를 따름
onChange={handleChange} // 3. 변경 요청 이벤트
/>
);
}
여기서 value={name}이 핵심이야. 이걸 적는 순간, State가 바뀌지 않으면 사용자가 키보드를 아무리 두들겨도 화면에 글자가 안 찍혀. React가 "안 돼, 돌아가" 하고 막아버리거든.
3) iOS 개발자를 위한 비유 🍎
이거 완전 SwiftUI의 @State와 Binding 관계랑 똑같아!
[SwiftUI]
struct ContentView: View {
@State private var text: String = "" // Source of Truth
var body: some View {
// TextField가 text 변수를 "바인딩" 하고 있지?
// text 변수가 바뀌면 TextField도 바뀌고, 반대도 성립함.
TextField("입력해", text: $text)
}
}
SwiftUI에서 $text를 넘겨주는 게 바로 React의 Controlled Input 패턴이야. 뷰(TextField)는 그냥 text 변수의 현재 상태를 거울처럼 비춰줄 뿐이지.
또는 UIKit의 UITextFieldDelegate에서 shouldChangeCharactersIn 메서드 기억나?
거기서 return false 하면 입력 안 되잖아?
React에서는 onChange에서 setState를 안 해주면 입력이 안 돼. 그만큼 강력한 통제권이 개발자한테 있는 거야.
[Q&A] React 내부에 이미 바인딩 되어있는거야?
아니, 결론부터 말하면 자동으로 되어있지 않아! 🙅♂️
SwiftUI를 쓰다 왔으면 이 부분이 조금 귀찮게 느껴질 수 있어.
SwiftUI의 TextField(text: $name)에서 $(Binding) 기호 하나가 해주는 일을, 리액트에서는 개발자가 직접 두 단계로 나눠서 작성해줘야 해.
이걸 리액트에서는 "단방향 데이터 흐름 (One-Way Data Flow)" 원칙 때문이라고 해.
1. SwiftUI vs React 비교
가장 직관적으로 비교해줄게.
[SwiftUI: 자동 (Two-way Binding)]
$를 붙이면, (1)화면에 보여주는 것과 (2)입력받아 수정하는 것이 한 방에 해결됨.
// SwiftUI는 알아서 북치고 장구치고 다 함
TextField("이름", text: $name)
[React: 수동 (One-way + Event)]
React는 "읽기"와 "쓰기"를 철저하게 분리해.
- 읽기 (value): State를 가져와서 보여줌.
- 쓰기 (onChange): 변경사항이 생기면 State를 업데이트해달라고 요청함.
// React는 네가 직접 연결해줘야 함
<input
value={name} // 1. 읽기 (State -> View)
onChange={(e) => setName(e.target.value)} // 2. 쓰기 (View -> State)
/>
그래서 코드를 짤 때 value만 넣고 onChange를 안 넣으면, 키보드를 아무리 쳐도 글자가 안 써지는 "먹통 입력창"이 되는 거야. (읽기 전용이 되어버리니까!)
아래 이미지는 React의 단방향 데이터 흐름과 다른 프레임워크의 양방향 바인딩 차이를 보여줘.
2. 왜 이렇게 불편하게 만들었어? 🤔
Vue.js나 Angular 같은 다른 웹 프레임워크는 SwiftUI처럼 양방향 바인딩(v-model)을 지원해. 근데 React는 굳이 왜 이걸 수동으로 하게 했을까?
**"데이터의 흐름을 추적하기 쉽게 만들기 위해서"**야.
- 디버깅의 명확성: 만약 입력창의 값이 이상하게 변했다?
- 양방향: "누가 바꾼 거야? 뷰야? 모델이야?" 헷갈림.
- React: "범인은 무조건 setName을 호출한 놈이다." 추적이 쉬움.
마치 iOS에서 KVO(Key-Value Observing)나 NotificationCenter 남발하면 데이터가 어디서 튀는지 모르는 지옥이 펼쳐지는 걸 막으려는 의도랑 비슷해.
3. 실무 팁: "너무 귀찮은데요?"
"폼이 100개면 onChange 함수를 100개 만들어야 해?"
당연히 귀찮지! 그래서 실무에서는 보통 두 가지 방법을 써.
- 커스텀 훅 (Prop Combination): 아까 배운 거! useInput 같은 훅을 만들어서 로직을 재사용해.
- 라이브러리 사용 (Form Library): **React Hook Form**이라는 엄청 유명한 라이브러리가 있어. 이걸 쓰면 SwiftUI처럼 거의 자동으로 바인딩해주고, 유효성 검사까지 쉽게 처리해줘.
요약
React에는 '자동 바인딩($)' 기능이 내장되어 있지 않다.
State를 꽂아주는 것(Output)과 setState로 값을 바꾸는 것(Input)을 개발자가 명시적으로 연결해야 한다!
(이게 React의 철학인 단방향 데이터 흐름이다.)
10. 리액트에서의 오류 경계 패턴
**오류 경계(Error Boundary)**는 리액트 앱이 완전히 뻗어버리는 걸 막아주는 **"안전장치(Safety Net)"**야. 🛡️
앱의 특정 부분에서 에러가 터졌을 때, 화면 전체가 하얗게 변하는(White Screen of Death) 대참사를 막고, "잠시 문제가 발생했어요" 같은 예쁜 안내 문구를 대신 보여주는 패턴이지.
1) 왜 필요해? (대참사 방지)
네가 쇼핑몰 앱을 만들었다고 쳐보자.
상품 리스트 컴포넌트 안에 있는 상품 하나가 데이터가 꼬여서 에러가 났어.
- Error Boundary 없을 때:
- 상품 하나 때문에 앱 전체가 하얗게 변하고 멈춤.
- 사용자: "뭐야 이 앱? 삭제해." 🤬
- Error Boundary 있을 때:
- 문제 있는 그 상품 카드 하나만 "에러 발생" UI로 바뀜.
- 나머지 헤더, 푸터, 다른 상품들은 멀쩡하게 잘 작동함.
- 사용자: "어? 이것만 안 나오네. 다른 건 되네." 🙂
마치 집에 **두꺼비집(누전 차단기)**이 있는 거랑 똑같아. 주방에서 합선됐다고 안방 불까지 다 꺼지면 안 되잖아? 주방 전기만 딱 끊어주는 거지.
2) 코드로 보면? (특별한 클래스 컴포넌트)
이건 아직 Class Component로만 만들 수 있어. (Hook은 아직 없음)
componentDidCatch 같은 생명주기 메서드를 쓰거든.
class ErrorBoundary extends React.Component {
state = { hasError: false };
// 1. 에러가 나면 State를 업데이트해서 UI를 바꿈
static getDerivedStateFromError(error) {
return { hasError: true };
}
// 2. 에러 로그를 기록함 (Sentry 같은 곳으로 전송)
componentDidCatch(error, info) {
console.log("에러 잡았다!", error, info);
}
render() {
if (this.state.hasError) {
// 3. 에러 났을 때 보여줄 대체 UI (Fallback UI)
return <h1>앗! 뭔가 잘못됐어요. 😭</h1>;
}
// 4. 평소에는 자식 컴포넌트를 그대로 보여줌
return this.props.children;
}
}
[사용법: 감싸주기만 하면 됨]
<ErrorBoundary>
<ProfileCard />
</ErrorBoundary>
이제 <ProfileCard> 안에서 스크립트 에러가 터지면, ErrorBoundary가 그걸 낚아채서 대체 화면을 보여줘.
3) iOS 개발자를 위한 비유 🍎
iOS 개발할 때 앱 죽는 거(Crash) 진짜 싫지?
- iOS의 상황:
- Array 인덱스 잘못 접근하면(Index out of range) 앱이 그냥 SIGABRT 뜨면서 꺼져버리잖아(Crash). 이걸 막으려면 코드마다 do-catch를 꼼꼼히 하거나 방어 코드를 짜야 하지.
- React의 Error Boundary:
- 이건 마치 **"특정 UIView 하나가 크래시 나도, 앱은 안 꺼지고 그 뷰만 빨간색 'Error View'로 바뀌게 만드는 마법"**이야.
"네비게이션 스택의 한 화면이 터졌는데, 앱이 안 죽고 '오류 화면'만 띄우고 사용자는 뒤로 가기를 누를 수 있는 상황"
이라고 생각하면 이해가 빠를 거야.
4) 주의할 점 (못 잡는 놈들) ⚠️
이게 만능은 아니야. **"렌더링(Rendering) 도중"**에 일어난 에러만 잡아.
아래 같은 건 못 잡아.
- 이벤트 핸들러 (onClick 같은 함수 내부 에러) -> 이건 그냥 try-catch 써야 함.
- 비동기 코드 (setTimeout, fetch 등 API 호출) -> 이것도 .catch()로 따로 잡아야 함.
- 서버 사이드 렌더링(SSR)
5) 실무 팁 (라이브러리 쓰자)
"아니, 요즘 다 함수형 컴포넌트(Hook) 쓰는데 언제 class 만들고 앉아있어?"
그래서 보통은 react-error-boundary 라는 라이브러리를 써.
import { ErrorBoundary } from 'react-error-boundary'
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>에러 났슈:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)
}
// 이렇게 깔끔하게 씀!
<ErrorBoundary FallbackComponent={ErrorFallback}>
<MyComponent />
</ErrorBoundary>
요약
- Error Boundary: 컴포넌트 트리의 부분적인 에러가 앱 전체를 망가뜨리는 걸 막는 방화벽.
- 역할: 에러를 포착하고(Catch), 로그를 남기고(Log), 대체 UI(Fallback)를 보여준다.
- iOS 비유: 뷰 하나가 터져도 앱은 살아있게 만드는 "안전 컨테이너 뷰".
- 팁: 직접 구현하기 귀찮으니 react-error-boundary 라이브러리를 많이 쓴다.
11. forwardRefs로 커스텀 컴포넌트 관리
forwardRef는 한마디로 **"자식의 내장 기관(DOM)을 부모가 직접 만질 수 있게 허락해주는 통행증"**이야. 🎫
원래 리액트 세계관에서 부모 컴포넌트는 자식 컴포넌트의 내부 사정(DOM)을 몰라야 정상이야. (캡슐화 원칙)
근데 가끔 부모가 자식 안에 있는 input 창에 포커스를 주거나, 스크롤을 옮겨야 할 때가 있잖아? 이때 쓰는 기술이야.
1) 문제 상황: "야, 그 리모컨 좀 줘봐!"
네가 CustomInput이라는 예쁜 컴포넌트를 만들었어.
부모 컴포넌트(App)에서 버튼을 누르면, 저 CustomInput 안에 있는 진짜 input 태그에 포커스를 딱 주고 싶어.
[실패하는 코드]
// 자식: 예쁜 인풋
function CustomInput(props) {
return <input className="pretty-input" {...props} />;
}
// 부모
function App() {
const inputRef = useRef(null);
// ❌ 에러 발생!
// 리액트: "함수형 컴포넌트는 ref를 못 받아요! 내장 기관 함부로 건드리지 마세요!"
return <CustomInput ref={inputRef} />;
}
리액트에서는 props는 물 흐르듯 내려가지만, ref는 특별 취급이라서 컴포넌트를 통과하지 못하고 튕겨 나가.
2) 해결책: forwardRef (중계기 설치)
이때 CustomInput을 **forwardRef**라는 함수로 감싸주면 해결돼.
말 그대로 **"Ref를 (자식한테) 전달(Forward)해준다"**는 뜻이야.
[성공하는 코드]
import { forwardRef, useRef } from 'react';
// 1. forwardRef로 감싸기!
// (props, ref) 두 개의 인자를 받게 됨.
const CustomInput = forwardRef((props, ref) => {
// 2. 부모가 준 ref를 진짜 input 태그에 연결(Toss!)
return <input ref={ref} className="pretty-input" {...props} />;
});
function App() {
const inputRef = useRef(null);
const handleFocus = () => {
// 3. 이제 부모가 자식의 input을 직접 조종 가능!
inputRef.current.focus();
};
return (
<>
<CustomInput ref={inputRef} />
<button onClick={handleFocus}>포커스 주기</button>
</>
);
}
이제 부모는 CustomInput 껍데기를 뚫고, 그 안에 있는 실제 <input> 태그를 inputRef.current로 잡아서 조종할 수 있어.
3) iOS 개발자를 위한 비유 🍎
이건 iOS에서 Custom View 안에 있는 TextField에 접근하는 상황이랑 똑같아.
[iOS 상황]
네가 MyCustomView를 만들었는데, 그 안에 private let textField = UITextField()가 숨어 있어.
근데 ViewController에서 "버튼 누르면 키보드 올라오게 해줘(becomeFirstResponder)"라고 하고 싶어.
- 그냥 접근 (실패): myCustomView.becomeFirstResponder() 해봤자 내부 텍스트필드는 반응 안 함.
- forwardRef 방식 (성공): MyCustomView가 내부의 textField를 밖으로 노출(Expose)시켜 주거나, 대리자(Delegate) 메서드를 열어주는 것.
"React의 ref는 iOS의 Outlet 변수랑 비슷해. forwardRef는 부모의 IBOutlet을 자식의 내부 View(UIView)에 꽂을 수 있게 구멍을 뚫어주는 거야."
4) 언제 써? (주의사항)
이거 너무 많이 쓰면 안 좋아. 리액트의 "단방향 데이터 흐름" 철학을 깨는 거거든. (부모가 자식을 직접 조종하니까)
✅ 써야 할 때 (Imperative Actions)
- 포커스 제어: 입력창 활성화.
- 스크롤 제어: 특정 위치로 스크롤 튕기기.
- 크기/위치 측정: getBoundingClientRect() 같은 거 써야 할 때.
- 애니메이션: DOM을 직접 건드려야 하는 복잡한 애니메이션.
❌ 쓰지 말아야 할 때
- 데이터를 전달하거나 상태를 바꿀 때. (이건 그냥 Props나 State 써!)
요약
forwardRef는 부모 컴포넌트가 자식 컴포넌트 내부에 있는 실제 HTML 요소(DOM)를 직접 만져야 할 때, ref를 통과시켜주는 "터널" 같은 역할이다!
'React' 카테고리의 다른 글
| React 코드가 브라우저에서 실행되기까지의 과정 (1) | 2026.01.12 |
|---|