React is designed around this concept. React assumes that every component you write is a pure function. This means that React components you write must always return the same JSX given the same inputs.
이 문장은 리액트의 핵심 철학을 완벽하게 요약하고 있습니다.
이번 글에서는 이 개념이 리액트의 설계와 성능에 어떤 영향을 미치는지 살펴보려 합니다.
순수 함수는 함수형 프로그래밍의 핵심 개념으로, 두 가지 조건을 만족해야 합니다.
1. 결정론적 출력(Deterministic output): 동일한 입력에 대해 항상 동일한 출력을 반환한다.
2. 부수 효과 없음(No side effects): 함수 외부의 상태를 변경하지 않는다.
function add(a, b) {
return a + b;
}
function calculateTax(amount, taxRate) {
return amount * taxRate;
}
function createGreeting(name) {
return `Hello, ${name}!`;
}
이 함수들은 모두 순수합니다. 동일한 입력에 대해 항상 동일한 결과를 반환하며, 외부 상태를 변경하지 않습니다.
let total = 0;
function addToTotal(value) {
total += value; // 외부 상태 변경
return total;
}
function logMessage(message) {
console.log(message); // 콘솔 출력은 부수 효과
return message;
}
function getRandomNumber() {
return Math.random(); // 호출할 때마다 다른 값 반환
}
이 함수들은 외부 상태를 변경하거나(addToTotal), 외부 시스템(콘솔 창)과 상호작용하거나(logMessage), 호출할 때마다 다른 결과를 반환하므로(random) 순수하지 않습니다.
왜 순수함수를 알아야할까요?
개인적으로 순수 함수를 이해하는 것 = 예측 가능한 로직을 짤 수 있게 된다고 생각합니다. 또한 다음과 같은 이점 때문에, 리액트는 컴포넌트를 순수 함수로 설계하도록 유도한다고 봅니다.
1. 디버깅 용이 : 버그가 발생하면, 입력과 출력만 확인하면 되므로 문제의 원인을 찾기 쉽다고 생각합니다.
2. 코드 품질 : 책임이 명확히 분리되어 있어 코드 이해와 유지 관리가 쉽습니다.
3. 병렬 실행 : 외부 상태에 의존하지 않으므로 동시에 여러 인스턴스를 안전하게 실행할 수 있습니다.
리액트 공식 문서에서 강조하는 “React assumes that every component you write is a pure function.” 이 철학은 리액트의 선언적 프로그래밍 모델의 핵심입니다. 컴포넌트가 순수할수록 리액트는 UI를 예측 가능하고 효율적으로 렌더링할 수 있습니다.
순수 컴포넌트의 기본 예시를 살펴보겠습니다.
function Greeting({ name }) {
return <h1>Hello, {name}</h1>;
}
이 함수는 동일한 name props를 받으면 항상 동일한 결과를 반환합니다. 외부 상태를 변경하지 않으며, 렌더링 과정에서 부수 효과를 일으키지 않습니다. 컴포넌트가 순수 함수라는 가정은 리액트의 여러 핵심 메커니즘을 가능하게 합니다. 그 중 메모이제이션과 관련해 이를 살펴보겠습니다.
메모이제이션이란, 이전에 계산한 값을 저장해두고 동일한 입력이 주어질 때 재계산 없이 저장된 결과를 반환하는 최적화 기법입니다.
메모이제이션과 순수성
메모이제이션은 “입력이 같으면 계산 결과를 저장해두고 재사용하는” 최적화 기법입니다. 리액트에서는 React.memo, useMemo, useCallback 같은 최적화 기법들은 컴포넌트와 함수의 순수성에 의존합니다.
// React.memo 예시
const MemoizedComponent = React.memo(({ data }) => {
return <div>{/* 결과 렌더링 */}</div>;
});
// useMemo 예시
function ExpensiveComponent({ data }) {
const processedData = useMemo(() => {
return expensiveCalculation(data);
}, [data]);
return <div>{processedData}</div>;
}
// useCallback 예시
function ParentComponent() {
const handleClick = useCallback(() => {
console.log('Button clicked!');
}, []);
return <ChildComponent onClick={handleClick} />;
}
React.memo는 기본적으로 얕은 비교를 통해 이전 props와 새 props를 비교하여, 변경이 없다면 이전 렌더링 결과를 재사용합니다. 이는 컴포넌트가 순수하다는 가정 하에서만 유효합니다.
const MemoizedBad = React.memo(({ staticProp }) => {
const now = new Date();
return (
<div>
<p>Props: {staticProp}</p>
<p>Current time: {now.toLocaleTimeString()}</p>
</div>
);
});
비순수 컴포넌트에서는 이런 최적화가 잘못된 결과를 초래할 수 있습니다. props가 변경되지 않아도 시간은 업데이트되어야 하는데 React.memo로 인해 업데이트되지 않을 수 있기 때문입니다.
이러한 문제는 외부 의존성을 props로 전달하거나, useEffect와 같은 훅을 사용하여 부수 효과를 관리함으로써 해결할 수 있습니다.
const MemoizedGood = React.memo(({ staticProp, currentTime }) => {
return (
<div>
<p>Props: {staticProp}</p>
<p>Current time: {currentTime}</p>
</div>
);
});
리액트에서 모든 컴포넌트는 순수 함수라는 가정은 단순한 이론적 개념이 아니라, 예측 가능하고 효율적인 UI 렌더링을 위한 실질적인 원칙이라고 생각합니다. 메모이제이션 기법들 또한 이 원칙을 기반으로 동작하며, 컴포넌트가 순수할 때 그 효과를 극대화할 수 있다고 느꼈습니다.
멱등성은 연산을 여러 번 적용해도 결과가 변하지 않는 성질입니다. 리액트 컴포넌트에 적용하면, 같은 props와 state로 여러번 렌더링해도 동일한 결과가 나와야 한다는 의미입니다.
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Counter 컴포넌트는 멱등적입니다. 같은 initialCount와 count 상태로 여러 번 렌더링해도 동일한 UI가 생성됩니다.
멱등성이 중요한 이유는 다음과 같습니다.
순수 함수와 멱등성, 같은 말 아닌가요?
리액트 공식 문서에서 순수 함수와 멱등성이 함께 언급된 부분을 보면서 살짝 혼란스러웠습니다. 처음에는 두 개념이 같은 의미를 가진다고 생각했기 때문입니다. 그러나 현실적으로는 사이드 이펙트가 전혀 없는 프로젝트를 만드는 것이 어렵고, 완벽하게 순수함을 유지하는 것도 쉽지 않습니다. 그래서 리액트에서는 최소한 멱등성을 지키는 것을 목표로 하는 게 아닐까 하는 생각이 들었습니다.
주요 차이점
구분 순수 함수 멱등 함수 적용 관점 입력과 출력의 관계에 초점
(동일 입력 → 동일 출력)반복 적용에 초점
(여러 번 적용해도 결과는 첫 번째 적용과 동일)부수 효과 부수 효과가 절대 없어야 함 부수 효과가 있을 수 있음
(첫 번째 호출에서는 부수 효과가 있어도, 이후 호출에서는 결과가 변하지 않음)실제 적용 예 함수형 프로그래밍의 핵심 원칙 HTTP 메서드(GET, PUT, DELETE),
분산 시스템 설계에서 중요한 개념리액트에서의 적용 컴포넌트의 렌더링 함수가 순수해야 함 같은 상태와 props로 여러 번 렌더링해도 동일한 결과가 나와야 함
Strict Mode는 왜 필요한가요?
Strict Mode는 개발 환경에서 컴포넌트를 일부러 두 번 렌더링하여 부수 효과를 조기에 감지할 수 있도록 돕습니다. 이를 통해 순수하지 않은 로직이나, 예상치 못한 사이드 이펙트를 사전에 발견하고 수정할 수 있습니다.
리액트 컴포넌트의 핵심은 렌더링 함수의 순수성이지만, 실제 애플리케이션에서는 데이터 패치, 이벤트 설정, DOM 조작과 같은 부수 효과가 필요합니다. 리액트는 이러한 부수 효과를 렌더링 과정과 명확히 분리하여 관리하도록 설계되었고, 이것이 바로 useEffect 훅이 탄생한 이유이지 않을까 생각합니다.
function UserData({ userId }) {
const [user, setUser] = useState(null);
if (!user) {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => setUser(data));
}
return <div>{user ? user.name : 'Loading...'}</div>;
}
이 코드는 렌더링 중에 부수 효과를 발생시켜 무한 루프와 예측 불가능한 동작을 불러 일으킵니다.
왜 예측 불가능하다고 하는건가요?
외부 데이터 소스(API)에 의존하기 때문에 같은userId를 입력해도 다른 결과를 반환할 수 있습니다. 순수 함수는 외부 상태에 의존하지 않아야 하는데, API 호출은 네트워크 상태, 서버 상태 등 컴포넌트 외부 요소에 의존합니다.
- 첫 번째 호출 시 API 서버가 정상이면 사용자 데이터를 반환
- 두 번째 호출 시 API 서버에 문제가 있으면 오류 반환
- 세 번째 호출 시 사용자 데이터가 업데이트되었다면 다른 결과 반환
올바른 접근법은 useEffect를 사용하여 부수 효과를 렌더링 이후 단계로 미루는 것입니다.
function UserData({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => setUser(data));
}, [userId]);
return <div>{user ? user.name : 'Loading...'}</div>;
}
이렇게 부수 효과를 격리함으로써 다음과 같은 이점을 얻을 수 있습니다.
useEffect를 사용하여 컴포넌트 마운트 또는 의존성 변경 시 데이터를 가져옵니다.useEffect의 설정 및 정리 패턴을 사용합니다.useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useRef와 useEffect를 조합하여 처리합니다.const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
setTimeout과 setInterval도 useEffect 내에서 관리합니다.setState의 성격을 정확히 이해하기 위해서는 리액트의 렌더링 모델과 부수효과의 정의를 함께 살펴볼 필요가 있습니다.
setState는 두 가지 관점에서 볼 수 있습니다.
함수 자체로서의 setState
기술적으로 setState 호출은 부수효과입니다. 컴포넌트 외부의 상태(리액트의 내부 상태)를 변경하기 때문입니다. 이는 순수 함수의 정의(동일 입력에 대해 동일 출력만 반환하고 외부 상태를 변경하지 않음)에 위배됩니다.
리액트 시스템 내에서의 setState
리액트의 설계 관점에서 setState는 공식적인 상태 업데이트 메커니즘으로 간주됩니다. 리액트는 개발자가 렌더링 함수 외부(이벤트 핸들러, useEffect 등)에서 setState를 호출하도록 의도적으로 설계되었습니다.
리액트팀은 setState를 관리된 부수 효과로 설계했습니다. 이는 렌더링 과정에서는 상태를 직접 변경하지 않고, 이벤트 핸들러나 특정 안전한 지점에서만 상태를 업데이트하도록 유도하기 위한 설계입니다.
간단한 예제를 통해 살펴보겠습니다.
function BadComponent() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
}
위 코드에서는 컴포넌트가 렌더링되는 동안 setCount를 호출하고 있습니다. 렌더링 → 상태 변경 → 다시 렌더링의 반복이 끝없이 이어지기 때문에, 무한 루프가 발생합니다. 리액트는 이러한 문제를 막기 위해 렌더링 중에는 상태를 변경하지 말 것을 원칙으로 하고 있습니다. (순수함수의 특징을 위반하기 때문이죠.)
반면, 상태를 안전하게 변경하려면 이벤트 핸들러 같은 렌더링 외부의 흐름에서 setState를 호출해야 합니다.
function GoodComponent() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
return <button onClick={handleClick}>{count}</button>;
}
이 경우에는 사용자가 버튼을 클릭할 때 handleClick이 실행되고, 그때 setCount로 상태를 변경합니다. 이벤트 핸들러는 렌더링과는 별개의 시점에서 실행되기 때문에 안전합니다.
리액트 파이프라인 내에서 setState의 위치를 더 자세히 살펴보면 다음과 같습니다.
setState 호출은 렌더링 단계가 아닌 이벤트 처리 단계에서 발생하므로 순수성을 해치지 않습니다.
setState는 기술적으로는 부수효과이지만, 리액트 시스템 내에서는 의도된 상태 업데이트 메커니즘으로 간주됩니다. 중요한 점은 다음 3가지이지 않을까 생각합니다.
리액트의 핵심 철학은 렌더링 단계의 순수성을 유지하면서, 부수효과는 명확하게 분리된 위치에서 관리하도록 하는 것입니다. 이러한 구분은 복잡한 UI 로직을 쉽게 이해하고 유지보수할 수 있게 해주는 리액트의 가장 큰 장점 중 하나라고 생각합니다.
이는 단순히 “코드를 함수처럼 작성하자”는 수준을 넘어, UI를 개발할 때 함수형 사고를 자연스럽게 녹여내는 하나의 효과적인 방법이라고 생각합니다. 이러한 관점에서 리액트의 순수 함수 원칙을 따르면, 다음과 같은 이점을 얻을 수 있습니다.
결국, 순수 함수와 부수 효과를 명확히 분리하는 리액트의 구조는, 복잡성과 버그를 줄이고, 복잡한 상호작용 속에서도 UI가 일관되게 동작하게 하여 사용자 경험을 향상시키는 데 큰 역할을 한다고 느꼈습니다.
참고 자료
https://ko.react.dev/reference/rules/components-and-hooks-must-be-pure
https://ko.react.dev/learn/keeping-components-pure