props drilling
props drilling은 props를 깊게 파내려간다는 의미로, 어떤 부모 컴포넌트가 데이터를 가지고 있는데
그 데이터를 실제로 필요로 하는 컴포넌트가 트리의 아랫쪽, 더 깊은 자식 컴포넌트에 있는 경우가 있습니다.
그러면 그 사이에 있는 중간 자식 컴포넌트들이 이 데이터를 직접 사용하지 않더라도
어쩔 수 없이 그 값을 계속 전달해야 하는 상황이 발생합니다.

실제 예시를 살펴보기 위해 다음과 같은 컴포넌트 구조를 상상해 봅니다.

루트 컴포넌트인 App에 사용할 데이터를 소유하고 있고, 4단계 자식 컴포넌트인 UserName에서 사용해야 합니다.
이 같은 경우는 전역 데이터로 흔히 발생하는 경우입니다.
중간에 있는 Layout, Sidebar, UserPanel은 자신이 활용하지 않을 props를 억지로 받고 전달하는 전달자 역할을 합니다.
이런 전역 데이터들이 여러 개라면, 모든 중간 컴포넌트들이 필요없는 데이터를 들고 내려가야 합니다.
중간 컴포넌트는 자신이 필요하지 않은 정보까지 전달해야 하기 때문에 점점 본래의 역할과 무관한 형태로 변합니다.
즉, 실제 역할과 상관없는 props들로 가득차게 됩니다.
이 현상은 중간 컴포넌트들을 재사용 가능하기 어려울 뿐만 아니라
새로운 전역 데이터가 추가될때마다 Tree 전체의 props를 수정해야 하는 문제를 일으킵니다.
이 내용에 따르면, props drilling은 코드가 보기 안 좋을 뿐 아니라, 유지보수성 및 재사용성을 크게 떨어뜨립니다.
중간 컴포넌트의 인터페이스는 불필요하게 복잡해지고, 데이터의 추적이 어렵습니다.
이제 이러한 문제를 해결하기 위한 새로운 전달 방식인 ContextAPI가 등장합니다!
전역 상태 저장소(ContextAPI)
하나의 데이터가 시스템 전체에 영향을 미치는 전체 시스템이 공유하는 값, 이것이 전역 상태입니다.
이 전역 상태를 하나의 저장소에서 꺼내쓰는 시스템이 ContextAPI인 것입니다.
내부 구조
우선 ContextAPI를 구성하는 핵심 요소들을 살펴보면, Context 객체 자체와 Provider와 Consumer가 있습니다.
ContextAPI는 createContext()를 통해 데이터를 담기 위한 틀을 만들고, 실제 이 안에서 Provider가 생명을 공급합니다.
여기서 안전장치로, createContext()를 호출할 때 초기값을 넣는 기능도 제공합니다. Provider에서의 예외 처리입니다.
실제 코드 구조를 보면, 먼저 Context.Provider를 사용해 value 속성에 전달할 객체 혹은 값을 넣습니다.
그것을 폴더 최상위에서 감싸면, 직접 전달되지 않더라도 각 컴포넌트가 필요할 때 언제든지 꺼내 쓸 수 있게 됩니다.
import { createContext } from 'react';
const NoticeContext = createContext("아무것도 없습니다.")
export default function App() {
return (
<NoticeContext.Provider value="전체 단수 점검 안내 (오후 2시 ~ 4시)">
<BuildingA />
<BuildingB />
<Playground />
</NoticeContext.Provider>
)
}이제 이렇게 공급된 데이터를 useContext를 통해 각 컴포넌트에서 실제로 사용할 수 있습니다.
각자 다른 컴포넌트에서도 Provider 내부에 있으면, 시스템이 제공하는 공통된 기능을 사용할 수 있는 것입니다.
예시로 주민센터 시스템 Provider를 구현해봅시다.
const CenterContext = createContext();
function CenterProvider({ children }) {
const [lostItems, setLostItems] = useState([])
const reportLost = (item) => setLostItems(prev => [...prev, item])
const claimItem = (item) => setLostItems(prev => prev.filter(i => i != item))
const value = { lostItems, reportLost, claimItem }
return (
<CenterContext.Provider value={value}>
{children}
</CenterContext.Provider>
)
}실제 이 기능을 사용하는 Consumer 컴포넌트의 예시를 봅시다.
export default function LostAndFoundDesk() {
const { lostItems, reportLost, claimItem } = useContext(CenterContext)
return (
<>
{lostItems.map(item => <div>{item}</div>)}
</>
)
}이를 통해 알 수 있는 것은 ContextAPI는 단순히 props drilling 해결을 넘어서
하나의 통합된 시스템으로 전역적 데이터와 기능을 제공하는 강력한 역할을 제공합니다.
재구성 - Context 기반 구조로 재구성하기
기존에 props drilling 방식을 사용하던 Tree를 Context 기반으로 재구성 해봅니다.
기존의 비효율적인 코드를 살펴보면 다음과 같습니다.
function App() {
const userName = 'chulsoo'
return <Layout userName={userName} />
}
function Layout({ userName }) {
return <Sidebar userName={userName} />
}
function Sidebar({ userName }) {
return <UserPanel userName={userName} />
}
function UserPanel({ userName }) {
return <UserName userName={userName} />
}
function UserName({ userName }) {
return <p>Welcome, {userName}</p>
}이제 위 코드를 Context Based Code로 교체해봅시다.
Step 1. 글로벌 데이터 통로 생성하기 (UserContext)
import { createContext } from 'react'
export const UserContext = createContext()Step 2. 데이터 공급자 구축 (UserProvider)
import { UserContext } from './UserContext'
export default function UserProvider({ children }) {
const userName = 'chulsoo'
return (
<UserContext.Provider value={userName}>
{children}
</UserContext.Provider>
)
}Step 3. 애플리케이션 최상위에 Provider로 감싸기
import UserProvider from './contexts/UserProvider'
import Layout from './components/Layout'
export default function App() {
return (
<UserProvider>
<Layout />
</UserProvider>
)
}Step 4. useContext로 데이터에 직접 접근
import { useContext } from 'react'
import { UserContext } from '../contexts/UserContext'
function UserName() {
const userName = useContext(UserContext)
return <p>Welcome, {userName}</p>
}Context + Reducer의 전역 상태 엔진
우리는 ContextAPI 구조를 배웠지만 실무에서는 더 복잡한 데이터 상태 구조를 경험하게 됩니다.
이 때 필요한 것이 또 useReducer Hook입니다.
두 개념을 합쳐 ContextAPI는 전달용 파이프로, useReducer는 상태 변경 두뇌로 적용합니다.
먼저 Counter Application을 예시로 살펴봅니다.
Step 1. 컨텍스트 그 자체 (순수한 데이터 통로 생성)
import { createContext } from 'react'
export const CounterContext = createContext()Step 2. 두뇌를 수행할 리듀서 만들기
export default function counterReducer(state, action) {
if (action.type === 'increment') {
return state + 1
}
if (action.type === 'decrement') {
return state - 1
}
return state
}Step 3. useReducer 사용하여 상태와 두뇌를 연결 및 컨텍스트 제공자 만들기
import { useReducer } from 'react'
import counterReducer from './counterReducer'
import CounterContext from './counterContext'
export default function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, 0)
return (
<CounterContext.Provider value={{ state, dispatch }}>
{children}
</CounterContext.Provider>
)
}Step 4. Provider 적용하기
import CounterProvider from './contexts/CounterProvider'
import CounterScreen from './components/CounterScreen'
export default function App() {
return (
<CounterProvider>
<CounterScreen />
</CounterProvider>
)
}Step 5. 전역 상태 소비하기
import { useContext } from 'react'
import CounterContext from '../contexts/CounterContext'
function CounterScreen() {
const { state, dispatch } = useContext(CounterContext)
return (
<div>
<h1>Count: {state}</h1>
<button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
<button onCLick={() => dispatch({ type: 'decrement' })}>Decrement</button>
</div>
)
}실전 예시 - 인증 컨텍스트
위에서 배운 전역 상태 엔진을 인증 시스템에 이식해보는 과정을 배웁니다.
로그인이 진행중인 대기 상태와 로그인이 성공했을 때의 데이터 저장, 로그인이 실패했을 때 오류 처리까지
모두 아우르는 복잡한 상태 흐름을 담게 됩니다.
이를 위해, 우리 시스템의 두뇌가 될 authReducer의 의사결정 회로를 설계해야 하는데
먼저 상태 변화의 흐름을 그려봅니다.

1. 로그인 시작 액션 -> 로그인 시도를 의미합니다. isLoading 상태를 참으로 만듭니다. 나머지는 비웁니다.
2. 로그인 성공 액션 -> 로그인 성공을 의미합니다. isLoading 상태를 false로 만들고, user 정보를 저장합니다.
3. 로그인 실패 액션 -> 로그인 실패를 의미하고, isLoading 상태를 false로 만들고, error 정보를 저장합니다.
4. 로그아웃 액션 -> 모든 정보를 지우고 초기 상태로 돌아가며, 다시 로그인 페이지로 보내라는 신호를 의미합니다.
이런 흐름을 바탕으로, 이 정보를 전역으로 퍼트려줄 AuthProvider를 구축해보겠습니다.
useReducer를 활용하여 앞서 만든 인증 로직에 따른 상태값을 저장합니다.
AuthProvider에는 유저 정보, 로딩 여부, 에러 메시지, 디스패치 함수를 데이터로 저장합니다.

리듀서(Reducer) 내부에서는 switch 문을 사용하여 경우에 따라 규칙을 정의합니다.
export default function authReducer(state, action) {
switch(action.type) {
case "LOGIN_START":
return { user: null, isLoading: true, error: null }
case "LOGIN_SUCCESS":
return { user: action.payload, isLoading: false, error: null }
case "LOGIN_FAILURE":
return { user: null, isLoading: false, error: action.payload }
case "LOGOUT":
return { user: null, isLoading: false, error: null }
default:
return state
}
}이제 AuthProvider에 useReducer 통해 상태와 리듀서 함수를 연결합니다.
import { useReducer } from 'react'
import authReducer from './authReducer'
import AuthContext from './AuthContext'
export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isLoading: false,
error: null
})
return (
<AuthContext.Provider value={{...state, dispatch }}>
{children}
</AuthContext.Provider>
)
}이제 Login 버튼 컴포넌트에 이벤트 핸들러를 연결합니다.
import { useContext } from 'react'
import AuthContext '../contexts/AuthContext'
export default function LoginButton({ email, password }) {
const { dispatch, user, isLoading, error } = useContext(AuthContext)
const click = async () => {
dispatch({ type: "LOGIN_START" })
try {
// 예시 API 호출
const res = await fetch('https://api.com/login', {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email, password })
})
dispatch({ type: "LOGIN_SUCCESS", payload: res.data.userInfo })
navigateTo("/main")
} catch {
dispatch({ type: "LOGIN_FAILURE", payload: "로그인에 실패했습니다."})
}
}
return (
<button onClick={click} disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
)
}Context 분리와 리 랜더링 최적화
ContextAPI를 사용하게 될 때 마주하게 되는 불필요한 리 랜더링 문제를 분석해보겠습니다.
기본적으로 ContextAPI의 value로 전달되는 객체들 중 단 하나의 값이라도 변한다면
해당 컨텍스트를 구독하고 있는 모든 컴포넌트는 무조건적인 리 랜더링을 발생시킵니다.
이것은 앱이 복잡해질수록 치명적인 성능 저하를 야기시킵니다.

이것의 근본적인 원은 value 객체 안에 상태와 함수를 한꺼번에 묶어 전달하기 때문인데,
우리는 이를 해결하기 위해 상태 통로, 명령 통로를 엄격히 분리해야 합니다.
이것을 위해 2개의 컨텍스트를 생성하고 Provider를 중첩하여 감싸야 합니다.
import { createContext, useReducer, useMemo } from 'react'
export const AuthStateContext = createContext()
export const AuthDispatchContext = createContext()
export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isLoading: false,
error: null
})
const memorizedState = useMemo(() => state, [state]) // 메모지에이션
return (
<AuthDispatchContext.Provider value={dispatch}>
<AuthStateContext.Provider value={memorizedState}>
{children}
</AuthStateContext.Provider>
</AuthDispatchContext.Provider>
)
}다중 전역 상태 엔진
하나의 애플리케이션 안에는 사용자 정보를 담는 엔진 뿐만 아니라,
업무 목록을 다루거나 상품 정보를 처리하는 등 제각각 독립적인 역할을 수행하는 엔진이
동시에 평화롭게 공존해야 합니다.

이러한 다중 전역 상태 엔진이 서로를 간섭하지 않으면서도 하나의 앱 안에서
어떻게 유기적으로 맞물려 돌아가는지 구체적인 설계 기법에 대해 알아보겠습니다.
이제 구현할 시스템의 핵심 철학은 각 도메인 별로 자치권을 부여하는 것입니다.
React 생태계에서, 인증 정보가 바뀌어 리 랜더링이 발생한다고 해서
할일 목록 컴포넌트에 영향이 가선 안됩니다.
이를 위해 각 도메인마다 독립적인 Provider와 Reducer를 구현합니다.
즉, 업무(Todo)를 담당하는 엔진과 인증(Auth)를 담당하는 엔진을 완전히 분리합니다.

이 설계도를 토대로 Provider 계층 구조를 설계할 때 계단식으로 층층이 쌓아올리는 것입니다.
여기서 기억해야 할 점은 Provider의 계층 구조 순서가 데이터의 의존성을 결정한다는 것입니다.
만약 업무(Task) 로직 안에서 로그인(Auth)한 정보를 참조해야 하는 상황이라면
Auth - Task 순서로 계층 구조를 설계해야 합니다.
이러한 다중 엔진 구조가 실제로 사용되는 모습을 살펴보겠습니다.
// TodoInput.js
import { useContext } from 'react'
import { AuthStateContext } from '../contexts/AuthContext'
import { TodoDispatchContext } from '../contexts/TodoContext'
function TodoInput() {
// 인증 엔진의 데이터 채널에서 유저 정보를 가져옵니다.
const { user } = useContext(AuthStateContext)
// 업무 관리 엔진의 명령 채널에서 기능을 가져옵니다.
const todoDispatch = useContext(TodoDispatchContext)
const handleSubmit = (text) => {
if (!user) {
alert("로그인이 필요한 서비스입니다.")
return
}
todoDispatch({ type: "ADD_TODO", payload: text })
}
}인증 엔진(Auth Engine)에서 로그인 정보를 가져오고, 업무 엔진(Task Engine)에서 기능을 가져옵니다.
그리고, 로그인 정보를 이용해 로그인 정보가 있을 때만 기능이 작동하도록 트리거합니다.
여기서 그동안 배운 채널 분리 전략이 통하는 순간입니다.
만약 유저 정보 및 로그인 정보 상태에 변화가 생겨 리 랜더링 되더라도, todoDispatch에는 영향을 받지 않습니다.

하지만 이 아키텍처의 단점이라고 한다면, 컴포넌트마다 매번 useContext를 불러오고
어떤 Context 파일을 가져와야 할지 고민하는 과정이 반복적이고 번거롭다는 점입니다.
이제 이러한 코드들을 커스텀 훅(Custom Hooks)을 통해 추상화하겠습니다.
커스텀 훅을 통한 추상화 전략
내부의 복잡한 구현 사항은 꽁꽁 숨기고, 사용자에게는 편리한 도구만을 제공하는
일종의 인터페이스를 구축하는 점입니다. 이것이 Custom Hooks의 사용 이유입니다.
1. useAuth
먼저, useAuthState를 정의해 useContext로 AuthStateContext를 불러옵니다.
export function useAuthState() {
const context = useContext(AuthStateContext)
if(context === undefined) {
throw new Error("useAuthState는 반드시 AuthProvider 안에서만 사용해야 합니다.")
}
return context
}마찬가지로, 명령 채널을 담당하는 useAuthDispatch도 같은 원리를 적용합니다.
export function useAuthDispatch() {
const context = useContext(AuthDispatchContext)
if(context === undefined) {
throw new Error("useAuthDispatch는 반드시 AuthProvider 안에서만 사용해야 합니다.")
}
return context
}이제 이 상태 및 dispatch 함수를 가져올 때 아래와 같이 가져옵니다.
const { user, isLoading } = useAuthState()
const dispatch = useAuthDispatch()2. useTask
같은 원리로 useTaskState()와 useTaskDispatch()를 정의합니다.
export function useTaskState() {
const context = useContext(TaskStateContext)
if(context === undefined) {
throw new Error("TaskStateContext는 TaskProvider 안에서만 불러올 수 있습니다.")
}
return context
}
export function useTaskDispatch() {
const context = useContext(TaskDispatchContext)
if(context === undefined) {
throw new Error("TaskDispatchContext는 TaskProvider 안에서만 불러올 수 있습니다.")
}
return context
}위에서와 같이 상태 및 dispatch 함수를 원하는 컴포넌트에서 적용합니다.