2023.11.01 - [Other/기록] - [회고] 우아한테크코스 2주차 회고 - 자동차 경주 게임
이번 주차에는 '로또 게임' 이라는 미션이 제시되었다.
문제 자체는 간단한 것 같은데, 지난 주차와 다르게 에러 메시지를 출력하고 다시 입력을 받아야한다는 점, Enum Type으로 로또 당첨의 등수를 표현한다는 점이 구현하기 어려웠다. 또한 함수의 길이가 15라인으로 제한되고, 단위 테스트에 대한 요구사항이 추가되었다.
이번 로또 미션을 구현하면서 고민한 점과 새롭게 적용한 점, 그리고 받은 피드백들을 정리해보겠다 😊
⚡ 프로젝트 구현 과정
⭐ 프로젝트 개요
- 사용자가 입력한 금액만큼 로또를 발행한다.
- 로또의 당첨 번호와 보너스 번호를 입력한다.
- 로또 번호의 숫자 범위는 1 ~ 45까지이다.
- 중복되지 않는 6개의 숫자와 보너스 번호 1개를 뽑는다.
- 발행한 로또의 개수와 번호를 정렬하여 출력한다.
- 당첨 내역과 수익률을 출력한다.
- 수익률은 둘째 자리에서 반올림한다.
- 예외 상황이 발생하면
[ERROR]
로 시작하는 에러 문구를 출력한다.
👩💻 패키지 구조
lotto
├── main
| |
| ├── controller
| | └── LottoController.class
| ├── domain
| | ├── Cost.class
| | ├── Lottos.class
| | ├── Lotto.class
| | ├── Number.class
| | ├── WinningNumbers.class
| | ├── BonusNumber.class
| | ├── DrawnNumbers.clas
| | ├── WinningResult.class
| | └── WinningType.class
| ├── dto
| | └── LottoResultDto.class
| └── global
| | ├── constants
| | | └── NumberType.class
| | ├── exception
| | | ├── ErrorMessage.class
| | | └── LottoException.class
| | ├── response
| | | ├── ApiResponse.class
| | | └── ErrorResponse.class
| | └── validator
| | └── Validator.class
| └── view
| ├── ui
| | ├── Input.class
| | └── Output.class
| ├── CostRequsetView.class
| ├── LottoResultView.class
| ├── WinningRequestView.class
| ├── BonusRequestView.class
| └── WinningResultView.class
└── Application.class
🛠 시스템 흐름도
1. issue()
구입 금액을 입력하고 발행한 로또를 출력한다.
2. draw()
당첨 번호와 보너스 번호를 추첨한다.
3. conclude()
당첨 내역과 수익률을 계산하고 출력한다.
📝 구현 기능 목록
로또 구입 금액의 입력에 맞게 로또를 발행하는 기능
- 구입 금액 입력 요청 메시지를 출력한다.
- 로또 구입 금액을 입력한다.
- 빈 문자열인지 검사한다.
- 숫자로 구성된 입력인지 검사한다.
- 1000원으로 나누어 떨어지는 금액인지 검사한다.
- 발행할 로또 수량을 계산한다.
- 수량에 맞게 로또를 발행한다.
- 6개의 숫자를 랜덤으로 생성한다.
- 각 숫자의 범위는 1부터 45까지이다.
- 하나의 로또에 숫자는 중복될 수 없다.
- 발행한 로또를 출력한다.
- 로또의 수량을 출력한다.
- 수량에 맞게 로또의 숫자들을 출력한다.
당첨 번호와 보너스 번호를 입력하는 기능
- 당첨 번호 입력 요청 메시지를 출력한다.
- 당첨 번호를 입력한다.
- 빈 문자열인지 검사한다.
- 올바른 쉼표로 구분된 입력인지 검사한다.
- 1부터 45까지 범위의 숫자인지 검사한다.
- 중복되지 않은 번호인지 검사한다.
- 보너스 번호 입력 요청 메시지를 출력한다.
- 보너스 번호를 입력한다.
- 빈 문자열인지 검사한다.
- 1부터 45까지 범위의 숫자인지 검사한다.
- 당첨 번호와 중복되지 않은 번호인지 검사한다.
당첨 내역과 수익률을 출력하는 기능
- 발행된 로또들과 당첨 번호, 보너스 번호를 비교한다.
- 발행된 로또 하나와 비교하여 일치하는 개수를 확인한다.
- 일치하는 개수에 맞추어 당첨 내역을 업데이트한다.
- 당첨 내역을 통해 수익률을 계산한다.
- 당첨된 금액에서 로또 구입 금액을 나눈다.
- 소수점 둘 째 자리에서 나눈다.
- 백분율로 계산한다.
- 당첨 내역과 수익률을 출력한다.
- '당첨 통계' 메시지를 출력한다.
- 당첨 내역을 출력한다.
- 총 수익률을 출력한다.
🧐 새롭게 고민한 내용들 (feat. 2주차 미션 코드 리뷰)
사소하지만 중요한 컨벤션 준수하기
정적 팩토리 메서드 네이밍 컨벤션
아무 생각없이 따라 사용했던 from, of 등의 메서드 네이밍이 별도의 컨벤션이 있다는 것을 처음 알게 되었다. 일정한 메서드를 다들 사용하고 있는데 컨벤션이 있을 것이라는 생각을 못하다니 .. 😮
이번에는 정적 팩토리 메서드의 변수 개수나 그 특성에 따라서 적절한 네이밍을 사용할 수 있도록 하였다.
변수와 메서드 선언 순서
자바에서 메소드 순서 :: 합니다, 개발공부📝 (tistory.com)
세세한 포인트를 잘 잡아주셔서 너무 도움이 되는 감사한 리뷰였다. 메서드 선언 순서도 컨벤션이 될 수 있음을 깨닫게 되었고, 위 링크를 통해 변수와 메서드의 순서를 명심하면서 개발 후 리팩토링하려고 노력하였다.
파일의 마지막 줄 개행
No newline at a end of file, 파일의 끝에는 개행 추가❗️ | Seongwon.dev
이것 또한 .. 무지했던 부분인데도 감사하게 짚어주셨다. 코드 리뷰의 장점은 이런 사소한 부분을 캐치할 수 있다는 것 같다.
찾아보니 파일 끝에 개행을 추가하는 것은 컨벤션을 넘어 하나의 표준이고, 컴파일러가 동작하는 과정에서 시스템 오류를 방지하기 위해 정해둔 것 같았다. 이번에는 IDE 자체에서 개행을 자동으로 추가하는 설정을 통해 이 부분을 보완하고자 하였다.
int ? Integer ? 원시값 사용하기
많은 분들이 내가 코드 상에서 제너릭 타입에서 사용한 것을 제외하고 단일한 변수에 int 원시값 대신 Integer이라는 참조 타입을 사용한 것에 대해 리뷰를 남겨주셨다.
나는 원시값을 포장해서 사용한다.라고 남긴 우아한 테크코스 문서의 말을 Integer를 사용해라!로 이해하였고, 원시값을 무조건 사용하지 않고 래퍼 클래스로 감싸서 사용하였다. 물론 Boolean도 마찬가지로. 그래서 이에 대한 리뷰를 받았을 때 내가 맞다고 생각하였으나, 점차 많은 분들의 리뷰를 읽고 탐색해보면서 이 생각을 고치게 되었다.
https://siyoon210.tistory.com/139
참조 타입은 주로 null check를 필요로 하는 경우, 즉 null이 될 수 있는 경우에 바로 참조하지 않고 언박싱 과정을 거치도록 하기 위해서 사용하는 것이다. 원시 타입이 스택 메모리에 값이 존재하는 것과는 별개로 참조 타입은 스택 메모리에는 참조 값만 있고 실제로 힙 메모리에 값이 존재한다.
따라서 메모리를 많이 차지하고, 속도가 낮다는 단점이 있었다. 이에 의거해서 원시값을 사용하는 것을 무조건 지양할 필요가 없다는 결론을 내렸고, null이 될 수 있거나 제너릭 타입에서 사용하는 경우에만 래퍼 클래스를 사용하기로 결정하였다.
https://limdingdong.tistory.com/9
그리고 내가 생각했던 원시값 포장이라는 것은 미리 정의된 것을 사용하는 게 아니라 스스로 클래스를 생성해 포장하는 것을 의미한다는 것을 새롭게 알게 되었다.
❌헝가리안 표기법❌
메서드 명, 변수 명에 자료형을 명시하는 것을 헝가리안 표기법이라고 한다.
나는 주로 메서드 명에 무심코 자료형을 사용하고 있었고, 심지어 그게 가독성이 좋은 직관적인 네이밍이라고 생각하고 있었다. 그러나 위 리뷰를 통해 그게 틀렸음을 알게 되고, 이번 미션 구현 시에는 이러한 부분을 신경쓰며 자료형을 쓰지 않기 위해 노력하였다.
예를 들어, 위에서 지적 받은 코드와 같은 기능을 하는 메서드의 이름을 리스트의 크기가 아닌 검증하고자 하는 도메인의 이름을 넣음으로써 자료형의 변화가 있어도 메서드를 수정할 필요가 없도록 하였다.
toString()을 사용한 출력 구문 정의에 대하여
Java 에서 toString 메소드의 올바른 사용 용도에 대하여 (hudi.blog)
1주차 코드 리뷰에서 toString()을 사용해 출력 구문을 만들어라 라는 피드백을 받았고, 2주차에 실제로 적용해보니 코드도 간단해지고 출력 구문에 대해 신경쓸 필요가 없다는 생각이 들었다. 그러나 실제로 구현한 후 리뷰를 받아본 결과, 정말 많은 분들이 이 방법에 대한 부정적인 견해를 드러내어주셨다.
그 이유를 간단히 정리하면 다음과 같다.
- toString() 오버라이딩은 디버깅을 위해 설계된 메소드이다.
- 만약 출력 구문이 변경되면 도메인 로직을 수정해야한다. 객체지향 원칙 중 개방 폐쇄 원칙에 어긋난다.
따라서 뷰 레이어에서 출력 구문을 렌더링하는 것으로 결정하였고, 이번 주차에서 그 부분을 적용하려고 노력하였다.
특히 이번 미션에서는 입력을 요청하고 출력 구문을 콘솔에 나타내는 것에 대한 요구사항이 많았다. 기존에는 뷰 레이어의 View 라는 단일 클래스에서 모든 입출력을 처리하였는데, 이번에는 비즈니스 로직 별로 클래스를 나누어 가독성이 좋게끔 구현하였다.
추가로, 뷰 레이어에서 요구사항을 처리하는 부분과 실제로 콘솔의 입출력을 담당하는 코드를 UI 패키지로 분리하였다. 그래서 최대한 뷰 레이어는 출력 구문과 입력의 처리에 집중할 수 있도록 하였다.
DTO (Data Transfer Object) 사용하기
사실 프리코스 미션을 구현하면서 DTO를 사용할 수 있다는 생각은 전혀 해보지 못하였다. 그래서 레이어 간 데이터를 전달할 때 도메인 클래스를 생성하여 뷰 레이어에 전달하는 등의 방식을 채택하였다.
그러나, 이번에 처음으로 DTO의 사용을 암시하는 코드 리뷰를 받게 되었고, 일회성으로 사용될 수 있는 데이터들까지 비즈니스 도메인으로 취급하고 있었다는 것을 알게되었다.
그래서 이번 프로젝트에서는 DTO를 적극 도입하고자 노력하였다. 그러나 어느 부분에서 어떻게 도입할 지, DTO의 쓰임과 책임의 범위가 어떤 것인지 고민하게 되었다.
예를 들어, 뷰 레이어에서 컨트롤러 레이어로 넘어가는 DTO면 문자열을 파싱하거나 검증하는 로직을 넣어도 되는걸까? 혹은 이미 도메인으로 정의되어있는 클래스에 저장된 데이터가 있는데, 굳이 DTO를 만들어서 레이어의 이동만을 위한 객체로 사용될 필요가 있을까? 등의 의문점이 생겼다.
그러나 MVC 아키텍처 패턴의 관점에서 생각해보면, 뷰는 도메인과 직접 연결되지 않는다는 점에서 같은 필드를 가지고 있는 도메인이 있더라도 더더욱 DTO를 사용해야한다는 것을 알게 되었다. 도메인은 도메인 그 자체로 레이어다. 레이어는 어디론가 다른 레이어를 건너뛰고 이동할 수 없으며, 다른 레이어와 데이터를 전달하고 전달 받음으로써 연결될 뿐이다. 그 역할을 하는 것이 DTO이고, 데이터 이동을 전담하는 객체라고 볼 수 있다.
이를 통해 DTO에서는 검증 로직이나 파싱과 같은 부가적인 로직이 들어가서는 안되고, 오로지 데이터 전달만을 목적으로 하는 클래스가 되어야 함을 알게 되었다.
Enum Type 십분 활용하기
이번 요구사항에서는 아래와 같이 구조화된 데이터를 저장하고 출력하는 부분이 있었다. '아, Enum class로 당첨 등수를 관리하면 되겠구나'는 바로 떠올릴 수 있었지만 아래와 같은 고민이 있었다.
- 하나의 당첨 등수에 해당하는 로또의 개수를 어떻게 저장할 수 있을까?
- enum class의 인스턴스 변수는 어떻게 설계해야할까?
- 당첨된 번호의 개수와 보너스 번호의 개수로 어떻게 당첨 등수를 찾아내고 EnumType으로 반환할 수 있을까?
EnumMap
하나의 당첨 등수에 몇 개의 로또가 대응되는가? 에 대한 첫 번째 질문은 쉽게 Map이라는 자료구조를 떠올릴 수 있다.
처음에는 Enum 상수의 이름 문자열을 Key로 가지는 Map<String, Integer>를 생각했다가, 구글링을 통해 EnumMap이라는 존재를 알게 되었다.
[JAVA] EnumMap 을 사용합시다. (manty.co.kr)
EnumMap을 사용하였을 때는 선언할 때부터 데이터의 사이즈가 특정 Enum class로 제한되기 때문에 성능 상 이점이 많다. 이를 적용해서 프로그램을 구현해보니 확실히 문자열을 사용해서 Key를 지정하는 것보다 훨씬 깔끔하고, 메모리도 효율적으로 관리할 수 있었다.
이렇게 EnumMap을 사용하여 당첨 등수와 그에 대응하는 로또의 개수를 저장하게 되었다.
당첨 기준에 대한 변수화
각각의 당첨 등수에 대해서 가지는 '일치하는 번호 갯수'와 '보너스 번호의 유무'에 대한 정보가 있다. 이를 Enum class의 인스턴스 변수로 선언해야하나? 라는 의문이 있었는데, 출력 형식에서 당첨 등수에 대한 확장성을 고려해 설계하는 과정에서 결국 인스턴스 변수로 선언하게 되었다.
요구사항에 따르면, 로또 당첨 통계에 대한 출력 구문을 렌더링할 때 아래와 같은 정보를 포함하고 있다.
- 일치하는 번호의 갯수
- 보너스 번호의 일치가 당첨 기준인 등수의 경우 보너스 번호의 일치 여부
- 금액
- 해당 등수에 당첨된 로또의 개수
마지막 요소는 Map의 Value에 해당하므로 제외하고, 나머지 3개의 변수를 Enum class에 아래와 같이 저장하기로 결정하였다.
public enum WinningType {
private final int winningCount;
private final boolean hasBonusNumber;
private final int price;
}
특히 보너스 번호가 당첨 기준에 포함되는 지를 저장하는 hasBonusNumber의 경우 아래와 같이 적절한 출력 구문을 반환하는 것에 사용되었다. 2번 등수에만 보너스 번호의 일치를 기준으로 한다고 해서 그 값을 하드코딩하는 것이 아니라, 이렇게 Enum class 내부의 인스턴스 변수로 선언하여 추후에 다른 등수가 더 추가되었을 때 대응할 수 있도록 설계하였다.
Function Interface
https://techblog.woowahan.com/2527/
나는 세 번째 문제에 대한 해답을 위 기술 블로그에서 찾았다. 내가 존경하는 개발자님인 이동욱님이 쓰신 글인데, 여기서 얻은 가장 값진 가르침은 Function Interface의 활용이다. 블로그의 내용을 발췌해서 적어보자면,
이렇게 Enum Type에 메서드를 작성해서 특정 상수에 대한 value를 동적으로 설정할 수 있다.
이를 프로젝트에 적용하기 위해 당첨 번호 갯수가 5이고 보너스 번호가 있다면 2등입니다라는 정보를 얻어낼 수 있는 함수를 작성해야했다. 상수를 단순 열거하는 용도로만 사용했었는데 특정 연산식을 사용해서 상수를 조회해야한다니 .. 앞길이 막막하였다.
하나 분명한 것은 블로그에도 나온 것처럼 상태와 행위가 한 곳에 있어야 하는 것이다. 그것이 함수형 인터페이스를 사용하는 이유이고, 만약 외부에서 EnumType을 조회하는 코드를 작성한다면 상태와 행위가 분리되어서 프로그램의 크기가 확장될 수록 유지보수가 어려워지는 한계가 있을 것이라고 생각하였다.
https://yeonyeon.tistory.com/200
매번 큰 도움이 되어주는 연로그님 ... 감사합니다 🙇♀️
여기에서 참고한 부분인데, BiPredicate라는 인터페이스가 미션에서 제시된 당첨 기준을 탐색하는 boolean 값을 표현하기 적합하다고 판단하여 해당 인터페이스를 사용하여 구현하였다.
그리고 WinningType을 조회하는 메서드를 위와 같이 작성하고, 이를 호출해서 로또 당첨 번호와 발행된 로또 번호를 비교하여 생성된 결과(LottoResultDto) 객체를 사용해 적절한 등수 (WinningType)을 조회하는 것에 성공하였다.
구현하고 보니, BiPredicate 인터페이스 말고도 다양한 인터페이스를 활용한 다른 방식도 많은 것 같다. 어디까지나 코드를 최적화하고 더 나은 방식으로 리팩토링하기 위해서는 알고 있어야 하는 지식의 범위가 넓고 어느정도 깊어야 하고, 그러기 위해서는 끊임없이 더 찾고 배워야하는 것 같다.
Javadoc 작성하기
이번에는 꼭 적용해보고 싶었던 Javadoc을 프로젝트에 적용해보았다.
클래스의 주석은 간단하게, 메서드의 주석은 파라미터와 반환값을 포함하도록 작성하였고 public 메서드에는 필수로, private 메서드에는 선택적으로 작성하였다.
사실 메서드 명으로도 알 수 있는 정보이고, 파라미터도 마찬가지로 네이밍을 잘 한다면 충분히 유추 가능하지 않을까? 하고 생각했는데, 읽는 사람 입장에서는 또 다를 수 있겠다는 생각을 하였다.
나부터도 다른 사람들이 메서드 네이밍을 아무리 잘해도 시간을 들여 생각해야 코드의 의도가 이해되니 말이다. Javadoc을 잘 사용하였는지는 의문이지만, 앞으로도 코드를 문서화하는 습관은 쭉 이어나갈 것 같다.
👀 느낀 점
이번 미션 구현 과정에서는 지난 주차 피드백을 모두 고민해보고 나의 판단 기준에 맞게 적용하는 연습을 하였다. 자동차 경주를 구현하는 지난 주차에도 마찬가지로 이 방식대로 사람들의 피드백과 나의 고민 과정을 프로그램에 녹여내고자 노력하였는데, 이 과정에서 내가 더 나은 개발자로 성장하고 있음을 체감할 수 있었다.
가령 사소한 컨벤션 하나도 지키지 못하였던 내가 컨벤션을 준수하면서 코드를 작성하고, 객체지향적인 원칙을 고민하지 않고 아무렇게나 개발하였던 습관을 버리고 객체의 역할과 책임에 대해 고민하면서 설계를 우선하는 습관을 가지게 되었다. 그리고 지난 주차보다 더 나은 결과물을 내려고 하다 보니 자연스럽게 내가 가진 문제점이 무엇인지 파악하면서 개선하는 방법을 찾게 되고, 이에 대한 피드백을 한번 더 받으면서 아, 이 부분은 잘 한거구나. 이 부분은 잘못 적용한거구나 스스로 깨닫고 더 나아갈 수 있게 되었다.
코드 리뷰에 대해 흥미가 없었는데, 많은 사람들에게 진귀한 코드 리뷰를 받고 다양한 관점에서 내가 작성한 결과물에 대해 토의해보는 것이 너무 재미있었다. 내가 언제 또 이런 좋은 경험을 할 수 있을까라는 생각이 들면서, 많은 것을 포기하고 프리코스에 전념하는 시간들이 전혀 아깝지 않았다.
한편, 이번 주차에서는 초기 설계에 시간을 많이 투자하지 않고 요구사항을 꼼꼼히 읽지 못해 한 번 미끄러져 코드를 작게 갈아엎었다는 점이 너무 아쉬웠다. 이번에는 잘못된 입력값에 의해 예외를 발생시키고 프로그램을 종료하는 것이 아니라, 다시 입력을 받도록 하는 요구사항이 새로 추가되었었다. 그러나 이를 인지하지 못하고 평소대로 개발하였다가, 급하게 코드를 수정하여 개발하다보니 도메인 로직에 대한 검증이 뷰 레이어에서 포함되어 검증되는 등 MVC 아키텍처에 대한 원칙을 준수하지 않고 개발한 측면이 있었다.
그래서 다음 미션에서는 난이도가 훨씬 올라간 만큼, 초기 설계에 많은 집중을 하려고 한다. 다음 주차 미션도 이번 주차보다 훨씬 성장해져있기를 바라며 ...
[미션 PR 링크]
https://github.com/woowacourse-precourse/java-lotto-6/pull/1941
'우아한테크코스 > 레벨0' 카테고리의 다른 글
[회고] 우아한 테크코스 최종 코딩테스트 대비 - 다리 건너기 (0) | 2023.12.13 |
---|---|
[회고] 우아한테크코스 1차 심사 합격 + 앞으로의 계획 (0) | 2023.12.13 |
[회고] 우아한 테크코스 프리코스 1주차 회고 - 숫자 야구 게임 (5) | 2023.11.27 |
[회고] 우아한테크코스 프리코스 4주차 회고 - 크리스마스 프로모션 (0) | 2023.11.17 |
[회고] 우아한테크코스 프리코스 2주차 회고 - 자동차 경주 게임 (1) | 2023.11.01 |