일급 컬렉션 (First Class Collection)
목차
자바로 도메인 로직을 작성하다 보면 List나 Map을 여기저기 직접 다루는 코드가 자연스럽게 늘어납니다.
처음에는 단순해 보이지만, 같은 컬렉션을 다루는 로직이 여러 곳에 흩어지고 나면 수정할 때마다 모든 곳을 찾아야 하는 상황이 생깁니다.
이 글에서는 컬렉션을 클래스로 감싸는 패턴인 일급 컬렉션이 무엇인지, 그리고 왜 사용하는지를 순서대로 살펴보겠습니다.
1. List를 그냥 쓰면 생기는 문제
로또 번호를 관리하는 코드를 예시로 보겠습니다. 로또 번호는 6개여야 하고, 중복이 없어야 합니다.
List<Long> numbers = new ArrayList<>();
numbers.add(1L);
numbers.add(7L);
numbers.add(13L);
// ...
이 규칙을 보장하려면 numbers를 사용하는 모든 곳에서 검증 로직을 반복해야 합니다.
// 서비스 A
if (numbers.size() != 6) throw new IllegalArgumentException();
// 서비스 B
if (numbers.size() != 6 || hasDuplicates(numbers)) throw new IllegalArgumentException();
// 서비스 C — 검증을 빠뜨림
List<Long>은 그냥 숫자의 목록일 뿐입니다. 로또 번호라는 도메인 규칙을 스스로 알지 못하기 때문에, 그 책임이 사용하는 쪽으로 새어나갑니다. 규칙이 바뀌면 흩어진 모든 곳을 찾아 수정해야 하고, 어딘가는 빠뜨리게 됩니다.
2. 일급 컬렉션이란
일급 컬렉션은 컬렉션을 하나의 클래스로 감싸되, 그 클래스 안에 컬렉션 외의 다른 멤버 변수를 두지 않는 패턴입니다.
public class LottoTicket {
private final List<Long> numbers;
public LottoTicket(List<Long> numbers) {
this.numbers = numbers;
}
}
단순히 List를 클래스로 감싼 것처럼 보이지만, 이 구조가 가져다주는 이점은 생각보다 큽니다.
3. 비즈니스에 종속적인 자료구조
일급 컬렉션의 가장 직접적인 장점은 비즈니스 규칙을 컬렉션 자체에 담을 수 있다는 점입니다.
public class LottoTicket {
private final List<Long> numbers;
public LottoTicket(List<Long> numbers) {
if (numbers.size() != 6) {
throw new IllegalArgumentException("로또 번호는 6개여야 합니다.");
}
if (numbers.size() != new HashSet<>(numbers).size()) {
throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
}
this.numbers = new ArrayList<>(numbers);
}
}
이제 LottoTicket은 항상 유효한 상태로만 존재합니다. 유효하지 않은 번호로는 객체 자체가 만들어지지 않습니다.
사용하는 쪽에서는 검증을 신경 쓸 필요가 없습니다. LottoTicket을 받았다면 이미 올바른 번호라는 것이 보장되기 때문입니다. 규칙이 바뀔 때도 LottoTicket 생성자 한 곳만 수정하면 됩니다.
4. 불변성 보장 — final의 한계와 방어적 복사
컬렉션을 불변으로 만들려 할 때 final 키워드만으로는 충분하지 않습니다. final은 재할당만 막을 뿐, 컬렉션 안의 값을 변경하는 것은 막지 않습니다.
final Map<String, Boolean> collection = new HashMap<>();
collection.put("1", true); // 가능 — 재할당이 아니라 값 추가
collection.put("1", false); // 가능 — 값 변경도 허용됨
일급 컬렉션은 값을 변경하는 메서드 자체를 제공하지 않아 외부에서 리스트에 요소를 추가하거나 제거할 수 없습니다.
그런데 여기서 한 가지 구분이 필요합니다. 방어적 복사와 변경 메서드 제거로 막을 수 있는 건 리스트 구조 자체입니다. 리스트 안에 들어 있는 요소 객체는 별개입니다.
Pay pay = new Pay(5000);
ImmutablePays pays = new ImmutablePays(List.of(pay));
pay.setAmount(0); // Pay 객체를 직접 변경
// pays 내부의 Pay도 amount = 0이 됨
리스트에 요소를 추가하거나 뺀 게 아닙니다. 리스트 안에 있는 객체의 내용이 바뀐 것입니다. 자물쇠는 채워놨는데 자물쇠 안의 물건 자체가 바뀐 상황입니다.
따라서 완전한 불변을 달성하려면 리스트 구조뿐 아니라 요소 객체 자체도 불변이어야 합니다. Pay가 final 필드만 가지고 setter가 없는 불변 객체라면, 리스트 구조도 요소도 모두 변경할 수 없는 상태가 됩니다.
public class ImmutablePays {
private final List<Pay> pays;
public ImmutablePays(List<Pay> pays) {
this.pays = new ArrayList<>(pays); // 방어적 복사
}
public Long getSum() {
return pays.stream()
.mapToLong(Pay::getAmount)
.sum();
}
// add, remove 같은 변경 메서드 없음
}
생성자에서 new ArrayList<>(pays)로 복사하는 이유도 있습니다. 복사하지 않으면 외부의 원본 리스트와 내부의 pays가 같은 리스트 객체를 가리키게 됩니다.
List<Pay> original = new ArrayList<>();
original.add(new Pay(5000));
ImmutablePays pays = new ImmutablePays(original); // 복사 없이 그대로 저장했다면
original.add(new Pay(3000)); // 외부에서 원본 수정
// pays 내부도 요소가 2개가 됨
new ArrayList<>(pays)로 복사하면 원본과 내부 리스트가 분리되므로, 외부에서 원본을 수정해도 내부 상태에 영향을 주지 않습니다.
내부 리스트를 반환할 때도 같은 문제가 생깁니다. getter로 내부 리스트를 그대로 반환하면, 외부에서 그 참조를 통해 리스트를 수정할 수 있습니다.
// 그대로 반환하면
public List<Pay> getPays() {
return pays;
}
List<Pay> list = immutablePays.getPays();
list.add(new Pay(9999)); // 내부 pays에 요소가 추가됨
list.remove(0); // 내부 pays에서 요소가 삭제됨
변경 메서드를 만들지 않았는데 getter 하나로 내부가 뚫리는 상황입니다. Collections.unmodifiableList()로 감싸면 수정 시도 자체를 막을 수 있습니다.
public List<Pay> getPays() {
return Collections.unmodifiableList(pays);
}
List<Pay> list = immutablePays.getPays();
list.add(new Pay(9999)); // UnsupportedOperationException 발생
원본 리스트를 감싼 읽기 전용 뷰를 반환하는 방식이라, 조회는 가능하지만 수정하려 하면 예외가 발생합니다.
5. 상태와 행위를 한 곳에서 관리
결제 수단별 합계를 계산하는 코드를 예시로 보겠습니다.
일급 컬렉션 없이 작성하면 관련 로직이 서비스 여러 곳에 흩어집니다.
// 서비스 A
Long naverPaySum = pays.stream()
.filter(pay -> PayType.isNaverPay(pay.getPayType()))
.mapToLong(Pay::getAmount)
.sum();
// 서비스 B — 같은 로직이 반복됨
Long naverPaySum = pays.stream()
.filter(pay -> PayType.isNaverPay(pay.getPayType()))
.mapToLong(Pay::getAmount)
.sum();
일급 컬렉션으로 감싸면 이 로직이 한 곳에 모입니다.
public class PayGroups {
private final List<Pay> pays;
public PayGroups(List<Pay> pays) {
this.pays = pays;
}
public Long getNaverPaySum() {
return getFilteredSum(pay -> PayType.isNaverPay(pay.getPayType()));
}
public Long getKakaoPaySum() {
return getFilteredSum(pay -> PayType.isKakaoPay(pay.getPayType()));
}
private Long getFilteredSum(Predicate<Pay> predicate) {
return pays.stream()
.filter(predicate)
.mapToLong(Pay::getAmount)
.sum();
}
}
결제 합계 계산 방식이 바뀌더라도 PayGroups 한 곳만 수정하면 됩니다. 상태(pays)와 그 상태를 다루는 행위(getNaverPaySum)가 같은 클래스 안에 있기 때문입니다.
6. 이름 있는 컬렉션
List<Pay>라는 타입만으로는 이것이 네이버페이 목록인지, 카카오페이 목록인지 알 수 없습니다. 변수명에 의존하게 되고, 변수명은 강제되지 않습니다.
// 변수명만으로 구분 — 강제되지 않음
List<Pay> naverPays = ...;
List<Pay> kakaoPays = ...;
일급 컬렉션은 타입 자체가 이름이 됩니다.
public class NaverPayGroup { ... }
public class KakaoPayGroup { ... }
클래스 이름이 생기면 몇 가지 실질적인 이점이 따라옵니다.
- 코드베이스에서
NaverPayGroup으로 검색이 가능해집니다. - 팀원과 이야기할 때 “네이버페이 그룹”이라는 명확한 이름으로 소통할 수 있습니다.
- 메서드 파라미터로
List<Pay>대신NaverPayGroup을 받으면, 잘못된 목록이 들어오는 것을 컴파일 타임에 막을 수 있습니다.
7. 유틸 클래스와 무엇이 다른가
“로직을 한 곳에 모은다”는 설명을 들으면 유틸 클래스와 비슷해 보일 수 있습니다. 하지만 둘은 성격이 다릅니다.
| 유틸 클래스 | 일급 컬렉션 | |
|---|---|---|
| 상태 보유 | 없음 (static 메서드만) | List를 필드로 보유 |
| 도메인 지식 | 없음 | 있음 |
| 예시 | StringUtils, MathUtils | LottoTicket, PayGroups |
유틸 클래스는 도메인을 모르는 순수한 계산 도구입니다. StringUtils는 문자열을 어떻게 다룰지 알지만, 그 문자열이 어떤 비즈니스 의미를 가지는지는 모릅니다.
일급 컬렉션은 상태를 가지고, 그 상태와 관련된 도메인 규칙을 직접 담습니다. 같은 컬렉션 조작이라도 “로또 번호의 중복 검사”라는 맥락을 알고 있습니다.
마무리
정리하면 다음과 같습니다.
- 컬렉션을 그냥 쓰면 비즈니스 규칙이 사용하는 쪽으로 새어나간다
- 일급 컬렉션은 컬렉션 하나만을 멤버 변수로 갖는 클래스다
- 생성자에서 검증하면 유효하지 않은 상태의 객체가 만들어지지 않는다
final만으로는 컬렉션 내부 변경을 막을 수 없고, 변경 메서드를 제공하지 않아야 진정한 불변이 된다- 관련 상태와 행위가 한 클래스에 모이므로 변경 시 수정 지점이 하나다
- 클래스 이름 자체가 의미를 전달하고, 검색과 소통이 명확해진다
일급 컬렉션은 거창한 패턴이 아닙니다. 컬렉션에 이름을 붙이고, 관련 규칙을 그 안에 담는 것입니다. 작은 변화지만 코드의 응집도와 명확성에 미치는 영향은 큽니다.