최근에 TDD를 다시 보게 되면서, 혼자 끄적이던 생각과 고민이 조금씩 정리가 되었습니다.
사실 처음에는 TDD를 그냥 '테스트 코드 먼저 작성하는 방식' 정도로만 알고 있었는데요,
이번에 프로젝트를 진행하면서 "테스트가 코드의 방향을 결정할 수도 있다"는 걸 체감하게 되었습니다.
TDD는 단순히 테스트를 먼저 짜는 게 아니더라구요
보통은 기능을 먼저 구현하고 테스트 코드를 나중에 붙이는 경우가 많잖아요? 그런데 TDD는 반대입니다.
테스트 코드를 먼저 작성하고, 그 테스트를 통과할 수 있는 최소한의 코드를 작성한 뒤, 마지막에 리팩토링을 진행합니다.
이게 단순히 순서만 바뀌는 게 아니라, "어떻게 코드를 짤 것인가" 자체가 달라집니다.
단계 설명
Red | 실패하는 테스트를 먼저 작성합니다 |
Green | 테스트를 통과시키기 위한 최소한의 코드 작성 |
Refactor | 중복 제거, 가독성 향상 등 리팩토링 수행 |
객체의 책임을 다시 보게 되었습니다
TDD로 개발을 하다 보니, 자연스럽게 객체의 책임과 역할을 더 깊게 고민하게 되더라구요.
예를 들어 숫자 야구 게임을 만든다고 할 때, 아래와 같은 코드가 있다고 해보죠.
private static GameResult getNumberMatchCount(UserNumber userNumbers, ComputerNumber computerNumber) {
int ball = 0, strike = 0;
Number[] computerNumberNumbers = computerNumber.getNumbers();
Number[] userNumbersNumbers = userNumbers.getNumbers();
for (int i = 0; i < 3; i++) {
int number = userNumbersNumbers[i].getNumber();
for (int j = 0; j < 3; j++) {
if (computerNumberNumbers[j].getNumber() == number && i == j) {
strike++;
break;
} else if (computerNumberNumbers[j].getNumber() == number) {
ball++;
break;
}
}
}
return new GameResult(strike, ball);
}
이 코드는 잘 돌아가긴 하지만, 책임이 어디에 있는지 명확하지 않습니다.
그래서 저는 TDD 관점에서 아래처럼 책임을 나누는 방식으로 리팩토링을 했습니다.
public class ComputerNumber {
private final Number[] numbers;
public GameResult compare(UserNumber user) {
int strike = 0, ball = 0;
Number[] userNumbers = user.getNumbers();
for (int i = 0; i < numbers.length; i++) {
if (numbers[i].equals(userNumbers[i])) {
strike++;
} else if (contains(userNumbers[i])) {
ball++;
}
}
return new GameResult(strike, ball);
}
private boolean contains(Number number) {
for (Number n : numbers) {
if (n.equals(number)) return true;
}
return false;
}
}
컴퓨터 숫자는 게임 내내 바뀌지 않는 기준값이기 때문에, 비교의 책임을 갖는 것이 더 자연스럽다고 판단했습니다. 이렇게 되면 userNumber는 단순 입력값이고, computerNumber는 기준값으로서의 역할을 하게 됩니다.
enum과 Value Object를 통한 의미 있는 설계
좀 더 확장해보면, 결과값도 단순히 int로 표현하기보다는 enum을 활용하는 게 훨씬 명확합니다.
public enum NumberMatchType {
STRIKE, BALL, NOTHING
}
그리고 각 자리마다 결과를 저장할 수 있는 클래스를 만들 수도 있습니다.
public class MatchResult {
private final List<NumberMatchType> results;
public MatchResult(List<NumberMatchType> results) {
this.results = results;
}
public long strikeCount() {
return results.stream().filter(r -> r == STRIKE).count();
}
public long ballCount() {
return results.stream().filter(r -> r == BALL).count();
}
}
또한 숫자 자체도 int가 아니라 검증과 동등성 로직을 가진 Value Object로 만들 수 있겠죠.
public class Number {
private final int value;
public Number(int value) {
if (value < 1 || value > 9) {
throw new IllegalArgumentException("숫자는 1~9 사이여야 합니다.");
}
this.value = value;
}
public boolean isSameValue(Number other) {
return this.value == other.value;
}
public int getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Number)) return false;
Number number = (Number) o;
return value == number.value;
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
이렇게 설계하면, 도메인 모델에 대한 신뢰도가 훨씬 높아지고 리팩토링도 훨씬 수월해집니다.
마무리하며
이번 경험을 통해 TDD는 단순히 "테스트 코드 먼저"가 아니라, 코드의 설계 자체를 테스트에서부터 시작하게 해주는 도구라는 걸 깊이 느끼게 되었습니다.
객체의 역할, 책임, 경계를 고민하게 되고, 이를 통해 자연스럽게 SOLID 원칙도 더 잘 지켜지게 되더라구요.
- 테스트가 코드 구조를 바꾼다
- 객체가 스스로 책임을 갖도록 설계한다
- 도메인은 더 단순해지고, 서비스는 조율만 한다
이런 깨달음이 조금이라도 도움이 되셨으면 좋겠습니다. 혹시 TDD나 객체지향 설계에 대해 같이 얘기 나누고 싶으시다면 언제든지 환영입니다 :)
참고자료
'ETC' 카테고리의 다른 글
커서(Cursor)를 써보고 나서, 진심으로 감탄했다 (0) | 2025.06.26 |
---|---|
실무에서 자주 사용하는 인텔리제이 단축키 정리 (0) | 2025.06.16 |
[k6] k6 사용법 & 실무 활용 방법 (0) | 2025.04.14 |
[Gradle] Gradle 라이브러리 참조 문제 정리 (0) | 2025.03.28 |
[Gradle] Gradle 캐시와 사용자 홈(GRADLE_USER_HOME) (0) | 2025.03.27 |