스프링 객체 매핑의 최적 솔루션 : MapStruct vs ModelMapper 비교 분석

jkky98·2025년 2월 13일
0

ProjectSpring

목록 보기
11/20
post-thumbnail

서론

객체 매핑(Object Mapping)이란?

객체 매핑(Object Mapping)이란 한 객체의 데이터를 다른 객체로 변환하는 과정을 의미한다.

소프트웨어 개발에서 서로 다른 데이터 모델을 변환해야 하는 경우가 자주 발생하는데, 특히 계층 간 데이터 교환이 필요한 경우 객체 매핑이 필수적으로 사용된다.

예를 들어, 데이터베이스에서 조회한 엔티티 객체를 그대로 클라이언트에 반환하는 것은 불필요한 정보 노출 및 유지보수 문제를 초래할 수 있다.

따라서, 엔티티(Entity) → DTO(Data Transfer Object) 형태로 변환하여 필요한 데이터만 가공해 전달하는 것이 일반적인 패턴이다.


객체 매핑이 필요한 이유

객체 매핑은 수작업으로 변환 로직을 구현할 수도 있지만, 객체 매핑 라이브러리(MapStruct, ModelMapper 등)를 활용하면 자동으로 변환을 처리할 수 있어 코드의 가독성과 유지보수성을 향상시킬 수 있다.


MapStruct와 ModelMapper가 대표적인 라이브러리인 이유

객체 매핑을 자동화하는 라이브러리는 여러 가지가 있지만, 그중 MapStructModelMapper가 가장 널리 사용된다.

이 두 라이브러리는 각각 성능과 개발 편의성 측면에서 강점을 가지며, 다음과 같은 이유로 대표적인 객체 매핑 라이브러리로 자리 잡았다.


MapStruct – 컴파일 타임 코드 생성 방식

특징:

  • MapStruct는 컴파일 타임에 매핑 코드를 자동 생성하는 방식이다.
  • 런타임에 Reflection을 사용하지 않으므로 빠른 성능을 보장한다.

장점:

  • 퍼포먼스가 뛰어남 (컴파일 시점에 코드를 생성하므로 런타임 오버헤드가 없음)
  • 명시적인 매핑 코드 작성 가능 (가독성이 높고 유지보수 용이)
  • 커스텀 매핑 기능 지원 (@Mapping, @Named, @Expression 활용 가능)

단점:

  • 매핑을 위한 인터페이스 및 어노테이션을 미리 정의해야 함
  • 동적 매핑이 어려움 (런타임에 동적으로 변환하는 기능이 없음)

ModelMapper – 런타임 리플렉션 기반 자동 매핑

특징:

  • ModelMapper는 런타임 시점에서 Reflection을 활용하여 객체를 자동 변환한다.
  • 개발자가 별도의 매핑 로직을 작성하지 않아도, 필드명이 동일하면 자동으로 매핑된다.

장점:

  • 설정이 간단하고 개발 속도가 빠름 (필드명이 같으면 자동 매핑)
  • 소규모 프로젝트나 빠른 개발이 필요한 경우 유용

단점:

  • 리플렉션 사용으로 인해 성능 저하 가능성 있음
  • 복잡한 매핑에서는 예상치 못한 매핑 결과가 발생할 수 있음
  • 필드명이 다를 경우 수동 설정 필요 (유지보수가 어려울 수 있음)

성능 비교

MapStruct의 컴파일 타임 코드 생성 방식

MapStruct는 컴파일 타임(Compile-time)에 자동으로 변환 코드를 생성하는 객체 매핑 라이브러리이다.
일반적인 객체 매핑 라이브러리는 리플렉션(Reflection)이나 런타임 동적 매핑을 활용하지만,
MapStruct는 정적 코드 생성 방식을 사용하여 성능을 최적화한다.


컴파일 타임 코드 생성이란?

컴파일 타임 코드 생성 방식이란, Java 코드가 컴파일되는 시점에 변환 코드가 자동으로 생성되는 기법이다.
MapStruct는 애너테이션 프로세서(Annotation Processor)를 활용하여 매핑 메서드를 구현한 클래스를 컴파일 시점에 생성한다.

즉, 개발자가 인터페이스만 정의하면, MapStruct가 자동으로 구현체를 생성하여 객체 변환을 수행한다.


MapStruct가 변환 코드를 생성하는 과정

MapStruct가 변환 코드를 생성하는 과정은 다음과 같다.

1. 인터페이스 정의

개발자는 변환할 객체에 대한 매퍼 인터페이스를 정의한다.

@Mapper(componentModel = "spring")
public interface PostMapper {
    @Mapping(source = "user.username", target = "username")
    PostDto toPostDto(Post post);
}

2. 컴파일 시 애너테이션 프로세서 실행

  • Java 컴파일러가 코드를 컴파일할 때, MapStruct의 애너테이션 프로세서가 실행된다.
  • 이 과정에서 PostMapper 인터페이스의 구현체가 자동으로 생성된다.
public class PostMapperImpl implements PostMapper {
    @Override
    public PostDto toPostDto(Post post) {
        if (post == null) {
            return null;
        }

        PostDto postDto = new PostDto();
        postDto.setId(post.getId());
        postDto.setTitle(post.getTitle());
        postDto.setContent(post.getContent());
        postDto.setUsername(post.getUser().getUsername());

        return postDto;
    }
}
  • PostMapperImpl 클래스는 컴파일 과정에서 자동 생성된 코드이다.
  • 개발자는 별도로 구현할 필요 없이, PostMapper.INSTANCE.toPostDto(post);를 호출하면 변환이 수행된다.

컴파일 시점에 변환을 위한 도구들을 준비한다는 것은

컴파일 시점(Compile-time)이란, 소스 코드가 실행되기 전에 컴파일러가 코드를 분석하고 기계어로 변환하는 과정을 의미한다.
Java에서는 Javac(Java Compiler)가 컴파일을 수행하며, 이 과정에서 문법 오류를 검사하고,
바이트코드(.class 파일)를 생성하여 JVM(Java Virtual Machine)에서 실행할 준비를 한다.
즉, 컴파일 시점에서는 프로그램이 실행되지 않으며, 오직 코드의 정적 분석과 변환 과정이 진행된다.


MapStruct는 이러한 컴파일 시점을 활용하여 매핑 코드를 자동으로 생성한다.
애너테이션 프로세서(Annotation Processor)를 통해 @Mapper가 붙은 인터페이스를 분석하고,
컴파일 과정에서 변환 로직이 포함된 MapperImpl 클래스를 생성한다.
이러한 방식 덕분에 런타임에 별도의 연산 없이, 컴파일된 최적화된 매핑 코드가 실행되므로 성능이 뛰어나다.
또한, 컴파일 시점에 오류를 감지할 수 있어, 잘못된 매핑 규칙이 있을 경우 빌드 과정에서 문제를 발견할 수 있다. 🚀

ModelMapper의 런타임 리플렉션 방식

ModelMapper는 런타임 시점(Runtime)에 리플렉션(Reflection)을 활용하여 객체를 자동 변환하는 방식을 사용한다.
즉, 프로그램이 실행될 때 객체의 필드와 메서드를 분석하여 필드명이 일치하는 경우 자동으로 매핑한다.
개발자가 별도의 매핑 규칙을 정의하지 않아도 필드명이 같다면 자동으로 변환되므로,
빠른 개발이 가능하고, 코드 작성이 간결하다는 장점이 있다.

하지만, ModelMapper는 리플렉션을 사용하기 때문에 런타임 성능 저하가 발생할 수 있다.
리플렉션은 JVM이 클래스의 메타데이터(필드, 메서드, 접근제어자 등)를 동적으로 조회하고 조작하는 기능을 제공하지만,
이 과정에서 추가적인 연산 비용이 발생하여 대량의 데이터를 변환할 경우 성능이 저하될 가능성이 높다.
또한, 필드명이 다를 경우 자동 매핑이 실패할 수 있어, 예상치 못한 오류가 발생할 가능성이 크다.
따라서, ModelMapper는 빠른 개발이 필요하지만 성능이 크게 중요하지 않은 소규모 프로젝트에서 유용하게 사용된다.

ModelMapper는 MapStruct보다 코드가 적게 생성될까?

ModelMapper는 런타임 시점에서 리플렉션을 활용하여 자동 변환을 수행하므로, 개발자가 명시적인 매핑 규칙을 설정하지 않아도 필드명이 동일하면 자동으로 매핑이 이루어진다.

이로 인해, 개발자가 직접 작성해야 하는 코드의 양은 MapStruct보다 적을 수 있다.

반면, MapStruct는 컴파일 타임에 변환 코드를 생성하기 때문에 매핑 규칙을 인터페이스로 명시적으로 정의해야 한다.

즉, @Mapping(source = "필드", target = "필드")과 같은 설정이 필요하므로, 개발자가 미리 매핑 규칙을 지정해야 하며, 이는 코드량이 증가하는 원인이 될 수 있다.

하지만, 실제로 실행되는 코드량을 비교하면 MapStruct가 더 적다.

MapStruct는 컴파일 시 변환 코드가 생성되므로 실행 시점에는 추가 연산이 필요하지 않다.
반면, ModelMapper는 매핑할 때마다 리플렉션을 실행하여 필드를 분석하므로 런타임 오버헤드가 발생한다.
즉, 개발자가 작성하는 코드의 양은 ModelMapper가 적지만, 실행 시점에 생성되는 코드는 MapStruct가 더 효율적이다.

벤치마크 결과 비교

객체 매핑 라이브러리의 성능을 비교하기 위해 여러 벤치마크 테스트가 진행되었으며,
일반적으로 MapStruct가 ModelMapper보다 훨씬 빠른 성능을 보인다.
이는 MapStruct가 컴파일 타임에 변환 코드를 생성하는 반면, ModelMapper는 런타임에 리플렉션을 사용하여 변환을 수행하기 때문이다.

벤치마크 테스트 예시

  • 테스트 환경: MacMini M4 24G RAM, IntelliJ, Java 17
  • 매핑 대상: PostPostDto
라이브러리1,000개 객체 변환10,000개 객체 변환100,000개 객체 변환
MapStruct1ms4ms34ms
ModelMapper48ms143ms625ms

1,000개 변환

10,000개 변환

100,000개 변환

테스트 코드

@SpringBootTest
@Profile("local")
public class ObjectMappingBenchmarkTest {

    private static final int TEST_SIZE = 1_000;
    private List<Post> posts;

    @Autowired
    private PostMapper postMapper;

    @Autowired
    private ModelMapperUtil modelMapperUtil;

    @BeforeEach
    void setUp() {
        posts = generateTestData();
    }

    @Test
    @Transactional
    void testMapStructPerformance() {
        long startTime = System.currentTimeMillis();
        List<PostDto> postDtos = postMapper.toPostDtoList(posts);
        long endTime = System.currentTimeMillis();

        System.out.println("🔹 MapStruct 변환 시간: " + (endTime - startTime) + "ms");
        assertThat(postDtos).hasSize(TEST_SIZE);
    }

    @Test
    @Transactional
    void testModelMapperPerformance() {
        long startTime = System.currentTimeMillis();
        List<PostDto> postDtos = modelMapperUtil.toPostDtoList(posts);
        long endTime = System.currentTimeMillis();

        System.out.println("🔹 ModelMapper 변환 시간: " + (endTime - startTime) + "ms");
        assertThat(postDtos).hasSize(TEST_SIZE);
    }

    private List<Post> generateTestData() {
        List<Post> postList = new ArrayList<>();
        for (long i = 0; i < TEST_SIZE; i++) {
            postList.add(Post.builder()
                    .title("Title " + i)
                    .postSummary("Summary " + i)
                    .postUrl("https://example.com/" + i)
                    .thumbnail("https://example.com/image" + i + ".jpg")
                    .writable(true)
                    .user(User.builder().username("user" + i).build())
                    .comments(new ArrayList<>()) // 빈 댓글 리스트
                    .likes(new ArrayList<>()) // 빈 좋아요 리스트
                    .postTags(new ArrayList<>()) // 빈 태그 리스트
                    .build());
        }
        return postList;
    }
}

📌 결과 해석:

  • MapStruct는 컴파일 시 최적화된 코드를 생성하여 빠른 변환 속도를 보인다.
  • ModelMapper는 리플렉션을 사용하기 때문에 객체 수가 많아질수록 성능 저하가 급격히 발생한다.
  • 객체 변환이 빈번한 대규모 프로젝트에서는 MapStruct가 압도적으로 유리하다.

따라서, 고성능이 요구되는 애플리케이션에서는 MapStruct를, 빠른 개발이 필요한 경우에는 ModelMapper를 고려하는 것이 바람직하다.

장단점 분석

MapStruct는 코드를 줄여준다는 느낌은 크지 않았다.

PostDto.of이라는 Dto 생성로직과 Post.of이라는 엔티티 생성로직이 있을 때 각 생성로직들은 한줄 한줄 객체 필드에 인자에서의 값을 넣어주는데, MapStruct 또한 일반적으로는 @Mapping을 통해 이를 작성해주어야 한다.

MapStruct는 Dto클래스와 엔티티 클래스의 생성로직을 한 곳에서 관리할 수 있으며 생성과정에서 부차적으로 생성되는 로직또한 @Named와 같은 애너테이션이 달린 메서드로 관리할 수 있었다.

반면 ModelMapper는 AI와 같이 이름을 통해 자동 매핑을 지원해준다. 당연히 그에 따른 규칙이 존재할테지만 개발자가 작성할 코드가 줄어든다.

하지만 리플렉션 방식이기에 결국 자동으로 코드가 불어나는 것은 막을 수 없다. 또한 불어나는 시점이 런타임 시점의 리플렉션 방식이기에 위의 벤치마크 테스트에서 성능적으로 mapStruct와 큰 차이를 보인다.

무엇을 사용해야 하나?

소규모 프로젝트에서는 ModelMapper에 대한 사용이 좋을 수 있다고 생각하는데 MapStruct가 그리 어려운가?라고 생각이 들었다.

그리고 생각보다 ModelMapper의 성능이 너무 너무 떨어진다.

난 어떤 프로젝트를 사용해도 MapStruct를 사용하거나 아니면 직접 생성 메서드를 작성할 것 같다.

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보