본문 바로가기

개발/Database

[StudyHub] FK 매핑으로 인한 외래키 참조 무결성 문제 해결

서론


스터디허브 프로젝트에서 무분별하게 FK를 설정한 탓에 문제가 발생했다.

 

아래는 스터디허브의 회원, 신청, 스터디의 ERD이다.

 

 

apply 테이블은 users, study 테이블의 식별자를 외래키로 참조한다.

 

apply 테이블이 users 테이블의 데이터를 참조할 때 users 테이블의 데이터를 지우려고 하면 외래키 제약조건에 의해 에러가 발생할 것으로 예상된다.

 

테스트를 돌려서 확인해보자.

 

 

문제


 

UserEntity와 StudyEntity의 식별자로 ApplyEntity를 조회하는 기능의 단위 테스트를 작성했다.

 

@Test
void 유저식별자와_스터디식별자로_스터디요청_엔티티_조회() {
    // given
    UserEntity user = userRepository.save(UserEntityFixture.DONGWOO.UserEntity_생성());
    StudyEntity study = studyRepository.save(StudyEntityFixture.INU.studyEntity_생성());
    ApplyEntity apply = makeApply(user, study);

    applyRepository.save(apply);
    applyRepository.flush();

    userRepository.delete(user);
    userRepository.flush();

    // when
    ApplyEntity result = applyRepository.findByUserIdAndStudyId(user.getId(), study.getId());

    // then
    assertAll(
            () -> assertEquals(result.getUserId(), user.getId()),
            () -> assertEquals(result.getStudyId(), apply.getId())
    );
}

 

 

apply가 user를 참조하고 있는 상태에서 테스트 코드를 실행하니 외래키 제약조건을 위반했다는 에러가 발생했다.

 

ERROR 20104 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : Referential integrity constraint violation: "CONSTRAINT_3B98: PUBLIC.APPLY FOREIGN KEY(USER_ID) REFERENCES PUBLIC.USERS(USER_ID) (CAST(1 AS BIGINT))"; SQL statement:

 

 

MySQL에서 테이블의 외래키 제약조건은 RESTRICT 로 선언되어있다.

 

RESTRICT 제약조건은 참조하는 테이블에 데이터가 남아 있으면 참조되는 테이블의 데이터를 삭제하거나 수정할 수 없다. 

 

참조하는 테이블은 apply, 참조되는 테이블은 user 테이블인데, 참조되는 테이블의 데이터인 user 데이터를 삭제하면 제약조건에 위배되기 때문에 에러가 발생하는 것이다.

 

 

해결


 

두가지 해결법이 존재한다.

 

  • ApplyEntity와 UserEntity를 FK 매핑을 하는 방식에서 id 매핑으로 변경.
  • CascadeType.REMOVE를 이용해 부모 엔티티인 UserEntity 삭제 시 자식 엔티티인 ApplyEntity도 같이 삭제한다.

 

 

 

FK -> ID


외래키 참조 방식에서 UserEntity의 Id만 가지고 있는 방식으로 변경하면 ApplyEntity -> UserEntity로의 참조가 없어지기 때문에 외래키 제약 조건에 위배되지 않게 된다.

 

ApplyEntity와 UserEntity 간의 연관관계를 끊고id 매핑 방식으로 엔티티를 수정하고 다시 테스트를 돌려보겠다.

 

// 연관관계 끊기 이전
@ManyToOne
@JoinColumn(name = "user_id")
private UserEntity userEntity;

// 연관관계 끊고 id 매핑
@Column(name = "user_id")
private Long userId;

 

 

 

 

외래키 제약조건에 위배되지 않고 정상적으로 테스트가 동작했다.

 

 

 

CascadeType.REMOVE


CascadeType.REMOVE와 orphanRemoval = true를 함께 사용하면 아래와 같은 효과를 가진다.

 

  • UserEntity가 삭제될 경우 해당 엔티티의 식별자를 외래키로 가지고있는 ApplyEntity가 삭제된다.
  • UserEntity와 ApplyEntity의 연관관계가 끊어졌을 경우 끊어진 ApplyEntity를 고아로 취급해 DB에서 삭제한다.

 

UserEntity에 Cascade 속성과 orphanRemoval 속성을 아래와 같이 추가해줬다.

 

@OneToMany(mappedBy = "userEntity", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ApplyEntity> applyEntities = new ArrayList<>();

 

 

Cascade 속성을 추가해줬으니 UserEntity를 save 하면서 applyEntity 또한 자동으로 save 하도록 테스트 코드를 수정했다.

 

@Test
void 유저식별자와_스터디식별자로_스터디요청_엔티티_조회() {
    // given
    UserEntity user = UserEntityFixture.DONGWOO.UserEntity_생성();
    StudyEntity study = studyRepository.save(StudyEntityFixture.INU.studyEntity_생성());
    ApplyEntity apply = makeApply(user, study);

    List<ApplyEntity> list = List.of(apply);
    user.updateApply(list);

    userRepository.save(user);
    userRepository.flush();

    userRepository.delete(user);
    userRepository.flush();

    // when
    ApplyEntity result = applyRepository.findByUserEntity(user);

    // then
    assertAll(
            () -> assertEquals(result.getUserEntity().getId(), user.getId()),
            () -> assertEquals(result.getStudyId(), apply.getId())
    );
}

 

 

테스트 결과 NullPointerException이 발생하고, delete 쿼리문이 날아간 뒤에 select 쿼리까지 날아갔다.

 

NullPointerException은 유저가 삭제된 뒤 applyRepository.findByUserEntity에서 삭제된 유저로 조회하려 해서 발생했다.

 

결과적으로 userEntity를 삭제하기 전 Cascade 속성을 통해 ApplyEntity를 먼저 삭제했고 UserEntity를 삭제했기 때문에 외래키 제약 조건에 위배되지 않은 것이다.

 

 

 

 

 

결론


 

스터디허브의 비즈니스 로직을 생각해 보았을 때 회원은 신청한 스터디에 관계없이 자유롭게 탈퇴가 가능해야한다. 하지만 연관관계 매핑을 통해 스터디 신청 테이블에서 외래키를 가지고 있는 방법으로 회원 탈퇴를 할 경우 외래키 제약조건에 의해 에러가 터진다. 

 

이를 해결하기 위해 식별자 값만 가지고 있는 id 매핑으로 해결할 수 있고, cascadeType 속성으로 회원 탈퇴 시 ApplyEntity도 자동으로 삭제해줄 수도 있다.

 

 

각자 장단점이 있지만 데이터 관련 작업은 실수가 발생해선 안되는 중요한 작업이기 때문에 예상하지 못한 쿼리문이 나갈 수 있는 cascadeType 속성은 지양하기로 결정했다.

 

불편함을 감수해서라도 보수적으로 다가가는 것이 좋을 것 같다는 생각이 있다.

 

그렇기에 id 매핑을 사용하고 데이터 삭제 시 아래와 같이 직접 로직을 만들어 쿼리문이 어떻게 날아가는지 파악하며 삭제하는 것이 좋을 것 같다.

 

 

 

 

 

https://www.youtube.com/watch?v=vgNHW_nb2mg

https://github.com/study-hub-inu/study-hub-server

 

GitHub - study-hub-inu/study-hub-server

Contribute to study-hub-inu/study-hub-server development by creating an account on GitHub.

github.com