10월 29일 시작된 3주 차 미션은 로또입니다.
https://github.com/woowacourse-precourse/java-lotto-7
GitHub - woowacourse-precourse/java-lotto-7
Contribute to woowacourse-precourse/java-lotto-7 development by creating an account on GitHub.
github.com
-> 제 PR입니다.
https://github.com/yeong0jae/java-lotto-7/tree/yeong0jae
GitHub - yeong0jae/java-lotto-7
Contribute to yeong0jae/java-lotto-7 development by creating an account on GitHub.
github.com
🔙 자동차 경주 다시 보기
🚗 2주 차 공통 피드백
공통 피드백에는 저번 주차와 같이 여러 가지 항목들이 보였다.
“테스트를 효과적으로 작성하는 방법을 학습하고 적용함으로써 객체지향에 도움 받기”를 3주 차 목표로 삼았기에, 공통 피드백 중 테스트와 관련된 내용에 집중했다. pdf 자료에는 assertJ가 제공하는 여러 가지 함수들에 대한 학습 가이드가 있어 큰 도움이 되었고 3주 차 미션에서 정말 유용하게 활용할 수 있었다. (자세한 내용은 아래에서ㅎㅎ)
반가운 피드백도 있었다. 1, 2주 차를 진행하면서 기능 목록을 완벽하게 작성하고 시작하려다 보니, 구현해 보기도 전에 과하게 설계하려는 내 모습이 보였다. 이에 큰 기능들만 목록에 작성해 두고 바로 구현을 시작해보고 싶었던 마음이 컸고, “기능 목록을 업데이트한다.”라는 피드백 덕분에 3주 차는 기능 목록을 키워나가며 과제를 수행해 볼 수 있는 기회가 되었다.
결과적으로 기능 목록을 크게 잡고 시작한 것이 패착이 되었지만..😂 "지속적으로 업데이트하며 살아있는 문서로 유지하라"라는 말이 "구현할 기능 목록을 대충 작성하고 시작하라"는 말은 아니었다.
그래도 덕분에 작은 기능 단위로 목록을 작성해 두는 것이 얼마나 중요한 작업인지 깨달았다.
위는 로또를 미션을 하면서 처음 만들었던 기능 목록과, 미션 제출 당시 기능 목록이다.
기능이 정말 많이 나눠지고, 불어난 것을 볼 수 있다. 업데이트할 수 있다고 하더라도, 처음에 충분히 기능을 작게 나눠두지 않으면 정말 어지러워진다... 😵💫
👀 타인의 눈으로 나를 효과적으로 돌아보기
이번 주차도 미션을 시작하기 전, 코드 리뷰와 공통 피드백에 대한 학습을 진행했다.
코드 리뷰의 가장 신기한 점은, 내가 구현할 때는 전혀 눈에 띄지 않던 실수들을 리뷰어들은 쉽게 발견한다는 사실이다.
자동차 경주 미션을 마치고 나서는 "이제는 더 이상 고칠 곳이 없을 것 같아!"라는 자신감에 차 있었다. 코드 가독성도 좋고 구조도 깔끔하다고 생각하며 PR을 올렸는데, 예상치 못하게 놓친 부분들을 리뷰어들의 눈을 통해 볼 수 있었다. 삭제하려고 했던 주석을 남겨둔 것, 랜덤 수의 범위를 1~9로 설정한 것 등, 다른 사람의 시각에서는 달리 보일 수 있다는 것을 다시금 느끼게 되었다. 😅
내가 나를 돌아보는 것도 중요하지만 나를 효과적으로 돌아보려면 다른 사람의 시선이 꼭 필요한 것 같다.
🎲 로또 미션 중 고민했던 사항들
👀 1. 로또는 누가 만들어요? (feat. 자동차 전진은 누가 해요?)
로또 미션을 하면서 LottoDrawer라는 객체를 분리하기까지 과정이 순탄치 않았다.
처음에는 Lotto(주어진 로또)와 LottoTicket(발행한 여러 개의 로또를 관리)라는 두 가지의 객체를 두었다.
하지만 “로또 발행”이라는 행동에는 랜덤 숫자 생성이라는 외부 시스템이 따라오기에, 어떤 도메인이 이를 의존하는지가 중요하다고 생각했다. 마치 자동차 경주 미션에서 “Car와 Cars 중 누가 숫자 생성기에 의존하는가?”와 비슷한 고민이었다. 자동차 경주의 경우는 차마다 성능이 다를 수 있고, 전진을 차가 스스로 수행하니 Car가 의존하도록 했지만 로또는 좀 애매하다고 느꼈다.
그럼에도 랜덤 수 생성을 LottoTicket이 하는 것이 적절해 보이는 이유가 있었다. 각각의 Lotto가 다른 확률을 가질 일은 없기 때문에 Lotto가 랜덤 수 생성 구현체를 주입받아 생성할 필요가 없다고 느꼈고, LottoTicket에서 랜덤 수 생성 구현체를 주입받아 같은 확률로 여러 개의 로또를 발행하면 된다고 판단했다. 또한 Lotto는 numbers 이외의 인스턴스 변수를 가질 수 없었기에 이는 더 옳은 선택이라고 생각했다.
하지만 문제는 LottoTicket 테스트에 있었다.. LottoTicket은 발행할 로또 개수와 랜덤 생성기 구현체를 주입받게 하였고, 이를 주입받아 여러 개의 로또를 발행하고 List<Lotto>를 리턴하는 private 메서드를 구현했고, 생성자에서 이를 실행해 List<Lotto>를 상태로 가졌다.
즉, 로또 개수를 받고, 그만큼 반복하면서, new Lotto를 실행하고, 이를 합쳐 List<Lotto>로 리턴하는, 상당히 큰 생성자임을 알아차리지 못했다.
그러나 LottoTicket을 테스트하면서 올바른 수의 로또 발행을 검증하는 것, 개별 로또 번호의 올바른 생성을 검증하는 것, 이를 위해 테스트 용 구현체를 이용하는 것 등 테스트의 단위가 다소 크다는 것을 인지하였고, LottoDrawer라는 객체를 새로 만들어 랜덤 생성기에 의존하고, Lottos의 생성을 책임지도록 객체를 분리할 수 있었다.
이에 LottoTicket은 List<Lotto>를 생성자에서 온전히 주입받아 상태로 가질 수 있게 되었고, LottoTicket은 작은 단위가 되어 쉽게 테스트할 수 있었다.
👀 2. 테스트가 보내는 객체 분리의 신호 (???: 진짜 분리한 거 맞아?)
당첨 통계를 내기 위해 WinningLotto라는 객체를 생성했고 당첨 번호와 보너스 번호를 상태로 갖도록 했다.
WinningLotto는 로또 티켓과 상태로 가진 당첨 번호 + 보너스 번호를 비교해 등수를 구하고 그 통계까지 구해야 했다.
그래서 먼저 "등수를 확인한다"라는 로직인 checkRank 메서드를 구현했다. (테스트도 같이 성공시켰다!)
그럼 이제 다음 기능을 구현할 차례였다. checkRank는 하나의 로또에 대한 순위였기에, 모든 로또(LottoTicket)에 대한 순위 통계도 구해야 했다. 그래서 calculateRanks를 구현했고, checkRank 메서드는 private으로 두어 calculateRanks가 사용하도록 만들었다.
문제는 여기부터 있었는데, checkRank는 private이므로 이를 내부 로직으로 가지는 public 메서드인 calculateRanks를 테스트해야 했다. 매개변수로 LottoTicket만을 받고, 각 로또에 대한 순위를 구하고 당첨 통계 합산하여 반환하는 거대한 메서드였고, 테스트를 위해 생성해야 하는 객체가 한두 개가 아니었다.
"분명 작은 단위로 만들어 나갔는데 왜 테스트가 커졌지?"라는 의문이 들었다. 정말 '작은 단위'가 맞나?
먼저 테스트가 크다고 느낀 이유가 뭘까 생각해 보았다.
테스트를 위해서는 LottoTicket 객체가 필요했고, LottoTicket을 위해서는 List<Lotto>가 필요했다. 또한 당첨 로또인 WinningLotto도 만들어둬야 했다. 하나의 기능을 테스트하는데 지금 만들어야 하는 객체가 몇 개지..?
테스트는 내게 "한 객체가 가진 책임이 크다"라는 신호를 보내고 있던 것이다.
내가 checkRank를 만든 후 이를 private으로 바꾸고 calculateRanks를 구현한 것은, 작은 단위의 기능을 만든 것이 아니었다. 시작만 작은 기능을 만들었을 뿐이지, calculateRanks를 구현하면서 작았던 기능 하나를 크게 키워온 것이다. 내가 한 '작은 기능 구현'은 내부에서의 의미 없는 분리였다.
정말 작은 단위로의 분리를 위해서는, 메서드만의 분리가 아닌 진짜 객체로서 자율적으로 하나의 역할을 부여하는, 그런 분리가 필요했던 것이다.
이를 깨닫고 순위를 관리하는 클래스인 Rank를 만들어 findRank 로직을 외부로 분리할 수 있었다. 테스트 또한 findRank의 테스트와 calculateRanks 테스트로 분리하여 더 편하게 할 수 있었다.
👀 3. 당첨 로또도 로또 아닌가? (feat. 상속을 했다가 말았다가..😵💫)
당첨 로또가 로또를 상속받아야 할지에 대한 고민도 컸다.
먼저 당첨 번호와 보너스 번호를 주입받아 WinningLotto를 생성했다. 여기서 문제는 당첨 번호를 주입받으면, WinningLotto에서 Lotto가 수행하는 유효성 검사들과 똑같은 로직을 실행해야 했다. 유효성 검사는 로또 번호 개수, 중복 검사, 범위 검사까지 3개가 있었는데 당연히 이 로직들을 똑같이 구현하기가 싫었고 낭비라고 생각했다.
그래서 "이미 구현되어 있는 로또를 활용하자!"라고 생각했다. 그래서 WinningLotto가 Lotto를 상속받도록 만들었다.
하지만 문제는 입력에 대한 예외가 발생하면, 재입력을 받아야 하는 요구사항이었다. 이 요구사항을 위해 LottoGame에서 winningNumber와 bonusNumber를 따로 입력받아야 했다. 예외가 발생하는 타이밍은 입력을 받고 객체 생성(유효성 검사)까지의 과정 중에 있었기 때문에, 당첨 번호를 미리 Lotto로 외부에서 만들고, 이를 WinningLotto가 주입받아야 했던 것이다.
이렇게 되면 이미 당첨 번호는 Lotto로서의 유효성 검사를 거치고 오기 때문에, 상속보다는 Lotto를 바로 주입받고 필드로 가지는 쪽이 낫다고 생각했다. 그래서 다시 상속을 제거했다. 대신 numbers가 아닌 Lotto를 당첨 번호로서 필드로 가지도록 했다.
(뭔가 상속을 쓰고 싶었는데 괜히 아쉬웠다..😅)
👀 4. 함수형 인터페이스는 이럴 때! (feat. 자동차 경주의 Runnable)
나는 2주 차 미션을 할 당시 함수형 인터페이스를 사용했었다. 하지만 외부에서 넘겨받은 행동을 수행한다는 점에서 코드가 스스로 만족스럽지 않았다. 하지만 이번 미션을 하면서는 되게 적절하게 사용했다는 느낌이 들어서 적어본다.
이번 미션에는 예외 발생 시 재입력을 받으라는 요구사항이 있었다.
하지만 미션에서 입력받는 순간은 총 3번이다. 그럼 그 세 번의 순간마다 똑같은 재시작 로직을 감싸서 구현해야 하나..? 싶었다. 이를 공통된 메서드로 묶어내고 싶어 고민하는 도중, 바로 2주 차에 사용했던 Runnable이 떠올랐다.
입력받는 로직을 Runnable처럼 매개변수로 넘기면 재시작 로직을 묶어내고 LottoGame에서 공통으로 사용할 수 있을 것 같았다.
하지만 Runnable은 한 가지 문제가 있었는데, 반환 값 없이 넘겨받은 함수를 실행하기만 한다는 점이었다. 나는 아래와 같이 purchase로 PurchaseAmount를 생성하고 리턴한 값으로 draw를 실행하려 했다. 그래서 리턴 값이 필요했다.
그러나 이를 해결하는 Supplier가 있었다.
Runnable은 인자도 반환 값도 없고, 단순히 실행만 하는 인터페이스라면, Supplier<T>는 인자는 없지만, 호출 시 특정 타입의 값을 반환하는 인터페이스이다. 그래서 Supplier를 활용해 동일한 동작을 묶어낼 수 있었다.
2주 차와는 달리, 객체지향에는 영향 없이 LottoGame에서의 반복되는 로직을 묶어내는 목적으로 함수형 인터페이스를 사용해 보면서 이럴 때 사용하는 거구나~ 싶었다.
👀 5. 테스트에서 @MethodSource 활용하기
2주 차 공통 피드백에는 학습 테스트가 있었고 @ParameterizedTest, @ValueSource, @CsvSource 등의 유용한 어노테이션이 많았다. 그중 @CsvSource를 새로 알게 되었는데 입력값뿐만 아니라 결괏값도 여러 값을 넣을 수 있다는 점이 정말 유용하다고 느꼈다.
@ParameterizedTest 사용 가이드에 대한 링크도 있어서 해당 문서를 읽어보았다.
https://www.baeldung.com/parameterized-tests-junit-5
그중 이번 미션에서 가장 유용하게 쓴 @MethodSource..! 를 배웠다.
문서에 따르면, @MethodSource는 간단한 인자가 아닌 복잡한 인자(즉, List와 같은 컬렉션)도 테스트하기 쉽게 제공되는 어노테이션이다.
말 그대로 인자 소스를 메서드로 전달받는다. @MethodSource("")에 소스를 전달할 메서드 명을 기입하면 된다.
위와 같이 findRank를 테스트한다면, findRankData라는 메서드에 각 테스트 케이스를 포함하는 Argument들을 정의하고, Stream<Argument>를 반환하면 된다. 그리고 이 메서드 명을 @MethodSource("findRankData")에 기입해주면
Arguments.of의 인자들이 순서대로 (6, false, Rank.FIRST)가 (int matchCount, boolean hasBonusNumber, Rank expectedRank)로 들어간다.
결과적으로 여러 입력 값에 대한 매핑되는 출력 값까지 깔끔하고 편하게 테스트할 수 있다. 개인적으로 @CsvSource 보다 가독성도 좋고, 컬렉션과 같은 복잡한 타입도 지원한다는 점이 좋았다.
🎟️ 로또는 어려웠다. 그럼에도?
지난 미션과는 달리 로또 미션을 하면서는 어려웠던 점과 고민한 점을 머릿속에서 꺼내는 것이 유독 어려웠다.
무엇을 어렵다고 느꼈는지, 어려움을 해결하기 위해 어떤 생각의 과정을 거쳤는지 꺼내보는 것이 익숙지 않은 것도 있겠지만, 무엇보다도 로또 미션이 어렵게 느껴졌기에 기능 구현 자체에만 정신이 팔렸던 것 같다.
돌아보면 미션을 수행하기 전 목표했던 객체지향, 작은 단위의 테스트 등을 지키려고 노력했지만 놓치는 순간들이 너무나 많았고, 기능을 구현할수록 내가 짠 코드에 대한 이해도가 떨어져 갔다.
명확함을 잃은 채로, 직관에 의존하여 가까스로 이번 미션을 제출할 수 있었다.
이번 로또 미션에서는 대부분의 문제 해결 과정에서 테스트의 도움을 받았다. 테스트는 항상 “한 객체가 가진 책임이 크다.”라는 신호를 보냈고, 덕분에 객체를 분리해 책임을 나눌 수 있었다. 공통 피드백에는 “테스트로 코드를 피드백한다.”라는 코멘트가 있었다.
내가 로또 미션을 수행하면서 테스트를 같이 구현해나가지 않았다면 정말 엉망인 코드가 만들어지지 않았을까.. 하는 생각이 든다.
이번 로또 미션 코드가 개인적으로 만족스럽지 않았지만 1, 2주 차에 비해 상당히 어려운 미션이었음에도 어느 정도 최소한의 요구사항과 구조를 지킬 수 있었던 이유는 이처럼 테스트가 코드를 피드백해준 덕분이라고 생각한다.
하지만 “요구사항에 있는 enum 클래스는 어떻게 사용하지?”, “지금 메서드가 한 가지 기능을 안 하는 것 같은데?”, “이 행동에 대한 책임은 누가 가져야 하지?”, “아 이 메서드가 여기 있으면 안 됐는데, 다시 짜야겠다” 등 너무 많은 요구사항들이 동시에 내 생각을 방해했고, 점점 “기능을 작게 바라보고 하나씩 구현해 나간다”는 명확한 목표를 잊어가며 미션을 수행하게 되었다.
구현할 기능 목록을 너무 크게 잡고 미션을 시작했던 것도 원인이 되었던 것 같다. 이런 어지러운 상황 속에서도 구현하기 전 생각해 둔 작게 나눈 기능 목록들이 있었다면, 이것에 의지해서 나아갈 수 있었을 텐데, 그런 장치 하나를 없애고 시작했으니.., 하지만 덕분에 지금까지 기능 목록이 얼마나 큰 역할을 해주었는지 빈자리가 느껴지는 미션이었다.
이런 아쉬움이 있음에도 어쩌면 테스트를 이용해 코드의 피드백을 받아보겠다는 목표는 이룬 걸 지도 모르겠다. “테스트로 어떻게 도움을 받을 수 있을까?”라는 질문에 꽤나 다가갔다는 느낌도 든다.
요 며칠은 로또 미션 코드리뷰와 공통 피드백을 고려해 개선점을 찾는 것에 집중해야겠다.
이번 미션은 충분히 기능을 작게 바라보지 못했고, 코드를 계속해서 분리하고 리팩토링 해야 하는 상황이 많았다.
그래서 4주 차에는 요구사항들을 충분히 작은 기능들로 나눠 바라보고 기능 목록을 작성하고, 이를 길잡이로 삼아 작은 핵심 로직부터 테스트를 성공시키며 미션을 수행하려 한다.
나는 아직 TDD에 대해 잘 알지 못하고 조금 멀게 느껴지기도 한다. 하지만 이렇게 테스트를 통해 코드에 피드백을 받는 경험을 쌓다 보면, 어느 순간 TDD를 모르는 듯하면서도 자연스럽게 TDD를 하고 있는 나를 발견하게 되지 않을까.
'woowacourse > precourse' 카테고리의 다른 글
[우아한테크코스] 7기 백엔드 최종 합격 회고 (8) | 2025.01.01 |
---|---|
[우아한테크코스] 최종 코딩테스트 회고, 그리고 나만의 5주차 (3) | 2024.12.19 |
[우아한테크코스] 프리코스 4주 차: 편의점 + 전체 회고 (1) | 2024.11.13 |
[우아한테크코스] 프리코스 2주 차 회고: 자동차 경주 (0) | 2024.10.29 |
[우아한테크코스] 프리코스 1주 차 회고: 문자열 덧셈 계산기 (0) | 2024.10.22 |