꾸준히 블로그를 쓰겠다는 다짐을 지키지 못하고 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파일을 쿼리를 볼 수 있게 설정하고, 테스트 코드를 작성한 후 날아가는 쿼리를 보자
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=?
위에서 보다시피 회원이 탈퇴를 하려 할 때 순서가
그렇다면 삭제하는 것 까지 그렇다 쳐도 왜 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) 설정을 걸어놓아야 한다.
위 설정을 해야 부모 엔티티를 삭제 할 때 자식 엔티티도 함게 문제 없이 삭제 될 수 있다.
비록 동시성으로 알고 삽질을 많이했지만 영속성과 동시성에러에 대해 생각해 볼 수 있는 좋은 경험이였다.(동시성 에러 관련 포스트도 곧 올릴 예정)