테스트 코드 5 - CascadeType.PERSIST와MERGE의 차이 / DataIntegrityException
[문제]
현재 파트너스는 브랜드를 CascadeType.MERGE로 받고 있다. 이를 CascadeType.PERSIST로 바꾸자 브랜드 생성 시 DataIntegrityException이 터졌다. 오류메시지는 다음과 같다.
org.springframework.dao.DataIntegrityViolationException: could not execute statement [Column 'description' cannot be null] [insert into brand (description,image,name) values (?,?,?)]; SQL [insert into brand (description,image,name) values (?,?,?)]; constraint [null]
Json 문서를 Jackson 라이브러리를 이용하여 DTO로 객체로 만들 때는 정상적으로 값이 들어오고, Brand를 연관관계 편의 메서드를 이용해 각각의 필드에 업데이트 할 때만 해도 해당 필드가 모두 정상적으로 들어와 있는것을 확인할 수 있다.
필드들의 (nullable=false)를 제거하자, 브랜드가 생성되기는 하지만 모든 필드들이 null로 초기화되었다.
updatePartnersBrand() 메서드를 수행하며 잘못된 Brand 객체를 생성 및 저장하려 할 때, 문제가 생기는 것으로 추정된다.
public void createBrand(Partners partners, BrandCreateRequest request) {
if (partners.getBrand() != null) {
throw BadRequestException.relationAlreadyExist("Brand");
}
Brand brand = request.toBrandEntity();
if (brandManager.existByBrandName(brand.getName())) {
throw ConflictException.duplicatedValue("Brand", brand.getName());
}
partnersManager.updatePartnersBrand(partners, brand);
}
public void updatePartnersBrand(Partners partners, Brand brand) {
partners.setBrand(brand);
partnersRepository.save(partners);
}
[고려할 사항]
- Brand 객체를 명시적으로 save() 메서드를 이용하여 저장하지 않고 있다. 그리고 해당 객체의 save()는 명시적으로 부르지 않고, cascade 옵션을 이용함으로써 의존 관계를 좀 더 명확히 관리하고자 한다.
- Partners 클래스에서 CascadeType.PERSIST를 이용해 partners 객체 persist() 시 brand 객체도 함께 저장되기를 기대하고 있다.
CascadeType.PERSIST
- ‘새로운’ 부모 엔티티가 영속성 컨텍스트에 올라갈 때(영속화될 때), 그 자식 엔티티도 함께 영속화된다. (파트너스가 영속성 컨텍스트에 올라가면 브랜드도 함께 영속성 컨텍스트에 올라간다.)
- 새로운 부모 엔티티와 함께 새로운 자식 엔티티를 DB에 저장할 때 유용하다.
CascadeType.MERGE
- ‘기존의’ 부모 엔티티가 ‘다시’ 영속성 컨텍스트에 올라갈 때, 관련된 자식 엔티티도 함께 ‘다시’ 영속성 컨텍스트에 올라간다(update).
이때 관련된 자식 엔티티가 없으면 새로 만들어서(insert) 영속성 컨텍스트에 올린다.
- MERGE는 기존 엔티티의 ID를 사용하여 엔티티를 병합한다.
- DB에 이미 부모 엔티티가 존재하는 경우에 (+ 자식 엔티티가 존재할 때) 상태 업데이트를 할 때 유용하다.
[해결 방안]
문제의 로직을 다시 살펴보자.
// 컨트롤러
@PostMapping("/partners/brand")
public ResponseEntity<CommonResponse<Void>> createBrand(
@AuthenticationPrincipal(expression = "partners") Partners partners,
@RequestBody BrandCreateRequest request){
brandService.createBrand(partners, request);
return ResponseEntity.ok(CommonResponse.success());
}
// 서비스
public void createBrand(Partners partners, BrandCreateRequest request) {
if (partners.getBrand() != null) {
throw BadRequestException.relationAlreadyExist("Brand");
}
Brand brand = request.toBrandEntity();
if (brandManager.existByBrandName(brand.getName())) {
throw ConflictException.duplicatedValue("Brand", brand.getName());
}
partnersManager.updatePartnersBrand(partners, brand);
}
// 매니저
public void updatePartnersBrand(Partners partners, Brand brand) {
partners.setBrand(brand);
partnersRepository.save(partners);
}
- 파트너스는 Spring Data JPA를 이용한 findByEmail()를 통해 DB에서 찾아올 수 있다. 이때 잠시 영속화가 된 뒤, findByEmail()이 끝나면 영속성 상태에서 해지된다.
- 따라서 파트너스 엔티티는 준영속 상태이고, 이 때 브랜드 엔티티는 아직 생성되지 않은 상태라 파트너스 엔티티에 null 값으로 존재하고, 비영속 상태이다.
- 이 상태에서 두 엔티티가 updatePartnersBrand()를 통해 연관관계를 맺는다.
CascadeType.PERSIST일 때 날아가는 세 개의 쿼리
이때 partnersRepository.save(partners);를 호출하면, cascade type.PERSIST 설정에 따라, 다음과 같은 쿼리가 날아간다.
Hibernate:
select
p1_0.id,
p1_0.brand_id,
p1_0.business_number,
p1_0.email,
p1_0.password
from
partners p1_0
where
p1_0.id=?
Hibernate:
insert
into
brand
(description, image, name)
values
(?, ?, ?)
Hibernate:
update
partners
set
brand_id=?,
business_number=?,
email=?,
password=?
where
id=?
- 쿼리1 : partners 테이블에서 주어진 id를 가진 파트너스의 id, brand_id, business_number, email, password를 조회 → 파트너스가 이미 존재하며 준영속 상태이기 때문에 해당 파트너스 엔티티를 조회하려는 시도 → 그러나 Brand를 조인하지 않고 파트너스만을 조회하는 쿼리이므로 브랜드는 여전히 비영속 상태
- 쿼리2 : 새로운 Brand 엔티티를 데이터베이스에 삽입. Brand가 영속 상태로 전환됨.
- 쿼리3 : Partners 엔티티에 새로운 Brand를 설정하고 이를 영속성 컨텍스트에 추가하면서 업데이트. 다시 말해, 영속 상태로 전환된 Brand를 Partners에 삽입하는 것이 아니라, 새로운 Brand를 만들어서 이를 영속화하고 Partners 엔티티를 업데이트한다.
다시 자세히 살펴보는 CascadeType.PERSIST 설정 시 partnersRepository.save(partners)를 수행하며 발생하는 일
- 연관간계 편의 메서드(partners.setBrand())를 이용해 파트너스에는 브랜드가 이미 들어간 상태.
- 준영속 상태의 파트너스를 영속화 하기 위해 파트너스의 PK값을 이용하여 select 쿼리가 실행된다. 하지만 “select * from Partners where partners.id = ? “ 라는 쿼리로는 brand의 모든 정보를 알 수 없다.
- 위의 쿼리로 가져온 partners는 완전한 객체가 아니고, brand의 필드를 가져올 수 없었기 때문에 모두 null인 상태이다. EntityManager는 두 개의 다른 파트너스 사이에서 DB의 정보를 더욱 신뢰한다.
- 위 쿼리로 알 수 있는 정보는 brand_id에 대한 정보뿐, 브랜드에 대한 다른 정보는 없다.
- brand_id는 아직 null이므로, Partners 레코드를 업데이트 하며 Brand 테이블에서 id를 채번해온다.
- 이후 브랜드에 대한 Insert문이 수행된다. CascadeType.Persist 설정으로 인해 브랜드를 만들어 persist()를 수행하기 때문
- @Entity 어노테이션으로 Brand 테이블의 스키마를 알고 있는 EntityManager는 Brand의 필드를 모두 포함한 제대로 된 Insert 쿼리를 날리지만, 영속성 컨텍스트의 Brand 는 2번 쿼리에서 id만 가져오므로 다른 필드가 모두 null이다.
- 이렇게 id만 잘 들어가고 모든 값이 null인 Brand 엔티티가 탄생한다.
CascadeType.MERGE일 때 날아가는 세 개의 쿼리
- CascadeType.MERGE의 정의를 다시 읽어본 후, CascadeType.MERGE일 때의 쿼리를 비교해본다.
💡 CascadeType.MERGE
기존의’ 부모 엔티티가 ‘다시’ 영속성 컨텍스트에 올라갈 때, 관련된 자식 엔티티도 함께 ‘다시’ 영속성 컨텍스트에 올라간다(update).
Hibernate:
select
p1_0.id,
b1_0.id,
b1_0.description,
b1_0.image,
b1_0.name,
i1_0.brand_id,
i1_0.id,
i1_0.created_at,
i1_0.description,
i1_0.image,
i1_0.modified_at,
i1_0.name,
i1_0.price,
p1_0.business_number,
p1_0.email,
p1_0.password
from
partners p1_0
left join
brand b1_0
on b1_0.id=p1_0.brand_id
left join
item i1_0
on b1_0.id=i1_0.brand_id
where
p1_0.id=?
Hibernate:
insert
into
brand
(description, image, name)
values
(?, ?, ?)
Hibernate:
update
partners
set
brand_id=?,
business_number=?,
email=?,
password=?
where
id=?
- 쿼리1 : partners 테이블에서 주어진 ID로 파트너와 관련된 브랜드 및 아이템 정보를 조회 → 엔티티들이 이미 존재한다면 영속화하려는 시도
- 쿼리2 : 새로운 브랜드를 삽입. 브랜드는 영속 상태.
- 쿼리3 : partners 엔티티가 영속 상태로 전환되고, 이미 영속 상태인 Brand 엔티티와의 관계가 설정된 후 해당 업데이트 내용을 저장
[결론]
- CascadeType.MERGE로 두는 것이 상황상 더 적합하다. 파트너스를 findBy로 호출하여 영속성 상태로 올리는 방법도 있지만, 이미 존재하는 파트너스 엔티티를 영속상태로 만드는 상황이기 때문에 MERGE로 가는 것이 올바른 선택으로 보인다.
- CascadeType에 대해 많이 배운 시간… 재미있었다!