deleteAllInBatch 사용 시 Cannot delete or update a parent row 에러

김유정·2024년 2월 18일
1

문제 상황

부모와 자식 테이블이 있었는데, 부모를 삭제하려고 하니 자꾸 삭제할 수가 없다고 SQL Error: 1451, SQLState: 23000 에러가 발생했다.

이 에러키는 자신을 참조하고 있는 자식 데이터가 존재할 때 부모를 삭제할 수 없다고 에러가 발생하는 것이다. 근데 나는 orphanRemoval 옵션을 true로 설정하여 자식이 같이 삭제되도록 처리하고자 했는데 왜 이런 에러가 발생하는지 이해할 수가 없었다.

알아서 자식이 잘 삭제될 줄 알았는데, 왜이러는건지 한참 고민했다. 근데 문득 deleteAllInBatch가 delete와 삭제 로직이 다르기 때문에, delete로 지워주면 다를 수 있지 않을까라는 생각이 들었다. 그래서 for 문을 돌면서 부모를 하나씩 삭제하도록 변경했더니 정말 되는 것이었다....!

문제의 코드

문제의 코드를 그대로 정리하기에는 많은 비즈니스 로직이 포함되어 있어서 간단한 형태로 코드를 재연해봤다.

테이블은 부모와 자식 관계로 심플하게 구성했으며,

위에 대한 Entity 클래스는 아래와 같이 작성했다.

Parent

@Entity
@NoArgsConstructor
@Getter
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long parentId;

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> childList = new ArrayList<>();

    private String parentName;

    public Parent(String parentName) {
        this.parentName = parentName;
    }
}

Child

@Entity
@NoArgsConstructor
@Getter
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long childId;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;

    private String childName;

    public Child(Parent parent, String childName) {
        this.parent = parent;
        this.childName = childName;
    }
}

그리고 Service 계층은 다음과 같이 부모와 자식을 등록하는 save() 메서드와 delete 메서드로 구성했다.

ParentService

@Service
@RequiredArgsConstructor
public class ParentService {
    private final ParentRepository parentRepository;
    private final ChildRepository childRepository;

    public void save() {
        Parent chicken = new Parent("닭");
        Child chick1 = new Child(chicken, "첫째 병아리");
        Child chick2 = new Child(chicken, "둘째 병아리");
        parentRepository.save(chicken);
        childRepository.save(chick1);
        childRepository.save(chick2);

        Parent dog = new Parent("개");
        Child puppy1 = new Child(dog, "첫째 강아지");
        Child puppy2 = new Child(dog, "둘째 강아지");
        parentRepository.save(dog);
        childRepository.save(puppy1);
        childRepository.save(puppy2);
    }

    public void delete() {
        Parent chicken = parentRepository.findByParentName("닭");
        Parent dog = parentRepository.findByParentName("개");
        parentRepository.deleteAllInBatch(List.of(chicken, dog));
    }
}

문제 상황

  1. 저장하는 API 호출
  2. save() 메서드에서 부모와 자식 저장
  3. 삭제하는 API 호출
  4. delete() 메서드에서 부모 삭제 → 문제 발생

정상적으로 동작하는 코드

deleteAllInBatch() 메서드를 사용하지 않고, delete() 메서드로 부모를 하나씩 지워주면 문제가 되지 않는다.

@Service
@RequiredArgsConstructor
public class ParentService {
    private final ParentRepository parentRepository;
    private final ChildRepository childRepository;

    public void save() {
        Parent chicken = new Parent("닭");
        Child chick1 = new Child(chicken, "첫째 병아리");
        Child chick2 = new Child(chicken, "둘째 병아리");
        parentRepository.save(chicken);
        childRepository.save(chick1);
        childRepository.save(chick2);

        Parent dog = new Parent("개");
        Child puppy1 = new Child(dog, "첫째 강아지");
        Child puppy2 = new Child(dog, "둘째 강아지");
        parentRepository.save(dog);
        childRepository.save(puppy1);
        childRepository.save(puppy2);
    }

    public void delete() {
        Parent chicken = parentRepository.findByParentName("닭");
        Parent dog = parentRepository.findByParentName("개");
        // 변경된 부분!!
        parentRepository.delete(chicken);
        parentRepository.delete(dog);
    }
}

왜 delete() 에서는 정상 동작하는 것일까?

delete() vs deleteAllInBatch

CrudRepository 인터페이스를 구현하고 있는 SimpleJpaRepository 에서 두 개의 메서드는 아래와 같이 정의되어 있다.

  • delete()

    	@Override
    		@Transactional
    		@SuppressWarnings("unchecked")
    		public void delete(T entity) {
    
    			Assert.notNull(entity, "Entity must not be null");
    
    			if (entityInformation.isNew(entity)) {
    				return;
    			}
    
    			Class<?> type = ProxyUtils.getUserClass(entity);
    
    			T existing = (T) entityManager.find(type, entityInformation.getId(entity));
    
    			// if the entity to be deleted doesn't exist, delete is a NOOP
    			if (existing == null) {
    				return;
    			}
    
    			entityManager.remove(entityManager.contains(entity) ? entity : entityManager.merge(entity));
    		}
  • deleteAllInBatch()

    	@Override
    		@Transactional
    		public void deleteAllInBatch(Iterable<T> entities) {
    
    			Assert.notNull(entities, "Entities must not be null");
    
    			if (!entities.iterator().hasNext()) {
    				return;
    			}
    
    			applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities,
    					entityManager)
    					.executeUpdate();
    		}

delete는 entityManager에서 remove 메서드를 호출하고 있고, deleteAllInBatch()는 applyAndBind 라는 내부 메서드를 호출하여 처리하고 있다.

delete() 처리 방식

정상적으로 처리되는 delete() 메서드의 처리 방식을 조금 더 구체적으로 살펴보자.

위의 SimpleJpaRepository 에서 정의해놓은 delete() 메서드를 보면, 결과적으로 entityManager 의 remove() 메서드를 호출하고 있는데, 이는 spring-boot-starter-data-jpa 내부에 존재하는 hibernate-core 라이브러리의 SessionImpl에서 구현하고 있다.

처리과정에 대해 아래와 같이 요약할 수 있다.

  1. SimpleJpaRepository 의 delete() 메서드 수행
  2. SessionImpl 의 remove() 메서드 수행하여 삭제 이벤트 추가
  3. DefaultDeleteEventListener 에서 삭제 이벤트 처리

삭제 이벤트를 처리하는 DefaultDeleteEventListener 의 코드 중 deleteEntity를 좀 더 자세하게 살펴보자.

	protected final void deleteEntity(
			final EventSource session,
			final Object entity,
			final EntityEntry entityEntry,
			final boolean isCascadeDeleteEnabled,
			final boolean isOrphanRemovalBeforeUpdates,
			final EntityPersister persister,
			final DeleteContext transientEntities) {

		...
        // entity 상태를 DELETED 로 변경
		persistenceContext.setEntryStatus( entityEntry, Status.DELETED );
		final EntityKey key = session.generateEntityKey( entityEntry.getId(), persister );

		cascadeBeforeDelete( session, persister, entity, transientEntities );

		new ForeignKeys.Nullifier(  entity, true, false, session, persister )
				.nullifyTransientReferences( entityEntry.getDeletedState() );
		new Nullability( session )
				.checkNullability( entityEntry.getDeletedState(), persister, NullabilityCheckType.DELETE );
		persistenceContext.registerNullifiableEntityKey( key );

		if ( isOrphanRemovalBeforeUpdates ) {
			// HHH-6484 이슈를 해결하기 위해 임시로 존재했던 코드
		}
		else {
			session.getActionQueue().addAction(
					new EntityDeleteAction(
							entityEntry.getId(),
							deletedState,
							version,
							entity,
							persister,
							isCascadeDeleteEnabled,
							session
					)
			);
		}

		cascadeAfterDelete( session, persister, entity, transientEntities );
	}

해당 메서드에서는 아래와 같은 로직을 통해 entity의 상태를 변경하고,
1. entity 상태를 DELETED로 변경한다.
2. cascadeBeforeDelete() 메서드 실행
entity의 자식을 조회하고, 자식들에 대해서도 삭제할 수 있도록 이벤트를 추가한다.
3. ActionQueue 에 EntityDeleteAction 을 추가한다.
ActionQueue를 어떤식으로 처리하는지 자세하게는 모르겠지만, sql 삭제 로그가 찍히는 시점으로 미루어봤을 때 flush() 또는 commit()이 되는 순간에 처리되는 것으로 추측된다.

여기에서 자식을 조회하는 cascadeBeforeDelete()에 대해서만 좀 더 살펴보면, 이 메서드는 내부에서 Cascade의 cascade() 메서드를 호출하는데 해당 메서드는 아래와 같다.

	public static <T> void cascade(
			final CascadingAction<T> action,
			final CascadePoint cascadePoint,
			final EventSource eventSource,
			final EntityPersister persister,
			final Object parent,
			final T anything) throws HibernateException {
        // cascade 조건에 해당한다면, 아래 코드 수행. 부모는 persister.hasCascades() 조건에 만족하여 아래 코드를 수행하고, 자식은 수행하지 않는다.
		if ( persister.hasCascades() || action.requiresNoCascadeChecking() ) {
            ...

			for ( int i = 0; i < types.length; i++) {
				final CascadeStyle style = cascadeStyles[ i ];
				final String propertyName = propertyNames[ i ];
				final boolean isUninitializedProperty =
						hasUninitializedLazyProperties &&
						!persister.getBytecodeEnhancementMetadata().isAttributeLoaded( parent, propertyName );

				final Type type = types[i];
				if ( style.doCascade( action ) ) {
					final Object child;
					if ( isUninitializedProperty  ) {
						...
					}
					else {
                        // 자식 조회
						child = persister.getValue( parent, i );
					}
					cascadeProperty(
							action,
							cascadePoint,
							eventSource,
							null,
							parent,
							child,
							type,
							style,
							propertyName,
							anything,
							false
					);
				}
				...
			}

			...
		}
	}

deleteAllInBatch() 처리 방식

	@Override
		@Transactional
		public void deleteAllInBatch(Iterable<T> entities) {

			Assert.notNull(entities, "Entities must not be null");

			if (!entities.iterator().hasNext()) {
				return;
			}

			applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities,
					entityManager)
					.executeUpdate();
		}

해당 메서드에서는 applyAndBind()와 executeUpdate() 메서드를 순차적으로 를 호출하고 있다.

QueryUtils

여기서 applyAndBind는 delete from Parent x where x = ?1 or x = ?2 와 같은 삭제 쿼리를 만들어서 반환하는 단순한 역할만 하고 있다.

	public static <T> Query applyAndBind(String queryString, Iterable<T> entities, EntityManager entityManager) {

		Assert.notNull(queryString, "Querystring must not be null");
		Assert.notNull(entities, "Iterable of entities must not be null");
		Assert.notNull(entityManager, "EntityManager must not be null");

		Iterator<T> iterator = entities.iterator();

		if (!iterator.hasNext()) {
			return entityManager.createQuery(queryString);
		}

		String alias = detectAlias(queryString);
		StringBuilder builder = new StringBuilder(queryString);
		builder.append(" where");

		int i = 0;

		while (iterator.hasNext()) {

			iterator.next();

			builder.append(String.format(" %s = ?%d", alias, ++i));

			if (iterator.hasNext()) {
				builder.append(" or");
			}
		}

		Query query = entityManager.createQuery(builder.toString());

		iterator = entities.iterator();
		i = 0;

		while (iterator.hasNext()) {
			query.setParameter(++i, iterator.next());
		}

		return query;
	}

이렇게 만들어진 쿼리는 executeUpdate() 메서드를 통해 실행되는데, 해당 메서드는 QuerySqmImpl 에서 구현하고 있다. 이 메서드는 delete() 메서드 수행 시 자식을 조회하고 DELETED 상태를 만든 후 추후에 flush()를 하는 것과는 다르게 cascade 옵션이 고려되고 있지 않은 것으로 보였다.

따라서 이렇게 여러 부모의 데이터를 삭제할 때는 for 문으로 반복해가면서 삭제하거나 자식을 먼저 수동으로 삭제해준 후에 deleteAllInBatch를 쓰거나 해야할 것 같다. deleteAllInBatch를 사용해도 문제가 나지 않도록 하기 위해서 어떻게 해야할지는 좀 더 공부해야할 것 같다.

참고

0개의 댓글