-
[Java/Spring] VO(Value Object)란 무엇인가?Spring 2025. 12. 11. 17:08

소프트웨어 개발을 하다 보면 DTO, Entity, 그리고 VO(Value Object)라는 용어를 자주 접하게 됩니다. 오늘은 그중에서도 VO(값 객체)에 대해 깊이 있게 다뤄보려 합니다.
단순히 "값을 담는 객체"라고만 알고 넘어가기엔 VO는 훨씬 많은 의미를 담고 있습니다. 특히 복잡한 비즈니스 로직을 다루는 Spring 애플리케이션에서 VO를 제대로 활용하면 코드의 안정성을 높일 수 있습니다.
1. VO(Value Object), '값'을 어떻게 정의할까?
VO는 말 그대로 값 객체(Value Object)입니다. 그렇다면 소프트웨어 관점에서 '값(Value)'이라는 것은 어떤 특징을 가져야 할까요?
우리는 1,000원짜리 지폐가 찢어져서 테이프를 붙인다고 해서 그 가치가 변했다고 하지 않습니다. 또한, 내 지갑에 있는 1,000원과 친구 지갑에 있는 1,000원은 서로 다른 종이조각이지만 '동일한 가치'를 지닙니다.
이처럼 소프트웨어에서 '값'으로 취급받기 위해서는 다음 세 가지 특징을 만족해야 합니다.
- 불변성 (Immutability): 한 번 생성되면 변하지 않는다.
- 동등성 (Equality): 서로 다른 인스턴스라도 내부 값이 같으면 같은 것으로 본다.
- 자가 검증 (Self-Validation): 생성 시점에 유효하지 않은 값은 객체로 만들어지지 않는다.
2. 불변성(Immutability): "변하지 않는다"
불변성은 말 그대로 '생성된 이후에는 상태가 절대 변하지 않음'을 의미합니다. 단순히 final 키워드 하나 붙이는 것을 넘어, 이는 소프트웨어 설계 관점에서 엄청난 이점을 가져다줍니다.
왜 중요한가? : 불확실성 속의 '안전지대'
소프트웨어는 본질적으로 불확실성으로 가득 찬 복잡계(Complex System)입니다. 사용자의 입력, 네트워크 상태, DB 데이터 등 수많은 요소가 시시각각 변합니다. 이런 상황에서 개발자가 모든 변수를 통제하기란 불가능에 가깝습니다.
이때 불변성은 개발자에게 '믿을 수 있고 확실한 영역'을 제공합니다. 변하지 않는다는 것은 예측 가능하다는 뜻이고, 예측 가능하다는 것은 신뢰할 수 있다는 뜻입니다. 불확실한 요소가 넘쳐나는 시스템 안에서, VO로 만든 영역만큼은 언제 어디서 참조하든 동일한 값을 보장받을 수 있게 됩니다.
Java로 구현하는 불변성
Java에서 불변성을 구현하기 위한 가장 대표적인 키워드는 final입니다. 하지만 단순히 필드에 final을 붙인다고 해서 완벽한 불변 객체가 되는 것은 아닙니다. 다음과 같은 요소들을 꼼꼼히 고려해야 합니다.
1) 모든 필드는 final이어야 한다.
생성자에서 한 번 값이 할당되면 다시는 바꿀 수 없도록 final을 선언합니다.
2) 참조 타입(Reference Type)의 불변성
가장 놓치기 쉬운 부분입니다. 만약 VO 내부에 List나 다른 객체와 같은 참조 타입을 필드로 가지고 있다면 어떻게 될까요? 필드 자체는 final이라 교체할 수 없어도, 그 객체 내부의 값(List의 add, remove 등)이 변경된다면 불변성은 깨지게 됩니다. 따라서 참조하는 객체 또한 불변이거나, 방어적 복사(Defensive Copy) 등을 통해 외부 변경을 차단해야 합니다.
3) 클래스는 final이어야 한다. (상속 방지)
상속은 불변성을 깨뜨릴 수 있는 잠재적 위협입니다. 누군가 내 VO를 상속받아 필드를 추가하거나 메서드를 오버라이딩하여 상태를 변경하게 만들 수 있기 때문입니다. 따라서 class 레벨에도 final을 붙여 상속을 막아야 합니다.
4) 순수 함수 (Pure Function): 불변성은 변수만의 이야기가 아니다
우리는 흔히 "모든 멤버 변수를 final로 선언했으니, 이 객체는 불변이다"라고 생각하기 쉽습니다. 하지만 이 명제는 틀렸습니다.
불변성은 단순히 변수에 값을 할당하지 못하게 막는 것만으로 완성되지 않습니다. 불변성은 변수뿐만 아니라 함수(메서드)에도 적용되는 개념이기 때문입니다. 진정한 VO가 되기 위해서는 멤버 변수의 불변성은 물론이고, VO 내부의 모든 메서드가 순수 함수(Pure Function)여야 합니다.
순수 함수란, "입력값이 같으면 언제나 항상 같은 값을 반환하는 함수"를 말합니다.
만약 변수가 모두 final이라도 메서드가 시점이나 외부 상황에 따라 다른 값을 내뱉는다면, 그 객체는 예측할 수 없으며 따라서 불변이라고 부를 수 없습니다.
가장 적절한 예시를 통해 비교해 보겠습니다.
(1) Bad Case: 변수는 final이지만, 함수가 순수하지 않은 경우아래 코드를 보면 amount 변수는 final로 선언되어 있습니다. 언뜻 보면 불변 객체처럼 보입니다. 하지만 getDiscountedPrice 메서드를 주목해 주세요.
import java.util.Random; public class ProductPrice { // 모든 멤버 변수는 final로 선언됨 (불변성 조건 1 만족?) private final long amount; public ProductPrice(long amount) { this.amount = amount; } // [순수 함수가 아님] // 입력값(없음)과 내부 상태(amount)가 같더라도, // 호출할 때마다 Random에 의해 결과가 달라진다. public long getDiscountedPrice() { return this.amount - new Random().nextInt(1000); } }이 객체는 생성된 후 amount 자체는 변하지 않지만, getDiscountedPrice()를 호출할 때마다 매번 다른 값을 반환합니다. 개발자는 이 객체가 10,000원짜리 제품이라는 것은 확신할 수 있지만, 할인된 가격이 얼마인지는 호출하기 전까지 절대 예측할 수 없습니다.
즉, 동작이 가변적이기 때문에 이 객체는 불변 객체(VO)로서의 신뢰성을 잃게 됩니다.
(2) Good Case: 변수도 final, 함수도 순수 함수인 경우진정한 불변성을 위해서는 메서드 또한 입력값이 같으면 항상 결과가 같아야 합니다. 랜덤 값이나 LocalDateTime.now()와 같은 통제 불가능한 요소를 메서드 내부에서 배제해야 합니다.
public class ProductPrice { private final long amount; public ProductPrice(long amount) { this.amount = amount; } // [순수 함수] // 입력값(discountAmount)과 내부 상태(amount)가 같다면 // 언제, 어디서, 누가 호출하든 항상 똑같은 결과를 반환한다. public ProductPrice applyDiscount(long discountAmount) { if (discountAmount > this.amount) { throw new IllegalArgumentException("할인액이 원가보다 클 수 없습니다."); } return new ProductPrice(this.amount - discountAmount); } }이제 applyDiscount 메서드는 외부의 랜덤 변수나 시간에 의존하지 않습니다. 오직 입력받은 파라미터와 자신의 내부 상태만을 사용하여 결과를 도출합니다.
- 입력: 1000원 할인
- 내부 상태: 10000원
- 결과: 항상 9000원인 객체 반환
이렇게 변수의 불변(final)과 함수의 불변(순수 함수)이 결합될 때, 비로소 우리는 이 객체를 VO(값 객체)라 부르고 시스템 내에서 완전히 신뢰할 수 있게 됩니다.
불변성의 진짜 목적 : 신뢰와 협력
우리는 왜 굳이 final을 붙이고, setter를 없애며 까다롭게 불변성을 지키려는 걸까요? 단순히 "VO는 불변이어야 한다"는 기술적 규칙을 따르기 위함이 아닙니다.
불변성을 지키는 진짜 목적은 '객체를 신뢰하기 위함'입니다.
소프트웨어는 수많은 객체가 서로 협력하며 돌아갑니다. 그런데 특정 객체가 협력해야 할 객체가 상태에 따라 동작이 오락가락한다면 어떨까요? 우리는 그 객체를 믿고 중요한 일을 맡길 수 없을 것입니다.
(1) 멀티 스레드 환경에서의 가변 객체일 때 문제점
가변(Mutable) 객체를 여러 스레드가 공유할 때 어떤 일이 벌어지는지 구체적인 시나리오를 통해 상상해 봅시다.
어떤 클래스가 있고, 내부 값을 변경할 수 있는 가변 객체입니다. 이 객체 하나를 스레드 A와 스레드 B가 동시에 참조하고 있다고 가정해 보겠습니다.
- [스레드 A] 작업 수행: 객체의 값을 10으로 설정하고 확인합니다. (값: 10)
- [스레드 B] 작업 수행: 갑자기 끼어들어 객체의 값을 20으로 변경해 버립니다. (값: 20)
- [스레드 A] 재확인: 아까 설정한 값을 다시 조회했는데, 뜬금없이 20이 나옵니다.
스레드 A 입장에서 이 객체는 '불확실한 객체'가 되어버렸습니다. 분명 같은 메서드를 호출했는데, 시점에 따라(스레드 B가 언제 끼어드느냐에 따라) 반환되는 결과가 달라집니다. 내가 세팅한 값이 유지될 것이라 믿을 수 없게 되는 순간, 이 객체는 '불확실한 객체'가 됩니다.
이런 예측 불가능성은 디버깅을 어렵게 만들고, 시스템의 신뢰도를 바닥으로 떨어뜨립니다.
(2) 예측 가능한 협력 (불변 객체일 때)
이 문제를 해결하려면 멤버 변수를 불변(final)으로 만들어야 합니다. 그렇다면 값을 바꿔야 할 때는 어떻게 할까요?
불변 객체는 setter를 통해 내부 값을 덮어쓰지 않습니다. 대신 변경 요청이 들어올 때, 변경된 값을 가진 '새로운 객체'를 생성해서 반환합니다.
똑같은 상황을 불변 객체로 다시 시뮬레이션해 보겠습니다.
- [스레드 A] 작업 수행: 객체(값 10)를 참조하여 작업을 합니다.
- [스레드 B] 작업 수행: 값을 20으로 바꾸고 싶어 합니다. 기존 객체를 바꾸는 게 아니라, 20을 가진 새로운 객체를 생성해 가져갑니다.
- [스레드 A] 재확인: 스레드 B가 무슨 짓을 했든, 스레드 A가 보고 있는 객체는 여전히(그리고 영원히) 값 10을 유지합니다.
이제 스레드 A와 스레드 B는 서로 간섭하지 않고, 각자 자신이 참조하는 객체의 값을 온전히 신뢰하며 동작할 수 있습니다.
(3) 결론: 일관성이 주는 안정감
이처럼 불변 객체를 활용하면, 멀티 스레드 환경에서도 데이터의 일관성(Consistency)이 유지됩니다.
- "이 객체의 값은 절대 변하지 않아."
- "내가 언제 다시 조회해도 아까 그 값일 거야."
이런 확신이 생기면 개발자는 복잡한 동시성 문제를 걱정하지 않고 비즈니스 로직에만 집중할 수 있습니다. 불변성은 코드를 예측 가능한 형태로 바꾸어 주며, 이는 곧 프로그램 전체의 신뢰성 향상으로 이어집니다.
불변성과 불확실성 관리하기에 대한 생각
우리는 지금까지 불변성의 장점에 대해 이야기했지만, 현실적인 소프트웨어 개발로 돌아오면 한 가지 의문이 듭니다.
"그럼 모든 객체를 불변으로 만들어야 하나요?"
정답은 "아니오"입니다. 소프트웨어는 결국 사용자의 입력에 반응하고, 데이터베이스의 상태를 변경하며 가치를 창출해야 합니다. 아무것도 변하지 않는 소프트웨어는 멈춰버린 시계와 같습니다. 즉, 가변성(Mutability)은 피할 수 없는 소프트웨어의 본질입니다.
그렇다면 VO를 설계하는 개발자는 어떤 관점을 가져야 할까요?
(1) 불확실성을 '제거'할 것인가, '관리'할 것인가?
개발자가 가져야 할 핵심 역량은 '불확실성을 제거할 수 있는 영역'과 '불확실성을 안고 가야 하는 영역'을 명확히 구분하는 것입니다.
- VO (Value Object): 불확실성을 '제거'하는 영역
- 이메일 주소, 금액, 좌표 등 도메인의 개념을 표현하는 객체들은 불변으로 만듭니다.
- 이 영역에서만큼은 멀티 스레드 문제, 사이드 이펙트, 상태 변경의 복잡성이 0(Zero)이 됩니다.
- 우리는 이 객체를 100% 신뢰할 수 있습니다.
- Entity / Service: 불확실성을 '관리'하는 영역
- 사용자의 잔고가 변경되거나, 상품의 배송 상태가 바뀌는 등 식별자를 가진 객체의 상태 변화는 필수적입니다.
- 이러한 상태 변화의 책임은 Entity나 비즈니스 로직(Service)에 위임합니다.
- 대신, 이곳에서 다루는 데이터들이 신뢰할 수 있는 불변 객체(VO)들로 구성되어 있다면, 상태 관리의 난이도는 획기적으로 낮아집니다.
(2) 견고한 소프트웨어를 위한 전략
결국 VO의 불변성은 시스템 전체의 복잡도를 낮추기 위한 설계 전략입니다.
도메인 주도 설계(DDD)나 일반적인 객체지향 모델링에서 엔티티(Entity)는 식별자(Identity)를 가지며 비즈니스 로직에 따라 상태(State)가 지속적으로 변합니다. 반면, VO는 이러한 엔티티의 상태를 구성하는 속성(Attribute)으로 사용됩니다.
만약 엔티티를 구성하는 속성인 VO가 가변적(Mutable)이라면 어떻게 될까요? 엔티티가 의도하지 않은 시점에 VO의 내부 값이 외부에서 변경될 수 있고, 이는 엔티티가 지켜야 할 불변식(Invariant)과 데이터의 무결성(Integrity)이 깨지는 치명적인 결과로 이어집니다.
따라서 개발자는 코드를 작성할 때 다음 원칙을 고수해야 합니다. "상태의 변경(Mutation)은 엔티티 레벨에서 통제하고, 속성 값(VO) 자체는 불변으로 유지하여 원자성(Atomicity)을 보장한다."
VO가 내부 상태를 변경하는 대신 새로운 객체로 교체(Replace)되는 방식으로 동작할 때, 우리는 비즈니스 로직에서 발생하는 사이드 이펙트를 최소화하고 데이터의 흐름을 명확하게 추적할 수 있습니다. 이것이 우리가 VO에 그토록 까다로운 불변성을 요구하는 진짜 이유입니다.
3. 동등성(Equality), 값의 의미를 정의하다
동등성(Equality)이란 무엇인가?
소프트웨어 개발에서 '같다'라는 개념은 두 가지로 나뉩니다.
- 동일성 (Identity): 물리적인 메모리 주소(Reference)가 같은가? (==)
- 동등성 (Equality): 내재된 값(정보)이 같은가? (equals)
VO는 말 그대로 '값 객체'입니다. 값 관점에서는 물리적인 위치보다 그 안에 담긴 의미가 중요합니다.
예를 들어, 우리가 친구에게 1,000원을 빌리고 갚을 때, 빌렸던 그 지폐(일련번호가 같은)를 그대로 돌려주는 사람은 없습니다. 내 지갑에 있는 1,000원과 친구의 1,000원은 서로 다른 종이조각이지만, 가치가 같기 때문에 같은 것으로 취급합니다.
이처럼 "객체의 참조 주소가 달라도, 가지고 있는 상태(값)가 같다면 같은 객체로 봐야 한다"는 것이 바로 동등성의 핵심입니다.
2. 왜 동등성을 보장해야 하는가? : 불확실성 제거
VO가 동등성을 추구하는 이유는 불변성과 마찬가지로 소프트웨어의 예측 가능성을 높이기 위함입니다.
만약 VO가 동등성을 보장하지 않는다면 어떤 일이 벌어질까요?
Money moneyA = new Money(1000); Money moneyB = new Money(1000); // 동등성이 보장되지 않은 경우 if (moneyA.equals(moneyB)) { System.out.println("같은 금액입니다."); } else { System.out.println("다른 금액입니다."); // 이곳이 실행됨 }분명 둘 다 1,000원인데 컴퓨터는 이를 '다르다'고 판단합니다. 이는 개발자의 직관과 시스템의 동작 사이에 괴리를 만듭니다. 값 객체가 논리적으로 같음에도 불구하고 물리적 주소가 다르다는 이유로 다르게 처리된다면, 우리는 이 객체를 사용하여 비즈니스 로직을 짤 때마다 불안감을 느껴야 합니다.
따라서 VO는 상태(값)가 같다면 언제나 같은 객체로 인식되도록 만들어야 합니다.
3. Java에서의 구현: equals()와 hashCode()
Java의 모든 객체는 기본적으로 Object 클래스를 상속받습니다. Object 클래스에 정의된 기본 equals() 메서드는 메모리상의 주소값(참조값)을 비교하도록 구현되어 있습니다.
// Object 클래스의 기본 equals 구현 public boolean equals(Object obj) { return (this == obj); // 주소값을 비교 (동일성 비교) }이는 VO의 설계 의도와 맞지 않습니다. 따라서 VO를 만들 때는 반드시 equals()와 hashCode()를 오버라이딩(재정의)하여, 참조값이 아닌 객체의 내부 상태(필드 값)를 비교하도록 변경해야 합니다.
hashCode()는 왜 같이 재정의해야 할까요?
equals()만 재정의하고 hashCode()를 재정의하지 않으면, HashMap이나 HashSet 같은 해시 기반 컬렉션에서 심각한 오류가 발생합니다. Java의 규약상 "equals()가 true인 두 객체는 반드시 같은 hashCode()를 반환해야 한다"는 규칙이 있기 때문입니다. 이 규칙을 어기면 논리적으로 같은 객체임에도 불구하고 Set에 중복 저장되거나 Map에서 값을 찾지 못하는 버그가 발생합니다.
4. Lombok으로 편리하게 구현하기: @Value
실무에서 모든 VO마다 equals, hashCode, toString, Getter 등을 일일이 작성하는 것은 번거롭고 실수하기 쉽습니다. 이때 Lombok의 @Value 애너테이션을 사용하면 VO를 매우 쉽고 강력하게 만들 수 있습니다.
import lombok.Value; @Value public class Money { long amount; }@Value 애너테이션 하나만 붙이면 Lombok은 컴파일 시점에 다음과 같은 작업들을 자동으로 수행해 줍니다.
- 모든 필드를 private final로 선언: 불변성 보장.
- 클래스를 final로 선언: 상속 방지 (불변성 강화).
- @Getter 생성: 값을 읽을 수 있는 메서드 생성.
- @AllArgsConstructor 생성: 모든 필드를 초기화하는 생성자 생성.
- @ToString 생성: 객체 정보를 문자열로 표현.
- @EqualsAndHashCode 생성: (핵심) 필드 값을 기준으로 하는 equals와 hashCode 메서드 자동 구현.
즉, @Value는 VO가 갖춰야 할 불변성과 동등성을 위한 보일러플레이트 코드를 한 방에 해결해 주는, VO 생성을 위한 전용 도구라고 할 수 있습니다.
동등성 vs 식별자 : VO에 ID가 없어야 하는 이유
동등성에 대해 이해했다면, VO를 설계할 때 반드시 지켜야 할 또 하나의 중요한 규칙을 짚고 넘어가야 합니다.
"VO에는 식별자(Identifier)를 넣어서는 안 된다."
즉, 데이터베이스의 Primary Key(PK)와 같은 id 필드를 VO의 멤버 변수로 포함해선 안 됩니다. 왜냐하면 '식별자의 정의'와 'VO의 동등성 개념'이 정면으로 충돌하기 때문입니다.
(1) VO에 ID가 들어간다면?
이해를 돕기 위해, 상품 정보를 담는 ProductInfo라는 클래스를 만든다고 가정해 봅시다. 초기 설계자는 이 클래스를 VO로 의도했습니다. 그런데 실수로(혹은 습관적으로) 식별자인 id 필드를 추가했습니다.
// [Bad Case] VO로 의도했으나 식별자(id)를 포함한 경우 @Getter @AllArgsConstructor @ToString public class ProductInfo { private Long id; // 식별자 (문제의 원인) private String name; private long price; // VO이므로 모든 필드를 기준으로 equals를 오버라이딩 했다고 가정 @Override public boolean equals(Object o) { ... } public ProductInfo withId(String name, long price){ return new ProductInfo(this.id, name, price); } }이제 이 클래스를 사용하는 상황을 시뮬레이션해 보겠습니다.
public static void main(String[] args) { // 1. 1번 ID를 가진 50,000원짜리 키보드 정보 생성 ProductInfo product1 = new ProductInfo(1L, "키보드", 50000L); // 2. product1을 바탕으로, ID는 같지만 가격이 할인된 새로운 객체 생성 ProductInfo product2 = product1.withId("키보드", 45000L); // 질문: 과연 이 둘은 같은 객체일까요? System.out.println(product1.equals(product2)); // 결과는? }(2) 충돌하는 세계관: 식별자 vs 값
위 코드에서 product1과 product2를 비교할 때, 우리는 딜레마에 빠지게 됩니다.
- 식별자(Entity)의 관점:
- "두 객체의 id가 1로 동일하므로, 가격이 바뀌었든 이름이 바뀌었든 이 둘은 같은 객체다."
- 동등성(VO)의 관점:
- "두 객체의 상태(가격: 50000 vs 45000)가 다르므로, 이 둘은 엄연히 다른 값(객체)이다."
이때 product1.equals(product2)의 결과는 무엇이 되어야 할까요? true여야 할까요, false여야 할까요? 이처럼 식별자가 존재하는 순간, 객체 판단 기준에 모호함이 생기고 우리가 그토록 제거하려 했던 '예측 불가능성'이 다시 발생합니다.
(3) 결론: 식별자가 있다면 VO가 아니다
이러한 모순이 발생하는 이유는 단순합니다. VO로 적합하지 않은 개념(식별자가 필요한 개념)을 억지로 VO로 만들려 했기 때문입니다.
- 식별자가 있다: 시간이 지남에 따라 상태가 변해도 여전히 '그 녀석'임을 식별해야 한다면, 그것은 엔티티(Entity)입니다.
- 식별자가 없다: 상태 그 자체가 정체성이며, 값이 다르면 그냥 다른 것이라면, 그것은 VO입니다.
따라서 동등성과 식별자는 의미상 공존할 수 없습니다. VO를 설계할 때는 id를 제거하고, 오직 값(상태)들에만 집중하세요. 그래야만 비로소 일관성 있고 예측 가능한 VO가 완성됩니다.
4. 자가 검증(Self-Validation)
지금까지 우리는 VO가 가져야 할 불변성(Immutability)과 동등성(Equality)에 대해 알아보았습니다. 하지만 이 두 가지 조건을 완벽하게 갖췄다 하더라도, 아직 해결되지 않은 문제가 하나 남아있습니다.
1. 아무리 불변이라도 '쓰레기 값'이라면?
만약 우리가 만든 Money 객체가 불변이고 동등성 비교도 완벽하게 지원한다고 가정해 봅시다. 그런데 누군가 이 객체를 생성할 때 실수로 -10,000원이라는 값을 넣었다면 어떻게 될까요?
// 불변이고 동등성도 있지만, 값이 '음수'인 상황 Money wrongMoney = new Money(-10000);한번 생성된 후에는 절대 변하지 않는(불변) 특성 때문에, 이 객체는 '영원히 잘못된 값(-10,000)'을 품고 시스템을 돌아다니게 됩니다.
객체가 아무리 불변성을 지키고 동등성을 보장한다고 해도, 값 자체가 비즈니스 로직에 위배되는 잘못된 값이라면 그 객체는 신뢰할 수 없습니다. 이를 해결하기 위해 필요한 마지막 조건이 바로 자가 검증(Self-Validation)입니다.
2. 자가 검증(Self-Validation)이란?
자가 검증은 말 그대로 "클래스 스스로 자신의 상태가 유효한지 검증하는 것"을 의미합니다.
가장 중요한 원칙은 "애초에 유효하지 않은 상태의 객체는 만들어질 수 없게 한다"는 것입니다. 객체가 생성되는 그 순간(생성자)에 값을 검증하고, 올바르지 않다면 생성을 막아버리는 것입니다.
이를 통해 우리는 다음과 같은 강력한 보장을 얻을 수 있습니다.
"이 시스템에 존재하는 모든 VO는, 그 값이 무조건 올바르다."
3. 검증의 책임을 객체에게 넘기기
많은 개발자들이 유효성 검증 로직을 서비스 레이어나 외부 메서드에 두는 실수를 범하곤 합니다.
(1) Bad Case: 외부에서 검증하는 경우
public void processPayment(int amount) { // 사용하는 곳마다 검증 로직이 흩어져 있음 (중복 코드, 누락 위험) if (amount < 0) { throw new IllegalArgumentException("금액은 0보다 커야 합니다."); } Money money = new Money(amount); // ... 비즈니스 로직 }이렇게 하면 Money를 사용하는 모든 곳에서 if (amount < 0) 체크를 반복해야 합니다. 만약 개발자가 실수로 검증을 빼먹는다면? 마이너스 금액이 결제되는 사고가 발생할 것입니다.
(2) Good Case: 자가 검증 (VO 내부에서 검증)
VO는 만들어질 때부터 검증을 마칩니다.
@Getter @EqualsAndHashCode public class Money { private final long amount; public Money(long amount) { // [자가 검증] 생성자에서 값을 검증 if (amount < 0) { throw new IllegalArgumentException("금액은 0보다 작을 수 없습니다."); } this.amount = amount; } }이제 new Money(-1000)을 호출하는 순간 예외가 발생하며 프로그램이 중단되거나 에러 처리가 됩니다. 즉, 잘못된 값을 가진 객체는 힙(Heap) 메모리에 존재조차 할 수 없게 됩니다.
4. 자가 검증이 주는 평화 : '노심초사'하지 않기
자가 검증이 완벽하게 구현된 VO를 사용하면, 외부(Service, Controller 등)에서 이 객체를 사용할 때 마음이 편안해집니다.
- "이 객체 안에 혹시 이상한 값이 들어있으면 어떡하지?"
- "여기서 null 체크나 범위 체크를 한 번 더 해야 하나?"
이런 걱정을 하며 노심초사할 필요가 없습니다. VO가 생성되었다는 사실 자체가 이미 값이 유효하다는 증거이기 때문입니다.
참고용 예시 코드(전체 특성 반영)
import java.util.Objects; /** * [VO의 조건] * 1. 불변성 (Immutability) * 2. 동등성 (Equality) * 3. 자가 검증 (Self-Validation) */ // 1-1. 상속을 막아 불변성이 깨지는 것을 방지 (final class) public final class Money { // 1-2. 한 번 할당되면 변하지 않는 상태 (private final) // 식별자(@Id)가 없음 -> 값 그 자체로 의미를 가짐 private final long amount; // 생성자 public Money(long amount) { // 3. 자가 검증 (Self-Validation) // 객체가 생성되는 시점에 유효성을 검증하여, 잘못된 상태의 객체 생성을 원천 차단 if (amount < 0) { throw new IllegalArgumentException("돈은 0보다 작을 수 없습니다. 입력값: " + amount); } this.amount = amount; } // 4. 순수 함수 (Pure Function) & 불변성 유지 // - 입력값이 같으면 항상 같은 결과를 반환 // - 내부 상태(this.amount)를 변경하지 않음 (Side Effect 없음) // - 변경된 값을 가진 '새로운 객체'를 반환 public Money plus(Money other) { if (other == null) { throw new IllegalArgumentException("연산할 금액 객체는 null일 수 없습니다."); } return new Money(this.amount + other.amount); } // 비즈니스 로직 예시 (순수 함수) public Money minus(Money other) { if (other == null) { throw new IllegalArgumentException("연산할 금액 객체는 null일 수 없습니다."); } if (this.amount < other.amount) { throw new IllegalArgumentException("잔액이 부족합니다."); } return new Money(this.amount - other.amount); } // Getter (Setter는 존재하지 않음) public long getAmount() { return amount; } // 2. 동등성 (Equality) 구현 // 참조값(메모리 주소)이 아닌, 내부의 값(amount)을 기준으로 비교 @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Money money = (Money) o; return amount == money.amount; } // equals를 재정의하면 hashCode도 반드시 재정의해야 함 (HashMap 등에서의 오동작 방지) @Override public int hashCode() { return Objects.hash(amount); } @Override public String toString() { return "Money{" + "amount=" + amount + '}'; } }5. 실전! Spring Boot와 JPA에서 VO 활용하기
앞서 우리는 VO가 가진 불변성, 동등성, 자가 검증이라는 강력한 무기에 대해 배웠습니다. 그렇다면 이 순수한 자바 객체를 실제 Spring Boot 프로젝트, 특히 데이터베이스와 연동되는 JPA(Hibernate) 환경에서는 어떻게 녹여낼 수 있을까요?
단순히 비즈니스 로직에서만 쓰고 버리는 것이 아니라, 엔티티(Entity)의 일부로서 VO를 활용하는 방법을 살펴보겠습니다.
1) JPA와 VO의 만남: @Embeddable과 @Embedded
Spring Data JPA를 사용할 때, VO를 엔티티의 속성으로 매핑하기 위해서는 JPA가 제공하는 임베디드 타입(Embedded Type) 기능을 사용해야 합니다.
- @Embeddable: "이 객체는 다른 엔티티의 일부로 들어갈 수 있는 값 타입(VO)이다"라고 선언합니다.
- @Embedded: 엔티티 내에서 "이 필드는 값 타입을 사용한다"라고 선언합니다.
앞서 만든 Money 객체를 JPA에서 사용할 수 있도록 아주 살짝 다듬어 보겠습니다. (Lombok과 JPA 어노테이션을 함께 사용합니다.)
Javaimport jakarta.persistence.Embeddable; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @Embeddable // 1. JPA에게 이 객체가 VO임을 알림 @Getter @EqualsAndHashCode // 2. 동등성 보장 (값 비교) @NoArgsConstructor(access = AccessLevel.PROTECTED) // 3. JPA 스펙상 기본 생성자가 필수 (외부 호출은 막음) public class Money { private long amount; // 생성자: 자가 검증 로직 포함 public Money(long amount) { if (amount < 0) { throw new IllegalArgumentException("금액은 0보다 작을 수 없습니다."); } this.amount = amount; } // 불변성을 지키는 비즈니스 로직 (plus, minus 등) public Money plus(Money other) { return new Money(this.amount + other.amount); } // ... 기타 메서드 }Note: JPA 구현체인 Hibernate는 리플렉션을 통해 객체를 생성하므로 기본 생성자(NoArgsConstructor)가 반드시 필요합니다. 하지만 VO의 불변성을 해치지 않기 위해 접근 제어자를 protected로 설정하여 외부에서의 무분별한 생성을 막아주는 것이 팁입니다.
2) 엔티티에서의 활용과 @AttributeOverride
이제 이 Money VO를 사용하여 Order(주문) 엔티티를 설계해 보겠습니다. 만약 VO를 쓰지 않았다면 long totalPrice, long deliveryFee 처럼 단순한 숫자 필드들이 나열되었을 것입니다.
하지만 VO를 사용하면 코드가 훨씬 더 직관적으로 변합니다.
import jakarta.persistence.*; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 1. 주문 총액 @Embedded @AttributeOverride( name = "amount", column = @Column(name = "total_price") // DB 컬럼명을 total_price로 매핑 ) private Money totalPrice; // 2. 배송비 (같은 Money VO를 재사용) @Embedded @AttributeOverride( name = "amount", column = @Column(name = "delivery_fee") // DB 컬럼명을 delivery_fee로 매핑 ) private Money deliveryFee; protected Order() {} public Order(Money totalPrice, Money deliveryFee) { this.totalPrice = totalPrice; this.deliveryFee = deliveryFee; } // 비즈니스 로직: 주문 취소 시 환불 금액 계산 등에서 VO의 메서드 활용 public Money calculateRefundAmount() { return this.totalPrice.minus(this.deliveryFee); // Money 객체끼리의 연산 } }- @AttributeOverride: 같은 VO(Money)를 한 엔티티 내에서 여러 번 사용할 때(총액, 배송비 등), DB 컬럼명이 겹치지 않도록 컬럼 이름을 재정의해 주는 역할을 합니다.
3) DB에는 어떻게 저장될까?
이렇게 설계하면 데이터베이스 테이블에는 Money라는 테이블이 따로 생기는 것이 아니라, orders 테이블에 total_price와 delivery_fee라는 컬럼이 생성되어 평평하게(Flat) 저장됩니다.
즉, 객체 지향적으로는 풍부한 의미를 가진 객체(VO)로 다루면서, 관계형 DB에는 효율적인 기본 타입(Primitive Type)으로 저장하는 두 마리 토끼를 잡을 수 있게 됩니다.
4) 서비스 계층(Service Layer)의 변화
VO를 적용하기 전과 후, 서비스 계층의 코드는 어떻게 달라질까요?
(1) VO 적용 전 (Primitive Obsession)
// 서비스 로직에 검증과 계산이 뒤섞여 있음 public void refund(long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); long refundAmount = order.getTotalPrice() - order.getDeliveryFee(); if (refundAmount < 0) { // 검증 로직이 서비스에 노출됨 throw new RuntimeException("환불 금액 오류"); } // ... }(2) VO 적용 후
public void refund(long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); // 검증과 계산 로직이 Money와 Order 내부로 숨어듦 Money refundAmount = order.calculateRefundAmount(); // 서비스 계층은 오직 '흐름'만 제어함 paymentClient.refund(refundAmount.getAmount()); }결과적으로 서비스 코드는 "무엇(What)을 할 것인가"에만 집중하게 되고, "어떻게(How) 계산하고 검증할 것인가"는 VO와 엔티티가 책임지게 됩니다. 이것이 바로 우리가 VO를 실무 프로젝트에 도입해야 하는 진짜 이유입니다.
마무리
지금까지 우리는 VO(Value Object)를 이해하기 위해 객체지향의 중요한 특징인 불변성, 동등성, 자가 검증에 대해 긴 시간 동안 살펴보았습니다.
하지만 이 글을 마치며 여러분께 꼭 드리고 싶은 말씀은, "이 객체가 교과서적인 VO의 정의에 부합하느냐, 아니냐"에 너무 매몰되지 말라는 것입니다.
실무에서 개발할 때 정말로 중요한 것은 용어의 정의를 완벽하게 지키는 것이 아닙니다. 그보다 훨씬 가치 있는 것은 VO가 추구하는 목적을 끊임없이 고민해보는 과정 그 자체입니다.
코드를 작성하며 스스로에게 다음과 같은 질문을 던져보세요.
- "어떻게 하면 내 동료들이 이 객체를 의심 없이 믿고 쓰게 할 수 있을까?"
- "어떤 상태를 불변으로 만들어야 사이드 이펙트를 차단할 수 있을까?"
- "이 데이터의 유효성은 어디까지 보장되어야 하는가?"
이러한 고민의 과정들이 쌓여 여러분의 코드는 더 견고해지고, 시스템은 안정적으로 변해갈 것입니다.
우리가 추구해야 할 것은 'VO'라는 타이틀이 아닙니다. 불변성을 통한 예측 가능성, 동등성을 통한 논리적 일관성, 자가 검증을 통한 데이터 무결성. 그리고 이를 통해 얻어지는 '신뢰할 수 있는 객체' 그 자체입니다.
이 글이 여러분이 작성하는 모든 객체에 '신뢰'를 불어넣는 데 작은 도움이 되기를 바랍니다.
'Spring' 카테고리의 다른 글
[Java/Spring] Entity: 개체 (0) 2025.12.12 [Java/Spring] DTO: 데이터 전송 객체 (0) 2025.12.12 [Spring] Spring AI란? (0) 2025.07.08 스프링(Spring)이란 무엇인가? (1) 2025.06.18 스프링(Spring)의 역사와 탄생 배경 (2) 2025.06.18