객체 매핑(Object Mapping)이란 한 객체의 데이터를 다른 객체로 변환하는 과정을 의미한다.
소프트웨어 개발에서 서로 다른 데이터 모델을 변환해야 하는 경우가 자주 발생하는데, 특히 계층 간 데이터 교환이 필요한 경우 객체 매핑이 필수적으로 사용된다.
예를 들어, 데이터베이스에서 조회한 엔티티 객체를 그대로 클라이언트에 반환하는 것은 불필요한 정보 노출 및 유지보수 문제를 초래할 수 있다.
따라서, 엔티티(Entity) → DTO(Data Transfer Object) 형태로 변환하여 필요한 데이터만 가공해 전달하는 것이 일반적인 패턴이다.
객체 매핑은 수작업으로 변환 로직을 구현할 수도 있지만, 객체 매핑 라이브러리(MapStruct, ModelMapper 등)를 활용하면 자동으로 변환을 처리할 수 있어 코드의 가독성과 유지보수성을 향상시킬 수 있다.
객체 매핑을 자동화하는 라이브러리는 여러 가지가 있지만, 그중 MapStruct
와 ModelMapper
가 가장 널리 사용된다.
이 두 라이브러리는 각각 성능과 개발 편의성 측면에서 강점을 가지며, 다음과 같은 이유로 대표적인 객체 매핑 라이브러리로 자리 잡았다.
✅ 특징:
✅ 장점:
@Mapping
, @Named
, @Expression
활용 가능) ✅ 단점:
✅ 특징:
✅ 장점:
✅ 단점:
MapStruct는 컴파일 타임(Compile-time)에 자동으로 변환 코드를 생성하는 객체 매핑 라이브러리이다.
일반적인 객체 매핑 라이브러리는 리플렉션(Reflection)이나 런타임 동적 매핑을 활용하지만,
MapStruct는 정적 코드 생성 방식을 사용하여 성능을 최적화한다.
컴파일 타임 코드 생성 방식이란, Java 코드가 컴파일되는 시점에 변환 코드가 자동으로 생성되는 기법이다.
MapStruct는 애너테이션 프로세서(Annotation Processor)를 활용하여 매핑 메서드를 구현한 클래스를 컴파일 시점에 생성한다.
즉, 개발자가 인터페이스만 정의하면, MapStruct가 자동으로 구현체를 생성하여 객체 변환을 수행한다.
MapStruct가 변환 코드를 생성하는 과정은 다음과 같다.
개발자는 변환할 객체에 대한 매퍼 인터페이스를 정의한다.
@Mapper(componentModel = "spring")
public interface PostMapper {
@Mapping(source = "user.username", target = "username")
PostDto toPostDto(Post post);
}
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;
}
}
컴파일 시점에 변환을 위한 도구들을 준비한다는 것은
컴파일 시점(Compile-time)이란, 소스 코드가 실행되기 전에 컴파일러가 코드를 분석하고 기계어로 변환하는 과정을 의미한다.
Java에서는 Javac(Java Compiler)가 컴파일을 수행하며, 이 과정에서 문법 오류를 검사하고,
바이트코드(.class 파일)를 생성하여 JVM(Java Virtual Machine)에서 실행할 준비를 한다.
즉, 컴파일 시점에서는 프로그램이 실행되지 않으며, 오직 코드의 정적 분석과 변환 과정이 진행된다.
MapStruct는 이러한 컴파일 시점을 활용하여 매핑 코드를 자동으로 생성한다.
애너테이션 프로세서(Annotation Processor)를 통해@Mapper
가 붙은 인터페이스를 분석하고,
컴파일 과정에서 변환 로직이 포함된MapperImpl
클래스를 생성한다.
이러한 방식 덕분에 런타임에 별도의 연산 없이, 컴파일된 최적화된 매핑 코드가 실행되므로 성능이 뛰어나다.
또한, 컴파일 시점에 오류를 감지할 수 있어, 잘못된 매핑 규칙이 있을 경우 빌드 과정에서 문제를 발견할 수 있다. 🚀
ModelMapper는 런타임 시점(Runtime)에 리플렉션(Reflection)을 활용하여 객체를 자동 변환하는 방식을 사용한다.
즉, 프로그램이 실행될 때 객체의 필드와 메서드를 분석하여 필드명이 일치하는 경우 자동으로 매핑한다.
개발자가 별도의 매핑 규칙을 정의하지 않아도 필드명이 같다면 자동으로 변환되므로,
빠른 개발이 가능하고, 코드 작성이 간결하다는 장점이 있다.
하지만, ModelMapper는 리플렉션을 사용하기 때문에 런타임 성능 저하가 발생할 수 있다.
리플렉션은 JVM이 클래스의 메타데이터(필드, 메서드, 접근제어자 등)를 동적으로 조회하고 조작하는 기능을 제공하지만,
이 과정에서 추가적인 연산 비용이 발생하여 대량의 데이터를 변환할 경우 성능이 저하될 가능성이 높다.
또한, 필드명이 다를 경우 자동 매핑이 실패할 수 있어, 예상치 못한 오류가 발생할 가능성이 크다.
따라서, ModelMapper는 빠른 개발이 필요하지만 성능이 크게 중요하지 않은 소규모 프로젝트에서 유용하게 사용된다.
ModelMapper는 런타임 시점에서 리플렉션을 활용하여 자동 변환을 수행하므로, 개발자가 명시적인 매핑 규칙을 설정하지 않아도 필드명이 동일하면 자동으로 매핑이 이루어진다.
이로 인해, 개발자가 직접 작성해야 하는 코드의 양은 MapStruct보다 적을 수 있다.
반면, MapStruct는 컴파일 타임에 변환 코드를 생성하기 때문에 매핑 규칙을 인터페이스로 명시적으로 정의해야 한다.
즉, @Mapping(source = "필드", target = "필드")
과 같은 설정이 필요하므로, 개발자가 미리 매핑 규칙을 지정해야 하며, 이는 코드량이 증가하는 원인이 될 수 있다.
하지만, 실제로 실행되는 코드량을 비교하면 MapStruct가 더 적다.
MapStruct는 컴파일 시 변환 코드가 생성되므로 실행 시점에는 추가 연산이 필요하지 않다.
반면, ModelMapper는 매핑할 때마다 리플렉션을 실행하여 필드를 분석하므로 런타임 오버헤드가 발생한다.
즉, 개발자가 작성하는 코드의 양은 ModelMapper가 적지만, 실행 시점에 생성되는 코드는 MapStruct가 더 효율적이다.
객체 매핑 라이브러리의 성능을 비교하기 위해 여러 벤치마크 테스트가 진행되었으며,
일반적으로 MapStruct가 ModelMapper보다 훨씬 빠른 성능을 보인다.
이는 MapStruct가 컴파일 타임에 변환 코드를 생성하는 반면, ModelMapper는 런타임에 리플렉션을 사용하여 변환을 수행하기 때문이다.
Post
→ PostDto
라이브러리 | 1,000개 객체 변환 | 10,000개 객체 변환 | 100,000개 객체 변환 |
---|---|---|---|
MapStruct | 1ms | 4ms | 34ms |
ModelMapper | 48ms | 143ms | 625ms |
@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
는 코드를 줄여준다는 느낌은 크지 않았다.
PostDto.of이라는 Dto 생성로직과 Post.of이라는 엔티티 생성로직이 있을 때 각 생성로직들은 한줄 한줄 객체 필드에 인자에서의 값을 넣어주는데, MapStruct 또한 일반적으로는 @Mapping
을 통해 이를 작성해주어야 한다.
MapStruct는 Dto클래스와 엔티티 클래스의 생성로직을 한 곳에서 관리할 수 있으며 생성과정에서 부차적으로 생성되는 로직또한 @Named
와 같은 애너테이션이 달린 메서드로 관리할 수 있었다.
반면 ModelMapper
는 AI와 같이 이름을 통해 자동 매핑을 지원해준다. 당연히 그에 따른 규칙이 존재할테지만 개발자가 작성할 코드가 줄어든다.
하지만 리플렉션 방식이기에 결국 자동으로 코드가 불어나는 것은 막을 수 없다. 또한 불어나는 시점이 런타임 시점의 리플렉션 방식이기에 위의 벤치마크 테스트에서 성능적으로 mapStruct
와 큰 차이를 보인다.
소규모 프로젝트에서는 ModelMapper
에 대한 사용이 좋을 수 있다고 생각하는데 MapStruct
가 그리 어려운가?라고 생각이 들었다.
그리고 생각보다 ModelMapper의 성능이 너무 너무 떨어진다.
난 어떤 프로젝트를 사용해도 MapStruct를 사용하거나 아니면 직접 생성 메서드를 작성할 것 같다.