콘텐츠로 이동

리팩터링 실전 강의 교재

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 - 인자 순서를 바꿔도 컴파일 통과 (userIdemail이 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장이 책의 무게 중심.


핵심 교훈

  1. 24 악취는 외울 게 아니라 알아보는 사전. 코드 보면서 "이건 X 악취" 라고 짚는 연습.
  2. 한 코드에 여러 악취가 동시에 있는 게 보통. 가장 큰 것부터.
  3. 악취 → 리팩터링 매핑은 결정 가이드. 항상 그대로 적용하는 규칙이 아님.
  4. CR 어휘로 쓰면 가성비가 폭증한다. "긴 함수입니다 → 함수 추출 검토" 한 줄.
  5. 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장부터의 카탈로그는 위험천만한 도구.