[OOP] 일급 컬렉션(First Class Collection)으로 컬렉션을 다뤄보자
목차
들어가기 전에
컬렉션은 굉장히 유용하고 그만큼 많이 사용되는 자료구조 인터페이스지만 그 자체로도 많은 기능을 구현할 수 있다.
이렇게 유용한 컬렉션을 일급 컬렉션(First Class Collection)으로 사용한다면 이미 강력한 기능을 갖고 있는 컬렉션을 더 안전하고 객체지향적으로 다룰 수 있다.
일급 컬렉션에 대해 정리해본다.
일급 컬렉션?
일급 컬렉션은 아래와 같이 멤버 변수로 컬렉션만 갖고 있는 클래스이다.
Kotlin
class Team(
private val players: MutableList<Player> = mutableListOf()
) {
// ... 기타 메소드들
}
Java
public class Team {
private final List<Player> players;
public Team(List<Player> players) {
this.players = players;
}
// ... 기타 메소드들
}
그런데 그냥 컬렉션이 필요한 로직에서 컬렉션 변수를 선언해서 사용해도 될 것을 왜 굳이 일급 컬렉션이라는 이름을 붙여가면서까지 포장을 해서 사용하는 것일까?
일급 컬렉션을 사용해서 얻을 수 있는 장점을 알아보자.
컬렉션이 스스로 책임을 다할 수 있다
객체 지향 프로그래밍에서는 단일 책임 원칙이라는 말이 있다.
하나의 클래스는 반드시 하나의 책임만을 가져야 한다는 말이다.
상품 목록을 받아서 DB에 저장하는 로직을 생각해보자.
Kotlin
class ProductService(
private val productRepository: ProductRepository
) {
fun addMultipleProducts(products: List<Product>) {
validatePrice(products)
validateDuplicateProduct(products)
productRepository.saveAllBatch(products)
}
private fun validatePrice(products: List<Product>) {
products.forEach { it.validatePrice() }
}
private fun validateDuplicateProduct(products: List<Product>) {
if (products.size != products.distinct().size) {
throw IllegalArgumentException("중복된 상품이 존재합니다.")
}
}
}
Java
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void addMultipleProducts(List<Product> products) {
validatePrice(products);
validateDuplicateProduct(products);
productRepository.saveAllBatch(products);
}
private void validatePrice(List<Product> products) {
for (Product product : products) {
product.validatePrice();
}
}
private void validateDuplicateProduct(List<Product> products) {
if (products.size() != products.stream().distinct().toList().size()) {
throw new IllegalArgumentException("중복된 상품이 존재합니다.");
}
}
}
이렇게 상품을 처리하는 로직은 보통 서비스 계층에서 수행하게 된다.
그런데 validatePrice()
와 validateDuplicateProduct()
의 경우는 리스트에 들어있는 상품 자체를 검증하는 로직인데도 ProductService에서 수행하고 있다.
당장은 ProductService 하나밖에 없지만 동일한 로직을 사용할 곳이 여러곳이라면 수많은 중복 코드가 생겨날 것이고 요구사항이 변경되었을 때 수정해야 할 부분을 미처 발견하지 못해 버그가 발생할 수도 있다.
addMultipleProducts()
의 파라미터인 products
를 일급 컬렉션으로 바꿔보자.
Kotlin
class ProductList(
private val products: MutableList<Product> = mutableListOf()
) {
fun toList(): List<Product> {
return products.toList()
}
fun validatePrice() {
products.forEach { it.validatePrice() }
}
fun validateDuplicateProduct() {
if (products.size != products.distinct().size) {
throw IllegalArgumentException("중복된 상품이 존재합니다.")
}
}
}
class ProductService(
private val productRepository: ProductRepository
) {
fun addMultipleProducts(products: ProductList) {
products.validatePrice()
products.validateDuplicateProduct()
productRepository.saveAllBatch(products.toList())
}
}
Java
public class ProductList {
private final List<Product> products;
public ProductList() {
this.products = new ArrayList<>();
}
public ProductList(List<Product> products) {
this.products = products;
}
public List<Product> toList() {
return products.stream().toList();
}
public void validatePrice() {
for (Product product : products) {
product.validatePrice();
}
}
public void validateDuplicateProduct() {
if (products.size() != products.stream().distinct().toList().size()) {
throw new IllegalArgumentException("중복된 상품이 존재합니다.");
}
}
}
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void addMultipleProducts(ProductList products) {
products.validatePrice();
products.validateDuplicateProduct();
productRepository.saveAllBatch(products.toList());
}
}
이제 리스트에 담겨있는 Product들을 검증하는 로직은 ProductList에서 직접 책임지게 되었다.
이로 인해 ProductService도 굉장히 간소해졌고 검증 로직에 수정사항이 발생하더라도 ProductList의 검증 로직만 수정하면 되기 때문에 버그가 발생할 가능성도 굉장히 줄어들었다.
예시 코드에서 다루는 값 검증 로직의 경우에는 아예 생성자 차원에서 객체를 생성할 때 값을 검증하게 하여 일급 컬렉션 객체를 사용하는 다른 클래스에서 신경 쓸 내용을 더욱 줄일 수도 있다.
Kotlin
class ProductList(
private val products: MutableList<Product> = mutableListOf()
) {
init {
validatePrice()
validateDuplicateProduct()
}
fun toList(): List<Product> {
return products.toList()
}
private fun validatePrice() {
products.forEach { it.validatePrice() }
}
private fun validateDuplicateProduct() {
if (products.size != products.distinct().size) {
throw IllegalArgumentException("중복된 상품이 존재합니다.")
}
}
}
class ProductService(
private val productRepository: ProductRepository
) {
fun addMultipleProducts(products: ProductList) {
productRepository.saveAllBatch(products.toList())
}
}
Java
public class ProductList {
private final List<Product> products;
public ProductList() {
this.products = new ArrayList<>();
}
public ProductList(List<Product> products) {
validatePrice();
validateDuplicateProduct();
this.products = products;
}
public List<Product> toList() {
return products.stream().toList();
}
private void validatePrice() {
for (Product product : products) {
product.validatePrice();
}
}
private void validateDuplicateProduct() {
if (products.size() != products.stream().distinct().toList().size()) {
throw new IllegalArgumentException("중복된 상품이 존재합니다.");
}
}
}
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void addMultipleProducts(ProductList products) {
productRepository.saveAllBatch(products.toList());
}
}
분산됐던 값 검증 로직을 생성자에서 모두 처리하게 되어 더 이상 ProductList를 사용하는 클래스에선 객체를 검증할 필요가 없어졌고 이 과정에서 값 검증 메소드도 모두 private
으로 변경할 수 있게 됐다!
이렇게 일급 컬렉션을 사용하면 분산될 수 있는 로직을 일급 컬렉션 클래스에 모아서 응집도를 높힐 수 있다.
컬렉션의 변경을 제한할 수 있다
일급 컬렉션을 사용한다면 반드시 필요한 기능만 구현하 컬렉션의 변경을 제한할 수 있다.
컬렉션은 참조 타입이기 때문에 코틀린의 val
이나 자바의 final
로 선언하더라도 컬렉션을 새로 할당하는 것만 막을뿐이지 해당 객체 내부 속성이 변경되는 것은 막을 수 없다.
두 경우 모두 val
과 final
로 리스트를 선언했지만 요소를 제거하거나 변경하는건 아무 문제 없이 할 수 있다.
하지만 컬렉션 객체를 일급 컬렉션으로 감싸주고 일급 컬렉션 객체 내부의 컬렉션에 접근할 수단을 만들지 않는다면 결과적으로 컬렉션의 변경을 제한할 수 있다.
위에서 다룬 ProductList의 내용을 보면 public
인 메소드는 복사된 불변 리스트를 반환하는 toList()
와 생성자밖에 없다.
즉, 외부에서 ProductList의 멤버 리스트에 접근할 방법이 없기 때문에 자연스럽게 불변 컬렉션이 되었다.
사실 이렇게만 하면 변경의 여지가 아예 없어지는 것은 아니다. ProductList 객체를 생성할 때 생성자의 인자로 넘겨주는 리스트 자체를 변경해버리면 ProductList도 영향을 받기 때문이다.(관련 내용 : [Java] UnmodifiableList는 진짜 불변 리스트가 아니다)
이를 방지하기 위해 생성자를 살짝 수정해줄 필요가 있다.
Kotlin
class ProductList(
products: List<Product> = mutableListOf() // 기본 생성자에서는 파라미터만 설정하고
) {
private val products = ArrayList(products) // 클래스 내부에서 인자로 받은 리스트를 복사!
init {
validatePrice()
validateDuplicateProduct()
}
fun toList(): List<Product> {
return products.toList()
}
private fun validatePrice() {
products.forEach { it.validatePrice() }
}
private fun validateDuplicateProduct() {
if (products.size != products.distinct().size) {
throw IllegalArgumentException("중복된 상품이 존재합니다.")
}
}
}
Java
public class ProductList {
private final List<Product> products;
public ProductList() {
this.products = new ArrayList<>();
}
public ProductList(List<Product> products) {
validatePrice();
validateDuplicateProduct();
this.products = new ArrayList<>(products); // 인자로 받은 리스트를 복사해서 할당!
}
public List<Product> toList() {
return products.stream().toList();
}
private void validatePrice() {
for (Product product : products) {
product.validatePrice();
}
}
private void validateDuplicateProduct() {
if (products.size() != products.stream().distinct().toList().size()) {
throw new IllegalArgumentException("중복된 상품이 존재합니다.");
}
}
}
ProductList의 생성자에서 멤버 리스트를 초기화할 때 인자로 받은 리스트를 바로 할당하지 않고 새로운 리스트로 복사해서 할당하도록 수정되었다.
이제 ProductList의 멤버 리스트는 원본 리스트와 완전히 연결이 끊어졌기 때문에 불변 컬렉션이 되었다고 말할 수 있게 되었다!
이렇게 불변 컬렉션으로 만드는 것 뿐만 아니라 가변 컬렉션으로 만들더라도 변경을 제한할 수도 있다. 가령, 요소를 추가할 때 특정 조건을 걸 수도 있고 요소 추가를 제외한 변경을 모두 불가능하게 할 수도 있다.
마치며
일급 컬렉션이라는 구조를 처음 마주하면 생기는 의문은 왜 컬렉션을 포장하면서까지 사용해야 하는지에 대한 의문이 가장 크다고 생각한다.
하지만 일급 컬렉션은 단순히 객체에 무언가를 시키기만 하면 된다는 객체 지향 프로그래밍을 향한 방법 중 하나이기 때문에 확실히 알아두고 사용하면 유용할 것이다.
참조 링크