JAVA/JAVA | Spring 학습기록

[Spring] MapStruct를 이용한 Entity <-> DTO 고찰 (2)

kth990303 2022. 12. 10. 22:56
반응형

mapstruct는 entity <-> dto를 자동으로 반환해주는 객체 매핑 라이브러리이다.

엄청 옛날에 이런 포스팅을 작성한 적이 있다. (진짜 옛날 글이라 지식이 부족할 때 작성했어서 굳이 열람할 필요는 없다.)

https://kth990303.tistory.com/131

 

[Spring] MapStruct를 이용한 Entity, Dto 반환 및 고찰

그 동안 View layer에서 Entity에 직접적으로 접근하도록 코드를 짰던 나에게, 이번 DTO 적용은 상당히 고된 일이었다. 사실 dto는 단순한 entity의 클론 느낌이라 적용이 크게 어렵지 않을 줄 알았는데,

kth990303.tistory.com

사실 위 포스팅은 DTO 반환에 대한 고찰이나 다름 없는 포스팅이었다.

 

지금 내가 생각하는 DTO의 필요성은 `거의 웬만한 상황에선 필요하다`는 생각이다. 그 이유는 아래와 같다.

 

  • 요구사항이 변경될 때 DTO가 아닌 Entity를 반환하면 API spec이 변경됨에 따라 도메인의 변경이 잦아질 수 있으며, 이는 비즈니스 로직에도 영향이 미칠 수 있다. View 영역만 변경하면 될 것을 Domain, Service, 그리고 그에 따른 테스트까지 전부 변경해야될 수도 있는 점.
  • 또, Entity를 직접 반환하게 되면 JPA를 사용할 때 순환참조가 발생할 수 있다. DTO를 만들면 해당 Entity에 대한 모든 정보가 아닌, 필요한 정보만 반환하게 해줌으로써 순환참조를 방지할 수 있다.
  • 하지만 간단한 프로젝트이거나 유지보수가 크게 필요하지 않을 경우에는, 오히려 클래스가 많아져 복잡성만 높아질 수 있다.

 

이번 포스팅은 DTO의 필요성에 대해 작성하려는 것이 아니므로 이 정도로 작성하고 넘어가려 한다.

이번에는 MapStruct, 과연 필요한가? 에 대한 주관적인 견해를 적어보려 한다.


결론부터 말하자면, mapstruct를 사용하지 않으려 한다.

 

그 이유를 적어보도록 하겠다.

 

1. 엔티티와 DTO 변환 생산성이 크지 않다.

아마 mapstruct를 사용하려는 이유가 entity <-> dto 변환이 상당히 귀찮아서일 것이다.

하지만, stream을 이용하거나 DTO의 정적 팩터리 메서드 생성으로 충분히 귀찮은 과정을 줄일 수 있다.

 

Post라는 게시글 리스트를 DTO로 반환하려는 과정을 생각해보자.

DTO에서 요구하는 정보들을 엔티티에서 getter로 꺼내서 넣어주고, new DTO()로 반환해야되는 번거로움이 생각날 수 있다.

하지만 이는 DTO의 정적 팩터리 메서드를 생성한다면, 서비스 로직은 충분히 코드량을 줄일 수 있다.

 

stream과 정적 팩터리 메서드를 이용한 List<Entity>에서 List<Dto> 반환

@Transactional(readOnly = true)
public List<PostIdDto> findAll() {
    return postRepository.findAll()
        .stream()
        .map(PostIdDto::from)
        .collect(Collectors.toUnmodifiableList());
}

map에서 DTO의 정적 팩토리 메서드를 통해 DTO형태로 반환해준 후, collect로 List로 묶어 편리하게 반환해줄 수 있다.

public static PostIdDto from(final Post post) {
    return new PostIdDto(
        post.getId(),
        post.getTitle(),
        post.getContents(),
        MemberIdDto.from(post.getMember()),
        post.getViews(),
        post.getPostPeriod()
    );
}

DTO의 정적 팩토리 메서드를 만들어두면, 이후에 서비스 로직에서 편리하게 DTO로 반환해줄 수 있다. 사실상 mapper의 역할이나 다름없는 셈.

 

실제로 mapstruct로 이루어진 코드를 mapstruct를 사용하지 않도록 마이그레이션하는 작업을 진행할 때, 의존성 제거 작업을 제외하곤 크게 번거로운 작업이 없을 것이다. stream과 정적 팩터리 메서드 생성만 해주면 되기 때문.

 

아래 mapstruct를 이용한 코드를 보자.

mapstruct의 mapper를 오버라이딩한 경우

mapstruct를 이용하면 위와 같이 상황에 따라 toDto 메서드와 toEntity 메서드를 오버라이딩하는 경우가 필요할 때가 있다.

오히려 클래스를 일일이 만들어 주어야 하고, 필요에 따라 위와 같이 오버라이딩해야되는 mapstruct 사용이 더 번거로운 경우가 많은 셈. mapstruct로 인해 오히려 코드 입력량이 많아지고 클래스 관리성이 어려워진 것이다.

 

2. mapper를 빈으로 등록하여 스프링 관리 대상에 놓아야 한다.

mapstruct 사용이 꺼려진 결정적 이유이다.

그냥 빈으로 등록해주면 되는 거 아닌가? 할 수도 있다.

사실 그렇긴 하다. 근데, 빈으로 등록해야된다는 것은 결국 의존관계를 신경써야된다는 것이다. 즉, 테스트 코드를 작성할 때 빈 등록 및 의존성 주입 여부에 따라 우리가 너무나도 보기 싫은 Failed to load ApplicationContext 에러를 볼 수 있다는 점!

참고로 위 에러는 컴파일 시점에 확인이 안된다는 큰 단점을 가진다.

즉, 런타임 시점에 에러가 발생하므로 그에 따른 생산성 저하 및 mapstruct에 대한 러닝커브가 충분히 생길 수 있다. 

 

Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'memberService' defined in file: Bean instantiation via constructor failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate: Constructor threw exception; nested exception is java.lang.RuntimeException: java.lang.ClassNotFoundException: Cannot find implementation for algopa.study.member.mapper.MemberIdMapper


Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate: Constructor threw exception; nested exception is java.lang.RuntimeException: java.lang.ClassNotFoundException: Cannot find implementation for algopa.study.member.mapper.MemberIdMapper

 

위와 같은 에러를 유지보수하면서 보게 될 수 있다.

 

또, mapstruct의 mapper는 build해야 generated 폴더에 생성이 된다.

다시 말해, build하지 않은 상태에서는 mapper가 생성이 안된다. 실제로 코딩하면서 빌드가 최신화되지 않은 상태로 테스트를 돌려서 문제가 생긴 적이 몇 번 있다. 그리고 이건 기분탓인지 모르겠는데 빌드 과정이 아무래도 조금 오래 걸리는 듯한 느낌도 있다.

 

3. 의존성 관련 자잘한 이슈들이 좀 있다.

이건 나중에 언젠가는 고쳐지겠지만... 몇몇 블로그 포스팅을 보면 mapstruct 버그가 일부 존재하는 것 같다.

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

build.gradle에서, implementation 'org.mapstruct:mapstruct:1.4.2.Final' 이랑
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'순서가 바뀌었을 때 버그가 있다는 후기가 있었다. 또, 1.4.2.Final 버전이 아닌 다른 버전을 사용했을 때 제대로 동작되지 않는 버그도 있었다. (지금은 이건 수정된 것 같다.)

 

결국 mapstruct도 하나의 라이브러리로써 의존성이 추가된 것이기 때문에 버전충돌 및 버그가 발생할 확률이 더욱 높아지는 셈. mapstruct를 사용해서 정말 편리해진다면 사용해야겠지만, stream과 DTO의 정적 팩터리 메서드를 사용함으로써 굳이 mapstruct를 사용해야되나? 싶어졌다.


이러한 이유들로 나는 mapstruct를 사용하지 않기로 결정했다.

 

mapstruct를 사용하지 않는다면 위에서 언급한 번거로운 의존성을 줄일 수 있고, 러닝커브 또한 낮아질 수 있다.

간단한 변환의 경우에는 mapstruct를 사용할 수도 있어보이지만, 개인적으론 stream을 활용하거나 DTO의 정적 팩터리 메서드를 사용하는 것으로 충분히 간편하게 변환할 수 있다고 생각한다.

 

물론, 어디까지나 응애개발자의 주관적인 견해일 뿐이므로 `mapstruct 별로구나!`라고 생각하지는 않았음 좋겠다.

반응형