DesignThroughTest
주니어 개발자들을 위한 패턴 언어 - 테스트를 검증 도구가 아닌 설계 도구로 사용하는 방법
Contents
The Story: The Imtestable Fortress vs. The User's Wish
두 명의 개발자, 민수와 하나가 '주문 할인 계산기'를 만들고 있다.
민수의 접근 (Implementation First): 민수는 할인 로직의 모든 복잡한 경우의 수를 생각하며 DiscountManager 클래스를 구현했다. "데이터베이스에서 사용자 등급을 가져오고, 현재 진행 중인 이벤트를 API로 조회하고..." 코드를 다 짠 후, 그는 테스트를 작성하려고 했다. "어... 테스트에서 데이터베이스 연결을 어떻게 흉내 내지? API 서버가 죽으면 테스트도 실패하나?" 민수의 코드는 외부 세상과 너무 단단히 결합되어 있어서, 테스트를 하려면 거대한 설정이 필요했다. 결국 그는 테스트 작성을 포기했다. "이건 테스트하기 너무 어려워."
하나의 접근 (Test First): 하나는 구현을 시작하기 전에, 자신이 이 코드를 어떻게 사용하고 싶은지 상상하며 테스트부터 적었다.
test "VIP user gets 10% discount" {
calculator = DiscountCalculator(user_grade="VIP")
price = calculator.apply(10000)
assert price == 9000
}"데이터베이스? API? 그건 나중에 생각하자. 지금 중요한 건 계산 로직이야." 하나는 테스트가 요구하는 대로 DiscountCalculator를 만들었다. 필요한 데이터는 생성자로 받게 설계되었다. 결과적으로 하나의 코드는 외부 의존성 없이 순수한 로직만 남았고, 사용하기 쉬웠으며, 테스트하기도 쉬웠다.
Context
새로운 기능을 구현해야 한다. 요구사항은 어느 정도 이해했다(TinyExperiment & TwoWorlds). 이제 코드를 작성하려고 한다.
일상적인 상황:
- "구현 다 하고 테스트 짜야지"라고 생각한다.
막상 구현 후 테스트를 짜려니, private 메서드를 테스트하고 싶거나 의존성 주입이 어려워 막막하다.
- 코드가 복잡해져서 "이걸 어떻게 호출해야 하지?" 스스로도 헷갈린다.
- 테스트 코드가 비즈니스 로직보다 더 복잡해진다(Mock 떡칠).
당신은 테스트를 숙제(Verification)로 여기고 있다. 하지만 테스트는 설계(Design)의 기회다.
Problem
구현을 먼저 하면, "작동하는 코드"는 얻을 수 있지만 "사용하기 편한 코드"는 얻기 힘들다.
구현 관점에서 코드를 짜면 내부 구현의 편의성을 우선하게 된다.
- "여기서 DB 바로 조회하면 편하겠네." (결합도 증가)
- "이 변수는 전역으로 쓰자." (부작용 증가)
나중에 이 코드를 사용하는 쪽(클라이언트)이나 테스트 코드에서 보면, 사용하기가 끔찍하게 불편하다는 것을 발견한다. 하지만 이미 코드는 굳어졌고, 고치기엔 너무 늦었다.
Solution
코드를 작성하기 전에, 그 코드를 사용하는 테스트를 먼저 작성하라.
Allen Holub은 "Design by Coding"을 강조했다. 소프트웨어 공학에서 설계(Design)는 도면을 그리는 것이 아니라 코드를 작성하는 과정 그 자체다. 따라서 테스트를 먼저 작성하는 것은 구현 전에 설계를 수행하는 가장 구체적인 활동이다.
Principle 1: Coding IS Design (코딩이 곧 설계다)
Allen Holub이 말했듯, 아키텍처 다이어그램은 설계가 아니다. 그것은 여행 지도일 뿐이다. 실제 설계 결정은 코드를 타이핑하는 순간 일어난다.
테스트를 작성하는 것은 "무엇을 만들 것인가(Spec)"와 "어떻게 사용할 것인가(Interface)"를 결정하는 설계 행위다.
구현 코드를 작성하는 것은 그 설계를 만족시키는 건축(Construction) 행위다.
Principle 2: Test is the First Client (테스트는 첫 번째 클라이언트다)
테스트 코드는 당신이 만드는 모듈의 첫 번째 사용자다.
- 테스트를 짜기 힘들다면? 당신의 설계가 나쁜 것이다(사용하기 불편한 것이다).
- 테스트에서 준비 과정(Setup)이 너무 길다면? 의존성이 너무 많은 것이다.
- 테스트 이름을 짓기 어렵다면? 함수가 너무 많은 일을 하는 것이다.
테스트가 보내는 고통의 신호(Pain Signal)를 무시하지 마라. 그것은 설계를 개선하라는 신호다.
Principle 3: Wishful Thinking (소망적 사고)
구현 방법을 고민하지 말고, "이런 인터페이스가 있었으면 좋겠다"는 소망을 코드로 적어라.
"여기서 calculate()만 호출하면 답이 나오면 좋겠어."
"이 객체는 User 객체 전체가 아니라 grade만 알면 좋겠어."
이 소망대로 테스트를 작성하면, 코드는 자연스럽게 느슨한 결합(Loose Coupling)과 높은 응집도(High Cohesion)를 가지게 된다.
Principle 4: Externalize Dependencies (의존성 드러내기)
테스트를 먼저 짜면 숨겨진 의존성(Global state, Singleton, DB calls)을 사용할 수 없다. 자연스럽게 필요한 모든 것을 인자(Argument)나 생성자(Constructor)로 받게 된다. 이것이 바로 의존성 주입(Dependency Injection)의 본질이다. 테스트가 그것을 강제한다.
Real Examples
Example 1: Decoupling Time
"오후 10시 이후에는 주문 불가" 로직을 만든다.
Implementation First (민수):
def place_order(order):
current_hour = datetime.now().hour // 시스템 시간에 강하게 결합!
if current_hour >= 22:
raise Error("Too late")
...테스트하려면? 시스템 시간을 조작해야 한다. 끔찍하다.
Design Through Test (하나): "테스트할 때 시간을 마음대로 넣고 싶어."
test "cannot order after 10pm" {
service = OrderService(clock=FixedClock(22:00)) // 시간을 주입받길 원함
assert throws service.place_order(order)
}이 테스트를 통과시키려면, OrderService는 Clock을 주입받아야만 한다. 설계가 개선되었다.
Example 2: Interface Discovery
복잡한 파싱 로직을 만든다.
Wishful Thinking: "그냥 파일 경로를 주면 파싱된 객체가 툭 튀어나왔으면 좋겠어."
parser = LogParser()
result = parser.parse("error.log")이렇게 쓰고 보니, File 객체를 넘기는 게 더 나을 것 같다.
parser = LogParser()
result = parser.parse(File("error.log"))구현하기 전에 API 디자인을 수정했다. 비용이 0이다.
Common Pitfalls
"Test First takes too long"
처음에는 느리다. 하지만 "디버깅 시간"과 "나중에 구조를 뜯어고치는 시간"을 포함하면 훨씬 빠르다. 그리고 무엇보다, 깔끔한 인터페이스는 평생 시간을 절약해준다.
Testing Implementation Details
테스트가 "어떻게(How)"를 검증하면 안 된다. "무엇(What)"을 검증해야 한다.
Bad: cache 변수에 값이 저장되었는지 확인.
- Good: 두 번째 호출이 더 빠른지 확인.
설계를 테스트한다는 것은 행동(Behavior)과 인터페이스(Interface)를 테스트하는 것이다.
Mocking Hell
테스트를 짜려는데 Mock 객체가 10개 필요하다면? "테스트를 잘 짜는 법"을 고민할 게 아니라, "설계를 뜯어고칠 때"다. 그 객체는 너무 많은 일을 하고 있다. 쪼개라(SingleResponsibility).
Connection to Other Patterns
BabySteps - 테스트를 하나 만들고(Red), 구현하고(Green), 리팩토링한다. 이 사이클이 BabyStep이다. 실천 방법
TinyExperiment - 설계를 확신할 수 없을 때, 테스트로 "사용 경험"을 실험해본다. 검증
CleanIsolation - 테스트하기 쉬운 코드는 격리된 코드다. DesignThroughTest는 격리를 유도한다. 결과
LanguageBuilding - 테스트 코드는 도메인 언어로 쓰여진 첫 번째 문서다. 표현
Signs of Success
- 코드를 구현할 때 "이거 어떻게 테스트하지?"라는 고민을 하지 않는다(이미 테스트가 있으니까).
- 클래스나 함수의 의존성이 명확하게 생성자에 드러난다.
- 테스트 코드가 문서처럼 읽혀서, 사용법을 알기 위해 테스트를 본다.
- 모듈 간의 결합도가 낮아서 하나만 떼어내어 재사용하기 쉽다.
The Ultimate Insight
테스트는 버그를 찾기 위한 것이 아니다. 테스트는 설계의 결함을 찾기 위한 것이다.
테스트를 먼저 작성하는 순간, 당신은 구현자(Implementer)가 아니라 사용자(User)가 된다. 사용자 관점에서 설계된 코드는 언제나 구현자 관점에서 설계된 코드보다 낫다.
CategoryPatternLanguage CategoryProgramming CategoryTDD CategoryDesign
