티스토리 뷰

리팩토링 할 코드

POST /api/v1/comment
댓글을 작성한다.
 
댓글 작성 API의 비즈니스 로직을 위한 전제 조건은 다음과 같다.

1. Client에서 댓글 작성을 요청한다.

2. 댓글의 구조는 원댓글-대댓글을 구별하기 위해, Comment Entity는 원댓글에 대한 필드인 OriginalComment 라는 Comment 를 가진다.

@Transactional
public void create(CreateCommentRequest request) {
    Users user = getUser(request);
    Posts post = getPost(request);

    Comment originalComment = null;
    if (request.getOriginalCommentId() != null) {
        originalComment = commentRepository.findById(request.getOriginalCommentId())
                .orElseThrow(() -> new BusinessException(ErrorCode.ORIGINAL_COMMENT_DOES_NOT_EXISTS));
    }

    Comment newComment = request.toEntity(user, post, originalComment.get().getOriginalComment());
    commentRepository.save(newComment);
}

여기서 문제점.

원댓글이 없으면 originalComment 가 존재하지 않는 경우가 발생하기 때문에 Optional로 NPE check 하게 되는데,
댓글 존재 유무에 대한 검사로 orElseThorw()로 예외처리를 하면 null을 저장하는 시점이 없어진다고 생각하여
null... 로 초기화를 하고 있다.
 
(왜 이런 기발하고 참신한 이상한 생각을 했는지 지금 보면... 할말이 없다..)
 
 

null 초기화를 사용하지 않는 방법으로 리팩토링을 시작해보자.

생각 1) 원댓글이 존재하지 않으면, request 요청받은 comment가 원댓글이 된다.

no.. 이건 애초에 접근법 자체가 잘못됐다.
 

생각 2) 프론트와 서버에서 하는 역할을 구분해서 생각해야 한다.

[기본 전제]

  • 클라이언트에서 request로 요청값 보내면 → 서버에서 null check 후 저장한다.
  • 여기서 요청값에는 OriginalCommentId 가 있다.
  • 따라서 해당 값들이 null 인지만 체크하면 되고, 서버에서는 데이터에서 어떤게 원댓글, 대댓글인지 판단은 따로 하지 않는다.

 

MapStruct

먼저 변환하면서 편리한 객체간 매핑을 위해 MapStruct를 사용해보았다.

[사용 중 발생한 에러]

Parameter 0 of constructor in () required a bean of type () that could not be found.

Parameter 4 of constructor in com.clone.instagram.domain.comment.service.CommentService required a bean of type 'com.clone.instagram.domain.comment.resources.CommentMapper' that could not be found.

 
해결 - bean 등록 확인하기

constructor의 4번째에 오는 인자에 필요한 bean이 제대로 등록이 안되어 있다는 것이다.
(4번째 인자는 당연히 Mapper interface 클래스였다..)

private final CommentMapper commentMapper;
@Mapper(
        componentModel = "spring", // spring component bean 등록 << 반드시 필요함
        injectionStrategy = InjectionStrategy.CONSTRUCTOR, // 생성자 주입 방식 사용 << 없어도 실행됨
        unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface CommentMapper {

    Comment from(CommentCreateRequest request);
}
  • Mapper interface의 @Mapper 어노테이션에 spring bean 으로 등록한다.
  • @Mapper(componentModel = "spring") : 해당 매퍼를 스프링 빈(bean)으로 등록

 
 

[MapStruct Processor 옵션 및 매핑 정책]

MapStruct는 Annotation Processor 를 이용한 매핑인 만큼, Annotation 을 통한 옵션이나, 매핑에 대한 정책을 @Mapper 에 설정할 수 있습니다.
 

ComponentModel

매퍼를 bean으로 등록할 수 있습니다.

@Mapper(componentModel = "spring")
public interface MessageMapper {
	...
}
  • 생성된 매퍼는 싱글톤 범위
  • 매퍼를 빈으로 등록해서 사용하는 경우나 매퍼 내부에서 다른 빈을 주입받아 사용이 필요한 경우, 위와 같이 빈을 등록하여 사용할 수 있습니다.

unmmappedTargetPolicy

해당 정책은 타겟이 되는 필드에 대한 정책입니다. Target 필드는 존재하는데 source의 필드가 없는 경우에 대한 정책입니다.

정책 옵션

  • ERROR : 매핑 대상이 없는 경우, 매핑 코드 생성 시 error 가 발생합니다.
  • WARN : 매핑 대상이 없는 경우, 빌드 시 warn 이 발생합니다.
  • IGNORE : 매핑 대상이 없는 경우 무시하고 매핑됩니다.

 

java: Unmapped target property: "replies".

@Component
public class CommentMapperImpl implements CommentMapper {

    @Override
    public Comment from(CommentCreateRequest request) {
        if ( request == null ) {
            return null;
        }

        Comment comment = new Comment();

        return comment;
    }
}

Mapper 구현체 클래스 CommentMapperImpl

DTO에서 Entity로 변환하는 from() 메서드에서 CommentCreateRequest(DTO) 를 Comment(Entity) 로 제대로 변환하지 못하고 있다.

 


ERROR : java: Unmapped target property: "replies".

@OneToMany(mappedBy = "originalComment")
private List<Comment> replies = new ArrayList<>();

Comment 엔티티의 OneToMany mapping 관계의 replies 필드에서 변환을 못한 것이다.


 

해결 : 매핑관계에서 제외시킬 사항은 @Mapping(ignore = true) 지정한다.

@Mapping(target = "replies", ignore = true)
Comment from(CommentCreateRequest request);

 
 

OriginalComment 매핑을 안해서, original_comment_id = null 로 들어간다.

  • 원래 원댓글에 request 로 받아온 -1을 넣어줘야 할까 고민 했다.
  • 근데 이렇게 임의로 특정 값을 넣는건 좋지 않은 방법이고, 값이 없으면 null 로 그대로 넣어주기로 했다.

 

Comment originalComment = commentRepository.findById(request.getOriginalCommentId())
        .orElseThrow();
Comment.setOriginalComment(originalComment);
  • 그렇다면 이렇게 originCommentId 필드에 추가하는 작업이 불필요할 거 같았는데,
    매핑을 아예 안한다면 originCommentId → Comment 변환하여 엔티티에 저장하는 로직을 수행하지 못한다.
  • DTO -> Entity 로 변환 후, originCommentId 에 대한 Comment 객체를 저장하는 로직이 필요하다.

 
 

MapStruct 의 사용 용도를 잘 생각하기

MapStruct...
DTO ↔ Entity 간 객체 변환을 편리하게 사용하기 위한 라이브러리다.
 
그런데 변환하기 위해 작성하다 보니 파라미터에 엔티티 객체를 넣고 있는 걸 발견했다.
이는 단순히 변환하기 위한 라이브러리의 역할과 맞지 않는 비즈니스적인 로직이 포함되어 보인다.

public interface CommentMapper {

    @Mapping(target = "replies", ignore = true)
    @Mapping(target = "writer", source = "writer")
    @Mapping(target = "post")
    @Mapping(target = "content", source = "request.content")
    @Mapping(target = "originalComment", source = "comment")
    Comment from(CommentCreateRequest request, Users writer, Posts post, Comment comment);
}
  • from 메소드는 객체 간의 매핑 외에 여러 객체를 받아서 조합하는 로직을 포함한다.
  • 이는 일반적으로 서비스 레이어에서 처리하고, MapStruct 매핑 메소드에서 처리하지 않는다.
  • 그래서 MapStruct를 사용할 때는 변환 로직을 간단하게 유지하고, 복잡한 로직은 서비스 레이어에서 처리하는 것이 좋다.

 
그래서 다음과 같이 매핑하는 처리만 MapStruct 메소드에서 처리하도록 변경했다.

@Mapping(target = "replies", ignore = true)
Comment from(CommentCreateRequest request);

 
이에 따라서 서비스 레이어도 로직이 변경되었다.

@Transactional
public void create(CommentCreateRequest request) {
    Users user = getUser(request); // user 검증
    Posts post = getPost(request); // post 검증
    
    Comment originalComment = commentRepository.findById(request.getOriginalCommentId());
    originalComment.setOriginalComment(originalComment);
    
    commentRepository.save(commentMapper.from(request, user, post, originalComment));
}
@Transactional
public void create(CommentCreateRequest request) {
    Users user = getUser(request); // user 검증
    Posts post = getPost(request); // post 검증
    
    Comment comment = commentMapper.from(request); // 딱 Request DTO 만 변환한다.
    Comment original = commentRepository.findById(request.getOriginalCommentId()).orElse(null);
    comment.create(user, post, original);
    
    commentRepository.save(comment);
}
  • Optional.orElse(null);
  • Optional 의 orElse()와 orElseGet() 등의 메소드를 사용하여 Optional 객체가 비어 있을 때의 기본값을 지정할 수 있다.

 
 

변경 사항 다시보기

// 변경 전
@Transactional
public void create(CreateCommentRequest request) {
    Users user = getUser(request);
    Posts post = getPost(request);

    Comment originalComment = null;
    if (request.getOriginalCommentId() != null) {
        originalComment = commentRepository.findById(request.getOriginalCommentId())
                .orElseThrow(() -> new BusinessException(ErrorCode.ORIGINAL_COMMENT_DOES_NOT_EXISTS));
    }

    Comment newComment = request.toEntity(user, post, originalComment.get().getOriginalComment());
    commentRepository.save(newComment);
}

// 변경 후
@Transactional
public void create(CommentCreateRequest request) {
    Users user = getUser(request);
    Posts post = getPost(request);
    
    Comment comment = commentMapper.from(request);
    Comment original = commentRepository.findById(request.getOriginalCommentId()).orElse(null);
    comment.create(user, post, original);
    
    commentRepository.save(comment);
}
  • User, Post 에 대한 유효성 검사는 그대로 두었다.

null 초기화

  • 임의로 객체를 null 로 초기화 하던 부분을 -> Optional null 처리 후, 비어 있을 때 orElse()와 같은 메서드로 기본값을 지정한다.
  • 처음부터 null 로 초기화 하면 추후에 어떤 부분에서 null이 발생하고, 예외처리 해야할지 모호해질 수 있기 때문에.. NullPointerException 에러 핸들링이 힘들어질 수 있다.

객체 간 변환

  • CreateCommentRequest에서 Entity로 변환하기 위한 toEntity 메서드를 직접 정의해서 사용하다가, MapStruct 사용으로 변환했다.
  • 잘만 사용하면 개발자의 실수를 줄여주고, 코드 작성하는데 편리함을 주어서 괜찮은 라이브러리 같다..

 
 
 
 
 
 
 

반응형