3주 차 프리코스가 끝이났다.
3주 차는 개인적으로 정말 많은 시간과 노력을 투자했다. 거의 하루도 빠짐없이 기능 구현 및 리팩토링을 진행했고, 그 결과 커밋 개수는 178개를 도달했다..!
사실 이번 주차 공통 피드백을 통해 추가적으로 리팩토링을 할 예정이라, 200개를 찍을 수도 있을 것이다.
무튼 이 정도로 정말 많이 신경썼다.
특히 1, 2주차의 공통 피드백과 1, 2주차 미션의 목표, 그리고 요구사항들을 계속해서 신경썼다.
위와 같이 스스로 부족하다고 느낀 부분을 체크리스트로 만들어 모든 항목들을 하나씩 짚어가며 리팩토링을 진행했다.
그리고 이전에 예외 처리
와 테스트 코드
에 대한 지식이 부족하다고 느껴져서, 자바의 정석으로 예외 처리와 Enum 파트를 공부했고, 구글링을 통해 JUnit, AssertJ을 공부했다.
예외 처리 공부할 때는 핸드폰으로 사진을 찍어놔 이동하면서도 보고 그랬다.
그래도 확실히 스스로 부족하다고 느낀 부분들을 공부하고, 신경쓰고 의식적으로 고쳐가려고 하면서 미션을 진행을 하니 저번 주보다는 괜찮게 짰다는 생각이 들었다.
실제로 같이 프리코스 백엔드를 진행하는 친구 한 명과, 개인 공부 목적으로 프리코스를 시작하려는 대학 동기에게 코드가 잘 읽힌다, 코드 잘 다룬다는 칭찬을 받았다.
사실 그간 요구사항 신경쓰고 스스로 피드백하면서 발전한다고 생각했지만, 어디까지나 나만의 생각일 뿐, 타인이 평가하는 말은 듣지 못했다. 그래서 내 코드에 대한 평가에 목이 말라 있었다.
그런데 마침 이런 칭찬을 들으니 갈증이 싹 해소되는 기분이 들었다. 그래도 정말 발전을 하고 있었구나..!
매주 새로운 미션이 올때면, 동시에 이전 미션의 공통 피드백도 함께 온다.
늘 공통 피드백을 보며 그래도 아예 모르는 내용들은 거의 없었기에 리마인드랑 메모만 하고 넘어갔다.
하지만 이번엔 확실히 다르다.
코드 설계, 디테일에 대한 피드백이 포함되어 있기에 가볍게 짚고 넘어갈 순 없다.
따라서 이번엔 피드백 하나하나, 내 코드에 직접 비교하여 셀프 피드백을 하고자 한다.
프로그래밍 요구사항을 보면 함수 15라인으로 제안하는 요구사항이 있다. 이 기준은 main() 함수에도 해당된다. 공백 라인도 한 라인에 해당한다. 15라인이 넘어간다면 함수 분리를 위한 고민을 한다.
요구사항 중에 하나의 함수 안에 15 라인이 넘지 말라는 요구사항이 있었다.
이는 하나의 함수가 하나의 역할만을 잘 하게끔 함수를 작성하라는 의도이다.
나는 이 요구사항을 지키기 위해 계속해서 의식하며 미션을 진행했다.
하지만, 하나의 함수가 도저히 15줄 이내로 수정할 수가 없었다.
public Rank judgeRank(int correctCount, boolean isCorrectBonus) {
if (correctCount == ConstantValue.THREE) {
return Rank.FIFTH;
}
if (correctCount == ConstantValue.FOUR) {
return Rank.FOURTH;
}
if (correctCount == ConstantValue.FIVE) {
if (isCorrectBonus) {
return Rank.SECOND;
}
return Rank.THIRD;
}
if (correctCount == ConstantValue.SIX) {
return Rank.FIRST;
}
return Rank.ZERO;
}
딱 16줄이지만, 어떻게 줄일 수 있는 방법이 없나 조금 더 생각을 해봐야겠다..
정상적인 경우를 구현하는 것보다 예외 상황을 모두 고려해 프로그래밍하는 것이 더 어렵다. 예외 상황을 고려해 프로그래밍하는 습관을 들인다. 예를 들어 로또 미션의 경우 아래와 같은 예외 상황을 고민해 보고 해당 예외에 대해 처리를 할 수 있어야 한다.
- 로또 구입 금액에 1000 이하의 숫자를 입력
- 당첨 번호에 중복된 숫자를 입력
- 당첨번호에 1~45 범위를 벗어나는 숫자를 입력
- 당첨 번호와 중복된 보너스 번호를 입력
일어날 수 있는 예외 상황을 모두 고려하는 습관이 필요하다고 느껴진다.
위 항목 중에선 로또 구입 금액에 1000 이하의 숫자를 입력
에 대한 예외가 부족했다.
나는 1000원 단위가 아니거나, 문자이면 예외가 발생하도록 구현했다.
즉, 음수이거나, 0원일 경우를 고려하지 않았다.
물론 내 설계로는 0원이면 로또를 0장 교환해주는 식으로 구현하긴 했지만, 사실상 프로그램 의도 상 예외로 잡는 것이 맞다고 생각된다.
비즈니스 로직과 UI 로직을 한 클래스가 담당하지 않도록 한다. 단일 책임의 원칙에도 위배된다.
public class Lotto { private List<Integer> numbers; // 로또 숫자가 포함되어 있는지 확인하는 비즈니스 로직 public boolean contains(int number) { ... } // UI 로직 private void print() { ... } }
현재 객체의 상태를 보기 위한 로그 메시지 성격이 강하다면 toString()을 통해 구현한다. View에서 사용할 데이터라면 getter 메서드를 통해 데이터를 전달한다.
여기서 핵심은 비즈니스 로직과 UI 로직을 한 클래스가 담당하지 않도록 한다. 라고 생각한다.
즉, 비스니스 로직은 Service
단에서, UI 로직은 Controller
단에서 처리하는 것이라고 생각하자.
View에서 사용할 데이터라면 getter 메서드를 통해 데이터를 전달한다. 라는 말도 주의깊게 짚고 넘어가자.
public enum Rank { FIRST(6, 2_000_000_000), SECOND(5, 30_000_000), THIRD(5, 1_500_000), FOURTH(4, 50_000), FIFTH(3, 5_000), MISS(0, 0); private int countOfMatch; private int winningMoney; private Rank(int countOfMatch, int winningMoney) { this.countOfMatch = countOfMatch; this.winningMoney = winningMoney; } }
나는 이번엔 Enum에 대해 처음으로 깊게 공부하고 사용하였다.
그리고 매직 넘버 사용을 피하기 위해 사용한 느낌이 크다.
하지만, Enum 클래스를 사용하는 이유와 목적에 대해 공부할 필요가 있다고 생각된다.
최근에 등장하는 프로그래밍 언어들은 기본이 불변 값이다. 자바는 final 키워드를 활용해 값의 변경을 막을 수 있다.
public class Money { private final int amount; public Money(int amount) { ... } }
이 부분은 사실 생소한 부분이다. Swift
를 공부할 때 let
이라는 타입이 있었는데, 다음 미션을 진행할 때는 이 let
을 생각해보며 구현해봐야겠다.
인스턴스 변수의 접근 제어자는 private으로 구현한다.
public class WinningLotto { private Lotto lotto; private Integer bonusNumber; public WinningLotto(Lotto lotto, Integer bonusNumber) { this.lotto = lotto; this.bonusNumber = bonusNumber; } }
이 부분은 체화된 부분이니 가볍게 읽고 넘어가자
Lotto 클래스는 numbers를 상태 값으로 가지는 객체이다.
그런데 이 객체는 로직에 대한 구현은 하나도 없고, numbers에 대한 getter 메서드만을 가진다.public class Lotto { private final List<Integer> numbers; public Lotto(List<Integer> numbers) { this.numbers = numbers; } public int getNumbers() { return numbers; } }
public class LottoGame { public void play() { Lotto lotto = new Lotto(...); // 숫자가 포함되어 있는지 확인한다. lotto.getNumbers().contains(number); // 당첨 번호와 몇 개가 일치하는지 확인한다. lotto.getNumbers().stream()... } }
Lotto에서 데이터를 꺼내지(get) 말고 메시지를 던지도록 구조를 바꿔 데이터를 가지는 객체가 일하도록 한다.
public class Lotto { private final List<Integer> numbers; public boolean contains(int number) { // 숫자가 포함되어 있는지 확인한다. ... } public int matchCount(Lotto other) { // 당첨 번호와 몇 개가 일치하는지 확인한다. ... } }
public class LottoGame { public void play() { Lotto lotto = new Lotto(...); lotto.contains(number); lotto.matchCount(...); } }
이 피드백에서 핵심은 메시지를 던지도록 구조를 바꿔 데이터를 가지는 객체가 일하도록 한다. 라고 생각한다.
실제로 난 3주 차에서 얼마나 잘 지켰는지 보았다.
결과는 꽤 나쁘지 않게 짰다고 느껴진다. 실제로 핵심 비즈니스 로직은 ManagerService
에서 수행하고, 중간중간 Manager
객체에 값을 던져, 저장하거나, Manager
가 가지고 있는 변수와 비교하여 결과를 반환하는 식으로 구현했기 때문이다.
필드(인스턴스 변수)의 수가 많은 것은 객체의 복잡도를 높이고, 버그 발생 가능성을 높일 수 있다. 필드에 중복이 있거나, 불필요한 필드가 없는지 확인해 필드의 수를 최소화한다.
예를 들어 총 상금 및 수익률을 구하는 다음 객체를 보자.public class LottoResult { private Map<Rank, Integer> result = new HashMap<>(); private double profitRate; private int totalPrize; }
위 객체의 profitRate와 totalPrize는 등수 별 당첨 내역(result)만 있어도 모두 구할 수 있는 값이다. 따라서 위 객체는 다음과 같이 하나의 필드만으로 구현할 수 있다.
public class LottoResult { private Map<Rank, Integer> result = new HashMap<>(); public double calculateProfitRate() { ... } public int calculateTotalPrize() { ... } }
이 부분은 아마 날 저격하지 않았나 싶을 정도이다..
실제 내 코드도
public class User {
private List<Lotto> lottos;
private double money;
private double totalPrize;
private Map<Rank, Integer> ranks = new HashMap<>();
private double yield;
이렇게 되어 있다. 결과적으로 totalPrize
와 yield
가 필요없는 셈이었다.
ranks에 몇 등했는지와, 얼만큼 했는지에 대한 정보가 들어있으니 총 상금과 그에 따른 수익률을 도출할 수 있었기 떄문이다.
테스트를 작성하면 성공하는 케이스에 대해서만 고민하는 경우가 있다. 하지만 예외에 대한 부분 또한 처리해야 한다. 특히 프로그램에서 결함이 자주 발생하는 부분 중 하나는 경계값이므로 이 부분을 꼼꼼하게 확인해야 한다.
@DisplayName("보너스 번호가 당첨 번호와 중복되는 경우에 대한 예외 처리") @Test void duplicateBonus() { assertThatThrownBy(() -> new WinningLotto(new Lotto(List.of(1, 2, 3, 4, 5, 6), 6)) ).isInstanceOf(IllegalArgumentException.class); }
이 부분은 실제로 꽤 잘 지켰다고 생각한다.
물론 빠트린 예외 케이스가 있지만, 내가 가정한 예외 케이스는 모두 테스트 코드로 구현했기 때문이다.
테스트 코드도 코드이므로 리팩터링을 통해 개선해나가야 한다. 특히 반복적으로 하는 부분을 중복되지 않게 만들어야 한다. 예를 들어 단순히 파라미터의 값만 바뀌는 경우라면 아래와 같이 테스트할 수 있다.
@DisplayName("천원 미만의 금액에 대한 예외 처리") @ValueSource(strings = {"999", "0", "-123"}) @ParameterizedTest void underLottoPrice(Integer input) { assertThatThrownBy(() -> new Money(input)) .isInstanceOf(IllegalArgumentException.class); }
이 부분도 뼈가 아팠다.. 이번 주차에 JUnit과 AssertJ에 대해 공부를 했지만, 정말 얕게 공부를 한 수준이었기에 테스트 코드를 작성하는 데에 있어 큰 자신감이 없었기 때문이다.
다음부터는 테스트 코드도 실제 프로덕트 코드 짜듯이 짜야겠다.
테스트를 위한 편의 메서드를 구현 코드에 구현하지 마라. 아래의 예시처럼 테스트를 통과하기 위해 구현 코드를 변경하거나 테스트에서만 사용되는 로직을 만들지 않는다.
테스트를 위해 접근 제어자를 바꾸는 경우
테스트 코드에서만 사용되는 메서드
사실 이 내용은 어찌보면 당연한 내용이라고 생각한다. 그러므로 가볍게 읽고 넘어가자.
아래 코드는 Random 때문에 Lotto에 대한 단위 테스트를 하기 힘들다. 단위 테스트가 가능하도록 리팩터링한다면 어떻게 하는 것이 좋을까?
import camp.nextstep.edu.missionutils.Randoms; public class Lotto { private List<Integer> numbers; public Lotto() { this.numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6); } } —————— public class LottoMachine { public void execute() { Lotto lotto = new Lotto(); } }
올바른 로또 번호가 생성되는 것을 테스트하기 어렵다. 테스트하기 어려운 것을 클래스 내부가 아닌 외부로 분리하는 시도를 해 본다.
public class Lotto { private List<Integer> numbers; public Lotto(List<Integer> numbers) { this.numbers = numbers; } } —————— import camp.nextstep.edu.missionutils.Randoms; public class LottoMachine { public void execute() { List<Integer> numbers = Randoms .pickUniqueNumbersInRange(1, 45, 6); Lotto lotto = new Lotto(numbers); } }
위 코드는 A 상황을 B로 바꾼 것이다.
A.
Application(테스트하기 어려움)
⬇️
LottoMachine(테스트하기 어려움)
⬇️
Lotto(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)B.
Application(테스트하기 어려움)
⬇️
LottoMachine(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)
⬇️
Lotto(테스트하기 쉬움)(참고. 메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기)
이처럼 단위 테스트를 할 때 테스트하기 어려운 부분은 분리하고 테스트 가능한 부분을 단위 테스트한다. 테스트하기 어려운 부분은 단위 테스트하지 않아도 된다. 남은 LottoMachine은 어떻게 테스트하기 쉽게 바꿀 수 있을지 고민해 본다.
대충, 테스트하기 어려운 단위의 로직이 있을 경우, 그 로직을 외부로 분리하고나면 테스트가 가능한 부분이 생기니 그 부분을 테스트하라는 말로 이해된다.
사실 이 부분은 아직은 크게 와닿지 않는 내용이었다.
하지만 충분히 테스트 하기 어려운 로직이 있을 것이다. 그땐 다시 이 내용을 읽도록 해야겠다.
가독성의 이유만으로 분리한 private 함수의 경우 public으로도 검증 가능하다고 여겨질 수 있다.
public 함수가 private 함수를 사용하고 있기 때문에 자연스럽게 테스트 범위에 포함된다.
하지만 가독성 이상의 역할을 하는 경우, 테스트하기 쉽게 구현하기 위해서는 해당 역할을 수행하는 다른 객체를 만들 타이밍이 아닐지 고민해 볼 수 있다.
다음 단계를 진행할 때에는 너무 많은 역할을 하고 있는 함수나 객체를 어떻게 의미 있는 단위로 분할할지에 초점을 맞춰 진행한다.
음,, 이 부분도 사실 크게 와닿지는 않는다.
나중에 필요하다면 다시 정독해야겠다.
그래도 내가 부족한 부분에 집중하여 그것들을 채우려고 노력을 하니 조금은 발전한 기분이 든다.
남은 마지막 4주차에도 내가 부족한 부분에 집중하여 꾸준히 의식적으로 고쳐나가려고 노력해야겠다.
또한 기능 요구 사항이 늘어난 상황에서, 모든 기능을 모두 구현 후 리팩토링하려면 리팩토링이 굉장히 골치아프거나 설계를 다시 해야하는 상황이 올 수도 있을 것이라는 생각이 들었다.
따라서 우선 기능 하나하나 차근히 설계 후에 구현하는 방식으로 진행해봐야겠다.
잘보고갑니다! 4주차도 화이팅입니다!!🔥