코드가 깔끔해지는 VO(Value Object)를 써야하는 이유
들어가며
값 객체(Value Object) 를 활용하여 코드를 더 깔끔하게 작성했던 경험을 살려 값 객체(VO, Value Object 이하 ‘값 객체’)에 대해서 개념과 이점을 정리한 글로 남기고 싶었다.
값 객체의 개념을 공부하다보면, 엔티티(Entity, 이하 ‘엔티티’) 그리고 더 나아가 도메인 주도 개발(DDD)라는 설계 패러다임까지 연관된, 개념으로 연결되는 것을 확인할 수 있다.
값 객체와 엔티티. 이 둘은 어떤 것을 의미하고 이와 연관된 도메인 주도 개발(DDD)은 또 무엇일까?
본 글에서는 이들의 개념을 살피기 위해 도움을 줄 수 있는 도메인 모델에 대해 간략하게 언급한다. 이어서 엔티티와의 비교를 통해 값 객체란 어떤 것인지 알아본다. 또 값 객체를 활용하여 어떤 이점을 취할 수 있는지를 다뤄보려고 한다.
값 객체에 대한 개념을 처음 접하거나, ‘도메인 주도 개발(DDD) 시작하기’ 책을 막 읽기 시작했다면 이 글이 도움이 될 수 있다. 값 객체를 적절하게 사용할 수 있다면 우리가 원하는 비지니스 로직을 캡슐화하면서 테스트가 용이한 구조로 만들 수 있다. 이것이 어떻게 가능한지 차근차근 알아보자.
도메인과 도메인 모델
값 객체를 언급하기 위해서는 “도메인”, “도메인 모델“의 이해가 선행적으로 필요하다. 도메인 모델이 무엇인지 간략하게만 짚고 넘어가자.
도메인은 실제 세계의 주제, 문제 영역을 의미한다. 도메인 모델이란 소프트웨어로 해결하고자하는 실제 문제의 개념과 규칙을 모델링한 것을 의미한다. 해결하고자하는 문제 영역에서 필요한 개념들을 표현한 것을 의미한다.
이를 간단한 비유를 통해 간접적으로 이해해 볼 수 있다.
동물원의 풍경을 바라보자. 우리를 거니는 동물들, 밥을 주는 사육사, 안내 직원들, 관람하러온 관람객들.
이 전체적인 풍경을 바라볼 때, 동물원은 ‘도메인’ 이 된다.
도메인 모델은 동물원에서 일어나는 일들을 설명해놓은 팜플렛 같은 역할을 한다. 예를 들어 ‘대동물관’에는 코끼리 ‘도토’가 살고 있다거나, 매일 김사육사가 동물들에게 먹이를 주는 시간이라던가, 코끼리 ‘도토’와 ‘잠보’의 관계가 설명되어 있는 등, 도메인 모델을 보면 도메인을 알 수 있게 된다.
코끼리 ‘도토’ 와 ‘잠보’ 는 서로를 구별할 수 있는 특별한 이름을 가지고 있다. 단순히 코끼리 1, 2가 아닌 대상을 구별할 수 있는 식별자를 갖는다. 여기서 코끼리는 엔티티가 된다.
코끼리 ‘도토’ 와 ‘잠보’ 가 매일 먹는 먹이 더미가 있다. 이 먹이 더미는 당근, 수박, 양배추로 이루어져있다. 당근과 수박에는 별도의 이름이 없다. 대신에 먹이 더미의 구성을 보고 코끼리가 먹는 코끼리 먹이 더미와 기린 먹이 더미로는 구분지을 수 있다. 이런 먹이 더미를 값 객체로 생각해볼 수 있다.
위의 예시를 통해서 도메인, 도메인 모델, 엔티티, 값 객체 이해에 대한 배경지식이 와닿았다면 다음은 엔티티와의 비교를 통해 값 객체를 본격적으로 살펴 볼 차례다.
엔티티(Entity)와 값 객체(VO, value object)란?
도메인 모델에서 중요한 역할을 하는 2가지 개념이 있다. 바로 엔티티와 값 객체이다.
엔티티(Entity)란?
엔티티는 다음과 같은 속성을 갖는 도메인 모델을 의미한다.
- 고유함을 확인할 수 있는 식별자를 갖는다.
서로 다른 엔티티는 서로 다른 식별자를 갖는다. 즉, 엔티티를 구별할 수 있는 속성인자를 갖는다. 이것이 값 객체와 다른 가장 큰 특성이다.
- 엔티티는 자신의 생명주기 안에서 가지고 있는 속성의 상태가 변경될 수 있다. 즉, 가변성을 가진다. 엔티티는 시간의 흐름에 따라 엔티티가 가지고 있는 속성의 상태값이 변경될 수 있다. 이 또한 값 객체와 구별되는 또 다른 특성이다.
예시를 통해 엔티티만이 갖는 속성을 이해해보자.
이 유저 엔티티는 앞서 언급한 엔티티의 2가지 속성을 충족한다.
첫째로, 고유한 자신을 나타내기 위한 id
값이 존재한다. 여기서 id
값은 앞서 설명한 식별자에 해당된다. 만약 다른 User 모델이 존재한다면 id
값을 기준으로 동등성을 비교하여 서로 같은 모델인지 아닌지를 확인할 수 있다.
두번째로, User 모델은 시간의 흐름에 따라 속성의 상태가 변경될 수 있다. User가 다른 User를 팔로우한다면 followingCount가 증가할 것이고, 누군가 해당 User를 팔로우한다면 followerCount
가 증가된다.
값 객체(Value Object)란?
값 객체란 도메인 모델에서 개념적으로 의미가 동일한 속성 값들의 묶음을 의미한다.
일반적으로 객체의 속성 값들은 원시타입(Primitive type)이다. 이러한 원시타입들을 묶어 새로운 도메인 개념을 표현하는 역할을 한다.
블로그 포스트를 나타내는 도메인 객체를 통해 값 객체의 구체적인 사례를 살펴보자.
- 포스트는 구별을 위한 식별자
id
를 갖는다. 위에서 살펴본 엔티티에 해당한다. - 포스트 엔티티는 작성자의 이름, 작성자의 이메일을 속성으로 갖는다.
- 포스트 엔티티는 포스트의 좋아요 수를 속성으로 갖는다.
- 포스트 엔티티는 내용, 수정 여부, 시간을 속성으로 갖는다.
1
2
3
4
5
6
7
8
9
10
public class Post {
private final Long id;
private final String authorName;
private final String authorEmail;
private final String content;
private final int likeCount;
private final boolean isEdit;
private final DateTime dateTime;
...
}
물론 다음과 같이 원시타입을 이용해서 객체의 속성을 정의하는 것이 문제가 되진 않는다. 하지만 값 객체를 이용하면 표현력이 풍부한 코드를 만들 수 있다.
어떤 값들을 묶어 값 객체로 표현할지는 요구사항에 따라서 달라진다. 위의 도메인 객체에서는 작성자와 관련된 속성(authorName
, authorEmail
) 들을 묶어 “작성자”라는 도메인 개념으로 표현할 수 있을 것 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Author {
private final String name;
private final String email;
public Author(String name, String email) {
this.name = name;
this.email = email;
}
public String getName(){
return name;
}
public String getEmail(){
return email;
}
}
위와 같이 “작성자(Author)”를 표현하는 값 객체를 만들 수 있다.
1
2
3
4
5
6
7
8
9
public class Post {
private final Long id;
private final Author author;
private final String content;
private final int likeCount;
private final boolean isEdit;
private final DateTime dateTime;
...
}
이전 보다 Post가 가지고 있는 속성이 “작성자”와 연관되어 있다는 것을 명확하게 표현할 수 있다.
식별자의 유무와 비교 방법
Author
객체를 보자. 엔티티와는 다른 부분이 존재한다. 값 객체는 식별자가 없다. 그렇다면 서로 다른 값 객체는 어떻게 비교할 수 있을까?
서로 다른 “Author” 값 객체의 비교는 식별자가 아닌 각각의 속성의 값으로 동등성 비교를 한다.
각 속성값들이 서로 일치하는지 비교해야하기 때문에 객체는 equals
와 hashCode
메서드의 오버라이드가 필요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Author {
private final String name;
private final String email;
...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Author author = (Author) o;
return Objects.equals(name, author.name) &&
Objects.equals(email, author.email);
}
@Override
public int hashCode() {
return Objects.hash(name, email);
}
}
동등성(equality) & 동일성(identity) 비교
동등성(equality) 비교란 두 객체가 논리적으로 같은지를 확인하는 비교를 말한다.
주로 객체가 가지고 있는 속성 값이 같은지를 비교한다.이와 대조적인 개념으로 동일성(identity) 비교가 있다. 동일성 비교는 객체가 물리적으로 같은지 확인하는 비교를 말한다. 메모리 주소가 실제로 일치하는지를 비교한다.
불변 타입
또 하나 엔티티와 다른 점은 속성의 상태가 변경될 수 있는 엔티티와는 다르게 값 객체는 속성의 상태가 변경될 수 없다. 즉, 불변(immutable) 타입으로 구현된다.
왜 값 객체는 불변타입으로 구현하는 것으로 방향이 잡혔을까 ?
그 이유는 값 객체의 정체성에 따른 특성 때문이라고 나는 생각한다. 앞서 값 객체는 속성 값의 동등성 비교를 통해 서로 다른 값 객체를 구분한다고 했다. 식별자의 의미보다 속상 값 자체에 값 객체의 의미가 있기 때문일 것이다.
값 객체의 속성 값 자체가 의미가 있는 상황에서 불변타입은 꽤나 좋은 이점들을 가져다준다.
첫번째로 한번 생성된 속성 값은 변경될 수 없으니 값 객체를 신뢰성 있게 사용할 수 있다. 신뢰성 있게 사용한다는게 무슨말일까? 다음은 불변상태가 아닌 값 객체를 가정한 예시이다.
1
2
3
4
5
6
Author author = new Author("홍길동", "hong@email.com");
Post post = new Post(author...);
author.setName("탐관오리");
System.out.println(post.author.getName()) /// expected: 홍길동, actual: 탐관오리
만약 Author
에 .setName()
과 같이 name
속성을 변경할 수 있는 메서드가 제공된다면 코드 어딘가에서 name
속성을 변경할 수 있는 “가능성” 이 생기게 된다. 따라서 개발자는 author
의 name
객체에 접근할 때마다, 해당 값이 예상하는 값(‘홍길동’)일 것이라고 확신할 수 없게 된다. 어디서 변경이 가능한지를 코드를 돌아다니며 찾아봐야한다.
값 객체의 변경 지점이 신뢰성에 문제가 발생하는 포인트가 되어버린다.
값이 변경될 수 없으니 동일한 입력값에 대해 언제나 동일한 결과 값을 반환한다. 외부 상태에 영향을 미치거나 받지 않는다. 이를 참조 투명성을 지킨다고 표현한다.
값이 변할 수 있는 가변 객체의 경우 멀티스레드 환경에서 동시 접근시 값이 예상치 못하게 변경될 가능성이 있지만 값이 변하지 않는 불변타입이기 때문에 여러 스레드가 동일한 값 객체에 접근해도 변하지 않고 안전하게 사용이 가능하다. 이를 스레드 안전(Thread-safe) 하다고 한다.
따라서 값 객체에는 속성 값을 변경할 수 있는 수정자, setter
를 제공하지 않는다.
📌 값 객체 정리
값 객체의 특성을 정리하면 다음과 같다.
- 값 객체는 식별자가 존재하지 않는다.
- 속성 값 자체로 동등성 비교를 한다.
- 때문에
equals
&hashCode
를 오버라이드 해야한다.
- 값 객체는 불변타입을 보장한다.
- 덕분에 값 객체를 신뢰성있게 사용할 수 있고,
- 참조투명성과 스레드 안전한 이점을 갖는다.
- 따라서
setter
를 제공하지 않는다.
둘의 차이점
엔티티와 값 객체를 정확히 동일 레벨에 두고 비교하기는 어렵다. 일반적으로 엔티티 안에 값 객체가 포함되어 엔티티의 생명주기에 따라가는 형태를 띈다. 따라서 엔티티와 값 객체는 서로 상호 보완적인 관계를 갖는다.
값 객체는 엔티티에 속하면서 엔티티가 표현하는 도메인 모델을 더욱 풍부하게 도와주는 역할이라고 정리할 수 있을 것 같다.
다음과 같이 정리해볼 수 있다.
구분 | 엔티티(Entity) | 값 객체(VO, Value Object) |
---|---|---|
식별성 | 고유한 식별자를 통해 식별 | 속성 값에 의해 식별 |
동일성 판단 | 동일한 식별자일때 == | 속성 값이 동등할 경우 equals |
상태 | 가변상태 | 불변상태 |
역할 | 도메인 모델의 중요 상태와 행위를 표현 | 도메인의 특정 속성이나 개념을 표현 |
생명주기 | 엔티티만의 생명주기를 가짐 | 엔티티를 따라감 |
값 객체(VO, value object)의 이점
원시타입의 속성을 한번 더 감싸 값 객체로 만드는 행위는 굳이 불필요해 보인다. 값 객체로 만드는 행위가 도대체 어떤 이점들을 가져다 줄 수 있을까?
1. 캡슐화로 인한 간결하고 유연한 코드
원시타입 속성을 별도의 객체로 한번 더 감싸므로써 이 공간에 원시타입 속성과 값과 관련된 로직들을 같이 보관할 수 있다. 이를 캡슐화라 한다.
속성과 밀접한 행위들을 외부로 노출시키지 않는다. 이를 통해 코드의 중복을 방지하고, 더 간결하고 유연한 코드를 작성할 수 있다. 바로 뒤에 언급하겠지만, 값과 관련된 행위들을 한 곳에 집중시킴으로써 작은 단위에서의 테스트 진행이 매우 수월해진다.
예를 들어 “피드의 컨텐츠의 길이는 200자를 넘어서는 안된다” 라는 비지니스 로직이 존재한다라고 했을때, 이러한 요구사항을 만족하는지 확인하려면 어떻게 해야할까? 원시타입을 그대로 사용하는 상황과 값 객체를 사용했을 때를 보면서 차이점을 확인해보자.
원시타입 속성 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
class Feed {
String content;
}
class FeedService {
Feed createFeed(String content) {
if (//**검증 로직**//) {
throw new Exception;
}
Feed feed = new Feed(content);
repository.save(feed);
}
}
피드를 생성하고 저장하는 로직을 작성하는데 있어 다음과 같은 구조의 코드 작성이 필요해진다. 나름 깔끔하고 괜찮은 코드지만, 여기에 몇 가지 질문을 통해 고민해볼만한 점들을 도출해볼 수 있다.
첫번째로 던질 질문은 FeedService
의 createFeed
는 어떤일을 하고 있을까?
- 피드를 생성하기 위해 컨텐츠를 검증하는 일
- 피드를 생성하고 저장하는 일
이어지는 질문으로, 만약 검증 로직이 하나가 아니라 5개, 10개 혹은 그보다 더 많이 늘어난다면?
- 검증 로직이 늘어날수록
createFeed
메서드의 검증 코드도 같이 늘어나게 된다.- 어떤 일을 하는 메서드인지 파악하기가 어려워진다.
- 만일 다른 곳에서 피드를 생성해야하는 일이 있을 경우, 검증 로직을 또 작성해주어야 한다. 중복된 코드가 작성된다.
- 만일 중복된 코드에서 수정해야 하는 일이 발생한다면 동시에 수정해야하는 포인트들이 많아진다.
createFeed
메서드에 검증 코드가 위치함으로써 서로 강하게 결합된다.
결론적으로 createFeed
가 해야하는 일이 많아지게 된다. 비지니스 로직은 언제든지 변할 수 있다. 관련된 코드들도 함께 변경되어야 한다면 의도하지 않은 실수가 끼어들게 될 여지를 남기게 되는게 아닐까?
값 객체 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Feed {
FeedContent content;
}
// ...
class FeedContent {
String content;
public FeedContent(String content){
checkContentLength(content);
}
private void checkContentLength(content) {
if (//**검증 로직**//) {
throw new Exception;
}
}
}
// ...
class FeedService {
Feed createFeed(String content) {
Feed feed = new Feed(content);
repository.save(feed);
}
}
값 객체를 사용하면 값과 관련된 로직들을 한 곳에 집중시킬 수 있다. 이 덕분에 FeedService
의 createFeed
메서드는 (굳이? 알지 않아도 되는) 검증 로직이 아니라 피드를 생성하고 저장하는 것에만 집중할 수 있다. 뿐만 아니라 검증 로직이 변경된다면, FeedContent
값 객체의 검증로직 부분만 수정해주면 된다.
검증 로직을 값 객체 안으로 옮김으로써 관련이 높은 개념들끼리 집중되며 응딥도는 올라갔다. 덕분에 중복되는 코드도 줄어들고, 서로가 해야할 책임이 보다 명확해졌다.
2. 테스트 용이
값 객체를 사용해 값과 값과 관련된 로직을 한 곳에 집중시키면 따라오는 이점이 한 가지 더 생긴다. 바로 테스트가 쉬워진다는 것이다.
위의 코드에서 원시타입 속성을 사용했을 때, “피드의 컨텐츠의 길이는 200자를 넘어서는 안된다” 라는 로직이 잘 동작하는지를 테스트하기 위해서는 어떤 것들이 필요할까?
Feed
객체에 들어갈 문자열과FeedService
의createFeed
메서드가 필요하다.
원시타입 속성 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void givenFeedServiceAndOverLengthContent_whenCreatedFeedContent_thenThrowsException() {
// given
FeedRepository repository = new FeedRepository();
FeedService feedSevice = new FeedService(repository...);
// 기타 FeedService에 필요한 의존관계 주입
...
String overLengthContent = 'a'.repeat(201);
// when & then
asserThrow(Exception.class, () -> feedService.createFeed(overLengthContent));
}
테스트 하고자 하는 것은 “피드 컨텐츠의 길이” 이지만, 피드를 생성하고 저장하는 createFeed
메서드가 필요하다. 조금 이질감이 든다. 테스트의 대상이 FeedService
의 createFeed
가 되어버린다.
뿐만 아니라 “피드의 컨텐츠의 길이”을 테스트할 때 뒤따라오는 createFeed
의 ‘피드를 생성하고 저장’하는 기능이 반드시 정상적으로 동작해야만 한다.
그래야지만 이 테스트가 정상적으로 통과했는지, 실패했는지 확신할 수 있기 때문이다. 한번에 여러 가지를 테스트하게 되어버리므로 이처럼 테스트에 의존관계가 생기는 것은 적절하지 않다.
값 객체 사용
1
2
3
4
5
6
7
8
9
@Test
void givenOverLengthContent_whenCreatedFeedContent_thenThrowsException() {
// given
String overLengthContent = 'a'.repeat(201);
// when & then
asserThrow(Exception.class, () -> new FeedContent(overLengthContent));
}
반면, 값 객체를 사용했을때는 “피드의 컨텐츠의 길이는 200자를 넘어서는 안된다” 라는 로직을 테스트하기 위해 값 객체인 FeedContent
만 필요하다. 검증로직이 FeedContent
의 checkContentLength
안에 위치하기 때문이다. 그럼 다음과 같이 테스트 코드를 작성해볼 수 있다.
훨씬 간단해졌다. 이제는 테스트를 위해 FeedService
가 필요하지 않다.
3. 명확한 의미를 표현
원시타입 대신 값 객체를 사용하면, 딱딱한 원시타입에 비지니스에 적합한 의미를 담아 표현할 수 있다. 즉, 원시타입을 래핑하는 과정을 통해서 한 단계 높은 차원에서의 의미 부여가 가능해진다.
예를 들면 Integer 타입의 속성에 1을 더하는 행위를 값 객체를 사용하면 “좋아요 수를 1 증가시킨다”라는 비지니스 행위로 표현이 가능하다.
원시타입 속성 사용
1
2
Post.likeCount = Post.likeCount + 1;
Post.likeCount = Post.likeCount - 1;
위의 코드는 “likeCount라는 변수에 1을 증가/감소시킨다”라고 읽힌다. 어떻게 하고자 하는지, 방법적인 의미가 더 드러나는 반면
값 객체 사용
1
2
Post.likeCount.like();
Post.likeCount.unlike();
값 객체를 활용한 코드는 “포스트에 좋아요/안좋아요” 행위를 하는 것으로 읽힌다. 무엇을 하고자 하는지가 명확해진다.
이처럼 구체적인 구현 방식을 값 객체 안으로 숨김으로써 비지니스 의미를 더 잘 드러내는 코드 작성이 가능해진다.
4. 신뢰성이 높다.
값 객체는 불변 타입으로 구현되기 때문에 어떤 값이 반환될 지 예상이 가능하다. 때문에 개발자는 값 객체가 가져올 부수효과를 걱정하지 않아도 된다. 이를 ‘신뢰성이 높다’라고 표현한다.
만약 값 객체가 불변타입이 아니어서 수정이 가능하다면, 값 객체를 호출하는 시점에 어떤 값이 튀어나올지 예상할 수 없게 된다. 때문에 개발자는 이 값 객체가 어디서 수정되었는지, 변화가 개입될 포인트가 어디인지 확인해봐야만 한다. 이를 ‘신뢰성이 낮다‘라고 표현한다.
값이 변하지 않는 특성 덕분에 값 객체를 캐싱해두고, 재사용하는 것을 자유롭게 사용할 수 있다.
마무리
지금까지 값 객체에 대해서 알아보았다. 쭉 흘러왔던 내용들을 찬찬히 복기해보면서 정리하자.
시작하기 전에 값 객체가 무엇인지 설명하기 위해 도메인과 도메인 모델, 그리고 엔티티에 대해 ‘동물원’에 비유하며 간략하게 알아보았다.
이어서 엔티티와의 비교를 통해 값 객체란 어떤 것을 의미하는지 알아본다. 엔티티는 식별자를 갖는 반면에 값 객체는 동일한 속성 값을 갖는지 값 자체 비교를 통해 구별한다는 것을 알아보았다.. 또한 불변타입으로 구현되며 덕분에 값을 신뢰성 있게 사용할 수 있다는 특징도 살펴봤다.
또 값 객체를 활용하여 어떤 이점을 취할 수 있는지를 다뤄봤다. 값과 관련된 로직들을 값 객체에 집중시킴으로써, 보다 유연하고, 테스트가 쉬운 코드로 변경해볼 수 있었다. 뿐만 아니라 적절한 값 객체의 이름을 통해 비지니스적인 의미를 더 명확하게 할 수 있었다.