React

ChatGPT처럼 내 메시지를 화면 위에 고정하는 방법

ChatGPT·Claude처럼 보낸 메시지를 뷰포트 상단에 붙이는 훅입니다. id 트리거, rAF, min-h-full 세 가지가 맞물려야 동작합니다.

geonoooo

1 min read

NOTE

메시지를 보내면 내 질문이 화면 상단에 붙고, 그 아래로 답변이 채워지는 패턴. id 트리거 + requestAnimationFrame + min-h-full 세 가지가 같이 맞물려야 동작합니다. 한 줄(min-h-full)이 훅 밖에 있어서 놓치기 쉽습니다.

ChatGPT나 Claude는 메시지를 보낸 순간 내가 친 질문이 화면 맨 위로 올라가고, 그 아래 빈 공간이 답변으로 채워집니다.

이렇게 하면 답변을 처음부터 읽을 수 있습니다. 일반적인 채팅 UI처럼 "새 메시지가 오면 가장 아래로 스크롤"을 그대로 붙이면 답변이 길어질수록 첫 줄이 위로 사라지고, 시선이 계속 아래로 끌려갑니다.

구현하면서 막힌 지점이 몇 군데 있어서 정리해둡니다.

1. 단순한 접근이 왜 안 통하는가

처음 떠올리는 건 이런 코드입니다.

useEffect(() => {
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);

문제는 AI 응답이 스트리밍으로 들어온다는 점입니다. 토큰 하나마다 messages 배열이 갱신되니 effect가 매 chunk마다 실행되고, 화면이 끝까지 따라갑니다. 사용자가 위로 올려 답변 앞부분을 다시 읽으려 해도 다음 토큰에 아래로 끌려갑니다.

동작우리가 원하는 것단순 구현 결과
새 user 메시지 추가그 메시지로 스크롤OK
AI 응답 토큰 도착화면 그대로 두기매 토큰마다 따라감 ❌
사용자가 수동 스크롤그 위치 유지다음 토큰에 끌려감 ❌

2. 핵심 아이디어 — "새 user 메시지일 때만" 발동

해결책은 단순합니다. assistant 메시지 변경은 무시하고, 새 user 메시지가 추가됐을 때만 한 번 스크롤하면 됩니다.

lastUserId 트리거를 만들면 됩니다. messages 배열에서 가장 최근 user 메시지의 id만 뽑고, 그 값이 바뀔 때만 effect가 실행되게 합니다.

const lastUserId = useMemo(() => {
  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i]?.role === "user") return messages[i].id;
  }
  return undefined;
}, [messages]);
 
useEffect(() => {
  // lastUserId가 바뀔 때만 = 새 user 메시지가 들어왔을 때만
}, [lastUserId]);

스트리밍 중에는 assistant 메시지만 추가되니까 lastUserId는 그대로 → effect 미실행 → 화면 고정.

3. 전체 훅

export const useChatScrollToUser = <T extends HTMLElement = HTMLDivElement>(
  messages: Array<{ id: string | number; role: string }>,
) => {
  const anchorRef = useRef<T>(null);
 
  const lastUserId = useMemo(() => {
    for (let i = messages.length - 1; i >= 0; i--) {
      if (messages[i]?.role === "user") return messages[i].id;
    }
    return undefined;
  }, [messages]);
 
  useEffect(() => {
    if (lastUserId === undefined) return;
    const id = requestAnimationFrame(() => {
      anchorRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
    });
    return () => cancelAnimationFrame(id);
  }, [lastUserId]);
 
  return { anchorRef };
};

코드는 짧지만 안에 결정 세 개가 들어 있습니다.

3-1. requestAnimationFrame이 왜 필요한가

useEffect는 React 커밋 직후, DOM이 갱신된 뒤에 실행됩니다. 그래서 이 시점의 scrollIntoView도 최신 레이아웃을 기준으로 동작합니다. 그런데도 한 프레임 미루는 이유는, 커밋 직후의 높이가 최종 높이가 아닐 수 있어서입니다. 스트리밍으로 이어 붙는 답변 블록이나 아직 로드되지 않은 이미지가 대표적입니다.

rAF는 다음 페인트 직전에 콜백을 실행합니다. 그 사이 반영된 DOM 변경까지 포함된 높이 위에서 스크롤합니다. 다만 rAF가 레이아웃 안정을 보장하는 것은 아닙니다. 다음 프레임 뒤에 콘텐츠가 더 붙으면 위치는 다시 어긋납니다.

3-2. cleanup으로 cancelAnimationFrame

빠른 연속 입력이나 언마운트 상황에서 예약된 rAF가 그대로 실행되면 이전 입력 기준으로 스크롤이 튑니다. cleanup에서 취소하는 이유입니다.

취소 범위는 정확히 알아둘 필요가 있습니다. cancelAnimationFrame이 없애는 것은 아직 실행되지 않은 콜백뿐입니다. 이미 시작된 behavior: "smooth" 스크롤은 멈추지 않습니다.

3-3. block: "start"

scrollIntoView의 기본값은 block: "start"지만 명시해두는 게 좋습니다. anchor 요소의 최상단을 스크롤 컨테이너의 최상단에 정렬하는 옵션이고, "user 메시지를 화면 위로" 동작이 여기서 나옵니다.

4. 진짜 핵심은 호출 측 레이아웃 — min-h-full

훅만 가지고는 동작이 완성되지 않습니다. 호출하는 쪽 레이아웃이 같이 맞아야 합니다.

<div className="flex-1 overflow-y-auto">
  {" "}
  {/* 스크롤 컨테이너 */}
  <div className="mx-auto flex h-full ...">
    <MessageList messages={history} /> {/* 과거 메시지들 */}
    {latest.length > 0 && (
      <div ref={anchorRef} className="flex min-h-full flex-col space-y-6">
        <MessageList messages={latest} /> {/* 최신 user + 이후 AI 답변 */}
      </div>
    )}
  </div>
</div>

여기서 min-h-full이 빠지면 패턴 전체가 동작하지 않습니다.

이유는 단순합니다. AI 답변이 짧으면 anchor 블록도 짧아지고, 스크롤 컨테이너에 스크롤할 공간이 없습니다. 그러면 scrollIntoView({ block: "start" })를 호출해도 anchor를 상단까지 끌어올릴 여유가 없어서, user 메시지가 중간에 머무릅니다.

min-h-full을 주면 anchor 블록이 항상 뷰포트 1개 높이를 확보합니다. 답변 길이와 무관하게 스크롤할 공간이 생기고, user 메시지가 상단에 정렬됩니다.

조건min-h-full 없음min-h-full 있음
답변이 긴 경우동작 OK동작 OK
답변이 짧은 경우user 메시지가 중간에user 메시지가 상단에 ✅

이 한 줄이 빠져서 한참 헤맸습니다. 원인을 알고 나면 허무한 종류

5. 전체 흐름

세 조각이 어떻게 맞물리는지 시퀀스로 보면 이렇습니다.

useChatScrollToUser 시퀀스 다이어그램

  1. 사용자가 메시지 전송 → messages 배열에 push
  2. lastUserId 재계산 → 이전과 다른 id
  3. useEffect 발동 → rAF 예약
  4. React가 새 user 메시지를 DOM에 렌더, min-h-full 블록이 뷰포트만큼 확장
  5. 다음 프레임에 scrollIntoView 실행 → user 메시지가 상단으로
  6. 이후 AI 답변 chunk가 도착해도 lastUserId는 그대로 → effect 미실행, 화면 고정
  7. 질문은 상단에 고정된 채로 답변이 아래로 채워짐

6. 정리

요구사항어떻게 해결했나
새 user 메시지를 상단으로scrollIntoView({ block: "start" })
스트리밍 중 화면 고정lastUserId만 의존성으로 (assistant 변경에 무반응)
같은 user 메시지에 재스크롤 XlastUserId 동일하면 effect 미실행
렌더 타이밍 동기화requestAnimationFrame + cleanup
짧은 답변에도 상단 고정부모에 min-h-full

코드는 짧지만 결정 다섯 개가 다 맞아야 의도대로 동작합니다. 특히 min-h-full은 훅 밖에 있어서 놓치기 쉽습니다.


다이어그램 mermaid 소스 (재export용)
sequenceDiagram
    autonumber
    participant U as User
    participant H as useChatScrollToUser
    participant R as React
    participant D as DOM
    participant V as Viewport
 
    U->>R: 메시지 전송 → messages.push(user)
    R->>H: messages 변경 → 훅 재실행
    H->>H: lastUserId 재계산 (새 id)
    Note over H: lastUserId 변경 감지<br/>useEffect 발동
    H->>R: requestAnimationFrame 예약
    R->>D: 새 user 메시지 렌더<br/>min-h-full 블록 확장
    D-->>R: 레이아웃 안정
    R->>D: rAF 콜백: scrollIntoView({block:"start"})
    D->>V: anchor 최상단 = 컨테이너 최상단
    V-->>U: user 메시지가 화면 상단에 고정
 
    Note over U,V: 이후 AI 답변 chunk 스트리밍
 
    R->>H: messages 변경 (assistant chunk)
    H->>H: lastUserId 동일
    Note over H: useEffect 미실행<br/>스크롤 그대로
    R->>D: 답변이 anchor 아래로 누적
    D-->>U: 질문은 상단 고정,<br/>답변이 아래로 채워짐

Comments