React 상태 관리, 왜 어려운가
React로 애플리케이션을 개발하다 보면 상태 관리는 항상 고민거리입니다. 처음에는 단순히 useState 몇 개로 시작했던 프로젝트가 점점 커지면서, 상태는 더 이상 "그냥 몇 개의 useState 훅"이 아니게 됩니다.
실제 프로덕션 환경에서는 여러 종류의 상태가 뒤섞이게 됩니다. 인풋이나 다이얼로그, 탭 같은 로컬 UI 상태가 있고, 레이아웃이나 테마, 인증 정보 같은 공유 UI 상태가 있습니다. 여기에 서버에서 받아오는 쿼리와 캐시 데이터, 비즈니스 로직을 담고 있는 도메인 상태, 그리고 불필요한 리렌더링을 최소화해야 하는 성능 제약까지 고려해야 합니다.
문제는 이 모든 것을 해결하는 완벽한 도구는 없다는 것입니다. 각각의 접근 방식은 구독(subscription), 세분성(granularity), 디버깅 가능성, 그리고 팀 워크플로우 측면에서 서로 다른 트레이드오프를 만들어냅니다.
이 글에서는 실제 프로덕션 환경에서 사용되는 여러 상태 관리 전략을 살펴보겠습니다. 흔한 투두 리스트 예제 대신, 마켓 대시보드, 거래 생명주기, 사용자 설정, 반응형 UI 같은 현실적인 예제를 사용할 것입니다.
React 상태 관리의 두 가지 근본적인 문제
모든 상태 관리 논의는 결국 두 가지 반대되는 문제점에 부딪히게 됩니다.
첫 번째는 데이터는 변경되었는데 UI가 업데이트되지 않는 문제입니다. 이것은 데이터 흐름의 문제로, React가 무엇이 변경되었는지 모르거나 변경이 구독 그래프 밖에서 일어난 경우입니다.
두 번째는 관련 데이터가 변경되지 않았는데도 UI가 업데이트되는 문제입니다. 이것은 구독의 문제로, 컴포넌트가 너무 많은 상태를 구독하거나 항상 새로운 값을 반환하는 방식으로 구독한 경우입니다.
React 생태계의 모든 라이브러리는 어떤 식으로든 이 두 가지 문제를 관리 가능하게 만들려는 시도입니다.
접근법 1: useState와 useReducer를 활용한 로컬 상태
가장 간단하면서도 견고한 접근법은 상태를 필요로 하는 컴포넌트에 로컬하게 유지하는 것입니다.
로컬 상태는 다음과 같은 경우에 적합합니다.
- 위젯 레벨의 인터랙션 (모달, 드롭다운, 아코디언)
- 단일 화면 내의 폼 입력과 유효성 검사
- 공유할 필요가 없는 일시적인 데이터
예를 들어 주식의 최근 50개 가격 틱을 시각화하는 작은 스파크라인 차트를 생각해봅시다. 이것은 순수한 UI 상태이며, 다른 어떤 것도 이 정보를 알 필요가 없습니다.
import { useReducer, useEffect } from "react";
interface Candle {
t: number;
p: number;
}
type Action = { type: "tick"; price: number };
function chartReducer(state: Candle[], action: Action): Candle[] {
switch (action.type) {
case "tick":
return [...state.slice(-49), { t: Date.now(), p: action.price }];
default:
return state;
}
}
export function Sparkline({ feed }: { feed: () => number }) {
const [candles, dispatch] = useReducer(chartReducer, []);
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick", price: feed() });
}, 500);
return () => clearInterval(id);
}, [feed]);
return (
<pre style={{ fontSize: 12 }}>
{JSON.stringify(candles.slice(-5), null, 2)}
</pre>
);
}
이 경우 React는 별도의 상태 관리 라이브러리가 필요하지 않습니다. 로직이 캡슐화되어 있고, 테스트하기 쉬우며, 컴포넌트를 리팩토링할 때 버리기도 쉽습니다.
로컬 상태가 이상적인 경우는 데이터를 멀리 떨어진 컴포넌트들과 공유할 필요가 없고, 컴포넌트가 해당 상태의 전체 생명주기를 소유할 수 있으며, 성능 특성이 단순하고 로컬한 경우입니다.
하지만 많은 형제 컴포넌트가 같은 상태를 필요로 하면서 반복적으로 상태를 끌어올리게 되거나, 다른 화면들이 공유된 엔티티나 워크플로우를 중심으로 조정해야 하거나, 디버깅을 위해 시간에 따른 상태의 변화를 검사해야 할 때는 로컬 상태만으로는 부족합니다.
상태를 공유하거나 여러 컴포넌트가 관찰해야 하는 순간, 우리는 보통 순수한 useState/useReducer를 넘어서게 됩니다.
접근법 2: 공유 UI 상태를 위한 React Context
React Context는 특정한 문제를 해결합니다. 바로 Prop Drilling입니다. 여러 레벨을 통해 props를 전달하는 대신, 서브트리의 최상단에 있는 Provider가 모든 하위 컴포넌트에 상태와 API를 노출할 수 있습니다.
Context는 다음과 같은 경우에 적합합니다.
- 테마
- 로케일
- 현재 인증된 사용자
- 기능 플래그
- 라우팅 메타데이터
- 거의 변경되지 않는 "주변" UI 상태
하지만 Context에는 날카로운 모서리가 있습니다. Provider의 value 참조가 변경되면 모든 소비자가 리렌더링됩니다. 메모이제이션이 중요합니다.
예를 들어 시장 대시보드에서 여러 컴포넌트가 사용자의 주식 관심 목록을 표시하고 수정하는 경우를 생각해봅시다.
import {
createContext,
useContext,
useCallback,
useMemo,
useState,
ReactNode,
} from "react";
interface WatchlistContextShape {
symbols: string[];
add: (symbol: string) => void;
remove: (symbol: string) => void;
}
const WatchlistContext = createContext<WatchlistContextShape | null>(null);
export function WatchlistProvider({ children }: { children: ReactNode }) {
const [symbols, setSymbols] = useState<string[]>(["AAPL", "MSFT"]);
const add = useCallback(
(sym: string) =>
setSymbols(prev => (prev.includes(sym) ? prev : [...prev, sym])),
[],
);
const remove = useCallback(
(sym: string) => setSymbols(prev => prev.filter(s => s !== sym)),
[],
);
const value = useMemo(
() => ({ symbols, add, remove }),
[symbols, add, remove],
);
return (
<WatchlistContext.Provider value={value}>
{children}
</WatchlistContext.Provider>
);
}
export function useWatchlist() {
const ctx = useContext(WatchlistContext);
if (!ctx) throw new Error("WatchlistProvider is missing");
return ctx;
}
이제 모든 하위 컴포넌트는 useWatchlist()를 호출하여 관심 목록을 읽거나 업데이트할 수 있습니다. 거의 변경되지 않는 상태에는 이 방식이 매우 잘 작동합니다.
Context의 장점은 외부 의존성이 없고, 주변 상태(테마, 로케일, 인증, 기능 플래그)에 완벽하며, 자연스러운 생명주기 범위 지정이 가능하다는 것입니다. Provider를 언마운트하면 상태가 리셋됩니다.
반면 단점은 value가 변경될 때마다 모든 소비자가 리렌더링되고(Provider를 분할하지 않는 한), 내장된 셀렉터나 세밀한 구독이 없으며, 액션 로그나 시간 여행 디버깅 기능이 제한적이라는 점입니다.
Context는 강력하지만 고빈도 또는 대규모 공유 상태의 기본값이 되어서는 안 됩니다. 그런 경우에는 다른 도구가 더 적합합니다.
접근법 3: 워크플로우 지향 상태를 위한 Redux Toolkit
Redux는 매우 구체적인 철학을 중심으로 설계되었습니다.
상태는 액션을 따른다. 모든 변경은 로그의 이벤트다.
현대적인 형태(Redux Toolkit + React-Redux)의 Redux는 다음과 같은 경우에 가장 잘 사용됩니다.
- 의미론적 의미를 가진 명확한 비즈니스 이벤트가 있을 때
- 디버깅을 위해 상태가 어떻게 그리고 언제 변경되었는지 검사해야 할 때
- 여러 팀이 공유되고 예측 가능한 아키텍처가 필요할 때
- 재현성과 시간 여행 디버깅이 중요할 때
거래 시스템에서 거래 주문 생명주기를 생각해봅시다.
- 초안 → 제출됨 → 실행됨 → 정산됨
이것은 단순한 상태가 아니라 추적 가능해야 하는 일련의 이벤트입니다.
// tradeSlice.ts
import { createSlice, configureStore, PayloadAction } from "@reduxjs/toolkit";
export type TradeStatus = "draft" | "submitted" | "executed" | "settled";
export interface Trade {
id: string;
symbol: string;
quantity: number;
status: TradeStatus;
}
interface TradeState {
items: Trade[];
}
const initialState: TradeState = {
items: [],
};
const tradesSlice = createSlice({
name: "trades",
initialState,
reducers: {
createTrade: (
state,
action: PayloadAction<{ id: string; symbol: string; quantity: number }>,
) => {
state.items.push({
...action.payload,
status: "draft",
});
},
submitTrade: (state, action: PayloadAction<string>) => {
const trade = state.items.find(t => t.id === action.payload);
if (trade && trade.status === "draft") {
trade.status = "submitted";
}
},
executeTrade: (state, action: PayloadAction<string>) => {
const trade = state.items.find(t => t.id === action.payload);
if (trade && trade.status === "submitted") {
trade.status = "executed";
}
},
settleTrade: (state, action: PayloadAction<string>) => {
const trade = state.items.find(t => t.id === action.payload);
if (trade && trade.status === "executed") {
trade.status = "settled";
}
},
},
});
export const {
createTrade,
submitTrade,
executeTrade,
settleTrade,
} = tradesSlice.actions;
export const store = configureStore({
reducer: {
trades: tradesSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
셀렉터는 컴포넌트를 스토어의 특정 슬라이스에 구독시킵니다. 선택된 슬라이스가 변경된 컴포넌트만 리렌더링됩니다.
Redux Toolkit의 장점은 강력한 DevTools(시간 여행, 액션 로그, 상태 검사), 예측 가능한 단방향 데이터 흐름(dispatch → reducer → new state), 대규모 팀과 장기 제품에 적합하며, 테스트하고 추론하기 쉽다는 것입니다.
단점은 로컬 상태나 Zustand보다 더 많은 구조와 보일러플레이트가 필요하고, 작은 앱이나 순수 시각적 위젯에는 과하다는 것입니다.
상태가 비즈니스 워크플로우와 감사 가능성과 밀접하게 연결되어 있을 때 Redux를 사용하세요. 단순히 UI 동작을 위한 것이 아닐 때 말입니다.
접근법 4: 최소한의 전역 스토어를 위한 Zustand
Zustand는 Context와 Redux 사이의 스위트 스팟에 위치합니다.
- 훅을 통해 접근하는 전역 스토어를 제공합니다.
- 컴포넌트는 셀렉터를 통해 구독합니다.
- 공식적인 액션/리듀서 패턴을 강제하지 않습니다.
Zustand는 다음과 같은 경우에 적합합니다.
- Redux의 의식 없이 전역 상태를 원할 때
- 여전히 셀렉터 기반 구독을 원할 때
- 영속성이나 로깅 같은 미들웨어가 필요하지만 완전한 Redux 의미론은 필요 없을 때
대시보드의 사용자 UI 설정(테마와 선호 통화)을 위한 스토어를 만들어봅시다.
// useUserPrefs.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Currency = "USD" | "EUR" | "JPY";
interface UserPrefsState {
darkMode: boolean;
currency: Currency;
toggleDarkMode: () => void;
setCurrency: (c: Currency) => void;
}
export const useUserPrefs = create<UserPrefsState>()(
persist(
(set) => ({
darkMode: false,
currency: "USD",
toggleDarkMode: () =>
set(prev => ({ darkMode: !prev.darkMode })),
setCurrency: (currency) => set({ currency }),
}),
{ name: "user-prefs" },
),
);
컴포넌트는 통화만 구독할 수 있습니다.
const currency = useUserPrefs(s => s.currency);
통화를 읽는 컴포넌트만 통화가 변경될 때 리렌더링됩니다.
Zustand의 장점은 작고 집중된 API, 셀렉터 기반 구독(세밀한 제어), 미들웨어(영속성, devtools)를 쉽게 연결할 수 있고, 중간 규모 앱과 기능 기반 스토어에 잘 작동한다는 것입니다.
단점은 덜 독단적인 아키텍처로 팀이 규칙을 정의해야 하고, 직접 정의하는 것 외에는 내장된 액션/이벤트 개념이 없다는 것입니다.
Redux가 무겁게 느껴지지만 Context로는 충분하지 않을 때 Zustand는 좋은 기본 선택이 될 수 있습니다.
접근법 5: 원자적이고 조합 가능한 상태를 위한 Jotai
Jotai는 상태를 atoms로 모델링합니다. 작고 개별적으로 구독 가능한 단위입니다. 컴포넌트는 읽는 atom에만 구독하고, 파생된 atom은 상태 조각들 간의 관계를 표현할 수 있습니다.
이것은 다음과 같은 경우에 유용합니다.
- 상태가 자연스럽게 많은 작은 단위로 분해될 때
- 파생된 값과 계산된 관계가 흔할 때
- 수동 셀렉터를 피하고 싶을 때
통화 변환 패널을 atoms로 만들어봅시다.
// atoms.ts
import { atom } from "jotai";
export const baseCurrencyAtom = atom<"USD" | "EUR">("USD");
export const targetCurrencyAtom = atom<"USD" | "EUR">("EUR");
export const amountAtom = atom(100);
const staticRates: Record<string, number> = {
"USD_EUR": 0.92,
"EUR_USD": 1.09,
};
export const convertedAmountAtom = atom((get) => {
const from = get(baseCurrencyAtom);
const to = get(targetCurrencyAtom);
const amount = get(amountAtom);
if (from === to) return amount;
const key = `${from}_${to}`;
const rate = staticRates[key] ?? 1;
return Math.round(amount * rate * 100) / 100;
});
React 컴포넌트에서는 다음과 같이 사용합니다.
// ConversionPanel.tsx
import { useAtom } from "jotai";
import {
amountAtom,
baseCurrencyAtom,
targetCurrencyAtom,
convertedAmountAtom,
} from "./atoms";
export function ConversionPanel() {
const [amount, setAmount] = useAtom(amountAtom);
const [base, setBase] = useAtom(baseCurrencyAtom);
const [target, setTarget] = useAtom(targetCurrencyAtom);
const [converted] = useAtom(convertedAmountAtom);
return (
<section>
<div>
<label>
Amount
<input
type="number"
value={amount}
onChange={e => setAmount(Number(e.target.value) || 0)}
/>
</label>
</div>
<div>
<select value={base} onChange={e => setBase(e.target.value as any)}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
<span>→</span>
<select value={target} onChange={e => setTarget(e.target.value as any)}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>
<p>
Result: <strong>{converted}</strong> {target}
</p>
</section>
);
}
convertedAmountAtom을 읽는 모든 컴포넌트는 의존성 중 하나가 변경될 때만 리렌더링됩니다.
Jotai의 장점은 세밀한 atom 레벨 구독, 간단한 멘탈 모델(atom은 그냥 값), 파생된 atom이 상태 간의 관계를 명시적으로 만든다는 것입니다.
단점은 매우 큰 앱에서 복잡한 atom 그래프를 추론하기 어려울 수 있고, 내장된 액션 개념이 없어서 워크플로우를 직접 인코딩해야 한다는 것입니다.
Jotai는 디자인 시스템, 폼, 컴포넌트 중심 애플리케이션에서 특히 좋습니다.
접근법 6: 관찰 가능하고 도메인 주도적인 상태를 위한 MobX
MobX는 더 반응적이고 객체 지향적인 접근 방식을 취합니다.
- 도메인 객체를 observable로 만듭니다.
observer로 래핑된 컴포넌트는 읽는 모든 observable 속성에 반응합니다.- 계산된 값은 의존성이 변경될 때 자동으로 업데이트됩니다.
이것은 데스크톱 같은 UI, 복잡한 폼, 많은 파생 값을 가진 도메인 모델에 강력합니다.
포트폴리오 메트릭 예제를 살펴봅시다.
// portfolioStore.ts
import { makeAutoObservable } from "mobx";
interface Position {
symbol: string;
shares: number;
price: number;
}
export class PortfolioStore {
positions: Position[] = [
{ symbol: "AAPL", shares: 10, price: 187 },
{ symbol: "MSFT", shares: 5, price: 320 },
];
constructor() {
makeAutoObservable(this);
}
get totalValue() {
return this.positions.reduce(
(sum, p) => sum + p.shares * p.price,
0,
);
}
updatePrice(symbol: string, price: number) {
const pos = this.positions.find(p => p.symbol === symbol);
if (pos) pos.price = price;
}
}
export const portfolioStore = new PortfolioStore();
컴포넌트에서는 다음과 같이 사용합니다.
// PortfolioWidget.tsx
import { observer } from "mobx-react-lite";
import { portfolioStore } from "./portfolioStore";
export const PortfolioWidget = observer(() => {
return (
<div>
<h2>Portfolio</h2>
<ul>
{portfolioStore.positions.map(p => (
<li key={p.symbol}>
{p.symbol}: {p.shares} @ ${p.price}
</li>
))}
</ul>
<p>Total value: ${portfolioStore.totalValue}</p>
</div>
);
});
MobX는 각 observer 컴포넌트가 읽는 observable을 추적하고, 관련 필드가 변경될 때만 해당 컴포넌트를 리렌더링합니다.
MobX의 장점은 매우 세밀한 반응성, 패턴을 이해하면 최소한의 보일러플레이트, 많은 파생 값을 가진 도메인 모델에 잘 맞는다는 것입니다.
단점은 더 암묵적인 동작으로 추적이 어떻게 작동하는지 이해해야 하고, 순수 함수형 패턴과는 덜 정렬된다는 것입니다.
MobX는 프로젝트가 반응형 도메인 객체를 일급 개념으로 받아들일 때 가장 잘 작동합니다.
접근법 7: 프록시 기반 가변 스토어를 위한 Valtio
Valtio도 프록시를 사용하지만 매우 인체공학적인 API에 중점을 둡니다.
- 상태는 일반 JavaScript 객체입니다.
proxy()가 반응형 레이어로 래핑합니다.useSnapshot()이 접근한 속성에 컴포넌트를 구독시킵니다.
이 접근법은 다음과 같은 고도로 인터랙티브한 UI에 이상적입니다.
- 드래그 가능한 레이아웃
- 많은 작은 위젯이 있는 대시보드
- 실시간 협업 커서
- 비주얼 에디터
윈도우 레이아웃 예제를 살펴봅시다.
// layout.ts
import { proxy } from "valtio";
interface WindowModel {
id: number;
x: number;
y: number;
title: string;
}
export const layoutState = proxy({
windows: [
{ id: 1, x: 80, y: 40, title: "Orders" },
{ id: 2, x: 320, y: 120, title: "Market Depth" },
] as WindowModel[],
move(id: number, x: number, y: number) {
const win = this.windows.find(w => w.id === id);
if (win) {
win.x = x;
win.y = y;
}
},
});
컴포넌트에서는 다음과 같이 사용합니다.
// Window.tsx
import { useSnapshot } from "valtio";
import { layoutState } from "./layout";
export function Window({ id }: { id: number }) {
const snap = useSnapshot(layoutState);
const win = snap.windows.find(w => w.id === id);
if (!win) return null;
return (
<div
style={{
position: "absolute",
left: win.x,
top: win.y,
padding: 12,
border: "1px solid #ccc",
background: "#fff",
}}
>
<strong>{win.title}</strong>
</div>
);
}
특정 필드를 읽는 컴포넌트만 해당 필드가 변경될 때 리렌더링됩니다.
Valtio의 장점은 자연스럽게 느껴지는 간단하고 가변적인 API, 스냅샷을 통한 속성 레벨 구독, 복잡하고 인터랙티브한 레이아웃에 적합하다는 것입니다.
단점은 기본적으로 스토어가 싱글톤인 경우가 많아 SSR과 다중 사용자 격리에 추가 작업이 필요하고, 액션이나 워크플로우에 대한 내장된 패턴이 적다는 것입니다.
Valtio는 UI가 단순한 폼보다는 데스크톱 애플리케이션이나 캔버스처럼 느껴질 때 강력한 선택입니다.
접근 방식 비교하기
다음은 논의된 접근 방식들을 어떻게 상태를 구독하고 얼마나 많은 구조를 요구하는지에 초점을 맞춰 비교한 표입니다.
| 접근 방식 | 구독 세분성 | 보일러플레이트 | DevTools / 디버깅 | 가장 적합한 용도 |
|---|---|---|---|---|
| 로컬 상태 | 컴포넌트 | 매우 낮음 | 기본 React DevTools | 간단한 위젯, 폼, 로컬 UI |
| Context | Provider당 모든 소비자 | 낮음 | 기본 React DevTools | 테마, 로케일, 인증, 플래그 |
| Redux Toolkit | 셀렉터 결과 | 중간 | 우수함 | 워크플로우, 로그, 다중 팀 제품 |
| Zustand | 셀렉터 결과 | 낮음 | 좋음 (미들웨어 포함) | 전역 앱 상태, 필터, 설정 |
| Jotai | Atom 값 | 낮음 | 좋음 | 원자적 상태, 파생 값, UI 조합 |
| MobX | Observable 속성 | 낮음 | 좋음에서 우수함 | 도메인 모델, 복잡한 파생 그래프 |
| Valtio | Proxy를 통한 속성 접근 | 낮음 | 기본에서 좋음 | 인터랙티브 대시보드, 레이아웃, 에디터 |
어떤 하나의 행이 "승자"는 아닙니다. 각 접근 방식은 예측 가능성, 표현력, 성능, 개발자 경험이라는 서로 다른 제약 조건을 최적화합니다.
실용적인 결정 체크리스트
상태 관리 전략을 선택할 때 몇 가지 실용적인 질문을 하는 것이 도움이 됩니다.
이 상태를 누가 소유하는가? 단일 컴포넌트에 속한다면 로컬로 유지하세요.
이 상태를 누가 읽어야 하는가? 하나의 서브트리에서만 필요하다면 Context나 로컬 스토어로 충분할 수 있습니다.
이 상태는 얼마나 자주 변경되는가? 고빈도 업데이트는 세밀하거나 반응형 모델(Zustand, Jotai, MobX, Valtio)을 선호합니다.
무슨 일이 일어났는지 로그가 필요한가? 그렇다면 DevTools를 갖춘 Redux를 이기기 어렵습니다.
이 코드베이스에서 몇 명이 작업하는가? 대규모 팀은 표준화된 패턴과 강력한 도구의 혜택을 받습니다.
상태가 비즈니스 워크플로우를 나타내는가? 그렇다면 전환을 액션으로 모델링하는 것이 매우 가치 있을 수 있습니다.
이러한 답변을 바탕으로 하이브리드 접근 방식을 사용하게 될 수도 있습니다.
- 폼과 일회성 컴포넌트를 위한 로컬 상태
- 테마/인증/로케일을 위한 Context
- 전역 UI 상태와 필터를 위한 Zustand
- 복잡한 비즈니스 워크플로우를 위한 Redux
- 원자적이고 파생된 UI 상태를 위한 Jotai
- 고도로 인터랙티브하거나 도메인이 많은 영역을 위한 MobX 또는 Valtio
마무리하며
React의 상태 관리는 단일한 "최고의" 라이브러리를 선택하는 것이 아닙니다. 다음을 이해하는 것입니다.
- React의 렌더링 사이클이 상태 변경과 어떻게 관련되는지
- 구독과 셀렉터가 성능에 어떻게 영향을 미치는지
- 다른 도구들이 업데이트와 워크플로우를 어떻게 모델링하는지
"어떤 라이브러리를 사용해야 하는가?"라고 묻는 대신, 다음과 같이 묻는 것이 더 생산적입니다.
- "이것은 어떤 종류의 상태인가?"
- "누가 이것을 관찰해야 하는가?"
- "변경 사항은 어떻게 추적되고 디버깅되어야 하는가?"
도구를 상태의 실제 모양(로컬 대 공유, 저빈도 대 고빈도, 단순 대 워크플로우 주도)에 맞춤으로써, 추론하기 쉽고 유지 관리하기 즐거운 React 애플리케이션을 구축할 수 있습니다.
만능 해결책은 없지만 명확한 패턴은 있습니다. 도구가 도메인의 실제 동작에 가까울수록 상태 레이어는 더 단순해집니다.
참고 자료