Design Pattern

MapStruct과 ModelMapper 비교

Java의 MapStruct과 ModelMapper를 비교하여 정리한 글

1. 비교하게 된 배경

빌더 패턴과 프로토 타입 패턴을 스터디 하던 도중 ModelMapper의 성능 이슈 문제로 물꼬를 틀어 시작 한얘기로 따로 한번정리를해보았습니다.

2. ModelMapper와 ModelStruct이란?

Controller, Service, Repository 등 레이어 간 데이터를 주고받을 때나 비즈니스 로직에서 하나의 객체를 타입이 다른 객체로 형(Type) 변환하거나 여러 객체를 다른 객체로 합치는 일은 매우 빈번하게 발생합니다. 이런 작업을 개발자가 모두 직접 하게되면 발생하는 문제점을 몇 가지 나열해 보면 다음과 같습니다.

  • 재미가 없고 반복적이고 코드 중복이 발생하기 쉽습니다.

  • 실수하기 쉽습니다.

  • 필드가 추가나 수정, 삭제가 일어날 경우 변환하는 로직에 대해서 수정이 필요합니다.

  • 비즈니스 로직에 섞이게 되면 코드가 복잡해집니다.

  • 결국 생산성을 떨어뜨립니다.

UserEntity userDTOToEntity(UserDTO userDTO) {
        return new userEntity(userDTO.getId(), 
                             userDTO.getPassword(),
                             userDTO.getName());
}

다음과 같이 개발자가 직접 작성한 DTO를 Entity로 변환하는 코드인데, 여기서는 비교적 단순한 객체라 코드가 짧지만 필드가 몇 개만 더 늘어나도 가독성이 떨어지고 개발자에게 피곤한 작업이 됩니다.

Object Mapping 라이브러리는 이런 문제를 해결해주는 아주 좋은 도구입니다. Object Mapping을 위한 ModelMapperMapStruct에 대해서 의존성 설정, Mapping 사용법과 차이점을 알아보겠습니다.

MapStruct의 장점이 많기 때문에 MapStruct위주로 ModelMapper와의 차이점을 알아보겠습니다.

  • MapStruct는 컴파일 시점에서 어노테이션을 읽어 구현체를 만들어내기 때문에 리플렉션이 발생하지 않습니다. (ModelMapper는 Mapping이 일어날 때 리플렉션이 발생)

  • MapStruct의 처리속도가 10-5m/s로 압도적으로 빠릅니다. (ModelMapper는 0.002m/s)

  • MapStruct는 컴파일 시 오류를 확인할 수 있습니다.

  • MapStruct는 디버깅이 쉽습니다.

  • MapStruct는 생성된 매핑 코드를 눈으로 직접 확인할 수 있습니다.

리플렉션 ? 객체를 통해 클래스의 정보를 분석해 내는 프로그래밍 기법. 구체적은 클래스 타입을 알지 못해도 컴파일된 바이트 코드를 통해 역으로 클래스의 정보를 알아내어 클래스를 사용하게 할 수 있습니다. 리플렉션 기법을 통해 객체의 타입을 모르는 상태에서 객체의 메서드를 호출할 수 있습니다. 즉 동적 바인딩이 되지 않던 자바에서 리플렉션이라는 프로그래밍 기법을 통해 동적 바인딩을 제공하는 것 입니다.

이렇게 MapStruct는 많은 장점을 가지고 있기 때문에 MapStruct 사용을 권장합니다.

또, 밑의 구글 트렌드로 봤을 때도 MapStruct가 Java Mapping 라이브러리 중 가장 많이 사용되고 있는 것을 알 수 있습니다.

ModelMapper의 간단한 사용법을 보고, MapStruct의 다양한 사용법을 살펴보겠습니다.

그럼 먼저 테스트에 사용할 Entity 객체와 DTO 객체를 만들겠습니다. Entity, VO, DTO를 사용할 때의 주의점을 따라Lombok을 사용하여 객체를 생성하였습니다.

Entity, VO, DTO의 차이 포스트 링크

Lombok의 사용법 및 주의점 포스트 링크

3. 코드로 살펴보기

UserEntity

Entity는 Setter 사용을 지양하여야 합니다.

@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserEntity {
    private Long id;
    private String password;
    private String name;
    private String nickName;
    private Date createDate;
    
    @Builder
    private UserEntity(Long id, String password, String name,
    		String nickName, Date createDate) {
    	this.id = id;
    	this.password = password;
    	this.name = name;
    	this.nickName = nickName;
    	this.createDate = createDate;
    }
}

UserDTO

@Getter
@Setter
@ToString
public class UserDTO {
    private Long id;
    private String password;
    private String name;
   
}

1. ModelMapper

의존성 설정

<!-- https://mvnrepository.com/artifact/org.modelmapper/modelmapper -->
<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>2.3.0</version>
</dependency>

Bean Configuration

@Configuration
public class ApplicationConfig {

    @Bean
    public ModelMapper modelMapper(){
        ModelMapper modelMapper = new ModelMapper();
        /* modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT); */
        return modelMapper;
    }
}

주석이 된 부분은 매칭 전략을 설정하는 부분이며 Standard, Loose, Strict 세 가지가 있고 Default는 Standard로 되어있습니다.

추가적인 내용은 http://modelmapper.org/user-manual/configuration/#matching-strategies 레퍼런스를 참조하면 되겠습니다.

Test

간단하게 테스트로 결과만 확인해보겠습니다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {ApplicationConfig.class})
public class ModelMapperTest {

	@Autowired
	ModelMapper modelMapper;
	
	@Test
	public void ModelMapperTest() throws Exception { 
		
		// Entity에는 Setter가 없기 때문에 ModelMapper는 밑의 방식으로 값을 넣어줄 수 있습니다.
		modelMapper.createTypeMap(UserDTO.class, UserEntity.class).setProvider(
			// Setter가 없는 클래스를 넣어줍니다.
			new Provider<UserEntity>() {
				public UserEntity get(ProvisionRequest<UserEntity> request) {
					// 변환할 클래스를 가져와서 Setter가 없는 클래스를 만듭니다.
					UserDTO dto = UserDTO.class.cast(request.getSource());
					return UserEntity.builder()
                        	.id(1L)
							.password(dto.getPassword())
							.name(dto.getName())
							.nickName(dto.getNickName())
							.createDate(new Date())
							.build();
				}
		});
		
		UserEntity userEntity = UserEntity.builder().id(1L)
				.password("testPassword")
				.name("testName")
				.nickName("testNickName")
				.createDate(new Date())
				.build();;
		
		// Entity -> DTO
		UserDTO resultDTO = modelMapper.map(userEntity, UserDTO.class);
		// DTO -> Entity, Setter가 없을 시 변환할 수 없음
		UserEntity resultEntity = modelMapper.map(resultDTO, UserEntity.class);
		
		System.out.println(resultDTO);
		System.out.println(resultEntity);
	}
}
  1. Entity -> DTO, modelMapper.map(Entity 객체, DTO클래스명.class)

  2. DTO -> Entity, modelMapper.map(DTO 객체, Entity클래스명.class)

사용방법은 위와 같이 변경할 객체와 변경될 객체의 클래스명을 전달하면 변환이 이루어 지는 것을 알 수 있습니다.

https://github.com/modelmapper/modelmapper/blob/master/core/src/test/java/org/modelmapper/functional/Immutable.java를 참고하여 Setter가 없는 경우에 값을 넣어주었습니다.

주의 사항

  • 만들어지는 대상은 Getter, 만드는 대상은 Setter가 필요합니다.

    • Entity가 DTO로 변환된다고 한다면 Entity에는 각 필드값을 읽을 수 있는 Getter가 DTO는 필드값을 넣을 수 있는 Setter들이 존재해야합니다.

    • createTypeMap를 통해 Setter가 없을 경우도 설정해줄 수 있지만, 그다지 좋아보이지는 않습니다.

  • 필드 작명, Standard(Default Staragy) 기준, 필드 이름이 같을 경우 자동으로 매핑이 이루어지지만 필드이름이 다를 경우 매핑이 이루어지지 않습니다

2. MapStruct

의존성 설정

<properties>
    <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
</properties>


<dependencies>
    <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>

...

    <plugins>
        <!-- maven 버전 설정을 위한 플러그인 입니다.-->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.5.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <!-- mapstruct-processor을 설정해줍니다. -->
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${org.mapstruct.version}</version>
                    </path>
                    <!-- other annotation processors -->
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    <plugins>

mapstruct 설정을 할 때, 몇 가지 주의할 점이 있습니다.

주의 사항

plugins와 pluginManagement의 차이

의존성을 설정할 때, pluginspluginManagement의 차이에 대해 주의하면서 작성합니다.

pluginManagement와 plugins의 차이와 주의할 점 포스팅

Lombok과 MapStruct의 순서

  • Lombok 다음 MapStruct dependency가 오는 경우

    • 먼저 실행된 Lombok이 Getter, Setter, Builder등을 만들고 그 다음에 실행된 MapStruct가 만들어진 Getter, Setter, Builder등을 이용해 코드를 생성합니다.

  • MapStruct 다음 Lombok dependency가 오는 경우

    • 먼저 실행된 MapStruct가 Getter, Setter, Builder등을 이용해 코드를 생성하여야 하는데, 만들어진 Getter, Setter, Builder등이 없기 때문에 생성에 실패합니다.

위와 같이 Lombok과 MapStruct를 같이 사용할 때, <dependencies> </dependencies>에서 Lombok과 MapStruct의 순서가 중요합니다.

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok-mapstruct-binding -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok-mapstruct-binding</artifactId>
    <version>0.2.0</version>
</dependency>

Lombok 1.18.16 버전 이상에서는 위와 같은 의존성을 추가할 경우 순서와 상관없이 사용할 수 있습니다.

Mapper 만들기

@Mapper
public interface UserMapper {
	UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
	
	UserEntity userDTOToEntity(UserDTO userDTO);
	
	UserDTO userEntityToDTO(UserEntity userEntity);
}
  • 기본적으로는 인터페이스에 @Mapper를 붙이면 됩니다. @Mapper를 사용하게 되면 MapStruct가 자동으로 UserMapper를 상속받아서 UserMapperImpl를 구현해줍니다. (아래와 같이 구현된 부분은 target -> generated-sources->...->Mapper가 있는 폴더에서 볼 수 있습니다.)

  • MapStruct에서는 UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); 같은 방식으로 Mapper Bean을 만드는 것을 권장한다고 합니다.

    • @Mapper(componentModel = "spring")@Autowired를 이용하여 만들 수도 있습니다.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2021-06-03T16:00:52+0900",
    comments = "version: 1.4.2.Final, compiler: Eclipse JDT (IDE) 1.3.1200.v20200916-0645, environment: Java 15.0.2 (Oracle Corporation)"
)
public class UserMapperImpl implements UserMapper {

    @Override
    public UserEntity userDTOToEntity(UserDTO userDTO) {
        if ( userDTO == null ) {
            return null;
        }

        UserEntityBuilder userEntity = UserEntity.builder();

        userEntity.id( userDTO.getId() );
        userEntity.name( userDTO.getName() );
        userEntity.password( userDTO.getPassword() );

        return userEntity.build();
    }

    @Override
    public UserDTO userEntityToDTO(UserEntity userEntity) {
        if ( userEntity == null ) {
            return null;
        }

        UserDTO userDTO = new UserDTO();

        userDTO.setId( userEntity.getId() );
        userDTO.setName( userEntity.getName() );
        userDTO.setPassword( userEntity.getPassword() );

        return userDTO;
    }
}

메서드의 파라미터에 오는 객체는 Getter, 메서드가 리턴하는 객체는 Setter가 필요하지만, Target 객체(메서드가 리턴하는 객체)에 @Builder 어노테이션이 달려있다면 Builder 메소드를 우선 사용합니다. (userDTOToEntity메서드를 보면 Builder를 이용한 것을 볼 수 있습니다.)

또, @Mapping 어노테이션과 속성을 이용해 다양한 경우를 설정해 줄 수 있습니다. (이에 대한 내용은 아래에서 다루겠습니다.)

Test

	@Test
	public void MapStructTest() throws Exception {
		
		UserEntity userEntity = UserEntity.builder()
            	.id(1L)
				.password("testPassword")
				.name("testName")
				.nickName("testNickName")
				.createDate(new Date())
				.build();
		
		// Entity -> DTO
		UserDTO resultDTO = UserMapper.INSTANCE.userEntityToDTO(userEntity);
		// DTO -> Entity, Setter가 없어도 @Builder가 붙어있다면 변환 가능
		UserEntity resultEntity = UserMapper.INSTANCE.userDTOToEntity(resultDTO);
		
		System.out.println(resultDTO);
		System.out.println(resultEntity);
	}

INSTANCE를 이용해 변환 메서드를 가져와 객체를 Mapping할 수 있습니다.

주의 사항

  • 메서드의 파라미터에 오는 객체는 Getter, 메서드가 리턴하는 객체는 Setter가 필요하지만, Target 객체(메서드가 리턴하는 객체)에 @Builder 어노테이션이 달려있다면 Builder 메소드를 우선 사용합니다.

4. @Mapping을 이용한 다양한 Mapping

MapStruct@Mapping 어노테이션의 속성을 이용해 다양한 경우의 Mapping을 설정해줄 수 있습니다.

서로 다른 속성 매핑

위의 UserDTO에서 닉네임을 nick이라는 이름으로 추가하고 UserEntity의 nickName을 UserDTO의 nick에 Mapping해야 하는 경우 source, target 속성을 이용해 설정해줄 수 있습니다.

@Getter
@Setter
@ToString
public class UserDTO {
    private Long id;
    private String password;
    private String name;
    private String nick;
}

아래와 같이 Mapping 메서드에 @Mapping(source = "파라미터 객체의 변수이름", target = "리턴 객체의 변수이름")와 같이 설정해주면 됩니다.

@Mapper
public interface UserMapper {
	UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
	
    @Mapping(source = "nick", target = "nickName")
	UserEntity userDTOToEntity(UserDTO userDTO);
	
	@Mapping(source = "nickName", target = "nick")
	UserDTO userEntityToDTO(UserEntity userEntity);
}

객체 합치기

UserDTO에 message 변수를 추가하고 파라미터를 통해 따로 받아줄 수 있습니다.

@Getter
@Setter
@ToString
public class UserDTO {
    private Long id;
    private String password;
    private String name;
    private String nick;
    private String message;
}

아래와 같이 파라미터에 message를 추가하여 사용할 수 있습니다.

이 때 기존의 @Mapping(source = "nickName", target = "nick")에서 source(파라미터 객체)의 nickName이 어떤 파라미터 객체의 것인지 알 수 없기 때문@Mapping(source = "userEntity.nickName", target = "nick") 같이 객체명을 명시해줍니다.

expression = "java(message + \".msg\")"처럼 expression속성을 사용해 message뒤에 자동으로 .msg를 붙일 수 있습니다. java()안에는 객체가 들어갈수도 있고, 메서드를 호출하는 등 다양하게 활용할 수 있습니다.

@Mapper
public interface UserMapper {
	UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
	
    @Mapping(source = "nick", target = "nickName")
	UserEntity userDTOToEntity(UserDTO userDTO);
	
	@Mapping(source = "userEntity.nickName", target = "nick")
    @Mapping(target = "message", expression = "java(message + \".msg\")")
	UserDTO userEntityToDTO(UserEntity userEntity, String message, Address address);
}

객체 합치기 2

Address라는 객체를 만들고, UserDTO에 Address객체를 가지는 속성을 추가해줍니다.

@Getter
@Setter
@ToString
public class Address {
	private String address;
	private int addressNum;
}
@Getter
@Setter
@ToString
public class UserDTO {
    private Long id;
    private String password;
    private String name;
    private String nick;
    private String message;
    private Address add;
}

아래와 같이 여러개의 @Mapping을 이용해 source, target을 지정해줄 수 있습니다.

UserDTO의 Address속성의 이름은 add이고 파라미터로 들어온 Address 객체의 이름은 address이기 때문에 source, target으로 지정해주어야 합니다.

@Mapper
public interface UserMapper {
	UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
	
    @Mapping(source = "nick", target = "nickName")
	UserEntity userDTOToEntity(UserDTO userDTO);
	
	@Mapping(source = "userEntity.nickName", target = "nick")
	@Mapping(source = "address", target = "add")
	UserDTO userEntityToDTO(UserEntity userEntity, String message, Address address);
}

속성 무시하기

UserEntity에는 createDate가 있는데 DTO에는 없기 때문에 DTO -> Entity로 변환할 때 createDate에는 null 값이 들어가게 됩니다.

이 때, @Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)을 이용해 정책을 설정해서 target 객체에 매핑 시 매핑되지 않은 속성이 있다면 컴파일 에러를 발생시키게 할 수 있습니다.

하지만 @Mappingignore속성을 이용해 매핑되지 않는 속성을 무시하게 할 수 있습니다.

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface UserMapper {
	UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
	
	@Mapping(target = "createDate", ignore = true)
	@Mapping(source = "nick", target = "nickName")
	UserEntity userDTOToEntity(UserDTO userDTO);
	
	@Mapping(source = "userEntity.nickName", target = "nick")
	@Mapping(source = "address", target = "add")
	UserDTO userEntityToDTO(UserEntity userEntity, String message, Address address);
}

정책

매핑 정책(Policy)전략(Strategy)를 설정할 수 있습니다. 아래는 몇 가지 유용한 매핑 정책과 전략에 대한 설명입니다.

Json String으로 변환하는 Mapper 직접 구현하기

가끔 MapStruct에서 매핑 코드를 구현하지 못하거나 직접 구현해야될 때가 있습니다. MapStructdefault 메서드를 이용해 Mapping 메서드를 직접 구현할 수 있게 해줍니다.

다음은 객체를 String으로 Mapping하는 메서드를 직접 구현한 것입니다.

@Mapper
public interface JsonMapper {
    ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    default String toString(Object obj) {
        try {
            return OBJECT_MAPPER.writeValueAsString(obj);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

ObjectMapper는 Jackson에서 Java Object와 JSON사이의 변환을 쉽게 해주는 클래스입니다.

  • writeValueAsString(obj) : obj객체를 Json String으로 변환시켜 줍니다.

  • readValue(arg, type) : arg을 type에 해당하는 클래스로 변환할 수 있습니다. Class객체, TypeReference가 올 수 있습니다.

    • ex ) OBJECT_MAPPER.readValue(arg, ArrayList.class); (arg에 해당하는 Json String을 ArrayList로 변환합니다.)

Mapper 속성 설정 공통화 하기

Mapper가 많아져서 Mapper마다의 설정 중 중복이 발생할 때 속성 설정을 공통화할 수 있습니다. MapStructMapperConfig를 만들어 @MapperConfig어노테이션을 이용해 공통화할 속성을 설정해줍니다.

특정 타입이나 객체 간 Mapping을 스스로할 수 없거나 다른 Mapper를 이용해야 할 때 uses속성을 사용할 수 있습니다. uses = JsonMapper.class로 설정해주면 객체에서 String으로 변환이 필요할 때 JsonMapper를 사용합니다.

@MapperConfig(unmappedTargetPolicy = ReportingPolicy.ERROR, uses = JsonMapper.class)
public interface MapStructMapperConfig {

}

아래와 같이 Mapper마다 사용할 수 있지만, 주석처럼 JsonMapper에 적용하게 되면 uses = JsonMapper.class때문에 순환참조가 일어날 수 있으니 조심해야 합니다.

@Mapper(config = MapStructMapperConfig.class)
public interface UserMapper {
	...
}

// @Mapper(config = MapStructMapperConfig.class)
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface JsonMapper {
    ...
}

5. Reference

https://huisam.tistory.com/entry/mapStruct

https://mangchhe.github.io/spring/2021/01/25/ModelMapperAndMapStruct/

https://meetup.toast.com/posts/213

https://wise-develop.tistory.com/18

https://asfirstalways.tistory.com/221

https://engaspect.tistory.com/27

Last updated