📌 테스트 주도 개발 - (TDD)
나에게 TDD란 💦
두 번째 미션은 사다리 게임으로 "모든 기능을 TDD로 구현해 단위 테스트가 존재해야 한다. 단, UI(System.out, System.in) 로직은 제외"라는 새로운 요구 사항이 추가되었다.
나는 프리코스 3주 차 미션인 로또를 TDD를 활용해 구현했었다. 테스트 주도 개발이 주는 장점을 직접 경험했지만 '앞으로도 모든 코드를 TDD로 작성해야겠다!' 하는 정도로 그 장점들이 크게 와닿지는 않았던 것으로 기억한다.
미리 작성된 테스트 케이스들은 글로 작성된 기능 구현 목록보다 더 구체적인 기능 요구 사항의 역할을 수행한다는 것이 내가 프리코스 회고 당시 느꼈던 TDD의 가장 큰 장점이다. 입력되는 인자 값들과 메서드의 반환값이 무엇인지를 명확하게 지정할 수 있어서 비즈니스 로직의 구현이 훨씬 수월했다.
누군가는 그냥 기능 구현 목록을 더 상세하게 적지 않은 나의 잘못이라는 결론을 내릴 수도 있다. 하지만 각 도메인의 메서드 이름을 적어 놓고, 입력에 상응하는 출력 값을 일일이 나열하는 일을 애플리케이션 단위로 하기에는 굉장히 버겁고 힘들다.
사람마다 성향이 다르겠지만 적어도 나는 글을 읽는 시간보다 코드를 읽는 시간이 더 긴 만큼, 글로 적힌 내용보다는 코드로 직접 보는 것이 훨씬 직관적으로 이해가 잘 되는 편이다. 내가 이걸 제대로 느꼈던 게 바로 아래 예시인데, "연속된 Path가 존재하지 않도록 수정한다."라는 한 줄의 글보다는 아래 테스트가 더 정확하게 코드가 어떻게 동작해야 하는지 알게 해 줬다.
그래서 대체 TDD가 뭔데 🤔
TDD는 Test Driven Development의 약자로, 쉽게 말하자면 프로덕션 코드를 작성하기 전에 실패하는 테스트를 작성한 후 테스트를 통과하도록 코드를 구현하는 방식이다. 강의 자료에 언급된 TDD를 해야 하는 이유 세 가지는 다음과 같다.
- 요구 사항의 변경으로 인해 코드를 수정해도 불안하지 않다.
- 처음부터 완벽한 설계를 하는 것이 아니라 점진적으로 설계를 개선해 나갈 수 있다.
- 빠른 피드백을 통해 개발 효율을 높일 수 있다.
그래서 나는 정말 테스트 주도 개발이 나에게도 이런 장점을 가져오는지 확인하며 이번 미션을 진행했다.
📌 [1단계] - 사다리 생성
1단계 미션 제출 🎈
페어 프로그래밍을 한 번 해 봐서 두 번째 미션은 보다 편한 마음으로 진행했다. 1단계는 요구 사항이 많지 않아서 첫 리뷰 요청 마감까지 여유 있게 작업할 수 있었고, 덕분에 페어와 정말 즐겁게 의견을 나눴다. '이게 바로 소프트 스킬의 성장?'이라는 착각이 잠시 들었지만... 돌이켜 보면 내 헛소리를 웃으면서 받아준 착한 페어 지분이 높은 것 같다 😅
사다리 생성과 출력을 위해 Ladder, LadderStep, Path 세 도메인으로 나눴고, 각 도메인이 사다리의 어느 부분을 담당하는지는 아래 사진에서 볼 수 있다. (참고로 저 자료는 리뷰 요청 시에 첨부하려고 페어가 피피티로 열심히 만들었다.)
아직 TDD에 많이 서툰 우리는 테스트 방향에 대한 피드백을 받기 위해 실패한 테스트를 작성하는 단계(test)와 프로덕션 코드를 구현하는 단계(feat / refactor)를 분리해서 커밋했다.
코드 리뷰와 피드백 🎁
1. Collections.unmodifiablieList()의 호출은 생성자에서
처음에는 getter에서 unmodifiableList()의 호출 결과를 반환하면 외부에서 수정하는 것을 막을 수 있어서 좋을 것 같다고 생각하여 다음과 같은 코드를 작성했다.
public record LadderStep(List<Path> ladderPaths) {
@Override
public List<Path> ladderPaths() {
return unmodifiableList(ladderPaths);
}
}
그리고 리뷰어에게 getter를 재정의하기보단 생성자를 재정의해서 ladderPaths 자체를 unmodifiableList로 받는 것은 어떠냐는 의견을 받았다. 메서드 호출 비용이 줄어드는 훨씬 효율적인 방법이었고, 사실 나는 record의 생성자를 재정의할 수 있다는 것을 몰랐다. 그래서 다음과 같이 생성자에 해당 로직을 적용하도록 수정했다.
public record LadderStep(List<Path> ladderPaths) {
public LadderStep {
ladderPaths = unmodifiableList(ladderPaths);
}
}
2. Scanner의 자원 해제
우리가 구현한 컨트롤러에는 뷰의 자원을 해제하기 위해 다음과 같이 inputView의 closeResourse()라는 메서드를 호출하고 있었다.
// LadderGame.java
public void run() {
final Participants participants = retryOnException(this::createParticipants);
final int width = participants.getNecessaryLadderWidth();
final Ladder ladder = createLadder(width);
printLadder(participants, ladder);
inputView.closeResource();
}
// InputView.java
public void closeResource() {
scanner.close();
}
이에 대해 컨트롤러가 Scanner에 대해 알아야 하냐는 질문을 받았다. 처음에는 Scanner 자원의 해제를 컨트롤러에서 하지 말라는 뜻으로 해석했는데, 도저히 해결 방법이 떠오르지 않았다. 애초에 뷰의 생성을 컨트롤러에서 하고 있고 Scanner 자원이 마지막으로 사용되는 시점을 알고 있는 것도 컨트롤러뿐이라서 떠오르는 아이디어가 없다고 솔직하게 말씀드렸다.
그런데 Scanner 에 대해 조금 찾아보니, 우리가 사용한 System.in이라는 표준 입력 스트림은 한 번 닫게 되면 프로그램이 실행되는 동안에는 다시 열 수 없다고 한다. 다음과 같은 코드를 작성하면 7번째 줄에서 예외가 발생한다. JVM이 종료 시에 더 이상 사용되지 않는 객체인 System.in을 메모리에서 제거하기 때문에 close() 작업을 하지 않아도 괜찮다.
public static void main(String[] args) {
Scanner scanner1 = new Scanner(System.in);
String s = scanner1.next();
scanner1.close();
Scanner scanner2 = new Scanner(System.in);
s = scanner2.next(); // 예외 발생
scanner2.close();
}
📌 [2단계] - 사다리 게임 실행
class와 record는 어떻게 구분할까? 🤔
인텔리제이로 2단계 미션을 수행하면서 class를 record로 변경하라는 제안이 계속 떴다. 내가 느끼기에 record는 VO에 가깝다는 생각이 들어서 getter가 아닌 다른 기능을 부가적으로 수행하면 경고를 무시하고 class로 정의했다. 그래서 이에 대한 궁금증이 생겨 리뷰어께 질문을 드렸다.
그러자 현업 기준으로는 팀 컨벤션에 따르는 것이 맞고, 리뷰어님 개인적 의견으로는 동일성이 아닌 동등성을 비교할 때는 record가 잘 어울리기 때문에 DTO나 VO 같은 불변의 데이터를 담는 경우에 record를 사용한다는 답변을 주셨다. 그래서 나는 앞으로 미션을 진행할 때 다음과 같은 기준에 부합할 때 record를 적용하려고 한다.
- getter와 생성자 외 다른 기능을 수행하지 않는다.
- 객체 비교 시 동일성이 아닌 동등성을 비교해야 한다.
생성자에 별도의 데이터 처리가 필요한 경우에 대해서는 아직 기준을 세우지 못해서 상황에 따라 판단해야 할 것 같다.
RandomGenerator의 분리 💡
나는 사다리 발판 유무(boolean)를 랜덤 하게 생성하는 부분과 사다리(Ladder)를 분리하기 위해 LadderStepsGenerator를 추상화하여 인터페이스를 만들었고, 다음과 같이 RandomStepsGenerator라는 구현체에서 List<LadderStep>을 반환하도록 코드를 작성했다.
public class RandomLadderStepsGenerator implements LadderStepsGenerator {
private static final Random RANDOM = new Random();
private final LadderSize ladderSize;
public RandomLadderStepsGenerator(LadderSize ladderSize) {
this.ladderSize = ladderSize;
}
@Override
public List<LadderStep> generate() {
final List<LadderStep> ladderSteps = new ArrayList<>();
while (ladderSteps.size() < ladderSize.getHeight()) {
final LadderStep currentLadderStep = generateLadderStep();
ladderSteps.add(currentLadderStep);
}
return ladderSteps;
}
private LadderStep generateLadderStep() {
final List<Path> ladderPaths = new ArrayList<>();
while (ladderPaths.size() < ladderSize.getWidth()) {
final Path currentPath = Path.from(pickRandomBoolean());
ladderPaths.add(currentPath);
}
return new LadderStep(ladderPaths);
}
private boolean pickRandomBoolean() {
return RANDOM.nextBoolean();
}
}
그리고 리뷰어한테 Path.from()의 내용만 달라질 것 같은데, 이를 주입해 줄 수 있는 방식으로 바꾸면 코드를 깔끔하게 짤 수 있을 것 같다는 의견을 받았다. 이 피드백을 버스에서 읽고 있었는데, 리팩터링 할 생각에 정신이 아득해져서 휴대폰 냅다 떨어트릴 뻔했다.
어떻게 이 피드백을 반영할 수 있을지 꽤 오래 고민을 했다. 그리고 내린 결론은 LadderStepsGenerator을 class로 변경한 후, List<LadderStep>을 반복 생성하는 로직을 옮기고 Boolean을 반환하는 함수형 인터페이스를 주입하는 방법이었다. 그리고 구현체는 여러 인스턴스가 필요하지 않을 것 같아 싱글톤 패턴을 적용했다.
public class LadderStepsGenerator {
private final PathAvailabilityGenerator pathAvailabilityGenerator;
public LadderStepsGenerator(final PathAvailabilityGenerator pathAvailabilityGenerator) {
this.pathAvailabilityGenerator = pathAvailabilityGenerator;
}
public List<LadderStep> generate(final LadderSize ladderSize) {
final List<LadderStep> ladderSteps = new ArrayList<>();
final int ladderWidth = ladderSize.getWidth();
while (ladderSteps.size() < ladderSize.getHeight()) {
final LadderStep currentLadderStep = generateLadderStep(ladderWidth);
ladderSteps.add(currentLadderStep);
}
return ladderSteps;
}
private LadderStep generateLadderStep(final int ladderWidth) {
final List<Path> ladderPaths = new ArrayList<>();
while (ladderPaths.size() < ladderWidth) {
final boolean pathAvailability = pathAvailabilityGenerator.generate(); // 주입된 부분
final Path currentPath = Path.from(pathAvailability);
ladderPaths.add(currentPath);
}
return new LadderStep(ladderPaths);
}
}
public class RandomPathAvailabilityGenerator implements PathAvailabilityGenerator {
private static final RandomPathAvailabilityGenerator INSTANCE = new RandomPathAvailabilityGenerator();
private static final Random RANDOM = new Random();
private RandomPathAvailabilityGenerator() {
}
public static RandomPathAvailabilityGenerator getInstance() {
return INSTANCE;
}
@Override
public boolean generate() {
return RANDOM.nextBoolean();
}
}
최대 난관은 테스트 수정이었다. 기존 테스트 구현체는 그냥 생성자로 입력받은 Lsit<LadderStep>을 반환해 주면 됐는데 boolean 값을 하나씩 반환하도록 수정하려니 막막했다.
public record TestLadderStepsGenerator(List<LadderStep> ladderSteps) implements LadderStepsGenerator {
@Override
public List<LadderStep> generate() {
return ladderSteps;
}
}
솔직히 말하자면 테스트 고치기 복잡할 것 같아서 피드백을 반영하지 않고 싶은 충동도 조금 있었다. 물론, 테스트 고치기 귀찮다고 프로덕션 코드를 개선하지 않는 건 정말 잘못된 선택이라는 걸 알기에 리팩터링을 해보기로 마음을 먹었다. 그리고 마침내 Iterator를 활용한 테스트용 구현체를 만들어서 무사히 미션을 수행했다.
public class TestPathAvailabilityGenerator implements PathAvailabilityGenerator {
private final Iterator<Boolean> pathAvailabilities;
public TestPathAvailabilityGenerator(final List<Boolean> pathAvailabilities) {
this.pathAvailabilities = pathAvailabilities.iterator();
}
@Override
public boolean generate() {
if (pathAvailabilities.hasNext()) {
return pathAvailabilities.next();
}
throw new NoSuchElementException("이미 모든 pathAvailability가 반환되었습니다.");
}
}
💭 미션을 마치면서
TDD가 변화에 대한 두려움을 줄여 준다는 말에 나는 살짝 반기를 들어 보고 싶다. 페어랑 코드를 짜면서 변경 사항이 생기면 테스트 코드부터 다시 수정해야 돼서 이건 TDD가 아니라 TDT(테스트 주도 테스트)가 아니냐는 농담까지 했었다. 부지런한 유전자를 가졌다면 모를까 나처럼 귀찮음이 정신과 육체를 지배한 사람에게는 테스트 환경이 마련되어 있지 않은 프로덕션 코드를 작성하는 불안감보다, 테스트 코드를 항상 먼저 수정해야 한다는 번거로움이 더 두려웠다. TDD가 즉각적인 피드백으로 코드 구현에 대한 안정감을 주는 것은 사실이지만, 설계가 어느 정도 된 후에 테스트를 작성하는 것보다는 훨씬 많은 양의 테스트를 작성하게 된다는 단점이 있다.
아이러니하게도 TDD는 정말 귀찮으면서 귀찮지 않았다. 그러니까 리팩터링을 할 때마다 테스트부터 수정하는 건 귀찮았지만, 코드 구현을 끝난 다음에 테스트를 작성하지 않아도 된다는 사실이 너무 편했다. 조삼모사이긴 하지만, 보통은 구현이 끝나면 내 코드는 잘 돌아갈 거라는 근자감이 앞서기 때문에 테스트를 작성하기 싫어진다. 그 점에서 볼 때 TDD는 정말 좋은 해결책이다.
근본적으로 봤을 때 TDD는 단순히 테스트를 먼저 작성하는 것에서 끝나는 것이 아니라 점진적인 설계를 돕는다는 점에서 의의가 있는데 나는 크게 와닿지 않는 것 같다. 이건 개발 역량의 한계라고도 볼 수 있겠지만, 나는 TDD로 코드를 작성하지 않았어도 지금과 거의 똑같은 설계를 했을 것 같다. TDD에 대한 인식 변화가 크게 생기지는 않았지만, 단위 테스트를 통해 구현된 코드의 정확성을 증명하는 과정이 정말 중요하다는 것을 깨닫는 계기가 되었다.
'우아한테크코스 > Level 1' 카테고리의 다른 글
[우아한테크코스 5주차] 블랙잭의 위험성에 대하여 🃏 (6) | 2024.03.16 |
---|---|
[우아한테크코스 1주차] 감자 생존 일지🥔 (0) | 2024.02.26 |