기술 블로그

함수 컴포넌트는 렌더를 캡처한다 본문

프론트엔드/Javascript

함수 컴포넌트는 렌더를 캡처한다

jaegwan 2026. 10. 8. 14:29
반응형

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 파일을 고른 렌더의 값 → 입력 전 타이틀

차례로 따라가면 이렇다.

  1. 파일을 고르는 순간 화면에 있던 렌더(N회차)의 handleUpload 가 실행된다. 그 안의 setValue 는 N회차에 받은 props 이고, 그 setValue 안의 form 은 N회차의 값이다.
  2. await 에서 멈춘 사이 타이틀을 입력하면 N+1, N+2 회차 렌더가 일어난다. 새 form 과 새 setValue 가 생기지만, 이미 실행 중인 handleUpload 는 계속 N회차의 setValue 를 들고 있다.
  3. 업로드가 끝나면 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 커스텀 스로틀링으로 지연을 크게 줘 본다.

참고

반응형

'프론트엔드 > 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
Comments