5기 프리코스 미션이었던 다리 건너기 문제를 풀어보았다.
자바 버전이 달라서 Console과 관련한 에러를 마주한 것을 포함, 3시간 5분 뒤에 ApplicationTest를 통과하였고 4시간 36분에 리팩토링을 끝내고 도메인 로직 테스트 코드 작성을 완료하였다.
사실 이번 미션에서는 객체지향 설계를 거의 지키지 않고 말그대로 돌아가는 쓰레기를 만들었다.
어떻게 출력할 지 생각하지 않고 그냥 내 맘대로 컨트롤러에서 객체를 만들어 넘겼는데, 이것 때문에 뷰에서 출력하는 로직을 짤 때 애먹었던 기억이 있다. 이전부터 나는 뷰는 신경쓰지 않고 컨트롤러까지만 신경쓰는 경향이 있었는데, 만약 뷰의 출력 로직이 매우 복잡하게 된다면 큰 문제가 생길 것 같다. 앞으로는 이 객체를 뷰에서 어떻게 사용할 수 있을지를 염두에 두고 개발하는 것으로 해야겠다. 😶
💪 프로젝트 개요
건널 수 있는 다리를 생성하고, 플레이어가 다리를 건너는 게임을 구현한다.
📝 구현 기능 목록
게임 시작 문구를 출력하는 기능
-
다리 건너기 게임을 시작합니다.
를 출력한다.
자동으로 다리를 생성하는 기능
- 다리의 길이를 입력한다.
-
다리의 길이를 입력해주세요.
를 출력한다. - 다리의 길이를 입력받는다.
- 빈 문자열이 아님을 검증한다.
- 숫자 입력임을 검증한다.
- 3 이상 20이하임을 검증한다.
-
- 다리를 생성한다.
- 다리의 길이만큼 0과 1 중 무작위 값을 생성한다.
- 0인 경우 아래 칸, 1인 경우 위 칸을 건널 수 있는 칸으로 저장한다.
플레이어가 다리를 이동하는 기능
-
이동할 칸을 선택해주세요. (위: U, 아래: D)
를 출력한다. - 플레이어가 이동할 칸을 입력한다.
- 빈 문자열이 아님을 검증한다.
- U 혹은 D의 입력임을 검증한다.
- 플레이어가 이동한 칸이 이동할 수 있는 칸인지 검사한다.
- 이동할 수 있는 칸을 선택한 경우 O를 표시한다.
- 모든 다리를 이동한 경우 결과를 반환한다.
- 이동할 수 없는 칸을 선택한 경우
- 재시도 혹은 종료 여부를 선택한다.
- 종료를 선택한 경우 결과를 반환한다.
- 이동할 수 있는 칸을 선택한 경우 O를 표시한다.
게임을 종료하는 기능
-
최종 게임 결과
를 출력한다. - 최종적으로 만들어진 다리의 상태를 출력한다.
-
게임 성공 여부: 성공
와 같이 게임 성공 여부를 출력한다. -
총 시도한 횟수: 2
와 같이 총 시도한 횟수를 출력한다.
칸 이동에 실패한 경우 재시도 혹은 종료 여부를 선택하는 기능
-
게임을 다시 시도할지 여부를 입력해주세요. (재시도: R, 종료: Q)
를 출력한다. - 플레이어는 재시도 혹은 종료 여부를 입력한다.
- 빈 문자열이 아님을 검증한다.
- R 혹은 Q의 입력임을 검증한다.
- 재시도를 선택한 경우, 처음부터 다시 다리를 이동한다.
- 종료를 선택한 경우, 게임을 종료한다.
👩🏫 보완해야할 점
학습을 중점으로 두고 공부하였던 프리코스와는 달리 현재는 최종 코딩 테스트를 위한 문제 풀이이므로, 실제로 최종 코딩 테스트 당시에 실수할 수 있는 포인트를 잡아서 보완해야할 점을 중점으로 작성해보려고 한다.
리스트를 다루는 함수 코드 분리하기
나는 리스트를 인자로 담아 전달하면, 복사본이 전달되기 때문에 값의 변형이 유지되지 않는 다고 생각하고 있었다.
그래서 리스트를 사용한 아래와 같은 함수의 경우 분리가 불가능하다고 생각하였지만, 실제로 테스트해본 결과 그렇지 않다는 것을 알게 되었다.
`
public void printMap(List<Move> moves) {
List<String> up = new ArrayList<>();
List<String> down = new ArrayList<>();
for (Move move : moves) {
if (move.direction().equals("U")) {
up.add(move.success());
down.add(NONE);
}
if (move.direction().equals("D")) {
up.add(NONE);
down.add(move.success());
}
}
ConsoleWriter.printlnMessage(generateSingleMapRow(up));
ConsoleWriter.printlnMessage(generateSingleMapRow(down));
ConsoleWriter.println();
}
뷰에서 맵의 상태를 출력하기 위해 위쪽에 위치한 다리의 상태를 저장하고 (up) 아래쪽에 위치한 다리의 상태를 저장한 (down) 코드이다. 이 함수는 함수의 길이가 너무 길다는 문제가 있었다. 이를 해결하기 위해서 두 개의 if 문을 함수로 만들어 아래와 같이 구현하였다.
public void printMap(List<Move> moves) {
List<String> up = new ArrayList<>();
List<String> down = new ArrayList<>();
for (Move move : moves) {
moveUp(up, down, move);
moveDown(up, down, move);
}
ConsoleWriter.printlnMessage(generateSingleMapRow(up));
ConsoleWriter.printlnMessage(generateSingleMapRow(down));
ConsoleWriter.println();
}
private void moveDown(List<String> up, List<String> down, Move move) {
if (move.direction().equals("D")) {
up.add(NONE);
down.add(move.success());
}
}
private void moveUp(List<String> up, List<String> down, Move move) {
if (move.direction().equals("U")) {
up.add(move.success());
down.add(NONE);
}
}
이게 최선의 방법인지에 대해서는 의문이 들지만, 이렇게 리스트를 전달하며 변형하는 방식이 함수 호출이 종료되어도 그대로 유지된다는 것을 배울 수 있는 코드였다.
콘솔에서 입력하는 시스템 상수를 관리하기
이번 요구사항에서는 R, Q와 같이 프로그램을 제어하는 상수를 사용자에게 입력받고, 그에 따라 분기하여 프로그램을 종료하거나 재시작하는 등의 로직을 설계하였다.
나는 위와 같이 직접 문자열을 작성하는 방식을 해결하였는데, 이러한 경우에는 아래와 같이 enum 클래스를 선언하여 관리하는 것이 훨씬 효율적이다.
* 우아한테크코스 백엔드 5기 깃짱님의 코드를 참고하였습니다. (감사합니다)
INVALID_GAME_COMMAND("R, Q 중의 값만 입력할 수 있습니다.")
public enum GameCommand {
RETRY("R"), QUIT("Q");
private final String command;
GameCommand(String command) {
this.command = command;
}
public static GameCommand from(String command) {
return Arrays.stream(GameCommand.values())
.filter(element -> element.command.equals(command))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(ExceptionMessage.INVALID_GAME_COMMAND.getMessage()));
}
}
이렇게 설계하게 되면 잘못된 입력이 주어졌을 때도, 도메인에서 충분히 검증할 수 있게 된다. (나는 현재 뷰에서 일일이 R, Q 에 해당하는 지 검사하는 로직을 작성하였다)
어디로 이동할 지 입력하는 UP, DOWN 도 마찬가지이다.
U, D 입력 외의 다른 입력이 주어졌을 때 해당 enum Type으로 변환하는 과정에서 예외를 처리할 수 있고, RandomGenerator에 의해 생성된 0 혹은 1의 입력이 UP, DOWN으로 변환되기 위한 함수를 여기서 작성할 수 있기 때문이다.
public enum BridgeSign {
UP("U", 1), DOWN("D", 0);
private final String sign;
private final int number;
BridgeSign(String sign, int number) {
this.sign = sign;
this.number = number;
}
public static BridgeSign from(int number) {
return Arrays.stream(BridgeSign.values())
.filter(element -> element.number == number)
.findAny()
.orElseThrow(() -> new IllegalArgumentException(ExceptionMessage.~~.getMessage()));
}
public static String numberToSign(int number) {
return from(number).sign;
}
}
변환하기 전 후로 다리를 생성하는 로직의 차이를 보면, 일일이 분기하는 코드를 제거하고 깔끔하고 확장성 있게 코드가 만들어진 것을 확인할 수 있다.
@ParameterizedTest
같은 상황에서 여러 입력값을 기반으로 테스트를 진행하고자 하였다.
그러나 여전히 레퍼런스를 참고하지 않고 술술 써내려가는 수준은 아닌 것 같아서, 복습 차 한 번 더 정리하려 한다.
@ValueSource
@ParameterizedTest
@ValueSource(strings = {"jammmy", "canny", "honny"})
void createUsername(string name){
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new User(VALID_EMAIL, name, password)
);
assertThat(e.getMessage()).isEqualTo(NAME_NOT_MATCH_MESSAGE);
}
@ValueSource는 strings 뿐만 아니라 ints, doubles, chars, booleans 등을 사용해서 원하는 타입을 파라미터로 복수로 주입할 수 있다.
@EmptySource, @NullSource
@ParameterizedTest
@NullSource
@EmptySource
void nullEmptyStrings(String text) {
assertTrue(text == null || text.trim().isEmpty());
}
이렇게 파라미터 값으로 Null이나 공백을 포함하기 위해서는 @EmptySource와 @NullSource를 사용할 수 있다.
@EnumSource
@ParameterizedTest
@EnumSource(value = TimeUnit.class, names = { "DAYS", "HOURS" })
void testWithEnumSourceInclude(TimeUnit timeUnit) {
assertTrue(EnumSet.of(TimeUnit.DAYS, TimeUnit.HOURS).contains(timeUnit));
}
특정 EnumType을 파라미터로 사용하기 위해서는 @EnumSource를 사용할 수 있다.
@MethodSource
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input, boolean expected) {
assertEquals(expected, Strings.isBlank(input));
}
private static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of(null, true)
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("not blank", false)
);
}
하나의 타입이 아닌 여러 타입의 복잡한 객체를 사용하는 경우, 이를 인수로 전달하기 위해 Method Source를 사용한다.
함수의 이름을 @MethodSource의 파라미터로 넣어서 Stream으로 여러 개의 Argument를 전달할 수 있다
이렇게 간단한 코딩 테스트 대비를 끝내었다.
초기에 급하게 설계하느라 조금 꼬인 감이 있는데, 그래도 빠르게 해결할 수 있는 게 다행이었다.
다음 문제를 풀어볼 때는 설계에 최소 10분을 투자하고 어떻게 기능을 구현할 지 대략적인 플로우 차트를 그리자. (복잡해보이는 문제일 수록 더더욱)
그리고 문제를 매번 풀 때마다 새롭게 알게 된 문법과 기법, 그리고 자주 사용되는 로직은 따로 정리해놓자.
지금 풀고 있는 모든 게 나중에 최종 코딩 테스트 때 큰 자산이 될 것이다.
아자! 😀
'우아한테크코스 > 레벨0' 카테고리의 다른 글
[회고] 우아한 테크코스 6기 최종 코딩테스트 후기 feat. 아쉬움 가득 ... (7) | 2023.12.17 |
---|---|
[회고] 우아한 테크코스 최종 코딩테스트 대비 - 지하철 노선도 (0) | 2023.12.14 |
[회고] 우아한테크코스 1차 심사 합격 + 앞으로의 계획 (0) | 2023.12.13 |
[회고] 우아한 테크코스 프리코스 1주차 회고 - 숫자 야구 게임 (5) | 2023.11.27 |
[회고] 우아한테크코스 프리코스 4주차 회고 - 크리스마스 프로모션 (0) | 2023.11.17 |