
1-9장!!
Part1. Code
Chapter9. The Clean Method
과거와 현재의 개발 루프를 비교해보자
| 과거 | 현재 | |
| 루프 시간 | 1-3일 | 5초-1시간 |
| 코드 작성 | 손으로 많은 양 | 작은 조각씩(AI 도움 가능) |
| 비용 | 코드 한 줄당 막대함 | 사소함 |
| 전략 | 사전 계획에 많은 시간 투자 | 실패를 통한 탐색 |
과거에는 변경 비용이 높아서, 현재는 진행 속도가 너무 빨라서 '코드를 정리한다는 생각'을 건너뛰게 된다.
Make It Right
왜 우리는 코드 정리를 건너뛸까?
1. 과거: 종이에 손으로 작성해서 수정이 물리적으로 불편했음. 그래서 pass!
2. 현재: IDE가 편리하지만, 빠른 진행 속도 때문에 정리가 시간 낭비처럼 느껴짐. 나중에 해!! pass!!
First, make it work. Then, make it right.
너무 많이 들어서 귀에 딱지 생길 것 같아요....
아무튼 켄트 벡 아조씨가 '먼저 작동하게 만들어라. 그 다음 올바르게 만들어라.'라고 했다.
하지만, 대부분의 개발자는 첫 번째만 하고, 두 번째는 건너뛴다.
대부분의 개발자는 자신이 고용된 이유가 첫 번째 때문이라고 생각한다!
그래서, 두 번째를 시간낭비로 생각함. (<<왜요???왜????? 그래서 그렇게 레거시가 만들어진거임????)
아무튼 현실적으로 봤을 때,
우리는 '두 가지 목표를 동시에 머릿속에 유지하지 못한다.'
작동하는 코드를 만드는데 정신적 에너지를 다 써버려서, 코드가 작동할 때 쯤엔 엉망이 되어있다.
그래서, 다 작동하게 만든 후에 그제서야 치울 여력이 생기는 것이다.
'정리하는 행위'가 이 '엉망이긴 하지만 그래도 작동하는 코드'의 결과를 보장할 수 있을까?
그건 '테스트를 병행'할 때 가능하다.
Clean code depends on fast, convenient, and comprehensive tests.
리팩토링 루프 (보통 5초~2분 소요)
1. 하나의 작은 것을 정리한다.
2. 테스트를 실행한다.
3. 테스트가 실패하면 되돌린다.
4. 1단계로 돌아간다.
리팩토링이란, 관찰 가능한 동작을 변경하지 않으면서,
소프트웨어를 더 이해하기 쉽고 수정 비용이 저렴하도록 내부 구조를 변경하는 것이다.
- Martin Fowler
Q. 코드를 작성하는 루프와 리팩토링하는 루프 사이를 얼마나 자주 전환해야 할까?
- As often as possible. '가능한 한 자주'

1. 작은 것을 작동하게 만든다
a. 테스트와 함께 작은 코드 조각을 작성한다.
b. 컴파일하고 테스트한다.
c. 너무 지저분하지 않으면 1-a.로 돌아간다.
2. 정리한다.
a. 하나의 작은 것을 정리한다.
b. 테스트를 실행한다.
c. 테스트가 실패하면 되돌리고 2-a로 돌아간다.
d. 정리할 것이 있으면 2a 단계로 돌아간다.
3. 1단계로 돌아간다.
이 1~3단계 루프는 테스트의 규율에 따라 몇 분에서 한 시간 정도 소요된다.
결국 당신이 multitalented mega 존재가 아니면 그냥 만들고 열심히 계속 치우면서
엉망 코드가 정리가 불가능할 수준이 되지 않도록 경계해야 한다는 뜻이다.
저자가 스시로 비유를 했는데,
초밥을 만드는 것도 멈추지 않고, 도구와 환경을 정리하는 것도 멈추지 않는 스시 셰프처럼 되어라.
저번엔 신문 기사 쓰기와 같다더니 이젠 초밥장인이 되라고 함.
개발자 빼고 다 되라고 함.
Example
실제로 예시 고치는 걸 보여주겠다고 함
이건 본문에서 직접 확인하는 게 좋을 듯.
저자는 본문에서 가상의 bobolia 주를 만들고, 이 주의 세금계산기를 TDD로 만들어 가는 과정을 보여줬다.
나는 대신 merge 게임 점수 계산기를 claude와 함께 예시로 만들어보겠다
*머지 게임: 수박게임처럼 같은 단계 아이템을 합치면, 다음 단계 아이템이 됨. 최고 단계 아이템을 제한 공간 내에 만들면 성공.
1단계: 가장 단순한 케이스
머지게임 기본 규칙: 결과 아이템 레벨 * 10점
레벨 1짜리 아이템 2개를 머지시키면 레벨2가 되고, 2*10=20점을 얻는다
TDD 방식대로, 먼저 실패하는 테스트를 작성하고 최소한의 코드로 통과시킨다.
# merge_score_test.py
import unittest
import merge_score
class MergeScoreTests(unittest.TestCase):
def test_basic_merge_score(self):
calculator = merge_score.ScoreCalculator()
merge_event = {"result_level": 2}
self.assertEqual(20, calculator.get_score(merge_event))
if __name__ == '__main__':
unittest.main()
위 테스트를 통과하는 최소한의 코드를 작성한다.
# merge_score.py
class ScoreCalculator:
def get_score(self, merge_event):
return merge_event["result_level"] * 10
2단계: 콤보 시스템 추가
- 2초 내 연속 머지 시 콤보가 발동된다.
- 콤보 2회: 1.5배, 콤보 3회: 2배, 콤보 4회 이상: 2.5배
def test_combo_2_multiplier(self):
calculator = merge_score.ScoreCalculator()
merge_event = {"result_level": 2, "combo": 2}
self.assertEqual(30, calculator.get_score(merge_event)) # 20 * 1.5
def test_combo_3_multiplier(self):
calculator = merge_score.ScoreCalculator()
merge_event = {"result_level": 2, "combo": 3}
self.assertEqual(40, calculator.get_score(merge_event)) # 20 * 2
def test_combo_4_plus_multiplier(self):
calculator = merge_score.ScoreCalculator()
merge_event = {"result_level": 2, "combo": 5}
self.assertEqual(50, calculator.get_score(merge_event)) # 20 * 2.5
이 테스트에 맞게 프로덕션 코드를 수정해보자
class ScoreCalculator:
def get_score(self, merge_event):
base_score = merge_event["result_level"] * 10
combo = merge_event.get("combo", 1)
if combo >= 4:
multiplier = 2.5
elif combo == 3:
multiplier = 2
elif combo == 2:
multiplier = 1.5
else:
multiplier = 1
return int(base_score * multiplier)
어라, 근데 이렇게 하면 아까 만든 test_basic_merge_score가 깨진다!
거기엔 'combo' 키가 없기 때문이다.
이런 새 필드를 추가할 때마다 기존 테스트를 전부 수정해야 하다니!
이건 테스트 설계에 문제가 있다는 신호이다.
3단계: 테스트 설계 개선
make_event 헬퍼 함수를 만들어서 기본값을 제공하자.
class MergeScoreTests(unittest.TestCase):
def setUp(self):
self.calculator = merge_score.ScoreCalculator()
def make_event(self, **kwargs):
"""기본값을 제공하는 헬퍼 함수"""
event = {
"result_level": 1,
"combo": 1,
}
event.update(kwargs)
return event
def test_basic_merge_score(self):
event = self.make_event(result_level=2)
self.assertEqual(20, self.calculator.get_score(event))
def test_combo_2_multiplier(self):
event = self.make_event(result_level=2, combo=2)
self.assertEqual(30, self.calculator.get_score(event))
def test_combo_3_multiplier(self):
event = self.make_event(result_level=2, combo=3)
self.assertEqual(40, self.calculator.get_score(event))
def test_combo_4_plus_multiplier(self):
event = self.make_event(result_level=2, combo=5)
self.assertEqual(50, self.calculator.get_score(event))
이제 새 필드가 추가되어도 make_event만 수정하면 나머지 테스트에 자동으로 적용된다.
4단계: 특수 아이템 보너스
황금 아이템과 무지개 아이템에 보너스를 추가하자!
- 황금(gold): +50% 보너스
- 무지개(rainbow): +100% 보너스
먼저 make_event에 기본값을 추가한다
def make_event(self, **kwargs):
event = {
"result_level": 1,
"combo": 1,
"item_type": "normal", # 새 필드 추가
}
event.update(kwargs)
return event
그리고 특수 아이템에 관한 테스트를 추가한다
def test_gold_item_bonus(self):
event = self.make_event(result_level=2, item_type="gold")
self.assertEqual(30, self.calculator.get_score(event)) # 20 * 1.5
def test_rainbow_item_bonus(self):
event = self.make_event(result_level=2, item_type="rainbow")
self.assertEqual(40, self.calculator.get_score(event)) # 20 * 2
def test_gold_item_with_combo(self):
# 골드(1.5배) + 콤보3(2배) = 20 * 1.5 * 2 = 60
event = self.make_event(result_level=2, item_type="gold", combo=3)
self.assertEqual(60, self.calculator.get_score(event))
이제 슬슬 본문 코드가 복잡해진다
class ScoreCalculator:
def get_score(self, merge_event):
base_score = merge_event["result_level"] * 10
combo = merge_event.get("combo", 1)
item_type = merge_event.get("item_type", "normal")
# 콤보 배수
if combo >= 4:
combo_mult = 2.5
elif combo == 3:
combo_mult = 2
elif combo == 2:
combo_mult = 1.5
else:
combo_mult = 1
# 아이템 보너스
if item_type == "rainbow":
item_mult = 2
elif item_type == "gold":
item_mult = 1.5
else:
item_mult = 1
return int(base_score * combo_mult * item_mult)
5단계: 난이도 보정
- 스테이지 난이도에 따라 점수가 달라진다
- easy: 1배, normal: 1.2배, hard: 1.5배
이것 역시 make_event에 먼저 추가해주고, 관련 테스트를 만들어준다.
def make_event(self, **kwargs):
event = {
"result_level": 1,
"combo": 1,
"item_type": "normal",
"difficulty": "normal", # 새 필드
}
event.update(kwargs)
return event
def test_hard_difficulty_bonus(self):
event = self.make_event(result_level=2, difficulty="hard")
self.assertEqual(30, self.calculator.get_score(event)) # 20 * 1.5
def test_easy_difficulty(self):
event = self.make_event(result_level=2, difficulty="easy")
self.assertEqual(20, self.calculator.get_score(event)) # 20 * 1
으악 이제 get_score 코드가 너무 길어졌다
def get_score(self, merge_event):
base_score = merge_event["result_level"] * 10
combo = merge_event.get("combo", 1)
item_type = merge_event.get("item_type", "normal")
difficulty = merge_event.get("difficulty", "normal")
# 콤보 배수
if combo >= 4:
combo_mult = 2.5
elif combo == 3:
combo_mult = 2
elif combo == 2:
combo_mult = 1.5
else:
combo_mult = 1
# 아이템 보너스
if item_type == "rainbow":
item_mult = 2
elif item_type == "gold":
item_mult = 1.5
else:
item_mult = 1
# 난이도 보정
if difficulty == "hard":
diff_mult = 1.5
elif difficulty == "easy":
diff_mult = 1
else:
diff_mult = 1.2
return int(base_score * combo_mult * item_mult * diff_mult)
6단계: 리팩토링 - 함수 분리
각 배수 계산을 별도 메서드로 분리한다
class ScoreCalculator:
def get_score(self, merge_event):
self.event = merge_event
base_score = self.event["result_level"] * 10
combo_mult = self._get_combo_multiplier()
item_mult = self._get_item_multiplier()
diff_mult = self._get_difficulty_multiplier()
return int(base_score * combo_mult * item_mult * diff_mult)
def _get_combo_multiplier(self):
combo = self.event.get("combo", 1)
if combo >= 4:
return 2.5
elif combo == 3:
return 2
elif combo == 2:
return 1.5
return 1
def _get_item_multiplier(self):
item_type = self.event.get("item_type", "normal")
if item_type == "rainbow":
return 2
elif item_type == "gold":
return 1.5
return 1
def _get_difficulty_multiplier(self):
difficulty = self.event.get("difficulty", "normal")
if difficulty == "hard":
return 1.5
elif difficulty == "easy":
return 1
return 1.2
7단계: 테이블 주도 설계
if-elif 체인들은 나중에 값이 바뀔 가능성이 높다.
테이블로 바꾸자.
class ScoreCalculator:
COMBO_TABLE = {1: 1, 2: 1.5, 3: 2} # 4 이상은 2.5
ITEM_TABLE = {"normal": 1, "gold": 1.5, "rainbow": 2}
DIFFICULTY_TABLE = {"easy": 1, "normal": 1.2, "hard": 1.5}
def get_score(self, merge_event):
self.event = merge_event
base_score = self.event["result_level"] * 10
combo_mult = self._get_combo_multiplier()
item_mult = self._get_item_multiplier()
diff_mult = self._get_difficulty_multiplier()
return int(base_score * combo_mult * item_mult * diff_mult)
def _get_combo_multiplier(self):
combo = self.event.get("combo", 1)
return self.COMBO_TABLE.get(combo, 2.5)
def _get_item_multiplier(self):
item_type = self.event.get("item_type", "normal")
return self.ITEM_TABLE.get(item_type, 1)
def _get_difficulty_multiplier(self):
difficulty = self.event.get("difficulty", "normal")
return self.DIFFICULTY_TABLE.get(difficulty, 1.2)
배수 값을 바꾸거나, 새 타입을 추가할 때 테이블만 수정하면 된다.
8단계: 최소 점수 보장
어떤 상황에서도 최소 5점을 보장하자는 항목이 추가 되었다.
def test_minimum_score_guarantee(self):
# 레벨 1, 콤보 없음, 일반 아이템, Easy = 1 * 10 * 1 * 1 * 1 = 10
event = self.make_event(result_level=1, difficulty="easy")
self.assertEqual(10, self.calculator.get_score(event))
def test_minimum_score_floor(self):
# 극단적으로 낮은 점수도 5점은 보장
# (실제로는 레벨 0이 없겠지만, 방어적으로)
event = self.make_event(result_level=0)
self.assertEqual(5, self.calculator.get_score(event))
테스트 코드를 이렇게 짜고, 돌려보면 둘 다 get_score 값이 0이 나올 것이다.
이럴 땐 엣지케이스를 개발자가 떠올려서 방어해줘야 한다.
return max(5, 기존 계산식) 이런 형태.
9단계: 아키텍쳐 분리
시스템이 커지고 있다. 변경 축(지난 장의 action 개념)을 기준으로 분리하자.
# score_calculator.py
class ScoreCalculator:
def __init__(self, combo_calc, item_calc, difficulty_calc):
self.combo_calc = combo_calc
self.item_calc = item_calc
self.difficulty_calc = difficulty_calc
def get_score(self, merge_event):
base_score = merge_event["result_level"] * 10
combo_mult = self.combo_calc.get_multiplier(merge_event)
item_mult = self.item_calc.get_multiplier(merge_event)
diff_mult = self.difficulty_calc.get_multiplier(merge_event)
final_score = int(base_score * combo_mult * item_mult * diff_mult)
return max(5, final_score)
class MergeEvent:
"""이벤트 데이터 캡슐화"""
def __init__(self, data):
self.data = data
@property
def result_level(self):
return self.data.get("result_level", 1)
@property
def combo(self):
return self.data.get("combo", 1)
@property
def item_type(self):
return self.data.get("item_type", "normal")
@property
def difficulty(self):
return self.data.get("difficulty", "normal")
# combo_calculator.py
class ComboCalculator:
TABLE = {1: 1, 2: 1.5, 3: 2}
def get_multiplier(self, event):
combo = event.get("combo", 1)
return self.TABLE.get(combo, 2.5)
# item_calculator.py
class ItemCalculator:
TABLE = {"normal": 1, "gold": 1.5, "rainbow": 2}
def get_multiplier(self, event):
item_type = event.get("item_type", "normal")
return self.TABLE.get(item_type, 1)
# difficulty_calculator.py
class DifficultyCalculator:
TABLE = {"easy": 1, "normal": 1.2, "hard": 1.5}
def get_multiplier(self, event):
difficulty = event.get("difficulty", "normal")
return self.TABLE.get(difficulty, 1.2)
- 콤보 계산: 게임 밸런스 팀이 자주 수정함
- 아이템 보너스: 기획 팀이 이벤트마다 수정함
- 난이도 보정: 스테이지 설계 팀이 관리함
def setUp(self):
combo_calc = ComboCalculator()
item_calc = ItemCalculator()
diff_calc = DifficultyCalculator()
self.calculator = ScoreCalculator(combo_calc, item_calc, diff_calc)
test의 setup이 main 역할을 한다.
최종 구조
ScoreCalculator (고수준 정책)
/ | \
v v v
Combo Item Difficulty
Calculator Calculator Calculator
\ | /
v v v
MergeEvent (데이터 캡슐화)
- SRP: 각 calculator가 하나의 배수 계산만 담당
- DIP: ScoreCalculator가 concrete class가 아닌 inteface에 의존 (구체적인 예시가 아니라 이런걸 담자~라는 약속에 의존)
- Strategy 패턴: 배수 계산 전략을 주입받음 (어떻게 배수를 계산할지를 갈아끼울 수 있음)
핵심 교훈
1. 켄트 벡: make it work, then make it right.
2. 테스트가 구체적일수록, prod 코드는 general해진다. (일반적으로 통용 가능해진다)
3. 테스트 설계도 중요하다. 속성 하나 추가했다고 테스트 다 수정? ㄴㄴ make_event 같은 헬퍼 함수 하나 만들기
4. 리팩토링 타이밍: 코드가 '지저분하다'고 느껴질 때 리팩토링(6단계). 너무 빨라도, 너무 늦어도 안된다.
Conclusion
As the tests grew in specificity, the production code grew in generality.
- 테스트: 점점 더 구체적인 케이스들을 추가
- 코드: 점점 더 일반적인 구조로 진화
여담으로 저자는 python을 안쓴지 20년 됐는데, 코파일럿이 도와줘서 금방 짤 수 있어서 좋았대요
작성: 2025.01.12. 00:40
'개발자 강화 > 개발 독서' 카테고리의 다른 글
| [Clean Code 2판] 1-11: Be Polite (0) | 2026.02.02 |
|---|---|
| [Clean Code 2판] 1-10: One Thing (0) | 2026.01.25 |
| [3차] Clean Code 스터디 (1) | 2026.01.06 |
| [Clean Code 2판] 1-7: Clean Functions (1) | 2026.01.06 |
| [Clean Code 2판] 1-6: Formatting (1) | 2025.12.25 |