24.05.29. ModelMapper vs MapStruct

develemon·2024년 6월 20일

Doran

목록 보기
7/13
post-thumbnail

24.05.29. 엔티티 리팩토링

이전 포스팅에 이어서 이번에는 매핑 모듈을 ModelMapper에서 MapStruct로 교체하는 과정에서 이 둘이 어떠한 차이가 있는지에 대해 알고서 교체하자는 취지로 둘을 살펴보기로 한다.

우선 왜 ModelMapper가 아닌 MapStruct인가? 앞선 포스팅에서는 간단히 아래와 같이 언급했다.

ModelMapper를 사용함에 있어서, 객체 간의 동일한 필드 이름에 따라 매핑이 되도록 하는 부분과 간결한 사용방식이 쉽게 사용하기 좋은 매퍼였다. 그러나 매핑이 필요한 객체들이 많아지게 되면 매핑에 필요한 로직이 모두 분산되어 코드를 관리하기가 어려워진다. 뿐만 아니라 매핑 방식에 있어서 성능상 더 유리한 MapStruct라는 것도 존재한다. 그래서 서비스의 규모가 커진다면 ModelMapper에 대한 방안을 고려해야 한다.

그렇다면 MapStructModelMapper의 동작 방식을 살펴보면서 둘을 비교해 MapStruct가 성능상 어떠한 유리한 점이 있고, 또 코드 유지보수성에 있어서 어떻게 좋은지 살펴보도록 하자.

동작 방식 비교 : 동작 시점에 따른 성능 차이


ModelMapperObjectMapper는 런타임에 Reflection을 통해 매핑을 하게 된다. 이 부분에서 MapStruct와 성능 차이를 갖게 되는데, 잠깐 Reflection이 무엇인지 살펴보도록 하자.

Reflection

리플렉션(Reflection)이란 구체적인 Class Type을 알지 못하더라도 해당 클래스의 method, type, variable들에 접근할 수 있도록 해주는 Java API이며, 컴파일된 바이트 코드를 통해 런타임에 동적으로 특정 클래스의 정보를 추출한다. 그리고 이렇게 런타임에 동적으로 특정 클래스의 정보를 추출하는 것을 동적 바인딩(Dynamic Binding)이라고 하는데, 여기서 바인딩이란 프로그램에 사용된 구성 요소의 실제 값 또는 프로퍼티를 결정짓는 행위, 즉 프로그램에서 사용되는 변수나 메서드 등 모든 것들이 결정되도록 연결해주는 것을 뜻한다. 바인딩을 결정 짓는 시점에 따라 정적 바인딩과 동적 바인딩으로 나뉘게 되는데, 쉽게 말하면 정적 바인딩은 이 결정짓는 행위를 컴파일 타임에 수행하고, 동적 바인딩은 런타임에 수행하게 된다.

컴파일 타임에는 어떠한 클래스를 사용해야 할지 모르지만 런타임에 클래스를 가져와서 실행해야하는 경우 리플렉션을 사용하며, Spring 프레임워크의 어노테이션 같은 기능들이 프로그램 실행 도중 동적으로 클래스의 정보를 가져와서 사용할 때에도 리플렉션을 이용한다. 리플렉션을 통해 Class, Constructor, Method, Field 등의 정보를 가져와 사용할 수 있으며, IDE의 자동완성 및 미리보기 기능도 리플렉션 사용 예이다.

참고 :
[Java] Reflection은 무엇이고 언제/어떻게 사용하는 것이 좋을까?
JAVA - 리플렉션 (Reflection)이란?

이처럼 ModelMapper는 객체 매핑 과정이 런타임에 리플렉션을 통해 이루어진다.

반면 MapStruct는 리플렉션이 아닌 컴파일 타임에 어노테이션을 읽어 최적화된 로직을 담은 구현체를 생성하게 된다. 그래서 런타임에 처리 부하를 덜어 ModelMapper와 비교해 더 유리한 성능을 가질 수 있게 된다.

코드 유지보수성 및 안전성


두 모듈의 간단한 예시 코드들을 통해 살펴보도록 하자.

ModelMapper

public BookDto toBookDto(Book book) {
	ModelMapper modelMapper = new ModelMapper();
	modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
	BookDto bookDto = modelMapper.map(book, BookDto.class);
    return bookDto;
}

public Book toBook(BookDto bookDto) {
	ModelMapper modelMapper = new ModelMapper();
	modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
	Book book = modelMapper.map(bookDto, Book.class);
    return book;
}

ModelMapper는 위와 같이 모듈 객체를 만들어주고 바로 매핑할 수 있어 쉽게 사용할 수 있다. 문제는 이렇게 매핑되는 객체들은 서로 필드 이름이 모두 동일해야만 적용이 가능하고, 또 이 객체들이 다양해짐에 따라 위와 같은 방식의 매핑 메서드들을 일대일로 만들어주어야 한다. 이에 따라 시스템이 조금만 커져도 코드 복잡성이 높아질 수밖에 없게 된다.

MapStruct

@Mapper(componentModel = "spring", imports = UUID.class)
public interface BookMapper {

    BookMapper INSTANCE = Mappers.getMapper(BookMapper.class);

    Book toBook(BookDto bookDto);
    
    BookDto toBookDto(Book book);
    
    @Mapping(target = "itemUuid", source = "itemUuid", defaultExpression = "java(UUID.randomUUID().toString())")
    @Mapping(target = "category", source = "category", defaultExpression = "java(Category.BOOK)")
    BookDto toBookDto(BookCreateRequest request, String itemUuid, Category category);
    
    ItemSimpleResponse toItemSimpleResponse(BookDto bookDto);
}

반면 MapStruct의 경우, 설정해야할 요소들은 조금 더 복잡해지긴 했지만, 매핑되는 객체들 간의 필드 이름이 서로 달라도 @Mapping 어노테이션의 옵션을 통해 targetsource를 매핑할 필드를 지정하여 보다 적절한 매핑을 편리하게 이용할 수 있고, defaultExpression 옵션을 통해 Java 코드로 값을 입력해줄 수도 있다. 그리고 ModelMapper는 단일 객체만을 통해 매핑을 이룰 수 있었지만, MapStruct는 여러 객체를 파라미터로 전달받아 해당 객체별로 필요한 필드들을 선별하여 매핑할 수도 있다.

그리고 컴파일 타임에 아래와 같은 구현체를 컴포넌트로 자동으로 생성한다.

BookMapperImpl.java

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2024-05-30T16:22:17+0900",
    comments = "version: 1.5.5.Final, compiler: javac, environment: Java 17.0.10 (Oracle Corporation)"
)
@Component
public class BookMapperImpl implements BookMapper {

    @Override
    public Book toBook(BookDto bookDto) {
        if ( bookDto == null ) {
            return null;
        }

        Book.BookBuilder book = Book.builder();

        book.itemUuid( bookDto.getItemUuid() );
        // ...
        book.bookReview( bookDto.getBookReview() );

        return book.build();
    }
    
    // ...
    
    @Override
    public BookDto toBookDto(BookCreateRequest request, String itemUuid, Category category) {
        if ( request == null && itemUuid == null && category == null ) {
            return null;
        }

        BookDto.BookDtoBuilder<?, ?> bookDto = BookDto.builder();

        if ( request != null ) {
            bookDto.itemName( request.getItemName() );
            bookDto.price( request.getPrice() );
            bookDto.stockQuantity( request.getStockQuantity() );
            bookDto.author( request.getAuthor() );
            bookDto.isbn( request.getIsbn() );
            bookDto.pages( request.getPages() );
            bookDto.publicationDate( request.getPublicationDate() );
            bookDto.contentsTable( request.getContentsTable() );
            bookDto.bookReview( request.getBookReview() );
        }
        if ( itemUuid != null ) {
            bookDto.itemUuid( itemUuid );
        }
        else {
            bookDto.itemUuid( UUID.randomUUID().toString() );
        }
        if ( category != null ) {
            bookDto.category( category );
        }
        else {
            bookDto.category( Category.BOOK );
        }

        return bookDto.build();
    }
    
    // ...
}

위 코드를 보면 파라미터들이 모두 null이 아닌지 검사하고, 매핑되는 객체를 생성 및 초기화를 할 때에는 빌더 패턴을 통해 이루어지게 된다. 여기서 또 코드 안전성을 확보하게 되는 지점은 바로 빌더 패턴의 활용이다.

ModelMapper의 경우 공식문서에서 다음과 같이 소개한다.

Providers

Providers allow you to provide your own instance of destination properties and types prior to mapping as opposed to having ModelMapper construct them via the default constructor. They can be configured globally, for a specific TypeMap, or for specific properties.

즉, ModelMapper는 대상 객체에 대해 기본 생성자인 @NoArgsConstructor를 반드시 필요로 하고, 또한 소스 객체의 getter 메서드와 대상 객체의 setter 메서드를 통해 값을 전달하기 때문에 엔티티의 경우에도 @Setter 어노테이션을 붙여야만 하게 된다. 그러나 MapStruct를 사용하게 된다면 @Setter@NoAgrsConstructor가 제거되어도 직접 만든 생성자에 대해 @Builder를 적용하면 되기에 엔티티에 대한 안전성을 확보할 수 있다.

MapStruct를 사용하자


이번 포스팅의 결론은 이것이다. 성능을 비교해서든 코드 유지보수성 및 안전성을 위해서든 시스템의 규모와 확장성을 고려한다면 ModelMapper 대신 MapStruct가 유리하다. 물론 처음 적용하는 데에 있어서 아무래도 모듈에 대한 이해도 필요하고 설정에서도 좀더 복잡하고 까다로웠지만, 그만큼 더 많은 기능 지원과 이점을 누릴 수 있기에 시간과 노력이 좀 들더라도 수고를 들일 필요가 있다.

다음 포스팅에서는 실제 프로젝트에 적용된 코드를 공유하도록 하겠다.

profile
유랑하는 백엔드 개발자 새싹 블로그

0개의 댓글