만들면서 배우는 아키텍처 그리고 매핑 프레임워크 MapStruct를 사용한 매핑
web/Spring

만들면서 배우는 아키텍처 그리고 매핑 프레임워크 MapStruct를 사용한 매핑

반응형

만들면서 배우는 아키텍처 (Get Your Hands Dirty on Clean Architecture)

 

요새 읽던 책중에 'Get Your Hands Dirty on Clean Architecture' 책이 인상 깊었다. 원서로 팀원들과 스터디 하고 나서 인상깊어서 '만들면서 배우는 아키텍처'라는 번역으로 다시 한번 봤다. 원서는 구어체로 되어있어 내용은 좋았지만 친절하지 못한 느낌이었다면 번역본은 깔끔하게 정리되어있어서 너무 맘에 들었다.

 

인상깊었던 몇 부분을 정리하면 첫번째로 육각형 아키텍처에서 포트-어댑터 패턴을 사용하여 호출 당하는쪽은 호출하는쪽을 몰라도 되고 port interface로 통신하는 부분이 인상 깊었고 interface가 만드는쪽에서만 명세(설계도)를 알 수 있다는 장점만을 생각했었는데 호출하는쪽에서도 자신이 호출해야하는 메소드만 알고 싶을때 interface를 사용할 수 있다는 부분에 대해서도 인상깊었다.

 

그리고 두 번째로 엔티티 중심의 개발의 문제와 도메인 중심으로 개발해야하는 이유 그리고 엔티티를 분리해야하는 핵심적인 이야기를 볼수있어 좋았다. (도메인은 특정 프레임워크 ORM등에 특성을 알아서는 안되기 때문에 엔티티와 도메인 로직은 공존해서는 안된다.) 

 

세번째로 각 계층으로 입력이 전파되면서 입력과 출력모델을 함께 사용할 경우 강결합이 발생할 수 있기 때문에 각 계층간에 연결을 서로 다른 input, output을 사용해야 한다고 이야기를 하고있다. 방법에 따라서 정도의 차이는 있는데 one-way mapping, two-way mapping, no mapping등 여러 전략이 있었다. 

 

그중 내생각에는 정도의 차이가 있을수 있겠지만 도메인 == 엔티티가 되는 아주 간결하고 입력과 응답이 엔티티, 도메인까지 필드 하나하나까지 같은 수준이 아니라면 no mapping 전략이 가능한 곳은 없다고 생각된다. 하지만 책에서도 나오듯이 매핑에는 극명한 단점이 존재하고 나 또한 이런 문제를 많이 경험했기에 공감하는 부분이 있다. 아래 예를 들어보겠다.

 

// AccountEntity 엔티티 클래스
@Getter
public class AccountEntity {
	private String name;
	private int age;
}

// Account 도메인 클래스
@Getter
public class Account {
	private final String name;
	private final int age;

	@Builder
	public Account(String name, int age) {
		this.name = name;
		this.age = age;
	}
}

// AccountEntity -> Account 매핑 클래스
public class AccountEntityMappingConverter {

public Account toDomain(AccountEntity accountEntity) {
			return Account.builder()
				.name(accountEntity.getName())
				.age(accountEntity.getAge()) 
				.build();
	}
}

위의 예시가 되고 있는 엔티티, 도메인의 경우 필드가 두개뿐이지만 실무에 사용하다보면 매핑에 사용되는 필드는 계속 추가될 것이고 추가될 때 아래의 순서대로 대게 필드값을 추가하게 된다.

  • 엔티티에 필드 추가
  • 도메인에 필드 추가
  • 엔티티 - 도메인 매핑 추가
  • 비즈니스 로직에 사용

 

하지만 테스트 코드를 잘 짜는것도 중요하지만 드물지 않게 매핑에서 필드 매핑을 추가하는 것을 누락하여 버그가 발생되는 경우가 꽤 있다.

 

이게 필드가 많아지고 강경합을 없애기 위해서 입력, 출력을 모든 use case 모두 다 다르게 설정할 경우에는 관리 대상이 많아지면서 누락될 여지가 충분히 있다. 번거로움 또한 두배이다.

 

그래서 알아보던 매핑 프레임워크 중에 가장 괜찮은 MapStruct라는 프레임워크를 정리했다.

 

 

MapStruct

 

MapStruct 소개 페이지에서 나온 문구를 살펴보면 MapStruct가 만들어지게 된 이유가 내가 느꼈던 불편함과 완전하게 일치했다.

Multi-layered applications often require to map between different object models (e.g. entities and DTOs). Writing such mapping code is a tedious and error-prone task. MapStruct aims at simplifying this work by automating it as much as possible.

In contrast to other mapping frameworks MapStruct generates bean mappings at compile-time which ensures a high performance, allows for fast developer feedback and thorough error checking.

 

기본 사용법

그럼 간단한 사용법을 알아보자.

우선 매핑에 사용될 객체 Account, AccountEntitiy를 추가한다.

package com.wedul.mapstructtest.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
public class Account {

    private final String name;
    private final int age;

    @Builder
    public Account(String name, int age) {
        this.name = name;
        this.age = age;
    }
}


package com.wedul.mapstructtest.entity;

import lombok.Builder;
import lombok.Getter;

@Getter
public class AccountEntity {

    private String name;
    private int age;

    @Builder
    public AccountEntity(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

그리고 MapStruct를 사용해서 Mapping interface를 하나 추가하는데 이때 상단에 @Mapper 애노테이션을 달아주고 input -> output으로 매핑할 메소드를 하나 만들어준다.

package com.wedul.mapstructtest.mapper;

import com.wedul.mapstructtest.dto.Account;
import com.wedul.mapstructtest.entity.AccountEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper
public interface AccountMapper {

    Account domainToEntity(AccountEntity accountEntity);

}

테스트 코드를 돌려서 검증해보면 정상적으로 매핑에 성공한 것을 알 수있다. 이곳에서 Mapper를 사용하기 위해서는 Instance 개체를 getMapper를 써서 사용하지만 스프링 의존성 주입을 통해서 사용하는 방법이 있는데 이건 아래 다음 부분에서 소개하도록 하겠다.

package com.wedul.mapstructtest;

import com.wedul.mapstructtest.dto.Account;
import com.wedul.mapstructtest.entity.AccountEntity;
import com.wedul.mapstructtest.mapper.AccountMapper;
import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;

import static org.assertj.core.api.Assertions.assertThat;

public class AccountMappingTest {

    AccountMapper INSTANCE = Mappers.getMapper(AccountMapper.class);

    @Test
    void shouldAccountMappingTest() {
        // given
        AccountEntity accountEntity = AccountEntity.builder()
                .age(10)
                .name("wedul")
                .build();

        // when
        Account account = INSTANCE.domainToEntity(accountEntity);

        // then
        assertThat(account.getAge()).isEqualTo(accountEntity.getAge());
        assertThat(account.getName()).isEqualTo(accountEntity.getName());
    }
}

위처럼 매핑이 되는 원리는 @Mapper 애노테이션이 붙은 곳에 MapStruct가 빌드 시점에 스펙에 맞게 동일한 이름을 가진 필드의 값을 매핑해주는 코드를 만들기 때문이다. 만약 필드 이름이 다르거나 혹은 타입이 변경되었을 때 등등 상황에서는 별도 애노테이션과 설정을 해주는 방법이 있다. 많은 부분에 대한 고려가 있어서 사용하기에 더 편리했다. 

 

빌드시에 생성된 코드를 살펴보자. gradle build시 ide에서 build/classes/java/main/ 밑에 인터페이스가 있는 곳에 *Impl이라는 이름이 붙어서 생성된다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.wedul.mapstructtest.mapper;

import com.wedul.mapstructtest.dto.Account;
import com.wedul.mapstructtest.dto.Account.AccountBuilder;
import com.wedul.mapstructtest.entity.AccountEntity;

public class AccountMapperImpl implements AccountMapper {
    public AccountMapperImpl() {
    }

    public Account domainToEntity(AccountEntity accountEntity) {
        if (accountEntity == null) {
            return null;
        } else {
            AccountBuilder account = Account.builder();
            account.name(accountEntity.getName());
            account.age(accountEntity.getAge());
            return account.build();
        }
    }
}

 

 

필드 이름이 다른경우

필드 이름이 다른경우 @Mapping annotation을 사용해서 source쪽 필드와 target쪽 필드를 명시해주면 된다. 아까 기본 설명부분에서 사용된 AccountEntity와 Account 클래스에 각각 서로 다른 이름을 생성하고 @Mapping annotation을 달아서 사용해보자.

@Getter
public class Account {

    private final String name;
    private final int age;
    private final String location;
    private final String homeNumber;

    @Builder
    public Account(String name, int age, String location, String homeNumber) {
        this.name = name;
        this.age = age;
        this.location = location;
        this.homeNumber = homeNumber;
    }
}

package com.wedul.mapstructtest.entity;

import lombok.Builder;
import lombok.Getter;

@Getter
public class AccountEntity {

    private String name;
    private int age;
    private String address;
    private String homeTel;

    @Builder
    public AccountEntity(String name, int age, String address, String homeTel) {
        this.name = name;
        this.age = age;
        this.address = address;
        this.homeTel = homeTel;
    }
}

package com.wedul.mapstructtest.mapper;

import com.wedul.mapstructtest.dto.Account;
import com.wedul.mapstructtest.entity.AccountEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper
public interface AccountMapper {

    @Mapping(source = "address", target = "location")
    @Mapping(source = "homeTel", target = "homeNumber")
    Account domainToEntity(AccountEntity accountEntity);

}

package com.wedul.mapstructtest;

import com.wedul.mapstructtest.dto.Account;
import com.wedul.mapstructtest.entity.AccountEntity;
import com.wedul.mapstructtest.mapper.AccountMapper;
import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;

import static org.assertj.core.api.Assertions.assertThat;

public class AccountMappingTest {

    AccountMapper INSTANCE = Mappers.getMapper(AccountMapper.class);

    @Test
    void shouldAccountMappingTest() {
        // given
        AccountEntity accountEntity = AccountEntity.builder()
                .age(10)
                .name("wedul")
                .build();

        // when
        Account account = INSTANCE.domainToEntity(accountEntity);

        // then
        assertThat(account.getAge()).isEqualTo(accountEntity.getAge());
        assertThat(account.getName()).isEqualTo(accountEntity.getName());
        assertThat(account.getHomeNumber()).isEqualTo(accountEntity.getHomeTel());
        assertThat(account.getLocation()).isEqualTo(accountEntity.getAddress());
    }
}

 

@Mapping 애노테이션은 여러개 붙일 수 있기 때문에 필드가 여러개 변경될경우에는 여러 애노테이션을 붙여서 사용하면 된다.

 

 

매핑을 위한 생성자 사용

MapStruct는 Builder가 있을경우 빌더를 사용하고 없을 경우 생성자를 사용하는데 생성자가 하나만 있을경우에는 그것을 사용하지만 없을경우에는 아래 규칙대로 사용한다.

  • @Default 애노테이션이 붙어있는 것
  • public으로 열려있는 생성자
  • 기본생성자
  • 기본생성자가 없고 파라미터가 있는 생성자만 여러개 있을 경우 에러 발생 (@Default 애노테이션 추가 필요)

 

public class Person {

    private final String name;
    private final String surname;

    public Person(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }
}

// GENERATED CODE
public class PersonMapperImpl implements PersonMapper {

    public Person map(PersonDto dto) {
        if (dto == null) {
            return null;
        }

        String name;
        String surname;
        name = dto.getName();
        surname = dto.getSurname();

        Person person = new Person( name, surname );

        return person;
    }
}

위 예시처럼 파라미터가 있는 생성자가 있을 경우 필드를 받아서 생성자로 초기화하여 객체를 생성한다.

 

 

 

스프링에서 Mapper 주입받아 사용하기

기본사용법에서 봤듯이 매번 INSTANCE 객체를 가지고 와서 사용하기에는 번거롭고 스프링 생태게와는 맞지 않는 느낌이 들수있다.

 

MapStruct에서는 여러 componetModel을 지원하는데 기본값으로 별도의 컴포넌트 모델이 없고 위에서 사용하던 것 처럼 Mapper.getMapper(Class)를 통해서 접근해서 사용해야한다.

 

스프링을 사용하기 위해서는 componentModel을 spring으로 지정해주면 된다. 아래 예시를 보자.

@Mapper(componentModel = "spring")
public interface AccountMapper {

    @Mapping(source = "address", target = "location")
    @Mapping(source = "homeTel", target = "homeNumber")
    Account domainToEntity(AccountEntity accountEntity);

}

나머지 코드는 위에 필드가 다른경우의 예시와 같고 Mapper 애노테이션에만 componentModel에만 spring으로 변경해줬다. 일일히 설정해주기 귀찮다면 gradle 설정 시 script에 option을 지정해줄 수 있다. 나머지 옵션들도 문서에서 확인해볼 수 있다. (https://mapstruct.org/documentation/dev/reference/html/#configuration-options)

compileJava {
    options.compilerArgs += [
        '-Amapstruct.defaultComponentModel=spring'
    ]
}

그럼 주입 시켜서 integereation test로 돌려보자.

package com.wedul.mapstructtest;

import com.wedul.mapstructtest.dto.Account;
import com.wedul.mapstructtest.entity.AccountEntity;
import com.wedul.mapstructtest.mapper.AccountMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class AccountMappingIntegrationTest {

    @Autowired
    private AccountMapper accountMapper;

    @Test
    void shouldAccountMappingTest() {
        // given
        AccountEntity accountEntity = AccountEntity.builder()
                .age(10)
                .name("wedul")
                .build();

        // when
        Account account = accountMapper.domainToEntity(accountEntity);

        // then
        assertThat(account.getAge()).isEqualTo(accountEntity.getAge());
        assertThat(account.getName()).isEqualTo(accountEntity.getName());
        assertThat(account.getHomeNumber()).isEqualTo(accountEntity.getHomeTel());
        assertThat(account.getLocation()).isEqualTo(accountEntity.getAddress());
    }


}

정상적으로 동작한다.

 

 

 

마무리

여러 옵션에 대해서 모두 알아볼수는 없기에 필요할 때마다 문서를 찾아서 확인해보면 될 것같다. 혹자는 매퍼 프레임워크를 사용할 경우 어떻게 값 매핑이 발생할지 모르기 때문에 사용하면 안된다. 불확실성이 있다고 이야기를 한다. 나도 동의하는바이지만 우리가 매핑을 직접했을 때 휴먼 에러에 대한 무서움도 인지해야한다. 그래서 결국에는 직접 매퍼클래스를 작성하건 라이브러리를 사용하던 테스트코드를 잘 짜면 해결이가능해진다. 매핑에 대한 테스트코드만 잘 짜져 있다고 한다면 라이브러리에서 이상한 값을 매핑하는것을 미리 확인할 수 있기 때문에 우리가 매퍼 클래스를 만들어서 매핑을 해야하는 보일러플레이트 코드를 제거할 수 있는 장점이 있기 때문에 나는 매퍼 클래스 사용을 동의한다. 

 

조직개편을 하면서 함께 일하게된 동료분과 새로운 프로젝트에 도입해보고 있다. 어떨지는 더 사용해봐야겠지만 현재로써는 만족스럽다. 같이 더 나은것을 찾아보고자 노력해주는분과 동료가 되어 좋고 앞으로 함께 잘해봤음 좋겠다.

 

 

https://mapstruct.org/

 

MapStruct – Java bean mappings, the easy way!

Java bean mappings, the easy way! Get started Download

mapstruct.org

https://github.com/weduls/mapstructtest/tree/master/src/main/java/com/wedul/mapstructtest

 

반응형