상태 · 데이터
채팅 검색 — 1년 전 메시지로 점프하고 양방향으로 무한 스크롤
검색은 원래 "카운터 + 하이라이트"가 전부였다. 키워드를 쳐도 화면은 그대로였다. 이걸 hit 메시지로 화면을 점프시키고, 그 자리에서 위·아래로 무한 스크롤하게 만드는 과정에서, 기존 단방향 페이지네이션을 양방향으로 바꿔야 했다 — 그건 프론트 혼자 못 하고 백엔드와 cursor 규약을 맞춰야 하는 일이었다.
Before / After
| Before | After | |
|---|---|---|
| 결과 표시 | 카운터 + 하이라이트 | + hit 메시지로 화면 중앙 점프 |
| 점프 후 스크롤 | 안 됨 | 위(과거)/아래(최신) 양방향 무한 fetch |
| 점프 누적 | 안 됨 | 여러 번 점프해도 이전 컨텍스트 보존 |
| 점프 사이 빈 구간 | 안 됨 | 스크롤 도달 시 자동 fetch (gap bridge) |
목표는 "1년 전 메시지여도 거기로 날아가서, 그 위치에서 자유롭게 스크롤된다"였다.
왜 어려운가 — 채팅 무한스크롤은 "한 방향"이었다
기존 채팅 무한스크롤은 아래로만(최신 방향) 페이징했다. 위로 올리면 과거를 더 부르는 단방향 — "맨 아래(최신)에서 시작해 거슬러 올라가는" 한 방향 모델이다.
그런데 검색 점프는 다르다. 임의의 과거 메시지(hit)가 화면 중앙에 떨어지고, 거기서 위(더 과거)로도 아래(더 최신)로도 스크롤된다. 즉 양방향 페이지네이션이 필요했다.
백엔드와 맞춘 것 — cursor 규약
검색을 두 API로 나누고, 그 설계를 백엔드와 맞췄다.
searchMessageHits— 검색 결과를id+sortOrder만 반환한다(본문 없음). 점프에 필요한 건 "어디로 갈지"(sortOrder)뿐이라, 본문까지 다 내리면 낭비다. 본문은 점프 후 따로 받는다.findChatRoomMessages— cursor 기준 조회. 여기서 방향이 갈렸다.- 기존 무한스크롤:
{ cursor, after: 20 }— sortOrder 초과 20개 (최신 방향, cursor 미포함) - 검색 점프:
{ cursor, before: 15, after: 15 }— cursor를 포함해 앞뒤 15개씩 (around)
- 기존 무한스크롤:
여기서 cursor를 포함하느냐(inclusive) 빼느냐(exclusive)가 방향마다 달랐다 — around 호출만 inclusive다. 이 차이를 프론트·백엔드가 같은 그림으로 안 맞추면 경계에서 메시지가 하나 빠지거나 겹친다. 실제로 이게 나중에 gap detection 버그를 일으켰다.
점프 인프라를 검색·답장이 공유한다
검색 hit 점프와 "답장 원본으로 가기"(quote 클릭)는 같은 동작이다 — 특정 메시지로 스크롤 + 그
주변을 around fetch. 그래서 둘을 TFocusedMessage { id, sortOrder } 하나로 통합하고, Zustand의
focusedMessage / anchor가 양쪽을 동일하게 처리하게 했다. ChatThread는 focusedMessageId만
구독하고, 스크롤 트리거는 별도 훅이 맡는다. 두 기능이 인프라를 공유하니 코드가 절반이 됐다.
Gap Bridging — 점프가 남긴 빈 구간 메우기
점프하면 hit 주변 around window만 로드된다. 그래서 기존에 보던 pages와 점프한 pages 사이에 빈 구간(gap)이 생긴다. 스크롤로 거기 닿으면 자연스럽게 채워야 했다.
gapCursorByMessageId(Map) — pages 간 sortOrder가 비연속인 지점을 감지해 경계 메시지 → cursor 매핑- 경계 메시지 뒤에 보이지 않는 gap sentinel div를 렌더
- IntersectionObserver가 sentinel 도달을 감지 →
bridgeGap(cursor)호출 findChatRoomMessages({ cursor, after: 20 })→setQueryData로 merge-insertbridgedCursorsRef(Set)로 같은 cursor 중복 fetch 방지
리스트 위·아래·gap 세 곳에 sentinel을 두고(맨 아래 sentinel은 점프 상태에서만 활성), 각자 다른 방향의 fetch를 트리거하게 했다.
삽질 — exclusive/inclusive 경계, layout shift
- gap detection 버그 — 위에서 본 cursor inclusive/exclusive 차이를 한 군데서 잘못 보면, 비연속 판정이 어긋나 gap을 못 찾거나 헛 gap을 만든다. 경계 조건을 양쪽 API 기준으로 다시 맞췄다
- 점프 위치가 밀리는 문제 — hit 메시지를 화면 중앙에 띄워도, 이미지·비디오가 비동기로 로드되며 layout shift가 나면 hit이 중앙에서 밀려났다. ResizeObserver로 높이 변화를 감지해 위치를 보정했다
배운 점
- 검색은 "찾기"가 아니라 "그 지점으로 데이터 창을 옮기는 일"이었다. 단방향 페이징을 양방향으로 바꾸는 건 프론트 혼자가 아니라, 백엔드와 cursor 규약(방향·inclusive 여부)을 같은 그림으로 맞추는 협업이었다
- 검색 점프와 답장 점프처럼 "같은 동작"을 한 인프라로 묶으면 코드도, 버그도 절반이 된다
- 점프가 만든 불연속(gap)을 sentinel + IntersectionObserver로 스크롤 시 자연히 채우면, 사용자는 그 빈틈을 못 느낀다