찾기 힘든 버그를 유발하는 Java DTO 컨버팅 노가다, 리팩토링하기

자바에서 DTO 컨버팅을 할 때 멤버 필드를 하나하나 명시해서 변환을 많이 하곤 합니다.
이럴 경우 휴먼 에러가 발생 할 확률이 매우 높습니다. 같이 아래 예시를 볼까요?
(본문 코드는 github에서 확인 가능합니다.)

여기 BookDTO와 BookVO가 있습니다.
DTO에서 VO로 혹은 반대로 VO에서 DTO로, 엔티티로 등등 다른 객체 타입으로 컨버팅을 하는 일은 실무에서 비일비재합니다.

public class BookDTO {
	private String title;
	private String author;
	private CurrencyType currencyType;
	private Date publishedAt;
	private Long price;
}

public class BookVO {
	private String title;
	private String author;
	private CurrencyType currencyType;
	private Date createAt;
	private Long price;
}

휴먼 에러 예시

1. 필드를 잘못 매핑

손가락이 가는대로 타이핑을 신명나게 하다보니 2번째 setAuthor에 getTitle로 set을 하고 말았습니다. 하지만 컴파일 에러가 없기 때문에 값 비교를 하지 않는 한 알기 어렵죠. 이런 케이스는 운영에 배포 후 버그나 장애로 발견되지 않는 한 개발 단계에서 사실 눈으로 확인하기 어렵습니다.

private BookVO convert(BookDTO bookDTO) {
	BookVO bookVO = new BookVO();
	bookVO.setTitle(bookDTO.getTitle());
	bookVO.setAuthor(bookDTO.getTitle()); // 다른 필드를 set 해줬지만 컴파일 에러가 없기 때문에 인지하기 어렵습니다.
	bookVO.setPrice(bookDTO.getPrice());
	bookVO.setCurrencyType(bookDTO.getCurrencyType());
	bookVO.setCreateAt(bookDTO.getPublishedAt());
	return bookVO;
}

Book 예제는 멤버 필드가 한눈에 들어오지만 대부분의 서비스에서는 수십개의 필드들이 줄을 서 있습니다. 비슷한 필드명이 많아 헷갈리기 쉽고, 필요한 모든 필드를 set 해줘야 하는 수고로움도 많습니다.

2. 신규 필드 추가시 매핑 비용 추가

이번엔 부제목이 필요하여 subTitle field를 신규 추가했습니다.

public class BookDTO {
	private String title;
	private String subTitle;
	// (이하 생략)
}
public class BookVO {
	private String title;
	private String subTitle;
	// (이하 생략)
}

제가 이 코드 주인이기에 다행이도 convert() 메서드가 생각나서 신규 필드 매핑을 추가해줬습니다. 하지만 코드 주인도 신규 필드 매핑을 놓칠 수도 있고 새롭게 인수인계 받은 개발자도 이 컨버터를 놓칠 수 있어서 신규 필드 매핑 누락이 발생할 수 있는 여지가 충분히 있습니다.

private BookVO convert(BookDTO bookDTO) {
	BookVO bookVO = new BookVO();
	bookVO.setTitle(bookDTO.getTitle());
	bookVO.setAuthor(bookDTO.getAuthor());
	bookVO.setSubTitle(bookDTO.getSubTitle()); // 새로운 subTitle 매핑을 추가해줘야 합니다.
	bookVO.setPrice(bookDTO.getPrice());
	bookVO.setCurrencyType(bookDTO.getCurrencyType());
	bookVO.setCreateAt(bookDTO.getPublishedAt());
	return bookVO;
}

물론 TC를 잘 작성했다면 충분히 발견 가능합니다. 하지만 그 전에 좀 더 쉽고 빠르게 코드를 작성 할 수 있다면 불필요한 비용을 줄이고 리팩토링을 할 수 있는 포인트가 될 수 있습니다. 이와 같은 기능을 제공하는 라이브러리가 바로 ModelMapper입니다.

ModelMapper

ModelMapper의 목표는 오브젝트 매핑을 쉽게 만드는 것이다.
사람이 하는 것과 같이 규칙 기반으로 매핑방법을 자동으로 결정하는데,
이는 특정 케이스 핸들링을 위한 리팩토링 안전 API를 제공함으로써 가능하다.

modelmapper.org

Dependency

  • model-mapper dependency
  • gradle
    compile group: 'org.modelmapper', name: 'modelmapper', version: '2.3.0'
  • maven
    <dependency>
        <groupId>org.modelmapper</groupId>
        <artifactId>modelmapper</artifactId>
        <version>2.3.0</version>
    </dependency>

필드 매핑을 한땀 한땀 손으로 해주었던 convert() 코드를 ModelMapper를 이용해 바꿔보겠습니다.

@Test
public void test_use_model_mapper() {
	ModelMapper modelMapper = new ModelMapper();
	BookVO bookVO = modelMapper.map(bookDTO, BookVO.class);

	Assert.assertEquals(bookVO.getTitle(), bookDTO.getTitle());
	Assert.assertEquals(bookVO.getAuthor(), bookDTO.getAuthor());
	Assert.assertEquals(bookVO.getSubTitle(), bookDTO.getSubTitle());
	Assert.assertEquals(bookVO.getCurrencyType(), bookDTO.getCurrencyType());
}

검증 코드를 빼면 단 2줄 만에 오브젝트 매핑이 이뤄졌습니다.
자 이번에는 BookDTO 및 BookVO는 필드명이 서로 같았지만 필드명이 서로 다르고, 사용자 정의 타입으로 선언된 필드들을 갖는 Order객체를 OrderDTO로 컨버팅 해보겠습니다.

public class Order {
	Customer customer;
	Address billingAddress;

	public static class Customer {
		Name name;
	}

	public static class Name {
		String firstName;
		String lastName;
	}

	public static class Address {
		String street;
		String city;
	}

}

public class OrderDTO {
	String customerFirstName;
	String customerLastName;
	String billingStreet;
	String billingCity;
}
@Test
public void test_convert_order_to_dto() {
	Order order = new Order()
		.setCustomer(new Order.Customer()
			.setName(new Order.Name()
			.setFirstName("DEV")
			.setLastName("BAEK")))
		.setBillingAddress(new Order.Address()
			.setCity("SEOUL")
			.setStreet("GAROSU-GIL"));

	ModelMapper modelMapper = new ModelMapper();
	OrderDTO orderDTO = modelMapper.map(order, OrderDTO.class);

	log.debug("order={}", order);
	log.debug("dto={}", orderDTO);

	Assert.assertEquals(order.getCustomer().getName().getFirstName(), orderDTO.getCustomerFirstName());
	Assert.assertEquals(order.getCustomer().getName().getLastName(), orderDTO.getCustomerLastName());
	Assert.assertEquals(order.getBillingAddress().getStreet(), orderDTO.getBillingStreet());
	Assert.assertEquals(order.getBillingAddress().getCity(), orderDTO.getBillingCity());
}

Matching Strategy

위와 같이 필드명이 정확히 일치하지 않아도 객체 필드명을 유추하여 지능적으로 맵핑해주는 전략은 Standard Matching Strategy 라고 합니다. ModelMapper를 생성자로 생성하면 기본 STANDARD 전략이 셋팅됩니다. 맵핑 전략은 STANDARD 외에 LOOSE와 STRICT가 있습니다.

  • STANDARD ( default )
    • 소스 속성을 대상 속성과 지능적으로 일치시킬 수 있음
    • 모든 대상 속성이 일치하고, 모든 소스 속성 이름에 토큰이 하나 이상 일치해야 함
    • 규칙
      • 토큰은 어떤 순서 로도 일치시킬 수 있음
      • 모든 대상 속성 이름 토큰이 일치해야 함
      • 모든 소스 속성 이름에는 일치하는 토큰이 하나 이상 있어야 함
  • LOOSE
    • 계층 구조의 마지막 대상 속성 만 일치하도록 하여 소스 속성을 대상 속성에 느슨하게 일치시킬 수 있음
    • 규칙
      • 토큰은 어떤 순서 로도 일치시킬 수 있음
      • 마지막 대상 속성 이름은 모든 토큰이 일치해야 함
      • 마지막 소스 특성 이름에는 일치하는 토큰이 하나 이상 있어야함
  • STRICT
    • 소스 속성을 대상 속성과 엄격하게 일치
    • 완벽한 일치 정확도를 허용하여 불일치 또는 모호성이 발생하지 않도록함
    • 소스와 대상 측의 속성 이름 토큰이 서로 정확하게 일치해야 함
    • 규칙
      • 토큰은 엄격한 순서로 일치
      • 모든 대상 속성 이름 토큰이 일치해야 함
      • 모든 소스 속성 이름에는 모든 토큰이 일치해야 함

반드시 필드명이 일치해야만 매핑을 하고자 한다면 STRICT 전략을 선택해야 합니다.

필드명이 다르지만 맵핑해야하는 경우

DTO와 VO에서 Date 타입의 필드명(createdAt, publishedAt)이 서로 다릅니다.

public class BookDTO {
	private String title;
	private String author;
	private CurrencyType currencyType;
	private Date publishedAt;
	private Long price;
}

public class BookVO {
	private String title;
	private String author;
	private CurrencyType currencyType;
	private Date createAt;
	private Long price;
}

따라서 두 필드는 서로 매핑되지 않습니다. 토큰에 유사포인트가 하나도 없죠.

@Test
public void test_not_mapping_createdAt_by_publishedAt() {
	ModelMapper modelMapper = new ModelMapper();
	BookVO bookVO = modelMapper.map(bookDTO, BookVO.class);
	Assert.assertNotEquals(bookVO.getCreateAt(), bookDTO.getPublishedAt());
}

저는 이 두개의 필드가 서로 매핑이 되면 좋겠습니다. 방법은 객체에 setter를 별로도 추가하거나 혹은 수기로 setter로 맵핑 작성해 해결 할 수 있습니다. 하지만 ModelMapper의 PropertyMap을 사용해서 매핑룰을 추가할 수 있습니다.

@Test
public void test_add_mappings() {
	PropertyMap<BookDTO, BookVO> bookMap = new PropertyMap<BookDTO, BookVO>() {
		protected void configure() {
			map().setCreateAt(source.getPublishedAt());
		}
	};

	ModelMapper modelMapper = new ModelMapper();
	modelMapper.addMappings(bookMap);
	BookVO bookVO = modelMapper.map(bookDTO, BookVO.class);

	Assert.assertEquals(bookVO.getCreateAt(), bookDTO.getPublishedAt());
}

잘 매핑이 되는 군요! PropertyMap의 제너릭의 순서의 의미는 <Source, Destination>인 점을 유의하면 됩니다.

매핑이 잘 되었는지 확인

만약 위에서 언급한 PropertyMap을 이용한 맵핑 룰을 추가하지 않았다면 createdAt과 publishedAt은 다른 값을 가지므로 매핑이 제대로 되지 않았다고 볼 수 있습니다.
필요하다면 두 객체간 필드가 잘 매핑되었는지 검증도 해볼 수 있는데 ModelMapper#validation 을 이용하면 됩니다. 이 메서드는 검증에 실패한 경우 Exception을 throw하기 때문에 호출 부에서 이를 반드시 고려해야 합니다.

먼저 검증에 실패한 경우는 다음과 같은 에러를 확인 할 수 있습니다.

org.modelmapper.ValidationException: ModelMapper validation errors:
1) Unmapped destination properties found in TypeMap[BookDTO -> BookVO]:
	dev.baek.modelmapper.book.BookVO.setCreateAt()
1 error
	at org.modelmapper.internal.Errors.throwValidationExceptionIfErrorsExist(Errors.java:246)
	at org.modelmapper.ModelMapper.validate(ModelMapper.java:547)

맵핑 룰을 추가해서 검증을 다시 수행하면 에러없이 성공합니다.

@Test
public void test_validate() {
	ModelMapper modelMapper = new ModelMapper();

	PropertyMap<BookDTO, BookVO> bookMap = new PropertyMap<BookDTO, BookVO>() {
		protected void configure() {
			map().setCreateAt(source.getPublishedAt());
		}
	};
	modelMapper.addMappings(bookMap);

	modelMapper.map(bookDTO, BookVO.class);
	modelMapper.validate();
}

자, 이렇게 간단히 ModelMapper 라이브러리를 살펴봤습니다.
적용이 어렵지 않기 때문에 현재 소스상에서 get/set을 정성스레 작성하고 계셨다면 이 라이브러리 도입을 통해 코드 리팩토링을 해보시는 건 어떨까요?
본문 코드는 github에서 확인 가능하며, 공식 사이트는 modelmapper.org 입니다.



쿠팡 파트너스 활동을 통해 일정액의 커미션을 제공받을 수 있습니다.