Map Struct 사용하기 - 1

API 서버 등 시스템을 개발하면 꼭 마주하는 것이 계층 간의 데이터 전달이다. 보통 사용자의 요청부터 데이터, 데이터에서 응답까지 하나로 묶지 않고 3개의 계층을 시스템을 나누어서 개발한다. 이 3개의 계층은 사용자의 요청과 응답을 담당하는 프레젠테이션 계층과 핵심 비즈니스 로직을 담당하는 비즈니스 계층, 실제 데이터를 접근해서 관리하는 데이터 계층으로 나뉜다. 그리고 이 계층 사이에서 하나의 객체가 아닌 서로 목적에 맞게끔 다양한 객체로 나타내어 데이터를 전달한다.

계층 사이에서 전달할 때 사용하는 객체를 DTO(Data Transfer Object)라 한다. 그리고 데이터 계층에서 테이블과 1:1로 매핑되어 객체로 나타낸 객체를 Entity라 한다. 추가적으로 DAO(Data Access Object)라 하여 데이터 접근을 위해 사용하는 객체가 있지만, 이미 Entity가 어느 정도 역할을 수행하고 있다고 보면된다. 그렇다고 해서 DAO가 필요하지 않은 것은 아니다. Entity로 표현하지 않고 일부 컬럼에 대해서 데이터를 가져올 때, 별도의 DAO 객체를 사용하기 때문이다.

현재까지 애플리케이션 서버를 개발하면서 DTO와 DTO 사이의 데이터 전달, DTO와 Entity 사이의 데이터를 전달할 때마다 변환해주는 함수를 두었다. 이 데이터 매핑 과정에서 별다른 비즈니스 로직이 없어도 하나하나씩 다 만들었다. 계속해서 반복하는 작업이었지만, 늘 그랬왔듯이 함수를 만들었다. 어쩔때는 Entity 객체를 그대로 사용자의 응답까지 사용한 경우도 허다했다. 조금이나마 이 반복하는 작업을 개선하고 싶었다.

그러다 문득  1-2년차때 읽었던 Model Mapper에 대한 블로그 글을 찾아봤다(블로그 글을 수집해놓은 것이 도움이 되었다). 이전에 무심코 읽었던 블로그 글이다. 그리고 블로그 코멘트 중 Map Struct를 발견했다.

Spring Framework에서 Map Struct 사용하기

Map Struct는 데이터 매핑을 하고 싶은 객체에 대한 인터페이스를 만들고 annotation을 사용하면 컴파일 시점에 인터페이스를 기반으로 하는 구체 클래스(concrete class)를 생성한다. 이 자동으로 생성된 구현 클래스를 스프링 컨텍스트의 빈(bean)으로 등록해서 사용하기 위해서는 @Autowried 가 필요하다. 공식 문서에 따르면 componentModel를 'spring’으로 지정하면 @Autowired 를 넣어서 구체 클래스를 생성한다.

Lombok의 Builder와 함께 Map Struct 사용하기

Gradle 설정(build.gradle)

// To use Map Struct and Lombok together,
// Refer to this: https://github.com/mapstruct/mapstruct-examples/tree/master/mapstruct-lombok
implementation 'org.projectlombok:lombok'
implementation 'org.mapstruct:mapstruct:1.4.2.Final'

annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'

거의 기본으로 사용하는 롬복(lombok)과 같이 사용하기 위해서는 위와 같이 디펜던시(dependency)설정이 필요하다. 최신화를 하지 않았으니 실제 사용하는 시점에서 달라질 수 있다.

사용 예시

Entity 객체에서 DTO 객체로 매핑하기(MSE-001-basic-usage)

@ToString
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "person")
@Entity
public class Person {
  @GeneratedValue(strategy = GenerationType.AUTO)
  @Id
  private Long id;
  @Column(length = 100)
  private String name;
}

Entity 객체로 Person.class 를 살펴보자. @AllArgsConstructor(access = AccessLevel.PRIVATE) 를 두어 외부에서 Entity 객체를 함부러 생성하지 못하도록 제한하고 @NoArgsConstructor(access = AccessLevel.PROTECTED) 를 두어 외부에서 생성하지 못하나 JPA에서 프록시 객체를 생성할 때, 사용할 수 있도록 만들었다. 그리고 @Builder 로만 통해서 객체를 생성할 수 있도록 접근을 열어두었다.

@ToString
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class PersonResponse {
  private final Long id;
  private final String name;
}

DTO 객체로 PersonResponse.class 를 보자. Entity 객체의 값을 그대로 담을 수 있는 하나의 객체이다. Entity와 DTO 사이의 데이터 매핑을 하기 위해서 Map Struct를 활용해보자.

@Mapper(componentModel = "spring")
public interface PersonResponseMapper {
    PersonResponse map(Person entity);
    List<PersonResponse> map(List<Person> entities);
}

인터페이스를 두어 Entity와 DTO 사이의 함수로 나타내주고 @Mapper 어노테이션을 통해 Map Struct 대상임을 나타냈다. 이 상황에서 컴파일을 진행하면 PersonResponseMapperImpl.class 라는 구체 클래스가 생성된다. 앞서 말했던 것처럼 ‘spring’으로 componentModel  값을 지정하면 구체 클래스인 PersonResponseMapperImpl.class 에 @Autowired 어노테이션이 붙여진채로 생성이 된다.

상속 관계에서 매핑하기(MSE-002-inheritance-usage)

@ToString
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseDatetime {
  @CreatedDate
  private LocalDateTime createdAt;
  @LastModifiedDate
  private LocalDateTime lastModifiedAt;
}

보통 Entity 마다 공통으로 들어가는 날짜에 대한 필드는 하나의 공통 객체로 둔다. 그리고 각각의 Entity 객체들은 이 공통 객체를 상속한다. 이렇게 상속 구조를 가진 객체도 Map Struct가 이해할 수 있을까.

@ToString
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class PersonResponse {
  private final Long id;
  private final String name;
  private final LocalDateTime createdAt;
  private final LocalDateTime lastModifiedAt;
}

DTO객체인 PersonResponse.class 객체에 날짜에 대한 필드를 추가하면 데이터가 매핑될 수 있도록 구체 클래스가 생성된다. 두 객체 사이에서 매핑할 필드들만 잘 정의한다면 Map Struct가 알아서 이를 반영한다.