입사하고 처음 받은 업무가 기존 어드민 패널의 기능 추가였다. 코드베이스를 열었을 때의 첫 느낌을 잊을 수 없다. Class 컴포넌트가 섞여있었다. jQuery가 부분적으로 남아있었다. CSS는 전역 스타일시트 하나에 3,000줄이 들어있었다. 상태 관리는 Redux인데, 액션 타입이 문자열 상수로 별도 파일에 200개 넘게 정의되어 있었다.
// actionTypes.js
export const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
export const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
export const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
export const UPDATE_USER_REQUEST = 'UPDATE_USER_REQUEST';
export const UPDATE_USER_SUCCESS = 'UPDATE_USER_SUCCESS';
// ... 200개 더
TypeScript는 없었다. PropTypes를 쓰고 있었는데, 절반 정도의 컴포넌트에서는 그마저도 없었다. ESLint 설정은 있는데 eslint-disable 주석이 파일마다 붙어있었다.
나는 속으로 생각했다. "이걸 다 갈아엎어야 하는 거 아닌가?"
대규모 리팩토링의 유혹
팀 리드에게 말했다. "이 코드베이스를 TypeScript로 마이그레이션하고, Redux를 걷어내고, 컴포넌트를 전부 함수형으로 바꾸면 좋겠다." 팀 리드는 "좋은 생각이야. 근데 일정은 어떻게 되지?"라고 물었다.
주말에 대략적인 계획을 세워봤다. TypeScript 마이그레이션에 2주, Redux에서 Zustand로 전환에 3주, Class 컴포넌트를 함수형으로 변환에 1주, CSS 모듈화에 2주. 총 8주. 그 사이에 신규 기능 개발은 멈춰야 한다.
팀 리드가 한마디 했다. "비즈니스 팀에 '8주 동안 새 기능 없다'고 말할 수 있어?" 당연히 없었다. 어드민을 쓰는 운영팀에서는 매주 새로운 요구사항이 들어오고 있었다. 필터 추가해달라, 엑셀 다운로드 기능 넣어달라, 대시보드에 차트 추가해달라.
"그러면 어떻게 해요?"
"같이 가는 거지."
첫 번째 실패: Big Bang 마이그레이션
사실 팀 리드의 말을 듣기 전에 몰래 시도를 한 적이 있다. 주말에 혼자 브랜치를 파서, 가장 큰 페이지인 유저 관리 페이지를 통째로 다시 작성했다. Class 컴포넌트를 함수형으로, Redux를 React Query로, CSS를 Tailwind로. 이틀 동안 꽤 열심히 했다.
월요일에 PR을 올렸다. Changed files: 47. 시니어가 PR을 보더니 "이건 리뷰가 불가능해"라고 했다. 맞는 말이었다. 47개 파일이 동시에 바뀐 PR을 누가 제대로 리뷰할 수 있겠나. 하나의 버그를 놓치면 유저 관리 페이지 전체가 터진다. 그리고 이 기간에 다른 사람이 유저 관리 페이지 관련 작업을 하면 머지 충돌이 걷잡을 수 없이 커진다.
PR을 닫았다. 그 주말의 작업이 전부 버려졌다. 아까웠지만, 시니어 말이 맞았다.
점진적 개선 전략
시니어가 알려준 방법은 이랬다. "새로 만드는 건 새 기준으로 만들고, 기존 코드는 건드릴 때만 조금씩 고쳐."
원칙은 단순했다.
- 새 파일은 TypeScript로 작성한다.
- 기존 파일을 수정할 때, 해당 파일을 TypeScript로 전환할 수 있으면 한다. 못 하면 그냥 둔다.
- 새 컴포넌트는 함수형 + hooks로 작성한다.
- 새 API 호출은 React Query를 쓴다. 기존 Redux 로직은 건드리지 않는다.
- PR 하나에 리팩토링과 기능 추가를 섞지 않는다.
5번이 특히 중요했다. 기능 추가 PR에 "김에 이것도 리팩토링했어요"를 넣으면, 버그가 생겼을 때 기능 변경 때문인지 리팩토링 때문인지 구분이 안 된다.
tsconfig의 strict: false부터 시작
TypeScript 전환은 tsconfig.json에 strict: false를 넣는 것부터 시작했다. strict: true로 하면 기존 JS 파일에서 에러가 수백 개 뜨니까.
{
"compilerOptions": {
"strict": false,
"allowJs": true,
"checkJs": false
}
}allowJs: true로 JS 파일과 TS 파일이 공존할 수 있게 했다. 새로 만드는 파일은 .tsx로, 기존 파일은 .jsx로 그대로 뒀다. 한 프로젝트에 .jsx와 .tsx가 섞여있는 게 보기 좋지는 않았지만, 현실적인 선택이었다.
3개월쯤 지나니까 새로 작성한 파일이 전체의 40% 정도가 됐다. 그즈음에 strict: true로 전환하고, 기존 JS 파일에서 나오는 에러를 하나씩 잡았다. 이때쯤이면 기존 코드에 대한 이해도가 높아져 있어서, 타입을 붙이는 과정에서 숨어있던 버그도 몇 개 발견했다.
Redux는 아직도 남아있다
입사한 지 거의 2년이 다 되어가는 지금도 Redux 코드는 일부 남아있다. 유저 인증 관련 전역 상태가 Redux에 있는데, 이걸 Zustand로 옮기면 영향 범위가 앱 전체다. 로그인, 로그아웃, 토큰 갱신, 권한 체크. 다 연결되어 있다.
"언젠가 옮기겠지"라고 생각했는데, 사실 지금 잘 돌아가고 있다. 코드가 예쁘지 않아도 동작하는 코드를 리스크를 감수하면서까지 바꿀 이유가 있을까? 이 질문에 대한 답이 "아니"인 한, Redux는 그냥 둔다. 새로운 상태가 필요하면 Zustand를 쓰면 되니까.
그래서 지금 코드베이스는 혼종이다. TypeScript와 JavaScript가 섞여있고, 함수형 컴포넌트와 Class 컴포넌트가 공존하고, React Query와 Redux가 같이 돌아간다. 보기에 깔끔하지 않다. 하지만 돌아간다. 그리고 방향은 명확하다. 새로운 코드는 모두 TypeScript + 함수형 + React Query다.
Strangler Fig 패턴
나중에 알게 된 건데, 우리가 한 방식에 이름이 있었다. Strangler Fig 패턴. 마틴 파울러가 이름 붙인 건데, 기존 시스템을 새 시스템으로 점진적으로 교체하는 방식이다. 무화과나무(fig)가 숙주 나무를 서서히 감싸면서 대체하는 것에서 따온 이름이라고 한다.
우리의 경우, 새로운 페이지를 만들 때는 완전히 새 스택으로 만들었다. 그리고 기존 페이지의 라우팅을 새 페이지로 하나씩 돌렸다. 사용자 입장에서는 변화를 느끼지 못한다. 내부적으로는 코드가 조금씩 새로운 것으로 교체되고 있을 뿐.
이 패턴이 좋은 건, 롤백이 쉽다는 거다. 새 페이지에서 문제가 생기면 라우팅만 원래대로 돌리면 된다. Big Bang 마이그레이션에서는 이게 안 된다. 전부 바꿨으니 전부 롤백해야 한다.
레거시의 맥락을 이해하기
레거시 코드를 볼 때 처음에는 "이걸 왜 이렇게 짰지?"라는 생각이 먼저 들었다. 근데 Git blame을 보면 이유가 나온다.
한 번은 이상하게 복잡한 날짜 처리 로직을 발견했다. 왜 이렇게 하드코딩을 했나 봤더니, 커밋 메시지에 "서버 타임존 이슈 핫픽스 — 아시아/서울 기준으로 강제 변환"이라고 적혀있었다. 그 커밋이 새벽 2시에 올라와 있었다. 프로덕션 장애 상황에서 급하게 고친 코드였다. 그 맥락을 알고 나니까, "이걸 왜 이렇게 짰지"가 "그때는 이렇게밖에 못 했겠구나"로 바뀌었다.
모든 레거시 코드에는 그때의 사정이 있다. 일정이 촉박했거나, 당시에는 더 나은 방법을 몰랐거나, 임시로 넣었는데 영구적이 되어버렸거나. 어떤 이유든 그 코드를 작성한 사람을 탓하는 건 의미가 없다. 중요한 건 지금부터 어떻게 개선할 것인가다.
완벽한 코드베이스는 없다
2년 가까이 일하면서 느낀 건, 완벽한 코드베이스란 존재하지 않는다는 거다. 아무리 깔끔하게 시작해도, 시간이 지나면 레거시가 된다. 지금 우리가 "새 기준"으로 작성하는 코드도 2년 뒤에 새로 온 개발자가 보면 "이걸 왜 이렇게 짰지?" 할 거다.
레거시는 제거해야 할 대상이 아니라, 같이 가야 할 동반자다. 어쩔 수 없이 공존해야 하는 거니까, 적어도 방향은 정해놓고 조금씩 나아가는 수밖에 없다. 그게 느리더라도.
가끔 처음 봤던 그 3,000줄짜리 전역 CSS 파일을 떠올린다. 지금은 1,200줄 정도로 줄었다. 나머지는 CSS Modules나 Tailwind로 이전됐다. 아직 1,200줄이 남았다는 게 찝찝하긴 한데, 처음보다는 나아졌고, 내일은 오늘보다 나을 거다. 아마도.
