가상 스크롤을 고치려고 가상화를 껐다
react-virtuoso 채팅에서 새 메시지가 바닥에 붙지 않고, 가만히 있어도 스크롤이 흔들렸다. 추정 높이로 목표 위치를 계산하는 followOutput과 재마운트 때의 재측정이 원인이었고, 관리자가 권한 해법과 직접 써 본 우회책 네 가지, TanStack Virtual 전환을 재현 결과로 비교했다.
react-virtuoso 채팅에서 새 메시지가 바닥에 붙지 않고, 가만히 있어도 스크롤이 흔들렸다. 추정 높이로 목표 위치를 계산하는 followOutput과 재마운트 때의 재측정이 원인이었고, 관리자가 권한 해법과 직접 써 본 우회책 네 가지, TanStack Virtual 전환을 재현 결과로 비교했다.
색약인 동료가 배지 색을 구분하지 못해서 WCAG 대비비를 계산해보니 테마 10개 중 6개가 4.5:1 기준에 못 미쳤다. 글자색을 흰색이나 검정 하나로 고정하면 어느 쪽이든 실패해서, 강조색과 짝을 이루는 --color-accent-foreground 토큰을 만들고 대비비를 테스트로 검사했다.
프로젝트 도중 svn에서 git으로 옮기면서 변경을 몰아서 커밋하던 습관이 작은 로컬 커밋 단위로 바뀌었고, 커밋당 파일 수 p99가 70개에서 23개로 줄었다. git worktree로 브랜치마다 AI 에이전트를 붙이는 방식과 svn이 더 잘 맞는 경우도 비교했다.
MCP를 AI용 API 정도로만 알다가 우리 백엔드의 MCP 서버 코드를 직접 읽어봤다. JSON-RPC 2.0 위에서 tools/list와 tools/call로 도구를 노출하고, 생성 규칙을 시스템 프롬프트 대신 JSON Schema로 강제한다는 점이 기존 LLM API 호출 방식과 가장 큰 차이였다.
요청은 코드로, 응답은 이름으로 오는 KOPIS API 때문에 코드 유니온, 이름 유니온, 옵션 배열, 매핑 객체를 따로 선언하고 있었다. TypeScript 5.0의 const 타입 파라미터와 infer로 배열 하나에서 네 가지를 모두 뽑아내는 createCodeTable 헬퍼를 만들었다.
KOPIS 공연 API에 사용자 필터를 쿼리 파라미터로 그대로 넘기면, 요청 URL을 캐시 키로 쓰는 Next.js Data Cache에서는 필터 조합마다 캐시가 따로 생긴다. 필수 조건으로 3개월치 목록만 받아 캐싱하고 장르·지역·검색어 필터는 받아온 배열에서 처리하도록 바꿨다.
Playwright MCP로 페르소나 QA를 돌리면 클릭할 때마다 화면 스냅샷을 모델에 보내 판단을 받느라 조작 1회에 12.8초가 걸렸다. 조작은 Playwright spec 코드가 돌리며 증거를 JSON으로 남기고 모델은 끝난 뒤 한 번만 읽게 나눠서 1회당 0.5초, 약 25배 빨라졌다.
Sentry를 붙였는데 API 실패가 하나도 잡히지 않았다. 원인은 실패를 throw하지 않고 값으로 반환하는 공통 API 래퍼였다. 5xx는 백엔드 모니터링에 맡기고, 프론트에서는 네트워크 단절이나 타임아웃처럼 요청이 서버에 닿지 못한 실패만 캡처하도록 나눴다.
콜드 로드 CLS를 0.385에서 78% 줄였는데도 첫 방문이 느리다는 리포트는 계속 왔다. 원인은 CLS·LCP·번들 분석 어디에도 잡히지 않던 4MB짜리 온보딩 PNG였고, webp 변환과 lazy loading으로 전송량을 87% 줄였다.
분기와 합류가 있는 이벤트 데이터를 배열이나 중첩 트리로 저장하면 어디서 막히는지, 그래프를 위치 공식·인접 리스트·간선 리스트로 표현하면 무엇이 다른지 비교했다. 우리 이벤트 시스템은 저장은 배열에 두고 연결만 id로 참조하는 인접·간선 리스트 혼합 구조였다.
해외에서 첫 접속하면 스플래시가 6초씩 떠 있었는데, 운영 서버가 pm2로 npm run dev(Vite 개발 서버)를 띄우고 있었다. 번들되지 않은 모듈 249개를 하나씩 왕복하느라 RTT가 큰 환경에서만 느렸고, Sentry로 계측한 뒤 빌드 산출물 서빙으로 바꿔 128ms로 줄였다.
저장 버튼이 헤더에 있어서 페이지에서 만든 폼에 접근할 수 없었다. useForm()을 Layout으로 올리고 각 페이지는 라우트 handle에 스키마만 선언하게 했다. 폼은 한 화면 안에서만 공유하면 돼서 전역 스토어 대신 FormProvider(Context)를 썼고, 대신 라우트가 바뀔 때마다 reset을 챙겨야 했다.
폼 값을 전역 스토어로 관리하다가 필수값 검증이 들어오면서 에러·dirty 상태까지 스토어에 쌓이고 리렌더가 늘었다. 엔티티별 form store 13개를 React Hook Form과 Zod 스키마로 옮기고, 폼 라이브러리가 필요한 시점을 필드 수와 검증 복잡도로 나눠 정했다.
SSE 중복 연결, SQLite 배치 insert 재진입, 채팅 더블클릭 중복 전송은 모두 상태를 확인하고 바꾸는 사이에 await가 끼어 생긴 race condition이었다. await보다 먼저, 함수에 들어오자마자 ref나 플래그로 락을 걸어 셋 다 막았다.
안녕하세요, 문제의 원인을 추적하고 해결하는 프론트엔드 개발자 김채연입니다. 현재 게임 제작 에디터와 실시간 플레이 화면 등 노코드 게임 제작 플랫폼의 프론트엔드를 개발하고 있습니다. 복잡한 폼과 에디터 상태 설계, 웹소켓·SSE 기반 실시간 통신, 성능 문제 분석 등을 맡아왔습니다. 화면에 드러난 현상에 머무르지 않고 측정을 통해 원인을 좁혀갑니다. 발견…
플레이 화면 상태를 Zustand store 하나에 몰아넣었더니 800줄 가까이 됐고, step을 바꾸는 액션이 채팅 상태까지 건드리고 있었다. 변경 주체와 생명주기를 기준으로 도메인별 store로 나누고, store끼리 엮이는 동작은 페이지 훅에서 조합하게 했다.
간헐적으로 나던 undefined TypeError를 try-catch로 덮어뒀는데, 원인은 fetch가 404를 reject하지 않고 공통 API 래퍼도 실패를 throw하지 않아 빈 데이터가 컴포넌트까지 내려온 것이었다. 404 응답을 select에서 null로 바꿔 빈 상태를 명시적으로 처리했다.
필드 타입이 늘 때마다 분기가 붙어 600줄 가까이 된 DynamicForm을 쪼갰다. 타입과 순수 헬퍼부터 떼어내 테스트를 붙이고, 180줄짜리 삼항 체인은 필드 타입별 컴포넌트를 매핑하는 레지스트리 패턴으로 바꿨다.
React Query로 옮긴 뒤 페이지를 다시 열 때마다 API 29건, 게임 생성 한 번에 109건이 나갔다. 커스텀 래퍼 훅이 staleTime을 undefined로 덮어써 전역 설정이 무시됐고, 인자 없는 invalidateQueries()가 모든 쿼리를 다시 부르고 있었다. 쿼리 키를 지정해 무효화하자 109건이 2건으로 줄었다.
useSuspenseQuery는 enabled 옵션이 없어서 URL param이 준비되기 전에 빈 요청을 보냈고, 한 컴포넌트에서 여러 번 부르면 직렬 워터폴이 생겼다. param이 늦게 오는 쿼리는 useQuery와 enabled로 분리하고, 워터폴은 진입 시 prefetchQuery로 병렬 캐싱해 요청을 22건에서 10건으로 줄였다.
채팅 메시지 하나가 추가될 때마다 헤더와 사이드바까지 한 화면이 30번 리렌더됐다. selector 없이 useChatStore()를 호출해 store 전체를 구독하고 있었다. selector와 useShallow로 구독 범위를 좁히고 파생 계산을 selector 밖으로 빼서 3번으로 줄였다.
채팅 메시지 컴포넌트에 React.memo를 붙여도 리렌더가 줄지 않았다. 인라인 객체·children·context가 memo 비교를 깨는 경우를 확인하고, 실제 원인이던 전체 메시지 개수 prop을 isLastMessage boolean으로 바꿔 해결했다.
라우트 43개를 사수 없이 혼자 맡으면서 AI를 코드 생성기 대신 설계와 테스트 시나리오 리뷰어로 쓰기 시작했다. 구현 전에 시나리오를 검토받고, 이해하지 못한 코드는 머지하지 않는 이해도 게이트와 AGENTS.md 규칙 파일로 AI 결과물을 검증한다.