이전 포스팅에서 ModelMapper와 MapStruct에 대해 비교하며 MapStruct가 어떠한 지점에서 유리한지를 살펴봤다. 이번에는 실제 프로젝트에 MapStruct를 적용한 코드들을 살펴보기로 한다.
빌드툴로 Maven을 사용하였기에 xml 방식으로 의존성을 설정하였다.
<properties>
<java.version>17</java.version>
<spring-cloud.version>2023.0.1</spring-cloud.version>
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
</properties>
...
<dependencies>
...
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
...
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</dependency>
</dependencies>
여기서 주의할 점은 MapStruct가 Lombok보다 dependency 선언이 뒤에 되어야 한다는 것이다. MapStruct는 Lombok의 @Getter, @Setter, @Builder를 이용하므로 Lombok보다 먼저 선언되는 경우 정상적으로 실행할 수 없다.
참고로 MapStruct 의존성 설정에 있어서 ChatGPT에 물어보면 다음 코드도 추가하라고 나오는데,
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version> <!-- 원하는 Maven Compiler Plugin 버전으로 설정 -->
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version> <!-- Lombok의 최신 버전으로 설정 -->
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
문제는 QueryDSL을 사용할 때 위 설정을 추가하면 Q클래스 생성이 되지 않는다. 확인해보니 위 설정은 deprecated 된 것으로, ChatGPT를 너무 믿어서는 안된다. 그러나 내가 작업하던 중에는 위 설정이 있든 없든 라이브러리가 제대로 추가되지 않아 @Mapper 어노테이션을 사용할 수 없어 너무 많은 시간을 버리게 되었는데, IDE가 라이브러리를 제대로 불러오지 못하는 경우도 있다는 것을 알았다. 너무 어이없지만 아픈 경험이었으므로 잊지 않고 메모해둔다...
앞선 포스팅을 통해서도 코드를 보였지만, 여기서 다시 한번 정리해본다.
@Mapper(componentModel = "spring", imports = UUID.class)
public interface BookMapper {
BookMapper INSTANCE = Mappers.getMapper(BookMapper.class);
Book toBook(BookDto bookDto);
BookDto toBookDto(Book book);
@Mapping(target = "itemUuid", source = "itemUuid", defaultExpression = "java(UUID.randomUUID().toString())")
@Mapping(target = "category", source = "category", defaultExpression = "java(Category.BOOK)")
BookDto toBookDto(BookCreateRequest request, String itemUuid, Category category);
ItemSimpleResponse toItemSimpleResponse(BookDto bookDto);
}
여기서 잊지 말아야 할 건, @Mapper 어노테이션의 옵션 설정이다. componentModel = "spring"을 설정하지 않으면 Spring에서 위의 코드를 컴포넌트로서 인식하지 못한다.
그리고 매핑 메서드는 @Mapping 어노테이션의 target과 source 옵션을 통해 파라미터를 전달받아 대상 객체로 자동으로 매핑해주게 되는데, 이 MapStruct의 장점은 이렇게 메서드 선언과 어노테이션 설정만 지정해주면 컴파일 타임에 이 인터페이스의 구현체를 자동으로 만들어주므로 구현에는 신경쓰지 않아도 된다는 점이다. 이대로 끝.
그럼 서비스 계층에서 이 인터페이스를 의존성 주입받아 객체로서 바로 사용할 수 있다.
@Service
@RequiredArgsConstructor
public class ItemServiceImpl implements ItemService {
private final ItemRepository itemRepository;
private final BookMapper bookMapper;
private final MessageUtil messageUtil;
// ...
@Override
@Transactional
public void saveBook(BookCreateRequest request) {
BookDto bookDto = bookMapper.toBookDto(request, UUID.randomUUID().toString(), Category.BOOK);
Item item = bookMapper.toBook(bookDto);
try {
itemRepository.save(item);
} catch (Exception e) {
throw new RuntimeException(messageUtil.getItemCreateErrorMessage());
}
}
// ...
}
MapStruct는 보다 다양한 기능을 지원하므로 몇몇 예시들을 더 덧붙여서 소개하자면,
@Mapper(imports = UUID.class)
public interface MessageMapper {
MessageMapper INSTANCE = Mappers.getMapper(MessageMapper.class);
@Mapping(source = "messageId", target = "messageId", defaultExpression = "java(UUID.randomUUID().toString())")
@Mapping(source = "requestDto.type", target = "type", defaultValue = "SMS")
@Mapping(source = "requestDto.sender", target="sender", ignore=true)
MessageListServiceDto toMessageListServiceDto(String messageId, Integer count, RequestDto requestDto);
}
defaultExpression 옵션 뿐만 아니라 defaultValue로 기본값 지정과 ignore을 통해 특정 필드를 무시할 수도 있다.
나아가 보다 복잡하거나 까다로운 설정이 필요하다면 구현체에서 직접 매핑 메서드를 커스텀할 수도 있다. 예를 들어
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-05-30T16:22:17+0900",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 17.0.10 (Oracle Corporation)"
)
@Component
public class BookMapperImpl implements BookMapper {
@Override
public Book toBook(BookDto bookDto) {
if ( bookDto == null ) {
return null;
}
Book.BookBuilder book = Book.builder();
book.itemUuid( bookDto.getItemUuid() );
book.itemName( bookDto.getItemName() );
book.price( bookDto.getPrice() );
book.stockQuantity( bookDto.getStockQuantity() );
book.itemImageUrl( bookDto.getItemImageUrl() );
book.category( bookDto.getCategory() );
book.author( bookDto.getAuthor() );
book.isbn( bookDto.getIsbn() );
book.pages( bookDto.getPages() );
book.publicationDate( bookDto.getPublicationDate() );
book.contentsTable( bookDto.getContentsTable() );
book.bookReview( bookDto.getBookReview() );
return book.build();
}
// ...
}
위의 코드는 자동으로 생성된 코드인데, category 필드에 기본값 설정이 필요하다면 아래와 같이 default를 붙여 메소드를 만들어주고 구현 메서드 대신 default로 정의한 메서드를 사용할 수 있다.
@Override
public default Book toBook(BookDto bookDto) {
// ...
String categoryType = Optional.ofNullable(bookDto.getType()).orElse("book").toUpperCase();
Category category = Category.BOOK;
if (categoryType.equals("BOOK")) {
category = Category.BOOK;
} else if (categoryType.equals("ALBUM")){
category = Category.ALBUM;
}
return book.builder()
.itemUuid( bookDto.getItemUuid() )
.itemName( bookDto.getItemName() )
// ...
.category( category )
// ...
.bookReview( bookDto.getBookReview() )
.build();
}
이 외에도 더 다양한 설정들이 있으나, MapStruct 적용기는 여기까지 정리하기로 한다.