orphanRemoval과 fk

김성재·2024년 6월 23일
2

꾸준히 블로그를 쓰겠다는 다짐을 지키지 못하고 3개월만에 밀린 내용들을 정리하려고 한다... 강제성이 없으면 항상 기록 하는 것을 미뤄서 반성해야겠다ㅠ

상황 발생

밖에서 평화롭게 저녁을 먹고 들어가고 있던 중 슬랙 에러 채널에 에러 메시지가 떴다(왜 항상 밖에 있거나 바쁠때 에러가 제일 많이 터지는지..🥲)
우선 에러 메시지를 보니 회원이 탈퇴하는 과정 중에 일어난 것과, owenr라는 메시지를 보니 사장님 쪽과 관련된 에러라는 것을 파악할 수 있다.

한창 동시성 에러가 알림으로 오던 때라 Exception: DataIntegrityViolationException을 보고 당연히 동시성 에러인 줄 알아서 잘못 접근하고 삽질을 엄청나게 했다.

위 에러를 이해하려면 orphanRemoval과 cascade의 동작이 어떻게 일어나는 지를 알아야 한다.

문제 인식

@Getter
@Entity
@NoArgsConstructor(access = PROTECTED)
@Table(name = "owners")
public class Owner {

    @Id
    @Column(name = "user_id", nullable = false)
    private Integer id;

    @MapsId
    @OneToOne(cascade = ALL)
    @JoinColumn(name = "user_id", referencedColumnName = "id")
    private User user;

    @Size(max = 12)
    @NotNull
    @Column(name = "company_registration_number", nullable = false, length = 12, unique = true)
    private String companyRegistrationNumber;

    @Column(name = "grant_shop", columnDefinition = "TINYINT")
    private boolean grantShop;

    @Column(name = "grant_event", columnDefinition = "TINYINT")
    private boolean grantEvent;

    @Size(max = 255)
    @Column(name = "account")
    private String account;

    @OneToMany(cascade = {PERSIST, MERGE, REMOVE}, orphanRemoval = true)
    @JoinColumn(name = "owner_id")
    private List<OwnerAttachment> attachments = new ArrayList<>();
    .
    .
    .
@Getter
@Entity
@Where(clause = "is_deleted=0")
@Table(name = "owner_attachments")
@NoArgsConstructor(access = PROTECTED)
public class OwnerAttachment extends BaseEntity {

    private static final String NAME_SEPARATOR = "/";
    private static final int NOT_FOUND_IDX = -1;

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Integer id;

    @ManyToOne(cascade = {PERSIST, MERGE, REMOVE})
    @JoinColumn(name = "owner_id")
    private Owner owner;
    .
    .
    .

위에 두 entity owner와 owner_attachments는 우리가 운영하고 있는 코인이라는 서비스에서 음식점 사장님과 관련된 엔티티들이다.(코인 페이지)
위에서 보면 알 수 있듯이 두 엔티티 owner와 owner_attachment의 관계는 일대다 관계이다.
위에서 owner_attachment의 owner_id 컬럼을 잘 봐두도록 하자.

jpa를 사용하는 사람이면 거의 다 알고 있듯이, orphanRemoval = true은 부모 객체가 삭제 될 때 부모 객체가 갖고 있던, 고아가 되는 자식 객체도 같이 삭제해주는 설정이다. 이제 아래와 같이 yml파일을 쿼리를 볼 수 있게 설정하고, 테스트 코드를 작성한 후 날아가는 쿼리를 보자

yml 파일

spring:
  flyway:
    enabled: false
  jpa:
    properties:
      hibernate:
        show_sql: true

테스트 코드

    @Test
    @DisplayName("사장님이 회원탈퇴를 한다.")
    void ownerDelete() {
        // given
        Owner owner = userFixture.현수_사장님();
        String token = userFixture.getToken(owner.getUser());

        // when
        RestAssured
            .given()
            .header("Authorization", "Bearer " + token)
            .when()
            .delete("/user")
            .then()
            .statusCode(HttpStatus.NO_CONTENT.value())
            .extract();

        // then
        assertThat(userRepository.findById(owner.getId())).isNotPresent();
    }

(위에서 fixture는 테스트 코드의 중복을 줄이기 위해 도입한 것이다. 자세한 것은 깃 참고)
코인 git

쿼리

Hibernate: 
    update
        owner_attachments 
    set
        owner_id=null 
    where
        owner_id=? 
        and (
            (
                is_deleted=0
            ) 
        )
Hibernate: 
    delete 
    from
        owner_attachments 
    where
        id=?
Hibernate: 
    delete 
    from
        owner_attachments 
    where
        id=?
Hibernate: 
    delete 
    from
        owners 
    where
        user_id=?
Hibernate: 
    delete 
    from
        users 
    where
        id=?




위에서 보다시피 회원이 탈퇴를 하려 할 때 순서가

  1. 자식 엔티티인 owenr_attachment의 fk 키를 null로 만들음
  2. 자식 엔티티 삭제
  3. 부모 엔티티 삭제
    로 됨을 알 수가 있다.

그렇다면 삭제하는 것 까지 그렇다 쳐도 왜 fk를 null로 만드는 것일까?
hibernate에서는 자식 객체를 삭제하기 전에 참조의 무결성을 위해서 먼저 fk를 null로 만드는 쿼리를 날린다고 한다.

db를 보면 owner_id가 not null로 되어있는데 왜 테스트 코드로는 에러가 안 난거지? 했는데 알고보니 owner_attachment 엔티티의 속성이 잘못 설정되어 있는 휴먼에러였다.

@Getter
@Entity
@Where(clause = "is_deleted=0")
@Table(name = "owner_attachments")
@NoArgsConstructor(access = PROTECTED)
public class OwnerAttachment extends BaseEntity {

    private static final String NAME_SEPARATOR = "/";
    private static final int NOT_FOUND_IDX = -1;

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Integer id;

    @ManyToOne(cascade = {PERSIST, MERGE, REMOVE})
    @JoinColumn(name = "owner_id", nullable = false)
    private Owner owner;

owner_id 컬럼에 nullable = false를 추가하니까 똑같은 에러 발생 성공!

해결

해결법은 간단하다.
그냥 자식 엔티티인 owenr_attachment의 fk가 null를 안해주면 된다.
updatable = false라는 설정을 추가해주면 fk를 null로 만들어주는 쿼리를 날리지 않는다고 한다. 추가해주면 성공적으로 탈퇴를 할 수 있게 된다

@Getter
@Entity
@NoArgsConstructor(access = PROTECTED)
@Table(name = "owners")
public class Owner {

    @Id
    @Column(name = "user_id", nullable = false)
    private Integer id;

    @MapsId
    @OneToOne(cascade = ALL)
    @JoinColumn(name = "user_id", referencedColumnName = "id")
    private User user;

    @Size(max = 12)
    @NotNull
    @Column(name = "company_registration_number", nullable = false, length = 12, unique = true)
    private String companyRegistrationNumber;

    @Column(name = "grant_shop", columnDefinition = "TINYINT")
    private boolean grantShop;

    @Column(name = "grant_event", columnDefinition = "TINYINT")
    private boolean grantEvent;

    @Size(max = 255)
    @Column(name = "account")
    private String account;

    @OneToMany(cascade = {PERSIST, MERGE, REMOVE}, orphanRemoval = true)
    @JoinColumn(name = "owner_id", updatable = false)
    private List<OwnerAttachment> attachments = new ArrayList<>();

요약

부모와의 관계를 fk로 참조하고 있는 자식 엔티티가 있고 부모 엔티티에 (orphanRemoval = true) 설정이 있다고 하자.
fk를 (nullable = false)로 해놓은 경우가 많을텐데 이 경우는
부모 엔티티가 갖고 있는 자식 컬럼에 (updatable = false) 설정을 걸어놓아야 한다.
위 설정을 해야 부모 엔티티를 삭제 할 때 자식 엔티티도 함게 문제 없이 삭제 될 수 있다.

느낀 점

비록 동시성으로 알고 삽질을 많이했지만 영속성과 동시성에러에 대해 생각해 볼 수 있는 좋은 경험이였다.(동시성 에러 관련 포스트도 곧 올릴 예정)

0개의 댓글