소프트웨어를 만들 때 우리는 필연적으로 '여러 개의 데이터' 를 다루게 됩니다.
이때 아무생각 없이 아래와 같은 코드를 짤 수가 있습니다.
List<OrderItem> orderItems = new ArrayList<>();
List<Car> cars = new ArrayList<>();
List<Integer> lottoNumbers = new ArrayList<>();
이렇게 컬렉션(List, Map, Set 등) 을 아무런 보호 장치 없이 서비스 레이어나 여기저기에 날것으로 노출하는 순간, 코드는 서서히 스파게티로 변하기 시작합니다.
1급 컬렉션이란?
소트웍스 앤솔러지라는 책의 '객체지향 생활 체조 원칙' 에서 처음 제안하였습니다.
"컬렉션을 포함한 클래스는 반드시 다른 멤버 변수를 가지지 않아야 한다"
즉, List 나 Map 같은 컬렉션을 하나의 클래스로 꽁꽁 감싸고, 그 클래스 안에는 그 컬렉션 '단 하나' 만 존재하게 만드는 것입니다.
한눈에 보기
일반적인 방식
Order 클래스 안에 Long id, String orderNo, List<OrderItem> items 등이 다 섞여 있음.
1급 컬렉션을 적용
List<OrderItem> 만을 전담으로 관리하는 OrderItems 라는 별도의 클래스를 만듦.
그리고 Order 클래스는 이 OrderItems 객체를 멤버 변수로 가짐
왜 써야할까?
그냥 List<OrderItem> 을 바로 쓰면 편한데, 굳이 클래스로 한 번 더 감싸서 코드를 복잡하게 맏드는 이유가 뭘까요?
비즈니스 로직과 검증의 응집도 극대화
우리가 장바구니(Cart) 기능을 만드는데, 다음과 같은 기획 조건이 있다고 가정해봅시다.
조건1: 장바구니에는 최대 5개의 상품만 담을 수 있다.
조건2: 장바구니에 담긴 상품들의 총금액을 계산할 수 있어야 한다.
1급 컬렉션이 아닌 경우
이 조건들을 검증하고 계산하는 로직이 온통 CartService 같은 외부 서비스 레이어에 흩어지게 됩니다.
@Service
public class CartService {
// 사용자의 장바구니 상품 목록이 날것의 List로 흘러다님
public void addCartItem(List<CartItem> currentCart, CartItem newItem) {
// 비즈니스 검증 로직이 서비스에 노출됨
if (currentCart.size() >= 5) {
throw new IllegalArgumentException("장바구니에는 최대 5개까지만 담을 수 있습니다.");
}
currentCart.add(newItem);
}
public int calculateTotalPrice(List<CartItem> currentCart) {
// 총금액 계산 로직이 서비스에 매번 구현됨 (코드 중복 위험)
return currentCart.stream()
.mapToInt(CartItem::getPrice)
.sum();
}
}
만약 다른 서비스(AdminCartService) 에서 상품을 추가할 때 이 if 문 검증을 빼먹는다면 시스템에 구멍이 뚤리게 됩니다.
즉, 데이터와 로직이 따로 노는 전형적인 절차지향 코드가 됩니다.
1급 컬렉션을 적용하는 경우
public class Cart {
// 외부에서 접근 불가능한 단 하나의 멤버 변수
private final List<CartItem> items;
public Cart(List<CartItem> items) {
// 생성 시점에 스스로 검증 -> 5개가 넘는 장바구니는 아예 세상에 태어날 수 없음!
if (items.size() > 5) {
throw new IllegalArgumentException("장바구니에는 최대 5개까지만 담을 수 있습니다.");
}
this.items = new ArrayList<>(items);
}
// 장바구니에 상품을 추가하는 비즈니스 행위도 스스로 제어
public void add(CartItem newItem) {
if (items.size() >= 5) {
throw new IllegalArgumentException("장바구니에는 최대 5개까지만 담을 수 있습니다.");
}
items.add(newItem);
}
// 총금액 계산 로직도 내부에서 처리 (응집도 최고)
public int calculateTotalPrice() {
return items.stream()
.mapToInt(CartItem::getPrice)
.sum();
}
}
이제 CartService 는 복잡한 조건문이나 계산식을 알 필요가 없습니다.
그저 cart.add(newItem) 이나 cart.calulateTotalPrice() 를 호출하기만 하면 됩니다.
1급 컬렉션을 구현할 때의 자바 실무 팁
1급 컬렉션을 만들 때 주니어들이 가장 많이 하는 실수가 바로 '얕은 복사' 로 인한 데이터 오염입니다.
public class RacingCars {
private final List<Car> cars;
public RacingCars(List<Car> cars) {
this.cars = cars; // 위험! 외부에서 전달된 List의 주소값을 그대로 참조함
}
}
만약 생성자를 위와 같이 짜면, 외부에서 다음과 같은 크리티컬한 버그를 유발할 수 있습니다.
List<Car> originalList = new ArrayList<>();
originalList.add(new Car("포르쉐"));
RacingCars racingCars = new RacingCars(originalList); // 1급 컬렉션 생성
// 외부에서 원래 리스트를 조작했는데, 1급 컬렉션 내부 데이터까지 바뀜!
originalList.add(new Car("모닝"));
따라서 반드시 생성자 단계에서 새로운 리스트로 복사하여 연결 고리를 끊어내야 안전합니다.
public class RacingCars {
private final List<Car> cars;
public RacingCars(List<Car> cars) {
// 새로운 ArrayList로 감싸서 외부 리스트와 주소값을 끊어냄
this.cars = new ArrayList<>(cars);
}
}
'Java' 카테고리의 다른 글
| [ 주니어 탈출기 ] 고수준 컴포넌트와 저수준 컴포넌트 (0) | 2026.06.02 |
|---|---|
| [ 주니어 탈출기 ] "인터페이스랑 뭐가 다를까?" - abstract 의 활용법 (0) | 2026.05.28 |
| [ 주니어 탈출기 ] 도메인(Domain) 개념 완벽 정리 (0) | 2026.05.18 |
| 좌충우돌 원격 서버 배포 및 Jenkins CI/CD 구축기 (0) | 2026.04.28 |
| 빈 검증에 대하여 (1) | 2026.03.23 |