자바에서 일급 컬렉션을 쓰는 이유: List를 감싸면 설계가 어떻게 달라질까
빠른 답
- 컬렉션에 규칙과 의미가 붙기 시작하면
List<Order>보다Orders처럼 이름 있는 타입이 의도를 더 분명하게 드러냅니다. - 검증은 생성 시점과 변경 메서드 안으로 모으고, 외부에는
add(),totalAmount()처럼 목적이 보이는 연산만 열어두는 쪽이 흐름을 읽기 쉽습니다. - 외부에 컬렉션을 그대로 노출하면 상태가 예상 밖에서 바뀌기 쉬워서, 읽기 전용 뷰나 복사본으로 경계를 분리하는 편이 도움이 됩니다.
- 다만 단순 전달용 목록까지 모두 감쌀 필요는 없고, 중복 검사·합계 계산·정렬 정책처럼 컬렉션 자체의 규칙이 생길 때 도입하는 편이 효과적입니다.
목차
한눈에 비교
왜 그냥 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이 더 어울립니다. 금액 오차를 줄이고 의도를 더 분명히 표현할 수 있기 때문입니다. 아래 예시는 Order와 Orders를 분리하고, 생성 검증과 읽기 전용 노출을 함께 담은 기본 형태입니다.
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()도 리스트 컨테이너를 고정할 뿐, 원소가 가변 객체라면 원소 내부 상태까지 막아주지는 않습니다. 그래서 불변성을 강하게 가져가려면 원소 타입도 값 객체처럼 설계하는 편이 좋습니다. 위 예시에서 Order를 record로 둔 이유도 이 방향과 잘 맞습니다.
언제 쓰면 도움이 되고, 언제는 과할 수 있을까
모든 컬렉션을 일급 컬렉션으로 만들 필요는 없습니다. 규칙과 의미가 뚜렷한 컬렉션이라면 효과가 크지만, 단순 전달용 데이터까지 감싸면 타입만 늘고 이점은 크지 않을 수 있습니다.
도입을 검토해볼 만한 경우는 보통 이렇습니다.
- 목록에 중복 금지, 최대 개수, 정렬 정책 같은 불변 조건이 있습니다.
- 합계, 평균, 검색, 그룹화처럼 컬렉션 중심 연산이 자주 나옵니다.
- 같은 방어 코드가 서비스 여러 곳에 반복됩니다.
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
댓글
댓글 쓰기