채영준 포트폴리오

chattea — 매칭 앱과 익명 커뮤니티

소개팅/매칭 앱에 익명 커뮤니티를 붙인 프로젝트입니다. 커뮤니티 화면을 별도 Vite React 앱으로 분리하고 Fastify 스트리밍 SSR로 렌더해 네이티브 탭의 WebView로 제공했습니다.

개인 프로젝트, FE 저장소 링크, BE 저장소 링크, Workspace 저장소 링크

첫 화면 대기 554ms → 즉시 준비: 스트리밍 SSR과 WebView 예열

커뮤니티 화면을 네이티브로 만들면 앱 릴리스 주기에 묶이고, WebView로 띄우면 문서와 데이터를 받는 동안 빈 화면이 보였습니다. '느리다'는 감각만으로는 어느 구간을 고쳐야 하는지 알 수 없어 측정 지점부터 만들었습니다.

탭 포커스 → WebView 마운트 → 로드 시작 → 로드 완료로 구간을 나누고, 웹 쪽 지표는 web-vitals beacon과 수집 엔드포인트를 직접 만들어 받았습니다. 재어 보니 대기가 둘로 갈렸습니다. SSR 스트림 시작이 prefetch 완료에 직렬로 묶여 있었고, WebView 마운트부터 로드 시작까지가 전체 대기의 대부분을 차지했습니다.

서버에서는 Fastify middlewareMode 위에서 renderToPipeableStream 스트리밍을 유지하되 데이터 구역을 useSuspenseQuery와 <Suspense> 경계로 내려, 쿼리가 끝나기 전에도 셸이 먼저 나가게 했습니다. 앱에서는 로그인 직후 숨겨진 WebView를 미리 띄워 프로세스와 HTTP 캐시를 예열했습니다.

Android 에뮬레이터에서 Maestro로 콜드 런을 5회 측정한 결과 탭부터 로드 완료까지 중앙값 554ms였던 대기가 사라졌고, 예열된 WebView가 진입 즉시 부착돼 콘텐츠 준비는 0ms 수준이었습니다. 쿼리 완료 전 셸 청크가 먼저 전송되는지는 회귀 테스트로 고정했습니다. 웹 앱은 앱 바이너리와 독립 배포라 커뮤니티 변경을 앱 릴리스 없이 낼 수 있습니다.

목록 갱신 시 행 렌더 30회 → 1회: React Compiler 도입

목록 한 행만 바뀌어도 화면의 모든 행이 다시 렌더됐습니다. 방 목록을 한 번 갱신하면 30개 행이 전부 다시 그려졌습니다.

수동 memo와 React Compiler를 비교했습니다. memo는 새 prop이나 의존이 추가될 때마다 경계가 깨져 유지비가 커지므로 컴파일러를 도입했습니다. try/catch로 감싼 async body 때문에 컴파일을 통째로 건너뛰는 hook이 있었고, 그 hook이 반환하는 콜백이 매 렌더 새로 만들어져 행 단위 memo가 무의미했습니다. render-count 측정으로 이를 확인했습니다.

async body를 모듈 헬퍼로 분리해 컴파일 대상으로 만들고 컴파일러가 대체하지 않는 행 단위 React.memo 경계와 LegendList의 estimatedItemSize만 수동으로 유지했습니다.

한 행만 바뀐 갱신에서 행 렌더는 30회에서 1회로, 좋아요 그리드는 20회에서 1회로 줄었습니다. 컴파일러를 켜고 끄며 render-count를 비교하는 테스트를 두어 이후 변경에서도 회귀를 잡게 했습니다.

폴링 20회/분 → 0회: 채팅 메시지 실시간 수신

채팅방에 있는 동안은 새 메시지를 즉시 보여야 하고 화면이 비활성인 동안에는 요청이 나가면 안 됐습니다. 기존에는 3초마다 GraphQL 폴링을 돌려 분당 최대 20회의 HTTP 요청이 나갔고, 새 메시지는 평균 1.5초 늦게 도착했습니다.

문제는 수신 구조였습니다. 클라이언트가 계속 물어보는 방식이라 폴링 주기만큼 지연이 생기고 화면을 보지 않는 동안에도 요청이 나갔습니다. 서버가 메시지를 밀어주는 방식으로 바꾸면서 WebSocket과 SSE를 비교했는데, 발송은 이미 GraphQL mutation으로 처리하고 있어 양방향이 필요 없었기 때문에 수신 전용인 SSE를 선택했습니다. 수신만 필요한 상황에 연결 유지와 재연결을 직접 관리하는 WebSocket은 오버엔지니어링이고, SSE는 일반 HTTP 위에서 자동 재연결을 지원해 요구에 맞는 가장 작은 선택이었습니다.

NestJS에 Last-Event-ID 기반 replay 엔드포인트를 만들었고, React Native에서는 EventSource 폴리필로 연결과 재연결, seq 기반 중복 제거를 구현했습니다. 연결이 끊기면 3초 폴링으로 되돌아가 네트워크가 불안정해도 메시지를 놓치지 않게 했고 화면 포커스와 앱 상태에 따라 연결을 붙였다가 떼어 유휴 요청을 없앴습니다.

분당 20회 나가던 폴링 요청은 0회가 됐고 새 메시지는 서버에서 오는 즉시 화면에 반영됩니다.

push — 근거 기반 커리어 AI 데스크톱 앱

공고 URL을 넣으면 이력서와 포트폴리오, 자기소개서를 단계별 승인 게이트로 만드는 AI 워크플로입니다.

개인 프로젝트, FE 저장소 링크, BE 저장소 링크, Workspace 저장소 링크

화면 전환 대기 105.3ms → 4.6ms: hover 시점 prefetch

사이드바 링크를 눌러 다른 목록 화면으로 이동할 때마다 쿼리가 끝날 때까지 화면이 비어 있었습니다. 클릭부터 쿼리 완료까지가 그대로 전환 대기가 됐습니다.

링크 클릭부터 대상 목록의 첫 렌더까지 performance 타이머로 재보니 대기의 대부분이 마운트 뒤에야 시작되는 쿼리 왕복이었습니다. 목록 쿼리가 화면 마운트에 직렬로 묶인 게 원인이었습니다.

클릭 전에 데이터를 미리 가져오면 대기가 사라지지만, 전부 prefetch하면 안 보는 화면까지 요청이 나갑니다. 그래서 '클릭 의도가 보이는 가장 이른 신호'를 기준으로 삼아 hover와 키보드 포커스 시점을 골랐습니다.

사이드바의 각 링크와 대화 목록 행의 onMouseEnter와 onFocus에서 TanStack Query prefetch를 호출하게 했습니다. prefetch는 화면 단위가 아니라 도메인별 함수로 나눠 문서, 지원, 근거 자료, 면접, 캘린더, 대화가 각자의 쿼리를 미리 받게 했고 키보드 탐색 사용자를 위해 focus에도 같은 동작을 걸었습니다.

클릭부터 화면 전환 완료까지 측정한 대기가 105.3ms에서 4.6ms로 줄었습니다. prefetch된 쿼리는 캐시에 남아 실제 진입 시 재요청 없이 바로 렌더됩니다.

렌더 커밋 초당 220회 → 60회: 스트림 상태 배칭

토큰이 도착할 때마다 setState를 호출해 메시지 목록 전체가 토큰당 한 번씩 다시 렌더됐습니다.

토큰 수와 렌더 커밋 수가 1:1로 묶인 게 병목이었습니다. React 렌더 큐보다 상태가 놓인 위치가 문제라고 판단했습니다.

토큰을 외부 버퍼에 모아 16ms 간격으로 flush하고 스트림 말풍선 컴포넌트만 그 상태를 직접 구독하게 바꿨습니다. 스트림 중 텍스트는 임시 상태로 두고 완료 이벤트 시점에만 메시지를 확정했습니다.

토큰 120개 시나리오에서 렌더 커밋이 초당 약 220회에서 60회로 줄었습니다. 이후 React Compiler를 도입해 커밋 안의 재렌더를 스트림 시나리오 32회에서 8회로, 스크롤 시나리오 72회에서 8회로 더 줄였습니다. 커밋 빈도는 배칭이, 커밋당 하위 트리 재렌더는 컴파일러가 담당하도록 역할을 나눴습니다.

마운트 노드 1,000개 → 10개, 스크롤 오차 3px 이내: 목록 가상화와 앵커 보정

대화가 쌓이면 메시지 목록이 전부 DOM에 붙어 렌더와 스크롤이 무거워졌습니다. 위로 스크롤해 과거 메시지를 불러올 때는 앞에 행이 삽입되면서 보고 있던 위치가 통째로 밀렸습니다.

문제는 둘이었습니다. 모든 행을 마운트하는 비용과, prepend 때 스크롤 위치가 깨지는 현상이었습니다. 라이브러리를 쓰지 않은 이유는 채팅 행 높이가 제각각이고 스트림 중에도 계속 변하기 때문입니다. 고정 높이를 가정한 라이브러리보다 실측 기반이 맞다고 판단했습니다.

행 개수가 임계값을 넘으면 보이는 구간 위아래에만 overscan을 두고 마운트하고 나머지는 상하 스페이서 높이로 대체했습니다. 각 행의 실제 높이는 ResizeObserver로 재측정해 갱신하고 prepend 전에는 화면에 보이는 행 하나를 앵커로 잡아 삽입 뒤 delta만큼 스크롤을 보정했습니다.

메시지 1,000개 기준 마운트된 행 노드가 10개 안팎으로 줄었고, 과거 메시지 prepend 시 스크롤 오차는 3px 이내로 유지됩니다.