D-1: 코드 스멜·리팩토링 기법·테스트 안전망 — 나쁜 코드를 멈추지 않고 좋은 코드로
목차 29
안녕하세요, 홍순구 튜터입니다. 지난 시간 우리는 카테고리 C를 닫으며 행위 패턴을 봤어요. 거대한 if/switch를 전략으로 갈아 끼우고, 메서드마다 중복되던 상태 분기를 상태 객체로 나눴죠. 그때 우리가 한 일에 사실 이름이 있었습니다. 바로 오늘의 주제, 리팩토링(refactoring)이에요.
지금까지 A·B·C를 걸어오며 "좋은 코드란 무엇인가"의 목표 지점을 봤습니다. 의도가 드러나는 이름(A), 응집도 높은 설계(B), 검증된 패턴(C)까지요. 그런데 현실의 코드는 처음부터 그 지점에 있지 않아요. 이미 지저분하게 짜여 돌아가고 있고, 우리는 그걸 멈추지 않고 좋은 코드로 옮겨야 합니다. 카테고리 D, 리팩토링이 다루는 게 정확히 그 일이에요.
카테고리 D — 리팩토링, 나쁜 코드를 좋은 코드로
A·B·C "좋은 코드란 무엇인가" (지난 시간들 — 목표 지점)
D-1 "나쁜 코드를 그 지점으로 옮기기" ← 오늘 여기
냄새 맡기 → 안전망 치기 → 작은 걸음으로 고치기
D-2 "레거시·코드 리뷰·아키텍처" (다음 시간)
오늘 가장 무서운 코드 리뷰 지적은 이거예요. "이거 건드리면 터질 것 같아서 못 고치겠는데요." 코드가 지저분한 걸 알면서도, 고쳤다가 어디가 깨질지 몰라 손을 못 대는 상황. 오늘 우리는 그 두려움을 없애는 법을 배웁니다. 냄새를 알아채고, 안전망을 치고, 작은 걸음으로 고치는 한 바퀴를요.
💡 오늘 수업의 핵심 — "냄새로 나쁜 코드를 알아채고, 테스트로 안전망을 친 뒤, 행동을 바꾸지 않으면서 작은 걸음으로 구조만 고친다 — 그리고 언제 멈출지까지 안다"
🎯 학습 목표
- 코드 스멜(code smell) 여섯 가지를 알아채고, 냄새가 "버그"가 아니라 "어디를 볼지 알려주는 단서"임을 이해한다.
- 리팩토링이 "행동을 바꾸지 않고 구조만 고치는" 작업임을 알고, 특성화 테스트(characterization test)로 안전망을 친 뒤 고치는 흐름을 익힌다.
- 핵심 기법(함수 추출·조건문 분해·매개변수 객체·다형성으로 조건문 교체)을 손에 넣고, 보이스카웃 규칙으로 언제 리팩토링하고 언제 멈출지 판단한다.
Step 1: "뭔가 구린데 콕 집어 말 못 하겠어요" — 코드 스멜이란
리팩토링의 출발은 "고치기"가 아니라 "알아채기"입니다. 어디가 나쁜지 모르면 고칠 수도 없으니까요. 그런데 나쁜 코드는 컴파일 에러처럼 빨간 줄로 알려 주지 않아요. 멀쩡히 잘 돌아갑니다. 그래서 우리에겐 "냄새를 맡는" 감각이 필요해요.
코드 스멜(code smell)이라는 말은 켄트 벡이 처음 썼고 마틴 파울러가 널리 퍼뜨린 표현입니다. 음식이 상하면 보기 전에 냄새부터 나죠? 코드도 비슷해요. 어딘가 구조가 잘못되면, 버그로 터지기 전에 먼저 "냄새"가 납니다. 중요한 건 이거예요. 냄새는 진단이 아니라 단서입니다. 냄새가 난다고 무조건 상한 건 아니에요. 다만 "여기 한번 들여다봐"라고 코를 끌어당기는 신호죠.
가장 흔하게 맡게 되는 냄새 셋부터 봅시다. 첫째는 중복 코드(Duplicated Code)예요.
// 같은 부가세 식이 세 메서드에 복붙돼 있다 (refactoring.smells.DuplicatedCode)
public class DuplicatedCode {
public long priceWithVat(long price) {
return Math.round(price * 1.1);
}
public long shippingWithVat(long shipping) {
return Math.round(shipping * 1.1);
}
public long giftWrapWithVat(long giftWrap) {
return Math.round(giftWrap * 1.1);
}
}
price * 1.1이라는 같은 식이 세 곳에 그대로 복사돼 있어요. 지금은 잘 돌아갑니다. 그런데 부가세가 10%에서 12%로 바뀌는 순간, 세 곳을 똑같이 고쳐야 해요. 한 곳만 빠뜨리면? 그때부터 버그입니다. 중복은 "변경 지점이 여러 개"라서 위험해요.
둘째는 긴 함수(Long Method)입니다.
// 한 메서드가 합계·할인·배송비·문자열 조립을 다 한다 (refactoring.smells.LongMethod)
public String checkout(int[] prices, String grade) {
int subtotal = 0;
for (int price : prices) {
subtotal += price;
}
int discount = 0;
if (grade.equals("VIP")) {
discount = subtotal / 10;
}
int discounted = subtotal - discount;
int shipping = discounted >= 30000 ? 0 : 3000;
int total = discounted + shipping;
return "합계 " + subtotal + "원, 할인 " + discount
+ "원, 배송비 " + shipping + "원, 최종 " + total + "원";
}
줄 수가 길다는 것 자체가 죄는 아니에요. 진짜 문제는 한 메서드 안에 "합계 계산"과 "할인 계산"과 "문자열 조립"이라는 서로 다른 추상화 수준의 일이 섞여 있다는 거예요. 어디까지가 한 가지 일인지 눈으로 잘라 가며 읽어야 합니다.
셋째는 큰 클래스(Large Class)예요. 한 클래스가 신원·인증·알림·결제처럼 서로 다른 이유로 바뀌는 필드를 다 들고 있는 경우죠. 알림 설정 하나 고치러 들어갔는데 결제 카드 필드 옆을 지나야 한다면, 그 클래스는 너무 많은 걸 알고 있는 거예요. B-1에서 본 응집도가 낮다는 신호고, 결국 책임별로 쪼개야 합니다.
⚠️ 언제 깨나 — 냄새가 난다고 무조건 고치는 게 아닙니다. 한 번만 쓰이고 사라질 코드의 작은 중복, 곧 지워질 프로토타입의 긴 함수까지 다 잡으려 들면 시간만 낭비해요. 냄새는 "여기 변경이 자주 일어나면 위험하다"는 경고지, 그 자체로 고쳐야 할 의무는 아닙니다. 변경이 잦은 곳의 냄새부터 코를 들이대세요.
💡 한 줄 정리
코드 스멜은 "버그가 되기 전의 구조적 경고"다. 중복 코드·긴 함수·큰 클래스가 가장 흔하며, 냄새는 처방이 아니라 "어디를 들여다볼지" 알려 주는 단서다.
🙋 학생 질문 — "튜터님, 동작도 잘 되는데 왜 굳이 냄새를 고쳐야 하나요?"
좋은 질문이에요. 핵심은 "지금"이 아니라 "다음에 고칠 때"입니다.
실무 코드는 한 번 짜고 끝나는 게 아니라 몇 년을 두고 계속 바뀌어요. 부가세율이 바뀌고, 등급이 추가되고, 화면이 달라집니다. 그때 냄새나는 코드는 "고치기 어려운 코드"가 돼요. 중복이 다섯 군데면 다섯 곳을 다 찾아 고쳐야 하고, 한 곳만 놓치면 버그죠. 긴 함수는 어디를 건드려야 할지 한참 읽어야 하고요.
그래서 냄새를 고치는 건 "지금 동작"을 위한 게 아니라 "다음 변경을 쉽게 하기 위한 투자"예요. 반대로, 앞으로 거의 안 바뀔 코드라면 냄새가 나도 그냥 둬도 됩니다. 그 판단이 바로 오늘 마지막 Step에서 다룰 내용이에요.
Step 2: "하나 고치려는데 열 군데를 고쳐야 해요" — 덜 보이는 스멜 셋
Step 1의 냄새 셋은 한눈에 보입니다. 코드가 길거나, 똑같이 생긴 게 여러 개거나요. 그런데 더 위험한 냄새는 "관계"에서 나서 잘 안 보여요. 한 줄만 봐선 멀쩡한데, 코드들 사이의 관계가 잘못된 경우죠. 셋을 봅시다.
첫째는 기능 욕심(Feature Envy)입니다.
// format 은 자기 필드는 안 쓰고 Address 데이터만 꺼내 쓴다 (refactoring.smells.FeatureEnvy)
public record Address(String zipcode, String city, String street) {
}
public String format(Address address) {
return address.zipcode() + " " + address.city() + " " + address.street();
}
format 메서드는 자기 클래스의 필드는 하나도 안 쓰고, Address의 데이터만 줄줄이 꺼내 쓰고 있어요. 이 메서드는 사실 Address가 할 일을 부러워하고 있는 거예요. A-3에서 배운 "데이터를 꺼내 와서 처리하지 말고, 데이터를 가진 쪽에 시켜라(Tell, Don't Ask)"가 떠오르시죠? 이 format은 Address 안으로 옮기는 게 맞습니다.
둘째는 데이터 뭉치(Data Clumps)예요.
// (시작 X·Y), (끝 X·Y)가 늘 함께 몰려다닌다 (refactoring.smells.DataClumps)
public double distance(int startX, int startY, int endX, int endY) {
int dx = endX - startX;
int dy = endY - startY;
return Math.sqrt((double) (dx * dx + dy * dy));
}
public boolean isSamePoint(int startX, int startY, int endX, int endY) {
return startX == endX && startY == endY;
}
startX, startY, endX, endY 네 개가 이 메서드에도, 저 메서드에도 함께 따라다녀요. 늘 같이 다니는 값들은 사실 하나의 개념(여기선 "점" 또는 "구간")인데 흩어져 있는 거예요. 이건 객체 하나로 묶으라는 신호고, Step 6에서 매개변수 객체로 정면으로 다룹니다.
셋째는 산탄총 수술(Shotgun Surgery)입니다. 이름이 무섭죠?
// '부가세 10%'라는 한 정책이 여러 메서드에 흩어져 있다 (refactoring.smells.ShotgunSurgery)
public long taxOf(long price) {
return Math.round(price * 0.1);
}
public long totalWithTax(long price) {
return price + Math.round(price * 0.1);
}
public String taxLabel() {
return "부가세 10% 포함";
}
"부가세 10%"라는 한 가지 정책이 세 메서드에 흩뿌려져 있어요. 세율을 한 번 바꾸려면 흩어진 0.1과 "10%"를 전부 찾아 고쳐야 합니다. 한 번의 변경이 산탄총처럼 여러 곳을 동시에 쏘아 맞추죠. 재미있는 건, 이게 큰 클래스의 정반대 냄새라는 거예요. 큰 클래스는 한 곳에 너무 많이 모인 냄새, 산탄총 수술은 한 가지가 너무 흩어진 냄새입니다.
두 냄새는 거울상이다
큰 클래스 [ 한 클래스 ] ← 너무 많은 책임이 한 곳에 모임
알림·결제·인증·신원...
산탄총 수술 [A] [B] [C] [D] ← 한 가지 정책(10%)이 여러 곳에 흩어짐
고치려면 네 곳을 다 쏴야 함
⚠️ 언제 깨나 — 여섯 냄새의 이름을 외우는 게 목적이 아니에요. 이름은 동료와 대화할 공통 어휘일 뿐입니다. "이거 기능 욕심이네요"라고 하면 한마디로 통하니까요. 정작 중요한 건 "이 코드를 바꾸려 할 때 어디가 어려운가"를 느끼는 감각이에요. 냄새 분류에 집착해 멀쩡한 코드까지 "이건 무슨 냄새지?" 하고 뜯어보는 건 과합니다.
💡 한 줄 정리
기능 욕심·데이터 뭉치·산탄총 수술은 한 줄이 아니라 코드들의 "관계"에서 나는 냄새다. 특히 산탄총 수술은 큰 클래스의 거울상으로, "한 가지가 여러 곳에 흩어진" 변경의 어려움을 가리킨다.
🙋 학생 질문 — "튜터님, 냄새 종류가 이렇게 많은데 다 외워야 하나요?"
아니요. 파울러의 책에는 냄새가 스무 개 넘게 나오지만, 그걸 다 외운다고 좋은 개발자가 되는 건 아니에요.
냄새 카탈로그의 진짜 가치는 두 가지예요. 하나는 공통 어휘입니다. "여기 중복 코드가 있어요", "이 메서드 기능 욕심이네요"라고 하면, 긴 설명 없이 무슨 문제인지 팀원이 바로 알아들어요. 다른 하나는 체크리스트예요. 코드 리뷰할 때 "혹시 데이터 뭉치는 없나? 산탄총 수술 위험은?" 하고 점검할 렌즈가 되어 주죠.
그러니 스무 개를 통째로 외우기보다, 오늘 본 여섯 개를 "아, 이런 느낌이구나" 하고 알아채는 감각만 가지면 충분합니다. 나머지는 코드를 고치다 보면 자연스럽게 눈에 들어와요.
Step 3: "구조만 바꾸고 동작은 그대로" — 리팩토링이란 무엇인가
냄새를 맡았으니 이제 고칠 차례죠. 그런데 그 "고치기"에 정확한 정의가 있어요. 마틴 파울러는 리팩토링을 이렇게 정의합니다. "겉으로 드러나는 동작은 바꾸지 않으면서, 내부 구조를 이해하기 쉽고 고치기 싸게 바꾸는 것."
이 정의에서 가장 중요한 말은 "동작은 바꾸지 않으면서"예요. 리팩토링은 기능을 추가하는 게 아닙니다. 버그를 고치는 것도 아니에요. 입력과 출력은 그대로 두고, 오직 안쪽 구조만 다듬는 거예요. 사용자는 아무 차이도 못 느끼지만, 다음에 코드를 고칠 개발자는 훨씬 편해집니다.
파울러는 이걸 "두 개의 모자"로 비유해요. 개발자는 두 가지 모자를 씁니다. 하나는 기능 추가 모자, 하나는 리팩토링 모자예요. 기능을 추가할 땐 동작이 바뀌니 테스트도 새로 늘어납니다. 리팩토링할 땐 동작이 안 바뀌니 테스트는 그대로죠. 핵심은 이 두 모자를 동시에 쓰지 말라는 거예요. 구조를 바꾸면서 기능도 슬쩍 추가하면, 나중에 버그가 났을 때 "구조 때문인지 새 기능 때문인지" 구분이 안 됩니다.
또 하나 중요한 원칙이 "작은 걸음(small steps)"이에요. 리팩토링은 한 번에 코드를 크게 갈아엎는 게 아닙니다. 아주 작은 변경을 하나 하고, 컴파일과 테스트로 "아직 멀쩡하지?"를 확인하고, 또 작은 변경을 하나 하고 확인하고요. 이걸 반복합니다.
작은 걸음 — 항상 초록불을 유지한다
크게 갈아엎기: [동작🟢] ─────크게 수정────→ [동작❓] 어디서 깨졌는지 모름
작은 걸음: [🟢]→[🟢]→[🟢]→[🟢]→[🟢] 깨지면 직전 한 걸음만 의심
왜 이렇게 답답할 만큼 잘게 갈까요? 만약 큰 변경 한 방에 코드가 깨지면, 그 큰 덩어리 어디가 문제인지 한참 뒤져야 해요. 작은 걸음으로 가면 깨지는 순간 "방금 그 한 걸음"만 의심하면 됩니다. 사실 우리가 그동안 본 TDD의 리듬인 Red-Green-Refactor, 그 마지막 R이 바로 이 리팩토링이에요. 테스트를 통과(Green)시킨 뒤, 통과 상태를 유지하면서 구조를 다듬는 단계죠.
⚠️ 언제 깨나 — 리팩토링은 공짜가 아니에요. 시간이 들고, 잘못하면 멀쩡한 코드를 망칠 위험도 있습니다. 그래서 "동작을 바꾸는 작업"과 절대 섞으면 안 돼요. 급한 버그를 고치는 중이라면, 먼저 버그만 고치고 리팩토링은 따로 합니다. 두 모자를 동시에 쓰는 순간 위험이 두 배가 돼요. 언제 리팩토링하고 언제 미룰지는 마지막 Step에서 정리합니다.
💡 한 줄 정리
리팩토링은 "겉보기 동작은 그대로, 내부 구조만 개선"하는 작업이다. 기능 추가와 섞지 않고(두 개의 모자), 항상 초록불을 유지하며 작은 걸음으로 간다.
🙋 학생 질문 — "튜터님, 리팩토링이랑 그냥 코드 새로 짜는 거(재작성)랑 뭐가 다른가요?"
방향이 완전히 달라요. 한마디로 리팩토링은 "조금씩 고치며 늘 돌아가는 상태 유지", 재작성은 "갈아엎고 처음부터 새로"입니다.
리팩토링은 매 걸음마다 코드가 동작해요. 중간에 멈춰도 배포할 수 있죠. 반면 재작성은 새 버전이 완성될 때까지 한참 동안 "동작 안 하는 상태"가 이어집니다. 그사이 원래 코드엔 또 새 요구사항이 들어오고, 새 코드는 그걸 못 따라가서 영영 못 끝나는 경우도 많아요. 유명한 "처음부터 새로 짜자"의 함정이죠.
그래서 실무의 기본값은 리팩토링이에요. 정말 구조가 근본부터 잘못됐고 안전망까지 갖춰진 특수한 경우에만 재작성을 고려합니다. 그 "테스트 없는 레거시를 어떻게 안전하게 손대나"가 바로 다음 Step의 주제예요.
Step 4: "테스트가 없어서 못 고쳐요" — 특성화 테스트로 안전망 치기
자, Step 3에서 "작은 걸음마다 테스트로 확인한다"고 했죠. 그런데 현실에서 가장 많이 듣는 말이 이거예요. "테스트가 없어서 리팩토링이 무서워요." 운영 중인 레거시 코드엔 테스트가 없는 경우가 많아요. 그럼 고쳤을 때 동작이 바뀌었는지 어떻게 알죠?
여기 테스트도 주석도 없는 레거시 한 토막이 있다고 해 봅시다.
// 테스트 없는 레거시 — 규칙은 코드에만 있다 (refactoring.characterization.LegacyPointPolicy)
public int earn(int amount, String grade, boolean firstPurchase) {
int point = amount / 100;
if (grade != null && grade.equals("VIP")) {
point = point * 2;
} else if (grade != null && grade.equals("VVIP")) {
point = point * 3;
}
if (firstPurchase) {
point = point + 1000;
}
if (point > 50000) {
point = 50000;
}
return point;
}
구매 금액과 등급으로 포인트를 계산하는데, 규칙이 코드에만 있어요. 등급 배수가 있고, 첫 구매 보너스가 있고, 상한도 있네요. 이걸 리팩토링하고 싶은데 테스트가 없습니다.
이때 쓰는 게 특성화 테스트(Characterization Test)예요. 다른 말로 "현재 동작을 그물로 떠서 고정하는 테스트"입니다. 핵심은 이거예요. 이 테스트는 "이 값이 옳은가?"를 묻지 않습니다. 오직 "지금 이 코드가 실제로 무슨 값을 내는가?"를 받아 적어요.
// 현재 동작을 그대로 박제한다 (refactoring.characterization.LegacyPointPolicyCharacterizationTest)
class LegacyPointPolicyCharacterizationTest {
private final LegacyPointPolicy policy = new LegacyPointPolicy();
@DisplayName("VIP는 2배, VVIP는 3배 — 등급 분기의 현재 동작을 고정")
@Test
void gradeMultiplies() {
assertEquals(200, policy.earn(10000, "VIP", false));
assertEquals(300, policy.earn(10000, "VVIP", false));
}
@DisplayName("적립 상한 50000점에서 잘린다 — 이 한도도 현재 동작이라 고정한다")
@Test
void cappedAtUpperLimit() {
assertEquals(50000, policy.earn(3_000_000, "VVIP", false));
}
}
여기서 200이나 50000은 우리가 "이게 정답이어야 해"라고 정한 값이 아니에요. 레거시 코드를 실제로 돌려 보고 "아, 지금 이 값이 나오는구나" 하고 받아 적은 값입니다. 이렇게 현재 동작을 단단히 고정해 두면, 이제 그물이 생긴 거예요. 코드를 어떻게 고치든 이 테스트가 깨지지 않으면 "동작이 안 바뀌었다"는 증거가 됩니다. 줄 없이 외줄을 타던 상황에서, 아래에 안전망을 친 거죠.
지난 시간 마지막에 제가 "코드를 바꾸면서도 행동이 바뀌지 않았음을 테스트로 증명하는 안전망을 이번에 정면으로 다루겠다"고 예고했었죠. 그게 바로 이 특성화 테스트예요. 그리고 이어지는 Step 5·6·7에서 우리가 실제로 코드를 고칠 때, 매번 "같은 테스트가 Before와 After 양쪽에서 통과하는지"로 행동 보존을 확인할 겁니다.
⚠️ 언제 깨나 — 특성화 테스트는 위험한 면도 있어요. 현재 동작에 버그가 있어도 그 버그까지 그대로 고정합니다. 그래서 특성화 테스트는 "옳은 명세"가 아니라 "현재 사진"일 뿐이에요. 리팩토링으로 구조를 안전하게 정리한 뒤, 버그는 그 위에서 따로 고쳐야 합니다. 구조 정리와 버그 수정을 한 번에 하려 들면, 또 두 개의 모자를 동시에 쓰는 셈이에요.
💡 한 줄 정리
특성화 테스트는 "옳은가"가 아니라 "지금 무슨 값을 내는가"를 그대로 받아 적어, 테스트 없는 레거시에 안전망을 친다. 이 그물이 있어야 리팩토링이 행동을 바꾸지 않았음을 매 걸음 확인할 수 있다.
🙋 학생 질문 — "튜터님, 레거시가 무슨 값을 내는지 일일이 손으로 다 알아내야 하나요?"
손으로 다 계산할 필요는 없어요. 실무에선 똑똑한 방법을 씁니다.
레거시 코드에 다양한 입력을 잔뜩 흘려보내고, 그 출력을 통째로 기록해 두는 거예요. 이걸 "골든 마스터(golden master)"라고 불러요. 일종의 정답지 사진이죠. 그다음 리팩토링을 하고, 같은 입력을 다시 흘려보내서 출력이 그 사진과 똑같은지 비교합니다. 다르면 어딘가 동작이 바뀐 거예요.
오늘 본 예제는 입력 몇 개를 골라 assertEquals로 직접 고정했지만, 실제 레거시에선 입력을 수백 개 자동 생성해 출력을 한꺼번에 비교하기도 해요. 핵심 원리는 똑같습니다. "옳은 값을 아는 것"이 아니라 "현재 값을 고정하는 것"이요. 그 위에서야 마음 놓고 구조를 바꿀 수 있어요.
Step 5: "함수가 100줄이에요" — 함수 추출·설명 변수·이름 바꾸기
안전망을 쳤으니 이제 진짜로 코드를 고쳐 봅시다. 가장 기본이자 가장 많이 쓰는 기법이 함수 추출(Extract Method)이에요. Step 1에서 본 긴 함수 냄새를 푸는 첫 번째 약이죠.
영수증 한 줄을 만드는 긴 함수가 있어요.
// Before — 합계·할인·배송비·조립이 한 덩어리 (refactoring.extract.ReceiptBefore)
public String print(int[] prices, boolean isVip) {
int sum = 0;
for (int p : prices) {
sum += p;
}
int d = isVip ? sum / 10 : 0;
int afterDiscount = sum - d;
int ship = afterDiscount >= 30000 ? 0 : 3000;
return "합계 " + sum + " / 할인 " + d + " / 배송비 " + ship + " / 최종 " + (afterDiscount + ship);
}
변수 이름도 sum, d, ship처럼 짧아 의도가 안 보이고, 30000이 무슨 뜻인지도 코드에 안 적혀 있어요. 이걸 세 가지 기법으로 동시에 고쳐 봅시다. 한 가지 일을 하는 덩어리를 함수로 추출하고, 매직 넘버를 이름 있는 상수로 바꾸고(A-1에서 배웠죠), 복잡한 조건식엔 설명 변수를 둡니다(A-2의 설명 변수예요).
// After — 한 가지 일씩 작은 함수로, 이름과 상수로 의도를 드러낸다 (refactoring.extract.Receipt)
public class Receipt {
private static final int FREE_SHIPPING_THRESHOLD = 30000;
private static final int SHIPPING_FEE = 3000;
private static final int VIP_DISCOUNT_DIVISOR = 10;
public String print(int[] prices, boolean isVip) {
int subtotal = subtotal(prices);
int discount = discountFor(subtotal, isVip);
int discountedTotal = subtotal - discount;
int shippingFee = shippingFeeFor(discountedTotal);
int finalTotal = discountedTotal + shippingFee;
return "합계 " + subtotal + " / 할인 " + discount
+ " / 배송비 " + shippingFee + " / 최종 " + finalTotal;
}
private int subtotal(int[] prices) {
int subtotal = 0;
for (int price : prices) {
subtotal += price;
}
return subtotal;
}
private int discountFor(int subtotal, boolean isVip) {
return isVip ? subtotal / VIP_DISCOUNT_DIVISOR : 0;
}
private int shippingFeeFor(int discountedTotal) {
boolean qualifiesForFreeShipping = discountedTotal >= FREE_SHIPPING_THRESHOLD;
return qualifiesForFreeShipping ? 0 : SHIPPING_FEE;
}
}
이제 print를 읽어 보세요. "합계 구하고 → 할인 구하고 → 배송비 구하고 → 최종 더한다"는 흐름이 그대로 읽히죠? 무엇을 하는지의 차례만 보이고, 각 단계가 어떻게 동작하는지는 작은 함수 안으로 숨었어요. 30000은 FREE_SHIPPING_THRESHOLD라는 이름을 얻었고, qualifiesForFreeShipping이라는 설명 변수가 "이게 무료 배송 조건이구나"를 말해 줍니다.
그리고 가장 중요한 점. 이 모든 변경에도 동작은 한 글자도 안 바뀌었어요. Step 4에서 친 안전망과 똑같은 방식의 테스트가 이걸 증명합니다.
// 같은 테스트가 Before와 After 양쪽에서 통과한다 = 행동 보존 (refactoring.extract.ExtractTest)
@DisplayName("VIP 주문: 추출 전후가 똑같은 영수증을 만든다")
@Test
void vipReceiptIsPreserved() {
String expected = "합계 25000 / 할인 2500 / 배송비 3000 / 최종 25500";
assertEquals(expected, before.print(new int[] {10000, 15000}, true));
assertEquals(expected, after.print(new int[] {10000, 15000}, true));
}
before(긴 함수)와 after(추출본)가 같은 입력에 같은 영수증을 내요. 구조는 완전히 달라졌는데 결과가 똑같다는 게 핵심이에요. 이게 리팩토링이 "동작을 바꾸지 않았다"는 증거입니다.
⚠️ 언제 깨나 — 함수 추출도 과하면 독이에요. 한 줄짜리 함수가 수십 개로 폭발하면, 오히려 흐름을 따라가려고 함수 사이를 계속 점프해야 합니다. A-2에서 본 그 경계죠. "이름을 붙이면 의도가 더 분명해지는가?"가 기준이에요.
subtotal()처럼 이름이 설명을 더해 주면 추출하고, 그냥 한 줄 감싸기만 되는 건 굳이 빼지 않습니다.
💡 한 줄 정리
함수 추출·설명 변수·이름 있는 상수는 긴 함수를 "무엇을 하는지" 읽히게 바꾸는 기본 삼총사다. 구조가 완전히 달라져도 같은 테스트가 양쪽에서 통과하면 행동은 보존된 것이다.
🙋 학생 질문 — "튜터님, 이렇게 함수로 잘게 쪼개면 함수 호출이 많아져서 느려지지 않나요?"
이론적으로는 호출이 늘지만, 현실에서 그 차이는 거의 0이에요. 자바를 비롯한 현대 런타임은 작은 메서드 호출을 인라이닝(inlining)이라는 최적화로 거의 공짜로 만들어 줍니다. 컴파일러와 JIT가 알아서 합쳐 버리거든요.
그래서 "메서드 쪼개면 느려진다"는 걱정 때문에 한 함수에 다 욱여넣는 건, 대부분의 경우 잘못된 거래예요. 0에 가까운 성능을 아끼려고 가독성을 통째로 버리는 셈이니까요. 정말 성능이 극단적으로 중요한 일부 구간(예: 초당 수백만 번 도는 루프 안)에서만 측정해 보고 판단하면 됩니다. 그리고 그 "성능 vs 가독성"의 진짜 판단 기준은 다음 시간 D-2에서 정면으로 다뤄요.
Step 6: "이 조건문이 무슨 뜻인지 모르겠어요" — 조건문 분해·매개변수 객체
두 번째 기법 묶음으로 갑니다. 복잡한 조건문과, 늘 함께 다니는 인자 뭉치를 정리하는 두 기법이에요.
먼저 조건문 분해(Decompose Conditional)입니다. 조건식과 그 안의 분기가 한 덩어리로 엉켜 의도가 안 보일 때 쓰죠.
// Before — 조건식과 분기 본문이 한 덩어리 (refactoring.conditional.HolidayFeeBefore)
public int fee(int baseFee, boolean isHoliday, boolean isPeakSeason) {
if (isHoliday || isPeakSeason) {
return baseFee + baseFee * 30 / 100;
} else {
return baseFee - baseFee * 5 / 100;
}
}
isHoliday || isPeakSeason이 무슨 뜻인지, baseFee * 30 / 100이 할증인지 할인인지 매번 머리로 풀어야 해요. 조건식과 각 분기를 이름 붙은 메서드로 분해해 봅시다.
// After — 조건과 분기를 의도가 드러나는 메서드로 분해 (refactoring.conditional.HolidayFee)
public int fee(int baseFee, boolean isHoliday, boolean isPeakSeason) {
if (isHigherSeason(isHoliday, isPeakSeason)) {
return surcharged(baseFee);
}
return discounted(baseFee);
}
private boolean isHigherSeason(boolean isHoliday, boolean isPeakSeason) {
return isHoliday || isPeakSeason;
}
private int surcharged(int baseFee) {
return baseFee + baseFee * 30 / 100;
}
private int discounted(int baseFee) {
return baseFee - baseFee * 5 / 100;
}
이제 fee는 "성수기면 할증, 아니면 할인"이라는 뜻 그대로 읽혀요. 숫자 30과 5는 surcharged, discounted라는 이름 뒤로 숨었고요. 코드가 곧 설명이 됐습니다.
다음은 매개변수 객체(Introduce Parameter Object)예요. Step 2에서 본 데이터 뭉치 냄새를 푸는 약이죠. 늘 함께 다니는 인자들을 객체 하나로 묶습니다.
// Before — (개수·무게·거리)가 인자 세 개로 함께 몰려다닌다 (refactoring.conditional.ShipmentCostBefore)
public int cost(int itemCount, int weightGram, int distanceKm) {
return itemCount * 500 + weightGram / 1000 * 300 + distanceKm * 100;
}
이 세 값은 다른 메서드에도 늘 같이 따라다녀요. 하나로 묶어 봅시다. A-2에서 여러 인자를 PostForm이라는 객체로 묶었던 그 기법이 돌아오는 거예요.
// 늘 함께 다니는 값을 의미 있는 객체로 묶는다 (refactoring.conditional.Shipment / ShipmentCost)
public record Shipment(int itemCount, int weightGram, int distanceKm) {
}
public int cost(Shipment shipment) {
return shipment.itemCount() * 500
+ shipment.weightGram() / 1000 * 300
+ shipment.distanceKm() * 100;
}
이제 인자가 Shipment 하나로 깔끔해졌어요. 호출부도 cost(shipment)처럼 단순해지고, 나중에 "보험 가입 여부" 같은 항목이 늘어도 Shipment만 넓히면 됩니다. 흩어진 세 값이 "배송 정보"라는 하나의 개념으로 이름을 얻은 거죠.
⚠️ 언제 깨나 — 매개변수 객체도 남발하면 의미 없는 껍데기만 늘어요. 인자가 두 개고 서로 별 관계도 없다면, 굳이 객체로 묶을 필요 없습니다. "이 값들이 정말 하나의 개념으로 늘 함께 다니는가?"가 기준이에요. 데이터 뭉치라는 냄새가 실제로 날 때만 묶으세요. 냄새도 없는데 "객체로 묶는 게 깔끔하니까" 하고 묶는 건 과한 추상화입니다.
💡 한 줄 정리
조건문 분해는 복잡한 조건과 분기에 이름을 붙여 코드를 설명으로 바꾸고, 매개변수 객체는 데이터 뭉치를 하나의 개념으로 묶는다. 둘 다 "이름으로 의도를 드러내는" 같은 정신이다.
🙋 학생 질문 — "튜터님, 조건문을 메서드로 빼면 if 한 줄 보려고 메서드를 또 찾아가야 하잖아요. 더 불편하지 않나요?"
그 느낌 충분히 이해해요. 그런데 둘 중 무엇을 읽기 쉬운지를 생각해 보세요.
if (isHoliday || isPeakSeason)과 if (isHigherSeason(...)) 중에서, 흐름을 따라갈 때 우리가 알고 싶은 건 "왜 이 분기를 타는가"예요. isHigherSeason은 그 답("성수기라서")을 한눈에 줍니다. isHoliday || isPeakSeason은 "이 둘이 합쳐지면 무슨 의미지?"를 매번 다시 해석하게 만들고요.
물론 메서드 본문이 궁금하면 한 번 들어가 봐야죠. 하지만 대부분의 읽기에선 이름만으로 충분해요. 세부 구현을 매번 볼 필요가 없으니 오히려 빨리 읽힙니다. 다만 Step 5에서 말했듯, 이름이 설명을 더해 줄 때만 빼는 거예요. isHoliday || isPeakSeason처럼 합쳐서 새 의미가 생기는 조건이 딱 그런 경우죠.
Step 7: "if-else가 등급마다 똑같이 반복돼요" — 다형성으로 조건문 교체
이번 기법은 오늘의 하이라이트예요. 그리고 지난 시간에 우리가 이미 한 번 해 본 일이기도 합니다. 다형성으로 조건문 교체(Replace Conditional with Polymorphism)죠.
타입에 따라 분기하는 코드를 봅시다.
// Before — 등급 문자열로 분기하는 type code (refactoring.polymorphism.GradeDiscountBefore)
public int discount(String grade, int amount) {
switch (grade) {
case "REGULAR":
return 0;
case "VIP":
return amount * 10 / 100;
case "VVIP":
return amount * 20 / 100;
default:
throw new IllegalArgumentException("알 수 없는 등급: " + grade);
}
}
등급 문자열로 갈래를 나누는 이런 코드를 타입 코드(type code)라고 불러요. 문제는 두 가지예요. 등급이 하나 늘 때마다 이 switch에 case를 더해야 하고, 더 나쁜 건 "등급별로 다르게 동작하는" 메서드가 여러 곳에 생기면 똑같은 switch가 그 수만큼 복사돼 흩어진다는 거예요. Step 2의 산탄총 수술 냄새가 여기서 나죠.
이걸 다형성으로 바꿉니다. 등급마다 분기하는 대신, 등급 자체가 자기 할인율을 알게 만드는 거예요.
// After — 등급이 스스로 할인을 안다 (refactoring.polymorphism.Grade)
public enum Grade {
REGULAR {
@Override
public int discount(int amount) {
return 0;
}
},
VIP {
@Override
public int discount(int amount) {
return amount * 10 / 100;
}
},
VVIP {
@Override
public int discount(int amount) {
return amount * 20 / 100;
}
};
public abstract int discount(int amount);
}
이제 discount를 부르는 쪽은 더 이상 "어떤 등급이지?"를 묻지 않아요. 그냥 grade.discount(amount)라고 부르면, 각 등급이 알아서 자기 계산을 합니다. switch가 통째로 사라졌죠.
여기서 지난 시간을 떠올려 보세요. C-3에서 우리가 거대한 if/switch를 전략 패턴으로 갈아 끼우고, 흩어진 조건문을 상태 패턴으로 모았잖아요. 사실 그게 바로 이 "다형성으로 조건문 교체"라는 리팩토링 기법이었어요. 패턴은 "도착지(좋은 구조)"의 이름이고, 리팩토링 기법은 "거기로 가는 길"의 이름이에요. 패턴을 알았기에, 이 switch를 어디로 끌고 가야 할지가 막히지 않는 거죠.
한 가지 짚고 갈 게 있어요. C-3의 전략·상태는 인터페이스와 여러 클래스로 만들었는데, 여기선 enum을 썼어요. 등급처럼 값이 닫혀 있는 집합(REGULAR/VIP/VVIP, 더 늘 일이 드문)이면 enum에 행동을 실어 같은 다형성을 더 간결하게 얻을 수 있습니다. 도착지는 같고, 길의 모양만 상황에 맞춘 거예요.
이건 언어를 가리지 않는 방향이에요. 파이썬에선 클래스 대신 "등급 → 함수" 딕셔너리로 같은 일을 합니다.
# After — 등급 -> 할인 함수 딕셔너리 (replace_conditional_with_polymorphism.py)
DISCOUNTS = {
"REGULAR": lambda amount: 0,
"VIP": lambda amount: amount * 10 // 100,
"VVIP": lambda amount: amount * 20 // 100,
}
def discount(grade, amount):
if grade not in DISCOUNTS:
raise ValueError(f"알 수 없는 등급: {grade}")
return DISCOUNTS[grade](amount)
자바는 enum, 파이썬은 딕셔너리. 도구는 다르지만 방향은 똑같아요. "switch/if 분기를 자료(객체·함수·딕셔너리)로 바꾼다." 중요한 건 그 방향이지, 그게 클래스냐 딕셔너리냐가 아니에요.
⚠️ 언제 깨나 — 분기가 두어 개뿐이고 앞으로 늘 일도 없는
if에 다형성을 들이대는 건 과잉이에요. 클래스나enum구조를 만드느라 오히려 코드가 더 복잡해지죠. 이 기법은 같은 타입 분기가 여러 곳에서 반복되거나, 분기가 계속 늘어날 게 보일 때 빛납니다. 한 곳에만 있는 작은if는 그냥if로 두는 게 맞아요. C-3에서 본 "세 번 규칙"을 기억하세요.
💡 한 줄 정리
다형성으로 조건문 교체는 타입 코드 switch를 "각 타입이 자기 행동을 아는" 구조로 바꾼다. C-3의 전략·상태가 도착지라면, 이 기법은 거기로 가는 길이다. 닫힌 집합이면 enum, 파이썬이면 딕셔너리로 같은 방향을 간다.
🙋 학생 질문 — "튜터님, switch가 한 곳에만 있으면 그냥 둬도 된다면서요? 그럼 이 기법은 언제 진짜 필요한 거예요?"
판단 기준은 "이 타입 분기가 앞으로 어떻게 변할까"예요.
타입 분기가 딱 한 곳에 있고 분기도 거의 안 늘 거라면, switch 하나가 오히려 더 읽기 쉬워요. 굳이 클래스 여러 개로 흩뜨릴 이유가 없죠. 그런데 같은 등급 분기가 할인 계산에도, 배송비 계산에도, 화면 표시에도 각각 switch로 복사돼 있다면? 등급 하나 추가할 때 그 모든 switch를 다 찾아 고쳐야 해요. 산탄총 수술이죠.
바로 그때 다형성이 답이에요. 등급마다 객체(또는 enum 상수)를 하나 만들어 두면, 새 등급은 그 객체 하나만 추가하면 끝나요. 흩어진 switch들이 전부 grade.discount(), grade.shippingFee()처럼 한 줄로 바뀌니까요. "같은 타입 분기가 반복되는가", 이게 신호입니다. 한 번 나온 분기엔 칼을 뽑지 마세요.
Step 8: "언제 리팩토링해야 하나요?" — 타이밍과 멈출 줄 아는 것
기법은 손에 넣었어요. 그런데 더 어려운 질문이 남았습니다. "그래서 언제, 어디까지 리팩토링하나요?" 시간은 늘 부족하고, 기능 마감은 코앞이죠. 따로 "리팩토링 주간"을 잡아 코드를 싹 갈아엎어야 할까요?
대부분의 경우 답은 아니에요. 현실적인 답은 보이스카웃 규칙(Boy Scout Rule)입니다. 보이스카웃에는 "캠프장을 찾았을 때보다 더 깨끗하게 만들고 떠나라"는 규칙이 있어요. 코드도 똑같이 합니다. "파일을 열어 기능을 고친 김에, 떠날 때 조금 더 깨끗하게 만들고 나온다." 거창한 리팩토링 프로젝트가 아니라, 평소 작업에 조금씩 섞는 거예요.
보이스카웃 규칙 — 따로 시간을 내지 않는다
❌ 리팩토링 주간: [────기능 개발────][──몰아서 대청소──] 현실에선 미뤄지다 안 함
✅ 들른 김에: [기능+조금깨끗] [기능+조금깨끗] [기능+조금깨끗] 매번 조금씩 나아짐
어떤 변수 이름이 헷갈리면 고치고, 중복 한 줄이 보이면 합치고, 긴 함수 한 조각을 추출하고요. 한 번에 다 고치려 들지 않아요. 들른 김에 조금. 이게 쌓이면 코드는 시간이 갈수록 나빠지는 게 아니라 좋아집니다.
그리고 C-3에서 본 세 번 규칙도 타이밍의 기준이에요. 비슷한 코드가 처음 나오면 그냥 둡니다(한 번은 우연). 두 번째 나오면 중복을 눈치채되 참아요. 세 번째 나오면 그때 추상화합니다. 너무 일찍 패턴을 꺼내면 YAGNI(필요하지도 않은 걸 미리 만드는 것)에 걸리니까요. 변화가 실제로 반복되는 걸 본 뒤에 구조를 바꾸는 거죠.
가장 중요한 건, 언제 리팩토링을 멈추거나 미룰지 아는 것이에요. 이게 주니어와 미들을 가르는 진짜 지점입니다.
⚠️ 언제 깨나(이 과목의 척추) — 리팩토링을 멈춰야 할 때가 분명히 있어요. ① 마감이 코앞이고 동작하는 코드라면, 지금은 멈추고 기록만 남깁니다. ② 곧 버려질 코드(폐기 예정 기능, 일회용 스크립트)는 깨끗하게 만들 가치가 없어요. ③ 안전망(테스트)이 없는데 시간도 없다면, 무리하게 손대는 게 더 위험합니다. ④ 무엇보다, 깨끗함은 목적이 아니라 수단이에요. 완벽하게 깨끗한 코드를 만드는 것 자체에 빠지면, 정작 해야 할 일을 못 합니다. "이 리팩토링이 다음 변경을 정말 쉽게 해 주는가?"를 늘 물으세요. 답이 "아니"면 멈추는 게 맞아요.
💡 한 줄 정리
리팩토링은 따로 시간을 빼는 게 아니라 보이스카웃 규칙으로 평소에 조금씩 섞는다. 세 번 규칙으로 너무 이른 추상화를 피하고, 마감·폐기 예정·안전망 부재일 땐 멈출 줄 안다. 깨끗함은 목적이 아니라 수단이다.
🙋 학생 질문 — "튜터님, 리팩토링하겠다고 하면 '그거 왜 해? 지금 잘 돌아가잖아'라고 해요. 어떻게 설득하죠?"
아주 현실적인 고민이에요. 핵심은 "코드를 위해"가 아니라 "비즈니스를 위해" 설명하는 거예요.
"이 코드가 지저분해서요"는 설득력이 약해요. 듣는 사람에겐 개발자의 취향처럼 들리거든요. 대신 이렇게 말해 보세요. "지금 이 부분이 이런 구조라, 다음에 요청하신 그 기능을 넣으려면 다섯 군데를 고쳐야 하고 버그 위험이 큽니다. 먼저 한 시간만 정리하면 그다음부터 한 곳만 고치면 돼요." 리팩토링을 "미래의 변경 속도에 대한 투자"로 번역하는 거죠.
그리고 가장 좋은 방법은 보이스카웃 규칙이에요. 따로 허락받을 큰 리팩토링이 아니라, 기능을 고치는 김에 그 주변을 조금씩 깨끗하게 만들면 굳이 설득할 일도 없어요. 다만 그때도 안전망(테스트)은 꼭 먼저예요. "고쳤는데 똑같이 돌아간다"를 증명할 수 있어야 "왜 건드렸냐"는 말에 떳떳합니다.
마무리
오늘은 카테고리 D의 문을 열며, A·B·C에서 본 "좋은 코드"로 나쁜 코드를 옮기는 법을 배웠어요. 거창한 재작성이 아니라, 냄새를 맡고 안전망을 친 뒤 작은 걸음으로 고치는 한 바퀴였죠.
오늘 배운 핵심 세 가지
💡 하나 — 냄새는 처방이 아니라 단서다. 중복 코드·긴 함수·큰 클래스·기능 욕심·데이터 뭉치·산탄총 수술. 이 여섯은 "버그"가 아니라 "여기 변경이 어렵다"는 신호예요. 이름을 외우는 것보다, 어디가 바꾸기 어려운지 알아채는 감각이 먼저입니다.
💡 둘 — 안전망 없이는 리팩토링도 없다. 리팩토링은 "동작은 그대로, 구조만" 고치는 작업이에요. 그걸 증명하려면 테스트가 필요하고, 테스트 없는 레거시엔 특성화 테스트로 현재 동작을 고정한 뒤 손댑니다. 같은 테스트가 Before·After 양쪽에서 통과하는 게 "행동 보존"의 증거예요.
💡 셋 — 기법보다 판단이 어렵다. 함수 추출·조건문 분해·매개변수 객체·다형성으로 조건문 교체까지 손에 넣었어요. 하지만 진짜 실력은 "언제 하고 언제 멈추나"입니다. 보이스카웃 규칙으로 조금씩, 세 번 규칙으로 너무 이르지 않게, 그리고 깨끗함이 목적이 되지 않게요.
다음 시간 예고
다음 시간(D-2)은 카테고리 D의 마지막이자, 이 과목 전체의 마무리예요. 오늘 우리는 "내가 짠 작은 예제"를 고쳤지만, 현실의 레거시는 훨씬 무섭습니다. 테스트도 없고, 의존성이 거미줄처럼 엉켜 있어 손댈 틈조차 안 보이죠. 그 레거시 코드에 "이음새(seam)"를 찾아 의존성을 끊고, 안전하게 손댈 틈을 만드는 법을 배웁니다. Strangler Fig라는, 낡은 시스템을 멈추지 않고 새것으로 교체하는 전략도 한 입 맛보고요.
그리고 코드 리뷰에서 무엇을 보고 어떻게 건설적으로 말하는지, 계층형·헥사고날 아키텍처가 무엇인지도 다룹니다. 무엇보다, 이 과목 내내 깔아 온 척추를 정면으로 세워요. "클린 코드는 도그마가 아니다." 오늘 마지막에 "깨끗함은 수단이지 목적이 아니다"라고 했죠. 그 말의 진짜 의미, "규칙을 외우는 게 주니어, 언제 깰지 아는 게 미들"을 D-2에서 끝까지 밀어붙입니다. 좋은 코드를 보는 안목의 마지막 한 조각, 다음 시간에 만나요.
과제
오늘 배운 냄새 식별과 리팩토링 기법을 직접 손으로 해 보는 과제입니다. 핵심은 "고치기 전에 먼저 동작을 고정하는 테스트를 떠올리고, 같은 테스트가 양쪽에서 통과하는지"를 늘 의식하는 거예요.
[기초] 긴 함수를 함수 추출로 정리하기
아래는 회원의 한 달 활동 점수를 계산해 등급 문자열을 만드는 긴 함수입니다. 합계·보정·등급 판정·문자열 조립이 한 덩어리로 섞여 있어요.
// Before — 한 메서드가 여러 일을 한다
public String monthlyGrade(int posts, int comments, int likes) {
int score = posts * 10 + comments * 3 + likes;
if (score < 0) {
score = 0;
}
String grade;
if (score >= 500) {
grade = "GOLD";
} else if (score >= 200) {
grade = "SILVER";
} else {
grade = "BRONZE";
}
return "점수 " + score + "점, 등급 " + grade;
}
이 함수를 "점수 계산"과 "등급 판정"을 각각 작은 함수로 추출하고, 매직 넘버(500, 200, 10, 3)에 이름 있는 상수를 붙여 정리해 보세요. 그리고 고치기 전에 입력 몇 개(예: posts=50, comments=0, likes=0)에 대한 출력을 먼저 적어 두고, 추출 후에도 그 출력이 똑같이 나오는지 확인하세요.
[응용] 데이터 뭉치를 매개변수 객체로 묶기
아래 코드는 (가로·세로·단가)라는 세 값이 두 메서드에 함께 따라다닙니다.
// Before — (width, height, pricePerArea)가 늘 함께 다닌다
public int wallpaperCost(int width, int height, int pricePerArea) {
return width * height * pricePerArea;
}
public int wallpaperCostWithMargin(int width, int height, int pricePerArea) {
int base = width * height * pricePerArea;
return base + base * 10 / 100;
}
이 세 값을 Wall이라는 매개변수 객체(record)로 묶어, 두 메서드가 Wall 하나만 받도록 고쳐 보세요. 그리고 "이 세 값이 정말 하나의 개념으로 늘 함께 다니는가"를 스스로에게 물어, 묶는 게 타당한지 한 문장으로 적어 보세요.
[심화] 타입 코드 switch를 다형성으로 교체하기
아래는 결제 수단에 따라 수수료를 계산하는 코드입니다. 그런데 같은 결제 수단 분기가 "수수료 계산"과 "표시 이름"에 각각 switch로 복사돼 있어요(산탄총 수술의 조짐).
// Before — 같은 타입 분기가 두 곳에 복사돼 있다
public int fee(String method, int amount) {
switch (method) {
case "CARD": return amount * 2 / 100;
case "BANK": return 0;
case "PHONE": return amount * 5 / 100;
default: throw new IllegalArgumentException(method);
}
}
public String label(String method) {
switch (method) {
case "CARD": return "신용카드";
case "BANK": return "계좌이체";
case "PHONE": return "휴대폰";
default: throw new IllegalArgumentException(method);
}
}
이 두 switch를 PaymentMethod라는 enum(또는 인터페이스 + 구현)으로 옮겨, 각 결제 수단이 자기 수수료와 이름을 알게 만들어 보세요. 그래서 새 결제 수단(예: POINT)이 생겨도 흩어진 switch를 찾아다니지 않고 한 곳만 추가하면 되게요. 여유가 있다면, 파이썬에서는 이 구조를 어떻게 표현할지("결제 수단 → 함수" 딕셔너리)도 한 번 스케치해 보세요.
생각해볼 주제
정답이 하나로 떨어지지 않는 질문들입니다. 혼자 고민해도 좋고, 동료와 논쟁해도 좋아요. 모두 "규칙을 아는가"를 넘어 "언제 적용하고 언제 멈추나"를 판단하는, 미들로 가는 질문들입니다.
1. 특성화 테스트가 버그까지 고정한다면, 그게 정말 안전망일까?
특성화 테스트는 "옳은 값"이 아니라 "현재 값"을 고정한다고 했습니다. 그렇다면 만약 레거시 코드에 이미 버그가 있다면, 특성화 테스트는 그 버그까지 "정상"으로 굳혀 버려요. 리팩토링 뒤에도 그 버그는 그대로 살아남죠. 이건 안전망의 한계일까요, 아니면 오히려 의도된 설계일까요? "구조를 바꾸는 일"과 "버그를 고치는 일"을 굳이 분리하는 이유는 무엇이고, 만약 리팩토링 도중에 버그를 발견하면 어떻게 해야 할지 생각해 보세요.
2. 보이스카웃 규칙 vs "건드리지 않는 게 미덕" — 어느 쪽이 옳을까?
"파일을 연 김에 조금 더 깨끗하게"가 보이스카웃 규칙입니다. 그런데 현장엔 정반대 격언도 있어요. "돌아가는 코드는 건드리지 마라(If it ain't broke, don't fix it)." 멀쩡한 코드를 리팩토링하다 오히려 버그를 넣는 일이 실제로 많거든요. 두 격언은 충돌하는 것처럼 보입니다. 어떤 상황에서 보이스카웃 규칙을 따르고, 어떤 상황에서 "건드리지 마라"를 따라야 할까요? 그 경계를 가르는 기준은 무엇일지 (안전망의 유무, 변경 빈도, 코드의 수명 등을 단서로) 생각해 보세요.
3. 패턴은 도착지, 리팩토링은 길 — 그럼 무엇을 먼저 배워야 할까?
오늘 우리는 "다형성으로 조건문 교체"라는 리팩토링 기법이 C-3의 전략·상태 패턴이라는 도착지로 가는 길이라고 했습니다. 그런데 순서를 뒤집어 생각해 볼 수도 있어요. 패턴(도착지)을 먼저 아는 게 중요할까요, 리팩토링 기법(길)을 먼저 아는 게 중요할까요? "패턴을 알아야 어디로 갈지 안다"는 입장과 "냄새를 알아야 무엇을 고칠지 안다, 패턴은 결과일 뿐이다"는 입장이 부딪힙니다. 패턴을 미리 알면 오히려 "패턴을 위한 패턴"의 함정(C-3에서 본)에 빠지기 쉽다는 시각도 있고요. 여러분은 어느 쪽이며, 그 이유는 무엇인가요?
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 함수를 어디서 자르든, 매개변수 객체의 이름을 뭐라 짓든, 그 상황에 맞으면 다른 모습도 좋습니다. 중요한 건 "냄새를 정확히 짚었는가", "고치기 전에 행동을 고정했는가", 그리고 "이 리팩토링이 정말 필요한가"를 판단하는 눈입니다.
과제 예시답안
🎯 [과제 1 예시답안] 긴 함수를 함수 추출로 정리하기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 함수 추출 | 점수 계산과 등급 판정을 각각 작은 함수로 뺐는가 | ★★★ |
| 매직 넘버 | 10·3·500·200에 이름 있는 상수를 붙였는가 |
★★☆ |
| 행동 보존 | 고치기 전 입력 몇 개의 출력을 적어 두고, 추출 후에도 같은지 확인했는가 | ★★★ |
| 추상화 수준 | monthlyGrade가 "무엇을 하는지"만 읽히는가 |
★★☆ |
풀이 예시
❌ Before — 점수 계산·등급 판정·문자열 조립이 한 메서드에 섞여 있다.
public String monthlyGrade(int posts, int comments, int likes) {
int score = posts * 10 + comments * 3 + likes;
if (score < 0) {
score = 0;
}
String grade;
if (score >= 500) {
grade = "GOLD";
} else if (score >= 200) {
grade = "SILVER";
} else {
grade = "BRONZE";
}
return "점수 " + score + "점, 등급 " + grade;
}
✅ After — 한 가지 일씩 작은 함수로 추출하고, 매직 넘버에 이름을 붙인다.
public class MonthlyGrade {
private static final int POST_WEIGHT = 10;
private static final int COMMENT_WEIGHT = 3;
private static final int GOLD_THRESHOLD = 500;
private static final int SILVER_THRESHOLD = 200;
public String monthlyGrade(int posts, int comments, int likes) {
int score = scoreOf(posts, comments, likes);
String grade = gradeOf(score);
return "점수 " + score + "점, 등급 " + grade;
}
private int scoreOf(int posts, int comments, int likes) {
int score = posts * POST_WEIGHT + comments * COMMENT_WEIGHT + likes;
return Math.max(score, 0);
}
private String gradeOf(int score) {
if (score >= GOLD_THRESHOLD) {
return "GOLD";
}
if (score >= SILVER_THRESHOLD) {
return "SILVER";
}
return "BRONZE";
}
}
이제 monthlyGrade는 "점수 구하고 → 등급 정하고 → 문장 만든다"는 흐름만 읽혀요. 점수를 어떻게 계산하는지, 등급을 어떻게 가르는지는 각 함수 안으로 숨었고요. score < 0을 0으로 누르던 부분은 Math.max(score, 0)이라는 한 줄로 의도가 더 또렷해졌습니다.
가장 중요한 건 고치기 전에 출력을 먼저 적어 두는 습관이에요. posts=50이면 "점수 500점, 등급 GOLD", posts=0이면 "점수 0점, 등급 BRONZE". 이 값들을 먼저 적어 두고 추출 후에도 똑같이 나오는지 확인하면, 그게 바로 작은 특성화 테스트입니다.
💡 튜터의 한마디 — 함수를 자르는 위치에 정답은 없어요. gradeOf 안의 if를 더 잘게 쪼갤 수도 있죠. 다만 기준은 하나예요. "이름을 붙이니 의도가 더 분명해지는가?" scoreOf·gradeOf는 그 답을 줍니다. 반대로 한 줄을 감싸기만 하는 함수라면 굳이 빼지 마세요. 추출은 수단이지 목적이 아닙니다.
🎯 [과제 2 예시답안] 데이터 뭉치를 매개변수 객체로 묶기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 매개변수 객체 | 세 인자를 Wall record 하나로 묶었는가 |
★★★ |
| 두 메서드 적용 | 두 메서드 모두 Wall 하나를 받도록 고쳤는가 |
★★★ |
| 중복 제거 | costWithMargin이 cost를 재사용하는가 |
★★☆ |
| 판단 | "이 셋이 정말 한 개념으로 늘 함께 다니는가"를 자문했는가 | ★★☆ |
풀이 예시
❌ Before — (가로·세로·단가)가 두 메서드에 같은 묶음으로 따라다닌다.
public int wallpaperCost(int width, int height, int pricePerArea) {
return width * height * pricePerArea;
}
public int wallpaperCostWithMargin(int width, int height, int pricePerArea) {
int base = width * height * pricePerArea;
return base + base * 10 / 100;
}
✅ After — 늘 함께 다니는 세 값을 "벽"이라는 하나의 개념으로 묶는다.
public record Wall(int width, int height, int pricePerArea) {
}
public class WallpaperCost {
public int cost(Wall wall) {
return wall.width() * wall.height() * wall.pricePerArea();
}
public int costWithMargin(Wall wall) {
int base = cost(wall);
return base + base * 10 / 100;
}
}
세 인자가 Wall 하나로 묶이니 두 메서드의 시그니처가 깔끔해졌어요. 덤으로 얻은 게 있어요. Before에서는 width * height * pricePerArea라는 계산이 두 메서드에 중복됐는데(중복 코드 냄새), After에서는 costWithMargin이 cost를 그대로 불러 써서 그 중복까지 사라졌습니다. 매개변수 객체로 묶었더니 재사용 길이 열린 거죠.
💡 튜터의 한마디 — 이 과제의 진짜 질문은 "묶을 수 있는가"가 아니라 "묶는 게 타당한가"예요. width·height·pricePerArea는 "벽 한 장"이라는 하나의 개념으로 늘 함께 다니니 묶는 게 맞습니다. 반대로 서로 아무 관계 없는 두 인자를 그저 개수를 줄이려고 묶으면, 의미 없는 껍데기 객체만 늘어요. 데이터 뭉치라는 냄새가 실제로 날 때만 묶는 겁니다.
🎯 [과제 3 예시답안] 타입 코드 switch를 다형성으로 교체하기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 다형성 전환 | 흩어진 두 switch를 enum(또는 인터페이스+구현)으로 옮겼는가 |
★★★ |
| 자기 책임 | 각 결제 수단이 자기 수수료와 이름을 아는가 | ★★★ |
| 확장성 | 새 수단이 한 곳 추가로 끝나는가(흩어진 switch 제거) | ★★★ |
| 언어 대조 | Python 딕셔너리 스케치를 시도했는가(여유) | ★☆☆ |
풀이 예시
❌ Before — 같은 결제 수단 분기가 fee와 label 두 곳에 복사돼 있다(산탄총 수술의 조짐).
public int fee(String method, int amount) {
switch (method) {
case "CARD":
return amount * 2 / 100;
case "BANK":
return 0;
case "PHONE":
return amount * 5 / 100;
default:
throw new IllegalArgumentException(method);
}
}
public String label(String method) {
switch (method) {
case "CARD":
return "신용카드";
case "BANK":
return "계좌이체";
case "PHONE":
return "휴대폰";
default:
throw new IllegalArgumentException(method);
}
}
✅ After — 각 결제 수단이 자기 수수료와 이름을 알게 한다.
public enum PaymentMethod {
CARD("신용카드") {
@Override
public int fee(int amount) {
return amount * 2 / 100;
}
},
BANK("계좌이체") {
@Override
public int fee(int amount) {
return 0;
}
},
PHONE("휴대폰") {
@Override
public int fee(int amount) {
return amount * 5 / 100;
}
};
private final String label;
PaymentMethod(String label) {
this.label = label;
}
public String label() {
return label;
}
public abstract int fee(int amount);
}
두 switch가 통째로 사라졌어요. 이제 새 결제 수단 POINT가 생겨도, 흩어진 switch를 찾아다닐 필요 없이 enum 상수 하나만 추가하면 됩니다. 수수료 계산과 표시 이름이 한 상수 안에 같이 있으니, "이 수단은 이렇게 동작한다"가 한곳에 모였고요. Step 2에서 본 산탄총 수술 냄새를 다형성으로 없앤 모습이에요.
여유가 있었다면 파이썬에서는 이렇게 스케치할 수 있어요. 정적 타입이 없으니 enum 대신 "수단 → (수수료 함수, 이름)" 딕셔너리로 같은 일을 합니다.
PAYMENTS = {
"CARD": (lambda amount: amount * 2 // 100, "신용카드"),
"BANK": (lambda amount: 0, "계좌이체"),
"PHONE": (lambda amount: amount * 5 // 100, "휴대폰"),
}
def fee(method, amount):
return PAYMENTS[method][0](amount)
def label(method):
return PAYMENTS[method][1]
💡 튜터의 한마디 — 여기서 다형성이 빛난 결정적 이유는 "같은 타입 분기가 두 곳(fee·label)에 반복됐다"는 점이에요. 만약 결제 수단 분기가 fee 한 곳에만 있고 앞으로 늘 일도 없다면, switch 하나가 오히려 더 읽기 쉽습니다. 분기가 여러 곳에 복사되거나 계속 늘어날 게 보일 때, 그때 다형성을 꺼내세요. C-3에서 본 "세 번 규칙"이 여기에도 그대로 적용됩니다.
생각해볼 주제
🤔 [생각해볼 주제 1] 특성화 테스트가 버그까지 고정한다면, 그게 정말 안전망일까?
문제 상황 요약
특성화 테스트는 "옳은 값"이 아니라 "현재 값"을 고정합니다. 그렇다면 레거시 코드에 이미 버그가 있을 때, 특성화 테스트는 그 버그까지 "정상"으로 굳혀 버려요. 리팩토링 뒤에도 버그는 그대로 살아남죠. 이건 안전망의 한계일까요, 아니면 의도된 설계일까요?
튜터의 가이드 및 해설
결론부터 말하면, 이건 한계가 아니라 의도된 설계예요. 핵심은 "리팩토링"과 "버그 수정"이 서로 다른 작업이라는 데 있습니다.
특성화 테스트는 명세(이래야 한다)가 아니라 사진(지금 이렇다)이에요. 리팩토링의 목적은 "동작을 바꾸지 않고 구조만" 바꾸는 것이고, 그게 안 바뀌었음을 증명하려면 "현재 동작"이 기준이어야 합니다. 만약 특성화 테스트가 "옳은 값"을 검사한다면, 리팩토링하다 깨졌을 때 그게 구조 변경 탓인지 원래 있던 버그 탓인지 구분이 안 돼요.
그래서 순서가 중요합니다. 리팩토링 도중에 버그를 발견하면 이렇게 합니다. 먼저 그 버그를 메모만 해 두고, 하던 구조 정리를 마칩니다(특성화 테스트는 계속 통과). 구조가 깨끗해진 뒤에야, 비로소 버그를 고치는 별도의 변경을 합니다. 이때는 테스트의 기댓값을 "옳은 값"으로 바꾸고(테스트가 빨갛게 됨), 코드를 고쳐 다시 통과시키죠. 이게 Step 3에서 본 "두 개의 모자를 동시에 쓰지 마라"의 실전 적용이에요.
- Option A (한 번에 다 고치기): 구조도 바꾸고 버그도 같이 고친다 → 빠른 듯하지만, 회귀가 나면 원인을 못 가린다.
- Option B (분리하기): 사진 찍기 → 구조 정리 → 버그 수정을 단계로 나눈다 → 각 단계가 작고 안전하다.
- 현업에서는 보통 Option B를 따릅니다. 커밋도 "리팩토링"과 "버그 수정"을 나눠, 나중에 문제가 생겼을 때 어느 변경이 원인인지 추적할 수 있게 합니다.
🎯 면접에선 이렇게 나온다
"특성화 테스트는 명세가 아니라 현재 동작의 사진입니다. 버그까지 고정하는 게 한계가 아니라 의도예요. 리팩토링은 '구조만', 버그 수정은 '동작 변경'이라 둘을 섞으면 회귀가 났을 때 원인을 가릴 수 없습니다. 그래서 저는 먼저 현재 동작을 고정해 구조를 안전하게 바꾸고, 버그는 그 위에서 기댓값을 옳은 값으로 바꾸는 별도 커밋으로 고칩니다. '두 개의 모자를 동시에 쓰지 않는다'는 원칙의 적용입니다."
💡 실무에선
대량의 레거시는 입출력을 통째로 비교하는 승인 테스트(approval test) 도구를 써서 골든 마스터를 자동 생성합니다. 그 과정에서 발견한 버그는 곧장 고치지 않고 이슈로 분리해, 리팩토링 PR과 버그 수정 PR을 따로 올리는 게 정석이에요.
🤔 [생각해볼 주제 2] 보이스카웃 규칙 vs "건드리지 마라" — 어느 쪽이 옳을까?
문제 상황 요약
"파일을 연 김에 조금 더 깨끗하게"가 보이스카웃 규칙입니다. 그런데 현장엔 정반대 격언도 있어요. "돌아가는 코드는 건드리지 마라(If it ain't broke, don't fix it)." 멀쩡한 코드를 리팩토링하다 오히려 버그를 넣는 일이 실제로 많거든요. 두 격언은 충돌하는 것처럼 보입니다. 언제 어느 쪽을 따라야 할까요?
튜터의 가이드 및 해설
이 둘은 사실 충돌이 아니라, 조건이 다른 같은 말이에요. "건드리지 마라"는 "영원히 손대지 마라"가 아니라 "근거 없이, 안전망 없이 함부로 건드리지 마라"는 경고입니다. 보이스카웃 규칙은 "안전망 위에서, 이미 여는 김에 조금씩 개선하라"는 권유고요. 둘을 가르는 기준을 몇 가지로 정리할 수 있어요.
-
안전망의 유무: 테스트가 있으면 보이스카웃 규칙을 따라도 안전합니다. 테스트가 없다면 먼저 특성화 테스트부터 치거나, 아니면 손대지 않는 게 맞아요.
-
변경 빈도: 자주 바뀌는 코드는 깨끗하게 만들 가치가 큽니다(투자 회수가 빠름). 몇 년째 안 바뀌고 잘 도는 코드는 굳이 건드릴 이유가 적어요.
-
작업 맥락: 내가 지금 그 파일을 어차피 고치는 중이라면, 주변을 조금 정리하는 건 보이스카웃 규칙입니다. 반대로 멀쩡한 파일을 "정리하고 싶어서" 일부러 여는 건 "건드리지 마라"에 가까워요.
-
코드 수명: 곧 폐기될 코드는 그냥 둡니다. 오래 살 코드일수록 정리의 값이 큽니다.
-
Option A (적극적 보이스카웃): 보이는 냄새는 다 고친다 → 코드가 점점 좋아지지만, 안전망 없이 하면 사고 위험.
-
Option B (보수적 동결): 꼭 필요한 것만 건드린다 → 안전하지만, 방치하면 코드가 썩는다.
-
현업에서는 보통 둘을 합칩니다. "내가 이미 여는 파일은, 안전망이 있다면, 그 주변만 조금 정리한다." 멀쩡한 코드를 일부러 찾아다니며 갈아엎지는 않아요.
🎯 면접에선 이렇게 나온다
"두 격언은 충돌이 아니라 조건이 다릅니다. '건드리지 마라'는 안전망 없는 무분별한 변경에 대한 경고이고, 보이스카웃 규칙은 안전망 위 점진적 개선이에요. 제 판단 기준은 세 가지입니다. ① 테스트가 있는가, ② 자주 바뀌는 코드인가, ③ 내가 이미 그 파일을 여는 작업인가. 셋이 맞으면 들른 김에 정리하고, 안전망도 없는데 멀쩡한 코드를 일부러 열어 고치는 건 하지 않습니다."
💡 실무에선
PR에서 "리팩토링 커밋"과 "기능 커밋"을 분리하면 두 격언을 동시에 만족시킬 수 있어요. 리뷰어가 "이 diff는 순수 구조 변경"과 "이 diff는 동작 변경"을 따로 볼 수 있어서, 보이스카웃 규칙을 따르면서도 변경 추적이 흐려지지 않습니다.
🤔 [생각해볼 주제 3] 패턴은 도착지, 리팩토링은 길 — 무엇을 먼저 배워야 할까?
문제 상황 요약
오늘 우리는 "다형성으로 조건문 교체"라는 리팩토링 기법이 C-3의 전략·상태 패턴이라는 도착지로 가는 길이라고 했습니다. 그렇다면 무엇을 먼저 아는 게 중요할까요? 패턴(도착지)일까요, 리팩토링 기법(길)일까요? "패턴을 알아야 어디로 갈지 안다"는 입장과 "냄새를 알아야 무엇을 고칠지 안다, 패턴은 결과일 뿐"이라는 입장이 부딪힙니다.
튜터의 가이드 및 해설
저는 냄새가 출발점이어야 한다는 쪽이에요. 다만 패턴을 몰라도 된다는 뜻은 아닙니다. 둘의 관계를 잘 보면 답이 보여요.
패턴을 먼저, 그것만 외우면 위험한 함정에 빠집니다. C-3 마지막에 본 "패턴을 위한 패턴"이에요. 망치를 들면 모든 게 못으로 보이듯, 패턴을 많이 알수록 멀쩡한 코드에도 자꾸 패턴을 욱여넣고 싶어집니다. "이거 그냥 함수 하나면 될 걸 클래스 다섯 개로 만들었네요"라는 코드 리뷰 지적이 그래서 나와요.
반대로 냄새부터 익히면 출발이 건강합니다. "여기 같은 조건문이 여러 곳에 반복되네(산탄총 수술)"라고 문제를 먼저 인식하고, "다형성으로 조건문 교체" 기법으로 고치다 보면, 도착하는 곳이 자연스럽게 전략이나 상태 패턴이에요. 즉 패턴은 목표가 아니라 결과입니다.
그렇다고 패턴이 쓸모없는 건 아니에요. 패턴을 알면 "이 if 지옥을 어디로 끌고 갈지"가 막히지 않습니다. 길을 가다 도착지의 사진을 미리 본 셈이라 빨라요. 그래서 가장 건강한 순서는 이래요. 냄새로 문제를 인식하고(출발), 리팩토링 기법으로 고치고(이동), 그 결과 도달한 구조에 패턴이라는 이름을 붙입니다(공통 어휘). 패턴은 "미리 휘두를 망치"가 아니라 "도착지를 부르는 이름"인 거죠.
- Option A (패턴 먼저): 도착지를 알아야 방향이 보인다 → 빠르지만 "패턴을 위한 패턴" 함정 위험.
- Option B (냄새 먼저): 무엇을 고칠지 알아야 한다, 패턴은 결과 → 건강하지만 도착지를 모르면 길이 막힐 수 있음.
- 현업에서는 보통 냄새와 기법을 먼저 익히고, 패턴은 "이미 한 리팩토링에 이름 붙이기"로 받아들입니다. 그래야 패턴을 알면서도 카고컬트에 빠지지 않아요.
🎯 면접에선 이렇게 나온다
"저는 냄새가 출발점이어야 한다고 봅니다. 패턴을 먼저 외우면 모든 문제가 패턴으로 보이는 '망치 함정'에 빠지기 쉬워요. 냄새로 문제를 인식하고 리팩토링 기법으로 고치다 도달하는 곳이 패턴이고, 패턴 이름은 그 도착지를 부르는 공통 어휘라고 생각합니다. 패턴을 아는 건 길이 막히지 않게 해 주지만, 그건 '미리 휘두를 망치'가 아니라 '도착지를 부르는 이름'이어야 합니다."
💡 실무에선
신입에게는 디자인 패턴 카탈로그를 통째로 외우게 하기보다, 냄새와 리팩토링 기법부터 가르치는 게 효과적이에요. 그런 다음 "방금 네가 한 그 리팩토링, 그게 전략 패턴이야"라고 이름을 붙여 주면, 패턴을 살아 있는 도구로 받아들이고 남용하지 않습니다.