우아한테크코스 - 프리코스 3주차 : 로또

미션은 로또를 포크하고 클론하는 것으로 시작한다.
기능을 구현하기 전 README.md에 구현할 기능 목록을 정리해 추가한다.
Git의 커밋 단위는 앞 단계에서 README.md에 정리한 기능 목록 단위로 추가한다.
AngularJS Git Commit Message Conventions을 참고해 커밋 메시지를 작성한다.
자세한 과제 진행 방법은 프리코스 진행 가이드 문서를 참고한다.


기능 요구 사항

간단한 로또 발매기를 구현한다.

  • 로또 번호의 숫자 범위는 1~45까지이다.
  • 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다.
  • 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다.
  • 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다.
    • 1등: 6개 번호 일치 / 2,000,000,000원
    • 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원
    • 3등: 5개 번호 일치 / 1,500,000원
    • 4등: 4개 번호 일치 / 50,000원
    • 5등: 3개 번호 일치 / 5,000원
  • 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다.
  • 로또 1장의 가격은 1,000원이다.
  • 당첨 번호와 보너스 번호를 입력받는다.
  • 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시키고, “[ERROR]”로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.
    • Exception이 아닌 IllegalArgumentException, IllegalStateException 등과 같은 명확한 유형을 처리한다.


입출력 요구 사항

입력
  • 로또 구입 금액을 입력 받는다.
  • 구입 금액은 1,000원 단위로 입력 받으며 1,000원으로 나누어 떨어지지 않는 경우 예외 처리한다.
    14000
    
  • 당첨 번호를 입력 받는다.
  • 번호는 쉼표,를 기준으로 구분한다.
    1,2,3,4,5,6
    
  • 보너스 번호를 입력 받는다.
    7
    
    출력
  • 발행한 로또 수량 및 번호를 출력한다.
  • 로또 번호는 오름차순으로 정렬하여 보여준다.
    8개를 구매했습니다.
    [8, 21, 23, 41, 42, 43] 
    [3, 5, 11, 16, 32, 38] 
    [7, 11, 16, 35, 36, 44] 
    [1, 8, 11, 31, 41, 42] 
    [13, 14, 16, 38, 42, 45] 
    [7, 11, 30, 40, 42, 43] 
    [2, 13, 22, 32, 38, 45] 
    [1, 3, 5, 14, 22, 45]
    
  • 당첨 내역을 출력한다.
    3개 일치 (5,000원) - 1개
    4개 일치 (50,000원) - 0개
    5개 일치 (1,500,000원) - 0개
    5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
    6개 일치 (2,000,000,000원) - 0개
    
  • 수익률은 소수점 둘째 자리에서 반올림한다. (ex. 100.0%, 51.5%, 1,000,000.0%)
    총 수익률은 62.5%입니다.
    
  • 예외 상황 시 에러 문구를 출력해야 한다.
  • 단, 에러 문구는 “[ERROR]”로 시작해야 한다.
    [ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.
    
실행 결과 예시
구입금액을 입력해 주세요.
8000

8개를 구매했습니다.
[8, 21, 23, 41, 42, 43] 
[3, 5, 11, 16, 32, 38] 
[7, 11, 16, 35, 36, 44] 
[1, 8, 11, 31, 41, 42] 
[13, 14, 16, 38, 42, 45] 
[7, 11, 30, 40, 42, 43] 
[2, 13, 22, 32, 38, 45] 
[1, 3, 5, 14, 22, 45]

당첨 번호를 입력해 주세요.
1,2,3,4,5,6

보너스 번호를 입력해 주세요.
7

당첨 통계
---
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
총 수익률은 62.5%입니다.

프로그래밍 요구 사항 1

  • JDK 21 버전에서 실행 가능해야 한다.
  • 프로그램 실행의 시작점은 Application의 main()이다.
  • build.gradle 파일은 변경할 수 없으며, 제공된 라이브러리 이외의 외부 라이브러리는 사용하지 않는다.
  • 프로그램 종료 시 System.exit()를 호출하지 않는다.
  • 프로그래밍 요구 사항에서 달리 명시하지 않는 한 파일, 패키지 등의 이름을 바꾸거나 이동하지 않는다.
  • 자바 코드 컨벤션을 지키면서 프로그래밍한다.
  • 기본적으로 Java Style Guide를 원칙으로 한다.

프로그래밍 요구 사항 2

  • indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다.
  • 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
  • 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메서드)를 분리하면 된다.
  • 3항 연산자를 쓰지 않는다.
  • 함수(또는 메서드)가 한 가지 일만 하도록 최대한 작게 만들어라.
  • JUnit 5와 AssertJ를 이용하여 정리한 기능 목록이 정상적으로 작동하는지 테스트 코드로 확인한다.
  • 테스트 도구 사용법이 익숙하지 않다면 아래 문서를 참고하여 학습한 후 테스트를 구현한다.
    • JUnit 5 User Guide
    • AssertJ User Guide
    • AssertJ Exception Assertions
    • Guide to JUnit 5 Parameterized Tests

**프로그래밍 요구 사항 3

  • 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현한다.
  • else 예약어를 쓰지 않는다.
    • else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다.
    • 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다.
  • Java Enum을 적용하여 프로그램을 구현한다.
  • 구현한 기능에 대한 단위 테스트를 작성한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
    • 단위 테스트 작성이 익숙하지 않다면 LottoTest를 참고하여 학습한 후 테스트를 작성한다.

라이브러리

  • camp.nextstep.edu.missionutils에서 제공하는 Randoms 및 Console API를 사용하여 구현해야 한다.
  • Random 값 추출은 camp.nextstep.edu.missionutils.Randoms의 pickNumberInRange()를 활용한다.
  • 사용자가 입력하는 값은 camp.nextstep.edu.missionutils.Console의 readLine()을 활용한다.

구현 세부사항

구매 금액에 따른 로또 자동 구매 및 결과 실험 애플리케이션

사용자로부터 구매할 로또의 금액과 당첨 번호, 보너스 번호를 입력받아 구매 금액 대비 누적 상금 금액을 최종적으로 출력한다.

주요 기능:

  1. 사용자 입력 받기
  • 로또 구입 금액 입력 (1,000원 단위이며 int 범위 내 양의 정수)
  • 당첨 번호 입력 (쉼표,를 기준으로 6개의 번호, 1~45 내 중복이 없는 - 정수, 숫자 내부가 아닌 공백 문자 )
  • 보너스 번호 입력 (숫자 내부가 아닌 공백 문자 , 1~45 내 당첨 번호와 중복이 없는 정수)
  1. 출력 기능
  • 구매한 로또의 수량 출력 (구입 금액 / 개당 로또 가격)
  • 자동 생성된 개별 로또의 전체 번호 출력
  • 자동 생성된 전체 로또의 당첨 통계 출력
  • 총 수익률 출력
  1. 입력값 형식 검증
  • 로또 구입 금액 입력값이 올바른 형식인지 확인한다.
  • 당첨 번호 입력값이 올바른 형식인지 확인한다.
  • 보너스 번호 입력값이 올바른 형식인지 확인한다.
  1. 로또 번호 자동 생성 기능
  • 로또 구입 금액 입력값에 맞는 개수로 각 6개의 숫자를 가지는 로또 인스턴스를 생성한다.
  1. 로또 별 당첨 확인 기능
  • 각 로또의 숫자를 확인한다.
  • 숫자의 일치 개수에 따라 당첨된 금액, 당첨된 로또의 개수를 수정한다.
  1. 총 수익률 계산 기능
  • 총 당첨된 금액과 로또 구입 금액을 확인한다.
  • 수익률로또 구입 금액 / 총 당첨된 금액을 계산한다.

명시되지 않은 요구사항에 대한 구현

입력

  • 로또 구입 금액의 범위는 int 범위(-2,147,483,648 ~ 2,147,483,647)를 넘지 않는다.
  • 로또 구입 금액의 입력값이 숫자형 포맷이 아닐 경우 예외 처리한다.
  • 로또 구입 금액의 앞뒤 공백 문자 는 제거한다.
  • 당첨 번호 입력 시 쉼표,를 기준으로 나누어진 개별 당첨 번호의 앞뒤에 있는 공백 문자 는 제거한다.
  • 쉼표,와 숫자, 숫자의 앞뒤에 존재하는 공백 문자 를 제외한 문자가 당첨 번호 입력값으로 주어질 경우 예외 처리한다.
  • 보너스 번호 입력 시 숫자의 앞뒤에 존재하는 공백 문자 는 제거한다.
  • 보너스 번호 입력 시 숫자 사이에 존재하는 공백 문자 와 숫자형 포맷이 아닌 문자를 입력값으로 할 경우 예외 처리한다.

기타

  • 수익률은 총 당첨 금액 / 구입 금액으로 정의한다.

예외 상황

다음의 경우 IllegalArgumentException 발생:

  구매 금액 예외 상황

  • 숫자가 아닌 문자 입력: abc, 1a00
  • 1000원 단위가 아닌 금액: 1500, 2300
  • 음수 입력: -1000
  • 0원 입력: 0
  • 숫자 사이에 공백 존재: 10 00
  • 당첨 번호 예외 상황
  • 6개가 아닌 번호 개수: 1,2,3,4,5, 1,2,3,4,5,6,7
  • 중복된 번호: 1,2,3,4,5,5
  • 범위를 벗어난 번호: 0,1,2,3,4,5, 1,2,3,4,5,46
  • 잘못된 형식: 1 2 3 4 5 6, 1-2-3-4-5-6
  • 개별 숫자 사이 공백 존재: 1,2 0,3,4,5,6

  보너스 번호 예외 상황

  • 당첨 번호와 중복: 당첨 번호가 1,2,3,4,5,6일 때 보너스 번호 6 범위를 벗어난 번호: 0, 46
  • 숫자가 아닌 입력: abc
  • null값 및 단일 공백

코드 구조

패키지 구조

src
    ├── main
    │   └── java
    │       └── lotto
    │           ├── Application.java
    │           ├── common
    │           │   ├── ErrorMessage.java
    │           │   ├── InputMessage.java
    │           │   ├── LottoConstants.java
    │           │   └── OutputMessage.java
    │           ├── controller
    │           │   └── LottoController.java
    │           ├── model
    │           │   ├── Lotto.java
    │           │   ├── LottoSimulator.java
    │           │   ├── Rank.java
    │           │   └── Statistics.java
    │           ├── service
    │           │   ├── InputService.java
    │           │   └── LottoService.java
    │           ├── util
    │           │   ├── StringToIntegerConverter.java
    │           │   └── WinningNumbersParser.java
    │           ├── validator
    │           │   ├── InputValidator.java
    │           │   └── LottoValidator.java
    │           └── view
    │               ├── InputView.java
    │               └── OutputView.java
    └── test
        └── java
            └── lotto
                ├── ApplicationTest.java
                ├── controller
                │   └── LottoControllerTest.java
                ├── model
                │   ├── LottoSimulatorTest.java
                │   ├── LottoTest.java
                │   ├── RankTest.java
                │   └── StatisticsTest.java
                ├── service
                │   ├── InputServiceTest.java
                │   └── LottoServiceTest.java
                ├── util
                │   ├── StringToIntegerConverterTest.java
                │   └── WinningNumbersParserTest.java
                └── validator
                    ├── InputValidatorTest.java
                    └── LottoValidatorTest.java

계층별 역할

1. Controller

  • LottoController: 전체 프로그램 흐름 제어 및 예외 처리

2. Model

  • Lotto: 로또 번호 6개를 관리하는 도메인 객체
  • LottoSimulator: 구매한 로또들과 당첨 정보를 관리
  • Rank: 당첨 등수별 일치 개수, 보너스 일치 여부, 당첨 금액 관리
  • Statistics: 당첨 통계 및 수익률 계산

3. Service

  • InputService: 사용자 입력값 검증 및 변환
  • LottoService: 로또 생성 및 당첨 통계 계산 로직

4. View

  • InputView: 사용자 입력
  • OutputView: 결과 출력

5. Validator

  • InputValidator: 입력값 유효성 검증
  • LottoValidator: 로또 번호 유효성 검증

6. Util

  • StringToIntegerConverter: 문자열을 정수로 변환
  • WinningNumbersParser: 당첨 번호 문자열 파싱

7. Common

  • ErrorMessage: 에러 메시지 상수
  • InputMessage: 입력 안내 메시지 상수
  • OutputMessage: 출력 메시지 상수
  • LottoConstants: 로또 관련 상수 (가격, 번호 범위 등)

주요 설계 결정

  • 책임 분리
    • Controller는 흐름 제어만 담당
    • View는 입출력만 담당
    • Validator는 검증 로직만 담당
  • 예외 처리 전략
    • 입력 단계 직후 모두 유효성 검사
    • IllegalArgumentException으로 통일된 예외 처리
    • [ERROR] 형식을 통한 테스트 코드에서의 의도한 예외 처리 확인
    • Controller에서 예외를 catch하여 에러 메시지 출력
  • 상수 관리
    • 매직 넘버 제거를 위해 Common 패키지에 상수 클래스 분리
    • 메시지 변경 시 한 곳만 수정하면 되도록 구성
  • 테스트 가능성
    • 각 계층별로 단위 테스트 작성
    • NsTest를 활용한 통합 테스트 구현

소스 코드

https://github.com/jynoh00/java-lotto-8/tree/jynoh00

  • 프로그램 진입점: src/main/java/lotto/Application.java
  • 테스트 코드: src/test/java/lotto/…

회고

3주차에선 기존 코드 작성 방식에서 벗어나 새로운 방법으로 프로젝트를 진행해 보았으며, 프로그래밍 요구 사항 3의 Java Enum 활용 및 단위 테스트에 집중하여 코드를 작성하고자 노력하였다. 이에 대해 회고하여보겠다.

먼저 코드 작성 방식에 대한 내용이다. 2주차 프로젝트에선 문제를 파악한 후 알고리즘을 생각하여 하나의 파일에 절차적으로 코드를 작성한 이후 리팩토링 과정에서 기능 별로 나누어 패키지 구조를 세부화 하였다. 이번 주차 프로젝트에선 문제를 파악한 직후 알고리즘을 생각할 때 MVC 패턴에 기반하여 구상한 패키지 기준으로 각자의 역할을 나누어 객체당 최대한 간단한 역할만 수행하게 구조화 한 후 코드를 작성하였다. 먼저 패키지 구조를 나누어 각 객체의 코드를 작성하는 것은 처음해보는 방식이어서 그런지 초반 개발 속도는 기존 방식보다 현저히 느렸지만, 전체 틀을 작성한 후 프로그램을 실행해보며 생각하지 못했던 오류들을 발견하고 고칠 때 각 역할을 맡은 객체의 코드만 수정하였더니 결과적으로 기존 방식보다 빠른 속도로 전체 코드를 작성할 수 있었다. 2주차에서 입력값과 출력값을 처리하는 뷰(view) 패키지에서 검증을 포함하여 코드가 다소 복잡하고 해당 검증 코드를 다른 객체에서 재사용하기에 무리가 있었는데, 검증(validator) 패키지를 따로 나누어 당첨 번호에 관련된 로또 값과 구매한 로또 번호들에 해당하는 값에 동일한 로직의 검증 코드를 재사용하여 효율적인 측면을 보였던 것 같다.

Enum 열거형 타입은 앞선 2주차에서도 예외 처리한 에러 메시지 상수들을 관리하는데 사용하였다. 이번 로또 과제에서도 역시 열거형 타입을 사용하여 에러 메시지의 출력값들을 상수화하여 관리했으며, 입출력에서의 메시지들 또한 열거형으로 묶어 common 패키지에 포함시켰다. 추가로 전체 코드에서 사용하는 숫자형 상수값들 또한 LottoConstants로 묶어 사용하여보았는데 개별 로또의 가격이나 로또 숫자의 개수 등 값이 바뀌어도 하나의 파일의 코드만 바꾸면 전체에 적용되는 이점이 개발된 코드를 수정함에 있어 시간적 이점 뿐 아닌 몇 값들을 수정하지 못해 발생하는 오류를 크게 줄여줄 수 있다는 것을 다시금 깨닫는 계기가 되었다.

테스트 코드 작성 과정은 특히 이번 주차에서 가장 힘들었던 부분이었다. 문법적으로도 익숙하지 않았고, 거시적인 코드만 콘솔을 통해 테스트를 해오던 방식에서 객체들로 세분화하여 각각의 테스트 코드를 작성하는 것은 보다 많은 시간을 소요했다. 그럼에도 테스트 코드 작성 과정에서 보다 많은 점을 배울 수 있었는데, 개별 객체들의 기능을 중점으로 해당 내용에 대한 테스트 코드를 작성하는 것이 중복된 기능과 단일 책임적이 못한 메서드의 기능들을 캐치할 수 있었다. 실제로 구매한 로또 번호들과 당첨 번호에 관련된 정보를 담당하는 LottoSimulator 모델에서 구매한 로또를 생성하는 메서드가 포함되어 있었고 이는 service 담당에 해당하는 LottoService 객체에서 이루어 졌어야 됐다. 또한 생성된 로또 번호에 대한 검증을 생성한 객체의 메서드로 진행하여 검증 과정의 중복이 있음을 알고 수정할 수 있었다.

전체적으로 다양한 내용에 대해 배우고 적용하며 많은 시행 착오를 겪었단 3주차였다. 특히 테스트 코드를 작성한 후 실행할 때 생각하지 못한 오류가 발생하였는데 Random 라이브러리의 pickUniqueNumbersInRange() 메서드에서 반환된 리스트를 직접 Collections.sort() 하였더니 UnsupportedOperationException이 발생하였다. 이를 해결하고자 라이브러리 코드를 한줄씩 살펴보며 각 메서드의 반환 내용과 구조에 대해 알아보았고 List 라이브러리의 메서드인 subList()가 반환하는 구조적 변경을 지원하지 않는다는 것을 알게되었다. 이후 Stream을 사용하여 새로운 독립 리스트를 생성해 사용하며 해결할 수 있었다. 알고리즘 파악과 패키지 구조 또한 중요하지만 기본 라이브러리의 메서드 기능과 같은 기본적 코드 지식 역시 공부가 필요하다는 점을 상기할 수 있는 기회였다.


우아한테크코스 - 프리코스 3주차 : 로또