이번 포스팅에선 DTO ( Data Transfer Object ), VO ( Value Object ), Record에 대해서 자세히 알아보고자 한다.
이 녀석은 말 그대로 데이터를 전송하는 객체이다. 이해를 위해 예시를 들어서 설명해보겠다.
로그인 기능을 이용한다고 가정하자

최대한 열심히 그려봤다.. 순서는 다음과 같을 것이다.
- 클라이언트에서 아이디와 비밀번호를 DTO에 담아 서버로 요청을 보낸다.
- 서버의 컨트롤러에서 해당 요청에 맞게 DTO에 있는 값을 서비스로 보낸다.
- 서비스에서 해당 DTO에 있는 값으로 Entity에 있는 값과 확인한다.
- 클라이언트 요청에 의한 응답 값을 서비스와 컨트롤러의 응답값을 담아 보낸다.
위와 같이 데이터가 전송될 때 꼭 필요한 객체이다. 이를 사용하는 주된 목표는 다음과 같다.
클라이언트와 서버 간, 혹은 계층 간에 데이터를 효율적이고 안전하게 전달하기 위한 객체
DTO를 사용하는 이유를 구체적으로 나열하자면 다음과 같다.
1. 데이터 보호 및 캡슐화 - 클라이언트에 필요한 정보만 제공하여 민감한 정보(비밀번호 등)를 보호
2. 데이터 형태 변경 및 가공 - 클라이언트의 요구 사항에 맞게 데이터를 가공, 다른 형식으로 변환
보다 많은 이유들이 있지만.. 중요한 건 이 두 가지 경우인 것 같다!
필자는 사실 이거에 대해 공부하기 전, VO에 대해선 알지 못했다.. 왜 사람들이 혼동하는 지도,,
정의하자면 다음과 같다.
도메인 계층에서 사용하는 객체로, 값을 나타내거나 특정 도메인 개념을 캡슐화한다.
VO는 비지니스 로직에서 사용될 수 있고, 예를 들면 다음과 같이 사용될 수 있다.
// Money 클래스 ( VO )
// 금액과 통화를 표현하며, 관련된 비즈니스 로직(더하기, 유효성 검사)을 진행
// 불변성을 보장하여 값 변경으로 인한 오류 방지.
public class Money(int amount, String currency) {
// 주 생성자: 금액과 통화에 대한 유효성 검사
public Money {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0원 이상이여야 합니다.");
}
this.amount = amount;
this.currency = currency;
}
// 두 Money 객체를 더하는 메서드.
// 같은 통화만 더할 수 있으며, 결과는 새로운 Money 객체로 반환.
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("같은 통화가 아닙니다.");
}
return new Money(this.amount + other.amount, this.currency);
}
public int getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
@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 && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
// Main 클래스
public class Main {
public static void main(String[] args) {
// Money 객체 생성
Money price = new Money(100, "USD");
Money discount = new Money(20, "USD");
// 더하기 연산: 같은 통화끼리만 가능
Money finalPrice = price.add(discount); // Money[amount=120, currency=USD]
// 유효성 검사: 다른 통화끼리 더하려고 하면 예외 발생
try {
Money euroPrice = new Money(50, "EUR");
price.add(euroPrice);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
이렇게 보면 VO가 그냥 값 기반으로 비교하는 것밖에 없는 게 아닌가 하는 생각이 들지만 VO의 핵심 역할은 다음과 같다.
예시
Money price = new Money(100, "USD"); // 의미 명확
int price = 100; // 의미 불명확, 통화 정보 없음
예시
// 금액과 통화의 일관성을 유지하면서 재사용 가능한 비즈니스 로직 제공
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("같은 통화가 아닙니다.");
}
return new Money(this.amount + other.amount, this.currency);
}
예시
public class Email(String address) {
public Email {
if (!address.contains("@")) {
throw new IllegalArgumentException("유효하지 않은 이메일 주소입니다.");
}
}
}
Money money1 = new Money(100, "USD");
Money money2 = new Money(100, "USD");
System.out.println(money1.equals(money2)); // true
지금까지의 포스팅을 보면 알겠지만 DTO와 VO는 확실하게 다르다.
갑자기 무슨 생뚱맞은 녀석인가 싶겠지만.. 이 녀석 아주 좋은 녀석이다.
Java 14 버전부터 나왔으며(정식 도입은 16), 데이터 저장을 위한 불변 객체를 만들 때 매우 유용하다.
그래서 이 녀석이 뭐냐고? 다음과 같다.
- Java에서 데이터를 캡슐화하고 불변 객체를 쉽게 생성하기 위해 도입된 특별한 클래스
- 불변성(Immutable)을 기본으로 지원하므로, 필드가 한 번 설정되면 변경할 수 없음
- 생성자, getter(accessor), toString, equals, hashCode를 자동으로 생성
우리가 앞서 봤던 DTO , VO와 둘 다 적합하지만 비교해봤을 때 다음과 같다.

큰 차이는 직렬화/역직렬화 같은데, 이게 뭔지도 알아봤다.
직렬화 - Java 객체를 JSON, XML, 또는 바이트 스트림으로 변환하는 과정
역직렬화 - JSON, XML, 또는 바이트 스트림을 다시 Java 객체로 복원하는 과정
쉽게 말하자면 클라이언트와 서버가 통신할 때 필요한 녀석이다.
뭐 결과적으론 VO에서 더 자주 사용된다는건데, 우리가 작성했던 코드를 record로 바꾸면 다음과 같다.
// Money record
// getter , equals , hashCode 생략으로 인한 코드 간결화
public record Money(int amount, String currency) {
public Money {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0원 이상이여야 합니다.");
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("통화가 비어있을 수 없습니다.");
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("같은 통화가 아닙니다.");
}
return new Money(this.amount + other.amount, this.currency);
}
}
// Main 클래스는 동일
훨씬 코드가 간결해졌다!
마지막으로 Mapper에 대해 정리해보도록 하겠다.
먼저 이 녀석이 뭔지에 대해서부터 설명을 하겠다.
- 데이터 간 변환을 책임지는 중간 계층 역할을 하는 도구 또는 클래스를 의미
- 주로 객체 간의 데이터 변환에 사용
- 데이터 구조가 다른 두 객체(DTO와 Entity 등) 간의 매핑을 처리하는 데 사용
역할은 다음과 같다. 해당 포스팅은 DTO에 대한 포스팅이므로 관련하여 설명하겠다.
서비스 계층에서 데이터를 주고받을 때, DTO와 Entity 간 변환은 필수적이다. Mapper는 이 변환 과정을 간단하고 명확하게 처리해준다. 예시 코드와 같이 사용할 수 있다.
public User toEntity(UserDTO dto) {
return new User(dto.getId(), dto.getName(), dto.getEmail());
}
public UserDTO toDTO(User entity) {
return new UserDTO(entity.getId(), entity.getName(), entity.getEmail());
}
변환 로직을 한 곳에 모아 중복과 복잡성을 줄여준다.
즉, Service나 Controller 계층에 흩어져 있는 변환 로직을 Mapper에서 통합할 수 있다.
좀 극단적인 것 같지만,, 예시를 보여 비교해보겠다.
// Mapper를 사용하지 않을 때
@Service
public class UserService {
public UserDTO createUser(UserDTO userDTO) {
User user = new User(userDTO.getId(), userDTO.getName(), userDTO.getEmail()); // 변환 로직
userRepository.save(user);
return new UserDTO(user.getId(), user.getName(), user.getEmail()); // 변환 로직
}
}
// Mapper를 사용할 경우
@Service
public class UserService {
private final UserMapper userMapper;
public UserDTO createUser(UserDTO userDTO) {
User user = userMapper.toEntity(userDTO); // 변환 로직 분리
userRepository.save(user);
return userMapper.toDTO(user); // 변환 로직 분리
}
}
2번과 비슷한 맥락이지만 데이터 변환 로직이 여러 곳에서 반복되면 코드 중복이 발생하게 되므로 Mapper를 사용하게 되면 코드 가독성 좋아지고 유지보수성이 향상된다. 예시는 다음과 같다.
// Mapper를 사용하지 않을 때
User user1 = new User(dto1.getId(), dto1.getName(), dto1.getEmail());
User user2 = new User(dto2.getId(), dto2.getName(), dto2.getEmail());
// Mapper를 사용할 경우
User user1 = userMapper.toEntity(dto1);
User user2 = userMapper.toEntity(dto2);
Mapper를 사용하는 방법엔 크게 3가지 정도로 나뉘어지는데, 다음과 같다.
- 커스텀 Mapper
- MapStruct
- ModelMapper
간단하게 사용법만 정리해보겠다. 먼저는 커스텀 Mapper의 예시이다.
// Mapper 인터페이스
public interface Mapper<D, E> {
E toEntity(D dto); // DTO → Entity 변환
D toDTO(E entity); // Entity → DTO 변환
}
// Mapper 구현체
@Component
public class UserMapper implements Mapper<UserDTO, User> {
@Override
public User toEntity(UserDTO dto) {
if (dto == null) return null;
return new User(dto.getId(), dto.getName(), dto.getEmail());
}
@Override
public UserDTO toDTO(User entity) {
if (entity == null) return null;
return new UserDTO(entity.getId(), entity.getName(), entity.getEmail());
}
}
// Service
@Service
public class UserService {
private final UserMapper userMapper;
public UserDTO getUser(Long id) {
User user = userRepository.findById(id).orElseThrow(() -> new RuntimeException("유저 x"));
return userMapper.toDTO(user); // Mapper 사용
}
}
다음은 MapStruct를 사용할 때이다.
// Mapper 인터페이스
@Mapper(componentModel = "spring")
public interface UserMapper {
User toEntity(UserDTO dto);
UserDTO toDTO(User entity);
}
// Service
@Service
public class UserService {
private final UserMapper userMapper;
@Autowired
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
public UserDTO getUser(Long id) {
User user = userRepository.findById(id).orElseThrow();
return userMapper.toDTO(user);
}
}
상당히 간단하다. ModelMapper도 마찬가지이다.
ModelMapper modelMapper = new ModelMapper();
UserDTO userDTO = modelMapper.map(user, UserDTO.class);
이것들을 비교하기 위해 한 가지 표를 가져왔다.

상황에 따라 사용하고 싶은 Mapper를 사용하면 될 듯 하다.
오랜만에 길고 긴 포스팅을 써봤는데,, 생각했던 것보다 DTO 단에서 알아야 하는 부분이 많아서 공부가 되었다. 누군가도 이 글을 보고 도움을 받길,,ㅎ