📕

1.2 Context API 동작 원리

이제부터 Context API를 사용하는 방법에 대해 알아보겠습니다. 본 예제는 create-react-app을 통한 프로젝트 생성을 완료 했다는 가정하에 진행하겠습니다.
 
npm으로 React 앱을 설치하는 명령어 입니다.
npx create-react-app 프로젝트이름
혹은 yarn으로 React 앱을 설치하는 명령어 입니다.
yarn create react-app 프로젝트이름
 

1.2.1 Context 생성

Context API를 활용해 예제를 만들기 전에 사용법에 대해 간단히 살펴보겠습니다. 생성한 프로젝트의 src 폴더 내 에 context 폴더 생성 후 MyContext.js 파일을 생성합니다. 이 파일은 Context 객체를 만들어 상태값을 저장하는 역할을 합니다.
 
Context 객체는 createContext 함수를 생성할 수 있습니다. createContext 함수는 파라미터로 초기 Context 값을 받습니다. 이 값은 빈 값일 수도 있고, 미리 정해진 값일 수도 있습니다.
// context/MyContext.js import { createContext } from "react" const CalculatorContext = createContext("초기값"); export default CalculatorContext;
 

1.2.2 Consumer 사용하기

components 폴더를 생성한 뒤 Calculator 컴포넌트를 만들어 위에서 생성한 Context를 사용해보겠습니다. CalculatorContext의 상태값을 불러오기 위해 Consumer를 사용합니다. Context.Consumer는 Context에서 제공한 데이터를 사용하여 UI를 렌더링하거나 설정한 값을 불러오는 역할을 합니다.
// components/Calculator.js import CalculatorContext from "../context/MyContext"; const Calculator = () => { return ( <CalculatorContext.Consumer> {value => <div>결과: {value}</div>} </CalculatorContext.Consumer> ) } export default Calculator;
SomeContext.Consumer 형태로 사용합니다. Consumer 사이에 중괄호를 열어 그 안에 함수를 넣어 사용하고 있습니다. 이런 패턴을 Function as a child 혹은 Render Props 라고 합니다. 컴포넌트의 하위 요소(children)가 있어야 할 자리에 일반 JSX 혹은 문자열이 아닌 함수를 전달하게 됩니다.
 
이제 해당 컴포넌트를 App.js 에서 렌더링 해주겠습니다.
// App.js import Calculator from "./components/Calculator"; function App() { return ( <Calculator /> ); } export default App;
그리고 프로젝트를 실행한 뒤 화면을 확인해 보세요.
 

1.2.3 Provider 사용하기

myContext.js의 초기값 const CalculatorContext = createContext("초기값"); 은 그대로 둔 상태에서 이번에는 새로운 상태값을 전달해보기 위해 Provider에 대해 알아보겠습니다. Context.Provider는 데이터를 제공하는 컴포넌트입니다.
 
이 컴포넌트는 Context 객체를 사용하여 데이터를 제공하고 하위 컴포넌트에서 이 데이터를 접근할 수 있게 해줍니다. Provider를 사용할 때는 데이터를 담고 있는 Context 객체를 감싸고, value 를 꼭 명시해주어야 하며 명시해주지 않으면 동작하지 않습니다. Provider를 사용하면 value prop을 통해 Context의 값을 변경할 수 있습니다.
App 컴포넌트를 아래와 같이 수정해보겠습니다.
// App.js import Calculator from "./components/Calculator"; import CalculatorContext from "./context/MyContext"; function App() { return ( <CalculatorContext.Provider value="값 변경!"> <Calculator /> </CalculatorContext.Provider> ); } export default App;
변경한 값이 잘 반영되었는지 브라우저에서 확인해보겠습니다. 값이 잘 변경되었나요?
notion imagenotion image
 
기존에 createContext 함수를 사용할 때 파라미터로 Context의 기본값을 지정해 주었습니다. 이렇게 지정한 기본값은 Provider를 사용하지 않았을 때만 사용됩니다. 만약 Provider를 사용했는데 value를 명시하지 않는다면 오류가 발생합니다. App 컴포넌트의 코드를 다음과 같이 수정해 보세요.
// App.js import Calculator from "./components/Calculator"; import CalculatorContext from "./context/MyContext"; function App() { return ( <CalculatorContext.Provider> <Calculator /> </CalculatorContext.Provider> ); } export default App;
그리고 다시 화면을 보면 다음과 같은 결과가 나타납니다.
notion imagenotion image
Provider를 사용할 땐 value 값을 명시해 주어야 제대로 동작한다는 것을 기억하세요.
 

1.2.4 동적 Context 사용하기

앞선 예시들은 고정적인 값만 사용했습니다. 동적 Context는 Context 값이 고정되어 있지 않고, 애플리케이션 상태에 따라 변경될 수 있는 Context를 말합니다. 또한 Context의 값으로 항상 상태 값만 사용해야 하는건 아닙니다. 함수, 객체, 배열 등 어떤 형태든지 사용할 수 있습니다. 이번 예제에서는 간단한 계산기를 만들어 보고 어떻게 동적 Context가 사용될 수 있는지 한번 살펴보겠습니다.
 
우선, Context의 기본값부터 변경해 주겠습니다.
// context/MyContext.js import { createContext } from "react" const CalculatorContext = createContext({ result: 0, add: () => {}, subtract: () => {}, multiply: () => {}, divide: () => {}, reset: () => {} }); export default CalculatorContext;
기본값으로 결과값, 덧셈, 뺄셈, 곱셈, 나눗셈 함수와 연산을 초기화 시켜줄 함수까지 객체에 담아 지정했습니다. 하지만 Provider를 사용해서 value prop으로 실제 값을 전달해 준다면, 그 값이 현재 Context의 값으로 사용되기 때문에 ‘굳이 따로 초기값을 설정할 필요가 있나?’ 라고 생각할 수 있습니다. 하지만 기본값과 Provider의 value에 넣는 객체 형태를 일치시켜 사용한다면 Context 코드를 볼 때 내부 값이 어떻게 구성되어 있는지 파악하기 쉽고, 실수로 Provider를 사용하지 않았을 때 리액트 애플리케이션에서 에러가 발생하지 않기 때문에 초기값을 작성하는 습관을 가지는게 좋습니다.
 
일반적으로 초기값으로 상태의 값이나 함수의 기능을 정의하여 사용하지만, 이번 예제에서는 Provider를 통해 value를 새로 전달하는 것을 보여주기 위해 CalculatorContext의 초기값에 기능이 없는 함수를 사용했습니다.
그러면 Provider를 사용하고 있는 App.js 파일도 수정해 주겠습니다.
// App.js import { useState } from "react"; import Calculator from "./components/Calculator"; import CalculatorContext from "./context/MyContext"; function App() { const [result, setResult] = useState(0); const add = (value) => setResult(result + value); const subtract = (value) => setResult(result - value); const multiply = (value) => setResult(result * value); const divide = (value) => setResult(result / value); const reset = () => setResult(0); return ( {/* 위에서 재정의한 값을 Provider에 value로 전달해줍니다. */} <CalculatorContext.Provider value={{ result, add, subtract, multiply, divide, reset }}> <Calculator /> </CalculatorContext.Provider> ); } export default App;
결과를 나타낼 상태와 각 연산에 대한 함수들을 value에 넣었습니다.
이제 Consumer컴포넌트도 수정해 주도록 하겠습니다.
// components/Calculator.js import { useState } from "react"; import CalculatorContext from "../context/CalcuContext"; const Calculator = () => { const [inputValue, setInputValue] = useState(""); return ( <CalculatorContext.Consumer> {({ result, add, subtract, multiply, divide, reset }) => ( <> <div>결과: {result}</div> <input type="number" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> <div> <button onClick={() => add(Number(inputValue))}>+</button> <button onClick={() => subtract(Number(inputValue))}>-</button> <button onClick={() => divide(Number(inputValue))}>/</button> <button onClick={() => multiply(Number(inputValue))}>*</button> <button onClick={() => { reset(0); setInputValue("");}}>C</button> </div> </> )} </CalculatorContext.Consumer> ) } export default Calculator;
해당 컴포넌트는 사용자가 입력한 숫자와 현재 결과값에 대한 연산을 해주는 기능을 포함하고 있습니다. 프로젝트를 실행한 뒤 자유롭게 연산을 수행하고 결과를 확인해 보세요.
notion imagenotion image
 
연산을 할 때마다 결과값이 잘 나타나나요? 우리들이 입력한 값과 연산을 해주는 함수에 따라 결과값이 동적으로 변하게 됩니다. 간단한 계산기 예제를 통해 동적인 Context가 어떻게 작동하는지 알아보았습니다.
 

1.2.5 다중 Context 사용

앞에서 진행한 계산기 예제는 하나의 Context를 사용하고 있습니다. 하지만 만약 여러개의 Context를 사용해야 한다면 어떻게 해야 할까요? 이번 챕터에서는 실제로 여러개의 Context를 사용한다면 어떤 형식으로 사용하는지 알아보겠습니다.
 
투두리스트를 추가하기 위해 CalculatorContext 아래 TodoContext를 추가해보겠습니다.
// context/MyContext.js import { createContext } from "react" export const CalculatorContext = createContext({ result: 0, add: () => {}, subtract: () => {}, multiply: () => {}, divide: () => {}, reset: () => {} }); export const TodoContext = createContext({ todos: [], onAddTodo: () => {}, onDeleteTodo: () => {}, onToggleTodo: () => {} });
투두리스트를 위한 새로운 Context를 만들어 주었습니다. App 컴포넌트와 계산기, 투두리스트를 같이 사용할 새로운 컴포넌트도 하나 생성해 주겠습니다.
// components/CombineComponents.js import { useState } from "react"; import { CalculatorContext, TodoContext } from "../context/MyContext"; const CombineComponents = () => { const [inputValue, setInputValue] = useState(""); return ( <CalculatorContext.Consumer> {({ result, add, subtract, multiply, divide, reset }) => ( <TodoContext.Consumer> {({ todos, onAddTodo, onDeleteTodo, onToggleTodo }) => ( <> <div>계산 결과: {result}</div> <input type="number" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> <div> <button onClick={() => add(Number(inputValue))}> + </button> <button onClick={() =>subtract(Number(inputValue))}> - </button> <button onClick={() => divide(Number(inputValue))}> / </button> <button onClick={() =>multiply(Number(inputValue))}> * </button> <button onClick={() => {reset(0); setInputValue("");}}> C </button> </div> <main> <div> <TodoForm onAddTodo={onAddTodo} /> <hr /> {todos.map((todo) => ( <section key={todo.id}> <input type="checkbox" onClick={() => onToggleTodo(todo.id)} /> <div style= {{ textDecoration: todo.isDone ? "line-through" : "none" }}> {todo.content} </div> <button onClick={() => onDeleteTodo(todo.id)}>❌ </button> </section> ))} </div> </main> </> )} </TodoContext.Consumer> )} </CalculatorContext.Consumer> ) } const TodoForm = ({ onAddTodo }) => { const [inputValue, setInputValue] = useState(""); return ( <form onSubmit={(e) => e.preventDefault()}> <input type="text" value={inputValue} placeholder="할 일을 작성해 주세요" onChange={(e) => setInputValue(e.target.value)} /> <button type="submit" onClick={() => { onAddTodo(inputValue); setInputValue("");}}>추가 </button> </form> ) } export default CombineComponents;
 
// App.js import { useState } from "react"; import CombineComponents from "./components/CombineComponents"; import { CalculatorContext, TodoContext } from "./context/MyContext"; function App() { const [result, setResult] = useState(0); const [todos, setTodos] = useState([]); const add = (value) => setResult(result + value); const subtract = (value) => setResult(result - value); const multiply = (value) => setResult(result * value); const divide = (value) => setResult(result / value); const reset = () => setResult(0); const onAddTodo = (content) => { const newTodo = { id: todos.length, content, isDone: false } setTodos([...todos, newTodo]); } const onDeleteTodo = (id) => { const newTodo = todos.filter(todo => todo.id !== id); setTodos(newTodo); } const onToggleTodo = (id) => { const newTodo = todos.map(todo => todo.id === id ? { ...todo, isDone: !todo.isDone } : todo); setTodos(newTodo); } return ( <CalculatorContext.Provider value={{ result, add, subtract, multiply, divide, reset }}> <TodoContext.Provider value={{ todos, onAddTodo, onDeleteTodo, onToggleTodo }}> <CombineComponents /> </TodoContext.Provider> </CalculatorContext.Provider> ); } export default App;
이제 프로젝트를 실행해 결과를 확인해 보겠습니다.
notion imagenotion image
계산기와 투두리스트가 잘 나타났나요? 이렇게 여러개의 Context를 사용할 때 이러한 방식으로 작성할 수 있습니다. 하지만 Context가 늘어남에 따라 App 컴포넌트에서 Provider가 늘어나고 코드의 깊이가 점점 깊어지게 됩니다. 이럴 땐 배열의 내장함수인 reduce와 리액트 컴포넌트의 React.createElement 함수를 활용하여 코드를 조금 더 간략하게 작성할 수 있습니다.  다음과 같이 코드를 수정해 보세요.
// App.js import React, { useState } from "react"; import CombineComponents from "./components/CombineComponents"; import { CalculatorContext, TodoContext } from "./context/MyContext"; const AppProvider = ({ contexts, children }) => { return contexts.reduce(( prev, { context: Context, value }) => <Context value={value}>{prev}</Context>, children); }; function App() { const [result, setResult] = useState(0); const [todos, setTodos] = useState([]); const add = (value) => setResult(result + value); const subtract = (value) => setResult(result - value); const multiply = (value) => setResult(result * value); const divide = (value) => setResult(result / value); const reset = () => setResult(0); const onAddTodo = (content) => { const newTodo = { id: todos.length, content, isDone: false } setTodos([...todos, newTodo]); } const onDeleteTodo = (id) => { const newTodo = todos.filter(todo => todo.id !== id); setTodos(newTodo); } const onToggleTodo = (id) => { const newTodo = todos.map(todo => todo.id === id ? { ...todo, isDone: !todo.isDone } : todo); setTodos(newTodo); } return ( <AppProvider contexts={[ { context: CalculatorContext.Provider, value: { result, add, subtract, multiply, divide, reset } }, { context: TodoContext.Provider, value: { todos, onAddTodo, onDeleteTodo, onToggleTodo } } ]}> <CombineComponents /> </AppProvider> ); } export default App;
contexts를 props로 전달만 해주면 Provider가 여러개 중첩되고, 코드가 깊어지는걸 방지할 수 있습니다. 위에서 React.createElement 함수를 사용하지 않은 이유는 createElement 함수는 주어진 타입의 React 요소를 생성하는데 사용되지만, JSX가 createElement 호출을 구문적으로 더 쉽게 표현하는 문법이기 때문에 createElement를 직접 호출할 필요가 없습니다.
 

1.2.6 useContext 훅 사용하기

위에서 설명한 예시들은 Consumer를 통해 Context 값을 읽어왔습니다. 하지만 Consumer보다 더 쉽고 간결하게 Context 값을 읽어올 수 있는 방법이 있습니다. 바로 useContext 훅입니다. 컴포넌트에서 사용중인 Consumer를 useContext 훅으로 수정 해 보겠습니다.
// components/CombineComponents.js import { useState, useContext } from "react"; import { CalculatorContext, TodoContext } from "../context/MyContext"; function CombineComponents() { const [inputValue, setInputValue] = useState(""); const { result, add, subtract, multiply, divide, reset } = useContext(CalculatorContext); const { todos, onAddTodo, onDeleteTodo, onToggleTodo } = useContext(TodoContext); return ( <> <div>계산 결과: {result}</div> <input type="number" value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> <div> <button onClick={() => add(Number(inputValue))}>+</button> <button onClick={() => subtract(Number(inputValue))}>-</button> <button onClick={() => divide(Number(inputValue))}>/</button> <button onClick={() => multiply(Number(inputValue))}>*</button> <button onClick={() => {reset(0); setInputValue("");}}>C</button> </div> <main> <div> <TodoForm onAddTodo={onAddTodo} /> <hr /> {todos.map((todo) => ( <section key={todo.id}> <input type="checkbox" onClick={() => onToggleTodo(todo.id)} /> <div style={{ textDecoration: todo.isDone ? "line-through" : "none" }}>{todo.content}</div> <button onClick={() => onDeleteTodo(todo.id)}>❌</button> </section> ))} </div> </main> </> ); } const TodoForm = ({ onAddTodo }) => { const [inputValue, setInputValue] = useState(""); return ( <form onSubmit={(e) => e.preventDefault()}> <input type="text" value={inputValue} placeholder="할 일을 작성해 주세요" onChange={(e) => setInputValue(e.target.value)} /> <button type="submit" onClick={() => { onAddTodo(inputValue); setInputValue(""); }}>추가</button> </form> ); } export default CombineComponents;
useContext 훅을 사용해 작성한 코드와 2-5 챕터까지 Consumer로 작성했던 코드를 비교해 보세요. 굉장히 간결해진 것을 확인할 수 있습니다. 그렇다면 Consumer와 useContext는 어떤 차이가 있을까요? 둘 다 성능 측면에서는 큰 차이가 없습니다. 다만 useContext 훅이 코드를 더 깔끔하게 만들고 가독성을 높여줍니다. 리액트 공식문서에서는 Consumer 사용방법이 레거시한 방법이라고 표현하므로 useContext 사용을 추천하고 있습니다. 하지만 useContext를 사용하게 된 배경을 알기 위해 위에서 설명한 Consumer 예제도 사용해보시길 권합니다.
 
지금까지 간단한 예제들과 함께 Context API를 사용하는 방법에 대해 알아보았습니다. 만약 여러분이 프로젝트를 진행하고 있거나, 프로젝트를 진행할 예정이라면 Context API를 사용해 보는건 어떨까요? 여러분의 프로젝트 퀄리티를 한층 더 향상시켜줄 것입니다.