B-4: useEffect & 부수효과 — 화면 밖의 일을 다루는 법
목차 56
안녕하세요, 홍순구입니다. 지난 시간에 커스텀 훅 셋을 만들었죠. useLikeToggle, useToggle, useCommentInput.
셋을 나란히 놓고 공통점을 찾아봅시다. 좋아요가 눌렸는지, 캡션이 펼쳐졌는지, 입력창에 무엇이 쓰였는지. 전부 화면에 보이는 값입니다. 값이 바뀌면 React 가 화면을 다시 그리고, 그걸로 이야기가 끝났어요.
그런데 앱에는 그렇게 끝나지 않는 일이 있습니다.
브라우저 탭 제목을 바꾸는 일을 생각해 보세요. 탭 제목은 React 가 그리는 화면 안에 없습니다. <main> 안 어디에도 탭 제목을 그리는 JSX 는 없어요. 그건 브라우저가 가진 것이고, React 는 그 존재를 모릅니다.
몇 초 뒤에 사라지는 알림도 그렇습니다. 알림을 띄우는 건 화면을 그리는 일이지만, "3초 뒤에 치운다" 는 건 시간이 흐르는 동안 벌어지는 일이에요. 그걸 걸어놓고 안 치우면 이미 사라진 화면을 건드리려 듭니다.
피드를 어디까지 내렸는지 기억하는 일도 마찬가지고요. 스크롤은 사용자가 브라우저에 하는 일이지 우리 컴포넌트에 하는 일이 아닙니다. 듣고 있으려면 브라우저에 직접 귀를 대야 하고, 다 듣고 나면 귀를 떼야 해요.
이런 일들을 부수효과(side effect)라고 부릅니다. 화면을 그리는 것 말고, 그리고 나서 세상에 일으키는 변화예요. 오늘은 이걸 다루는 useEffect 를 배웁니다.
그런데 오늘 수업의 절반은 쓰지 않는 법입니다. 이게 더 중요해요. useEffect 는 입문자가 가장 많이, 가장 잘못 쓰는 훅이거든요. 마지막 두 Step 은 통째로 "이건 effect 가 아니다" 에 씁니다.
오늘의 여정
Step 1~2 탭 제목 맞추기 · 언제 다시 도는지 정하기
│
Step 3~4 알림 타이머와 정리 · 개발 모드가 두 번 도는 이유
│
Step 5~6 스크롤 위치 기억하기 · 다시 구독하지 않고 최신 값 읽기
│
Step 7~8 계산은 렌더 중에 · 이벤트는 핸들러에
▼
화면 밖의 일까지 챙기는 인스타 피드
💡 오늘 수업의 핵심 — "화면 밖과 맞출 때만 effect, 그리고 시작했으면 끝낸다"
useEffect 는 React 가 모르는 바깥 세상과 우리 화면을 맞추는 도구입니다. 바깥과 맞추는 일이 아니면 쓰지 않고, 쓸 때는 정리까지 함께 적습니다.
🎯 학습 목표
- 렌더와 부수효과를 구분하고, 의존성 배열로 effect 가 다시 도는 시점을 정할 수 있다
- 클린업 함수로 타이머와 구독을 정리하고, 개발 모드의 이중 실행이 무엇을 검사하는지 안다
- effect 가 필요 없는 경우를 알아보고 렌더 중 계산이나 이벤트 핸들러로 옮길 수 있다
Step 1: "화면 밖의 일 — 렌더가 끝난 뒤에 벌어지는 것"
인스타그램 웹을 열어두고 다른 탭에서 작업하다 보면, 탭 제목만 보고도 돌아갈 곳을 찾게 되죠. 우리 피드에도 그런 걸 붙여봅시다. 좋아요를 몇 개 눌렀는지 탭 제목에 띄우는 겁니다.
순수 JS 로 하던 시절엔 고민할 게 없었어요. 아무 데나 이렇게 쓰면 됐습니다.
document.title = '인스타그램 (좋아요 3)';
React 에서도 똑같이 하면 될까요? App 안에 그대로 넣어봅시다.
// apps/web-spa/src/App.tsx — 이렇게 하면 안 됩니다
export function App() {
const { posts, likedCount, toggle } = useLikeToggle(feedPosts);
document.title = `인스타그램 (좋아요 ${likedCount})`;
return <main className="feed">{/* 아래는 그대로 */}</main>;
}
저장하는 순간 에디터에 빨간 줄이 그어집니다.
Modifying a variable defined outside a component or hook is not allowed.
Consider using an effect.
"컴포넌트 바깥에 정의된 값을 고치는 것은 허용되지 않습니다. effect 를 쓰는 걸 고려하세요."
B-2 에서 이 규칙을 만난 적 있습니다. 그때는 clickCount += 1 처럼 일반 변수를 고치려다 걸렸고, 메시지 끝이 "상태를 쓰는 걸 고려하세요" 였어요. 같은 규칙인데 이번엔 끝 문장이 다릅니다. 고치려는 것이 컴포넌트 안의 값이 아니라 바깥 세상의 값이라, 답도 상태가 아니라 effect 라고 알려주는 거예요.
왜 렌더 중에 바깥을 건드리면 안 되나
컴포넌트 함수의 일은 하나입니다. props 와 state 를 받아서 "화면이 이렇게 생겨야 한다" 는 결과를 돌려주는 것. 그 이상도 이하도 하지 않아야 해요.
이 규칙을 지키면 React 가 자유로워집니다. 컴포넌트를 언제 부를지, 몇 번 부를지, 부르다 말지를 React 가 알아서 정할 수 있어요. 결과만 돌려주는 함수니까 여러 번 불러도 세상이 달라지지 않거든요.
반대로 컴포넌트가 부를 때마다 세상을 바꾸면 이 자유가 사라집니다. React 가 화면을 미리 계산해 보려고 컴포넌트를 한 번 더 불렀는데 탭 제목이 바뀌어버리면, 그건 React 가 의도한 일이 아니에요.
컴포넌트 함수가 하는 일
props · state ──▶ [ 컴포넌트 함수 ] ──▶ 화면이 이렇게 생겨야 한다는 결과
│
└── 여기서 바깥 세상을 건드리면 안 된다
(탭 제목 · 타이머 · 이벤트 구독 · 저장소)
화면을 그리고 난 다음 ──▶ [ effect ] ──▶ 바깥 세상과 맞춘다
그래서 React 는 따로 마련해 둔 곳이 있습니다. 화면을 다 그리고 난 다음에 실행되는 곳, 그게 useEffect 입니다.
첫 effect
// apps/web-spa/src/App.tsx
import { useEffect } from 'react';
export function App() {
const { posts, likedCount, toggle } = useLikeToggle(feedPosts);
useEffect(() => {
document.title = `인스타그램 (좋아요 ${likedCount})`;
});
return (
<main className="feed">
<header className="feed-header">
<h1 className="feed-title">인스타그램</h1>
<span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
</header>
<Section title="피드">
<Feed posts={posts} onToggleLike={toggle} />
</Section>
</main>
);
}
빨간 줄이 사라집니다. 브라우저를 새로고침하고 탭을 보세요. 인스타그램 (좋아요 1) 이라고 떠 있습니다. minji 의 게시물이 처음부터 좋아요가 눌려 있으니 1이 맞아요. jaehoon 게시물의 좋아요를 눌러보면 탭 제목이 인스타그램 (좋아요 2) 로 바뀝니다.
바뀐 건 딱 하나입니다. 같은 코드를 함수로 감싸서 useEffect 에 넘긴 것뿐이에요. 그런데 그 감싸는 행위가 React 에게 이렇게 말합니다.
"이건 화면을 그리는 코드가 아니에요. 다 그리고 난 다음에 실행해 주세요."
그리는 것과 맞추는 것
useEffect 를 처음 보면 "화면을 그린 뒤에 실행되는 코드" 정도로 이해하게 되는데, 조금 더 정확한 표현이 있습니다.
React 공식 문서는 effect 를 동기화(synchronization)로 설명합니다. React 가 관리하는 화면과, React 가 모르는 바깥 세상을 같은 상태로 맞추는 일이라는 뜻이에요.
지금 우리가 한 것도 딱 그렇습니다. likedCount 라는 React 쪽 값과 document.title 이라는 브라우저 쪽 값을 같게 맞췄어요. 좋아요가 늘면 탭 제목도 따라 늘어야 하니까요.
이 관점이 왜 중요하냐면, 오늘 Step 7~8 에서 "이건 effect 가 아니다" 를 판단할 때 쓰는 기준이 바로 이거거든요. 맞출 바깥 세상이 있는가. 없으면 effect 가 아닙니다.
💡 한 줄 정리
컴포넌트 함수는 화면 결과만 돌려주고, 바깥 세상을 건드리는 일은 useEffect 안에서 합니다. effect 는 React 쪽 값과 바깥 세상을 같게 맞추는 도구예요.
🙋 학생 질문 — "튜터님, 콘솔에 로그를 넣어봤더니 두 번씩 찍히는데요?"
잘 보셨습니다. effect 안에 console.log 를 넣고 새로고침하면 정말 두 줄이 찍힙니다.
버그가 아니고, 여러분 코드가 잘못된 것도 아니에요. 개발 모드에서만 React 가 일부러 두 번 돌립니다. 무엇을 검사하려고 그러는지는 Step 4 에서 정면으로 다룰 거예요. 지금 미리 설명하면 아직 배우지 않은 개념을 끌어와야 해서, 그 전에 한 가지를 먼저 배워야 합니다.
당장은 이렇게만 알아두세요. 두 줄이 찍히는 건 정상이고, 여러분이 만든 앱을 실제로 배포하면 한 번만 실행됩니다.
한 가지 더 궁금하실 텐데, 로그가 두 번 찍힌다고 탭 제목이 이상해지지는 않습니다. 같은 값을 두 번 넣는 것뿐이니까요. 그런데 "두 번 실행돼도 괜찮은 코드" 와 "두 번 실행되면 망가지는 코드" 가 있어요. 그 차이가 Step 4 의 주제입니다.
Step 2: "언제 다시 도는가 — 의존성 배열"
방금 만든 effect 를 다시 보세요.
useEffect(() => {
document.title = `인스타그램 (좋아요 ${likedCount})`;
});
이 effect 는 언제 실행될까요? 지금 상태로는 "화면을 그릴 때마다 매번" 입니다.
탭 제목 정도면 매번 돌아도 티가 안 납니다. 그런데 effect 안에서 하는 일이 무거워지면 얘기가 달라져요. 서버에 요청을 보내거나, 큰 계산을 하거나, 이벤트를 구독하는 일이라면 화면이 다시 그려질 때마다 그걸 반복하게 됩니다.
그래서 useEffect 는 두 번째 인자를 받습니다. 의존성 배열(dependency array)이에요. "이 값들이 지난번과 달라졌을 때만 다시 돌아라" 는 지시입니다.
세 갈래를 직접 비교해 봅시다
말로 들으면 헷갈리니 눈으로 봅시다. 같은 일을 하는 effect 셋을 나란히 두고 의존성 배열만 다르게 적은 화면을 하나 만들게요.
// apps/web-spa/src/components/EffectTimingDemo.tsx
import { useEffect, useState } from 'react';
export function EffectTimingDemo() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// ① 배열을 아예 안 적으면 — 렌더가 끝날 때마다 매번
useEffect(() => {
console.log('① 배열 없음 · count =', count);
});
// ② 빈 배열이면 — 화면에 처음 붙을 때 한 번
useEffect(() => {
console.log('② 빈 배열 · 처음 한 번');
}, []);
// ③ [count] 면 — count 가 지난번과 달라졌을 때만
useEffect(() => {
console.log('③ [count] · count =', count);
}, [count]);
return (
<div className="effect-demo">
<p className="effect-demo-values">
count: {count} / other: {other}
</p>
<button className="effect-demo-button" onClick={() => setCount(count + 1)}>
count +1
</button>
<button className="effect-demo-button" onClick={() => setOther(other + 1)}>
other +1
</button>
</div>
);
}
App 의 <Section> 위에 <EffectTimingDemo /> 를 잠깐 넣고 브라우저 콘솔을 열어주세요. B-2 에서 key 를 비교할 때 KeyDemo 를 잠깐 붙였던 것과 같은 방식입니다. 다 확인하면 지울 거예요.
버튼을 눌러가며 콘솔이 어떻게 쌓이는지 세어봅시다.
세기 전에 하나만 짚고 갑니다. Step 1 의 🙋 에서 말했듯 지금은 화면에 처음 붙을 때 각 줄이 한 번씩 더 찍힙니다. 개발 모드가 하는 일인데 이유는 Step 4 에서 볼 거예요. 아래 표는 그 한 줄을 뺀, effect 가 실제로 도는 횟수입니다. 콘솔에서 첫 줄 하나씩은 빼고 세시면 표와 맞습니다.
화면에 처음 붙었을 때 셋 다 한 번씩 돌고, 그다음부터 갈립니다.
| 누른 것 | ① 배열 없음 | ② 빈 배열 | ③ [count] |
|---|---|---|---|
| 처음 붙었을 때 | 1 | 1 | 1 |
count +1 |
2 | 1 | 2 |
other +1 |
3 | 1 | 2 |
읽는 법은 이렇습니다.
① 배열 없음 은 어느 버튼을 눌러도 늘어납니다. 화면이 다시 그려졌다는 사실 하나만 보거든요.
② 빈 배열 은 처음 이후로 영영 늘지 않습니다. 지켜볼 값이 하나도 없다고 적었으니, 달라질 값도 없는 셈이에요.
③ [count] 가 오늘의 핵심입니다. count +1 을 누르면 늘어나는데 other +1 을 누르면 안 늘어나요. other 가 바뀌면서 화면은 다시 그려졌지만, count 는 그대로였으니 이 effect 는 돌 이유가 없다고 판단한 겁니다.
화면이 다시 그려질 때마다 React 가 하는 일
이번 렌더의 [count] ┐
├──▶ 하나씩 비교 ──▶ 다 같으면 건너뛴다
지난 렌더의 [count] ┘ 하나라도 다르면 effect 를 다시 돌린다
비교는 === 로 합니다. 숫자나 문자열이면 값이 같으면 같은 것이고, 객체나 배열이면 같은 객체인지를 봅니다. 이 차이는 오늘 Step 6 에서 다시 만나게 될 거예요.
App 에 적용하기
이제 탭 제목 effect 에 배열을 달아줍시다. 이 effect 가 신경 쓰는 값은 likedCount 하나입니다.
// apps/web-spa/src/App.tsx
useEffect(() => {
document.title = `인스타그램 (좋아요 ${likedCount})`;
}, [likedCount]);
이제 댓글을 달아서 화면이 다시 그려져도 탭 제목 코드는 돌지 않습니다. 좋아요를 눌렀을 때만 돌아요.
무엇을 넣어야 하는지는 규칙이 정해준다
의존성 배열에 무엇을 넣을지는 감으로 정하는 게 아닙니다. effect 안에서 쓰는 값 중 컴포넌트가 만든 것은 전부 넣어야 해요. props, state, 컴포넌트 안에서 선언한 함수와 변수 전부입니다.
빠뜨리면 어떻게 되냐면, effect 가 옛날 값을 붙들고 있게 됩니다. 좋아요가 5개가 됐는데 탭 제목은 2에서 멈춰 있는 식이에요.
다행히 이걸 손으로 관리할 필요는 없습니다. 우리 프로젝트에 깔려 있는 규칙이 빠진 값을 짚어줍니다.
React Hook useEffect has a missing dependency: 'likedCount'.
Either include it or remove the dependency array
"의존성이 빠졌습니다. 넣거나, 배열 자체를 없애세요." 이 경고가 뜨면 대체로 넣는 것이 정답입니다.
그리고 배열은 반드시 배열이어야 합니다. 값 하나를 그냥 적으면 타입이 막아요.
useEffect(() => {
console.log(count);
}, count);
Argument of type 'number' is not assignable to parameter of type 'readonly unknown[]'
"number 는 배열 자리에 들어갈 수 없습니다." 대괄호를 빠뜨린 겁니다.
💡 한 줄 정리
의존성 배열은 "이 값들이 달라졌을 때만 다시 돌아라" 는 지시입니다. 배열 없음은 매번, 빈 배열은 처음 한 번, [값] 은 그 값이 달라졌을 때예요.
🙋 학생 질문 — "튜터님, 그럼 배열을 빈 걸로 두면 제일 효율적인 거 아닌가요?"
효율만 보면 그렇습니다. 그리고 바로 그래서 위험해요.
빈 배열은 "이 effect 는 어떤 값도 신경 쓰지 않는다" 는 선언입니다. 그런데 effect 안에서 likedCount 를 쓰면서 배열은 비워두면, 선언과 실제가 어긋나죠.
이때 무슨 일이 생기냐면, effect 는 처음 실행될 때 봤던 likedCount 를 계속 붙들고 있습니다. 자바스크립트 함수가 만들어질 당시의 바깥 값을 기억하는 성질 때문인데, 좋아요를 아무리 눌러도 탭 제목이 처음 값에서 안 움직이는 걸 보게 됩니다.
이걸 낡은 값을 붙들고 있다고 해서 stale closure 라고 부릅니다. 규칙이 경고로 잡아주는 게 정확히 이 상황이에요.
그러니 순서를 뒤집어 생각하세요. 배열을 먼저 정하고 코드를 맞추는 게 아니라, 코드가 쓰는 값을 배열에 적는 겁니다. 배열이 마음에 안 들면 배열을 손대지 말고 effect 안에서 그 값을 안 쓰도록 코드를 바꾸세요. 오늘 Step 6 에서 그 방법 하나를 배웁니다.
Step 3: "시작했으면 끝내야 한다 — 클린업"
인스타그램에서 좋아요를 누르면 아래쪽에 짧은 알림이 떴다가 사라지죠. 우리 피드에도 붙여봅시다.
먼저 알림 한 줄을 그리는 컴포넌트를 만듭니다.
// apps/web-spa/src/components/Toast.tsx
interface ToastProps {
message: string;
}
export function Toast({ message }: ToastProps) {
return (
<div className="toast" role="status">
{message}
</div>
);
}
role="status" 를 붙인 이유는 화면을 못 보는 사용자에게도 알림이 떴다는 걸 전하기 위해서입니다. 화면 낭독기가 이 영역의 글이 바뀌면 읽어줘요.
App 에서 좋아요를 누를 때 문구를 채웁니다. B-3 까지는 Feed 에 toggle 을 그대로 넘겼는데, 이제 알림도 함께 띄워야 하니 App 에 핸들러를 하나 두고 그걸 넘깁니다.
// apps/web-spa/src/App.tsx
const [toastMessage, setToastMessage] = useState<string | null>(null);
function handleToggleLike(id: number) {
const target = posts.find((post) => post.id === id);
toggle(id);
if (target) {
setToastMessage(
target.liked
? `${target.username}님의 게시물 좋아요를 취소했습니다`
: `${target.username}님의 게시물을 좋아합니다`,
);
}
}
find 는 못 찾으면 undefined 를 돌려주니 if (target) 으로 걸러줍니다. A-2 에서 배운 좁히기가 여기서 일합니다.
JSX 에는 이렇게 답니다.
<Section title="피드">
<Feed posts={posts} onToggleLike={handleToggleLike} />
</Section>
{toastMessage !== null && <Toast message={toastMessage} />}
좋아요를 눌러보세요. 알림이 뜹니다. 그런데 안 사라져요. 띄우기만 했지 치우는 코드를 안 썼으니까요.
3초 뒤에 치우기
3초 뒤에 지우는 건 브라우저 타이머가 할 일입니다. setTimeout 이죠. 그런데 이건 화면을 그리는 일이 아니라 시간이 흐르는 동안 벌어지는 일이니, effect 가 맡습니다.
// apps/web-spa/src/App.tsx — 아직 미완성입니다
const TOAST_DURATION = 3000;
useEffect(() => {
if (toastMessage === null) {
return;
}
setTimeout(() => setToastMessage(null), TOAST_DURATION);
}, [toastMessage]);
문구가 없으면 할 일도 없으니 일찍 빠져나갑니다. 문구가 생기면 3초짜리 타이머를 걸어요.
눌러보면 잘 됩니다. 알림이 뜨고 3초 뒤에 사라져요. 여기서 멈추면 오늘 수업의 절반을 놓치게 됩니다.
연달아 눌러보세요
좋아요 하나를 누르고, 2초쯤 뒤에 다른 게시물의 좋아요를 눌러보세요. 두 번째 알림이 뜬 다음 시계를 세어봅시다.
3초를 못 채웁니다. 1초쯤 지나면 사라져요.
왜 그런지 그림으로 보면 명확합니다.
0초 첫 알림 뜸 ──────────── 3초 뒤에 치우라는 타이머 ①
2초 둘째 알림 뜸 ────────── 3초 뒤에 치우라는 타이머 ②
3초 타이머 ① 이 터짐 ──▶ 알림을 치운다
그런데 지금 떠 있는 건 둘째 알림이다
5초 타이머 ② 가 터짐 ──▶ 이미 없는 걸 또 치운다
첫 알림이 걸어둔 타이머는 첫 알림이 사라졌다고 해서 없어지지 않습니다. 브라우저가 계속 들고 있다가 3초가 되면 그냥 터뜨려요. 그때 터진 타이머는 자기가 누구를 치우는지 모릅니다. 그저 알림을 지울 뿐이에요.
시작한 것을 안 끝냈기 때문에 생긴 일입니다.
클린업 함수
React 는 이 문제를 위한 장치를 마련해 뒀습니다. effect 안에서 함수를 돌려주면, React 가 그 함수를 정리할 때 불러줍니다. 이걸 클린업 함수라고 불러요.
// apps/web-spa/src/App.tsx
useEffect(() => {
if (toastMessage === null) {
return;
}
const timerId = setTimeout(() => setToastMessage(null), TOAST_DURATION);
return () => clearTimeout(timerId);
}, [toastMessage]);
setTimeout 이 돌려주는 번호를 붙들어 뒀다가, 클린업에서 clearTimeout 으로 취소합니다.
이제 다시 연달아 눌러보세요. 두 번째 알림이 3초를 온전히 채웁니다.
순서를 따라가 보면 이렇습니다. 문구가 바뀌면 [toastMessage] 가 달라지니 effect 가 다시 돌아야 하는데, React 는 다시 돌리기 전에 지난번 클린업을 먼저 부릅니다. 그 순간 첫 타이머가 취소되고, 그다음에 새 타이머가 걸려요.
문구가 바뀌면 React 가 하는 일
① 지난번 effect 의 클린업을 부른다 ──▶ 첫 타이머 취소
② 이번 effect 를 돌린다 ──▶ 새 타이머 시작
화면에서 컴포넌트가 사라질 때도 클린업이 불립니다. 알림이 떠 있는 채로 페이지를 떠나면 타이머가 취소돼요. 안 그러면 이미 없는 컴포넌트의 상태를 바꾸려 드니까요.
클린업은 함수여야 합니다
여기서 자주 나오는 실수가 하나 있습니다. 화살표 함수의 몸통을 중괄호 없이 짧게 쓰는 습관 때문에 생겨요.
useEffect(() => setTimeout(() => setMessage(''), 3000), []);
한 줄로 깔끔해 보이는데 타입이 막습니다.
Type 'number' is not assignable to type 'void | Destructor'
중괄호 없는 화살표 함수는 몸통의 결과를 그대로 돌려줍니다. setTimeout 은 타이머 번호를 돌려주니, 그 번호가 클린업 자리로 흘러간 거예요. React 는 거기 함수가 오기를 기대하는데 숫자가 왔습니다.
Destructor 는 React 가 클린업 함수에 붙인 이름입니다. 타입이 "여기는 아무것도 안 돌려주거나, 정리하는 함수를 돌려주는 자리다" 라고 말해주고 있어요. 중괄호를 씌우면 해결됩니다.
💡 한 줄 정리
effect 에서 무언가를 시작했다면 클린업 함수에서 끝냅니다. React 는 effect 를 다시 돌리기 전에, 그리고 컴포넌트가 사라질 때 클린업을 먼저 불러줍니다.
🙋 학생 질문 — "튜터님, 클린업은 언제 꼭 필요하고 언제 없어도 되나요?"
기준은 간단합니다. effect 가 뭔가를 시작했으면 필요하고, 값만 맞췄으면 필요 없습니다.
탭 제목 effect 를 보세요. document.title 에 문자열을 넣은 게 전부입니다. 시작된 것이 없으니 끝낼 것도 없어요. 클린업이 없는 게 맞습니다.
반대로 이런 것들은 전부 클린업이 필요합니다.
setTimeout·setInterval→clearTimeout·clearIntervaladdEventListener→removeEventListener- 서버와 연결을 맺는 것 → 연결 끊기
- 구독을 시작하는 것 → 구독 해지
패턴이 보이시죠. 이름이 짝을 이룹니다. 시작하는 함수를 쓸 때 짝이 되는 함수가 머릿속에 떠오르면, 그건 클린업이 필요한 코드예요.
애매한 것이 하나 있는데, 값을 맞추는 effect 도 "원래대로 되돌려야 하는가" 를 한 번은 생각해 볼 만합니다. 예를 들어 어떤 화면에서만 탭 제목을 바꾼다면, 그 화면을 떠날 때 제목을 되돌리는 게 맞을 수 있어요. 지금 우리 앱은 화면이 하나뿐이라 그럴 일이 없습니다. 화면이 여러 개가 되는 C-1 에서 다시 생각해 볼 문제예요.
Step 4: "왜 두 번 도나요 — 개발 모드의 이중 실행"
Step 1 에서 미뤄뒀던 이야기를 할 차례입니다. effect 안에 console.log 를 넣으면 왜 두 줄이 찍히는가.
범인은 앱을 처음 띄우는 파일에 있습니다.
// apps/web-spa/src/main.tsx
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
StrictMode 는 B-1 에서 Vite 가 만들어준 뒤로 손대지 않았던 껍데기입니다. 이름 그대로 엄격한 모드예요. 개발 중에만 동작하고, 빌드해서 배포한 앱에서는 아무 일도 하지 않습니다.
이게 하는 일 중 하나가 effect 를 한 번 더 돌려보는 겁니다. 정확히는 이렇습니다.
StrictMode 없이 처음 붙을 때
setup
StrictMode 로 처음 붙을 때
setup ──▶ cleanup ──▶ setup
시작하고, 정리하고, 다시 시작합니다. 그냥 두 번 돌리는 게 아니라 중간에 클린업을 끼워 넣어요.
이게 핵심입니다. React 는 "이 effect 를 껐다 켜도 멀쩡한가" 를 시험하는 거예요. 실제 앱에서는 화면을 떠났다 돌아오는 일이 늘 벌어지는데, 그때마다 effect 는 정리됐다가 다시 시작됩니다. 그 왕복을 개발 중에 미리 한 번 겪게 해서 문제를 일찍 보여주는 겁니다.
클린업이 없으면 이렇게 보입니다
클린업을 안 쓴 effect 를 StrictMode 로 띄우면 이렇게 됩니다.
setup ──▶ setup
정리 없이 두 번 시작됐어요. 타이머라면 두 개가 걸린 것이고, 이벤트 구독이라면 리스너가 두 개 붙은 겁니다.
여기가 바로 개발 모드 이중 실행의 쓸모입니다. 클린업을 빠뜨린 effect 는 개발 중에 티가 납니다. 알림이 두 번 뜨거나, 로그가 두 줄씩 쌓이거나, 요청이 두 번 나가요. 그 이상한 모습이 "정리를 안 했구나" 를 알려주는 신호입니다.
Step 3 에서 클린업 없이 만들었던 알림 코드를 기억하시나요. 그때 우리는 연달아 눌러야 문제를 봤습니다. 만약 그 effect 가 처음 붙을 때부터 타이머를 걸었다면, StrictMode 가 즉시 두 개를 걸어서 문제를 바로 보여줬을 거예요.
이중 실행은 처음 붙을 때만입니다
한 가지 오해하기 쉬운 게 있어서 짚고 갑니다. 버튼을 눌러서 화면이 다시 그려질 때는 두 번 돌지 않습니다. 이중 실행은 컴포넌트가 화면에 처음 붙는 순간에만 일어나요.
Step 2 에서 "첫 줄 하나씩은 빼고 세라" 고 했죠. 이제 빼지 말고 콘솔에 찍힌 그대로 세어봅시다.
| 누른 것 | ① 배열 없음 | ② 빈 배열 | ③ [count] |
|---|---|---|---|
| 처음 붙었을 때 | 2 | 2 | 2 |
count +1 |
3 | 2 | 3 |
other +1 |
4 | 2 | 3 |
처음 줄만 하나씩 커졌고, 그 뒤로 늘어나는 폭은 Step 2 의 표와 똑같습니다. 클릭 한 번에 하나씩이에요.
그래서 콘솔을 읽을 때는 첫 줄 하나는 빼고 세면 실제 동작과 맞습니다.
그럼 StrictMode 를 벗기면 되지 않나요
두 줄이 거슬려서 main.tsx 에서 <StrictMode> 를 지우고 싶어질 겁니다. 그러면 로그는 깔끔해지죠.
그런데 그건 온도계가 높은 숫자를 가리킨다고 온도계를 치우는 것과 같습니다. 이중 실행이 드러내는 문제는 원래 있던 문제예요. 그걸 안 보이게 만든다고 사라지지 않습니다. 배포한 앱에서 사용자가 화면을 오갈 때 터질 뿐이에요.
이 주제는 오늘 마지막에 한 번 더 생각해 볼 거리로 남겨두겠습니다.
💡 한 줄 정리
개발 모드에서 effect 는 시작·정리·시작 순으로 한 번 더 돕니다. 껐다 켜도 멀쩡한지 미리 시험하는 장치이고, 여기서 이상하면 클린업을 빠뜨린 겁니다.
🙋 학생 질문 — "튜터님, 두 번 돌아도 괜찮은 코드는 어떻게 쓰나요?"
기준이 하나 있습니다. effect 를 껐다 켰을 때 처음과 같은 상태로 돌아오는가.
탭 제목은 통과합니다. 같은 문자열을 두 번 넣어도 결과가 같아요.
타이머는 클린업이 있어야 통과합니다. 클린업이 취소해주니 시작·정리·시작을 거쳐도 살아 있는 타이머는 하나예요.
이벤트 구독도 마찬가지입니다. 붙였다 떼고 다시 붙이면 리스너는 하나입니다.
통과 못 하는 대표적인 코드가 "개수를 하나 늘린다" 같은 것들이에요. 두 번 돌면 2가 됩니다. 클린업으로 되돌릴 수도 있지만, 사실 이런 코드는 애초에 effect 가 할 일이 아닌 경우가 대부분입니다. Step 7~8 에서 그 이야기를 합니다.
서버에 요청을 보내는 코드도 통과 못 합니다. 두 번 보내지니까요. 그래서 effect 안에서 데이터를 가져오는 게 생각보다 까다로운데, 이건 오늘 마무리에서 짚고 C-3 에서 제대로 다룹니다.
Step 5: "브라우저에 직접 귀 기울이기 — 구독과 스크롤 위치"
B-2 에서 이벤트를 배울 때 이런 이야기를 했습니다. 버튼에 onClick 을 넘기면 React 가 세 가지를 대신해 준다고요.
첫째, 요소를 찾아줍니다. 둘째, 언제 붙이고 언제 뗄지를 정해줍니다. 셋째, 붙이는 코드와 그리는 코드가 같은 자리에 있어서 읽기 쉽습니다.
그리고 이렇게 덧붙였죠. 브라우저 창 크기 변화나 문서 전체의 스크롤 같은 건 JSX 로 감쌀 요소가 없으니 직접 붙여야 한다고요. 그 이야기를 지금 합니다.
스크롤은 window 에게 일어나는 일입니다. <main> 이나 <div> 에게 일어나는 일이 아니에요. 그래서 onScroll 을 넘길 JSX 가 없습니다. window 에 직접 귀를 대야 하고, 그러면 React 가 대신해 주던 세 가지를 우리가 해야 해요.
만들 것은 이겁니다. 피드를 어디까지 내렸는지 기억해뒀다가, 새로고침하고 돌아오면 그 자리로 되돌려주기.
구독하고, 정리하기
먼저 스크롤을 듣기만 해봅시다.
// apps/web-spa/src/hooks/useScrollRestore.ts
import { useEffect } from 'react';
export function useScrollRestore() {
useEffect(() => {
function handleScroll() {
console.log('지금 위치:', window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}
세 줄이 한 덩어리입니다. 함수를 만들고, 붙이고, 클린업에서 뗍니다.
떼는 것을 안 쓰면 어떻게 되는지는 이제 짐작하실 겁니다. 개발 모드에서 시작·정리·시작을 거치는데 정리가 없으니 리스너가 두 개 붙어요. 스크롤 한 번에 로그가 두 줄씩 찍힙니다.
떼는 코드를 쓸 때 자주 나오는 실수가 하나 있습니다.
return () => window.removeEventListener('scroll', handleScroll());
handleScroll 뒤에 괄호를 붙였습니다. 붙일 때는 안 붙였는데 뗄 때만 붙는 실수예요. 타입이 막습니다.
No overload matches this call.
Argument of type 'void' is not assignable to parameter of type '(this: Window, ev: Event) => any'
괄호를 붙이면 함수를 부른 것이고, 그 결과인 void 가 넘어갑니다. removeEventListener 는 어떤 함수를 뗄지 알아야 하니 함수 자체를 받아야 해요. 붙일 때와 뗄 때 똑같은 함수를 넘겨야 한다는 점도 함께 기억해 두세요. 다른 함수를 넘기면 에러 없이 조용히 안 떨어집니다.
위치를 적어두기
로그 대신 브라우저에 적어둡시다. sessionStorage 는 탭이 살아 있는 동안 값을 기억해주는 저장소예요. 탭을 닫으면 같이 지워지니 "이번 방문 동안만" 기억하기에 알맞습니다.
// apps/web-spa/src/hooks/useScrollRestore.ts
const STORAGE_KEY = 'feed-scroll';
useEffect(() => {
function handleScroll() {
sessionStorage.setItem(STORAGE_KEY, String(window.scrollY));
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
sessionStorage 는 문자열만 담을 수 있어서 String() 으로 감쌌습니다.
돌아왔을 때 되돌리기
이제 반대 방향입니다. 화면에 붙을 때 적어둔 값이 있으면 그 자리로 스크롤합니다.
// apps/web-spa/src/hooks/useScrollRestore.ts
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved !== null) {
window.scrollTo(0, Number(saved));
}
}, []);
getItem 은 값이 없으면 null 을 돌려주니 걸러줍니다. 꺼낸 값은 문자열이라 Number() 로 되돌립니다.
여기서 판단할 게 하나 있습니다. 이 코드를 위의 구독 effect 안에 같이 넣을까, 따로 뺄까?
따로 뺍니다. 둘은 하는 일이 다르거든요. 하나는 "돌아왔을 때 딱 한 번" 이고, 다른 하나는 "스크롤하는 내내" 입니다. 지금은 둘 다 의존성 배열이 비어 있어서 같이 넣어도 똑같이 동작하지만, 나중에 하나만 조건이 바뀌면 억지로 붙여둔 게 발목을 잡아요.
하는 일이 다르면 effect 도 나눕니다. effect 하나에 여러 일을 담지 않는 것이 읽기에도, 고치기에도 낫습니다.
훅으로 완성하기
두 effect 를 훅 하나에 담았습니다. B-3 에서 배운 커스텀 훅이 그대로 쓰입니다.
// apps/web-spa/src/hooks/useScrollRestore.ts
import { useEffect } from 'react';
// 브라우저가 기억해줄 자리 이름. 탭을 닫으면 같이 지워진다.
const STORAGE_KEY = 'feed-scroll';
// 피드를 어디까지 내렸는지 적어뒀다가, 돌아왔을 때 그 자리로 되돌린다.
export function useScrollRestore() {
// 하는 일이 다르면 effect 도 나눈다 — 이건 "돌아왔을 때 한 번"
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved !== null) {
window.scrollTo(0, Number(saved));
}
}, []);
// 이건 "스크롤하는 내내"
useEffect(() => {
function handleScroll() {
sessionStorage.setItem(STORAGE_KEY, String(window.scrollY));
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}
App 에서 한 줄로 부릅니다.
// apps/web-spa/src/App.tsx
useScrollRestore();
B-3 의 훅들과 다른 점이 눈에 띄시나요. useLikeToggle 은 posts 와 toggle 을 돌려줬는데 이 훅은 아무것도 안 돌려줍니다. 화면에 그릴 값을 만드는 훅이 아니라 바깥 세상과 맞추는 일만 하는 훅이라 그래요. 커스텀 훅이 항상 값을 돌려줘야 하는 건 아닙니다.
브라우저에서 확인해 봅시다. 피드를 아래로 내리고 새로고침하세요. 내렸던 자리로 돌아옵니다.
지금 피드는 게시물이 두 장뿐이라 스크롤이 짧을 텐데, 브라우저 창을 세로로 줄이면 넉넉히 확인할 수 있어요.
화면에 붙을 때 스크롤하는 내내 화면을 떠날 때
저장소에서 꺼낸다 지금 위치를 적는다 구독을 끊는다
그 자리로 되돌린다 (계속)
스크롤 구독을 맺는다
💡 한 줄 정리
window 처럼 JSX 로 감쌀 수 없는 대상은 effect 안에서 직접 구독하고, 클린업에서 반드시 끊습니다. 하는 일이 다르면 effect 를 나눠 적습니다.
🙋 학생 질문 — "튜터님, 스크롤할 때마다 저장하면 너무 자주 저장되는 거 아닌가요?"
맞습니다. 스크롤 이벤트는 손가락 한 번 튕기는 동안에도 수십 번 터집니다. 그때마다 sessionStorage 에 쓰는 거예요.
다행히 sessionStorage 에 짧은 문자열 하나를 쓰는 건 아주 가벼운 작업이라 이 정도로는 티가 안 납니다. 그래서 오늘은 이대로 둡니다.
문제가 되는 건 이벤트마다 무거운 일을 할 때예요. 서버에 요청을 보내거나, 화면 전체를 다시 계산하거나 하는 일들이요. 그럴 때는 이벤트가 아무리 자주 터져도 실제 작업은 가끔만 하도록 묶는 방법을 씁니다. 마지막 이벤트 이후 200밀리초 동안 조용하면 그때 한 번 실행하는 식이에요.
이런 기법에는 이름이 있습니다. 찾아보실 분들을 위해 적어두면 디바운스(debounce)와 스로틀(throttle)입니다. 지금 당장 필요하진 않고, 성능을 정면으로 다루는 C-4 에서 "측정해 보니 느리더라" 는 근거와 함께 꺼내는 게 순서예요.
성능 이야기가 나올 때마다 기억해 두시면 좋은 순서가 있습니다. 재보고 나서 고칩니다. 느릴 것 같아서 미리 감싸두는 코드는 대개 복잡하기만 하고 빨라지지 않아요.
Step 6: "다시 구독하지 않고 최신 값 읽기 — useEffectEvent"
기능을 하나 더 얹어봅시다. 인스타그램에서 피드를 끝까지 내리면 "모두 확인했습니다" 같은 안내가 뜨죠. 우리도 끝에 닿으면 알림을 띄웁시다. 오늘 좋아요를 몇 개 눌렀는지 함께 알려주면 좋겠어요.
먼저 끝에 닿았는지 판단하는 계산이 필요합니다.
// apps/web-spa/src/hooks/useScrollRestore.ts
function isAtBottom() {
return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1;
}
내린 거리에 창 높이를 더한 값이 문서 전체 높이에 닿으면 끝입니다. 마지막 - 1 은 소수점 오차 때문에 아슬아슬하게 안 닿는 경우를 위한 여유예요.
훅이 알림을 직접 띄우면 안 됩니다. 알림 문구를 아는 건 App 이지 이 훅이 아니거든요. 그래서 B-3 에서 배운 대로 할 일을 함수로 받습니다.
// apps/web-spa/src/hooks/useScrollRestore.ts — 아직 미완성입니다
export function useScrollRestore(onReachBottom: () => void) {
useEffect(() => {
function handleScroll() {
sessionStorage.setItem(STORAGE_KEY, String(window.scrollY));
if (isAtBottom()) {
onReachBottom();
}
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [onReachBottom]);
}
App 에서는 이렇게 씁니다.
// apps/web-spa/src/App.tsx
useScrollRestore(() => {
setToastMessage(`게시물을 모두 확인했습니다 · 좋아요 ${likedCount}개`);
});
동작은 합니다. 끝까지 내리면 알림이 뜨고 좋아요 개수도 맞아요. 그런데 여기 문제가 숨어 있습니다.
좋아요를 누를 때마다 구독을 다시 맺습니다
의존성 배열을 보세요. [onReachBottom] 입니다. effect 안에서 그 함수를 쓰니까 넣어야 하죠. 규칙도 넣으라고 합니다.
그런데 App 이 넘기는 저 화살표 함수는 좋아요를 누를 때마다 새로 만들어집니다. 그 함수가 안에서 likedCount 를 읽고 있고, 좋아요를 누르면 그 값이 바뀌거든요. 값이 달라지면 함수도 새로 만들어져야 문구가 맞습니다.
생김새가 같아도 다른 함수예요. React 는 의존성을 === 로 비교하니 매번 달라졌다고 판단합니다.
결과가 이렇습니다.
| 한 일 | 구독을 붙인 횟수 | 구독을 뗀 횟수 |
|---|---|---|
| 화면에 붙었을 때 | 1 | 0 |
| 좋아요 3번 누른 뒤 | 4 | 3 |
Step 2 에서와 같은 약속입니다. 개발 모드가 처음에 한 번 더 붙였다 떼는 몫은 빼고 센 숫자예요.
좋아요를 누를 때마다 스크롤 구독을 끊었다가 다시 맺고 있습니다. 좋아요와 스크롤은 아무 상관이 없는데도요.
좋아요를 한 번 누를 때마다
likedCount 가 바뀐다
│
▼
그 값을 읽는 새 함수가 만들어진다 ──▶ [onReachBottom] 이 달라진다
│
▼
클린업 실행 ──▶ removeEventListener
effect 실행 ──▶ addEventListener
지금은 리스너 하나 붙였다 떼는 거라 티가 안 납니다. 그런데 이게 서버와 맺은 연결이라면 어떨까요. 좋아요 한 번에 연결이 끊겼다 다시 붙습니다. C-5 에서 실시간 통신을 다룰 때 이 문제가 정면으로 나와요.
원인은 "반응해야 하는 값" 과 "읽기만 하는 값" 이 섞인 것
의존성 배열의 뜻을 다시 떠올려 봅시다. "이 값이 달라지면 다시 시작하라" 였죠.
그런데 onReachBottom 은 그런 값이 아닙니다. 우리가 원하는 건 "끝에 닿았을 때 그때의 최신 함수를 부르는 것" 이지, "함수가 바뀌었으니 구독을 다시 맺는 것" 이 아니에요.
같은 effect 안에 성격이 다른 두 종류의 값이 섞여 있는 겁니다.
effect 가 쓰는 값 두 종류
반응해야 하는 값 달라지면 구독을 다시 맺어야 하는 값
(예: 어떤 방에 연결할지, 어떤 요소를 지켜볼지)
읽기만 하는 값 달라져도 구독은 그대로 두고,
불릴 때 최신 값만 보면 되는 값
(예: 알림 문구를 만드는 함수, 지금 좋아요 개수)
의존성 배열은 이 둘을 구분할 방법이 없습니다. 넣으면 다시 시작하고, 빼면 낡은 값을 붙들어요. 배열에서 그냥 빼버리면 Step 2 에서 말한 stale closure 가 됩니다.
useEffectEvent
React 19.2 에 이 문제를 위한 훅이 들어왔습니다. useEffectEvent 예요.
파일 전체는 이렇게 됩니다. Step 5 에서 만든 복원 effect 는 그대로 있고, 구독 effect 만 손봤어요.
// apps/web-spa/src/hooks/useScrollRestore.ts
import { useEffect, useEffectEvent } from 'react';
// 브라우저가 기억해줄 자리 이름. 탭을 닫으면 같이 지워진다.
const STORAGE_KEY = 'feed-scroll';
// 화면 맨 아래에 닿았는지 — 1px 은 소수점 오차를 감안한 여유다
function isAtBottom() {
return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1;
}
// 피드를 어디까지 내렸는지 적어뒀다가, 돌아왔을 때 그 자리로 되돌린다.
// 끝까지 내려간 순간에는 넘겨받은 일을 한 번 해준다.
export function useScrollRestore(onReachBottom: () => void) {
// 구독을 다시 맺는 이유가 되지 않으면서, 불릴 때는 가장 최근 값을 본다
const handleReachBottom = useEffectEvent(onReachBottom);
// 하는 일이 다르면 effect 도 나눈다 — 이건 "돌아왔을 때 한 번"
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved !== null) {
window.scrollTo(0, Number(saved));
}
}, []);
// 이건 "스크롤하는 내내"
useEffect(() => {
function handleScroll() {
sessionStorage.setItem(STORAGE_KEY, String(window.scrollY));
if (isAtBottom()) {
handleReachBottom();
}
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
}
구독 effect 에서 두 군데가 바뀌었습니다. 넘겨받은 함수를 useEffectEvent 로 한 번 감쌌고, 의존성 배열이 비었습니다.
다시 세어 보면 이렇습니다.
| 한 일 | 구독을 붙인 횟수 | 구독을 뗀 횟수 |
|---|---|---|
| 화면에 붙었을 때 | 1 | 0 |
| 좋아요 3번 누른 뒤 | 1 | 0 |
처음 맺은 구독 하나로 끝까지 갑니다.
그런데 낡은 값을 보는 문제는 없을까요. 지금 우리 피드는 minji 의 게시물에 좋아요가 눌린 채로 시작하니 처음 개수가 1입니다. jaehoon 의 게시물에도 좋아요를 눌러 2로 만든 다음 끝까지 내려보세요.
알림에 2가 뜹니다. 구독은 화면에 처음 붙을 때 맺어졌고, 그때 개수는 1이었는데도요.
이것이 useEffectEvent 가 하는 일입니다. 불리는 순간 가장 최근 렌더의 값을 봅니다. 구독을 다시 맺지 않으면서도요.
지켜야 하는 규칙
편해 보이는 만큼 규칙이 빡빡합니다. 넷을 기억하세요.
하나 — 컴포넌트나 커스텀 훅의 맨 위에서 선언합니다. 훅이니까요. B-3 에서 배운 훅 규칙이 그대로 적용됩니다. 조건문 안에 넣으면 이렇게 막습니다.
React Hook "useEffectEvent" is called conditionally.
React Hooks must be called in the exact same order in every component render
둘 — effect 안에서만 부릅니다. 렌더 도중에 부르면 막힙니다.
`onTick` is a function created with React Hook "useEffectEvent",
and can only be called from Effects and Effect Events in the same component
셋 — 다른 컴포넌트에 넘기지 않습니다. props 로 내려보내면 위 메시지에 한 문장이 더 붙어요.
It cannot be assigned to a variable or passed down
넷 — 의존성 배열에 넣지 않습니다. 넣으면 이렇게 알려줍니다.
Functions returned from `useEffectEvent` must not be included in the dependency array.
Remove `onTick` from the list
배열에서 빼라고 대놓고 말해줍니다. 애초에 배열에 안 넣으려고 만든 도구니까요.
⚠️ 이름 때문에 오해하기 쉬운 게 있습니다. Effect Event 는 이벤트 핸들러가 아닙니다. onClick 자리에 넣거나 클릭 핸들러 안에서 부르면 위의 둘째 메시지로 막혀요. 여기서 "이벤트" 는 사용자의 클릭이 아니라 effect 안에서 벌어지는 사건을 뜻합니다.
언제 꺼내 쓸까
마지막으로 균형을 잡고 갑시다. useEffectEvent 는 의존성 배열을 비우는 편법이 아닙니다. 공식 문서도 그렇게 쓰지 말라고 못을 박아뒀어요.
판단 기준은 하나입니다. 그 값이 달라졌을 때 effect 를 다시 시작해야 하는가.
- 시작해야 한다 → 의존성 배열에 넣습니다
- 시작할 필요 없고 최신 값만 읽으면 된다 →
useEffectEvent로 뺍니다
구독을 다시 맺기 귀찮아서 전부 useEffectEvent 로 감싸면, 다시 시작해야 할 때도 안 하게 됩니다. 그건 규칙이 잡아주지도 않아요.
💡 한 줄 정리
useEffectEvent 는 "다시 시작할 이유는 아니지만 최신 값은 읽어야 하는" 것을 의존성 배열 밖으로 빼줍니다. 최상위에서 선언하고, effect 안에서만 부르고, 배열에는 넣지 않습니다.
🙋 학생 질문 — "튜터님, 그냥 의존성 배열에서 빼고 규칙 경고를 무시하면 안 되나요?"
안 됩니다. 그리고 두 방법의 차이가 오늘 배운 것의 핵심이라 짚고 갈게요.
배열에서 그냥 빼면 effect 안의 함수는 처음 만들어질 때 봤던 값을 계속 붙들고 있습니다. 좋아요를 눌러 2로 만들어도 알림에는 계속 1이 뜬다는 뜻이에요. 화면에 처음 붙었을 때가 1이었으니까요. 구독을 다시 안 맺는다는 목적은 이뤘지만, 값이 낡았습니다.
useEffectEvent 로 빼면 구독은 그대로 두면서 값은 최신입니다. 겉보기 결과가 정반대예요.
경고를 주석으로 끄는 방법도 인터넷에 많이 돌아다니는데, 그건 문제를 안 보이게 만들 뿐 고치지 않습니다. 옛날에는 마땅한 도구가 없어서 그렇게들 했어요. 지금은 도구가 생겼으니 그걸 쓰면 됩니다.
오래된 코드나 블로그에서 그 주석을 만나면 이렇게 읽으시면 됩니다. "이 코드는 useEffectEvent 가 없던 시절에 쓰인 것이구나."
Step 7: "이건 effect 가 아니다 ① — 계산은 렌더 중에"
여기부터 오늘의 나머지 절반입니다. useEffect 를 쓰지 않는 법이요.
입문자가 가장 많이 하는 실수부터 봅시다. 지금 App 에는 좋아요를 누른 게시물 개수가 떠 있죠. 이걸 처음부터 만든다면 이런 코드가 자연스러워 보입니다.
// 이렇게 하면 안 됩니다
const [posts, setPosts] = useState<Post[]>(feedPosts);
const [likedCount, setLikedCount] = useState(0);
useEffect(() => {
setLikedCount(posts.filter((post) => post.liked).length);
}, [posts]);
읽어보면 말이 됩니다. "posts 가 바뀌면 개수를 다시 세서 넣어라." 동작도 합니다.
그런데 저장하는 순간 빨간 줄이 그어집니다.
Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems
such as manually updating the DOM, state management libraries, or other platform APIs.
...
Avoid calling setState() directly within an effect
"effect 안에서 곧바로 상태를 바꾸면 렌더가 연쇄적으로 일어납니다." 그리고 effect 가 무엇을 위한 것인지 다시 설명해 줍니다. React 와 바깥 시스템을 맞추는 것이라고요. Step 1 에서 이야기한 그 동기화입니다.
왜 문제인가
화면이 몇 번 그려지는지 세어보면 명확합니다.
| 화면에 처음 붙을 때 | 좋아요를 토글할 때 | |
|---|---|---|
| effect 로 맞추는 판 | 2번 | 2번 |
| 렌더 중에 계산하는 판 | 1번 | 1번 |
매번 한 번씩 더 그립니다. 순서를 따라가면 이유가 보여요.
effect 로 맞추는 판
① posts 로 화면을 그린다 likedCount 는 아직 옛날 값
② 화면이 붙는다 사용자가 잠깐 옛날 값을 본다
③ effect 가 돈다 ──▶ setLikedCount
④ 화면을 다시 그린다 이제 맞는 값
②와 ③ 사이에 사용자가 틀린 값을 봅니다. 지금은 눈에 안 띌 만큼 짧지만, 없어도 될 왕복이라는 점은 분명해요.
더 중요한 문제는 따로 있습니다. likedCount 는 상태일 이유가 없습니다.
상태는 "다른 것으로부터 알아낼 수 없는 값" 만 담습니다. posts 를 보면 좋아요 개수는 언제든 셀 수 있어요. 세면 되는 값을 굳이 따로 저장해두면, 두 값이 어긋날 틈이 생깁니다.
그냥 세면 됩니다
const [posts, setPosts] = useState<Post[]>(feedPosts);
const likedCount = posts.filter((post) => post.liked).length;
두 줄입니다. effect 도, 두 번째 상태도, 의존성 배열도 필요 없어요. posts 가 바뀌면 컴포넌트가 다시 실행되고, 그때 이 줄이 다시 계산됩니다. 항상 맞을 수밖에 없어요.
사실 우리는 이미 이렇게 하고 있었습니다. B-3 에서 만든 훅을 열어보세요.
// apps/web-spa/src/hooks/useLikeToggle.ts
import { useState } from 'react';
import type { Post } from '../types/instagram';
import { toggleLike } from '../lib/likes';
// 피드가 들고 있던 좋아요 상태와 갱신 함수를 통째로 옮겨 이름을 붙인 것이다.
// 옮긴 것은 코드일 뿐, 상태는 여전히 이 훅을 부른 컴포넌트의 것이다.
export function useLikeToggle(initialPosts: Post[]) {
const [posts, setPosts] = useState(initialPosts);
const likedCount = posts.filter((post) => post.liked).length;
function toggle(id: number) {
setPosts(toggleLike(posts, id));
}
return { posts, likedCount, toggle };
}
상태는 posts 하나뿐이고, likedCount 는 그 아래 한 줄에서 세고 있습니다. 지난 시간에 아무 설명 없이 지나간 그 줄이, 오늘 배운 기준으로 보면 옳은 선택이었어요.
판단하는 법
이렇게 자문해 보세요. 이 값을 이미 있는 값으로 계산할 수 있나?
계산할 수 있으면 상태로 두지 말고 렌더 중에 계산합니다. 좋아요 개수, 필터링된 목록, 글자 수, 버튼을 눌러도 되는지 여부. 전부 계산으로 나오는 값이에요.
계산할 수 없는 것만 상태입니다. 사용자가 입력한 글, 서버가 준 데이터, 열렸는지 닫혔는지 같은 것들이요.
⚠️ 한 가지 헷갈리기 쉬운 경우가 있습니다. 규칙이 잡는 것은 effect 몸통에서 곧바로 상태를 바꾸는 코드예요. 타이머나 이벤트 콜백 안에서 바꾸는 것은 잡지 않습니다. Step 3 의 알림이 그랬죠. 3초 뒤 콜백에서 상태를 바꿨지만 경고가 안 떴습니다.
규칙 메시지가 그 이유를 직접 말해줍니다. "바깥 시스템에서 변화가 올 때 콜백 안에서 상태를 바꾸는 것" 은 effect 가 원래 하라고 있는 일이라고요. 바깥에서 온 소식을 React 로 들여오는 건 정당합니다. 이미 React 안에 있는 값을 다시 계산해 넣는 것이 문제예요.
💡 한 줄 정리
이미 있는 값으로 계산되는 것은 상태로 두지 말고 렌더 중에 계산합니다. effect 몸통에서 곧바로 상태를 바꾸고 있다면 대개 잘못 짚은 것입니다.
🙋 학생 질문 — "튜터님, 계산이 무거우면 매번 다시 계산하는 게 낭비 아닌가요?"
좋은 질문인데, 답이 요즘 많이 바뀌었습니다.
먼저 대부분의 계산은 무겁지 않습니다. 게시물 몇십 개를 filter 로 훑는 정도는 눈 깜짝할 사이예요. 화면을 한 번 그리는 비용이 그보다 훨씬 큽니다. 그래서 effect 로 한 번 더 그리게 만드는 쪽이 오히려 손해예요.
정말 무거운 계산이 있다면 결과를 기억해뒀다가 재사용하는 방법이 있습니다. 그런데 우리 프로젝트에는 React Compiler 라는 게 켜져 있어서, 이런 기억해두기를 빌드할 때 자동으로 붙여줍니다. B-1 에서 환경을 만들 때 함께 넣어둔 것이 이거예요.
그래서 지금 여러분이 할 일은 깔끔하게 쓰고 맡기는 것입니다. 컴파일러가 무엇을 자동으로 해주는지, 자동으로 안 되는 곳에서 어떻게 손으로 붙이는지는 C-4 에서 DevTools 로 직접 확인하며 배웁니다.
어느 쪽이든 effect 로 상태를 하나 더 만드는 것은 답이 아닙니다. 그건 빨라지지도 않으면서 어긋날 위험만 더하거든요.
Step 8: "이건 effect 가 아니다 ② — 이벤트는 핸들러에"
두 번째 실수는 조금 더 그럴듯해서 알아채기 어렵습니다.
Step 3 에서 알림을 만들 때를 떠올려 보세요. 우리는 handleToggleLike 안에서 문구를 채웠습니다. 그런데 이런 방법도 떠오릅니다.
// 이렇게 하면 안 됩니다
useEffect(() => {
if (liked) {
setMessage('게시물을 좋아합니다');
}
}, [liked]);
"좋아요 상태가 바뀌면 알림을 띄운다." 말이 되죠. 심지어 더 선언적으로 보입니다. 상태를 지켜보다가 반응하는 모습이니까요.
직접 확인해 봅시다. 이미 좋아요가 눌려 있는 게시물을 여는 상황입니다. 우리 피드에는 그런 게시물이 있죠. minji 의 게시물이 처음부터 liked: true 입니다.
화면을 열자마자 아무도 누르지 않았는데 알림이 뜹니다.
당연합니다. effect 는 "누가 눌렀는지" 를 모르거든요. 아는 건 liked 가 true 라는 사실뿐이고, 화면에 처음 붙을 때도 그건 true 였습니다.
핸들러에 둔 판으로 같은 상황을 열면 알림이 안 뜹니다. 누르지 않았으니까요.
좋아요가 이미 눌린 게시물을 열었을 때
effect 로 감지하는 판 화면 열림 ──▶ liked 가 true ──▶ 알림이 뜬다 ✗
핸들러에 둔 판 화면 열림 ──▶ 아무 일도 없다 ✓
기준은 "무엇 때문에 일어났는가"
두 방식의 차이는 원인을 아는가입니다.
핸들러는 원인을 압니다. 사용자가 이 버튼을 눌렀다는 사실 자체가 핸들러가 불린 이유거든요. 그래서 "누르면 알림" 을 정확히 표현합니다.
effect 는 원인을 모릅니다. 값이 이렇게 됐다는 결과만 볼 뿐이에요. 사용자가 눌러서 true 가 됐든, 서버에서 받아와서 true 였든, 화면을 처음 열 때부터 true 였든 구분할 수 없습니다.
그래서 이렇게 자문하세요. 이 일은 무엇 때문에 일어나야 하나?
- 사용자가 무언가 했기 때문에 → 그 행동을 받는 핸들러에 씁니다
- 화면이 떠 있는 동안 바깥과 맞춰야 하기 때문에 → effect 에 씁니다
알림은 앞쪽입니다. 누가 눌렀기 때문에 뜨는 것이지, 화면이 떠 있어서 뜨는 게 아니에요.
Step 5 의 스크롤 구독은 뒤쪽입니다. 사용자의 어떤 클릭 때문이 아니라, 이 화면이 떠 있는 동안 계속 브라우저와 맞춰야 하는 일이거든요.
오늘 배운 것을 한 장으로
오늘 만든 코드를 이 기준으로 다시 훑어봅시다.
| 하는 일 | 어디에 | 왜 |
|---|---|---|
| 탭 제목 맞추기 | effect | 화면이 떠 있는 동안 브라우저와 맞춘다 |
| 알림을 3초 뒤 치우기 | effect | 시간이 흐르는 동안 벌어지는 일이고 정리가 필요하다 |
| 스크롤 위치 기억·복원 | effect | 브라우저 구독이 필요하고 정리가 필요하다 |
| 좋아요 개수 세기 | 렌더 중 계산 | posts 로 계산되는 값이다 |
| 알림 문구 채우기 | 핸들러 | 사용자가 눌렀기 때문에 일어난다 |
세 갈래로 나뉩니다. 이 표를 만드는 감각이 오늘의 결론이에요.
이 일을 어디에 쓸까
값에서 계산되는가? ──▶ 렌더 중에 계산
│ 아니오
▼
사용자가 한 일 때문인가? ──▶ 이벤트 핸들러
│ 아니오
▼
화면이 떠 있는 동안
바깥과 맞춰야 하는가? ──▶ useEffect (정리까지 함께)
위에서부터 물어보고, 먼저 걸리는 곳에 씁니다. useEffect 는 맨 아래예요. 앞의 둘로 안 되는 일만 여기까지 내려옵니다.
💡 한 줄 정리
사용자가 한 일 때문에 벌어지는 일은 이벤트 핸들러에 씁니다. effect 는 원인을 모르고 결과만 보기 때문에, 화면을 열자마자 엉뚱하게 실행될 수 있습니다.
🙋 학생 질문 — "튜터님, 그럼 상태 변화에 반응하는 코드는 다 잘못된 건가요?"
아니요. 상태에 반응하는 effect 가 맞는 경우도 있습니다. 다만 반응해서 하는 일이 바깥 세상과 맞추는 일 이어야 해요.
오늘 만든 탭 제목 effect가 그렇습니다. likedCount 라는 상태에 반응하죠. 그런데 반응해서 하는 일이 브라우저 탭 제목을 맞추는 일이라 정당합니다.
문제가 되는 건 반응해서 하는 일이 React 안에서 끝나는 일 일 때예요. 상태를 또 바꾸거나, 알림을 띄우거나 하는 것들이요. 그건 바깥과 맞추는 게 아니라 앱 내부의 흐름이고, 그 흐름은 원인이 되는 곳에 쓰는 게 맞습니다.
구분이 헷갈릴 때 도움이 되는 질문이 하나 있습니다. 이 화면을 껐다 켰을 때 이 일이 다시 일어나야 하나?
탭 제목은 다시 일어나야 합니다. 화면이 떠 있으면 제목도 맞아야 하니까요. 좋아요 알림은 다시 일어나면 안 됩니다. 사용자는 아까 한 번 눌렀을 뿐이거든요.
Step 4 에서 개발 모드가 effect 를 껐다 켜본다고 했죠. 그 시험이 잡아내는 게 정확히 이런 겁니다.
마무리
오늘은 useEffect 하나를 배웠는데, 절반은 안 쓰는 법이었습니다. 조금 이상하게 들릴 수 있지만 이게 이 훅의 성격이에요. 힘이 세서 아무 데나 쓰이고, 아무 데나 쓰면 문제가 생깁니다.
오늘 배운 핵심 세 가지
💡 하나 — 컴포넌트 함수는 화면 결과만 돌려줍니다. 바깥 세상을 건드리는 일은 그리고 난 다음, effect 안에서 합니다. 그래야 React 가 컴포넌트를 언제 몇 번 부를지 자유롭게 정할 수 있어요. 렌더 중에 document.title 을 건드리려 했을 때 규칙이 막아선 이유가 이것이었습니다.
💡 둘 — 시작한 것은 끝냅니다. 타이머를 걸었으면 취소하고, 구독을 맺었으면 끊습니다. 클린업을 빠뜨리면 개발 모드가 시작·정리·시작으로 한 번 더 돌려보며 티를 냅니다. 그 이중 실행이 거슬린다면 끄는 게 아니라 클린업을 채워야 해요.
💡 셋 — effect 는 마지막 선택지입니다. 값에서 계산되면 렌더 중에 계산하고, 사용자가 한 일 때문이면 핸들러에 씁니다. 그 둘로 안 되는 일, 그러니까 화면이 떠 있는 동안 바깥과 맞춰야 하는 일만 effect 까지 내려옵니다.
가장 흔한 오해 하나 — effect 로 데이터 가져오기
마지막으로 짚고 갈 것이 있습니다. 오늘 배운 걸 손에 넣으면 거의 모두가 다음으로 이걸 하려고 해요.
useEffect(() => {
fetch('/api/posts')
.then((response) => response.json())
.then((data) => setPosts(data));
}, []);
지금 우리 피드는 feedPosts 라는 하드코딩된 배열을 씁니다. 이걸 서버에서 받아오면 진짜 앱이 되겠죠. 그리고 위 코드는 언뜻 오늘 배운 것과 잘 맞아 보입니다. 서버는 바깥 세상이고, 화면이 붙을 때 한 번 맞추는 일이니까요.
그런데 여기서 실무의 고통이 시작됩니다. 시작만 해도 이런 것들을 손으로 관리해야 해요.
- 로딩 중인지, 실패했는지를 상태로 따로 만들어 관리해야 합니다
- 요청이 두 개 겹치면 늦게 출발한 게 먼저 도착할 수 있고, 그러면 화면이 옛날 데이터로 되돌아갑니다
- 화면을 떠나면 요청을 취소해야 합니다
- 같은 데이터를 여러 화면이 쓰면 각자 따로 받아옵니다
- 부모가 받고 자식이 또 받으면 요청이 줄줄이 이어져 느려집니다
async 를 붙여 간단히 만들려는 시도도 막힙니다.
useEffect(async () => {
const response = await fetch('/api/posts');
}, []);
Argument of type '() => Promise<void>' is not assignable to parameter of type 'EffectCallback'
effect 가 돌려주는 값은 클린업 함수여야 하는데 async 함수는 Promise 를 돌려주거든요. 규칙이 친절하게 대안까지 보여줍니다. effect 안에 async 함수를 따로 만들고 그걸 부르라고요. 그러면 통과는 하는데, 위에 적은 다섯 가지 고통은 그대로 남습니다.
그래서 오늘은 데이터 가져오기를 하지 않습니다. 여러분이 뭔가를 덜 배운 게 아니라, 이건 effect 로 풀 문제가 아니라서 그래요. C-3 에서 서버 데이터를 전담하는 도구를 만나면 위 다섯 가지가 한꺼번에 정리됩니다. 그때 오늘의 이 코드를 다시 꺼내서 "이래서 아팠다" 를 함께 볼 거예요.
다음 시간 예고
오늘 만든 것들을 타입 관점에서 다시 보면 빈 곳이 보입니다.
useScrollRestore 는 함수를 하나 받는데 그 함수의 타입을 어떻게 적었는지 다시 보세요. () => void 라고 손으로 적었죠. useToggle 은 [boolean, () => void] 를 손으로 적었고요. 훅이 주고받는 값에 타입을 어떻게 다는 게 좋은지, 안 적으면 무엇이 잘못 추론되는지를 아직 정리하지 않았습니다.
B-2 에서 useState<DraftComment[]>([]) 처럼 꺾쇠 안에 타입을 적었던 것도 기억하시죠. 왜 그때는 적어야 했는지, 안 적으면 무슨 일이 생기는지도 미뤄뒀습니다.
그리고 오늘 알림을 만들며 남긴 숙제가 하나 있어요. 지금 알림은 문구 하나를 상태로 들고 있습니다. 같은 문구가 연달아 뜨면 [toastMessage] 가 안 바뀌어서 타이머가 새로 걸리지 않아요. 지금 피드는 사용자가 둘이라 문구가 겹칠 일이 없지만, 알림 종류가 늘어나면 흔들립니다. 여러 값이 함께 움직여야 할 때 상태를 어떻게 묶는지도 다음 시간의 주제입니다.
다음 시간에는 훅에 타입을 다는 법과, 어떤 타입에도 쓰이는 컴포넌트를 만드는 법을 배웁니다.
과제
[구현] 창 너비를 듣는 훅 만들기
오늘 스크롤을 구독했으니 이번엔 창 크기입니다. 브라우저 창을 좁히면 알려주는 기능을 만들어 주세요.
apps/web-spa/src/hooks/useWindowWidth.ts에useWindowWidth훅을 만들어 주세요. 지금 창 너비를 숫자로 돌려줍니다.- 훅 안에서
resize이벤트를 구독하고, 클린업에서 반드시 끊어 주세요. - 처음 값은
window.innerWidth로 시작하면 됩니다. App머리말에 현재 너비를너비 1024px처럼 띄워 주세요. 좋아요 개수 옆입니다.- 너비가 640 미만이면
화면이 좁아요 · 가로로 넓히면 더 편해요라는 안내를 머리말 아래에 띄워 주세요. 이 안내를 띄울지 말지는 상태로 두지 마세요. 오늘 Step 7 에서 세운 기준을 그대로 적용하면 됩니다. - 구독 콜백 안에서 상태를 바꾸게 될 텐데, 이건 규칙에 안 걸립니다. 왜 안 걸리는지 Step 7 마지막에 적어둔 이유를 다시 읽고 한 줄로 정리해 주세요.
- 브라우저 창을 실제로 좁혔다 넓혔다 해보고, 숫자가 따라오는지 확인해 주세요.
다 만들고 나면 npm run typecheck -w web-spa 와 npm run lint -w web-spa 를 돌려 둘 다 통과하는지 확인해 주세요.
[탐구] 정리를 안 하면 무슨 일이 생기는지 직접 보기
Step 3~4 에서 말로만 들은 것을 손으로 확인해 봅시다. 각 단계에서 무슨 일이 일어났는지 한두 줄씩 적어 주세요. 확인이 끝나면 전부 원래대로 되돌립니다.
useScrollRestore의return () => window.removeEventListener(...)줄을 지우고, 구독 함수 안에console.log를 하나 넣어 주세요. 스크롤할 때 로그가 몇 줄씩 찍히나요? 그 숫자가 나온 이유를 Step 4 로 설명해 보세요.- 그 상태에서
main.tsx의<StrictMode>를 잠깐 벗겨 보세요. 로그가 몇 줄로 바뀌나요? 문제가 사라진 걸까요, 안 보이게 된 걸까요? - 알림 effect 의
clearTimeout을 지우고, 좋아요를 2초 간격으로 연달아 눌러 보세요. 두 번째 알림이 몇 초 만에 사라지나요? - 탭 제목 effect 의 의존성 배열을
[]로 바꿔 보세요. 좋아요를 눌러도 탭 제목이 안 바뀔 텐데, 화면의 좋아요 개수는 어떻게 되나요? 둘이 갈리는 이유를 적어 주세요. useScrollRestore안에서useEffectEvent로 만든 함수를 의존성 배열에 넣어 보세요. 에디터가 뭐라고 하나요?- 마지막으로 그 함수를
<Feed>에 props 로 넘겨 보세요. 앞의 메시지와 무엇이 다른지 한 줄로 정리해 주세요.
생각해볼 주제
1. 개발 모드의 이중 실행을 끄고 싶어질 때
Step 4 에서 StrictMode 를 벗기면 로그가 깔끔해진다고 했습니다. 실제로 팀에서 이런 제안이 나오는 경우가 있어요. 개발할 때 요청이 두 번씩 나가서 서버 로그가 지저분하다는 이유로요.
여러분이라면 어떻게 답하시겠어요. 그리고 정말로 벗겨야 하는 상황이 있다면 어떤 경우일까요.
2. 훅 하나에 effect 를 몇 개까지 담을까
useScrollRestore 안에는 effect 가 두 개 있습니다. 복원하는 것과 구독하는 것이요. 하는 일이 다르니 나눴죠.
그런데 이 논리를 계속 밀면 훅 자체를 useScrollSave 와 useScrollRestore 둘로 나누는 것도 가능합니다. 반대로 창 크기까지 합쳐서 useBrowserSync 하나로 묶을 수도 있고요.
어디서 선을 그으시겠어요. B-3 에서 컴포넌트를 쪼갤 때 세운 기준이 훅에도 그대로 적용될까요, 아니면 다른 기준이 필요할까요.
3. "이건 반응하지 마" 가 위험해지는 순간
useEffectEvent 는 어떤 값이 바뀌어도 effect 를 다시 시작하지 않게 해줍니다. 편한 만큼 위험하기도 해요. 정말 다시 시작해야 하는 값까지 감싸버리면, 규칙도 안 잡아주고 화면은 조용히 낡은 채로 남습니다.
팀원이 "구독이 자꾸 다시 맺어져서 useEffectEvent 로 감쌌어요" 라고 했을 때, 여러분은 무엇을 확인하시겠어요. 그 판단을 코드 리뷰에서 어떻게 물어보면 좋을까요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 창 너비를 듣는 훅 만들기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 클린업 | resize 구독을 클린업에서 반드시 끊었는가 |
25 |
| 의존성 배열 | 빈 배열로 두어 구독을 한 번만 맺는가 | 15 |
| 처음 값 | useState(window.innerWidth) 로 첫 값을 채웠는가 |
10 |
| 안내를 상태로 안 둠 | isNarrow 를 상태가 아니라 계산으로 만들었는가 |
20 |
| 훅 분리 | 너비를 듣는 일을 useWindowWidth 로 빼냈는가 |
15 |
| 규칙 설명 | 콜백 안 setWidth 가 왜 규칙에 안 걸리는지 적었는가 |
10 |
| 검사 통과 | typecheck 와 lint 가 모두 통과하는가 |
5 |
배점이 클린업과 isNarrow 에 몰려 있습니다. 이 둘이 오늘 배운 것의 양 끝이거든요. 하나는 "effect 를 쓸 때 반드시 하는 것", 다른 하나는 "effect 를 안 쓰는 것" 입니다.
풀이 예시
훅부터 만듭니다.
// apps/web-spa/src/hooks/useWindowWidth.ts
import { useEffect, useState } from 'react';
export function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
파일 확장자가 .ts 입니다. JSX 가 한 줄도 없으니까요. B-3 에서 useLikeToggle 을 만들 때와 같은 이유예요.
useState(window.innerWidth) 로 시작한 것을 봐주세요. useState(0) 으로 두면 화면을 처음 열었을 때 잠깐 0이 보입니다. 창을 한 번 움직여야 제 값이 들어오거든요. 처음부터 알 수 있는 값이면 처음부터 넣는 게 맞습니다.
의존성 배열은 비어 있습니다. 이 구독은 어떤 값에도 반응할 필요가 없어요. 창 크기가 바뀌면 handleResize 가 알아서 최신 값을 읽습니다.
Step 5 의 useScrollRestore 와 다른 점이 하나 있어요. 그 훅은 아무것도 안 돌려줬는데 이 훅은 width 를 돌려줍니다. 스크롤 위치는 화면에 그릴 일이 없었지만 창 너비는 화면에 띄워야 하거든요. 훅이 값을 돌려줄지 말지는 화면이 그 값을 필요로 하는가로 갈립니다.
이제 App 에서 씁니다. 오늘 만든 알림과 스크롤 복원은 그대로 두고, 아래 세 군데만 더합니다.
파일 위쪽에 기준 너비를 하나 둡니다.
// apps/web-spa/src/App.tsx
const NARROW_WIDTH = 640;
App 안에서는 훅을 부르고 좁은지를 계산합니다.
// apps/web-spa/src/App.tsx
const width = useWindowWidth();
// 안내를 띄울지는 너비로 계산되는 값이라 상태로 두지 않는다
const isNarrow = width < NARROW_WIDTH;
JSX 에서는 머리말에 숫자를 더하고, 머리말 바로 아래에 안내를 답니다.
// apps/web-spa/src/App.tsx
<header className="feed-header">
<h1 className="feed-title">인스타그램</h1>
<span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
<span className="feed-width">너비 {width}px</span>
</header>
{isNarrow && <p className="narrow-notice">화면이 좁아요 · 가로로 넓히면 더 편해요</p>}
isNarrow 가 이 과제의 핵심입니다. 상태가 아니라 그냥 변수예요. width 를 알면 좁은지 아닌지는 언제든 계산됩니다. Step 7 에서 세운 기준 그대로입니다.
만약 이렇게 썼다면 되돌리세요.
// 이렇게 하면 안 됩니다
const [isNarrow, setIsNarrow] = useState(false);
useEffect(() => {
setIsNarrow(width < NARROW_WIDTH);
}, [width]);
동작은 합니다. 그런데 화면을 한 번 더 그리고, width 와 isNarrow 가 어긋날 길을 열어둡니다. 규칙도 빨간 줄로 막아요.
왜 콜백 안의 setWidth 는 규칙에 안 걸리나
과제가 한 줄로 정리해 보라고 한 것이 이겁니다.
규칙이 잡는 것은 effect 몸통에서 곧바로 상태를 바꾸는 코드입니다. 위 훅에서 setWidth 는 effect 몸통이 아니라 handleResize 안에 있어요. 그 함수는 effect 가 도는 순간이 아니라 나중에 브라우저가 창 크기를 바꿨을 때 불립니다.
규칙 메시지가 이유를 직접 말해줍니다. 바깥 시스템에서 변화가 왔을 때 콜백 안에서 상태를 바꾸는 것은 effect 가 원래 하라고 있는 일이라고요.
한 줄로 적으면 이렇게 됩니다.
이미 React 안에 있는 값을 다시 계산해 넣는 것이 아니라, 바깥에서 온 소식을 React 안으로 들여오는 것이라서.
자주 나오는 실수
클린업을 빠뜨립니다. 가장 많은 실수이고 배점도 가장 큽니다. 개발 중에는 티가 잘 안 나는데, 화면을 오갈 때마다 리스너가 하나씩 쌓입니다.
의존성 배열에 width 를 넣습니다.
// 이렇게 하면 안 됩니다
}, [width]);
규칙이 시키지도 않았는데 넣는 경우가 있어요. effect 안에서 width 를 읽지 않으니 넣을 이유가 없습니다. 넣으면 창을 움직일 때마다 구독을 끊었다 다시 맺습니다. Step 6 에서 본 그 문제예요.
setWidth(width + 1) 처럼 이전 값을 씁니다. 지금 필요한 건 브라우저가 아는 실제 너비지 우리가 계산한 값이 아닙니다. window.innerWidth 를 그때그때 읽으세요.
안내 문구를 effect 로 띄웁니다. useEffect(() => { if (width < 640) setNotice(...) }, [width]) 처럼요. 화면에 무엇이 보일지는 계산이지 부수효과가 아닙니다.
💡 튜터의 한마디
이 과제는 짧지만 오늘 배운 세 가지를 한 번에 씁니다. 바깥을 구독하고, 정리하고, 계산되는 값은 상태로 안 두는 것이요.
실무에서 이 훅은 거의 그대로 쓰입니다. 화면 크기에 따라 다른 레이아웃을 보여주는 일이 흔하거든요. 다만 진짜 프로젝트에서는 CSS 미디어 쿼리로 되는 일을 굳이 자바스크립트로 하지 않는지 먼저 따져봅니다. 보이고 안 보이고는 CSS 가 더 잘하고 더 빠르니까요.
자바스크립트가 필요한 건 너비에 따라 다른 컴포넌트를 그려야 할 때 입니다. 좁을 때는 아예 다른 구조를 쓰거나, 무거운 컴포넌트를 아예 안 그리고 싶을 때요. 그 판단이 E-3 에서 Tailwind 로 반응형을 다룰 때 다시 나옵니다.
🎯 [과제 2 예시답안] 정리를 안 하면 무슨 일이 생기는지 직접 보기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 리스너 누수 | 스크롤 한 번에 로그가 두 줄인 것을 확인하고 이유를 Step 4 로 설명했는가 | 20 |
| StrictMode 판단 | 로그가 한 줄로 줄어든 것을 "해결" 이 아니라 "가려짐" 으로 읽었는가 | 20 |
| 타이머 누수 | 두 번째 알림이 약 1초 만에 사라지는 것을 확인했는가 | 15 |
| 낡은 값 | 탭 제목은 멈추는데 화면 숫자는 따라오는 이유를 적었는가 | 20 |
| 두 메시지 구분 | 배열에 넣었을 때와 넘겼을 때의 메시지 차이를 짚었는가 | 20 |
| 되돌리기 | 확인 후 전부 원래대로 되돌렸는가 | 5 |
풀이 예시
첫째 — 클린업을 지우고 스크롤하면 로그가 두 줄씩 찍힙니다.
useScrollRestore 의 클린업을 지운 상태로 스크롤을 한 번 하면 이렇게 됩니다.
지금 위치: 120
지금 위치: 120
Step 4 의 이중 실행 때문입니다. 개발 모드는 effect 를 시작·정리·시작 순으로 돌리는데, 정리하는 코드가 없으니 정리가 건너뛰어졌어요. 그래서 addEventListener 만 두 번 실행됐고, 리스너가 두 개 붙었습니다. 스크롤 한 번에 두 개가 각각 반응하는 겁니다.
둘째 — StrictMode 를 벗기면 한 줄로 줄어듭니다. 그런데 문제는 그대로입니다.
지금 위치: 120
깔끔해 보이죠. 하지만 사라진 건 증상이지 문제가 아닙니다. 확인하는 방법이 있어요.
화면에서 이 컴포넌트를 떼어낸 다음 스크롤해 보세요. 컴포넌트는 사라졌는데 로그가 계속 찍힙니다. 아무도 안 쓰는 리스너가 브라우저에 남아서 계속 일하고 있는 거예요.
실제 앱이라면 화면을 오갈 때마다 이런 리스너가 하나씩 쌓입니다. 열 번 오가면 열 개가 붙어 있고, 스크롤 한 번에 열 번 반응해요. 이걸 메모리 누수라고 부릅니다.
StrictMode 는 이 문제를 만든 게 아니라 앞당겨 보여준 것입니다. 온도계를 치운다고 열이 내리지 않아요.
셋째 — clearTimeout 을 지우고 2초 간격으로 연달아 누르면 두 번째 알림이 약 1초 만에 사라집니다.
첫 알림이 걸어둔 3초 타이머가 살아 있다가, 첫 알림이 뜬 지 3초가 되는 순간 터집니다. 그때 화면에 떠 있는 건 두 번째 알림이에요. 두 번째 알림은 뜬 지 1초밖에 안 됐는데 지워집니다.
타이머는 자기가 누구를 지우는지 모릅니다. 그저 "알림을 지워라" 는 명령만 들고 있어요.
넷째 — 탭 제목은 멈추는데 화면 숫자는 따라옵니다.
탭 제목 effect 의 배열을 [] 로 바꾸면 이렇게 갈립니다.
| 처음 | 좋아요를 하나 더 누른 뒤 | |
|---|---|---|
| 화면의 좋아요 개수 | 1개 | 2개 |
| 탭 제목 | 인스타그램 (좋아요 1) | 인스타그램 (좋아요 1) |
둘이 갈리는 이유는 누가 다시 계산하는가가 다르기 때문입니다.
화면의 숫자는 컴포넌트가 다시 실행되면서 매번 새로 계산됩니다. 상태가 바뀌면 React 가 컴포넌트를 다시 부르고, 그때 likedCount 가 다시 계산돼요.
탭 제목 effect 는 다릅니다. 배열이 비어 있으니 처음 한 번만 실행되고, 그 안의 함수는 처음 만들어질 때 봤던 likedCount 를 그대로 붙들고 있습니다. 그게 Step 2 에서 이야기한 stale closure 예요.
한 줄로 정리하면 이렇습니다.
화면은 매 렌더마다 다시 계산되지만, effect 안의 함수는 마지막으로 실행됐을 때의 값에 멈춰 있다.
다섯째와 여섯째 — 두 메시지는 종류가 다릅니다.
useEffectEvent 로 만든 함수를 의존성 배열에 넣으면 이렇게 나옵니다.
Functions returned from `useEffectEvent` must not be included in the dependency array.
Remove `onTick` from the list
같은 함수를 <Feed> 에 props 로 넘기면 이렇게 나옵니다.
`onTick` is a function created with React Hook "useEffectEvent",
and can only be called from Effects and Effect Events in the same component.
It cannot be assigned to a variable or passed down
한 줄로 정리하면 이렇습니다.
앞엣것은 "배열에서 빼라" 는 안내이고, 뒤엣것은 "이 컴포넌트 밖으로 나갈 수 없다" 는 금지다.
앞엣것은 고치는 법이 분명한 실수라 경고로 알려주고, 뒤엣것은 함수의 성질 자체를 어기는 것이라 에러로 막습니다. useEffectEvent 로 만든 함수는 자기를 만든 컴포넌트의 effect 안에서만 살 수 있어요.
자주 나오는 실수
둘째 항목에서 "해결됐다" 고 적습니다. 로그가 한 줄이 됐으니 고쳐진 것처럼 보이거든요. 화면을 떠난 뒤에도 반응하는지 확인해 보면 답이 나옵니다.
넷째 항목에서 "둘 다 안 바뀐다" 고 적습니다. 아마 화면 숫자를 안 보고 탭만 봤을 거예요. 두 곳을 나란히 두고 비교해야 갈리는 게 보입니다.
되돌리기를 빠뜨립니다. 다음 모듈은 오늘 코드에서 이어집니다. git diff 로 확인하고 전부 되돌려 주세요.
💡 튜터의 한마디
이 과제의 진짜 주제는 증상과 원인을 구분하는 것입니다.
로그가 두 줄 찍히는 건 증상이에요. 원인은 리스너가 두 개 붙은 것이고, 그 원인의 원인은 클린업이 없는 것입니다. StrictMode 를 벗기면 증상만 사라집니다.
실무에서 이 구분을 못 하면 시간을 많이 씁니다. "로컬에선 멀쩡한데 배포하면 이상해요" 같은 문제가 대체로 이런 모양이거든요. 개발 모드가 미리 보여준 신호를 껐던 겁니다.
그래서 이상한 걸 봤을 때 순서를 이렇게 잡으세요. 무엇이 보이는가 → 왜 그렇게 보이는가 → 그 원인은 무엇인가. 세 번째까지 가야 고친 겁니다.
🤔 [생각해볼 주제 1] 개발 모드의 이중 실행을 끄고 싶어질 때
문제 상황 요약
팀에서 이런 제안이 나왔습니다. 개발 중에 요청이 두 번씩 나가서 서버 로그가 지저분하고, 백엔드 팀에서도 중복 요청이 뭐냐고 물어본다는 겁니다. StrictMode 를 벗기면 깔끔해진다고 하네요.
여러분이라면 어떻게 답하시겠어요.
튜터의 가이드 및 해설
먼저 무엇이 두 번 나가는지 봅니다.
요청이 두 번 나간다는 건 그 요청을 보내는 effect 에 클린업이 없다는 뜻입니다. 정확히는 취소가 없는 거예요. 시작·정리·시작에서 정리가 아무것도 안 하니 요청 두 개가 그대로 살아서 나갑니다.
그러니 첫 답은 이겁니다. 이건 StrictMode 가 만든 문제가 아니라 원래 있던 문제입니다. 화면을 열었다 닫았다 하면 배포된 앱에서도 똑같이 두 번 나가요.
두 번 나가서 생기는 진짜 피해를 따져봅니다.
조회 요청이 두 번 나가는 건 대체로 낭비에 그칩니다. 그런데 주문을 넣거나 좋아요를 누르는 요청이 두 번 나가면 데이터가 망가져요. 후자를 발견했다면 StrictMode 는 은인입니다. 배포 전에 잡아준 거니까요.
그럼 벗겨도 되는 경우가 있을까요.
거의 없습니다. 굳이 꼽자면 남이 만든 라이브러리가 이중 실행을 못 견디고, 고칠 수도 바꿀 수도 없을 때 정도예요. 그때도 앱 전체를 벗기는 게 아니라 그 부분만 감싸는 방법을 먼저 찾습니다.
서버 로그가 지저분한 문제 자체는 따로 답이 있습니다.
이건 실은 다른 문제예요. 개발 환경 로그를 어떻게 볼지의 문제지 프론트엔드 코드의 문제가 아닙니다. 백엔드 팀에는 "개발 모드에서 의도적으로 검사를 돌리는 중" 이라고 설명하면 됩니다.
그리고 대부분의 경우, 애초에 데이터를 effect 로 가져오지 않게 되면 이 논쟁 자체가 사라집니다. C-3 에서 배울 도구는 같은 요청이 겹치면 알아서 하나로 합쳐주거든요.
🎯 면접관을 홀리는 핵심 멘트
"StrictMode 를 끄자는 제안이 나오면 저는 먼저 무엇이 두 번 실행되는지부터 봅니다. 이중 실행은 문제를 만드는 게 아니라 클린업이 빠진 자리를 앞당겨 보여주는 장치라서, 끄는 순간 그 문제는 배포 후에 사용자가 대신 발견하게 되거든요. 조회 요청이면 낭비지만 쓰기 요청이면 데이터가 깨지는 사고라, 저는 오히려 이중 실행에서 뭐가 깨지는지를 기능 점검 항목으로 씁니다."
🤔 [생각해볼 주제 2] 훅 하나에 effect 를 몇 개까지 담을까
문제 상황 요약
useScrollRestore 안에는 effect 가 두 개 있습니다. 복원하는 것과 구독하는 것이요. 하는 일이 다르니 나눴습니다.
그런데 이 논리를 계속 밀면 훅 자체를 둘로 나눌 수도 있고, 반대로 창 크기까지 합쳐 하나로 묶을 수도 있습니다. 어디서 선을 그으시겠어요.
튜터의 가이드 및 해설
effect 를 나누는 기준과 훅을 나누는 기준은 다릅니다.
먼저 effect 부터. 이건 기준이 분명합니다. 의존성이 다르면 반드시 나눕니다. 한 effect 는 [roomId] 에 반응해야 하고 다른 하나는 [] 여야 한다면, 합치는 순간 둘 중 하나가 틀린 시점에 돌아요. 선택의 여지가 없습니다.
지금은 둘 다 [] 라 합쳐도 동작은 같습니다. 그래도 나눈 건 읽는 사람을 위해서예요. 합쳐두면 나중에 한쪽만 조건이 생겼을 때 떼어내는 일부터 해야 합니다.
훅을 나누는 기준은 B-3 에서 세운 것이 그대로 갑니다.
B-3 에서 컴포넌트를 쪼갤 때 세 가지를 물었죠. 자기 동작이 있는가, 묶을 것이 둘 이상인가, 다른 곳에서도 쓰는가.
훅에도 그대로 적용됩니다. 다만 하나가 더 붙어요. 함께 쓰이는가.
useScrollSave 와 useScrollRestore 를 나눈다고 해봅시다. 저장만 하고 복원은 안 하는 화면이 있을까요? 없습니다. 둘은 늘 함께 다녀요. 늘 함께 부를 것을 둘로 나누면 쓰는 쪽이 두 줄을 적어야 하고, 하나를 빠뜨릴 길이 생깁니다.
반대로 useBrowserSync 로 창 크기까지 묶으면 어떨까요. 창 너비만 필요한 화면이 분명히 있을 겁니다. 그런 화면이 스크롤 저장까지 딸려오는 건 손해예요.
그래서 선은 여기입니다.
- 늘 함께 쓰이는가 → 한 훅에 담습니다
- 따로 쓰일 수 있는가 → 나눕니다
useScrollRestore 는 앞쪽이고, 창 너비는 뒤쪽입니다.
한 가지 더 — 이름이 알려줍니다.
훅 이름에 "그리고" 를 넣어야 설명이 된다면 너무 많이 담은 겁니다. useScrollAndWidth 같은 이름이 떠오르면 나눌 때예요. B-3 에서 컴포넌트 이름이 억지스러워지면 쪼갤 덩어리가 아니라고 했던 것의 반대 방향입니다.
🎯 면접관을 홀리는 핵심 멘트
"effect 를 나누는 기준과 훅을 나누는 기준을 저는 따로 둡니다. effect 는 의존성이 다르면 무조건 나눠야 하는 기술적 제약이 있지만, 훅은 함께 쓰이는지를 봅니다. 늘 같이 불릴 것을 나누면 쓰는 쪽이 하나를 빠뜨릴 수 있고, 따로 쓰일 것을 묶으면 필요 없는 구독까지 딸려오니까요. 판단이 애매하면 이름을 지어봅니다. 이름에 '그리고' 가 들어가야 하면 너무 많이 담은 겁니다."
🤔 [생각해볼 주제 3] "이건 반응하지 마" 가 위험해지는 순간
문제 상황 요약
팀원이 코드 리뷰에 이런 설명을 달았습니다. "구독이 자꾸 다시 맺어져서 useEffectEvent 로 감쌌어요."
무엇을 확인하시겠어요. 그리고 그 질문을 어떻게 던지면 좋을까요.
튜터의 가이드 및 해설
먼저 이 도구의 위험을 정확히 알아둡시다.
의존성 배열을 잘못 비우면 규칙이 경고를 줍니다. 값이 빠졌다고 알려줘요. 그런데 useEffectEvent 로 감싸면 경고가 안 뜹니다. 문법적으로 올바른 사용이니까요.
그래서 이 도구로 실수하면 아무도 안 알려줍니다. 화면은 조용히 낡은 채로 남고, 나중에 "가끔 이상해요" 같은 제보로 돌아와요.
확인할 것은 하나입니다.
감싼 값이 달라졌을 때 effect 를 다시 시작해야 하는가.
다시 시작해야 하는 값의 예를 들면 이런 것들입니다. 어떤 방에 연결할지, 어떤 요소를 지켜볼지, 어떤 주소를 구독할지. 이런 게 바뀌면 기존 구독은 이제 틀린 대상을 보고 있는 겁니다. 반드시 끊고 다시 맺어야 해요.
읽기만 하면 되는 값은 이런 것들이고요. 알림 문구를 만드는 함수, 지금 좋아요 개수, 현재 테마. 구독 대상이 달라지는 게 아니라 불릴 때 참고할 값일 뿐입니다.
질문은 값을 지목해서 던집니다.
"useEffectEvent 를 쓰신 이유가 뭔가요" 는 좋지 않습니다. 이미 이유를 적었으니까요. 다시 시작해야 하는지를 묻는 질문이어야 합니다.
"여기서
roomId가 바뀌면 기존 연결은 어떻게 되나요?"
이렇게 물으면 답이 셋 중 하나로 나옵니다. "끊고 다시 맺어야죠" 면 의존성 배열로 옮겨야 하는 값이고, "그대로 둬도 돼요" 면 지금이 맞고, "생각 안 해봤는데요" 면 함께 확인해 보면 됩니다.
한 가지 더 볼 것 — 감싼 범위입니다.
콜백 하나를 감쌌는지, 아니면 여러 값을 한꺼번에 밀어 넣었는지를 봅니다. 후자면 그 안에 다시 시작해야 하는 값이 섞여 있을 가능성이 커요.
마지막으로, 대안이 있는지 봅니다.
구독이 자꾸 다시 맺어지는 이유가 부모가 렌더할 때마다 새 함수를 만들어서라면, 그 함수를 아예 컴포넌트 밖으로 빼거나 훅 안으로 옮겨서 문제 자체를 없앨 수도 있습니다. 감싸는 건 그게 안 될 때의 선택이에요.
🎯 면접관을 홀리는 핵심 멘트
"
useEffectEvent를 리뷰할 때 저는 딱 하나만 묻습니다. 이 값이 바뀌면 기존 구독은 틀린 대상을 보게 되나요. 틀린 대상을 보게 되면 그건 의존성 배열에 있어야 할 값이고, 아니면 감싸는 게 맞습니다. 이 도구가 특히 조심스러운 이유는 잘못 써도 린트가 안 잡아준다는 점이에요. 의존성을 그냥 지우면 경고라도 뜨는데, 감싸면 문법적으로 완전히 올바른 코드라서 조용히 낡은 화면만 남습니다."