지금까지 느꼈던 'Project'의 최적화에는 3단계가 있다.

  • 코드의 최적화
    • 코드의 줄 수를 줄이고, visibility가 높은 메서드로 바꾸고, Logic을 깔끔하게 짜는 것
  • DB의 최적화
    • DB에 왔다갔다 하는 비용을 줄이고, 가능한 한 번에 내가 원하는 Data만을 가지고 오는 것
  • 변환의 최적화
    • application level에서, DB에서 가지고 왔던 리소스들을 더욱 빠르게 변환하는 것

이번 프로젝트는 객체 간의 매핑으로 손수 mapping을 해 주었던 지난 프로젝트와는 다르게,

mapping framework를 이용해 매핑을 진행해보려고 한다.

 

Mapping framework란?

매핑 프레임워크는 한 형식이나 구조에서 다른 형식이나 구조로 데이터를 쉽게 변환하는 도구 또는 라이브러리입니다. 한 객체 유형에서 다른 객체 유형으로 데이터를 변환하는 프로세스를 자동화하며 종종 서로 다른 클래스 또는 데이터 구조 간의 매핑을 포함합니다. 매핑 프레임워크의 주요 목적은 데이터 변환 프로세스를 단순화하고 능률화하여 수동 코딩의 필요성과 잠재적인 오류 원인을 줄이는 것입니다.

 

결국, 어떤 엔티티에서 DTO로의 변환을 쉽게 해주는 프레임워크다.


널리 사용되는 mapping framework의 장, 단점을 알아보자.

  1. ModelMapper
    • 장점 : 런타임에 리플렉션을 사용하여 자동으로 매핑되므로 설정이 간편하고 유연하다.
    • 단점 : 리플렉션을 사용하기 때문에 상대적으로 성능이 느리고, 컴파일 시점에 오류를 알아채기 어렵다. (굉장히 큰 단점이라고 생각한다. JPQL이 아닌 Querydsl을 사용하는 이유에도 이런 이슈들이 있어서이다.)
  2. MapStruct
    • 장점 : 컴파일 타임에 생성되는 mapping code로 인해 성능이 좋고, 디버깅이 쉽다. 또, 생성자 주입, Lombok과의 호환성 등 다양한 기능을 제공한다. (그러나 lombok에 의존하기 때문에 gradle / maven 설정에 lombok 하위에 dependency 추가를 해 주어야 한다.)
    • 단점 : 설정이 복잡하고 (내가 이 글을 쓰는 이유 - 블로그마다 설명이 너무 다르다.) 컴파일타임에 코드 생성이 필요해서 빌드가 느려진다.
  3. Orika
    • 장점 : 런타임에 Byte code를 생성하여 매핑되므로, 리플렉션보다 빠른 성능을 제공한다. 또한 Mapstruct보다 더 빠른 성능을 제공하고, "유연"하다.
    • 단점 : 디버깅이 어렵고, 성능이 Mapstruct보다 더 떨어진다.
  4. Dozer
    • 장점 : 런타임에 리플렉션을 사용하여 자동으로 매핑되어, 설정이 간편하고 유연하다. XML 및 어노테이션 기반의 매핑 설정을 제공한다.
    • 단점 : 리플렉션을 사용하여 성능이 상대적으로 느리고, 더이상 지원하지 않는다. dozer github에 가보면 다음과 같은 말이 있다.
The project is currently not active and will more than likely be deprecated in the future. If you are looking to use Dozer on a greenfield project, we would discourage that. If you have been using Dozer for a while, we would suggest you start to think about migrating onto another library, such as :
[mapstruct](https://github.com/mapstruct/mapstruct)
[modelmapper](https://github.com/modelmapper/modelmapper)
For those moving to mapstruct, the community has created a [Intellij plugin](https://plugins.jetbrains.com/plugin/20853-dostruct) that can help with the migration.

우리 서비스 이제 중단하니까 mapstruct나 modelmapper 써라

  1. Jmapper
    • 장점 : 바이트 코드 생성을 사용하여, 성능이 빠르고 설정이 간단하다.
    • 단점 : 디버깅이 어렵고, reference가 많이 없다.

그래서 보통 2가지의 mapping framework를 사용하거나, java lang으로 손수 변환해준다.
추후에 실험하겠지만, ModelMapper보다는 Mapstruct가 압도적으로 빠른 속도와 성능을 자랑한다.

둘 다 external library를 이용하지만, ModelMapper는 매핑을 할 때 리플렉션을 발생시키고, MapStruct는 컴파일 시점에서 annotation을 이용해 구현체를 만들어내기 때문에 리플렉션을 발생시키지 않아서 차이가 나는 것이다.
또, 개인적으로 엔티티 클래스에 Setter 어노테이션을 넣는것을 많이 지양하는 편이라, Mapstruct를 사용하기로 했다.

설정

build.gradle

dependencies{ 
//mapstruct 
 implementation 'org.mapstruct:mapstruct:1.5.3.Final' 
 annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final' 
}

먼저, 쉬운 예제부터 해보자.

comment > entity > Comment

@Getter 
@NoArgsConstructor 
@Entity 
public class Comment extends BaseEntity { 
    @Id 
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    private Long commentId; 

    @Column(nullable = false) 
    private String comment; 

    @ManyToOne(fetch = FetchType.LAZY) 
    @JoinColumn(name = "ticketId") 
    private Ticket ticket; 

    @ManyToOne(fetch = FetchType.LAZY) 
    @JoinColumn(name = "userId") 
    private User user; 

    public Comment(User user, Ticket ticket, CommentRequestDto commentList) { 
    this.commentId = getCommentId(); 
    this.user = user; 
    this.ticket = ticket; 
    this.comment = commentList.getComment(); 
    } 

    public void update(CommentRequestDto commentRequestDto) { 
     this.comment = commentRequestDto.getComment(); 
    } 
  }

Mapstruct는 보통 Java Beans 규약을 따르므로, setter가 있어야 Mapstruct가 이를 이용해 속성을 정의할 수 있다. 그러나 MapStruct 1.3버전 이상에서는 생성자 주입을 지원한다.

 

comment > dto > CommentResponseDto

@Getter 
@NoArgsConstructor 
public class CommentResponseDto { 
 private Long commentId; 
 private String comment;
 private String email;

 @QueryProjection
 public CommentResponseDto(Long commentId, String comment, String email) { 
  this.commentId = commentId;
  this.comment = comment;
  this.email = email; 
 } 

 public CommentResponseDto(Comment c) { 
  this.commentId = c.getCommentId(); 
  this.comment = c.getComment(); 
  this.email = c.getUser().getEmail(); 
 } 
}

Dto <-> Entity간의 변환 수행시 다음과 같은 사항들을 고려해야 한다.

  • Property name : MapStruct는 Source와 Target간 동일한 이름을 가진 속성을 매핑하려고 한다. 그래서Entity와 DTO가 다른 Attributes를 가지고 있는 경우 @Mapping으로 설정을 지정해 줄 수 있다. Querydsl같은 경우 allias로 지정했던 것과 비슷하게 생각하면 된다.
  • Proeprty type : MapStruct는 java의 primitive, boxed, 그리고 standart type(String, LocalDate 등)에 대해서는 자동으로 변환해 줄 수 있다. 그러나 다른 복잡한 타입같은 경우, @Mapping이나 @Mapper 어노테이션들로 설정을 해 주어야 한다.
  • Null값 핸들링 : MapStruct는 source 객체의 Null값 처리를 디폴트로 할 수 있다. null handling을 따로 해주고 싶으면, NullValueMappingStrategyNullValueCheckStrategy 구성으로 바꿔줄 수 있다.
  • 중첩 매핑 : 엔티티나 DTO가 중첩된 객체나 컬렉션을 포함하는 경우 @Mapping이나 @InheritInverseConfiguration 어노테이션을 이용하여 중첩된 객체에 대한 매핑 규칙을 구성할 수 있다.
  • 매핑하기 싫은 속성 : DTO나 엔티티에 서로 없는 속성이 있는 경우 @Mapping(target = "", ignore = true)를 통해 속성 매핑을 건너뛸 수 있다. 다량의 ignore가 있을 경우 @Mapping 어노테이션을 두 번 사용해주면 된다.
  • 불변성(immutability) : DTO가 불변 속성인 경우(생성자 기반의 initialization등을 사용하는 경우) @Builder등으로 MapStruct configuration을 통해 생성자 또는 빌더 패턴을 사용하는 매핑 코드를 생성할 수 있다.

그럼, Mapping을 시작해 보자!

주의할 점

  • Comment Entity에 있는 ticket field은 commentResponseDto에서 나타나지 않는다.
  • Comment Entity에 있는 user field는 commentResponseDto에서 user의 email 주소로 나타나야 한다.

comment > dto > CommentMapper

@Mapper(componentModel = "spring")
public interface CommentMapper{
    @Mappings({
        @Mapping(target = "ticket", ignore = true)
        @Mapping(target = "email", source = "comment.user.email")
    })
    CommentResponseDto mapToResponseDto(Comment comment);
}
  • @Mapper(componentModel = "spring") : Mapper 어노테이션은 Mapstruct Code generator가 해당 인터페이스의 구현체를 생성해준다. componentModel = "Spring"은, spring에 맞게 bean으로 등록해준다는 뜻을 가지고 있다.
    • @Mapper(componentModel = "spring")을 써주지 않으면 Instance를 생성해야 한다. 우리는 TicketMapper니까 interface 안에 INSTANCE = Mappers.getMapper(TicketMapper.class);를 써주면 된다.
  • 여기서 target 객체는 CommentResponseDto, source 객체는 comment이다. 그래서 target 먼저, source를 나중에 써 주면 좋다.
  • comment(source)에 있는 ticket field는 dto에 field로 존재하지 않아서, MapStruct에서 알아서 걸러준다.
    build를 하면, 자동으로 build > generated를 열어보자. 자동으로 구현체가 생겨있는 것을 알 수 있다.

build > generated > sources > annotationProcessor > java > main > dir > comment > dto > CommentMapperImpl

@Generated(  
    value = "org.mapstruct.ap.MappingProcessor",  
    date = "2023-04-04T03:36:44+0900",  
    comments = "version: 1.5.3.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 11.0.17 (Azul Systems, Inc.)"  
)  
@Component  
public class CommentMapperImpl implements CommentMapper {  

    @Override  
    public CommentResponseDto mapToResponseDto(Comment comment) {  
        if ( comment == null ) {  
            return null;  
        }  

        CommentResponseDto.CommentResponseDtoBuilder commentResponseDto = CommentResponseDto.builder();  

        commentResponseDto.email( commentUserEmail( comment ) );  
        commentResponseDto.commentId( comment.getCommentId() );  
        commentResponseDto.comment( comment.getComment() );  

        return commentResponseDto.build();  
    }  

    private String commentUserEmail(Comment comment) {  
        if ( comment == null ) {  
            return null;  
        }  
        User user = comment.getUser();  
        if ( user == null ) {  
            return null;  
        }  
        String email = user.getEmail();  
        if ( email == null ) {  
            return null;  
        }  
        return email;  
    }  
}

 

builder annotation을 기준으로, 생성되어있음을 알 수 있다. Test를 짜서 실험해보도록 하자!

 

test > CommentMapperTest

@Test  
public void commentMapperTest() {  
   //given  
   User user1 = mock(User.class);  
   when(user1.getEmail()).thenReturn("pengooseDev@daum.net");  

   Comment comment = mock(Comment.class);  
   when(comment.getCommentId()).thenReturn(1L);  
   when(comment.getComment()).thenReturn("test");  
   when(comment.getTicket()).thenReturn(mock(Ticket.class));  
   when(comment.getUser()).thenReturn(user1);  

   //when  
   CommentResponseDto commentResponseDto = commentMapper.mapToResponseDto(comment);  

   //then  
   assertThat(commentResponseDto.getCommentId()).isEqualTo(1L);  
   assertThat(commentResponseDto.getEmail()).isEqualTo("pengooseDev@daum.net");  
}

잘 변환되고 있음을 알 수 있다.
다시 돌아가서, 다음으로는 dto를 엔티티로 변환해보자.

 

comment > dto > commentRequestDto

@Getter 
@NoArgsConstructor 
public class CommentRequestDto{
 @Schema(name = "ticket Id") 
 @NotNull(message = "ticket Id is required")
 private Long ticketId;
 private String comment; 
}

comment > entity > Comment : Constructor

@Builder  
public Comment(User user, Ticket ticket, CommentRequestDto commentList) {  
   this.commentId = getCommentId();  
   this.user = user;  
   this.ticket = ticket;  
   this.comment = commentList.getComment();  
}

comment > dto > CommentMapper

@Mapper(componentModel = "spring")
public interface CommentMapper{
    @Mappings({  
       @Mapping(target = "commentId", ignore = true),  
       @Mapping(target = "comment", source = "commentRequestDto.comment"),  
       @Mapping(target = "ticket", source = "ticket"),  
       @Mapping(target = "user", source = "user")  
    })  
    Comment mapToEntity(CommentRequestDto commentRequestDto, Ticket ticket, User user);
}
  • CommentId는 ResponseDto, Ticket, User 어디에도 없는 Auto-generated되는 field이다. 사실 이런 값은 Mapper에서 알아서 걸러주지만, 이번에는 무시한다고 적었다.
    TC를 짜보자.

test > CommentMapperTest

@Test  
public void toEntityTest() {  
   //given  
   User user = mock(User.class);  
   when(user.getEmail()).thenReturn("pengooseDev@daum.net");  

   CommentRequestDto commentRequestDto = mock(CommentRequestDto.class);  
   when(commentRequestDto.getComment()).thenReturn("test");  
   when(commentRequestDto.getTicketId()).thenReturn(1L);  

   Ticket ticket = mock(Ticket.class);  
   when(ticket.getTicketId()).thenReturn(1L);  
   when(ticket.getTicketTitle()).thenReturn("test Ticket");  

   //when  
   Comment comment = commentMapper.mapToEntity(user, ticket, commentRequestDto);  

   //then  
   assertThat(comment.getComment()).isEqualTo("test");  
   assertThat(comment.getTicket().getTicketId()).isEqualTo(1L);  
   assertThat(comment.getUser().getEmail()).isEqualTo("pengooseDev@daum.net");  
   assertThat(comment.getCreatedAt().toString()).isNotNull();  
}

그러나, assertThat(comment.getCreatedAt().toString()).isNotNull(); 이 실패하고, null로 나온다. 이를 검증하기 위해 서버를 한 번 띄워보자.

 

commentService

@Service  
@RequiredArgsConstructor  
@Transactional  
public class CommentService {  

   private final CommentMapper commentMapper;   

   // 댓글 작성  
   public ResponseEntity<SuccessResponseDto> createComment(User user,  
      CommentRequestDto commentRequestDto) {  
      user = validateUserByEmail(user.getEmail());  
      Ticket ticket = TicketValidation(commentRequestDto.getTicketId());  
      // comment 작성  
      // Comment comment = new Comment(user, ticket, commentRequestDto);  
      Comment comment = commentMapper.mapToEntity(user, ticket, commentRequestDto);  
      commentRepository.save(comment);  
      // 상태 반환  
      return SuccessResponseDto.toResponseEntity(SuccessCode.CREATED_SUCCESSFULLY);  
   }  

   ... 나머지 메소드
}

원래 comment를 생성하던 생성자를 주석처리하고, Mapper 메소드를 새로 새겨 넣었다.

postman에서는 성공적으로 만들어졌다고 나오고, 이제 db를 확인 해 보자.

DB에도 id와 auditAware가 정상적으로 반환되었음을 알 수 있다.

조금 더 난이도를 올려서!

이번엔, Comment와 @ManyToOne관계에 있는 Ticket를 매핑해보자.
TicketMapper interface를 만들어보자. Entity를 DTO로 변형하는것부터 시작하자!

 

ticket > entity > Ticket

@Getter  
@NoArgsConstructor  
@Entity  
@AllArgsConstructor  
@DynamicUpdate  
public class Ticket extends BaseEntity {  
   @Id  
   @GeneratedValue(strategy = GenerationType.IDENTITY)  
   private Long ticketId;  

   private String ticketTitle; 
   private String ticketDescription;
   private int priority;  
   private int difficulty;  
   private LocalDate expiredAt;  
   private LocalDate completedAt;  

   @ManyToOne(fetch = FetchType.LAZY)  
   @JoinColumn(name = "task_Id")  
   private Task task;  

   @ManyToOne(fetch = FetchType.LAZY)  
   @JoinColumn(name = "user_Id")  
   private User user;  

   @Enumerated(value = EnumType.STRING)  
   private TicketStatus status;  

   @ManyToOne(fetch = FetchType.LAZY)  
   @JoinColumn(name = "label_Id")  
   private Label label;  

   @OneToMany(mappedBy = "ticket", cascade = CascadeType.REMOVE)  
   private List<Comment> comment = new ArrayList<>();  

   ... // 생성자 및 연관관계 편의 메소드
}

난이도가 확 올라갔다. 이제는 primitive type을 제외하고도, 다른 Class type이 들어있다.

 

ticket > dto > TicketResponseDto

@Getter  
@NoArgsConstructor  
public class TicketResponseDto {  

   private Long ticketId;  
   private String title;  
   private String description;  
   private TicketStatus status; 
   private int priority;  
   private int difficulty;  
   private String assigned;  
   private LocalDate expiredAt; 
   private LocalDate completedAt;  
   private String label;  
   private List<CommentResponseDto> commentList;  
   @Builder  
   public TicketResponseDto(Ticket ticket) {  
      ticketId = ticket.getTicketId();  
      title = ticket.getTicketTitle();  
      description = ticket.getTicketDescription();  
      ticketStatus = ticket.getStatus();  
      priority = ticket.getPriority();  
      difficulty = ticket.getDifficulty();  
      assigned = ticket.getUser() == null ? null : ticket.getUser().getEmail();  
      expiredAt = ticket.getExpiredAt();  
      label = ticket.getLabel() == null ? null : ticket.getLabel().getLabelTitle();  
      commentList = ticket.getComment().stream().map(CommentResponseDto::new).collect(Collectors.toList());  
   }  
}

ticket > dto > TicketMapper

@Mapper(componentModel = "spring")  
public interface TicketMapper {  

    @Mappings({  
       @Mapping(target = "ticketId", source = "ticket.ticketId"),  
       @Mapping(target = "title", source = "ticket.ticketTitle"),  
       @Mapping(target = "description", source = "ticket.ticketDescription"),  
       @Mapping(target = "status", source = "ticket.status"),  
       @Mapping(target = "priority", source = "ticket.priority"),  
       @Mapping(target = "difficulty", source = "ticket.difficulty"),  
       @Mapping(target = "assigned", source = "ticket.user.email"),  
       @Mapping(target = "expiredAt", source = "ticket.expiredAt"),  
       @Mapping(target = "completedAt", source = "ticket.completedAt"),  
       @Mapping(target = "label", source = "ticket.label.labelTitle"),  
       @Mapping(target = "commentList", ignore = true),  
       @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    })
    TicketResponseDto mapToResponseDto(Ticket ticket);    


}
  • description같은 부분은 entity에서 Ticketdescription, responseDto같은 경우에는 description으로 나간다. 그래서 source(지금은 entity, 꺼내올 객체)에는 ticketDescription, target(지금은 dto, 만들 객체)에는 description으로 binding 해주는 작업이 필요하다.
  • @BeanMapping으로, null값에 대해서는 무시하겠다는 이야기이다.
    • BeanMapping을 활용할때는 주의해야 한다. 프론트엔드와 협업을 할 때, 프론트엔드에서는 null에 대해 ?로 체이닝을 걸어줄 수 있는데, 아예 필드가 안나간다면 곤란하기 때문이다.
  • 문제는 CommentList를 반환하려면 commentList가 comment 형태가 아닌 CommentResponseDto 형태로 나가야하는데, 이것은 어떻게 처리해 줘야 할까? 일단은 ignore=true로 놓아보자. Comment형태로 반환하기엔 DTO를 만든 의미가 없어진다.
  • 이 때 이용하는것이, @Mapper(uses={CommentMapper.class})이다. commentMapper를 이용해 자동으로 Comment를 CommentDto로 변환해주는 로직을 작성한다.

MapStruct를 활용해 매핑을 처리하다 보면, 의도하지 않았던 필드에 매핑되는 경우가 있다. 매핑 메서드를 생성한 이후에는 생성된 MapperImpl을 꼭 확인해서 의도한대로 필드들이 잘 매핑되었는지 확인하는 것이 좋다. Querydsl로 예를 들자면, Queryprojection을 이용해서 바인딩을 할 때 ResponseDto와 순서가 일치하지 않으면 (allias를 붙여주지 않는다면 일어날 수 있다) 나중에 결과가 꼬여 title이 description 자리에, description이 title 자리에 들어가서 return 될 수 있다!

이제, TC를 짜보자!

 

test > TicketMapperTest

@Test  
public void ticketToDtoTest() {  
   //given  
   User user1 = mock(User.class);  
   when(user1.getEmail()).thenReturn("pengooseDev@daum.net");  
   Label label1 = mock(Label.class);  
   when(label1.getLabelId()).thenReturn(1L);  
   when(label1.getLabelTitle()).thenReturn("testLabel");  
   when(label1.getLabelLeader()).thenReturn("pengooseDev@daum.net");  

   Ticket ticket = mock(Ticket.class);  
   when(ticket.getTicketId()).thenReturn(1L);  
   when(ticket.getTicketTitle()).thenReturn("testTicket");  
   when(ticket.getTicketDescription()).thenReturn("testTicketDescription");  
   when(ticket.getStatus()).thenReturn(TicketStatus.TODO);  
   when(ticket.getUser()).thenReturn(user1);  
   // 일부러 ticket.assigned를 설정 안함  
   when(ticket.getExpiredAt()).thenReturn(null);  
   when(ticket.getCompletedAt()).thenReturn(null);  

   //when  
   TicketResponseDto ticketResponseDto = ticketMapper.mapToResponseWithoutComment(ticket);  

   //then  
   assertThat(ticketResponseDto.getTicketId()).isEqualTo(ticket.getTicketId());  
   assertThat(ticketResponseDto.getTitle()).isEqualTo("testTicket");  
   Assertions.assertNull(ticketResponseDto.getCommentList(), "commentList should be null");  

}

잘 통과했다! 그럼 실제로 서버를 돌려서 dto가 commentList를 어떻게 반환하는지 확인하자.

아쉽게도, commentList field를 완전히 지우는 데는 실패했다.
그렇다면, 이를 해결하는 방법은 두 가지가 남았다.

  • 필요한 정보만을 가지는 클래스를 작성 후, 이를 상속받아 더 자세한 정보를 제공하는 클래스
  • @JsonView로 basic한 view와 detail한 view를 나누기

그렇다면, 이제 남은 것은 Mapstruct가 왜 좋은지에 대한 성능 테스트이다.
다음과 같이 10만번의 mapping 결과

@Test  
public void speedMapStruct() {  
   //given  
   Ticket ticket = mock(Ticket.class);  
   Comment comment = mock(Comment.class);   
   ticket.getComment().add(comment);  

   //when  
   long startMapstruct = System.currentTimeMillis();  
   for (int i = 0; i < 100000; i++) {  
      TicketResponseDto ticketResponseDto = ticketMapper.toDto(ticket);  
   }  
   long takenTimeWithMapStruct = System.currentTimeMillis() - startMapstruct;  

   //then  

   System.out.println("MapStruct로 걸린 시간: " + takenTimeWithMapStruct);  
}  

@Test  
public void speedWithManual() {  
   //given  
   Ticket ticket = mock(Ticket.class);  
   Comment comment = mock(Comment.class);   
   ticket.getComment().add(comment);  
   //when  
   long startManual = System.currentTimeMillis();  
   for (int i = 0; i < 100000; i++) {  
      TicketResponseDto ticketResponseDto = new TicketResponseDto(ticket);  
   }  
   long takenTimeWithManual = System.currentTimeMillis() - startManual;  

   //then  
   System.out.println("Manual로 걸린 시간: " + takenTimeWithManual);  
}

출력 결과, Mapstruct는 5.8초, java 수동 변환은 5.1초가 걸렸다.
그런데 생각을 해 보면 Mapstruct도 java-based 외부 라이브러리기 때문에
대용량 데이터에서도 둘간의 성능 차이는 비슷할 것으로 예상한다.

더 단점을 가져와 보시오

구글링을 통해, spring의 대가 김영한 선생님이 생각하는 Mapping framework의 단점을 찾았다.

  1. 모델이 단순하면 상관이 없는데, 매핑해야 하는 모델이 복잡하거나 서로 차이가 많이 나면... 머리속으로 생각을 좀 많이 해야 합니다. 이게 어느정도 복잡해지면 생각하시는 시간 때문에 비용이 더 들어가더라구요.
  2. 직접 수동으로 할 때는 컴파일 시점에 오류를 잡을 수 있는데, 이건 실행을 해봐야 오류를 찾을 수 있습니다.
  3. ModelMapper는 동시성 성능 이슈가 있습니다. 수천 TPS의 리엑티브 모델에서는 이 부분이 명확하게 병목으로 나왔습니다. 물론 수천 TPS가 안되는 상황에서는 상관이 없습니다. (MapStruct는 모르겠네요)
    결국 장단점이 있는데요. 저는 사용을 안합니다. ㅎㅎ(사용하다가 사용을 안하게 되었으니, 언젠가는 바뀔지도 모르겠습니다.)
    복잡한 실무에서 엔티티를 DTO로 변경하는게 이상적으로 딱딱 맞아 떨어지는 경우만 있는 것도 아니고, 수동으로 작업하면 결국 컴파일 시점에 오류를 잡을 수 있다는 장점도 있구요. 그리고 무엇보다! 수동으로 해도 손까락만 약간 힘들지 몇분 안걸립니다. ㅋㅋ

그럼 왜 씀?

위의 단점에도 불구하고, Mapstruct를 사용하는 이유는 다음과 같다.

  • 객체간의 변환 로직을 분리해서, 코드의 가독성과 유지 보수성이 향상된다.
  • 다른 mapping library와 다르게, 실행 할 때 코드를 생성하여, 런타임 오류를 줄일 수 있다.
  • framework 사용 시, 협업에 있어서 조금 더 '일관된 코드' 작성이 가능하다.
  • 개발자가 직접 변환 로직 짜는 시간을 줄여준다.

그럼 님 씀?

ㅋㅋ...전 안쓸듯..

-참고-
https://velog.io/@cham/Java-MapStruct-%EC%82%AC%EC%9A%A9%EB%B2%95
https://huisam.tistory.com/entry/mapStruct#Dependecy%--%EC%--%A-%EC%A-%---%EC%-D%--%EC%A-%B-%EC%--%B-%--%EC%--%A-%EC%A-%---
https://mein-figur.tistory.com/entry/mapstruct-1
https://ryumodrn.tistory.com/26
https://meetup.nhncloud.com/posts/213

/api/resource/1/innerResource/2/innerinnerResource/3 등으로 endpoint를 짜는 도중에

이러다간 끝이 없겠다 싶어서 Pathvariable에 들어가던 resource의 id값을 RequestBody로 받기로 했다.

근데 Resource와 innerResource는 일대다 관계 매핑을 해 주고 있던 차라, InnerResource에서 id값을 받지 못하면 상위 엔티티에 매핑을 하지 못하기 때문에 Resource의 id값에 @NotBlank를 걸어주었다. 왜 NotBlank를 걸어주었는지는 밑에 나온다.

그리고 실행을 돌렸더니 아래와 같은 결과가 나오게 되었다.

2023-03-24 12:58:17.393  WARN 20412 --- [  restartedMain] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'handlerExceptionResolver' defined in class path resource 
[org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate 
[org.springframework.web.servlet.HandlerExceptionResolver]: Factory method 'handlerExceptionResolver' threw exception; nested exception is java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [class org.springframework.validation.BindException]: {protected org.springframework.http.ResponseEntity com.ddalggak.finalproject.global.error.GlobalExceptionHandler.handleBindException(org.springframework.validation.BindException), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'handlerExceptionResolver' defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: 
Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.web.servlet.HandlerExceptionResolver]: Factory method 'handlerExceptionResolver' threw exception; nested exception is java.lang.IllegalStateException: 
Ambiguous @ExceptionHandler method mapped for [class org.springframework.validation.BindException]: {protected org.springframework.http.ResponseEntity com.ddalggak.finalproject.global.error.GlobalExceptionHandler.handleBindException(org.springframework.validation.BindException), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}

keyWord : handlerExceptionResolver, BindException

사유 : @ExceptionHandler에 걸어놓은 BindException이 GlobalExceptionHandler에서 동일한 예외 처리를 하고 있기 때문에 뭐 어떤 핸들러 찾아가야 하는지를 스프링에서 모른다. 그래서 Ambiguous(모호성) 문제가 대두되게 된다. 근데 나는 뭘 해준게 없는 것 같은데....

왜 이런 이유가 터졌는지에 대해 알아보자면 2가지 이유가 있다.
첫 번째로 우리는 exceptionHandler에 ResponseEntityExceptionHandler를 상속받아서 사용하고 있었다.
ResponseEntityExceptionHandler는 Spring MVC에서 발생할 수 있는 예외들에 대해서 미리 Handling을 해 놓은 클래스로써, 다음의 exception들에 대해서 미리 handling을 해 놓고 있다.


BindingException, MethodArgumentNotValidException을 포함한 몇가지들을 핸들링하고 있는데, globalExceptionHandler에서 충분히 다양한 ExceptionHandling을 하지 않을 때 유용하게 사용할 수 있다.
물론 Custom한 Exception 등에 대해서는 잡아주지 않는다.
그런데 나는 다음과 같은 코드를 작성하였다.

@ExceptionHandler(BindException.class)  
protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {  
   log.error("handleBindException throws BindException : {}", e.getMessage());  
   return ErrorResponse.from(INVALID_REQUEST, e.getBindingResult());  
}

그래서 ResponseEntityExceptionHandler도 BindException에 대해 핸들링하고 있고, 나는 거기다 또 @ExceptionHandler로 BindException을 잡아주느라 Ambiguous 문제가 터진 것이었다.
그래서 첫 번째 해결책으로 ResponseEntityExceptionHandler를 상속하고 있으니 BindException.class를 따로 핸들링하는 메소드를 작성하는게 아니라 이미 작성된 메소드를 오버라이딩 해 보았다.

@Override  
protected ResponseEntity<Object> handleBindException(BindException ex, HttpHeaders headers,  
   HttpStatus status,  
   WebRequest request) {  
   log.error("handleBindException throws BindException : {}", ex.getMessage());  
   return ErrorResponse.from(INVALID_REQUEST, ex.getBindingResult());  
}

그런데 이상한 결과가 나왔다.
No validator could be found for constraint 'javax.validation.constraints.NotBlank
이라는 문구가 출력되었다.
이게 뭐지 하고 한참을 들여다보니 다음과 같은 오류가 있었다.
Bean validation에 대해 여러 어노테이션이 있다는 것은 알았지만, 이 어노테이션들에 대한 차이에 대해서는 모르고 있어서 한번 찾아봤더니 다음과 같은 결과가 있었다.

Bean validation

Dto나 entity에서 null valid를 할 때 다음과 같은 방식이 사용된다.

  • @Notnull
  • @NotEmpty
  • @NotBlank
    어떤 차이가 있을까?@Notnullnotnull은 이름 그대로 null만 아니면 된다. " " , "" 이런것도 다 받는다.
    @Size, @Min, @Max등 이용할 수도 있고, @Email로 이메일 형식도 받아올 수 있다.@NotEmptynotempty는 notnull에서 +""가 추가된 것이다. 그럼 " "는 어떡하냐고? " "는 받는다.
    근데 나 " "도 막고싶어.@NotBlank막아드렸습니다.

나는 당연히도 NotBlank를 차용했는데, 사용하고 난 코드는 다음과 같다.

@NotBlank(message = "projectId값이 필요합니다.")
private Long projectId;

그런데 역시 NotNull, NotEmpty같은 어노테이션들이 괜히 있는게 아니었다.
@NotNull은 Nullable한 타입에 선언해야 해서 primitive 타입에는 선언할 수가 없다.
@NotBlank는 단 하나의 공백 문자열도 들어가면 안 돼서 String이나 CharSequence등에만 사용할 수 있다.
@NotEmpty는 empty 키워드가 들어갈 수 있는 CharSequence, Collection, Map, Array 등만 가능하다.
그런데 나는 Long 타입에 대해 @NotBlank를 주고 있었으니, 오류를 잡으려고 validation을 걸었는데 역설적이게도 validation에 대해 오류가 잡힌 것이었다.

다시 돌아와서, NotBlank를 NotNull로 잡아주고 돌렸다. 그랬더니 다음과 같은 오류를 뱉었다.

2023-03-24 14:02:40.301  WARN 42280 --- [nio-8080-exec-4] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [1] in public org.springframework.http.ResponseEntity<?> com.ddalggak.finalproject.domain.task.controller.TaskController.createTask(com.ddalggak.finalproject.global.security.UserDetailsImpl,com.ddalggak.finalproject.domain.task.dto.TaskRequestDto): [Field error in object 'taskRequestDto' on field 'projectId': rejected value [null]; codes [NotNull.taskRequestDto.projectId,NotNull.projectId,NotNull.java.lang.Long,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [taskRequestDto.projectId,projectId]; arguments []; default message [projectId]]; default message [project Id is required]] ]

내 validation 표현식은 어딜가고 이런 오류를 뱉는거죠?
그런데 생각해보니까 또 당연한 오류였다. 지금은 Binding이 안되는게 아니라 Null값에 대해 오류를 잡는거라 BindingException이 터진게 아니라 ArgumentNotValid 옵션이 뜨는거였다.
그래서 다시 또 다음과 같은 메소드를 작성하였다.

@Override  
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,  
   HttpHeaders headers, HttpStatus status, WebRequest request) {  
   log.error("handleBindException throws BindException : {}", ex.getMessage());  
   return ErrorResponse.from(INVALID_REQUEST, ex.getBindingResult());  
}

결과 : Nice

근데 이쯤되면 고민이 온다. ResponseEntityExceptionHandler를 사용해서 Override로 커스텀 해 줄 것이냐, 아니면 다음과 같이 Exception을 잡아 줄 것이냐
[원래 의도는 이랬다]

@ExceptionHandler(BindException.class)  
protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {  
   log.error("handleBindException throws BindException : {}", e.getMessage());  
   return ErrorResponse.from(INVALID_REQUEST, e.getBindingResult());  
}

bindException을 걸고 나머지 핸들링을 하지 못한 오류에 대해서는

@ExceptionHandler(Exception.class)  
private ResponseEntity<ErrorResponse> handleExceptionInternal(ErrorCode errorCode, String message) {  
   return ResponseEntity.status(errorCode.getHttpStatus())  
      .body(makeErrorResponse(errorCode, message));  
}

다 여기 짬통에 박아두느냐
개인의 편의 차이인 것 같다.

[참조]
https://velog.io/@u-nij/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-221029
https://itecnote.com/tecnote/java-error-creating-bean-with-name-handlerexceptionresolver-defined-in-class-path-resource/
https://yuja-kong.tistory.com/128
https://sas-study.tistory.com/473
https://blog.naver.com/PostView.naver?blogId=writer0713&logNo=221605253778&parentCategoryNo=&categoryNo=83&viewDate=&isShowPopularPosts=false&from=postView

강의를 듣다보니 다음과 같은 설명이 나왔다.

@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {

    @Autowired MemberJpaRepository memberJpaRepository;

    @Test
    public void testMember(){
        Member member = new Member("memberA");
        Member savedMember = memberJpaRepository.save(member);

        Member findMember = memberJpaRepository.find(savedMember.getId());

        assertThat(findMember.getId()).isEqualTo(member.getId());
    }
}

평소같으면 그냥 @RequiredArgsConstructor로 의존성을 주입해주었을텐데, 이상하게도 @Autowired밖에 지원하지 않는다.

이유는 일반적인 코드에서는 스프링 컨테이너가 @Bean을 가져와 자동으로 주입을 해 주지만,

테스트같은 경우에는 JUnit5 Test Container라는 별도의 컨테이너가 사용된다. 그래서 DI의 타입이 정해져 있어서 @Autowired만 사용이

가능하다.

+ Recent posts