[Spring] DTO와 MapStructure: 효율적인 객체 매핑 구현하기

bien·2025년 3월 18일
0

Java_Spring_Backend

목록 보기
10/10

DTO란?

  • DTO (Data Transfer Object)
    • 마틴 파울러(Martin Fowler)가 Patterns of Enterprise Application Architecture라는 책에서 처음 소개한 엔터프라이즈 애플리케이션 아키텍처 패턴 중 하나
    • 데이터를 전송하기 위한 용도의 객체
    • 스프링에서는 주로 계층구조 간 데이터 전송 용도로 사용된다.
      • 클라이언트 -> 서버: 요청 데이터
      • 서버 -> 클라이언트: 응답 데이터
  • 장점
    1. 요청에 필요한 객체를 만들어 프로그램에 적절하게 사용 가능
    2. 객체 내 필드와 유효성 검사를 엄격하게 진행 가능
    3. 가독성이 좋음

편의성 증가 & 유지보수성의 장점
한번에 많은 데이터를 옮겨 호출이 줄어들기에 효율적이다.

DTO의 사용 용도

  • DTO의 사용용도
    1. 계층간 이동(Service <-> Controller)
    2. 요청 (요청Body <-> Controller 내 핸들러)
  • 계층간 이동에는 로직을 가지고 있지 않은 순수한 데이터 객체로, 불변성 유지를 위해 Setter 사용을 지양한다.
  • 요청에 필요한 DTO는 Entity간 객체 매핑을 통해 Entity로 변환하는 경우가 많은데, 이 경우 Builder 패턴을 이용해 작성할 수 있다.
  • 효율적 관리를 위해 외부 Mapper 클래스를 사용할 수도 있다.

빌더패턴 예시

    public Product toEntity() {
        return Product.builder()
                .name(name)
                .description(description)
                .price(price)
                .stockQuantity(stockQuantity)
                .build();

Mapper란?

  • Mapper
    • DTO 클래스와 엔티티(Entity) 클래스를 서로 변환해주는 것
      • DB에 저장하기 위해 엔티티에 맞게 변환해주는 과정

도메인 기능이 늘어나거나 변경될 때마다 매번 DTO 내부에 구현을 하는 것은 굉장히 비효율적이다.
이런 경우 속도가 빠른 외부 라이브러리를 사용할 수 있다.

1. 외부 Mapper 클래스 사용

엔티티 혹은 DTO 클래스 내부에서 변환을 실행하지 않고, 외부 Mapper 클래스를 만들어 사용한다.

구현 예시

public class ProductMapper {
    public static Product toEntity(ProductRequest request) {
        return Product.builder()
                .name(request.getName())
                .description(request.getDescription())
                .price(request.getPrice())
                .stockQuantity(request.getStockQuantity())
                .build();
    }
    
    // Entity에서 DTO로의 변환 또는 다른 매핑 메서드도 추가 가능
}

특징

  • 장점
    • 단일 책임 원칙 준수
    • 테스트 용이성
  • 단점
    • 코드량 증가: 추가적 클래스가 필요하다.
    • 복잡성 증가: 간단한 매핑의 경우 오히려 복잡성이 증가할 수 있다.

2. 외부 Mapper 라이브러리

Java에서 사용한 매퍼 라이브러리의 비교는 다음과 같다.
해당 데이터에 따르면, MapStruct가 굉장히 빠르게 작동하는 것을 알 수 있다.

MapStruct란?

  • MapStruct
    • 자바 애플리케이션에서 DTO(Data Transfer Object)와 엔티티 간 데이터 변환을 자동화하는 라이브러리
    • 특히 스프링환경에서 외부 Mapper 클래스로 사용하면 DTO와 엔티티 간 매핑 로직을 깔끔하게 분리할 수 있어 코드의 가독성과 유지보수성을 높일 수 있다.
    • 컴파일 타임에 매핑 코드를 생성하여 런타임 성능을 최적화하며, 애노테이션을 기반으로 설정해 사용이 간편하다.

스프링에서 코드 작업 시, 레이어간 이동에서 다양한 DTO가 생기게 된다.
계층 간 이동(Controller -> Service, Service -> Repository)으로 데이터가 이동하는 과정에서, 필드 간 차이가 많지 않다면 매핑 과정에서 노가다성 코드만 늘어나게 된다.
이런 상황에서 MapStruct를 통해 객체 내 많은 필드에 대해 쉽게 매핑할 수 잇다.

주요 특징

  • 컴파일 타임 매핑: 매핑 코드를 컴파일 시 생성하여 런타임 오버해드가 없고, 타입 안정성을 보장한다.
  • 스프링 통합: @Mapper(componentModel = "spring")를 사용하면 스프링 빈으로 등록되어 의존성 주입(DI)가 가능하다.
  • 복잡한 매핑 지원: 필드 이름이 다른 경우, 중첩 객체, 컬렉션 매핑 등을 @Mapping 애노테이션을으로 처리할 수 있다.
  • 커스텀 매핑: 외부 서비스 호출이나 복잡한 변환 로직이 필요할 때, 기본 메서드나 @Named 어노테이션을 통해 커스텀 로직 추가가 가능하다.

🚨 주의 사항

  1. 필수 필드 체크
    • MapStruct는 매핑만 처리하므로, DTO의 필수 값 검증은 별도로 처리해야 한다.
  2. null 처리
    • 기본적으로 null 값은 그대로 전달되므로, 커스텀 로직으로 null을 처리해야 할 수 있다.
  3. 생성된 코드 확인
    • MapStruct는 target/generated-source/annotations에 구현 클래스를 생성하므로, 디버깅 시 이를 확인하면 된다.

장단점

설정 방법

1. 의존성 추가

	// MapStruct
	implementation 'org.mapstruct:mapstruct:1.5.5.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
	implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
	annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'

Lombok과 함께 사용하는경우, Lombok이 제대로 작동하지 않는 문제가 발생할 수 있다.
이러한 상황에서 lombok-mapstruct-binding을 dependency에 추가해 문제를 해결할 수 있다.
(dependency 순서에 따라 해당 문제가 발생하지 않을 수도 있다.)

2. Mapper 인터페이스 정의

인터페이스 또는 추상 클래스에 @Mapper 애노테이션을 붙임으로써, 손쉽게 Mapper를 만들 수 있다.
Mapper를 만들게 되면, 자동으로 MapperImpl이 생성되었음을 확인할 수 있다.

  • 사용자가 생성한 Mapper
@Mapper
public interface CarMapper {
	@Mapping(target = "manufacturer", source = "make")
    @Mapping(target = "seatCount", srouce = "numberOfSeats")
    CarDto carToCarDto(Car car);
    
    @Mapping(target = "fullName", source = "name")
    PersonDto personToPersonDto(Person person);
}
  • 생성된 MapperImpl (generated 패키지에 생성됨)
// GENERATED CODE
public class CarMapperImpl implements CarMapper {

    @Override
    public CarDto carToCarDto(Car car) {
        if (car == null) {
            return null;
        }

        CarDto carDto = new CarDto();

        if (car.getFeatures() != null) {
            carDto.setFeatures(new ArrayList<String>(car.getFeatures()));
        }

        carDto.setManufacturer(car.getMake());
        carDto.setSeatCount(car.getNumberOfSeats());
        carDto.setDriver(personToPersonDto(car.getDriver()));
        carDto.setPrice(String.valueOf(car.getPrice()));

        if (car.getCategory() != null) {
            carDto.setCategory(car.getCategory().toString());
        }

        carDto.setEngine(engineToEngineDto(car.getEngine()));

        return carDto;
    }

    @Override
    public PersonDto personToPersonDto(Person person) {
        //...
    }

    private EngineDto engineToEngineDto(Engine engine) {
        if (engine == null) {
            return null;
        }

        EngineDto engineDto = new EngineDto();

        engineDto.setHorsePower(engine.getHorsePower());
        engineDto.setFuel(engine.getFuel());

        return engineDto;
    }
}

MapStruct를 활용해서 매피을 처리하다 보면, 잘못된 설정으로 인해 의도하지 않은 필드에 매핑되는 경우가 있다.
매핑 메서드를 생성한 이후에는 생성된 MapperImpl를 꼭 확인해서 의도한대로 모든 필드들이 잘 매핑되었는지 확인하는 것이 좋다.

3.1. Mapper를 Bean으로 생성하고자 하는 경우

Mapper를 생성할 때 Bean으로 지정하고자 한다면, @Mapper 애노테이션 지정 시 componentModel을 spring으로 지정하면 된다.

@Mapper(componentModel = "spring")

3.2. Mapper에서 다른 클래스 내의 메서드 또는 Mapper를 사용하고자 하는 경우

Mapper에서 매핑 시 Mapper 외부의 메서드를 사용하고자 하는 경우, @Mapper의 property 중 하나인 uses를 활용하여 다른 클래스를 지정할 수 있다. 여러 클래스를 활용하고자 한다면, 중괄호 {}를 통해 지정해야 한다.

@Mapper(uses=DataMapper.class)
public interface CarMapper {
	CarDto carToCarDto(Car car);
}    

4. 서비스에서 사용

@Service
public class ProductService {
    private final ProductMapper mapper;

    @Autowired
    public ProductService(ProductMapper mapper) {
        this.mapper = mapper;
    }

    public Product createProduct(ProductRequest request) {
        return mapper.toEntity(request); // **
    }
}

Reference

profile
Good Luck!

0개의 댓글