안녕하세요, 여러분! 오늘은 React.js를 다루면서 정말 자주 듣게 되고, 또 그만큼 중요한 개념인 '컴포넌트의 순수성(Purity)'에 대해 깊이 있게 다뤄보려고 합니다.
자바스크립트 함수 중에는 순수(pure) 함수라는 것이 있습니다. 순수 함수는 오직 계산만 수행하고 그 외의 다른 작업은 전혀 하지 않는 함수를 말해요. 여러분이 작성하는 React 컴포넌트들을 엄격하게 이런 순수 함수로만 작성한다면, 코드베이스가 점점 커지더라도 원인을 알 수 없는 기괴한 버그나 예측 불가능한 동작들을 통째로 피할 수 있게 됩니다.
하지만 이런 엄청난 이점을 얻기 위해서는 여러분이 반드시 지켜주셔야 할 몇 가지 규칙들이 있어요. 오늘 저와 함께 그 규칙들을 하나씩 파헤쳐 보겠습니다!
컴퓨터 과학(그리고 특히 함수형 프로그래밍의 세계)에서 순수 함수(a pure function)는 다음과 같은 특징을 가진 함수를 말합니다.
아마 여러분은 이미 순수 함수의 아주 좋은 예시 하나를 알고 계실 거예요. 바로 수학 시간에 배운 '수학 공식'입니다.
다음과 같은 수학 공식을 한 번 생각해 볼까요? y = 2x
만약 x = 2라면, y = 4가 됩니다. 이건 항상 그래요. 예외는 없습니다.
만약 x = 3이라면, y = 6이 됩니다. 이것도 항상 그렇죠.
x = 3일 때, 하루의 시간대나 주식 시장의 상태 같은 외부 요인에 따라서 y가 갑자기 9가 되거나 -1이 되거나 2.5가 되는 일은 절대 없습니다.
y = 2x 라는 공식에서 x = 3 이라면, y는 언제나 6이 됩니다.
이걸 자바스크립트 함수로 만들어보면 다음과 같은 모습이 될 거예요.
function double(number) {
return 2 * number;
}
위의 예시에서 double 함수는 순수 함수입니다. 이 함수에 3을 넘겨주면, 무조건 6을 반환할 거예요. 언제나요!
React는 바로 이 '순수 함수'라는 개념을 중심으로 설계되었습니다. React는 여러분이 작성하는 모든 컴포넌트가 순수 함수라고 가정합니다. 이 말은 즉, 여러분이 작성한 React 컴포넌트에 동일한 입력값(Props 등)이 주어지면, 언제나 동일한 JSX를 반환해야 한다는 뜻입니다. 코드를 한 번 볼까요?
// src/App.js
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>
);
}
여러분이 Recipe 컴포넌트에 drinkers={2}를 전달하면, 이 컴포넌트는 2 cups of water가 포함된 JSX를 반환합니다. 항상요.
만약 drinkers={4}를 전달하면, 이번엔 4 cups of water가 포함된 JSX를 반환하겠죠. 항상요.
마치 수학 공식처럼 딱 떨어지지 않나요?
여러분의 컴포넌트를 요리 레시피라고 생각해 보세요. 레시피를 그대로 따르고 요리 과정 중간에 임의로 새로운 재료를 막 추가하지 않는다면, 여러분은 매번 똑같은 요리를 완성하게 될 겁니다. 그 "요리(dish)"가 바로 컴포넌트가 React에게 렌더링(render)해달라고 제공하는 JSX인 셈이죠.

React의 렌더링 과정은 반드시 항상 순수해야 합니다. 컴포넌트들은 오직 자신들의 JSX를 반환(return) 하기만 해야 하고, 렌더링되기 전에 존재했던 그 어떤 객체나 변수도 변경(change) 해서는 안 됩니다. 그렇게 변경해 버리면 그 순간 컴포넌트는 '불순(impure)'해지게 되거든요!
아래에 이 규칙을 시원하게 깨버리는 컴포넌트 예시가 있습니다. 주의 깊게 봐주세요.
// expectedErrors: {'react-compiler': [5]}
// src/App.js
let guest = 0;
function Cup() {
// Bad: 이미 존재하는 외부 변수를 변경하고 있습니다! (사이드 이펙트 발생)
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를 prop으로 전달(passing props)하는 방식으로 깔끔하게 고칠 수 있습니다.
// src/App.js
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 prop에만 의존하게 되었으니까요.
일반적으로, 여러분은 컴포넌트들이 어떤 특정한 순서로 렌더링될 거라고 기대해서는 안 됩니다. y = 2x 를 먼저 계산하든 y = 5x 를 먼저 계산하든 순서는 상관없는 것처럼 말이죠. 두 공식은 서로 아무런 영향을 주지 않고 독립적으로 계산됩니다.
이와 마찬가지로, 각각의 컴포넌트는 오직 "스스로를 위해서만 생각해야" 하며, 렌더링 과정에서 다른 컴포넌트들과 서로 조율하려고 하거나 의존해서는 안 됩니다. 렌더링은 마치 학교 시험과 같아요. 각 컴포넌트는 오직 자기 자신의 시험지(JSX)만 스스로 풀어내야 합니다! 남의 시험지를 훔쳐보거나 답을 바꿔주면 안 되겠죠?
아직 이 모든 걸 다 사용해보진 않으셨겠지만, React에는 렌더링 중에 읽을 수 있는 세 가지 종류의 입력값이 있습니다. 바로 props, state, 그리고 context 입니다. 여러분은 항상 이 입력값들을 '읽기 전용(read-only)'으로만 취급해야 합니다.
만약 사용자의 입력에 반응해서 무언가를 변경하고 싶다면, 변수에 값을 직접 쓰는 대신 반드시 state를 설정(set state)해야 합니다. 컴포넌트가 렌더링되는 도중에는 절대로 기존에 존재하던 변수나 객체를 직접 변경해서는 안 됩니다.
강사 보충 설명: 개발하다 보면 "선생님! 콘솔 로그(console.log)를 한 번만 적었는데 왜 두 번씩 출력되나요?"라고 질문하시는 분들이 정말 많습니다. 이게 바로 React의 "Strict Mode" 때문이랍니다!
React는 개발(development) 환경에서 각 컴포넌트 함수를 두 번씩 호출하는 "Strict Mode(엄격 모드)"를 제공합니다. 컴포넌트 함수를 일부러 두 번씩 실행해봄으로써, Strict Mode는 위에서 배운 순수성 규칙을 위반하는 컴포넌트를 쉽게 찾아낼 수 있도록 도와줍니다.
아까 잘못된 예시 코드를 실행했을 때 "Guest #1", "Guest #2", "Guest #3" 대신 "Guest #2", "Guest #4", "Guest #6"이 화면에 나타난 것을 눈치채셨나요? 원본 함수가 불순(impure)했기 때문에, 함수가 두 번씩 호출되면서 결과가 완전히 망가져 버린 것입니다.
하지만 guest를 prop으로 받아 수정한 순수한 버전의 컴포넌트는 함수가 매번 두 번씩 호출되더라도 완벽하게 잘 작동합니다. 순수 함수는 오직 계산만 수행하므로 두 번 호출한다고 해서 결과가 달라지거나 다른 곳에 영향을 주지 않기 때문이죠. double(2)를 두 번 호출한다고 반환값이 바뀌지 않는 것처럼요! 같은 입력엔, 같은 출력. 언제나요!
Strict Mode는 프로덕션(실제 서비스 환경)에서는 아무런 영향을 주지 않으므로 사용자의 앱 속도를 느려지게 하지 않습니다. Strict Mode를 사용하려면 루트 컴포넌트를 <React.StrictMode>로 감싸면 되는데, 최신 프레임워크(Next.js 등)들은 기본적으로 이 모드가 켜져 있기도 합니다.
위에서 봤던 문제의 핵심은 컴포넌트가 렌더링 중에 이미 기존에 존재하던 외부 변수를 변경했다는 점이었습니다. 프로그래밍에서는 이걸 좀 더 무섭게 들리도록 "변이(mutation)"라고 부르기도 해요. 순수 함수는 함수 스코프 외부에 있는 변수나, 함수가 호출되기 전에 만들어진 객체를 '변이(mutate)'시키지 않습니다. 그렇게 하면 함수가 불순해지니까요!
하지만, 렌더링하는 동안에 여러분이 '방금 막 새로 생성한' 변수나 객체를 변경하는 것은 완전히 괜찮습니다! 아래 예시를 볼까요? 여러분은 [] 빈 배열을 만들어서 cups라는 변수에 할당한 다음, 그 안에 12개의 컵을 push해서 집어넣습니다.
// src/App.js
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 바깥에 있는 그 어떤 코드도 배열 안에서 이런 일이 벌어졌다는 사실을 알 길이 없습니다.
우리는 이것을 "지역 변이(local mutation)"라고 부릅니다. 마치 외부에는 철저히 감춰진, 컴포넌트 자신만의 작은 비밀 공간 같은 거라고 생각하시면 돼요. 이 비밀은 컴포넌트 밖으로 새어 나가지 않기 때문에 안전합니다!
지금까지 함수형 프로그래밍과 순수성에 대해 열변을 토했지만, 솔직히 프로그래밍을 하다 보면 어딘가에서는, 어느 시점에서는 무언가가 반드시 변경되어야만 합니다. 화면에 데이터를 보여주기만 할 순 없잖아요? 화면을 업데이트하고, 애니메이션을 시작하고, 데이터를 변경하는 이런 모든 작업들을 사이드 이펙트(side effects, 부수 효과)라고 부릅니다. 이런 것들은 렌더링 "도중"에 일어나는 게 아니라, 렌더링과 독립적으로 "측면에서(on the side)" 벌어지는 일들이죠.
React에서 사이드 이펙트들은 대개 이벤트 핸들러(event handlers) 안에 위치하게 됩니다. 이벤트 핸들러란 여러분이 버튼을 클릭하는 등의 액션을 취할 때 React가 실행하는 함수들을 말합니다. 비록 이벤트 핸들러 함수 자체는 컴포넌트 내부에 정의되어 있지만, 이 함수들은 렌더링 도중에 실행되는 것이 아닙니다! 그렇기 때문에 이벤트 핸들러는 순수 함수일 필요가 없습니다. 마음껏 변이를 일으키고 사이드 이펙트를 만들어도 괜찮습니다.
강사 보충 설명: 만약 모든 방법을 다 써봤는데도 여러분의 사이드 이펙트를 처리할 적절한 이벤트 핸들러를 도저히 찾지 못했다면 어떡할까요? 그럴 때는 컴포넌트 안에서 useEffect를 호출해서 반환된 JSX에 사이드 이펙트를 연결할 수 있습니다. 이렇게 하면 React에게 "렌더링이 전부 끝난 다음에, 사이드 이펙트가 허용되는 시점에 이 코드를 실행해줘!"라고 지시하게 됩니다. 하지만 주의하세요! useEffect 접근 방식은 언제나 여러분의 '최후의 보루(last resort)'가 되어야 합니다. useEffect를 남용하면 코드의 흐름을 쫓기 어려워지고 앱의 성능이 떨어질 수 있거든요.
가능한 한, 오직 렌더링 과정만으로 여러분의 로직을 표현하려고 노력해 보세요. 생각보다 훨씬 많은 것들을 순수한 렌더링만으로 해결할 수 있다는 사실에 깜짝 놀라실 겁니다!
순수 함수를 작성하는 것은 사실 약간의 습관과 규율이 필요한 일입니다. 하지만 이것을 습관화하면 정말 놀라운 기회들을 얻을 수 있어요!
강사 보충 설명: 사실 지금 개발되고 있는 React의 모든 새로운 기능들은 전부 이 순수성의 이점을 극대화하는 방향으로 만들어지고 있습니다. 데이터 패칭(Data Fetching)부터 애니메이션, 성능 최적화(Concurrent 기능 등)에 이르기까지, 컴포넌트를 순수하게 유지하는 것은 곧 강력한 React 패러다임의 진정한 힘을 이끌어내는 열쇠랍니다!
오늘 배운 핵심 내용 정리(Recap)입니다!
useEffect를 사용하세요.자, 이제 배운 내용을 바탕으로 간단한 도전 과제들을 풀어볼까요?
이 컴포넌트는 자정부터 아침 6시 사이에는 <h1> 태그의 CSS 클래스를 "night"로 설정하고, 그 외의 시간대에는 "day"로 설정하려고 시도합니다. 그런데 코드가 제대로 동작하지 않네요. 이 컴포넌트를 고칠 수 있나요?
컴퓨터의 시간대를 임시로 변경해보면 여러분의 해결책이 작동하는지 확인할 수 있습니다. 현재 시간이 자정부터 아침 6시 사이라면 시계의 색상이 반전되어야 합니다!
힌트: 렌더링은 무언가 상태를 "계산(calculation)"하는 과정이어야지, DOM을 직접 조작하는 등 무언가 동작을 "수행(do)"하려고 해서는 안 됩니다. 같은 아이디어를 순수하게 다르게 표현할 수 있을까요?
// src/Clock.js (active)
export default function Clock({ time }) {
const hours = time.getHours();
if (hours >= 0 && hours <= 6) {
document.getElementById('time').className = 'night';
} else {
document.getElementById('time').className = 'day';
}
return (
<h1 id="time">
{time.toLocaleTimeString()}
</h1>
);
}
// src/App.js (hidden)
import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
return (
<Clock time={time} />
);
}
body > * {
width: 100%;
height: 100%;
}
.day {
background: #fff;
color: #222;
}
.night {
background: #222;
color: #fff;
}
정답을 확인해 볼까요!
이 컴포넌트는 렌더링 중에 직접 DOM을 건드리고 있었습니다(사이드 이펙트). 대신 className을 변수로 계산한 다음에, 렌더링 결과물(JSX) 안에 포함시켜주면 이 컴포넌트를 순수하게 고칠 수 있습니다.
// src/Clock.js (active)
export default function Clock({ time }) {
const hours = time.getHours();
let className;
if (hours >= 0 && hours <= 6) {
className = 'night';
} else {
className = 'day';
}
return (
<h1 className={className}>
{time.toLocaleTimeString()}
</h1>
);
}
// src/App.js (hidden)
import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
return (
<Clock time={time} />
);
}
body > * {
width: 100%;
height: 100%;
}
.day {
background: #fff;
color: #222;
}
.night {
background: #222;
color: #fff;
}
이 예시에서는 사이드 이펙트(직접 DOM 수정하기)가 전혀 필요하지 않았어요. 오직 올바른 값을 가진 JSX를 계산해서 반환해주기만 하면 되는 문제였습니다.
두 개의 Profile 컴포넌트가 서로 다른 데이터와 함께 나란히 렌더링 되어있습니다. 첫 번째 프로필의 "Collapse(접기)" 버튼을 누른 다음, 다시 "Expand(펼치기)" 버튼을 눌러보세요. 양쪽 프로필 모두가 동일한 인물을 보여주고 있는 걸 확인할 수 있을 겁니다. 이건 명백한 버그죠!
버그의 원인을 찾아내고 고쳐보세요.
버그가 있는 코드는 Profile.js 안에 있습니다. 파일을 처음부터 끝까지 꼼꼼히 읽어보세요! 외부 변수에 뭔가 저장하고 있지 않나요?
// expectedErrors: {'react-compiler': [7]}
// src/Profile.js
import Panel from './Panel.js';
import { getImageUrl } from './utils.js';
let currentPerson; // 범인은 바로 이 녀석입니다!
export default function Profile({ person }) {
currentPerson = person;
return (
<Panel>
<Header />
<Avatar />
</Panel>
)
}
function Header() {
return <h1>{currentPerson.name}</h1>;
}
function Avatar() {
return (
<img
className="avatar"
src={getImageUrl(currentPerson)}
alt={currentPerson.name}
width={50}
height={50}
/>
);
}
// src/Panel.js (hidden)
import { useState } from 'react';
export default function Panel({ children }) {
const [open, setOpen] = useState(true);
return (
<section className="panel">
<button onClick={() => setOpen(!open)}>
{open ? 'Collapse' : 'Expand'}
</button>
{open && children}
</section>
);
}
// src/App.js
import Profile from './Profile.js';
export default function App() {
return (
<>
<Profile person={{
imageId: 'lrWQx8l',
name: 'Subrahmanyan Chandrasekhar',
}} />
<Profile person={{
imageId: 'MK3eW3A',
name: 'Creola Katherine Johnson',
}} />
</>
)
}
// src/utils.js (hidden)
export function getImageUrl(person, size = 's') {
return (
'[https://i.imgur.com/](https://i.imgur.com/)' +
person.imageId +
size +
'.jpg'
);
}
.avatar { margin: 5px; border-radius: 50%; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
width: 200px;
}
h1 { margin: 5px; font-size: 18px; }
가장 큰 문제는 Profile 컴포넌트가 외부에 미리 존재하던 currentPerson 이라는 변수에 값을 쓰고(write) 있었고, Header와 Avatar 컴포넌트는 그 외부 변수를 읽어오고 있었다는 점입니다. 이로 인해 세 개의 컴포넌트 모두가 불순해졌고 동작을 예측하기 어렵게 만들었죠.
버그를 고치려면, 이 currentPerson 변수를 완전히 삭제해 버리세요. 대신, 모든 정보를 Profile에서 Header와 Avatar에게 props를 통해 명시적으로 전달해야 합니다. 두 하위 컴포넌트가 person prop을 받도록 수정하고, 데이터를 아래로 계속 전달해 주세요.
// src/Profile.js (active)
import Panel from './Panel.js';
import { getImageUrl } from './utils.js';
export default function Profile({ person }) {
return (
<Panel>
<Header person={person} />
<Avatar person={person} />
</Panel>
)
}
function Header({ person }) {
return <h1>{person.name}</h1>;
}
function Avatar({ person }) {
return (
<img
className="avatar"
src={getImageUrl(person)}
alt={person.name}
width={50}
height={50}
/>
);
}
// src/Panel.js (hidden)
import { useState } from 'react';
export default function Panel({ children }) {
const [open, setOpen] = useState(true);
return (
<section className="panel">
<button onClick={() => setOpen(!open)}>
{open ? 'Collapse' : 'Expand'}
</button>
{open && children}
</section>
);
}
// src/App.js
import Profile from './Profile.js';
export default function App() {
return (
<>
<Profile person={{
imageId: 'lrWQx8l',
name: 'Subrahmanyan Chandrasekhar',
}} />
<Profile person={{
imageId: 'MK3eW3A',
name: 'Creola Katherine Johnson',
}} />
</>
);
}
// src/utils.js (hidden)
export function getImageUrl(person, size = 's') {
return (
'[https://i.imgur.com/](https://i.imgur.com/)' +
person.imageId +
size +
'.jpg'
);
}
.avatar { margin: 5px; border-radius: 50%; }
.panel {
border: 1px solid #aaa;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
width: 200px;
}
h1 { margin: 5px; font-size: 18px; }
항상 명심하세요! React는 컴포넌트 함수들이 어떤 특정한 순서대로 실행될 것이라고 전혀 보장하지 않습니다. 따라서 외부에 변수를 하나 만들어두고 컴포넌트들끼리 서로 값을 주고받으며 통신하려고 하면 안 됩니다. 컴포넌트 간의 모든 통신은 오직 props를 통해서만 이루어져야 합니다!
회사의 CEO가 여러분에게 온라인 시계 앱에 인스타그램 같은 "스토리(stories)" 기능을 추가해달라고 요청했고, 여러분은 도저히 거절할 수 없었습니다. 눈물을 머금고 여러분은 stories 리스트를 prop으로 받아 렌더링하고, 맨 마지막에 "Create Story(스토리 만들기)" 자리표시자를 띄워주는 StoryTray 컴포넌트를 작성했습니다.
"Create Story"를 보여주기 위해 여러분은 전달받은 stories 배열의 맨 끝에 가짜 스토리 객체를 push로 하나 쑤셔 넣는 방식으로 구현했습니다. 그런데 이상하게도 화면에는 "Create Story"가 한 번이 아니라 그 이상 여러 번 나타나고 있네요. 이 이슈를 고쳐주세요!
// src/StoryTray.js (active)
export default function StoryTray({ stories }) {
stories.push({
id: 'create',
label: 'Create Story'
});
return (
<ul>
{stories.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
</ul>
);
}
// expectedErrors: {'react-compiler': [16]}
// src/App.js (hidden)
import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';
const initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
const [stories, setStories] = useState([...initialStories])
const time = useTime();
// HACK: 문서를 읽는 동안 메모리가 무한히 커지는 것을 방지합니다.
// 이 부분은 저희가 자체 규칙을 어긴 해킹입니다. 무시하세요!
if (stories.length > 100) {
stories.length = 100;
}
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<h2>It is {time.toLocaleTimeString()} now.</h2>
<StoryTray stories={stories} />
</div>
);
}
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
ul {
margin: 0;
list-style-type: none;
}
li {
border: 1px solid #aaa;
border-radius: 6px;
float: left;
margin: 5px;
margin-bottom: 20px;
padding: 5px;
width: 70px;
height: 100px;
}
// sandbox.config.json (hidden)
{
"hardReloadOnChange": true
}
정답 확인 시간입니다! 시계가 업데이트될 때마다 "Create Story"가 두 번씩 추가되고 있는 거 눈치채셨나요? 이것이 바로 우리가 렌더링 도중에 외부 데이터를 변이(mutation)시키고 있다는 결정적인 힌트입니다. 앞서 말했듯 Strict Mode는 이런 이슈를 더 눈에 잘 띄게 하려고 일부러 컴포넌트를 두 번씩 호출하거든요.
StoryTray 함수는 순수하지 않습니다. prop으로 넘겨받은 stories 배열의 원본에 push 메서드를 호출함으로써, 이 컴포넌트가 렌더링을 시작하기도 전에 이미 만들어져 있던 외부 객체를 무단으로 수정하고 있는 셈이죠. 이 때문에 버그가 발생하고 결과를 예측하기 어려워진 겁니다.
가장 단순하고 우아한 해결책은 원본 배열을 아예 건드리지 않고, "Create Story" 아이템을 배열 바깥에서 별도로 렌더링하는 것입니다.
// src/StoryTray.js (active)
export default function StoryTray({ stories }) {
return (
<ul>
{stories.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
<li>Create Story</li>
</ul>
);
}
// expectedErrors: {'react-compiler': [16]}
// src/App.js (hidden)
import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';
const initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
const [stories, setStories] = useState([...initialStories])
const time = useTime();
if (stories.length > 100) {
stories.length = 100;
}
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<h2>It is {time.toLocaleTimeString()} now.</h2>
<StoryTray stories={stories} />
</div>
);
}
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
ul {
margin: 0;
list-style-type: none;
}
li {
border: 1px solid #aaa;
border-radius: 6px;
float: left;
margin: 5px;
margin-bottom: 20px;
padding: 5px;
width: 70px;
height: 100px;
}
다른 방법으로는, 원본 배열에 요소를 집어넣기 전에 아예 완전히 새로운 배열로 복사(clone)해서 사용하는 방법도 있습니다. (지역 변이 기법 활용)
// src/StoryTray.js (active)
export default function StoryTray({ stories }) {
// 배열을 복사합니다!
const storiesToDisplay = stories.slice();
// 방금 만든 새 배열이므로 push 해도 원본 배열에는 아무 영향을 주지 않습니다.
storiesToDisplay.push({
id: 'create',
label: 'Create Story'
});
return (
<ul>
{storiesToDisplay.map(story => (
<li key={story.id}>
{story.label}
</li>
))}
</ul>
);
}
// expectedErrors: {'react-compiler': [16]}
// src/App.js (hidden)
import { useState, useEffect } from 'react';
import StoryTray from './StoryTray.js';
const initialStories = [
{id: 0, label: "Ankit's Story" },
{id: 1, label: "Taylor's Story" },
];
export default function App() {
const [stories, setStories] = useState([...initialStories])
const time = useTime();
if (stories.length > 100) {
stories.length = 100;
}
return (
<div
style={{
width: '100%',
height: '100%',
textAlign: 'center',
}}
>
<h2>It is {time.toLocaleTimeString()} now.</h2>
<StoryTray stories={stories} />
</div>
);
}
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
ul {
margin: 0;
list-style-type: none;
}
li {
border: 1px solid #aaa;
border-radius: 6px;
float: left;
margin: 5px;
margin-bottom: 20px;
padding: 5px;
width: 70px;
height: 100px;
}
이 방식을 쓰면 변이가 지역적(local)으로만 머물게 되므로 렌더링 함수를 순수하게 유지할 수 있습니다. 하지만 여전히 주의할 점은 있어요. 만약 여러분이 복사한 새 배열이라 할지라도 그 안에 담긴 '객체의 내용물' 자체를 수정하려고 든다면, 껍데기만 복사하는 얕은 복사(shallow copy)로는 부족하고 안의 아이템들까지 전부 복제(clone)해 주어야 한답니다.
강사 팁: 개발자로서 자바스크립트의 배열 메서드 중에서 '어떤 놈이 원본을 변경하고, 어떤 놈이 새 배열을 반환하는지'를 명확히 기억해 두는 것은 정말 큰 도움이 됩니다. 예를 들어 push, pop, reverse, sort 같은 메서드들은 원본 배열 자체를 변이(mutate) 시키지만, slice, filter, map 같은 착한 메서드들은 원본을 건드리지 않고 완전히 새로운 배열을 만들어 낸다는 사실을 꼭 외워두세요!