"VO(Value Object)에 대해서 들어보기는 했는데 왜 사용하는거지?"
이번 글에서는 VO(Value Object)의 개념과 장점 및 단점을 예제 코드를 통해 쉽게 알아보겠습니다.
"VO(Value Object)란 무엇일까요?"
VO(Value Object)란 무엇일까요?
VO는 Value Object로 해석 그대로 값 객체를 의미합니다. 즉, 값을 표현하는 객체입니다.
그렇다면, "값은 그냥 원시 타입(Primitive Type)으로 표현하면 되지 않을까? 굳이 VO를 사용할 이유가 있을까?" 하는 의문이 들 수 있습니다.
VO에 대한 필요성을 이해하기 위해 예시 상황과 코드를 통해 살펴보겠습니다.
예시 상황은 입력받은 상품에 대한 정보를 저장하는 과정에서 가격이 0이하인지 검사를 한 후, 저장을 하는 상황입니다.
VO를 사용하지 않은 예제 코드
package org.example.vo;
public class Product {
private int price;
public Product(String name, int price) {
checkPrice(price);
}
public int getPrice() {
return price;
}
private void checkPrice(int price) {
if(price <= 0) {
throw new IllegalArgumentException("가격은 음수가 될 수 없습니다.");
}
this.price = price;
}
}
이 코드는 일반적인 상품을 나타내는 코드입니다.
Product 클래스는 원시타입인 price라는 필드 변수를 갖습니다.
"이 원시타입을 VO(Value Object)로 변경하면 어떻게 될까요?"
VO를 적용한 예제 코드
package org.example.vo;
public class Price {
private final int amount;
public Price(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("가격은 음수가 될 수 없습니다.");
}
this.amount = amount;
}
public int getAmount() {
return amount;
}
}
기존에 원시 타입이던 price를 별도의 Price 클래스로 만들어 줍니다.
이제 Product 클래스에 price를 VO로 변경해 보겠습니다.
package org.example.vo;
public class Product {
private final Price price;
public Product(Price price) {
this.price = price;
}
public Price getPrice() {
return price;
}
}
이제 Product 클래스의 price 필드는 원시 타입이 아니라 VO(Price) 객체가 되었습니다.
간단하게 VO를 사용하는 방법을 예시코드를 통해 알아보았습니다.
"VO라는 개념은 어느정도 알겠고, 중요한건 그래서 왜 VO를 사용할까?"
VO 사용 이유
VO는 단순히 원시 값을 객체로 감싸는 것이 아니라, 도메인 로직을 명확하게 구조화하고 유지보수성을 높이는 핵심적인 설계 방식입니다.
"어떠한 특성이 있길래 도메인 로직을 명확하게 구조화하고 유지보수성을 높이는 핵심 설계 방식이 될까요?"
VO가 가지는 2가지 특성과 이 특성들이 주는 이점들에대해 알아보도록 하겠습니다. 특성은 다음과 같습니다.
1. 불변성을 통한 데이터의 무결성을 보장
값이 불변한 경우 어떠한 이점이 있을까요? 다음과 같은 이점이 있습니다.
(1) 값이 변경될 수 없는 불변 객체이므로, 예기치 않은 데이터 변조를 방지할 수 있습니다.
쉽게 말해, 개발자가 실수로 데이터를 변경하는 상황을 방지해 줍니다. 상황 예시를 통해 알아보겠습니다.
A 개발자가 특정 객체에 대해 불변 처리를 하지 않고 개발을 완료했습니다.
이후 B 개발자가 실수로 A 개발자가 절대 변경하면 안 되는 값을 수정해 버렸습니다.
다음 날, A 개발자는 스스로에게 질문합니다. "어제 이후로 코드를 건든 적이 없는데, 왜 데이터가 바뀌어 있지?"
만약 A 개발자가 데이터를 불변 객체로 만들었다면 이런 문제는 발생하지 않았을 것입니다.
(2) 동시성 문제를 줄이고, 멀티스레드 환경에서도 안전한 코드 작성을 유도합니다.
즉, 여러 스레드가 동시에 불변 객체 값에 접근을 해도 값이 변경되지 않으므로 데이터가 불일치되는 문제가 발생하지 않습니다.
상황을 코드를 통해 알아보겠습니다.
"정말 불변 객체는 데이터 불일치 문제가 발생하지 않을까요?"
불변 객체는 불일치 문제가 발생하지 않는다는 점을 증명하기 위해 간단한 실험을 하도록 하겠습니다.
저, 멀티 스레드 환경에서 사용되는 가변 객체부터 실험을 해보도록 하겠습니다.
public class MutableObject {
public int count;
public MutableObject() {
this.count = 0;
}
public void increment() {
this.count++;
}
}
이제 MultiThread환경을 구축해 2개의 스레드가 동시 접근을 통해 count의 값을 증가시켜준다면 어떻게 될까요?
실험 코드는 다음과 같습니다.(각 스레드에서 count의 값을 10000을 증가시키도록 해주었습니다.)
public class MultiThreadTest {
public static void main(String[] args) {
AtomicReference<MutableObject> data = new AtomicReference<>(new MutableObject());
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
data.get().increment(); // 내부 값 변경
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("최종 값: " + data.get().count); // 예상값: 2000, 실제값: ?
}
}
"가변 객체를 실험한 이 코드에서 데이터 불일치를 어떻게 증명할건데?"
총 3번 실험을 해서, 출력 결과의 일치여부로 판단하면 됩니다. 만약 가변 객체라면, 데이터 불일치 문제로 인해 테스트할때마다 출력 값이 다르게 나오게 됩니다.
3번 실험의 출력결과는 다음과 같습니다.
제가 의도한 결과는 20,000이 출력되야 하는데, 결과값은 3번 전부 달랐습니다. 이 실험을 통해 가변 객체는 데이터 불일치가 일어난다는 사실을 입증했습니다.
"실험마다 결과값이 다른거랑 데이터 불일치가 일어나는 거랑 무슨 상관이지?" 라고 질문하실 수도 있습니다.
이 실험에서 의미하는 데이터 불일치는 다음과 같은 의미를 가집니다.
Thread1이 count 값을 0으로 읽음
Thread2도 같은 시점에 count 값을 0으로 읽음
Thread1이 count 값을 1로 증가 후 저장
Thread2도 count 값을 1로 증가 후 저장
최종적으로 count에는 1의 값이 저장
무언가 이상하지 않나요? 분명 Thread1이 count값을 1로 증가후 저장 했는데, Thread2가 Thread1을 통해 저장된 count값인 1을 읽어서 2로 증가시키는 것이 아닌, Thread2가 count의값을 0에서 1로 증가후 저장한다는 사실이 이상합니다.
이렇게 진행되는 이유는 Thread1과 Thread2가 각각 데이터를 읽은 시점에는 count가 0이기 때문입니다. 각 스레드는 그 시점에 읽은 값들을 처리해줄 뿐입니다.
"그래서, 데이터 불일치가 뭔데요?"
두 개의 스레드가 같은 데이터를 동시에 읽지만, 서로의 변경 사항을 반영하지 않기 때문에 각각 다른 시점의 데이터를 기반으로 처리하여 결과가 꼬이는 문제입니다. 즉, 두 스레드가 참조하는 값이 최신화된 값이 아니라, 각자 조회한 시점의 값이기 때문입니다.
쉽게 말해, 데이터 불일치란 두 스레드가 참조하는 값의 불일치를 의미합니다.
이제, 불변 객체는 정말 데이터 불일치가 발생하지 않는지 실험해보도록 하겠습니다.
불변 객체의 코드는 다음과 같습니다.
public class ImmutableObject {
private final int count;
public ImmutableObject(int count) {
this.count = count;
}
public ImmutableObject increment() {
return new ImmutableObject(this.count + 1); // 새로운 객체를 반환
}
public int getCount() {
return count;
}
}
가변 객체에 진행했던 같은 방식으로 진행하도록 하겠습니다.
실험 코드는 다음과 같습니다.
public class MultiThreadTest {
public static void main(String[] args) {
AtomicReference<ImmutableObject> data = new AtomicReference<>(new ImmutableObject(0));
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
data.updateAndGet(prev -> prev.increment()); // 안전하게 새로운 객체로 업데이트
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("최종 값: " + data.get().getCount()); // 항상 2000 보장
}
}
3번 실험의 출력결과는 다음과 같습니다.
제가 의도한 대로 결과는 20,000이 출력되고, 결과값은 3번 전부 20,000으로 같게 됩니다.
이 실험을 통해 불변 객체는 데이터 불일치 문제가 발생하지 않는다는 사실을 확인했습니다.
이제, 다시 원점으로 돌아와 VO(Value Object)의 특성에 대해서 알아보도록 하겠습니다.
두번째 특성은 다음과 같습니다.
2. 자가 유효성 검증
"자가 유효성 검증이 뭘까요?" 스스로 유효성 검증을 한다는 의미입니다.
이전에 작성한 Product 클래스에서 자가 유효성 검증을하는 로직은 어느부분일까요? 코드는 다음과 같습니다.
public Price(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("가격은 음수가 될 수 없습니다.");
}
this.amount = amount;
}
전달된 amount값이 0보다 작은 값인지 검증을 해주고 있습니다.
그렇다면, "자가 유효성 검증이라는 개념은 알겠는데, 굳이 VO(Value Object)를 사용하지 않아도, 검증해줄 수 있는거 아니야? 원시 타입으로 amount를 선언해 놓고 Product 클래스 내부에 메서드를 하나 만들어서 검증해주면 되지않나?" 라고 생각할 수 있습니다.
사실, 원시타입을 선언해 주고, 유효성 검증 로직을 사용해서 검증을 해도 됩니다.
"이제 와서 된다고? 그럼 VO를 쓸 필요가 없지않아?"라고 생각할 수 있습니다.
만약, Product클래스에 존재하는 필드변수가 price 한개가 아닌, 극단적으로 100만개가 존재한다면 어떨까요?
100만개의 유효성 검증 메서드를 100만개를 모두 Product 클래스 안에 존재할 것입니다. 클래스가 엄청 비대해지고, 무거워집니다.
또한, 책임과 역할을 잘 분리해줍니다. 사실 Product라는 클래스는 price의 값을 사용하는 것이지, price 값이 유효한지 검증을 해주어야 하는 역할은 불분명합니다. VO를 사용해서 price의 값의 유효성은 Price라는 클래스 자체에서 검증하게 해줌으로써 책임과 분리를 명확하게 해줄 수 있습니다.
"VO를 사용하는 것은 늘 좋은점만 있을까요?" 그렇지 않습니다. VO의 단점에 대해서도 알아보겠습니다.
VO의 단점
(1) 객체 생성의 비용 증가
VO는 불변 객체(Immutable Object) 로 사용되기 때문에 값을 변경할 때마다 새로운 객체를 생성해야 합니다.
(2) VO 객체 간 비교가 번거로움
VO는 객체 식별자가 없고, 동등성 비교(Equals)를 값 기반으로 수행해야 합니다.
동등성 비교를 해주어야 하므로, 매번 재정의 해주는 번거로움이 존재합니다.
(3) 복잡한 도메인에서는 적용이 어려움
VO는 단순한 값(금액, 좌표, 거리 등)에는 매우 적합하지만, 복잡한 도메인에서는 VO가 과도하게 분리되면서 오히려 설계가 복잡해질 수 있습니다.
정리
이 글에 정리한 VO가 가지는 특성 (1) 불변성 (2) 자가 유효성 검증 이 있고, 각 특성이 주는 이점으로는 불변성은 멀티 스레드 환경에서 데이터 불일치 문제 예방, 자가 유효성 검증은 책임과 역할을 분리하여 유지보수성을 높이고, 클래스가 본래 자신의 역할에 집중할 수 있도록 해준다는 점이 있습니다.
따라서, VO를 활용하면 안정성과 가독성이 높은 코드를 작성할 수 있으며, 도메인 객체가 불필요한 검증 로직에서 벗어나 본연의 역할에 집중할 수 있습니다.