일급컬렉션에 대한 얘기를 접하게 되었습니다.
처음 듣는 키워드였는데, 검색을 통해 해당 개념이 많이 언급되고 있다는 것을 알게 되었고 따라서 개념 정리를 준비하게 되었습니다.
참조한 블로그는 일급 컬렉션 글 중 가장 유명한 일급 컬렉션의 소개와 써야할 이유 게시글을 참조하였으며, 부가적인 설명에 관해서는 일급 컬렉션을 사용하는 이유 게시글을 적극 참조했습니다. 도움을 주셔서 감사합니다 :)
소트웍스 엔솔로지의 객체지향 생활 체조 원칙에 등장한 개념입니다.
해당 개념은 Collection을 포함한 클래슨느 반드시 다른 멤버 변수가 없어야 한다를 의미합니다. 즉, Collection을 wrapping 하면서, wrapping한 Collection 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라 칭하는 것입니다.
아래와 같은 코드가 있다고 합시다. (일급 컬렉션의 소개와 써야할 이유 의 예제를 차용하였습니다.)
Map<String> map = new HashMap<>();
map.put("1", "A"); // 1등은 A등급
map.put("2", "B"); // 2등은 B등급
...
이와 같은 코를 아래와 같이 Wrapping하는 것을 일급 컬렉션이라합니다.
public class GameRanking{
private Map<String, String> ranks;
public GameRanking(Map<String, String> ranks){
this.ranks = ranks; }
}
즉, Collection(ex. Map)을 Wrapping하는데, 그 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라 하는 것입니다.
해당 구조를 사용하게 된다면
일급 컬렉션의 소개와 써야할 이유 의 예제를 차용하였습니다.
로또 게임을 만든다고 가정합니다. 해당 게임은
각 사용자가 로또 게임을 진행하고 있다고 해봅시다.
public class LottoService {
private static final int LOTTO_NUMBERS_SIZE = 6;
// 로또 번호 생성하는 서비스 로직
public List<Integer> createNumberSet() {
List<Integer> numbers = createNonDuplicateNumbers();
validateSize(numbers); // 6개인지 검증
validateDuplicate(numbers); // 중복이 있는지 검증
return numbers;
}
// 6개인지 검증하는 서비스 로직
private void validateSize(List<Integer> numbers) {
if (numbers.size() != LOTTO_NUMBERS_SIZE) {
throw new IllegalArgumentException("로또 번호는 6개여야 합니다.");
}
}
// 중복 번호 있는지 검증하는 로직
private void validateDuplicate(List<Integer> numbers) {
Set<Integer> numbersSet = new HashSet<>(numbers);
if (numbers.size() != LOTTO_NUMBERS_SIZE) {
throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
}
}
...
}
하지만 이렇게 서비스단에서 검증 로직을 처리하게 된다면, 해당 코드의 전체를 모르는 신입 개발자가 들어왔을 때 로또 번호 List가 있는 모든 장소엔 검증 로직이 필요한건지 확실히 알기가 어렵습니다. 모든 코드와 도메인을 알고 있지 않다면 언제든 문제가 발생할 여지가 있는 것입니다.
따라서 해당 코드를 아래와 같은 일급 컬렉션으로 변경한다면
public class LottoTicket {
private static final int LOTTO_NUMBERS_SIZE = 6;
private final List<Integer> numbers;
public LottoTicket(List<Integer> numbers){
validateSize(numbers);
validateDuplicate(numbers);
this.numbers = numbers;
}
private void validateSize(List<Integer> numbers) {
if (numbers.size() != LOTTO_NUMBERS_SIZE) {
throw new IllegalArgumentException("로또 번호는 6개여야 합니다.");
}
}
// 중복 번호 있는지 검증하는 로직
private void validateDuplicate(List<Integer> numbers) {
Set<Integer> numbersSet = new HashSet<>(numbers);
if (numbers.size() != LOTTO_NUMBERS_SIZE) {
throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
}
}
}
로또 번호가 필요한 곳에서는 이 일급컬렉션만 사용하면 되기 때문에 보다 편리한 코드가 완성됩니다.
public class LottoServiceTwo {
public void createLottoNumbers(){
LottoTicket lottoTicket = new LottoTicket(createNonDuplicateNumbers());
}
...
}
일급 컬렉션을 사용하는 이유의 예제를 차용했습니다.
GS 편의점에서 아이스크림을 팔고 있다고 해봅시다.
이 편의점에는 아이스크림의 종류를 10가지 이상 팔지 못하는 규칙이 있다고 합니다.
위와 같은 조건을 위해선 List iceCreams의 크기가 10을 넘으면 안된다는 검증이 필요해집니다.
해당 조건은 아래와 같이 작성할 수 있습니다.
public class GSConvenienceStore {
private List<IceCream> iceCreams;
public GSConvenienceStore(List<IceCream> iceCreams) {
validateSize(iceCreams)
this.iceCreams = iceCreams;
}
private void validateSize(List<IceCream> iceCreams) {
if (iceCreams.size() >= 10) {
new throw IllegalArgumentException("아이스크림은 10개 이상의 종류를 팔지 않습니다.")
}
}
// ...
}
이런 방식으로 코드를 작성한다면
아이스크림 뿐만 아니라 과자, 라면 등에도 이런 검증이 들어가야한다면?
위와 같은 다양한 고민이 필요해집니다.
GS에도 동일한 검증 로직을 추가하거나, 상품별로 검증 로직이 각각 필요해진다면 당연히 클래스의 역할이 무거워지고 , 중복 코드가 많아질 것입니다.
이를 해결하기 위해 List<IceCream> iceCreams
의 주인공인 IceCream
을 일급 객체로 만들어봅시다.
public class IceCreams{
private List<IceCream> iceCreams; // 상태
public IceCreams(List<IceCream> iceCreams){
validateSize(iceCreams);
this.iceCreams = iceCreams;}
private void validateSize(List<IceCream> iceCreams){
if(iceCreams.size() >= 10){
new throw IllegalArgumentException("아이스크림 10개 이상 종류 안팔아요");
}
} // 로직
public IceCream find(String name){
return iceCreams.stream()
.filter(iceCream::isSameName)
.findFirst()
.orElseThrow(RuntimeException::new)
}
...
}
이렇게 된다면 GS 편의점, CU 편의점 클래스는 다음과 같이 변화합니다.
public class GSConvenienceStore {
private IceCreams iceCreams;
public GSConvenienceStore(IceCreams iceCreams) {
this.iceCreams = iceCreams;
}
public IceCream find(String name) {
return iceCreams.find(name);
}
// ...
}
public class CUConvenienceStore {
private IceCreams iceCreams;
public CUConvenienceStore(IceCreams iceCreams) {
this.iceCreams = iceCreams;
}
public IceCream find(String name) {
return iceCreams.find(name);
}
// ...
}
이런 식으로 변화하게 된다면, 과자, 라면 등의 새로운 검증 대상 품목이 생기더라도 그 검증은 해당 품목의 일급 컬렉션이 담당하게 됩니다. 그리고 편의점 클래스가 했던 역할을 각 품목의 일급 컬렉션에게 위임하여 상태와 로직을 관리하게 할 수 있어집니다.
이렇게 된다면 클래스의 부담을 줄일 수 있고 중복 코드를 줄일 수 있음에 의미가 있습니다.
일급 컬렉션의 소개와 써야할 이유의 예제를 차용하였습니다.
여러 Pay가 있고 이 중 naver pay 금액의 합이 필요하다고 가정해봅시다.
일급 컬렉션의 소개와 써야할 이유에서 소개하고 있는 것과 같이 일반적으로는 값과 로직이 분리된 상태로 존재하게 됩니다.
이 상황에서는 문제가 발생합니다. pays
와 계산 로직은 서로 관계가 있는데, 이것이 코드로 표현이 잘 되지 않는 것입니다.
이렇게 된다면, 신규 화면 개설시 naver pay 계산 로직
이 따로 존재하다는 것이 파악이 되지 않는다면, 똑같은 역할을 하는 메소드를 새로 만들어 중복이 발생할 수도 있고 이로 인해 관리 포인트가 증가할 확률이 매우 높습니다.
하지만 일급 컬렉션을 사용하여 네이버 페이 총 금액을 뽑으려면 이렇게 해야한다는 계산식을 컬렉션과 함께 둔다면 문제는 간편해집니다.
public class PayGroups {
private List<Pay> pays;
public PayGroups(List<Pay> pays) {
this.pays = pays;
}
public Long getNaverPaySum() {
return pays.stream()
.filter(pay -> PayType.isNaverPay(pay.getPayType()))
.mapToLong(Pay::getAmount)
.sum();
}
}
다른 결제 수단들의 합이 필요하다면 컬렉션을 활용한 람다식으로 리팩토링도 가능합니다.
public class PayGroups {
private List<Pay> pays;
public PayGroups(List<Pay> pays) {
this.pays = pays;
}
public Long getNaverPaySum() {
return getFilteredPays(pay -> PayType.isNaverPay(pay.getPayType()));
}
public Long getKakaoPaySum() {
return getFilteredPays(pay -> PayType.isKakaoPay(pay.getPayType()));
}
private Long getFilteredPays(Predicate<Pay> predicate) {
return pays.stream()
.filter(predicate)
.mapToLong(Pay::getAmount)
.sum();
}
}
이렇게 된다면 일급 컬렉션의 소개와 써야할 이유의 예제와 같이 상태와 로직이 한 곳에서 관리됨을 확인할 수 있습니다.
일급 컬렉션에 대해 조사하던 중, 일급 컬렉션을 사용하는 이유 게시글을 통해 새로운 부분을 배울 수 있어 정리하게 되었습니다.
개발자를 희망하는 학생으로서, 레퍼런스를 볼 때 끊임없이 질문을 던지고 새로운 관점으로 보려고 시도해야 한다는 것을 느낄 수 있었던 글이었습니다 :)
해당 글에선, 기존 '일급 컬렉션의 장점'으로 주로 언급이 되던 컬렉션의 불변성 보장에 대해 이렇게 말합니다. 일급 컬렉션은 불변성을 보장하지 않고 + 보장하도록 구현할 필요가 없다.
뿐만 아니라 보장하려면 어떻게 해야하는지에 대한 내용도 포함되어 있습니다.
"일급 컬렉션" 개념이 처음 등장한 객체지향 체조에서는 일급 컬렉션의 이점이 불변이라는 언급을 하지 않고 있습니다.
다음과 같은 일급 컬렉션이 있다고 생각해봅시다.
public class Lotto{
private final List<LottoNumber> lotto;
public List<LottoNumber> getLotto(){
return lotto;}
}
해당 코드는 private 컬렉션에 대한 setter를 구현하지 않아 불변처럼 보일 수 있지만, setter를 사용하지 않아도 lotto
에 변화를 줄 수 있음에 불변하지 않습니다.
public class Lotto {
private final List<LottoNumber> lotto;
public Lotto(List<LottoNumber> lotto) {
this.lotto = lotto;
}
public List<LottoNumber> getLotto() {
return lotto;
}
}
public class LottoNumber {
private final int lottoNumber;
public LottoNumber(int lottoNumber) {
this.lottoNumber = lottoNumber;
}
}
}
위와 같이 구현되어있을 때
@Test
public void 변화_테스트() {
List<LottoNumber> lottoNumbers = new ArrayList<>();
lottoNumbers.add(new LottoNumber(1));
Lotto lotto = new Lotto(lottoNumbers);
lottoNumbers.add(new LottoNumber(2));
}
이런 상황이면 lotto를 get했을 때 [LottoNumber{lottoNumber=1}, LottoNumber{lottoNumber=2}]
가 모두 조회됩니다.
lottoNumbers와 Lotto 클래스 내부의 lotto 멤버 변수 주소값이 같기 때문에 해당 결과가 발생됩니다.
Lotto class의 멤버 변수인 lotto가 파라미터로 받은 lottoNumbers의 영향을 받지 않기 위해선 멤버변수에 저장되는 주소값을 재할당하는 아래의 코드로 변경하면 됩니다.
public class Lotto {
private final List<LottoNumber> lotto;
public Lotto(List<LottoNumber> lotto) {
this.lotto = new ArrayList<>(lotto);
}
public List<LottoNumber> getLotto() {
return lotto;
}
}
하지만 이렇게 구현을 완료하더라도
@Test
public void lotto_변화_테스트() {
List<LottoNumber> lottoNumbers = new ArrayList<>();
lottoNumbers.add(new LottoNumber(1));
Lotto lotto = new Lotto(lottoNumbers);
lotto.getLotto().add(new LottoNumber(2));
}
하면 마찬가지로 [LottoNumber{lottoNumber=1}, LottoNumber{lottoNumber=2}]
가 나오게 된다.
이를 해결하기 위해 unmodifiableList_를 사용한다고 한다.
public class Lotto {
private final List<LottoNumber> lotto;
public Lotto(List<LottoNumber> lotto) {
this.lotto = new ArrayList<>(lotto);
}
public List<LottoNumber> getLotto() {
return Collections.unmodifiableList(lotto);
}
}