MapStruct란 Java 애플리케이션에서 객체 간 매핑을 편리하게 해주는 코드 생성 라이브러리로 DTO(Data Transfer Object)와 엔티티 간 변환 작업을 간편하게 하기 위해 사용한다.
1. MapStruct는 불필요한 리플렉션을 사용하지 않고 컴파일 타임에 매핑 코드를 생성하여 성능이 뛰어나다.
2. 매핑을 위한 반복적인 코드를 줄이고, 간결하게 작성이 가능하다.
이 때 Annotation processor를 이용해 자동화 된 매핑을 제공한다. (컴파일 타임에 Impl 클래스를 생성)
Lombok을 사용하는 경우 Lombok의 getter/setter/builder를 이용하여 매핑을 하기 때문에 이 때는 Lombok 라이브러리 의존성이 먼저 추가되어 있어야 한다.
Annotation processor
Java 컴파일 타임에 동작하며 애노테이션을 분석하여 관련 코드를 생성하거나 검증하는 것을 의미
MapStruct 적용
1. 의존성 추가
implementation 'org.mapstruct:mapstruct:1.6.3'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
2. 기존에 작성하였던 Mapper → MapStruct로 변경
변경 전
public abstract class PostMapper {
// 1
public static PostResponse.PostResponseDto toPostResponseDto(Post post) {
return PostResponse.PostResponseDto
.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.createdAt(post.getCreatedAt())
.createdBy(post.getCreatedBy())
.build();
}
// 2
public static Post toPost(PostServiceDTO.PostSaveServiceDTO postSaveDto, TeamMember teamMember) {
return Post.builder()
.title(postSaveDto.getTitle())
.team(teamMember.getTeam())
.content(postSaveDto.getContent())
.member(teamMember.getMember())
.build();
}
// 3
public static PostResponse.PostListResponseDto toPostListResponseDto(Page<Post> posts, Long memberId) {
Page<PostResponse.PostResponseDto> pages = posts.map(post -> toPostResponseDtoWithIsMine(
post,
memberId.equals(post.getMember().getId())
));
PagedModel<PostResponse.PostResponseDto> pagedModel = new PagedModel<>(pages);
return PostResponse.PostListResponseDto.builder()
.posts(pagedModel)
.build();
}
// 4
private static PostResponse.PostResponseDto toPostResponseDtoWithIsMine(Post post, Boolean isMine) {
return PostResponse.PostResponseDto
.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.createdAt(post.getCreatedAt())
.createdBy(post.getCreatedBy())
.isMine(isMine)
.build();
}
}
변경 후
@Mapper(componentModel = "spring") // MapStruct가 인터페이스에 대한 구현체를 컴파일 타임에 자동 생성 (compontModel = "spring") 해당 매퍼가 스프링 빈으로 등록
public interface PostMapper { // 인터페이스로 변경
@Mapping(target = "isMine", ignore = true)
PostResponse.PostResponseDto toPostResponseDto(Post post);
@Mapping(source = "teamMember.team", target = "team")
@Mapping(source = "teamMember.member", target = "member")
@Mapping(target = "id", ignore = true)
Post toPost(PostServiceDTO.PostSaveServiceDTO postSaveDto, TeamMember teamMember);
default PostResponse.PostListResponseDto toPostListResponseDto(Page<Post> posts, Long memberId) {
Page<PostResponse.PostResponseDto> pages = posts.map(post -> toPostResponseDtoWithIsMine(
post,
memberId.equals(post.getMember().getId())
));
PagedModel<PostResponse.PostResponseDto> pagedModel = new PagedModel<>(pages);
return PostResponse.PostListResponseDto.builder()
.posts(pagedModel)
.build();
}
PostResponse.PostResponseDto toPostResponseDtoWithIsMine(Post post, Boolean isMine);
}
(1) Post를 입력으로 받아 PostResponseDTO를 생성 (매핑 시 특정 필드를 제외하는 경우)
- @Mapping(target = "isMine", ignore = true) 매개변수로 들어온 Post에서 isMine 필드를 제외하고 DTO를 만들어서 반환
- (source: 매개변수로 넘어오는 객체의 필드)
(target : 변환되는 객체의 필드 )
(2) PostSaveServiceDTO와 TeamMember를 입력으로 받아 Post 엔티티를 생성 (특정 필드를 지정하여 매핑하는 경우)
- teamMember.team → Post.team으로 매핑
- teamMember.member → Post.member로 매핑
- ID는 GenerationType.IDENTITY로 자동 생성되므로 무시
- 나머지 필드는 자동으로 매핑
(3) 매핑이 복잡한 경우 메서드에 default 메서드를 구현하면 직접 정의한 메서드를 사용할 수 있다. (사용자 정의 메서드 사용하는 경우)
(4) Post 객체에 없는 필드를 받아와서 DTO에 추가하는 경우 (매핑 시 여러 파라미터를 추가하는 경우)
- 매개변수가 MapStruct 메서드에 추가되면, 자동으로 매핑 인자로 사용할 수 있다.
이렇게 하고 컴파일을 하면 /build/generated/sources/annotationProcessor/... 하위에 PostMapperImpl 구현체가 생성된다.
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2025-02-26T19:34:15+0900",
comments = "version: 1.6.3, compiler: IncrementalProcessingEnvironment from gradle-language-java-8.11.1.jar, environment: Java 17.0.10 (Amazon.com Inc.)"
)
@Component
public class PostMapperImpl implements PostMapper {
@Override
public PostResponse.PostResponseDto toPostResponseDto(Post post) {
if ( post == null ) {
return null;
}
PostResponse.PostResponseDto.PostResponseDtoBuilder postResponseDto = PostResponse.PostResponseDto.builder();
postResponseDto.id( post.getId() );
postResponseDto.title( post.getTitle() );
postResponseDto.content( post.getContent() );
postResponseDto.createdAt( post.getCreatedAt() );
postResponseDto.createdBy( post.getCreatedBy() );
return postResponseDto.build();
}
@Override
public Post toPost(PostServiceDTO.PostSaveServiceDTO postSaveDto, TeamMember teamMember) {
if ( postSaveDto == null && teamMember == null ) {
return null;
}
Post.PostBuilder post = Post.builder();
if ( postSaveDto != null ) {
post.title( postSaveDto.getTitle() );
post.content( postSaveDto.getContent() );
}
if ( teamMember != null ) {
post.team( teamMember.getTeam() );
post.member( teamMember.getMember() );
}
return post.build();
}
@Override
public PostResponse.PostResponseDto toPostResponseDtoWithIsMine(Post post, Boolean isMine) {
if ( post == null && isMine == null ) {
return null;
}
PostResponse.PostResponseDto.PostResponseDtoBuilder postResponseDto = PostResponse.PostResponseDto.builder();
if ( post != null ) {
postResponseDto.id( post.getId() );
postResponseDto.title( post.getTitle() );
postResponseDto.content( post.getContent() );
postResponseDto.createdAt( post.getCreatedAt() );
postResponseDto.createdBy( post.getCreatedBy() );
}
postResponseDto.isMine( isMine );
return postResponseDto.build();
}
}
추가
1. 스프링을 사용하지 않는 경우라면 생성된 구현체를 아래와 같이 받아올 수 있다.
@Mapper // MapStruct가 인터페이스에 대한 구현체를 컴파일 타임에 자동 생성.
public interface PostMapper {
PostMapper INSTANCE = Mappers.getMapper(PostMapper.class);
...
}
2. 두 객체 간 매핑하려는 필드명이 다른 경우
@Mapping(source="매핑하려는 대상", target="매핑이 되는 대상")을 명확히 작성한다.
@Mapping(source = "post.title1", target = "title2")
PostResponse.PostResponseDto toPostResponseDto(Post post);
3. Default Value를 지정해야 하는 경우
만약 source의 필드 값이 null이 들어올 때 defaultValue을 채워넣고 싶다면 다음과 같이 할 수 있다.
defaultValue의 경우 String 뿐만 아니라 Integer, LocalDateTime, Long 등 지정 가능하다.
@Mapping(target = "name", source = "name", defaultValue = "default")
@Mapping(target = "id", ignore = true)
Team toTeam(TeamServiceDTO.CreateTeamServiceDTO request);
// TeamMapperImpl
Team.TeamBuilder team = Team.builder();
if ( request.getName() != null ) {
team.name( request.getName() );
}
else {
team.name( "default" );
}
그런데 해당 코드처럼 만약 enum 타입을 Default로 받아야 한다면?
public static Team toTeam(TeamServiceDTO.CreateTeamServiceDTO request) {
return Team.builder()
.name(request.getName())
.status(TeamActiveStatus.ACTIVE)
.build();
}
상수를 매핑하는 옵션 constant = enum값을 지정해주면 된다.
@Mapping(target = "id", ignore = true)
@Mapping(target = "status", constant = "ACTIVE")
Team toTeam(TeamServiceDTO.CreateTeamServiceDTO request);
// TeamMapperImpl
team.status( TeamActiveStatus.ACTIVE );
여기서 문자열을 입력했는데 MapStruct가 알아서 타입 변환을 해주는 이유는 암묵적으로 다양한 타입 변환을 지원하기 때문이다.
- int ↔ String
- String ↔ enum
- BigInteger ↔ String
- String ↔ Date
- ...
4. 컬렉션 매핑하는 경우
아래와 같이 컬렉션으로 반환하면서 컬렉션 내의 해당 타입으로 변환하는 메서드가 존재한다면 MapStruct는 자동으로 해당 메서드를 호출하여 요소를 변환, 컬렉션에 담아서 반환한다.
@Mapper(componentModel = "spring")
public interface NotificationMapper {
NotificationResponse.NotificationResponseDto toNotificationResponseDto(Notification notification);
List<NotificationResponse.NotificationListResponseDto> toNotificationListResponseDto(List<Notification> notifications);
}
// NotificationMapperImpl
@Override
public List<NotificationResponse.NotificationListResponseDto> toNotificationListResponseDto(List<Notification> notifications) {
if ( notifications == null ) {
return null;
}
List<NotificationResponse.NotificationListResponseDto> list = new ArrayList<NotificationResponse.NotificationListResponseDto>( notifications.size() );
for ( Notification notification : notifications ) {
list.add( notificationToNotificationListResponseDto( notification ) );
}
return list;
}
+ java: Can't generate mapping method from iterable type from java stdlib to non-iterable type.
자바의 컬렉션과 반복 불가능한 유형을 매핑할 때 발생하는 문제.
파라미터로 들어온 컬렉션을 DTO 내 컬렉션에 매핑하려고 할 때 아래 코드처럼 하면 매핑이 될 것이라고 생각하였는데, 문제가 발생하였다.
NotificationListResponseDto toNotificationResponseListDto(List<Notification> notifications);
해결 방법
1. DTO 내에 컬렉션을 제외하고 다른 필드가 존재한다면 정상적으로 매핑이 된다.
NotificationListResponseDto toNotificationResponseListDto(List<Notification> notifications, String test);
// NotificationMapperImpl
@Override
public NotificationResponse.NotificationListResponseDto toNotificationResponseListDto(List<Notification> notifications, String test) {
if ( notifications == null && test == null ) {
return null;
}
NotificationResponse.NotificationListResponseDto.NotificationListResponseDtoBuilder notificationListResponseDto = NotificationResponse.NotificationListResponseDto.builder();
notificationListResponseDto.notifications( notificationListToNotificationResponseDtoList( notifications ) );
notificationListResponseDto.test( test );
return notificationListResponseDto.build();
}
2. default 메서드 직접 정의
default NotificationListResponseDto toNotificationResponseListDto(List<Notification> notifications, String test) {
return NotificationListResponseDto.builder()
.notifications(notifications.stream().map(this::toNotificationResponseDto)
.collect(Collectors.toList()))
.build();
}
5. 복잡한 로직이나, 공통적인 로직을 재사용하고 싶은 경우
@Named을 사용하여 매핑 메서드의 이름을 지정하고 이를 qualifiedByName 메서드를 통해 해당 메서드 사용을 강제할 수 있다.
@Mapping(source = "name", target = "name", qualifiedByName = "concatFullName")
UserDto toUserDto(User user);
@Named("concatFullName")
default String concatFullName(String name) {
return name + "Last";
}
// UserMapperImpl
user.name( concatFullName( request.getName() ) );
참고
MapStruct 1.6.3 Reference Guide
If set to true, MapStruct in which MapStruct logs its major decisions. Note, at the moment of writing in Maven, also showWarnings needs to be added due to a problem in the maven-compiler-plugin configuration.
mapstruct.org
https://www.baeldung.com/java-mapstruct-iterable-to-object
'Spring' 카테고리의 다른 글
[Spring] WebSocket & STOMP 채팅 기능 구현하기 (2) (0) | 2025.02.25 |
---|---|
[Spring] WebSocket & STOMP 채팅 기능 구현하기 (1) (0) | 2025.02.25 |
[Spring] 스프링에서 직렬화, 역직렬화 (0) | 2025.01.25 |
[Spring] SSE 구현 (0) | 2025.01.15 |
[Spring] API 및 예외 응답 통일 (0) | 2025.01.11 |