[Java] Mapstruct 사용하기

🔥Log·2024년 7월 7일
0

Java

목록 보기
18/22

☕ 개요


이번 글에서는 Mapstruct를 통해서 객체 간 맵핑을 효율적으로 관리할 수 있는 방법에 대해서 알아보도록 하겠다.



🤔 Mapstruct ?


1) Mapstruct를 사용하는 이유

Java로 개발을 하다보면, DTO를 엔티티로 변환하거나 엔티티를 또 다른 DTO로 변환하는 것과 같은 상황이 많이 발생한다.

'변환 메서드'를 직접 구현해서 객체 간 변환을 진행해도 되지만, 이는 손이 매우 많이가고 클래스의 멤버 변수가 많아지면 휴먼 에러를 발생시키기 딱 좋은 환경이 돼버린다.

이런 상황에서 변환 작업을 편리하게 처리할 수 있는 Mapstruct같은 라이브러리가 필요해지게 된다.

2) Mapstruct의 특징

Mapstruct는 인터페이스와 어노테이션으로 객체 변환에 대해서 정의하면, 컴파일 단계에서 이에 대한 구현체 클래스를 생성해서 이 구현체가 실제 맵핑에 사용된다. 그래서 컴파일 단계에서 디버깅될 수 있다는 장점을 갖고 있고, Reflection을 사용하는 맵핑 라이브러리들에 비해서는 빠른 성능을 갖고 있다.



✍️ Mapstruct 사용하기


1) 설치 (build.gradle)

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보다 아래에 명시되어 있어야한다고 한다.

2) 객체 만들기

  1. User 클래스를 이렇게 만들었다.
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;

}
  1. User와 맵핑해줄 UserDto 클래스를 이렇게 만들었다.
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class UserDto {

    private String email;
    private String name;
    private Integer age;

}
  1. User와 맵핑해줄 Address 클래스도 만들어주었다.
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의 코드를 아래에서 확인해보면 알 수 있다.



🔨 Mapper 클래스 만들기


1) 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이다. 즉, UserDtoUser로 맵핑하는 메서드이다.
  • toEntityWithAddress(): UserDtoAddressUser를 맵핑하는 메서드이다.

2) 구현체 확인하기

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);
    }

}

테스트 코드를 위와 같이 간단히 작성해보았고, 맵핑이 잘되는 것도 확인하였다.



☕ 마무리


이 글에서 소개한 기능 및 옵션들은 매우 기본적인 것들이다. 디테일하게는 매우 다양한 상황에서도 맵핑할 수 있는 다양한 옵션들이 제공된다. 더 다양한 기능들이 궁금하다면 공식문서 보다도 네이버 클라우드 공식 블로그를 참고해보면 좋을 것 같다. ㅎㅎ 친절히 잘 설명이 되어있다!



🙏 참고


0개의 댓글