우아한테크코스 6기 프리코스 3주차
1. 2주차 공통 피드백
✅ README.md를 상세히 작성한다
- 2주차와 달리 어떠한 프로젝트인지 간단하게 기술하였다.
- 여태 README.md를 작성할 때는 우아한테크코스에서 미션으로 제공하는 README.md를 참고해서 만들었다. 해당 링크를 훑어보며 마크 다운에 대해 다시 정리했다.
- 마크 다운 중 체크 박스 기능을 사용해 구현한 기능을 한눈에 볼 수 있도록 확인했다. (참고)
✅ 기능 목록을 재검토한다
기존 기능 목록에서는 클래스 설계, 그리고 각각의 메서드를 어떻게 구현했는지 너무 상세하게 적은 것 같았다. 1주차 숫자 야구 피드백 강의와 비슷한 형태로 구현할 기능을 간단한 문장 형태로 적고 발생할 수 있는 예외 상황도 함께 기술했다.
✅ 값을 하드 코딩하지 않는다
자바에서의 상수는 한번 초기화하면 그 값을 바꿀 수 없는 변수이다. 다음의 변수는 모두 상수로 설정해 주었다.
// Lotto 클래스
public static final int PRICE = 1000;
public static final int NUMBERS_COUNTER = 6; // 로또 번호 최대 개수
public static final int START_NUMBER = 1;
public static final int END_NUMBER = 45;
// LottoApplication 클래스
final int NUMBER_OF_LOTTO_TO_DRAW = getNumberOfLotto(receivedAmount);
모두 상수이지만 Lotto 클래스의 상수는 모든 로또에 공통적인 특징을 갖고 있고 NUMBER_OF_LOTTO_TO_DRAW는 인스턴스마다 다른 고유의 값을 갖고 있다는 차이가 있다. 로또의 가격은 게임을 몇 번 돌리든 가격이 천 원으로 일정하지만 발행할 로또의 개수는 받은 돈에 따라 달라지기 때문에 이러한 차이점을 준 것이다.
사실 Enum 클래스 자체도 상수의 모음이다.
// Result 클래스
private void decideRank() {
if (matchingNumbersCounter == Rank.FIRST.getNumberOfMatching()) {
rank = Rank.FIRST;
}
if (matchingNumbersCounter == Rank.THIRD.getNumberOfMatching()) {
rank = Rank.THIRD;
if (isBonusMatching) {
rank = Rank.SECOND;
}
}
if (matchingNumbersCounter == Rank.FOURTH.getNumberOfMatching()) {
rank = Rank.FOURTH;
}
if (matchingNumbersCounter == Rank.FIFTH.getNumberOfMatching()) {
rank = Rank.FIFTH;
}
}
원래는 당첨 번호와 로또를 비교해 일치하는 숫자의 개수를 모두 하드코딩 했으나 Enum Rank에 정의하여 위와 같이 바꾸어주었다. 이렇게 하면 번호가 일치해야 하는 조건이 바뀔 때 값을 여러 곳에서 변경하지 않아도 된다.
✅ 변수 이름에 자료형은 사용하지 않는다
✅ 한 함수가 한 가지 기능만 담당하게 한다
피드백에서 나온 2주 차 예시를 다음과 같이 나누어 보았다.
public List<String> userInput() {
System.out.println("경주할 자동차 이름을 입력하세요(이름은 쉼표(,)를 기준으로 구분).");
String userInput = Console.readLine().trim();
String[] splittedName = userInput.split(",");
for (int index = 0; index < splittedName.length; index++) {
if (splittedName.length < 1 || splittedName.length > 5) {
throw new IllegalArgumentException("[ERROR] 자동차 이름은 1자 이상 5자 이하만 가능합니다.");
}
}
return Arrays.asList(splittedName);
}
2번째 줄: 안내 문구 출력 기능
3-4번째 줄: 사용자 입력값 서식 설정(formatting) 기능
5-9번째 줄: 유효성 검사 기능
이렇게 user의 입력값을 받는 한 함수가 세 가지 기능을 포함하고 있다는 사실을 알 수 있다.
3주차 미션의 Lotto 클래스를 예로 들면 Lotto 인스턴스를 만들 때 (로또 발행) 유효성 검사를 하게 된다. 이때, validate 메서드 하나에 모든 종류의 유효성 검사를 넣는 것이 아니라 검사마다 분리해 validate가 이 검사를 각각 호출하게 만들었다.
또한 이 요구사항에 따라 Result라는 클래스를 만들게 되었다. 티켓(당첨 번호 + 보너스 번호)과 발행한 로또를 비교하는 작업은 몇 개의 번호가 일치하는지, 그리고 보너스 번호가 일치하는지 두 가지 결과를 낳게 된다. 이 과정을 발행 모든 로또와 일일이 다 비교를 해야 하는데 이를 모두 함수로 구현하게 되면 함수의 크기가 너무 커지기 때문이다.
✅ 함수가 한 가지 기능을 하는지 확인하는 기준을 세운다
- 중복되어 사용되는 코드 확인
- 함수의 길이 15라인 넘어가지 않도록 확인
- 열 제한 100자 이내 (Java 코딩 컨벤션)
// Result 클래스
private void decideRank() {
if (matchingNumbersCounter == Rank.FIRST.getNumberOfMatching()) {
rank = Rank.FIRST;
}
if (matchingNumbersCounter == Rank.THIRD.getNumberOfMatching()) {
rank = Rank.THIRD;
if (isBonusMatching) {
rank = Rank.SECOND;
}
}
if (matchingNumbersCounter == Rank.FOURTH.getNumberOfMatching()) {
rank = Rank.FOURTH;
}
if (matchingNumbersCounter == Rank.FIFTH.getNumberOfMatching()) {
rank = Rank.FIFTH;
}
}
해당 메서드는 순위를 결정한다. 위 기준에 모두 부합하였기 때문에 어떻게 줄여볼까 고민했었지만 도저히 방법이 생각나지 않았다.
// LottoApplication 클래스
void execute() {
try {
int receivedAmount = getReceivedAmount();
List<Lotto> createdLottos = new ArrayList<>();
final int NUMBER_OF_LOTTO_TO_DRAW = getNumberOfLotto(receivedAmount);
for (int i = 0; i < NUMBER_OF_LOTTO_TO_DRAW; i++) {
createdLottos.add(drawLotto());
}
Output.printCreatedLottos(createdLottos);
Lotto pickedNumbers = getPickedNumbers();
Bonus bonus = getBonusNumber();
Ticket ticket = new Ticket(pickedNumbers, bonus); // 구매자의 티켓
List<Result> results = compareTicketAndLottos(ticket, createdLottos);
int[] rankCounter = getRankingCounter(results);
Output.printWinningStatistic(rankCounter);
double totalProfit = calculateWinningProfit(receivedAmount, calculateTotalWinningAmount(rankCounter));
Output.printTotalProfit(String.valueOf(totalProfit));
} catch (IllegalArgumentException iae) {
Output.printErrorMessage(iae.getMessage());
}
}
로또 게임의 모든 진행을 담당하는 execute 메서드 또한 15 라인이 넘었기 때문에 모든 로또를 발행 (drawLottos) 하는 메서드를 따로 만들고 Output을 담당하는 메서드도 각각의 관련된 메서드가 담당하도록 아래와 같이 바꾸어주었다.
// LottoApplication 클래스
void execute() {
try {
Buyer buyer = new Buyer(getReceivedAmount(), new Ticket(getPickedNumbers(), getBonusNumber()));
List<Lotto> drawnLottos = drawLottos(buyer);
List<Result> results = compareTicketAndLottos(buyer.getTicket(), drawnLottos);
int[] rankCounter = getRankCounter(results);
calculateWinningProfit(buyer.getReceivedAmount(), calculateTotalWinningAmount(rankCounter));
} catch (IllegalArgumentException iae) {
Output.printErrorMessage(iae.getMessage());
}
}
LottoApplication, Rank 클래스에 100자를 넘는 줄이 있었지만 나머지는 최대한 줄이려고 노력했다.
✅ 테스트를 작성하는 이유에 대해 본인의 경험을 토대로 정리해 본다
우선 피드백에서 얘기한 테스트 작성의 유용함은 빠른 피드백 그리고 학습 도구로서의 사용이다.
<피드백에서 제공한 파일로 학습한 것 (학습테스트를 통해 JUnit 학습하기.pdf)>
String 클래스에 대한 학습 테스트는 2주차에 다루었기 때문에 생략했다.
주석 (@)
- @BeforeEach - 이 annotation을 사용한 메서드 같은 경우 해당 테스트 클래스의 인스턴스 변수를 초기화하는 작업을 해준다. 마치 생성자, 초기화 블록과 같은 역할을 한다.
- @ParameterizedTest - 하나의 테스트 메서드를 다수의 매개변수로 여러 번 실행시킬 수 있다.
- @ValueSource - 리터럴 값의 배열을 제공할 수 있다.
- @CsvSource - 구분자로 구분된 String 리터럴을 배열로 준다. 구분자는 기본적으로 쉼표이며 다른 기호로 지정할 수도 있다.
단, 타입에 제한이 있다.
- short (with the shorts attribute)
- byte (bytes attribute)
- int (ints attribute)
- long (longs attribute)
- float (floats attribute)
- double (doubles attribute)
- char (chars attribute)
- java.lang.String (strings attribute)
- java.lang.Class (classes attribute)
assertThat, assertThatThrownBy, assertEquals를 주로 사용했다.
<이번 미션 테스트로 학습한 것>
기능별로 테스트 메서드를 작성하게 되면 피드백에서 언급했던 한 함수는 한 기능만 하도록 하는 것이 자연스러워진다. 예를 들어 당첨 번호를 입력받을 때는 많은 제한 사항이 있다. 이런 제한 사항을 검사하는 일은 보통 인스턴스가 생성될 때, 생성자 호출 시 이루어진다. 이때 어떤 예외 사항이 발생할 수 있는지 미리 생각해 보고 예외의 경우(각기 다른 테스트 메서드)에 따라 함수를 나누면 상호보완이 된다. 여러 기능을 하는 함수에서는 어디서 문제점이 발생하는지 파악하기 힘든데 기능 별로 세분화해서 테스트를 구성, 각 테스트에 맞춰 메서드를 작성하게 되면 문제점 파악 및 코드 작성이 훨씬 수월해진다.
✅ 처음부터 큰 단위의 테스트를 만들지 않는다
package racingcar;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
class CarTest {
@Test
void 현재_상태를_실행_결과와_같이_출력() {
Car testCar = new Car("jun");
for (int i = 0; i < 5; i++) { // 5번 이동
testCar.moveForward();
}
for (int i = 0; i < 2; i++) { // 2번 멈춤
testCar.stop();
}
assertThat(testCar.toString()).isEqualTo("jun : -----"); // toString() 생략 불가
}
}
2주차 내가 만든 Car 기능에 대한 테스트이다. 어차피 해당 메서드가 자동차 전진, 멈춤에 대한 기능과 출력하는 기능 모두 포함하고 있는 것이니 굳이 작은 단위의 테스트로 분리할 필요가 없다고 생각했다. 하지만 위에서 언급한 것처럼 이렇게 되면 테스트 케이스 크기가 커지고 에러가 발생했을 경우 에러 위치를 찾기가 어렵다.
또한 LottoApplication controller에 대한 테스트 코드는 만들지 않았는데 controller는 명령을 전달받고 처리, 알려주는 역할만 하기 때문이다. 그리고 execute(실행)을 제외한 모든 메서드도 private이라 따로 호출할 수 없다.
2. 요구 사항
✅ Java Enum을 적용한다.
- Enum은 클래스 중 조금 더 세분화된, 상수만 있는 클래스이다. 처음에 Enum 클래스를 만들어야지 작정하고 만든 것은 아니고 코드를 계속 짜다 보니 상수 - 상금 금액, 당첨 번호와 일치하는 숫자의 개수, 출력값의 설명이 모두 등수와 관련된 상수여서 하나의 Rank Enum 클래스를 만들게 되었다.
- Enum도 클래스이기 때문에 멤버를 추가할 수 있다. 1등부터 5등, 그 외의 열거형 상수를 만들어주고 상금 금액, 당첨 번호와 일치하는 숫자의 개수, 출력값의 설명을 멤버로 추가해 주었다.
- 출력 요구 사항에 보면 금액을 쉼표를 기준으로 구분해서 보여주었기 때문에 형식화 클래스 DecimalFormat을 사용해 long 타입 값을 알맞은 형태의 String으로 반환했다. 여기에서 금액을 int 타입이 아닌 long 타입을 사용한 이유는 1등의 상금 금액이 int의 최댓값 20억에 근사하기 때문이었다. 만약 int 값으로 계산을 하면 최댓값이 넘어가는 상황이 발생할 수도 있다.
✅ 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
- ResultTest 클래스 테스트 메서드를 @ParameterizedTest 형식으로 작성을 해보려 했으나 타입에 제한이 있어서 구현에 실패했다. 대신 모든 결과에 대해 맞는 1등부터 5등까지 맞는 순위가 매겨지는지를 확인하기 위해 5개의 테스트 메서드를 만들었다.
- 발행된 로또 중 하나와 티켓 (당첨 번호 + 보너스 번호)를 비교하는 메서드는 반환 타입이 void이지만 서로 일치하는 번호를 세주는 변수(matchingNumbersCounter)를 증가한다. 하지만 이 변수의 접근 제어자가 private이기 때문에 테스트 메서드로 따로 확인할 방법이 없었다.
- Buyer의 테스트 클래스는 따로 없다. Buyer가 입력값으로 제공하는 당첨 번호와 보너스 번호에서 발생할 수 있는 예외들은 각각 로또, 보너스 클래스가 이미 처리하기 때문이다.
✅ 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
✅ 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다.
처음에 UI를 담당하는 로직은 해당 요구 사항 위에 있는 문장 "단, UI(System.out, System.in, Scanner) 로직은 제외한다." 때문에 무엇인지 알 수 있었다. 그렇기 때문에 view라는 패키지를 만들고 입력과 출력을 담당하는 클래스를 각각 만들었다. 해당 클래스들의 메서드는 인스턴스 변수를 사용하지 않기 때문에 모두 static 메서드로 구현했다.
문제는 앞부분 핵심 로직이 도대체 뭘까 고민을 많이 했다. 핵심, 도메인, 비즈니스 로직 모두 같은 말이다. 간단히 정리해 이는 모두 "소프트웨어가 해결하려는 현실의 문제"이다.
이번 로또 미션을 예로 들면 로또를 구매자가 지불한 만큼 생성, 랜덤 하게 서로 다른 로또를 만드는 것, 이런 것들이 모두 도메인 로직에 속한다.
그렇다면 우리가 작성하는 모든 코드가 현실의 문제를 해결하려는 의도가 아닐까 하는 의문이 든다. 하지만 다음 문장에 따라 비즈니스 로직과 그 외 애플리케이션 서비스 로직을 구분할 수 있다.
비즈니스 로직은 의사 결정을 한다.
이번 로또 미션을 예로 들어보자.
1. 구매자에게 돈을 받는다.
2. 돈이 1,000원 단위가 아니면 구매자에게 1,000원 단위가 아닌 돈은 받을 수 없다고 알려준다.
3. 로또를 구매 금액만큼 발행한다.
4. 순위를 매긴다.
5. 당첨금을 계산한다.
6. 구매자에게 당첨금 결과를 알려준다.
여기에서 3, 4, 5번은 로또라는 의사 결정에 기여를 하고 나머지 1번, 2번과 6번은 입출력 즉 UI를 담당하고 있다. 이외에도 결제할 때 외부 결제 시스템을 이용하도록 요청하는 네트워킹 또는 구매 이력 등을 데이터베이스에 저장하는 행위들이 있지만 미션에서는 간단하게 도메인 로직, UI 로직만 구분하도록 했다.
이러한 이해를 바탕으로 해당 로직을 반영하는 구조인 MVC 패턴을 적용했다.
<Model (lotto.entity)>
Model 혹은 entity는 객체의 정보를 저장하는 곳이다. 보통은 데이터베이스에서 불러오지만 이 로또 앱에서는 클래스가 인스턴스를 생성해 앱이 실행되는 동안 담는 역할을 하도록 클래스 형태로 만들었다.
<View (lotto.view)>
말 그대로 view는 데이터를 보여준다. 데이터를 보여주는 형태는 표, 그래프, 문자 어떤 형태로든 상관없다. View는 또한 controller에게 사용자(구매자)의 행동을 처리하도록 요청한다.
<Controller (lotto의 LottoApplication)>
그림에서도 확인할 수 있듯이 controller는 model과 view를 중재하는 역할을 한다. 명령을 받으면 그에 대한 결과를 view와 model에게 각각 업데이트해준다.
이 패턴을 적용할 때 한 가지 헷갈렸던 부분은 과연 유효성 검사는 어떤 클래스가 해야 하냐는 것이었다. 그래서 다음과 같은 원칙을 세우고 이에 따라 유효성 검사를 하는 함수를 각각의 클래스의 메서드로 집어넣었다.
A라는 예외가 발생할 경우 B가 처리한다.
예를 들어 A: 구입 금액이 1400원이라고 하면 당연히 이를 처리해야 하는 B는 로또 앱일 것이다.
이와 같은 맥락으로 Buyer의 Bonus는 단지 정수 하나일 뿐인데 굳이 객체를 만든 이유를 설명해 보자면, 보너스 값을 입력받을 때 유효성 검사를 해야 하는데 보너스 번호를 Buyer의 인스턴스 변수로 집어넣게 되면 Buyer가 유효성 검사를 하는 것으로 해석되기 때문에 당첨 번호와 보너스를 갖고 있는 Ticket 객체를 만들었다.
✅ Lotto 클래스에 필드(인스턴스 변수)를 추가할 수 없다.
public static final int PRICE = 1000;
public static final int NUMBERS_COUNTER = 6; // 로또 번호 최대 개수
public static final int START_NUMBER = 1;
public static final int END_NUMBER = 45;
해당 변수들은 인스턴스 변수가 아니라 상수이기 때문에 추가해 주었다.
✅ Lotto의 패키지 변경은 가능하다.
entity 패키지로 변경해 주었다.
✅ 예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]"로 시작해야 한다.
원래는 예외 상황이 발생했을 때 throw로 예외를 던지고 끝냈다. 하지만 이럴 경우 구매자는 어떤 에러가 발생했는지 모르고 프로그램이 종료되게 된다. 예외를 던질 때 생성자에 문자열을 제공해 주면 어떤 에러가 발생했는지 알려줄 수 있다.
UI를 담당하는 로직을 구분해서 구현해야 하기 때문에 모든 예외를 execute에서 try-catch문으로 잡아 Ouput의 static 메서드에 넘겨주었다.