전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

클래스 컴포넌트 0개로 다시 쓰기 — 1년의 프론트엔드 리뉴얼

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

2026. 07. 14. 17:47:1610분 읽기프로젝트/데이터 플랫폼
#마이그레이션#리뉴얼#React
조회 39
옛 골조 옆에 새 구조를 세워 옮겨가는 공사 일러스트

저희 레포에는 class 컴포넌트가 0개예요. 유일한 class는 커스텀 Error 서브클래스 하나뿐입니다.

이 이야기는 프롤로그에서 이어져요. 팀이 떠난 자리에서 리뉴얼을 강행하기로 했고, 그 실행은 "무엇을 버릴지" 정하는 일의 연속이었습니다.

2025년 4월, 이전 세대의 오래된 class 기반 React 코드베이스를 점진 마이그레이션하는 대신 그린필드로 다시 쓰기로 했어요. 1년이 지난 지금 돌아보면 — 진짜 어려웠던 건 첫 스택 고르기가 아니라, 도중에 만든 것들을 폐기하는 결정이었습니다.

(회사 B2B 데이터 분석 플랫폼 이야기예요. 내부 데이터와 도메인은 걷어내고 기술 결정만 연재로 남깁니다. 이번이 1편, 총론이에요.)

리뉴얼 타임라인 — 그린필드 시작부터 런타임 기여 플랫폼 확정까지
그린필드 시작부터 런타임 기여 플랫폼 확정까지, 1년의 타임라인.

왜 점진 마이그레이션이 아니었나

가장 많이 받은 질문이에요. 그린필드는 무모하다는 게 상식이니까요. 저도 그 상식에 동의합니다.

기준점진 마이그레이션그린필드
초기 위험낮음높음
중간 상태두 스택이 오래 공존함없음
필요한 사람 수두 코드베이스를 동시에 돌봐야 함한쪽만
되돌리기언제든 가능첫 릴리스 전까지만
끝나는 시점명확하지 않음명확함

저희 상황에서 결정적이었던 건 세 번째 줄이에요. 점진 마이그레이션은 사람이 많을 때 안전한 전략입니다. 두 코드베이스를 동시에 돌볼 손이 있으니까요.

그런데 저는 혼자였어요. 혼자서 두 개를 돌보는 건 안전한 게 아니라 둘 다 방치하는 거예요.

그래서 위험을 다른 자리로 옮겼습니다. 초기 위험은 그대로 감수하고, 대신 "중간 상태를 만들지 않는다"는 규율로 갚기로 했어요. 이 결정이 이 글 뒤쪽에 나오는 레거시 예외 없음 원칙의 뿌리입니다.

1막 — 첫 스택 결정

시작 스택은 React 19 + TypeScript + Vite + Tailwind 4, 상태는 Redux Toolkit과 TanStack Query 병행. React Compiler를 처음부터 켜서 useMemo/useCallback 수동 최적화를 컴파일러에 위임했어요. 테스트는 Vitest와 Playwright, API 목킹은 MSW.

여기까지는 2025년에 새 프로젝트를 시작하는 팀이라면 대부분 비슷하게 골랐을 거예요. 스택 선택은 리뉴얼에서 가장 쉬운 부분입니다.

왜 하필 이 조합이냐고요? 특별한 이유가 없다는 게 이유예요. 2025년의 무난한 정답을 골라 두고, 결정 체력은 어려운 문제에 아껴 뒀습니다. 어려운 건 전부 그 다음에 왔거든요.

고르기 쉬운 것과 어려운 것

스택 선택이 쉬웠다는 말이 이상하게 들릴 수 있으니, 실제로 뭘 놓고 고민했는지 적어 둘게요. 대부분 30분 안에 끝났습니다.

자리고른 것같이 검토한 것기각한 이유
번들러ViteNext.js · Webpack배포 대상이 정적 산출물이라 서버 런타임이 필요 없었어요
서버 상태TanStack Query직접 만든 fetch 훅캐시 무효화 규칙을 우리가 다시 발명할 이유가 없었어요
클라이언트 상태Redux ToolkitZustand · Jotai대시보드 편집처럼 되돌리기가 필요한 화면에서 상태 전이를 추적해야 했어요
스타일Tailwind 4CSS-in-JS런타임 스타일 계산이 대용량 표 렌더에 그대로 얹히는 게 싫었어요
API 목킹MSW서버 픽스처같은 목을 테스트와 로컬 개발이 같이 쓰게 하고 싶었어요

표를 다시 보면 공통점이 하나 있어요. 전부 "틀렸을 때 얼마나 싸게 바꿀 수 있는가"로 골랐습니다. 최고를 고른 게 아니라, 되돌리기 쉬운 쪽을 골랐어요.

React Compiler를 처음부터 켠 이유

useMemo와 useCallback을 손으로 붙이는 최적화는 코드 리뷰에서 제일 소모적인 논쟁 중 하나예요. 근거가 대부분 감이거든요.

// 컴파일러가 최적화를 맡고, 사람은 규칙 위반만 본다
{
  plugins: ["react-compiler"],
  rules: {
    "react-compiler/react-compiler": "error",
  },
}

// 그래서 리뷰에서 사라진 문장들
// - "여기 useMemo 걸어야 하지 않을까요?"
// - "이 콜백 매 렌더마다 새로 만들어지는데요"
// - "의존성 배열에 이거 빠진 것 같은데요"

대신 새로운 문장이 생겼어요. "이 컴포넌트가 컴파일러 규칙을 어기고 있습니다." 이건 도구가 잡아 주니까 사람이 판단할 게 없죠. 논쟁이 검사로 바뀌면 리뷰가 짧아집니다.

class 0개는 취향이 아니라 규칙이었어요

함수 컴포넌트로 가자는 합의는 어느 팀에나 있어요. 문제는 그 합의가 지켜지고 있는지 아무도 모른다는 겁니다. 그래서 검사로 박았어요.

// 허용되는 classError 서브클래스뿐
// (스택 트레이스와 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일파일 이동만으로 끝났음
전역 상태 일부상시슬라이스마다 조금씩서버 상태와 분리해 뒀음

오른쪽 열만 보면 답이 나와요. 되돌릴 수 있었던 이유가 전부 "미리 경계를 그어 뒀다"예요. 되돌리는 결정을 빨리 내리는 팀은 용감한 게 아니라, 되돌리는 비용을 미리 낮춰 둔 겁니다.

반대로 못 되돌린 것도 있어요. 초기에 급하게 넣은 날짜 처리 유틸은 지금도 그대로 남아 있습니다. 경계 없이 여기저기서 직접 가져다 썼거든요.

되돌릴 수 없는 결정은 잘못된 결정이 아니었어요. 경계를 안 그은 결정이었습니다.


리뉴얼은 한 번의 재작성이 아니었어요. 상용 그리드를 넣었다 뺐고, 중간 아키텍처를 만들었다 폐기했어요. 되돌리는 결정을 빠르게, 그리고 공식적으로 내릴 수 있는 구조 — 그게 리뉴얼의 실제 성과입니다.

각론은 세 갈래로 나눠 뒀어요. 빌드 없이 모든 걸 바꾸는 재빌드 없는 플랫폼, 문서 대신 기계가 규칙을 지키는 규칙은 기계가 지킨다, 그리고 이 블로그에서 제일 딥한 구역인 직접 만든 엔진들. 어느 문으로 들어가셔도 좋습니다.

시리즈 이어읽기

혼자 남은 리뉴얼

2 / 2

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.