이 글은 엘레강트 오브젝트 새로운 관점에서 바라본 객체지향 도서를 보며 스터디한 글입니다.
책에서 주장하는 내용을 정리하였으며 예제들은 모두 코틀린 코드로 변환하여 작성하였습니다.
목차
- 불변이란?
- 식별자 가변성(Identity Mutability)
- 실패 원자성(Failure Atomicity)
- 시간적 결합(Temporal Coupling)
- 부수효과 제거(Side effect-free)
- NULL 참조 없애기
- 스레드 안정성
- 더 작고 더 단순한 객체
- 주관적인 생각
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
- 동일하지 않은 2개의 Cash 클래스 생성합니다. ( 하나는 값이 5가 할당된 five, 하나는 값이 10이 할당된 ten )
- 생성한 2개의 Cash 클래스를 Map에 Put 합니다.
- 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")
- Cash 객체를 생성
- dollars, cents 속성값을 변경
- price를 프린트합니다.
결과는 price=29.95
위 코드의 순서만 변경해보도록 하겠습니다.
val price = Cash()
price.dollars = 29
println("price=$price")
price.cents = 95
- Cash 객체를 생성
- dollars 속성값을 변경
- price를 프린트합니다.
- 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% 객체지향으로 작성하기 보단 상황에 맞게 유연하게 객체지향과 절차지향적인 방법을 섞어서 작성해야한다고 생각합니다.
이 책의 주장, 제 생각과 다르시면 댓글로 남겨주세요. :)
'Study > object' 카테고리의 다른 글
[엘레강트 오브젝트] 2-8장 모의 객체(Mock) 대신 페이크 객체(Fake)를 사용하세요. (0) | 2021.09.05 |
---|---|
[엘레강트 오브젝트] 2-7장 문서를 작성하는 대신 테스트를 만드세요 (0) | 2021.09.01 |
[엘레강트 오브젝트] 2-5장 퍼블릭 상수(Public Constant)를 사용하지 마세요 (0) | 2021.08.21 |
[엘레강트 오브젝트] 2-4장 메서드 이름을 신중하게 선택하세요 (0) | 2021.08.18 |
[엘레강트 오브젝트] 2-3장 항상 인터페이스를 사용하세요 (0) | 2021.08.17 |