OverView

JPA 기반의 API 서버를 구축할때 엔티티를 그대로 반환하는 것은 보안, 성능상 위험할 수 있다. 때문에 DTO(Data Transfer Object) 라는 별도의 값 엔티티를 만들어서 사용한다. DTO를 생성하는 방법은 여러가지인데 정적 팩터리 메서드를 제공해서 사용하는 방법, ModelMapper, Orika, Jmapper, Mapstruct 같은 별도의 라이브러리를 사용하는 방법 등등 여러가지가 있다. 이번시간에는 차례대로 Mapstruct, Jmapper, Orika를 사용해서 조금 더 쉽게 엔티티를 DTO로 변환하는 방법에 대해서 알아보도록 하겠다.

참고로 이 순서는 baeldung에서 java-performance-mapping-frameworks 이라는 포스팅을 통해서 가장 성능이 좋은 mapping 라이브러리 3개를 선정했다. 관심이 있다면 참고해도 좋을 것 같다.

시작하기 전에

Order라는 객체 내부에는 CustomerAddress 타입의 멤버변수를 갖고 있다. 클라이언트에게는 단순히 flat된 형태로 보내야하는 요구사항이 있어서 OrderDto 타입으로 변환해야 한다는 간단한 시나리오가 존재한다.

Order.java

package me.sup2is;

public class Order {

    private Customer customer;
    private Address billingAddress;
    private Address shippingAddress;

    public Order() {
    }

    public Order(Customer customer, Address billingAddress, Address shippingAddress) {
        this.customer = customer;
        this.billingAddress = billingAddress;
        this.shippingAddress = shippingAddress;
    }

// getter and setter
}

Customer.java

package me.sup2is;

public class Customer {
    private String name;

    public Customer() {
    }


    public Customer(String name) {
        this.name = name;
    }


// getter and setter
}

Address.java

package me.sup2is;

public class Address {

    private String street;
    private String city;

    public Address() {
    }

    public Address(String street, String city) {
        this.street = street;
        this.city = city;
    }

// getter and setter
}


OrderDto.java

package me.sup2is;

public class OrderDto {

    private String customerName;
    private String shippingStreetAddress;
    private String shippingCity;
    private String billingStreetAddress;
    private String billingCity;

    public OrderDto() {
    }

    public OrderDto(String customerName, String shippingStreetAddress, String shippingCity, String billingStreetAddress, String billingCity) {
        this.customerName = customerName;
        this.shippingStreetAddress = shippingStreetAddress;
        this.shippingCity = shippingCity;
        this.billingStreetAddress = billingStreetAddress;
        this.billingCity = billingCity;
    }

//getter and setter

}

이제 MapStruct, JMapper, Orika를 사용해서 객체 매핑을 구현해보도록 하겠다.

MapStruct

MapStruct는 apache 2.0 라이센스를 갖고 있고 2020년 7월 기준으로 스타 약 2.8k를 보유하고 있는 대표적인 매핑 라이브러리중 하나이다. 자세한 정보는 https://github.com/mapstruct/mapstruct에서 확인할 수 있다.

MapStruct는 java 애너테이션 기반의 매핑 프레임워크이고 다음과 같이 인터페이스를 선언하는 방식으로 사용한다.

@Mapper
public interface CarMapper {
 
    CarMapper INSTANCE = Mappers.getMapper( CarMapper.class );
 
    @Mapping(source = "numberOfSeats", target = "seatCount")
    CarDto carToCarDto(Car car);
}

인터페이스를 선언함과 동시에 MapStruct가 자동으로 컴파일타임에 구현체를 만들어 실제 사용 가능한 상태로 만든다.

MapStruct의 장점 세가지에 대해 소개한다.

  1. 리플렉션을 사용하지 않고 메서드 호출을 사용해서 빠른 실행
  2. 컴파일 타임에 타입을 검사하고 명시적이다.
  3. 컴파일 타임에 잘못된 정보를 알려준다.

간단한 엔티티 클래스들을 생성하고 MapStruct를 사용해서 매핑을 구현해보자.

MapStruct 사용하기

먼저 의존성을 다음과 같이 추가한다.

        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>1.3.1.Final</version>
        </dependency>

...

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>1.3.1.Final</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

가장 기본적인 방법으로 @Mapper 애너테이션과 @Mapping 애너테이션을 혼합한 형태로 사용 가능하다.

MapStructOrderMapper.java

package me.sup2is.modelmapper;

import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;

@Mapper
public interface MapStructOrderMapper {

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

    @Mapping(source = "customer.name", target = "customerName")
    @Mapping(source = "shippingAddress.street", target = "shippingStreetAddress")
    @Mapping(source = "shippingAddress.city", target = "shippingCity")
    @Mapping(source = "billingAddress.street", target = "billingStreetAddress")
    @Mapping(source = "billingAddress.city", target = "billingCity")
    OrderDto orderToOrderDto(Order order);

    @InheritInverseConfiguration
    Order orderDtoToOrder(OrderDto orderDTO);

}

@Mapper 애너테이션을 사용해서 이 인터페이스가 MapStruct 객체임을 알리면 MapStruct가 알아서 자동으로 MapStructOrderMapperImpl.java 라는 구현체를 만들어낸다.

@Mapping 애너테이션을 사용해서 sourcetarget을 지정할 수 있다. target 옵션의 경우 반환되는 타입인 OrderDto 클래스에 매핑할 변수명이되고 source 옵션은 파라미터로 제공되는 Order 클래스의 매핑될 변수명이된다. 객체간 탐색은 .을 사용해서 탐색할 수 있다.

@InheritInverseConfiguration을 사용해서 역방향으로 객체를 매핑하는것 역시 가능하다.

준비가완료됐다면 컴파일을 한 뒤에 target 디렉토리에서 MapStructOrderMapperImpl.class가 적절하게 생성되었는지 확인해보자.

자동으로 만들어진 MapStructOrderMapperImpl.class

package me.sup2is.modelmapper;

public class MapStructOrderMapperImpl implements MapStructOrderMapper {
    public MapStructOrderMapperImpl() {
    }

    public OrderDto orderToOrderDto(Order order) {
        if (order == null) {
            return null;
        } else {
            OrderDto orderDto = new OrderDto();
            orderDto.setBillingStreetAddress(this.orderBillingAddressStreet(order));
            orderDto.setShippingCity(this.orderShippingAddressCity(order));
            orderDto.setShippingStreetAddress(this.orderShippingAddressStreet(order));
            orderDto.setCustomerName(this.orderCustomerName(order));
            orderDto.setBillingCity(this.orderBillingAddressCity(order));
            return orderDto;
        }
    }

    
    ...
        
}

테스트

객체 매핑이 잘 이뤄지는지 간단한 테스트를 작성해보도록 하자

MapStructTest.java

package me.sup2is;

import me.sup2is.modelmapper.*;
import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class MapStructTest {

    @Test
    public void basic_mapping() {
        //given
        Address billingAddress = new Address("경리단길", "서울");
        Address shippingAddress = new Address("광평로", "서울");
        Customer customer = new Customer("sup2is");
        Order order = new Order(customer, billingAddress, shippingAddress);

        //when
        OrderDto orderDTO = MapStructOrderMapper.INSTANCE.orderToOrderDto(order);

        //then
        assertEquals(billingAddress.getCity(), orderDTO.getBillingCity());
        assertEquals(billingAddress.getStreet(), orderDTO.getBillingStreetAddress());
        assertEquals(shippingAddress.getCity(), orderDTO.getShippingCity());
        assertEquals(shippingAddress.getStreet(), orderDTO.getShippingStreetAddress());
        assertEquals(customer.getName(), orderDTO.getCustomerName());

    }

    @Test
    public void inverse() {
        //given
        Address billingAddress = new Address("경리단길", "서울");
        Address shippingAddress = new Address("광평로", "서울");
        Customer customer = new Customer("sup2is");
        Order order = new Order(customer, billingAddress, shippingAddress);
        OrderDto orderDTO = MapStructOrderMapper.INSTANCE.orderToOrderDto(order);

        //when
        Order toOrder = MapStructOrderMapper.INSTANCE.orderDtoToOrder(orderDTO);

        //then
        assertEquals(orderDTO.getBillingCity(), toOrder.getBillingAddress().getCity());
        assertEquals(orderDTO.getBillingStreetAddress(), toOrder.getBillingAddress().getStreet());
        assertEquals(orderDTO.getCustomerName(), toOrder.getCustomer().getName());
        assertEquals(orderDTO.getShippingCity(), toOrder.getShippingAddress().getCity());
        assertEquals(orderDTO.getShippingStreetAddress(), toOrder.getShippingAddress().getStreet());
    }

}

양방향으로 매핑이 잘 되는것을 확인할 수 있다. MapStruct에 대한 자세한 정보는 https://mapstruct.org/documentation/dev/reference/html/에서 확인할 수 있다.

JMapper

두번째로 JMapper를 소개한다. JMapper 역시 오픈소스 프로젝트이고 apache 2.0 라이센스를 갖고 있다. JMapper에 대한 자세한 정보는 https://github.com/jmapper-framework/jmapper-core 에서 확인할 수 있다.

JMapper는 애너테이션기반, xml 기반, api 형태로 다양한 매핑 방법을 지원한다.

Annotation

class Destination{                      class Source{
    @JMap
    private String id;                      private String id;
    @JMap("sourceField")                    
    private String destinationField;        private String sourceField;
    private String other;                   private String other;

    // getters and setters...               // getters and setters...
}                                       }

XML

<jmapper>
  <class name="it.jmapper.bean.Destination">
    <attribute name="id">
      <value name="id"/>
    </attribute>
    <attribute name="destinationField">
      <value name="sourceField">
    </attribute>
  </class>
</jmapper>

API

JMapperAPI jmapperAPI = new JMapperAPI()
    .add(mappedClass(Destination.class)
             .add(attribute("id")
                     .value("id"))
             .add(attribute("destinationField")
                     .value("sourceField")));

아래는 JMapper가 제공하는 기능에 대해 나열했다.

JMapper 사용하기

JMapper를 사용하기위해 의존성을 추가해준다.

        <dependency>
            <groupId>com.googlecode.jmapper-framework</groupId>
            <artifactId>jmapper-core</artifactId>
            <version>1.6.0.1</version>
        </dependency>

앞에서 설명한것처럼 JMapper를 사용하는 방법은 총 세가지인데 API를 사용하는 방법을 먼저 알아보도록 하겠다. JMapper에서 제공하는 JMapperAPI를 사용해서 다음과 같이 객체 매핑을 구현할 수 있다.

JMapperTest.java

package me.sup2is;

import com.googlecode.jmapper.JMapper;
import com.googlecode.jmapper.api.JMapperAPI;
import me.sup2is.modelmapper.*;
import org.junit.Test;

import static com.googlecode.jmapper.api.JMapperAPI.attribute;
import static org.junit.Assert.assertEquals;

public class JMapperTest {
    
    @Test
    public void jmapper_using_api() {
        //given
        JMapperAPI jMapperAPI = new JMapperAPI()
                .add(JMapperAPI.mappedClass(OrderDto.class)
                        .add(attribute("customerName").value("${customer.name}"))
                        .add(attribute("shippingStreetAddress").value("${shippingAddress.street}"))
                        .add(attribute("shippingCity").value("${shippingAddress.city}"))
                        .add(attribute("billingStreetAddress").value("${billingAddress.street}"))
                        .add(attribute("billingCity").value("${billingAddress.city}")));

        JMapper<OrderDto, Order> orderMapper = new JMapper<>(OrderDto.class, Order.class, jMapperAPI);
        Address billingAddress = new Address("경리단길", "서울");
        Address shippingAddress = new Address("광평로", "서울");
        Customer customer = new Customer("sup2is");
        Order order = new Order(customer, billingAddress, shippingAddress);

        //when
        OrderDto orderDto = orderMapper.getDestination(order);

        //then
        assertEquals(billingAddress.getCity(), orderDto.getBillingCity());
        assertEquals(billingAddress.getStreet(), orderDto.getBillingStreetAddress());
        assertEquals(shippingAddress.getCity(), orderDto.getShippingCity());
        assertEquals(shippingAddress.getStreet(), orderDto.getShippingStreetAddress());
        assertEquals(customer.getName(), orderDto.getCustomerName());
    }
}

애너테이션 기반으로도 다음과 같이 사용할 수 있다. 별도의 JMapperOderDto 라는 클래스를 만들어서 @JMap 애너테이션으로 매핑될 필드명을 지정해줄 수 있다.

package me.sup2is.modelmapper;

import com.googlecode.jmapper.annotations.JMap;

public class JMapperOrderDto {

    @JMap("${customer.name}")
    private String customerName;
    @JMap("${shippingAddress.street}")
    private String shippingStreetAddress;
    @JMap("${shippingAddress.city}")
    private String shippingCity;
    @JMap("${billingAddress.street}")
    private String billingStreetAddress;
    @JMap("${billingAddress.city}")
    private String billingCity;

//getter and setter
    
}

실제 사용방법은 아래와 같다.

JMapperTest.java


    @Test
    public void jmapper_using_annotation() {
        JMapper<JMapperOrderDto, Order> orderMapper = new JMapper<>(JMapperOrderDto.class, Order.class);
        Address billingAddress = new Address("경리단길", "서울");
        Address shippingAddress = new Address("광평로", "서울");
        Customer customer = new Customer("sup2is");
        Order order = new Order(customer, billingAddress, shippingAddress);

        //when
        JMapperOrderDto orderDto = orderMapper.getDestination(order);

        //then
        assertEquals(billingAddress.getCity(), orderDto.getBillingCity());
        assertEquals(billingAddress.getStreet(), orderDto.getBillingStreetAddress());
        assertEquals(shippingAddress.getCity(), orderDto.getShippingCity());
        assertEquals(shippingAddress.getStreet(), orderDto.getShippingStreetAddress());
        assertEquals(customer.getName(), orderDto.getCustomerName());
    }

추가적으로 JMapper에는 Relational Mapping 이라는 기능이 있는데 아래와같이 User 객체 한개로 UserDto1, UserDto2를 동시에 매핑하게 해주는 기술이다.

public class User {
    private long id;    
    private String email;
}

public class UserDto1 {  
    private long id;
    private String username;
}

public class UserDto2 {
    private long id;
    private String email;
}

@Test
public void givenUser_whenUseApi_thenConverted(){
    JMapperAPI jmapperApi = new JMapperAPI()
      .add(mappedClass(User.class)
      .add(attribute("id")
        .value("id")
        .targetClasses(UserDto1.class,UserDto2.class))
      .add(attribute("email")
        .targetAttributes("username","email")
        .targetClasses(UserDto1.class,UserDto2.class)));
    
    RelationalJMapper<User> relationalMapper = new RelationalJMapper<>
      (User.class,jmapperApi);
    User user = new User(1L,"john@test.com");
    UserDto1 result1 = relationalMapper
      .oneToMany(UserDto1.class, user);
    UserDto2 result2 = relationalMapper
      .oneToMany(UserDto2.class, user);
 
    assertEquals(user.getId(), result1.getId());
    assertEquals(user.getEmail(), result1.getUsername());
    assertEquals(user.getId(), result2.getId());
    assertEquals(user.getEmail(), result2.getEmail());            
}

relationalMapper.oneToMany() 메서드를 사용해서 두가지 형태의 Dto를 매핑하는 것을 확인할 수 있다. JMapper에 더욱 자세한 내용은 https://jmapper-framework.github.io/jmapper-core/에서 확인할 수 있다. JMapper는 두번째로 빠른 속도를 갖고 있지만 점유율은 매우 낮은 편이다.

Orika

마지막으로 Orika에 대해서 알아보도록 하겠다. Orika도 apache 2.0 라이센스 오픈소스 프로젝트이다. 한국에서는 많이 사용하지 않지만 점유율이 꽤 높은편에 속한다. https://github.com/orika-mapper/orika

다음은 Orika가 제공하는 기능들이다.

  • Map complex and deeply structured objects
  • “Flatten” or “Expand” objects by mapping nested properties to top-level properties, and vice versa
  • Create mappers on-the-fly, and apply customizations to control some or all of the mapping
  • Create converters for complete control over the mapping of a specific set of objects anywhere in the object graph–by type, or even by specific property name
  • Handle proxies or enhanced objects (like those of Hibernate, or the various mock frameworks)
  • Apply bi-directional mapping with one configuration
  • Map to instances of an appropriate concrete class for a target abstract class or interface
  • Map POJO properties to Lists, Arrays, and Maps

Orika 사용하기

의존성을 추가해준다.

        <dependency>
            <groupId>ma.glasnost.orika</groupId>
            <artifactId>orika-core</artifactId>
            <version>1.5.4</version>
        </dependency>

다른 라이브러리들과 비슷하게 사용하기때문에 크게 어려운 점은 없을 것 같다.

OrikaTest.java

package me.sup2is;

import ma.glasnost.orika.BoundMapperFacade;
import ma.glasnost.orika.MapperFacade;
import ma.glasnost.orika.MapperFactory;
import ma.glasnost.orika.converter.ConverterFactory;
import ma.glasnost.orika.impl.DefaultMapperFactory;
import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class OrikaTest {

    @Test
    public void orika_mapping() {
        //given
        MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
        mapperFactory.classMap(OrderDto.class, Order.class)
                .field("customerName","customer.name")
                .field("shippingStreetAddress", "shippingAddress.street")
                .field("shippingCity", "shippingAddress.city")
                .field("billingStreetAddress", "billingAddress.street")
                .field("billingCity", "billingAddress.city")
                .register();


        Address billingAddress = new Address("경리단길", "서울");
        Address shippingAddress = new Address("광평로", "서울");
        Customer customer = new Customer("sup2is");
        Order order = new Order(customer, billingAddress, shippingAddress);

        //when
        MapperFacade mapperFacade = mapperFactory.getMapperFacade();
        OrderDto orderDto = mapperFacade.map(order, OrderDto.class);

        //then
        assertEquals(billingAddress.getCity(), orderDto.getBillingCity());
        assertEquals(billingAddress.getStreet(), orderDto.getBillingStreetAddress());
        assertEquals(shippingAddress.getCity(), orderDto.getShippingCity());
        assertEquals(shippingAddress.getStreet(), orderDto.getShippingStreetAddress());
        assertEquals(customer.getName(), orderDto.getCustomerName());
    }

양방향 매핑, 매핑과정에서 특정 로직을 수행하고싶다면 별도의 Converter를 생성할 수 있다.

MyOrikaConverter.java

package me.sup2is;

import ma.glasnost.orika.MappingContext;
import ma.glasnost.orika.converter.BidirectionalConverter;
import ma.glasnost.orika.metadata.Type;

public class MyOrikaConverter extends BidirectionalConverter<Order, OrderDto> {
    @Override
    public OrderDto convertTo(Order source, Type<OrderDto> destinationType, MappingContext mappingContext) {
        return new OrderDto(source.getCustomer().getName(),
                source.getShippingAddress().getStreet(),
                source.getShippingAddress().getCity(),
                source.getBillingAddress().getStreet(),
                source.getBillingAddress().getCity());
    }

    @Override
    public Order convertFrom(OrderDto source, Type<Order> destinationType, MappingContext mappingContext) {
        return new Order(new Customer(source.getCustomerName()),
                new Address(source.getBillingStreetAddress(), source.getBillingCity()),
                new Address(source.getShippingStreetAddress(), source.getShippingCity()));
    }
}

이렇게 생성한 Converter를 등록시켜주기만 하면 정상적으로 동작하는것을 확인할 수 있다. BoundMapperFacade 를 사용해서 양방향 으로 매핑이 잘 되는지 확인해 보자.

    @Test
    public void orika_map_reverse() {
        //given
        MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
        ConverterFactory converterFactory = mapperFactory.getConverterFactory();
        converterFactory.registerConverter(new MyOrikaConverter());

        Address billingAddress = new Address("경리단길", "서울");
        Address shippingAddress = new Address("광평로", "서울");
        Customer customer = new Customer("sup2is");
        Order order = new Order(customer, billingAddress, shippingAddress);
        BoundMapperFacade<Order, OrderDto> boundMapperFacade = mapperFactory.getMapperFacade(Order.class, OrderDto.class);
        OrderDto orderDto = boundMapperFacade.map(order);

        //when
        Order toOrder = boundMapperFacade.mapReverse(orderDto);

        //then
        assertEquals(orderDto.getBillingCity(), toOrder.getBillingAddress().getCity());
        assertEquals(orderDto.getBillingStreetAddress(), toOrder.getBillingAddress().getStreet());
        assertEquals(orderDto.getCustomerName(), toOrder.getCustomer().getName());
        assertEquals(orderDto.getShippingCity(), toOrder.getShippingAddress().getCity());
        assertEquals(orderDto.getShippingStreetAddress(), toOrder.getShippingAddress().getStreet());
    }

테스트가 문제없이 동작하는 것을 확인할 수 있다.

마무리

총 세가지의 Object Mapping 라이브러리들을 확인했는데 점유율 순으로 보면 MapStruct가 압도적인 1위를 차지하고있다. 많은 사람들이 사용하기때문에 그만큼 성능도 보장되어있고 새로운 기능이나 버그픽스가 더 빠르게 이루어질것이다. 그리고 실제로 사용했을때도 MapStruct를 사용할때 코드가 가장 깔끔하고 컴파일타임에 거의 웬만한 에러를 감지할 수 있어서 개발 단계에서도 빠르게 사용할 수 있을 것 같다.


포스팅은 여기까지 하겠습니다. 퍼가실때는 출처를 반드시 남겨주세요!

예제: https://github.com/sup2is/study/tree/master/java/java-object-mapping

References