본문 바로가기

Hub Development/Java

[Java] 일급 컬렉션 (First Class Collection)의 사용

728x90

📌 일급 컬렉션 (First Class Collection)


🔹우테코 프리코스 미션을 진행하며 학습했던 일급 컬렉션 내용에 대해 정리하고자 한다. 내가 service로 구현해야겠다고 생각하는 기준은 비즈니스 로직을 갖고 있는 즉, 조건과 행위에 대한 내용을 service로 구현하려고 했다. 하지만 로또 미션에서는 조건까지 service에서 작성하게 되는 경우 로또 번호가 필요한 모든 부분에서 service의 조건을 검증하는 로직 (validateRange, validateDuplicate)이 들어가야 하는 문제점이 발생하는데 이를 자료구조로 직접 만든다면 발생할 문제를 최소화시킬 수 있다는 것을 알게 되었다. → 이러한 클래스를 일급 컬렉션이라고 한다.

 

이 규칙의 적용은 간단하다.

일급 컬렉션(First-Class Collection)은 상태와 행위를 함께 캡슐화하는데 주로 컬렉션을 사용하는 디자인 패턴이다. 일급 컬렉션은 컬렉션을 하나의 객체로 취급하여 해당 컬렉션과 관련된 동작을 통합하고, 높은 응집도와 캡슐화를 통해 코드의 가독성과 유지보수성을 향상시키는 데 도움이 된다.

 

아래의 코드를 예시로 들자면

Map<String, String> map = new HashMap<>();
map.put("1", "A");
map.put("2", "B");
map.put("3", "C");

아래와 같이 Wrapping 하는 것을 얘기한다.

public class GameRanking {

    private Map<String, String> ranks;

    public GameRanking(Map<String, String> ranks) {
        this.ranks = ranks;
    }
}

Collection을 Wrapping하면서, 그 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라 한다.

Wrapping 함으로써 다음과 같은 이점을 가지게 된다.

  1. 비즈니스에 종속적인 자료구조
  2. Collection의 불변성을 보장
  3. 상태와 행위를 한 곳에서 관리
  4. 이름이 있는 컬렉션

📑 1. 비지니스에 종속적인 자료구조


🔹프리코스의 로또 미션 조건을 예시로 들자면,

 

6개의 번호가 존재

    - 보너스 번호는 이번 예제에서 제외한다.

6개의 번호는 서로 중복되지 않아야 함

 

이런 조건을 서비스 메서드에서 구현을 해보면 아래처럼 된다.

package lotto.domain.wrapper;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class LottoService {
    private final int LottoSize = 6;

    public static Lotto create() {
    	List<Integer> numbers = input();
        
        validateSize(numbers);
        validateDuplicate(numbers);
        validateRange(numbers);
        
        // 생략
    }

    public static Lotto matchLotto() {
    	List<Integer> numbers = input();
        
        validateSize(numbers);
        validateDuplicate(numbers);
        validateRange(numbers);
        
        for (Lotto buyLotto : buyLottos.getBuyLottos()) {
            // 생략
        }
    }

    private void validateSize(List<Integer> numbers) {
        if (numbers.size() != LOTTO_SIZE) {
            throw INVALID_SIZE.getException();
        }
    }

    private void validateDuplicate(List<Integer> numbers) {
        Set<Integer> uniqueNumbers = new HashSet<>(numbers);

        if (uniqueNumbers.size() != numbers.size()) {
            throw DUPLICATE_NUMBER.getException();
        }
    }

    private void validateRange(List<Integer> numbers) {
        for (int number : numbers) {
            if (number < MIN_LOTTO_NUMBER || number > MAX_LOTTO_NUMBER) {
                throw INVALID_RANGE.getException();
            }
        }
    }
}

 

서비스 메서드에서 비즈니스 로직을 처리했다. 이럴 경우 큰 문제가 있는데 로또 번호가 필요한 모든 장소에선 검증로직이 들어가야만 한다는 점이다. 위와 같이 create 메서드와 matchLotto 메서드에서 로또번호를 검증하는 부분이 중복적으로 들어가는 걸 확인할 수 있다. 

 

이렇게 모두 검증을 해야할까? 해결 방법은 없는 걸까? 없다면 직접 만들면 된다.

아래와 같이 해당 조건으로만 생성 할 수 있는 자료구조를 만들면 위에서 언급한 문제들이 모두 해결된다.

그리고 이런 클래스를 일급 컬렉션이라고 부른다.

package lotto.domain.wrapper;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import static lotto.handler.ConstantsHandler.*;
import static lotto.handler.ErrorHandler.*;

public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        validateSize(numbers);
        validateDuplicate(numbers);
        validateRange(numbers);

        this.numbers = numbers;
    }

    private void validateSize(List<Integer> numbers) {
        if (numbers.size() != LOTTO_SIZE) {
            throw INVALID_SIZE.getException();
        }
    }

    private void validateDuplicate(List<Integer> numbers) {
        Set<Integer> uniqueNumbers = new HashSet<>(numbers);

        if (uniqueNumbers.size() != numbers.size()) {
            throw DUPLICATE_NUMBER.getException();
        }
    }

    private void validateRange(List<Integer> numbers) {
        for (int number : numbers) {
            if (number < MIN_LOTTO_NUMBER || number > MAX_LOTTO_NUMBER) {
                throw INVALID_RANGE.getException();
            }
        }
    }
}

이제 로또 번호가 필요한 모든 로직은 이 일급 컬렉션만 있으면 된다.

public class LottoController {

    public void run() {
        Lotto lotto = new Lotto(numbers); // 생성자를 만듦으로써 번호에 대한 검증이 자동으로 이루어진다.
    }
}

비즈니스에 종속적인 자료구조가 만들어져, 이후 발생할 문제가 최소화되었다.

 

🤔 2. 불변


🔹일급 컬렉션은 컬렉션의 불변을 보장한다. 여기서 final을 사용하면 안 되나?라고 생각할 수 있지만.

Java의 final은 정확히는 불변을 만들어주는 것은 아니며, 재할당만 금지하는 것이다.

 

아래 테스트 코드를 참고해 보자.

    @Testpublic void final도_값변경이_가능하다() {
//givenfinal Map<String, Boolean> collection = new HashMap<>();

//when
        collection.put("1", true);
        collection.put("2", true);
        collection.put("3", true);
        collection.put("4", true);

//then
        assertThat(collection.size()).isEqualTo(4);
    }

이를 실행해 보면!

값이 추가 되는 걸을 확인할 수 있다. 이미 collection은 비어있는 HashMap으로 선언되었음에도 값이 변경될 수 있다는 것이다.

 

추가 테스트

    @Testpublic void final은_재할당이_불가능하다() {
//givenfinal Map<String, Boolean> collection = new HashMap<>();

//when
        collection = new HashMap<>();

//then
        assertThat(collection.size()).isEqualTo(4);
    }

이 코드는 바로 컴파일에러가 발생한다.

final로 할당된 코드에 재할당할 순 없기 때문이다. 보이는 것처럼 Java의 final은 재할당만 금지한다.

이외에도 member.setAge(10)과 같은 코드 역시 작동해 버리니 반쪽짜리라 할 수 있다. 요즘과 같이 소프트웨어 규모가 커지고 있는 상황에서 불변 객체는 아주 중요하다. 각각의 객체들이 절대 값이 바뀔 일이 없다는 게 보장되면 그만큼 코드를 이해하고 수정하는데 사이드 이펙트가 최소화되기 때문이다.

 

Java에서는 final로 그 문제를 해결할 수 없기 때문에 일급 컬렉션 (Frist Class Collection)과 래퍼 클래스 (Wrapper Class) 등의 방법으로 해결해야만 한다. 그래서 아래와 같이 컬렉션의 값을 변경할 수 있는 메서드가 없는 컬렉션을 만들면 불변 컬렉션이 된다.

package lotto.domain.wrapper;

import java.util.stream.Collectors;

import static lotto.handler.ConstantsHandler.*;
import static lotto.handler.ErrorHandler.*;

public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        this.numbers = numbers;
    }

    public List<Integer> sortLottoNumbers() {
        return numbers.stream()
                .sorted()
                .collect(Collectors.toList());
    }
}

 

위의 sortLottoNumbers 메서드는 값의 재할당 뿐만이 아니라 컬렉션의 값까지 변경할 수 없는 불변객체를 만들고 있다. 즉, 이 클래스의 사용법은 새로 만들거나 값을 가져오는 것뿐인 것이다. List라는 컬렉션에 접근할 수 있는 방법이 없기 때문에 값을 변경/추가가 안된다. 다른 방법으로는 Collections.unmodifiableList(); 혹은 List.coptOf()를 활용하여 원본의 객체와 전혀 연관이 없는 새로운 객체를 전달하는 방법이 있다.

 

🧪 3. 상태와 행위를 한 곳에서 관리


🔹일급컬렉션의 장점은 값과 로직이 함께 존재할 수 있다는 부분이다.

예를 들어 페이(Pay)들이 모여있고 네이버페이와 카카오페이의 합 로직이 필요하다고 가정해 본다면,

List<Pay> pays = Array.asList(
	new Pay(NAVER_PAY, 1000),
	new Pay(KAKAOPAY, 2000)
);

Long naverPaySum = pays.stream()
	.fileter(pay -> pay.getPayType().equals(NAVER_PAY))
	.sum();Copy

일반적으로 위와 같이 pay 리스트를 선언 후 NAVER_PAY 만을 필터링하여 sum() 연산을 해준다.

이 상황에서는 똑같은 기능을 하는 메서드를 중복생성 할 수 있다는 문제점이 발생한다. 

 

결국 NAVER_PAY의 합계를 구하기 위해서 합계 계산식과 컬렉션을 함께 두어야 한다.

만약 KAKAO_PAY의 합계도 필요하다면 코드가 흝어질 확률이 높아지는데 이를 방지할 수 있다.

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()));
    }
}Copy

 

위와 같이 PayGroups라는 일급컬렉션을 생성하여 상태와 로직을 한 곳에서 관리할 수 있다.

 

🍁 4. 이름이 있는 컬렉션


🔹컬렉션에 이름을 붙일 수 있다.

예를 들어 naver pay와 kakao pay의 리스트는 다른데 이를 구분하기 위해서 가장 흔하게 사용하는 방법은 변수명을 다르게 하는 것이다.

List<Pay> naverPays = createNaverPays();
List<Pay> kakaoPays = createKaKaoPays();

위 코드의 문제점은

  • 네이버페이 그룹이 어떻게 사용되는지 검색하기 위해서 변수명으로만 검색가능하다.
  • 변수명에 불과하기때문에 의미 부여하기 어렵다.

여기에 일급컬렉션을 적용하면 이 컬렉션을 기반으로 용어 사용 및 검색을 하면 된다.

NaverPays naverPays = new NaverPays(createNaverPays());
KaKaoPays kakaoPays = new KaKaoPAys(createKaKaoPays());

 

📸 참조


https://jojoldu.tistory.com/412