React
1 min read

AI 챗 UI에서 새 메시지를 화면 상단에 고정시키기

ChatGPT/Claude처럼 user 메시지를 뷰포트 상단에 박아두는 훅 — id 트리거 + rAF + min-h-full 세 가지가 맞물리는 패턴

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 커밋 직후에 실행되지만, 그 시점에는 새 user 메시지의 DOM이 아직 페인트되지 않았을 수 있습니다. 곧바로 scrollIntoView를 호출하면 직전 레이아웃 기준의 잘못된 위치로 스크롤됩니다.

rAF는 다음 페인트 직전에 콜백을 실행합니다. DOM 레이아웃이 안정된 다음 프레임으로 한 틱 미루는 셈입니다.

3-2. cleanup으로 cancelAnimationFrame

빠른 연속 입력이나 언마운트 상황에서 예약된 rAF가 그대로 실행되면 의도치 않은 스크롤이나 메모리 누수가 생길 수 있어서, cleanup에서 취소합니다. 작지만 빠뜨리기 쉬운 부분입니다.

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