📕 목차
1. TDD의 목적
2. TDD와 클린 코드
3. 참고 자료
1. TDD의 목적
📌 개요
TDD를 공부하기 위해 자료를 찾아보면 가장 쉽게 볼 수 있는 내용이 TDD의 리듬과 F.I.R.S.T에 관한 내용이다.
✒️ TDD의 리듬
1. Red: 실패하는 작은 테스트를 작성하라. 처음에는 컴파일조차 되지 않을 수 있다.
2. Green: 빨리 테스트가 통과하게끔 한다. 이를 위해 어떠한 죄악을 저질러도 좋다.
3. Refactoring: 일단 테스트를 통과하게만 하는 와중에 생긴 모든 중복을 제거하라.
그런데 이상하지 않은가?
어째서 프로덕션 코드보다도 테스트가 선행되며, 개발자가 실패할 것을 뻔히 아는 빨간 줄을 마주보게 만들까?
테스트 코드를 먼저 작성한다는 것은 무엇을 의미하며, 테스트 코드의 목적이 대체 무엇일까?
📌 TDD의 목표
If I test the code I write, I get better quality code. What would happen if I look the process to. he extream: writing tests before the code itself?
- Extream Programming 20 years later by Kent Beck -
TDD는 관점에 따라 두 개의 다른 목표를 지닌다.
- 디자인 관점: 검증이 아닌 기능 사양의 명세를 목표로 한다.
- 기술적 관점: 작동하는 깔끔한 코드를 목표로 한다.
여기서 쉽게 지나칠 수 있지만 중요한 내용이 있다. TDD는 결코 테스트가 주목적이 아니라는 점이다.
즉, "테스트 주도 개발"이라는 용어로 인해 테스트 기술처럼 오인할 수 있지만, 실제로 TDD는 개발의 모든 활동을 구조화하기 위한 분석 및 설계 기술에 해당한다는 것이다.
✒️ Clean Code
"클린 코드가 무엇인가?"에 대한 질문에 대한 답은 개발자마다 다양할 수 있을 것이다.
내가 생각하기엔 "읽기 쉽고, 변경에 용이해야 한다"가 되지 않을까 싶다.
물론 이 말만으로 정의하기엔 클린 코드는 너무 광범위한 영역을 아우른다는 점이다.
📌 기능 구현과 설계 구조의 갈등
Make it work. Make it right. Make it fast
- Kent Beck -
TDD는 개발자가 두 가지 목표를 동시에 추구할 수 없다는 가정을 전제로 시작한다.
- 올바른 기능 작동
- 올바른 설계 구조
TDD를 사용하기 전의 개발 순서를 생각해보자. 물론 사람마다 차이는 있겠지만 대부분 다음과 같을 것이다.
- 기능을 구현하기 위한 전체 설계를 구상한다.
- 설계를 기반으로 코드를 작성한다.
- Best Practice에 대해 기능이 성공적으로 동작함을 확인한다.
- 코드를 리팩토링 한다 .
- 예외 케이스에 대한 테스트를 추가한다.
겉보기엔 너무 당연하고, 문제가 없어 보이지만 해본 사람은 알 것이다. 이상과 현실은 언제나 다르다는 것을...
문제점을 하나씩 뜯어보자.
- 기능을 구현하기 위한 전체 설계를 구상한다.
- 초기에 기능 전체 설계를 구상하는 것은 미래에 발생할 수 있는 모든 요구사항을 예측하고 반영하려는 시도다.
- SW 개발에선 요구사항이 자주 변경되므로 초기 설계는 쉽게 구식이 될 수 있다.
- 시간이 지나면서 처음의 설계와 실제 요구 사항의 간극이 커져, 코드 복잡성을 증가시킬 수 있다.
- 설계를 기반으로 코드를 작성한다.
- 코드를 작성하게 되면 개발자는 필연적으로 기능 구현에 집중하게 된다.
- 이 과정에서 설계의 원칙이 무시되거나 왜곡될 수 있다.
- 특히 복잡한 기능일 수록 "일단 동작하는 코드"에 포커싱하여 더더욱 오류를 범하기 쉽다.
- Best Practice에 대해 기능이 성공적으로 동작함을 확인한다.
- 우선 개발자는 의도한 기능이 의도대로 동작하는 지 확인한다.
- 이 과정에서는 테스트가 없기 때문에 전체 시스템에 어떠한 영향을 주는지, 다른 부분과 어떻게 상호작용하는 지 파악하기 어렵다.
- 코드를 리팩토링 한다.
- 개발자는 리팩토링을 통해 설계와 구현 간의 불일치를 해결하고자 하지만, 이미 구현된 코드를 대폭 수정한다는 것은 상당한 스트레스와 불안을 야기한다.
- 결국, 리팩토링 과정은 그저 부분적이거나 피상적인 수준에서 그치게 된다.
- 재설계 과정에서 사용하지 않은 코드나 중복 기능들로 인해 확장성이 떨어지고, 코드 복잡도는 올라가게 된다.
- 예외 케이스에 대한 테스트를 추가한다.
- 이미 구현된 후 테스트 케이스를 추가하는 것은 기존 코드를 다시 분석하고 수정하는 과정을 수반해야 한다.
- 테스트가 이미 프로덕트 코드에 맞춰 작성되면서, 테스트 코드가 난해해질 수 있다.
공감이 안 될 수도 있겠지만, 적어도 나는 실제로 위와 같은 실패의 경험을 마주했었다.
그리고 그 문제는 두 가지의 문제를 동시에 해결하려고 했던 문제에서 비롯한다.
📌 TDD의 가정과 사이클
💡 개발자는 올바른 기능 작동과 올바른 설계 구조라는 두 가지 목표를 동시에 추구할 수 없다.
- 올바른 기능 작동
- TDD는 실패하는 테스트를 먼저 작성하여, 구현해야 할 기능의 목표를 명확히 한다.
- 이를 통해 기능 구현에 집중하고, 작은 단위로 기능을 검증함으로써 올바르게 동작함을 지속적으로 확인할 수 있다.
- 예외 케이스 또한 처음부터 테스트에 포함되므로, 기능이 예외 상황에서도 올바르게 작동하는 지 처음부터 검증할 수 있다.
- 올바른 설계 구조
- 테스트를 작성한 후 최소한의 코드로 기능을 구현하고, 이후 리팩토링 단계를 통해 설계를 개선시킨다.
- 이 과정에서 기능 구현과 설계 개선이 분리되어, 한 번에 한 가지 목표에 집중할 수 있다.
- 설계와 구현 과정에서 발생하는 예외 처리 로직 또한 자연스럽게 반영할 수 있다.
결국 TDD는 기존 방식에서 본질적인 문제를 찾아내, 그 문제를 해결하기 위해 나노 주기/마이크로 주기(그리고 기본 주기)의 반복을 통하여 불필요한 설계를 피하고, 최소한의 간결한 코드를 지향하도록 만들게 된다.
내가 클린 코드를 만드는 것이 아니라, 테스트가 클린 코드를 만들도록 주도하는 것이다.
2. TDD와 클린 코드
📌 Clean code that works (작동하는 깔끔한 코드)
TDD는 테스트 케이스 작성을 위해 가장 먼저 pre-condition과 input에 대한 output을 정의하도록 강제한다.
이는 코드가 무엇을 해야 하는지 명확하게 규정하고, 올바른 동작을 보장하기 위해 필요한 조건들을 명시하게 된다.
결과적으로 Test case가 그 자체로 하나의 명세서 역할을 수행하게 된다.
하지만 이것만으로는 클린 코드(변경에 용이한 코드)라고 말할 수 없다.
일반적으로 "변경에 용이한 코드"를 이야기할 때는 다양한 관점이 존재하지만, 아키텍처 관점에서 SOLID 원칙의 OCP 원칙을 준수해야만 한다.
TDD는 과연 어떠한 방법으로 우리가 OCP 원칙을 준수하도록 만들까?
📌 Losse coupling (느슨한 결합)
테스트를 작성하기 쉽지 않다면, 그건 테스트가 아니라 설계에 문제가 있다는 신호다.
✒️ 느슨한 결합의 정의
- 시스템 구성요소가 서로 약하게 연간되어 관계를 떼어낼 수 있고, 한 구성요소에 변화가 생겼을 때 다른 구성요소의 성능이나 존재에 최소한의 영향을 끼지는 상태
- 구성요소가 다른 구성요소의 정의에 대해 많은 지식이 없이도 사용할 수 있는 상황
TDD는 인터페이스와 구현을 분리하는 설계를 장려한다.
하지만 이건 개발자가 신경써서 작성을 하도록 권고하는 사항보다는 결국 그렇게 되기 마련이다.
왜냐하면, 구현체를 직접적으로 사용하여 테스트를 수행하려는 행위는 테스트 케이스 작성을 힘들게 만들고 사이드 이펙트를 발생시키기 때문이다.
🤔 구현체를 사용한 테스트 케이스
public class PaymentProcessorTest {
@Test
public void testCreditCardPayment() {
PaymentProcessor paymentProcessor = new PaymentProcessor();
String result = paymentProcessor.processCreditCardPayment(100.0);
assertEquals("Paid 100.0 using Credit Card", result);
}
@Test
public void testPayPalPayment() {
PaymentProcessor paymentProcessor = new PaymentProcessor();
String result = paymentProcessor.processPayPalPayment(100.0);
assertEquals("Paid 100.0 using PayPal", result);
}
}
결제 시스템을 테스트한다고 가정해보자.
PaymentProcessor 클래스는 특정한 결제 방식과 강하게 결합되어 있기 때문에, 새로운 결제 방식이 추가될 때마다 PaymentProcessor 클래스에 새로운 메서드를 추가하고 인스턴스를 생성해야 한다.
public class PaymentProcessor {
public String processCreditCardPayment(double amount) {
CreditCardPayment creditCardPayment = new CreditCardPayment();
return creditCardPayment.pay(amount);
}
public String processPayPalPayment(double amount) {
PayPalPayment payPalPayment = new PayPalPayment();
return payPalPayment.pay(amount);
}
}
// CreditCardPayment.java
public class CreditCardPayment {
public String pay(double amount) {
return "Paid " + amount + " using Credit Card";
}
}
// PayPalPayment.java
public class PayPalPayment {
public String pay(double amount) {
return "Paid " + amount + " using PayPal";
}
}
위의 방식은 코드의 유연성을 현저하게 떨어트린다.
그리고 결제 방식을 추가할 때마다 새로운 메서드 그리고 결제 클래스 인스턴스 생성 코드가 중복된다.
각 결제 방식에 대해서 테스트 코드도 강하게 결합되기 때문에 코드의 복잡성을 증가시키면서, 새로운 결제 방식이 추가될 때마다 테스트 코드도 함께 수정할 수밖에 없다.
그리고 이미 알겠지만, 위 코드는 이미 SRP 원칙과 OCP 원칙을 충실하게 위배하고 있다.
✨ 인터페이스로 분리
public class PaymentProcessorTest {
@Test
public void testCreditCardPayment() {
PaymentProcessor paymentProcessor = new CreditCardPayment();
String result = paymentProcessor.processPayment(100.0);
assertEquals("Paid 100.0 using Credit Card", result);
}
@Test
public void testPayPalPayment() {
PaymentProcessor paymentProcessor = new PayPalPayment();
String result = paymentProcessor.processPayment(100.0);
assertEquals("Paid 100.0 using PayPal", result);
}
}
// PaymentProcessor.java
public interface PaymentProcessor {
String pay(double amount);
}
// CreditCardPayment.java
public class CreditCardPayment implements PaymentProcessor {
@Override
public String pay(double amount) {
return "Paid " + amount + " using Credit Card";
}
}
// PayPalPayment.java
public class PayPalPayment implements PaymentProcessor {
@Override
public String pay(double amount) {
return "Paid " + amount + " using PayPal";
}
}
위 코드는 어떠한가?
테스트가 더 이상 구현체가 아닌 추상체에 의존하게 함으로써, PaymentProcessor가 특정 결제 방식에 종속되지 않도록 만든다.
또한 테스트에 필요한 Mock 객체를 사용하여 테스트가 편리하도록 만들 수도 있다.
(예를 들어, PaymentProceesor가 외부 통신 로직을 포함하며 임의의 테스트 내에서 실행되어야 할 때, 가짜 응답을 유도해낼 수 있다.)
즉, 테스트가 수월해지게 만들기 위해 작업한 것이 결과적으로 변경에 용이한 코드를 만들어낸다.
새로운 결제 시스템이 추가되어야 한다면, PaymentProcessor를 수정하지 않고, 다른 구체 클래스를 만들기만 하면 된다.
너무 억지스럽다고 생각할 수도 있다.
그리고 실제로는 잘못된 테스트 케이스를 작성하지 않기 위해 심혈을 기울여야 한다는 것도 동의한다.
하지만 TDD가 말하고자 하는 개발 접근법을 이해해보려 노력한다면 이 내용이 반드시 공감이 될 것이라 생각한다.
3. 참고 자료