React Compiler를 켜고 나서 — 회귀 4건을 잡은 기록
빌드는 통과했지만 렌더가 순수하다는 전제가 지켜지는지는 아무도 확인한 적이 없었어요. 전수 E2E로 확인했더니 회귀 4건이 나왔고, 넷 다 타입 검사와 린트를 통과했어요.
프론트엔드 · · 10분
React Compiler는 메모이제이션을 자동으로 넣어 줘요. useMemo와 useCallback을 손으로 관리하지 않아도 되니 코드가 줄고 실수도 줄죠. 대신 전제가 하나 붙어요.
렌더는 순수하다. 같은 입력이면 같은 결과가 나온다고 믿고 컴파일러가 렌더 결과를 캐시해요.
저희 어드민에도 켜 보기로 했어요. 빌드는 깔끔하게 통과했습니다. 1,052개 파일에서 2,585건이 컴파일됐고, 실패한 37건은 전부 try/catch 미지원이라 그 파일만 최적화에서 빠지는 정도였어요.
그런데 빌드가 통과했다는 건 문법이 맞다는 뜻이지, 저 전제가 지켜진다는 뜻은 아니에요. 우리 코드가 정말 순수한지는 아무도 확인해 준 적이 없었거든요.
확인해 봤더니 회귀가 4건 나왔어요. 넷 다 타입 검사와 린트를 통과했고, 단위 테스트도 통과한 것들이었고요.
확인할 방법이 하나뿐이었어요
"렌더가 순수한가"는 타입으로 표현되는 성질이 아니에요. 그래서 정적 도구로는 안 잡혀요. 남은 방법은 하나였어요. 값을 바꾸고 화면이 따라오는지 실제로 보는 것.
마침 E2E 스펙이 126개 있었어요. 컴파일러를 켠 빌드로 전부 돌려 보기로 했죠.
그런데 여기서 예상 못 한 벽을 만났어요. 그 스위트는 한 번도 전부 초록이었던 적이 없었어요.
126개를 처음으로 한꺼번에 돌렸어요
스펙 수는 다섯 달 동안 이렇게 늘었어요.
| 시점 | E2E 스펙 |
|---|---|
| 3월 | 0개 |
| 5월 | 34개 |
| 7월 | 76개 |
| 8월 | 126개 |
처음엔 한 명이 자기 화면을 확인하려고 쓰기 시작했어요. 쓸 만하다 싶으니 팀에 권했고, 그 뒤로 늘었죠. 숫자가 늘고 있으니 잘 되고 있다고 생각했고요.
근데 늘어나는 동안 아무도 전체를 돌려 보지 않았어요. 이유가 셋 있었는데, 셋 다 나름 타당했습니다.
먼저 CI에서 못 돌려요. 브라우저를 띄우는 잡은 러너 자원을 많이 먹거든요. 저희 러너 사양으로는 감당이 안 돼서 파이프라인엔 린트·타입 검사·빌드·배포만 있어요. 지금도 그래요.
그래서 MR 올릴 때 로컬에서 돌리기로 했어요. 합리적인 절충인데, 사람에 기대는 규칙이라 균일하지 않았어요. 잘 돌린 사람도 있고 환경이 안 맞아 포기한 사람도 있었고요.
마지막이 제일 컸어요. 돌려도 자기가 건드린 부분만 돌려요. 목록 화면을 고친 사람이 알림 화면 스펙까지 돌릴 이유는 없잖아요. 합리적인 행동인데, 그 결과 126개가 한 번에 도는 순간이 영영 오지 않아요.
첫 전수 실행은 32.6분이 걸렸어요. 476건 통과에 285건 실패, 그중 238건이 30초 타임아웃이었습니다.
낡은 테스트부터 걷어내야 했어요
285건 대부분은 제품이 아니라 테스트가 낡은 것이었어요. 화면 이름과 컬럼명이 바뀐 기대값, 사이드바 링크와 본문 제목을 동시에 잡아 버리는 로케이터, 이미 지나간 응답을 영원히 기다리는 코드 같은 것들.
타이밍 쪽에서 반복해서 나온 네 가지는 적어 둘 만해요.
- 화면 이동 뒤에 응답을 기다리면 이미 지나간 응답이라 영원히 기다려요. 이동 전에 등록해야 해요.
- 목록 응답 전에 빈 상태가 먼저 그려져요. 그걸 결과로 판단하면 데이터가 있는데도 테스트가 통째로 건너뛰어져요.
- 캐시된 쿼리에는 새 요청이 없어요. 오지 않는 응답을 기다리다 죽고요.
- 예외를 삼켜
false로 바꾸는 습관적인.catch()가 "요소 없음"으로 둔갑시켜요. 멀쩡한 화면이 실패로 뒤집힙니다.
공용 컴포넌트 쪽도 몇 개 나왔어요. 배지에 식별 속성이 없어 테스트가 집을 수 없었고, 사이드 패널과 확인 다이얼로그가 같은 역할을 써서 구분이 안 됐어요. 우상단에 고정된 플로팅 버튼이 페이지 헤더의 액션 버튼을 덮고 있기도 했고요. 덮인 버튼은 클릭이 30초를 다 씁니다.
17일 동안 이걸 고치는 커밋만 42개를 쌓았어요. 컴파일러를 켜기도 전에요.
여기서 얻은 건 따로 정리해 뒀어요. 테스트를 늘리는 일과 유지하는 일은 완전히 다른 일이라는 것, 그리고 전수 실행이 사람의 결심에 달려 있으면 그 순간은 오지 않는다는 것. 다만 이 글의 본론은 그다음이에요.
걷어내고 나니 진짜 4건이 보였어요
낡은 테스트를 정리하자 실패가 158건으로 줄고 실행 시간도 8.9분이 됐어요. 타임아웃은 238건에서 24건으로 떨어졌고요. 그리고 그 안에 운영에 나가면 안 되는 회귀 4건이 섞여 있었습니다.
| 증상 | 원인 | 컴파일러 탓? |
|---|---|---|
| 목록에서 정렬·검색을 누르면 브라우저가 멈춘다 | TanStack Table v8→v9 이관 후 남은 중복 effect 가 자기를 재실행 | 아니오 |
| 검색을 누르면 브라우저가 멈춘다 | data ?? { items: [] } 폴백이 렌더마다 새 배열을 만들어 참조가 계속 바뀜 |
아니오 |
| 입력값이 화면에는 보이는데 저장 payload에서 빠진다 | register() 결과가 메모되어 reset() 이후 필드가 재등록되지 않음 |
예 |
| 값을 되돌려도 저장 버튼이 사라지지 않는다 | react-hook-form 이 dirtyFields 객체를 제자리에서 바꿔 참조가 고정 |
예 |
절반은 컴파일러와 무관했어요. 컴파일러를 꺼도 똑같이 멈추거든요. 그런데 컴파일러 도입 검증으로 전수 E2E를 돌리기 전까지 아무도 몰랐어요. 두 번째 프리징은 검색 결과가 있으면 정상이라 수동 테스트로는 잘 안 걸리고요.
입력값이 저장에서 사라졌어요
진짜 컴파일러 회귀부터 볼게요.
상세 화면에서 값을 입력하고 저장을 누르면, 화면에는 값이 그대로 보이는데 서버가 "필수 항목이 없다"며 거절했어요. 요청 본문을 보니 그 필드들이 아예 빠져 있더군요.
RC off : { "report_date": "2026-03-30", "doc_id": "…", "is_draft": false }
RC on : { "is_draft": false }
DOM에는 값이 있는데 폼 내부 상태에는 없었어요. 필드가 폼에 등록되어 있지 않았던 거예요.
register(name)이 돌려주는 건 name·onChange·onBlur·ref를 담은 객체예요. 이걸 입력에 펼쳐 넣죠. 컴파일러가 이 호출 결과를 메모하면 객체 참조가 고정되고, 그 안의 ref 콜백도 같은 함수가 돼요. 그리고 React는 ref 콜백이 그대로면 다시 호출하지 않습니다.
방아쇠는 reset()이었어요. 서버 값이 늦게 도착하는 화면은 보통 이렇게 쓰거든요.
useEffect(() => {
if (data) reset({ reportDate: data.report_date ?? '', ... })
}, [data, reset])
컴파일러가 없을 때는 reset()이 등록을 떨궈도 다음 렌더에서 register()가 새 객체를 돌려주고, ref가 재부착되며 저절로 복구됐어요. 컴파일러를 켜면 그 복구가 일어나지 않습니다.
추측을 쌓지 않으려고 세 방향에서 확인했어요. 컴파일러 없이 빌드하면 정상, 해당 컴포넌트에 'use no memo' 를 붙이면 정상, reset 호출만 제거해도 정상. 셋이 같은 방향을 가리켜야 원인을 못 박을 수 있으니까요.
고치는 방법은 reset 자체를 안 쓰는 것이었어요. react-hook-form 의 values 옵션은 필드 등록을 유지한 채 값만 동기화하거든요. 공용 훅이 이걸 받도록 바꾸고, 같은 패턴을 쓰던 15개 화면을 전부 옮겼습니다.
useFormProps({
schema,
defaultValues,
// useEffect + reset 대신 values 로 동기화한다
values: data ? { reportDate: data.report_date ?? '', ... } : undefined,
})
남은 reset() 3건은 성격이 달라요. 성공 후 패널을 닫으며 비우거나 저장 후 dirty만 지우는 경우라, 곧 언마운트되거나 값이 그대로여서 재등록 문제가 안 생겨요.
되돌려도 저장 버튼이 남았어요
인라인 편집이 있는 화면이에요. 값을 바꾸면 저장 버튼이 나타나고 원래 값으로 되돌리면 사라져야 하는데, 되돌려도 남았어요.
버튼 노출은 react-hook-form 의 dirtyFields 가 결정해요. 부모가 렌더 중에 꺼내서 자식에게 prop으로 넘기고 있었고요.
진짜 원인은 한 층 아래에 있었어요. react-hook-form 은 dirtyFields 객체를 제자리에서 바꿔요. 필드가 변경되거나 해제되어도 객체 참조는 그대로죠. 컴파일러는 그 참조를 의존성으로 보고 렌더 결과를 캐시하니, 값이 바뀌어도 다시 그리지 않는 거예요.
여기가 문서의 틈이에요
컴파일러에는 비호환 라이브러리 목록이 있어요. react-hook-form 도 올라가 있고요. 그래서 useForm 을 직접 호출한 컴포넌트는 컴파일 대상에서 빠져 보호됩니다.
하지만 그 판정은 prop 경계를 넘지 않아요. dirtyFields 를 prop 으로 받은 자식은 useForm 을 호출하지 않으니 정상적으로 컴파일돼요. 부모는 안전한데 자식이 깨지는 구조인 거죠.
처음엔 "구독이 끊겼으니 자식이 직접 구독하면 된다"고 봤어요. 안 됐어요. 구독을 자식으로 옮겨도 객체 참조가 그대로니 컴파일러의 캐시 판정이 바뀌지 않더라고요. 세 가지를 직접 재서 갈랐습니다.
| 방식 | 되돌린 뒤 저장 버튼 |
|---|---|
| prop 으로 넘김 | 남아 있음 (실패) |
자식에서 useFormState |
남아 있음 (실패) |
'use no memo' |
사라짐 (정상) |
그러니 'use no memo' 는 도피가 아니라 이런 상황을 위해 있는 장치예요. 라이브러리가 값을 제자리에서 바꾸는 한 그 컴포넌트는 순수하지 않으니, 메모를 포기하는 게 맞습니다.
같은 패턴을 전수 조사했더니 formState 를 prop 으로 넘기는 곳은 이 한 군데뿐이었어요. isValid나 isDirty를 직접 읽는 15군데는 전부 useForm 을 호출한 컴포넌트 안이라 보호 범위에 들어가고요.
계측을 의심하기 전에 계측 대상을 의심하세요
첫 번째 프리징에는 곁가지 교훈이 하나 있었어요.
인계받은 문서에 "원인은 TanStack Table v8→v9 이관으로 보이는데, 고쳐도 그대로다"라고 적혀 있었어요. 그런데 코드는 이미 고쳐져 있었고 진단도 맞았어요. 틀린 건 계측 절차였습니다. 미리보기 서버를 재기동하지 않아 옛 번들을 재고 있었던 거예요. 재빌드만으로는 반영되지 않을 때가 있더라고요.
맞는 진단이 틀렸다고 버려질 뻔했어요. 계측 결과가 예상과 다르면 코드를 의심하기 전에 계측 대상이 최신인지부터 확인하는 게 좋아요.
무엇이 통했나
네 건 모두 타입 검사·린트·단위 테스트를 통과했어요. 컴파일러용 린트 규칙에도 안 걸리고요. 그 규칙은 렌더 중 특정 호출만 보거든요.
결국 통한 절차는 단순했어요.
컴파일러를 켠 빌드로 전수 E2E를 돌린다. 프리징은 타임아웃 무더기로 드러났고, 폼 회귀는 서버 응답으로 드러났어요.
켠 빌드와 끈 빌드를 대조한다. 이게 제일 강력했어요. 같은 조작을 두 빌드에서 하고 네트워크 payload나 DOM을 비교하면 컴파일러 탓인지가 한 번에 갈려요. 앞의 두 건은 여기서 "무관"으로 분류됐고, 뒤의 두 건은 "탓"으로 확정됐고요.
원인을 세 방향에서 확인한다. 컴파일러 없이 빌드, 'use no memo' 부착, 의심 코드 제거. 셋이 같은 방향을 가리키면 추측이 아니에요.
DOM과 내부 상태를 따로 본다. 세 번째 회귀에서 결정적이었어요. DOM에는 값이 있는데 제출 payload에는 없다 — 이 대비가 "등록이 풀렸다"는 결론으로 곧장 이어졌거든요.
남길 규칙
- 폼에서 서버 값을 채울 때
useEffect+reset을 쓰지 않아요.values로 동기화해요. - 가변 인스턴스에 의존하는 컴포넌트는
'use no memo'로 뺍니다. react-hook-form 의dirtyFields처럼 라이브러리가 값을 제자리에서 바꾸면 구독을 바꿔도 해결되지 않아요. - TanStack Table 에 넘기는
data·columns의 참조를 고정해요. 빈 배열은 모듈 상수로 둡니다. - 비호환 라이브러리 목록을 믿지 않아요. 목록에 없는 것도 위험하고, 목록에 있어도 prop 경계를 넘으면 보호되지 않아요.
정리하면
컴파일러 도입은 성능 작업이 아니었어요. 가정 검증 작업이었어요. "렌더는 순수하다"는 가정을 코드베이스가 실제로 지키고 있는지 확인하는 일. 지키지 않는 곳은 대개 라이브러리 경계에 있었고, 그 경계는 타입으로 표현되지 않아서 도구가 잡아 주지 못했어요.
그래서 도입 비용은 컴파일러를 켜는 비용이 아니라 가정을 어긴 코드를 찾는 비용이에요. 저희 경우엔 전수 E2E가 그 비용을 대신 냈고요. 다만 그 스위트가 한 번도 초록이었던 적이 없었기 때문에, 낡은 테스트 수백 건을 먼저 걷어내야 진짜 버그가 보였습니다.
돌아보면 컴파일러가 한 일은 성능을 올린 것보다, 우리가 지키고 있다고 믿었던 규칙을 실제로는 어기고 있던 자리 네 군데를 알려 준 것에 가까워요. 그중 둘은 컴파일러가 없었어도 이미 깨져 있던 버그였고요.
도움이 되었다면 눌러주세요
댓글 0
첫 댓글을 남겨보세요.