안녕하세요, 저는 Kernel 360 백엔드 2기 크루 박소은이라고 합니다 !

제가 오늘 이야기할 주제는 VO입니다.

VO는 Value Object의 약자로, 값 객체입니다. 말 그대로 값 그 자체를 표현하는 객체입니다.

VO

= Value Object (값 객체)
= 값 그 자체를 표현하는 객체



🖼️ 실생활 속 VO의 예시

실생활에서 값 그 자체를 표현하는 객체에는 어떤 것이 있을까요?

돈은 VO와 유사한 객체라고 볼 수 있습니다. 아래에 오만원권 세 장이 있습니다. 지폐에는 고유번호가 적혀 있습니다. 고유번호가 다르다고 해서 사람들이 다른 오만원이라고 생각할까요?

image

고유 번호가 다르더라도 계산할 때는 모두 50000이라는 액수로 동일하게 취급됩니다. VO도 마찬가지로, 값이 같다면 같은 객체라고 판단합니다.

지폐를 VO 객체로 표현한다면, 아래와 같이 코드를 작성할 수 있습니다.

package org.example.vo;

@EqualsAndHashCode
@Getter
public class PaperMoney {
	private final Integer paperMoneyValue;

	private static final List<Integer> VALID_PAPER_MONEY = Arrays.asList(1000, 5000, 10000, 50000);

	public PaperMoney(Integer paperMoneyValue) {
		validatePaperMoney(paperMoneyValue);
		this.paperMoneyValue = paperMoneyValue;
	}
	
	private void validatePaperMoney(Integer paperMoneyValue) {
				if(!VALID_PAPER_MONEY.contains(paperMoneyValue)) {
						throw new IllegalArgumentException("유효하지 않은 지폐입니다.");
    }
	}
}

한국 지폐는 1000원, 5000원, 10000원, 50000원권이 있기 때문에 이 네 종류의 지폐에 해당되지 않는다면 지폐 VO 객체가 생성될 때 예외를 발생시키고 있습니다.

🧐 왜 VO를 사용해야 하나요?

E2E 프로젝트를 진행하며 VO를 사용하게 되었는데요, 그 과정을 먼저 이야기해보고자 합니다 !

1️⃣ 타입 안전성 보장

Entity의 중요 필드를 VO 객체로 선언함으로써, Entity 객체 생성 시 각 필드가 유효한 타입임이 보장됩니다.

아래 회원 Entity 코드를 보면, 모든 필드가 String 타입으로 선언되어 있습니다.

package org.example;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE)
	private Long id;

	private String email;

	private String name;

	private String nickname;

	private String password;

	private String phoneNumber;

	public MemberEntity(String email, String name, String nickname, String password, String phoneNumber) {
		this.email = email;
		this.name = name;
		this.nickname = nickname;
		this.password = password;
		this.phoneNumber = phoneNumber;
	}
}

모든 필드가 같은 String 타입으로 선언되어 있기 때문에 생성자 방식으로 회원 Entity 객체를 생성할 때 동일한 타입의 여러 파라미터를 전달하게 됩니다.

MemberEntity member = new MemberEntity("sso@kernel.com", "박소은", "sso", "1234!!", "010-1111-2222");

이렇게 객체를 생성할 경우 아래와 같이 파라미터 순서가 뒤바뀌는 등 개발자가 실수할 여지가 있습니다.

// 이메일과 이름 파라미터 순서가 잘못된 경우
MemberEntity member = new MemberEntity("박소은", "sso@kernel.com", "sso", "1234!!", "010-1111-2222");

인자로 전달되는 값의 순서가 바뀌더라도 동일 타입이기 때문에 컴파일 에러가 발생하지 않습니다.

사실 이메일과 이름은 전혀 다른 종류의 값이지만 모두 동일한 String 타입으로 선언되었기 때문에, 상식적으로는 유효하지 않은 값을 가진 객체가 생성됩니다.

컴파일 시점에 파악할 수 없기 때문에 만약 이를 사람의 눈으로 알아채지 못해서 한참 후에 발견한다면 치명적인 실수로 이어질 수 있습니다.

2️⃣ 그래서 @Builder를 사용하지 않나요?

이런 생성자 방식의 단점을 해소하기 위해 흔히 @Builder를 사용하는데요! 생성자에 매개변수가 많다면 빌더를 고려할 수 있습니다.

결론부터 이야기하자면, 객체의 원자적 생성 관점에서 Entity에 @Builder를 사용하는 것은 좋지 않은 방법일 수 있습니다.

@Builder를 사용해 코드를 수정해보겠습니다.

package org.example;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class MemberEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE)
	private Long id;

	private String email;

	private String name;

	private String nickname;

	private String password;

	private String phoneNumber;
}

MemberEntity memberEntity = MemberEntity
		.builder()
		.email("sso@kernel.com")
		.name("박소은")
		.nickname("sso")
		.password("1234!!")
		.phoneNumber("010-1111-2222")
		.build();

@Builder 사용 시 필요한 데이터만 설정해 객체를 생성할 수 있습니다.

다만, 아래와 같이 필수적으로 포함되어야 하는 필드를 개발자의 실수로 누락할 가능성이 있습니다. 이는 컴파일 에러로 확인할 수 없기 때문에 중대한 실수로 이어질 수 있습니다.

MemberEntity memberEntity = MemberEntity
		.builder()
		// 이메일 필드 설정 누락
		.name("박소은")
		.nickname("sso")
		.password("1234!!")
		// 휴대폰 번호 필드 설정 누락
		.build();

객체의 원자성이란 객체가 항상 유효한 상태를 유지해야 한다는 뜻입니다.

회원은 반드시 이메일과 휴대폰 번호를 가져야 한다고 가정하겠습니다. 그러나 빌더 패턴 사용 시 두 필드를 누락하고 회원 생성이 가능합니다. 이 회원은 유효하지 않은 회원이며 회원 객체의 원자성이 보장되지 않았습니다.

Entity는 db와 밀접하게 연관이 있어 원자성 보장이 중요한 객체라고 생각합니다. 따라서 E2E 기간 동안 저희 팀에서는 Entity에 붙은 @Builder를 모두 제거하는 리팩토링을 진행했습니다.

3️⃣ VO를 사용하면 어떻게 바뀌나요?

아래는 이메일, 패스워드, 휴대폰 번호를 각각 Email, Password, PhoneNumber VO 객체로 선언한 회원 Entity 코드입니다.

package org.example;

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE)
	private Long id;

	@Convert(converter = EmailConverter.class)
	private Email email;

	private String name;

	private String nickname;

	@Convert(converter = PasswordConverter.class)
	private Password password;

	@Convert(converter = PhoneNumberConverter.class)
	private PhoneNumber phoneNumber;

	public MemberEntity(Email email, String name, String nickname, Password password, PhoneNumber phoneNumber) {
		this.email = email;
		this.name = name;
		this.nickname = nickname;
		this.password = password;
		this.phoneNumber = phoneNumber;
	}
}

MemberEntity member = new MemberEntity(new Email("sso@kernel.com"), 
		"박소은", "sso",
		new Password("1234!!"),
		new PhoneNumber("010-1111-2222"));

이메일과 패스워드, 휴대폰 번호는 모두 다른 타입이기 때문에, 파라미터의 순서가 뒤바뀔 때 컴파일 에러가 발생합니다. 이를 통해 타입 안전성이 보장되고 있다는 것을 확인할 수 있습니다.

또한 필수적인 필드를 누락하고 회원을 생성할 수 없기 때문에 객체의 원자적 생성 또한 보장되고 있습니다.

🔖 VO의 특징 1 - 불변성

VO

= Value Object (값 객체)
= 값 그 자체를 표현하는 객체
= 불변 객체
  = 생성자로만 초기화


VO는 불변 객체입니다. 내부 값은 private final로 선언되어 있으며, @Setter 등의 메서드로 상태를 변경할 수 없습니다. 또한 생성자로만 값을 초기화할 수 있습니다.

위에서 봤던 PaperMoney 객체를 다시 보며 VO의 불변성을 확인해보겠습니다.


package org.example.vo;

@EqualsAndHashCode
@Getter
public class PaperMoney {
	private final Integer paperMoneyValue;

	private static final List<Integer> VALID_PAPER_MONEY = Arrays.asList(1000, 5000, 10000, 50000);

	public PaperMoney(Integer paperMoneyValue) {
		validatePaperMoney(paperMoneyValue);
		this.paperMoneyValue = paperMoneyValue;
	}
	
	private void validatePaperMoney(Integer paperMoneyValue) {
				if(!VALID_PAPER_MONEY.contains(paperMoneyValue)) {
						throw new IllegalArgumentException("유효하지 않은 지폐입니다.");
    }
	}
}

PaperMoney VO 클래스의 값인 paperMoneyValueprivate final로 선언되어 있습니다. 값이 한 번 초기화되면 변경할 수 없습니다.

paperMoneyValue 값의 초기화는 생성 시에 이루어지고 있습니다.

그런데 만약 1,000원짜리 지폐를 아버지가 50,000원짜리로 바꿔주셨다고 가정해봅시다. 이를 코드 상에는 어떻게 반영할 수 있을까요?

VO는 내부 상태를 변경하는 것이 아니라, 변경된 상태를 가지고 있는 새 객체를 만들어야 합니다.


package org.example;

import org.example.vo.PaperMoney;

public class MoneyService {

	PaperMoney paperMoney = new PaperMoney(1000);
	PaperMoney newPaperMoney = new PaperMoney(50000);

}

1,000원권 지폐가 50,000원권 지폐로 바뀔 수 없습니다. 새로운 50,000원권 지폐를 발행하여 교체해야 합니다. 1,000원권 지폐는 무슨 일이 생겨도 1,000원이라는 값을 유지해야 하며, 이는 절대 변하지 않는 값입니다. 만약 이 값이 변한다면 위조 지폐가 되겠죠 ?

이렇게 지폐의 값에 변화가 생길 때마다 새롭게 객체를 만들어줘야 합니다 !


잦은 객체 생성?

그럼 너무 잦은 객체 생성이 발생하는 건 아닐까? 생각할 수도 있을 것 같습니다. 실제로 저희 팀에서는 잦은 객체 생성에 의문을 느껴 멘토님께 질문하며 답을 찾았습니다.

스크린샷 2024-09-18 오후 8 50 38


🪪 VO의 특징 2 - 자가 유효성 검사

VO

= Value Object  (값 객체)
= 값 그 자체를 표현하는 객체
= 불변 객체
  = 생성자로만 초기화
= 값의 유효성 검사는 생성 시 이루어진다.
  = 유효하지 않은 값으로 값 객체를 만들 수 없다.


package org.example.vo;

@EqualsAndHashCode
@Getter
public class PaperMoney {
	private final Integer paperMoneyValue;

	private static final List<Integer> VALID_PAPER_MONEY = Arrays.asList(1000, 5000, 10000, 50000);

	public PaperMoney(Integer paperMoneyValue) {
		validatePaperMoney(paperMoneyValue);
		this.paperMoneyValue = paperMoneyValue;
	}
	
	private void validatePaperMoney(Integer paperMoneyValue) {
				if(!VALID_PAPER_MONEY.contains(paperMoneyValue)) {
						throw new IllegalArgumentException("유효하지 않은 지폐입니다.");
    }
	}
}

VO 객체는 생성 시점부터 항상 유효한 상태를 유지해야 합니다. 값의 유효성 검사는 생성 시에 이루어지며, 유효하지 않은 값으로는 VO 객체를 생성할 수 없습니다.

💵 VO의 특징 3 - 동등성 비교

1️⃣ 동일성과 동등성

먼저 동일성과 동등성 개념을 잠시 짚고 넘어가겠습니다.

동일성

  • 메모리 내 주소값이 같은지를 비교합니다.

동등성

  • 논리적으로 동일한 값을 나타내고 있는지를 비교합니다.
  • 동일한 객체는 동등합니다.

image (1)

2️⃣ 동등성 비교

  • 동등성 비교 : 객체가 비록 다르더라도 내부의 데이터가 같은지를 비교하는 것❗


VO

= Value Object  (값 객체)
= 값 그 자체를 표현하는 객체
  = 값으로만 비교되는 객체 = 동등성 비교가 가능해야 한다.
  = equals()와 hashCode() 오버라이딩
= 불변 객체
  = 생성자로만 초기화
= 값의 유효성 검사는 생성 시 이루어진다.
  = 유효하지 않은 값으로 값 객체를 만들 수 없다.


앞선 지폐 예시에서, 고유 번호가 다르더라도 모든 5만원권은 동일한 액수의 금액으로 취급됩니다. VO 객체도 마찬가지로 내부 상태(값)가 같다면 동등하게 취급되어야 하는데요! 오직 값으로만 비교되어야 완전한 VO 객체라고 할 수 있습니다.

동등성 비교가 가능하려면 equals()hashCode() 메서드를 모두 오버라이딩해줘야 합니다.


3️⃣ Equals, HashCode

1. Equals

public boolean equals(Object obj)
  • 객체의 번지를 비교해서
  • 동일한 객체라면 true를 리턴하고, 그렇지 않으면 false를 리턴

2. HashCode

public int hashCode()
  • 객체의 번지를 이용해서
  • 객체의 해시코드(객체를 식별하는 정수)를 리턴


4️⃣ 동등성 비교 테스트

@EqualsAndHashCode 어노테이션을 통해 편리하게 equals()hashcode() 메서드를 오버라이드할 수 있습니다.

NoOverridePhoneNumber 클래스는 @EqualsAndHashCode 를 붙이지 않았고, PhoneNumber 클래스에는 @EqualsAndHashCode 를 붙였습니다.

이 두 가지 클래스에 대해 각각 같은 값으로 두 개의 객체를 만들어 동등 비교를 하는 테스트입니다.


package org.example;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

class MainTest {
    @Test
    void equals_override_되지_않으면_값이_같아도_다르게_판단한다() {
        // given
        NoOverridePhoneNumber phoneNumber1 = new NoOverridePhoneNumber("010-1111-2222");
        NoOverridePhoneNumber phoneNumber2 = new NoOverridePhoneNumber("010-1111-2222");
        // when
        boolean result = phoneNumber1.equals(phoneNumber2);
        boolean result2 = phoneNumber1.hashCode() == phoneNumber2.hashCode();
        // then
        assertThat(result).isFalse();
        assertThat(result2).isFalse();
    }

    @Test
    void equals_override_하면_값이_같을때_동등하게_판단한다() {
        // given
        PhoneNumber phoneNumber1 = new PhoneNumber("010-1111-2222");
        PhoneNumber phoneNumber2 = new PhoneNumber("010-1111-2222");
        // when
        boolean result = phoneNumber1.equals(phoneNumber2);
        boolean result2 = phoneNumber1.hashCode() == phoneNumber2.hashCode();
        // then
        assertThat(result).isTrue();
        assertThat(result2).isTrue();
    }
}


테스트 결과, NoOverridePhoneNumber 클래스의 경우 false를 리턴하고 PhoneNumber 클래스는 true를 리턴하는 것을 확인할 수 있습니다.

따라서, VO 객체에는 equals()hashcode() 메서드를 오버라이드하여, 동등 비교가 가능하도록 해야 합니다. 값으로만 비교되어야 하는 객체이기 때문이죠!


🎧 String과 VO 클래스의 유사점

VO 객체와 유사한 특징을 가지고 있는 클래스로는 String 클래스가 있습니다. 특히 불변성과 동등성 비교 측면에서 유사한 점이 많은데요 !

1. 불변성

String hello = "hello";
hello = hello + " vo";

기존 hello 객체의 상태를 변경하는 것이 아니라 새로운 객체를 생성합니다.

이때 “hello”, “vo”, “hello vo” 총 3개의 객체가 만들어집니다. 변경 전과 변경 후의 해시코드는 다릅니다.

2. 동등성 비교

String 클래스는 equals(), hashCode() 메서드를 재정의해서 내부 문자열이 같은지를 비교합니다.

public boolean equals(Object anObject) {
		if (this == anObject) {
		    return true;
    }
    return (anObject instanceof String aString)
		    && (!COMPACT_STRINGS || this.coder == aString.coder)
		    && StringLatin1.equals(value, aString.value);
}

문자열 안의 내용이 같다면 true를 리턴합니다.


🙋 Q&A

1️⃣ VO 클래스를 어디까지 만들어야 할까요?

💭 만약 모든 필드에 대해 VO 객체를 만든다면 클래스가 너무 늘어나지 않을까요?


회원 Entity 예시에서는 이메일, 전화번호, 패스워드에 대해서 VO 객체를 만들었습니다. 이 외에 이름, 닉네임 필드는 여전히 String 타입으로 남아있습니다. 제가 생각했던 기준은 다음과 같습니다.


  1. 정규표현식 등으로 검증이 필요할 만큼 특징이 뚜렷한 필드인가? (값에 엄격한 규칙이 필요한가?)
  2. 크기 제한 등과 같은 간단한 검증만 필요하다면 DTO의 Validation 어노테이션으로 처리하자.


아래는 멘토링 받은 답변을 정리한 내용입니다. 😺

해당 애플리케이션의 도메인에 따라 달라집니다.

long 으로 표현할 수도 있고, 돈이 도메인이 되는 곳(ex. 금융)에서는 Money 타입이 필요합니다.

그럼 이름이 중요한 도메인은 어떻게 될까요? Name 타입을 사용할 수도 있지만, FirstName, LastName으로 더 구분해서 만들 수도 있습니다.

또, 사용자는 모든 도메인에서 User타입으로만 표현될까요?
송금이라는 도메인을 예로 생각해보면, A 사용자가 B 사용자에게 돈을 송금한다고 가정했을 때, 두 종류의 User가 존재합니다. User 타입을 더 세분화하면 애초에 Sender, Receiver라는 타입으로 나눌 수 있습니다.

본인이 개발하는 애플리케이션의 도메인 내에서 핵심 도메인을 찾고 그 도메인을 타입을 세분화 시켜주고 부수 도메인은 비교적 덜 세분화시켜주는 결정이 필요합니다.


2️⃣ @Builder 사용은 Entity에서만 지양하면 될까요?

💭 원자적 객체 생성 관점에서 Entity에 빌더 패턴 사용을 지양한다고 하셨는데, 그럼 DTO나 다른 객체에서도 사용하지 않는 것이 좋나요?


E2E를 진행하며 저희 팀에서는 엔티티에서만 빌더를 제거하였고, 이외의 객체에서는 비교적 자유롭게 사용했습니다. 특히 DTO는 client의 요청을 담고 있기 때문에 email, password, phoneNumber 모두 String 타입으로 저장됩니다. 그래서 타입 안전성을 보장하기 어렵다고 생각해 builder를 사용하고 있었습니다.


아래는 멘토링 받은 답변을 정리한 내용입니다. 😺

무분별한 빌더 사용은 지양하되, DTO에는 유연하게 사용해도 무방합니다.

작은 객체생성자 생성방식을 고려해보면 좋을 것 같습니다.

빌더의 장점은 알고계시다시피 필드들을 명확히 할당할 수 있고, 객체의 크기가 크더라도 필드들을 헷갈리지 않고 할당할 수 있습니다.

다만 큰 객체의 생성은 빌더로 극복할게 아니라 객체의 분리를 통해 해결하는 것이 좋습니다. 객체의 크기가 작아진다면, 앞서 이야기한 빌더의 장점이 많이 사라집니다. 작은 객체를 만들땐 굳이 빌더를 안써도 객체를 만드는게 명확해지기 때문입니다.

또 dto는 일반적인 객체라고 보기 힘든 객체입니다. 객체는 캡슐화를 지켜야하는데 dto는 태생부터 캡슐화는 무시하고 그냥 데이터만 운반하는 객체라 편하게 만들어도 됩니다. 클린코드라는 책에서는 객체를 자료구조와 객체라고 구분해서 얘기하는데 dto는 저 구분에서 자료구조로 분류가 됩니다. 따라서 dto는 유연하게 가셔도 무방합니다 !




출처는 아래와 같습니다. 긴 글 읽어주셔서 감사합니다 !


이것이 자바다 - 신용권 (한빛미디어) Ch12.3 Object 클래스

[10분 테코톡] 📍인비의 DTO vs VO

VO란? https://velog.io/@livenow/Java-VOValue-Object란

CatchLine 서비스를 개발하며 받은 멘토링

https://github.com/Kernel360/E2E2-CATCHLINE/pull/59