Study/object

[엘레강트 오브젝트] 2-6장 불변 객체로 만드세요

에디개발자 2021. 8. 31. 07:00
반응형

나를 닮았다고 한다...

이 글은 엘레강트 오브젝트 새로운 관점에서 바라본 객체지향 도서를 보며 스터디한 글입니다.

책에서 주장하는 내용을 정리하였으며 예제들은 모두 코틀린 코드로 변환하여 작성하였습니다.

 

목차

  1. 불변이란?
  2. 식별자 가변성(Identity Mutability)
  3. 실패 원자성(Failure Atomicity)
  4. 시간적 결합(Temporal Coupling)
  5. 부수효과 제거(Side effect-free)
  6. NULL 참조 없애기
  7. 스레드 안정성
  8. 더 작고 더 단순한 객체
  9. 주관적인 생각

 


1. 불변이란?

Immutable, 즉 변경할 수 없음을 의미합니다. 예시로 살펴보겠습니다.

 

가변 객체

먼저 가변 객체는 내부 속성값을 변경이 가능한 객체입니다.

// 가변 객체
class Cash(
    private var dollars: Int
)

// 변경이 가능
val cash = Cash(50)
cash.dollars = 10

 

불변 객체

불변 객체는 내부 속성값을 변경이 불가능한 객체입니다.

// 불변 객체
class ImmutableCash(
    private val dollars: Int
)

 

불변 객체의 속성값을 수정할 때 속성값을 변경한다면 개발자에게 혼란을 줄 수 있습니다.

class Cash(
    private var dollars: Int
) {
    fun mul(factor: Int) {
        this.dollars *= factor
    }
}

val five = Cash(5)
five.mul(10)
print(five.dollars). // 50

이 예제는 Cash라는 객체에 속성값에 5를 주어 five라는 변수로 선언했습니다. 하지만 five 객체에 mul 메서드를 호출하여 속성값을 5에서 50으로 변경하였습니다. 이 때 객체명은 five 이지만 속성값은 50이 되어 혼란에 빠질 수 있습니다.

 

불변 객체를 수정한다면 속성값을 수정하는 대신 새로운 객체를 반환해야합니다.

class ImmutableCash(
    private val dollars: Int
) {
    // 새로운 객체 반환
    fun mul(factor: Int) =
        ImmutableCash(dollars * factor)
}

 

불변 객체를 사용하자

불변 객체를 사용한다면 응집력이 높아지고 느슨하게 결합하여 유지보수하기 쉬운 객체로 만들 수 있습니다.

 

2. 식별자 가변성(Identity Mutability)

동일해 보이는 두 객체를 비교한 후 한 객체의 상태를 변경할 때 문제가 발생합니다. 

  • 두 객체는 동일하지 않지만 동일하다고 생각한다.
  • 두 객체는 동일하지만 동일하지 않다고 생각한다.

 

간단한 예시로 두 객체는 동일하지 않지만 동일하다고 생각하는 케이스에 대해서 살펴보겠습니다.

// Cash 클래스의 속성값은 가변성이다.
val map = mutableMapOf<Cash, String>()
val five = Cash(5)
val ten = Cash(10)

map[five] = "five"
map[ten] = "ten"
five.mul(2)

println(map)        // {10=five, 10=ten}
println(map[five])  // five
  1. 동일하지 않은 2개의 Cash 클래스 생성합니다. ( 하나는 값이 5가 할당된 five, 하나는 값이 10이 할당된 ten )
  2. 생성한 2개의 Cash 클래스를 Map에 Put 합니다.
  3. five Cash 클래스의 속성값을 변경합니다. 

현재는 모든 코드를 한눈에 알아볼 수 있어 문제의 심각성을 파악할 수 없지만 만약 아래와 같은 코드로만 되어있다면 개발자는 문제를 찾는데 있어 어려움을 겪을 것 입니다.

five.dollars == ten.dollars // true

 

3. 실패 원자성(Failure Atomicity)

완전하고 견고한 상태의 객체를 가지거나 아니면 실패하거나 둘 중 하나만 가능한 특성입니다. 

 

가변 객체의 경우

class Cash(
    private var dollars: Int,
    private var cents: Int
) {
    fun mul(factor: Int) {
        this.dollars *= factor
        if (cents < 0) // 잘못된 경우
            throw RuntimeException("Failed!!!")
        this.cents *= factor 
    }

    override fun toString(): String {
        return "$dollars"
    }
}

위 코드에서 만약에 잘못된 경우 RuntimeException을 발생시킵니다. 그렇다면 dollars 속성값만 수정되고 cents 속성값은 수정되지 않았습니다. 이럴 경우 찾기 힘든 버그가 발생할 수 있습니다. 

 

불변 객체의 경우

어떤 것도 수정이 불가능하고 새로운 상태를 가진 객체를 반환하기 때문에 위와 같은 결함이 발생하지 않습니다.

class ImmutableCash(
    private val dollars: Int,
    private val cents: Int
) {
    fun mul(factor: Int): ImmutableCash {
        if (cents < 0) // 잘못된 경우
            throw RuntimeException("Failed!!!")
        
        return ImmutableCash(
            dollars * factor,
            cents * factor
        )
    }
}

 

4. 시간적 결합(Temporal Coupling)

특정한 순서에 따라 결과값이 바뀌는 문제를 시간적 결합이라고 합니다. 

 

아래의 코드는 시간적 결합이 발생하는 코드입니다. 

val price = Cash()
price.dollars = 29
price.cents = 95
println("price=$price")
  1. Cash 객체를 생성 
  2. dollars, cents 속성값을 변경
  3. price를 프린트합니다.
 결과는 price=29.95

 

위 코드의 순서만 변경해보도록 하겠습니다.

val price = Cash()
price.dollars = 29
println("price=$price")
price.cents = 95
  1. Cash 객체를 생성 
  2. dollars 속성값을 변경
  3. price를 프린트합니다.
  4. cents 속성값을 변경
 결과는 price=29.00

 

코드의 순서만 변경하였는데 결과값이 달라진 것을 확인할 수 있습니다. 이 코드는 완벽한 절차지향적인 코드입니다.

객체지향적으로 변경하겠습니다.

val price = Cash(29, 95)
println("price=$price")

객체를 생성할 때 생성자에서 속성값을 할당해주기 때문에 순서에 상관없이 일정한 값을 리턴합니다. 

 

5.부수효과 제거(Side effect-free)

fun print(price: Cash) {
    println("Now Price: $price")
    price.mul(2)
    println("After Price: $price")
}

위와 같은 메서드가 있다고 가정합니다. 지금 가격과 이 후의 가격을 Print하는 메서드입니다. 

 

fun logic() {
    val five = Cash(5)
    // 수 십줄의 로직
    print(five)
    // 수 백줄의 로직
    
    println(five)  // 5가 아닌 10이다.
}

위 메서드를 많은 로직을 처리하는 Service에서 호출한다고 가정했을 때 개발자는 price 객체 속성값이 바꼈는지 찾아야합니다. 디버그를 해서 한 줄 한 줄... 하지만 불변성으로 선언한다면 내부 속성값이 변경될 수 없기때문에 이와 같은 Side effect가 발생하지 않을 것 입니다.

 

6. NULL 참조 없애기

class User(
    private val id: Int, 
    name: String? = null,
)

이 클래스는 name 속성값으로 NULL이 할당됩니다. 왜 이런 클래스가 필요한 걸까요? 앞 장에서 무수히 다룬 내용은 클래스는 유니크한 속성값들을 캡슐화한 것이라고 알고 있습니다. 하지만 그 중 속성값 중에 null이 필요한 경우가 무엇이 있을까요?

  • 클래스에 대한 설계를 잘못한 경우
  • 다른 클래스를 만들어야하는데 하나의 클래스에서 모두 처리하려고 하는 경우

즉, NULL 참조는 없어야하며 객체를 생성할 때 모든 속성값에 값을 부여해야만 합니다.

 

7. 스레드 안정성

YongCash라는 클래스에서 값을 할당 한 후 mul() 메서드를 호출하여 println하는 예제입니다.

fun main() {
    val price = YongCash(15, 10)
    val process = YongProcess()

    val a = GlobalScope.launch { process.go(price) }
    val b = GlobalScope.launch { process.go(price) }

    runBlocking {
        a.join()
        b.join()
    }
}

class YongProcess {
    fun go(price: YongCash) {
        println("Thread: ${Thread.currentThread().name}")
        price.mul(2)
        println(price)
    }
}

class YongCash(
    private var dollars: Int,
    private var cents: Int
) {
    fun mul(factor: Int) {
        this.dollars *= factor
        this.cents *= factor
    }

    override fun toString(): String = "$dollars.$cents"
}

 

이 코드에서 예측하는 결과는 "30.20, 60.40" 일 것입니다. 하지만 간혹 60.20이라는 값이 나타날 때가 있습니다. 이유는 멀티쓰레드에서 cents에 곱하기 2를 하기 전에 println를 찍기 때문입니다. 

위 코드로 30번 돌려봤으나 책에서 작성한 결과값은 나타나지 않았습니다.. 
정말 희박한 확률로 발생할 수 있는 문제이고 문제가 발생한다면 왜 발생했는지, 버그 구현이 어렵다는 의미입니다. 즉, 유지보수가 매우 어려워질 수 있습니다. 

 

이와 같은 문제를 방지하려면 불변성으로 작성해야합니다.

synchronized를 선언하면 안되나요??
synchronized를 선언하면 멀티쓰레드에서 문제가 발생하지 않을 것 입니다. 하지만 하나의 쓰레드에서 위 메서드를 사용중이라면 다른 쓰레드에서 접근이 불가능하기 때문에 멀티쓰레드의 장점을 살릴 수 없습니다.

 

8. 더 작고 더 단순한 객체

객체가 단순해질수록 응집도는 높아지고 유지보수가 쉬워집니다.

이 책에서 주장하는 클래스의 최대 라인 수는 250줄이라고 합니다.

가변 객체라면 많은 속성을 가지고 있을 확률이 높아지고 생성자 또한 많아질 것 입니다. 그렇다면 많은 라인이 추가될 것입니다. 하지만 불변성 객체라면 적은 속성을 가지기 때문에 적은 생성자를 생성하며 이 객체에서 해결하고자 하는 행위 또한 많치 않을 것 입니다. 그리하여 불변성 객체로 생성하고 적은 라인수를 유지하여 유지보수성을 높여야합니다.

 


9. 주관적인 생각

이번 장도 대부분의 내용에 동의합니다. 하지만 너무 극단적으로 사용하기엔 너무나도 아쉬운 부분들이 존재합니다.

 

첫 번째.

JPA의 Dirty-Checking 기능을 예로 들어보겠습니다.

 

Dirty-Checking은 Transaction 상태에서 DB 데이터를 조회한 Entity 객체가 영속성을 유지하는 과정에서 값이 변하고 Transaction이 종료될 때 DB의 값과 변경사항이 있다면 자동으로 Update를 수행하는 방법입니다. 이 경우 Entity객체의 속성값을 변경할 수 있어야만 Dirty-Checking을 사용할 수 있습니다. 

 

<이미지 첨부>

 

두 번째

JPA의 @DynamicInsert, @DynamicUpdate 기능을 예로 들어보겠습니다.

 

'NULL 참조 없애기' 관련 내용입니다. JPA에서 Entity는 DB 테이블과 동일한 개념의 객체입니다.

  • 테이블의 모든 필드에 반드시 값이 할당되어야만 하는 것은 아닙니다. DB Table Column에 Default 값을 설정하고 Entity에서는 Null값을 할당(@DynamicInsert)하여 Default 값을 DB에게 위임하는 방법도 존재합니다.
  • Dirty-Checking을 수행할 때도 Null값을 선언한다면 해당값은 무시하고 나머지 변경된 값 위주로 Update를 수행하는 기능(@DynamicUpdate)도 존재합니다.  

 

세 번째

 이 책의 2-4장에서 ( 메서드 이름을 신중하게 선택하세요 ) 빌더와 조정자로 나뉘어 메서드 이름을 선택하는 데 조정자는 객체 내부의 속성값을 변경하는 경우입니다. 하지만 이 장에서는 불변성을 주장하고 있습니다. 그렇다면 조정자를 사용하지 말라는 주장인데..?? 

 

제가 내린 결론은 객체지향은 이런식으로 작성할 수 있지만 모든 코드를 100% 객체지향으로 작성하기 보단 상황에 맞게 유연하게 객체지향과 절차지향적인 방법을 섞어서 작성해야한다고 생각합니다. 

 

 

이 책의 주장, 제 생각과 다르시면 댓글로 남겨주세요. :)



반응형