Post

[우아한테크코스] 프리코스 2주차 후기

[우아한테크코스] 프리코스 2주차 후기

2주차 프리코스 과제는 자동차 경주를 구현하는 문제였다. 각 자동차별 이동이 랜덤을 활용해 구현해야 했고 진행 과정과 결과에 대한 출력이 모두 동반되는 문제였다. 1주차 과제보다 구현만 생각하면 더 쉬운 과제라고 생각이 됐지만 좋은 설계에 대한 고민에서는 더 어려운 과제였던 것 같다.

구현 링크


1. 애플리케이션 아키텍처 설계

이전 과제보다 아키텍처 설계가 더 어렵다고 느껴졌던 과제다. 결과만 출력하면 됐던 지난 과제와 달리 진행 상황에 대한 출력이 동반되어 이를 어떻게 처리하는 것이 좋을지 고민이 됐고 하나의 자동차가 아닌 자동차들의 경주를 다루다보니 이를 어떻게 다루면 좋을지 고민이 됐다.

일단 출력이 다양한 형태로 이루어져야 한다는 점에서 OutputView를 분리하는게 좋다는 생각이 들었고, 입력 역시 깔끔하게 InputView로 분리하기로 했다. 자동차의 경우 RacingCar라는 클래스를 통해 객체 형태로 다루는게 좋겠다는 생각이 들었고 경주에 참여하는 자동차들에 대해 리스트로 바로 다루는 것이 아닌 이를 한번 감싼 RacingCars라는 객체를 활용해 일급 컬렉션 형태로 다루었다. 이후 실제 경주를 담당하는 RacingGame 클래스와 입력에 대한 유효성 검증용 유틸리티 클래스를 두는 구조로 마무리했다.

해당 과제의 아키텍처에 대해 이걸 MVC 패턴이라고 말할 수 있을까에 대한 어려움도 있었다. RacingCar, RacingCars이 Model, RacingGame이 Controller, InputView, OutputView가 View나 크게 다르지 않은 구조였지만 Contoller와 View의 결합이 강하다는 생각이 들었다. View의 출력으로 요청에 대한 응답이 종료되는 것이 웹 MVC의 요청 응답 흐름인데 한번의 요청에 반복적으로 응답을 하는 모양이니 이제 과연 적절한 구조일까 의문이 있었다. 하지만 더 좋은 아이디어를 찾지 못했고 현재 구조로 구현을 하게 됐다.


2. Model - RacingCar, RacingCars 클래스

RacingCar 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * 자동차 클래스
 */
public final class RacingCar {

    private static final int INITIAL_POSITION = 0;

    private final String name;
    private int position;

    public RacingCar(String name) {
        this.name = name;
        this.position = INITIAL_POSITION;
    }

    public RacingCar(RacingCar racingCar) {
        this.name = racingCar.getName();
        this.position = racingCar.getPosition();
    }

    public String getName() {
        return name;
    }

    public int getPosition() {
        return position;
    }

    public void move() {
        position++;
    }
}

이름과 위치를 필드로 갖고 getter는 제공하며 move 메서드를 통해 1칸 이동하는 로직만 넣어줬다. 자동차 클래스는 경주 규칙과 같은 비즈니스 요구사항과 무관해야 한다고 생각하여 최대한 순수한 상태로 만드는 것을 목표로 했다. 또한 상속을 열어줄 이유가 없어서 final 클래스로 설계했다.

RacingCars 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
 * 자동차 리스트 일급 컬렉션
 */
public final class RacingCars {

    private final List<RacingCar> racingCars;

    public RacingCars(List<String> names) {
        this.racingCars = names.stream()
                .map(RacingCar::new)
                .toList();
    }

    /**
     * BooleanSupplier를 통해 자동차별로 랜덤 이동하는 메서드
     *
     * @param canMove
     */
    public void move(BooleanSupplier canMove) {
        for (RacingCar racingCar : racingCars) {
            if (canMove.getAsBoolean()) {
                racingCar.move();
            }
        }
    }

    /**
     * 가장 많이 이동한 위치를 반환하는 메서드
     *
     * @return
     */
    private int getMaxPosition() {
        return racingCars.stream()
                .mapToInt(RacingCar::getPosition)
                .max()
                .orElse(0);
    }

    /**
     * 우승한 자동차들을 리스트로 반환하는 메서드
     *
     * @return
     */
    public List<String> getWinners() {
        return racingCars.stream()
                .filter(racingCar -> racingCar.getPosition() == getMaxPosition())
                .map(RacingCar::getName)
                .toList();
    }

    /**
     * 일급 컬렉션의 필드를 방어적 복사로 제공하는 메서드
     *
     * @return
     */
    public List<RacingCar> snapshot() {
        return racingCars.stream()
                .map(RacingCar::new)
                .toList();
    }
}

RacingCars는 자동차들을 관리하는 일급 컬렉션으로 현재 자동차들을 경주 규칙에 맞춰 이동하는 메서드, 우승한 자동차들은 반환하는 메서드, 현재 자동차들의 상태를 반환하는 메서드만 제공하여 최소한의 기능만 유지했다.

현재 자동차들을 경주 규칙에 맞춰 이동하는 메서드는 move 메서드로 BooleanSupplier를 파라미터로 받아서 이동 여부에 대한 판단은 외부에 맡기고 그저 자동차들을 이동만 하게끔 구현했다. 이를 통해 경주 규칙에 대한 책임을 지지 않는 역할 분리를 깔끔하게 해낼 수 있었다.

우승한 자동차들은 반환하는 메서드는 getWinners 메서드로 우승한 자동차들의 이름만 반환해서 내부 객체를 노출하지 않았다.

현재 자동차들의 상태를 반환하는 메서드는 snapshot 메서드로 출력을 위해 자동차의 모든 정보를 반환해야하니 사실상 getter랑 유사하지만 방어적 복사를 통해 내부 객체를 노출하지 않게 구현했다.


3. View - InputView, OutputView 클래스

InputView 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * 입력 프롬프트를 출력하는 뷰 클래스
 */
public class InputView {

    private static final String RACING_CAR_NAMES_INPUT_PROMPT = "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)";
    private static final String ROUND_INPUT_PROMPT = "시도할 횟수는 몇 회인가요?";

    /**
     * 객체 생성 및 상속 방지
     */
    private InputView() {
        throw new AssertionError();
    }

    public static void printRacingCarNamesInputPrompt() {
        System.out.println(RACING_CAR_NAMES_INPUT_PROMPT);
    }

    public static void printRoundInputPrompt() {
        System.out.println(ROUND_INPUT_PROMPT);
    }
}

입력에 대한 프롬프트만 출력하는 간단한 클래스로 프롬프트를 상수로 빼서 유지 보수성을 높혔다.

OutputView 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
 * 출력을 담당하는 뷰 클래스
 */
public final class OutputView {

    private static final String OUTPUT_GUIDE_PROMPT = "\n실행 결과";
    private static final String RACING_CAR_POSITION_MARKER = "-";
    private static final String RACING_CAR_DELIMITER = " : ";
    private static final String WINNER_INTRO_PROMPT = "최종 우승자 : ";
    private static final String WINNER_SEPARATOR = ", ";

    public void printOutputGuidePrompt() {
        System.out.println(OUTPUT_GUIDE_PROMPT);
    }

    public void printRacingCars(List<RacingCar> racingCars) {
        StringBuilder message = new StringBuilder();

        for (RacingCar racingCar : racingCars) {
            message.append(formatRacingCar(racingCar)).append("\n");
        }

        System.out.println(message);
    }

    private String formatRacingCar(RacingCar racingCar) {
        return racingCar.getName() + RACING_CAR_DELIMITER + RACING_CAR_POSITION_MARKER.repeat(racingCar.getPosition());
    }

    public void printWinners(List<String> winners) {
        System.out.println(WINNER_INTRO_PROMPT + String.join(WINNER_SEPARATOR, winners));
    }
}

현재 자동차들의 상태와 우승자를 출력하는 간단한 클래스로 프롬프트를 상수로 빼서 유지 보수성을 높혔다.

InputViewOutputView를 어떻게 활용해야 할 지 고민이 많았는데 두 View를 모두 컨트롤러로 넘겨서 로직을 수행하는 것은 그저 main 메서드에서 하는 일을 컨트롤러로 넘기는 것 밖에 안된다는 판단이 들었다. 따라서 InputViewmain에서 활용하는 정적 유틸리티 클래스로, OutputView는 컨트롤러로 넘겨서 비즈니스 로직 수행 결과를 출력하는 클래스로 설계했다.


4. Controller - RacingGame 클래스

사실상 컨트롤러의 역할을 하는 RacingGame 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * 자동차 경주 게임 클래스
 */
public final class RacingGame {

    private static final int RANDOM_MIN = 0;
    private static final int RANDOM_MAX = 9;
    private static final int MOVE_THRESHOLD = 4;

    private final RacingCars racingCars;
    private final OutputView outputView;

    public RacingGame(List<String> names, OutputView outputView) {
        this.racingCars = new RacingCars(names);
        this.outputView = outputView;
    }

    /**
     * 주어진 라운드 수만큼 경주 진행
     */
    public void run(final int round) {
        outputView.printOutputGuidePrompt();

        for (int i = 0; i < round; i++) {
            racingCars.move(() -> Randoms.pickNumberInRange(RANDOM_MIN, RANDOM_MAX) >= MOVE_THRESHOLD);

            outputView.printRacingCars(racingCars.snapshot());
        }

        outputView.printWinners(racingCars.getWinners());
    }
}

생성자를 통해 RacingCarsOutputView를 들고 있고 run 메서드에서 출력과 경주를 반복하는 구조로 설계했다. 경주의 경우 랜덤 값이 4 이상인지 여부로 판단하는데 이를 람다로 넘겨서 경주 로직은 RacingGame 클래스가 책임지고 이동만 RacingCars 클래스가 책임지는 구조를 완성할 수 있었다.


5. Validator - RacingCarNameValidator, RoundValidator 클래스

유효성 검증에 대해 이번에는 메인 로직 수행 전에 검증을 마치는 방식을 적용했다. 입력으로 자동차의 이름들과 라운드 수가 들어오므로 각각 검증용 유틸리티 클래스를 두어 유효성 검증을 했고 비즈니스 로직 수행부터는 검증을 수행하지 않았다.

RacingCarNameValidator 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
 * 자동차 이름 검증용 유틸리티 클래스
 */
public class RacingCarNameValidator {

    // 이름의 최대 길이
    private static final int MAX_LEN = 5;

    private static final String NO_NAME_MASSAGE = "이름이 비어있습니다.";
    private static final String OVER_LEN_NAME_MASSAGE = "이름은 " + MAX_LEN + "자 이하만 가능합니다.";
    private static final String DUPLICATED_NAME_MASSAGE = "중복된 이름이 존재합니다.";

    /**
     * 객체 생성 및 상속 방지
     */
    private RacingCarNameValidator() {
        throw new AssertionError();
    }

    /**
     * List로 주어진 이름에 대한 검증 수행
     *
     * @param names
     */
    public static void validate(List<String> names) {
        if (names.stream().anyMatch(name -> name == null || name.isBlank())) {
            throw new IllegalArgumentException(NO_NAME_MASSAGE);
        }

        if (names.stream().anyMatch(name -> name.length() > MAX_LEN)) {
            throw new IllegalArgumentException(OVER_LEN_NAME_MASSAGE);
        }

        if (names.stream().distinct().count() != names.size()) {
            throw new IllegalArgumentException(DUPLICATED_NAME_MASSAGE);
        }
    }
}

RoundValidator 클래스는 아래와 같이 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
 * 라운드 수 검증용 유틸리티 클래스
 */
public class RoundValidator {

    // 공통 예외 메시지
    private static final String MESSAGE = "시도할 횟수는 1 이상의 정수여야 합니다.";

    /**
     * 객체 생성 및 상속 방지
     */
    private RoundValidator() {
        throw new AssertionError();
    }

    /**
     * 주어진 라운드 수가 자연수가 아니거나 int형 범위를 넘어가면 IllegalArgumentException 발생
     *
     * @param round
     */
    public static void validate(String round) {
        // null 이거나 공백이면 제외
        if (round == null || round.isBlank()) {
            throw new IllegalArgumentException(MESSAGE);
        }

        try {
            int value = Integer.parseInt(round);

            // 음수면 제외
            if (value <= 0) {
                throw new IllegalArgumentException(MESSAGE);
            }
        } catch (NumberFormatException e) {
            // int 형변환이 불가능하면 제외
            throw new IllegalArgumentException(MESSAGE);
        }
    }
}

해당 방식에 대한 코드 리뷰에서 유효성 검증을 호출하는 쪽에서 검증 후 넘겨주어야 하냐 아니면 호출 당하는 객체가 유효성 검증을 수행해야 하냐에 대한 토론이 있었다. 전반적으로 호출 당하는 객체 스스로 유효성 검증이 필요하는 주장이 설득력이 있었는데 잘못된 상태를 갖는 불량 객체의 생성 가능성이 주된 이유였다. 나 역시 이 점에는 동의해서 객체 스스로에 대한 방어 로직이 있어야 한다는 점은 동의했는데 자동차의 이름이 5글자 이하여야 한다는 조건은 비즈니스 로직에 가까운데 이걸 자동차 스스로 검증하는게 맞는가에 대한 의문은 있었다.

자동차 이름이 문자열이어야 한다거나 null이면 안되는 검증은 인정하는데 해당 부분에 대한 검증도 자동차가 수행해야하는 것인지는 100% 납득되지는 않았다. 하지만 자동차가 가지는게 더 낫다에는 동의한다.


6. 과제 후기

해당 과제는 자동차의 이동이 랜덤 값에 의해 결정된다는 점에서 이게 테스트가 가능한가에 대한 의문이 있었고, 테스트 코드 작성보다 실제 테스트를 그냥 해보는 것에 집중했다.

공식적인 2주차 피드백에서 이 문제에 대한 언급이 있을 것 같았는데 아주 디테일하지는 않았지만 작은 단위 테스트를 통해 이동 여부를 판단하는 것을 권장하도록 있었다.


This post is licensed under CC BY 4.0 by the author.