orphanRemoval과 fk

김성재·2024년 6월 23일

꾸준히 블로그를 쓰겠다는 다짐을 지키지 못하고 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개의 댓글