리팩터링 실전 강의 교재¶
3장 — 코드에서 나는 악취¶
대상: Java/Spring 백엔드 입문~중급 수강생 형식: 개념 설명 → 비유 → 현업 예제 → 함정 → 체크리스트 → 퀴즈 전제 환경: Java 17+, Spring Boot 3.x
0. 이 장을 시작하기 전에¶
0.1 학습 목표¶
- "언제 리팩터링해야 하는가" 라는 질문에 24가지 진단 사전으로 답한다.
- 각 악취가 어떤 리팩터링 기법(6~12장)으로 처방되는지 한 줄로 연결한다.
- 코드 리뷰에서 "이 부분이 X 악취입니다" 라고 이름으로 합의 가능해진다.
0.2 큰 그림 — 24 악취를 6개 묶음으로¶
24개를 통째로 외우면 부담이지만, 6개 묶음으로 보면 외울 게 없습니다.
[ 이름·중복 ] [ 크기·분산 ] [ 데이터 ]
1 기이한 이름 3 긴 함수 5 전역 데이터
2 중복 코드 4 긴 매개변수 목록 6 가변 데이터
24 주석(대체) 20 거대한 클래스 10 데이터 뭉치
11 기본형 집착
16 임시 필드
22 데이터 클래스
[ 결합·분산 ] [ 추상화 과부족 ] [ 상속·인터페이스 ]
7 뒤엉킨 변경 13 반복문 12 반복되는 switch
8 산탄총 수술 14 성의 없는 요소 21 대안 클래스
9 기능 편애 15 추측성 일반화 23 상속 포기
17 메시지 체인
18 중개자
19 내부자 거래
비유 — 악취는 "주방의 신호"입니다.
음식이 상한 게 아니라 "이 도구는 빼야겠다"는 신호. 24개의 신호를 알면, 사고가 나기 전에 미리 정리(리팩터링)할 수 있습니다.
0.3 현업에서 왜 중요한가¶
- 코드 리뷰가 "왠지 별로다" → "X 악취입니다" 로 정확해집니다. 합의가 빨라집니다.
- 24 악취는 CR 코멘트의 표준 어휘. 팀이 같은 이름을 쓰면 PR이 단순해집니다.
- 신입에게 "왜 이렇게 짜면 안 되는가" 를 설명하는 자산.
0.4 사용법¶
각 악취마다: - 한 줄 정의 + 신호(어떻게 알아보는가) - 비유 또는 짧은 예 - 처방(어떤 리팩터링 기법으로 풀까) - 현업 메모 (있을 때만)
묶음 A — 이름·중복¶
3.1 기이한 이름 (Mysterious Name)¶
정의: 함수·변수·클래스 이름이 의도를 못 드러내, 코드를 매번 읽어야 무슨 일을 하는지 알 수 있는 상태.
신호:
- process, handle, data, info, manager, helper, util 같은 무의미한 단어
- temp, tmp, x1, x2 같은 의미 없는 약자
- 클래스 이름과 실제 책임이 어긋남
비유: "회의실 A·B·C·D" — 들어가 봐야 어느 방인지 안다.
처방: - 함수 선언 바꾸기(6.5) — 함수 이름·시그니처를 동시에 - 변수 이름 바꾸기(6.7) — IDE Rename 단축키 1회 - 필드 이름 바꾸기(9.2)
현업 메모: 이름 변경은 PR 1개 단독으로 올리세요. 다른 변경과 섞이면 리뷰 부담이 폭증합니다.
3.2 중복 코드 (Duplicated Code)¶
정의: 같거나 거의 같은 코드가 두 곳 이상.
신호: - 한 곳 고치면 다른 곳도 같이 고쳐야 하는데, 빠뜨려 사고가 난다 - 비슷한 if 분기·비슷한 SQL·비슷한 DTO 변환
비유: "주방에 같은 레시피 카드가 5장" — 한 장 고치면 4장이 옛 레시피.
처방: - 같은 클래스 안 → 함수 추출하기(6.1) - 형제 클래스 → 메서드 올리기(12.1) - 무관한 클래스 → 클래스 추출하기(7.5) 또는 새 유틸 함수
3의 법칙: 두 번까지는 그냥 두고, 세 번째에 정리.
3.24 주석 (Comments)¶
3장의 마지막이지만 "이름·중복" 묶음의 연장이라 여기로 옮김.
정의: 코드가 의도를 못 드러내 주석으로 보충하는 상태.
신호: - 함수 위에 "이 함수는 X를 한다" 주석 (이름이 그걸 못 드러냄) - "TODO: 나중에 고치자" — 이름이 빨라야 함 - 코드 안에 "여기는 조심" — 그 위험을 함수로 캡슐화하지 않음
처방: - 함수 추출(6.1) + 함수 선언 바꾸기(6.5) — 주석이 설명하던 부분을 이름이 설명하는 함수로 - 어서션 추가하기(10.6) — "여기는 X가 참" 주석은 어서션으로
좋은 주석: "왜" 를 설명하는 주석은 유지 (정책·외부 제약·과거 사고 회피). "무엇" 을 설명하는 주석은 코드로 옮긴다.
묶음 B — 크기·분산¶
3.3 긴 함수 (Long Function)¶
정의: 한 함수가 너무 많은 일을 함.
신호: - 스크롤 없이 화면에 안 들어옴 - 함수 안에 큰 단락이 여럿 (빈 줄로 구분된 블록이 3개+) - 의도를 한 줄로 설명 못 함
비유: "한 페이지짜리 레시피" — 어디부터 봐야 할지 모름.
처방: - 함수 추출(6.1) — 의도가 다른 단락마다 - 같은 함수의 임시 변수가 많다 → 임시 변수를 질의 함수로(7.4) 후 추출 - 매개변수가 많아 추출이 어렵다 → 매개변수 객체 만들기(6.8) 먼저
경험칙: 함수가 6줄 넘으면 한 번 의심. 10줄 넘으면 거의 항상 추출 후보.
3.4 긴 매개변수 목록 (Long Parameter List)¶
정의: 함수의 매개변수가 4개 이상.
신호: - 호출부가 인자 의미를 외워야 함 — IDE 도움 없이는 못 부름 - 같은 매개변수 묶음이 여러 함수에 반복 → 데이터 뭉치(3.10)와 결합
처방: - 매개변수 객체 만들기(6.8) — 한 record/클래스로 묶기 - 객체 통째로 넘기기(11.4) — 호출부가 이미 그 객체를 갖고 있다면 - 질의 함수로 매개변수 바꾸기(11.5) — 다른 매개변수로 유도 가능하면 - 플래그 인수 제거하기(11.3) — boolean은 분리된 함수로
3.20 거대한 클래스 (Large Class)¶
정의: 한 클래스가 너무 많은 책임.
신호: - 필드가 20개+, 메서드 30개+ - 클래스 이름에 "Manager", "Service", "Helper" 가 붙음 - 일부 필드만 쓰는 메서드가 모여 "부분 클래스" 가 보임
처방: - 클래스 추출하기(7.5) — 함께 변하는 필드·메서드를 한 클래스로 떼냄 - 슈퍼클래스 추출하기(12.8) — 공통 부분을 부모로 - 서브클래스 추출(타입 코드를 서브클래스로 — 12.6)
현업: Spring Service가 1,000줄을 넘으면 거의 확실히 이 악취. 응집된 작은 서비스 여럿으로 나눠라.
묶음 C — 데이터¶
3.5 전역 데이터 (Global Data)¶
정의: 어디서든 변경 가능한 상태.
신호: static 가변 필드, 싱글턴의 가변 상태, 전역 설정 객체의 변경 가능 필드.
처방: - 변수 캡슐화하기(6.6) — getter/setter를 거치게 - 가능하면 불변화 (final + java.time + List.copyOf)
현업: Spring 빈의 필드는 사실상 전역 — 변경 가능하면 멀티스레드 사고 단골 (concept-keepalive-timeout-race 패턴과 같은 원리).
3.6 가변 데이터 (Mutable Data)¶
정의: 한 곳에서 값이 바뀌었는데, 다른 곳에서 옛 값으로 계산하는 사고.
처방: - 변수 캡슐화(6.6) — 모든 변경이 한 함수를 거치게 - 변수 쪼개기(9.1) — 한 변수가 여러 의미로 쓰이면 분리 - 파생 변수를 질의 함수로(9.3) — 저장 대신 매번 계산 - 참조를 값으로(9.4) — 가변 객체를 불변 값으로
3.10 데이터 뭉치 (Data Clumps)¶
정의: 늘 함께 다니는 데이터 묶음.
신호:
- 여러 클래스가 같은 3·4개 필드를 함께 가짐 (start/end, street/city/zip)
- 여러 함수의 매개변수가 같은 묶음
처방:
- 클래스 추출하기(7.5) — Period/Address 같은 값 객체로
- 매개변수 객체(6.8) — 함수 인자도 같이 정리
3.11 기본형 집착 (Primitive Obsession)¶
정의: String/int/long으로 모든 도메인을 표현.
신호:
- String userId, String email, int amountInCents — 다 같은 String/int
- 인자 순서를 바꿔도 컴파일 통과 (userId와 email이 String이라 자리 바뀜)
처방:
- 기본형을 객체로 바꾸기(7.3) — UserId/Email/Money 같은 record/값 객체
- 도메인 상수 → enum
Effective Java 연결: Item 62(다른 타입이 적절하면 문자열 회피).
3.16 임시 필드 (Temporary Field)¶
정의: 가끔만 채워지는 필드.
신호: - 어떤 메서드가 호출됐을 때만 의미가 있는 필드 - "이 필드가 null이면 X 동작을 안 했다" 같은 암묵 규칙
처방: - 클래스 추출(7.5) — 임시 필드들만 묶어 별도 클래스로 - 특이 케이스 추가하기(10.5) — 없을 때를 명시적 객체로
3.22 데이터 클래스 (Data Class)¶
정의: 행동 없이 데이터(필드 + getter/setter)만 갖는 클래스.
신호: getter/setter밖에 없고, 다른 클래스가 그 데이터로 행동을 만들어 씀 (= 기능 편애·3.9의 원인).
처방: - 데이터를 쓰는 행동을 함수 옮기기(8.1) 로 이 클래스 안으로 - 캡슐화 강화 — setter 제거(11.7), 불변화
예외: DTO·record처럼 의도된 데이터 컨테이너는 OK. 그러나 도메인 모델이 데이터 클래스이면 빈혈 모델 — 거의 항상 악취.
묶음 D — 결합·분산¶
3.7 뒤엉킨 변경 (Divergent Change)¶
정의: 한 곳을 바꾸려면 같은 클래스 안 여러 곳을 함께 바꿔야 함.
신호: 한 클래스가 "두 가지 이유로 변경됨" (Single Responsibility 위배).
처방: - 단계 쪼개기(6.11) — 흐름을 단계로 분리 - 함수 옮기기(8.1) + 클래스 추출(7.5) — 변경 이유별로 클래스 분리
3.8 산탄총 수술 (Shotgun Surgery)¶
정의: 한 변경을 위해 여러 클래스를 동시에 수정.
신호: "X 추가하려면 A·B·C·D·E 다 손대야 해요" — PR이 5+ 파일.
처방: - 함수 옮기기(8.1) / 필드 옮기기(8.2) — 흩어진 책임을 한 곳으로 - 여러 함수를 클래스로(6.9) — 같은 변경 이유의 함수를 한 클래스로 - 인라인(6.2·7.6) — 너무 작은 단위들이 흩어진 경우 통합 후 재분리
뒤엉킨 변경 vs 산탄총 수술: 한 클래스 안 vs 여러 클래스에 걸쳐. 처방 방향이 반대.
3.9 기능 편애 (Feature Envy)¶
정의: 함수가 자기 클래스 데이터보다 다른 클래스 데이터에 더 관심.
신호:
- 메서드 안에서 other.getA() + other.getB() * other.getC() 처럼 다른 객체 getter 연속 호출
- 자기 클래스 필드는 거의 안 씀
처방: - 함수 옮기기(8.1) — 그 데이터를 가진 클래스로 함수 이동 - 일부만 다른 클래스를 원하면 → 함수 추출(6.1) 후 옮기기
Tell, Don't Ask: 데이터를 꺼내 와 호출자가 처리하지 말고, 데이터를 가진 객체에게 시켜라.
3.17 메시지 체인 (Message Chains)¶
정의: a.getB().getC().getD().doIt() 같은 긴 호출 사슬.
신호: 한 줄에 점(.)이 4개+, 중간이 깨지면 어디서 났는지 추적 어려움.
처방:
- 위임 숨기기(7.7) — a.doIt() 처럼 한 단계로
- 함수 추출(6.1) + 함수 옮기기(8.1) — 사슬을 호출하는 동작 자체를 옮김
디미터 법칙: "한 점만 허용" 은 과도하지만, 호출 깊이가 깊을수록 결합도가 올라가는 건 사실.
3.18 중개자 (Middle Man)¶
정의: 위임만 하는 클래스. 메서드의 대부분이 다른 객체로 위임.
신호: getX() → inner.getX(), doY() → inner.doY() 가 80%+.
처방: - 중개자 제거하기(7.8) — 호출자가 내부 객체를 직접 부르게
위임 숨기기(7.7)의 정반대. 너무 위임 → 너무 노출 사이 균형.
3.19 내부자 거래 (Insider Trading)¶
정의: 클래스 간 비공식적인 사적 정보 공유 과다.
신호: - 다른 클래스의 private 영역을 friend·package로 들여다봄 - "이 둘은 한 패키지에 있어서 서로의 내부를 안다"
처방: - 함수 옮기기·필드 옮기기 — 책임을 합치거나 - 위임 숨기기 — 공식 인터페이스 통하게 - 같은 변경 이유라면 클래스 합치기
묶음 E — 추상화 과부족¶
3.13 반복문 (Loops)¶
정의: 명령형 반복 (for/while) 이 의도를 가림.
신호: 반복문 안에 if + 누적 변수 + break + continue 가 섞임.
처방:
- 반복문을 파이프라인으로 바꾸기(8.8) — stream().filter().map().collect()
- 반복문 쪼개기(8.7) — 한 반복문이 여러 일을 할 때
주의: 단순 반복은 그대로 두는 게 가독성 더 좋을 수도. Effective Java Item 45와 같은 맥락 — 가독성 1순위.
3.14 성의 없는 요소 (Lazy Element)¶
정의: 거의 비어 있는 클래스·함수.
신호: 단 한 곳에서만 호출되고 한 줄밖에 안 하는 함수, 메서드 1~2개뿐인 클래스.
처방: - 함수 인라인(6.2) / 클래스 인라인(7.6) - 계층 합치기(12.9) — 부모/자식이 거의 같으면
3.15 추측성 일반화 (Speculative Generality)¶
정의: "나중에 필요할까 봐" 만들어 둔 추상화·확장 포인트.
신호:
- 구현체가 하나뿐인 인터페이스
- 빈 추상 클래스
- 안 쓰이는 매개변수
- Foo<T> 같은 제네릭인데 항상 Foo<String> 으로만 쓰임
처방: - 클래스 인라인(7.6) / 함수 인라인(6.2) - 계층 합치기(12.9) - 매개변수 제거 (함수 선언 바꾸기 — 6.5)
YAGNI: You Aren't Gonna Need It. 필요해질 때 리팩터링으로 그때 추가.
묶음 F — 상속·인터페이스¶
3.12 반복되는 switch문 (Repeated Switches)¶
정의: 같은 분기 조건이 여러 곳에 반복.
신호: 같은 switch(type) 또는 if (type == X) 가 3+ 곳에서 동일 분기.
처방: - 조건부 로직을 다형성으로(10.4) — 타입별 클래스를 만들고 메서드 오버라이딩 - 또는 타입 코드를 서브클래스로(12.6)
1장에서 본 예: switch(play.type) 을 Performance 서브클래스(Tragedy/Comedy)로.
3.21 서로 다른 인터페이스의 대안 클래스들 (Alternative Classes with Different Interfaces)¶
정의: 같은 일을 하는데 시그니처가 다른 두 클래스.
신호: a.findUser(id) vs b.getUserById(id) — 사실상 같은 일.
처방: - 함수 선언 바꾸기(6.5) — 한쪽에 맞춰 시그니처 통일 - 슈퍼클래스 추출(12.8) — 공통 인터페이스 만들기
3.23 상속 포기 (Refused Bequest)¶
정의: 자식 클래스가 부모의 기능 일부를 무시·재정의해 의미를 없앰.
신호:
- @Override 메서드가 부모 동작과 무관 — 또는 throw new UnsupportedOperationException()
- 부모의 절반 메서드만 자식이 의미 있게 씀
처방: - 메서드/필드 내리기(12.4·12.5) — 부모에서 자식들만 쓰는 것을 내림 - 서브클래스를 위임으로 바꾸기(12.10) — is-a가 아니라 has-a 관계
LSP 위반의 신호 — Effective Java Item 18(상속 대신 컴포지션)와 직결.
24 악취 한눈에 — 처방 매트릭스¶
| 묶음 | 악취 | 주 처방 |
|---|---|---|
| 이름·중복 | 1·2·24 | 함수 추출·이름 바꾸기·함수 선언 바꾸기 |
| 크기·분산 | 3·4·20 | 함수 추출·매개변수 객체·클래스 추출 |
| 데이터 | 5·6·10·11·16·22 | 캡슐화·클래스 추출·기본형→객체·다형성 |
| 결합·분산 | 7·8·9·17·18·19 | 함수/필드 옮기기·단계 쪼개기·위임 숨기기/제거 |
| 추상화 | 13·14·15 | 파이프라인·인라인·YAGNI |
| 상속·인터페이스 | 12·21·23 | 조건부 로직→다형성·슈퍼클래스 추출·위임 변환 |
→ 거의 모든 악취가 6장(기본)·7장(캡슐화)·8장(이동) 의 카탈로그로 해결됩니다. 그래서 6·7·8장이 책의 무게 중심.
핵심 교훈¶
- 24 악취는 외울 게 아니라 알아보는 사전. 코드 보면서 "이건 X 악취" 라고 짚는 연습.
- 한 코드에 여러 악취가 동시에 있는 게 보통. 가장 큰 것부터.
- 악취 → 리팩터링 매핑은 결정 가이드. 항상 그대로 적용하는 규칙이 아님.
- CR 어휘로 쓰면 가성비가 폭증한다. "긴 함수입니다 → 함수 추출 검토" 한 줄.
- YAGNI — 14·15는 반대 방향 (너무 적게 / 너무 많이). 둘 다 악취.
함정 / 주의¶
- 악취가 있다고 항상 리팩터링하지 않는다. 곧 삭제될 코드·낮은 우선순위는 그대로 둘 수도.
- 24개를 다 적용하려 들면 산만. 한 PR에 한 악취.
- 개인 취향과 헷갈리지 마라 — 객관적 기준(테스트 가능성·결합도·변경 빈도) 으로 판단.
체크리스트 (코드 리뷰용 표준 어휘)¶
- 이름이 의도를 드러내는가 (1·24)
- 중복이 있는가 — 3번째인가 (2)
- 함수가 화면을 넘기는가 (3)
- 매개변수 4개+ 인가 (4)
- 가변·전역 상태가 멀티스레드 환경에 노출되는가 (5·6)
- String/int로 도메인 표현 (
userId/status등) (11) - 같은 switch가 여러 곳에 (12) — 다형성 후보
- 한 PR이 5+ 파일 (8 산탄총 수술)
- 다른 객체 getter를 줄줄이 부르는 함수 (9 기능 편애)
- 단일 구현 인터페이스·안 쓰이는 매개변수 (15 추측성 일반화)
- 자식이 부모 메서드 다 무시 (23 상속 포기)
퀴즈¶
Q1. "데이터 뭉치"와 "기본형 집착"의 관계?
A. 데이터 뭉치는 "같이 다니는 묶음"이고, 기본형 집착은 그 묶음이 String/int인 경우. 둘 다 클래스 추출(7.5) 로 풀린다. 데이터 뭉치 → 값 객체로 묶기 → 그 과정에서 String이 UserId record로 승격되면 기본형 집착도 함께 해결.
Q2. "뒤엉킨 변경"과 "산탄총 수술"의 방향 차이?
A. 뒤엉킨 변경 = 한 클래스가 여러 변경 이유를 가짐 → 클래스를 쪼개라. 산탄총 수술 = 한 변경 이유가 여러 클래스에 흩어짐 → 한 클래스로 모아라. 방향이 정반대지만 둘 다 SRP 위배라는 점에서 같다.
Q3. "기능 편애"가 OOP의 어떤 원칙과 가장 직결되나?
A. Tell, Don't Ask (묻지 말고 시켜라). 다른 객체 데이터를 꺼내 와 처리하는 대신, 그 객체에게 일을 시켜라. 오브젝트 6.02와 같은 메시지.
Q4. 같은 switch가 3+ 곳에 반복될 때, "그냥 함수로 추출"하면 안 되는 이유는?
A. 분기 분포(어떤 case가 어떤 동작)는 여전히 한 곳에 묶여 있어 새 case 추가가 여전히 산탄총 수술. 다형성으로 바꿔야 새 타입 = 새 클래스 1개 추가로 끝난다 (OCP).
Q5. "추측성 일반화"와 "데이터 클래스"가 다 악취인데, 모순 아닌가? 추상화는 좋은 거 아니었나?
A. 추상화 자체가 좋은 게 아니라 현재 필요에 맞는 추상화 가 좋다. 너무 적으면 데이터 클래스(행동 없음), 너무 많으면 추측성 일반화(쓸데없는 확장점). 현재 코드를 정확히 비추는 추상화 가 정답 — 이를 유지하기 위해 리팩터링이 있다.
다음 장 예고 — 4장: 테스트 구축하기¶
리팩터링의 안전망인 자가 테스트 코드 를 어떻게 만드는가. 첫 테스트·픽스처·경계 조건·"빨강-초록-리팩터" 의 한 단계 부족한 점까지. 이 장이 없으면 6장부터의 카탈로그는 위험천만한 도구.