현재 퀴즈 ←→ 선지 , 퀴즈 ←→ 주관식 정답에선 양방향 매핑이 되고 있다.
MultipleQuiz.java: Choice를 List로 매핑하고 있다.
@Entity
@DynamicInsert
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue(value = "MultipleQuiz")
@Getter
public class MultipleQuiz extends Quiz {
@OneToMany(mappedBy = "multipleQuiz", cascade = CascadeType.ALL)
private List<Choice> choices = new ArrayList<>();
@Builder
public MultipleQuiz(int number, String question, String photoUrl, Education education, int appearSecond,
Generation generation) {
super(number, question, photoUrl, education, appearSecond, generation);
}
public void addChoices(List<Choice> choices) {
this.choices.addAll(choices);
choices.forEach(choice -> choice.matchMultipleQuiz(this));
}
}
Choice.java: 객관식 퀴즈(MultipleQuiz)를 매핑하고 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Choice extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "choice_id")
private Long id;
@Column(name = "choice_number")
private Integer choiceNumber;
@Column(name = "choice_content")
private String content;
@Column(name = "choice_correct")
@Enumerated(EnumType.STRING)
private ChoiceCorrect isCorrect;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "quiz_id")
private MultipleQuiz multipleQuiz;
private Choice(Integer choiceNumber, String content, ChoiceCorrect isCorrect) {
this.choiceNumber = choiceNumber;
this.content = content;
this.isCorrect = isCorrect;
}
public static Choice of(CreateChoiceRequest request) {
return new Choice(
request.getNumber(),
request.getContent(),
request.getIsAnswer()
);
}
public void matchMultipleQuiz(MultipleQuiz multipleQuiz) {
this.multipleQuiz = multipleQuiz;
}
}
이 양방향 매핑의 장단점을 생각해보고 없애자.
쿼리가 줄어드는가?
문제를 조회할 때 양방향 매핑을 쓴다고 쿼리가 줄어들까? 객관식 문제에 양방향 매핑된 Choice(선지)를 조회하는 쿼리를 비교해보자.
- 양방향 매핑 시 조회 쿼리
4개의 선지를 조회하는데 1번의 쿼리만 발생한다.
MultipleQuiz.getChoices()를 통해 바로 조회를 할 수 있다.
- 단방향으로도 아래와 같이 JPA를 활용하면 하나의 쿼리만 나간다.
List<Choice> choices = choiceRepository.findAllByMultipleQuiz((MultipleQuiz) quiz);
발생한 쿼리
select c1_0.quiz_id, c1_0.choice_id, c1_0.choice_number, c1_0.choice_content, c1_0.created_at, c1_0.choice_correct, c1_0.modified_at from choice c1_0 where c1_0.quiz_id=?
결론: 양방향을 쓴다고 쿼리가 줄어들지 않는다.
연관관계 편의 메소드
퀴즈 → 객관식 정답, 객관식 선지 → 퀴즈의 양방향 모두 연관관계 편의 메서드가 존재한다.
// MultipleQuiz.java의 선지추가
public void addChoices(List<Choice> choices) {
this.choices.addAll(choices);
choices.forEach(choice -> choice.matchMultipleQuiz(this));
}
// Choice.java의 퀴즈 추가
public void matchMultipleQuiz(MultipleQuiz multipleQuiz) {
this.multipleQuiz = multipleQuiz;
}
이렇게 연관관계 편의 메서드를 사용하면 Quiz객체에서 addChoices 메서드만 호출해도 선지에 대한 Choice 쿼리가 나가지만, 이를 양쪽으로 사용하면 toString()이나 Lombok을 사용할 때 무한 루프 문제가 발생할 수 있다.
실제로, 이렇게 addChoices내부에 forEach로 matchMultipleQuiz 를 추가하면 불필요한 Update 쿼리가 나간다.
choices.forEach(choice -> choice.matchMultipleQuiz(this));
또한 이미 생성된 정답, 선지의 문제가 바뀔일이 없으므로 matchMultipleQuiz 메서드는 제거하자.
그렇다면, 연관관계 편의 메소드를 한쪽에서만 구현했으니 괜찮을까? 실제 문제를 업로드 하는 과정에서 사용되는 코드를 보며 계속 고민해보자.
문제 업로드 시
List<Choice> choices = request.getChoices().stream()
.map(Choice::of)
.toList();
// choiceRepository.saveAll(choices);
log.info("객관식 선지 생성 : {}개", choices.size());
createdMultipleQuiz.addChoices(choices);
quizRepository.save(createdMultipleQuiz);
이렇게 matchMultipleQuiz를 제거하고, 문제 업로드를 하니 연관관계가 제대로 매핑되지 않는 문제가 생겼다.
양방향 매핑은 객체 양쪽 모두에게 매핑을 해줘야하는데 한쪽 메서드를 사용하지 않게되니 이런 문제가 발생한 것이다.
따라서, addChocies 메서드 또한 사용하지 않고, Choice를 생성할때 Quiz자체를 바로 매핑해주는 방식으로 코드를 수정했다.
List<Choice> choices = request.getChoices().stream()
.map(choice -> Choice.of(choice, quiz)
.toList();
choiceRepository.saveAll(choices);
log.info("객관식 선지 생성 : {}개", choices.size());
//createdMultipleQuiz.addChoices(choices);
- 메서드의 파라미터가 여러개라면 of를 사용한다. //ex List.of()..
- 메서드의 파라미터가 1개라면 from을 사용한다.
https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EC%A0%95%EC%A0%81-%ED%8C%A9%ED%86%A0%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%83%9D%EC%84%B1%EC%9E%90-%EB%8C%80%EC%8B%A0-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90
결국, 어느 한 쪽에서도 연관관계 편의 메서드를 사용하지 않았다. 그렇다면 양방향 매핑은 필요 없는 것이 아닐까? 마지막으로 고민해야할 이유가 남아있다.
퀴즈를 삭제했을때 Choice, ShortAnswer들이 따라서 삭제 되어야한다.
객관시 선지와 주관식 정답은 퀴즈가 삭제되면 따라서, 삭제되어야한다.
만약, 양방향 매핑을 사용하지 않고 Quiz를 삭제하면 SQL에서 외래키 제약조건에 따른 에러가 발생한다.
지우려는 Quiz 객체를 참조하는 Entity가 있으니 삭제가 불가능하다는 SQL 1451에러가 발생한다.
연관관계 매핑의 핵심은 객체간의 ‘생명 주기’에 있다.
하나의 객체가 소멸될때 참조하는 객체가 같이 소멸되는지 여부가 중요한데, 퀴즈와 선지(또는 주관식 정답)이 그러하다.
이 부분 때문에 양방향 매핑을 남길까 하는 고민을 했다.
하지만, 퀴즈 업로드 삭제 쿼리 줄이기 에서 퀴즈 삭제 연산 최적화를 하는 과정에서 퀴즈를 삭제하기전 수동으로 선지와 주관식 정답을 제거하기에, 양방향 매핑을 할 이유가 더이상 존재하지 않는다.
마지막 고민: 확장 가능성
다만, 마지막으로 고민이 되던 것은 “비즈니스 로직 상 퀴즈가 삭제되면 Choice 또는 ShortAnswer이 삭제되는 것이 맞는데 이를 표시하기 위해 남길 필요가 있을까?”에 대한 고민이 됐다.
기존 작업이 아닌, 새로운 문제 삭제가 필요할 때 자연스럽게 작업이 처리되게 두어야하지 않을까? 하는 고민이 되었다.
- 새로운 작업 시 개발자의 편의를 위해 양방향 매핑을 남기자.
- 새로운 작업을 개발자가 인지하고 양방향 매핑을 다시 걸 수 있게 양방향 매핑을 지우자.
2가지 고민 중, 아무래도 ‘삭제’ 연산에 관한 이야기이기에 새로운 작업을 할 때 개발자가 해당 시점에서 고민을 할 수 있게 하는 것이 보수적이지 않을까해서 양방향 매핑을 제거했다.
<최종 코드>
@Entity
@DynamicInsert
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue(value = "MultipleQuiz")
public class MultipleQuiz extends Quiz {
@Builder
public MultipleQuiz(Integer number, String question, String photoUrl, Education education, int appearSecond,
Generation generation) {
super(number, question, photoUrl, education, appearSecond, generation);
}
}
'프로젝트 > COTATO.KR' 카테고리의 다른 글
정답 제출 API 멱등성 처리하기 (0) | 2024.06.01 |
---|---|
멱등성을 통한 정답 제출 API 중복 요청 방지 (이론) (0) | 2024.06.01 |
여러 객체를 삭제할 때 쿼리 줄이기 (0) | 2024.04.24 |
MethodArgumentNotValidException 처리하기 (0) | 2024.04.17 |
Controller 리팩토링 (0) | 2024.04.15 |