[ JAVA ] VO ( Value Object )

Wooju Kang ·2025년 7월 7일
post-thumbnail

GIF 출처 : https://www.amigoscode.com/courses/java

🖥 Contents


1 ) VO ( Value Object ) 란 ?

2 ) VO의 설계 조건

3 ) 스프링에서 VO 도입




1 ) VO ( Value Object ) 란?


VO ( Value Object ) 란 ?

: 식별자가 없는 값 중심의 객체이다. 도메인 중심 설계 ( DDD ) 에서 한개 또는 여러개의 필드를 묶어서 특정 값을 나타낸다.

VO 도입 배경?

: VO는 도메인의 의미를 명확하게 표현하고 안전하며 설계의 안정성을 강화하기 위해 사용한다. 예를 들어 User 도메인을 설계하기 위해 엔티티 클래스를 생성했다고 가정해보자.

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {

     @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(name="user_username",nullable = false,unique = true)
    private String username;

    @Column(name="user_password",nullable = false)
    private String password;

    @Column(name="user_nickname",nullable = false,unique = true)
    private String nickname;

    @Column(name="user_createDate",nullable = false)
    private LocalDateTime createDate;

    @Enumerated(EnumType.STRING)
    @Column(name="user_level",nullable = false)
    private Role role;
    
    @Column(name="user_email",nullable = false)
    private String email;
    
    @Column(name="user_emailPass",nullable = false)
    private String emailPass;
}

해당 클래스 내부에는 다수의 필드가 정의되어있다.

이중에서 email과 관련된 필드가 두개가 존재한다. 만약 특정 도메인 내부에서만 사용되는 필드가 아닌 다른 테이블에서도 중복적으로 사용되는 경우 보일러 플레이트가 발생한다. 또한 해당 코드가 어떠한 의도를 가지고 만들었고 어떤 의미를 가지는지 명확히 알 수 없다.

도메인이 가지는 의미를 명확하게 표현한다는 것은 단순히 필드의 목적을 보여주는 것 뿐만 아니라 해당 필드가 어떤 형식이어야 하는지 , 어떤 제약이 있는지를 포함하는 뜻이다.

이를 통해 해당 필드에 임의의 제약을 걸어 특정 제약을 통과하는 값은 전달되지 못하도록 하여 타입 안정성 즉, 안전하게 객체를 표현할 수 있다.

해당 코드에 VO를 적용하여 email에 대해서 받을 수 있는 타입을 제약할 수 있다.

EmailVO.java

import jakarta.persistence.Embeddable;
import lombok.Getter;

import java.util.Objects;

@Getter
@Embeddable
public class Email {

    private String email;
    private String emailPass;

    protected Email() {
        // JPA가 사용하는 기본 생성자, 외부 호출 금지
    }

    public Email(String email, String emailPass) {
        if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다.");
        }
        if (emailPass == null || emailPass.isBlank()) {
            throw new IllegalArgumentException("이메일 비밀번호는 필수입니다.");
        }
        this.email = email;
        this.emailPass = emailPass;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Email)) return false;
        Email that = (Email) o;
        return Objects.equals(email, that.email) &&
               Objects.equals(emailPass, that.emailPass);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email, emailPass);
    }
}

User.java ( VO 적용 )

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {

     @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(name="user_username",nullable = false,unique = true)
    private String username;

    @Column(name="user_password",nullable = false)
    private String password;

    @Column(name="user_nickname",nullable = false,unique = true)
    private String nickname;

    @Column(name="user_createDate",nullable = false)
    private LocalDateTime createDate;

    @Enumerated(EnumType.STRING)
    @Column(name="user_level",nullable = false)
    private Role role;
    
    @Column(name="user_email",nullable = false)
    private String email;
    
    @Column(name="user_emailPass",nullable = false)
    private String emailPass;
    
    @Embedded
    private EmailVO email;
}



2 ) VO의 설계 조건


앞서 언급했듯이 VO는 단순히 데이터를 묶는 용도가 아니며 , 설계상으로 객체지향 설계의 안정성과 신뢰성을 보장해야 하기 때문에 철저한 설계 조건을 준수해야한다.

식별방식

: VO는 동일성을 객체의 주소값이 아닌 값 자체로 판단한다. 예를들어 VO의 필드가 모두 같을 경우. 같은 객체로 간주한다. 따라서 VO를 엔티티에 추가하게 될 경우에는 @id와 같은 식별 어노테이션을 포함해서는 안된다.

불변성 ( Immutable )

: VO는 참조 공유 시 값 변경으로 인한 부작용 방지 및 스레드 안전성의 이유로 불변성상태를 유지해야한다. 불변 상태를 유지하는 방식에는 여러가지가 존재한다.

  • final 필드 선언
  • setter 생성하지 않기
  • 생성자를 통해서만 값 설정하기
  • record 타입으로 클래스 지정하기

일반적으로 JPA를 활용하는 애플리케이션인 경우 final 필드 및 protected 타입의 생성자를 통해 JPA 리플랙션에 활용한다.

ex )

public final class Email {

    private String email;
    private String emailPass;

    protected Email() {
        // JPA가 사용하는 기본 생성자, 외부 호출 금지
    }

    public Email(String email, String emailPass) {
        if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다.");
        }
        if (emailPass == null || emailPass.isBlank()) {
            throw new IllegalArgumentException("이메일 비밀번호는 필수입니다.");
        }
        this.email = email;
        this.emailPass = emailPass;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Email)) return false;
        Email that = (Email) o;
        return Objects.equals(email, that.email) &&
               Objects.equals(emailPass, that.emailPass);
    }

    @Override
    public int hashCode() {
        return Objects.hash(email, emailPass);
    }
}



유효성 검사

: VO는 항상 올바른 값만 가질수 있어야한다. 이를 위해 생성자나 정적 팩토리 메소드에서 유효성 검증을 철저히 수행해야한다. 즉 , VO는 내부에서 생성 시에 유효성 검사를 진행한 후 생성된다.

ex )

public Email(String email, String emailPass) {
        if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다.");
        }
        if (emailPass == null || emailPass.isBlank()) {
            throw new IllegalArgumentException("이메일 비밀번호는 필수입니다.");
        }
        this.email = email;
        this.emailPass = emailPass;
    }

해당 로직을 살펴보면 총 두가지 유효성 검사를 진행한다. 먼저 이메일 형식을 검사한 후 emailPass필드가 null로 설정되어있는지 체크한 후 this 키워드를 통해 새로운 객체를 생성한다.

직렬화 문제

: VO를 record 타입으로 지정하거나 스프링의 @Embeddable로 클래스를 정의한 경우 기본 생성자 ( 파라미터가 없는 ) 이 없으면 Jackson이 객체를 생성할 수 없어 JSON형식으로 변환할 수 없다. 따라서 직렬화 작업이 필요한 경우 데이터 포멧 변환 과정에서 발생할 수 있는 예외를 처리해야한다.




3) 스프링에서 VO 도입


: 스프링 프레임워크에서는 VO를 효과적으로 설계할 수 있도록 하는 도구들을 제공한다.

@Embeddable , @Embedded

: JPA를 통해 VO를 Entity에 내장할 때 , VO 클래스에는 @Embeddable을 붙이고 엔티티의 필드에는 @Embedded로 선언해주면 된다. 스프링 데이터 JPA가 이를 자동으로 매핑해준다.

Ex )

@Entity
public class User { 

   @Embedded
   private Email email;


}

@Embeddable
public final class Email{
   
   private String value;
   
   protected email(){}
   public Email(String value){
       // 내부 로직

또한 스프링에서는 직렬&역직렬화와 관련하여 어노테이션( @JsonProperty,@JsonCreator을 지원하고 있어 원하는 타입의 JSON 필드를 지정할 수 있다.

ex )

public final class Email {

    private final String value;

    @JsonCreator
    public Email(@JsonProperty("value") String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}

또한 스프링에서 제공하는 유효성 검사 도구 + ExceptionHandler조합을 통해 유효성에 어긋나는 VO를 던질경우 JSON타입의 에러 메시지로 응답할 수 있도록 설계할 수 있다. 커스텀 예외처리는 다음 포스팅을 참고하면 된다. https://velog.io/@space1102/Spring-CustomException


  • 참고

https://velog.io/@kyy00n/VO-란
https://ksh-coding.tistory.com/83
https://devmango.tistory.com/205

profile
배겐드 📡

0개의 댓글