공부내용공유

Spring ModelMapper, MapStruct 적용에 관하여 본문

Spring/Spring

Spring ModelMapper, MapStruct 적용에 관하여

forfun 2023. 6. 1. 16:14

서론


현재 진행중인 프로젝트에는 주요 Domain은 강의강사이다. 이 두 도메인은 필드 들도 많고 다양한 화면에 노출되고 다양한 로직에 사용이된다.

기본적인 Create, Update를 위한 dto들도 당연히 있지만 제일 많은것들은 Read에 관련된 Dto들이다.

Dto들에도 굉장히 많은 filed들이 있고 처음에는 entity 에서 dto를 만들때 builder 패턴을 사용해서 하나하나 다 코드를 쳐서 만들었다.

그러나 프로젝트를 진행하면서 디자인이나 기획이 바뀌는 경우가 종종 있었고 이때마다 entity에도 정보가 추가되는 경우도 있었다.

이때마다 모든 builder를 돌아다니면서 하나하나 추가하는것은 잦은 실수를 유발했고 이를 어떻게 해야하나 고민하면서 Mapper에 대해 알게 되었다.

본론


Mapstruct와 ModelMapper , builder를 하나하나 다 치는것은 각각 장단점이 있었고 프로젝트에 적용할때 어떤 방식을 적용하는게 맞을까 고민을 많이 했다.

현재로는 대부분의 경우 mapstruct를 적용했고 modelMapper를 한부분에서 적용했는데 이는 아래에서 설명을 하겠다.

목차

  • MapStruct란
  • ModelMapper 란
  • 생각해볼점, 해결해야 할점

MapStruct란

mapStruct란 object들 간의 mapping을 쉽게 도와주는 라이브러리이다.

 

특히 어플리케이션에서 entity 와 dto간의 mapping에서 자주 사용되고 나 또한 이를 위해 mapstruct를 사용하였다.

MapStruct의 장점

  • 컴파일 시점에서 코드를 생성한다. → 런타임 안정성을 보장한다.
  • 위에서 얘기했던 builder 패턴을 일일히 다 작성할때 생길 수 있는 실수를 최소화 해준다.
  • 생성된 implementation 코드를 직접 확인하여 잘못된 부분을 확인할 수 있다.
  • 리플렉션이 일어나지 않는다. (ModelMapper의 경우는 리플렉션이 일어납니다)

MapStruct 사용법

//MapStruct
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'

build.gradle에 의존성을 주입한다.

이때 lombok 관련 dependency 보다 나중에 들어가야 정상적으로 작동한다.

mapper implementation 코드다 getter, setter , builder 를 이용하여 생성되기 때문이다.

@Mapper(componentModel = "spring")
public interface LectureMapper {
    //Lecture -> FindAllLectureRes
    FindAllLecturesRes toFindAllLecturesRes(Lecture lecture, LectureDate lectureDate);
    //CreateLectureReq -> Lecture
    @Mapping(target = "status", constant = "RECRUITING")
    Lecture toLecture(CreateLectureReq createLectureReq);
}

이런식으로 만들고 싶은 mapper를 domainMapper interface를 만들고 선언을 해주면 된다.

이렇게 선언을 해주고 build를 하면

@Component
public class LectureMapperImpl implements LectureMapper {
    @Override
    public FindAllLecturesRes toFindAllLecturesRes(Lecture lecture, LectureDate lectureDate) {
        if ( lecture == null && lectureDate == null ) {
            return null;
        }
        FindAllLecturesRes.FindAllLecturesResBuilder findAllLecturesRes = FindAllLecturesRes.builder();
        if ( lecture != null ) {
            findAllLecturesRes.id( lecture.getId() );
            findAllLecturesRes.mainTitle( lecture.getMainTitle() );
            findAllLecturesRes.subTitle( lecture.getSubTitle() );
            findAllLecturesRes.status( lecture.getStatus() );
            findAllLecturesRes.city( lecture.getCity() );
            findAllLecturesRes.place( lecture.getPlace() );
            findAllLecturesRes.mainTutor( lecture.getMainTutor() );
            findAllLecturesRes.subTutor( lecture.getSubTutor() );
            findAllLecturesRes.time( lecture.getTime() );
            List<LocalDateTime> list = lecture.getLectureDates();
            if ( list != null ) {
                findAllLecturesRes.lectureDates( new ArrayList<LocalDateTime>( list ) );
            }
        }
}

이런식으로 코드가 interface에 대한 implementation 코드가 자동으로 작성되어 있다.

dto 가 하나의 object만 요구한다면 인자로 하나의 object만 선언해주면 되고

여러개가 필요할 경우 여러개의 object를 선언해주면 된다.

하지만 이때 object간의 이름이 같을 경우 설정을 통해 명확이 mapping을 해줘야 한다.

자세한 설정 및 기능들은 다른 글에서 작성하겠다.

이렇게 대부분의 entity ↔ dto 간의 변환을 maptsruct로 하다가 난관에 부딪혔다.

현재 프로젝트에서는 dto 든 entity든 setter 사용을 지양하고 builder 패턴을 사용하기로 결정했다.

그런데 update 부분은 setter 설정 없이는 mapstruct를 사용할 수 없었다.

( implementation 코드를 보면 setter 어노테이션이 없으면 코드가 비어있음 )

@Override
    public void updateLecture(UpdateLectureReq updateLectureReq, Lecture lecture) {
        if ( updateLectureReq == null ) {
            return;
        }
        if ( lecture.getLectureDates() != null ) {
            lecture.getLectureDates().clear();
            List<LocalDateTime> list = updateLectureReq.getLectureDates();
            if ( list != null ) {
                lecture.getLectureDates().addAll( list );
            }
        }
    }

여기서 많은 고민을 했다. setter를 여는게 맞을까? lecture에는 굉장히 많은 filed들이 있고 이를 하나한 다 의미있는 method ( changeLectureName) 을 만들어서 null인지 아닌지 판단하고 바꿔주는게 효율적일까?

그렇게 고민을 하면서 검색을 하다가 ModelMapper 에 관해 알게되었다.

ModelMapper에 관하여

한마디로 설명하자면 source object 에 있는 필드값들을 destination object로 mapping시켜주는 라이브러리이다.

ModelMapper 장,단점

장점

  • 간결한 코드 작성
  • lombok 라이브러리와 충돌이 없다.
  • setter 없이도 update 가능

단점

  • 컴파일러 시점에서 최적화를 하지 못한다.
  • 다른 방식보다 오버헤드가 많아 성능이 좋지 않다.
    • 매칭 및 매핑 로직에서 Reflection API를 사용해서 객체 필드 정보를 추출한다.
    • Map을 만들어서 다음 들어온 인자와 매칭시켜준다.
    • Map 정보를 기준으로 매핑해준다.
    • 사실 이 메커니즘을 정확히는 이해를 못했다. 일단 다른 방식보다 비용이 크다라고만 이해하였다.

이러한 성능 이슈로 modelMapper도 사용을 지양하고 싶었으나 mapStruct로는 setter 없이 update를 할 수 없어 일단 ModelMapper를 사용하였다.

즉, 이 프로젝트에서 ModelMapper는 update에서만 사용이 되었고 이 글에서는 어떤식으로 사용했는지만 설명하고 설정이나 깊은 내용은 추후 다른 글을 통해 포스팅을 하겠다.

//ModelMapper
implementation 'org.modelmapper:modelmapper:3.1.0'

build.gradle에 의존성을 추가해주고

@Configuration
public class ModelMapperConfig {
    @Bean
    public ModelMapper modelMapper(){
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                .setFieldAccessLevel(AccessLevel.PRIVATE)
                .setFieldMatchingEnabled(true)
                .setPropertyCondition(Conditions.isNotNull());
        return modelMapper;
    }
}

config 파일을 만들고 설정 해 주었다.

ModelMapper - Configuration

위 공식 사이트 문서에서

modelMapper.getConfiguration()
.setFieldMatchingEnabled(true)
.setFieldAccessLevel(AccessLevel.PRIVATE);

이 설정을 통해 ModelMapper 가 private filed를 match 할 수 있게 해준다고 나와있다.

Configuration (ModelMapper 3.1.1-SNAPSHOT API)

여기에는 다양한 config 관련 api들 이 있다.

Lecture lecture = lectureRepository.findById(id)
	.orElseThrow(() -> new BaseException(Code.LECTURE_NOT_FOUND));

modelMapper.map(updateLectureReq,lecture);

setPropertyCondition (Condition.isNotNull) 을 통해서 null 인 파트는 거르게 설정했다.

그렇게 설정한 이유는 update를 할때 원하는 값만 넣어도 update를 되게 하여 update api의 사용성을 늘리고 싶어서 그랬다.

이렇게 설정하면 dirty checking을 통해 update query문이 나간다.

생각해볼 점, 해결해야 할 점

아직 뭐가 제일 맞는 방법인지 모르겠다. 사실 정답이 있는지도 모르겠고

만약 프로젝트 컨벤션에서 setter 어노테이션을 열어놓기만 하고 직접 사용을 안한다 하면 mapstruct로도 update가 가능하다. 프로젝트 컨벤션과 상황에 따라 고민을 하고 적절한 방식을 적용하는게 맞는거 같다.

여기서 나아가 애초에 한 entity에 너무 많은 filed 들이 들어가 있나 라는 생각도 들었다. 이도 프로젝트 초반 부분 erd나 entity를 설계할때 많은 고민이 있었는데 사실 다들 경험이 많지 않아 어려웠던 부분이기도 하다.

앞으로 실무에서, 다양한 프로젝트에서 entity 설계할때 생각할 점, 다양한 상황에 적합한 mapping 방식 선택하기 , convention 정하기 와 같은것들을 보고 배우며 나만의 기준을 확립해나갈 것이다.