Spring

[Mapstruct] uses = 옵션 사용 ( 다른 엔티티 DTO들 간의 Mapper 메서드 공유하기 )

테디규 2023. 2. 22. 21:28

문제

Question Entity

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Question extends Auditable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long questionId;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;
    @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Answer> answers = new ArrayList<>();

    public void setAnswer(Answer answer){
        answers.add(answer);
        if(answer.getQuestion() != this){
            answer.setQuestion(this);
        }
    }
}

Answer Entity

@Entity
@NoArgsConstructor
@Setter
@Getter
public class Answer extends Auditable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long answerId;
    //Todo: 사용자 객체
//    @Column(nullable = false)
//    private Owner owner;
    @ManyToOne
    @JoinColumn(name = "question_id")
    private Question question;
    private Long score;
    private Boolean isAccepted;
    @Lob // Large Object
    @Column(nullable = false)
    private String content;

    public void setQuestion(Question question){
        this.question = question;
        if(!this.question.getAnswers().contains(this)){
            this.question.setAnswer(this);
        }
    }
}

애노테이션을 보면 알겠지만 Answer와 Question는 다대일 관계를 가지고 있다.

{
    "data": {
        "questionId": 1,
        "title": "제목입니다",
        "content": "내용입니다.",
        "answers": [
            {
                "answerId": 1,
                "questionId": null, ## null 이 아닌 1이 나와야함
                "score": 0,
                "isAccepted": false,
                "content": "해답은 다음과 같습니다. 어쩌구 저쩌구@@@@",
                "createdAt": "2023-02-22T18:24:33.039779",
                "modifiedAt": "2023-02-22T18:24:33.039779"
            }
        ]
    }
}

위는 Mapstruct를 통해 최종적으로 만들어지는 Response Json 이다. Question 안에 Answer 목록들이 있는 형태로 되어있는데 questionId에 값이 들어오지 않고 있는 모습을 볼 수 있었다.

문제 해결

원인은 MapStruct를 통해 만든 Mapper들이 서로 연동되지 않기 때문이었다.

AnswerMapper

@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface AnswerMapper {
    Answer answerPostDtoToAnswer(AnswerDto.Post requestBody);
    Answer answerPatchDtoToAnswer(AnswerDto.Patch requestBody);
    @Mapping(source = "question.questionId", target = "questionId")
    AnswerDto.Response answerToAnswerResponseDto(Answer answer);
    List<AnswerDto.Response> answersToAnswerResponseDtos(List<Answer> answers);
}

Answer Mapper는 위의 @Mapping 을 통해 객체필드값(source)에서 알맞은 부분을 가져와 ResponseDto의 필드에(target) 넣어주도록 설정해주었다.

QuestionMapper

@Mapper(componentModel = "spring")
public interface QuestionMapper {
    Question questionPostDtoToQuestion(QuestionDto.QuestionPostDto questionPostDto);
    Question questionPatchDtoToQuestion(QuestionDto.QuestionPatchDto questionPatchDto);
    QuestionDto.QuestionResponseDto questionToQuestionResponseDto(Question question);
    List<QuestionDto.QuestionResponseDto> questionsToQuestionResponseDtos(List<Question> questions);
}
@Getter
        @Builder
        public static class QuestionResponseDto {
            private Long questionId;
            private String title;
            private String content;
            private List<AnswerDto.Response> answers;
        }

QuestionMapper에서는 Question ResponseDto를 만들기 위해 AnswerDto.Response(DTO 라고 생각하시면 됩니다.)를 사용한다.

가장 쉽게 생각해볼 것은 “@Mapping을 써볼수 있나?” 이지만 QuestionResponseDto 에 객체 필드가 가 아닌 List형식으로 answers 필드가 생성되어 있으므로 @Mapping 지정해주는 것은 불가능하다.

다시 생각해보자. 현재 가장 핵심인 문제는 Answer 엔티티를 AnswerDto.Response로 변환해주는 메서드가 없다는 것이다.

그러므로 Answer 엔티티를 AnswerDto.Response 로 변환해주는 메서드를 가진 AnswerMapper를 QuestionMapper 에서 사용할 수도록 하는 것이다. 이는 아래처럼 use 옵션을 붙여주면 된다.

@Mapper(componentModel = "spring",uses = AnswerMapper.class)
public interface QuestionMapper {
    Question questionPostDtoToQuestion(QuestionDto.QuestionPostDto questionPostDto);
    Question questionPatchDtoToQuestion(QuestionDto.QuestionPatchDto questionPatchDto);
    QuestionDto.QuestionResponseDto questionToQuestionResponseDto(Question question);
    List<QuestionDto.QuestionResponseDto> questionsToQuestionResponseDtos(List<Question> questions);
}

이제 Mapstruct가 적절히 매핑코드를 생성해주어, Answer 엔티티의 객체필드로 된 Question에서 ID만 뽑아와 DTO에 넣어주는 작업을 해주게 된다. 그럼 올바른 ID 값을 얻을 수 있다.

{
    "data": {
        "questionId": 1,
        "title": "제목입니다",
        "content": "내용입니다.",
        "answers": [
            {
                "answerId": 1,
                "questionId": 1
                "score": 0,
                "isAccepted": false,
                "content": "해답은 다음과 같습니다. 어쩌구 저쩌구@@@@",
                "createdAt": "2023-02-22T18:24:33.039779",
                "modifiedAt": "2023-02-22T18:24:33.039779"
            }
        ]
    }
}

좀 더 디테일하게 확인해보고 싶으면?

build - generated - annotationProcessor - 하위 main 에서 Mapstruct가 동작한 구현체를 확인해보자. use의 유무에 따라 어떤 구현체가 만들어지는지 확인해보면 더 깊은 이해를 할 수 있다.

  • AnswerMapperImpl 같은 이름으로 생성된다.

출처: https://honinbo-world.tistory.com/103