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


그리고 이번주에는 빠르게 구현을 시작하기 보다는 지켜야 할 것, 새로 배워야 할 것을 먼저 충분히 파악하고자 했습니다. 그래서 이전에는 사용해보지 않은 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라는 명칭을 사용했던 것이 생각났다.
잘보고갑니다!