클로저·this·viewport를 지키며 큰 컴포넌트를 쪼갠 작업기
"이 컴포넌트 너무 크니까 좀 쪼개 줘." 이 요청을 받으면, 사람이든 AI 에이전트든 가장 쉽게 빠지는 함정이 줄 수(LOC)를 줄이는 데 최적화하는 것이다. 함수로 뽑고 helper로 흩어 메인 본문을 짧게 만든다. 지표는 좋아진다. 그런데 화면이 깜빡이고, 차트 호버가 죽고, 다른 탭에 다녀오면 그래프가 어긋났다. 기술 트렌드를 시각화하는 한 화면(기술 트리·버블 차트·보고서)을 다섯 갈래로 쪼개면서 나는 이걸 다섯 번 확인했다. 큰 컴포넌트·훅을 분해해 봤고 useEffect나 차트 라이브러리에서 미묘한 회귀를 겪어 본 사람이라면 익숙한 장면일 것이다.
줄을 세는 일과 의미를 지키는 일은 다르다
분해를 하다 보면 매번 같은 단어로 돌아왔다. 런타임 계약. 코드가 겉으로 드러내지 않지만 동작이 의존하는 암묵적 약속이다. 클로저가 값을 캡처하는 시점, 이벤트 콜백에서 this가 가리키는 대상, 측정이 "안정적"이라 말할 수 있는 조건 — 전부 계약이다. 타입 시그니처에도 안 보이고, 단위 테스트도 잘 못 잡는다. 그래서 LOC만 보고 분해하면 지표는 좋아지는데 동작은 조용히 깨진다.
흔히 말하는 관심사 분리(SoC)와는 결이 다르다. SoC가 "코드를 어떤 단위로 나눌까"라면, 런타임 계약은 "나눈 뒤에도 같은 약속이 지켜지는가"다. 잘 나눴는데 약속이 깨질 수 있고, 그 반대도 가능하다. 이번에 지켜야 했던 계약은 셋이었다. useEffect의 단회 반영, 차트 콜백의 this 바인딩, 그리고 비활성 탭 복귀 시 viewport 안정 조건.
탭에 다녀오면 그래프가 어긋났다
가장 눈에 보이는 계약은 세 번째였다. 분해 전에는 requestAnimationFrame으로 매 프레임 컨테이너 크기를 재며 "이제 안정됐나?"를 묻는 폴링 루프가 있었다(이름도 waitForStableSize였다). 폴링이 거기 있던 이유는 분명했다. 백그라운드 탭에서는 컨테이너가 0 ×0으로 접히고, @xyflow는 트리를 화면에 맞추려면 실제 크기가 필요하다. 그래서 복귀 직후 첫 측정은 거의 틀리고, "맞을 때까지" 매 프레임 다시 재는 코드가 자라난 것이다. 문제는 그 대가였다. 매 프레임 layout을 읽고, 사용자가 다른 탭에 가 있어도 멈추지 않고 돌았다. 이걸 이벤트 구동으로 바꿨다.

바뀐 구조는 세 레벨이 맞물린다. 브라우저 레벨에서 visibilitychange가 "탭 복귀" 게이트를 열고, ResizeObserver가 컨테이너 크기 변화를 추적한다. visibilitychange는 크기를 직접 재지 않는다 — 복귀 시점만 열어 주고, 안정화 추적은 ResizeObserver에 맡긴다. 라이브러리 레벨에서 @xyflow의 setViewport로 최종 위치를 적용하고, 프레임워크 레벨에서 effect cleanup이 observer를 disconnect해 백그라운드로 새는 관찰을 막는다.
핵심은 안정 조건 자체는 그대로라는 점이다. "크기가 0이 아니고 더는 안 변할 때 측정한다"는 의미는 같고, 그 표현만 '매 프레임 측정'에서 '변화 이벤트 + 150ms debounce'로 바뀌었다. 정직하게 덧붙이면, 일회성 viewport 계산을 위한 rAF 호출 하나는 남겼다. 내가 걷어낸 건 매 프레임 도는 폴링 루프지, rAF 그 자체가 아니다.
setState를 루프 안에 두는 순간 ERROR가 사라졌다
가장 위험했던 건 첫 번째 계약이었다. 상태·뷰 동기화 훅의 메인 useEffect가 245줄이었는데 54줄로 줄였다. 줄은 5분의 1이 됐지만, 이 분해가 성립한 건 단 하나 — "누적은 지역 변수에, 반영은 루프가 끝난 뒤 한 번만"이라는 클로저 캡처 의미를 그대로 지켰기 때문이다.
처음 떠오른 "깔끔한" 버전은 이랬다.
// 처음 버전 — 깔끔해 보이지만 '단회 반영' 계약을 깬다
function handleMessage(msg, ctx) {
if (isLoadingBlock(msg)) {
ctx.setReportLoadingData(msg.data); // 메시지마다 반영 → effect deps 재실행
}
}
useEffect(() => {
messages.forEach((m) => handleMessage(m, ctx));
}, [messages]);
메시지마다 setState가 호출되면 effect 의존성이 다시 돌고, 뒤늦게 도착한 LOADING 블록이 먼저 반영된 ERROR를 덮어쓴다. 원래 코드가 막고 있던 바로 그 버그다. 추출은 했지만 계약은 깼다.
고친 버전은 누적과 반영을 분리한다.
// 고친 버전 — 누적은 accum에, store 반영은 루프 종료 후 단 한 번
type LoopAccum = {
latestReportData: ReportStatus | null;
latestInProgressId: string | null;
hasStatusBlock: boolean;
};
function reduceMessage(msg: Message, accum: LoopAccum) {
if (isLoadingBlock(msg)) {
accum.latestReportData = msg.data; // setState 없이 누적만
accum.latestInProgressId = msg.reportId;
accum.hasStatusBlock = true;
}
}
useEffect(() => {
const accum = createAccum();
messages.forEach((m) => reduceMessage(m, accum)); // helper로 빼도
if (accum.hasStatusBlock) { // 같은 클로저를 캡처 → 의미 동일
setReportLoadingData(accum.latestReportData); // 단회 반영
if (accum.latestInProgressId) setCurrentReportId(accum.latestInProgressId);
}
}, [messages]);
함수로 추출해도 accum은 같은 클로저를 캡처하므로 의미가 보존된다. 결과는 화면에서 갈렸다. 처음 버전에서는 보고서 상태가 ERROR로 떴다가 곧바로 LOADING으로 되돌아가 깜빡였고, 고친 버전에서는 같은 메시지 배치를 처리해도 최종 상태가 한 번만 반영돼 ERROR가 유지됐다. 줄 수는 비슷한데 동작은 정반대다.
어떤 건 빼고 어떤 건 남겼나
경계 판단이 늘 "빼라"는 아니었다. 버블 차트의 point.events(mouseOver/mouseOut)는 this로 해당 포인트에 접근한다. Highcharts가 콜백을 호출할 때 this를 포인트에 묶어 주기 때문이다. 이걸 화살표 함수로 "정리"해 바깥으로 빼면 this 바인딩이 끊긴다. 그래서 이 콜백은 일부러 인라인 일반 함수로 남겼다.
반대 방향도 있었다. 한 모달(383줄)이 함수 본문 안에서 또 다른 트리 모달 컴포넌트를 정의하고 있었다. 이러면 부모가 리렌더 될 때마다 컴포넌트 정체성이 새로 생겨, React가 같은 UI를 언마운트·리마운트 하며 내부 상태와 포커스를 날린다. 이 경우엔 반대로 모듈 레벨의 별도 컴포넌트로 빼는 게 계약을 지키는 길이었고, 본문은 101줄로 줄었다. 경계의 핵심은 추출이냐 보존이냐의 방향이 아니라 근거다. this에 묶인 콜백은 남기고, 렌더마다 새로 만들어지면 안 되는 컴포넌트는 뺀다.
DRY도 같은 저울에 올렸다. 트리 데이터를 다루는 두 훅을 하나로 합치자는 제안이 있었지만, 한쪽은 실시간 스트리밍(SSE) 기반 생성이고 다른 쪽은 보고서 조회 기반이라 설계가 이미 분화돼 있었다. 합치면 조건 분기 복잡도가 유지보수 이익을 넘는다고 보고, 통합을 접고 Non-goal로 남겼다. 중복 제거가 늘 이득은 아니다.
이 모든 분해에서 안전망은 테스트가 아니었다. 이 화면에는 활발히 유지되는 테스트가 없어서, 나는 깨질 계약을 직접 재현하는 쪽을 택했다. 분해 전후로 같은 시나리오 — 보고서가 ERROR로 떴다가 LOADING으로 되돌아가는 깜빡임, 다른 탭에 다녀온 뒤 어긋나던 그래프 — 를 손으로 재연해 동작이 같은지 확인했다. 의미를 보존했다는 말은, 보존됐는지 눈으로 확인했다는 뜻이어야 한다.
다섯 번의 분해에서 남은 결론은 단순하다. 리팩터링의 단위는 줄 수가 아니라 의미다. useEffect의 단회 반영도, 차트의 this 바인딩도, viewport 안정 조건도 추출 전후로 같아야 하고, 모든 걸 빼는 게 아니라 계약에 묶인 코드는 남기는 게 더 나은 분해다.
지금 당신의 가장 큰 useEffect를 열어, setState가 루프 안에서 호출되는지부터 확인하라.
결국 이게 왜 프런트엔드의 일이냐면, 우리가 실제로 다루는 게 코드 줄이 아니라 사람이 보는 화면과 그 화면을 떠받치는 런타임 계약이기 때문이다. 인터페이스를 잘 만드는 craft는 하나고, 표면이 사람이든 에이전트든 "의미를 보존하고, 보존했는지 확인하는" 규율은 똑같이 적용된다. 줄 수는 그 계약의 그림자일 뿐이다.
참고
- ResizeObserver — 요소 크기 변화 관찰 (MDN): https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
- visibilitychange 이벤트 (MDN): https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
- Highcharts
plotOptions.series.point.events— 포인트 이벤트의this바인딩: https://api.highcharts.com/highcharts/plotOptions.series.point.events - React Flow(@xyflow) Viewport 타입: https://reactflow.dev/api-reference/types/viewport
- Vercel React Best Practices — "Don't Define Components Inside Components": https://github.com/vercel-labs/agent-skills/blob/main/skills/react-best-practices/AGENTS.md
수치(245→54·383→101줄)는 본 분해 작업 기준이며, API 동작은 각 출처의 최신 문서(2026-06) 기준입니다.