우아한테크코스 5기 프리코스 2주차 회고

yoondgu·2022년 11월 9일
0
post-thumbnail
post-custom-banner

미션 시작 전 학습 환경 설정

많은 종류의 고민과 어려움이 매일 새롭게 생겨나다보니, 이번 주차부터는 정제된 문장이 아니더라도 매일 투두리스트와 함께 일지 형식으로 기록하며 작업하기 시작했습니다.

그리고 이번주에는 빠르게 구현을 시작하기 보다는 지켜야 할 것, 새로 배워야 할 것을 먼저 충분히 파악하고자 했습니다. 그래서 이전에는 사용해보지 않은 API를 검색해보고 적용하는 일을 더 적극적으로 할 수 있던 것 같습니다.

커밋 컨벤션에 개인원칙 추가하기

먼저 지난주 미흡했던 점 중 하나인 “기능 단위 커밋”을 보다 일관적으로 작성하기 위하여, 주어진 커밋 컨벤션에 개인적으로 지킬 원칙을 추가해 정리했습니다. 뿐만 아니라 깃허브의 이슈번호를 사용하여 각 기능과 커밋을 연결해서 기능 단위 별 변경 이력을 쉽게 파악하도록 했고 message body를 충분히 활용했습니다.

Java API를 충분히 활용하기

그리고 1주차 미션 피어 리뷰를 통해서 저는 활용해보지 못한 람다 표현식과 스트림 활용 예시를 많이 볼 수 있었습니다. 이를 계기로 이번 미션에서 필요할 때 바로 떠올릴 수 있도록 테코톡 영상 시청 등으로 간단하게 이에 대해 학습했습니다.

주어진 미션에 가장 집중하기

이번 미션에 대해서는 주어진 목표인 “함수 분리”와 “함수 별 테스트 작성”에 집중하고자 했습니다. 그리고 throws 키워드로 예외 발생 여부를 명시하는 등, 다른 사람과 공유할 수 있는 코드라고 생각하면서 개발하고자 했습니다. 갈 수록 단순 과제가 아니라 더 좋은 코드를 작성하고 싶어서 몰두하게 되고, 그 과정에서 계속 새로운 걸 공부하게 되는 소중한 시간인 것 같습니다.

🚀 2주차 미션 소감

미션 설명 및 제출 내용 보기

함수 분리

함수 분리를 “잘” 하기 위하여 기능 목록에 더해 구현 범위와 설계 계획을 함께 작성했습니다. 과거에 자바 학습 과정에서 마찬가지로 입출력으로 상호작용하는 서점 어플리케이션을 만들어 본 적이 있습니다. 그래서 그 때와 마찬가지로 ControllerService로 흐름 제어와 비즈니스 로직을 분리하는 큰 구조 내에서 개발하고자 했습니다. 하지만 큰 단위의 기능부터 구현하려고 하면, 나중에는 그 구조 자체를 바꾸는 것이 어려워진다는 것을 1주차 미션에서 깨달았기 때문에 미리 정리한 기능 목록과 범위를 참고하되, 그 안의 작은 기능부터 먼저 구현하고자 했습니다.

구현 기능 목록

입력값에 따라 절차적으로 진행되는 프로그램이기 때문에 절차에 따라 기능 목록을 정리하였습니다.

  1. 어플리케이션 실행 시 안내 메시지를 출력한다. 숫자 야구 게임을 시작합니다.
  2. 사용자가 맞혀야 하는 컴퓨터의 숫자를 생성한다.
    • 1~9까지 서로 다른 임의의 수 3개를 만든다.
    • 해당 게임이 종료될 때까지 변하지 않는 수로 저장한다.
    • 단위 테스트 : 컴퓨터숫자_생성 [✅]
      • 생성한 컴퓨터의 숫자를 담은 List 객체의 null 여부, class type, 크기를 체크한다.
  3. 사용자가 생각하는 컴퓨터의 숫자를 입력받는다.
    • 입력 받기 전에는 안내 메시지를 출력한다. 숫자를 입력해주세요 :
    • 1~9까지 서로 다른 수 3개를 입력받는다.
    • 입력받은 값을 검증한 뒤 실제 컴퓨터의 숫자와 비교할 수 있도록 저장한다.
    • 단위 테스트 : 사용자숫자_생성 [✅]
      • 저장한 사용자의 숫자를 담은 List 객체의 null 여부, class type, 크기를 체크한다.
      • 전달받은 사용자 입력값에 따른 예외발생 여부, 예외메시지를 체크한다.
  4. 입력받은 숫자와 컴퓨터의 숫자를 비교한 결과에 따라 힌트 메시지를 출력한다. 3스트라이크 / 1볼 1스트라이크 / 낫싱 ...
    • 힌트 메시지가 3스트라이크가 아니면 3번 기능부터 다시 실행한다.
    • 힌트 메시지가 3스트라이크가 맞으면 7번 기능을 실행한다.
    • 단위 테스트 : 힌트메시지_생성 [✅]
      • 입력받은 사용자 숫자와 컴퓨터 숫자를 비교하여 결과를 확인한 뒤 반환하는 힌트메시지가 기대값과 같은지 체크한다.
  5. 구현 과정에서 기존의 5번 기능은 삭제되었으나, 깃허브 이슈관리 번호와의 혼선을 방지하고자 기능 번호를 변경하지 않았음
  6. 해당 게임을 종료하고 사용자에게 입력값을 받아 게임 재시작 또는 어플리케이션을 종료한다.
    1. 사용자가 모든 숫자를 맞히면 해당 게임을 종료하고 아래와 같은 메시지를 출력한다.
      3개의 숫자를 모두 맞히셨습니다! 게임 종료
      게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.
    2. 안내 메시지에 따른 숫자를 입력받는다.
      1. 입력받은 숫자가 1이면 2번 기능부터 다시 실행한다.
      2. 입력받은 숫자가 2이면 어플리케이션을 완전히 종료한다.
    • 단위 테스트 : 게임종료_후_재시작 [✅]
      • 테스트케이스에서 주어진 랜덤 값과 입력 값을 이용해 실행했을 때 정상적으로 게임 종료 후 재시작되는지 체크한다.
  7. 3, 7번 기능에서 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException 예외를 발생시킨다.
    • 해당 예외를 발생시키고 어플리케이션이 종료되도록 한다.
    • 테스트에 활용하기 위해 예외에 대한 상세 메시지를 함께 전달한다.
    • 단위 테스트1 : 키보드_숫자_입력받기 [✅]
      • 입력받은 값이 정수인지 여부에 따른 반환값, 예외발생 여부, 예외메시지를 체크한다.
    • 단위 테스트2 : 키보드_재시작_종료_키워드_입력받기 [✅]
      • 입력받은 값이 정해진 재시작/종료 키워드인 "1", "2" 중 하나가 아니면 예외발생하는지 체크한다. (ex: "01", "재시작")
      • 입력값이 "1", "2" 중 하나이면 반환값이 기대값(true/false)과 같은지 체크한다.
    • 단위 테스트3 : 예외_테스트 3번 기능 구현 이후 [✅], 7번 기능 구현 이후 [✅]
      • 최종적으로 어플리케이션 자체에서 IllegalArgumentException 예외를 발생시켰는지 확인한다.

구현 범위 및 설계

  • 1번 기능은 Application클래스의 main 메소드에서 직접 구현한다.
  • 2, 3, 4, 7번 기능은 GameController, GameService, GameView 구조 내에서 구현한다.
    • GameService 클래스에서 숫자 생성, 숫자 입력(저장), 결과 계산과 같이 작은 단위의 기능을 수행한다.
    • GameView 클래스에서 게임 진행 중 이루어지는 입출력을 통한 사용자 상호작용을 수행한다.
    • GameController에서 GameServiceGameView를 이용해 게임 시작, 반복, 종료와 같은 큰 단위의 기능을 구현한다.
  • 6번 기능은 단순 데이터타입 검증과 비즈니스 로직에 따른 검증 두 가지 절차로 나누어 구현한다.
    • 공통된 경우(정수가 아님, 키워드가 아님)는 GameView에서 키보드 입력값을 받는 유틸클래스를 사용해 먼저 처리한다.
    • 입력한 숫자가 게임에서 정한 숫자의 형식에 맞지 않는 등 비즈니스 로직에 따른 예외는 GameService 내에서 처리한다.
  • 각 역할을 담당하는 세부클래스를 정의하여 구현한다.
    • model 패키지 (Controller,Service에서 비즈니스 로직 수행을 위해 사용됨)
      • GameNumbers : 게임에 사용되는 숫자의 속성을 정의하는 추상클래스
        • Computer : 컴퓨터 숫자를 생성하는 클래스
        • Player : 사용자 숫자를 생성하는 클래스
      • Result : 게임 내 한 라운드의 결과와 힌트메시지를 도출하는 클래스
    • utils 패키지 (전역적으로 사용할 수 있는 유틸리티 기능을 제공함)
      • KeyboardReader : 데이터타입에 맞는 사용자의 입력값을 받는 클래스
      • MessagePrinter : 문자열 또는 지정된 메시지값을 콘솔창에 출력하는 클래스
      • resources 패키지 : 상수로 처리하는 메시지, 약속된 키워드 값을 Enum 클래스로 관리함

물론 미리 작성한 가장 작은 단위의 기능 또한 실제로 구현해보니 더 작은 단위의 분리가 이루어졌습니다. 위 기능 목록과 설계 내용은 구현 과정 중에 계속해서 수정을 거친 내용입니다. 최종적으로는 view 담당 클래스까지 만들어서 MVC 패턴으로 구현하고자 했습니다.

이 과정에서 상속, 다형성을 적절히 활용하고자 했습니다. 똑같은 게임 숫자의 조건을 가진 사용자의 숫자와 컴퓨터의 숫자 클래스는 같은 추상클래스를 상속하게 했습니다.
반대로 입출력을 처리하는 등 공통적으로 사용되는 메소드들은 유틸 클래스에 정의하여 최대한 일반적인 기능을 수행하도록 만들었습니다.

추상 클래스 활용

abstract class GameNumbers {
    static final int NUMBER_COUNT = 3;
    static final int START_INCLUSIVE = 1;
    static final int END_INCLUSIVE = 9;

    void addValidNumber(List<Integer> numberList, int number) {
        if (isAddable(numberList, number)) {
            numberList.add(number);
        }
    }

    abstract boolean isAddable(List<Integer> numberList, int number);
}

Player, Computer 클래스가 이 클래스를 상속받아 위 상수와 메소드를 사용합니다. isAddable 메소드는 각 도메인 로직에 맞게 오버라이딩합니다. (랜덤숫자 생성 / 입력값 검증 후 생성)

Enum 클래스 활용

함수를 분리하고 독립적으로 만들기 위해서 이전에는 안써본 Java API 또한 활용해 볼 수 있었습니다. 메시지에 대한 상수가 너무 많아지자 이를 Enum 클래스로 분리해보았습니다.

public enum OutputMessage {
    RUN_APPLICATION("숫자 야구 게임을 시작합니다."),
    EXIT_BY_ERROR("오류로 인해 어플리케이션이 종료됩니다."),
    GUESS_COMPUTER_NUMBERS("숫자를 입력해주세요 : "),
    PLAYER_WIN("3개의 숫자를 모두 맞히셨습니다! 게임 종료"),
    RESTART_OR_QUIT("게임을 새로 시작하려면 " + InputKey.RESTART.text()
            + ", 종료하려면 " + InputKey.QUIT.text() + "를 입력하세요.")
    ;

    private final String text;

    OutputMessage(String text) {
        this.text = text;
    }

    public String text() {
        return text;
    }
}

인터페이스와 람다표현식 활용

또 테스트 코드에서는 중복 코드를 줄이고자 미션에서 제공되는 mission utils 라이브러리 내 NsTest 클래스의 run() 메소드를 활용하는 동시에 내부 클래스마다 다른 메소드를 실행할 수 있도록 인터페이스와 람다표현식을 활용해보았습니다.


class KeyboardReaderTest extends NsTest {
    private ReadingType readingType;
    private Object returnValue;

    interface ReadingType {
        Object read();
    }

    @Nested
    @DisplayName("키보드로 정수로 이루어진 문자열만 입력받기 테스트")
    class ReadOnlyIntegerTest {
        @BeforeEach
        void initializeReaderType() {
            setReadType(KeyboardReader::readLineOnlyInteger);
        }

        @Test
        @DisplayName("키보드 숫자 입력받기 : 정수값을 입력받은 경우 해당 문자열을 그대로 반환")
        void 키보드_숫자_입력받기_정수값을_입력받은_경우() {
            run("1");
            assertThat(returnValue.toString())
                    .isNotNull()
                    .isNotEmpty()
                    .isEqualTo("1");
        }
 		...
    }
    
     @Nested
    @DisplayName("키보드로 결정키워드 재시작/종료 입력받기 테스트")
    class ReadLineAsBooleanKeyTest {
        @BeforeEach
        void initializeReaderType() {
            setReadType(() -> KeyboardReader.readLineAsBooleanKey(InputKey.RESTART, InputKey.QUIT));
        }

        @Test
        @DisplayName("재시작/종료 키워드 입력받기 : 재시작 키워드를 받은 경우 true를 반환")
        void 키보드_결정_키워드_받기_재시작() {
            run(InputKey.RESTART.text());
            assertThat((boolean) returnValue).isTrue();
        }
		...
    }

    private void setReadType(ReadingType type) {
        this.readingType = type;
    }

    @Override
    public void runMain() {
        returnValue = readingType.read();
    }
}

다만 프로그램 절차의 흐름 대로 기능 목록을 정리 했더니, 정작 핵심 기능(두 숫자를 비교하여 결과를 도출)에 대한 클래스 설계가 모호해졌다는 점이 가장 아쉽습니다. Player, Computer 객체에서 만든 두 개의 List를 사용해 Result 객체를 생성하는 식으로 구현했습니다.
하지만 핵심 기능부터 생각하면서 구현했다면 보다 현실 세계와 유사한 방식으로 클래스를 구현할 수 있지 않았을까 생각이 듭니다.

함수 별 테스트 작성

테스트 코드 작성법 학습

개발 과정 속에서 “적시에” 단위 별로 진행해야 한다는 점이 중요하다고 생각했습니다. 그래서 테코톡 영상 및 검색, 라이브러리 분석을 통해 JUnit과 Assertj에 대한 기본적인 학습과 연습을 진행한 뒤 개발을 시작했습니다.
JUnit5, AssertJ 개요 및 사용법

처음에는 코드 작성법 뿐만 아니라 “어떤 기준으로 무엇을 테스트해야 하는지”도 막막하고 어려웠습니다. 하지만 예시 코드를 찾아보고, 메소드의 관점에서 확인해야 할 사항들을 정리하며 테스트 케이스를 만들어보니 조금씩 익숙해질 수 있었던 것 같습니다.
아래 테스트 케이스들은 모두 가장 작은 단위의 함수에 대하여 작성하였습니다.

단위 테스트를 통한 빠른 피드백

각 기능 단위 별로 테스트와 구현을 함께 진행하긴 했지만, TDD와는 다르게 기능 구현을 먼저 한 뒤 테스트를 작성하고 결과를 확인했습니다. 그러나 이러한 개발 단위가 계속 쌓이다 보니 어느새 기존의 테스트를 기준으로 구현하는 제 모습을 발견할 수 있었습니다. 리팩토링을 하더라도 구현한 기능에 문제가 없는지 확인하기가 매우 수월했습니다. 덕분에 생각지 못한 오류를 빨리 발견해 수정할 수 있었습니다. 뿐만 아니라 테스트 코드를 읽거나 작성하면서 개발 내용을 파악하는 데에도 큰 도움이 되었습니다.

주어진 테스트 코드 분석하기

미션에서 주어진 테스트 코드를 통해서도 배운 점이 많았습니다. 처음에는 해당 테스트의 작동 원리를 완전히 이해하지 않아도 나중에 기능을 다 구현 했을 때 통과를 하면 될 거라고 안일하게 생각했습니다.
하지만 시간이 갈 수록 테스트 중심으로 개발을 하게 되면서, “게임종료재시작” 테스트를 점검하게 되었고 생각지 못한 실패 원인을 발견했습니다. 랜덤값 생성 코드의 중복 실행으로 인해 테스트 코드에서 전달한 인자값이 로직 내에서 정상적으로 사용되지 않았기 때문이었습니다.

@Test
    void 게임종료_후_재시작() {
        assertRandomNumberInRangeTest(
                () -> {
                    run("246", "135", "1", "597", "589", "2");
                    assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
                },
                1, 3, 5, 5, 8, 9
        );
    }
  • 이 때 run 메소드는
    • 파라미터를 byte단위의 inputStream을 생성하여 System의 in에 set하는 command(args) 메소드와
    • Application의 main 메소드를 실행시키는 runMain() 메소드를 실행한다.
    • "246", "135", "1", "597", "589", "2" 을 input으로 설정한뒤 main 메소드의 출력 결과를 output() 으로 얻어 확인하는 것이다.
    • value, values … 는 랜덤 메소드가 실행됐을 때 대신 반환값으로 들어간다. (mockito 사용)
    • output() 메소드는 테스트 실행 중에만 특정 stream을 System.out에 설정하여 출력되는 값을 가져오는 메소드이다.
  • assertRandomNumberInRangeTest 와 같은 assertion들과 그에 사용된 메소드들은 우테코 프리코스에서 세팅해놓은 라이브러리 camp.nextstep.edu.missionutils.test에서 제공하고 있다.

이를 통해 테스트 코드에 대한 이해가 중요하다는 점 역시 깨달았습니다. 그리고 ApplicationTest에 작성된 코드를 보다 잘 이해하고 나니 직접 작성하는 테스트 코드에서도 비슷한 방식을 적용해 볼 수 있었습니다.

위 내용에서도 "TDD"를 언급했는데, 지난 회고에 "TDD"가 이런거였구나를 알 것 같다는 말을 쓴 지난 주의 제 자신이 벌써 부끄러워집니다.. 단위 테스트를 활용하며 개발을 해본 것이지 Test Driven Develope은 별개로 나중에 더 배워나가야 할 사항이라고 봐야 맞을 것 같습니다. 그래도 테스트를 통해 개발 과정에서 계속해서 빠르게 피드백받고, 수정해나갈 수 있었다는 점에서는 분명히 Test가 피드백을 통해 더 나은 개발을 위해 Drive해준다는 걸 느껴보지 않았나 싶습니다.

✅ 3주차 미션에서 보완할 점

  • 다음 주차에도 가장 중요한 것이 무엇인지, 요구된 목표가 무엇인지 정확히 파악하는 일을 우선시 할 것.
  • 기능 목록
    • README.md를 상세히 작성하되, 클래스 설계와 같이 변경 가능성이 있는 내용은 작성하지 않는다.
    • 구현 과정 중 변경 사항을 기록하며 살아 있는 문서를 작성하기 위해서, 불필요한 내용 없이 체계적으로 수정될 수 있는 구조를 가진 목록을 작성하자.
  • 예외사항에 대해서도 확실하게 정리할 것.
  • 변수 이름에 자료형을 사용하지 말 것.
    • 이번에 List 객체를 만들 때 numberList라는 명칭을 사용했던 것이 생각났다.
  • 작은 단위 기능 구현과 테스트를 함께 진행하는 것을 잊지 말 것.
  • 프로그램 실행 순서가 아니라, 핵심 기능부터 계획 및 구현할 것.
  • 프로그램 구조를 설계할 때 필요하다면 UML 작성해볼 것.
  • 이번에는 stream을 사용해보지 못했는데, 다음에는 필요한 상황에 활용해볼 것.
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 11월 9일

잘보고갑니다!

답글 달기