프로젝트의 리팩토링을 진행하면서 운영환경에서는 예외가 발생하지않지만 유독 테스트코드에서만 ObjectOptimisticLockingFailureException
가 발생하는 상황을 마주하였습니다. 사례에 대한 분석과 왜 이런 예외가 발생하였는지 기록해보고자 합니다.
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: delete from schedule where schedule_id=?;
테스트하고자하는 코드는 아래와 같습니다. (이해에 필요한 부분만 가져왔습니다.)
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class FacilityServiceTest {
...
@DisplayName("시설 오픈시간 및 요일 세팅을 업데이트한다.")
@Test
public void updateFacility_withSetting() {
...
// given: 새로운 시설을 생성하며 3개월치 기본 스케줄도 함께 생성된다. (모든 요일, 24시간 오픈)
Long facilityId = facilityService
.createFacility(space.getId(), createRequest, hostMember.getEmail());
...
// when: 오픈시간 및 요일 세팅을 업데이트한다. (평일, 9~21시 오픈)
FacilitySettingUpdateRequest updateRequest = new FacilitySettingUpdateRequest(
timeSettings, weekdaySettings);
facilityService.updateFacilitySetting(facilityId, updateRequest, hostMember.getEmail());
// then: 기본 세팅을 '평일 9~21시'로 바꿨으므로 '평일 0~24시'를 포함하는 스케줄은 없다.
Facility facility = facilityRepository.findById(facilityId).get();
assertThat(scheduleQueryRepository.isIncludingSchedule(facility,
new DateTimeRange(workingDay, 0, workingDay, 24))).isFalse();
}
}
간략히 설명하자면, Facility
는 Schedule
과 OneToMany연관 관계를 갖고있습니다. 또한 Facility
가 삭제되거나 Facility
에서 Schedule
에 대한 참조를 끊을 경우 이는 가비지데이터가 되기때문에, 영속성 전이 유형(CascadeType)은 All
로 고아객체제거(OrphanRemoval)은 true
로 설정했습니다.
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Schedules {
@OneToMany(mappedBy = "facility", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Schedule> schedules = new ArrayList<>();
...
}
cascade
: 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속상태로 만들고 싶을때 사용하는 옵션.
- CascadeType : ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH
- 타입에 따른 동작은 여기 참고 Baeldung - Overview of JPA/Hibernate Cascade Types
orphanRemoval
: 부모엔티티와 연관관계가 끊어진 자식엔티티 즉, 고아객체(Orphan)을 삭제하고싶을 때 사용하는 옵션.cascade = CascadeType.ALL, orphanRemoval = true
: 부모엔티티를 통해 자식의 생명주기
관리 가능Parant parent = entityManager.find(Parnet.class, id); Child child = new Child(); child.setParent(parent); parent.addChile(child); // flush 시점 자식 저장.
Parant parent = entityManager.find(Parnet.class, id); parent.remove(child); // flush 시점 자식 삭제.
출처 : 자바ORM표준JPA프로그래밍(책)
위 옵션에 따라 부모엔티티인 Facility를 통해 자식엔티티인 Schedule의 생명주기를 관리하도록 하였습니다.
Facility생성 시 지정된 timeSettings와 weekDaySettings에 맞게 3개월치 Schedule를 함께 생성하도록 Facility 생성자에 해당 로직을 추가해주었습니다.
// Facility.class
@Embedded
private Schedules schedules;
@Builder
public Facility(Long id, String name, Boolean reservationEnable,
Integer minUser, Integer maxUser, String description,
Space space, TimeSettings timeSettings, WeekdaySettings weekdaySettings) {
...
if (shouldCreateSchedules()) {
timeSettings.setFacility(this);
weekdaySettings.setFacility(this);
this.schedules = Schedules.create3MonthSchedules(
this.timeSettings, this.weekdaySettings, YearMonth.now()); // 3개월치 스케줄 생성
}
}
private boolean shouldCreateSchedules(){
return this.timeSettings != null && this.weekdaySettings != null;
}
Facility의 timeSettings와 weekDaySettings변경되면 스케줄데이터도 다시 생성되야하므로, 기존 스케줄을 부모컬렉션에서 제거한 후 새로운 스케줄을 추가해주었습니다.
// Facility.class
public void updateSetting(TimeSettings timeSettings, WeekdaySettings weekdaySettings) {
this.timeSettings.update(timeSettings, this);
this.weekdaySettings.update(weekdaySettings, this);
schedules.update3Month(this.timeSettings, this.weekdaySettings); // 스케줄 업데이트
}
}
// Schedules.class
@OneToMany(mappedBy = "facility", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Schedule> schedules = new ArrayList<>();
public void update3Month(TimeSettings timeSettings, WeekdaySettings weekdaySettings) {
this.schedules.clear(); // 부모 컬렉션에서 기존 스케줄 제거 (고아객체)
Schedules createSchedules = Schedules.create3MonthSchedules(
timeSettings, weekdaySettings, YearMonth.now()); // 새로운 3개월치 스케줄 생성
this.schedules.addAll(createSchedules.getSchedules()); // 부모 컬렉션에 추가
}
문제가 있는 것은 아니었으나, Facility
세팅 업데이트 시 기존 Schedule
들은 고아객체가됨으로써 Transaction이 commit될 때(flush 될 때) 스케줄 id를 where절로 걸고 하나하나 delete쿼리가 나갔습니다..
delete
from
schedule
where
schedule_id=?
만약 모든 요일을 오픈하는 시설이라면 3개월치 약 90개의 스케줄 삭제쿼리가 나가게됩니다. 여러 User가 업데이트를 하게 된다면 부하가 예상되므로 삭제정도는 한방쿼리로 해결하고자 리팩토링을 진행하게 되었습니다.
전에는 위에서 설명한 facility.updateSetting
메서드만을 사용해 부모엔티티 컬렉션에서만 제거하였다면
// FacilityService.class
@Transactional
public void updateFacilitySetting(Long facilityId, FacilitySettingUpdateRequest updateRequest,
String loginEmail) {
...
facility.updateSetting(updateRequest.toTimeSettings(), updateRequest.toWeekdaySettings());
}
리팩토링 후에는 QueryDsl을 사용해 직접 한방쿼리를 날린 후 부모엔티티 컬렉션에서 제거하였습니다.
// FacilityService.class
@Transactional
public void updateFacilitySetting(Long facilityId, FacilitySettingUpdateRequest updateRequest,
String loginEmail) {
...
// facility와 연관된 schedule을 직접 삭제하는 쿼리를 날려줌.
scheduleQueryRepository.deleteSchedules(facility);
facility.updateSetting(updateRequest.toTimeSettings(), updateRequest.toWeekdaySettings());
}
// ScheduleQueryRepository.class
public void deleteSchedules(Facility facility) {
jpaQueryFactory
.delete(schedule)
.where(facilityEq(facility))
.execute();
}
private BooleanExpression facilityEq(Facility facility) {
return facility != null ? schedule.facility.eq(facility) : null;
}
그리고 운영환경에서는 아래와 같이 단하나의 삭제쿼리만 나가게 되어 개선에 성공하였습니다.
delete
from
schedule
where
facility_id=?
하지만 테스트환경에서는 ObjectOptimisticLockingFailureException
예외가 발생하며 실패하게됩니다.
@DisplayName("시설 오픈시간 및 요일 세팅을 업데이트한다.")
@Test
public void updateFacility_withSetting() {
...
Long facilityId = facilityService
.createFacility(space.getId(), createRequest, hostMember.getEmail());
...
FacilitySettingUpdateRequest updateRequest = new FacilitySettingUpdateRequest(
timeSettings, weekdaySettings);
facilityService.updateFacilitySetting(facilityId, updateRequest, hostMember.getEmail());
Facility facility = facilityRepository.findById(facilityId).get();
// 여기서 예외 발생 (직접 DB에 쿼리를 날려서 결과를 얻어와 검증하는 코드)
assertThat(scheduleQueryRepository.isIncludingSchedule(facility,
new DateTimeRange(workingDay, 0, workingDay, 24))).isFalse();
}
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: delete from schedule where schedule_id=?;
결론부터 말씀드리자면, 종합적인 2가지 원인이 있습니다.
1. OrphanRemoval 설정
2. 영속성 컨텍스트와 DB의 상태 불일치
위에 테스트코드를 순서대로 분석하며 설명드리겠습니다.
해당 테스트 클래스에는 @Transactional
이 걸려있습니다. 따라서 테스트를 시작하면TestTransaction
이 시작하게됩니다. 그리고 TestTransaction
은 기본적으로 트랜잭션이 끝나면 Rollback을 수행합니다.
@SpringBootTest
@Transactional
class FacilityServiceTest {
...
}
로그에도 Rollback 옵션이 true로 켜져있는 것을 볼 수 있습니다.
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test context [DefaultTestContext@6636448b testClass = FacilityServiceTest, 중략 ... rollback [true]
Update를 테스트하기 위해서는 먼저 데이터를 생성해줘야합니다. 그리고 저는 아래와 같이 데이터를 생성해줬는데요.
// given: 새로운 시설을 생성하며 3개월치 기본 스케줄도 함께 생성된다. (모든 요일, 24시간 오픈)
Long facilityId = facilityService
.createFacility(space.getId(), createRequest, hostMember.getEmail());
로그를 보면 생성쿼리는 나가지만, Transaction은 commit되지 않았습니다. 즉, DB에는 실제 데이터가 없는 상태입니다.
insert
into
facility
(facility_id, created_time, updated_time, description, max_user, min_user, name, reservation_enable, space_id)
values
(default, ?, ?, ?, ?, ?, ?, ?, ?)
// 이 뒤로 schedule, time_setting, weekday_setting 생성 쿼리도 나감.
// when: 오픈시간 및 요일 세팅을 업데이트한다. (평일, 9~21시 오픈)
FacilitySettingUpdateRequest updateRequest = new FacilitySettingUpdateRequest(
timeSettings, weekdaySettings);
facilityService.updateFacilitySetting(facilityId, updateRequest, hostMember.getEmail());
먼저 facility와 연관된 Schedule을 삭제하는 한방 쿼리를 날립니다.
// FacilityService.class
@Transactional
public void updateFacilitySetting(Long facilityId, FacilitySettingUpdateRequest updateRequest,
String loginEmail) {
...
scheduleQueryRepository.deleteSchedules(facility); // 한방쿼리 날리기
...
}
그리고 여기서도 Transaction은 commit되지 않았기때문에 delete쿼리가 나갔어도 실제 삭제된 데이터는 전혀 없는 상황입니다.
delete
from
schedule
where
facility_id=?
새로운 스케줄을 생성하기 전, 부모엔티티 컬렉션에서 기존 스케줄을 제거해줍니다.
// FacilityService.class
@Transactional
public void updateFacilitySetting(Long facilityId, FacilitySettingUpdateRequest updateRequest,
String loginEmail) {
...
facility.updateSetting(updateRequest.toTimeSettings(), updateRequest.toWeekdaySettings());
}
// Facility.class
public void updateSetting(TimeSettings timeSettings, WeekdaySettings weekdaySettings) {
...
schedules.update3Month(this.timeSettings, this.weekdaySettings);
}
// Schedules.class
public void update3Month(TimeSettings timeSettings, WeekdaySettings weekdaySettings) {
this.schedules.clear(); // 부모 엔티티 컬렉션에서 기존 스케줄 제거 (고아객체 생성)
Schedules createSchedules = Schedules.create3MonthSchedules(
timeSettings, weekdaySettings, YearMonth.now());
this.schedules.addAll(createSchedules.getSchedules());
}
이렇게 되면 기존 스케줄들은 고아객체가 됩니다. 그리고 OrphanRemoval설정이 true로 되어있기때문에 영속성컨텍스트는 해당 고아객체를 삭제할 '준비'를 하게됩니다.
고아객체는 flush될 때(영속성컨텍스트와 DB와 동기화 작업 시) 더티체킹으로 삭제가 필요하다면 삭제를 수행합니다. 따라서 쿼리는 나갈지 안나갈지는 모릅니다.
// then: 기본 세팅을 '평일 9~21시'로 바꿨으므로 '평일 0~24시'를 포함하는 스케줄은 없다.
Facility facility = facilityRepository.findById(facilityId).get();
assertThat(scheduleQueryRepository.isIncludingSchedule(facility,
new DateTimeRange(workingDay, 0, workingDay, 24))).isFalse();
scheduleQueryRepository.isIncludingSchedule
은 직접 DB에 쿼리를 날려 값을 얻어오는 메서드입니다. 따라서 DB를 조회하기 전 영속성컨텍스트와 DB와의 동기화가 필요하게됩니다.
즉, flush가 일어날시기입니다.
여기서 영속성 컨텍스트는 더티체킹을 진행하여 DB에 반영할 변경사항이 있는지 확인하고 쿼리를 날리게되는데 여기서 예외가 발생하게됩니다.
아까 3-1번에서 한방쿼리로 기존 스케줄데이터는 삭제됐으므로 고아객체를 삭제할 필요가 없지만, 영속성 컨텍스트는 삭제하기위해 delete
쿼리를 날리는 시도를하고 ObjectOptimisticLockingFailureException
를 마주하게됩니다.
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1;
예외메세지를 보면 실제 delete from schedule where schedule_id=?
쿼리의 결과로 1개의 row가 업데이트 되기를 기대했지만 0개의 row가 업데이트됐다고하죠.
아래는 GPT의견 + 저의 생각입니다!
현재 2번에서 생성된 데이터가 실제로 DB에 없기때문에 3-1번에서 한방 쿼리로 삭제된 데이터도 없는 상태입니다.
즉, 변화가 없기때문에 영속성 컨텍스트도 알아차리지 못하고 최초 조회 상태를 업데이트 하지 않는 것으로 보입니다.
디버깅 해보면 Facility 생성 후 updateFacilitySetting메서드가 끝날 때까지 아무런 조회 쿼리가 나가지 않습니다.
그리고 검증 단계에서 최초에 조회된 Facility(2번에서 처음 생성된 Facility)와 현재의 Facility를 비교하여 더티체킹이 일어납니다. 이를 통해 영속성 컨텍스트는 참조가 삭제된 스케줄 엔티티들(고아 객체들)이 최초 조회 객체에는 존재하므로 DB에 존재한다고 판단하고 이 엔티티들을 삭제하기 위한 삭제 쿼리를 실행하려고 합니다.
하지만 DB에는 데이터가 없기때문에 당연히 0개의 row가 업데이트 됩니다.
정말 해당 이유가 맞을까요? 한방 쿼리로 삭제된 데이터가 없기때문에, 영속성 컨텍스트가 이를 인식하지 못하고 최초 조회 객체를 업데이트 하지 않아 고아객체를 삭제하려한다. 라는 가설을 검증하기 위해 실험을 진행했습니다.
// given: 새로운 시설을 생성하며 3개월치 기본 스케줄도 함께 생성된다. (모든 요일, 24시간 오픈)
Long facilityId = facilityService
.createFacility(space.getId(), createRequest, hostMember.getEmail());
TestTransaction.flagForCommit(); // 현재 트랜잭션 커밋상태로 만들기
TestTransaction.end(); // 트랜잭션 종료
한방쿼리로 실제 데이터를 삭제 시키기위해 테스트 데이터 생성 후 강제로 트랜잭션을 Commit시켰습니다.
로그를 보면, Transaction이 시작된 후 Facility를 생성쿼리가 나가고 Commit까지 된부분을 확인할 수 있습니다.
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test context [DefaultTestContext@5b74bb75 testClass = FacilityServiceTest, ...중략 rollback [true]
...
[ Test worker] org.hibernate.SQL :
insert
into
facility
(facility_id, created_time, updated_time, description, max_user, min_user, name, reservation_enable, space_id)
values
(default, ?, ?, ?, ?, ?, ?, ?, ?)
// 이 뒤로 schedule, time_setting, weekday_setting 생성 쿼리도 나감.
...
[ Test worker] o.s.t.c.transaction.TransactionContext : Committed transaction for test: [DefaultTestContext@5b74bb75 testClass = FacilityServiceTest, ... 중략
TestTransaction.start(); // 새로운 트랜잭션 생성
// when: 오픈시간 및 요일 세팅을 업데이트한다. (평일, 9~21시 오픈)
FacilitySettingUpdateRequest updateRequest = new FacilitySettingUpdateRequest(
timeSettings, weekdaySettings);
facilityService.updateFacilitySetting(facilityId, updateRequest, hostMember.getEmail());
새로운 Transaction이 시작되고 한방 쿼리로 스케줄 데이터를 삭제 후, 부모엔티티 컬렉션에서 기존 스케줄 참조가 삭제됩니다. 이때 실제 DB에 데이터가 존재하므로 삭제가 진행됩니다.
그리고 여기서 주목할점은 전과 달리 delete 한방 쿼리를 날린 후 updateFacilitySetting 메서드가 끝나는 시점 변경이 일어난 엔티티를 DB에서 새롭게 조회해온다는 점입니다.
[ Test worker] o.s.t.c.transaction.TransactionContext : Began transaction (2) for test context [DefaultTestContext@57fc6759 testClass = FacilityServiceTest ...중략 rollback [true]
[ Test worker] org.hibernate.SQL :
delete
from
schedule
where
facility_id=?
[ Test worker] org.hibernate.SQL :
select
timesettin0_.facility_id as facility4_7_1_,
timesettin0_.time_setting_id as time_set1_7_1_,
timesettin0_.time_setting_id as time_set1_7_0_,
timesettin0_.facility_id as facility4_7_0_,
timesettin0_.end_time as end_time2_7_0_,
timesettin0_.start_time as start_ti3_7_0_
from
time_setting timesettin0_
where
timesettin0_.facility_id=?
[ Test worker] org.hibernate.SQL :
select
weekdayset0_.facility_id as facility3_8_1_,
weekdayset0_.weekday_setting_id as weekday_1_8_1_,
weekdayset0_.weekday_setting_id as weekday_1_8_0_,
weekdayset0_.facility_id as facility3_8_0_,
weekdayset0_.weekday as weekday2_8_0_
from
weekday_setting weekdayset0_
where
weekdayset0_.facility_id=?
[ Test worker] org.hibernate.SQL :
select
schedules0_.facility_id as facility5_5_1_,
schedules0_.schedule_id as schedule1_5_1_,
schedules0_.schedule_id as schedule1_5_0_,
schedules0_.date as date2_5_0_,
schedules0_.facility_id as facility5_5_0_,
schedules0_.end_time as end_time3_5_0_,
schedules0_.start_time as start_ti4_5_0_
from
schedule schedules0_
where
schedules0_.facility_id=?
이는 더티 체킹 시 사용되는 최조 조회 상태를 업데이트 하는 것으로 보입니다. 정말인지 아래에서 확인해봅시다.
// then: 기본 세팅을 '평일 9~21시'로 바꿨으므로 '평일 0~24시'를 포함하는 스케줄은 없다.
Facility facility = facilityRepository.findById(facilityId).get();
assertThat(scheduleQueryRepository.isIncludingSchedule(facility,
new DateTimeRange(workingDay, 0, workingDay, 24))).isFalse();
검증 쿼리를 날리기 전, flush가 일어날 때 영속성 컨텍스트가 제대로 더티체킹을 진행할 수 있을까요?
결과를 보면 더이상 고아객체를 삭제하려는 쿼리는 나가지 않고, 테스트코드는 성공하게 됩니다.. 🥹!
즉, 최초 조회 상태가 3-1번 한방쿼리가 나간 상태로 업데이트 된것으로 보이며 기존 schedule데이터는 삭제되어있는 상태로 현재 Facility와 더티체킹이 진행됩니다. 따라서 고아객체를 삭제하려는 쿼리는 나가지 않게되고 새로운 스케줄 데이터만 insert되어 테스트 코드는 성공하게됩니다!
위에까지는 Exception이 발생한 원인을 파악하기위한 저의 삽질 및 가설검증과정이었습니다.
해결방법으로 OrphanRemoval옵션을 제거하거나, 위 사례처럼 하나의 테스트메서드에서 Transaction을 분리하는 방법이 있습니다.
처음에 OrphanRemoval옵션을 제거할까 싶었습니다. 하지만 스케줄 한개를 추가하거나 업데이트할 때 연속되는 스케줄을 합치는 작업에서 컬렉션 remove가 사용되기 때문에 이를 제거하는건 비효율적이라고 판단했습니다. 한두개 삭제할땐 고아객체로 만드는게 직접 쿼리를 날리는것 보다 편하고 코드도 깔끔해지니까요.
따라서 아래와 같이 테스트코드를 수정하였습니다.
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class FacilityServiceTest {
...
@DisplayName("시설시간 및 요일 세팅을 업데이트한다.")
@Test
public void updateSettingAndVerify() {
// 1. 시설 생성 Transaction(Commit)
Long facilityId = facilityService
.createFacility(space.getId(), createFacility(true), hostMember.getEmail());
TestTransaction.flagForCommit();
TestTransaction.end();
// 2. 시설 업데이트 및 검증 Transaction(Rollback)
TestTransaction.start();
updateFacilitySetting(facilityId);
assertThatSchedules(facilityId);
TestTransaction.end();
// 3. 생성된 데이터 삭제 Transaction(Commit)
TestTransaction.start();
deleteAll();
TestTransaction.flagForCommit();
TestTransaction.end();
}
private void updateFacilitySetting(Long facilityId) {
FacilitySettingUpdateRequest updateRequest = new FacilitySettingUpdateRequest(
createTimeSetting(9, 21),
createWeekDaySetting(
DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY)
);
facilityService.updateFacilitySetting(facilityId, updateRequest, hostMember.getEmail());
}
private void assertThatSchedules(Long facilityId) {
Facility facility = facilityRepository.findById(facilityId).get();
assertThat(scheduleQueryRepository.isIncludingSchedule(facility,
new DateTimeRange(workingDay, 0, workingDay, 24))).isFalse();
}
private void deleteAll() {
facilityRepository.deleteAll();
spaceRepository.deleteAll();
categoryRepository.deleteAll();
memberRepository.deleteAll();
}
}
트랜잭션 구간을 총 3개로 분리하였으며, 다른 테스트케이스에 영향을 주지 않기 위해 생성됐던 데이터는 모두 삭제해주었습니다.