[React] requestAnimationFrame으로 채팅창 스크롤 최적화하기
포스트
취소

[React] requestAnimationFrame으로 채팅창 스크롤 최적화하기

🧐 들어가며: 새 메시지가 왔는데 스크롤이 애매하게 멈췄어요

채팅 UI를 만들 때 자주 필요한 기능이 있습니다.

새 메시지가 추가되면 채팅창 스크롤을 가장 아래로 내려서 사용자가 최신 메시지를 바로 볼 수 있게 하는 기능입니다.

처음에는 메시지를 추가한 직후 바로 스크롤을 내렸습니다.

1
element.scrollTop = element.scrollHeight;

그런데 실제 화면에서는 가끔 스크롤이 끝까지 내려가지 않거나, 연속으로 메시지가 들어올 때 살짝 덜컥거리는 느낌이 있었습니다.

이 문제는 메시지 배열은 업데이트되었지만, 브라우저가 아직 새 DOM 높이를 계산하기 전일 수 있기 때문에 생깁니다. 즉, JavaScript는 실행됐지만 화면의 레이아웃 계산은 아직 끝나지 않은 시점인 거예요.

이럴 때 사용할 수 있는 API가 requestAnimationFrame입니다.


💡 requestAnimationFrame은 어떤 역할을 할까요?

requestAnimationFrame은 브라우저에게 다음 화면을 그리기 직전에 콜백을 실행해달라고 요청하는 API입니다.

1
2
3
requestAnimationFrame(() => {
  // 다음 프레임 직전에 실행됩니다.
});

setTimeout은 단순히 일정 시간이 지난 뒤 콜백을 실행합니다. 반면 requestAnimationFrame은 브라우저의 렌더링 흐름에 맞춰 실행됩니다.

채팅 스크롤처럼 DOM 높이가 바뀐 뒤 그 값을 기준으로 작업해야 할 때는 이 차이가 꽤 중요합니다.


🛠️ 채팅창 스크롤에 적용하기

메시지가 바뀔 때마다 다음 프레임에 스크롤을 아래로 이동시키는 예제입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
"use client";

import { useEffect, useRef, useState } from "react";

const ChatRoom = () => {
  const [messages, setMessages] = useState<string[]>([]);
  const scrollRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const frameId = requestAnimationFrame(() => {
      const scrollElement = scrollRef.current;

      if (!scrollElement) return;

      scrollElement.scrollTo({
        top: scrollElement.scrollHeight,
        behavior: "smooth",
      });
    });

    return () => {
      cancelAnimationFrame(frameId);
    };
  }, [messages]);

  const handleSendMessage = () => {
    setMessages((prevMessages) => [
      ...prevMessages,
      "새 메시지가 도착했습니다!",
    ]);
  };

  return (
    <div className="flex h-[500px] flex-col overflow-hidden rounded-2xl border bg-gray-950">
      <div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-4">
        {messages.map((message, index) => (
          <div
            key={`${message}-${index}`}
            className="w-fit max-w-[80%] rounded-xl bg-gray-800 px-3 py-2 text-white"
          >
            {message}
          </div>
        ))}
      </div>

      <div className="border-t border-gray-800 p-4">
        <button
          type="button"
          onClick={handleSendMessage}
          className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white"
        >
          메시지 전송 테스트
        </button>
      </div>
    </div>
  );
};

export default ChatRoom;

여기서 중요한 부분은 useEffect 안에서 바로 scrollTo를 호출하지 않고, requestAnimationFrame 안에서 호출했다는 점입니다.

1
2
3
4
5
6
7
8
9
10
const frameId = requestAnimationFrame(() => {
  const scrollElement = scrollRef.current;

  if (!scrollElement) return;

  scrollElement.scrollTo({
    top: scrollElement.scrollHeight,
    behavior: "smooth",
  });
});

메시지 상태가 바뀐 뒤 브라우저가 다음 프레임을 준비하는 시점에 스크롤 높이를 읽기 때문에, 새 메시지가 반영된 높이를 기준으로 이동할 수 있습니다.


🧹 cancelAnimationFrame으로 정리하기

requestAnimationFrame은 예약된 콜백을 나중에 실행합니다.

컴포넌트가 사라졌는데 예약된 콜백이 뒤늦게 실행되면, 이미 없는 DOM을 참조하려고 할 수 있습니다. 그래서 useEffect의 cleanup에서 예약을 취소해주는 편이 안전합니다.

1
2
3
return () => {
  cancelAnimationFrame(frameId);
};

채팅처럼 메시지가 빠르게 여러 번 추가될 수 있는 UI에서는 이런 정리 코드가 특히 중요합니다.


📊 setTimeout과 비교해보기

비슷한 문제를 해결하려고 다음처럼 작성하는 경우도 있습니다.

1
2
3
setTimeout(() => {
  scrollToBottom();
}, 0);

이 방식도 운 좋게 동작할 때가 많습니다. 하지만 setTimeout은 브라우저 렌더링 타이밍에 맞춰 실행되는 API가 아닙니다.

비교하면 이렇게 볼 수 있습니다.

항목setTimeoutrequestAnimationFrame
실행 기준지정한 시간브라우저 렌더링 프레임
DOM 측정 안정성상황에 따라 흔들릴 수 있음레이아웃 계산 이후에 맞추기 쉬움
애니메이션/스크롤부드럽지 않을 수 있음프레임 흐름에 맞추기 좋음
백그라운드 탭계속 실행될 수 있음브라우저가 실행 빈도를 조절함

채팅 스크롤처럼 화면 업데이트와 밀접한 작업은 requestAnimationFrame이 더 잘 맞습니다.


✅ 마무리

새 메시지가 추가된 뒤 스크롤을 아래로 내리는 기능은 단순해 보이지만, 실제로는 DOM 업데이트와 브라우저 렌더링 타이밍을 함께 고려해야 합니다.

requestAnimationFrame을 사용하면 브라우저가 다음 화면을 그리기 직전의 안정적인 시점에 스크롤 위치를 계산할 수 있습니다.

DOM 크기를 읽거나, 스크롤을 이동시키거나, 화면 변화 직후 위치를 계산해야 한다면 setTimeout보다 requestAnimationFrame을 먼저 떠올려보면 좋습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

[React] textarea 높이 자동 조절: Tailwind CSS와 useRef로 구현하기

[React] window.matchMedia로 반응형 사이드바 구현하기