Spring Boot: MapStruct: DTO를 Entity로, Entity를 DTO로(feat. MapStruct는 Record를 지원할까?)

Letsdev·2023년 5월 28일
6
post-thumbnail

DTO: Data Transfer Object

직역을 하면 '데이터 전송 객체'로, 데이터를 주고 받기 위해 사용하는 데이터입니다.
강타입 언어인 자바는 이 데이터 전송 객체를 타입으로 작성하거나(일반적), 그 외 여러 데이터를 표현할 수 있는 수단을 사용해야 합니다.

public record MemberCreateRequestDto(
        @NotBlank
        @Pattern(...)
        String username,
        
        @NotBlank
        @Pattern(...)
        @JsonProperty("password")
        String rawPassword,
        
        @NotBlank
        @Pattern(...)
        String nickname
) {}

DTO를 Entity로

일부 DTO는 JPA Entity나 도메인 모델로 변환할 필요가 있습니다.

여러 구간에 사용할 수 있는 DTO 중에서도 주로 Request/Response에 사용할 Body들의 데이터 타입을 DTO 클래스라고 명명합니다. 그 외 구간에 사용하려는 DTO들은 각 역할에 맞는 이름을 더 고민하는 편입니다.

그런데 요청/응답 양식에 맞춰 DTO 클래스를 만들고 사용하면, 결국 어느 단계에서는 DTO를 Entity로 변환하거나, Entity를 DTO로 변환해야 하는 경우가 많습니다.

방법 1: DTO에 .toEntity() 메서드

많이들 사용하는 방법 중 하나입니다.
틀린 방식도 아닙니다.

다만, 이 예시처럼 비밀번호 해싱에 대한 책임을 어디에 둘지, 그리고 DTO는 가급적 데이터를 담기만 하고 엔티티로 변환하는 기능 등을 담지 않는 것이 조금은 더 바람직하지 않은지(꼭 준수할 필요는 없지만.) 등 사소한 찝찝함이 남아 있었습니다.

public record MemberCreateRequestDto(
        @NotBlank
        @Pattern(...)
        String username,
        
        @NotBlank
        @Pattern(...)
        @JsonProperty("password")
        String rawPassword,
        
        @NotBlank
        @Pattern(...)
        String nickname
) {
    public Member toEntity(String hashedPassword) {
        // return new Member(username, hashedPassword, nickname, MemberStatus.ACTIVE, ServerTime.now());
        return Member.builder()
                .username(username)
                .password(hashedPassword)
                .nickname(nickname)
                .build();
    }
    
    public Member toEntity(PasswordEncoder passwordEncoder) {
        return toEntity(passwordEncoder.encode(rawPassword));
    }
}

방법 2: Data Mapper

데이터 간 매핑을 해 주는 구현체를 사용할 수도 있습니다.
대표적으로는 ModelMapper가 많이 사용되었는데, 리플렉션을 사용하는 방식이라 성능에서는 양보하는 면이 있었습니다.

이를 대체하는 것이 MapStruct입니다. MapStruct는 컴파일 타임에 미리 구현 클래스 파일을 준비해 주기 때문에 준비 과정에 시간은 더 요구하겠지만, 프로덕션은 성능을 더 보장받게 됩니다. 이런 부가적인 것 때문에 성능에서 감안하는 영역이 생기지 않게 컴파일타임에 미리 준비함으로써, 간혹 퍼포먼스 최적화가 필요한 기능들을 개발할 때도 이 매퍼를 활용하기에 부담이 덜하도록 한 것 같습니다.

코드는 매우 간단합니다. 스프링에서 사용한 예시입니다.

@Mapper(componentModel = "spring")
public interface MemberMapper {
    Member from(MemberCreateRequestDto dto, MemberStatus status);
    Member from(MemberCreateRequestDto dto, MemberStatus status, OffsetDateTime joinDate);
}

필드명이 잘 작성되어 있다면 이런 식으로 원하는 이름으로 함수를 선언하면 됩니다.
그러면 이 인터페이스의 구현 클래스를 MapStruct의 어노테이션 프로세서가 미리 만들어 주고, 빈으로 등록할 준비까지 마쳐 줍니다.(실행하면 우리가 신경쓰지 않아도 빈 등록 이루어짐.)

MapStruct

의존성 추가

그냥 의존성과 어노테이션 프로세서까지만 추가하면 될 것 같겠지만, 프로젝트가 롬복을 사용하는 경우가 많을 텐데, 이 둘의 어노테이션 프로세서가 충돌하는 사례가 꽤 있는 것 같습니다.

다음 중 아래쪽 어노테이션 프로세서는 롬복이 맵스트럭트와 바인딩에 무리가 없도록 해 주는 것으로 보입니다.

build.gradle:

dependencies {
    // Map Struct
    implementation 'org.mapstruct:mapstruct:1.5.3.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
    annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
}

VM Options: 기본 Component Model 변경

제안하는 것은 컴파일 타임에 VM Arguments에 -Amapstruct.defaultComponentModel=spring 옵션을 넣어 주는 것입니다.
이러면 매퍼를 추가할 때 @Mapper 어노테이션에 componentModel = "spring" 같은 코드를 매번 추가하지 않아도 됩니다.

다른 VM Options가 더 있는 예시로는, 이런 식으로 추가할 수 있습니다.
(가령 --enable-preview가 있다는 전제)

build.gradle:

// Enable Java Preview (1)
compileJava {
    options.compilerArgs += ['--enable-preview', '-Amapstruct.defaultComponentModel=spring']
}

// Enable Java Preview (2)
compileTestJava {
    options.compilerArgs += ['--enable-preview', '-Amapstruct.defaultComponentModel=spring']
}

// Enable Java Preview (3)
test {
    useJUnitPlatform()
    jvmArgs(['--enable-preview', '-Amapstruct.defaultComponentModel=spring'])
}

그러면 이렇게 간단하게 사용하면 됩니다.

기본적인 사용

@Mapper // (componentModel = "spring")
public interface MemberMapper {
    Member from(MemberCreateRequestDto dto, MemberStatus status);
    Member from(MemberCreateRequestDto dto, MemberStatus status, OffsetDateTime joinDate);
}

지금까지 사용해 봤을 때, Java 16 record, Builder 등도 잘 인식해서 적용됐습니다.

MapStruct는 단지 DTO만을 위한 게 아니기 때문에, 가령 도메인 모델과 JPA 엔티티를 구분해서 사용하는 곳들은 이런 적용 예시도 있습니다.

// `BaseEntityMapper.java`
public interface BaseEntityMapper<DOMAIN, ENTITY> {
    DOMAIN toDomain(ENTITY entity);
    ENTITY toEntity(DOMAIN domain);
}
@Mapper
public interface MemberEntityMapper
        extends BaseEntityMapper<Member, MemberEntity> {
    
}

필드 지정

필드 지정은 @Mapping(target = "...", source = "obj1.field1.subfield1") 이런 어노테이션을 사용하면 되는데, 여러 개라면 @Mappings(value = { ... }) 이 중괄호 안에 @Mapping(...) 어노테이션을 나열해 주면 됩니다.

@Mapper
public interface MemberMapper {
    @Mappings({
        @Mapping(target = "password", source = "dto.rawPassword"),
        @Mapping(target = "joinDate", source = "now")
    })
    Member from(
            MemberCreateRequestDto dto,
            MemberStatus status,
            OffsetDateTime now
    );
}

value 표기는 생략 가능하니까, @Mappings({ ... }) 이렇게 소괄호와 중괄호를 붙여 쓰는 형태가 되면 됩니다.

여러 객체 간 필드 이름이 충돌해서 애매한 경우에도 명시적으로 source = "a.b.c" 형태로 변수명과 필드 이름을 이어 가며 점을 찍어 명시적으로 매칭해 주면 됩니다.

빈으로 등록됐습니다.

생성자 주입 예시입니다.
(final + @RequiredArgsConstructor)

@Service
@RequiredArgsContstructor
public final class MemberCommandService
        implements MemberSignUpUseCase,
                MemberPasswordResetUseCase,
                MemberStatusUpdateUseCase {

    private final MemberRepository memberRepository;
    private final MemberMapper memberMapper; // usage
    private final PasswordEncoder passwordEncoder;

    // ...
    
    @Override
    public Member signUp(Member member) {
        // ...
        member.password = passwordEncoder.encode(member.password);
        return memberRepository.save(member);
    }
    
    @Override
    public Member signUp(MemberSignUpRequestDto dto, MemberStatus status) {
        Member member = memberMapper.from(dto, status);
        return signUp(member);
    }
}
profile
아 성장판 쑤셔 (블로그 이전) https://letsdev.hashnode.dev

0개의 댓글