계산과 부수 효과
1. 계산과 부수 효과
계산은 입력을 받아 값을 돌려주는 데서 끝납니다. 부수 효과(Side Effect)는 문서 제목, 브라우저 저장소, 서버처럼 함수 바깥에 있는 것을 바꾸는 일입니다.
장바구니 합계를 보여 주는 컴포넌트 하나에 두 종류의 코드가 함께 들어 있습니다.
import { useEffect } from "react";
interface CartItem {
id: number;
price: number;
quantity: number;
}
interface Props {
items: CartItem[];
}
export default function CartSummary({ items }: Props) {
// 계산: items 로 합계를 만듭니다
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 부수 효과: 브라우저 탭 제목을 바꿉니다
useEffect(() => {
document.title = `장바구니 ${items.length}개`;
}, [items.length]);
return <p>합계 {totalPrice}원</p>;
}
totalPrice를 만드는 줄은 items만 보고 숫자 하나를 내놓습니다.
이 줄을 몇 번 실행해도 브라우저에는 아무 흔적이 남지 않습니다.
useEffect 안의 줄은 다릅니다.
실행하고 나면 브라우저 탭 제목이 바뀌어 있습니다. 컴포넌트 바깥에 있는 document를 고쳤기 때문입니다.
| 구분 | 계산 | 부수 효과 |
|---|---|---|
| 하는 일 | 입력으로 값을 만듦 | 바깥의 상태를 바꿈 |
| 예 | items.reduce(...) | document.title = ... |
| 결과가 남는 곳 | 반환값 | 함수 바깥 |
| 여러 번 실행하면 | 같은 값이 또 나옴 | 바깥이 그만큼 또 바뀜 |
2. 순수 함수와 순수한 계산
순수 함수(Pure Function)는 같은 입력에 항상 같은 결과를 돌려주는 함수입니다. 호출 전부터 있던 값도 바꾸지 않습니다.
2.1 props로 값을 계산하는 컴포넌트
interface Props {
price: number;
rate: number; // 할인율. 0.1 이면 10%
}
export default function DiscountPrice({ price, rate }: Props) {
const finalPrice = Math.round(price * (1 - rate));
return <strong>{finalPrice}원</strong>;
}
price가 10000, rate가 0.1이면 언제 렌더링해도 9000원이 나옵니다.
렌더링 결과가 props만으로 정해지는 컴포넌트입니다.
2.2 계산 함수로 분리하기
할인가 계산은 화면과 상관없는 규칙입니다. 컴포넌트 밖의 함수로 빼면 규칙만 따로 읽고 확인할 수 있습니다.
// 할인가 계산 규칙. 화면과 상관없이 숫자만 다룹니다
export const calculateDiscountPrice = (price: number, rate: number) =>
Math.round(price * (1 - rate));
export default function DiscountPrice({ price, rate }: Props) {
const finalPrice = calculateDiscountPrice(price, rate);
return <strong>{finalPrice}원</strong>;
}
컴포넌트를 렌더링하지 않고 함수만 불러서 결과를 확인합니다.
calculateDiscountPrice(10000, 0.1); // 9000
calculateDiscountPrice(10000, 0.1); // 9000, 몇 번 불러도 같습니다
2.3 순수 함수의 조건
| 조건 | 뜻 | 깨는 예 |
|---|---|---|
| 같은 입력이면 같은 출력 | 인자 말고는 결과에 영향을 주는 것이 없음 | Date.now(), Math.random(), 바깥 변수 읽기 |
| 바깥을 바꾸지 않음 | 호출 전부터 있던 객체·변수를 건드리지 않음 | 인자로 받은 배열에 push, 전역 변수 수정 |
React 공식 문서(react.dev)의 Keeping Components Pure 페이지는 두 조건을 이렇게 적습니다.
| 원문 | 번역 |
|---|---|
| Same inputs, same output. | 입력이 같으면 출력도 같다 |
| It minds its own business. | 자기 일만 한다. 호출 전부터 있던 객체나 변수를 바꾸지 않는다 |
퀴즈. 다섯 함수 중 순수 함수는 어느 것일까요?
// A
const formatPrice = (price: number) => `${price}원`;
// B
const getDday = (target: Date) =>
Math.ceil((target.getTime() - Date.now()) / 86_400_000);
// C
const addItem = (cart: string[], item: string) => {
cart.push(item);
return cart;
};
// D
const withItem = (cart: string[], item: string) => [...cart, item];
// E
const pickCoupon = (coupons: string[]) =>
coupons[Math.floor(Math.random() * coupons.length)];
정답 보기
A와 D입니다.
| 함수 | 판정 | 이유 |
|---|---|---|
A formatPrice | 순수 함수 | 인자만으로 문자열을 만듦 |
B getDday | 순수 함수 아님 | 같은 target이어도 내일 부르면 1 줄어듦. 첫 조건 위반 |
C addItem | 순수 함수 아님 | 인자로 받은 cart 원본에 push. 두 번째 조건 위반 |
D withItem | 순수 함수 | 새 배열을 만들어 돌려주고 원본은 그대로 둠 |
E pickCoupon | 순수 함수 아님 | 같은 coupons여도 부를 때마다 다른 쿠폰. 첫 조건 위반 |
C와 D는 반환값만 보면 똑같이 item이 들어간 배열입니다.
차이는 호출한 쪽이 가진 cart에서 드러납니다.
const cart = ["사과"];
addItem(cart, "배");
console.log(cart); // ["사과", "배"] 원본이 바뀜
const cart2 = ["사과"];
withItem(cart2, "배");
console.log(cart2); // ["사과"] 원본 그대로
2.4 함수형 프로그래밍과의 관계
함수형 프로그래밍(Functional Programming)은 프로그램을 순수 함수의 조합으로 짜는 방식입니다. 이미 있는 값을 바꾸지 않고 새 값을 만들어 돌려줍니다.
서버 요청이나 저장처럼 부수 효과가 꼭 필요한 일은 그대로 하되, 계산과 섞이지 않게 정해 둔 자리에서만 합니다.
React의 렌더링 규칙도 같은 원칙을 따릅니다. 컴포넌트는 props, state, context가 같으면 같은 JSX를 돌려줘야 합니다. React 공식 문서는 이렇게 적습니다.
React assumes that every component you write is a pure function.
React는 작성된 모든 컴포넌트가 순수 함수라고 가정합니다.
세 원칙을 React의 규칙과 짝지으면 이렇습니다.
| 원칙 | 뜻 | React의 규칙 |
|---|---|---|
| 순수 함수 | 같은 입력에 같은 출력, 바깥을 바꾸지 않음 | props·state·context가 같으면 같은 JSX를 돌려줌 |
| 불변성 | 값을 고치지 않고 새 값을 만들어 돌려줌 | props·state를 직접 바꾸지 않고 [...cart, item]처럼 새로 만듦 |
| 부수 효과 격리 | 부수 효과를 정해 둔 자리에 모음 | 렌더링이 아닌 이벤트 핸들러와 useEffect에서 실행 |
3. Side Effect란?
부수 효과는 나쁜 코드가 아니라 앱이 하는 일 그 자체입니다. 부수 효과가 하나도 없는 앱은 화면에 값을 그리기만 하고 아무것도 저장하거나 보내지 못합니다.
따져야 할 것은 부수 효과를 어디에 두느냐입니다. 메모를 고치는 화면에는 부수 효과가 세 개 들어 있습니다.
import { useEffect, useState, type ChangeEvent } from "react";
interface Props {
initialTitle: string;
initialContent: string;
}
export default function MemoEditor({ initialTitle, initialContent }: Props) {
const [title, setTitle] = useState(initialTitle);
const [content, setContent] = useState(initialContent);
// 화면에 보이는 동안 탭 제목을 메모 제목에 맞춥니다
useEffect(() => {
document.title = title || "제목 없는 메모";
}, [title]);
// 본문을 입력할 때마다 임시 저장합니다
const handleContentChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
localStorage.setItem("memo-draft", e.target.value);
};
// 저장 버튼을 눌렀을 때만 서버로 보냅니다
const handleSave = async () => {
await fetch("/api/memo", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});
};
return (
<>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={content} onChange={handleContentChange} />
<button onClick={handleSave}>저장</button>
</>
);
}
| 부수 효과 | 바꾸는 바깥 | 둔 자리 | 실행되는 이유 |
|---|---|---|---|
document.title | 브라우저 탭 제목 | useEffect | 화면을 처음 열 때도 메모 제목을 탭에 보여 주려고 |
localStorage.setItem | 브라우저 저장소 | 이벤트 핸들러 handleContentChange | 사용자가 본문을 입력해서 |
fetch | 서버에 저장된 메모 | 이벤트 핸들러 handleSave | 사용자가 저장 버튼을 눌러서 |
세 부수 효과 모두 렌더링 중에는 실행되지 않습니다.
이벤트 핸들러는 사용자가 입력하거나 버튼을 누를 때, useEffect는 렌더링 결과가 화면에 반영된 뒤에 실행됩니다.
4. Mutation은 무조건 나쁜가?
2.3에서 순수 함수는 호출 전부터 있던 값을 바꾸지 않는다고 했습니다. 그렇다면 함수 안에서 값을 바꾸는 코드는 모두 부수 효과일까요?
Mutation은 이미 있는 변수나 객체의 값을 바꾸는 일입니다. 같은 Mutation이어도 바뀐 값이 누구 것이냐에 따라 계산으로 남기도 하고 부수 효과가 되기도 합니다.
4.1 컴포넌트 안에서 let 값을 바꾸는 경우
interface CartItem {
id: number;
price: number;
quantity: number;
}
interface Props {
items: CartItem[];
}
export default function CartTotal({ items }: Props) {
let total = 0;
for (const item of items) {
total += item.price * item.quantity; // total 을 계속 바꿉니다
}
return <p>합계 {total}원</p>;
}
total은 반복문을 돌 때마다 값이 바뀝니다.
그래도 CartTotal은 순수 함수의 두 조건을 지킵니다.
total은 이번 렌더링이 시작될 때 0으로 만들어지고 렌더링이 끝나면 버려집니다.
바뀌는 과정을 볼 수 있는 코드는 CartTotal 안에만 있습니다. 같은 items면 몇 번 렌더링해도 같은 합계가 나옵니다.
4.2 props로 받은 배열을 바꾸는 경우
interface Props {
scores: number[];
}
export default function TopScores({ scores }: Props) {
const top3 = scores.sort((a, b) => b - a).slice(0, 3);
return (
<ol>
{top3.map((score, rank) => (
<li key={rank}>{score}</li>
))}
</ol>
);
}
여기서도 값을 바꾸는 코드는 한 줄입니다.
다른 점은 scores를 이 컴포넌트가 만들지 않았다는 것입니다.
퀴즈. 마지막 줄에서 무엇이 찍힐까요?
const getTop3 = (scores: number[]) => scores.sort((a, b) => b - a).slice(0, 3);
const scores = [70, 95, 80, 60, 88];
const top3 = getTop3(scores);
console.log(top3); // [95, 88, 80]
console.log(scores); // ?
출력 결과 보기
[95, 88, 80, 70, 60]
sort는 새 배열을 만들지 않고 호출한 배열 자체를 정렬합니다.
getTop3가 돌려준 top3는 slice로 만든 새 배열입니다.
다만 slice보다 먼저 실행된 sort가 원본 scores를 이미 정렬해 두었습니다.
TopScores가 받은 scores가 부모의 state라면 부모의 state 배열이 부모도 모르게 정렬됩니다.
같은 배열을 받아 입력 순서대로 보여 주려던 형제 컴포넌트도 정렬된 순서로 그립니다.
원본은 두고 복사본을 정렬하면 됩니다.
// 복사본을 만들어 정렬합니다
const top3 = [...scores].sort((a, b) => b - a).slice(0, 3);
// ES2023 의 toSorted 는 정렬된 새 배열을 돌려줍니다
const top3 = scores.toSorted((a, b) => b - a).slice(0, 3);
let total | scores.sort() | |
|---|---|---|
| 바뀌는 값을 만든 곳 | 이번 렌더링 안 | 부모 컴포넌트 |
| 렌더링이 끝난 뒤 | 버려짐 | 바뀐 채로 남음 |
| 바뀐 것을 보는 쪽 | CartTotal뿐 | 부모, 같은 배열을 받은 형제 |
| 순수 함수 조건 | 지킴 | 두 번째 조건 위반 |
둘 다 Mutation입니다. 영향이 닿는 범위가 다릅니다.
5. Local Mutation
Mutation 자체보다 변경된 값이 외부에 영향을 주는지가 중요합니다. 렌더링 중에 새로 만든 배열과 객체는 마음껏 바꿔도 됩니다.
5.1 컴포넌트 안에서 만든 배열에 push 하기
import type { ReactNode } from "react";
interface Props {
tags: string[];
}
export default function TagList({ tags }: Props) {
const items: ReactNode[] = [];
for (const tag of tags) {
items.push(<li key={tag}>#{tag}</li>); // 방금 만든 items 에 push
}
return <ul>{items}</ul>;
}
items는 이번 렌더링에서 빈 배열 []로 새로 생성됐습니다.
push로 계속 바꿔도 TagList 바깥의 코드는 이 배열이 있는지조차 모릅니다.
React 공식 문서는 이것을 Local Mutation이라고 부릅니다.
it's like your component's little secret.
컴포넌트만 아는 작은 비밀 같은 것입니다.
같은 화면은 tags.map(...)으로 push 없이 만들 수도 있습니다.
그래도 push를 피할 이유는 없습니다. 바뀌는 배열이 이번 렌더링에서 만든 items라서 TagList는 여전히 순수 함수입니다.
5.2 props로 받은 배열에 push 하기
export default function TagList({ tags }: Props) {
tags.push("NEW"); // 부모가 넘긴 배열에 push
return <p>태그 {tags.length}개</p>;
}
모양은 5.1과 거의 같습니다.
이번에는 push하는 배열이 부모 것입니다.
TagList가 렌더링될 때마다 부모의 tags에 "NEW"가 하나씩 쌓입니다.
부모가 ["react", "ts"]를 넘겼다면 첫 렌더링 뒤 3개, 다시 렌더링하면 4개가 됩니다.
부모가 같은 tags를 다른 컴포넌트에도 넘겼다면 그 컴포넌트도 늘어난 배열을 봅니다.
TagList가 부모 모르게 부모의 값을 바꾼 것이고, 이것이 부수 효과입니다.
| 구분 | Local Mutation | External Mutation |
|---|---|---|
| 바꾸는 대상 | 이번 렌더링에서 만든 배열·객체·변수 | props, state, 컴포넌트 바깥 변수 |
| 다시 렌더링하면 | 결과가 같음 | NEW가 또 쌓여 결과가 달라짐 |
| 바깥에서 보이는가 | 안 보임 | 부모, 형제, 다음 렌더링이 봄 |
| 렌더링 중에 | 써도 됨 | 쓰면 순수함이 깨짐 |
Mutation을 만나면 바뀐 값을 이번 렌더링 바깥에서 누가 보는지 확인합니다. 아무도 보지 않으면 그 Mutation은 계산의 일부입니다.
6. React 코드에서 계산과 부수 효과 구분하기
사용자 이름을 화면에 보여 주고 브라우저 저장소에도 기억해 두는 컴포넌트입니다.
import { useEffect } from "react";
interface User {
firstName: string;
lastName: string;
}
interface Props {
user: User;
}
export default function UserPage({ user }: Props) {
// 계산
const userName = `${user.firstName} ${user.lastName}`;
// 부수 효과
useEffect(() => {
localStorage.setItem("userName", userName);
}, [userName]);
return <h1>{userName}</h1>;
}
userName은 user만 보고 문자열을 만듭니다. localStorage.setItem은 브라우저 저장소를 바꿉니다.
2.3의 두 조건에 대 보면 앞은 계산, 뒤는 부수 효과입니다.
이번에는 이벤트 핸들러 대신 useEffect를 썼습니다.
userName은 부모가 넘긴 props에서 나오므로, UserPage 안에는 이름이 바뀌었다고 알려 주는 이벤트가 없습니다.
6.1 렌더링은 계산하는 단계
React가 UserPage를 렌더링한다는 것은 이 함수를 호출해 JSX를 받아 온다는 뜻입니다.
호출이 언제, 몇 번 일어날지는 React가 정합니다.
부모가 다시 렌더링될 때도, 개발 모드의 StrictMode가 렌더링이 순수한지 확인하려고 일부러 두 번 부를 때도 함수 본문 전체가 다시 실행됩니다. 본문에 계산만 있으면 몇 번 불려도 같은 JSX가 나옵니다.
React가 렌더링 코드에 순수함을 요구하는 이유가 이것입니다. React 공식 문서는 렌더링 코드를 이렇게 설명합니다.
Rendering code must be pure. Like a math formula, it should only calculate the result, but not do anything else.
렌더링 코드는 순수해야 합니다. 수학 공식처럼 결과를 계산하기만 하고 다른 일은 하지 않아야 합니다.
6.2 Effect는 커밋 뒤에 실행되는 부수 효과
렌더링 결과를 DOM에 반영하는 단계를 커밋이라고 합니다.
useEffect에 넘긴 함수는 커밋이 끝난 뒤에 실행됩니다.
의존성 배열의 값이 이전 커밋과 같으면 Effect를 건너뜁니다.
비교에는 Object.is를 씁니다.
| 구분 | 렌더링 | Effect |
|---|---|---|
| 하는 일 | 화면에 그릴 JSX 계산 | 바깥 세계와 동기화 |
| 실행 시점 | React가 필요할 때마다 | 커밋이 끝난 뒤 |
| 실행 횟수 | 여러 번일 수 있음 | 의존성 값이 바뀐 커밋마다 |
| 서버 렌더링에서 | 실행됨 | 실행되지 않음 |
Effect의 cleanup과 useLayoutEffect와의 실행 시점 차이는 의존성 배열을 사용하는 훅에 정리되어 있습니다.
7. 계산과 부수 효과를 분리해서 보기
앞에서 본 UserPage에서 setItem을 렌더링 안으로 옮긴 코드와 원래 코드를 나란히 놓고 봅니다.
7.1 계산과 부수 효과가 섞인 코드
export default function UserPage({ user }: Props) {
const userName = `${user.firstName} ${user.lastName}`;
localStorage.setItem("userName", userName);
return <h1>{userName}</h1>;
}
이 코드도 이름을 제대로 보여 주고 저장도 합니다. 렌더링이 한 번만 일어난다면 문제가 보이지 않습니다.
7.2 분리한 코드
export default function UserPage({ user }: Props) {
const userName = `${user.firstName} ${user.lastName}`;
useEffect(() => {
localStorage.setItem("userName", userName);
}, [userName]);
return <h1>{userName}</h1>;
}
두 코드의 차이는 setItem이 렌더링 안에 있는지, 커밋 뒤에 있는지 하나입니다.
렌더링 횟수는 두 코드가 같습니다. 달라지는 것은 setItem이 실행되는 횟수입니다.
| 상황 | 섞인 코드의 setItem | 분리한 코드의 setItem |
|---|---|---|
| 첫 화면 | 1번 | 1번 |
userName과 상관없는 state가 바뀌어 다시 렌더링 | 1번 더 | 호출 안 함 |
userName이 바뀌어 다시 렌더링 | 1번 더 | 1번 더 |
| 서버 렌더링 (Next.js 등) | localStorage is not defined 오류 | 실행 안 함 |
섞인 코드는 렌더링할 때마다 저장합니다. 저장할 이유가 생겼는지는 따지지 않습니다.
분리한 코드는 userName이 바뀐 커밋 뒤에만 저장합니다. 의존성 배열에 바깥을 다시 맞출 조건을 적어 둔 셈입니다.
개발 모드 StrictMode에서는 첫 화면의 setItem 호출이 두 코드 모두 2번입니다. 이유는 다릅니다.
섞인 코드는 렌더링이 두 번 호출되기 때문입니다. 분리한 코드는 StrictMode가 Effect를 setup → cleanup → setup 순서로 한 번 더 실행하기 때문입니다.
그 뒤 상관없는 state로 다시 렌더링하면 섞인 코드만 2번씩 더 저장합니다.
7.3 반대 방향으로 섞인 코드
부수 효과를 렌더링에 넣는 것과 반대로, 계산을 Effect에 넣는 경우도 있습니다.
import { useEffect, useState } from "react";
export default function UserPage({ user }: Props) {
const [userName, setUserName] = useState("");
useEffect(() => {
setUserName(`${user.firstName} ${user.lastName}`);
}, [user.firstName, user.lastName]);
return <h1>{userName}</h1>;
}
StrictMode를 끈 상태에서 user가 { firstName: "Gildong", lastName: "Hong" }이면 이 컴포넌트는 처음 나타날 때 두 번 렌더링됩니다.
- 첫 렌더링:
userName이 초깃값""인 채로 커밋됩니다. - Effect 실행:
setUserName("Gildong Hong")이 다시 렌더링을 예약합니다. - 두 번째 렌더링: 그제야
<h1>Gildong Hong</h1>이 커밋됩니다.
userName은 user만 있으면 바로 계산되는 값입니다.
state와 Effect를 거치면 낡은 값으로 한 번 렌더링하고 곧바로 다시 렌더링합니다.
React 공식 문서의 You Might Not Need an Effect(Effect가 필요 없을 수도 있습니다) 페이지가 첫 예제로 다루는 경우입니다.
고치는 방법은 state와 Effect를 지우고 const userName = `${user.firstName} ${user.lastName}`;로 렌더링 중에 바로 계산하는 것입니다.
7.1은 렌더링 자리에 부수 효과를 두었습니다. 7.3은 계산을 Effect 자리로 옮겼습니다. 방향만 반대이고 원인은 같습니다.
코드를 읽을 때 줄마다 이 줄이 바깥을 바꾸는지 물어보면 각 줄이 있어야 할 자리가 보입니다.
8. 정리
- 계산은 입력으로 값을 만들고 끝나는 코드입니다. 부수 효과는 함수 바깥의 상태를 바꿉니다.
- 순수 함수는 같은 입력에 같은 출력을 돌려주고 호출 전부터 있던 값을 바꾸지 않습니다.
- 부수 효과는 앱에 꼭 필요합니다. 렌더링 밖의 이벤트 핸들러나
useEffect에서 실행합니다. - Mutation은 바뀐 값이 누구 것인지에 따라 영향 범위가 다릅니다.
- 렌더링 중에 만든 값을 바꾸는 Local Mutation은 순수함을 깨지 않습니다. props나 바깥 변수를 바꾸는 External Mutation은 깹니다.
- 렌더링은 계산 단계라서 React가 여러 번 호출할 수 있습니다. Effect는 커밋 뒤, 의존성 값이 바뀌었을 때 실행됩니다.
- 부수 효과를 렌더링에 넣어도, 계산을 Effect에 넣어도 렌더링이 반복될 때 문제가 드러납니다.