티스토리 뷰

 

목차

    배경

    최근 넥스트 스탭의 TDD, 클린 코드 with Kotlin 8기를 수강하며, 가능한 함수형 프로그래밍을 따르고 불변 객체로 설계하며 과제를 수행하고 있다. 최근 몇 년간, Java 8부터 도입된 Stream API와 코틀린의 일급 시민, 람다 표현식 등 등 함수형 프로그래밍은 새로운 패러다임으로 떠오르고 있는데, 그 중 불변 객체는 함수형 프로그래밍의 패러다임과 잘 맞는 중요한 구현 방안 중 하나이다. 이번 글에서는 불변 객체 설계와 그 예시, 장점과 단점을 살펴본다.

    예시

    value 값을 갖는 Element 인터페이스를 작성해보자. 이 인터페이스를 구현하는 방안은 가변과 불변에 따라 두 가지로 나뉜다.

     

    먼저 가변 객체로 설계하고 구현하는 방안은 다음과 같다. updateValue() 메서드를 호출하면 value의 값 자체를 변경한다.

    package cell
    
    data class Cell(
        override var value: Char = VALUE,
    ) : Element {
        fun updateValue(value: Char) {
            this.value = value
        }
    
        companion object {
            private const val DEFAULT = 'C'
            private const val VALUE: Char = DEFAULT
        }
    }

     

    다음으로 불변 객체로 설계하는 방안은 다음과 같다. updateValue() 메서드를 호출하면 값이 변경된 새로운 객체 자체를 반환한다.

    package cell
    
    interface Element {
        val value: Char
    }
    
    package cell
    
    data class Cell(
        override val value: Char = VALUE,
    ) : Element {
        fun updateValue(): Cell = Cell(value = value)
    
        companion object {
            private const val DEFAULT = 'C'
            private const val VALUE: Char = DEFAULT
        }
    }

     

    불변 객체 설계의 장점

    객체를 불변으로 설계하는 것은 코드 안정성과 가독성을 높이는데 큰 도움을 준다. 그 장점을 자세히 살펴보면 다음과 같다.

    1. 상태 변경으로 인한 버그 방지

    객체의 상태 자체가 변경되지 않으므로 이로 인한 사이드 이펙트(Side Effects)가 발생하지 않는다. 쉽게 말해 객체의 값 변경 자체가 발생하지 않으므로 여러 스레드가 접근하더라도 안전하게 사용할 수 있다. 즉, 경합상태(Race Condition)가 발생하지 않아 Thread safe하다.

    2. 가독성과 유지보수성 향상

    객체가 한 번 생성되면 변경되지 않으므로 코드를 읽고 이해하기 쉽다. 또한 기존 객체를 변경하지 않고 새로운 객체를 반환하므로 코드의 의도가 명확하다. 예를 들어 다음과 같이 주문 정보를 나타내는 Order 클래스가 있다고 가정해보자. 

    class Order(
        var status: String,
    ) {
        fun changeStatus(status: String) {
            this.status = status // 기존 객체의 상태를 변경
      }
    }
    
    // 사용 예시
    val order = Order("CREATED")
    
    // 상태 변경
    order.changeStatus("SHIPPED")

     

    주문 상태가 달라지는 경우 위와 같이 같이 changeStatus()를 호출하여 객체의 상태를 변경한다. 이와 같이 로직이 단순한 경우 큰 문제가 되지 않지만, 코드가 더 복잡해질 경우 상태 추적을 어렵게 만든다. 객체 생성 시점과 런타임 시점에 상태가 달라질 수 있고, 이러한 상태가 혼재되면 코드를 읽고 이해하기 어렵게 만든다.

     

    이를 불변 객체로 선언하면 다음과 같다.

    class Order(
        val status: String,
    ) {
        fun changeStatus(status: String): Order =
        	// 새로운 객체를 반환
        	Order(status)
      }
    }
    
    // 사용 예시
    val order = Order("CREATED")
    
    // 상태 변경
    val shippedOrder = order.changeStatus("SHIPPED")

     

    changeStatus()라는 메서드와 shippedOrder라는 네이밍을 통해 상태가 변경되었다는 의도를 명확히 나타낼 수 있다. 또한 원본 객체와 상태가 변경된 객체가 분리되어 있어 코드의 흐름을 쉽게 이해할 수 있다.

     

    또한 원본 객체 order는 절대 변경되지 않으므로 테스트나 추가 로직에 따른 상태 변경이 발생하는지 추적할 필요도 없어진다. 즉 상태 변경에 따른 부작용을 줄이고 가독성과 유지보수성을 향상할 수 있다.

    3. 선언적 스타일과 함수형 프로그래밍

    불변 객체는 선언적 스타일을 따라 함수형 프로그래밍 패러다임에 적절하다. 예를 들어, 상품 가격 별 할인을 적용하는 요구사항을 살펴보자.

     

    1. 가격이 1000 이상인 상품은 200 할인.
    2. 가격이 500 이상 1000 미만인 상품은 10% 할인.
    3. 가격이 500 미만인 상품은 제외.

    먼저 절자척 스타일과 가변 객체로 이 요구사항을 구현해보면 다음과 같다.

    data class Product(
        val name: String, 
        var price: Double,
    )
    
    fun main() {
        val products = mutableListOf(
            Product("Laptop", 1500.0),
            Product("Phone", 800.0),
            Product("Tablet", 400.0),
            Product("Monitor", 1200.0)
        )
    
        val discountedProducts = mutableListOf<Product>()
    
        for (product in products) {
            if (product.price >= 1000) {
            	// 200 할인 적용
                product.price -= 200.0 
                discountedProducts.add(product)
            } else if (product.price in 500.0..999.99) {
            	// 10% 할인 적용
                product.price *= 0.9 
                discountedProducts.add(product)
            }
            
            // 가격이 500 미만인 상품은 추가하지 않음
        }
    }

     

    for 루프 내부에서 필터링, 할인 계산, 리스트 추가 등 다양한 작업이 혼재되어 있다. 요구사항이 지금처럼 간단한 경우에도 한 눈에 의도를 파악하기 어렵다. 로직이 복잡해질수록 더더욱 코드를 읽고 이해하기 어려워진다.

     

    또한 product의 setPrice()를 호출하여 상태를 직접 수정하여 원본 객체를 변경하고 있다. 이는 경합상태와 같은 사이드 이펙트를 야기할 수 있다. 예를 들어 동일한 product 객체를 다른 곳에서 참조하고 있다면, 상태 변경으로 인해 예기치 않은 동작이 발생할 가능성이 있다.

     

    마지막으로 새로운 조건이나 로직이 추가되는 경우, for loop 내의 여러곳을 수정해야한다. 새로운 조건이 추가된다면 어느 로직을 수정해야할지, for loop와 if-else 구문을 살펴봐야하고, 수정 중에 예기치 못하게 영향을 줄 수도 있다.

     

    위 코드를 선언적 스타일과 불변 객체로 작성하면 다음과 같다.

    data class Product(
        val name: String, 
        val price: Double,
    ) {
        fun applyDiscount(discount: Double): Product {
            return copy(price = price - discount)
        }
    
        fun applyPercentageDiscount(percentage: Double): Product {
            return copy(price = price * (1 - percentage / 100))
        }
    }
    
    fun main() {
        val products = listOf(
            Product("Laptop", 1500.0),
            Product("Phone", 800.0),
            Product("Tablet", 400.0),
            Product("Monitor", 1200.0)
        )
    
        val discountedProducts = products.filter { it.price >= 500 } // 500 이상인 상품만 필터링
                                         .map { product ->
                                             when {
                                                 product.price >= 1000 -> product.applyDiscount(200.0)
                                                 product.price in 500.0..999.99 -> product.applyPercentageDiscount(10.0)
                                             }
                                         }
    }

     

    앞선 코드에 비해 가독성이 향상된 것을 볼 수 있다. 가격이 500 이상인 상품에 대해서만 가격 별로 다른 할인 방식을 적용한다는 의도를 한 눈에 파악할 수 있다. 또한 원본 객체를 변경하지 않아 안전하게 사용할 수 있다. 

     

    만약 새로운 조건이나 할인 로직이 추가되어도 map 블록 내부에 when 문만 수정하면 된다. 즉 작업 흐름이 데이터 변환 파이프라인으로 설계되어 명확하고 유지보수에 용이해진다.

     

    만일 다른곳에서 기존 product 객체를 사용하고 있더라도, 이 로직은 상태 변경이 아니라 새로운 객체를 생성하므로 경합 상태로부터 안전하고 Thread Safe하다. 

    불변 객체 설계의 단점

    물론 불변 객체의 단점도 존재한다. 먼저 매번 새로운 객체를 생성하므로 메모리 사용률이 증가할 수 있다. 또한, 대용량 데이터를 처리하거나 성능이 매우 중요한 경우(시스템 프로그래밍, 네트워크 프로그래밍 등) 불변 객체가 성능 병목이 될 수 있다. 

     

    하지만 현대 JVM에서는 객체 생성과 가비지 컬렉션이 최적화되어 있어 큰 문제가 되지 않는 경우가 많다. 이 덕분에 대부분의 상업용 애플리케이션에서는 큰 성능 저하 없이 불변 객체를 사용할 수 있다.

    불변 객체가 적합한 경우

    마지막으로 불변 객체와 가변 객체가 적절한 상황에 대해 살펴보자. 먼저 상태 변경이 필요하지 않거나 드문 경우 불변 객체가 적절하다. 앞서 살펴본 예시 코드와 같이 상태를 변경하는 대신 새로운 객체를 생성하는 것은 코드 안정성을 높이고 디버깅을 쉽게한다.

     

    또한 멀티 쓰레드 환경에서 개발하는 경우, 불변 객체는 Thread Safe하므로 동시성 문제로부터 어느정도 자유롭다.

     

    마지막으로 코드의 유지보수성과 가독성이 중요한 경우 불변 객체가 적합하다. 불변 객체는 상태 변경이 없기 때문에 프로그램의 흐름을 이해하기 쉽고, 코드의 흐름을 예측할 수 있다. 특히 함수형 프로그래밍에 적합한데, 코틀린과 같은 언어에서 람다 표현식과 함께 사용하기 좋다.

    가변 객체가 적합한 경우

    시스템 프로그래밍, 운영체제 개발 등 성능이 중요하거나 게임 개발, 실시간 데이터 처리 등 상태 변경이 매우 빈번한 경우 가변 객체가 적합하다. 또한 메모리 성능이 제한적이거나 사용량이 높아 최적화가 필요한 경우 가변 객체를 사용하면 하나의 객체를 재사용하면서 메모리 사용량을 줄일 수 있다.

    Comments