코드 리팩토링: 실제로 작동하는 기법들

요약

코드 리팩토링은 새 기능을 추가하지 않으면서도 코드 구조를 개선하는 작업이다. 이 가이드는 실무에서 정말 쓰는 패턴을 다룬다: Extract Method, Composing, Branch-by-Abstraction, 조건문 정리. 리팩토링할 신호와 건드리면 안 될 때를 명확히 알게 된다.

다크 홈 오피스에서 두 모니터로 코드 리팩토링 작업 중인 개발자

코드 리팩토링: 실제로 작동하는 기법들

대부분의 개발자는 리팩토링의 중요성을 안다. 무엇을 하지 않으면 더 어려워지는지, 코드가 정말로 정리가 필요한지 vs 전체 재작성이 필요한지 판단하는 건 덜 명확하다. 여기 6개월간 레거시 추천 시스템을 다루며 깨달은 것들, 그리고 다음 번엔 어떻게 할지에 대한 이야기다.

다크 홈 오피스에서 두 모니터로 코드 리팩토링 작업 중인 개발자

리팩토링이 정말 필요하다는 신호들

엉망으로 보이는 함수가 다 손 댈 가치는 없다. 진지하게 봐야 할 신호 3가지:

중복이 여러 곳에서 나타난다. 같은 데이터 변환을 3개의 컨트롤러에서 반복하고 있다면, 이건 스타일 문제가 아니다. 메인터넌스의 폭탄이다. 상위의 한 줄 수정이 3곳을 고쳐야 한다는 뜻이고, 당신은 한 곳을 빠뜨릴 것이다.

한 곳의 수정이 전혀 다른 기능을 부순다. 결제 플로우의 버그 수정이 갑자기 유저 프로필 렌더링에 영향을 준다면, 결합도가 너무 높다. 코드베이스가 별개인 책임들을 하나로 취급하고 있다.

신입 엔지니어가 30분 안에 방향을 잡지 못한다. 이건 과소평가된 신호다. 파일 전체를 읽지 않고는 그 파일이 뭐 하는지 설명할 수 없다면, 그 파일은 너무 많은 일을 한다. Appier에선 온보딩 시간을 재기 시작했다. 설명에 15분 이상 걸리는 파일들이 리팩토링 백로그에 들어갔다.

코드 체인지 집중도. git log --stat을 실행해서 어떤 파일이 가장 자주 건드려지는지 봐라. 같은 3개 파일이 전체 커밋의 60%를 차지한다면, 그게 신호다. 높은 변경량 + 높은 복잡도 = 80/20 핫스팟이고, 여기부터 시작할 가치가 있다.

리팩토링을 건너뛰는 경우: 코드가 작동하고, 아무도 건드리지 않으며, 확장할 이유가 없다. Boy Scout Rule(쓰고 떠날 땐 더 깨끗하게)은 좋은 원칙이지만, 쓰지 않는 캠프사이트까지 청소할 필요는 없다.

Extract Method: 80% 상황에서 쓸 기법

Extract Method는 긴 함수에서 의미 있는 로직 덩어리를 빼내서 이름을 붙이는 것이다. 그 이름이 문서다.

Before:

def process_order(order):
    # validate
    if not order.get('user_id'):
        raise ValueError('Missing user_id')
    if order.get('amount', 0) <= 0:
        raise ValueError('Invalid amount')
    # apply discount
    if order.get('coupon') == 'LAUNCH20':
        order['amount'] = order['amount'] * 0.8
    # save
    db.save(order)

After:

def process_order(order):
    validate_order(order)
    apply_discount(order)
    db.save(order)

def validate_order(order):
    if not order.get('user_id'):
        raise ValueError('Missing user_id')
    if order.get('amount', 0) <= 0:
        raise ValueError('Invalid amount')

def apply_discount(order):
    if order.get('coupon') == 'LAUNCH20':
        order['amount'] = order['amount'] * 0.8

"After" 버전이 더 길다. 괜찮다. 각 함수는 이제 존재 이유가 하나씩만 있고, 할인 로직이 바뀔 때(그리고 바뀐다), 정확히 어디로 가야 하는지 안다.

보람이 있으려면: 원래 함수가 20-25줄 정도 이상이거나, # validate 같은 주석을 달아야 할 때다. 그 주석이 함수 이름 대기 중인 것이다. # apply discount 주석을 달았다면, 메서드의 이름은 이미 정해진 거고, 아직 추출만 안 한 거다.

실전 법칙: 다음 5줄을 설명하는 인라인 주석을 달고 싶으면, 그건 추출 후보다.

Composing Methods: Extract Method로 충분하지 않을 때

Composing은 더 나간다. 한 메서드를 추출하는 대신, 전체 클래스나 모듈을 더 작은, 집중된 컴포넌트들로 분해한다. 파일이 600줄로 자라서 5개의 별개 책임을 한다면 이게 필요하다.

패턴: 각 책임을 구분하고, 각각 자기 클래스나 모듈을 주고, 상위 레벨에서 연결한다. Martin Fowler는 이걸 "한 개의 비대해진 클래스를 협력하는 여러 작은 클래스의 집합으로 변환"이라고 부른다.

실전에서 어려운 부분은 메커니즘이 아니라, 한 책임이 끝나고 다른 책임이 시작하는 지점을 찾는 것이다. 유용한 휴리스틱: 함수나 클래스가 뭐 하는지 "그리고(and)"라는 말 없이 설명할 수 있다면, 책임이 하나다. "그리고"가 나오면, 나눠야 한다.

찾는 다른 방법: 파일 맨 위의 import들을 보자. 하나의 모듈이 6개의 다른 영역에서 import한다면(데이터베이스, 이메일, 로깅, 결제, 리포팅), 하는 일이 너무 많다. 각 import 클러스터가 자기 모듈의 후보다.

Composing은 테스트 가능성도 높인다. 400줄의 클래스는 단위 테스트하기 어렵다. 명확한 인터페이스의 100줄 클래스 4개는 모킹과 격리가 훨씬 쉽다.

코드 아키텍처 리팩토링 다이어그램을 계획 중인 화이트보드 개발자

Branch-by-Abstraction: 배포 중에 리팩토링하기

이건 더 아키텍처 쪽이지만, 프로덕션 환경에서 대규모 리팩토링을 생존시키는 기법이다.

아이디어: 오래된 구현에서 새 구현으로 한 번에 바꾸는 대신, 추상화 레이어(보통 인터페이스나 프로토콜)를 그 코드 주위에 도입한다. 오래된 것과 새로운 것 모두 인터페이스를 만족한다. 새 구현을 기능 플래그 뒤에서 배포하고, 양쪽을 동시에 돌리다가, 새 것이 안정화되면 옛날 것을 지운다.

사이드 프로젝트에서 중요한 이유: 결제 통합을 다시 짜거나 ORM을 옮길 때, branch-by-abstraction은 현재 버전이 프로덕션에서 작동하는 동안 새 것을 구워낼 수 있다는 뜻이다. 3주간 메인과 멀어지는 "리팩토링 브랜치" 같은 건 없다.

대가: 깔끔한 인터페이스를 정의하는 데 선행 작업이 더 필요하다. 교체할 것이 위험할 때(인증, 빌링, 데이터 접근) 가치 있다.

구체적 예: 레거시 이벤트-트래킹 모듈이 PostgreSQL 테이블에 직접 썼다. Kafka 기반 파이프라인으로의 이동은 40+ 호출 지점을 건드려야 했다. Branch-by-abstraction으로, EventTracker 인터페이스를 도입했다. 플래그 뒤에서 구현을 바꿨다. 2주간 양쪽을 동시에 돌린 후, 옛날 것을 지웠다. 다운타임 0, 긴급 배포 0.

조건문 정리하기: 과소평가된 정리

조건문 로직이 복잡함을 숨긴다. 5개의 중첩된 if를 가진 함수는 5개의 별개 조건을 가드 클로즈나 다형성으로 표현한 것보다 테스트, 읽기, 확장이 다 어렵다.

중첩된 조건을 가드 클로즈로 바꾼다:

Before:

function getDiscount(user) {
  if (user) {
    if (user.isActive) {
      if (user.plan === 'pro') {
        return 0.20;
      } else {
        return 0.05;
      }
    } else {
      return 0;
    }
  } else {
    return 0;
  }
}

After:

function getDiscount(user) {
  if (!user) return 0;
  if (!user.isActive) return 0;
  if (user.plan === 'pro') return 0.20;
  return 0.05;
}

같은 로직. 훨씬 읽기 쉽다. 앞의 리턴들이 들여쓰기 피라미드를 없애고, 각 조건을 따로 읽게 해준다.

조건을 다형성으로 바꾼다: 같은 if type === X 체크가 여러 함수에서 반복되면, 베이스 클래스나 프로토콜을 정의하고, 타입마다 하나씩 구현하고, 호출 지점에서 디스패치되게 한다. 건너뛰는 경우: 타입이 2개뿐이고 3번째가 생길 가능성이 낮다면, 추상화 오버헤드는 이진 선택지에 맞지 않다.

중복 조건을 통합한다: 같은 조건을 3곳에서 체크하고 있으면, 그 조건을 이름 붙은 predicate 함수로 추출한다. if (isEligibleForDiscount(user))는 3부로 나뉜 체크를 반복하는 것보다 낫다.

공유 책상에서 페어 프로그래밍 및 코드 리뷰 세션 중인 두 개발자

기능을 객체들 사이에서 옮기기: 덜 알려진 기법

때론 메서드가 잘못된 클래스에 있다. 코드 자체가 잘못된 게 아니라, 자기 클래스보다 다른 클래스의 데이터를 더 많이 쓰기 때문이다. 이게 옮길 신호다.

Move Method 패턴은 간단하다: 메서드를 찾고, 실제로 어떤 클래스에 더 의존하는지 확인하고, 거기로 옮기고, 호출자들을 갱신한다. IDE의 린트가 깨진 레퍼런스를 잡아낼 것이다.

사이드 프로젝트에서 가장 많이 보이는 것: 어디 속할지 명확하지 않은 모든 걸 모으는 유틸리티 클래스들. helpers.jsutils.py 파일이 400줄로 자라난다. 어느 시점 이 함수들은 자신이 조작하는 도메인 객체 위에 있어야 한다. User 객체를 받아서 5가지를 하는 함수는 utils가 아니라 User 위에 있어야 한다.

건너뛰는 경우: 프로젝트가 2주 되고 아직 형태를 찾고 있다면, 구조를 오버-엔지니어링하지 마라. 실제 쓰임에서 설계가 나타나게 하고, 그 다음에 깨끗한 도메인 모델에 투자해라. 너무 일찍 옮기면 요구사항이 바뀔 때 다시 옮긴다.

리팩토링 전에 해야 할 것: 테스트 베이스라인

테스트 없는 리팩토링은 도박이다. 정리하는 줄 알았는데, 망가뜨리는 거다.

최소 안전망: characterization tests. 건드리려는 코드의 현재 행동을 포착하는 테스트를 쓴다. 뭐 해야 하는지가 아니라, 지금 뭐 하는지. 이 테스트는 설계를 검증하는 게 아니라 회귀를 잡는 데 있다.

베이스라인을 잡은 후, 작은 커밋으로 리팩토링한다. 패턴 하나씩 한 커밋. 뭔가 깨지면, 뭐가 깨뜨렸는지 정확히 안다. 다른 방법과 비교: 3일짜리 리팩토링 세션을 하나의 거대한 diff로 커밋하기. CI가 새벽 2시에 실패하면, 40개 변경 중 뭐가 깨뜨렸는지 모른다.

리팩토링 시작 전 실전 체크리스트:

유용한 제약: 커밋 메시지 한 문장으로 리팩토링을 설명할 수 없다면, 한 번에 너무 많이 한다.

데이터 정리하기: 다른 모든 걸 개선하는 패턴

지저분한 조건문이 많은 이유는 데이터 모델이 잘못되었기 때문이다. raw 프리미티브를 넘겨다니면, 타입 체크 조건이 곳곳에 생긴다.

프리미티브를 객체로 바꾼다(string emailEmailAddress가 되고, int centsMoney가 된다). 검증 로직의 집이 생긴다. 12곳에서 if not re.match(EMAIL_REGEX, email)을 체크하는 대신, EmailAddress 생성자가 한 번만 한다.

이건 Fowler의 "Replace Type Code with Class" 패턴이다. 화려하진 않지만, 조건문 무성을 거의 다른 어떤 것보다 잘 줄인다.

매직 넘버도 같은 원칙: if status == 3은 읽기 어렵다. if status == OrderStatus.SHIPPED는 아니다. 매직 넘버를 이름 붙은 상수로 바꾸는 데 5분이 들고, 누군가 코드를 읽을 때마다 값어치를 한다.

리팩토링하면 안 될 때: 사람들이 건너뛰는 경우들

대부분의 가이드가 여기서 끝난다. 모든 코드베이스가 리팩토링할 가치는 없고, 정리를 항상 좋은 것으로 취급하면, 다른 종류의 기술 부채가 될 뿐이다.

하드 마감 직전에는 리팩토링하지 마라. 리팩토링은 변화를 가져온다. 변화는 위험을 가져온다. 48시간 안에 클라이언트 데모가 있다면, 지금은 아니다.

삭제할 코드를 리팩토링하지 마라. 다음 스프린트에서 기능이 잘려나간다면, 구현을 정리하는 건 낭비다. 정리에 투자하기 전에 삭제를 확인해라.

스타일만으로는 리팩토링하지 마라. 코드가 작동하고, 테스트되고, 아무도 건드리지 않으면, 당신이 다르게 썼을 거라는 사실은 바꿀 이유가 아니다. 회귀의 위험은 실제이고, 이득은 미학이다.

명확한 목표 없이는 리팩토링하지 마라. "이거 정리해"는 작업이 아니다. "OrderProcessor의 검증 로직을 전용 Validator 클래스로 추출"은 작업이다. 모호한 범위는 범위 증대로 이어지고, 이는 3주간 메인에서 멀어졌다 컨플릭트로 병합되는 브랜치를 만든다.

이건 완벽한 규칙 집합이 아니다. 실무에서 쓸 수 있는 규칙이고, 프로덕션 코드베이스에서는 실무용이 완벽함을 이긴다.

6개월 증분 리팩토링이 실제로 어떤 모습인지

팀이 증분적으로, 매 스프린트 조금씩, 자신들이 배포하는 기능과 맞춰서 리팩토링하면, 작업이 더 싼 코드베이스를 갖춘다. 처음엔 더 빠르지 않지만, 확장이 빠르고, 디버그가 빠르고, 신입 동료 온보딩이 빠르다.

솔로 빌더: 이득은 개인적이다. 당신은 6개월 뒤의 신입 엔지니어다. 오늘 정리한 코드는 1월에 다시 읽지 않아도 이해하는 코드다.

추천 시스템 프로젝트의 6개월 스냅샷: 1200줄의 서비스 파일로 시작했고, 테스트는 없었다. 매 기능 스프린트마다 메서드를 추출했고, 각 섹션을 건드릴 때마다 characterization 테스트를 더했다. 도메인을 더 잘 이해하면서, 것들을 맞는 클래스로 옮겼다. 6개월 후, 같은 로직이 9개 파일에 나뉘었고, 각각 평균 130줄, 모두 테스트, 모두 뭐 하는지를 이름으로 표현했다. 배포 빈도는 주 1회에서 매일로. 버그로부터 복구 시간은 대략 40% 줄었다.

그 숫자들은 연구가 아니다. 한 팀, 한 시스템에서 나온 거다. 하지만 방향은 맞다. 더 작고 깨끗한 단위는 빠르게 바뀐다. 전부가 그 베팅이다.

자주 묻는 질문

코드 리팩토링이란 뭐고, 왜 중요한가?
코드 리팩토링은 외부 동작을 바꾸지 않고 코드 구조를 재구성하는 작업이다. 중요한 이유는 코드베이스를 이해하고 확장하고 디버그하기 쉽게 만들기 때문이고, 앞으로의 기능 개발과 버그 수정 비용을 직접 줄인다.
Extract Method 리팩토링 기법이란?
Extract Method는 긴 함수에서 의미 있는 로직 블록을 빼내 자신의 이름을 붙인다. 그 이름이 인라인 문서 역할을 한다. 가장 자주 쓰는 리팩토링 기법이고, 함수가 20-25줄을 넘을 때 첫 선택지로 쓸 가치가 있다.
리팩토링하면 안 될 때는?
하드 마감 직전, 삭제할 코드, 작동하고 아무도 건드리지 않는 코드에서는 리팩토링하지 마라. 리팩토링은 변화를 가져오고 위험을 만들기 때문에, 명확한 이득이 있을 때만 가치 있다.
Branch-by-Abstraction이란?
Branch-by-Abstraction은 교체할 코드 주위에 인터페이스 레이어를 도입하고, 옛 구현과 새 구현을 모두 그 인터페이스가 만족하게 한다. 새 버전을 기능 플래그 뒤에 배포하고 양쪽을 병렬로 돌린 뒤, 안정화되면 옛 것을 지움으로써 위험한 일괄 교체를 피한다.
가드 클로즈는 조건문을 어떻게 정리하는가?
가드 클로즈는 중첩된 if-else 블록을 함수 맨 위의 조기 리턴으로 바꾼다. 각 조건이 만족 안 되면 즉시 나가고, 들여쓰기 피라미드를 제거하고, 행복한 경로를 한눈에 읽게 한다.
리팩토링 전에 테스트가 필요한가?
필요하다. Characterization 테스트는 건드리려는 코드의 현재 행동을 포착한다. 테스트 없이는 리팩토링이 추측일 뿐이고, 뭔가를 깨뜨렸는데 프로덕션까지 모를 수도 있다. 테스트부터, 작은 커밋으로 리팩토링한다.
Move Method 리팩토링 기법이란?
Move Method는 함수를 자신이 가장 많이 의존하는 클래스로 옮긴다. 함수가 다른 클래스의 데이터를 자기 클래스보다 더 많이 쓰면, 그건 잘못된 위치의 신호다. 대부분의 IDE가 메커니즘 이동과 레퍼런스 갱신을 자동으로 처리한다.