일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- builder
- nestjs
- 일급 객체
- lombok
- factory
- Dependency Injection
- java
- middleware
- 일급 컬렉션
- Volatile
- spring security
- synchronized
- Spring
- OAuth 2.0
- Google OAuth
- Today
- Total
HJW's IT Blog
DTO ↔ 엔티티 변환, MapStruct로 자동화하기 본문
0. MapStruct 란 무엇인가?
0.0 DTO <-> 도메인 엔티티 변환
Java 코드를 작성하다 보면 DTO와 도메인 엔티티 간의 변환 작업이 빈번하게 발생한다. 특히, 계층적 구조를 가진 애플리케이션에서는 Controller - Service - Repository를 거치며 DTO와 엔티티를 서로 변환하는 일이 반복된다. 이를 수작업으로 작성할 경우,
- 필드가 추가되거나 변경될 때마다 수동으로 코드를 수정해야 한다.
- 사람이 작성하는 코드이므로 일부 필드를 누락할 가능성이 있다.
필드의 수가 적다면 직접 변환 로직을 작성해도 문제가 없을 것이다. 그러나 필드가 수십 개 이상이거나 변환 로직이 복잡해질 경우, 실수로 인해 오류가 발생할 확률이 급격히 증가한다.
이때 MapStruct는 이러한 변환을 간결하게 처리하고 자동화하여 중복 코드를 제거하며 가독성과 유지보수성을 높이고, 오류 발생 가능성을 현저히 줄일 수 있다.
0.1 왜 MapStruct인가?
Java에는 DTO <-> 엔티티 변환을 지원하는 라이브러리가 여러 개 존재한다. 대표적으로 ModelMapper, BULL, JMapper 등이 있다. 그럼에도 MapStruct를 선택하는 이유는 다음과 같다.
0.1.1. 성능이 뛰어나다
스프링 객체 매핑의 최적 솔루션 : MapStruct vs ModelMapper 비교 분석
기술 선택 이전 사전조사
velog.io
이 벤치마크 에 따르면, ModelMapper보다 MapStruct는 20~40배 성능이 뛰어나다.
0.1.2. 컴파일 시점 타입 안정성 검사
대부분의 변환 라이브러리는 런타임에 리플렉션을 사용하기 때문에 오류가 실행 시점에서야 발견된다. 반면, MapStruct는 컴파일 시점에 타입 안정성을 검사하여 오류를 사전에 방지한다.
0.1.3. 프레임워크 및 라이브러리와의 높은 호환성
특히 MapStruct는 Lombok과 함께 사용할 경우 코드 양을 대폭 줄일 수 있다.
0.1.4. 엄격한 Null 값 처리
MapStruct는 NullValuePropertyMappingStrategy, NullValueCheckStrategy 등의 다양한 null 처리 전략을 제공하여 안정적인 변환이 가능하다.
1. 기본 사용법
1.1 수동 변환 vs MapStruct
우선, 수동으로 DTO ↔ 엔티티 변환을 처리하는 경우를 살펴보자.
public class UserDTO {
private String name;
private String email;
// getters, setters
}
public class User {
private String name;
private String email;
private Instant createAt;
// getters, setters
}
변환 로직을 수동으로 작성하면 다음과 같다.
public class UserMapper {
public static User toEntity(UserDto dto) {
return new User(dto.getName(), dto.getEmail());
}
public static UserDto toDto(User user) {
return new UserDto(user.getName(), user.getEmail());
}
}
위 코드는 간단하지만, MapStruct를 사용하면 더욱 간결하게 작성할 수 있다.
@Mapper(componentModel = "spring")
public interface UserMapper {
User toEntity(UserDto dto);
UserDto toDto(User user);
}
1.2 필드명이 다른 경우
필드명이 다를 경우 @Mapping을 활용해 명시적으로 지정할 수 있다.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "name", target = "username")
User toEntity(UserDto dto);
@Mapping(source = "username", target = "name")
UserDto toDto(User user);
}
1.3 null 처리
DTO에서 넘어온 값이 null이라면 어떻게 될까? 기본적으로 MapStruct는 null을 그대로 전달하지만, 이를 제어할 수도 있다.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "name", target = "username", defaultValue = "Guest")
UserDto toDto(User user);
}
1.4 @AfterMapping, @BeforeMapping
매핑 전후의 처리를 담당할 수도 있다.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "name", target = "username")
UserDto toDto(User user);
@AfterMapping
default void setExtraField(@MappingTarget UserDto dto) {
dto.setExtraInfo("Processed");
}
}
1.5 컬렉션 처리
MapStruct는 컬렉션 타입도 자동 변환할 수 있다.
@Mapper(componentModel = "spring")
public interface UserMapper {
List<UserDto> toDtoList(List<User> users);
List<User> toEntityList(List<UserDto> dtos);
}
1.6 중첩 객체 처리
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "user.name", target = "name")
@Mapping(source = "user.email", target = "email")
@Mapping(source = "address.address", target = "address")
UserResponseDTO toDto(User user, Address address);
}
1.7 조건부 무시
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "id", ignore = true)
User toEntity(UserDto dto);
}
1.8 조건부 매핑
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "name", conditionQualifiedByName = "isNotEmpty")
User toEntity(UserDTO dto);
@Named("isNotEmpty")
default boolean isNotEmpty(String value) {
return value != null && !value.trim().isEmpty();
}
}
MapStruct를 활용하면 DTO ↔ 엔티티 변환을 간결하고 안전하게 처리할 수 있다. 더 이상 수작업으로 변환 로직을 작성하며 오류를 걱정할 필요가 없다.
1.9 @MappingTarget 을 이용한 기존 객체 업데이트
일반적인 매핑은 새로운 객체를 생성하지만, 기존 객체를 업데이트해야 하는 경우 @MappingTarget을 사용할 수 있다.
특히, 부분 업데이트(PATCH) 요청을 처리할 때 기존 객체를 유지하면서 특정 필드만 변경하는 것이 중요하다.
이 작업을 수작업으로 한다면,
public User updateUser(Long id, UpdateUserDTO updateDto) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
if (updateDto.getName() != null) {
user.setName(updateDto.getName());
}
if (updateDto.getEmail() != null) {
user.setEmail(updateDto.getEmail());
}
if (updateDto.getPassword() != null) {
user.setPassword(updateDto.getPassword());
}
return userRepository.save(user);
}
와 같은 모습이 될 것이다. 필드가 추가될 때 마다 조건문이 추가되어야 하기 때문에 유지보수성이 떨어진다.
이제 MapStruct 를 사용한 예시를 살펴보자.
@Mapper(componentModel = "spring", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface UserMapper {
void updateUserFromDto(UpdateUserDTO dto, @MappingTarget User user);
}
@Override
public User updateUser(Long id, UpdateUserDTO updateDto) {
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
userMapper.updateUserFromDto(updateDto, user); // ✅ 자동 매핑 수행
return userRepository.save(user);
}
nullValuePropertyMappingStrategy 는 이후 자세히 설명하겠지만, 간단하게 mapping 하려는 필드값이 null 이면 기존 값을 유지한다는 설정이다.
2. Entity와 DTO 변환: expression, users, qualifiedByName 활용
2.1 복잡한 변환 로직을 간결하게 처리하는 방법
Entity와 DTO 간 변환을 수행할 때, 단순한 필드 매핑을 넘어 복잡한 변환 로직이 필요한 경우가 있다. 예를 들어, 여러 개의 필드를 조합하거나, 특정 형식을 적용해야 하는 상황이 있을 수 있다. 이러한 변환을 효율적으로 수행하는 방법을 살펴본다.
예제: 변환이 필요한 필드 매핑
아래와 같은 User, Address, UserResponseDTO 클래스가 있다고 가정한다.
public class User {
private String firstName;
private String lastName;
private LocalDate birthDate;
private List<String> roles;
// Getters, Setters
}
public class Address {
private String city;
private String street;
private String zipCode;
// Getters, Setters
}
public class UserResponseDTO {
private String fullName;
private int age;
private String fullAddress;
private String roleString;
// Getters, Setters
}
다음과 같은 변환이 필요하다.
- firstName + lastName → fullName
- birthDate → age (현재 날짜 기준으로 계산)
- city + street + zipcode → fullAddress
- List<String> roles → String roleString (쉼표로 구분된 문자열)
이러한 변환은 비즈니스 로직으로 분리할 수도 있지만, 본질적으로 변환 로직에 가까우므로 매핑 과정에서 처리하는 것이 적절하다.
2.2 expression을 활용한 직접 변환
expression을 사용하면 직접 Java 코드를 활용하여 변환 로직을 적용할 수 있다.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "fullName", expression = "java(user.getFirstName() + \" \" + user.getLastName())")
@Mapping(target = "age", expression = "java(java.time.Period.between(user.getBirthDate(), java.time.LocalDate.now()).getYears())")
@Mapping(target = "fullAddress", expression = "java(address.getStreet() + ", " + address.getCity() + " (" + address.getZipCode() + ")")")
@Mapping(target = "roleString", expression = "java(String.join(", ", user.getRoles()))")
UserResponseDTO toDto(User user, Address address);
}
expression을 활용할 경우
- 간단한 변환 로직을 한 줄의 Java 코드로 작성 가능
- @Mapping 애너테이션 내에서 바로 변환 적용 가능
하지만, 변환 로직이 복잡해지면 expression이 길어지고 유지보수가 어려워질 수 있다. 이를 개선하기 위해 Helper 클래스를 활용할 수 있다.
2.3 qualifiedByName을 활용한 Helper 클래스 적용
변환 로직을 Helper 클래스로 분리하면 코드의 가독성과 재사용성을 높일 수 있다.
Helper 클래스 정의
public class UserMapperHelper {
@Named("combineName")
public static String combineName(User user) {
return user.getFirstName() + " " + user.getLastName();
}
@Named("formatAddress")
public static String formatAddress(Address address) {
return address.getStreet() + ", " + address.getCity() + " (" + address.getZipCode() + ")";
}
}
이제 UserMapper에서 Helper 클래스를 활용한다.
@Mapper(componentModel = "spring", uses = {UserMapperHelper.class})
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
@Mapping(target = "fullName", qualifiedByName = "combineName")
@Mapping(target = "age", expression = "java(java.time.Period.between(user.getBirthDate(), java.time.LocalDate.now()).getYears())")
@Mapping(target = "fullAddress", qualifiedByName = "formatAddress")
@Mapping(target = "roleString", expression = "java(String.join(", ", user.getRoles()))")
UserResponseDTO toDto(User user, Address address);
}
qualifiedByName을 활용할 경우
- 변환 로직을 Helper 클래스에서 정의하여 재사용 가능
- @Named 애너테이션을 활용해 명확하게 매핑 가능
3. 공통 필드에 대한 어노테이션 매핑
여러 entity들이 id, createdAt, name 같은 공통 필드를 가진다면, 어노테이션을 활용하여 변환 로직을 중앙에서 정의할 수 있다.
@Retention(RetentionPolicy.CLASS)
@Mapping(target = "id", ignore = true)
@Mapping(target = "creationDate", expression = "java(new java.util.Date())")
@Mapping(target = "name", source = "groupName")
public @interface ToEntity { }
이제 여러 엔티티 변환 시 중복되는 매핑을 줄일 수 있다.
@Mapper
public interface StorageMapper {
StorageMapper INSTANCE = Mappers.getMapper(StorageMapper.class);
@ToEntity
@Mapping(target = "weightLimit", source = "maxWeight")
ShelveEntity map(ShelveDto source);
@ToEntity
@Mapping(target = "label", source = "designation")
BoxEntity map(BoxDto source);
}
이렇게 하면 id, creationDate, name 필드에 대한 변환 로직은 공통으로 적용되며, 개별적인 매핑이 필요한 부분만 추가로 정의하면 된다.
4. Builder Pattern 활용하기
MapStruct 는 Builder 패턴을 지원하여 불변 객체를 매핑할 수 있도록 한다. 즉, 이를 잘 활용한다면 setter 없이도 객체를 변환할 수 있다.
public class Person {
private final String name;
private final int age;
protected Person(Person.Builder builder) {
this.name = builder.name;
this.age = builder.age;
}
public static Person.Builder builder() {
return new Person.Builder();
}
public static class Builder {
private String name;
private int age;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Person build() { // MapStruct가 자동 감지
return new Person(this);
}
}
}
- Person 클래스는 setter 없이 Builder 를 통해 인스턴스를 생성한다
- Person.Builder 내부에 name() 및 age() 메서드를 제공한다
- build() 를 통해 최종 객체를 생성한다.
public class PersonDto {
private String name;
private int age;
// Getters & Setters
}
@Mapper(componentModel = "spring")
public interface PersonMapper {
Person toEntity(PersonDto dto);
}
별도의 설정 없이도 MapStruct 는 자동으로 Person.builder() 를 감지하여 매핑한다.
4.1 Lombok @Builder 활용하기
Lombok 의 @Builder 를 활용한다면, 코드를 더욱 간결하게 만들 수 있다.
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Person {
private final String name;
private final int age;
}
@Mapper(componentModel = "spring")
public interface PersonMapper {
Person toEntity(PersonDto dto);
}
Lombok을 사용하면 별도의 Builder 클래스를 정의할 필요 없이 @Builder 애너테이션을 통해 자동으로 Builder 패턴을 적용할 수 있다.
5. UnmappedTargetPolicy
UnmappedTargetPolicy 를 통해, 매핑되지 않은 필드가 있을 경우 어떻게 처리할지 설정할 수 있다.
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface PersonMapper {
@Mapping(source = "fullName", target = "name")
Person toEntity(PersonDto dto);
}
- 위 예시는, 매핑되지 않은 필드가 있다면 컴파일 에러가 발생한다
- Policy 3가지
- IGNORE : 매핑되지 않은 필드 무시 (기본값)
- WARN : 매핑되지 않은 필드 경고 로그
- ERROR : 매핑되지 않은 필드 컴파일 오류
5.1 NullValuePropertyMappingStrategy
NullValuePropertyMappingStrategy 는 매핑할 때, null 값에 대한 처리를 맡는다
@Mapper(componentModel = "spring", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface PersonMapper {
Person toEntity(PersonDto dto);
}
- IGNORE : null 값이 들어온다면 기존 값을 유지
- SET_TO_NULL : null 로 교체
- SET_TO_DEFAULT : 각 primitive type 의 기본값을 적용한다 (int -> 0, boolean -> false...)
5.2 NullValueMappingStrategy
NullValueMappingStrategy 는 매핑할 전체 객체가 null 일 때의 행동을 정의하는 것이다.
@Mapper(componentModel = "spring", nullValueMappingStrategy = NullValueMappingStrategy.RETURN_DEFAULT)
public interface PersonMapper {
Person toEntity(PersonDto dto);
}
- RETURN_NULL : null 이 들어오면 null 반환
- RETURN_DEFAULT : null 이 들어오면 새로운 객체 생성
6. Message Entity 변환: MapStruct 활용하기
6.1 Controller에서 DTO를 통해 Message 생성
예를 들어, CreateMessageDto를 컨트롤러에서 받아온다고 가정한다.
@Getter
@Setter
public class CreateMessageDto {
private String userId;
private String content;
private List<MultipartFile> multipart;
public CreateMessageDto() {}
}
Message 엔티티는 다음과 같이 정의되어 있다.
public class Message {
private String UUID;
private String userId;
private String channelId;
private String content;
private Boolean isEdited;
private Instant createdAt;
private Instant updatedAt;
private List<BinaryContent> binaryContents;
// Getters, Setters, Builder
}
6.2 MapStruct 없이 변환할 경우
public Message toEntity(CreateMessageDto dto) {
Message message = Message.builder()
.UUID(UUID.randomUUID().toString()) // UUID 수동 생성
.userId(dto.getUserId())
.content(dto.getContent())
.channelId(UUID.randomUUID().toString()) // 채널 ID 수동 생성
.isEdited(false)
.createdAt(Instant.now())
.updatedAt(Instant.now())
.build();
if (dto.getMultipart() != null && !dto.getMultipart().isEmpty()) {
List<BinaryContent> binaryContents =
binaryContentMapper.fromMessageFiles(dto.getMultipart(), dto.getUserId(), message.getUUID(), message.getChannelId());
message = message.toBuilder().binaryContents(binaryContents).build();
}
return message;
}
6.2.1 문제점
✅ 불필요한 코드 반복 → UUID, 채널 ID, 시간 설정 등을 매번 직접 작성해야 함.
✅ 가독성이 낮음 → 핵심 로직(파일 변환)과 기본값 설정이 뒤섞여 있음.
✅ 확장성이 부족 → 새로운 필드 추가 시 코드 변경이 많아짐.
6.3 MapStruct 활용하여 변환 간소화
@Mapper(componentModel = "spring", uses = BinaryContentMapper.class)
public interface MessageMapper {
@Mapping(target = "UUID", ignore = true)
@Mapping(target = "isEdited", constant = "false")
@Mapping(target = "binaryContents", ignore = true)
Message toEntity(CreateMessageDto dto);
@AfterMapping
default void mapFiles(
@MappingTarget Message.MessageBuilder messageBuilder,
CreateMessageDto dto,
@Context BinaryContentMapper binaryContentMapper
) {
if (dto.getMultipart() != null && !dto.getMultipart().isEmpty()) {
messageBuilder.binaryContents(
binaryContentMapper.fromMessageFiles(dto.getMultipart(), dto.getUserId(), messageBuilder.getUUID(), messageBuilder.getChannelId())
);
}
}
}
6.4 MapStruct 적용 후 개선점
1. 코드가 간결해진다
- 수동으로 UUID, 채널 ID, 기본값을 설정하는 코드가 사라짐.
- 핵심 로직(파일 변환)만 남아서 가독성이 좋아짐.
2. 유지보수가 쉬워진다
- 새로운 필드가 추가되더라도 @Mapping 애너테이션만 수정하면 됨.
- @AfterMapping을 활용하여 파일 변환 로직을 따로 분리할 수 있음.
3. 성능 향상 및 일관성 유지
- MapStruct는 컴파일 타임에 코드 생성 → 런타임 성능 저하 없음.
- DTO → Entity 변환 로직이 일관되게 유지됨 → 실수 방지.
결과적으로, MapStruct를 활용하면 코드의 가독성을 높이고 유지보수성을 개선하며, 변환 로직의 일관성을 유지할 수 있다.
이후 추가된 내용
7. 상속 처리
7.1 기본적인 상속 Mapping
@Mapper
public interface BaseMapper {
@Mapping(target = "id", ignore = true) // ID는 보통 변경되지 않으므로 무시
BaseDto toDto(BaseEntity entity);
}
@Mapper(uses = BaseMapper.class)
public interface SubMapper {
@InheritConfiguration(name = "toDto") // 상위 매핑을 재사용
SubDto toDto(SubEntity entity);
}
여러 엔티티들 사이에 공통적으로 처리되어야 하는 필드가 있다면 위와 같이 부모 Mapper 를 상속받는 자식 Mapper 를 생성할 수도 있다.
8. 연쇄적 엔티티-DTO 변환
MapStruct 는 잘 정의된 인터페이스에 따라 한 엔티티 내부의 엔티티를 DTO 내부의 DTO 로 변환할 수도 있다.
public class User extends BaseUpdatableEntity {
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private BinaryContent profile;
@OneToOne(mappedBy = "user", cascade = CascadeType.PERSIST, orphanRemoval = true, fetch = FetchType.LAZY) // nullable 고려
private UserStatus status;
}
public class BinaryContent extends BaseEntity {
@Column(name = "file_name", nullable = false)
private String fileName;
@Column(nullable = false)
private Long size;
@Column(name = "content_type", nullable = false)
private String contentType;
}
이렇게 두개의 객체가 있을때, 다음과 같이 interface 들이 정의되어 있다면,
@Mapper
public interface BinaryContentMapper {
@Mapping(target = "id", source = "id")
@Mapping(target = "size", source = "size")
@Mapping(target = "contentType", source = "contentType")
BinaryContentDto toDto(BinaryContent content);
}
@Mapper(uses = BinaryContentMapper.class, imports = {UUID.class, PasswordEncryptor.class})
public interface UserMapper {
@Mapping(target = "id", source = "id")
@Mapping(target = "username", source = "username")
@Mapping(target = "email", source = "email")
@Mapping(target = "profile", source = "profile")
@Mapping(target = "online", source = "status", qualifiedByName = "userStatusSetter")
UserResponseDto toDto(User user);
}
public record BinaryContentDto(
UUID id,
String fileName,
Long size,
String contentType
) {
}
public record UserResponseDto(
UUID id,
String username,
String email,
BinaryContentDto profile,
boolean online
) {
}
이와 같이 연쇄적인 mapping 이 가능하다
'JAVA' 카테고리의 다른 글
JPA - Entity들 사이의 연관관계 (0) | 2025.02.25 |
---|---|
JPA - EntityManager와 영속성 컨텍스트 이해하기 (0) | 2025.02.25 |
Spring 없이 의존성 관리와 팩토리 패턴 구현하기 (0) | 2025.01.24 |
JVM 은 어떻게 동작하는가 (1) | 2025.01.24 |
JAVA Volatile 키워드와 멀티쓰레드 (0) | 2025.01.13 |