[채팅방 개선기 1] 가상 스크롤을 썼는데도 채팅방이 느렸던 이유
2026-05-21
먼저 이번 작업으로 무엇이 달라졌는지부터 보자. 각 이미지의 왼쪽이 개선 전, 오른쪽이 개선 후다.
React error #185가 발생한다.개선 후 — 가상 렌더링과 캐싱을 적용해 데이터를 아무리 더 불러와도 DOM 노드 수가 늘지 않는다.
개선 후 — 출력 속도를 일정하게 유지하고 스크롤 위치를 안정적으로 고정했다.
개선 후 — 커서 기반 양방향 페이지네이션으로 첫 메시지까지 즉시 이동한다.
베이비챗에서 사용자가 가장 오래 머무는 화면은 채팅방이다. 그래서 채팅방이 조금 느린 건, 사실상 서비스 전체가 느린 것처럼 느껴진다. 특히 대화가 수백 건, 수천 건 쌓인 오래된 채팅방에서는 문제가 꽤 명확하게 드러났다.
- 스크롤할수록 점점 느려진다.
- 오래된 방은 들어가는 순간부터 버벅인다.
- 메시지를 삭제하거나 재생성하면 스크롤이 갑자기 튄다.
- 첫 대화로 이동하려면 한참을 기다려야 한다.
- 심한 경우
Maximum update depth exceeded, 즉React error #185와 함께 화면이 사실상 멈춘다.
처음에는 단순하게 생각했다.
"메시지가 많아서 느린가 보다."
그런데 이 정도로 문제를 정의해서는 손댈 곳을 찾기 어려웠다. 메시지가 많다는 건 결과일 뿐이고, 실제로는 어떤 비용이 메시지 수와 함께 커지고 있는지를 찾아야 했다. 정리해보니 병목마다 비례하는 대상이 달랐다.
| 증상 | 비용이 증가하는 기준 |
|---|---|
| AI 답변 한 번에 화면이 멈춘다 | 메시지 개수 × 청크 수 |
| 긴 대화일수록 렌더가 느려진다 | 메시지 개수 |
| 위로 스크롤할수록 더 느려진다 | 누적 DOM 노드 수 |
| 삭제·되돌리기에서 화면이 튄다 | 가상 스크롤 위치 보정 |
| 첫 대화로 이동하는 데 오래 걸린다 | 전체 대화 길이 |
이 기준이 보이고 나니 문제도 훨씬 선명해졌다. 특히 첫 번째 줄이 눈에 띄었다. 다른 항목들은 메시지 수에 비례하는데, 이건 곱셈이었다.
답변 하나에 렌더링이 수만 번 일어나고 있었다
가장 심각했던 건 AI 답변이 스트리밍되는 동안이었다. AI 답변 하나는 짧은 텍스트가 아니다. 마크다운이 파싱되면서 HTML 태그가 수십 개, 길면 백 개 이상 생긴다.
그리고 답변 하나가 완성되기까지 서버에서는 수백 번의 청크가 날아온다. 문제는 청크가 도착할 때마다 생성 중인 메시지뿐 아니라 화면의 모든 메시지가 함께 다시 렌더링되고 있었다는 것이다. 메시지 100개가 있는 방에서 청크가 300번 오면 렌더는 3만 번이다.
대화가 길고 답변이 길면 수십만 번까지 갔다. 앞에서 본 Maximum update depth exceeded의 정체가 여기였다.
왜 전체가 다시 그려졌을까
원인은 스트리밍 상태가 컴포넌트 트리의 꼭대기에 있었다는 점이다. 기존 구조에서 useCompletion은 컨테이너 훅에서 호출됐다.
// 컨테이너 훅
const { complete, completion, isLoading } = useCompletion({
...completionConfig,
onError: handleCompletionError,
onFinish: handleCompletionFinish,
});그리고 컨테이너 컴포넌트는 이 값으로 만든 임시 메시지를 기존 메시지 배열에 이어 붙여 목록에 내려줬다.
const filteredMessages = useMemo(
() => (localMessage ? [...messages, localMessage] : messages),
[localMessage, messages],
);
<MessageList
messages={filteredMessages}
generatedMessage={generatedMessage}
isGenerating={!!localMessage}
/>목록 안에서도 마찬가지였다.
data={
isGenerating
? [...messages, { role: 'loading', id: 'loading' }]
: suggestions.length > 0
? [...messages, { role: 'suggestions', id: 'suggestions' }]
: messages
}여기서 두 가지가 겹친다.
하나는 상태의 위치다. completion이 트리 꼭대기에 있으니, 청크가 하나 도착할 때마다 컨테이너부터 목록, 그 아래 모든 메시지까지 렌더가 그대로 흘러내렸다.
다른 하나는 배열 참조다. data에 배열 리터럴을 그때그때 만들어 넘기고 있어서, 내용이 같아도 렌더마다 참조가 달라졌다. 개별 메시지 컴포넌트에 memo를 걸어둬도 상위에서 새 배열이 내려오면 소용이 없다.
정리하면 이렇다.
청크 도착
→ completion 갱신 (컨테이너)
→ 컨테이너 리렌더
→ [...messages, localMessage] 새 배열 생성
→ 목록 리렌더
→ 모든 메시지 아이템 리렌더이 흐름이 청크마다 반복됐다.
해결 — 생성 중인 메시지를 목록 밖으로 꺼내기
해결의 방향은 명확했다.
지금 생성 중인 메시지만 다시 그린다.
그러려면 스트리밍 메시지가 과거 메시지들과 같은 렌더 트리에 묶여 있으면 안 됐다.
그래서 구조를 이렇게 나눴다.
Virtuoso
├─ data = 서버에서 확정된 과거 메시지만 (스트리밍 중에는 바뀌지 않음)
└─ Footer = 생성 중인 메시지 (StreamingFooterContent)가상 스크롤의 data에는 확정된 메시지만 넣는다. 생성 중인 메시지는 목록의 데이터가 아니라 목록 아래에 붙는 Footer 슬롯에서 따로 렌더한다.
핵심은 그다음이다. useCompletion을 감싼 useChatStream을 컨테이너가 아니라 Footer 컴포넌트 안에서 호출했다.
export const StreamingFooterContent = memo(function StreamingFooterContent({
characterId,
isBabies,
}: {
characterId: string;
isBabies?: boolean;
}) {
// useCompletion + buffered updates + auto-scroll 모두 여기서 구동
useChatStream(characterId, !isBabies);
// ...
});사실상 이 한 줄의 위치가 전부였다. 스트리밍 상태가 리프 컴포넌트에 있으니, 청크가 아무리 많이 도착해도 리렌더는 Footer 서브트리에서 멈춘다. 위에 있는 목록과 과거 메시지들은 아예 관여하지 않는다.
텍스트는 별도 store에 쓴다
스트리밍 중인 텍스트도 부모에서 props로 내려주지 않는다. Zustand store에 쓰고, 필요한 컴포넌트만 구독하게 했다.
updateStreamContent: (tempId, content) =>
set((state) => ({
messages: state.messages.map((m) =>
m.tempId === tempId ? { ...m, content } : m,
),
})),const localMessages = useLocalMessagesStore((s) =>
s.messages.filter((m) => isVisibleLocalMessage(m, cacheMessageIds)),
);props로 내려주면 결국 부모가 리렌더돼야 하지만, store 구독은 구독한 컴포넌트만 깨운다.
Footer 자체가 다시 마운트되지 않도록
여기에 함정이 하나 더 있었다.
Virtuoso에 넘기는 Footer 컴포넌트가 렌더마다 새 함수면, Virtuoso 입장에서는 슬롯 컴포넌트가 통째로 바뀐 것이다. 그러면 Footer가 언마운트·리마운트되고, 그 안에서 돌던 스트리밍도 끊긴다.
그래서 render prop은 ref에 담아두고, Footer 콜백의 의존성은 최소한으로 유지했다.
const renderFooterRef = useRef(renderFooter);
renderFooterRef.current = renderFooter;
const Footer = useCallback(
() => (
<>{renderFooterRef.current({ hasMessages, isLoading: isPastLoading })}</>
),
[hasMessages, isPastLoading],
);
const components = useMemo(
() => ({ Header, Footer, Scroller }),
[Header, Footer, Scroller],
);의존성은 hasMessages와 isPastLoading 둘뿐이고, 스트리밍 중에는 둘 다 변하지 않는다.
부모가 매 렌더 새 renderFooter를 만들어도 ref만 갈아끼워질 뿐 슬롯 컴포넌트의 정체성은 유지된다.
대신 감수해야 했던 것
목록과 스트리밍 메시지를 분리하면 두 개의 서로 다른 소스가 한 화면에 이어 붙는다. 경계에서 처리할 일이 생긴다.
- 마지막 항목 판정 — 로컬 메시지가 있으면 Footer가 실제 마지막이므로, 목록의 마지막 아이템은 마지막이 아니다.
hasLocalMessages로 하단 여백 계산을 보정했다. - 로컬 → 서버 전환 — 생성이 끝나면 같은 메시지가 서버 캐시에도 들어온다. 잠깐 두 번 보일 수 있어서, 서버 ID가 캐시에 존재하는지로 판별해 로컬 쪽을 렌더 대상에서 뺐다.
- stale closure — 전송·재생성 콜백이
renderFooterRef와 Virtuoso 슬롯 메모이제이션 체인을 거치면서 오래된 closure로 전달될 수 있었다. 그래서 "지금 생성 중인가" 같은 가드는 closure 값 대신 store에서 직접 읽도록 했다.
// 호출 시점의 fresh 상태를 store에서 직접 읽는다.
// 이 callback이 ChatMessageList의 renderFooterRef + Virtuoso 슬롯 메모이제이션
// 체인을 거치며 stale closure로 전달될 수 있어, closure의 isStreaming은 신뢰 X.
if (
useLocalMessagesStore
.getState()
.messages.some((m) => m.status === 'streaming')
) {
// ...
}메모이제이션을 강하게 걸수록 closure가 오래 살아남는다는 걸 여기서 배웠다. 성능을 위해 렌더를 끊어내면, 그만큼 최신 값을 어디서 읽을지를 따로 정해줘야 했다.
결과
청크 하나가 도착했을 때 다시 그려지는 범위가 이렇게 바뀌었다.
개선 전: 전체 메시지 목록 (메시지 수만큼)
개선 후: 생성 중인 메시지 1개메시지가 100개든 5,000개든 스트리밍 중 렌더 비용은 동일해졌다.
Maximum update depth exceeded로 화면이 멈추던 문제도 여기서 사라졌다. 그리고 이 변경 덕분에 다음 문제가 비로소 눈에 들어왔다.
렌더 횟수를 줄이고 나니, 이번엔 렌더 한 번의 비용이 보이기 시작했다.
가상화를 쓰고 있는데도 왜 느렸을까
그다음 병목은 메시지 목록을 렌더링하는 코드 안에 있었다. 채팅 메시지는 현재 메시지 하나만 보고 그릴 수 있는 경우가 생각보다 많지 않다.
예를 들어 이런 정보가 필요하다.
- 이전 메시지와 발화자가 같은지
- 말풍선 꼬리를 붙일지
- 현재 메시지가 마지막 메시지인지
- 재생성에 사용할
promptId가 무엇인지
그러다 보니 현재 메시지가 전체 배열에서 몇 번째인지 알아야 했고, 기존 코드에서는 indexOf를 사용하고 있었다.
itemContent={(_, message) => {
const dataIndex = messages.indexOf(message);
const prevMsg =
dataIndex > 0 ? messages[dataIndex - 1] : undefined;
// ...
}}코드만 보면 별로 특별할 게 없다.
문제는 이 한 줄이 메시지마다 실행되고 있었다는 것이다. indexOf는 배열 앞에서부터 원하는 값을 찾을 때까지 순회한다. 메시지 하나의 위치를 찾는 데 O(n)이 들고, 이 작업을 여러 메시지에서 반복하면 전체 비용은 O(n²)에 가까워진다.
채팅에서는 더 불리했다.
사용자가 주로 보고 있는 건 최신 메시지이고, 최신 메시지는 대부분 배열 뒤쪽에 있다. indexOf 입장에서는 거의 최악의 위치다. 여기에 AI 답변 스트리밍이 시작되면 화면이 초당 여러 번 다시 갱신된다.
짧은 대화에서는 전혀 눈에 띄지 않던 한 줄이, 대화가 길어지면서 꽤 큰 비용이 되고 있었다.
그런데 해결 방법은 의외로 단순했다.
react-virtuoso의 itemContent는 첫 번째 인자로 이미 현재 아이템의 인덱스를 넘겨주고 있었다. 우리는 그 값을 _로 버려놓고 다시 indexOf로 찾고 있었다.
- itemContent={(_, message) => {
- const dataIndex = messages.indexOf(message);
+ itemContent={(virtuosoIndex, message) => {
+ const dataIndex =
+ virtuosoIndex - stableFirstItemIndex;
const prevMsg =
dataIndex > 0
? messages[dataIndex - 1]
: undefined;이걸 바꾸면서 항목 하나의 위치를 찾는 비용은 O(n)에서 O(1)이 됐다. 전체 목록 기준으로 보면 O(n²)에 가까웠던 계산이 O(n) 수준으로 내려온 셈이다. 여기서 한 가지 확실히 알게 된 게 있다.
가상화를 적용했다고 렌더링 성능 문제가 자동으로 해결되는 건 아니다.
가상화는 DOM에 올라가는 항목 수를 줄여준다. 하지만 화면에 20개만 그린다고 해도, 그 20개를 계산할 때마다 전체 메시지 배열을 훑고 있다면 대화가 길어질수록 여전히 느려진다.
정리하면 두 문제는 별개였다.
가상화
→ DOM에 몇 개를 올릴 것인가
indexOf 제거
→ 그 몇 개를 계산하는 데 얼마가 드는가둘을 같이 해결해야 했다.
스크롤할수록 느려지던 이유
그다음은 DOM이었다.
채팅 메시지 하나는 단순한 <div> 하나가 아니다. 실제로는 프로필 이미지, 이름, 말풍선, Markdown, 링크, 이미지, 코드 블록, 액션 버튼 등이 함께 렌더링된다. 특히 AI 답변 하나에는 HTML 태그가 수십 개, 길면 백 개 이상 생기기도 한다.
과거 대화를 계속 불러오면서 이 노드들을 전부 DOM에 남겨두면 메시지가 수천 건일 때 DOM 노드는 금방 수만 개까지 늘어난다. "위로 스크롤할수록 느려진다"는 현상의 정체가 이쪽에 있었다. 목록에 react-virtuoso 기반 가상화를 적용한 이유도 여기에 있다.
핵심은 단순하다.
데이터는 수천 건이어도, DOM에는 지금 화면 주변의 메시지만 둔다.
대화가 길어져도 DOM 크기는 뷰포트를 기준으로 거의 일정하게 유지된다. 자연스럽게 메모리 사용량과 브라우저의 레이아웃 비용도 줄어든다. 다만 앞에서 본 것처럼 DOM만 줄이는 것으로는 부족했다.
DOM 개수와 메시지 하나를 계산하는 비용을 각각 줄이고 나서야 대화 길이에 덜 민감한 구조가 됐다.
가상 스크롤에서 더 까다로웠던 건 '위치'였다
가상화를 적용하고 나니 새로운 문제가 생겼다. 메시지를 삭제하거나 답변을 재생성했을 때 화면이 갑자기 위아래로 튀는 현상이었다. 원인은 react-virtuoso의 firstItemIndex에 있었다.
Virtuoso는 이 값이 감소하면 보통 이렇게 해석한다.
목록 위쪽에 새로운 아이템이 추가됐구나.
이건 원래 굉장히 유용한 기능이다.
과거 메시지를 위에 prepend했을 때 현재 보고 있던 메시지의 위치가 움직이지 않도록 Virtuoso가 스크롤 위치를 알아서 보정해준다. 문제는 기존 구현에서 firstItemIndex를 messages.length에 직접 연결하고 있었다는 점이다.
그 상태에서는 실제로 위쪽에 메시지가 추가되지 않아도 메시지 개수만 바뀌면 firstItemIndex가 변한다.
예를 들면 이런 경우들이다.
- 메시지 삭제
- 답변 되돌리기
- 답변 재생성
- 임시 메시지를 실제 메시지로 교체
Virtuoso 입장에서는 이런 변경도 prepend처럼 보였다. 그래서 필요하지 않은 스크롤 보정을 하고, 화면이 튀었다. 해결할 때는 단순히 길이만 보는 대신 정말로 위쪽에 메시지가 추가됐는지를 확인했다.
판단 조건은 두 가지였다.
- 메시지 개수가 늘어났고
- 첫 번째 메시지 ID가 달라졌다
두 조건이 동시에 맞을 때만 prepend로 처리했다.
if (stableIndexRef.current === null || currLen === 0) {
stableIndexRef.current =
START_INDEX - currLen;
} else if (
currLen > prevMsgLenRef.current &&
currFirstId !== prevFirstMsgIdRef.current
) {
const delta =
currLen - prevMsgLenRef.current;
stableIndexRef.current -= delta;
}
firstItemIndex = stableIndexRef.current;일반적으로 새 메시지가 채팅 아래쪽에 추가되면 첫 번째 메시지 ID는 그대로다. 반대로 과거 메시지를 위쪽에 붙이면 메시지 개수가 늘면서 첫 번째 ID도 바뀐다.
이 차이를 이용하니 실제 prepend와 단순한 데이터 변경을 구분할 수 있었고, 삭제나 재생성 때 발생하던 스크롤 튐도 사라졌다.
첫 대화로 가기 위해 수천 건을 읽을 필요는 없었다
렌더링 문제를 어느 정도 정리한 뒤에는 "맨 위로 이동" 기능이 눈에 들어왔다.
기존 구현은 꽤 정직했다.
버튼을 누르면 첫 메시지가 나올 때까지 과거 메시지 API를 계속 호출했다.
대화가 50개라면 별 문제가 없다.
5,000개라면 이야기가 달라진다.
사용자가 원하는 건 첫 번째 메시지인데, 클라이언트는 거기에 도착하기 위해 중간에 있는 수천 건의 메시지를 전부 읽고 있었다.
원인은 offset 기반 API였다.
api.get('/api/messages/...', {
params: {
offset,
limit,
},
});offset 방식은 채팅처럼 계속 변하는 목록에서 불편한 점이 많았다. 과거 메시지를 읽는 동안 새 메시지가 생기거나 기존 메시지가 삭제되면 offset=40이 가리키던 위치 자체가 밀릴 수 있다. 그러면 같은 메시지를 다시 받거나 일부 메시지를 건너뛸 가능성도 생긴다.
hasMore 역시 클라이언트가 응답 개수로 추측하고 있었다.
const hasMore =
data.messages.length === limit;마지막 페이지의 메시지 수가 우연히 정확히 limit개라면, 실제로는 데이터가 없는데도 다음 요청을 한 번 더 보내게 된다. 무엇보다 가장 큰 문제는 가장 오래된 메시지부터 조회할 방법이 없었다는 것이다. 그래서 API를 양방향 cursor 방식으로 바꿨다.
api.get('/api/v2/messages/...', {
params: {
order,
after,
before,
limit,
},
});페이지 상태도 서버가 명확하게 내려주도록 했다.
interface MessageResponse {
messages: MessageObject[];
nextCursor: string;
prevCursor: string;
hasNext: boolean;
hasPrev: boolean;
}그리고 order를 추가해 최신 메시지뿐 아니라 가장 오래된 메시지부터도 조회할 수 있게 했다. 여기서부터 "맨 위로 이동"의 비용이 완전히 달라졌다. 기존에는 첫 메시지에 도달할 때까지 중간 페이지를 계속 읽어야 했다면, 이제는 오래된 첫 페이지 하나만 조회하면 된다.
한 채팅방을 양쪽 끝에서 읽기
쿼리 구조도 두 개로 나눴다.
평소 채팅방에 들어올 때는 지금까지처럼 최신 메시지부터 가져온다.
const desc =
useInfiniteMessagesV2({
order: 'desc',
});그리고 사용자가 "맨 위로"를 누르는 순간 ASC 쿼리를 활성화한다.
const asc =
useInfiniteMessagesV2({
order: 'asc',
enabled: ascEnabled,
});ASC는 가장 오래된 메시지부터 앞으로 오고, DESC는 최신 메시지부터 과거 방향으로 간다. 대화를 계속 탐색하다 보면 언젠가 두 구간이 만난다.
여기서 한 번 실수도 했다.
ASC 데이터와 DESC 데이터를 그냥 이어 붙이면 될 것 같았는데, 아직 가져오지 않은 중간 구간이 있는 상태에서도 두 배열이 바로 붙어버렸다.
예를 들어 ASC 쪽에는 1100번 메시지가 있고 DESC 쪽에는 49005000번 메시지만 있는데, 둘을 합치면 화면에서는 100번 다음에 바로 4900번이 등장한다. 사용자 입장에서는 대화가 갑자기 수천 건 건너뛴 것처럼 보인다.
그래서 먼저 ASC 구간이 실제로 DESC 구간까지 도달했는지 확인했다.
const ascReachedDesc = useMemo(() => {
if (
descMessages.length === 0 ||
ascMessages.length === 0
) {
return false;
}
const oldestDescId =
String(descMessages[0]!.id);
return ascMessages.some(
(message) =>
String(message.id) === oldestDescId
);
}, [ascMessages, descMessages]);아직 만나지 않았다면 ASC 구간만 보여준다. 두 구간이 실제로 만난 뒤에야 ID 기준으로 중복을 제거하고 하나의 목록으로 합친다.
if (!ascReachedDesc) {
return ascMessages;
}
const seenIds = new Set(
ascMessages.map((message) =>
String(message.id)
)
);
const uniqueFromDesc =
descMessages.filter(
(message) =>
!seenIds.has(String(message.id))
);
return [
...ascMessages,
...uniqueFromDesc,
].sort((a, b) => a.id - b.id);결국 선택은 단순했다.
중간이 비어 있는 두 구간을 억지로 붙이느니, 지금 확보한 연속된 대화만 제대로 보여주자.
버튼을 누르기 전에 첫 페이지를 받아두기
API가 빨라져도 버튼을 누른 순간부터 네트워크 요청을 시작하면 짧게라도 로딩이 보인다. 그래서 채팅방에 들어왔을 때 오래된 메시지의 첫 페이지를 미리 받아두도록 했다.
if (queryClient.getQueryData(ascKey)) {
return;
}
queryClient.prefetchInfiniteQuery({
queryKey: ascKey,
queryFn: () =>
fetchMessagesV2({
order: 'asc',
limit: 500,
}),
initialPageParam: '',
});"맨 위로" 버튼을 실제로 누르는 사용자는 많지 않다. 그래도 첫 페이지 하나 정도의 비용으로 클릭했을 때 바로 반응하는 경험을 만들 수 있다면 충분히 괜찮은 트레이드오프라고 봤다. TanStack Query의 캐시 덕분에 같은 채팅방을 다시 오갈 때 불필요한 요청도 줄일 수 있었다.
렌더링만 보고 있었으면 놓쳤을 것들
성능을 추적하다 보니 렌더링과 직접적인 관련은 없어 보이지만, 계속 비용을 만들고 있던 코드도 발견했다.
대표적인 게 SSE 구독이었다.
SSE 이벤트 핸들러를 effect dependency에 그대로 넣어두고 있었는데, 핸들러 함수가 렌더링할 때마다 새로 만들어지면서 구독도 함께 끊겼다가 다시 연결되고 있었다. 채팅 스트리밍 중에는 렌더 자체가 자주 발생한다. 그만큼 불필요한 재연결도 늘어날 수 있었다.
그래서 핸들러는 ref에 보관해 최신 함수만 바꾸고, 실제 SSE 연결 자체는 유지하도록 변경했다. setTimeout도 같은 방식으로 ref에서 관리하고 컴포넌트가 사라질 때 명시적으로 정리했다. 하나씩 떼어놓고 보면 큰 문제처럼 보이지 않는다.
하지만 채팅방은 상태 변경도 많고 네트워크 이벤트도 계속 발생하는 화면이다. 이런 곳에서는 작은 비용이 한 번 발생하는 것보다 작은 비용이 계속 반복되는 것이 더 무섭다.
결국 봐야 했던 건 '무엇에 비례하는가'였다
개선 내용을 정리하면 이렇다.
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 스트리밍 청크 1회 | 전체 메시지 리렌더 | 생성 중인 메시지만 리렌더 |
| 스트리밍 상태 위치 | 컨테이너 최상단 | Footer 리프 + store 구독 |
| 메시지 위치 탐색 | 항목마다 O(n) | O(1) |
| 화면 갱신 계산량 | O(n²)에 가까움 | O(n) |
| DOM 노드 | 스크롤할수록 증가 | 뷰포트 기준 일정 |
| 삭제·재생성 | 스크롤 보정 오작동 | 실제 prepend만 보정 |
| 맨 위로 이동 | 첫 메시지까지 반복 조회 | 오래된 첫 페이지 즉시 조회 |
| 페이지네이션 | offset 기반 | 양방향 cursor 기반 |
| SSE 구독 | 렌더마다 재연결 가능 | 연결 유지 |
이번 작업을 하면서 재미있었던 건, 가장 큰 문제들이 특별히 복잡한 코드에 숨어 있지 않았다는 점이다.
훅을 호출하는 위치 하나.
indexOf 한 줄.
firstItemIndex를 바꾸는 조건 하나.
페이지네이션 방향 하나.
각각 따로 보면 정말 작은 구현이다.
그런데 이 코드들이 메시지 수나 렌더 횟수에 비례해서 반복되기 시작하면 채팅방 전체의 사용성을 바꿔버린다. 그래서 성능 문제를 볼 때 가장 많이 했던 질문은 이것이었다.
이 코드는 무엇에 비례해서 비싸지는가?
이 질문을 가지고 렌더링 경로를 따라가니 "채팅방이 왠지 느리다"는 막연한 문제가 하나씩 구체적인 비용으로 바뀌었다. 그리고 비용이 무엇에 비례하는지 보이기 시작하니 해결 방법도 생각보다 단순해졌다.
다음 글에서는 렌더링 횟수를 줄이는 것만으로는 해결되지 않았던 또 다른 문제를 다뤄보려고 한다. AI가 보내는 토큰은 원래 일정한 간격으로 도착하지 않는다.
그런데 화면에서는 어떻게 하면 일정하고 부드럽게 흘러가는 것처럼 보여줄 수 있을까?
다음 편에서는 토큰이 도착하는 속도와 화면에 그리는 속도를 분리했던 과정을 정리해보려 한다.