MapStruct란?
MapStruct는 Java 기반의 라이브러리로, 객체 간의 매핑을 손쉽게 도와주는 코드 생성기이다.
이 라이브러리는 주로 서비스 계층과 데이터 접근 계층 사이, 또는 다양한 계층 간의 데이터 전송 객체(DTO)와 도메인 또는 엔티티 객체 간의 변환 작업을 자동화하기 위해 사용된다.
개발자는 변환 로직을 직접 작성하는 대신, 인터페이스에 어노테이션을 사용하여 어떤 필드가 어떻게 매핑되어야 하는지를 선언적으로 정의한다. 그 후, MapStruct는 이 인터페이스를 기반으로 구현 클래스를 자동 생성한다. 이 과정은 프로젝트의 컴파일 시점에 이루어지므로, 런타임 시에 추가적인 성능 저하 없이 매핑 로직을 실행할 수 있다.
MapStruct 동작 원리
MapStruct의 핵심 동작 원리는 다음과 같이 이해할 수 있다:
- 인터페이스 정의: 개발자는 변환해야 하는 객체 간의 매핑 규칙을 정의하는 인터페이스를 작성한다. 이 인터페이스에는 MapStruct의 어노테이션(@Mapper)을 사용하여 입력 타입과 출력 타입을 명시한다.
- 코드 생성: 프로젝트가 컴파일되는 시점에 MapStruct는 이 인터페이스를 읽고, 정의된 매핑 규칙에 따라 실제 작업을 수행할 구현 클래스를 자동으로 생성한다. 이 구현 클래스는 매핑에 필요한 모든 코드를 포함한다.
- 런타임 실행: 애플리케이션이 실행될 때, MapStruct에 의해 생성된 구현 클래스는 런타임 오버헤드 없이 사용된다. 개발자는 마치 자신이 직접 작성한 코드처럼 이 구현 클래스를 사용할 수 있다.
MapStruct 사용 예제
1. 의존성 추가
먼저, Spring Boot 프로젝트의 build.gradle에 MapStruct 관련 의존성을 추가한다.
(버전은 각자 버전에 맞는 걸 선택하면 된다.)
def mapstructVersion = '1.5.2.Final'
def lombokMapStructBindingVersion = '0.2.0'
dependencies {
compileOnly "org.projectlombok:lombok-mapstruct-binding:${lombokMapStructBindingVersion}"
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
}
lombok-mapstruct-binding
Lombok과 MapStruct를 함께 사용할 때 발생할 수 있는 문제를 방지하기 위한 라이브러리다.
Lombok은 컴파일 시점에 코드(예: getter, setter, 생성자 등)를 자동으로 생성하는 라이브러리이며, MapStruct는 이러한 메서드들을 사용하여 객체 간 매핑 코드를 생성한다. Lombok과 MapStruct가 서로의 코드 생성 시점을 올바르게 인식할 수 있도록 연결해 주는 역할을 한다. 이 의존성은 실제 코드에 포함되지 않고, 컴파일 시에만 필요하기 때문에 compileOnly로 선언된다.
mapstruct-processor
MapStruct의 핵심 기능을 담당하는 어노테이션 프로세서다. 이 프로세서는 소스 코드 레벨에서 @Mapper 등의 어노테이션을 처리하여, 객체 간 매핑을 위한 구현체 코드를 컴파일 시점에 자동으로 생성한다. 매핑 코드를 수동으로 작성할 필요를 없애주므로, 개발자는 매핑 로직에 대한 인터페이스만 정의하면 된다. annotationProcessor 구성을 사용하여, 이 의존성이 컴파일 시점에만 사용되도록 지정한다.
DTO와 엔티티 클래스 생성
사용할 엔티티와 DTO 클래스를 정의해 보자.
@Setter
@Getter
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userName;
private String userEmail;
}
@Getter
public class UserDto {
private String name;
private String email;
}
Mapper 인터페이스 정의
Request DTO를 엔티티로 변환하는 Mapper 인터페이스를 정의해 보자.
Mapper 인터페이스를 정의하는 방법에는 두 가지가 있다.
첫 번째 방법 - componentModel 옵션 미사용
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
@Mapping(source = "name", target = "userName")
@Mapping(source = "email", target = "userEmail")
User entityFromDto(UserDto userDto);
}
두 번째 방법 - componentModel 옵션 사용
@Mapper(componentModel = "spring") 어노테이션을 추가하는 것은 MapStruct가 생성하는 매퍼 구현체를 Spring의 빈(Bean)으로 등록하라는 지시다. 이렇게 함으로써, Spring의 의존성 주입(Dependency Injection) 기능을 사용하여 매퍼 인스턴스를 관리할 수 있게 된다.
즉, MapStruct 매퍼를 Spring 프레임워크가 관리하는 컴포넌트로 만들고 싶을 때 componentModel = "spring" 옵션을 사용하면 된다.
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "name", target = "userName")
@Mapping(source = "email", target = "userEmail")
User entityFromDto(UserDto userDto);
}
여기서 두 방법의 차이를 더 자세하게 분석해 보자.
수동 관리(@Mapper 어노테이션만 사용)
@Mapper 어노테이션만 사용하고, componentModel을 지정하지 않은 경우, 매퍼 인터페이스는 단순한 Java 클래스로 처리된다. 이 경우 MapStruct는 매퍼 인터페이스에 대한 구현체를 생성하지만, 이 구현체는 Spring ApplicationContext에 등록되지 않는다.
즉, 매퍼의 인스턴스를 사용하기 위해서는 개발자가 직접 Mappers.getMapper(Class) 메소드를 호출하여 인스턴스를 생성해야
한다. 이 과정은 Spring의 의존성 주입 기능을 사용하지 않기 때문에 "수동 관리"라고 한다. 즉, 매퍼의 생성과 사용, 생명 주기를 개발자가 직접 관리해야 한다.
(매퍼 사용 예시 코드)
UserMapper mapper = Mappers.getMapper(UserMapper.class);
User user = mapper.entityFromDto(userDto);
자동 관리(@Mapper(componentModel = "spring") 사용)
@Mapper(componentModel = "spring") 어노테이션을 사용하면, MapStruct가 생성하는 매퍼 구현체가 Spring의 빈(Bean)으로 자동 등록된다. 이렇게 되면 Spring 프레임워크가 매퍼 인스턴스의 생성, 의존성 주입, 생명 주기 관리 등을 자동으로 처리한다.
즉, 매퍼 인스턴스를 필요로 하는 클래스에서 @Autowired 어노테이션을 사용하여 매퍼 인스턴스를 자동으로 주입받을 수 있다. 이 과
정에서 개발자는 매퍼 인스턴스의 생성 방법이나 시점을 직접 관리할 필요가 없으며, Spring이 이를 대신 관리해 주기 때문에 "자동 관리"라고 한다.
@Service
public class UserService {
private final UserMapper userMapper;
@Autowired
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
// 사용 예
public void someMethod() {
User user = userMapper.entityFromDto(userDto);
}
}
이러한 세부 사항은 MapStruct를 사용하는 프로젝트의 구체적인 요구 사항과 객체의 특성에 따라 달라질 수 있다.
MapStruct 사용할 때 고려해야 할 부분
MapStruct를 사용할 때 필수적으로 필요한 것은 다음과 같다:
- 소스 객체(source): 매핑할 소스 객체의 필드 값을 읽기 위해 게터(Getter) 메서드가 필수적으로 필요하다.
- 대상 객체(target): 매핑할 대상 객체의 필드에 값을 설정하기 위해 세터(Setter) 메서드가 필수적으로 필요하다.
이는 MapStruct가 Java Bean 규약을 따르기 때문이다. Java Bean 규약에 따르면, 속성에 접근하기 위해서는 해당 속성에 대한 getter와 setter 메서드를 제공해야 한다.
생성자와 관련하여
- 기본 생성자: MapStruct는 객체를 생성할 때 기본적으로 기본 생성자를 사용한다. 그 후, 세터 메서드를 통해 필드 값을 설정한다.
- 매개변수가 있는 생성자: 매개변수가 있는 생성자를 사용하는 경우가 있을 수 있으나, MapStruct는 기본적으로 세터를 통한 필드 설정을 선호한다. 매개변수가 있는 생성자를 사용하는 경우는 주로 불변 객체를 다룰 때다. 이런 경우, MapStruct는 매핑된 필드 값으로 객체를 생성하기 위해 해당 생성자를 사용할 수 있다. 하지만 이를 위해서는 추가적인 설정이 필요할 수 있으며, MapStruct의 기본 동작 방식은 아니다.
예외적인 상황
세터가 없는 불변 객체나 레코드(Java 14 이상)의 경우, 모든 필드 값을 초기화하는 매개변수가 있는 생성자를 통해 객체를 생성하고 초기화한다. 이 경우, 세터 메서드는 필요하지 않다.
반면, 게터 메서드는 소스 객체에서 값을 읽어오기 위해 여전히 필요하다.
요약
- 소스 객체에서는 게터 메서드가 필수적으로 필요하다.
- 대상 객체에서는 주로 세터 메서드가 필수적이다. 하지만, 불변 객체를 처리하는 경우나 매개변수가 있는 생성자를 통해 객체를 초기화하는 특별한 설정을 사용하는 경우에는 세터 메서드가 필요하지 않을 수 있다.
@Mapping 어노테이션
MapStruct는 필드 이름이 서로 다른 경우 @Mapping 어노테이션을 사용하여 매핑을 명시적으로 선언할 수 있다. 위의 예시에서 UserDto의 name과 email 필드는 User의 userName과 userEmail 필드에 각각 매핑된다.
만약 UserDto와 User 객체의 필드 이름이 완전히 동일하다면, @Mapping 어노테이션 없이도 MapStruct가 자동으로 필드를 매핑한다. 이는 필드 이름이 일치할 경우 MapStruct가 자동으로 필드 간의 매핑을 추론할 수 있기 때문이다. 따라서, 복잡한 매핑 로직이 필요하지 않은 간단한 경우에는 @Mapping 어노테이션을 생략하고, 단순히 추상 메서드만을 선언하여 사용할 수 있다. 이렇게 함으로써
코드의 간결성을 유지할 수 있다.
지금까지는 MapStruct의 기본적인 사용법을 알아봤다. 이제 다음 글에서 @AfterMapping, @Named 같은 어노테이션과 추가 활용법도 알아보자
'Spring > Spring Boot' 카테고리의 다른 글
Redis에서 @RedisHash, @Id, @Indexed의 쓰임새 (0) | 2024.06.10 |
---|---|
객체 간 매핑을 도와주는 MapStruct 라이브러리(2) - 추가 어노테이션 (0) | 2024.04.09 |
Lombok의 @Builder 어노테이션으로 객체 생성 (0) | 2024.04.02 |
RequestDto에서 MultipartFile 필드 사용 방법: 객체 바인딩 방법부터 테스트 코드까지 (3) | 2024.01.13 |
[Spring Boot] 스프링 부트 3에 레디스 적용하기 (1) | 2024.01.09 |