본문으로 건너뛰기

컴포넌트를 순수하게 유지하기

일부 자바스크립트 함수는 순수합니다. 순수 함수는 계산만 수행하며 그 이상은 수행하지 않습니다. 컴포넌트를 순수한 함수로만 엄격하게 작성하면, 코드베이스가 커질 때 발생하는 당황스러운 버그와 예측 불가능한 동작을 방지할 수 있습니다. 그러나 이러한 이점을 얻으려면 몇 가지 규칙을 따라야 합니다.

학습 내용
  • 순수성이 무엇이며 순수성이 버그를 피하는 데 어떻게 도움이 되는지
  • 렌더링 단계에서 변경 사항을 제외하여 컴포넌트를 순수하게 유지하는 방법
  • 엄격 모드를 사용하여 컴포넌트에서 실수를 찾는 방법

순수성: 공식으로서의 컴포넌트

컴퓨터 과학(특히 함수형 프로그래밍의 세계)에서 순수 함수는 다음 특성을 갖는 함수입니다.

  • 자신의 일만 신경 씁니다. 호출되기 전에 존재했던 객체나 변수를 변경하지 않습니다.
  • 동일한 입력, 동일한 출력. 동일한 입력이 주어지면 순수 함수는 항상 동일한 결과를 반환해야 합니다.

순수 함수의 예로는 많이 봤을 수학 공식이 있습니다.

y = 2x라는 수학 공식이 있다고 가정해 보겠습니다.

x = 2이면 y = 4입니다. 항상 그렇습니다.

x = 3이면 y = 6입니다. 항상 그렇습니다.

x = 3이면 시간이나 주식 시장의 상태에 따라 y9 또는 –1 또는 2.5이거나 그 이외의 수일 수 있습니다.

y = 2x이고 x = 3이면 y항상 6입니다.

이를 자바스크립트 함수로 만들면 다음과 같습니다.

jsx
function double(number) {
return 2 * number;
}
jsx
function double(number) {
return 2 * number;
}

위의 예시에서 double순수 함수입니다. 3을 전달하면 함수는 6을 반환합니다. 항상 그렇습니다.

리액트는 이 개념을 중심으로 설계되었습니다. 리액트는 여러분이 작성하는 모든 컴포넌트가 순수 함수라고 가정합니다. 즉, 작성된 리액트 컴포넌트에 동일한 입력이 주어지면 항상 동일한 JSX를 반환해야 합니다.

jsx
function Recipe({ drinkers }) {
return (
<ol>
<li>Boil {drinkers} cups of water.</li>
<li>
Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.
</li>
<li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
</ol>
);
}
export default function App() {
return (
<section>
<h1>Spiced Chai Recipe</h1>
<h2>For two</h2>
<Recipe drinkers={2} />
<h2>For a gathering</h2>
<Recipe drinkers={4} />
</section>
);
}
jsx
function Recipe({ drinkers }) {
return (
<ol>
<li>Boil {drinkers} cups of water.</li>
<li>
Add {drinkers} spoons of tea and {0.5 * drinkers} spoons of spice.
</li>
<li>Add {0.5 * drinkers} cups of milk to boil and sugar to taste.</li>
</ol>
);
}
export default function App() {
return (
<section>
<h1>Spiced Chai Recipe</h1>
<h2>For two</h2>
<Recipe drinkers={2} />
<h2>For a gathering</h2>
<Recipe drinkers={4} />
</section>
);
}

drinkers={2}Recipe에 전달하면 2 cups of water가 포함된 JSX가 반환됩니다. 항상 그렇습니다.

drinkers={4}를 전달하면 4 cups of water가 포함된 JSX가 반환됩니다. 항상 그렇습니다.

이는 수학 공식과 동일합니다.

컴포넌트를 조리법에 비유할 수 있습니다. 조리법을 따르고 요리 과정에서 새로운 재료를 도입하지 않으면 매번 같은 요리를 얻을 수 있습니다. 여기서 JSX가 접시입니다. 컴포넌트는 렌더링될 것을 리액트에 JSX로 제공합니다.

부작용: 의도한(의도하지 않은) 결과

리액트의 렌더링 프로세스는 항상 순수해야 합니다. 컴포넌트는 JSX만 반환해야 하며, 렌더링 전에 존재했던 객체나 변수를 변경해서는 안 됩니다. 이는 컴포넌트를 순수하지 않게 만들 수 있습니다.

다음은 이 규칙을 위반하는 컴포넌트입니다.

jsx
let guest = 0;
function Cup() {
// 기존 변수를 변경하므로 좋지 않습니다!
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}
jsx
let guest = 0;
function Cup() {
// 기존 변수를 변경하므로 좋지 않습니다!
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}

이 컴포넌트는 외부에서 선언된 guest 변수를 읽고 쓰고 있습니다. 즉, 이 컴포넌트를 여러 번 호출하면 다른 JSX가 생성됩니다! 게다가 다른 컴포넌트가 guest를 읽으면, 렌더링 시점에 따라 다른 JSX를 생성합니다! 이건 예측 가능하지 않습니다.

공식 y = 2x에 비유하자면, x = 2일 때 y = 4가 된다는 보장이 없는 것입니다. 테스트는 실패할 수 있고, 사용자는 당황할 수 있고, 비행기는 하늘에서 떨어질 수 있습니다. 이것이 어떻게 혼란스러운 버그로 이어질 수 있는지 우리는 잘 알고 있습니다!

대신 guest를 프롭으로 전달하여 이 컴포넌트를 고칠 수 있습니다.

jsx
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup guest={1} />
<Cup guest={2} />
<Cup guest={3} />
</>
);
}
jsx
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup guest={1} />
<Cup guest={2} />
<Cup guest={3} />
</>
);
}

이제 컴포넌트는 순수합니다. 컴포넌트가 반환하는 JSX가 guest 프롭에만 의존하기 때문입니다.

일반적으로 컴포넌트가 특정 순서로 렌더링될 것으로 기대해서는 안 됩니다. y = 2x의 호출 시기가 y = 5x 이전인지 이후인지는 상관없습니다. 두 공식은 서로 독립적으로 해결되기 때문입니다.

마찬가지로 각 컴포넌트는 렌더링 중에 다른 컴포넌트와 조율하거나 의존하지 말고 스스로 생각해야 합니다. 렌더링은 학교 시험과 같습니다. 각 컴포넌트는 스스로 JSX를 계산해야 합니다!

심화

StrictMode로 순수하지 않은 계산 감지하기

아직 모두 사용하지는 않았지만 리액트에는 렌더링하는 동안 읽을 수 있는 props, state, context라는 세 가지 입력이 있습니다. 이 입력은 항상 읽기 전용으로 다뤄야 합니다.

사용자 입력에 따라 무언가를 변경하려면, 변수에 쓰는 대신 상태를 설정해야 합니다. 컴포넌트가 렌더링되는 동안에는 기존 변수나 객체를 변경해서는 안 됩니다.

리액트는 개발 중에 각 컴포넌트의 함수를 두 번 호출하는 엄격 모드를 제공합니다. 컴포넌트 함수를 두 번 호출함으로써 엄격 모드는 위 규칙을 위반하는 컴포넌트를 찾습니다.

기존 예시에서 guest #1, guest #2, guest #3 대신 guest #2, guest #4, guest #6이 어떻게 표시되었는지 기억하세요. 기존 함수는 순수하지 않으므로 함수를 두 번 호출하면 규칙이 위반됩니다. 그러나 수정된 순수 함수는 두 번씩 호출되어도 문제없이 작동합니다. 순수 함수는 계산만 하므로 두 번 호출해도 아무것도 변경되지 않습니다. double(2)을 두 번 호출해도 반환되는 것이 바뀌지 않습니다. y = 2x를 두 번 풀어도 y는 바뀌지 않습니다. 동일한 입력, 동일한 출력. 항상 그렇습니다.

엄격 모드는 프로덕션에 영향을 미치지 않으므로 사용자의 앱 속도를 저하시키지 않습니다. 엄격 모드를 선택하려면 최상위 컴포넌트를 <React.StrictMode>로 감싸면 됩니다. 일부 프레임워크는 기본적으로 이 작업을 수행합니다.

지역 변형: 컴포넌트의 작은 비밀

위의 예시에서의 문제는 렌더링 중에 컴포넌트가 기존 변수를 변경했다는 것입니다. 이를 종종 변형(mutation)이라고 부르는데 조금 무섭게 들리기도 합니다. 순수 함수는 함수의 범위 밖에 있는 변수나 호출 전에 생성된 객체를 변경하지 않습니다. 이는 함수를 순수하지 않게 만듭니다!

그러나 렌더링 중에 방금 만든 변수와 객체를 변경하는 것은 전혀 문제가 되지 않습니다. 다음 예시에서는 [] 배열을 만들고, cups 변수에 할당한 다음, 12개의 컵을 push합니다.

jsx
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaGathering() {
let cups = [];
for (let i = 1; i <= 12; i++) {
cups.push(<Cup key={i} guest={i} />);
}
return cups;
}
jsx
function Cup({ guest }) {
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaGathering() {
let cups = [];
for (let i = 1; i <= 12; i++) {
cups.push(<Cup key={i} guest={i} />);
}
return cups;
}

cups 변수나 [] 배열이 TeaGathering 함수 외부에서 생성되었다면 큰 문제가 될 것입니다! 항목을 해당 배열에 넣어 기존 객체가 변경되기 때문입니다.

그러나 TeaGathering 내부에서 동일한 렌더링 중에 생성했기 때문에 괜찮습니다. TeaGathering 외부의 어떤 코드도 이런 일이 생겼던 것을 알 수 없습니다. 이를 지역 변형이라고 부르며 컴포넌트의 작은 비밀로도 이해할 수 있습니다.

부작용을 일으킬 수 있는 곳

함수형 프로그래밍은 순수성에 크게 의존하지만, 언젠가, 어딘가에서는 무언가가 변경되어야 합니다. 이는 프로그래밍의 핵심입니다! 화면 갱신, 애니메이션 시작, 데이터 변경과 같은 변화를 부작용(side effect)이라고 합니다. 이는 렌더링 도중이 아니라 부수적으로(on the side) 발생하는 것입니다.

리액트에서 부작용은 일반적으로 이벤트 처리기에 속합니다. 이벤트 처리기는 어떤 작업을 수행할 때(예: 버튼 클릭) 리액트가 실행하는 함수입니다. 이벤트 처리기는 컴포넌트 내부에서 정의되지만 렌더링 중에는 실행되지 않습니다! 따라서 이벤트 처리기는 순수할 필요가 없습니다.

다른 옵션을 모두 사용했지만 부작용에 적합한 이벤트 처리기를 찾을 수 없다면, 컴포넌트에서 useEffect 호출을 사용하여 반환된 JSX에 연결할 수 있습니다. 이렇게 하면 나중에, 렌더링 이후에, 부작용이 허용될 때 실행하도록 리액트에 지시합니다. 하지만 이 방법은 최후의 수단이어야 합니다.

가능하면 렌더링만으로 논리를 표현하세요. 이 규칙을 따르면 먼 곳까지 갈 수 있을 것입니다!

심화

리액트는 왜 순수성을 중요하게 생각하나요?

순수 함수를 작성하려면 약간의 습관과 훈련이 필요합니다. 그러나 이를 극복하면 다음과 같은 놀라운 기회를 제공합니다.

  • 컴포넌트가 다른 환경(예: 서버)에서 실행될 수 있습니다! 동일한 입력에 대해 동일한 결과를 반환하므로 하나의 컴포넌트가 많은 사용자 요청을 처리할 수 있습니다.
  • 입력이 변경되지 않은 컴포넌트는 렌더링을 건너뛰어 성능을 향상시킬 수 있습니다. 순수 함수는 항상 동일한 결과를 반환하므로 캐시에 안전합니다.
  • 깊은 컴포넌트 트리를 렌더링하는 도중에 일부 데이터가 변경되면, 리액트는 오래된 렌더링을 완료하는 데 시간을 낭비하지 않고 렌더링을 다시 시작할 수 있습니다. 순수성 덕분에 언제든지 계산을 중단해도 안전합니다.

우리가 만들고 있는 새로운 리액트 기능은 모두 순수성을 활용합니다. 데이터 가져오기에서 애니메이션, 성능에 이르기까지 컴포넌트를 순수하게 유지하면 리액트 패러다임의 힘이 발휘됩니다.

요약

  • 컴포넌트는 순수해야 합니다. 이는 다음을 의미합니다.
    • 자신의 일만 신경 씁니다. 렌더링 전에 존재했던 객체나 변수를 변경해서는 안 됩니다.
    • 동일한 입력, 동일한 출력. 동일한 입력이 주어지면 컴포넌트는 항상 동일한 JSX를 반환해야 합니다.
  • 렌더링은 언제든지 발생할 수 있으므로 컴포넌트는 서로의 렌더링 순서에 의존하지 않아야 합니다.
  • 컴포넌트가 렌더링에 사용하는 입력을 변경해서는 안 됩니다. 여기에는 프롭, 상태, 컨텍스트가 포함됩니다. 화면을 갱신하려면 기존 객체를 변경하는 대신 상태를 설정합니다.
  • 반환하는 JSX에서 컴포넌트의 논리를 표현하도록 노력하세요. 변경이 필요하다면, 보통은 이벤트 처리기에서 변경하는 것이 좋습니다. 최후의 수단으로 useEffect를 사용할 수 있습니다.
  • 순수 함수를 작성하려면 약간의 연습이 필요하지만, 이는 리액트 패러다임의 힘을 발휘하게 해 줍니다.