첫 회사에서 있었던 일이다. 결제 페이지를 담당하고 있었는데, 코드가 엉망이었다. 하나의 컴포넌트에 결제 수단 선택, 쿠폰 적용, 가격 계산, 배송지 입력이 전부 들어 있었다. 600줄짜리 단일 파일. 누가 봐도 리팩토링이 필요했다.
주말에 시간을 내서 깔끔하게 쪼갰다. PaymentMethodSelector, CouponApplier, PriceCalculator, ShippingForm. 네 개의 컴포넌트로 분리하고, 각각 독립적으로 동작하게 만들었다. 코드가 보기 좋아졌다. DRY 원칙도 잘 지켰다. 뿌듯했다.
월요일에 PR을 올렸더니 시니어가 코멘트를 달았다. "분리 기준이 UI 기준인데, 비즈니스 로직 기준으로 나누는 게 맞지 않을까?" 처음엔 무슨 말인지 이해가 안 됐다. 컴포넌트를 잘 나눈 것 같은데 뭐가 문제지?
그 시니어와 30분 정도 이야기를 나눴고, 내가 결제 도메인을 얼마나 피상적으로 이해하고 있었는지 깨달았다. 쿠폰 적용과 가격 계산은 내 분류에서 별개의 컴포넌트였지만, 비즈니스적으로는 하나의 "주문 금액 산정" 과정이었다. 결제 수단 선택은 단순한 UI가 아니라 결제 수단에 따라 가격 계산 로직이 달라지는 핵심 분기점이었다. 내가 코드 구조만 보고 도메인을 안 본 거였다.
Dan Abramov가 말한 "Goodbye, Clean Code"
이 경험을 한 후에 Dan Abramov의 "Goodbye, Clean Code"를 읽었는데 완전히 공감했다. 그가 말한 이야기는 이렇다. 동료가 작성한 그래픽 에디터 코드에 반복되는 리사이즈 메서드가 있었다. 밤에 그걸 추상화해서 깔끔하게 만들었는데, 다음 날 아침에 원래대로 되돌리라는 이야기를 들었다.
왜? 그 "반복"처럼 보이는 코드가 실은 각각 다른 요구사항에 대응하기 위한 것이었기 때문이다. 하나의 추상화로 묶어버리면, 나중에 각 도형의 핸들 동작이 달라져야 할 때 추상화를 깨야 한다. Abramov의 표현을 빌리면, "요구사항 변화에 대응하는 능력을 중복 제거와 교환한 것이고, 그건 좋은 거래가 아니었다."
이 글의 결론이 좋았다. "Clean code가 너를 이끌게 하되, 그 다음엔 놓아줘라." 깔끔한 코드가 나쁜 게 아니라, 깔끔함을 목적으로 삼으면 위험하다는 거다. 리팩토링의 진짜 목적은 코드를 예쁘게 만드는 게 아니라 문제를 더 잘 이해하는 거다.
첫 번째 이야기: 상태 관리를 쪼개면서 알게 된 것
프로젝트 초기에 전역 상태를 하나의 거대한 store에 넣었다. 사용자 정보, 장바구니, UI 상태, 알림 목록이 전부 한 곳에 있었다. "어차피 전역이니까" 하는 안일한 생각이었다.
기능이 늘어나면서 store가 비대해졌고, 어떤 액션이 어떤 상태를 바꾸는지 추적하기가 어려워졌다. 리팩토링을 결심하고 상태를 분리하기 시작했다.
처음엔 단순히 파일을 쪼개는 수준이었다. userStore, cartStore, uiStore, notificationStore. 근데 쪼개다 보니 이상한 점이 보이기 시작했다.
장바구니에 상품을 추가하면 알림이 뜨는 로직이 있었다. 이 로직은 cartStore에 있어야 할까, notificationStore에 있어야 할까? 둘 다 말이 되는 것 같으면서 둘 다 아닌 것 같았다. 고민하다가 깨달았다. 이건 store의 문제가 아니라, 도메인 이벤트의 문제였다. "장바구니에 상품 추가됨"이라는 이벤트가 있고, 그 이벤트를 구독하는 여러 처리기가 있는 구조가 맞았다.
리팩토링 전에는 이 개념이 머릿속에 없었다. 코드를 쪼개는 행위 자체가 "이 시스템이 어떻게 동작해야 하는가"에 대한 생각을 강제한 거다. Martin Fowler의 말처럼, 리팩토링은 소프트웨어 구조를 수정하는 것이지만, 그 과정에서 진짜 일어나는 건 개발자의 이해가 깊어지는 것이다.
두 번째 이야기: 조건문을 정리하면서 발견한 숨은 규칙
검색 필터 기능을 유지보수하고 있었다. 카테고리 필터, 가격 범위 필터, 정렬 옵션, 키워드 검색이 조합되는데, 이 조합 로직이 if-else 중첩으로 되어 있었다. 한 20단계쯤 들여쓰기가 들어가 있었다.
"이건 정리 좀 해야 한다"는 생각으로 리팩토링을 시작했다. 처음에는 조건문을 함수로 추출하는 수준이었다. isValidPriceRange(), hasActiveCategory() 같은 함수들.
근데 함수를 만들다 보니 이상한 패턴이 보였다. 가격 범위 필터가 특정 카테고리에서만 다르게 동작하고 있었다. 전자제품 카테고리에서는 가격 범위가 만 원 단위인데, 식품 카테고리에서는 천 원 단위였다. 이걸 처리하는 코드가 if문 안에 if문 안에 하드코딩되어 있었다.
아무도 이 규칙을 문서화하지 않았다. 기획서에도 없었다. 어떤 시점에 누군가가 급하게 추가한 것 같았다. 리팩토링을 안 했으면 나도 이 규칙의 존재를 몰랐을 거다.
결국 이 규칙을 명시적으로 드러내는 설정 객체를 만들었다.
const categoryFilterConfig: Record<string, FilterConfig> = {
electronics: { priceStep: 10000, sortOptions: ['price', 'rating', 'date'] },
food: { priceStep: 1000, sortOptions: ['price', 'freshness', 'rating'] },
default: { priceStep: 5000, sortOptions: ['price', 'rating', 'date'] },
};
이 설정 객체가 도메인 지식을 코드에 명시적으로 담은 거다. if-else 중첩 속에 숨어 있던 비즈니스 규칙이 눈에 보이게 됐다. 기획자한테 이 설정을 보여줬더니 "아 맞아, 이거 작년에 추가한 건데 문서 업데이트를 깜빡했네"라는 반응이 돌아왔다.
리팩토링이 아니었으면 이 암묵적 규칙은 영원히 코드 깊숙이 숨어 있었을 거다.
세 번째 이야기: 컴포넌트를 합치면서 이해한 사용자 흐름
보통 리팩토링 하면 쪼개는 거라고 생각하는데, 합치는 것도 리팩토링이다.
회원가입 플로우를 담당하면서 겪은 일이다. 기존 코드는 이메일 입력 컴포넌트, 비밀번호 입력 컴포넌트, 약관 동의 컴포넌트, 프로필 입력 컴포넌트가 각각 독립적으로 존재했다. 깔끔해 보였다. SRP(단일 책임 원칙)도 잘 지킨 것 같았다.
근데 실제 사용자 경험 문제가 있었다. 이메일을 입력하고 "이미 가입된 이메일입니다" 에러가 뜨면, 비밀번호 입력 컴포넌트가 초기화됐다. 각 컴포넌트가 독립적이다 보니 상태 공유가 안 됐던 거다. 이걸 고치려고 상위 컴포넌트에서 모든 상태를 관리하게 바꿨더니, 상위 컴포넌트가 비대해지면서 각 하위 컴포넌트의 독립성은 무의미해졌다.
고민 끝에 EmailAndPasswordStep, AgreementStep, ProfileStep 이렇게 세 개로 재구성했다. 이메일과 비밀번호를 하나의 스텝으로 합친 거다. "이건 SRP 위반 아닌가?" 싶었는데, 사용자 관점에서 이메일과 비밀번호 입력은 하나의 행위("계정 정보 입력")였다.
이걸 깨닫는 데 코드를 합치는 과정이 필요했다. 컴포넌트를 합쳐보니까 "아, 사용자한테는 이게 하나의 단계구나"라는 게 보였다. 기술적 분리와 사용자 경험적 분리는 다를 수 있다는 걸 코드를 만지면서 체감한 거다.
Dan Abramov의 WET Codebase 이야기가 여기서도 통한다. DRY하게 분리하는 게 항상 좋은 건 아니다. 때로는 중복이 있더라도 맥락을 함께 두는 게 이해하기 쉬운 코드를 만든다. 그리고 그 판단은 코드를 직접 움직여봐야 할 수 있다.
리팩토링은 왜 "생각 정리"인가
세 이야기의 공통점을 정리하면 이렇다.
첫 번째: 상태를 쪼개면서 "이벤트 기반 구조"라는 개념을 이해했다. 두 번째: 조건문을 정리하면서 "숨겨진 비즈니스 규칙"을 발견했다. 세 번째: 컴포넌트를 합치면서 "사용자 관점의 단위"를 이해했다.
전부 리팩토링 전에는 몰랐던 것들이다. 코드를 물리적으로 이동시키는 행위가 머릿속에서 멘탈 모델을 재구성하게 만든다. "이 함수를 여기로 옮기면 어떻게 될까?" "이 두 모듈을 합치면 어떤 의미가 될까?" 이런 질문을 계속 던지다 보면, 코드가 아니라 도메인에 대한 이해가 깊어진다.
Fowler가 리팩토링에 대해 이런 말을 했다. "좋은 산문처럼, 소프트웨어 아키텍처도 프로그래머가 제품이 뭘 해야 하는지를 더 알게 되면서 정기적인 수정이 필요하다." 여기서 핵심은 "더 알게 되면서"다. 리팩토링은 "더 알게 된 결과"가 아니라 "더 알게 되는 과정" 그 자체다.
실용적인 리팩토링 접근법
내가 리팩토링할 때 따르는 규칙 몇 가지.
이유 없이 리팩토링하지 않는다. "코드가 더러워 보여서"는 충분한 이유가 아니다. "이 코드를 수정해야 하는데, 구조 때문에 수정이 어렵다"가 리팩토링할 이유다. 변경할 일이 없는 코드는 아무리 더러워도 놔둔다.
리팩토링과 기능 추가를 동시에 하지 않는다. 커밋을 분리한다. 리팩토링 커밋 따로, 기능 추가 커밋 따로. 섞으면 리뷰어도 괴롭고, 문제가 생겼을 때 뭐가 원인인지 파악하기 어렵다.
리팩토링 전에 테스트를 확인한다. 테스트가 없는 코드를 리팩토링하면 안 되는 건 아닌데, 리스크가 높다. 최소한 주요 동작을 커버하는 테스트가 있는 상태에서 시작한다. 없으면 테스트부터 작성한다. 이것도 결국 시스템을 이해하는 과정이다.
작게 시작한다. 전체를 한 번에 바꾸려고 하면 중간에 지쳐서 반쪽짜리 리팩토링이 되기 쉽다. 하나의 함수, 하나의 모듈에서 시작해서 점진적으로 넓혀간다.
결국 리팩토링은 대화다
혼자 하는 리팩토링보다 팀과 함께 하는 리팩토링에서 더 많이 배운다. 왜냐하면 "이걸 왜 이렇게 바꿨는지" 설명하는 과정에서 내 이해가 한 번 더 정리되기 때문이다.
PR에 리팩토링 의도를 적을 때 "코드를 깔끔하게 정리했습니다"라고 쓰면 리뷰어 입장에서는 별로 도움이 안 된다. "장바구니와 알림의 결합도를 낮추기 위해 이벤트 기반 구조로 변경했습니다. 이렇게 하면 나중에 알림 채널이 추가되어도 장바구니 로직을 건드리지 않아도 됩니다." 이렇게 쓰면 리뷰어도 도메인 이해를 공유하게 되고, 더 좋은 피드백을 줄 수 있다.
리팩토링은 코드를 예쁘게 만드는 미용 시술이 아니다. 문제를 이해하고, 그 이해를 코드에 반영하는 과정이다. 그 과정에서 도메인을 더 깊이 알게 되고, 팀의 공유 지식이 늘어나고, 결과적으로 더 좋은 소프트웨어가 만들어진다. 코드가 깔끔해지는 건 부산물이다. 진짜 성과는 머릿속이 깔끔해지는 거다.
