1. 값 객체란?
프로그래밍 언어에는 원시 데이터 타입이 있다. 이 원시 데이터 타입만 사용해 시스템을 개발 할 수 있지만, 때로는 시스템 특유의 값을 정의해야 한다.
이러한 시스템 특유의 값을 표현하기 위해 정의하는 객체를 값 객체라고 한다.
1
2
var fullName = "naruse masanodu";
Console.WriteLine(fullName);
1
> naruse masanodu
위에서는 성명인 naruse masanodu
을 출력 하지만 여기서 성씨만 출력을 해야 한다고 하면 어떻게 되는가?
1
2
3
4
var fullName = "naruse masanodu";
var tokens = fullName.Split(' ');
var lastName = tokens[0];
Console.WriteLine(lastName);
1
> naruse
위와 같이 fullName에서 성씨 부분만 떼어내 출력해야 한다.
하지만 여기서 john smith
의 경우는 어떻게 되는가? john smith
의 성은 smith
이기 때문에 위 로직에는 문제가 있다.
그래서 우리는 이러한 문제를 해결하기 위해 일반적으로 클래스를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class FullName
{
public string FirstName { get; }
public string LastName { get; }
public FullName(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
}
var fullName = new FullName("masanobu", "naruse");
Console.WriteLine(fullName.LastName);
1
> naruse
변수 fullName은 이름 그대로 성명을 나타내는 객체로, 값을 표현한다.
도메인 주도 설계에서 말하는 값 객체는 이렇듯 시스템 특유의 값을 나타내는 객체이다.
2. 값의 성질과 값 객체 구현
- 값의 성질
- 변하지 않는다.
- 주고 받을 수 있다.
- 등가성을 비교 할 수 있다.
2.1 값의 불변성
1
2
3
4
var greet = "안녕하세요.";
Console.WriteLine(greet); // '안녕하세요' 출력
geet = "Hello";
Console.WriteLine(greet); // Hello 가 출력
우리가 일반적으로 값을 수정하는 방법이다. 우리는 값을 수정 할 때 새로운 값을 대입한다.
사실 대입은 값을 수정하는 과정이 아니며 수정 된 것은 변수의 내용이지, 값 자체가 수정 되는 것은 아니다.
쉽게 생각하면 영수증을 떠올리면 된다.
우리는 물건을 사고 영수증을 받는데 영수증에 값이 잘 못 찍혔을 때, 영수증을 새로 발급 받지 이미 받은 영수증을 수정하지 않는다.
1
2
var fullName = new FullName("masanobu", "naruse");
fullName.ChangeLastName("sato");
개발자들은 위 코드를 자연스럽게 생각할 것이다. 하지만 FullName 클래스를 값으로 생각하면 부자연스러운 부분이 생긴다.
바로 값이 수정 됐기 때문이다.
FullName은 시스템 특유의 값을 표현하는 객체이므로 변하지 않아야 한다. 그러므로 ChangeLastName 같은 메서드는 존재해서는 안된다.
2.2 교환 가능하다.
값은 불변이다. 그러므로 값 객체의 수정 역시 대입문을 통해 이루어져야 한다.
1
2
var fullName = new FullName("masanobu", "naruse");
fullName = new FullName("masanobu", "sato");
2.3 등가성 비교 가능
시스템 고유의 값인 값 객체는 구성하는 속성(인스턴스 변수)을 통해 비교가 가능해야 한다.
1
2
3
4
5
6
var nameA = new FullName("masanobu", "naruse");
var nameB = new FullName("john", "smith");
var compareResult = nameA.FirstName == nameB.FirstName && nameA.LastName == nameB.LastName;
Console.WriteLine(compareResult);
위와 같이 값 객체의 속성을 꺼내 비교하는 코드는 틀린점이 없고 자연스러워 보이지만 FullName 객체가 값이라고 생각하면 아래와 같이 부자연스러운 코드다.
1
Console.WriteLine(1.Value == 0.Value); //false?
값 객체는 시스템 고유의 값이다. 따라서 값의 속성을 꺼내 비교하는 것이 아니라, 직접 값끼리 비교해야 자연스럽다.
1
2
3
4
5
6
var nameA = new FullName("masanobu", "naruse");
var nameB = new FullName("john", "smith");
var compareResult = nameA.equals(nameB);
Console.WriteLine(compareResult);
이렇게 값 객체 내부에서 비교 메서드를 제공하면 한곳만 수정하면 되기 때문에 변화에 쉽게 반응 할 수 있다.
예를 들어 MiddleName을 추가해야 한다면 코드 곳곳에 숨겨진 비교 부분을 찾지 않고 값 객체 내부의 Equals 메서드만 수정하면 된다.
3. 값 객체가 되기 위한 기준
현재 FullName 클래스를 구성하는 firstName이나 lastName 등의 속성은 원시타입인 문자열로 되어 있다.
아래의 코드는 가능한 모든 속성을 값 객체로 만든 FullName 클래스이다.
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
27
28
29
30
31
32
33
34
35
36
37
class FullName : IEquatable<FullName>
{
private readonly FirstName firstName;
private readonly LastName lastName;
public FullName(FirstName firstName, LastName lastName)
{
this.firstName = fitstName;
this.lastName = lastName
}
}
class FirstName
{
private readonly string value;
public FirstName(string value)
{
if(string.IsNullOrEmpty(value))
throw new ArgumentException("최소 한글자 이상이여야 함.", nameof(value));
this.value = value;
}
}
class LastName
{
private readonly string value;
public LastName(string value)
{
if(string.IsNullOrEmpty(value))
throw new ArgumentException("최소 한글자 이상이여야 함.", nameof(value));
this.value = value;
}
}
위 코드를 보고 지나치다라고 생각 할 수도 있다. 하지만 firstName과 lastName에 조건이 들어 갈 때는 좀 더 복잡해 진다.
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
27
28
29
class FullName : IEquatable<FullName>
{
private readonly string firstName;
private readonly string lastName;
public FullName(string firstName, string lastName)
{
if(firstName == null)
throw new ArgumentNullException(nameof(firstName));
if(lastName == null)
throw new ArgumentNullException(nameof(lastName));
if(ValidateName(firstName))
throw new ArgumentException("허가되지 않은 문자 사용됨.", nameof(value));
if(ValidateName(lastName))
throw new ArgumentException("허가되지 않은 문자 사용됨.", nameof(value));
this.firstName = fitstName;
this.lastName = lastName
}
private bool ValidateName(string value)
{
return Regex.IsMatch(value, @"^[a-zA-Z]+$");
}
}
위와 같이 원시타입아라도 인자를 전달 받는 시점에서 검사하면 규칙을 강제할 수 있다.
물론 값 객체로 정의해도 문제는 없다. 하지만 성과 이름을 따로 다룰 필요가 없다면 하나의 타입으로 다룰 수도 있다.
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
27
28
29
30
31
32
class Name
{
private readonly string value;
public Name(string value)
{
if(value == null)
throw new ArgumentNullException(nameof(value));
if(!Regex.IsMatch(value, @"^[a-zA-Z]+$"))
throw new ArgumentException("허가되지 않은 문자 사용됨.", nameof(value));
this.value = value;
}
}
class FullName
{
private readonly Name firstName;
private readonly Name lastName;
public FullName(Name firstName, Name lastName)
{
if(firstName == null)
throw new ArgumentNullException(nameof(firstName));
if(lastName == null)
throw new ArgumentNullException(nameof(lastName));
this.firstName = firstName;
this.lastName = lastName;
}
}
4. 행동이 정의 된 값 객체
값 객체는 독자적인 행위를 정의 할 수 있다. 값 객체는 데이터만 저장하는 컨테이너가 아니라 행동을 가질 수도 있는 객체이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Money
{
private readonly decimal amount;
private readonly string currency;
(... 생략 ...)
public Money Add(Money arg)
{
if(arg == null)
throw new ArgumentNullException(nameof(arg));
if(currency != arg.currency)
throw new ArgumentException($"화폐 단위가 다름 (this:{currency}, arg:{arg.currency})");
return new Money(amount + arg.amount, currency);
}
}
돈을 더하려면 화폐의 단위가 일치해야 하므로 조건을 추가한다.
또한 값 객체는 불변이므로 계산된 결과는 새로운 인스턴스로 반환한다.
5. 값 객체를 도입 했을 때의 장점
- 표현력이 증가한다.
- 무결성이 유지된다.
- 잘못된 대입을 방지한다.
- 로직이 코드 이곳저곳에 흩어지는 것을 방지한다.
5.1 표현력의 증가
1
var modelNumber = "a20421-100-1";
위 제품번호는 원시타입으로 나타내기 때문에 각각 어떤 것을 의미하고 있는지 알 수가 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class ModelNumber
{
private readonly string productCode;
private readonly string branch;
private readonly string lot;
(... 생략 ...)
public override string ToString()
{
return productCode + "-" + branch + "-" + lot;
}
}
ModelNumber 클래스의 정의를 보면 제품번호가 제품코드(productCode)와 지점번호(branch), 로트번호(lot)으로 구성되는 것을 알 수 있다.
값 객체는 자기 정의를 통해 자신이 무엇인지에 대한 정보를 제공하는 자기 문서화를 돕는다.
5.2 무결성의 유지
만약 사용자 명이 3글자 이상이여야 한다는 조건이 붙으면, 아래와 같이 두 글자 일 때는 따로 if문으로 비교를 해서 처리를 계속하야 한다.
1
2
3
4
5
6
7
8
9
10
var userName = "me"
if(userName.Length >= 3)
{
// 유효 하므로 계속 진행
}
else
{
throw new Exception("유효하지 않은 값");
}
값의 유효성을 매번 확인 할 수는 있지만 이 if문 조건은 코드 이곳저곳 흩어지게 된다.
값 객체 내부에 조건을 추가하면 값 객체만 수정하면 되므로 유효하지 않은 값을 걱정 할 필요가 없다.
5.3 잘못된 대입 방지하기
개발자라면 대입문을 잘 못 사용한 적이 많을 것이다.
1
2
3
4
5
6
User CreateUser(string name)
{
var user = new User();
user.Id = name;
return user;
}
위 코드는 Id에 name을 넣어 버리는 오류를 범했다.
1
2
3
4
5
6
class User
{
public UserId Id {get; set;}
public UserName Name {get; set;}
}
1
2
3
4
5
6
User CreateUser(UserName name)
{
var user = new User();
user.Id = name; // 컴파일 에러 발생
return user;
}
값 객체를 정의하여 사용하면 IDE가 이러한 에러는 컴파일에서 잡아 낼 수 있다.
5.4 로직을 한곳에 모아두기
코드의 중복이 많아지면 코드를 수정하는 난이도가 급상승하게 된다.
아래의 코드는 User를 생성하는 곳과 수정하는 곳의 중복 코드가 들어가게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void CreateUser(string name)
{
if(name == null)
throw new ArgumentNullException(nameof(name));
if(name.Length < 3)
throw new ArgumentException("3글자 이상이어야 함.", nameof(name));
(... 생략 ...)
}
void UpdateUser(string id, string name)
{
if(name == null)
throw new ArgumentNullException(nameof(name));
if(name.Length < 3)
throw new ArgumentException("3글자 이상이어야 함.", nameof(name));
(... 생략 ...)
}
이 처럼 같은 조건을 확인해야 하는 코드 일 경우 값 객체를 활용하면 로직을 한 곳에 모을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class UserName
{
private readonly string value;
public UserName(string value)
{
if(value == null)
throw new ArgumentNullException(nameof(value));
if(value.Length < 3)
throw new ArgumentException("3글자 이상이어야 함.", nameof(value));
this.value = value;
}
}
1
2
3
4
5
6
7
8
9
10
void CreateUser(string name)
{
var userName = new UserName(name);
var user = new User(userName);
}
void UpdateUser(string id, string name)
{
var userName = new UserName(name);
}
참고
C# 9.0부터 record 형식이 도입 되었습니다.
- https://docs.microsoft.com/ko-kr/dotnet/csharp/whats-new/csharp-9
record의 경우 불변성, 값 비교, with문을 통한 새로운 인스턴스 생성 등 값 객체에 가장 적합한 타입으로 보입니다.
고민
값 객체를 Interface로 만들 필요가 있는가?
- 값 객체는 원시 데이터 만큼 시스템의 가장 아래에 위치하는 도메인 객체로 보여집니다.
- 값 객체에는 특별한 로직이 들어가지 않을 것으로 보이는데 만약 비슷한 값 객체를 만들어야 한다면 Interface로 만들어야 하나?
- 아님 코드 중복을 감수하더라도 따로 만들어야 하는가?
값 객체의 단위 테스트
- 값 객체의 경우 특별한 로직이 들어가지 않을 것으로 보입니다.
- 값 객체 하나하나 단위 테스트를 하게 된다면 많은 리소스가 필요 할 것으로 보이는데 값 객체도 단위 테스트를 하는 것이 좋은가?