Spiaminto

JPA 쓰기 지연 저장소 flush 시점 관련 테스트 및 정리. 본문

학습정리(공개)

JPA 쓰기 지연 저장소 flush 시점 관련 테스트 및 정리.

spiaminto 2024. 6. 6. 16:50

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 

조회 쿼리 메서드 사용 시 불필요한 Join 이 사용되는 이유 (velog.io)

[JPA] Auto Increment가 포함된 Insert쿼리는 언제 나갈까? (velog.io)