사다리 미션을 진행하면서 사다리의 높이를 뜻하는 Height
라는 객체를 만들었다.
public class Height {
private static final int UPPER_BOUND = 12;
private final int value;
...
사다리의 높이를 의미하는 value
라는 원시값을 포장하는 용도로 객체를 설계했고, Height
클래스는 int
를 사용하는 대신 Height
라는 의미있는 타입 명칭을 사용하는 의도로 만들었다. 이에 대해 코드 리뷰를 받았는데, 원시값 포장과 값 객체의 차이점을 공부해보라는 피드백이 있었다.
값 객체는 무엇일까? 그리고 원시값 포장과 다르게 어떤 상황에서 값 객체가 사용되는걸까?
원시값 포장
int, String, double 등 원시 형태의 변수를 그대로 사용하는 것이 아니라, 도메인 내에서 다루는 의미로 명확하게 표현할 수 있는 이름으로 지정하는 것이다. 로또 미션을 예시로 하여 원시값 포장을 하는 이유에 대해 알아보자.
public class Number {
private int value;
...
하나의 로또 번호를 저장하기 위해 Number
라는 클래스를 생성했다. 이 클래스는 int
타입의 단일 로또 번호를 감싸서 Number
라는 이름으로 외부에 표시한다.
public class Lotto {
private final List<Number> numbers;
private Lotto(final List<Number> numbers) {
Validator.validate(numbers);
this.numbers = numbers;
}
그리고 6개의 로또 번호를 리스트로 저장하는 Lotto 클래스를 보자. 원시값 포장한 Number를 타입으로 가지는 리스트를 선언하니, 로또 하나가 여러 개의 로또 번호를 가진다는 것을 쉽게 알 수 있었다.
원시값 포장은 단순히 원시적인 타입을 하나의 의미있는 이름으로 포장했다는 것이 다가 아니다. 하나의 원시 값에 대한 여러 가지 책임들이 하나의 클래스로 응집되었다는 점에서도 의미가 있다.
예를 들어, 로또 번호는 1에서 45까지의 제한이 있다. 이러한 로또 번호
에 대한 검증을 원시값 포장 없이 구현하려고 하면, 외부에서 로또 번호를 사용할 때마다 Range Check를 해야하는 문제가 있다.
public class Lotto {
private final List<Integer> numbers;
private Lotto(List<Integer> numbers) {
for (int number : numbers) {
if (number < 1 || number > 45) {
throw new IllegalArgumentException("잘못된 로또 번호입니다.");
}
}
this.numbers = numbers;
}
Lotto
라는 개념은 그냥 로또 번호 6개만 저장하는 건데, 번호 하나하나를 다 검증해야한다니 너무 책임이 크지 않겠는가? 그리고 프로그래밍을 하는 사람도 int
타입으로 된 로또 번호가 1에서 45 사이의 값이 확실하게 맞는지 단언하기도 힘들 것 같다.
이러한 책임을 Number
라는 Wrapper Class에게 넘기면, 원시값을 포장하여 의미있는 이름을 부여하는 동시에 검증 등 도메인 지식에 대한 적용도 가능한 두 마리 토끼를 잡을 수 있다.
public class Number {
private int value;
private Number(final int value) {
if (value < 1 || value > 45) {
throw new IllegalArgumentException("잘못된 로또 번호 입력입니다.");
}
this.value = value;
}
이렇게 객체 간의 결합도를 낮추고 응집도를 높이는 목적을 달성할 수 있다 👍
값 객체
값 객체는 도메인 상에서 고유하게 식별될 수 있는 특정 값을 나타내는 객체를 의미한다. 원시값 포장 처럼 하나 이상의 원시 값을 클래스 단위로 포장한다는 형태 자체는 동일하다. 그래서 값 객체는 원시값 포장의 포함 관계라고 할 수 있지만, 값 객체는 단순한 원시값 포장과는 조금 더 특수한 상황에서 사용된다.
마틴 파울러는 아래와 같이 값 객체에 대해 서술한다.
나는 프로그래밍 할 때 무언가를 조합하여 표현하는 것이 유용하다는 것을 자주 발견한다. 2차원 좌표는 x 값과 y 값으로 구성되고, 돈은 숫자와 통화로 구성되어 있다. 기간은 시작 날짜와 끝 날짜로, 그리고 각각의 날짜는 또한 년, 월, 일로 구성될 수 있을 것 이다.
마틴 파울러 (Martin Fowler)
내가 이해한 값 객체는 현실 세계에서 고유하게 존재하는 무언가를 프로그래밍 세계에서 표현하기 위한 하나의 기법이다.
예를 들어, 만 원 짜리 지폐 하나는 그 자체로 고유하다. 서로 다른 지폐를 서로 다른 사람이 들고 있어도 만 원 지폐는 같은 사물이고 같은 가치라고 할 수 있다. 또한, 값이 `만 원`이라는 상태가 결코 변하지 않기 때문에 안심하고 여기 저기 거래하는 데 사용할 수 있다.
따라서, 이러한 값 객체 (VO, Value Object)를 프로그래밍에서 구현하기 위해서는 두 가지 조건이 필요하다.
- equals & hashcode 재정의
- 상태가 변하지 않는 불변 객체
equals & hashcode 재정의
첫 번째 조건부터 생각해보자. equals와 hashcode를 재정의한다는 것은 상태가 같으면 같은 객체로 취급한다
라는 동등성을 보장한다는 의미가 있다.
흔히 아는 트럼프 카드를 예시로 들어보겠다.
두 개의 서로 다른 카드가 있다. 두 카드는 카드 모양은 스페이드로 같지만, 카드의 값이 J와 Q로 서로 다르기 때문에 서로 다른 카드라고 할수 있다.
하지만 아래처럼 카드 모양도 같고 카드의 값도 같다면 두 카드는 같은 카드라고 할 수 있을 것 이다.
이를 프로그래밍 상에서도 구현하기 위해 equals 와 hashcode를 재정의하여 상태가 같은 경우 같은 객체로 취급되도록 하는 것이다.
public class Card {
private Denomination denomination; // 카드의 값
private Suit suit; // 카드의 모양
...
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final Card card = (Card) o;
return denomination == card.denomination && suit == card.suit;
}
@Override
public int hashCode() {
return Objects.hash(denomination, suit);
}
}
이를 통해 아래와 같이 여러 번 카드를 생성해도 같은 카드로 취급될 수 있다.
메모리 값을 통해 비교하는 것을 동일성 비교라고 하고, 위처럼 내부 속성값을 통해 비교하는 것을 동등성 비교라고 한다. equals & hashcode를 재정의함으로써 객체가 동등성 비교를 할 수 있다면 VO가 되기 위한 첫 번째 기준을 맞춘 것이다.
상태가 변하지 않는 불변 객체
이렇게 동등성 비교를 통해 VO는 속성 값 자체가 객체 식별의 기준이 되었다. 하지만 객체의 상태가 변경되어 식별 기준이 변경되면 문제가 생기지 않을까?
카드 자체는 여러 공유 자원에서 동시에 사용될 수 있는 요소이다.
만약, 사용자가 1000만명인 프로그램에서 사용자마다 모두 서로 다른 카드들을 생성한다면 메모리 자원의 낭비가 심하지 않겠는가? 그렇다면 모든 사용자가 같은 카드 객체를 공유한다면 메모리를 절약할 수 있을 것 같은데, 이러한 관점에서 객체의 동기화를 염두에 둘 수 있다.
카드가 만약 상태가 변하지 않는 요소라면, 여러 공유 자원에서 동시에 사용되는 객체라고 해도 동기화를 신경쓰지 않아도 된다. 동시에 메모리 성능 상의 이점도 얻을 수 있다.
아래에서 등장한 card1과 card2가 서로 다른 사용자에게 부여되었다고 해보자.
void test() {
Card card = new Card(QUEEN, SPADE);
User user1 = new User("pobi");
User user2 = new User("anna");
user1.receive(card);
user2.receive(card);
// user중 한명이 실수로 카드를 Queen이 아닌 King으로 변경했다!
card.setDenomination(Denomination.KING);
assertThat(user1.hasCard(new Card(QUEEN, SPADE))).isTrue(); // fail
}
공유 자원인 Card를 User 중 한명이 실수로 변경하게 되면, 이 상태 변경은 모든 사용자에게 영향을 끼치게 된다.
Card 객체가 불변이 된다면 객체의 상태가 항상 일정함을 보장하게 되고, 여러 가지 오류를 줄여 유지보수성이 높은 코드를 작성할 수 있게 된다.
그럼 어떻게 Card를 불변 객체로 구현할 수 있을까?
먼저 클래스의 속성 값에 `final` 키워드를 작성한다. 변수 선언 시 `final` 키워드를 통해 재할당을 막을 수 있다.
그리고 setter 등 값의 상태를 변경하는 메서드를 제거한다. 값의 상태가 변경될 수 있는 요소들을 제거하여, 객체가 불변이 될 수 있도록 유지함으로써 VO의 두 번째 조건을 만족시킬 수 있다.
+) 객체의 속성이 Collection인 경우에는 방어적 복사 등을 활용해서 내부 속성이 외부에서 참조되는 것을 막는다. 이와 관련된 글은 나중에 후속으로 포스팅하겠다.
public class Card {
private final Denomination denomination;
private final Suit suit;
public Card(Denomination denomination, Suit suit) {
this.denomination = denomination;
this.suit = suit;
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final Card card = (Card) o;
return denomination == card.denomination && suit == card.suit;
}
@Override
public int hashCode() {
return Objects.hash(denomination, suit);
}
}
이렇게 Card를 VO로 만들어보았다. 이제 트럼프 카드 하나는 고유한 하나의 값 객체로서 프로그램에서 역할을 안정적으로 다 할 수 있게 되었다.
클래스들은 가변적이여야 하는 매우 타당한 이유가 있지 않는 한 반드시 불변으로 만들어야 한다. 만약 클래스를 불변으로 만드는 것이 불가능하다면, 가능한 변경 가능성을 최소화하라.
- Effective Java -
이펙티브 자바에는 위와 같은 문구가 있다고 한다.
프로그램을 설계하며 최대한 VO로 구현해보려고 했지만, 객체가 상태가 변경됨을 전제로 하여 설계된 경우에는 값 객체로 만들어야겠다는 강박을 버리고 상태를 변경시켜주었다. 도메인 내에서 객체가 여러 명에게 동시에 공유되어도 괜찮은 자원인지 확인해보고, 가변 객체로 설계하였을 때 어떤 사이드 이펙트가 있을 지 생각해보는 것이 중요할 것 같다.
'우아한테크코스 > 레벨1' 카테고리의 다른 글
[디자인 패턴] 상태 패턴 (State Pattern) 이란? 적용해보며 느낀 장점과 단점 (2) | 2024.04.13 |
---|---|
[OOP] 객체 분리의 필요성? 객체에게 적절한 책임을 부여해야 하는 이유 (3) | 2024.04.11 |
[회고] 우아한테크코스 레벨 1 회고 : 성장에 대하여 (7) | 2024.04.04 |
[회고] 우아한테크코스 6기 백엔드 레벨1 7주차 회고 (0) | 2024.04.01 |
[회고] 우아한테크코스 6기 백엔드 레벨1 6주차 회고 (3) | 2024.03.24 |