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. 전체 흐름
세 조각이 어떻게 맞물리는지 시퀀스로 보면 이렇습니다.
- 사용자가 메시지 전송 →
messages배열에 push lastUserId재계산 → 이전과 다른 iduseEffect발동 → rAF 예약- React가 새 user 메시지를 DOM에 렌더,
min-h-full블록이 뷰포트만큼 확장 - 다음 프레임에
scrollIntoView실행 → user 메시지가 상단으로 - 이후 AI 답변 chunk가 도착해도
lastUserId는 그대로 → effect 미실행, 화면 고정 - 질문은 상단에 고정된 채로 답변이 아래로 채워짐
6. 정리
| 요구사항 | 어떻게 해결했나 |
|---|---|
| 새 user 메시지를 상단으로 | scrollIntoView({ block: "start" }) |
| 스트리밍 중 화면 고정 | lastUserId만 의존성으로 (assistant 변경에 무반응) |
| 같은 user 메시지에 재스크롤 X | lastUserId 동일하면 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