기술 블로그
함수 컴포넌트는 렌더를 캡처한다 본문
Dan Abramov 의 How Are Function Components Different from Classes? 를 실제 버그에 대입해 본 기록
증상
어드민의 포트폴리오 수정 화면에서 이런 제보가 들어왔다.
- 썸네일 이미지를 올리는 동안 타이틀을 고치면, 업로드가 끝나는 순간 타이틀이 고치기 전으로 돌아간다.
- 썸네일과 로고를 연달아 올리면, 먼저 끝난 이미지가 사라진다.
- 새 글 작성 화면에서는 일어나지 않는다.
처음엔 직접 해 봐도 재현되지 않았다. 사내망에서 작은 이미지는 0.2초면 올라가서, 그 사이에 뭔가를 입력할 틈이 없었다. DevTools Network 탭에 Latency 15000ms 커스텀 스로틀링을 걸자 바로 재현됐다. 단서는 "업로드가 진행되는 동안 입력한 것만 사라진다"는 점이었다.
코드
줄이면 이렇다.
// 수정 페이지
function EditPage() {
const [draft, setDraft] = useState<FormValue | null>(null);
const form = draft ?? serverForm; // 손대기 전엔 서버 값
const setValue = (patch: Partial<FormValue>) => {
setDraft({ ...form, ...patch }); // ← 문제의 한 줄
};
return <PortfolioForm value={form} setValue={setValue} />;
}
// 폼 컴포넌트
function PortfolioForm({ value, setValue }: PortfolioFormProps) {
const handleUpload = async (field: "thumbnail" | "logo", file: File) => {
const uploaded = await uploadFile(file); // 여기서 몇 초 기다린다
setValue({ [field]: toImage(uploaded) });
};
// ...
}
새 글 화면도 같은 폼을 쓰지만, setValue 가 setForm((prev) => ({ ...prev, ...patch })) 였다. 차이는 그것뿐이었다.
Dan Abramov 의 팔로우 버튼
Dan 의 글은 클래스와 함수 컴포넌트의 차이를 팔로우 버튼 하나로 보여준다. 누르면 3초 뒤 알림을 띄운다.
// 클래스
class ProfilePage extends React.Component {
showMessage = () => alert("Followed " + this.props.user);
handleClick = () => setTimeout(this.showMessage, 3000);
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
// 함수
function ProfilePage(props) {
const showMessage = () => alert("Followed " + props.user);
const handleClick = () => setTimeout(showMessage, 3000);
return <button onClick={handleClick}>Follow</button>;
}
Dan 의 프로필에서 팔로우를 누르고, 3초 안에 Sophie 의 프로필로 넘어가면 결과가 갈린다.
- 클래스:
Followed Sophie— 틀렸다. 팔로우한 건 Dan 이다. - 함수:
Followed Dan— 맞다.
원인은 this 다. React 는 렌더할 때마다 this.props 를 최신 값으로 바꿔 끼운다. 그래서 3초 뒤의 this.props.user 는 이미 Sophie 다. 반면 함수 컴포넌트의 props 는 그 렌더 호출에 넘어온 인자라서 바뀌지 않는다. 글의 결론은 한 줄이다.
Function components capture the rendered values.
(함수 컴포넌트는 렌더된 값을 캡처한다.)
글은 state 도 마찬가지라고 이어 간다. useState 로 읽은 값도 그 렌더의 값으로 고정된다.
우리 버그는 이 이야기의 뒷면이다
팔로우 예제와 업로드 버그를 나란히 놓으면 구조가 같다.
| 팔로우 예제 | 업로드 버그 |
|---|---|
| 버튼 클릭 | 파일 선택 |
setTimeout(…, 3000) |
await uploadFile(file) |
| 기다리는 동안 프로필 변경 | 기다리는 동안 타이틀 입력 |
3초 뒤 props.user 를 읽음 |
업로드 뒤 setValue 안의 form 을 읽음 |
| 클릭한 렌더의 값 → Dan | 파일을 고른 렌더의 값 → 입력 전 타이틀 |
차례로 따라가면 이렇다.
- 파일을 고르는 순간 화면에 있던 렌더(N회차)의
handleUpload가 실행된다. 그 안의setValue는 N회차에 받은 props 이고, 그setValue안의form은 N회차의 값이다. await에서 멈춘 사이 타이틀을 입력하면 N+1, N+2 회차 렌더가 일어난다. 새form과 새setValue가 생기지만, 이미 실행 중인handleUpload는 계속 N회차의setValue를 들고 있다.- 업로드가 끝나면 N회차의
setValue가setDraft({ ...N회차 form, thumbnail })를 실행한다. 폼 전체가 파일을 고르던 순간의 사진으로 덮이고, 그 사이 입력이 사라진다.
썸네일과 로고를 겹쳐 올린 경우도 같다. 두 업로드 모두 "업로드 전" 사진을 들고 있어서, 나중에 끝난 쪽이 먼저 끝난 이미지를 지운다.
여기서 반전이 있다. Dan 의 예제에서 캡처는 버그를 막아 준 기능이었다. "누구를 팔로우했나"는 클릭한 순간의 사실이기 때문이다. 우리 코드에서는 같은 캡처가 버그였다. "이미지를 폼에 붙인다"는 업로드가 끝난 순간의 폼에 해야 하는 일인데, 파일을 고른 순간의 폼을 기준으로 했다.
그러니 물어야 할 질문은 "캡처가 좋은가 나쁜가"가 아니다. 이 비동기 작업이 끝났을 때 필요한 건 시작한 순간의 값인가, 지금의 값인가?
원리: state 변수는 저장소의 "복사본"이다
왜 고정되는지는 useState 를 흉내 내 보면 보인다. 브라우저 콘솔에 붙여넣어 실행할 수 있다.
let store = "기존"; // React 가 안쪽에 들고 있는 진짜 state
function useState() {
const setState = (next) => {
store = typeof next === "function" ? next(store) : next;
// (실제 React 는 여기서 컴포넌트를 다시 호출한다 = 다시 렌더)
};
return [store, setState]; // 지금 store 의 값을 "복사해서" 돌려준다
}
function Page() {
const [form, setForm] = useState();
const setValue = (patch) => setForm(form + patch); // 복사본 기준
const setValueFixed = (patch) => setForm((prev) => prev + patch); // 적용 시점 기준
return { setValue, setValueFixed };
}
const held = Page(); // 파일 선택: 이 렌더의 함수를 업로드가 들고 감
Page().setValue(" 수정"); // 업로드 중 입력 → store = "기존 수정"
held.setValue(" +이미지"); // 업로드 완료 → store = "기존 +이미지" (입력 유실)
const [form] = useState() 는 저장소와 연결된 선이 아니다. 그 순간 저장소에 적힌 값을 옮겨 적은 것이다. setState 는 저장소를 고치고 다시 렌더를 요청할 뿐, 옛 렌더에서 이미 옮겨 적은 변수에는 손댈 수 없다. 다음 렌더가 새 변수에 다시 옮겨 적는다.
React 가 이렇게 만든 건 일관성 때문이다. 렌더 도중에 값이 바뀌면 화면 절반은 옛 값, 절반은 새 값으로 그려질 수 있다. 렌더마다 한 장의 사진을 쓰게 해서 그런 어긋남을 원천적으로 막았다. 대가는 오래 기다리는 핸들러에서 사진이 낡을 수 있다는 것이다.
해결: 업데이터 함수
// before
setDraft({ ...form, ...patch });
// after
setDraft((prev) => ({ ...(prev ?? serverForm), ...patch }));
업데이터 함수(prev => …)는 사진을 보지 않는다. React 가 적용하는 순간의 최신 state 를 prev 로 넘겨준다. 업로드를 기다리던 handleUpload 는 여전히 N회차의 setValue 를 부른다. 하지만 그 함수가 이제 "지금 폼에 이미지만 붙여 달라"고 요청하므로 결과가 맞다. (prev ?? serverForm 은 원래 규칙을 그대로 옮긴 것이다. 아직 손대지 않아 draft 가 null 이면 서버 값이 기준이다.)
같은 원리로 업로드 중 표시도 고쳤다. 원래는 칸 하나만 기억하는 state 였다.
const [uploading, setUploading] = useState<"thumbnail" | "logo" | null>(null);
썸네일을 올리는 중에 로고를 고르면 값이 "logo" 로 덮여 썸네일의 스피너와 잠금이 풀렸다. 먼저 끝난 쪽은 값을 null 로 지워 남은 쪽 스피너까지 껐다. 칸별로 나누고, 역시 업데이터 함수로 바꿨다.
const [uploading, setUploading] = useState({ thumbnail: false, logo: false });
setUploading((prev) => ({ ...prev, [field]: true })); // 시작
setUploading((prev) => ({ ...prev, [field]: false })); // 끝
언제 무엇을 쓰나
Dan 의 글이 제시하는 탈출구(ref)까지 합치면 이렇게 정리된다.
| 비동기 작업이 끝났을 때 필요한 것 | 예 | 방법 |
|---|---|---|
| 시작한 순간의 값 | 팔로우 알림, 클릭 당시 조건으로 보낸 요청의 결과 표시 | 그대로 둔다 (함수 컴포넌트의 기본 동작) |
| 지금 state 를 바탕으로 새 state 만들기 | 업로드 결과를 폼에 합치기, 카운터 증가 | 업데이터 함수 setX(prev => …) |
| 지금 값을 읽기만 하기 | 3초 뒤 최신 입력값으로 알림 | useRef 에 최신 값을 담고 ref.current 로 읽기 |
ref 방식은 Dan 의 글에 나오는 형태 그대로다.
const latestMessage = useRef("");
useEffect(() => {
latestMessage.current = message;
});
const showMessage = () => alert("You said: " + latestMessage.current);
테스트로 고정하기
느린 망에서만 드러나는 버그라 손으로는 다시 잡기 어렵다. 업로드를 테스트가 직접 끝내 줄 때까지 매달아 두는 식으로 재현했다.
const pending = new Map<string, (file: UploadedFile) => void>();
vi.mock("@/features/file/_hooks/useUploadFileMutation", () => ({
useUploadFileMutation: () => ({
mutateAsync: (file: File) =>
new Promise<UploadedFile>((resolve) => pending.set(file.name, resolve)),
}),
}));
it("업로드 중에 입력한 내용이 업로드 완료 후에도 남는다", async () => {
// [수정하기] → 썸네일 선택 → 타이틀 입력 → 업로드 완료
await user.upload(thumbnailInput, png("new-thumb.png"));
await user.type(titleInput, " 수정");
await act(async () => pending.get("new-thumb.png")?.(uploaded("new-thumb.png")));
expect(titleInput).toHaveValue("기존 타이틀 수정");
});
수정 전 코드로 돌리면 타이틀이 "기존 타이틀" 로 돌아가 실패하고, 수정 후에는 통과한다. 겹쳐 올리기 케이스도 같은 방식으로 만들었다.
정리
- 함수 컴포넌트의 이벤트 핸들러는 자기가 만들어진 렌더의 props·state 를 캡처한다.
await·setTimeout·.then()처럼 나중에 실행되는 코드도 마찬가지다. - 캡처는 대개 장점이다. 함정이 되는 건 오래 기다린 뒤 캡처한 값으로 state 를 덮어쓸 때다.
- 비동기 뒤에서 이전 state 를 바탕으로 새 state 를 만든다면
prev => …를 쓴다. 읽기만 한다면 ref 를 쓴다. - 로컬에서 재현이 안 되면 DevTools 커스텀 스로틀링으로 지연을 크게 줘 본다.
참고
- Dan Abramov, How Are Function Components Different from Classes?
- Dan Abramov, A Complete Guide to useEffect — "Each Render Has Its Own…" 절
- React 문서, 스냅샷으로서의 State
- React 문서, state 업데이트 큐
- MDN, 클로저
'프론트엔드 > Javascript' 카테고리의 다른 글
| [TIL] 객체에 정렬 곁들이기 (0) | 2023.11.09 |
|---|---|
| [TIL] 클로저 (0) | 2023.11.06 |
| [TIL] 깊은 복사 (0) | 2023.10.23 |
| [TIL]꼬리 재귀 (0) | 2023.10.13 |
| [TIL]동기와 비동기 (0) | 2023.10.13 |
