사다리 미션 회고록

SeokHwan An·2023년 3월 8일
0

우아한테크코스

목록 보기
3/4

이번 사다리 미션을 진행하면서 처음으로 TDD를 진행해보았습니다. TDD(Test-Driven-Development)는 테스트 주도 개발을 의미하며 프로덕트 코드를 작성하기에 앞서서 테스트 코드를 먼저 작성하고 그에 맞게 프로덕트 코드를 작성해가나는 개발방법입니다.

처음 해보는 개발 방법이어서 어려운 부분도 있었지만 테스트를 먼저 작성하는 과정을 통해 비즈니스적으로 제약조건을 고려한 프로덕트 코드를 작성할 수 있었습니다. 이 부분이 TDD의 가장 큰 장점이라고 생각했습니다.

이 외에도 미션을 진행하면 고민했던 부분과 리뷰를 받은 부분에 대해 정리해보려고 합니다.

TDD를 하면서 느낀 점


TDD를 처음 하면서 느낀 점은 먼저 쉽지 않다는 것을 느꼈습니다. 어렵다고 느낀 이유는 작은 단위의 코드를 바로 떠올리는 것이 쉽지 않았기 때문입니다. 그 동안에는 항상 프로덕트 코드를 먼저 작성하고 메소드를 작은 단위로 분리한 다음에 그 기능을 테스트하는 코드를 작성했었습니다. 이렇게 하는 방식에 적응 되었다 보니 큰 단위의 그림을 그려나가는 것에는 자신이 있었지만 작은 단위부터 그림을 그려나가는 부분이 부족하다는 것을 알 수 있었습니다.

그럼에도 TDD를 하면서 좋았던 점도 있었습니다. 테스트 코드를 먼저 작성하면서 발생할 수 있는 오류 사항이나 예외 상항을 미리 파악할 수 있었고 메서드의 크기가 작게 작성되어서 이후 리팩토링을 하는데에도 도움이 되었습니다.

또한 프로덕트 코드에 대한 테스트 커버리지가 높아졌습니다. 기존에는 간단하지만 중요한 핵심 기능(java API를 활용한 간단한 기능)들에 대해서는 가볍게 생각하고 넘어가는 일이 있었습니다. 하지만 테스트 코드를 먼저 작성하다보니 이 부분까지 채워나갈 수 있었고 놓치기 쉬운 핵심기능들에 대해서도 탄탄하게 관리할 수 있었습니다.

아직은 TDD가 익숙하지 않지만 TDD를 해보면서 작은단위로 코드를 관리하는데 좋은 개발방법이라는 것을 느낄 수 있었습니다.

정적 팩토리 메소드를 활용하자


정적 팩토리 메소드는 이번 미션을 진행하면서 처음 접하게 된 개념이었습니다. 정적 팩토리 메소드를 간단하게 설명을 하면 객체 생성의 역할을 하는 클래스 메서드입니다.

자세한 설명을 보고 싶으시다면https://tecoble.techcourse.co.kr/post/2020-05-26-static-factory-method/ 여기를 참고하기 바랍니다.

MVC 패턴을 고려했을 때 Controller는 Model과 View를 연결하는 역할을 가지고 있다고 정의했고 그에 따라 코드를 작성했습니다.

public class Controller {

    public void run() {
        List<String> playerNames = inputView.readPlayerName();
        Players players = new Players(generatePlayers(playerNames));
        ...
    }

    private List<Player> generatePlayers(List<String> playerNames) {
        List<Player> players = new ArrayList<>();
        for (String playerName : playerNames) {
            players.add(new Player(playerName));
        }
        return players;
    }
}

위의 generatePlayers 메소드는 입력받은 이름 List를 Player의 List로 변환해주는 작업을 해주는 역할을 합니다. 이와 같은 상황에서 Controller가 Players를 생성하는 기반을 만들어줄 필요가 있을까? 라는 의문점을 가질 수 있습니다. 저 역시 처음 이 메소드를 작성하면서 스스로 처리할 수 는 없을까? 라는 고민을 많이 했습니다. 해결방법으로 부 생성자정적 팩토리 메소드를 이용하는 방안에 대해서 리뷰를 받았고 이번 미션에서는 정적 팩토리 메소드를 활용했습니다.

정적 팩토리 메소드를 사용한 이유는 몇 가지의 이유가 있는데 먼저 Players의 생성자의 인자가 List이고 부 생성자 역시 List를 인자로 받아야 하는 상황으로 오버로딩이 되지 않았습니다.

또 다른 이유는 정적 팩토리 메소드의 장점인 이름을 가질 수 있다라는 것이었습니다. 생성자는 오버로딩에 되더라고 따로 이름을 가지지 않기에 여러 생성자가 존재할 경우 파악하는 것이 쉽지 않을 수 있지만 정적 팩토리 메소드는 네이밍을 통해 어떤 역하를 하는지 명확하게 표현할 수 있는 점이 크게 와닿았습니다.

마지막 이유로는 다양한 요인을 통해서 객체를 생성해야하는 경우에 대처가 가능했다는 것 입니다. 이번 미션에서 특히 사다리를 생성하는 부분에서 인자가 사다리 높이, 플레이어 수, 랜덤 생성기가 필요했는데 모두 사다리의 인스턴스 필드는 아니었습니다. 이와 같은 상황에서 정적 팩토리 메소드를 활용하니 Controller가 가벼워지고 Domain을 보다 튼튼하게 관리할 수 있었습니다.

public class Players {

    private final List<Player> players;

    private Players(List<Player> players) {
        validatePlayersSize(players);
        validatePlayerDuplication(players);
        this.players = players;
    }

    public static Players generatePlayers(List<String> playerNames) {
        List<Player> players = playerNames.stream()
            .map(Player::new)
            .collect(Collectors.toList());
        return new Players(players);
    }
}

방어적 복사를 이용하자


Collection 데이터를 전달하는 과정에서 이전에는 다른 객체나 혹은 View로 전달하는 과정에서 원본데이터를 전달했었는데 다음과 같은 피드백을 받게 되었습니다.

방어적 복사를 학습하면서 원본 데이터를 전달받은 쪽에서 원본 데이터를 회손할 수 있는 문제가 있다는 것을 알게 되었습니다.

방어적 복사는 Collection Data(원본 데이터)를 넘겨줄 때 객체를 복사하여 참조하는 주소 값을 다르게 하는 것입니다. 쉽게 말하면 겉 표지만 다르게 한다라고 보면 좋을 것 같습니다. 그렇기에 방어적 복사를 통해 전달된 데이터는 원본 데이터와 완전히 다른 객체를 의미하는 것은 아닙니다. 그 이유는 내부의 원소(객체)는 같은 주소 값을 가지게 때문입니다. 즉 깊은 복사는 아닙니다.

그럼에도 깊은 복사가 아닌 방어적 복사를 왜 사용할까?에 대해 고민을 많이 해보았습니다. 나름대로 고민을 하고 내린 결과는 깊은 복사는 비용이 높다는 것이었습니다. 깊은 복사는 겉 표지뿐만 아니라 내부 객체까지 주소 값을 다르게 참조해 주는 복사 방법입니다. 그렇기에 객체를 포함한 Collection을 깊은 복사하기 위해서는 순회하면서 각 객체가 다른 주소 값을 가지게 해주어야 합니다.

그럼 방어적 복사를 쓰면 내부 값을 변경할 수 있는데 이는 어떻게 깊은 복사를 대체할 수 있을까 고민을 할 수 있는데 이는 내부의 객체를 불변으로 하게 되면 외부에서 수정이 불가하기 때문에 방어적 복사를 이용하더라도 외부에서 원본을 수정할 수 없습니다.

자세한 내용은 https://velog.io/@seokhwan-an/방어적-복사을 참고해주시면 좋을 것 같습니다.

테스트 코드를 또 다른 명세서라고 생각하자


지금까지 개발을 하면서 테스트 코드를 작성하는 이유는 단순하게 프로덕트의 코드가 동작을 잘 작동하는지 검증하는 것이라고 생각했습니다. 하지만 우하한 테크코스 미션을 진행해 나가면서 테스트 코드 또한 명세서 역할을 할 수 있는 문서라는 생각이 들었습니다.

그렇게 생각한 이유는 테스트 코드는 프로덕트 코드가 어떻게 동작할 것인지에 대해 설명하는 코드라고 생각했기 때문입니다.. 특히나 TDD를 진행하면서 어떻게 하면 프로덕트 코드를 효과적으로 테스트 할 수 있을지(설명할 수 있을 지)에 대해 고민했습니다.

테스트 코드에 대해 리뷰를 받으면서 어떻게 하면 테스트 코드가 좋은 명세서가 될 수 있을지에 대해 고민을 많이 했고 나름의 기준을 세웠습니다.

프로덕트 코드에서 성공하는 테스트와 실패하는 테스트 모두를 관리하자

이는 테스트 코드가 명세서라고 생각하기 이전에 개발자가 자신이 짠 코드를 보장할 수 있는 유일한 방법이라고 생각했습니다. 지금은 미션이 간단해서 오류를 검증하는 메소드의 경우 오류를 발생시키는 경우에 대해서만 테스트를 진행했었습니다.

하지만 현실에서 사용하는 서비스의 경우 많은 객체가 서로 상호작용하고 있기에 가능한 모든 경우(성공과 실패 모두)를 다 검증해야 개발의 불안함을 줄일 수 있다는 조언을 듣게 되었고 저 역시 이 부분에 공감을 하였습니다. 공감을 했던 이유는 어떤 기능에 대해 에러를 발생시키는 테스트 코드만 있는 경우에 이를 제외한 상황에서는 항상 오류가 발생하지 않음을 보장할 수 있을까? 라는 의심을 가지게 되었고 확실하게 기능이 보장이 된다는 느낌을 받지 못했기 때문입니다.

그렇기에 기능에 대한 테스트 코드를 작성할 때 성공과 실패 모두를 체크하는 경우를 나누어서 관리하는 것이 코드를 작성하는 것이 본인에게도 그 코드를 보는 다른 개발자에게도 win-win하는 방법이라고 생각합니다.

테스트 코드도 프로덕트 코드처럼 관리하자

테스트 코드도 프로덕트 코드처럼 변화에 대응하는 코드를 작성하는 것이 필요하다고 느꼈습니다.

변화에 대응하지 못하는 코드

@Test
@DisplayName("참가자들의 의 이름을 반환한다.")
void getPlayerNames() {
		//given
    List<Player> playerList = List.of(new Player("judy"), new Player("ako"), new Player("pobi"));
    Players players = new Players(playerList);

    //when
    List<String> test = players.getPlayersName();

    //then
    Assertions.assertTrue(test.containsAll(List.of("ako", "judy", "pobi")));
}

위의 테스트 코드의 문제점은 given에 주어진 playerList의 “judy”, “ ako”, “pobi”가 변경되면 테스트가 실패한다는 것입니다. 이는 테스트 코드를 관리하는 입장에서 불편하고 변화된 테스트에서 given과 then을 함께 수정해야하는 문제가 발생합니다. 위의 코드보다 관리하기 쉬운 테스트 코드를 작성하면 다음과 같습니다.

변화에 대응되는 코드

@Test
@DisplayName("참가자들의 의 이름을 반환한다.")
void getPlayerNames() {
		//given
		List<String> playerNames = List.of("judy", "ako", "pobi");
    List<Player> playerList = List<Player> players = expectedPlayNames.stream()
            .map(Player::new)
            .collect(Collectors.toList());
					
    Players players = new Players(playerList);

    //when
    List<String> test = players.getPlayersName();

    //then
    Assertions.assertTrue(test.containsAll(playerNames))

다음과 같이 테스트 코드를 관리하면 다양한 이름에 대한 테스트에도 코드의 변화 없이 쉽게 대응되는 것을 알 수 있습니다. 앞선 테스트 코드와 작은 차이지만 테스트 코드를 보다 편하게 관리할 수 있는 포인트라고 생각합니다.

가독성이 높은 코드를 작성하자

추상화 계층이 높은 클래스에서 given-when-then에 맞게 테스트 코드를 작성하다보면 given이 많아지게 됩니다. 이럴 때에는 테스트 코드에서도 private로 메소드를 분리하여 가독성을 높이는 것이 좋다는 것을 느꼈습니다.다음은 사다리 게임에 테스트 코드 일부 입니다.

@Test
void 사다리_결과_테스트() {
		//given
		List<Player> player = List.of(new Player("ako"), new Player("split"), new Player("ash"));
    Players players = new Players(player);
    int height = 5;
    TestGenerator testGenerator = setUpTestGenerator();
    Ladder ladder = Ladder.generateLadder(height, players, testGenerator);
    List<Prize> prize = List.of(new Prize("꽝"), new Prize("5000"), new Prize("꽝"));
    Prizes prizes = new Prizes(players.getPlayersSize(), prize);
    LadderGame ladderGame = new LadderGame(players, ladder, prizes);
		...
}

위의 코드를 보면 ladderGame을 작성하기 위해서 많은 객체들이 필요한데 이를 생성하는 코드를 일일이 읽고 해석하는 것은 불필요한 작업이라고 생각했습니다. 이와 같이 각 객체를 생성하는 부분은 private 메소드로 분리하는 것이 보다 가독성이 높은 것을 확인할 수 있었습니다.

@Test
void 사다리_결과_테스트() {
		//given
		TestGenerator testGenerator = setUpTestGenerator();
    Players players = generatePlayer();
    Ladder ladder = generateLadder(5, players, testGenerator);
    Prizes prizes = generatePrizes(players);
    LadderGame ladderGame = new LadderGame(players, ladder, prizes);
		...
}

private Players generatePlayer() {
		List<Player> player = List.of(new Player("ako"), new Player("split"), new Player("ash"));
		return new Players(player);
}

private Ladder generateLadder(int height, Players players, TestGenerator testGenerator) {
		return Ladder.generateLadder(height, players, testGenerator); 
}

private Prizes generatePrizes(Players players) {
		List<Prize> prize = List.of(new Prize("꽝"), new Prize("5000"), new Prize("꽝"));
		return new Prizes(players.getPlayersSize(), prize);
}

위와 같이 private 매소드로 분리를 하면 처음 테스트 코드를 보는 다른 개발자들도 쉽게 읽어 나갈 수 있다고 생각합니다.

사다리 미션 소감


이번 사다리 미션에서 TDD를 적용해 작은 단위의 메소드를 고려하는 in - out 방법을 많이 이용해보았는데 큰 개념으로 부터 작은 개념으로 가는 out - in 방식보다 코드를 더 작은 단위부터 테스트 하기 쉽다는 것을 느낄 수 있었습니다. 그리고 코드를 개선해 나가면서 자동차 미션보다는 새로운 개념들도 적용해보면서 발전하고 있다는 느낌을 받을 수 있어서 즐거웠던 미션이었습니다.

저번 미션에서 남았던 고민인 validation을 static 메소드로 관리하는 것에 대한 고민이 있었는데 유틸클래스를 학습하면 고민을 해결할 수 있었습니다. 먼저 validation을 static 메소드로 관리해야할까? 라고 의문을 던졌을 때 대답은 “아니었다” 였습니다. validation은 inputview에서만 이용하는 검증 로직을 모아둔 객체였습니다. 그럼에도 왜 static으로 해야할까라는 고민을 했던 이유는 inputview 객체가 validation 객체를 의존한다는 것에 두려움이 있었기 때문입니다. 근데 다시 생각해보면 우리가 객체를 만드는 이유는 서로 협력을 하기 위한 것인데 서로 연관이 있는 것끼리는 확실하게 의존관계를 형성해도 된다는 것을 이번 미션을 하면서 깨닫게 되었습니다.

0개의 댓글