[Spring Boot] MVC 아키텍처 - DTO , VO , Record , Mapper

정원준·2024년 12월 26일

Back-End

목록 보기
7/9

이번 포스팅에선 DTO ( Data Transfer Object ), VO ( Value Object ), Record에 대해서 자세히 알아보고자 한다.

DTO ( Data Transfer Object )

이 녀석은 말 그대로 데이터를 전송하는 객체이다. 이해를 위해 예시를 들어서 설명해보겠다.
로그인 기능을 이용한다고 가정하자

최대한 열심히 그려봤다.. 순서는 다음과 같을 것이다.

  1. 클라이언트에서 아이디와 비밀번호를 DTO에 담아 서버로 요청을 보낸다.
  2. 서버의 컨트롤러에서 해당 요청에 맞게 DTO에 있는 값을 서비스로 보낸다.
  3. 서비스에서 해당 DTO에 있는 값으로 Entity에 있는 값과 확인한다.
  4. 클라이언트 요청에 의한 응답 값을 서비스와 컨트롤러의 응답값을 담아 보낸다.

위와 같이 데이터가 전송될 때 꼭 필요한 객체이다. 이를 사용하는 주된 목표는 다음과 같다.

클라이언트와 서버 간, 혹은 계층 간에 데이터를 효율적이고 안전하게 전달하기 위한 객체

DTO 사용 이유

DTO를 사용하는 이유를 구체적으로 나열하자면 다음과 같다.

1. 데이터 보호 및 캡슐화 - 클라이언트에 필요한 정보만 제공하여 민감한 정보(비밀번호 등)를 보호
2. 데이터 형태 변경 및 가공 - 클라이언트의 요구 사항에 맞게 데이터를 가공, 다른 형식으로 변환

보다 많은 이유들이 있지만.. 중요한 건 이 두 가지 경우인 것 같다!

VO ( Value Object )

필자는 사실 이거에 대해 공부하기 전, VO에 대해선 알지 못했다.. 왜 사람들이 혼동하는 지도,,

그래서 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가 그냥 값 기반으로 비교하는 것밖에 없는 게 아닌가 하는 생각이 들지만 VO의 핵심 역할은 다음과 같다.

1. 값의 의미를 명확히 표현

  • VO는 단순한 데이터 타입(숫자, 문자열)으로 표현할 수 없는 값의 의미를 표현
  • 값과 관련된 속성, 제약조건, 유효성을 한 객체로 캡슐화하여 도메인 모델의 가독성을 높이고 유지보수를 용이하게 함

예시

Money price = new Money(100, "USD");  // 의미 명확
int price = 100;                     // 의미 불명확, 통화 정보 없음

2. 관련 비즈니스 로직 캡슐화

  • VO는 값과 관련된 비즈니스 로직을 포함하여, 도메인 계층에서 복잡한 로직을 한 곳에 모으는 역할을 함
  • 값 자체의 무결성을 유지하면서 로직을 캡슐화하여 중복 코드와 복잡성을 줄임

예시

// 금액과 통화의 일관성을 유지하면서 재사용 가능한 비즈니스 로직 제공
public Money add(Money other) {
    if (!this.currency.equals(other.currency)) {
        throw new IllegalArgumentException("같은 통화가 아닙니다.");
    }
    return new Money(this.amount + other.amount, this.currency);
}

3. 데이터 무결성 보장

  • VO는 값을 캡슐화하여 유효성 검사를 중앙화한다. 이를 통해 잘못된 값이 시스템에 들어오는 것을 방지
  • 값의 유효성을 VO 내부에서 강제하므로, 시스템 전반에서 데이터의 안정성이 높아짐

예시

public class Email(String address) {
    public Email {
        if (!address.contains("@")) {
            throw new IllegalArgumentException("유효하지 않은 이메일 주소입니다.");
        }
    }
}

4. 값 기반 비교

  • VO는 값 자체로 객체를 구분하기 때문에, 객체의 참조가 아닌 값을 기반으로 비교
  • 이는 VO가 같은 값을 가지면 같은 객체로 간주해야 하는 비즈니스 요구사항에 적합
    예시
Money money1 = new Money(100, "USD");
Money money2 = new Money(100, "USD");

System.out.println(money1.equals(money2));  // true

지금까지의 포스팅을 보면 알겠지만 DTO와 VO는 확실하게 다르다.

Record

갑자기 무슨 생뚱맞은 녀석인가 싶겠지만.. 이 녀석 아주 좋은 녀석이다.
Java 14 버전부터 나왔으며(정식 도입은 16), 데이터 저장을 위한 불변 객체를 만들 때 매우 유용하다.
그래서 이 녀석이 뭐냐고? 다음과 같다.

  • Java에서 데이터를 캡슐화하고 불변 객체를 쉽게 생성하기 위해 도입된 특별한 클래스
  • 불변성(Immutable)을 기본으로 지원하므로, 필드가 한 번 설정되면 변경할 수 없음
  • 생성자, getter(accessor), toString, equals, hashCode를 자동으로 생성

우리가 앞서 봤던 DTO , VO와 둘 다 적합하지만 비교해봤을 때 다음과 같다.

VO와 DTO의 record 적합성 비교


큰 차이는 직렬화/역직렬화 같은데, 이게 뭔지도 알아봤다.

직렬화 - 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에 대해 정리해보도록 하겠다.

Mapper

먼저 이 녀석이 뭔지에 대해서부터 설명을 하겠다.

  • 데이터 간 변환을 책임지는 중간 계층 역할을 하는 도구 또는 클래스를 의미
  • 주로 객체 간의 데이터 변환에 사용
  • 데이터 구조가 다른 두 객체(DTO와 Entity 등) 간의 매핑을 처리하는 데 사용

Mapper의 역할

역할은 다음과 같다. 해당 포스팅은 DTO에 대한 포스팅이므로 관련하여 설명하겠다.

1. 데이터 변환

서비스 계층에서 데이터를 주고받을 때, 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());
}

2. 변환 로직 중앙화

변환 로직을 한 곳에 모아 중복과 복잡성을 줄여준다.
즉, 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); // 변환 로직 분리
    }
}

3. 코드 가독성 및 유지보수성 향상

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 사용법

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 단에서 알아야 하는 부분이 많아서 공부가 되었다. 누군가도 이 글을 보고 도움을 받길,,ㅎ

0개의 댓글