일급 컬렉션(First Class Collection)의 소개, 써야할 이유

728x90

최근에 List 타입이나 Map의 반환타입을 가진 메서드나 혹은 필드를 일급컬렉션으로 대체해보라는 코멘트를 받았다. 

일급 컬렉션.... 일급컬렉션 ... 분명 들어본...것 같은데? 

뭐였지... 뭐였더라? 

다소 미화된 필자의 모습

이런 생소한(?), 혹은 알듯 말듯한 용어가 나올때마다 당황스럽지만 우리에게는 구몬 아니 구글 선생님이 계신다!

오픈카톡방에서 조졸두님이라 불리우는 동욱님의 블로그에 '일급 컬렉션'에서 원하는 내용이 있었다. 그것도 자세히! 

간단하게 정리해보면서 내 것으로 만들어 보려고 한다. (오늘도 동욱님의 공유지식에 감사함을 느끼며.... 정리를 시작해보자)


일급 컬렉션은 나뿐만 아니라 대부분의 개발자들에게 쉽지 않은 개념이였던 것 같다. 

일급 컬렉션은 객체지향적으로, 리팩토링하기 쉬운 코드를 만들기 위해 필요하다. 먼저 이 일급 컬렉션의 목적을 상기시키고 시작해보자! 

일급 컬렉션이라는 용어는 '소트웍스 앤솔로지'라는 서적의 객체지향 생활체조 파트에서 언급되었다.

규칙 8: 일급 콜렉션 사용 
이 규칙의 적용은 간단하다. 
콜렉션을 포함한 클래스는 반드시 다른 멤버 변수가 없어야 한다. 
각 콜렉션은 그 자체로 포장돼 있으므로 이제 콜렉션과 관련된 동작은 근거지가 마련된셈이다.
필터가 이 새 클래스의 일부가 됨을 알 수 있다.
필터는 또한 스스로 함수 객체가 될 수 있다.
또한 새 클래스는 두 그룹을 같이 묶는다든가 그룹의 각 원소에 규칙을 적용하는 등의 동작을 처리할 수 있다.
이는 인스턴스 변수에 대한 규칙의 확실한 확장이지만 그 자체를 위해서도 중요하다.
콜렉션은 실로 매우 유용한 원시 타입이다.
많은 동작이 있지만 후임 프로그래머나 유지보수 담당자에 의미적 의도나 단초는 거의 없다.

간단하게 설하면 아래의 코드를

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 GameRank(Map<String, String> ranks) {
        this.ranks = ranks;
    }
}

Collection을 Wrapping하면서, 그 외 다른 변수가 없는 상태일급 컬렉션이라 한다. 
Wrapping 함으로써 다음과 같은 이점을 가지게 된다. 

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

이제부터 하나 하나 소개를 해나간다.

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

예를 들어 다음과 같은 조건으로 로또 복권 게임을 만든다고 가정하자.

로또 복권은 아래의 조건을 가진다.

  • 6개의 번호가 존재 (보너스 번호는 이번 예제에서 제외하겠다)
  • 6개의 번호는 서로 중복되지 않아야 함

일반적으로 이런 일은 서비스 메서드에서 진행한다.
그래서 구현을 해보면 아래처럼 된다. 

public class LottoService {
    private static final int LOTTO_NUMBERS_SIZE = 6;
    
    public void createLottoNumber() {
        List<Long> lottoNumbers = createNonDuplicateNumbers();
        validateSize(lottoNumbers);
        validateDuplicate(lottoNumbers);
        
        //이후 로직 실행
    }
    
    private void validateSize(List<Long> lottoNumbers) {
        if (lottoNumbers.size() != LOTTO_NUMBERS_SIZE) {
            throw new IllegalArgumentException("로또 번호는 6개만 가능합니다");
        }
    }
    
    private void validateDuplicate(List<Long> lottoNumbers) {
        Set<Long> nonDuplicateNumbers = new HashSet<>(lottoNumbers);
        if(nonDuplicateNumbers.size() != LOTTO_NUMBERS_SIZE) {
            throw new IllegalArgumentException("로또 번호들은 중복될 수 없습니다");
        }
    }
    ....
    

서비스 메서드(= createLottoNumber 메서드)에서 비즈니스 로직을 처리했다. 
이럴 경우 큰 문제가 있는데.

로또 번호가 필요한 모든 장소에선 검증로직이 들어가야만 한다. 

  • List<Long> 으로 된 데이터는 모두 검증 로직이 필요할까?
  • 신규 입사자분들은 어떻게 이 검증로직이 필요한지 알 수 있을까?

등등 모든 코드와 도메인을 알고 있지 않다면 언제든 문제가 발생할 여지가 있다. 

그렇다면 이 문제를 어떻게 깔끔하게 해결할 수 있을까? 

  • 6개의 숫자로만 이루어져야만 하고
  • 6개의 숫자는 서로 중복되지 않아야만 하는

이런 자료구조가 없을까? 
없으면 우리가 직접 만들면 된다.

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

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

public class LottoTicket {
    private static final int LOTTO_NUMBERS_SIZE = 6;
    
    private final List<Long> lottoNumbers;
    
    public LottoTicket(List<Long> lottoNumbers) {
        validateSize(lottoNumbers);
        validateDuplicate(lottoNumbers);
        this.lottoNumbers = lottoNumbers;
    }
    
    private void validateSize(List<Long> lottoNumbers) {
        if (lottoNumbers.size() != LOTTO_NUMBERS_SIZE) {
            throw new IllegalArgumentException("로또 번호는 6개만 가능합니다");
        }
    }
    
    private void validateDuplicate(List<Long> lottoNumbers) {
        Set<Long> nonDuplicateNumbers = new HashSet<>(lottoNumbers);
        if(nonDuplicateNumbers.size() != LOTTO_NUMBERS_SIZE) {
            throw new IllegalArgumentException("로또 번호들은 중복될 수 없습니다.");
        }
    }
}

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

public class LottoService2 {
    public void createLottoNumber() {
        LottoTicket lottoTicket = new LottoTicket(createNonDuplicateNumbers());
        // 이후 로직 쭉쭉 실행 
    }

 

2. 불변

일급 컬렉션은 컬렉션의 불변을 보장한다. 

여기서  final 을 사용하면 안되는가? 라는 질문을 많이 한다. 
하지만 똑바로 알아야 하는 것이 있는데, Java의  final 은 정확히는 불변을 만들어주는 것은 아니며, 재할당만 금지한다.

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

@Test
public void final도_값변경이_가능하다() {
    //given
    final 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으로 선언되었음에도 값이 변경될 수 있다는 것이다. 

예로들어 위의 코드의 //when 부분만 아래의 코드와 같이 변경된다면

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

컴파일 에러가 발생한다. 

final로 할당된 코드에 재할당 할 수는 없기 때문이다.

지금까지 본 것처럼Java의 final은 재할당만 금지한다. 
이외에도 member.setAge(10) 과 같은 코드 역시ㅣ 작동해버리기 반쪽짜리라 할 수 있다. 

요즘과 같이 소프트웨어 규모가 커지고 있는 상황에서 불변 객체는 아주 중요하다.
각각의 객체들이 절대 값이 바뀔일이 없다는게 보장되면 그만큼 코드를 이해하고 수정하는데 사이드 이펙트가 최소화되기 때문이다. 

Java에서는 final로 그 문제를 해결할 수 없기 때문에 일급 컬렉션(First Class Collection)과 래퍼 클래스 (Wrapper Class)등의 방법으로 해결해야만 한다. 

그래서 아래와 같이 컬렉션의 값을 변경할 수 있는 메서드가 없는 컬렉션을 만들면 불변 컬렉션이 된다. 

public class Orders {
    private final List<Order> orders;
    
    public Orders(List<Order> orders) {
        this.orders = orders;
    }
    
    public long getAmountSum() {
        return orders.stream()
                     .mapToLong(Order::getAmount)
                     .sum();
    }
}

이 클래스는 생성자와 getAmountSum() 외에 다른 메서드가 없다.
즉, 이 클래스의 사용법은 새로 만들거나 값을 가져오는 것뿐이다.
List라는 컬렉션에 접근할 수 있는 방법이 없기 때문에 값을 변경/추가 할 수 없다.

이렇게 일급 컬렉션을 사용하면, 불변 컬렉션을 만들 수 있다. 

(불변성은 항상 보장되는 것이 아니라, 선택적인 요소라는 점은 조심하자👀)

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

일급 컬렉션의 세번째 장점은 값과 로직이 함께 존재한다는 것이다. (이 부분은 Enum의 장점과도 같다)

예를 들어 여러 Pay들이 모여있고, 이 중 NaverPay 금액의 합이 필요하다고 가정해보자.
일반적으로 아래와 같이 작성한다. 

@Test
public void 로직이_밖에_있는_상태() {
    //given
    List<Pay> pays = Arrays.asList(   //값이 있는 곳
        new Pay(NAVER_PAY, 1000L),
        new Pay(NAVER_PAY, 1500L),
        new Pay(KAKAO_PAY, 2000L),
        new Pay(TOSS, 3000L));
        
     //when
     Long naverPaySum = pays.stream()   // 계산은 여기서
             .filter(pay -> pay.getPayType().equals(NAVER_PAY))
             .mapToLong(Pay::getAmount)
             .sum();
             
     //then
     assertThat(naverSum).isEqualtTo(2500L);
}
  • List에 데이터를 담고
  • Service 혹은 Util 클래스에서 필요한 로직 수행 

이 상황에서는 문제가 있다.
결국   pays   라는 컬렉션과 계산 로직은 서로 관계가 있는데, 이를 코드로 표현이 안된다.

Pay타입의 상태에 따라 지정된 메서드에서만 계산되길 원하는데, 현재 상태로는 강제할 수 있는 수단이 없다. 
지금은 Pay타입의 List라면 사용될 수 있기 때문에 히스토리를 모르는 분들이라면 실수할 여지가 많다.

  • 똑같은 기능을 하는 메서드를 중복 생성할 수 있다.
    히스토리가 관리 안 된 상태에서 신규화면이 추가되어야 할 경우 계산 메서드가 있다는 것을 몰라 다시 만드는 경우가 빈번하다.

    만약 기존 화면의 계산 로직이 변경 될 경우, 신규 인력은 2개의 메서드의 로직을 다 변경해야하는지, 해당 화면만 변경해야하는지 알 수 없다. 

    관리 포인트가 증가할 확률이 매우 높다.
  • 계산 메서드를 누락할 수 있다. 
    리턴 받고자 하는 것이 Long 타입의 값이기 때문에 이 계산식을 써야한다고 강제할 수 없다.

결국에 네이버페이 총 금액을 뽑을려면 이렇게 해야한다는 계산식을 컬렉션과 함께 두어야 한다.
만약 네이버페이 외에 카카오 페이의 총 금액도 필요하다면 더더욱 코드가 흩어질 확률이 높다.

그래서 이 문제 역시 일급 컬렉션으로 해결한다.

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

이렇게 PayGroups라는 일급 컬렉션이 생김으로서 상태와 로직이 한 곳에서 관리 된다. 

4. 이름이 있는 컬렉션

마지막 장점은 컬렉션에 이름을 붙일 수 있다는 것이다.
같은 Pay들의 모임이지만 네이버페이의 List와 카카오페이의 List는 다르다. 
그렇다면 이 둘을 구분하려면 어떻게 해야할까? 
가장 흔한 방법은 변수명을 다르게 하는 것이다. 

@Test
public void 컬렉션을_변수명으로() {
    //given
    List<Pay> naverPays = createNaverPays();
    List<Pay> kakaoPays = createKakaoPays();
    
    //when
    
    //then
    ...
}

위 코드의 단점이 뭘까? 

  • 검색이 어렵다

    네이버페이 그룹이 어떻게 사용되는지 검색 시 변수명으로만 검색할 수 있다.

    이 상황에서 검색은 거의 불가능하다.

    네이버페이의 그룹이라는 뜻은 개발자마다 다르게 지을 수 있기 때문이다. 
  • 명확한 표현이 불가능하다

    변수명에 불과하기 때문에 의미 부여가 어렵다.


    이는 개발팀 / 운영팀간에 의사소통시 보편적인 언어로 사용하기가 어려움을 의미한다.

    중요한 값임에도 이를 표현할 명확한 단어가 없는 것이다. 

위 문제 역시 일급 컬렉션으로 쉽게 해결할 수 있다. 

네이버페이 그룹과 카카오페이 그룹 각각의 일급 컬렉션을 만들어 이 컬렉션 기반으로 용어사용과 검색을 하면 된다. 

@Test
public void 일급컬렉션의_이름으로() {
    //given
    NaverPays naverPays = new NaverPays(createNaverPays());
    
    KakaoPays kakaoPays = new KakaoPays(createKakaoPays());
    
    //when
    
    //then
}

개발팀 / 운영팀 내에서 사용될 표현은 이제 이 컬렉션에 맞추면 된다. 
검색 역시 이 컬렉션 클래스를 검색하면 모든 사용 코드를 찾아낼 수 있다. 

 


마무리하며.....

보통 다른 블로그에서 글을 읽어도 나만의 방식으로 많이 바꿔서 적는 편인데(원래 그러는게 맞는건가?
동욱님 블로그 설명이 워낙 훌륭해서 그런것도 있고, 내가 일급 컬렉션에 대해 아는게 전혀 없었기 때문에 예를 들거나하는 부분에 더 창의성을 발휘 하기 어려웠던게 아닌가 싶다. 

나의 글이 읽기 힘들다면.... "기억보단 기록을" 블로그에서 일급 컬렉션에 대해 정리해놓은 글을 보라! 

 

 

 

 

 

728x90

'프로그래밍 공부 > Java' 카테고리의 다른 글

JDK 21, Virtual Thread  (1) 2024.03.17
Java - Comparable vs Comparator  (0) 2020.08.03
J2SE,J2EE의 차이점  (0) 2020.03.14
Java - extends, implements, abstract 차이  (0) 2020.01.13
Java - Equals, Hashcode 메소드  (3) 2019.12.15