티스토리 뷰

벌써 마지막 미션을 받았다. 자소서를 쓴지 며칠 지나지 않은 것 같은데 11월 달은 유독 더 빨리 지나간 것 같이 느껴진다. 그만큼 몰입하면서 성장했다고 생각한다. 이번 포스팅 역시 4주차 미션을 진행하면서 배운 내용과 아쉬운 점에 대해 작성하도록 하겠다. 배운 내용을 말하기 전에 3주차 회고를 보면 아쉬운 점을 작성했는데, 그것을 바탕으로 4주차 때 3가지 목표를 세웠다.  

 

  • MVC 패턴 적용해보기
  • 외부에서 객체를 주입받는 의존성 주입(DI) 구현해보기
  • 테스트하기 좋은 코드로 리팩토링 해보기

위 목표들을 최대한 적용해보려고 노력했고, 그 결과 조금 더 Clean Code에 대해서 알아가고 있는 것 같아 뿌듯했다. 목표를 세워서 해당 목표를 중심으로 구현해보는 것도 좋은 것 같다.

 

MVC 패턴

지난 과제를 수행하면서 가장 아쉬운 부분이었기 때문에 더욱 더 신경쓰면서 MVC 패턴을 적용하였다. 아래는 최종 패키지를 분리한 모습이다. 

 

 

기본적으로 Controller, Model(domain), view에 대한 로직을 분리했고, 추가로 서비스 계층을 추가했다. 도메인은 View와 Controller에 의존하지 않도록 구현을 했고, View에 데이터를 보낼 때 모든 사용자들에게 공통적으로 보여지는 데이터가 아닌 사용자마다 다르게 나타나는 데이터만 보내도록 했다. 자세한 내용은 3주차 회고를 참고하면 좋을 것 같다. 

 

DTO 객체 사용

DTO는 계층 간 데이터를 전달하는 용도이다. 입력 클래스에서 바로 Controller에 전달하는 것이 아니기 때문에 Controller와 View의 강하게 결합된 관계를 느슨하게 유지할 수 있다. 결합도 느슨하게 유지한다는 것은 곧 유지보수하기 좋은 코드라는 뜻이다. 그럼 어떻게 사용했는지 알아보자. 

 

아래는 입력클래스에서 사용자가 이동할 칸을 입력 받고, 입력받은 데이터의 유효성을 검사한 후 반환하는 메서드다. 

    public String readMoving() {
        while (true) {
            try {
                System.out.println(INPUT_MOVING_Block);
                return InputValidator.checkBlock(Console.readLine());
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }
    }

 

BridgeGameController.java

String bridgeSize = inputView.readBridgeSize();

 

컨트롤러에서 입력한 데이터를 바로 받고 있다. Controller는 Model과 View를 의존하지만, 강하게 결합되어 있다면 유지보수하기가 힘들어진다. 예를 들어서 입력 클래스에서 String으로 반환해주는 값을 int형으로 바꿔야 된다고 가정해보자. 

    public int readMoving() {
        while (true) {
            try {
                System.out.println(INPUT_MOVING_Block);
                return InputValidator.checkBlock(Console.readLine());
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }
    }

 

그럼 컨트롤러에서도 바꿔주어야 한다. 

int bridgeSize = inputView.readBridgeSize();

 

A가 바뀌면 B도 바뀌어야 된다는 것은 A와 B가 서로 강하게 결합되어 있다는 의미이다. 이것을 DTO를 사용해 느슨하게 풀어줄 수 있다. 먼저 입력 데이터를 전달할 DTO를 만들어주자. 

public class BridgeSizeRequestDto {
    private final int size;

    public BridgeSizeRequestDto(int size) {
        this.size = size
    }

    public int getBridgeSize() {
        return size.getSize();
    }
}

 

그럼 입력 클래스에서 DTO를 반환하도록 만들어주자. 

    public BridgeSizeRequestDto readMoving() {
        while (true) {
            try {
                System.out.println(INPUT_MOVING_Block);
                return new BridgeSizeRequestDto(InputValidator.checkBlock(Console.readLine()));
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
            }
        }
    }

 

컨트롤러에서는 다음과 같이 받아주면 된다. 

BridgeSizeRequestDto requestDto = inputView.readBridgeSize();

 

이렇게 하고 입력 데이터를 int가 아닌 다시 String으로 바꿔보자. 그럼 컨트롤러에서도 값을 바꿔줘야 할까? 그렇지 않다! DTO가 가지고 있는 데이터만 변경해주면 된다. 

public class BridgeSizeRequestDto {
    private final String size;

    public BridgeSizeRequestDto(String size) {
        this.size = size
    }

    public int getBridgeSize() {
        return size.getSize();
    }
}

 

이렇게 DTO를 사용함으로써 Controller와 View의 결합도를 줄일 수 있다. 결국 유지보수가 좋아진다. DTO의 장점은 또 있다. 바로 DTO 안에서 유효성 검사를 할 수 있다는 점이다. 지금은 아래와 같이 입력값을 검증한 후 DTO에 전달하고 있다. 

return new BridgeSizeRequestDto(InputValidator.checkBlock(Console.readLine()));

 

입력 데이터에 대한 유효성 검사는 해당 데이터를 가지고 있는 클래스에 하는게 좋다. 상태를 직접 관리할 수 있기 때문이다. 그럼 DTO에서 유효성 검사를 하도록 코드를 변경해보자. 아래는 입력 검증 클래스에서 다리 크기를 검증하는 메서드다. 

public BridgeSize(String size) {
        int bridgeSize = validateOnlyNumber(size);
        validateMinSize(bridgeSize);
        validateMaxSize(bridgeSize);
        this.size = bridgeSize;
    }

    private int validateOnlyNumber(String input) {
        if (!ONLY_NUMBER_REGEX.matcher(input).matches()) {
            throw new IllegalArgumentException(NOT_ONLY_NUMBER);
        }
        return Integer.parseInt(input);
    }

    private void validateMinSize(int size) {
        if (size < MIN_SIZE) {
            throw new IllegalArgumentException(NOT_MIN_SIZE);
        }
    }

    private void validateMaxSize(int size) {
        if (size > MAX_SIZE) {
            throw new IllegalArgumentException(NOT_MAX_SIZE);
        }
    }

 

이것을 다리 크기를 가지고 있는 DTO에서 하도록 바꿔보겠다. 

public class BridgeSizeRequestDto {
    ...
    
    private final int size;

    public BridgeSizeRequestDto(String size) {
        int bridgeSize = validateOnlyNumber(size);
        validateMinSize(bridgeSize);
        validateMaxSize(bridgeSize);
        this.size = bridgeSize;
    }
    
    private int validateOnlyNumber(String input) {
        if (!ONLY_NUMBER_REGEX.matcher(input).matches()) {
            throw new IllegalArgumentException(NOT_ONLY_NUMBER);
        }
        return Integer.parseInt(input);
    }

    private void validateMinSize(int size) {
        if (size < MIN_SIZE) {
            throw new IllegalArgumentException(NOT_MIN_SIZE);
        }
    }

    private void validateMaxSize(int size) {
        if (size > MAX_SIZE) {
            throw new IllegalArgumentException(NOT_MAX_SIZE);
        }
    }

    public int getBridgeSize() {
        return size.getSize();
    }
}

 

원시타입을 포장하라

위에서 입력 데이터에 대한 검증을 DTO에서 진행하였다. 여기서 이상한 부분이 있다. DTO의 목적은 데이터 전달인데, 유효성 검사까지 맡아도 되나? 였다. 만약 DTO에서 검증을 하게 된다면 데이터 전달, 유효성 검사 두 부분을 담당하는 것이기 때문에 목적에 맞지 않게 사용한 것이다. 그래서 이번에 원시타입을 포장함으로써 유효성 검사를 따로 분리해보았다. 

 

다리 크기에 대한 클래스를 따로 만들어 해당 클래스에서 유효성 검사를 하도록 했다. 

public class BridgeSize {
    ...
    private final int size;

    public BridgeSize(String size) {
        int bridgeSize = validateOnlyNumber(size);
        validateMinSize(bridgeSize);
        validateMaxSize(bridgeSize);
        this.size = bridgeSize;
    }

    private int validateOnlyNumber(String input) {
        if (!ONLY_NUMBER_REGEX.matcher(input).matches()) {
            throw new IllegalArgumentException(NOT_ONLY_NUMBER);
        }
        return Integer.parseInt(input);
    }

    private void validateMinSize(int size) {
        if (size < MIN_SIZE) {
            throw new IllegalArgumentException(NOT_MIN_SIZE);
        }
    }

    private void validateMaxSize(int size) {
        if (size > MAX_SIZE) {
            throw new IllegalArgumentException(NOT_MAX_SIZE);
        }
    }

    public int getSize() {
        return size;
    }
}

 

그럼 DTO에서 어떻게 사용할 수 있을까? 아래와 같이 사용한다. 

public class BridgeSizeRequestDto {
    private final BridgeSize size;

    public BridgeSizeRequestDto(String size) {
        this.size = new BridgeSize(size);
    }

    public int getBridgeSize() {
        return size.getSize();
    }
}

 

아래와 같이 사용함으로써 DTO를 사용하는 목적에 맞게 사용할 수 있고, 원시타입을 포장하여 유지보수에 도움이 된다. 계속 유지보수, 유지보수라고 말을 하는데 결국 좋은 코드란 유지보수하기 좋은 코드다. 

 

객체를 외부에서 주입받는 DI

이게 무슨말인지 모르겠는 분들을 위해 한 가지 예를 들어보겠다. 첫 번째 코드는 객체를 외부에서 주입받지 않는 코드이고, 두 번째 코드는 객체를 외부에서 주입받는다. 

 

객체를 외부에서 주입받지 않음

    public BridgeGameController() {
        this.bridgeGame = new BridgeGame();
    }

 

외부에서 주입받음

    public BridgeGameController(BridgeGame bridgeGame) {
        this.bridgeGame = bridgeGame;
    }

 

객체를 외부에서 주입받음으로써 얻는 이득은 뭘까? 이번 다리 건너기 게임을 보면, 다리를 생성한다. 생성된 다리는 게임이 재시작 되어도 초기화 되어서는 안된다. 총 시도 횟수도 게임이 재시작되면 초기화 되는 것이 아닌, 계속 시도 횟수를 더해주어야 한다. 이렇게 생성된 다리를 가지는 Bridge, 총 시도 횟수라는 상태를 가지고 있는 Player 객체가 있다. 그리고 A라는 클래스에서 Bridge와 Player를 쓴다고 가정해보자. 

 

A.java

public class A {
    private final Player player;
    private final Bridge bridge;
    
    public A() {
    	this.player = new Player();
        this.bridge = new Bridge();
    }

 

그리고 B라는 클래스에서도 두 객체를 쓴다고 가정해보자. 

public class B {
    private final Player player;
    private final Bridge bridge;
    
    public B() {
    	this.player = new Player();
        this.bridge = new Bridge();
    }

 

어떤 일이 벌어졌을까? 바로 A 클래스의 두 인스턴스와 B 클래스의 두 인스턴스는 다르다. 두 인스턴스가 다르면 어떤 심각한 상황이 발생하는지 예시를 들어보겠다. A라는 클래스에서는 플레이어가 다리를 다 건너면 플레이어에 있는 결과 상태를 성공으로 바꾸는 로직이 있다. 

public class A {
    private final Player player;
    private final Bridge bridge;
    
    public A() {
    	this.player = new Player();
        this.bridge = new Bridge();
    }
    
    ...
    
    player.success();

 

Player.java

public class Player {
    ...
    private Result result;

    ...

    public void success() {
        this.result = Result.SUCCESS;
    }

    ...

    public enum Result {
        SUCCESS("성공"),
        FAIL("실패"),
        PLAYING("진행중");

        private final String name;

        Result(String name) {
            this.name = name;
        }

        public String getName() {
            return this.name;
        }
    }
}

 

B라는 클래스에서는 플레이어의 결과 상태가 PLAYING이 아니라면 false를 던지는 로직이 있다. 

public class B {
    private final Player player;
    private final Bridge bridge;
    
    public B() {
    	this.player = new Player();
        this.bridge = new Bridge();
    }
    
    public void isPlaying() {
    	player.isPlaying();
    }

 

Player.java

    public boolean isPlaying() {
        return result.equals(Result.PLAYING);
    }

 

과연 위 isPlaying에서 false가 나올까? 결국 A 클래스에서의 Player와 B 클래스에서의 Player는 서로 다른 인스턴스이기 때문에, 아무리 값을 바꿔도 영향을 끼치지 않는다. 이러한 문제를 객체를 외부에서 주입받음으로써 어떻게 해결하는지 보도록 하자. Spring에서의 DI와 비슷하게 짜기 위해 빈(인스턴스)들을 보관하고 주입하는 컨테이너를 만들었다.

public class ApplicationContainer {
    private static Player playerInstance;
    private static Bridge bridgeInstance;

    private ApplicationContainer() {
    }

    //Beans
    public static Player player() {
        if(playerInstance == null) {
            playerInstance = new Player();
        }
        return playerInstance;
    }

    public static Bridge bridge() {
        if(bridgeInstance == null) {
            bridgeInstance = new Bridge();
        }
        return bridgeInstance;
    }
}

 

위 코드를 보면 싱글턴 패턴으로 런타임 시점, 즉 Player 인스턴스를 처음 쓰는 시점에 생성을 하고 그 다음부터는 생성한 인스턴스를 갖다 쓰는 방식으로 구현하였다. 싱글턴 패턴에 대해서는 이 글을 참고하자. 이렇게 하면 Player와 Bridge는 단 하나의 인스턴스만을 생성하기 때문에 아까의 문제점을 해결할 수 있다. 구현한 인스턴스는 아래와 같이 쓸 수 있다. 

public class Application {
    ...

    private static BridgeGame bridgeGame() {
        return new BridgeGame(bridgeMaker(), ApplicationContainer.player(), facade());
    }

    private static BridgeMaker bridgeMaker() {
        return new BridgeMaker(bridgeNumberGenerator());
    }

    private static BridgeNumberGenerator bridgeNumberGenerator() {
        return new BridgeRandomNumberGenerator();
    }

    private static BridgeGameFacade facade() {
        return new BridgeGameFacade(ApplicationContainer.bridge(), ApplicationContainer.player());
    }
}

 

BridgeGame.java

public class BridgeGame {
    private final BridgeMaker bridgeMaker;
    private final Player player;
    private final BridgeGameFacade bridgeGameFacade;

    public BridgeGame(BridgeMaker bridgeMaker, Player player, BridgeGameFacade bridgeGameFacade) {
        this.bridgeMaker = bridgeMaker;
        this.player = player;
        this.bridgeGameFacade = bridgeGameFacade;
    }

    ...
}

 

BridgeGameFacade.java

public class BridgeGameFacade {
    private final Bridge bridge;
    private final Player player;
    private BridgeStatus bridgeStatus;

    public BridgeGameFacade(Bridge bridge, Player player) {
        this.bridge = bridge;
        this.player = player;
        this.bridgeStatus = new BridgeStatus();
    }

    ...
}

 

Facade 패턴

서비스 계층에서 로직을 작성하다 보면, 비즈니스 로직이 아닌 코드들이 존재하는 경우가 있다. 

public BridgeResponseDto move(SelectBlockRequestDto dto) {
        int currentPosition = player.getCurrentPosition();
        String blockToMove = dto.getBlock();
        String blockMark = expressionOfBlock(currentPosition, blockToMove);
        BridgeStatus bridgeStatus = marking(blockMark, blockToMove);
        return new BridgeResponseDto(bridgeStatus);
    }

    private String expressionOfBlock(int currentPosition, String block) {
        if (bridge.canCross(currentPosition, block)) {
            player.move();
            isDoneCrossingBridge(player.getCurrentPosition());
            return GameConstance.CROSSABLE_EXPRESSION;
        }
        player.fail();
        return GameConstance.NOT_CROSSABLE_EXPRESSION;
    }

    private void isDoneCrossingBridge(int currentPosition) {
        if(bridge.isDoneCrossingBridge(currentPosition)) {
            player.success();
        }
    }

    private BridgeStatus marking(String mark, String block) {
        if(block.equals(GameConstance.UP_BLOCK_EXPRESSION)) {
            bridgeStatus.addStatus(mark, GameConstance.EMPTY_BLOCK);
            return bridgeStatus;
        }

        bridgeStatus.addStatus(GameConstance.EMPTY_BLOCK, mark);
        return bridgeStatus;
    }

 

만약 개발자가 아닌 다른 사람들이 본다면 이해할 수 있을까? 다른 사람이 보더라도 흐름을 쉽게 이해할 수 있게 짜는 것이 중요하다. 위 코드를 다른 클래스로 분리하고, 칸으로 이동한다 라는 메세지만 보내면 다른 사람들도 코드를 해석하기 수월할 것이다. 이렇게 내부 로직을 숨기고 겉으로 코드 흐름을 판단할 수 있는 패턴을 퍼사드 패턴(Facade Pattern)이라고 한다. 그럼 Facade 패턴을 구현해보자.

public class BridgeGameFacade {
    private final Bridge bridge;
    private final Player player;
    private BridgeStatus bridgeStatus;

    public BridgeGameFacade(Bridge bridge, Player player) {
        this.bridge = bridge;
        this.player = player;
        this.bridgeStatus = new BridgeStatus();
    }

   ...

    public BridgeStatus moveToBlock(int currentPosition, String blockToMove) {
        String blockMark = expressionOfBlock(currentPosition, blockToMove);
        return marking(blockMark, blockToMove);
    }

    private String expressionOfBlock(int currentPosition, String block) {
        if (bridge.canCross(currentPosition, block)) {
            player.move();
            isDoneCrossingBridge(player.getCurrentPosition());
            return GameConstance.CROSSABLE_EXPRESSION;
        }
        player.fail();
        return GameConstance.NOT_CROSSABLE_EXPRESSION;
    }

    private void isDoneCrossingBridge(int currentPosition) {
        if(bridge.isDoneCrossingBridge(currentPosition)) {
            player.success();
        }
    }

    private BridgeStatus marking(String mark, String block) {
        if(block.equals(GameConstance.UP_BLOCK_EXPRESSION)) {
            bridgeStatus.addStatus(mark, GameConstance.EMPTY_BLOCK);
            return bridgeStatus;
        }

        bridgeStatus.addStatus(GameConstance.EMPTY_BLOCK, mark);
        return bridgeStatus;
    }
}

 

그럼 서비스 계층에서는 비즈니스 로직만이 남게 된다.

    public BridgeResponseDto move(SelectBlockRequestDto dto) {
        int currentPosition = player.getCurrentPosition();
        String blockToMove = dto.getBlock();
        BridgeStatus bridgeStatus = bridgeGameFacade.moveToBlock(currentPosition, blockToMove);
        return new BridgeResponseDto(bridgeStatus);
    }

 

플레이어의 현재 위치와 이동하려는 칸을 가지고 움직이고 있다는 것을 쉽게 알 수 있다. 복잡한 내부 로직을 숨김으로써 가독성도 좋아진다. 또한 OCP의 법칙도 지킬 수 있다.

 

Pattern은 비싼 객체다

대부분 정규식으로 검증을 할 때 Pattern 객체를 사용한다. 하지만 Pattern 객체는 입력받은 정규표현식에 해당하는 유한 상태 머신을 만들기 때문에 인스턴스 생성 비용이 높다고 한다. 하지만 이런 생성 비용이 높은 Pattern 객체를 메서드마다 사용하게 되면 성능에 문제가 있을 수 도 있다.

 

대표적으로 아래 코드를 보면 알 수 있다. 

    private void validateInputBlock(String block) {
        if (!Pattern.matches(BLOCK_REGEX, block)) {
            printNotFormatException();
        }
    }
    
    private void validateInputRetry(String retry) {
        if (!Pattern.matches(RETRY_REGEX, retry)) {
            printNotFormatException();
        }
    }

 

메서드 안에서 Pattern 객체를 생성하게 되면 메서드가 끝난 후 GC가 처리해준다. 비싼 객체를 계속해서 생성하고 처리하니 성능에 이슈가 있다. 위 코드를 어떻게 바꿀 수 있을까? 클래스 초기화 과정에서 직접 생성하여 캐싱해두고 재사용을 하면 성능이 개선된다. 

public class InputValidator {
    private static final Pattern BLOCK_FORMAT = Pattern.compile("^[UD]$");
    private static final Pattern GAME_RETRY_FORMAT = Pattern.compile("^[RQ]$");

    ...

    private static void validateInputFormat(String input, Pattern format) {
        if (!format.matcher(input).matches()) {
            printNotFormatException();
        }
    }
}

 

그리고 똑같은 입력 형식을 검사하기 때문에 메서드를 하나로 줄일 수 있다. 

 

테스트 코드 리팩토링

3주차 피드백에도 있던 내용이다. 아래와 같은 테스트 코드가 있다고 가정해보자.

    @DisplayName("칸을 선택할 때 형식(U 또는 D)에 맞지 않다면 예외 처리")
    @Test
    void should_notFormatBlockException_When_inputBlock() {
        String block = "A";
        final String NOT_FORMAT = "입력 형식이 맞지 않습니다.";
        assertThatThrownBy(() -> InputValidator.checkBlock(block))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(NOT_FORMAT);
    }

 

U 또는 D가 아니라면 입력 형식이 맞지 않습니다. 라는 에러메세지를 띄워야 한다. A는 당연히 U 또는 D가 아니니까 에러메세지를 띄운다. 하지만 A만 확인하면 될까? U 또는 D를 제외한 나머지 알파벳도 테스트를 해주어야 한다. 간단하게 생각하면 아래와 같이 할 수 있을 것이다. 

    @DisplayName("칸을 선택할 때 형식(U 또는 D)에 맞지 않다면 예외 처리")
    @Test
    void should_notFormatBlockException_When_inputBlock() {
        String block = "A";
        final String NOT_FORMAT = "입력 형식이 맞지 않습니다.";
        assertThatThrownBy(() -> InputValidator.checkBlock(block))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(NOT_FORMAT);
    }
    
    @DisplayName("칸을 선택할 때 형식(U 또는 D)에 맞지 않다면 예외 처리")
    @Test
    void should_notFormatBlockException_When_inputBlock() {
        String block = "B";
        final String NOT_FORMAT = "입력 형식이 맞지 않습니다.";
        assertThatThrownBy(() -> InputValidator.checkBlock(block))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(NOT_FORMAT);
    }
    
    @DisplayName("칸을 선택할 때 형식(U 또는 D)에 맞지 않다면 예외 처리")
    @Test
    void should_notFormatBlockException_When_inputBlock() {
        String block = "C";
        final String NOT_FORMAT = "입력 형식이 맞지 않습니다.";
        assertThatThrownBy(() -> InputValidator.checkBlock(block))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(NOT_FORMAT);
    }
    
    ...
    
    @DisplayName("칸을 선택할 때 형식(U 또는 D)에 맞지 않다면 예외 처리")
    @Test
    void should_notFormatBlockException_When_inputBlock() {
        String block = "Z";
        final String NOT_FORMAT = "입력 형식이 맞지 않습니다.";
        assertThatThrownBy(() -> InputValidator.checkBlock(block))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(NOT_FORMAT);
    }

 

하지만 코드가 엄청나게 더러워진다. 소문자와 대문자를 합치면 총 50개의 중복된 테스트 코드를 작성해야 하니까.. 어지럽다.

JUnit에서는 두 어노테이션을 사용해 해결할 수 있다.

@ValueSource
@ParameterizedTest

 

@ValueSource의 strings를 쓰게 되면, strings에 들어간 값들을 하나하나 확인한다.

@DisplayName("칸을 선택할 때 형식(U 또는 D)에 맞지 않다면 예외 처리")
    @ValueSource(strings = {
            "A", "B", "C", "E", "F", "G", "H", "I", "G", "K", "L", "M", "N",
            "O", "P", "Q", "R", "S", "T", "V", "W", "X", "Y", "Z",
            "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n",
            "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"})
    @ParameterizedTest
    void should_notFormatBlockException_When_inputBlock(String block) {
        final String NOT_FORMAT = "입력 형식이 맞지 않습니다.";
        assertThatThrownBy(() -> InputValidator.checkBlock(block))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining(NOT_FORMAT);
    }

 

무려 50개의 중복 코드를 단 한 번의 테스트 코드로 줄였다! 이렇게 테스트 코드도 리팩토링을 하면서 개선할 수 있다. 하지만 아쉬웠던 점들도 있었다. 

 

테스트할 때 의존성 주입

서비스 클래스를 테스트하려고 했는데, 서비스는 3개의 인스턴스를 외부에서 주입받아서 쓴다. 그래서 아래와 같이 직접 다 생성해주었다.

public class BridgeGameTest {
    private BridgeGame bridgeGame;
    private Bridge bridge;
    private Player player;

    @BeforeEach
    void init() {
        BridgeNumberGenerator generator = new BridgeRandomNumberGenerator();
        BridgeMaker bridgeMaker = new BridgeMaker(generator);
        player = new Player();
        bridge = new Bridge();
        BridgeGameFacade facade = new BridgeGameFacade(bridge, player);
        bridgeGame = new BridgeGame(bridgeMaker, player, facade);
    }

    ...
}

 

과연 이렇게 하나하나 생성하는 것이 옳은 코드일까 걱정했다. 하지만 다른 방법이 생각나지 않았다. 스프링에서의 테스트 코드를 작성할 때는 스프링에서 자동으로 해주는데, 기회가 되면 내부 동작이 어떻게 흘러가는지 알고 싶다. 

 

테스트하기 좋은 코드

테스트하기 좋은 코드를 짜기가 너무 어렵다. 제어할 수 없는 코드를 빼려고 했지만 실패했다. 아래는 다리를 생성하는 로직이 잘 동작하는지 확인하는 테스트 코드다.

    @DisplayName("건널 수 있는 다리들을 매개변수로 받아 다리 생성")
    @Test
    void should_createBridge_When_create() {
        final String bridgeSize = "3";
        BridgeSizeRequestDto dto = new BridgeSizeRequestDto(bridgeSize);
        bridgeGame.create(dto);
        //이제 생성된 다리가 맞는지 확인해야 한다.
    }

 

다리를 생성하고, 생성된 다리를 확인해야 되는데, 랜덤 값으로 생성되기 때문에 생성된 다리 결과를 예측할 수 없었다. 즉 랜덤 값은 제어할 수 없기 때문에 매개변수로 넣어보려고 했지만 실패했다. 그래서 아래와 같이 테스트하였다. 

    @DisplayName("건널 수 있는 다리들을 매개변수로 받아 다리 생성")
    @Test
    void should_createBridge_When_create() {
        final String bridgeSize = "3";
        BridgeSizeRequestDto dto = new BridgeSizeRequestDto(bridgeSize);
        bridgeGame.create(dto);
        assertThat(bridge.isDoneCrossingBridge(3)).isTrue();
    }

 

Bridge.java

    public boolean isDoneCrossingBridge(int position) {
        return position == bridge.size();
    }

 

isDoneCrossingBridge는 플레이어의 현재 위치가 맨 마지막 칸에 있는지 확인한다. 이 메서드를 이용해 3인 다리 크기만큼 다리가 생성되었다면 플레이어의 현재 위치가 3일 때 True를 리턴하는 것으로 테스트했다. 더 좋게 테스트할 수 있을 것 같았지만 더 나은 코드가 생각나지 않아 아쉽다. 

 

다른 부트캠프를 참여해보지는 않았지만 우테코와 가장 큰 차이점은 '자기 주도 학습'을 가르칠 수 있다는 게 아닐까 싶다. 4주동안 미션을 수행하면서 다양한 디자인 패턴, 테스트 코드 짜는 법, 디미터의 법칙, OOP, 테스트 하기 좋은 코드, 더 나아가서 get 메서드를 지양한다는 의미를 찾다가 도메인 주도 개발이 뭔지 알게 되었다 어느샌가 혼자 모르는 것의 개념을 학습하고, 실제 코드에 적용해보는 것에 익숙해졌다. 또한 전 미션에 짰던 코드의 문제점들도 보였다. 모두 다 우테코 프리코스를 진행한 덕분이다. 4주동안 쉴틈없이 바빴지만 오히려 바빠서 좋았던 것 같다!

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday