이번 글에서는 Mapstruct를 통해서 객체 간 맵핑을 효율적으로 관리할 수 있는 방법에 대해서 알아보도록 하겠다.
Java로 개발을 하다보면, DTO를 엔티티로 변환하거나 엔티티를 또 다른 DTO로 변환하는 것과 같은 상황이 많이 발생한다.
'변환 메서드'를 직접 구현해서 객체 간 변환을 진행해도 되지만, 이는 손이 매우 많이가고 클래스의 멤버 변수가 많아지면 휴먼 에러를 발생시키기 딱 좋은 환경이 돼버린다.
이런 상황에서 변환 작업을 편리하게 처리할 수 있는 Mapstruct
같은 라이브러리가 필요해지게 된다.
Mapstruct는 인터페이스와 어노테이션으로 객체 변환에 대해서 정의하면, 컴파일 단계에서 이에 대한 구현체 클래스를 생성해서 이 구현체가 실제 맵핑에 사용된다. 그래서 컴파일 단계에서 디버깅될 수 있다는 장점을 갖고 있고, Reflection을 사용하는 맵핑 라이브러리들에 비해서는 빠른 성능을 갖고 있다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.5.5.Final' // ⭐
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' // ⭐
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
위는 나의 build.gradle
파일이다.
mapstruct
는 내부적으로 lombok
을 사용하고 있어서 lombok
도 필요한데, lombok
보다 아래에 명시되어 있어야한다고 한다.
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class User {
private Long id;
private String email;
private String name;
private Integer age;
private Address address;
}
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class UserDto {
private String email;
private String name;
private Integer age;
}
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class Address {
private String location;
private String detail;
}
User 클래스가 Target이 되고, UserDto와 Address가 Source가 될 것이다.
두 종류 모두 Getter와 Setter는 꼭 필요하고, Source의 경우엔 기본 생성자 또한 꼭 있어야한다.
왜 그런지는 Mapstruct에서 생성된 Mapper의 코드를 아래에서 확인해보면 알 수 있다.
import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.factory.Mappers;
import study.javamapstruct.domain.Address;
import study.javamapstruct.domain.User;
import study.javamapstruct.domain.UserDto;
@Mapper(
componentModel = SPRING,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
User toEntity(UserDto userDto);
User toEntityWithAddress(UserDto userDto, Address address);
}
Mapstruct의 사용법은 매우 다양하게 커스텀이 가능하고, 옵션도 다양하다. 위 Mapper는 기본적인 기능들을 사용하고 있다.
각 값 또는 메서드에 대해서 설명하자면 아래와 같다.
@Mapper
: 이 어노테이션을 붙이면, Mapstruct가 구현체를 만들어줄 대상이 된다.componentModel
: Mapper를 Bean으로 생성하고 싶다면, 사용하면 되는 옵션이다. 나는 Spring Bean으로 생성하기 위한 옵션으로 사용하였다.unmappedTargetPolicy
: Target에 있는 필드 중 맵핑되지 않는 필드를 어떻게 처리할 것인지 결정할 수 있는 옵션이고, 나는 무시해버리도록 설정하였다.INSTANCE
: UserMapper
컴포넌트를 주입해서 사용할 수도 있지만, UserMapper.INSTANCE
로 인스턴스를 호출해서 사용할 수도 있다.toEntity()
: 메서드의 Return값이 Target이고 파라미터가 Source이다. 즉, UserDto
를 User
로 맵핑하는 메서드이다.toEntityWithAddress()
: UserDto
와 Address
로 User
를 맵핑하는 메서드이다.import javax.annotation.processing.Generated;
import org.springframework.stereotype.Component;
import study.javamapstruct.domain.Address;
import study.javamapstruct.domain.User;
import study.javamapstruct.domain.UserDto;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-07-07T23:11:48+0900",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.2 (Amazon.com Inc.)"
)
@Component
public class UserMapperImpl implements UserMapper {
@Override
public User toEntity(UserDto userDto) {
if ( userDto == null ) {
return null;
}
User user = new User();
user.setEmail( userDto.getEmail() );
user.setName( userDto.getName() );
user.setAge( userDto.getAge() );
return user;
}
@Override
public User toEntityWithAddress(UserDto userDto, Address address) {
if ( userDto == null && address == null ) {
return null;
}
User user = new User();
if ( userDto != null ) {
user.setEmail( userDto.getEmail() );
user.setName( userDto.getName() );
user.setAge( userDto.getAge() );
}
user.setAddress( address );
return user;
}
}
Mapper클래스를 작성한 후, 빌드해보면 구현체가 이렇게 생성된 것을 확인할 수 있다. 이는 당연하게도 내가 작성한 코드가 아닌 Mapstruct가 내가 작성한 Interface를 기반으로 생성해준 코드이다.
보는 것과 같이 Getter, Setter, 기본 생성자가 사용되고 있어서 Source 및 Target 클래스에도 Getter, Setter 그리고 기본 생성자가 정의되어 있어야한다.
또, unmappedTargetPolicy = ReportingPolicy.IGNORE
옵션을 사용했기 때문에 toEntity()
의 구현체에서는 setAddress()
하는 코드가 없는 것도 확인할 수 있다.
import static org.assertj.core.api.Assertions.assertThat;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import study.javamapstruct.domain.Address;
import study.javamapstruct.domain.User;
import study.javamapstruct.domain.UserDto;
@Slf4j
class UserMapperTest {
@Test
void 맵핑_테스트() throws Exception {
UserMapper userMapper = UserMapper.INSTANCE;
// 샘플 값
String email = "test@test.com";
String name = "Test name";
int age = 20;
String location = "서울";
String detail = "강남";
// DTO 생성
UserDto dto = new UserDto();
dto.setEmail(email);
dto.setName(name);
dto.setAge(age);
// DTO -> Entity 검증
User entity = userMapper.toEntity(dto);
assertThat(entity.getEmail()).isEqualTo(email);
assertThat(entity.getName()).isEqualTo(name);
assertThat(entity.getAge()).isEqualTo(age);
// DTO + Address -> Entity 검증
Address address = new Address(location, detail);
User entityWithAddress = userMapper.toEntityWithAddress(dto, address);
assertThat(entityWithAddress.getAddress().getLocation()).isEqualTo(location);
assertThat(entityWithAddress.getAddress().getDetail()).isEqualTo(detail);
}
}
테스트 코드를 위와 같이 간단히 작성해보았고, 맵핑이 잘되는 것도 확인하였다.
이 글에서 소개한 기능 및 옵션들은 매우 기본적인 것들이다. 디테일하게는 매우 다양한 상황에서도 맵핑할 수 있는 다양한 옵션들이 제공된다. 더 다양한 기능들이 궁금하다면 공식문서 보다도 네이버 클라우드 공식 블로그를 참고해보면 좋을 것 같다. ㅎㅎ 친절히 잘 설명이 되어있다!