클래스 컴포넌트 0개로 다시 쓰기 — 1년의 프론트엔드 리뉴얼
v3 유지보수와 v4 초기 개발을 병행하며 시작한 리뉴얼. 상용 피벗 대체와 기여 구조 전환에서 무엇을 비교하고 검증했는지, 인원 변화와 실제 운영 범위를 함께 정리했어요.

저희 레포에는 class 컴포넌트가 0개예요. 유일한 class는 커스텀 Error 서브클래스 하나뿐입니다.
이 이야기는 프롤로그에서 이어져요. 리뉴얼을 진행하던 중 팀 인원이 줄었고, 그 실행은 "무엇을 버릴지" 정하는 일의 연속이었습니다.
2025년 1~3월에는 v3 유지보수와 v4 초기 설계·구현을 병행했고, 4월부터 v4 개발을 본격화했어요. 1년이 지난 지금 돌아보면 — 진짜 어려웠던 건 첫 스택 고르기가 아니라, 도중에 만든 것들을 폐기하는 결정이었습니다.
(회사 B2B 데이터 분석 플랫폼 이야기예요. 내부 데이터와 도메인은 걷어내고 기술 결정만 연재로 남깁니다. 이번이 1편, 총론이에요.)

유지보수와 새 설계가 겹쳤던 시기
2025년 1월부터 3월까지는 v3 유지보수와 v4 초기 설계·구현을 병행했어요. 처음부터 혼자 시작한 프로젝트는 아니었습니다. 프론트엔드 3인에서 1인 운영 시기를 거쳐 현재는 2인으로 일하고 있어요.
기존의 클래스형 React 코드베이스를 v4에서 다시 쓰면서, 고객사별 변경과 공통 기능이 계속 얽히는 구조도 함께 정리했습니다. 코드 문법을 바꾸는 일에 그치지 않고 이후 확장을 어느 경계에서 받을지 정해야 했어요.
새 구조를 만드는 중에도 제품 운영은 이어졌습니다. 그래서 ‘새로 쓰면 한쪽만 돌보면 된다’는 단순한 이야기는 아니에요. 기존 운영과 신규 개발이 겹친 시기, 인원이 줄어든 시기를 구분해 보는 편이 실제 과정에 가깝습니다.
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막 — 상용 피벗을 직접 구현으로 바꾸기
초기에는 상용 피벗을 사용했어요. 요구 기능을 커스텀할 수는 있었지만 비슷한 요구가 생겨도 라이브러리 계약에 맞춰 수정하는 공수가 반복됐습니다. 당시 적용 방식에서는 사내 Axios API client와 서버 계산을 연결하는 데도 별도 대응이 필요했어요.
무료 대안은 기능이 부족해 상당 부분을 만들어야 했고, 유료 대안은 라이선스 비용에 커스텀 공수가 더해졌어요. 피벗은 초기 기본 기능과 유지보수를 직접 맡기로 했고, 일반 테이블과 트리는 커스텀이 쉬운 무료 라이브러리를 활용했습니다.
셀 합산·역산과 팝업 동작을 정의하고 AI 보조로 구현했어요. 작은 데이터의 계산 결과를 Excel과 대조하고, 서브에이전트의 계산·테스트 로직 리뷰와 직접 스모크 테스트로 확인했습니다.
실제 제품에서 상용 피벗을 대체해 해당 라이선스 비용은 없어졌어요. 대신 인터랙션 변경에서 생기는 회귀도 직접 수정하며 운영해야 했습니다. 교체의 근거는 반복 커스텀 공수와 비용이었으며, 이전 글의 렌더·메모리 성능 배수는 원본 근거를 다시 확보하지 못해 제외했어요.
3막 — 내가 만든 아키텍처를 내가 폐기하기
리뉴얼 중반, 패키지 경계를 잡기 위한 중간 아키텍처(entry 기반 Registry 모델)를 설계하고 상당 부분 구현까지 갔어요. 그리고 몇 달 뒤, 더 나은 최종 구조(런타임 기여 플랫폼 — 다음 글에서 다뤄요)로 수렴하면서 이 중간 아키텍처를 공식 폐기했습니다.
여기서 중요한 건 폐기의 형식이에요. 그냥 코드를 지우고 끝내지 않았어요. 폐기된 설계 문서 상단에 "실행 금지 · 비권위 이력 문서 — 이 모델은 폐기되었다"를 명시하고 아카이브 폴더로 강등한 뒤, 최종 아키텍처 문서 하나만 단일 권위로 지정했습니다. AI와 협업하는 코드베이스에서 이게 특히 중요했어요. 폐기된 문서가 살아 있으면 사람도 AI도 그걸 근거로 옛 패턴을 부활시키거든요. 문서에도 삭제가 아니라 '공식 사망 선고'가 필요합니다.
폐기에도 절차가 필요했어요
코드를 지우는 건 오전 반나절이면 끝나요. 어려운 건 그 설계를 근거로 쓴 문서, 그 문서를 읽고 짠 코드, 그리고 그 문서를 학습한 AI 어시스턴트입니다.
| 단계 | 하는 일 | 안 하면 벌어지는 일 |
|---|---|---|
| 1 · 사망 선고 | 문서 최상단에 폐기 표시와 대체 문서 링크 | 검색으로 걸린 사람이 그대로 따라 함 |
| 2 · 강등 | archive 폴더로 이동하고 목차에서 제거 | 목차만 본 사람이 최신인 줄 앎 |
| 3 · 단일 권위 지정 | 살아 있는 문서를 딱 하나로 명시 | 두 문서가 충돌할 때 판정 기준이 없음 |
| 4 · 코드 제거 | 같은 PR에서 구현체까지 삭제 | 문서만 죽고 코드가 살아남아 부활함 |
---status: superseded # 실행 금지 · 비권위 이력 문서superseded_by: docs/architecture/runtime-contribution.mdsuperseded_at: 2025-11reason: 런타임 기여 모델로 수렴하면서 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 게이트로 강제하고, 구조 검사와 브라우저 검증의 범위를 구분하는 과정 — 는 이어지는 시리즈들에서 하나씩 다룰게요.
되돌린 결정들
상용 피벗을 자체 구현으로 바꾸고, 중간 아키텍처를 최종 기여 구조로 전환했어요. 결정 자체보다 변경이 퍼지는 경계와 남겨야 할 문서를 함께 정리하는 일이 중요했습니다.
외부 라이브러리를 감싸 두면 영향을 줄일 수 있지만 모든 교체가 래퍼 하나로 끝나는 것은 아니에요. API 연동, 계산 검증과 화면 인터랙션까지 확인해야 했습니다.
리뉴얼은 한 번의 재작성이 아니었어요. 상용 그리드를 넣었다 뺐고, 중간 아키텍처를 만들었다 폐기했어요. 되돌리는 결정을 빠르게, 그리고 공식적으로 내릴 수 있는 구조 — 그게 리뉴얼의 실제 성과입니다.
각론은 세 갈래로 나눠 뒀어요. 이미 반입된 플러그인의 활성화·설정을 갱신하는 재빌드 없는 플랫폼, 문서 대신 기계가 규칙을 지키는 규칙은 기계가 지킨다, 그리고 이 블로그에서 제일 딥한 구역인 직접 만든 엔진들. 어느 문으로 들어가셔도 좋습니다.

