클래스 컴포넌트 0개로 다시 쓰기 — 1년의 프론트엔드 리뉴얼
class 컴포넌트 0개로 다시 쓴 1년. 상용 그리드를 4개월 만에 뺐고, 제가 만든 아키텍처를 제가 폐기했어요. 리뉴얼의 실제 성과는 되돌리는 결정을 공식적으로 내리는 구조였습니다.

저희 레포에는 class 컴포넌트가 0개예요. 유일한 class는 커스텀 Error 서브클래스 하나뿐입니다.
이 이야기는 프롤로그에서 이어져요. 팀이 떠난 자리에서 리뉴얼을 강행하기로 했고, 그 실행은 "무엇을 버릴지" 정하는 일의 연속이었습니다.
2025년 4월, 이전 세대의 오래된 class 기반 React 코드베이스를 점진 마이그레이션하는 대신 그린필드로 다시 쓰기로 했어요. 1년이 지난 지금 돌아보면 — 진짜 어려웠던 건 첫 스택 고르기가 아니라, 도중에 만든 것들을 폐기하는 결정이었습니다.
(회사 B2B 데이터 분석 플랫폼 이야기예요. 내부 데이터와 도메인은 걷어내고 기술 결정만 연재로 남깁니다. 이번이 1편, 총론이에요.)

왜 점진 마이그레이션이 아니었나
가장 많이 받은 질문이에요. 그린필드는 무모하다는 게 상식이니까요. 저도 그 상식에 동의합니다.
| 기준 | 점진 마이그레이션 | 그린필드 |
|---|---|---|
| 초기 위험 | 낮음 | 높음 |
| 중간 상태 | 두 스택이 오래 공존함 | 없음 |
| 필요한 사람 수 | 두 코드베이스를 동시에 돌봐야 함 | 한쪽만 |
| 되돌리기 | 언제든 가능 | 첫 릴리스 전까지만 |
| 끝나는 시점 | 명확하지 않음 | 명확함 |
저희 상황에서 결정적이었던 건 세 번째 줄이에요. 점진 마이그레이션은 사람이 많을 때 안전한 전략입니다. 두 코드베이스를 동시에 돌볼 손이 있으니까요.
그런데 저는 혼자였어요. 혼자서 두 개를 돌보는 건 안전한 게 아니라 둘 다 방치하는 거예요.
그래서 위험을 다른 자리로 옮겼습니다. 초기 위험은 그대로 감수하고, 대신 "중간 상태를 만들지 않는다"는 규율로 갚기로 했어요. 이 결정이 이 글 뒤쪽에 나오는 레거시 예외 없음 원칙의 뿌리입니다.
1막 — 첫 스택 결정
시작 스택은 React 19 + TypeScript + Vite + Tailwind 4, 상태는 Redux Toolkit과 TanStack Query 병행. React Compiler를 처음부터 켜서 useMemo/useCallback 수동 최적화를 컴파일러에 위임했어요. 테스트는 Vitest와 Playwright, API 목킹은 MSW.
여기까지는 2025년에 새 프로젝트를 시작하는 팀이라면 대부분 비슷하게 골랐을 거예요. 스택 선택은 리뉴얼에서 가장 쉬운 부분입니다.
왜 하필 이 조합이냐고요? 특별한 이유가 없다는 게 이유예요. 2025년의 무난한 정답을 골라 두고, 결정 체력은 어려운 문제에 아껴 뒀습니다. 어려운 건 전부 그 다음에 왔거든요.
고르기 쉬운 것과 어려운 것
스택 선택이 쉬웠다는 말이 이상하게 들릴 수 있으니, 실제로 뭘 놓고 고민했는지 적어 둘게요. 대부분 30분 안에 끝났습니다.
| 자리 | 고른 것 | 같이 검토한 것 | 기각한 이유 |
|---|---|---|---|
| 번들러 | Vite | Next.js · Webpack | 배포 대상이 정적 산출물이라 서버 런타임이 필요 없었어요 |
| 서버 상태 | TanStack Query | 직접 만든 fetch 훅 | 캐시 무효화 규칙을 우리가 다시 발명할 이유가 없었어요 |
| 클라이언트 상태 | Redux Toolkit | Zustand · Jotai | 대시보드 편집처럼 되돌리기가 필요한 화면에서 상태 전이를 추적해야 했어요 |
| 스타일 | Tailwind 4 | CSS-in-JS | 런타임 스타일 계산이 대용량 표 렌더에 그대로 얹히는 게 싫었어요 |
| API 목킹 | MSW | 서버 픽스처 | 같은 목을 테스트와 로컬 개발이 같이 쓰게 하고 싶었어요 |
표를 다시 보면 공통점이 하나 있어요. 전부 "틀렸을 때 얼마나 싸게 바꿀 수 있는가"로 골랐습니다. 최고를 고른 게 아니라, 되돌리기 쉬운 쪽을 골랐어요.
React Compiler를 처음부터 켠 이유
useMemo와 useCallback을 손으로 붙이는 최적화는 코드 리뷰에서 제일 소모적인 논쟁 중 하나예요. 근거가 대부분 감이거든요.
// 컴파일러가 최적화를 맡고, 사람은 규칙 위반만 본다
{
plugins: ["react-compiler"],
rules: {
"react-compiler/react-compiler": "error",
},
}
// 그래서 리뷰에서 사라진 문장들
// - "여기 useMemo 걸어야 하지 않을까요?"
// - "이 콜백 매 렌더마다 새로 만들어지는데요"
// - "의존성 배열에 이거 빠진 것 같은데요"대신 새로운 문장이 생겼어요. "이 컴포넌트가 컴파일러 규칙을 어기고 있습니다." 이건 도구가 잡아 주니까 사람이 판단할 게 없죠. 논쟁이 검사로 바뀌면 리뷰가 짧아집니다.
class 0개는 취향이 아니라 규칙이었어요
함수 컴포넌트로 가자는 합의는 어느 팀에나 있어요. 문제는 그 합의가 지켜지고 있는지 아무도 모른다는 겁니다. 그래서 검사로 박았어요.
// 허용되는 class는 Error 서브클래스뿐
// (스택 트레이스와 instanceof 판별이 필요해서)
class DomainError extends Error {}
// 나머지는 전부 막힘 — eslint no-restricted-syntax
{
selector: "ClassDeclaration:not([superClass.name='Error'])",
message: "class 대신 함수와 모듈을 쓰세요",
}이 규칙 하나가 1년을 버텼습니다. 새 코드가 class를 들고 오면 빌드가 막히니까, "이번만 예외로"가 생길 자리가 없었어요.
2막 — 상용 그리드, 4개월 만의 롤백
문제가 드러나다
데이터 분석 플랫폼이니 테이블·피벗이 제품의 심장이에요. 초기에는 상용 그리드 라이브러리를 도입하고 래퍼 컴포넌트로 감싸서 썼습니다. 일정 압박 속에서 합리적인 선택이었는데, 규모가 커지자 문제가 드러났어요. 번들이 MB 단위로 무거웠고, 대용량 렌더링 성능이 요구에 못 미쳤고, 무엇보다 래퍼 안쪽을 우리가 제어할 수 없었죠.
일정 압박 속에서 합리적이었던 선택을 걷어내자고 스스로를 설득하려면, 감이 아니라 숫자가 필요했어요.
감이 아니라 숫자를 만들기
상용 그리드를 빼자는 말은 꺼내기가 껄끄러웠어요. 도입을 결정한 게 저였거든요. 자기가 내린 결정을 자기가 뒤집으려면 감으로는 안 됩니다.
그래서 벤치마크를 먼저 설계했어요. 어려운 건 측정이 아니라 "무엇을 잴 것인가"였습니다. 초당 프레임 같은 걸 재면 아무도 설득되지 않거든요.
| 지표 | 재는 방법 | 왜 이걸 쟀나 |
|---|---|---|
| 초기 렌더 | 100만 행을 주입하고 첫 페인트까지 | 사용자가 "느리다"고 말하는 순간이 정확히 여기예요 |
| 메모리 점유 | 렌더 직후 힙 스냅샷 | 탭을 여러 개 열어 두는 사용 패턴이라 누적이 문제였어요 |
| 프레임 드랍 | 고정 속도로 10초 스크롤, 16ms 초과 프레임 수 | 체감 성능은 평균이 아니라 최악값이 만들어요 |
| 번들 증가분 | 그리드 관련 청크만 분리해서 측정 | 초기 로딩은 표를 안 쓰는 화면도 같이 부담해요 |
| 제어 가능성 | 요구 기능 12개 중 래퍼 밖에서 구현 가능한 개수 | 숫자가 아닌데 가장 중요했어요 |
결정타는 마지막 줄이었습니다. 성능은 그럭저럭 참을 수 있어요. 그런데 고객이 요구한 셀 병합 규칙 하나를 래퍼 안쪽에 손을 못 대서 구현할 수 없다면, 그건 참을 수 있는 종류의 문제가 아니에요.
숫자가 모이자 회의가 짧아졌어요. 반대 의견이 없어서가 아니라, 반대할 근거를 같은 표 위에서 대야 했기 때문입니다.
래퍼는 뺄 때 진가가 나온다
자체 가상화 테이블을 만들어 벤치마크를 돌렸어요. 100만 행 기준 초기 렌더 약 3.7배, 메모리 약 2.4배 우위, 번들은 수십 KB로 감소. 근거가 쌓이자 결정은 빨랐습니다. 도입 4개월 만에 상용 그리드를 완전히 제거하고 자체 구현으로 통합했어요.
이때 배운 것 — 외부 의존을 래퍼로 감싸두면, 롤백도 래퍼 교체로 끝나요. 래퍼는 도입할 때가 아니라 뺄 때 진가가 나옵니다.
3막 — 내가 만든 아키텍처를 내가 폐기하기
리뉴얼 중반, 패키지 경계를 잡기 위한 중간 아키텍처(entry 기반 Registry 모델)를 설계하고 상당 부분 구현까지 갔어요. 그리고 몇 달 뒤, 더 나은 최종 구조(런타임 기여 플랫폼 — 다음 글에서 다뤄요)로 수렴하면서 이 중간 아키텍처를 공식 폐기했습니다.
여기서 중요한 건 폐기의 형식이에요. 그냥 코드를 지우고 끝내지 않았어요. 폐기된 설계 문서 상단에 "실행 금지 · 비권위 이력 문서 — 이 모델은 폐기되었다"를 명시하고 아카이브 폴더로 강등한 뒤, 최종 아키텍처 문서 하나만 단일 권위로 지정했습니다. AI와 협업하는 코드베이스에서 이게 특히 중요했어요. 폐기된 문서가 살아 있으면 사람도 AI도 그걸 근거로 옛 패턴을 부활시키거든요. 문서에도 삭제가 아니라 '공식 사망 선고'가 필요합니다.
폐기에도 절차가 필요했어요
코드를 지우는 건 오전 반나절이면 끝나요. 어려운 건 그 설계를 근거로 쓴 문서, 그 문서를 읽고 짠 코드, 그리고 그 문서를 학습한 AI 어시스턴트입니다.
| 단계 | 하는 일 | 안 하면 벌어지는 일 |
|---|---|---|
| 1 · 사망 선고 | 문서 최상단에 폐기 표시와 대체 문서 링크 | 검색으로 걸린 사람이 그대로 따라 함 |
| 2 · 강등 | archive 폴더로 이동하고 목차에서 제거 | 목차만 본 사람이 최신인 줄 앎 |
| 3 · 단일 권위 지정 | 살아 있는 문서를 딱 하나로 명시 | 두 문서가 충돌할 때 판정 기준이 없음 |
| 4 · 코드 제거 | 같은 PR에서 구현체까지 삭제 | 문서만 죽고 코드가 살아남아 부활함 |
---
status: superseded # 실행 금지 · 비권위 이력 문서
superseded_by: docs/architecture/runtime-contribution.md
superseded_at: 2025-11
reason: 런타임 기여 모델로 수렴하면서 entry 레지스트리가 불필요해짐
---
> 이 모델은 폐기되었습니다.
> 이 문서를 근거로 코드를 작성하지 마세요.AI와 같이 개발하면서 이 절차가 훨씬 중요해졌어요. 사람은 "아 이거 옛날 거네" 하고 넘기지만, 문서를 읽는 도구는 그 감을 못 씁니다. 폐기 표시가 없으면 폐기된 설계가 그대로 다음 제안으로 돌아와요.
문서에도 삭제가 아니라 사망 선고가 필요한 이유예요. 지우면 흔적이 사라져서 "왜 이렇게 안 했지?"를 다시 묻게 되고, 남겨 두면 부활합니다. 남기되 죽었다고 명시하는 게 답이었어요.
규율 — 레거시 예외 없음
1년 내내 유지한 원칙이 하나 있어요. 전환할 때 wrapper, facade, shim, 이중 실행 경로를 남기지 않는다. 같은 수직 슬라이스 안에서 신규 구현과 레거시 삭제를 동시에 끝냅니다. 구형 다이얼로그를 정리할 때는 사용처 61개 파일을 감사 문서로 전수조사하고, 새 팝업 체계의 사이즈 기준표를 먼저 세운 뒤, 런타임 잔존 0개를 달성하고 나서 회귀 방지 경계 테스트까지 추가했어요.
"일단 같이 두고 점진적으로"가 안 통하는 이유는 간단해요. 두 경로가 공존하는 순간 새 코드가 어느 쪽을 따라야 하는지 모호해지고, 모호함은 반드시 레거시 쪽으로 기울거든요. 점진 마이그레이션을 하려면 슬라이스 단위로 완전히 끝내는 빅뱅-슬라이스가 우리에게는 맞았습니다.
수직 슬라이스가 뭔지 정확히 정해 뒀어요
"슬라이스 단위로 끝낸다"는 말은 듣기엔 좋은데, 실제로는 어디까지가 한 슬라이스인지 매번 싸움이 나요. 그래서 완료 조건을 체크리스트로 고정했습니다.
// 한 슬라이스가 "끝났다"고 말할 수 있는 조건
// 하나라도 빠지면 그 변경은 머지되지 않습니다
[ ] 새 구현이 동작한다
[ ] 옛 구현의 호출부가 0개다 // grep 결과를 근거로 첨부
[ ] 옛 구현 파일이 삭제됐다
[ ] wrapper / facade / shim 이 남아 있지 않다
[ ] 옛 경로로 돌아가면 실패하는 테스트가 있다네 번째 줄이 핵심이에요. 전환 중에 호환 레이어를 하나 남기면 그게 영원히 남습니다. 지울 이유가 아무에게도 급하지 않거든요.
구형 다이얼로그를 걷어낼 때가 이 규칙의 시험대였어요. 사용처가 61개 파일이었거든요.
| 순서 | 한 일 | 결과 |
|---|---|---|
| 1 | 사용처 전수 조사 문서 작성 | 61개 파일을 화면 성격별 4종류로 분류 |
| 2 | 새 팝업 체계의 사이즈 기준표 확정 | "이 경우엔 어느 크기" 논쟁을 미리 종료 |
| 3 | 분류별로 나눠 전환 | 한 번에 61개를 건드리지 않음 |
| 4 | 런타임 잔존 0개 확인 | 소스 검색 + 빌드 산출물 검색 양쪽으로 |
| 5 | 회귀 방지 경계 테스트 추가 | 옛 컴포넌트를 import 하면 테스트가 실패 |
5번이 없으면 앞의 네 개가 전부 무의미해져요. 반년 뒤에 누군가 옛 컴포넌트를 되살리는 순간 원점이니까요. 정리는 이벤트가 아니라 상태고, 상태는 테스트로만 유지됩니다.
1년의 결과
워크스페이스 패키지 19개, TS/TSX 약 6,100개 파일, 커밋 3,388개, 테스트 파일 1,051개. 숫자보다 중요한 건, 이 코드베이스가 지금도 큰 구조 변경을 두려움 없이 수행한다는 사실이에요. 그게 가능한 이유 — 아키텍처를 문서가 아니라 CI 게이트로 강제하고, UI 불변을 해시로 증명하는 안전망 — 는 이어지는 시리즈들에서 하나씩 다룰게요.
되돌린 결정들
1년 동안 되돌린 결정만 따로 세어 봤어요. 자랑은 아닌데, 이 목록이 리뉴얼의 실제 모양에 제일 가깝습니다.
| 되돌린 것 | 유지 기간 | 되돌리는 데 든 시간 | 되돌릴 수 있었던 이유 |
|---|---|---|---|
| 상용 그리드 | 약 4개월 | 약 3주 | 래퍼 하나로 감싸 뒀음 |
| entry 기반 레지스트리 | 약 5개월 | 약 2주 | 경계 테스트가 의존 방향을 잡아 줬음 |
| 초기 폴더 구조 | 약 2개월 | 3일 | 파일 이동만으로 끝났음 |
| 전역 상태 일부 | 상시 | 슬라이스마다 조금씩 | 서버 상태와 분리해 뒀음 |
오른쪽 열만 보면 답이 나와요. 되돌릴 수 있었던 이유가 전부 "미리 경계를 그어 뒀다"예요. 되돌리는 결정을 빨리 내리는 팀은 용감한 게 아니라, 되돌리는 비용을 미리 낮춰 둔 겁니다.
반대로 못 되돌린 것도 있어요. 초기에 급하게 넣은 날짜 처리 유틸은 지금도 그대로 남아 있습니다. 경계 없이 여기저기서 직접 가져다 썼거든요.
되돌릴 수 없는 결정은 잘못된 결정이 아니었어요. 경계를 안 그은 결정이었습니다.
리뉴얼은 한 번의 재작성이 아니었어요. 상용 그리드를 넣었다 뺐고, 중간 아키텍처를 만들었다 폐기했어요. 되돌리는 결정을 빠르게, 그리고 공식적으로 내릴 수 있는 구조 — 그게 리뉴얼의 실제 성과입니다.
각론은 세 갈래로 나눠 뒀어요. 빌드 없이 모든 걸 바꾸는 재빌드 없는 플랫폼, 문서 대신 기계가 규칙을 지키는 규칙은 기계가 지킨다, 그리고 이 블로그에서 제일 딥한 구역인 직접 만든 엔진들. 어느 문으로 들어가셔도 좋습니다.

