기본 콘텐츠로 건너뛰기

자바에서 일급 컬렉션을 쓰는 이유: List를 감싸면 설계가 어떻게 달라질까

자바에서 일급 컬렉션을 쓰는 이유: List를 감싸면 설계가 어떻게 달라질까

빠른 답

  • 컬렉션에 규칙과 의미가 붙기 시작하면 List<Order>보다 Orders처럼 이름 있는 타입이 의도를 더 분명하게 드러냅니다.
  • 검증은 생성 시점과 변경 메서드 안으로 모으고, 외부에는 add(), totalAmount()처럼 목적이 보이는 연산만 열어두는 쪽이 흐름을 읽기 쉽습니다.
  • 외부에 컬렉션을 그대로 노출하면 상태가 예상 밖에서 바뀌기 쉬워서, 읽기 전용 뷰나 복사본으로 경계를 분리하는 편이 도움이 됩니다.
  • 다만 단순 전달용 목록까지 모두 감쌀 필요는 없고, 중복 검사·합계 계산·정렬 정책처럼 컬렉션 자체의 규칙이 생길 때 도입하는 편이 효과적입니다.

한눈에 비교

표현 대상
List<Order> 는 저장 구조를 보여주고, Orders 는 도메인 안에서 어떤 주문 묶음인지 의미를 함께 드러냅니다.
책임 위치
List<Order> 를 직접 쓰면 검증과 계산이 서비스나 유틸로 흩어지고, 일급 컬렉션을 쓰면 관련 규칙을 한 타입 안에 모을 수 있습니다.
변경 방식
List 는 add , remove , clear 같은 범용 API가 열려 있지만, Orders 는 허용할 변경만 메서드로 제한할 수 있습니다.
검증 시점
raw 컬렉션은 사용할 때마다 방어 코드가 따라붙기 쉽고, 일급 컬렉션은 생성 시점과 변경 시점에 규칙을 일관되게 적용하기 쉽습니다.
외부 노출
List 를 그대로 반환하면 내부 상태가 새기 쉽지만, 일급 컬렉션은 읽기 전용 목록, 개수, 합계처럼 필요한 표면만 제공할 수 있습니다.
테스트 대상
서비스 테스트에서 목록 규칙까지 함께 검증하는 대신, 일급 컬렉션 단위로 규칙을 직접 테스트하기 쉬워집니다.

왜 그냥 List 로는 도메인 규칙이 흩어질까

List<Order>는 자료구조로는 충분하지만, 그 목록이 어떤 성격을 가진 값인지는 알려주지 못합니다. 이 목록이 중복 주문 번호를 허용하지 않는지, 음수 금액 주문을 담을 수 없는지, 합계를 자주 계산해야 하는지 같은 규칙은 타입에서 드러나지 않습니다. 그래서 규칙은 자연스럽게 서비스 메서드, 컨트롤러, 매퍼, 유틸리티 코드로 분산됩니다.

처음에는 단순한 null 검사 정도로 시작해도, 기능이 늘어나면 중복 검사, 최대 개수 제한, 정렬 정책, 통계 계산이 여기저기 붙습니다. 문제는 컬렉션을 다루는 코드가 많아질수록 "어디가 진짜 규칙의 기준점인지" 찾기 어려워진다는 점입니다. 같은 목록을 다루는데 어떤 메서드는 중복을 막고, 어떤 메서드는 그냥 추가해 버리면 상태 일관성이 깨지기 쉽습니다.

아래 코드는 흔히 보이는 형태입니다. 동작은 하지만, 주문 목록의 정책이 서비스 안으로 들어와 있습니다.

public class OrderService {

    public void addOrder(List<Order> orders, Order newOrder) {
        if (newOrder == null) {
            throw new IllegalArgumentException("order is null");
        }

        boolean duplicated = orders.stream()
                .anyMatch(order -> order.orderNumber().equals(newOrder.orderNumber()));

        if (duplicated) {
            throw new IllegalArgumentException("duplicate order number");
        }

        orders.add(newOrder);
    }

    public BigDecimal totalAmount(List<Order> orders) {
        return orders.stream()
                .map(Order::amount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

이 구조에서는 OrderService가 흐름 조합과 목록 규칙 관리를 동시에 맡습니다. 서비스가 커질수록 코드가 길어지고, 같은 조건문이 다른 서비스에도 복제되기 쉽습니다. 컬렉션이 도메인 규칙을 담고 있다면, 그 규칙은 컬렉션을 소유한 타입 쪽으로 옮기는 편이 더 읽기 쉬운 구조가 됩니다.

일급 컬렉션은 무엇을 바꾸는가

일급 컬렉션은 자바의 공식 문법이 아니라 설계 패턴에 가깝습니다. 보통은 컬렉션 하나를 감싸는 클래스를 만들고, 그 컬렉션과 관련된 비즈니스 규칙과 파생 연산을 그 클래스가 책임지게 하는 방식을 말합니다.

여기서 중요한 것은 "감쌌다"는 모양보다 "책임이 이동했다"는 사실입니다. List<Order>Orders로 바꾸면 다음과 같은 변화가 생깁니다.

  • 저장 구조보다 도메인 의미가 먼저 보입니다.
  • 허용할 변경과 금지할 변경을 직접 정할 수 있습니다.
  • 합계 계산, 중복 검사, 개수 제한 같은 규칙이 호출자 밖으로 새지 않습니다.
  • 테스트도 Orders 자체의 규칙 중심으로 더 짧게 쓸 수 있습니다.

이 패턴은 컬렉션을 객체처럼 다룬다는 점에서 값 객체와도 닮아 있습니다. 다만 둘은 완전히 같은 개념은 아닙니다. 값 객체는 값의 동일성과 불변성에 더 초점이 있고, 일급 컬렉션은 컬렉션과 그 주변 규칙의 캡슐화에 더 초점이 있습니다. 실무에서는 둘이 함께 쓰이는 경우가 많습니다.

실전 코드로 보는 Orders 구현

주문 금액처럼 계산이 자주 따라오는 값은 double보다 BigDecimal이 더 어울립니다. 금액 오차를 줄이고 의도를 더 분명히 표현할 수 있기 때문입니다. 아래 예시는 OrderOrders를 분리하고, 생성 검증과 읽기 전용 노출을 함께 담은 기본 형태입니다.

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public record Order(String orderNumber, BigDecimal amount) {

    public Order {
        Objects.requireNonNull(orderNumber, "orderNumber");
        Objects.requireNonNull(amount, "amount");

        if (amount.signum() < 0) {
            throw new IllegalArgumentException("amount must be zero or positive");
        }
    }
}

public final class Orders {
    private final List<Order> values;

    public Orders(List<Order> orders) {
        Objects.requireNonNull(orders, "orders");

        List<Order> copied = new ArrayList<>(orders);
        validateNoNull(copied);
        validateNoDuplicateOrderNumber(copied);

        this.values = copied;
    }

    public void add(Order order) {
        Objects.requireNonNull(order, "order");

        if (contains(order.orderNumber())) {
            throw new IllegalArgumentException(
                    "duplicate order number: " + order.orderNumber()
            );
        }

        values.add(order);
    }

    public BigDecimal totalAmount() {
        return values.stream()
                .map(Order::amount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public int size() {
        return values.size();
    }

    public List<Order> items() {
        return List.copyOf(values);
    }

    private boolean contains(String orderNumber) {
        return values.stream()
                .anyMatch(order -> order.orderNumber().equals(orderNumber));
    }

    private void validateNoNull(List<Order> orders) {
        if (orders.stream().anyMatch(Objects::isNull)) {
            throw new IllegalArgumentException("orders must not contain null");
        }
    }

    private void validateNoDuplicateOrderNumber(List<Order> orders) {
        long distinctCount = orders.stream()
                .map(Order::orderNumber)
                .distinct()
                .count();

        if (distinctCount != orders.size()) {
            throw new IllegalArgumentException("duplicate order number exists");
        }
    }
}

이 코드에서 중요한 부분은 세 가지입니다.

첫째, 생성자에서 전달받은 목록을 그대로 저장하지 않고 복사합니다. 원본 리스트 참조를 들고 있으면, 호출한 쪽에서 나중에 목록을 바꿔도 내부 상태가 함께 흔들립니다.

둘째, 목록 자체의 유효성 검사를 생성 시점에 끝냅니다. null 원소가 없는지, 주문 번호가 중복되지 않는지 같은 조건은 Orders가 책임집니다.

셋째, 외부에는 items()로 복사본만 내보냅니다. 내부 목록을 직접 수정하는 통로를 열어두지 않기 위해서입니다.

생성 검증, 변경 통제, 계산을 한곳에 모으는 방식

일급 컬렉션의 장점은 컬렉션과 관련된 판단을 한 군데에 모은다는 데 있습니다. 보통은 다음 세 종류의 책임이 함께 움직입니다.

  • 생성 검증: 애초에 유효한 목록인지 확인합니다.
  • 변경 통제: 어떤 연산만 허용할지 정합니다.
  • 파생 계산: 합계, 개수, 필터링 결과 같은 값을 계산합니다.

이 세 가지가 흩어지면 서비스 계층이 비대해지기 쉽습니다. 반대로 Orders 안으로 모으면 "주문 목록에 대한 규칙은 여기서 본다"는 기준이 생깁니다. 코드 읽는 사람도 더 적은 파일을 오가게 됩니다.

서비스 계층은 이때 사라지는 것이 아니라 역할이 정리됩니다. 서비스는 트랜잭션, 저장소 호출, 외부 시스템 연동, 응답 조립 같은 흐름을 맡고, 목록 자체의 규칙은 도메인 타입이 맡는 식입니다.

public class OrderApplicationService {
    private final OrderRepository orderRepository;

    public OrderSummary register(OrderCommand command) {
        Orders orders = orderRepository.findByCustomerId(command.customerId());

        orders.add(new Order(command.orderNumber(), command.amount()));
        orderRepository.save(command.customerId(), orders);

        return new OrderSummary(
                orders.size(),
                orders.totalAmount(),
                orders.items()
        );
    }
}

이 구조에서는 중복 주문 번호 검사나 합계 계산 로직이 서비스에 들어가지 않습니다. 서비스는 순서를 조합하고, 도메인 타입은 상태 규칙을 지키는 방식으로 경계가 나뉩니다.

읽기 전용 뷰와 복사본은 다르다

컬렉션을 감쌀 때 자주 놓치는 부분이 있습니다. Collections.unmodifiableList()는 수정 불가능한 뷰를 돌려주지만, 원본 리스트가 다른 경로에서 바뀌면 그 변화는 그대로 반영됩니다. 반면 List.copyOf()는 현재 시점의 스냅샷에 더 가깝습니다.

List<Order> source = new ArrayList<>();
source.add(new Order("A-001", new BigDecimal("12000")));

List<Order> view = Collections.unmodifiableList(source);
List<Order> snapshot = List.copyOf(source);

source.add(new Order("A-002", new BigDecimal("5000")));

System.out.println(view.size());     // 2
System.out.println(snapshot.size()); // 1

이 차이는 운영 중 버그로 이어질 수 있습니다. 외부에 "읽기 전용으로 줬다"고 생각했는데, 실제로는 내부 상태가 다른 경로에서 바뀌어 조회 결과가 달라질 수 있기 때문입니다.

물론 List.copyOf()도 리스트 컨테이너를 고정할 뿐, 원소가 가변 객체라면 원소 내부 상태까지 막아주지는 않습니다. 그래서 불변성을 강하게 가져가려면 원소 타입도 값 객체처럼 설계하는 편이 좋습니다. 위 예시에서 Orderrecord로 둔 이유도 이 방향과 잘 맞습니다.

언제 쓰면 도움이 되고, 언제는 과할 수 있을까

모든 컬렉션을 일급 컬렉션으로 만들 필요는 없습니다. 규칙과 의미가 뚜렷한 컬렉션이라면 효과가 크지만, 단순 전달용 데이터까지 감싸면 타입만 늘고 이점은 크지 않을 수 있습니다.

도입을 검토해볼 만한 경우는 보통 이렇습니다.

  • 목록에 중복 금지, 최대 개수, 정렬 정책 같은 불변 조건이 있습니다.
  • 합계, 평균, 검색, 그룹화처럼 컬렉션 중심 연산이 자주 나옵니다.
  • 같은 방어 코드가 서비스 여러 곳에 반복됩니다.
  • Orders, LineItems, Participants처럼 이름 자체가 도메인 의미를 가집니다.

반대로 다음 상황에서는 굳이 감싸지 않아도 괜찮습니다.

  • 외부 API 요청이나 응답 DTO처럼 전달만 하는 목록입니다.
  • 한 메서드 안에서 잠깐 생성하고 바로 소비하는 임시 컬렉션입니다.
  • 도메인 규칙보다 단순한 데이터 변환이 목적입니다.

즉, 질문은 "이 컬렉션이 단순한 저장 구조인가"보다 "이 컬렉션이 규칙을 가진 도메인 상태인가"에 가깝습니다. 후자라면 이름 있는 타입으로 끌어올렸을 때 얻는 이점이 분명해집니다.

구현할 때 함께 보는 구조 예시

일급 컬렉션은 단일 클래스 기법처럼 보이지만, 실제로는 패키지 경계와도 잘 맞물립니다. 컬렉션 규칙이 도메인 쪽에 머물도록 두면 애플리케이션 서비스가 불필요하게 비대해지는 것을 줄일 수 있습니다.

com.example.order
├── application
│   └── OrderApplicationService.java
├── domain
│   ├── Order.java
│   ├── Orders.java
│   └── OrderRepository.java
└── api
    └── OrderSummary.java

이런 구조에서는 application이 흐름을 조합하고, domain이 상태 규칙을 담당합니다. 단순히 폴더를 나누는 문제가 아니라, 어떤 코드가 어떤 규칙의 주인인지 드러나는 구성이 됩니다.

자주 나오는 오해와 주의점

일급 컬렉션을 도입했다고 해서 자동으로 설계가 좋아지는 것은 아닙니다. 몇 가지 흔한 오해는 미리 구분해 둘 만합니다.

첫째, 감싸기만 하면 충분하다고 보기 쉽습니다. 예를 들어 record Orders(List<Order> values) {}처럼 선언하고 values()를 그대로 노출하면, 실제로는 raw List를 다른 이름으로 감싼 것에 가깝습니다. 규칙과 연산이 바깥으로 새면 장점이 줄어듭니다.

둘째, 래퍼 클래스가 단순 위임만 하게 되는 경우가 있습니다. size(), get(), stream()만 그대로 노출하고 도메인 메서드가 없다면 타입은 늘었는데 설계 중심은 여전히 List에 남습니다.

셋째, 영속성 프레임워크나 직렬화 경계에서는 형태를 조금 조심해서 잡아야 합니다. 예를 들어 ORM이 컬렉션 필드를 직접 추적하는 방식이라면, 내부 불변성과 프레임워크 제약이 충돌할 수 있습니다. 이런 경우에는 도메인 내부에서는 일급 컬렉션을 쓰고, 영속화 계층이나 DTO 변환 지점에서만 raw 컬렉션으로 풀어내는 식으로 경계를 분리하는 편이 낫습니다.

넷째, 컬렉션 성능 특성을 함께 봐야 합니다. 주문 번호 중복 검사가 잦다면 List 기반 선형 탐색이 충분한지, 아니면 내부적으로 Set이나 인덱싱 전략이 더 어울리는지도 함께 판단할 수 있습니다. 일급 컬렉션의 목적은 자료구조를 숨기는 것이지, 자료구조 선택을 무시하는 것은 아닙니다.

마무리

일급 컬렉션은 List를 더 멋지게 감싸는 문법이 아니라, 컬렉션에 붙은 규칙과 의미를 어디에 둘 것인지에 대한 선택입니다. 컬렉션이 도메인 상태로 다뤄져야 할 때는 이름 있는 타입으로 올려두는 편이 검증 위치, 변경 경로, 계산 책임을 더 또렷하게 만듭니다.

반대로 규칙이 없는 전달용 목록이라면 그대로 두는 편이 단순합니다. 결국 중요한 기준은 컬렉션이 단순한 그릇인지, 아니면 도메인 규칙의 소유자인지입니다. 그 경계가 보이기 시작할 때 일급 컬렉션은 꽤 실용적인 도구가 됩니다.

원문 참고

https://www.maeil-mail.kr/question/53

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...