MapStruct란?
Java 애노테이션 프로세서로, 타입 안전한 Bean 매핑 클래스를 자동으로 생성하는 도구이다
- 매핑 인터페이스만 작성하면 컴파일 시 자동으로 구현체를 생성해준다
- 컴파일 시점에 구현체를 생성하므로 런타임 오버헤드가 없다.
- 또한, 순수한 Java 메서드 호출만을 사용하여 매핑을 수행하므로, 리플렉션(Reflection)이나 동적인 매핑 방식보다 성능이 뛰어나다.
- 장점
- 반복적인 코드를 줄여줌
- 매핑 코드를 직접 작성하면 시간이 오래걸린다
- 오류 발생 가능성을 줄여줌
- 컴파일단계에서 잡아줘서
- 반복적인 코드를 줄여줌
MapStruct의 철학
MapStruct는 개발자가 직접 작성한 것처럼 보이는 코드를 자동 생성하는 것을 목표로 한다.
즉, 리플렉션(reflection) 없이 순수한 getter/setter 호출 방식으로 속성을 복사
설정
plugins {
id "com.diffplug.eclipse.apt" version "3.26.0" // Eclipse에서만 필요
}
dependencies {
// 최신 버전 mapsturuct
implementation "org.mapstruct:mapstruct:1.6.3"
compileOnly 'org.projectlombok:lombok'
// Lombok 관련 애노테이션 프로세서를 먼저 등록하여 Lombok이 먼저 동작하도록 함
annotationProcessor 'org.projectlombok:lombok'
// Lombok과 MapStruct 충돌 해결을 위한 라이브러리라는데 안해도 문제 없어서 주석처리
// annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor "org.mapstruct:mapstruct-processor:1.6.3"
// 테스트 코드에서도 MapStruct를 사용할 경우 추가
testAnnotationProcessor "org.mapstruct:mapstruct-processor:1.6.3"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
추가 설정
✅ Gradle 설정 예시 (컴파일러 옵션)
compileJava {
options.compilerArgs += [
'-Amapstruct.suppressGeneratorTimestamp=true', // 생성 코드에 타임스탬프 추가 방지
'-Amapstruct.suppressGeneratorVersionInfoComment=true', // 생성 코드에 버전 정보 주석 추가 방지
'-Amapstruct.verbose=true' // MapStruct의 주요 동작 로그 출력
"-Amapstruct.defaultComponentModel=spring" // Spring 환경이라면 추가
]
}
- 이 설정이 필요한 이유
- 아래는 MapStruct가 컴파일에서 자동으로 생성한 구현체 예시이다
- 여기서 @Generated 애노테이션이 자동으로 붙음
- 이렇게 하면 MapStruct가 @Generated을 아예 안 붙이거나 최소한의 정보만 남기게 됨
- 이 설정이 필요한 이유
- 아래는 MapStruct가 컴파일에서 자동으로 생성한 구현체 예시이다
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2025-03-11T12:34:56",
comments = "Generated by MapStruct"
)
public class UserMapperImpl implements UserMapper {
@Override
public UserDto toDto(User user) {
if (user == null) {
return null;
}
UserDto userDto = new UserDto();
userDto.setName(user.getName());
return userDto;
}
- 여기서 @Generated 애노테이션이 자동으로 붙음
- 이렇게 하면 MapStruct가 @Generated을 아예 안 붙이거나 최소한의 정보만 남기게 됨
Mapper 정의하기
@Mapper
public interface CarMapper {
@Mapper 애노테이션
- MapStruct 코드 생성기가 빌드 타임에 CarMapper 인터페이스의 구현체를 생성하도록한다.
- 생성된 메서드 구현에서는 소스 타입(예: Car)에서 대상 타입(예: CarDto)의 동일한 이름을 가진 속성으로 값을 복사합니다.
- 속성 이름이 동일할 경우, MapStruct는 자동으로 매핑을 수행합니다.
- 속성 이름이 다를 경우, @Mapping 애노테이션을 사용하여 명시적으로 지정
규칙
- 이름이 동일한 필드는 자동으로 매핑됨
- 이름이 다르면 @Mapping을 사용하여 매핑을 지정해야 함
- 타입이 다르면 자동 변환하거나 추가 매핑 메서드를 호출
- 리스트 등 컬렉션 타입도 자동 매핑이 가능하다
명시적으로 매핑되게 설정도 가능
별도의 매핑 메서드를 생성하거나 기존 메서드를 호출할 수도 있다.
- 기본적으로 MapStruct는 자동 매핑 규칙을 갖지만 @BeanMapping(ignoreByDefault = true)을 사용하면 모든 필드를 명시적으로 매핑해야 함.
@BeanMapping(ignoreByDefault = true)
@Mapping(target = "name", source = "fullName")
PersonDto personToPersonDto(Person person);
기본 매핑 동작 원리
@Mapper
public interface CarMapper {
@Mapping(target = "manufacturer", source = "make")
@Mapping(target = "seatCount", source = "numberOfSeats")
CarDto carToCarDto(Car car);
@Mapping(target = "fullName", source = "name")
PersonDto personToPersonDto(Person person);
}
@Mapper가 지정된 인터페이스는 MapStruct 코드 생성기에 의해 컴파일 타임에 구현 클래스가 자동생성된다
(생성되는 코드 예시)
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<>( 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;
}
}
코드 분석
- 인자 값 null 체크
- 대상객체(DTO) 생성
- 읽기 가능한 모든 속성을 자동 매핑 시도
- 원본 객체(Car)의 모든 읽기 가능 속성은 대상 객체(DTO)의 동일한 속성으로 자동 복사된다
- 읽기 가능 ⇒ getter, public
- 속성명(필드명)이 동일하면 : 자동 매핑
- 속성명(필드명)이 다를 경우 : @Mapping을 사용하여 명시 매핑 가능
- target = 대상 객체의 속성
- source = 원복 객체의 속성
@Mapping(target = "manufacturer", source = "make") ..... carDto.setManufacturer( car.getMake() );
- 타입이 달라도 자동 변환 적용
- carDto.setPrice( String.valueOf( car.getPrice() ) );
- 참조 객체의 매핑 처리
- Car의 속성에 참조 객체(driver)가 포함된 경우, 추가적인 매핑 메서드(personToPersonDto 등)를 호출하여 처리할 수 있다.
- 기본적으로 부모 클래스의 속성도 포함하여 매핑된다
- getter를 쓰는거니까 당연한 것.
- 모든 경우에 적용되는 건 아니고, 특정 경우에는 설정이 필요 (ex. 특정 변환이 필요, 복잡한 참조 객체 등)
- 이런 경우, 아래와 같이 설정해야 부모 속성이 자동 매핑됨
- @MapperConfig(mappingInheritanceStrategy = AUTO_INHERIT_ALL_FROM_CONFIG)
커스텀 매핑
MapStruct는 자동으로 매핑 메서드를 생성하지만,
특정 매핑은 자동 생성이 불가능하거나, 추가적인 로직이 필요할 수 있음.
이 경우 커스텀 매핑 메서드(custom method)를 직접 작성하여 사용할 수 있다.
방법은 3가지인데 (java 8 이상 부터는 default 방법, abstract 방법 사용 ㄱ)
방법 1. default
Java 8 이상에서는 인터페이스의 default 메서드를 활용하여 직접 매핑 로직을 구현할 수도 있다.
@Mapper
public interface CarMapper {
@Mapping(target = "manufacturer", source = "make")
CarDto carToCarDto(Car car);
**default** PersonDto personToPersonDto(Person person) {
if (person == null) {
return null;
}
PersonDto dto = new PersonDto();
dto.setFullName(person.getFirstName() + " " + person.getLastName());
return dto;
}
}
(실제 mapstruct가 구현하는 코드 예시)
public class CarMapperImpl implements CarMapper {
@Override
public CarDto carToCarDto(Car car) {
if (car == null) {
return null;
}
CarDto carDto = new CarDto();
carDto.setManufacturer(car.getMake());
carDto.setSeatCount(car.getNumberOfSeats());
carDto.setDriver(personToPersonDto(car.getDriver())); <<<<<<<<<<<<<<<
return carDto;
}
}
이렇게 carToCarDto() 구현 메서드에서 수동으로 작성한 personToPersonDto()를 자동 호출
방법 2. 추상 클래스로 매퍼 정의
MapStruct 매퍼는 인터페이스뿐만 아니라 추상 클래스에서도 정의할 수 있다.
추상 클래스를 사용하면 멤버 변수와 추가적인 메서드를 정의할 수 있다는 장점
@Mapper
public abstract class CarMapper {
@Mapping(target = "manufacturer", source = "make")
public abstract CarDto carToCarDto(Car car);
public PersonDto personToPersonDto(Person person) {
if (person == null) {
return null;
}
PersonDto dto = new PersonDto();
dto.setFullName(person.getFirstName() + " " + person.getLastName());
return dto;
}
}
여러 개의 소스 파라미터 사용
MapStruct는 하나의 매핑 메서드에서 여러 개의 소스 객체를 받을 수 있도록 지원 이를 통해, 여러 개의 엔티티(Entity)를 하나의 DTO(Data Transfer Object)로 변환할 수 있다.
기본
@Mapper
public interface AddressMapper {
@Mapping(target = "description", source = "person.description")
@Mapping(target = "houseNumber", source = "address.houseNo")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
설명:
- Person과 Address 객체를 받아서 DeliveryAddressDto로 변환.
- description 속성은 person.description에서 가져옴.
- houseNumber 속성은 address.houseNo에서 가져옴.
주의할 점 - 같은 이름의 속성 존재
같은 이름의 속성이 존재할 경우 어떤 소스 객체의 값을 사용할지 명확히 지정해야 한다.
@Mapper
public interface AddressMapper {
@Mapping(target = "description", source = "person.description")
@Mapping(target = "houseNumber", source = "address.houseNo")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
위 코드에서 description 속성은 Person과 Address 객체에 동시에 존재할 수 있다. 따라서 @Mapping(target = "description", source = "person.description")처럼 어느 객체의 속성을 사용할지 명확하게 지정해야 한다.
잘못된 예시 (에러 발생)
@Mapper
public interface AddressMapper {
@Mapping(target = "description", source = "description") // 어느 객체인지 불명확함!
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
직접 소스 파라미터 지정하여 매핑
MapStruct에서는 소스 객체 전체가 아닌 개별 속성 값을 직접 전달하는 방식도 지원
@Mapper
public interface AddressMapper {
@Mapping(target = "description", source = "person.description")
@Mapping(target = "houseNumber", source = "nn")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Integer nn);
}
hn이라는 정수(Integer) 값을 houseNumber 속성에 직접 매핑.
즉, hn처럼 별도의 원시 데이터 타입(Primitive Type)을 매핑할 수도 있음.
여러 소스 null 처리
- 여러 개의 소스 객체를 사용하는 경우, 모든 소스 객체가 null이면 결과도 null이 됨.
- 하지만 하나라도 null이 아니면 대상 객체가 생성되며, null이 아닌 속성만 매핑됨.
예시
@Mapper
public interface AddressMapper {
@Mapping(target = "description", source = "person.description")
@Mapping(target = "houseNumber", source = "address.houseNo")
DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}
동작 방식:
Person 객체 Address 객체 결과 (DeliveryAddressDto)
null | null | null 반환 |
null | Address 존재 | Address 정보만 매핑 |
Person 존재 | null | Person 정보만 매핑 |
Person 존재 | Address 존재 | Person + Address 정보 모두 매핑 |
기존 객체 업데이트
기본적으로 MapStruct는 새로운 객체를 생성하여 반환한다.
하지만 때때로 기존 객체를 수정하는 방식의 매핑이 필요할 수 있다.
이때 @MappingTarget을 사용하면 기존 객체를 업데이트할 수 있다.
기본 예시
@Mapper
public interface CarMapper {
void updateCarFromDto(CarDto carDto, @MappingTarget Car car);
Car updateCarFromDto(CarDto carDto, @MappingTarget Car car);
// 업데이트 된 객체 반환하도록 할 수도 있음
}
- CarDto에서 Car로 매핑하면서 새로운 객체를 생성하지 않고, 기존 객체를 수정.
- @MappingTarget을 사용하여 car 객체를 업데이트할 수 있도록 지정.
- 내부 동작
- public class CarMapperImpl implements CarMapper { @Override public void updateCarFromDto(CarDto carDto, Car car) { if (carDto == null) { return; } car.setManufacturer(carDto.getManufacturer()); car.setSeatCount(carDto.getSeatCount()); car.setPrice(carDto.getPrice()); } }
'백엔드 > 스프링' 카테고리의 다른 글
스프링 배치 - JOB (0) | 2025.04.05 |
---|---|
컴포너트 스캔 (0) | 2025.04.03 |
단위 테스트가 어려운 이유: 과도한 Mocking (0) | 2025.03.29 |
복잡한 동적 쿼리 테스트하기 (0) | 2025.03.27 |
Spring에서 파일 다운로드 코드 구현하기 (다양한 리소스) (0) | 2025.03.10 |