우테코 프리코스 로또 미션의 코드리뷰를 보다가 Enum 사용에 관한 피드백을 받게 되었습니다.
"ENUM 이 로직의 일환인 조건 파악을 가져도 된다고 생각한 기준이 있나요?"
위 피드백을 받게 된 로직을 살펴보겠습니다.
우선, 로직을 간략하게 설명하자면, 로또 등수를 계산하기 위해서 일치하는 당첨 번호 개수와, 보너스 번호 당첨 여부를 매개변수로 받아서
LottoRank를 순회하며 isMatchingCondition() 메서드를 통해 일치하는 당첨 등수를 반환하는 로직 입니다.
구현 이유
구현 할 당시에는 "당첨 등수가 자신의 당첨 등수를 계산하는 책임을 가지는 것이 맞는 것 아닌가?" 라는 생각으로 구현했습니다.
그래서 Enum과 함수형 인터페이스를 사용해서 각각의 상수에 메서드를 오버라이드 하여 각 상수별로 자신의 당첨 조건을 계산할 수 있도록 하였습니다.
🤔 피드백이 전달하고자 하는 바는?
피드백의 핵심 내용으로 "해당 Enum이 등수 계산 로직을 가져도 되는 이유" 에 대해서 생각해 보았습니다.
이유는 위에서 얘기한 것 처럼 "당첨 등수의 로직은 당첨 등수가 책임져야 하기 때문" 이라고 생각했습니다.
그렇다면 피드백의 의도는 무엇일까요?
Enum의 책임은 어디까지?
LottoRank Enum은 로또 등수의 상수를 모아놓은 열거형 입니다. 이 Enum이 가져야할 책임은 어디까지 일까요?
크게 두가지로 분류할 수 있을것 같습니다.
- 1. 상수만의 집합
- 2. 자신의 등수를 직접 계산
앞서 소개했던 코드에서는 2번을 책임으로 생각하여 구현하였습니다.
이번 주제에서 바라볼 책임의 관점은 1. 상수만의 집합 입니다.
* (모든 것에는 정답이 없습니다. 각각의 상황에 맞게 판단 후, 적용하는것이 중요합니다.)
과도한 책임이 아닐까?
현재 LottoRank 클래스는 당첨 등수 상수의 집합과, 당첨 등수의 계산 을 가진다고 볼 수 있습니다.
즉, 상수의 집합 + 동작을 포함하고 있습니다.
해당 설계의 장점에는 어떤것들이 있을까요?
장점
1. 캡슐화
- 위 로직은 당첨 등수 상수별로 자신의 등수를 계산하고 있습니다. 즉 각각의 상수에 대한 캡슐화가 이루어졌다고 볼 수 있습니다. 만약 또 다른 등수가 추가된다고 할 때, 해당 등수는 고유한 비즈니스 로직을 가지고 해당 상태에 대한 로직을 수행할 수 있습니다.
2. 중복 제거
- 당첨 조건 로직을 상수 내부에 캡슐화 하여 반복되는 분기문을 줄일 수 있습니다.
장점이 있다면 단점도 있을 수 있습니다.
단점
1. Enum의 거대화
- 각각의 상수가 고유한 비즈니스 로직을 가지고 있다면 이는 조건의 변경시 Enum이 거대해질 수 있는 가능성이 있습니다.
- 예를 들어, "로또 당첨 등수에 대한 조건이 변하거나, 특정 등수에 대한 로직이 변경되어야 하거나, 다양한 등수 조건이 추가되어야 할 때" 비즈니스 로직이 추가될수록 Enum이 커질 것이고, 많은 로직을 하나의 Enum에서 처리해야 할 수 있습니다.
2. 테스트의 어려움
- 각각의 등수 로직이 하나의 Enum에 포함되어 있기 때문에 각각의 등수 조건을 테스트 하기가 힘들다는 단점이 있습니다.
그렇다면 이러한 단점을 어떻게 해결 할 수 있을까요?
전략 패턴을 활용한 책임 분리
위 코드의 단점은 상수와 비즈니스 로직이 결합되어 있다는 것이 문제였습니다. 그렇다면 이 둘의 결합을 느슨하게 하고, 계산 로직을 별도의 책임으로 분리하여 리팩토링 할 수 있을 것 같습니다.
public class FirstRankMatcher implements LottoRankMatcher {
@Override
public boolean matchRank(int numberMatchCount, boolean bonusNumberMatch) {
return numberMatchCount == 6;
}
}
public class SecondRankMatcher implements LottoRankMatcher {
@Override
public boolean matchRank(int numberMatchCount, boolean bonusNumberMatch) {
return numberMatchCount == 5 && bonusNumberMatch;
}
}
public class ThirdRankMatcher implements LottoRankMatcher {
@Override
public boolean matchRank(int numberMatchCount, boolean bonusNumberMatch) {
return numberMatchCount == 5 && !bonusNumberMatch;
}
}
// 나머지 등수에 대해서도 구현체를 생성합니다.
...
LottoRankMatcher 인터페이스를 구현하는 각각의 구현체를 구현합니다. 이들은 각각의 로또 등수에 대한 계산 로직을 가지고 있습니다.
이렇게 각각의 구현체를 만들게 되면 각각의 등수에 대한 독립적인 계산 로직을 가질 수 있고, 추후 특정 등수에 대한 로직이 변경되어야 할 때 해당하는 등수의 구현체를 찾아서 수정해주면 됩니다.
public enum LottoRank {
NO_LUCK("3개 미만 일치 (0원)",
0L,
new NonRankMatcher()),
FIFTH("3개 일치 (5,000원)",
5_000L,
new FifthRankMatcher()),
FOURTH("4개 일치 (50,000원)",
50_000L,
new FourthRankMatcher()),
THIRD("5개 일치 (1,500,000원)",
1_500_000L,
new ThirdRankMatcher()),
SECOND("5개 일치, 보너스 볼 일치 (30,000,000원)",
30_000_000L,
new SecondRankMatcher()),
FIRST("6개 일치 (2,000,000,000원)",
2_000_000_000L,
new FirstRankMatcher());
이제 해당 구현체들을 각각의 등수에 맞는 상수에 주입 시키면 됩니다.
이전 구현 방식과의 차이점은 무엇이 있을까요?
1. 비즈니스 로직의 분리
- 기존의 하나의 Enum에 존재하는 각각의 상수에 존재하던 비즈니스 로직을 외부로 분리시켜 결합도를 낮췄습니다.
2. 유연성 확보
- 로또 등수의 추가와 등수 정책에 대한 변경이 생길 경우 전체 Enum을 수정해야 했지만, 변경 후엔 새로운 등수가 추가 되어도, 해당 등수에 대한 구현체만 주입시켜 Enum의 수정 없이도 확장이 가능 합니다.
3. 테스트 용이
- 등수별 구현체를 독립적으로 테스트할 수 있습니다.
class FirstRankMatcherTest {
@DisplayName("1등 당첨 조건이라면 true를 반환한다.")
@Test
void matchRank() {
//given
int numberMatchCount = 6;
boolean bonusNumberMatch = false;
FirstRankMatcher firstRankMatcher = new FirstRankMatcher();
//when
boolean result = firstRankMatcher.matchRank(numberMatchCount, bonusNumberMatch);
//then
assertThat(result).isTrue();
}
}
//나머지 테스트도 동일하게 진행
...
🤝 Trade - off
어느 방법이든 정답은 없습니다. 요구사항이 복잡하지 않고, 지금처럼 단조롭다면 각각의 상수에 비즈니스 로직을 구현하는것이 더 좋을 수도 있다고 생각합니다. 중요한건 변화에 빠르게 대응할 수 있도록 변경의 가능성을 열어두고 개발하는 것이 중요하다고 생각합니다.
요약
1. Enum과 함수형 인터페이스를 사용하는것은 좋은 선택이다.
2. 무조건 좋은 것은 아니다.
3. 각각의 상황에 맞게 유연하게 구현하는 능력이 중요하다.
모자란 지식으로 적은 글 입니다!
틀린 부분이 있다면 얼마든지 지적해주시면 감사하겠습니다. 😊
📚 참고
Enum 사용시 메서드 오버라이딩을 주의해서 사용해야 합니다