대부분의 애플리케이션에서 POJO를 다른 POJO로 변환하는 코드를 많이 볼 수 있다.
예를 들어 일반적인 유형의 변환은 엔티티와 클라이언트의 DTO 간에 발생한다.
이것이 바로 MapStruct가 해결하는 문제이다.
매퍼를 수동으로 만드는 것은 대부분 반복적이고, 시간이 많이 소요되며, 실수의 여지가 많다.
비즈니스 로직에 섞이게 되면 소스 또한 빌더의 호출로 인해 가독성이 떨어지게 된다.
해당 라이브러리는 빈 매퍼 클래스를 자동으로 생성해준다.
어노테이션 기반으로 작성되며 Bean으로 등록할 수 있어 여러 프레임워크의 DI를 활용하여 사용할 수도 있다.
같은 기능의 라이브러리인 modelMapper에 비해 리플렉션이 없이 메서드 호출만 하기 때문에 성능에 대한 영향이 없다.
리플렉션이란? 클래스, 필드, 메소드 등의 정보를 동적으로 검사하고 접근하는 것을 말한다.
리플렉션을 사용하면 런타임에 클래스의 구조를 검사하고 메소드를 호출해야 한다.
이는 정적으로 바인딩된 메소드 호출에 비해 오버헤드를 발생시킨다.
동적 검사 및 호출에는 추가적인 연산이 필요하며, 동일한 작업을 반복해야 할 때마다 동적으로 접근해야 한다.
이는 캐싱 기능의 부재를 의미하며, 반복적인 리플렉션 호출로 인해 성능이 저하될 수 있다
성능 저하를 최소한으로 해야하는 상황에선 필요한 경우에만 리플렉션을 사용하고 정적으로 바인딩 된 메소드를 사용해야 한다.
또, 리플렉션은 실행 시간에 객체 구조를 검사하므로, 컴파일 타임에 오류를 감지할 수 없다.
따라서, 오류가 발생할 경우 런타임에서야 확인할 수 있다.
개발단계에서 예외처리를 제대로 해놓지 않았다면 런타임에 발생한 오류가 정확히 어느 부분인지 확인하기 어려울 수 있다.
Spring에선 dependency를 추가하여 간단하게 사용 할 수 있다.
MapStruct의 핵심 개념
1. @Mapper: 매핑 인터페이스를 정의할 때 사용하는 어노테이션.
- @Mapper 어노테이션을 사용하여 인터페이스를 정의하면, MapStruct는 해당 인터페이스의 구현체를 생성한다.
2. @Mapping: 매핑을 정의할 때 사용하는 어노테이션.
- 소스 객체와 대상 객체 간의 필드를 매핑하는 방법을 지정한다.
필드 이름이 동일한 경우 자동으로 매핑될 수 있으며, 복잡한 매핑을 위해 @Mapping 어노테이션을 사용하여 명시적으로 매핑 규칙을
지정할 수 있다.
3. @Mappings: 여러 개의 @Mapping 어노테이션을 그룹화할 때 사용하는 어노테이션.
- 매핑 규칙이 여러 개인 경우 @Mappings 어노테이션을 사용하여 각각의 매핑 규칙을 지정할 수 있다.
4. @MappingTarget: 매핑 대상 객체에 해당 어노테이션을 사용하여 대상 객체를 직접 참조.
- 이를 통해 기존 객체를 수정하거나 새로운 객체를 생성하지 않고 매핑을 수행할 수 있다
예제소스
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper( CarMapper.class );
@Mapping(target = "manufacturer", source = "make")
@Mapping(target = "seatCount", source = "numberOfSeats")
CarDto carToCarDto(Car car);
@Mapping(target = "fullName", source = "name")
PersonDto personToPersonDto(Person person);
}
MapStruct에서 자동으로 생성한 인터페이스에 대한 구현체
// 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;
}
}
Instance를 왜 생성해야 할까?
MapStruct는 컴파일 타임에 코드를 생성하여 매핑 기능을 제공하는데, 이를 위해 인터페이스의 구현체가 필요하다.
Mappers.getMapper() 메서드는 MapStruct가 이 구현체를 생성하고 반환하는 역할을 수행한다.
그래서 해당 인터페이스의 인스턴스를 생성하여 매핑 기능에 접근할 수 있다.
인터페이스의 인스턴스를 생성하지 않고는 직접적으로 매핑 기능에 접근할 수 없지만, MapStruct에서 생성된 구현체를 통해
매핑 기능을 호출할 수 있다.
인스턴스를 생성하면 해당 인스턴스를 통해 매핑 기능에 접근할 수 있으며, 이를 통해 매핑 규칙을 적용하고
객체 간의 변환을 수행할 수 있다.
따라서, 인터페이스의 인스턴스를 생성하지 않으면 컴파일 타임에는 해당 인터페이스의 매핑 기능에 접근할 수 없다.
그럼 왜 인터페이스는 컴파일 타임에 접근 할 수 없을까?
컴파일 타임(Compile Time)은 코드를 컴파일하여 실행 가능한 형태로 변환하는 단계이다.
이 단계에서는 코드의 구문 검사, 타입 체크, 최적화 등이 이루어지며, 최종적으로 실행 가능한 프로그램으로 변환된다.
인터페이스란 자바에서 추상화된 형태의 구조이며, 구체적인 구현이 없다.
즉, 인터페이스 자체에는 실행 가능한 코드가 존재하지 않는다.
인터페이스는 단지 추상 메서드의 집합이며, 이를 구현한 클래스에서 실제 동작을 정의하게 된다.
위에서 설명했듯이 MapStruct는 컴파일 타임에 코드를 생성하여 매핑 기능을 제공하는데,
이는 인터페이스와 구현 클래스 간의 매핑 규칙을 생성하고, 매핑 코드를 구현 클래스에 추가하는 과정을 의미한다.
컴파일 타임에 인터페이스에 접근하여 매핑 기능을 호출하는 것은 불가능하다.
인터페이스의 인스턴스를 생성하면 해당 인터페이스를 구현한 구체적인 클래스의 인스턴스를 생성하는 것이며,
이를 통해 매핑 기능에 접근할 수 있게 된다.
따라서, 컴파일 타임에는 인터페이스에 직접적으로 접근할 수 없는 이유는 인터페이스 자체에 실행 가능한 코드가 없고,
매핑 기능은 구체적인 클래스를 통해 동작하기 때문이다.
@Mapping(target = "manufacturer", source = "make")
@Mapping(target = "seatCount", source = "numberOfSeats")
CarDto carToCarDto(Car car);
@Mapping의 target은 매핑하고 싶은 DTO의 값을, source는 매개변수의 값을 지정해주면 되겠다.
그럼 인터페이스 내에서 메서드를 정의하고 싶다면 어떻게 해야할까?
@Mapper
public interface CarMapper {
@Mapping(...)
...
CarDto carToCarDto(Car car);
default PersonDto personToPersonDto(Person person) {
//hand-written mapping logic
}
}
default 생성자를 이용해서 사용자정의 메서드를 구현하면 된다.
default 생성자를 왜 지정해줘야 할까?
default 생성자를 사용하는 이유는 MapStruct가 코드를 생성할 때 기본 생성자를 요구하기 때문이다.
MapStruct는 객체 간 매핑을 위해 프록시 객체를 생성하는데, 이때 프록시 객체는 매핑 대상 객체의 기본 생성자를 호출하여 생성된다.
따라서 매핑 대상 객체에 기본 생성자가 필요하다.
일반적으로 Java 클래스는 기본 생성자가 이미 제공되지만, 명시적으로 생성자를 정의하는 경우에는 기본 생성자가 자동으로 생성되지 않는다.
이때, MapStruct에서 매핑을 수행하기 위해서는 해당 객체에 기본 생성자를 명시적으로 추가해주어야 한다.
예를 들어, 다음과 같이 Person 클래스에 매개변수를 가진 생성자를 정의한 경우를 생각해보자.
public class Person {
private String name;
public Person(String name) {
this.name = name;
}
// Getter and Setter
}
위의 코드에서는 매개변수를 가진 생성자만 정의되어 있으므로, 기본 생성자가 자동으로 생성되지 않는다.
이 경우, MapStruct에서 매핑을 수행하기 위해서는 Person 클래스에 기본 생성자를 추가해주어야 한다.
public class Person {
private String name;
public Person() {
// 기본 생성자
}
public Person(String name) {
this.name = name;
}
// Getter and Setter
}
이렇게 기본 생성자를 추가함으로써 MapStruct가 매핑을 수행할 수 있다.
따라서 MapStruct를 사용할 때, 기본 생성자를 명시적으로 정의해야 하는 경우에는 default 생성자를 추가해주어야 한다.
MapStruct reference 문서
https://mapstruct.org/documentation/stable/reference/html/#Preface
'SPRING BOOT' 카테고리의 다른 글
Rate limit 알고리즘의 종류 (0) | 2024.08.26 |
---|---|
[SpringBoot] 실행환경에 따른 설정파일 변경 ( @EnableConfigurationProperties , @ConfigurationProperties ) (0) | 2023.05.14 |
카멜케이스와 스네이크케이스-Spring Boot와 Mybatis의 활용 (0) | 2021.08.12 |