많은 종류의 고민과 어려움이 매일 새롭게 생겨나다보니, 이번 주차부터는 정제된 문장이 아니더라도 매일 투두리스트와 함께 일지 형식으로 기록하며 작업하기 시작했습니다.
그리고 이번주에는 빠르게 구현을 시작하기 보다는 지켜야 할 것, 새로 배워야 할 것을 먼저 충분히 파악하고자 했습니다. 그래서 이전에는 사용해보지 않은 API를 검색해보고 적용하는 일을 더 적극적으로 할 수 있던 것 같습니다.
먼저 지난주 미흡했던 점 중 하나인 “기능 단위 커밋”을 보다 일관적으로 작성하기 위하여, 주어진 커밋 컨벤션에 개인적으로 지킬 원칙을 추가해 정리했습니다. 뿐만 아니라 깃허브의 이슈번호를 사용하여 각 기능과 커밋을 연결해서 기능 단위 별 변경 이력을 쉽게 파악하도록 했고 message body를 충분히 활용했습니다.
그리고 1주차 미션 피어 리뷰를 통해서 저는 활용해보지 못한 람다 표현식과 스트림 활용 예시를 많이 볼 수 있었습니다. 이를 계기로 이번 미션에서 필요할 때 바로 떠올릴 수 있도록 테코톡 영상 시청 등으로 간단하게 이에 대해 학습했습니다.
이번 미션에 대해서는 주어진 목표인 “함수 분리”와 “함수 별 테스트 작성”에 집중하고자 했습니다. 그리고 throws 키워드로 예외 발생 여부를 명시하는 등, 다른 사람과 공유할 수 있는 코드라고 생각하면서 개발하고자 했습니다. 갈 수록 단순 과제가 아니라 더 좋은 코드를 작성하고 싶어서 몰두하게 되고, 그 과정에서 계속 새로운 걸 공부하게 되는 소중한 시간인 것 같습니다.
함수 분리를 “잘” 하기 위하여 기능 목록에 더해 구현 범위와 설계 계획을 함께 작성했습니다. 과거에 자바 학습 과정에서 마찬가지로 입출력으로 상호작용하는 서점 어플리케이션을 만들어 본 적이 있습니다. 그래서 그 때와 마찬가지로 Controller
와 Service
로 흐름 제어와 비즈니스 로직을 분리하는 큰 구조 내에서 개발하고자 했습니다. 하지만 큰 단위의 기능부터 구현하려고 하면, 나중에는 그 구조 자체를 바꾸는 것이 어려워진다는 것을 1주차 미션에서 깨달았기 때문에 미리 정리한 기능 목록과 범위를 참고하되, 그 안의 작은 기능부터 먼저 구현하고자 했습니다.
입력값에 따라 절차적으로 진행되는 프로그램이기 때문에 절차에 따라 기능 목록을 정리하였습니다.
- 어플리케이션 실행 시 안내 메시지를 출력한다.
숫자 야구 게임을 시작합니다.
- 사용자가 맞혀야 하는 컴퓨터의 숫자를 생성한다.
- 1~9까지 서로 다른 임의의 수 3개를 만든다.
- 해당 게임이 종료될 때까지 변하지 않는 수로 저장한다.
- 단위 테스트 :
컴퓨터숫자_생성
[✅]
- 생성한 컴퓨터의 숫자를 담은 List 객체의 null 여부, class type, 크기를 체크한다.
- 사용자가 생각하는 컴퓨터의 숫자를 입력받는다.
- 입력 받기 전에는 안내 메시지를 출력한다.
숫자를 입력해주세요 :
- 1~9까지 서로 다른 수 3개를 입력받는다.
- 입력받은 값을 검증한 뒤 실제 컴퓨터의 숫자와 비교할 수 있도록 저장한다.
- 단위 테스트 :
사용자숫자_생성
[✅]
- 저장한 사용자의 숫자를 담은 List 객체의 null 여부, class type, 크기를 체크한다.
- 전달받은 사용자 입력값에 따른 예외발생 여부, 예외메시지를 체크한다.
- 입력받은 숫자와 컴퓨터의 숫자를 비교한 결과에 따라 힌트 메시지를 출력한다.
3스트라이크 / 1볼 1스트라이크 / 낫싱 ...
- 힌트 메시지가
3스트라이크
가 아니면 3번 기능부터 다시 실행한다.- 힌트 메시지가
3스트라이크
가 맞으면 7번 기능을 실행한다.- 단위 테스트 :
힌트메시지_생성
[✅]
- 입력받은 사용자 숫자와 컴퓨터 숫자를 비교하여 결과를 확인한 뒤 반환하는 힌트메시지가 기대값과 같은지 체크한다.
- 구현 과정에서 기존의 5번 기능은 삭제되었으나, 깃허브 이슈관리 번호와의 혼선을 방지하고자 기능 번호를 변경하지 않았음
- 해당 게임을 종료하고 사용자에게 입력값을 받아 게임 재시작 또는 어플리케이션을 종료한다.
- 사용자가 모든 숫자를 맞히면 해당 게임을 종료하고 아래와 같은 메시지를 출력한다.
3개의 숫자를 모두 맞히셨습니다! 게임 종료
게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.
- 안내 메시지에 따른 숫자를 입력받는다.
- 입력받은 숫자가 1이면 2번 기능부터 다시 실행한다.
- 입력받은 숫자가 2이면 어플리케이션을 완전히 종료한다.
- 단위 테스트 :
게임종료_후_재시작
[✅]
- 테스트케이스에서 주어진 랜덤 값과 입력 값을 이용해 실행했을 때 정상적으로 게임 종료 후 재시작되는지 체크한다.
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
에서GameService
와GameView
를 이용해 게임 시작, 반복, 종료와 같은 큰 단위의 기능을 구현한다.- 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 메소드는 각 도메인 로직에 맞게 오버라이딩합니다. (랜덤숫자 생성 / 입력값 검증 후 생성)
함수를 분리하고 독립적으로 만들기 위해서 이전에는 안써본 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해준다는 걸 느껴보지 않았나 싶습니다.
numberList
라는 명칭을 사용했던 것이 생각났다.
잘보고갑니다!