본문 바로가기

SpringBoot

[Spring] DTO와 VO

반응형

개요

 

프로그래밍을 하다 보면 데이터를 전달하고 표현해야 한다는 사실에 의문을 가지는 사람을 없을 것입니다.

그렇다면 어떻게(how)라는 방법이 중요한 문제라 생각합니다.

 

본 페이지에서는 Spring Boot를 공부하면서 DTOVO를 활용해 데이터를 전달하고 표현하는 방식에 대해 설명합니다.


내용

1. DTO (Data Transfer Object)

DTO는 Controller, Service, View 간 데이터를 주고받는 용도로 사용합니다.

오로지 데이터를 저장하고 전달하는 역할에 집중하기 위해 로직을 포함하지 않습니다. (getter와 setter만을 가집니다.)

일반적으로 식별자나 특정 속성에 의해 두 객체가 동일하다 판단합니다.

 

예시
// data class로 선언함으로써 getter와 setter 자동 생성
data class UserDTO(
	var id: Long?,
	var username: String,
	var email: String
)

 

2. VO (Value Object)

VO는 값 자체를 표현하는 용도로 사용합니다.

DTO와 달리 로직을 포함할 수 있고, 불변성을 가지는 데이터를 저장하므로 getter만 존재합니다.

 

예시
// data class이지만 속성을 val로 선언함으로써 setter를 생성하지 않음
// 또한 val이기 때문에 속성 직접 변경 불가
data class Money (
	val amount: Int,
	val currency: String
)

// private으로 감추는 것도 가능
// 외부에서 값을 변경할 수 없게 되어 불변성이 보장
data class Money (
	private var amount: Int,
	private var currency: String
)

 

VO는 다른 메모리 주소를 가질지라도 모든 속성 값이 동일하면 같은 객체로 판단합니다.

 

예시
val money1 = Money(1000, "won")
val money2 = Money(1000, "won")

print(money1 == money2) // true (속성 비교)
print(money1 === money2) // false (메모리 주소 비교)

※ Kotlin에서 == 연산자는 equals()를 호출.

 

일반 class로 구현하게 되면 equals()와 hashCode()를 직접 구현(Override) 해야 합니다.

 

예시
class Money (
	private val amount: Int,
	private val currency: String
) {
	override fun equals(other: Any?): Boolean {
		if (this === other) return true
		if (other !is User) return false
		return amount == other.amount && currency == other.currency
	}
	
	override fun hashCode() = this.amount.hashCode() + this.currency.hashCode()
}

 

VO 생성 시점에 유효성 검사를 한다는 특징이 있습니다.

생성자를 private으로 두고, 정적 메소드 패턴을 통해 객체를 생성하는 방식이 일반적입니다.

 

예시
class Money (
	private val amount: Int,
	private val currency: String
) {
	override fun equals(other: Any?): Boolean {
		if (this === other) return true
		if (other !is User) return false
		return amount == other.amount && currency == other.currency
	}
	
	override fun hashCode() = this.amount.hashCode() + this.currency.hashCode()
	
  companion object {
    fun of(amount: Int, currency: String): Money {
        validateMoney(amount, currency)
        return Money(amount, currency)
    }
  }
}

 

3. 예시로 DTO와 VO 이해하기

DTO는 ‘학생의 성적표’와 유사하다.

 

학생의 성적표에는 학생의 이름, 학년, 학번, 그리고 각 과목별 성적이 기록됩니다.

매 학기가 지날 때마다 과목별 성적이 변할 수 있지만, 우리는 성적표가 동일한 학생의 것임을 알고 있습니다.

이유는 이름, 학년, 학번으로 학생을 구별하기 때문입니다.

 

이처럼 DTO는 식별자(ID), 혹은 특정 속성들이 동일하면 두 객체가 동등하다 판단합니다.

즉, UserDTO에서 name과 email이 서로 다르더라도 id가 동일하면 두 객체는 동등하다 여기는 것입니다.

 

예시
// id로만 서로 다른 UserDTO를 비교할 수 있게 equals()와 hashCode() override
data class UserDTO(
    var id: Long?,
    var username: String,
    var email: String
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is UserDTO) return false
        return id == other.id // id만 비교
    }

    override fun hashCode(): Int {
        return id?.hashCode() ?: 31 // id만 사용하여 hashCode 생성
    }
}

val user1 = UserDTO(1, "Kim", "kim@example.com")
val user2 = UserDTO(1, "Lee", "lee@example.com")

print (user1 == user2) // true

 

VO는 ‘화폐’와 같다.

 

1000원 짜리 지폐가 2장 있습니다.

서로의 발권 번호는 다르지만 2장의 지폐가 가지는 가치는 동일하기 때문에

우리는 2장 모두 같은 1000원 지폐라 말합니다.

 

이처럼 VO는 그 값을 나타내는 속성이 동일하다면 두 객체는 동등하다 판단합니다.

 

예시
val money1 = Money(1000, "won")
val money2 = Money(1000, "won")

print(money1 === money2) // "발권 번호(메모리 주소)"가 다르지만
print(money1 == money2) // "가치(amount)"와 "통화(currency)"는 동일하다

 


정리

개념  DTO (Data Transfer Object) VO (Value Object)
정의 계층 간 데이터 전송을 위한 객체 특정 값을 표현하는 객체
식별 방법 ID 또는 특정 속성 활용 값이 같으면 같은 객체로 판단
가변성 가변 불변 (값 변경 불가)
라이프사이클 컨트롤러-서비스-뷰 간 일시적으로 사용됨 엔티티 내부에서 값으로 사용됨
어디서 사용? Controller ↔ Service ↔ View (API 응답 등) Entity 내부의 값 필드로 사용
주요 목적 데이터를 변환해 계층 간 전달 특정 개념을 값으로 표현

참고

https://curiousjinan.tistory.com/entry/spring-data-transfer-vo-dto

 

스프링에서 데이터 전달의 핵심: VO와 DTO의 이해 및 활용

스프링에서 Data를 전달하는 객체에는 VO, DTO가 있는데 이게 어떤것인지 알아보자 VO (Value Object)와 DTO (Data Transfer Object)는 모두 Java 및 Spring과 같은 객체 지향 프로그래밍 및 프레임워크에서 데이터

curiousjinan.tistory.com


추가

VO가 로직을 포함한다면 의미 전달이 명확해지고, 비즈니스 변경이 일어났을 때 전체 코드를 수정할 필요 없이 VO 하나만 고치면 되므로 유지보수가 쉬워진다는 장점이 있습니다.

 

VO를 사용하기 전/후를 비교해 설명해 보겠습니다.

 

로직 미포함

먼저 VO가 로직을 포함하지 않을 때입니다.

예시
val remain = Money(20000, "KRW")
val money = remain.amount + 10000

 

20000원의 정보를 지니는 Money 객체를 생성한 뒤에, 10000원을 추가하고 있습니다.

이때 발생할 수 있는 문제는 다음과 같습니다.

  • 10000이 보너스, 수수료, 추가 결제 금액 등 무엇을 의미하는 값인지 알기 어려움
  • remain.amount는 단순 Int이기 때문에 "통화" 정보가 사라지게 되므로, 논리적 에러가 발생할 수 있음
  • 사실 10000이 "원"이 아니라 "엔"이었다면, 해당 리터럴을 사용하는 모든 곳을 수동으로 찾아서 수정해야 할 수도 있음

로직 포함

VO가 로직을 포함할 때입니다.

 

예시
data class Money(
    var amount: Int,
    val currency: String
) {
    fun add(other: Money): Money {
        require(this.currency == other.currency) {
            "Cannot add different currencies: ${this.currency} and ${other.currency}"
        }
        return Money(this.amount + other.amount, this.currency)
    }
}

val remain = Money(20000, "KRW")
val bonus = Money(10000, "KRW")
val total = remain.add(bonus.amount)

 

위의 코드를 통해서 가져갈 수 있는 장점은 다음과 같습니다.

  • Money(10000, "KRW")처럼 통화 단위와 함께 값을 명시하기 때문에, 금액이 어떤 의미인지 한눈에 파악 가능
  • bonus, fee, discount 등으로 명확한 변수명과 VO를 사용하면 도메인 의미가 코드에 드러남
  • 금액 계산은 VO 내부 메소드(add)를 통해 일어나므로, "통화" 일치나 음수 방지 같은 정책을 한 곳에서 통제 가능
  • 나중에 비즈니스 로직이 바뀌더라도, VO만 수정하면 전체 코드에 일관서 있게 적용

즉, "원" 통화로 현재 남아 있는 금액(remain)에 보너스(bonus)를 지급받아 합계(total)를 구하는 흐름을 바로 알아챌 수 있습니다.

반응형