[Spring] Entity와 Dto.. 쉽게 변환할 수는 없을까?

무심코·2023년 3월 8일
1

Entity- DTO

JPA를 사용하다보면 Entity와 DTO를 변환하는 상황을 매번 마주친다. Controller 와 Service에서는 DTO가 전달되고 Service와 Repository에서는 Entity가 전달되니 사용자의 요청이 한번 들어온다면 Entity-DTO 사이의 변환은 필수적이다.

builder를 사용한 DTO 생성

Entity를 DTO로 변환할 때 가장 단순한 방법은 DTO를 직접 만들어주는 방법일 것이다.

@Builder
@Getter
public class GetMapsDto {
    private Integer map_id;

    private String map_name;

    private Integer lock_flag;

    private LocalDateTime create_at;
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "maps")
public class Maps extends BaseEntity {

    @Id
    @Column(name = "map_id")
    private Integer map_id;

    @Column(name = "map_name")
    private String map_name;

    @Column(name = "k_map_name", length = 25)
    private String k_map_name;

    @Column(name = "lock_flag")
    private Integer lock_flag;

    @Column(name = "start_node")
    private Integer start_node;

    @Column(name = "greedy_node")
    private String greedy_node;

    @Builder
    public Maps(Integer map_id, String map_name, String k_map_name, Integer lock_flag, Integer start_node,
                String greedy_node) {
        this.map_id = map_id;
        this.map_name = map_name;
        this.k_map_name = k_map_name;
        this.lock_flag = lock_flag;
        this.start_node = start_node;
        this.greedy_node = greedy_node;
    }

    // default 값으로 초기화
    @PrePersist
    public void prePersist() {
        this.lock_flag = this.lock_flag == null ? 0 : this.lock_flag;
        this.low_battery_judgment_value = this.low_battery_judgment_value == null ? 25 : this.low_battery_judgment_value;
        this.greedy_node = this.greedy_node == null ? "" : this.greedy_node;
    }
}

만약 다음과 같이 GetMapsDto DTO와 Maps Entity가 존재할때 builder를 사용하여 Dto를 직접 만들어 줄 수 있다.

GetMapsDto.builder()
         .map_id(mapsById.get().getMap_id())
         .map_name(mapsById.get().getMap_name())
         .lock_flag(mapsById.get().getLock_flag())
         .build();

하지만 매번 이와 같은 변환 방법은 dto, entity가 추가될때 마다 모든 부분에서 달라져야 한다는 점에서 비효율적이다. 또한 매번 builder를 사용하여 DTO나 Entity를 만들어줘야 한다는 점에서 생산성이 떨어진다. DTO와 Entity에서 사용하는 Field가 많으면 많아질수록 소요되는 비용은 늘어날 것이다.

그래 그럼 다른 방법이 없을까

좀더 깔끔한 방법이 존재한다.
DTO와 Entity간 객체 Mapping을 편하게 도와주는 라이브러리로 MapStruct 라이브러리가 존재한다. 비슷한 라이브러리로 ModelMapper가 존재하지만 ModelMapper는 변환과정에서 리플렉션(구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API)이 발생한다는 단점이 있어 MapStruct를 사용하기로 한다. MapStruct는 컴파일 시점에 구현체를 만들어내기 때문에 리플렉션이 발생하지 않아 빠른 변환처리가 가능하다.

또한 다른 Mapping Frameworks들과 비교하여 MapStruct가 같은 시간 높은 처리량과 빠른 속도를 보여준다.

MapStruct

사용 방법

그렇다면 사용 방법을 살펴보자
먼저 build.gradle 에 다음 dependency를 추가한다.

implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'

이후 다음과 같은 Mapper interface를 정의해준다.

@Mapper(componentModel = "spring")
public interface MapsMapper {
    MapsMapper MAPPER = Mappers.getMapper(MapsMapper.class);

	// Entity -> DTO
    GetMapsDto toDto(Maps maps); 
    
    // DTO -> Entity
    Maps toEntity(GetMapsDto);
}

이 상태만으로는 Mapping 진행이 안된다. 위와 같은 Mapper interface를 정의한 후에 Project Build를 실행해주면 다음과 같이 MapsMapper를 implements한 MapsMapperImpl가 생성된 것을 확인할 수 있다.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2023-03-08T09:56:28+0900",
    comments = "version: 1.4.2.Final, compiler: javac, environment: Java 17.0.1 (Oracle Corporation)"
)

@Component
public class MapsMapperImpl implements MapsMapper {

    @Override
    public GetMapsDto toDto(Maps maps) {
        if ( maps == null ) {
            return null;
        }

        GetMapsDtoBuilder getMapsDto = GetMapsDto.builder();

        getMapsDto.map_id( maps.getMap_id() );
        getMapsDto.map_name( maps.getMap_name() );
        getMapsDto.lock_flag( maps.getLock_flag() );
        getMapsDto.start_node( maps.getStart_node() );
        getMapsDto.greedy_node( maps.getGreedy_node() );
        getMapsDto.arrive_anisotropy( maps.getArrive_anisotropy() );

        return getMapsDto.build();
    }
}

MapsMapperImpl를 살펴보면 직접 Builder를 사용하는 방식과 동일하게 Maps Entity field중 GetMapsDto에서 사용하는 field를 선택하여 build() 하여주는 것을 확인할 수 있다.

이후 만약 @Service 단에서 Repository에서 받은 Entity를 DTO로 변환시킨다면 다음과 같이 Mapper를 사용할 수 있다.

private final MapsMapper mapsMapper;

...

public GetMapsDto getMapsByMapsId(int mapsId) throws BaseException {
		// 1. Repository에서부터 Entity 전달
        Optional<Maps> mapsById = mapsRepository.findById(mapsId);

        if (mapsById.isEmpty()) {
            throw new BaseException(BaseResponseStatus.INVALID_MAP_ID);
        }
        
        // 2. 전달받은 Entity를 toDto 메서드를 사용해 원하는 DTO로 변환
        return mapsMapper.MAPPER.toDto(mapsById.get());
    }

단점은 없을까?

MapStruct를 사용시 아래와 같은 단점들이 존재한다.

  1. 전혀 다른 형태의 필드 매핑을 시도하는 경우 제공되는 기능으로 해결 가능한 경우가 많으나, Mapping 로직이 매우 복잡해진다.

  2. 변경 불가능한 필드에 대한 매핑을 제공하지 못한다. (final 필드 - Constructor 주입)

  3. Lombok Library와 충돌이 발생할 수 있다. (실제로는 Lombok annotation processor가 getter나 builder 등을 만들기 전에 mapstruct annotation processor가 동작하여 매핑할 수 있는 방법을 찾지 못해 발생하는 문제이다.)

  4. 직접 수동으로 할 때는 컴파일 시점에 오류를 잡을 수 있는데, MapStruct을 사용하면 실행을 해봐야 오류를 찾을 수 있다.

우아한 형제들의 김영한님의 경우 복잡한 실무에서 엔티티를 DTO로 변경하는게 모든 상황에서 딱딱 맞아 떨어지지 않고 수동으로 작업하면 컴파일 시점에 오류를 잡을 수 있고 생각보다 금방 작성하여 굳이 사용하지 않으신다고 한다.

일단 사용하면서 아직까지는 Mapping 로직이 불필요하게 복잡해지거나 라이브러리 충돌로 인한 이슈는 발생하지 않고 있다. 컴파일 시점에서 오류를 잡을 수 없다는 점이 조금 불안한 점이긴 하지만 일단 사용하면서 발생하는 이슈들을 핸들링하고 사내 구조 전범위로 사용할지 생각해봐야겠다.

참고 자료

https://www.baeldung.com/java-performance-mapping-frameworks
https://www.inflearn.com/questions/15292/dto-%EB%B3%80%ED%99%98-%EC%8B%9C-%EC%9A%B0%EC%95%84%ED%95%9C%ED%98%95%EC%A0%9C%EB%93%A4%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%B2%98%EB%A6%AC%ED%95%98%EC%8B%9C%EB%82%98%EC%9A%94

profile
지나치지 않기 위하여

0개의 댓글