본문 바로가기

Kotlin

Kotlin에 MapStruct 적용할 때 주의할 점

개요

매퍼 라이브러리로 mapstruct를 사용하는 과정에서 생각과 달리 작동하던 부분이 있어서 애를 먹고 고민했던 부분이 있다.

그 과정에서 알게된 것들(코틀린의 기본 문법 포함)에 대해 정리하는 글.

 

적용한 mapstruct 버전 : 1.5.5 final

미리보기

이 글의 핵심을 먼저 mapstruct의 도큐먼트 인용으로 언급하자면 다음과 같다.


3.9. Using Constructors

MapStruct supports using constructors for mapping target types. When doing a mapping MapStruct checks if there is a builder for the type being mapped. If there is no builder, then MapStruct looks for a single accessible constructor. When there are multiple constructors then the following is done to pick the one which should be used:

  • If a constructor is annotated with an annotation named @Default (from any package, see Non-shipped annotations) it will be used.
  • If a single public constructor exists then it will be used to construct the object, and the other non public constructors will be ignored.
  • If a parameterless constructor exists then it will be used to construct the object, and the other constructors will be ignored.
  • If there are multiple eligible constructors then there will be a compilation error due to ambiguous constructors. In order to break the ambiguity an annotation named @Default (from any package, see Non-shipped annotations) can used.


https://mapstruct.org/documentation/stable/reference/html/#mapping-with-constructors

 

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

 

대상 유형 = target 이다. 매핑을 수행할 때 해당 유형에 대한 빌더가 있는지 확인한다고 했는데, mapstruct는 롬복 등의 라이브러리도 지원한다. 그러나 코틀린에서는 일반적으로는 빌더 패턴을 사용하지 않기 때문에, 빌더와 관련된 내용은 코틀린과는 직접적으로 관련 없다.

이 글에서 언급할 특히 중요한 부분은 다음 내용이다.

MapStruct은 대상 유형의 매핑에 생성자를 사용할 수 있습니다. (…)
여러 생성자가 있는 경우 사용할 생성자를 선택하기 위해 다음 단계를 따릅니다.
(…)

  • 매개 변수가 없는 생성자가 존재하는 경우 해당 생성자가 객체를 생성하는 데 사용되며, 다른 생성자는 무시됩니다.

예제코드로 문제점 찾기

예제 코드를 만든 후 실제로 mapstruct으로 매퍼를 만들어서 적용해 보는 과정에서 문제점이 뭔지 살펴볼 것이다.

먼저 ComputerDto, ComputerRequest라는 이름으로 두 개의 코틀린 클래스를 만들었다. 두 클래스 모두 data 클래스로 만들었다.

DTO 생성

// dto -> 디폴트 설정하지 않음
data class ComputerDto(
    val CPU: String,
    val RAM: String,
    val storage: String,
)

dto에는 디폴트 값을 설정하지 않았다.

Request 생성

// req -> 모두 디폴트 값 설정
data class ComputerRequest(
    val CPU: String = "Default CPU",
    val RAM: String = "Default RAM",
    val storage: String = "Default Storage"
)

request에는 모두 디폴트 값을 설정했다.

mapstruct의 사용법을 알려주기 위한 글이 아니니 사용법은 생략하도록 하고, 예제를 쉽게 유지하기 위해 dto와 request의 필드명과 타입은 모두 일치시켰다.

Mapper 생성

그러고 나서 mapstruct를 이용한 mapper 인터페이스를 다음과 같이 생성하였다.

@Mapper(componentModel = "spring")
interface ComputerMapper {

    companion object {
        val INSTANCE: ComputerMapper = Mappers.getMapper(ComputerMapper::class.java)
    }

    fun requestToDto(request: ComputerRequest): ComputerDto

    fun dtoToRequest(dto: ComputerDto): ComputerRequest

}

이제 gradle에서 build를 진행하면 자동으로 ComputerMapper의 구현체가 mapstruct에 의해 만들어진다.

메인 클래스를 하나 생성하여 매퍼 구현체를 테스트해 보자.

Mapper 구현체 테스트

1. request(디폴트 값) → dto

fun main() {
    // 1. req -> dto
    val req1 = ComputerRequest()
    val dto1 = ComputerMapper.INSTANCE.requestToDto(req1)

    println("req -> dto : $dto1")
}

ComputerRequest에는 모든 값이 디폴트 설정되어 있으므로, 빈 파라미터(기본 생성자)를 이용해서 객체를 하나 생성하였다. 그리고 매퍼를 이용해서 request 객체를 dto로 변환하였다. 변환한 뒤 변환이 잘 되었는지 println 함수로 찍어보았다.

dto에는 request의 디폴트 값인 Default CPU, Default RAM, Default Storage 가 담겨있어야 한다.

req -> dto : ComputerDto(CPU=Default CPU, RAM=Default RAM, storage=Default Storage)

잘 변환이 되었다.

2. request(값 설정) → dto

    val req1_2 = ComputerRequest("CPU", "RAM", "storage")
    val dto1_2 = ComputerMapper.INSTANCE.requestToDto(req1_2)

    println("req -> dto : $dto1_2")

위와 다른 점은 ComputerRequest 객체를 생성할 때 매개변수가 모두 있는 생성자를 사용하였다는 것이다. 매개변수에 CPU, RAM, storage라는 값을 저장하였다.

이렇게 만든 request 객체를 역시 매퍼를 사용해서 dto로 변환하였다.

실행을 해보면 dto에는 매개변수로 넘긴 값인 “CPU, RAM, storage”가 들어있어야 한다.

req -> dto : ComputerDto(CPU=CPU, RAM=RAM, storage=storage)

다행히 잘 들어있었다.

3. dto → request (문제 발생)

    val dto2 = ComputerDto(
        "New CPU",
        "New RAM",
        "New Storage"
    )
    val req2 = ComputerMapper.INSTANCE.dtoToRequest(dto2) 

    println("dto -> req : $req2")

이번에는 ComputerDto 객체를 만들어 매퍼를 이용해 request 객체로 변환해 보려 한다. ComputerDto에는 디폴트 값이 없으므로 모든 매개변수를 넣어준다. New CPU, New RAM, New Storage라는 값으로 넣었다.

실행 결과를 예측해보면, req에는 New CPU, New RAM, New Storage라는 값이 들어있어야 할 것이다.

dto -> req : ComputerRequest(CPU=Default CPU, RAM=Default RAM, storage=Default Storage)
// ???

그런데 여기서 문제가 발생한다. 예측한 New CPU, New RAM, New Storage라는 값 대신에 Default CPU, Default  RAM, Default  Storage라는 값이 들어있다.

 

기본 생성자가 없는 클래스(dto)에서 기본 생성자가 있는 클래스(request)로 형변환 시 데이터가 정상적으로 들어가지 않는다.

그 이유는 위의 mapstruct docs 내용에서 언급했듯이 기본 생성자가 있는 클래스로의 형변환에서는 기본 생성자가 사용되고, 다른 생성자들은 무시되기 때문이다.

If a parameterless constructor exists then it will be used to construct the object, and the other constructors will be ignored.

문제 해결하기

코틀린 문법 복습

잠깐 코틀린 문법을 확인하고 가자.

 

Kotlin 공식문서의 Data classes 항목에는 이런 내용이 나온다.

https://kotlinlang.org/docs/data-classes

💡 JVM에서 생성된 클래스가 매개변수가 없는 생성자를 가져야 할 때, 해당 클래스의 프로퍼티에 기본값이 지정되어 있어야 한다.

💡 JVM에서, 기본 생성자 파라미터들이 모두 기본값을 가지고 있는 경우, 컴파일러는 추가적으로 매개변수가 없는 생성자를 생성합니다. 이로써 Jackson 또는 JPA와 같은 라이브러리와 함께 Kotlin을 사용할 때 클래스 인스턴스를 매개변수가 없는 생성자를 통해 생성하기 쉬워집니다.

따라서 예제 코드 중 모두 디폴트 값을 설정한 ComputerRequest 클래스에는 기본 생성자(NoArgsConstructor), 모든 매개변수를 가진 생성자(AllArgsConstructor) 두 가지의 생성자가 있다.

이는 코틀린 바이트코드를 디컴파일해서 확인할 수도 있다. (이 글에서는 생략)

mapstruct의 기본 동작 방식은 클래스에 여러 생성자가 있을 때 기본 생성자가 있다면 기본 생성자를 사용한다.

mapstruct이 생성한 구현체 확인

mapstruct에 의해 자동으로 생성된 ComputerMapperImple을 보면 이렇게 구현이 되어 있다.

@Component
public class ComputerMapperImpl implements ComputerMapper {

    @Override
    public ComputerDto requestToDto(ComputerRequest request) {
        if ( request == null ) {
            return null;
        }

        String cPU = null;
        String rAM = null;
        String storage = null;

        cPU = request.getCPU();
        rAM = request.getRAM();
        storage = request.getStorage();

        ComputerDto computerDto = new ComputerDto( cPU, rAM, storage );

        return computerDto;
    }

    @Override
    public ComputerRequest dtoToRequest(ComputerDto dto) {
        if ( dto == null ) {
            return null;
        }

        ComputerRequest computerRequest = new ComputerRequest();

        return computerRequest;
    }
}

source: request, target: dto인 위의 메서드는 target인 dto에 기본 생성자가 없기 때문에 mapstruct에서 AllArgsConstructor를 이용해서 변환을 하였다.
ComputerDto computerDto = new ComputerDto( cPU, rAM, storage );

source: dto, target: request인 아래의 메서드는, target인 request에 기본 생성자가 존재하기 때문에 기본 생성자를 이용하였다.
ComputerRequest computerRequest = new ComputerRequest();

그러니 내가 아무리 dto를 만들어서 매개변수에 ComputerDto(CPU=New CPU, RAM=New RAM, storage=New Storage)로 매퍼에 넘겨줘도, 매퍼에서는 기본 생성자로 target 객체를 만들기 때문에 적용을 할 수 없는 것이다.

 

만약 이 상태에서 매퍼가 정상적으로 동작하게 하고싶다면 어떻게 해야 할까?

해결 방법 1. val → var

기본 생성자로 생성된 request 객체에 setter를 사용해서 값을 초기화해 주어야 할 것이다.

ComputerRequestval 키워드로 변수들이 선언되어 있는데, 이를 var로 바꿔보자.

data class ComputerRequest(
    var CPU: String = "Default CPU",
    var RAM: String = "Default RAM",
    var storage: String = "Default Storage"
)

그리고 다시 빌드를 해보면 ComputerMapperImpl 내부가 이렇게 바뀐다.

    @Override
    public ComputerRequest dtoToRequest(ComputerDto dto) {
        if ( dto == null ) {
            return null;
        }

        ComputerRequest computerRequest = new ComputerRequest();

        computerRequest.setCPU( dto.getCPU() );
        computerRequest.setRAM( dto.getRAM() );
        computerRequest.setStorage( dto.getStorage() );

        return computerRequest;
    }
}

valvar로 바꿈으로써 ComputerRequestsetter가 생겼기 때문에 매개변수로 받은 dto의 값들을 빈 request 객체에 세팅해 줄 수 있다.

메인 함수를 실행해도 당연히 의도한 대로 결과를 얻을 수 있다.

dto(값 설정) -> req : ComputerRequest(CPU=New CPU, RAM=New RAM, storage=New Storage)

해결 방법 2: 모든 properties에 default 값 설정하지 않기

data class ComputerRequest(
    val CPU: String = "Default CPU",
    val RAM: String = "Default RAM",
    val storage: String
)

이렇게 storage에 있는 디폴트 값을 삭제한 뒤에 다시 빌드를 해보자. 그러면 ComputerRequest에 있던 기본 생성자가 사라진다. 따라서 mapstruct는 AllArgsConstructor를 사용하게 되고, mapper 구현체 코드는 다음과 같이 수정된다.

    @Override
    public ComputerRequest dtoToRequest(ComputerDto dto) {
        if ( dto == null ) {
            return null;
        }

        String cPU = null;
        String rAM = null;
        String storage = null;

        cPU = dto.getCPU();
        rAM = dto.getRAM();
        storage = dto.getStorage();

        ComputerRequest computerRequest = new ComputerRequest( cPU, rAM, storage );

        return computerRequest;
    }

이때의 실행 결과도 의도대로 잘 형변환이 된다.

 

정리하기

  1. 코틀린의 기본 문법 : JVM에서, 코틀린의 모든 primary 생성자가 디폴트 값을 가지고 있으면, 컴파일러는 추가로 파라미터 없는 생성자(NoArgsConstructor, 기본 생성자)를 생성하고 이 생성자는 디폴트 값을 사용한다.
  2. 자바 라이브러리를 코틀린에서 사용할 때 언어의 차이점에 따른 동작방식을 잘 이해하고 사용하자.