최근에 Effective Java를 다시 읽다보니, 현업에서 활용할 일이 꽤 있을 것으로 생각되서 글로 정리해보려 한다.
Collection을 Wrapping하면서, Wrapping한 Collection 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라 한다.
이를 코드로 표현하면,
Map<String, String> gameRankingMap = new HashMap<>();
gameRankingMap.put("1", "A");
gameRankingMap.put("2", "B");
gameRankingMap.put("3", "C");
위의 예제 코드와 같이 밖에서 map으로 따로 관리하던 것을 아래처럼 wrapper class로 운용하는 것이다.
public class GameRanking {
private Map<String, String> ranks;
public GameRanking(Map<String, String> ranks) {
this.ranks = ranks;
}
}
일급 컬렉션이란 단어는 소트웍스 앤솔로지 의 객체지향 생활체조 파트에서 언급이 되었고 아래와 같은 규칙이 있다.
규칙 8: 일급 콜렉션 사용
컬렉션을 포함한 클래스는 반드시 다른 멤버 변수가 없어야 한다.
책에서 언급된 이러한 규칙으로 인해 각 컬렉션은 그 자체로 포장돼 있으므로 이제 컬렉션과 관련된 동작은 근거지가 마련된 셈이고 이 컬렉션은 새 클래스의 일부가 되는 동시에 스스로 함수 객체가 될 수 있다.
이처럼 컬렉션을 Wrapping 함으로써 가질 수 있는 장점은 다음과 같다.
로또 복권 게임을 만든다고 할 때, 다음과 같은 조건이 있으며 Service Class에선 로또 번호를 관리한다고 가정해보자.
(1) 로또 번호는 6개의 번호가 존재한다.
(2) 로또 번호의 6개의 번호는 서로 중복되지 않는다.
public class LottoService {
private static final int LOTTO_NUMBER_SIZE = 6;
public List<Integer> createLottoNumber() {
final List<Integer> lottoNumberList = createNonDuplicateNumbers();
validateSize(lottoNumberList);
validateDuplicate(lottoNumberList);
return lottoNumberList;
}
private void validateSize(List<Integer> lottoNumberList) {
if (lottoNumberList.size() != LOTTO_NUMBER_SIZE) {
throw new IllegalArgumentException("로또 번호는 6개")
}
}
private void validateDuplicate(List<Integer> lottoNumberList) {
Set<Integer> nonDuplicateNumbers = new HashSet<>(lottoNumberList);
if (nonDuplicateNumbers.size() != LOTTO_NUMBER_SIZE) {
throw new IllegalArgumentException("로또 번호는 중복이 안된다")
}
}
private List<Integer> createNonDuplicateNumbers() {
...
}
}
이렇게 비지니스 로직을 LottoService Class에서 처리할 경우
로또 번호가 필요한 모든 장소에선 검증로직이 들어가야만 한다는 문제가 생기게 되며 이는 즉, 관리 포인트가 증가하게 된다.
모든 코드와 도메인을 알고 있지 않다면, 언제든 문제가 발생할 여지가 있게되는 것이다.
하지만 만약, 자료구조 자체가 비즈니스 로직을 담게된다면
이러한 문제는 자연스럽게 해결하게 되고 이러한 부분이 일급 컬렉션의 가장 큰 장점이 된다.
/**
* 일급 컬렉션 LottoTicket
*/
public class LottoTicket {
private static final int LOTTO_NUMBER_SIZE = 6;
private final List<Inteager> lottoNumberList;
public LottoTicket(List<Inteager> lottoNumberList) {
validateSize(lottoNumberList);
validateDuplicate(lottoNumberList);
this.lottoNumberList = lottoNumberList;
}
private void validateSize(List<Integer> lottoNumberList) {
if (lottoNumberList.size() != LOTTO_NUMBER_SIZE) {
throw new IllegalArgumentException("로또 번호는 6개")
}
}
private void validateDuplicate(List<Integer> lottoNumberList) {
Set<Integer> nonDuplicateNumbers = new HashSet<>(lottoNumberList);
if (nonDuplicateNumbers.size() != LOTTO_NUMBER_SIZE) {
throw new IllegalArgumentException("로또 번호는 중복이 안된다")
}
}
}
이렇게 비즈니스에 종속적인 자료구조가 만들어지기때문에
LottoService에선 안심하게 lottoNumberList를 사용하여 이용할 수 있게 된다.
public class LottoService {
public LottoTicket createLottoNumber() {
return new LottoTicket(createNonDuplicateNumbers());
}
private List<Integer> createNonDuplicateNumbers() {
...
}
}
java의 final
keyword를 사용하는 일반 collection의 경우 정확히 불변성을 만들어주는 것은 아니며, 메모리 재할당만 금지한다. 즉, add()
, remove()
를 통해 컬렉션 element는 변경할 수 있다.
요즘과 같이 프로젝트 규모가 커지고 있는 상황에서 각각의 객체들이 절대 값이 바뀔 일이 없다는게 보장되면 그만큼 코드를 이해하고 수정하는데 사이드 이펙트가 최소화되기 때문에 불변 객체는 아주 중요하다.
이러한 부분을 일급 컬렉션으로 해결할 수 있다.
public class Orders {
private List<Order> orderList;
public Orders(List<Order> orderList) {
this.orderList = orderList;
}
public long getTotalAmount() {
return orderList.stream()
.mapToLong(Order::getAmount)
.sum();
}
}
해당 class는 collection에 대한 getter 메소드가 없어 List에 접근할 수 없기 때문에 값을 변경/추가 할 수 없게되고
이를 통해 안정적인 Orders를 운용할 수 있는 환경을 만들 수 있게 된다.
일급 컬렉션은 상태와 행위를 한 곳에서 관리할 수 있기 때문에 외부에서 중복된 메서드 생성과 같은 문제를 해결할 수 있다.
@Test
public void 로직이_밖에_있을_경우() {
List<Pay> pays = List.of(
new Pay(NAVER_PAY, 1000),
new Pay(NAVER_PAY, 1500),
new Pay(KAKAO_PAY, 2000),
new Pay(TOSS_PAY, 3000));
Long naverPayAmount = pays.stream()
.filter(pay -> pay.getPayType().equals(NAVER_PAY))
.mapToLong(Pay::getAmount)
.sum();
assertThat(naverPaySum).isEqualTo(2500);
}
위의 예제의 경우, pays
와 계산 로직
은 서로 관계가 있지만, 독립적으로 존재하게 되어
NaverPay 총 금액을 뽑으려면 컬렉션과 각각의 계산식이 항상 함께 두어야 한다.
또한, KakaoPay의 로직도 별도로 존재하게어 코드가 흩어지거나 중복 메소드가 생길 가능성이 농후하다.
이러한 부분은 Enum
을 통해서 pays
와 계산 로직
을 함께할 수 있지만,
ex) Enum 예제 (https://techblog.woowahan.com/2527)
일급 컬렉션을 통해서도 해결할 수 있다.
/**
* 일급 컬렉션 PayGroups
*/
public class PayGroups {
private List<Pay> pays;
public PayGroups(List<Pay> pays) {
this.pays = pays;
}
public Long getPaySum(PayType payType) {
return getFilteredPays(pay -> payType == pay.getPayType());
}
private Long getFilteredPays(Predicate<Pay> predicate) {
return pays.stream()
.filter(predicate)
.mapToLong(Pay::getAmount)
.sum();
}
}
이렇게, 일급 컬렉션을 통해 상태와 로직을 한 곳에서 관리 된다.
일급 컬렉션의 마지막 장점은 네이밍이 가능하다는 것이다.
만약, 각각의 pay 정보를 List로 관리한다면 다음과 같게 되고
List<Pay> naverPays = new ArrayList<>();
List<Pay> kakaoPays = new ArrayList<>();
List<Pay> tossPays = new ArrayList<>();
이는 또 다음과 같은 단점을 갖게 된다.
하지만, 이러한 문제도 NaverPay 그룹과 KakaoPay 그룹을 각각의 일급 컬렉션을 사용하여 관리하면, 해당 컬렉션을 기반으로 검색하면 되기에 쉽게 해결 할 수 있게 된다.
NaverPays naverPays = new Pays(createNaverPays());
KakaoPays kakaoPays = new Pays(createKakaoPays());
ref)
https://jojoldu.tistory.com/412
https://tecoble.techcourse.co.kr/post/2020-05-08-First-Class-Collection/