Spiaminto
JPA 쓰기 지연 저장소 flush 시점 관련 테스트 및 정리. 본문
1. 예상치 못한 update 쿼리 발생
채팅 앱 개발도중, 같은 Entity 를 여러 서비스에서 수정해야 하는 경우가 생겼는데, Entity 의 set 메서드를 통해 데이터를 변경한 뒤 트랜잭션이 끝나는 시점에 update 쿼리가 발생할 것이라 생각했으나, 트랜잭션이 끝나기 전에 update 쿼리가 나가는 경우가 발생했다.
class TwentyGameService {
//...
protected void finishGame(long roomId) {
log.info("TwentyGameService.finishGame() start");
resetOrder(roomId); // 순서 초기화, ChatRoom.set(..)
memberService.twentyUnreadyAll(roomId); // 모든유저 unready, @Modifying 쿼리
RoomMember roomOwner = memberService.findRoomOwner(roomId);
gptService.deActivateGpt(roomId, roomOwner.getUserId()); // gpt 비활성화, ChatRoom.set(..)
memberInfoService.deleteAllByRoomId(roomId); // memberInfo 삭제, @Modifying 쿼리
log.info("TwentyGameService.finishGame() end");
} // 메서드 종료 후 트랜잭션 종료
}
class ChatRoomService {
//...
public void resetTwentyOrder(Long id) {
repository.findById(id).ifPresent(chatRoom -> {
chatRoom.setTwentyNext(0);
});
}
}
class CustomGptService {
//...
public void deActivateGpt(Long roomId, Long userId) {
//...
ChatRoom room = roomService.findById(roomId);
room.setGptActivated(false);
room.setGptUuid(null);
}
}
위와 같은 상황에서 ChatRoom 엔티티에 대한 update 쿼리는, ChatRoomService.resetTwentyOrder(), CustomGptService.deActivateGpt() 를 통해 수정된 모든 사항을 반영하여 TwentyGameService.finishGame() 메서드 종료 후 트랜잭션이 끝날때 실행될것이라 예상할 수 있다.
다만, 해당 예시에서 @Modifying 어노테이션을 이용한 update 쿼리가 중간에 나가기 때문에, 해당 쿼리(jpql)의 수행이 flush 를 트리거 하여 ChatRoom 엔티티에 대한 update 쿼리가 같이 실행될것이라고도 예상할 수 있다.
하지만 실제로는 Chatroom 에 대한 update 쿼리가 2번 나갔으며, 로그와 같이 첫 update 쿼리의 실행은 @Modifying 쿼리 실행시 가 아닌, RoomMember 조회 시 실행되었음을 알 수 있다.
이후 여러번 수정해본 결과 메서드 순서나 엔티티 변경 순서에 따라 쿼리의 실행여부 및 순서가 다르게 나타났기에, 이에 대한 이해를 높이기 위해 공식 문서 등을 참조하여 여러가지 테스트를 수행하여 보았다.
2. hibernate doc 에서의 flush 타이밍 관련 정보
hibernate 에서 flushMode 는 다음과 같이 제공되며, 기본값은 AUTO 이다.
ALWAYS: Flushes the Session before every query.
AUTO: This is the default mode, and it flushes the Session only if necessary.
COMMIT: The Session tries to delay the flush until the current Transaction is committed, although it might flush prematurely too.
MANUAL: The Session flushing is delegated to the application, which must call Session.flush() explicitly in order to apply the persistence context changes.
AUTO 모드에서는 아래와 같은 조건에서 flush 를 수행한다.
- 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
이는 한글로 풀이하면 대략 다음과 같은데
- 트랜잭션을 커밋하기 전
- 대기열에 존재하는 엔티티 작업(entity actions)과 겹칠 수 있는 JPQL/HQL 쿼리 실행 전
- 동기화(등록)되지 않은 native SQL 쿼리 실행 전
이 중 현재 영향을 끼치고 있는 조건은 2번쨰 조건으로 예상되므로, 이와 관련된 테스트를 작성해 보았다.
3. 테스트
3.1. BeforeEach @BeforeEach 를 통한 저장, flush, 재조회
@BeforeEach
public void before() {
testRoom = new ChatRoom("TestRoom");
roomRepository.save(testRoom);
testMember = new RoomMember(testRoom, 99999L, "test");
testMember2 = new RoomMember(testRoom, 99998L, "test2");
memberRepository.save(testMember);
memberRepository.save(testMember2);
em.flush();
em.clear();
testRoom = roomRepository.findById(testRoom.getId()).get();
testMember = memberRepository.findById(testMember.getId()).get();
testMember2 = memberRepository.findById(testMember2.getId()).get();
log.info("===before with cleared ===");
}
3.2 테스트: 같은 자료형 엔티티 조회
@Test
public void idSelect() {
log.info("식별자로 자신 조회"); // select jpql 실행 x, update 실행 x
testMember.setUsername("newTest");
memberRepository.findById(testMember.getId());
}
@Test
public void noneIdSelect() {
log.info("식별자가 아닌 자신조회"); // select jpql 실행 o, update 실행 o
testMember.setUsername("newTest");
memberRepository.findByRoomIdAndUserId(testRoom.getId(), testMember.getUserId());
}
@Test
public void noneIdSelectOtherEntity() {
log.info("식별자가 아닌 다른 엔티티 조회"); // select jpql 실행 o, update 실행 o
testMember.setUsername("newTest");
memberRepository.findByRoomIdAndUserId(testRoom.getId() + 1, testMember2.getUserId() + 1);
}
@Test
public void selectAll() {
log.info("동류 엔티티 전체조회"); // select jpql 실행 o, update 실행 o
testMember.setUsername("newTest");
memberRepository.findAll();
}
위와 같이 식별자를 통해 jpql 실행없이 1차캐시에서 가져오는 경우를 제외하면, 엔티티 내부 데이터와 관계 없이 모든 jpql 실행이 flush 를 트리거해 update 가 실행되었다.
이는 count 등의 집계함수를 사용할때도 동일하게 적용되었다.
3.3 테스트: 연관관계 엔티티 조회
@Test
public void noneRelationSelect() {
log.info("연관관계 없는 엔티티 조회");
testMember.setUsername("newTest");
infoRepository.findByUserId(testMember.getUserId()); // select jpql 실행 o, update 실행 X
}
@Test
public void relationIdSelect() {
log.info("식별자로 연관관계 있는 엔티티 조회");
testMember.setUsername("newTest");
roomRepository.findById(testRoom.getId()); // select jpql 실행 x, update 실행 x
log.info("jqpl 직접 조회 (chat_room 테이블만)" ); // select jpql 실행 o, update 실행 x
roomRepository.findByName(testRoom.getName());
log.info("jqpl 직접 조회 (chat_room 테이블과 room_member 테이블 join)");
roomRepository.findRoomWithMembers(testRoom.getId()); // select jpql 실행 o, update 실행 o
// 해당결과는 연관관계의 주인이 아닌 Chatroom 엔티티 수정후 RoomMember 조회때도 동일.
}
위와 같이 연관관계 여부에 관계없이, 기본적으로 엔티티를 조회하는 jpql 을 실행하더라도 flush 가 트리거 되지 않았다.
하지만 쓰기지연 저장소의 쿼리(UPDATE room_member ...) 에 의해 변경 가능성이 있는 테이블(room_member)을 join 하여 조회할때 flush 가 트리거 되었고, 해당 select 쿼리 직전에 update 쿼리가 실행되었다.
3.4 테스트: 기타 작업 (@Modifying)
@Test
public void modifyingQuery() {
testMember.setUsername("newTest");
// roomRepository @Modifying @Query 실행 -> flush X
roomRepository.setGptUuidById(testRoom.getId(), "gptUuid");
// memberRepository @Modifying @Query 실행 -> flush O
memberRepository.setTwentyReadyByRoomIdAndUserId(testRoom.getId(), testMember.getUserId(), true);
}
@Modifying 을 통해 jpql 을 실행할때도 마찬가지로 쓰기지연 저장소의 쿼리에 의해 변경 가능성이 있는 테이블에 쿼리할때 flush 가 실행되는것을 확인할 수 있다.
4. 결론
사실 flush 시점에 대해 이리저리 알아볼때, "jpql 실행 시" 와 같이 단순히 적혀있는 글이 많아 문제를 이해하는데 다소 어려움이 있었다. 결국 공식문서를 찾아본 결과
'The reason why the A entity query didn’t trigger a flush is that there’s no overlapping between the A and the P tables'
의 문장을 포함하는 코드와 설명을 확인할수 있었고, 테스트를 통해 좀 더 명확하게 이해할 수 있었다.
flush 시점에 대한 이해는 용어나 관점에 따라 다양하게 표현될 수 있다고 생각한다. 나는
' 쓰기지연 저장소의 쿼리에 의해 변경 가능성이 있는 테이블에 (jpql 등으로) 쿼리할 때 '
이렇게 이해하고 넘어가려 한다.flush 라는 행위 자체가 '영속성 컨텍스트'와 '테이블(DB)'을 (일종의)동기화 하는 작업이기 때문에, 그러니까 '테이블' 을 수정하는 것이기 때문에 기준을 '테이블'로 잡고 생각하는것이 맞겠구나... 그런생각도 든다.
5. 추가 사항
flush 나 jpa 동작방식, jpql 실행 등에 대해 이런저런 검색과 테스트 중 알게되거나 직접 확인한 사항에 대한 간단한 메모
5.1. insert 쿼리는 id(pk) 의 생성방식이 IDENTITY 인 경우 객체를 영속화(persist) 할때 즉시 실행된다.
IDENTITY 방식 특성상 id값의 생성을 DB 에 위임하기 때문에 id값을 기준으로 객체를 관리하는 영속성 컨텍스트는 id값을 받아오기 위해 즉시 insert 쿼리를 날려야 한다. IDENTIY 이외의 방식에서는 쓰기지연 저장소에 insert 쿼리가 저장된다.( id 값 획득은 jdbc 드라이버의 preparedStatement 에 Statement.RETURN_GENERATED_KEYS 스펙 참고)
5.2. OneToMany 또는 ManyToOne 에서, One 쪽이 따로 로딩 되었을경우 LazyLoading 시 쿼리가 발생하지 않는다.
이는 당연하다면 당연한 건데... Many 쪽은 전체가 다 로딩되어 있어도 LazyLoading 시에 쿼리가 발생하기 때문에 혼자 착각하고 있던 부분이라 적어둔다.
가령 Room 과 Member 가 일 대 다 관계이고 room1 이 member1, member2 만 가지며 셋 모두 영속성 컨텍스트에 존재할때, room1.getMembers() 는 LazyLoading 시 쿼리를 발생시키고 member1.getRoom() 은 쿼리를 발생시키지 않는다.Many 쪽은 예측할 수 없고 One 쪽은 1개임을 예측할 수 있기때문에 당연한 거지만...
5.3 쿼리 메서드의 조건에 외래키를 사용할 경우 join 이 발생한다.
class Room {
@Id @Column(name="room_id")Long id;
//...
}
class Member {
@Id Long id;
@ManyToOne @JoinColumn(name="room_id") Room room;
Long userId;
//...
}
class MemberRepository extends JpaRepository<Member, Long> {
Member findByRoomIdAndUserId(Long roomId, Long userId);
//...
}
위와같은 코드에서 findByRoomIdAndUserId() 메서드에 의해 생성된 최종 sql 은 room 과의 join 을 발생시킨다.
쿼리메서드는 엔티티가 직접 가지는 필드만 참조할 수 있기 때문에 Long roomId 는 Member 엔티티에서 직접 참조될 수 없으므로 Room.id 를 참조하며, 이때문에 Room 엔티티의 기본키 사용으로 인한 join 이 발생하게 된다.
이는 참조 엔티티를 직접 파라미터로 넣거나, @Query 를 이용한 jpql 작성으로 해결할 수 있다.
6. 참고
https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html#flushing
'학습정리(공개)' 카테고리의 다른 글
Selenium Java 의 java.util.concurrent.TimeoutException 문제와 Playwright (0) | 2024.09.25 |
---|---|
Supabase 무료플랜 임베딩 데이터 용량 초과 대응후기 (0) | 2024.09.12 |
아마존 리눅스 2023 Cloudwatch 로그 스트리밍 구성 설정 마이그레이션 후기 (0) | 2024.04.23 |
채팅앱 후기 - Thymeleaf 사용중 csrf 토큰 관련 에러 (0) | 2023.07.24 |
채팅앱 후기 - GPT 응답에 timeout 걸기 (0) | 2023.07.21 |