본문 바로가기
백엔드/스프링

MapStruct

by ARlegro 2025. 4. 2.

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()); } }