점프 투 스프링부트 추천 기능 개선 - ManyToMany 명시적 중간 테이블 도입 & 일부 로직 개선

박철현·2024년 6월 7일
0

점프투스프링부트

목록 보기
14/14

기존 코드 및 변경 필요성

기존 코드

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class) // + enableJpaAuditing => JPA Auditing 활성
// JPA Auditing : 시간에 대해 자동으로 값을 넣어주는 기능
public class Answer {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer id;

	@Column(columnDefinition = "TEXT")
	private String content;

	@CreatedDate
	private LocalDateTime createDate;

	@LastModifiedDate
	private LocalDateTime modifyDate;

	@ManyToOne // 부모 : question
	private Question question;

	@ManyToOne
	private SiteUser author;

	@ManyToMany
	@LazyCollection(LazyCollectionOption.EXTRA)
	private Set<SiteUser> voters = new LinkedHashSet<>();
@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class) // + enableJpaAuditing => JPA Auditing 활성
// JPA Auditing : 시간에 대해 자동으로 값을 넣어주는 기능
public class Question {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Integer id;

	@Column(length = 200)
	private String subject;

	@Column(columnDefinition = "TEXT")
	private String content;

	@CreatedDate
	private LocalDateTime createDate;

	@LastModifiedDate
	private LocalDateTime modifyDate;


	@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
	@LazyCollection(LazyCollectionOption.EXTRA) // answerList.size(); 
 
	@ManyToOne
	private SiteUser author;

	@ManyToMany
	@LazyCollection(LazyCollectionOption.EXTRA)
	private Set<SiteUser> voters = new LinkedHashSet<>();

	private int view = 0;

	public void updateView() {
		this.view++;
	}

	/* 게시판 분류
	0 : 질문답변
	1 : 강좌
	2 : 자유게시판
	 */
	private int category;

	public QuestionEnum getCategoryAsEnum() {
		switch (this.category) {
			case 0:
				return QuestionEnum.QNA;
			case 1:
				return QuestionEnum.FREE;
			case 2:
				return QuestionEnum.BUG;
			default:
				throw new RuntimeException("올바르지 않은 접근입니다.");
		}
	}

	public String getCategoryAsString() {
		switch (this.category) {
			case 0:
				return "질문과답변";
			case 1:
				return "자유게시판";
			case 2:
				return "버그및건의";
			default:
				throw new RuntimeException("올바르지 않은 접근입니다.");
		}
	}

	@OneToMany(mappedBy = "question", cascade = {CascadeType.REMOVE})
	@ToString.Exclude
	@LazyCollection(LazyCollectionOption.EXTRA) // commentList.size(); 함수가 실행될 때 SELECT COUNT 실행
	private List<Comment> comments = new ArrayList<>();

}

DB 중간 Mapping Table 명시 지정 필요성

  • ManyToMany 설정으로 인해 중간 테이블 매핑
  • 관계형 DB에서는 N:M 불가능하여 중간 테이블 매핑 필요
  • JPA에서 중간 테이블 자동 매핑 해주나 중간 테이블에 속할 속성을 관리(?)할 수 없음
    • 기본적으로 두 N:M 관계있는 Id 두속성으로만 구성
    • 중간 테이블에서 추가로 저장할 속성 지정 불가
      • 예를들면 좋아요 관계에서 언제 눌렀는지 등의 속성

자동 mapping table 확인

  • 그래서 일단 생기는 테이블 확인
  • 음 뭐 일단 ok하고 테이블 확인
    • answer_voter : 음 뭐 두개 속성 id로만 구성 확인~
  • 추천 버튼 눌렀을 때 쿼리 확인
    • 잘 가네? 엥간한듯

필요성

  • 필요한 중간 테이블로서 누가 어떤 질문 or 답변에 추천을 눌렀다가 잘 동작함
  • 하지만 언제 눌렀는가에 대한 정보가 없음
  • 별도 엔티티화 하여 중간 엔티티를 둬서 누른 시점도 저장하면 좋을듯..!!

Entity 수정

DB 테이블 결과

Question - QuestionVoter 분리

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class) // + enableJpaAuditing => JPA Auditing 활성
// JPA Auditing : 시간에 대해 자동으로 값을 넣어주는 기능
public class Question {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(length = 200)
	private String subject;

	@Column(columnDefinition = "TEXT")
	private String content;

	@CreatedDate
	private LocalDateTime createDate;

	@LastModifiedDate
	private LocalDateTime modifyDate;

	@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
	@LazyCollection(LazyCollectionOption.EXTRA) // answerList.size(); 함수가 실행될 때 SELECT COUNT 실행
	// N+1 문제는 발생하지만, 한 페이지에 보여주는 10개의 게시물의 정보를 가져와서 개수를 표기하는 것 보다는 덜 부담
	private List<Answer> answerList = new ArrayList<>();

	@ManyToOne
	private SiteUser author;

	@OneToMany
	@LazyCollection(LazyCollectionOption.EXTRA)
    // 변경!!
	private Set<QuestionVoter> voters = new HashSet<>();

}
@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class QuestionVoter {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne
	private Question question;

	@ManyToOne
	private SiteUser voter;

	@CreatedDate
	private LocalDateTime createdAt;

}

로직 수정

@Transactional
	public void vote(Question question, SiteUser siteUser) {
		QuestionVoter questionVoter = questionVoterRepository.findByQuestionAndVoter(question, siteUser);

		// 이미 추천했다면 삭제
		if(questionVoter != null) {
			// answer에 Set 갱신
			question.getVoters().remove(questionVoter);
			// 연관관계 주인도 업데이트
			questionVoterRepository.delete(questionVoter);
			return;
		}

		// 추천 안했다면 새로 추천 처리
		QuestionVoter newQuestionVoter = new QuestionVoter();
		newQuestionVoter.setQuestion(question);
		newQuestionVoter.setVoter(siteUser);
		// 연관관계 주인 및 Set 저장
		QuestionVoter saveQuestionVoter = questionVoterRepository.save(newQuestionVoter);
		question.getVoters().add(saveQuestionVoter);
        // 아래도 되긴하네요
        // question.getVoters().add(newQuestionVoter); 
	}
  • 중간 테이블에서 데이터 있는지 확인하고 있으면 삭제, 없으면 생성
  • save 결과를 받아서 Set에 추가하는 이유는 Id 생성 전략이 Strategy로 DB에 맡기고, AutoIncrement기에 객체 생성시점에 Id를 결정할 수 없기 때문에 save 결과를 받음
    • question.getVoters().add(newQuestionVoter); 해도 되네유
  • Set에만 넣어주면 JPA에서는 연관관계 주인이 아니기 때문에 변경사항 무시 -> 연관관계 주인인 QuestionVoter를 명시적 save는 선택이 아닌 필수!!

중간 테이블 DB

  • 생성일(추천일) 개념으로 필드 추가 성공

Answer - AnswerVoter 분리

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class) // + enableJpaAuditing => JPA Auditing 활성
// JPA Auditing : 시간에 대해 자동으로 값을 넣어주는 기능
public class Answer {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(columnDefinition = "TEXT")
	private String content;

	@CreatedDate
	private LocalDateTime createDate;

	@LastModifiedDate
	private LocalDateTime modifyDate;

	@ManyToOne // 부모 : question
	private Question question;

	@ManyToOne
	private SiteUser author;

	@OneToMany(fetch = FetchType.LAZY)
	@LazyCollection(LazyCollectionOption.EXTRA)
    // 변경!!
	private Set<AnswerVoter> voters = new HashSet<>();

	@OneToMany(mappedBy = "answer", cascade = {CascadeType.REMOVE})
	@ToString.Exclude
	@LazyCollection(LazyCollectionOption.EXTRA) // commentList.size(); 함수가 실행될 때 SELECT COUNT 실행
	private List<Comment> comments = new ArrayList<>();
}
@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class AnswerVoter {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne
	private Answer answer;

	@ManyToOne
	private SiteUser voter;

	@CreatedDate
	private LocalDateTime createdAt;
}

로직 수정

	@Transactional
	public void vote(Answer answer, SiteUser siteUser) {
		AnswerVoter answerVoter = answerVoterRepository.findByAnswerAndVoter(answer, siteUser);

		// 이미 추천했다면 삭제
		if(answerVoter != null) {
			// answer에 Set 갱신
			answer.getVoters().remove(answerVoter);
			// 연관관계 주인도 업데이트
			answerVoterRepository.delete(answerVoter);
			return;
		}

		// 추천 안했다면 새로 추천 처리
		AnswerVoter newAnswerVote = new AnswerVoter();
		newAnswerVote.setAnswer(answer);
		newAnswerVote.setVoter(siteUser);
		AnswerVoter saveAnswerVote = answerVoterRepository.save(newAnswerVote);
		answer.getVoters().add(saveAnswerVote);
        // 아래도 되긴 하네유
        // answer.getVoters().add(newAnswerVote);
	}
  • 로직 설명은 위 Question 설명과 같아 생략합니다.

중간 테이블 DB

  • 생성일(추천일) 개념으로 필드 추가 성공

코드 및 학습 자료

일부 로직 개선

POST 요청 변경 및 Thymleaf 활용하여 추천 여부에 따라 버튼 다르게 보이게 변경

컨트롤러 수정

QuestionController

	@PreAuthorize("isAuthenticated()")
	@PostMapping("/vote/{id}")
	public String questionVote(Principal principal, @PathVariable("id") Long id) {
		Question question = questionService.getQuestion(id);
		SiteUser siteUser = userService.getUser(principal.getName());
		questionService.vote(question, siteUser);
		return String.format("redirect:/question/detail/%s", id);
	}
    
	@GetMapping("/detail/{id}")
	public String detail(Model model, @PathVariable Long id, AnswerForm answerForm,
				@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "") String sort, Principal principal) {
		Question question = questionService.getQuestion(id);
		model.addAttribute("question", question);

		Page<Answer> paging = answerService.getAnswerPage(question, page, sort);
		model.addAttribute("paging", paging);
		
		// 사용자가 로그인 했다면
		if (principal != null && principal.getName() != null) {
			// 질문에 대한 추천 여부 확인
			SiteUser currentUser = userService.getUser(principal.getName());
			boolean hasQuestionVoted = question.getVoters().stream()
				.anyMatch(v -> v.getVoter().equals(currentUser));
			model.addAttribute("hasQuestionVoted", hasQuestionVoted);

			// 답변에 대한 추천 여부 확인
			List<Boolean> hasAnsweredVoted = paging.getContent().stream()
				.map(answer -> answer.getVoters().stream()
					.anyMatch(v -> v.getVoter().equals(currentUser)))
				.collect(Collectors.toList());
					model.addAttribute("hasAnsweredVoted", hasAnsweredVoted);
		} else {
			model.addAttribute("hasQuestionVoted", false);
			model.addAttribute("hasAnsweredVoted", Collections.emptyList());
		}

		return "question/question_detail";
	}
----------
AnswerController

    @PreAuthorize("isAuthenticated()")
	@PostMapping("/vote/{id}")
	public String answerVote(Principal principal, @PathVariable("id") Long id) {
		Answer answer = answerService.getAnswer(id);
		SiteUser siteUser = userService.getUser(principal.getName());
		answerService.vote(answer, siteUser);
		return String.format("redirect:/question/detail/%s#answer_%s",
			answer.getQuestion().getId(), answer.getId());
	}
    
  • GetMapping -> PostMapping 변경
  • detail 로직 변경
    • 로그인한 사용자가 해당 질문 / 답변에 추천했다면 여부를 모델객체에 넣기
    • Answer의 경우 1:N 관계로, 현재 페이지에 보여줄 답변 인덱스마다 T/F여부를 넣은 리스트를 반환

QuestionDetail.html 수정

// Question 부분

<!-- 로그인 하지 않은 경우 기존꺼 그대로 노출-->
<a sec:authorize="isAnonymous()" class="btn btn-sm btn-outline-secondary" th:href="@{/user/login}">
  추천(로그인필요)
  <span class="badge rounded-pill bg-success" th:text="${#sets.size(question.voters)}"></span>
</a>

<!-- 로그인한 경우 -->
<form sec:authorize="isAuthenticated()" th:if="${hasQuestionVoted}" th:action="@{|/question/vote/${question.id}|}" method="post">
  <button type="submit" onclick="return confirm('추천을 취소하시겠습니까?');" class="btn btn-sm active btn-outline-secondary">
    추천
    <span class="badge rounded-pill bg-success" th:text="${#sets.size(question.voters)}"></span>
  </button>
</form>
<form sec:authorize="isAuthenticated()" th:if="${!hasQuestionVoted}" th:action="@{|/question/vote/${question.id}|}" method="post">
  <button type="submit" onclick="return confirm('추천하시겠습니까?');" class="recommend btn btn-sm btn-outline-secondary">
    추천
    <span class="badge rounded-pill bg-success" th:text="${#sets.size(question.voters)}"></span>
  </button>
</form>
// Answer 부분

<!-- 로그인한 경우 -->
<form sec:authorize="isAuthenticated()" th:if="${hasAnsweredVoted[stat.index]}" th:action="@{|/answer/vote/${answer.id}|}" method="post">
  <button type="submit" onclick="return confirm('추천을 취소하시겠습니까?');" class="btn btn-sm active btn-outline-secondary">
    추천
    <span class="badge rounded-pill bg-success" th:text="${#sets.size(answer.voters)}"></span>
  </button>
</form>
<form sec:authorize="isAuthenticated()" th:if="${!hasAnsweredVoted[stat.index]}" th:action="@{|/answer/vote/${answer.id}|}" method="post">
  <button type="submit" onclick="return confirm('추천하시겠습니까?');" class="recommend btn btn-sm btn-outline-secondary">
    추천
    <span class="badge rounded-pill bg-success" th:text="${#sets.size(answer.voters)}"></span>
  </button>
</form>

<!-- 로그인 하지 않은 경우 기존꺼 그대로 노출-->
<a sec:authorize="isAnonymous()" class="btn btn-sm btn-outline-secondary" th:href="@{/user/login}">
  추천(로그인필요)
  <span class="badge rounded-pill bg-success" th:text="${#sets.size(answer.voters)}"></span>
</a>

로직개선

@Transactional
	public void vote(Question question, SiteUser siteUser) {
		QuestionVoter questionVoter = questionVoterRepository.findByQuestionAndVoter(question, siteUser);

		// 이미 추천했다면 삭제
		if(questionVoter != null) {
			// answer에 Set 갱신
			question.getVoters().remove(questionVoter);
			// 연관관계 주인도 업데이트
			questionVoterRepository.delete(questionVoter);
			return;
		}

		// 추천 안했다면 새로 추천 처리
		QuestionVoter newQuestionVoter = new QuestionVoter();
		newQuestionVoter.setQuestion(question);
		newQuestionVoter.setVoter(siteUser);
		// 연관관계 주인 및 Set 저장
		QuestionVoter saveQuestionVoter = questionVoterRepository.save(newQuestionVoter);
		question.getVoters().add(saveQuestionVoter);
        // 아래도 되긴하네요
        // question.getVoters().add(newQuestionVoter); 
	}
  • 로직 개선
    • (기존) 추천 한번 더 눌렀을 때 Set이라 중복 카운트 안됨
    • (개선) 추천 한번 더 눌렀을 때 추천 취소 처리

화면 변경

Case 1 - 로그인하고 추천하지 않은 경우(기존 동일)


Case 2 - 로그인하고 추천한 경우(취소 여부 확인 & 추천한 버튼 기본 active)


  • 취소 시 active 해제 및 추천 취소 처리

Case 3 - 로그인 하지 않은 경우

  • 로그인 필요 및 옆에 추천 수 노출
  • 클릭 시 로그인 페이지 이동

코드확인 및 출처

profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글