변경 감지 쿼리 실행 순서

변경 감지시 UPDATE문의 쿼리 실행 순서를 알아본다.

Chocomilk
Dong-gle
11 min readOct 8, 2023

--

1. 개요

1.1. 개요

flush() 호출시 동작은 다음과 같다. 참조 무결성을 보장하기 위해 다음과 같은 순서로 쿼리를 호출한다고 한다.

  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

1.1. 의문점

프로젝트를 진행하면서 데이터베이스 구조를 링크드 리스트 구조로 만들었다. 이는 엔티티 간의 순서를 나타내고, 순서를 수정할 때에 수정 쿼리를 최소화하기 위함이었다.

순서를 수정할 때에는 전후의 엔티티들과의 연관관계를 적절하게 끊어주고 맺어주어야 하는데, UPDATE 쿼리가 내가 예상한 순서대로 나가지 않는 문제가 발생했다. 따라서 이번 글에서는 flush() 발생시 UPDATE 쿼리 실행 순서를 알아보고자 한다.

1.3. 예제 도메인

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String memberName;
}

아주 간단한 예제 엔티티를 구성했다. 이를 기반으로 아래의 예제 상황을 살펴보자.

2. 예제 상황

2.1. 테스트 코드1

실제로 운영 환경에서는 MemberINSERT가 이미 되어 있을 것으로 가정했기 때문에, 트랜잭션을 분리하여 경계를 짓고 싶었다. @Transactional 애너테이션을 사용하면서, 전파 옵션과 em.flush(), em.clear()를 사용할 수도 있었지만 예제를 단순화하기 위해 TransactionTemplate을 사용해봤다.

@SpringBootTest
class MemberSpringBootTest {
@Autowired private MemberRepository memberRepository;
@Autowired private TransactionTemplate template;
@Autowired private EntityManager em;

@Test
void test() {
//given, when
template.executeWithoutResult(result -> {
Member member1 = new Member("member1");
Member member2 = new Member("member2");
Member member3 = new Member("member3");

memberRepository.saveAll(List.of(member1, member2, member3));
});

//then
template.executeWithoutResult(result -> {
Member member1 = memberRepository.findByMemberName("member1");
Member member2 = memberRepository.findByMemberName("member2");
Member member3 = memberRepository.findByMemberName("member3");

System.out.println("=====");

member1.changeName("memberA");
member2.changeName("memberB");
member3.changeName("memberC");
});
}
}

given, when절의 트랜잭션은 기본 데이터를 넣어 놓는 것이기 때문에 설명을 생략하겠다. then절의 트랜잭션에서 발생한 쿼리를 살펴보면..

# member1 조회
select
m1_0.id,
m1_0.member_name
from
member m1_0
where
m1_0.member_name='member1'

# member2 조회
select
m1_0.id,
m1_0.member_name
from
member m1_0
where
m1_0.member_name='member2'

# member3 조회
select
m1_0.id,
m1_0.member_name
from
member m1_0
where
m1_0.member_name='member3'

---

# member1 수정
update
member
set
member_name='memberA'
where
id=1

# member2 수정
update
member
set
member_name='memberB'
where
id=2

# member3 수정
update
member
set
member_name='memberC'
where
id=3

짐작한대로, 세 개의 조회 쿼리 이후에 UPDATE쿼리가 발생했다. memberAmemberBmemberC 순으로 UPDATE 쿼리가 발생했다. (id보다는 변경된 이름이 더 알아보기 편할 것으로 예상되어, 바뀐 member_name으로 설명한 것이다.)

2.2. 테스트 코드2

그렇다면 다음과 같이 변경을 가하는 메서드를 호출하는 순서를 바꾸면 어떻게 될까?

@Test
void test() {
//given
...

//then
template.executeWithoutResult(result -> {
Member member1 = memberRepository.findByMemberName("member1");
Member member2 = memberRepository.findByMemberName("member2");
Member member3 = memberRepository.findByMemberName("member3");

System.out.println("=====");

// 바뀐 부분!!
member3.changeName("memberC");
member2.changeName("memberB");
member1.changeName("memberA");
});
}

위의 케이스에서 UPDATE 쿼리는 어떤 순서대로 날라갈까?!?! UPDATE 쿼리 부분만 살펴보자.

# member1 수정
update
member
set
member_name='memberA'
where
id=1

# member2 수정
update
member
set
member_name='memberB'
where
id=2

# member3 수정
update
member
set
member_name='memberC'
where
id=3

이상하게도 changeName()을 호출한 순서처럼 memberCmemberBmemberA가 아니라 이전의 테스트 케이스와 동일한 순서대로 memberAmemberBmemberC 순으로 UPDATE 쿼리가 발생하는 것을 확인할 수 있다.

2.3. 테스트 코드3

그렇다면 마지막으로 member조회하는 순서를 변경해보자. 아래와 같은 상황에서는 UPDATE 쿼리의 순서가 어떻게 될까?

@Test
void test() {
//given
...

//then
template.executeWithoutResult(result -> {
// 바뀐 부분!!
Member member3 = memberRepository.findByMemberName("member3");
Member member2 = memberRepository.findByMemberName("member2");
Member member1 = memberRepository.findByMemberName("member1");

System.out.println("=====");

member1.changeName("memberA");
member2.changeName("memberB");
member3.changeName("memberC");
});
}

이전처럼 memberAmemberBmemberC 순서대로 UPDATE 쿼리가 발생할까?

# member3 수정
update
member
set
member_name='memberC'
where
id=3

# member2 수정
update
member
set
member_name='memberB'
where
id=2

# member1 수정
update
member
set
member_name='memberA'
where
id=1

아니다. 이제는 또 memberCmemberBmemberA 순서대로 UPDATE 쿼리가 발생한다.

3. 정리

위의 일련의 과정을 통해 하나의 규칙을 찾을 수 있었다. 그것은 UPDATE 쿼리는 변경을 가한 메서드를 호출한 순서대로 발생하는 것이 아니라, 영속성 컨텍스트에 엔티티가 올라온 순서대로 발생한다는 것입니다.

테스트 코드2에서 설명했던 코드를 토대로 정리해보면

@Test
void test() {
//given
...

//then
template.executeWithoutResult(result -> {
Member member1 = memberRepository.findByMemberName("member1");
Member member2 = memberRepository.findByMemberName("member2");
Member member3 = memberRepository.findByMemberName("member3");

System.out.println("=====");

member3.changeName("memberC");
member2.changeName("memberB");
member1.changeName("memberA");
});
}
조회할 때까지의 영속성 컨텍스트의 모습이다.
조회할 때까지의 영속성 컨텍스트의 모습이다.
member3.changeName(
member3.changeName(“memberC”); 호출
member2.changeName(
member2.changeName(“memberB”); 호출
member1.changeName(
member1.changeName(“memberA”); 호출

commit()이 호출되면 flush()가 호출되고, 변경 감지(dirty-checking)가 발생할 것이다. 이때 1차 캐시에 올라온 엔티티의 순서대로 변경 사항을 감지할 것이다.

flush()호출 후, 변경 감지
flush()호출 후, 변경 감지
1차 캐시에 저장된 엔티티의 순서대로 변경 감지를 진행하고 쓰기 지연 SQL 저장소에 UPDATE 쿼리 저장. 이후 DB에 반영.
1차 캐시에 저장된 엔티티의 순서대로 변경 감지를 진행하고 쓰기 지연 SQL 저장소에 UPDATE 쿼리 저장. 이후 DB에 반영

따라서 UPDATE쿼리는 변경을 가한 순서대로 쿼리가 발생하는 것이 아니라, flush()가 일어나는 시점에 1차 캐시에 올라온 엔티티의 스냅샷을 순차적으로 비교하여, 그 순서대로 쿼리가 발생한다는 것을 알 수 있었다.

4. 마무리

따라서 변경 감지를 사용해 UPDATE를 하는데, 하나의 트랜잭션 내에서 여러 개의 UPDATE가 발생하고 이들 간의 순서를 보장해야 한다면, 직접 flush()를 중간중간에 호출해주어야 한다는 것을 알았다. 이런 부분은 정말 디버깅일 쉽지 않아서 많은 시간을 투자해야만 했던 것 같다.

5. 참고

--

--