2장이 “언제 리팩터링하는가”에 원칙으로 답했다면, 3장은 감각으로 답한다. 켄트 벡과 함께 정리한 24가지 악취(smell) 목록이다. “지표가 몇을 넘으면 리팩터링하라” 같은 정량 규칙 대신, 코드를 읽다 “뭔가 이상한데” 싶은 직관에 이름을 붙여준다. 규칙은 상황마다 예외가 생기지만 감각은 오래간다는 게 파울러의 생각이다. 각 악취에는 처방 — 어떤 리팩터링 기법으로 고치는지 — 이 연결돼 있어, 냄새를 맡으면 6~12장 카탈로그로 찾아가면 된다.
악취 24종 한눈에 보기
성격이 비슷한 것끼리 묶으면 이렇게 정리된다.
| 그룹 | 악취 | 핵심 신호 |
|---|---|---|
| 이름·주석 | 기이한 이름, 주석 | 이름이 역할을 못 알리거나, 주석이 나쁜 코드의 변명 |
| 비대함 | 긴 함수, 큰 클래스, 긴 매개변수 목록, 데이터 뭉치 | 한 덩어리가 너무 많은 걸 안다 |
| 중복·결합 | 중복 코드, 뒤엉킨 변경, 산탄총 수술 | 한 번 고칠 걸 여러 번 고쳐야 함 |
| 책임 오배치 | 기능 편애, 내부자 거래, 메시지 체인, 중개자 | 로직·데이터가 엉뚱한 곳에 있음 |
| 데이터 모델링 | 기본형 집착, 반복되는 switch, 성의 없는 요소, 임시 필드 | 도메인 개념이 코드로 표현되지 않음 |
| 변경 취약성 | 전역 데이터, 가변 데이터, 뒤엉킨 변경 | 아무 데서나 바뀌어 추적 불가 |
| 상속·확장 | 상속 포기, 서로 다른 인터페이스의 대안 클래스들, 추측성 일반화 | 상속·추상화를 잘못 씀 |
목록을 통째로 외울 필요는 없다. 아래 대표 악취들만 손에 익히면 나머지는 “책임이 잘못된 곳에 있다”의 변주로 자연스럽게 읽힌다.
이 책의 진짜 힘은 악취마다 처방 기법이 짝지어져 있다는 것이다. 24종을 다 늘어놓으면 스파게티가 되니, 대표 악취 몇 개와 그 첫 처방만 이어 보면 “냄새 → 카탈로그” 반사가 어떻게 작동하는지 감이 온다.
flowchart LR n1[기이한 이름] --> f1[이름 바꾸기] n2[긴 함수] --> f2[함수 추출하기] n3[중복 코드] --> f2 n4[긴 매개변수 목록] --> f3[매개변수 객체 만들기] n5[데이터 뭉치] --> f4[클래스 추출하기] n6[기능 편애] --> f5[함수 옮기기] n7["반복되는 switch"] --> f6[다형성으로 바꾸기]
같은 처방(함수 추출하기)이 여러 악취의 첫 대응이 된다는 점도 눈에 띈다 — 기본기 하나가 넓게 쓰인다는 신호다.
기이한 이름 (Mysterious Name)
제일 흔하고 제일 싸게 고치는 악취다. 이름이 하는 일을 알리지 못하면 읽는 사람은 매번 본문을 뜯어봐야 한다. 함수 선언 바꾸기, 변수 이름 바꾸기, 필드 이름 바꾸기로 처방한다.
// before
function getThem() {
const list = []
for (const c of theCells) if (c.v === 2) list.push(c)
return list
}
// after — 이름만 바꿔도 의도가 드러난다
function flaggedCells() {
const flagged = []
for (const cell of gameBoard) if (cell.status === FLAGGED) flagged.push(cell)
return flagged
}파울러는 이름을 잘 못 짓겠다는 것 자체를 더 깊은 설계 문제의 신호로 본다. 좋은 이름이 안 떠오르면, 그 개념이 아직 잘못 쪼개져 있다는 뜻일 때가 많다.
중복 코드 (Duplicated Code)
같은 구조가 여러 곳에 있으면, 하나를 고칠 때 나머지를 빠뜨리기 쉽다. “겉모습이 같은 코드를 보면 미묘한 차이가 있는지 늘 살펴야 하는” 인지 비용도 든다. 함수 추출하기로 하나로 모으고, 조각이 흩어져 있으면 문장 슬라이드하기로 먼저 붙인 뒤 추출한다. 형제 클래스에 중복이 있으면 메서드 올리기로 상위로 끌어올린다.
// before — 두 함수에 같은 계산이 복붙됨
function priceOfBook(order) {
return order.qty * 8 - Math.max(0, order.qty - 500) * 8 * 0.05
}
function priceOfPen(order) {
return order.qty * 3 - Math.max(0, order.qty - 500) * 3 * 0.05
}
// after — 공통 계산을 추출
function discountedPrice(order, unitPrice) {
return order.qty * unitPrice - Math.max(0, order.qty - 500) * unitPrice * 0.05
}긴 함수 (Long Function)
파울러가 경험상 가장 큰 효과를 본 악취다. 오래 사는 코드일수록 “짧은 함수 + 좋은 이름”의 조합이 강력하다. 함수가 짧아지면 이름이 곧 주석 역할을 해, 본문을 안 읽어도 흐름이 읽힌다. 처방의 99%는 함수 추출하기다.
파울러의 유명한 지침 — “주석을 달고 싶은 충동이 들면, 그 부분을 함수로 추출하고 주석 내용을 함수 이름으로 삼아라.” 조건문은 조건문 분해하기로, 반복문은 각 반복을 별도 목적으로 나누는 반복문 쪼개기 후 추출한다.
// before
function printOwing(invoice) {
let outstanding = 0
console.log("****** 고객 채무 ******") // 헤더
for (const o of invoice.orders) outstanding += o.amount // 미해결 채무 계산
const dueDate = new Date() // 마감일 계산
dueDate.setDate(dueDate.getDate() + 30)
console.log(`고객명: ${invoice.customer}, 채무액: ${outstanding}, 마감일: ${dueDate}`)
}
// after — 주석이 함수 이름이 되었다
function printOwing(invoice) {
printBanner()
const outstanding = calculateOutstanding(invoice)
const dueDate = calculateDueDate()
printDetails(invoice, outstanding, dueDate)
}긴 매개변수 목록 (Long Parameter List)
매개변수가 많으면 그 자체로 헷갈리고, 종종 함수가 너무 많은 일을 한다는 신호다. 다른 매개변수로부터 구할 수 있는 값은 매개변수를 질의 함수로 바꾸기로 없애고, 늘 함께 다니는 값들은 매개변수 객체 만들기로 묶는다. 한 객체에서 여러 값을 뽑아 넘기고 있다면 객체 통째로 넘기기를, 여러 함수가 같은 인자 뭉치를 공유하면 여러 함수를 클래스로 묶기를 쓴다.
// before
function priceRange(start, end, min, max, currency) {
/* ... */
}
// after — 늘 붙어 다니는 값을 객체로
function priceRange(dateRange, priceBand) {
/* ... */
}뒤엉킨 변경 vs 산탄총 수술
방향이 정반대인 한 쌍이라 함께 봐야 이해가 된다. 둘 다 “변경 이유(축)와 코드 구조가 어긋나 있다”는 문제다.
- 뒤엉킨 변경(Divergent Change): 한 모듈이 서로 다른 여러 이유로 자꾸 바뀐다. “DB를 바꿔도 여기, 금융 상품을 추가해도 여기를 고친다”면 맥락이 하나로 뭉쳐 있는 것. 단계 쪼개기나 클래스 추출하기로 맥락별로 갈라놓는다.
- 산탄총 수술(Shotgun Surgery): 반대로, 하나의 변경을 하려는데 여러 모듈을 조금씩 다 손대야 한다. 흩어진 로직을 함수 옮기기·필드 옮기기로 한곳에 모으거나 여러 함수를 클래스로 묶기로 응집시킨다.
한 문장으로 — 뒤엉킨 변경은 “하나가 여러 이유로 바뀐다(쪼개라)”, 산탄총 수술은 “한 이유로 여럿이 바뀐다(모아라)“.
기능 편애 (Feature Envy)
어떤 함수가 자기가 속한 모듈의 데이터보다 남의 모듈 데이터와 더 많이 대화한다면, 그 함수는 엉뚱한 곳에 살고 있다. 데이터 가까이로 이사 보내면 된다 — 함수 옮기기. 함수 일부만 그렇다면 그 부분을 함수 추출한 뒤 옮긴다.
// before — total은 order의 속내를 계속 캐묻는다
function total(order) {
return order.basePrice + order.taxRate * order.basePrice - order.discount
}
// after — 계산을 order 자신에게 맡긴다
class Order {
get total() {
return this.basePrice + this.taxRate * this.basePrice - this.discount
}
}원칙은 “함께 변하는 것은 함께 둔다”. 데이터와 그 데이터를 만지는 행위를 한곳에 모으는 게 핵심이다.
데이터 뭉치 (Data Clumps)
늘 몰려다니는 데이터 조각들이 있다. 시작일과 종료일, 혹은 좌표의 x·y·z. 여러 함수 시그니처에 같은 세 값이 반복 등장한다면 이들은 사실 하나의 객체가 되고 싶은 것이다. 클래스 추출하기로 묶고 매개변수 객체 만들기로 넘긴다. 값 하나를 지워봤을 때 나머지가 의미를 잃는다면, 그건 확실한 데이터 뭉치다.
// before
function distance(x1, y1, x2, y2) {
/* ... */
}
// after
class Point {
constructor(x, y) {
this.x = x
this.y = y
}
}
function distance(p1, p2) {
/* ... */
}기본형 집착 (Primitive Obsession)
전화번호를 그냥 문자열로, 돈을 그냥 숫자로 계속 들고 다니는 습관이다. 그러면 포맷팅·검증·통화 계산 같은 도메인 로직이 여기저기 흩어진다. 작은 **값 객체(Value Object)**를 만들어 개념을 코드로 표현한다 — 기본형을 객체로 바꾸기. 조건문이 타입 코드에 따라 갈린다면 타입 코드를 서브클래스로 바꾸기까지 이어진다.
// before — 돈이 그냥 숫자라, 통화가 코드 어디에도 없다
const price = 1500
// after — Money가 금액·통화·포맷을 스스로 안다
class Money {
constructor(amount, currency) {
this.amount = amount
this.currency = currency
}
toString() {
return new Intl.NumberFormat("ko-KR", { style: "currency", currency: this.currency }).format(
this.amount,
)
}
}TypeScript로 도메인을 짤 때 나도 이 악취에 자주 걸린다. string으로 다 넘기다 보면 어느새 검증 로직이 열 군데에 복사돼 있다.
반복되는 switch (Repeated Switches)
같은 조건 분기(switch나 if/else 사슬)가 코드 곳곳에 똑같이 나타나면, 새 case를 추가할 때마다 모든 분기를 찾아 고쳐야 한다 — 산탄총 수술의 씨앗이다. 분기를 클래스 계층으로 옮기고 조건부 로직을 다형성으로 바꾸기로 처방한다. 1장 마지막 단계에서 장르별 계산기 클래스로 바꾼 것이 바로 이 처방이었다.
// before — 이 switch가 여러 함수에 반복된다
function bird(b) {
switch (b.type) {
case "유럽제비":
return "보통이다"
case "아프리카제비":
return b.coconuts > 2 ? "지쳤다" : "보통이다"
case "노르웨이파랑앵무":
return b.voltage > 100 ? "그을렸다" : "예쁘다"
}
}
// after — 각 타입이 자기 행동을 안다
class EuropeanSwallow {
get plumage() {
return "보통이다"
}
}
class AfricanSwallow {
get plumage() {
return this.coconuts > 2 ? "지쳤다" : "보통이다"
}
}
class NorwegianBlue {
get plumage() {
return this.voltage > 100 ? "그을렸다" : "예쁘다"
}
}나머지 악취들 (빠르게)
- 전역 데이터 / 가변 데이터: 아무 데서나 바뀔 수 있는 데이터는 버그 추적을 지옥으로 만든다. 변수 캡슐화하기가 최소한의 방어선이고, 변수 쪼개기·질의 함수와 변경 함수 분리하기로 변경 지점을 좁힌다.
- 성의 없는 요소(Lazy Element): 실속 없이 존재만 하는 함수나 클래스. 함수 인라인·클래스 인라인으로 없앤다.
- 추측성 일반화: 언젠가 쓸까 봐 미리 넣은 유연성. YAGNI 위반이다. 안 쓰이는 훅과 추상은 걷어낸다.
- 임시 필드: 특정 상황에서만 값이 채워지는 필드. 클래스 추출하기로 그 필드들만의 집을 지어준다.
- 메시지 체인 / 중개자:
a.b().c().d()처럼 객체 사슬을 타고 들어가면 결합이 강해진다(위임 숨기기). 반대로 위임만 하는 껍데기 클래스는 중개자 제거하기. - 내부자 거래: 모듈끼리 뒷문으로 지나치게 데이터를 주고받음. 경계를 다시 긋는다.
- 주석: 주석 자체는 악취가 아니다. 다만 나쁜 코드를 변명하려고 붙인 주석은 탈취제일 뿐 — 그 주석이 가리키는 코드를 리팩터링하면 대개 주석이 필요 없어진다.
정리
- 악취 목록은 암기가 아니라 감각 훈련용이다. 리뷰 때 “뭔가 이상한데”에 정확한 이름을 붙여주는 사전으로 쓴다.
- 24종은 결국 몇 개의 큰 축으로 수렴한다 — 이름이 나쁘거나, 덩어리가 너무 크거나, 중복·결합이 있거나, 책임이 엉뚱한 곳에 있거나, 도메인 개념이 코드로 표현되지 않았거나.
- 이 책의 진짜 힘은 악취마다 처방(기법)이 짝지어져 있다는 것이다. 냄새를 맡는 즉시 6~12장 카탈로그로 넘어가 처방을 찾는다.
- 뒤엉킨 변경(쪼개라)과 산탄총 수술(모아라)은 방향이 반대인 한 쌍으로 함께 기억하면 잊히지 않는다.
- 클린 코드 17장과 겹치지만, 이쪽은 “그래서 어떤 기법으로 고치는가”까지 못박아준다는 점에서 처방전이 더 구체적이다.