[ElectionPJT/JPA] JPA의 외래 키 무결성 / Candidate Entity에서 양방향 매핑을 포기한 이유

1. 양방향 매핑을 하려고 했던 이유

  • Candidate를 삭제할 경우, Cascade(영속성 전이)를 통해 쉽게 관련 sns, youtube까지 삭제하고 싶었습니다.
  • Total likes, Total comments 등 통계 기능을 도입할 때 편할 것 같았습니다.

2. 양방향 매핑을 포기하는 이유

처음 생각: 삭제하기 위한 Candidate를 id로 조회할 때, 관련 엔티티(sns, youtube)들을 함께 fetch 조인해야 cascade가 잘 적용되겠지?

그래서 join fetch 쿼리를 작성하던 중 김영한님의 인프런 강의에서 들었던 내용이 생각났습니다.

  1. 둘 이상의 컬렉션은 페치 조인하면 안된다
  2. @ToMany 페치 조인에서는 페이징을 사용할 수 없다
  • 둘 이상의 컬렉션을 페치 조인할 경우, 1:N:N 매핑이 되어 자료가 말도 안되게 뻥튀기 될 위험이 있으며, 정합성 이슈가 발생할 수도 있다.
  • 위와 같은 이유로, @ToMany 페치 조인에서는 페이징 사용이 제한되며, 만약 억지로 사용하면 메모리에서 페이징이 되기 때문에 굉장히 위험하다.

또한 Total likes, Total comments 등 통계 기능은 Sns, Youtube 테이블을 모두 조인해서 결과를 내야하는데... 강의 내용에 따르면

여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요 한 데이터들만 조회해서 DTO로 반환하는 것이 효과적

  • 따라서 양방향 매핑을 더 이상 사용할 이유가 없어졌습니다.

3. 그렇다면 참조 무결성은 어떻게 보장해야 할까

현재 Sns, Youtube 엔티티는 Candidate에 연관관계가 걸려있기 때문에 Spring Boot 시작 시 다음과 같은 쿼리가 날아갑니다.

이제 Candidate에서 Cascade로 관리하지 않기 때문에, candidateRepository.remove(candidate)를 시도하면 다음과 같은 오류가 발생합니다.

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint ["FKDGYKRP9VG5PBS7S05I53938T2: PUBLIC.SNS FOREIGN KEY(CANDIDATE_ID) REFERENCES PUBLIC.CANDIDATE(CANDIDATE_ID) (2)"; SQL statement:
delete from candidate where candidate_id=? [23503-200]]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement

즉, 외래 키 무결성 요건이 위배되기 때문에 쿼리 실행 자체가 막히게 됩니다. 당연한 일입니다. 스프링에서 외래 키 무결성을 관리하지 않으니까요.
따라서 이 경우에는 DB에서 외래 키 무결성을 관리하도록 설정을 바꿔주어야 합니다.

SQL에서는 ON DELETE CASCADE로 DB에서 설정하지만, 우리는 Spring Boot로 테이블을 create하기 때문에 다음과 같은 어노테이션을 Sns, Youtube 엔티티에 달아줘야 합니다.

@OnDelete(action = OnDeleteAction.CASCADE)
public class Youtube {

    @Id @GeneratedValue
    @Column(name = "youtube_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "candidate_id")
    @OnDelete(action = OnDeleteAction.CASCADE) //여기에 추가해 주시면 됩니다.
    private Candidate candidate;
    
    ...
}

이렇게 변경하고 나면, alter 쿼리에 on delete cascade가 추가되어 날아갑니다.

@Inheritance(strategy = InheritanceType.JOINED)으로 설정된 엔티티의 경우에는

@Entity
@DiscriminatorValue("F")
@OnDelete(action = OnDeleteAction.CASCADE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Facebook extends Sns{

    private int likes;
    private int comments;
    private int shares;

이렇게 해주시면 됩니다.


4. 변경된 코드

Candidate Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Candidate {

    @Id @GeneratedValue
    @Column(name = "candidate_id")
    private Long id;

    private int number;

    @Column(name = "candidate_name")
    private String name;

    @Column(name = "candidate_likes")
    private int likes;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "city_id")
    private City city;

    @Builder
    public Candidate(int number, String name, City city) {
        this.number = number;
        this.name = name;
        this.likes = 0;
        this.city = city;
    }
}
  • 연관관계 메서드 삭제

Candidate Service


5. Candidate 엔티티 변경 후 테스트

테스트 코드

@Test
    public void 후보_삭제_ON_DELETE_CASCADE() throws Exception {
        //given
        City city = createCity();
        Candidate candidate = new Candidate(1, "Jake", city);
        candidateRepository.save(candidate);
        Facebook facebook = new Facebook(candidate, "content1", "url", LocalDateTime.now(), 1, 1, 1);
        facebookRepository.save(facebook);
        Youtube youtube = new Youtube(candidate, "url", "title", "thumbnail", LocalDateTime.now(), "description", 1, 1, LocalDateTime.now());
        youtubeRepository.save(youtube);

        //when
        Long candidateId = candidate.getId();
        candidateService.delete(candidateId);

        //then
        assertEquals(0, snsRepository.findAllByCandidateId(candidateId).size());
        assertEquals(0, youtubeRepository.findAllByCandidateId(candidateId).size());

    }

테스트 결과

좋은 웹페이지 즐겨찾기