0. 들어가기 전
JPA를 사용해서 개발하던 중에 예상과 다르게 동작하는 테스트를 발견하게 되었습니다.
Update 로직을 delete, insert로 구현했는데 delete 후 insert가 되는 것이 아니라, insert 후 delete로 동작하는 것을 확인했습니다.
왜 이러한 쿼리 순서로 실행되는지 상황부터 원인, 해결방법까지 살펴보도록 하겠습니다!
1. 문제 상황
먼저 기본 컨텍스트를 알려드리면 다음과 같습니다.
- Job - Skill 1:N이지만, 연관관계는 맺지 않고 JobId로 간접 참조
- Skill의 update 로직 메소드에서 상황 발생
/**
* DELETE & INSERT
*/
@Transactional
public void replaceUpdateByJobId(Collection<Skill> skills, String jobId) {
skillRepository.deleteAllByJobId(jobId);
...
skillRepository.saveAll(skillEntities);
}
- 일반 컬럼인 JobId에 해당하는 모든 Skill 삭제 (DeleteAll)
- 저장할 Skill들 INSERT (SaveAll)
위처럼 DELETE, INSERT로 여러 건의 업데이트를 처리하는 로직을 수행하는 메소드를 개발했습니다.
이때 제가 예상한 쿼리 순서는 다음과 같습니다.
- SELECT 1건 (영속성 컨텍스트에 처리하려는 Skill을 불러오기 위한 쿼리)
- DELETE 여러건 (jobId에 해당하는 Skill 개수만큼)
- INSERT 여러건 (INSERT할 Skill 개수만큼)
그런데, 실제 나가는 쿼리 로그를 보면 다음과 같습니다.
select
se1_0."id",
se1_0."code",
se1_0."job_id",
se1_0."keyword",
se1_0."type",
se1_0."updated_at",
se1_0."updated_by"
from
"job"."skill" se1_0
where
se1_0."job_id"=?
---
insert
into
"job"."skill" ("code", "job_id", "keyword", "type", "updated_at", "updated_by", "id")
values
(?, ?, ?, ?, ?, ?, ?)
... (개수 만큼)
---
delete
from
"job"."skill"
where
"id"=?
... (개수 만큼)
- SELECT 1건
- INSERT 여러 건
- DELETE 여러 건
SELECT 까지는 JPA의 동작대로 영속성 컨텍스트를 불러와야 하므로 잘 가져오는 것을 확인할 수 있습니다.
그런데 왜 DELETE가 먼저 동작하지 않고 INSERT 이후에 DELETE가 동작하는 걸까요?
해당 이유를 아래에서 살펴보겠습니다.
2. JPA(Hibernate)의 쿼리 실행 순서
JPA(Hibernate)는 쿼리 실행 순서가 정해져 있습니다.
JPA에서 쿼리가 실행되는 시점은 flush()를 하는 시점에 쓰기 지연 저장소에 저장했던 쿼리를 실행하게 됩니디.
따라서, 쿼리 실행 순서를 처리하는 로직도 flush()를 하는 로직에 존재합니다.
flush()를 실행하는 로직은 따라가 보면 AbstractFlushingEventListener의 performExecutions에서 처리하고 있습니다.
해당 메소드의 주석을 살펴보면 다음과 같습니다.
* AbstractFlushingEventListener - performExecutions(...)
Execute all SQL (and second-level cache updates) in a special order so that foreign-key constraints cannot be violated:
1. Inserts, in the order they were performed
2. Updates
3. Deletion of collection elements
4. Insertion of collection elements
5. Deletes, in the order they were performed
- 모든 SQL을 FK 제약 조건을 위반하지 않게 하기 위해 다음과 같은 순서로 실행한다.
- 1. INSERT
- 2. UPDATE
- 3. Collection 요소들의 DELETE
- 4. Collection 요소들의 INSERT
- 5. DELETE
설명을 보면, Hibernate에서 쿼리 실행 순서를 INSERT가 가장 먼저 실행되도록 처리하기 때문에 순서가 바뀐 것을 알 수 있습니다.
이는 설명에도 있듯이 FK 제약 조건을 위반하지 않게 하기 위함입니다.
- 1:N 관계의 Job - Skill에서 Job Delete 쿼리와 Skill Insert 쿼리가 순서대로 실행된다면 FK 제약 조건을 위반하게 됩니다.
- 이러한 상황을 막기 위해 순서를 고정하여 FK 제약조건을 위반하는 쿼리는 예외가 발생하도록 하는 것입니다.
이렇게 원인은 비교적 간단하게 Hibernate의 처리 로직을 찾으면 알 수 있었습니다.
3. DELETE 시 Where 절에 왜 일반 필드가 아닌 PK로 삭제할까?
위에서 원인은 찾았지만 또 예상과 다른 쿼리가 있었습니다.
바로 다음과 같은 DELETE 쿼리였습니다.
delete
from
"job"."skill"
where
"id"=?
- deleteAllByJobId를 실행한 쿼리
PK가 아닌 일반 필드인 JobId로 삭제하는 Spring Data JPA 커스텀 메소드를 정의하여 삭제했습니다.
그런데, 실제 실행되는 쿼리는 JobId가 아닌 PK로 delete를 하고 있었습니다.
전체 쿼리를 자세히 살펴보면, jobId를 사용하는 곳은 SELECT 쿼리입니다.
select
se1_0."id",
se1_0."code",
se1_0."job_id",
se1_0."keyword",
se1_0."type",
se1_0."updated_at",
se1_0."updated_by"
from
"job"."skill" se1_0
where
se1_0."job_id"=?
...
---
delete
from
"job"."skill"
where
"id"=?
... (개수 만큼)
이러한 쿼리 동작은 영속성 컨텍스트의 1차 캐시와 관련이 있습니다.
영속성 컨텍스트의 1차 캐시에서는 가져온 Entity의 PK를 Key로 하고, Value를 Entity로 저장하게 됩니다.
따라서, 동작은 다음과 같은 순서로 이루어지게 됩니다.
- DB에서 Entity를 가져올 때 커스텀한 메소드의 필드인 JobId로 SELECT
- 가져온 Entity를 1차 캐시에 저장 (Key : PK, value : Entity)
- 이후 작업은 영속성 컨텍스트 1차 캐시에 있는 Entity를 사용하기 때문에 PK로 쿼리를 실행
해당 순서로 실행하기 때문에, 결론적으로 일반 컬럼으로 delete를 실행할 때 SELECT만 해당 컬럼이 사용되고
이후 작업은 모두 PK로 처리되는 것입니다.
4. 그렇다면, Delete -> Insert로 처리하려면 어떻게 할 수 있을까?
현재 로직에서 Delete, Insert를 하더라도 FK 제약 조건에 위반되는 것이 아니기 때문에 해당 순서로 실행해도 문제가 없습니다.
하지만 Hibernate의 기본 동작이 Insert 후 Delete로 동작하기 때문에 추가적으로 커스텀하는 해결 방법이 필요합니다.
아래와 같은 해결 방법을 통해 해결할 수 있습니다.
- 강제 flush() 실행
- JPQL 사용
4-1. 강제 Flush() 실행
Hibernate의 flush() 로직은 쓰기 지연 저장소에 쌓인 쿼리를 실행하는 순서를 조작합니다.
이때, Delete -> Insert를 다 쌓고 flush()를 하지 않고 Delete가 쌓였을 때 flush()를 호출하게 된다면 정상적으로 동작합니다.
...
private final EntityManager em;
/**
* DELETE & INSERT
*/
@Transactional
public void replaceUpdateByJobId(Collection<Skill> skills, String jobId) {
skillRepository.deleteAllByJobId(jobId);
em.flush();
...
skillRepository.saveAll(skillEntities);
}
- 위와 같이 delete 로직 후 바로 flush()를 호출하면 가능합니다.
- 하지만, 다음과 같은 2가지 이유로 사용하지 않았습니다.
- 일반적으로 Service와 Repository 계층이 나뉘어져 있는데, EntityManager를 Service 계층에서 사용하는 것이 책임에 맞지 않는 것 같다.
- 이후에 나올 JPQL을 사용하는 방법보다 쿼리가 더 많이 실행된다.
- SELECT 쿼리 1건
- DELETE 쿼리 여러 건 (삭제할 Entity 개수만큼, PK로 삭제하기 때문에)
- INSERT 쿼리 여러 건
4-2. JPQL 사용
JPA의 영속성 컨텍스트를 사용하지 않고 직접 DB에 쿼리를 실행하는 JPQL을 사용하면 해결할 수 있습니다.
public interface SkillRepository extends JpaRepository<SkillEntity, String> {
@Modifying
@Query("DELETE FROM SkillEntity s WHERE s.jobId = :jobId")
void deleteAllByJobId(String jobId);
}
- @Query와 @Modifying 어노테이션을 사용하여 JPQL을 작성하고 DB에 직접 쿼리를 실행할 수 있습니다.
- 이렇게 동작하면 JPA에서 flush()를 호출하지 않고 직접 DB에 쿼리를 실행하기 때문에 Delete -> Insert가 가능합니다.
- 또, JPA의 영속성 컨텍스트를 사용하지 않기 때문에 쿼리 수가 강제 flush() 방법보다 적습니다.
- DELETE 쿼리 1건 (jobId에 해당하는 Skill을 모두 한번에 삭제)
- INSERT 쿼리 여러 건
- 위와 같이, 영속성 컨텍스트를 사용하지 않기 때문에 가져오는 SELECT 쿼리가 필요 없고, 각 Skill의 PK로 삭제하는 것이 아니라 일반 필드 jobId로 삭제할 수 있기 때문에 쿼리 1건으로 여러 개의 Row를 삭제할 수 있게 됩니다.
따라서, 구현 시에는 JPQL을 사용하여 위와 같은 문제를 해결했습니다.
※ @Query & @Modifying
- @Query를 통해 Spring Data JPA에서 영속성 컨텍스트를 사용하지 않고 직접 DB에 쿼리를 실행할 수 있습니다.
- @Modifying은 실행되는 JPQL이 SELECT가 아닌 DML(INSERT, UPDATE, DELETE)임을 명시하는 어노테이션입니다.
- Spring Data JPA에서 DML문을 직접 DB에 실행하기 위해서는 @Modifying과 @Query를 함께 선언해야 합니다.
- @Query만 선언하고 DML을 JPQL로 작성한 경우, QueryExecutionRequestException가 발생합니다.
- @Modifying만 선언한 경우 당연히, JPA 영속성 컨텍스트의 동작대로 실행되므로 예상과 다르게 동작합니다.
※ @Modifying의 flushAutomatically(), clearAutomatically()
@Modifying 어노테이션에는 2가지 속성이 존재합니다.
- flushAutomatically (boolean) default false : 해당 쿼리 실행 전에 영속성 컨텍스트의 변경 사항을 DB에 flush 할지 여부
- clearAutomatically (boolean) default false : 해당 쿼리 실행 후 영속성 컨텍스트를 clear 할지 여부
flushAutomatically 같은 경우에는 Hibernate의 FlushModeType의 기본 값이 AUTO로 동작하기 때문에
일반적으로는 false로 기본값이어도 flush가 수행됩니다.
해당 AUTO 동작은 JPQL로 실행 시에는 영향을 받는 Entity에 관해서만 flush가 실행됩니다.
AUTO flush
By default, Hibernate uses the AUTO flush mode which triggers a flush in the following circumstances:
* prior to committing a Transaction
* prior to executing a JPQL/HQL query that overlaps with the queued entity actions
* before executing any native SQL query that has no registered synchronization
따라서, 해당 JPQL 실행 시 다른 Entity의 쿼리까지 flush 하고 싶다면 true로 설정해야 합니다.
clearAutomatically 같은 경우에는 마찬가지로 비즈니스 로직에 따라서 설정을 다르게 해야합니다.
만약, JPQL을 실행한 내용을 가져와서 이후 로직에 사용해야 한다면 true로 설정해야 할 것 입니다.
Reference
https://bin-repository.tistory.com/165
[SpringDataJPA] @Modifying 과 @Query 의 관계와 동작방식 (@Query 없이 @Modifying 만 사용한다면 어떻게 될까?
1. @Modifying 이란? @Modifying 어노테이션은 @Query 어노테이션으로 작성된 수정, 삭제 쿼리 메소드를 사용할 때 필요하다. 즉, 조회 쿼리를 제외하고 데이터에 변경이 일어나는 INSERT, UPDATE, DELETE 쿼리
bin-repository.tistory.com
'Spring > JPA' 카테고리의 다른 글
JPA에서 PostgreSQL Batch Insert 사용하기 (feat. Batch Size) (0) | 2025.01.30 |
---|---|
[JPA] JPA, Spring Data JPA의 내부 동작 원리 알아보기 (0) | 2024.08.05 |
테스트 시 @ElementCollection 테이블 동적으로 가져오기(테스트 데이터 초기화) (0) | 2024.05.09 |
[JPA] JPA N+1 문제 및 근본적인 원인에 대한 개인적인 고찰 (6) | 2024.04.05 |
[JPA] JPA 1:N 관계에서 연관관계 주인을 1 대신 N에 두는 이유 (4) | 2023.08.06 |