Chapter1 - What is Test-Driven Development
- What is TDD ?
What is TDD ?
1.1 TDD의 정의
TDD(Test-Driven Development)는 개발자가 프로덕션 코드를 작성하기 전에 요구 사항에 집중하여 새로운 기능에 대한 테스트 시나리오를 먼저 생각하고 작성하게 한다. 작성된 테스트 시나리오를 기반으로 테스트 코드를 작성하고 테스를 실행하여 실패 하는 것을 확인한다. 그리고 테스트를 통과 하기 위한 가장 간단한 코드를 작성하고 다시 테스트 코드를 실행하여 테스트가 통과됨을 확인한다. 테스트가 통과된 이후에는 필요에 따라 리팩토링을 할 수 있다. 이러한 주기(Red-Green-Refactor)를 반복하면서 개발하는 것을 TDD 라고 한다.
Figure1 - Red-Green-Refactor Cycle
테스트 시나리오란 제품이 갖춰야 할 기능을 검증하기 위한 작은 단위이다. 쉽게 말해, 특정한 유저 스토리(user story)나 상황을 가정한 후, 그 상황에서 소프트웨어가 올바르게 작동하는지를 확인하기 위한 것이다.
예를 들어, "로그인 기능"에 대한 테스트 시나리오는 다음과 같을 수 있다.
- 사용자가 올바른 이메일과 비밀번호를 입력하면 로그인에 성공한다.
- 잘못된 비밀번호를 입력하면 에러 메시지가 표시된다.
- 이메일이나 비밀번호를 입력하지 않으면 로그인 버튼이 비활성화된다.
1.1.1 전통적 개발 방식
전통적인 개발 방식은 '선(先) 설계, 후(後) 구현' 이라는 명확한 선형적 단계를 갖는다.
- 설계 문서 작성
- 클래스 다이어그램 설계
- 코드 구현
- 테스트 작성 (선택사항)
전통적인 개발 방식의 장점은 개발 초기에 아키텍처와 상세 설계 문서를 작성하기 때문에 프로젝트의 청사진을 제시하는 역할을 한다. 모든 팀원이 동일한 목표와 구조를 이해하고, 외부 팀과의 협업이나 미팅 시 이 문서를 기준으로 명확하고 효율적인 커뮤니케이션이 가능하다. 이는 특히 대규모 팀이나 역할 분담이 명확한 환경에서 안정적인 가이드라인이 된다.
반면, 단점으로는 변화에 대한 취약성과 피드백이 느리다는 점이 있다. 프로젝트 초기에 모든 요구사항을 완벽하게 예측하고 설계하는 것이 거의 불가능하며 개발 과정에서 발생하는 예상치 못한 문제나 변경된 요구사항을 반영하려면, 비싼 비용을 들여 설계 문서부터 다시 수정해야 한다. 또한 설계의 결함이 존재할 수도 있다. 이러한 결함은 코드를 모두 구현하고 테스트 단계에 이르러서야 발견되는 경우가 많다. 이미 많은 시간이 흐른 뒤에 문제를 발견하면 수정 비용은 기하급수적으로 증가한다. 마지막으로 테스트가 개발의 마지막 선택사항으로 밀려나면서, 시간 압박 속에 형식적으로 진행되거나 생략되기 쉽다. 이는 코드의 품질 저하와 잠재적인 버그 증가로 이어진다.
1.1.2 전통적 개발 방식과 TDD의 현명한 융합
앞서 두 방식의 차이점을 살펴보았지만, 이는 둘 중 하나를 완전히 배제해야 함을 의미하지 않는다. 오히려 현대의 복잡한 소프트웨어 개발에서는 두 접근법의 장점을 현명하게 융합하는 실용적인 지혜가 필요하다.
TDD는 만능 설계 도구가 아니다. TDD는 개별 객체의 역할, 책임, 상호작용 등 상세하고 테스트하기 쉬운 설계를 이끌어내는 데 탁월하지만, 시스템 전체의 아키텍처나 모듈 간의 큰 흐름, 비즈니스의 핵심적인 프로세스를 조망하는 데는 한계가 있다.
따라서 필자는 다음과 같은 하이브리드 접근법을 적극적으로 장려한다.
개발 초기 단계에서 전체 시스템의 비전과 아키텍처를 담은 설계 문서를 작성하고, 주요 서비스 간의 상호작용을 표현하는 시퀀스 다이어그램 등을 그린다. 이 과정은 프로젝트의 기술적 방향성을 설정하고, 여러 팀 간의 역할과 책임을 명확히 하며, 커뮤니케이션의 기준점을 마련하는 데 필수적이다. 이렇게 정의된 큰 설계의 경계 안에서, 개별 기능과 모듈을 구현할 때는 TDD를 적극적으로 활용한다. TDD는 테스트 용이성, 단일 책임 원칙, 유연성 등 고품질의 코드를 만들어내는 데 가장 효과적인 도구이다. 개발자는 TDD를 통해 견고하고 유지보수하기 쉬운 코드를 구체적으로 완성해 나간다.
결론적으로, 거시적인 설계와 미시적인 구현을 분리하여 각 단계에 가장 적합한 도구를 사용하는 것이 핵심이다.
1.2 TDD의 핵심 가치
1.2.1 인터페이스 설계에 대한 즉각적인 피드백
"Test-Driven Development offers: Immediate Feedback for Interface Design Decisions" - Kent Beck
많은 사람들이 TDD를 단순히 '버그를 줄이는 테스트 기법'으로만 생각하지만, TDD 의 진정한 힘은 코드를 사용하는 첫 번째 관점을 개발자에게 강제한다는 데 있다. 테스트 코드를 먼저 작성하는 행위는, 곧 내가 만들 기능(클래스, 메서드 등)의 첫 번째 고객이 되는 경험을 의미한다.
@Test
fun `체스 기물이 유효한 이동을 할 수 있는지 확인한다`() {
val pawn = Pawn(Color.WHITE)
val from = Position('e', 2)
val to = Position('e', 4)
// 이 테스트를 작성하는 순간, 우리는 설계 결정을 내려야 한다:
// 1. Pawn 클래스가 필요하다
// 2. Color 열거형이 필요하다
// 3. Position 클래스가 필요하다
// 4. 이동 검증 메서드가 필요하다
val result = pawn.isValidMove(from, to)
result shouldBe true
}
이 실패하는 테스트를 작성하는 순간, 우리는 다음과 같은 인터페이스 설계에 대한 즉각적인 피드백을 받는다.
- 메서드 이름은 직관적인가?: 테스트 코드에 썼을 때 자연스럽게 읽히는 이름이 좋은 이름이다.
- 매개변수는 다루기 쉬운가?: 객체를 통째로 넘기는 게 편할까, 아니면 필요한 ID 값 몇 개만 넘기는 게 나을까? 테스트를 작성하며 객체를 생성하는 비용이 크다면, 인터페이스가 불편하다는 신호다.
- 결과(반환 값)는 사용하기 편리한가?: 반환된 객체에서 원하는 값을 얻기 위해 여러 번의 메서드 호출이 필요하다면, 이는 인터페이스가 비효율적이라는 증거다.
- 의존성은 명확한가?: 이 기능을 테스트하기 위해 너무 많은 Mock 객체나 복잡한 설정이 필요한가? 그렇다면 해당 코드가 과도한 의존성을 갖고 있다는 강력한 피드백이다.
아직 존재하지 않는 메서드를 호출하고, 어떤 객체를 생성하며, 그 결과로 무엇을 반환받을지 상상하며 테스트 코드를 작성하는 과정 자체가 바로 '인터페이스 설계(interface design)' 이다.
구현부터 시작했다면 이러한 설계적 결함은 이미 코드가 완성된 후에야, 다른 동료 개발자나 미래의 내가 그 코드를 사용할 때 발견하게 된다. 하지만 TDD는 바로 그 설계의 첫 단추를 끼우는 순간에 "이 인터페이스는 사용하기에 좋은가?" 라는 질문을 던지고 즉각적인 피드백을 줌으로써, 훨씬 더 유연하고 직관적인 설계를 이끌어낸다.
1.2.2 사양 문서
TDD로 작성된 테스트 코드는 그 자체로 가장 정확하고 항상 최신 상태를 유지하는 문서가 된다. 잘 짜인 테스트 코드만 읽어봐도 다음을 명확히 알 수 있다.
- 이 클래스는 어떻게 생성해야 하는가?
- 이 메서드는 어떤 매개변수를 필요로 하는가?
- 정상적인 경우 어떤 값을 반환하는가? (Happy Path)
- 예외적인 경우(e.g., 잘못된 입력) 어떻게 동작하는가? (Edge Cases)
동료 개발자가 새로운 코드를 이해해야 할 때, 부정확할 수 있는 주석이나 오래된 문서 대신 테스트 코드를 통해 기능의 사양(Specification)을 가장 빠르고 정확하게 파악할 수 있다.
1.2.3 변화에 대한 자신감
"이 코드를 고쳐도 다른 곳이 망가지지 않을까?"라는 두려움은 유지보수를 어렵게 만드는 가장 큰 적이다. TDD를 통해 구축된 테스트 코드는 '안전망(Safety Net)' 역할을 한다.
기능 추가, 성능 개선, 리팩토링 등 어떤 변경을 하더라도, 기존 테스트들을 모두 통과시킨다면 "최소한 기존 기능은 망가뜨리지 않았다"는 강력한 자신감을 얻을 수 있다. 이 심리적 안정감은 개발자가 주저 없이 코드를 개선하고 시스템을 건강하게 유지하도록 돕는다.
1.2.4 간결하고 목적 지향적인 설계
TDD는 '실패하는 테스트를 통과시키는 가장 간단한 코드'를 작성하는 것을 원칙으로 한다. 이는 YAGNI(You Ain't Gonna Need it) 원칙을 자연스럽게 실천하도록 유도한다.
미래에 필요할 것 같은 기능을 미리 예측해서 복잡하게 구현하는 '과잉 설계(Over Engineering)'를 방지하고, 오직 현재의 요구사항을 만족시키는 데 집중하게 만든다. 그 결과, 시스템은 불필요한 복잡성 없이 간결하고 명료한 구조를 갖게 된다.
1.2.5 낮은 총소유비용
초기 개발 속도는 TDD를 적용하지 않는 것보다 다소 느리게 느껴질 수 있다. 하지만 소프트웨어의 생명주기 전체를 보면 이야기가 달라진다.
- 디버깅 시간의 극적인 감소
- 새로운 기능 추가의 용이성
- 안전한 리팩토링을 통한 시스템의 유연성 확보
- 새로운 팀원의 빠른 적응
이 모든 요소가 결합되어 장기적으로 버그 수정, 유지보수, 기능 확장에 드는 총비용을 크게 절감시킨다. TDD는 단기적인 속도 경쟁이 아닌, 지속 가능한 소프트웨어를 만들기 위한 장기적인 투자이다.
1.3 TDD의 사용 기준
TDD는 '당면한 문제에 대한 모든 것을 아직 알지 못하여 설계의 방향성을 잡기 어려운 사람'들을 돕는다. TDD는 정답이 정해져 있지 않은 복잡한 문제나 생소한 도메인을 마주했을 때, 실패와 성공의 짧은 순환을 통해 점진적으로 최적의 설계를 발견해나가게 된다. 다시 말해, TDD는 복잡한 도메인에 대한 이해가 부족할 때 점진적으로 설계를 발견해나가는 도구로 사용하기 좋다.
이미 잘 알고 있거나, 자주 구현해본 영역(e.g 단순 CRUD)의 경우 전통적인 개발 방식을 따르는 것이 생산성 측면에서는 훨씬 좋을 수 있다.
1.3.1 체스 엔진 개발 사례
필자는 체스 엔진을 개발할 때, 처음에는 다음과 같은 불확실성이 존재했다.
- 도메인 복잡성: 체스 규칙의 모든 세부사항을 완벽히 알지 못함
- 설계 불확실성: 최적의 클래스 구조를 미리 예측하기 어려움
- 상호작용 복잡성: 기물 간의 복잡한 상호작용 패턴
이런 상황에서 TDD는 점진적 발견 도구로 작용했다.
// 1단계: 가장 단순한 테스트부터 시작
test("폰이 전방으로 1칸 이동할 수 있다") {
val pawn = Pawn(Color.WHITE)
pawn.isValidMove(Position('e', 2), Position('e', 3)) shouldBe true
}
// 2단계: 복잡성을 점진적으로 추가
test("폰이 초기 위치에서 2칸 이동할 수 있다") {
val pawn = Pawn(Color.WHITE)
pawn.isValidMove(Position('e', 2), Position('e', 4)) shouldBe true
}
// 3단계: 예외 상황 발견
test("폰이 대각선으로 빈 칸에 이동할 수 없다") {
val pawn = Pawn(Color.WHITE)
pawn.isValidMove(Position('e', 2), Position('f', 3)) shouldBe false
}
1.4 소프트웨어 설계 도구로서의 TDD
TDD는 단순히 버그를 찾는 활동을 넘어, 코드의 구조를 건강하게 잡아가는 강력한 설계 도구이다. 테스트를 먼저 작성하는 행위는 개발자가 코드의 첫 번째 사용자가 되도록 하여, 자연스럽게 좋은 설계 원칙들을 따르도록 유도한다.
체스 엔진을 TDD 방식으로 개발할 때, 체스판을 나타내는 Board와 각 기물을 의미하는 Piece는 핵심적인 모델이다. 이때 TDD를 진행하는 순서, 즉 어떤 모델의 테스트를 먼저 작성하느냐에 따라 설계 접근법이 달라질 수 있다.
예를 들어 Board와 Piece의 상호작용을 먼저 테스트하면 기물의 역할을 정의하는 Piece 인터페이스를 우선적으로 설계하게 된다. 반대로 Pawn, Knight 같은 개별 기물의 구체적인 동작부터 테스트하면, 각 구현체의 공통된 역할을 나중에 추상화(abstraction)하여 Piece 인터페이스를 도출할 수도 있다.
TDD 를 통해서 추상화를 창조하는 것이 아니라 찾아내게 된다.
1.4.1 인터페이스 우선 정의 (Top-Down 방식)
이 방식은 객체 간의 협력과 상호작용을 먼저 정의하며 설계를 진행한다. 개별 구현이 아닌 시스템의 큰 그림을 먼저 그릴 때 주로 사용된다.
- Board 테스트: Board 클래스의 movePiece(piece, from, to) 메서드를 테스트한다고 상상해 보자.
- 역할(Role) 정의: 이 테스트를 작성하려면 movePiece 메서드에 넘겨줄 '어떤 말'이 필요하다. 하지만 Board 입장에서 그게 Pawn인지 Knight인지는 중요하지 않다. 단지 "스스로 움직일 수 있는지 검증하는 역할(isValidMove)"을 수행할 수 있는 인터페이스이면 된다.
- 인터페이스 정의: Board를 테스트하기 위해 실제 Pawn을 만드는 것은 번거롭다. 대신 가짜 객체(Mock)를 사용하고 싶고, 가짜 객체를 만들려면 역할에 대한 정의, 즉 Piece 인터페이스가 필요하다. Board의 테스트가 Piece 인터페이스의 존재를 강제하는 것이다.
- 구현체 개발: Board의 테스트가 통과되고 나면, 이제 Piece 인터페이스를 실제로 구현할 Pawn, Knight 등의 구체적인 클래스를 TDD로 하나씩 개발한다.
// 1. Board 테스트가 이 인터페이스의 필요성을 이끌어냅니다.
interface Piece {
fun isValidMove(from: Position, to: Position): Boolean
// ... 기타 필요한 역할들
}
// 2. Board는 구체적인 클래스가 아닌 Piece 인터페이스에 의존한다.
class Board {
fun movePiece(piece: Piece, from: Position, to: Position) {
if (piece.isValidMove(from, to)) {
// 말을 실제로 움직이는 로직
}
}
}
1.4.2 리팩토링을 통한 설계 발견 (Bottom-Up 방식)
이 방식은 구체적인 구현에서 시작하여 공통점을 발견하고, 이를 통해 추상화를 이끌어내는 직관적인 접근법이다. 점진적으로 설계를 개선해 나가는 TDD의 특징을 가장 잘 보여준다.
추상화(abstraction)의 본질은 중요하지 않은 세부 사항을 과감히 숨김으로써, 우리가 정말로 집중해야 할 핵심적인 아이디어만을 명확하게 드러내는 것이다. 소프트웨어에서 추상화를 통해 객체의 복잡한 내부 구현은 감추고, 명확하게 약속된 기능(인터페이스)만을 외부에 공개함으로써, 거대한 시스템을 더 작고 관리 가능한 부분들의 합으로 다룰 수 있게 된다.
- 구현과 테스트: 먼저 Pawn 클래스를 TDD로 개발한다. 이 과정에서 Pawn을 위한 테스트 코드와 isValidMove() 메서드가 포함된 Pawn 클래스가 만들어진다.
- 또 다른 구현과 테스트: 다음으로 Knight 클래스를 TDD로 개발한다. 마찬가지로 Knight를 위한 테스트와 isValidMove() 메서드가 있는 Knight 클래스가 생성된다.
- 리팩토링 (설계의 발견): 이제 Pawn과 Knight라는 두 구체적인 클래스에 공통된 메서드(isValidMove)가 존재함을 인지하게 된다. 이는 개선이 필요한 '코드 스멜(Code Smell)'이다.
- 인터페이스 추출: Red-Green-Refactor 사이클의 리팩토링 단계에서 이 공통된 행위를 'Extract Interface' 리팩토링을 통해 Piece라는 인터페이스로 추출한다. 이 과정에서 인터페이스는 처음부터 계획된 것이 아니라, 코드의 진화 과정 속에서 '발견(Discovered)' 된 것이다.
1-1. PawnTest: 실패하는 테스트 작성 (Red)
PawnTest.kt 파일을 만들고 테스트를 작성한다. 아직 Pawn 클래스가 없으므로 이 코드는 컴파일조차 되지 않는다.
// PawnTest.kt
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class PawnTest {
@Test
fun `Pawn은 앞으로 한 칸 이동할 수 있다`() {
// ERROR: 'Pawn' 클래스가 존재하지 않음
val pawn = Pawn()
// ERROR: 'isValidMove' 메서드가 존재하지 않음
val canMove = pawn.isValidMove("e2", "e3")
assertTrue(canMove)
}
}
1-2. PawnTest: 테스트 통과 (Green)
이제 테스트를 통과시키는 가장 간단한 코드를 작성한다.
// Pawn.kt
class Pawn {
// 일단 테스트를 통과시키기 위해 무조건 true를 반환한다.
fun isValidMove(from: String, to: String): Boolean {
return true
}
}
2-1. KnightTest: 실패하는 테스트 작성 (Red)
// KnightTest.kt
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class KnightTest {
@Test
fun `Knight는 L자 형태로 이동할 수 있다`() {
// ERROR: 'Knight' 클래스가 존재하지 않음
val knight = Knight()
// ERROR: 'isValidMove' 메서드가 존재하지 않음
val canMove = knight.isValidMove("g1", "f3")
assertTrue(canMove)
}
}
2-2. KnightTest: 테스트 통과 (Green)
Knight.kt를 만들고 테스트를 통과시킨다.
// Knight.kt
class Knight {
fun isValidMove(from: String, to: String): Boolean {
return true
}
}
이제 테스트를 실행하면 성공(Green)한다.
3단계: 리팩토링 (설계의 발견)
이제 우리에겐 두 개의 클래스가 있다.
class Pawn {
fun isValidMove(from: String, to: String): Boolean { /* ... */ }
}
class Knight {
fun isValidMove(from: String, to: String): Boolean { /* ... */ }
}
Board와 같은 다른 객체가 이들을 다루려면, Pawn인지 Knight인지 일일이 확인해야 한다. 이는 OCP(Open-Closed Principle, 개방-폐쇄 원칙)에 위배되며 확장성이 떨어지는 설계이다. 바로 이 중복된 메서드 시그니처가 개선이 필요한 코드 스멜이다.
리팩토링 단계에서 두 클래스의 공통된 행위인 isValidMove를 인터페이스로 추출한다.
// Piece.kt
interface Piece {
fun isValidMove(from: String, to: String): Boolean
}
그리고 기존 클래스에 인터페이스를 구현한다.
// Pawn.kt
class Pawn : Piece { // Piece 인터페이스 구현
override fun isValidMove(from: String, to: String): Boolean {
// 내용은 바뀌지 않음
return true
}
}
// Knight.kt
class Knight : Piece { // Piece 인터페이스 구현
override fun isValidMove(from: String, to: String): Boolean {
// 내용은 바뀌지 않음
return true
}
}
이 모든 리팩토링 과정에서 기존에 작성했던 PawnTest와 KnightTest는 전혀 수정 없이 계속 통과해야 한다. 이것이 안전한 리팩토링의 핵심이다.
이제 Board는 Pawn이나 Knight를 직접 다루는 대신, Piece라는 역할을 통해 상호작용할 수 있는 더 나은 설계를 갖게 되었다. 이처럼 인터페이스는 처음부터 계획된 것이 아니라, 코드의 진화 과정 속에서 자연스럽게 발견되었다.
1.4.3 단일 책임 원칙(SRP) 적용 유도
TDD의 "작고 집중된 테스트"라는 속성은 개발자가 자연스럽게 단일 책임 원칙(Single Responsibility Principle, SRP) 을 따르도록 이끈다.
하나의 테스트는 하나의 기능만 검증하는 것이 가장 이상적이다. 만약 한 클래스가 좌표 계산, 이동 규칙 검증, 점수 계산 등 여러 책임을 한꺼번에 수행한다면, 이를 테스트하는 코드는 매우 복잡해지고 관리하기 어려워진다.
개발자는 테스트를 쉽게 작성하기 위해 본능적으로 거대한 클래스를 작은 단위로 분리하려고 시도한다. 바로 이 과정에서 각 클래스가 자연스럽게 하나의 책임만 갖는 구조로 설계된다.
체스 예제에서 Pawn의 이동을 테스트하는 상황을 생각해 보자. TDD를 진행하면 "좌표가 체스판 안에 있는가?"라는 책임과 "폰의 이동 규칙에 맞는가?"라는 두 가지 책임이 분리되어야 함을 쉽게 깨닫게 된다.
- "좌표의 유효성과 표현" 이라는 책임 → Position 클래스로 분리
- "폰의 행마 규칙 검증" 이라는 책임 → Pawn 클래스에 유지
// TDD가 자연스럽게 단일 책임을 유도한다.
// Position 클래스는 오직 '좌표의 유효성과 표현'이라는 단일 책임만 가진다.
class Position(val file: Char, val rank: Int) {
init {
require(file in 'a'..'h') { "Invalid file: $file" }
require(rank in 1..8) { "Invalid rank: $rank" }
}
}
// Pawn 클래스는 Position 객체를 받아 '폰의 행마 규칙'이라는 자신의 핵심 책임에만 집중한다.
class Pawn(private val color: Color) {
fun isValidMove(from: Position, to: Position): Boolean {
// ... 폰의 이동 규칙 검증 로직 ...
return true
}
}
이처럼 TDD는 더 나은 설계를 위해 고민하도록 강제하기보다, 테스트하기 쉬운 코드를 작성하려는 노력이 저절로 좋은 설계 원칙을 따르게 만드는 선순환 구조를 만들어 낸다.
1.4.4 테스트 용이성(Testability) 강화
TDD는 "어떻게 테스트할 것인가?"라는 질문에서 시작하기 때문에, 그 과정에서 만들어진 코드는 본질적으로 테스트하기 쉬운(Testable) 구조를 갖게 된다. 테스트하기 어려운 코드는 TDD의 짧은 개발 주기를 통과할 수 없으므로, 개발자는 자연스럽게 테스트가 용이한 방향으로 설계를 개선하게 된다.
이러한 특성은 주로 다음 두 가지 설계 패턴으로 나타난다.
첫째, 의존성 주입(Dependency Injection)이 자연스럽게 도입된다.
테스트를 작성할 때, 우리는 특정 로직을 외부 환경과 분리하여 고립된 상태에서 검증하고 싶어 한다. 만약 Game 클래스 내부에서 Board 객체를 직접 생성한다면(val board = Board()), 테스트 코드에서 Board의 상태를 제어할 수 없어 Game 클래스를 독립적으로 테스트하기 매우 어렵다.
TDD는 이러한 문제를 해결하기 위해, 필요한 의존성을 외부에서 전달받는 의존성 주입 구조를 사용하도록 유도한다.
// 의존성을 외부에서 주입받아 테스트에서 제어하기 쉬운 구조가 된다.
class Game(private val board: Board = Board.initialize()) {
/* ... */
}
위 코드처럼 생성자를 통해 Board를 주입받으면, 테스트 시에는 미리 정해진 상태를 가진 Board나 가짜 Board(Mock)를 주입하여 Game의 로직만을 정확하게 검증할 수 있다.
둘째, 불변성(Immutability)을 지향하게 된다.
테스트는 '입력'과 그로 인한 '결과'를 명확하게 확인할 때 가장 작성하기 쉽다. 어떤 메서드를 호출했을 때, 객체의 상태가 직접 바뀌거나 예상치 못한 부수 효과(Side Effect)가 발생한다면 테스트는 복잡해지고 결과를 예측하기 어려워진다.
TDD는 이러한 복잡성을 피하기 위해, 기존 상태를 바꾸기 보다 새로운 상태를 가진 객체를 반환하는 방식의 설계를 장려한다.
class Game(private val board: Board) {
// 이 메서드는 현재 Game의 상태를 바꾸는 대신,
// 이동이 적용된 '새로운' Game 객체를 반환한다.
fun makeMove(move: Move): Game {
val newBoard = board.applyMove(move)
return Game(newBoard) // 불변성(Immutability)
}
}
// 테스트에서는 반환된 새로운 객체의 상태만 검증하면 되므로 명확해진다.
@Test
fun `게임에서 유효한 이동을 할 수 있다`() {
// Arrange: 초기 게임 상태 설정
val initialGame = Game()
// Act: 수를 두어 새로운 게임 상태를 얻음
val newGame = initialGame.makeMove(Move.parse("e2e4"))
// Assert: 새로운 게임의 보드 상태가 올바른지 검증
newGame.getBoard().get(Position('e', 4)) shouldBe Pawn(Color.WHITE)
}
이처럼 TDD는 단순히 테스트 코드를 만드는 행위를 넘어, 의존성 주입과 불변성 같은 좋은 설계 패턴을 자연스럽게 체득하게 하여 코드 전체의 품질과 유지보수성을 높이는 강력한 도구이다.
1.5 결론: TDD, 단순한 테스트를 넘어
이번 장에서는 테스트 주도 개발(TDD)이 무엇인지, 그리고 그 핵심 철학은 무엇인지 살펴보았다.
TDD를 처음 접하면 단순히 '테스트 코드를 먼저 작성하는 것'이라고 이해하기 쉽다. 하지만 TDD의 본질은 테스트라는 행위를 넘어, 소프트웨어를 설계하고 만들어가는 프로페셔널한 개발 방식 그 자체에 있다. Red-Green-Refactor라는 짧은 주기의 반복은 단순한 코딩 습관이 아니라, 다음과 같은 가치를 코드에 녹여내는 강력한 메커니즘이다.
- 살아있는 문서: 테스트 코드는 그 자체로 가장 정확한 명세서가 된다.
- 설계 개선 도구: 테스트하기 쉬운 코드를 작성하려는 노력이 자연스럽게 좋은 설계(SRP, DI 등)로 이어진다.
- 변화에 대한 자신감: 촘촘한 테스트 안전망은 언제든 리팩토링하고 새로운 기능을 추가할 수 있는 심리적 안정감을 제공한다.
결국 TDD는 미래에 발생할 불확실성과 변화라는 리스크를 빠르고 지속적인 피드백으로 관리하는 기술이다. 다음 장부터는 이러한 TDD의 원칙을 실제 코드에 어떻게 적용하는지 더 구체적인 예시와 함께 살펴보도록 하겠다.