본문 바로가기
Language/자바&코틀린

코틀린 querydsl, mapstruct 생성자에 따른 동작 방식

by 코딩공장공장장 2024. 3. 16.

코틀린, 스프링, jpa, querydsl 환경에서 불변객체를 만드는 방법에 대해 알아보겠다.

 

자바 위주의 스프링, querydsl, mapstruct 개념이나 사용법을 그대로 받아들이려고 하면 코틀린에서 객체의 불변성이 깨지거나 디폴트값 선언으로 비즈니스 규칙을 깨트리는 생성자들이 생겨날 수 있다.

 

나의 경우도 그러하였는데 코틀린, 스프링, querydsl, mapstruct 환경에서 객체 불변성을 지키며 디폴트값을 제거하는 방법에 대해 공유하겠다.

Querydsl을 통해 매핑받는 객체 디폴트 값 없애기

projection.bean, projection.fields

projection.bean 방식은 자바 빈 객체 형태만 받을 수 있다. 허나 세터 메서드가 없어도 리플렉션을 통해 매핑이 가능하다고 한다.

세터가 없어도 가능하기에 projection.bean과 projection.fields는 사실상 동작하는 방식이 같다. 따라서 함께 설명하겠다.

 

두 방식은 인자값 없는 기본생성자를 필요로 한다. 인자값 없는 기본 생성자를 통해 객체를 먼저 생성하고 필드명과 일치하는 쿼리의 칼럼 값을 매핑 시킨다.

 

코틀린에서 인자값 없는 생성자를 만들기 위해서는 디폴트값이 모두 있어야한다.

 

인자값 없는 생상자가 없다면 com.querydsl.core.types.ExpressionException 와 같은 에러가 난다.

 

따라서 위 두 방식은 디폴트값을 선언해야하기에 의미없는 디폴트값으로 도메인 객체의 비즈니스 규칙을 깨트릴 수 있다.

 

허나 단점만 있는 것은 아니다.

 

필드명과 정확히 일치하는 칼럼으로 매핑시켜주지만 일치하지 않는 경우 컴파일 오류가 아니라

 

디폴트값 그대로 매핑되어 있는다.

projection.constructor

이방식은 dto가 가지고 있는 생성자를 그대로 사용하여 매핑을 시켜주기에 의미없는 디폴트 값을 선언하지 않아도 된다.

 

도메인 객체의 비즈니스 규칙을 지킬 수 있다.

 

허나, 이 방식의 가장 큰 단점은 타입이 같은 속성에 대해 칼럼 순서를 다르게 선언하면 알아차리기 어렵다는 것이다.

 

projection.bean, projection.fields, projection.constructor 모두 객체의 불변성은 지킬 수 있다.

 

허나 무의미한 디폴트 값 선언으로 무분별한 생성자 오버로딩을 유발하고 이로인해 비즈니스 규칙을 깨는 도메인 객체가 생성될 수 있다는 점은 projection.bean, projection.fields의 큰 단점이다.

 

개인적으로 가장 큰 단점은 비즈니스 규칙을 깨는 것이라고 생각한다.

 

라이브러리나 프레임워크의 사용방식 때문에 비즈니스 규칙을 깨는 것은 옳지 않다고 생각한다.

 

tradeoff가 있을 수 있겠지만 규칙을 깨지 않는 방법이 있다면 해당 방법을 사용하는것이 좋아 보인다.

 

projection.constructor는 비즈니스 규칙을 지킬 수 있지만 순서가 변경되도 알아차리기 어려운 점으로 개발자의 실수를 발견하기 어렵다.

 

하지만 코틀린에서 이런 실수를 어느정도 방지할 수 있는 방법이 있다.

data class Person(
    val id: Int,
    val birth: Int,
    val phone: Int,
) {
    init {
        require(id > 0)
        require(birth.toString().length == 6)
        require(phone.toString().length in 11 .. 12)
    }
}

위와 같이 init 메서드에 require을 적용하여 객체 속성에 validation을 걸을 수 있다.

 

만약 DB에 birth 칼럼 값이 930123과 같이 6자리라면 6자리를 체크하고 

 

phone 번호가 - 없이 숫자만 입력되어있다면 11자리, 12자리인지 체크함으로써 

 

속성이 모두 같은 Int 타입이더라도 도메인 객체의 속성 규칙을 체크함으로써 생성자 방식에서 칼럼 순서를 바꾸는 실수를 어느정도 잡을 수 있다.

 

이러한 패턴으로 객체를 설계한다면 개인적으로 constructor가 갖는 단점을 충분히 상쇄 시킬 수 있을 것이라고 생각한다.

 

Mapstruct의 생성자 사용방식

mapstruct는 변환 결과인 target 객체에 어떤 생성자가 있는냐에 따라 mapstruct가 사용하는 생성자가 달라진다.

 

사용법에 따라 세터를 선언해야하는 경우도 있다.

 

허나 3rd-party-library 사용을 위해 불변한 객체를 가변객체로 바꾸는 것은 좋은 방식이 아니라고 생각한다.

 

다행히 Mapstruct도 이런 상황을 위해 해결법을 제공하고 있다.

 

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

 

위 링크는 맵스트럭츠가 생성자를 어떻게 사용하는지 설명하고 있다.

  • @Default 어노테이션을 생성자에 붙여주면 해당 생성자로 사용한다.
    (@Default는 맵스트럭츠가 제공하는 것이 아니라 커스텀 어노테이션 또는 3rd-party annotation도 가능)
  • public 생성자가 하나 존재한다면 해당 생성자로 사용한다.
  • 인자값 없는 생성자가 존재하면 이 생성자가 타깃 객체를 생성하는데 사용된다.
    => 이후 세터를 통해 매핑
  • 만약 매핑 자격이 있는 생성자가 여러개 생겨난다면 컴파일 에러가 나타나고 이때 @Default와 같은 어노테이션 매핑 방식을 사용한다면 해당 방식으로 사용된다.

(public 생성자가 하나 존재한다면 해당 생성자로 사용한다는 것은 너무 당연하니 별도의 설명은 하지 않겠다.)


인자값 없는 생성자가 존재하면 해당 생성자로 객체를 만들고 세터 방식으로 매핑

이 방식을 예제와 함께 먼저 설명하겠다. (예제에서 dto 설계가 안티패턴이지만 맵스트럭츠 구동원리 설명을 위해 넣었다.)

data class PersonSearchRequest(
    val name:String? = null,
    val age:Int? = null
)
data class PersonSearchDto(
    val name:String? = null,
    val age:Int? = null
)

 

위와 같이 PersonSearchRequest를 source(원본)로 PersonSearchDto를 target(변환 결과)으로 설정하였다.

 

위 객체는 person을 검색하는 검색 조건을 담고 있는 객체이다.

 

이때, target의 주생성자 오버로딩으로 인해 인자값 없는 생성자가 생겨나기 때문에 맵스트럭츠는 인자값 없는 생성자로 타깃 객체를 만들고 세터를 통해 값을 매핑한다.

 

허나, val 키워드는 세터 메서드를 제공하지 않는다.

그렇다면 어떻게 될까?? 매퍼를 만들고 테스트 코드로 확인해보자.

@Mapper
interface PersonSearchMapper {

    companion object {
        val INSTANCE: PersonSearchMapper = Mappers.getMapper(PersonSearchMapper::class.java)
    }
    
    fun convertToDto(person: PersonSeachRequest): PersonSearchDto
}
@Test
fun `mapper test`() {
    val personSearchRequest = PersonSearchRequest(name = "brown", age = 20)
    val personSearchDto = PersonMapper.INSTANCE.convertToDto(personSearchRequest)
    
    println(personSearchDto)
    
    assertThat(personSearchDto.name).isEqualTo(personSearchRequest.name)
    assertThat(personSearchDto.age).isEqualTo(personSearchRequest.age)
}

 

출력값 : PersonSearchDto(name=null, age=null)

 

테스트는 실패하고 속성값은 위와 같이 모두 null로 리턴 되었다. 세터가 없으니 매핑이 되지 않는다.

 

세터를 위해 var 키워드로 바꾸고 객체의 불변성을 깨트려야하나??

 

맵스트럭츠에서 이를 어노테이션을 통해 해결할 수 있다.

[참고]
타깃 객체의 디폴트 값이 null로 설정되있는데, 사실 이는 객체 설계가 잘못되었다.
매퍼는 보통 레이어간 객체 변환에 많이 사용된다.
그런데 타깃은 주로 하위모듈의 input dto로 상위모듈에서 값을 주기에 null값을 디폴트로 줄 필요가 없다.
Request객체가 컨트롤러 메서드의 인자값이고 클라이언트에서 값을 주지 않으면 디폴트로 null을 주어
검색 조건이 적용되지 않도록 하겠지만 request가 null을 dto에게 주는데 dto에도 디폴트값을 null로 설정할 필요가 없다.

 

@Default 어노테이션을 생성자에 붙여주면 해당 생성자로 사용

위 케이스에서 적절한 생성자가 있음에도 인자값 없는 생성자가 존재하여 인자값 없는 생성자가 매핑 작업에 사용되었다.

허나 어노테이션을 붙여 매핑 작업에 사용될 생성자를 직접 지정할 수 있다.

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR)
annotation class Default()
data class PersonSearchDto @Default constructor(
    val name:String? = null,
    val age:Int? = null
)
@Default
fun convertToDto(person: PersonSeachRequest): PersonSearchDto

 

위와 같이 @Default라는 커스텀 애노테이션을 만들고 사용할 생성자와 메서드에 붙여주면

 

해당 메서드가 실행될 때 인자값 없는 생성자가 아닌 애노테이션이 적용된 메서드로 실행시킬 수 있다.

 

커스텀 어노테이션은 아무 역할 없고 저렇게 알려주는 용도일 뿐이니 하나 만들어 놓고 여기저기 사용할 수 있다.

 

이때 반드시 커스텀 어노테이션일 필요 없고 사용하는 3rd-party 어노테이션을 붙여도 유효하게 사용할 수 있다.

 

인자값 없는 생성자를 명시적으로 선언한 Entity 구조에서 어떻게 매핑하는지 알아보자.

@Entity
class PersonEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int = 0
        protected set
    var name: String? = null
        protected set
    var age: Int? = null
        protected set

    constructor()
    
    constructor(id: Int, name: String, age: Int) {
        this.id = id
        this.name = name
        this.age = age
    }
}

 

public class PersonMapperImpl implements PersonMapper {

    @Override
    public PersonEntity convertToEntity(PersonDto personDto) {
        if ( personDto == null ) {
            return null;
        }

        PersonEntity personEntity = new PersonEntity();

        return personEntity;
    }
}

 

매퍼 구현체 소스를 보니 위와 같이 인자값 없는 생성자로 Entity가 생성되고 세터가 동작하지 않았다.

 

Entity의 세터가 protected 접근지정자이고 Entity와 매퍼를 다른 패키지에 두고 테스트하니 세터 사용이 되지 않은 것이다.

 

세터 사용을 위해 매퍼와 Entity를 같은 패키지에 위치 시키지는 않을 것이다.

 

이번엔 constructor() 생성자를 없애고 no-args 플러그인을 적용하여 자바 컴파일시 인자값 없는 생성자가 생성되게 하고 테스트를 해보자.

 

매퍼 구현체를 보니

public PersonEntity convertToEntity(PersonDto personDto) {
    if ( personDto == null ) {
        return null;
    }
    String name = null;
    int age = 0;
    name = personDto.getName();
    if ( personDto.getAge() != null ) {
        age = personDto.getAge();
    }
    int id = 0;
    PersonEntity personEntity = new PersonEntity( id, name, age );
    return personEntity;
}

 

위와 같이 인자값이 있는 생성자가 사용되었다.

 

매퍼가 구현체를 만들 때 noargs 플러그인을 무시하고 선언되어 있는 생성자를 통해 생성한다.

 

Entity설계시  no-args 플러그인을 사용하는 것은 좋은 방식이기도 하고 매퍼가 no-args 플러그인을 무시하니 다행히 문제되는 상황없이 해결이 가능하다.

 

혹시나 이부분이 미심쩍다면 이전의 @Default와 같이 애노테이션을 붙여 처리하는 것도 좋은 방식이라고 생각한다.

 

매핑 자격이 있는 생성자가 여러개 생겨난다면 컴파일 에러

constructor(id: Int, name: String, age: Int) {
    this.id = id
    this.name = name
    this.age = age
}

constructor(name: String, id: Int, age: Int) {
    this.id = id
    this.name = name
    this.age = age
}

 

위와 같이 같은 인자를 받는 생성자를 오버로딩을 통해 순서만 바꾸면

 

error: Ambiguous constructors found for creating

 

위와 같은 컴파일 에러가 난다.

 

허나 두 생성자 중 한곳에 @Default를 붙여 사용하면 해당 애노테이션이 붙은 생성자로 값을 매핑한다.

 

굳이 누가 저렇게 생성자를 생성할 것이라고 생각하지 않는다.

 

또한 주생성자를 통한 생성자 오버로딩에서 저렇게 순서만 다른 생성자는 생겨나지 않는다.

 

 

반응형