15장이 이미 좋은 코드를 다듬는 이야기였다면, 이 장은 실전에 가깝다. JCommon 라이브러리의 SerialDate(날짜를 다루는 자바 클래스)를 놓고 진짜 리팩터링을 수행한다. 순서가 중요하다 — 먼저 돌게 만들고(테스트), 그 다음 옳게 만든다(리팩터링). 이 장의 진짜 교훈은 개별 기법이 아니라 그 순서, 그리고 “체크아웃했을 때보다 깨끗하게 체크인한다”는 보이스카우트 규칙을 남의 코드에도 똑같이 적용한다는 태도다.
먼저 돌게 만든다 — 테스트가 안전망이다
리팩터링 전에 테스트 커버리지부터 확인했다. 기존 테스트는 전체 실행 가능 문장의 절반 정도만 검증하고 있었다. 저자는 경계 조건과 실패 케이스를 포함한 테스트를 대거 보강했고, 그 과정에서 실제 버그들이 드러났다. 커버리지가 충분해진 뒤에야 구조에 손을 대기 시작한다.
테스트를 짜다 보면 코드가 “우연히 도는” 지점이 보인다. 그 지점들이 리팩터링에서 가장 먼저 다뤄야 할 위험 지대다. 커버리지를 늘리는 것 자체가 리팩터링을 시작하기 전의 정찰이다.
// 경계 조건을 파고드는 테스트 — 윤년 2월 29일, 월말 계산
@Test
public void lastDayOfFebruary() {
assertEquals(29, SerialDate.lastDayOfMonth(FEBRUARY, 2000)); // 400의 배수
assertEquals(28, SerialDate.lastDayOfMonth(FEBRUARY, 1900)); // 100의 배수, 윤년 아님
assertEquals(29, SerialDate.lastDayOfMonth(FEBRUARY, 2004)); // 4의 배수
}판단 기준: 리팩터링 착수 여부는 “지금 테스트가 내 변경을 잡아낼 수 있는가”로 정한다. 그렇지 않다면 첫 작업은 코드 수정이 아니라 테스트 보강이다. 함정: 커버리지 숫자만 높이려 자명한 getter까지 테스트하는 건 낭비다. 경계와 분기, 실패 경로에 테스트를 집중한다.
그 다음 옳게 만든다
보강된 테스트를 안전망 삼아 변경을 작은 단위로 쌓아 간다. 각 변경 뒤에 테스트를 돌려 초록불을 확인하고 다음으로 넘어간다.
주석 정리
법적 정보처럼 반드시 남겨야 하는 주석은 유지한다. 반대로 변경 이력, 주석 처리된 죽은 코드처럼 소스 관리 도구가 대신 기억해 주는 정보는 지운다.
// before — 소스 관리가 기억할 정보를 주석으로 남김
// 1.0: 최초 작성 (2001-05-11)
// 1.1: getDescription() 추가 (2001-05-30)
// int oldSerial = this.serial; // 예전 계산 방식, 혹시 몰라 남겨둠
public abstract class SerialDate implements MonthConstants { ... }
// after — 이력·죽은 코드 제거, 저작권 고지만 유지
/* 저작권 (C) 2001-2005, Object Refinery Limited. */
public abstract class SerialDate implements MonthConstants { ... }판단 기준: “이 주석이 사라지면 git이 대신 답해 주는가?”가 기준이다. 그렇다면 지운다. 함정: 죽은 코드를 “혹시 몰라” 남기면, 읽는 사람마다 그게 살아 있는 코드인지 판단하는 비용을 문다.
이름이 곧 설계다
SerialDate라는 이름은 구현(내부적으로 일련번호로 날짜를 표현한다는 사실)을 노출한다. 추상 클래스는 그 표현 방식을 몰라야 마땅하다. 추상화 수준에 맞는 DayDate로 바꾼다. 또한 MonthConstants를 상속해 JANUARY, FEBRUARY 같은 상수를 끌어 쓰던 구조는 상수 상속(constant interface)이라는 오래된 꼼수다. 이를 Month enum으로 교체하면 타입 안전성이 생겨, lastDayOfMonth(13, year) 같은 잘못된 호출이 컴파일 단계에서 막힌다.
판단 기준: 이름이 “무엇을 하는가(추상)“가 아니라 “어떻게 구현했는가(표현)“를 말하면 새는 이름이다. SerialDate는 후자였다. 함정: 상수를 상속으로 끌어 쓰면 편하지만, 그 상수는 타입이 아니라 그냥 int라서 오용을 막지 못한다. enum이 그 구멍을 닫는다.
추상 클래스에서 구현을 밀어내기
가장 핵심적인 변경이다. 상위 추상 클래스 SerialDate가 정적 팩토리 메서드 안에서 하위 구현 SpreadsheetDate를 직접 new로 생성하고 있었다. 상위가 하위를 아는 것 — 의존 방향이 거꾸로 선 것이다. 이를 ABSTRACT FACTORY로 분리해, 상위는 팩토리 인터페이스에만 의존하고 구체 클래스는 팩토리 구현 뒤로 숨긴다.
판단 기준: “기초 클래스는 파생 클래스를 몰라야 한다”는 17장 G7 냄새의 실사례다. 상위 클래스 코드에 하위 클래스 이름이 등장하면 그 자리가 팩토리를 넣을 자리다. 함정: 팩토리를 도입하면 클래스 수가 늘어 처음엔 복잡해 보인다. 하지만 이 분리 덕에 새 날짜 표현(예: RelativeDate)을 상위 클래스 수정 없이 끼워 넣을 수 있게 된다.
중복과 죽은 코드 제거
안 쓰는 변수, 도달 불가능한 분기, 두 번 계산하는 로직을 지운다. 테스트가 각 삭제를 지켜 준다.
스텝으로 따라가기 — SerialDate에서 DayDate로
위에서 기법을 하나씩 설명했다. 이제 핵심 골격 하나를 축약판으로 놓고, 세 걸음이 클래스 전체를 어떻게 움직이는지 스냅샷으로 되짚는다. Next를 누를 때마다 이전 단계 대비 바뀐 줄이 강조된다.
public abstract class SerialDate implements MonthConstants { public static final int MINIMUM_YEAR_SUPPORTED = 1900; public static final int[] LAST_DAY_OF_MONTH = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; public abstract int toSerial(); // 상위 클래스가 하위 구현(SpreadsheetDate)을 직접 생성한다 public static SerialDate createInstance(int day, int month, int year) { return new SpreadsheetDate(day, month, year); } public static boolean isLeapYear(int year) { if ((year % 4) != 0) return false; else if ((year % 400) == 0) return true; else if ((year % 100) == 0) return false; else return true; } // MonthConstants 상속으로 FEBRUARY 같은 int 상수를 끌어 쓴다 public static int lastDayOfMonth(int month, int year) { int result = LAST_DAY_OF_MONTH[month]; if (month != FEBRUARY) return result; else if (isLeapYear(year)) return result + 1; else return result; }}
세 걸음을 관통하는 방향이 있다. 1단계에서 상수와 로직이 뒤엉키고 상위가 하위를 알던 덩어리가, 2단계에서 이름과 타입을 얻고, 3단계에서 의존 방향을 바로잡으며 구체 클래스가 팩토리 뒤로 사라진다. 어느 걸음도 겉보기 동작을 바꾸지 않는다 — 그래서 매 걸음마다 테스트가 초록불이어야 한다.
정리
- 리팩터링의 첫 단계는 코드 수정이 아니라 테스트 확보다. 커버리지 없는 리팩터링은 회귀를 심는 일이다.
- 이름 변경은 사소해 보이지만 추상화 수준을 바로잡는 설계 행위다.
SerialDate → DayDate는 구현을 노출하던 이름을 의도를 말하는 이름으로 되돌렸다. - 상수 상속을 enum으로 바꾸면 편의를 잃는 대신 타입 안전성을 얻는다. 대개 그 거래는 남는 장사다.
- 상위 클래스에 하위 클래스 이름이 등장하면 팩토리를 넣을 자리다. 의존 방향을 바로잡으면 확장이 상위 수정 없이 가능해진다.
- 오픈소스든 사내 코드든 원리는 같다: 체크아웃했을 때보다 깨끗하게 체크인한다. 저자조차 이 과정에서 실수했고, 보강한 테스트가 그 실수를 잡아냈다.
다음장으로 17장