[Kotlin] 값 객체(Value Object)와 불변성(Immutablity) 그리고 코틀린 value 클래스
1. 값 객체(Value Object)와 불변성(Immutablity)
[ 값 객체(Value Object) 와 참조 객체(reference object) ]
프로그래밍을 하다 보면, 사물을 복합적으로 표현하는 것이 유용한 때가 있다. 예를 들어, 좌표는 x 값과 y값으로 표현될 수 있고, 금액은 숫자와 통화로 구성될 수 있다.
class Point(
val x: Int,
val y: Int,
)
이때 두 객체가 동일한지를 판단해야 하는 경우가 있는데, 예를 들어 두 Point 객체가 모두 (2, 3)이라는 좌표를 나타낸다면, 이를 동일하게 취급하는 것이 합리적일 수 있다.
val p1 = Point(2, 3);
val p2 = Point(2, 3);
check(p1 == p2)
이렇듯 속성 값에 의해 동등한 객체를 값 객체(Value Object)라고 부른다. 이와 달리 속성 값이 아닌 식별자를 바탕으로 동등성을 구분할 수 있는 참조 객체(Reference Object)라는 개념 역시 존재한다. 예를 들어 여러 개의 주문서가 존재하고, 각각의 주문서마다 개별의 id와 같은 식별자가 존재하여 주문서를 식별할 수도 있다. 따라서 우리는 객체에 대한 구분을 값 객체(value object)와 참조 객체(reference object)로 나누어 바라볼 수 있어야 한다.
자바(Java)나 코틀린(Kotlin) 또는 자바스크립트(JavaScript)와 같은 프로그래밍 언어는 객체의 동등성을 비교할 때 기본적으로 객체의 참조(reference)를 비교한다. 따라서 우리가 원하는 클래스를 값 객체로 만들기를 원한다면 동등성을 비교하는 메서드를 오버라이딩하면 된다. 대표적으로 자바 언어에서는 equals와 같은 메서드를 오버라이딩해야 할 것이다.
프로그램을 가장 훌륭하게 작성하는 방법은 상태가 변경되는 오브젝트들과
수학적인 값을 나타내는 오브젝트들의 조합으로 표현하는 것이다.
- Kent Beck -
어떤 개념을 참조 객체(reference object)로 처리할지 또는 값 객체(value object)로 처리할지는 문맥(Context)에 따라 다르다. 만약 시스템 내에서 해당 객체를 계속 추적해야 하며, 객체가 표현하는 개념이 유일하게 하나만 존재해야 한다면 참조 객체로 만들 수 있다. 반면에 객체가 추적할 필요가 없는 단순한 값이며, 속성값이 동일하면 동일한 객체로 간주해도 무방하다면 값 객체로 만들 수 있다.
대부분의 경우 집 주소는 값 객체로 처리하는 것이 좋을테지만, 더 정교한 매핑 시스템에서는 우편 주소를 복잡한 계층 모델에 연결하는 것이 더 의미가 있을 수 있다. 이렇듯 문맥을 알지 못하고서는 상황에 맞는 적합한 해결책을 선택할 수 없다. 그럼에도 불구하고 대표적으로 값 객체를 사용할 수 있는 케이스들을 살펴볼 수 있다.
문자열과 같은 일반적인 타입을 적절한 값 객체로 대체할 수 있다. 예를 들어, 전화번호를 문자열로 표현하면, 변수에 대한 타입 검사와 유효성 검사 등을 수행할 수 있고, 부적절한 처리(함수 호출 등)을 방지할 수 있습니다. 그 외에도 점(Point), 금액(Money), 범위(Range) 등 역시 값 객체로 구현하기 적합하다. 이들의 경우 추가적으로 풍부한 동작을 구현할 수 있다. 예를 들어, Range 클래스를 사용해 보면, 시작과 끝 속성의 중복된 조작을 피하고 더 풍부한 동작을 활용할 수 있다.
값 객체는 전체 도메인의 복잡성을 낮추는 유용한 분석 개념이다. 풍부한 도메인 모델(rich domain model)의 작성을 위해서는 유용하지만 비즈니스 적인 관점에서 가치가 없는 작은 개념을 값 객체로 모델링함으로써 추적성과 별칭 문제에 대한 부담 없이 해당 객체를 참조할 수 있도록 한다. 어떤 개념을 값 객체로 취급하는 순간 해당 객체의 생명 주기가 매우 짧고 단순해질 것이다. 뿐만아니라 이렇게 구현되어 도메인 특화된 값 객체는 코드에서 리팩토링의 중심이 되어 시스템을 크게 단순화할 수 있도록 도와줄 수 있다.
[ 값 객체(Value Object) 와 불변성(Immutability) ]
값 객체의 장점 중 하나는, 메모리 내의 동일한 객체에 대한 참조인지 아니면 값이 동일한 다른 객체에 대한 참조인지를 신경 쓸 필요가 없다는 것이다. 그러나 조심하지 않으면 이러한 안일함이 문제를 일으킬 수 있다.
Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));
// this means we need a retirement party
Date partyDate = retirementDate;
// but that date is a Tuesday, let's party on the weekend
partyDate.setDate(5);
assertEquals(new Date(Date.parse("Sat 5 Nov 2016")), retirementDate);
// oops, now I have to work three more days :-(
이것은 별칭 문제(Aliasing Bug)의 한 예시로, 한 곳에서의 값 변경이 예상치 못한 다른 곳에도 영향을 미칠 수 있다. 이런 버그를 피하기 위해 마틴 파울러(Martin Fowler)는 값 객체가 불변(immutable)이어야 한다고 얘기한다. 값을 변경하고 싶다면, 기존 객체를 수정하는 대신 새로운 객체를 생성하는 것이다. 객체의 경우, 단순히 설정 메서드(setter)를 제공하지 않음으로써 이를 달성할 수 있다. 불변성(immutability)은 별칭 문제를 피하는 데 가장 좋은 기법이다.
Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));
Date partyDate = retirementDate;
// treat date as immutable
partyDate = new Date(Date.parse("Sat 5 Nov 2016"));
// and I still retire on Tuesday
assertEquals(new Date(Date.parse("Tue 1 Nov 2016")), retirementDate);
[ 구현 수준과 의미 수준에서 바라본 값 객체의 불변성(Immutability) ]
값 객체가 불변성이 되면 좋은데, 그렇다면 “값 객체의 본질에 불변성이 포함되는가?” 역시 고민해볼 수 있다.
설계 관점에서 우리가 다루는 모든 것은 의미 수준과 구현 수준을 함께 고민해야 하며, 값 객체 역시 예외가 아니다. 의미 수준(Semantic Level)에서 값 객체는 불변이며 참조 투명성을 만족해야 하지만, 구현 수준(Implementation Level) 에서는 성능이나 메모리 이슈로 값 객체가 불변이 아닐 수도 있다. 대표적으로 자바나 코틀린의 Collection 라이브러리는 의미적으로는 값 객체지만, 구현 수준에서는 가변 객체이거나 불변 객체와 가변 객체를 함께 제공하기도 한다.
마찬가지로 식별성 관점에서도 의미 수준와 구현 수준이 달라질 수 있는데, 예를 들어 Java에서 String 리터럴은 의미 수준에서는 값 객체이다. 하지만 구현 수준에서는 String Pool(Flyweight 패턴의 적용 예)로 관리하기 때문에 상태를 비교하지 않고 식별자를 비교한다. 이렇듯 우리는 의미 수준과 설계 수준을 구분해서 바라볼 필요가 있다.
참고로 현대의 프로그래밍 언어들은 이러한 값 객체를 보다 쉽게 구현할 수 있도록 자체적으로 지원하려는 경향이 있다. 물론 위의 값 객체와 100% 딱 맞는 구현은 아니지만, 그럼에도 불구하고 살펴보고 학습해볼 여지는 있다.
2. 코틀린 value 클래스와 @JvmInline 애노테이션
[ Kotlin value 클래스 ]
애플리케이션을 개발하다 보면 특정 값을 도메인 타입으로 표현하기 위해 해당 값을 클래스로 래핑하는 것이 유용한 경우가 있다. 예를 들어 주식 거래를 위한 프로그램을 개발한다고 하면, 금액은 항상 양수여야 하므로, 다음과 같이 항상 양수의 값만 갖는 Money 클래스를 생성할 수 있다.
class Money(
private val amount: Int
) {
init {
require(amount > 0)
}
}
그러나 이와 같은 방식은 추가적인 힙 할당을 요구하므로 런타임 오버헤드를 유발할 수 있으며, 특히 원시 타입(primitive type)을 감싸는 경우에 심해질 수 있다.
코틀린(Kotlin)은 이러한 문제를 해결하기 위해 인라인 클래스(inline class)라는 특별한 종류의 클래스를 도입했다. 인라인 클래스는 값 기반 클래스(value-based class)의 하위 집합으로, 고유한 정체성(identity)을 가지지 않으며, 오직 값을 담을 수만 있다. 인라인 클래스를 선언하려면 다음과 같이 value라는 키워드를 클래스의 이름 앞에 작성해주면 된다.
value class Money(
private val amount: Int
) {
init {
require(amount > 0)
}
}
이때 인라인 클래스는 기본 생성자(primary constructor)에서 단일 속성을 가져야 하며, 런타임 시에 인라인 클래스의 인스턴스는 이 단일 속성으로 표현된다. 즉, 별도의 객체로 만들어지는 것이 아니라 해당 단일 속성의 타입으로 대체되는 것이다.
이것이 인라인 클래스의 주요 특징으로, 클래스 이름에 inline이라는 용어가 사용된 이유이기도 하다. 클래스의 데이터가 사용처에 "인라인"된다는 뜻이다.
코틀린과 함께 스프링 프레임워크 등을 사용하여 JVM 백엔드를 개발하는 경우에는 클래스 선언 부에 @JvmInline 애노테이션을 함께 붙여주어야 한다. 만약 @JvmInline 애노테이션을 붙이지 않으면 Kotlin 컴파일러는 이 클래스가 일반적인 Kotlin 클래스라고 가정하여 컴파일을 하여 인라인 처리나 불필요한 객체 생성 생략이 처리되지 않는다. 따라서 JVM 환경이라면 @JvmInline 애노테이션을 선언하여 다음과 같은 최적화와 이점을 누릴 필요가 있다.
- 인라인 최적화: 인라인 처리되기 때문에 value class를 사용한 곳에서 불필요한 객체가 생성되지 않으며, 성능이 최적화됨
- 원시타입 최적화: 만약 value class가 기본형 데이터 타입을 감싸고 있다면, 해당 기본형 타입으로 컴파일됨.
- 호환성: Java는 value class 개념을 이해하지 못하기 때문에 @JvmInline 애노테이션이 붙어야 Java와의 상호 운용성에서 해당 클래스가 제대로 동작함
참고자료
- https://martinfowler.com/bliki/ValueObject.html
- https://kotlinlang.org/docs/inline-classes.html#inheritance
- https://www.facebook.com/100002272078924/posts/8180562412029413