개발을 하다 자주 VO라는 개념을 들은적이 있습니다.
대략적으로 값 객체 패턴(Value object pattern) 즉, 객체를 값처럼 쓸 수 있고, 제약사항 중 하나는 객체의 인스턴스 변수가 생성자를 통해서 일단 설정된 후에는 결코 변하지 않음을 보장한다는 것입니다.
저도 위와 같이 얕게만 알고있었는데, 이번 기회를 통해 제대로 알아보려 합니다.
제가 봤던 재미난 글을 통해 포스팅을 작성하려합니다.
VO(Value Object)?
만약 아래와 같은 질문을 받았다고 생각해봅시다.
사람의 나이를 나타내기 위해 어떠한 변수 타입을 사용해야 할까요?
[] Integer
[] Boolean
[] String
어떠한 결정을 내릴건가요?
"Boolean은 true, false니까.. 일단 아니고,, Integer나 String중 하나겠지" 라고 생각했나요?
이렇게 생각하지 않았다면, 잠깐 미소를 띠고, 본인에게 박수를 쳐도 좋습니다.
정답은 "3개중에 없다" 입니다. 사람의 나이를 나타내기 위해선 Age라는 사용자가 선언한 타입을 사용해야 합니다.
"Integer로 충분하지 않나요?" 라고 물어본다면, 틀렸습니다.
Integer는 age(나이)가 가지지 않는 속성과 연산들을 가지고 있습니다.
- 두 나이를 더하거나 빼는 것이 말이 되나요? - 아마도 말이됩니다.
- 두 나이를 곱하거나 나누는 것이 말이 되나요? - 이렇게 생각하면 안됩니다.
- "부정적 연령"을 허용하는게 말이되나요? - 안됩니다.
Age는 정수가 아니기 때문에 위와 같이 표현될 수 없다는게 주요 쟁점입니다.
도메인의 객체를 나타내기 위해 primitive 타입을 쓰는 나쁜 관습은 primitive obsession
라 불릴정도로 흔합니다.
아직 Integer에 대한 얘기가 더 남았습니다.
나이를 나타내는 Integer 변수가 유효한 값으로 초기화 되게 하려면 어떻게 해야할까요?
아마 코드의 모든 곳, 즉 할당이 일어나기 전의 모든 곳에서 명시적으로 확인을 해야합니다.
또한 한번 할당이 된 integer값이 이후에 변경되지 않는다는 것을 어떻게 보장할까요?
불변의 변수를 허용하는 언어를 사용하거나, 선언시에 그렇게 하지 않는한, 아마 보장할 수 없을 것입니다.
primitive 타입이 도메인 객체를 모델링하기 위해 충분하지 않은 이유를 위에서 볼 수 있었습니다.
그러면 도대체 어떠한 것을 사용해야할까요?
여기서 Value Obejct 필요성이 나타납니다.
나이 예제에서 봤듯이 Value Object는 도메인에 입력된 타입값을 나타냅니다.
자 서론이 길었습니다. 이제 Value Object를 자세히 알아봅시다.
Value Object 기본 특성.
Value Object에는 기본 특성이 있습니다.
- Immutability(불변성) - 수정자가(setter) 없다
- value equality(값 동등성) - 내부 값 동등성 검사
- self validation(자가 유효성 검사) - 생성자에서 validate
1. Immutability(불변성) - 수정자가(setter) 없다
Value Object는 불변입니다.
불변이라는 뜻은 한번 생성되면 이후 내부 값을 바꿀 수 없음을 의미합니다. 즉 setter를 허용하지 않는다는 말입니다.
쉽게 풀어서, 생성자에게 하나 혹은 그 이상의 파라미터가 주입되서 객체가 만들어지면, 돌아갈수 없음을 의미합니다. 해당 객체는 GC에 의해 폐기 될 때까지 동일함을 보장합니다.
이후에 변경할 수 없다는게 왜 이점으로 작용할까요?
1.1 Hassle-free Sharing (번거로움 없는 공유)
Value Object는 코드의 다른 부분에서 수정되지 않기 때문에 Reference(참조)로 공유 할 수 있습니다.
이것은 side effect를 피하기 위해 사용되는 코드의 복잡성과 부하를 극적으로 감소시킵니다.
또한 Multi-thread 환경에서 그 이점이 뚜렷해집니다.
# 생성된 이후에는, 수정자(setter)를 통해 수정되지 않습니다.
final class Number{
private int number;
public Number(int number){
this.number = number;
}
}
1.2 Improved Semantics (향상된 의미)
불변성을 다른 규칙과 결합해야 합니다.
규칙: 무의미한 Getter를 Value Object에 추가하지 않기.
"나중에 필요하겠지"라는 생각으로 아무 메소드나 추가하지 않습니다.
초기 클래스에는 생성자와 private 접근 지정자인 속성만 있어야 합니다.
이를 통해 나중에 변환될 데이터에 대해 생각할 수 있습니다. 이는 Value Object가 언제 사용될지 이해할 때 메소드를 결정할 수 있음을 의미합니다.
이렇게 한다면, 무의미한 인터페이스를 피하고 Value Object에 대한 의미있는 이름과 동작을 정의할 수 있습니다.
Value Object를 조작하는 방법입니다.
생성자 또는 정적 메소드를 통해 새 인스턴스를 만들기
현재 객체에서 다른 객체를 생성하기
내부 데이터를 추출하여 다른 유형으로 변환하기
정적 팩토리 메소드 패턴을 활용하여 생성하기 (생성자를 private하게 바꿈)
위의 네 방법은 Value Object를 명확하게 해줍니다.
final class ComplexNumber {
private float realPart;
private float complexPart;
// 정적 메소드를 통해 새 인스턴스 만들기
public static ComplexNumber zero() {
return new ComplexNumber(0, 0);
}
// 생성자를 통해 새 인스턴스를 만들기
private ComplexNumber(float realPart, float complexPart) {
this.realPart = realPart;
this.complexPart = complexPart;
}
// 정적 팩토리 메소드 패턴을 활용하여 생성하기 (생성자를 private하게 바꿈)
public static ComplexNumber of(float realPart, float complexPart) {
return new ComplexNumber(realPart, complexPart);
}
// 현재 객체에서 다른 객체를 생성하기
public ComplexNumber add(ComplexNumber anotherComplexNumber) {
return new ComplexNumber(
realPart + anotherComplexNumber.realPart,
complexPart + anotherComplexNumber.complexPart
);
}
// 내부 데이터를 추출하여 다른 유형으로 변환하기
public String toString() {
return String.format("%f + %f i", realPart, complexPart);
}
}
2. value equality(값 동등성) - 내부 값 동등성 검사
친구와 포커게임을 하는 상상을 해봅시다. 서로 다른 포커 덱에서 각각 하나의 카드를 선택하고, 나와 친구가 같은 카드를 선택했는지 알아야 하는 상황입니다. 여러분은 어떻게 할건가요?
아마도 여러분은 친구의 카드를 가져와 자신의 카드와 같은 숫자인지, 같은 그림인지 확인할 것입니다. 즉, 동일한 속성을 가지고 있는지 여부를 확인할 것입니다.
다시, 지금 여러분이 앞서 확인 한 카드를 친구와 교환한다고 상상해봅시다. 친구 카드와 여러분의 카드가 달라진 것이 있나요?
달라진 것은 없습니다. 여러분은 여전히 같은 카드를 쥐고 있습니다. 두 카드가 같은 속성을 가지고 있기 때문에, 그 두 카드는 구별할 수 없게 되는 것입니다.
결국 앞의 두 카드는 Value Object라고 말할 수 있습니다.
내부의 값이 동일한 두 객체는 동일한 것으로 판단합니다. 즉, 내부 값이 모두 각각 동일한지 확인하여 동등성을 테스트 할 수 있습니다.
'동일성' 식별자를 기반으로 객체가 같은지를 판단할 수 있는 성질
'동등성' 상태를 이용해 두 값이 같은지 판단할 수 있는 성질
final class Card {
private Rank rank;
private Suit suit;
public Card(Rank rank, Suit suit) {
this.rank = rank;
this.suit = suit;
}
public boolean sameAs(Card anotherCard) {
return rank.sameAs(anotherCard.rank) &&
suit.sameAs(anotherCard.suit);
}
}
- 무의미한(나중에 필요하겠지란 생각으로) equals()와 hashCode()는 사용하지 않아야 합니다.
3. self validation(자가 유효성 검사) - 생성자에서 validate
Value Object는 context에서 유효한 값만 허용합니다. 이는 유효하지 않는 값으로 값 객체를 만들 수 없음을 의미합니다.
생성자가 주입될 때 값의 유효성을 확인해야 합니다. 유효하지 않으면 의미있는 에러를 표출합니다. 이는 객체의 인스턴스에 더이상 if가 없음을 의미합니다. 모든 유효성 검사는 생성 시간에 이루어집니다.
이러한 강제 유효성 검사는 의미 있고 명시적인 방법으로 도메인 제약 조건을 표현하는데도 유용합니다.
public class Numbers {
private List<Integer> numbers;
private Numbers(List<Integer> numbers) {
this.numbers = numbers;
validateNumbers();
}
public static Numbers of(List<Integer> numbers) {
return new Number(numbers);
}
private void validateNumbers() {
if (this.numbers.contains(0)) {
throw new IllegalArgumentException("0 이외의 값만 허용합니다");
}
}
}
- 여기선 값 주입 후 validate를 진행했는데. 이는 방어적 복사 기법이라 합니다. (Multi-Thread에 안전)
요약
객체를 Value Object로 만들기 위한 체크 리스트
- 불변하며 수정자가 없음
- 도메인의 의미론을 반영
- 런타임 동안 정보의 흐름과 변환 방법을 보여줌.
- default나 쓸모없는 getter 메소드가 없다
- private 속성을 가지며 다른 Value Object와 비교할 수 있다.
댓글