개요
프로그래밍을 하다 보면 데이터를 전달하고 표현해야 한다는 사실에 의문을 가지는 사람을 없을 것입니다.
그렇다면 어떻게(how)라는 방법이 중요한 문제라 생각합니다.
본 페이지에서는 Spring Boot를 공부하면서 DTO와 VO를 활용해 데이터를 전달하고 표현하는 방식에 대해 설명합니다.
내용
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)를 구하는 흐름을 바로 알아챌 수 있습니다.
'SpringBoot' 카테고리의 다른 글
[Spring] Spring Cache와 JPA 1차 캐시 비교 (1) | 2025.06.03 |
---|---|
[Spring] Cache annotation 사용성 개선하기 with Kotlin (1) | 2025.05.11 |
[Spring] Cache 사용하기 (@Cacheable, @CachePut, @CacheEvict) (1) | 2025.05.06 |
[Spring] Filter, Interceptor 그리고 AOP (0) | 2025.03.03 |