- Published on
우아한테크코스 5기 백엔드 프리코스 3주차 후기 - 로또 게임
프리코스 3주차 미션을 마쳤다! 이번 주차 주제는 로또 게임 🎱 2주차에 비해 구현할 프로그램 자체도 어려워졌고, 요구 사항도 많아졌다. 3주차 미션을 진행하며 새로 배우고, 고민한 것들을 기록하고자 한다!
Tmi) 기능 목록을 작성하다가 옆에 있던 친구가 삘 받아서 진짜로 로또를 샀다.. 나도 번호를 골랐는데, 당첨은 안 되었다고 한다 😅
새로 배운 것들
- 도메인 로직과 비즈니스 로직(UI 등)의 구분 (도움이 되었던 레퍼런스)
- Java Enum의 적용
- 자바에서의 에러 전파
클래스 분리와 서브 패키지 분류
클래스의 분리와 설계는 저번 주차부터 꾸준히 고민하던 부분이다. 그러나 2주차에는 게임의 핵심 로직과 입력 로직만을 분리해 아쉬움이 남았던 기억이 있다. 3주차에는 다음과 같은 구조를 설계해 적용해보았다!
Lotto
ㄴ Lotto.domain : Lotto, LottoGame
ㄴ Lotto.ui : Input, Output
ㄴ Lotto.validator : Validator
- Lotto는 기본으로 제공된 클래스로, 각 로또 한 장을 의미한다. 로또 번호의 검증, 당첨 번호와의 매칭 확인, 각 로또의 등수 계산 등 로또 한 장을 단위로 이루어져야 할 작업들을 맡는다.
- LottoGame은 로또 발행, 게임 결과 계산, 수익률 계산 등 한 번의 로또 게임이 진행될 때 필요한 기능들을 담당한다.
- Input, Output, Validator는 이름 그대로 입력, 출력, 예외처리를 담당하는 친구들!
또, 이렇게 분리한 클래스들을 로직에 따라 분리하여 domain, ui, validator의 세 개로 분류했다. 이에 처음으로 서브 패키지를 도입해 적용해보았다. 이렇게 클래스 하나의 역할을 명확히 하고, 이에 따라 클래스를 분리 및 분류하니 각 클래스의 역할이 더욱 명확해지고 모듈의 응집도가 높아졌음을 체감할 수 있었다.
Enum.. 이 넘 어떻게 사용할까
새로 주어진 요구사항: Java Enum을 적용해라! 하지만 Enum이 뭔지 모른다 🤔 알아보니 '열거형 상수'라는 것으로, 관련이 있는 상수들을 집합으로 묶은 것이라 한다. 어떻게 사용해보면 좋을까 고민하다가 각 로또의 당첨 결과(등수)를 나타내는 enum Rank를 만들어 보기로 했다. 1등부터 5등까지 각 등수는 일련의 연속된 개념이고, 각 등수에 따라 당첨금과 설명 등 상세 내용이 달라지기 때문에 이들을 한 번에 엮어 그룹화하면 관리하기 좋을 것 같았다.
package lotto.domain;
public enum Rank {
FIRST_PLACE("1등", 1, 2000000000, "6개 일치 (2,000,000,000원) - "),
SECOND_PLACE("2등", 2, 30000000, "5개 일치, 보너스 볼 일치 (30,000,000원) - "),
THIRD_PLACE("3등", 3, 1500000, "5개 일치 (1,500,000원) - "),
FOURTH_PLACE("4등", 4, 50000, "4개 일치 (50,000원) - "),
FIFTH_PLACE("5등", 5, 5000, "3개 일치 (5,000원) - "),
NO_PLACE("6등 이하", 6, 0, "2개 이하 일치 - ")
;
private final String title;
private final int place;
private final int profit;
private final String detail;
// 그리고 getter 메소드들..
}
이런 식으로 enum Rank를 구성해보았다. 각 로또의 당첨 등수 계산, 전체 당첨금 계산, 결과 출력 등 등수 정보가 필요한 곳에서는 이 Rank의 getter 메소드들을 사용하여 Rank 데이터에 접근한다. 따라서 추후 내용 수정이 필요하게 되면 Rank만 수정하면 되며, 유지보수도 용이할 것이다. 또한, Rank의 내부 멤버변수들은 당첨금 등 중요한 정보를 저장하며 이들은 프로그램 실행 도중 변경되어서는 안되므로, private 및 final 세팅을 통해 나름의 캡슐화와 보안 처리(?)를 했다.
자바 예외 전파하기
⛔️ 5 tests completed, 1 failed. FAILURE: Build failed with an exception.
ApplicationTest의 예외 테스트가 자꾸 실패했다. 입력으로 '1000j'가 들어온 경우에 대한 테스트이다. 당황스러운 것은 나는 이미 그 부분에 대한 예외 처리를 구현해, 동작을 확인해두었다는 것이다.. WHAT ??? 🤯
아래는 정수가 아닌 입력에 대해 내가 기존에 구현해두었던 예외 처리. 입력을 받는 Input 클래스의 getPurchaseAmount() 메소드에서 입력을 받은 뒤 이 함수를 호출해 예외를 확인하고, 예외가 발생하면 IllegalArgumentException을 던지는 의도로 구현했다. 실제 실행에선 문제가 없었지만, 예외 테스트는 계속 실패했다. (각 문자의 아스키코드를 직접 검사하는 상당히 비효율적인 로직으로 작성했는데, 결론적으로 이 메소드는 삭제되어 사용되지 않았다. 이에 대해서는 후술한다.)
public static void checkPurchaseMoneyInputForm(String purchaseMoneyInput) {
for (int i=0; i<purchaseMoneyInput.length(); i++) {
char inputDigit = purchaseMoneyInput.charAt(i);
if (inputDigit < 48 || inputDigit > 58) {
throw new IllegalArgumentException("[ERROR] 정수를 입력하세요");
}
}
}
알아보니 이는 assertThat()이 예외를 인식하지 못했기 때문에 생기는 문제다. 예외를 인식하지 못하니 당연히 해당 예외에서 발생한 에러 메시지 출력도 인식하지 못하고, 테스트가 실패로 끝나게 된다.
이에 대한 해결책 중 하나로, 예외 전파의 개념을 새로 알게 되었다. 예외 전파란 예외가 발생한 뒤, 발생한 예외가 다른 상위 계층으로 전달될 때마다 이를 새로운 예외에 포함시켜 다시 예외를 던짐으로써 발생한 예외를 보존하는 과정이다.
발생한 예외를 올바르게 전파하도록 getPurchaseAmount()를 다음과 같이 수정했다. 기존의 getPurchaseAmount()는 위 checkPurchaseMoneyInputForm() 호출을 통해 입력 형태를 확인하고 만약 예외가 발생한다면 checkPurchaseMoneyInputForm()에서 예외가 발생한 채 프로그램이 종료되고, 예외가 발생하지 않는다면 정수 변환 등의 추후 동작이 실행된다.
수정된 getPurchaseAmount()에서는 try ... catch 문을 사용하여, 정수 변환을 시도한 뒤 에러가 발생하면 메시지를 출력하는 식으로 변경되었다.
public int getPurchaseAmount() {
System.out.println("구입금액을 입력해 주세요.");
String purchaseMoneyInput = Console.readLine();
int purchaseMoney = 0;
try {
purchaseMoney = Integer.valueOf(purchaseMoneyInput);
} catch (Exception notOnlyNumber) {
System.out.println("[ERROR] 정수를 입력하세요");
}
Validator.checkPurchaseMoney(purchaseMoney);
int purchaseAmount = purchaseMoney / 1000;
return purchaseAmount;
}
이때, 숫자가 아닌 입력을 걸러내는 로직을 직접 구현할 필요가 없다. 자바의 Exception 클래스에서 제공하는 NumberFormatException 케이스가 있기 때문이다. 따라서 숫자로 변환할 수 없는 문자가 포함된 예외가 발생했을 경우 따로 커스텀 예외를 만들지 않아도 되며, Exception 객체가 감지되었을 경우를 기반으로 예외 처리를 구현했다.
내가 기존에 구현한 방식대로면 [입력 로직] -> [입력값 검증 로직] 이 진행된 뒤 [입력값 검증 로직]에서 예외가 발생하고 프로그램이 그 위치에서 종료된다. 그러나 이렇게 수정하고 나니 [입력 로직]에서 바로 예외 발생을 확인하고 처리할 수 있어 문제가 발생한 위치를 더욱 명확하게 알 수 있겠다는 생각이 들었다.
이렇게 폭풍 구글링 끝에 코드 수정을 마치고 나니 어느덧 제출 8분 전이었다. 정말.. 신이 도왔다.. ㅠㅠ 하지만 아직 Junit5 사용과 자바의 예외 처리, 예외 전파에 대한 내용은 익숙치 않아서 해결은 했지만 찝찝한 느낌이다. 이에 대해서는 더 공부가 필요하다.
🔍 도움을 받은 자료들 https://yeonyeon.tistory.com/170, https://castleone.tistory.com/12
잘한 점
1주차부터 꾸준히 기능 목록을 업데이트 해오고 있었다. 1주차에는 이게 기초 설계가 탄탄하지 않아서 그런 줄 알고 반성을 했는데, 2주차 공통 피드백을 통해 기능 목록을 꾸준히 재검토 & 업데이트 하는 습관은 좋은 습관임을 알게 되었다.
하나의 메소드가 하나의 기능만을 수행하게 하라는 원칙을 잘 지키고 있는 것 같다. 이번 주차부터 새로 생긴 요구 사항으로 함수의 길이 제한(15라인)이 생겼는데, 꾸준히 1기능 1메소드를 기준으로 연습해왔더니 의외로 그리 어렵지 않게 요구 사항을 준수할 수 있었다.
아쉬운 점
아직 테스트 코드 작성이 어렵다. 저번 주차에도 어려워했던 부분인데.. 좀 더 열심히 공부를 했어야 했다. 단위 테스트를 적절하게 작성하지 못한 것이 아쉽다.
상수에 대한 하드코딩을 하지 말라는 원칙을 지키지 못한 것. 문자열, 상수 등을 따로 빼고 상수화(static final)시켜 사용했어야 했는데 막상 구현하다보니 나도 모르게 코드에 그대로 문자열, 상수를 직접 넣고 있었다. 다음주에는 이런 부분도 꼼꼼히 개선해야겠다.
LottoGame 클래스에서는 로또 게임에 대한 전반적인 로직을 담당한다. 이 전체적인 흐름을 진행하는 play() 메소드를 만들었는데, 이 메소드에서는 번호 입력부터 수익률 출력까지 모든 동작의 메소드 호출을 담당하기 때문에 함수 하나의 기능이 너무 비대해진 느낌이다. 짜면서도 계속 '이게 맞나..' 라는 생각이 들었는데, 이 부분에 대해서는 미션 제출이 끝나고 커뮤니티에 한 번 의견을 여쭤봐야겠다.