본문 바로가기

FastCampus - 한번에 끝내는 Java|Spring 웹 개발/04 JPA

JPA Ch 11 - Dirty Check에 의한 batch 성능 이슈 - 패스트캠퍼스 챌린지 45일차

어쩌다가 마지막 강의가 쉬어가는 시간이 된 건지.

JPA 강의의 마지막 내용은... 뒤에 더 없어요?... 자동으로 저장을 해주는 Dirty Check 기능에 의해서 성능적 이슈가 생기는 상황입니다.

한 줄로 정리하자면 다수의 내용을 읽을 때에는 @Transaction(readOnly=true) 를 설정하자는 거구요.

JPA의 "자동 저장"

JPA에는 dirty check 기능이 들어있어서 Persistence Context (영속 컨텍스트) 에서 관리되는 개체가 수정된 경우 세션(@Transactional) 종료 시 수정 여부를 파악해서(dirty check) 수정된 부분을 반영해줍니다. repo.save() 호출 없이도 반영되는 거죠. 예전 강의에서도 배웠던가요...?

이걸 확인하기 위해서 @Transactional 이 있는 예시를 만들어봅시다.

일단 업데이트되는 걸 좀 편하게 보기 위해서 @DynamicUpdate 부터 Comment 에 추가해줍시다. 이전에도 몇 번 나왔었지만, 이게 설정되면 JPA에서 업데이트된 필드만 반영하게 됩니다.

@Data
@NoArgsConstructor
@Entity
@DynamicInsert
@DynamicUpdate
public class Comment {
생략

테스트용으로 Transactional 이 달려있는 서비스 클래스를 추가합니다. Transactional 이 끝났을때 업데이트되었음을 확인하기 위한 용도입니다.

@Service
open class CommentService(
    private val commentRepo: CommentRepo,
    private val entityManager: EntityManager
) {
    @Transactional
    open fun initComments() {
        (0 until 10).forEach {
            val comment = Comment().apply { commentText = "우왕 댓글 단당" }
            commentRepo.save(comment)
        }
    }

    @Transactional
    open fun updateComments(doSave: Boolean) {
        commentRepo.findAll().forEach { comment ->
            comment.commentText = "우왕 댓글 바뀌었당"
            if (doSave) { commentRepo.save(comment) }
        }
    }
}

테스트 코드에서는 업데이트 후 저장 (commentRepo.save()) 을 호출하는 경우와 호출하지 않는 경우를 확인해봅니다.

@SpringBootTest
class CommentServiceTest {
    @Autowired
    private lateinit var commentService: CommentService

    @Autowired
    private lateinit var commentRepo: CommentRepo

    @Test
    fun testSave() {
        commentService.initComments()
        println("---------------------------------")
        commentService.updateComments(doSave = true)
    }

    @Test
    fun testNoSave() {
        commentService.initComments()
        println("---------------------------------")
        commentService.updateComments(doSave = false)
    }
}

예상사히는 대로 updateComments(doSave = false) 여도, 필드를 수정하는 부분이 트랜젝션 안에 있으므로 업데이트 쿼리가 호출됩니다.

더티 체크 눈으로 확인하기

필드의 dirty 여부를 판단하는 정황을 로그에서 확인해보려면 로그에서 dirty 를 검색하면 됩니다. 단, logging.level.root = trace 로 바꾸고 나서요. 당연하게도 로그가 미친듯이 뿜어져나오므로 프로덕션에서의 사용은 금물입니다.

logging:
  level:
    root: trace

로그를 보면 "dirty check" 라고 명확하게 찍혀있고, 어떤 클래스인지도 나옵니다. 너무 많아서 일부만 편집해서 가져와봤습니다.

   ///////////////////////////////////////////////////////
  /////////////// initComments() 입니다. ////////////////
 /////////////////   10번 찍힘 /////////////////////////
///////////////////////////////////////////////////////

DEBUG 3760 --- [    Test worker] org.hibernate.SQL                        : 
    insert 
    into
        comment
        (comment_text) 
    values
        (?)
Hibernate: 
    insert 
    into
        comment
        (comment_text) 
    values
        (?)

   ///////////////////////////////////////////////////////
  /////////////// findAll() 입니다.///// ////////////////
///////////////////////////////////////////////////////

DEBUG 3760 --- [    Test worker] org.hibernate.SQL                        : 
    select
        comment0_.id as id1_5_,
        comment0_.comment_text as comment_2_5_,
        comment0_.commented_at as commente3_5_,
        comment0_.review_id as review_i4_5_ 
    from
        comment comment0_
Hibernate: 
    select
        comment0_.id as id1_5_,
        comment0_.comment_text as comment_2_5_,
        comment0_.commented_at as commente3_5_,
        comment0_.review_id as review_i4_5_ 
    from
        comment comment0_

   ////////////////////////////////////////////////
  /////////////// 더티 체크  ///// ////////////////
///////////////////////////////////////////////////
DEBUG 3760 --- [    Test worker] o.h.e.i.AbstractFlushingEventListener    : Dirty checking collections
TRACE 3760 --- [    Test worker] o.h.e.i.AbstractFlushingEventListener    : Flushing entities and processing referenced collections

아래 3줄이 10번 반복
TRACE 3760 --- [    Test worker] o.h.p.entity.AbstractEntityPersister     : kr.co.fastcampus.jpasandbox.review.Comment.commentText is dirty
TRACE 3760 --- [    Test worker] o.h.e.i.DefaultFlushEntityEventListener  : Found dirty properties [[kr.co.fastcampus.jpasandbox.review.Comment#1]] : [commentText]
TRACE 3760 --- [    Test worker] o.h.e.i.DefaultFlushEntityEventListener  : Updating entity: [kr.co.fastcampus.jpasandbox.review.Comment#1]

TRACE 3760 --- [    Test worker] o.h.e.i.AbstractFlushingEventListener    : Processing unreferenced collections
TRACE 3760 --- [    Test worker] o.h.e.i.AbstractFlushingEventListener    : Scheduling collection removes/(re)creates/updates

업데이트 쿼리도 10번 반복
DEBUG 3760 --- [    Test worker] org.hibernate.SQL                        : 
    update
        comment 
    set
        comment_text=? 
    where
        id=?
Hibernate: 
    update
        comment 
    set
        comment_text=? 
    where
        id=?

강의에서는 DefaultFlushEntityEventListener (hibernate 쪽 코드) 라는 클래스를 살펴봤습니다. 로그를 찍는 부분이 거기 있었죠.

readOnly 로 더티 체크 피하기

나는 그냥 조회만 해도 되는데, 변경되었는지 여부를 추적하는 기능이 켜져있으면 확인하려는 데이터가 한두개 정도면 괜찮겠지만 10만개같이 엄청 많은 수량이 되면 성능 저하는 피할 수 없을 것입니다. 그럴땐 @Transactional(readOnly=true) 를 넣어봅시다.

(강의 끝나고 따로 하는) 실습 과정에서 테스트 메소드에도 넣어봤는데, initComments() 에서 save를 호출하면서 예외가 발생했습니다. readOnly 트랜젝션에서 save 는 호출 불가능한걸로. readOnly 의 문서를 보니 힌트니까 무시될수도 있다고 하네요. 주의해야겠습니다.

대신 서비스에 @Transactional(readOnly = true) 인 메소드를 하나 추가해줍니다.

@Service
open class CommentService(
    private val commentRepo: CommentRepo
) {

//                 vvvvvvvvvvvvvvv 추가
    @Transactional(readOnly = true)
    open fun updateCommentsReadonly(doSave: Boolean) {
        commentRepo.findAll().forEach { comment ->
            comment.commentText = "우왕 댓글 바뀌었당"
            if (doSave) { commentRepo.save(comment) }
        }
    }
}

테스트도... 적당히 추가해줍시다.

@SpringBootTest
open class CommentServiceTest {

    생략

    @Test
    open fun testReadonly() {
        commentService.initComments()
        println("---------------------------------")
        commentService.updateCommentsReadonly(doSave = false)
    }
}

readOnly 이므로 필드를 바꾸는 건 되지만 더티 체크 기능은 비활성화되어서 암만 수정을 해도 저장하지 않으면 반영되지 않습니다. 쿼리 로그에서도 findAll() 것만 찍히네요.

---------------------------------
Hibernate: 
    select
        comment0_.id as id1_5_,
        comment0_.comment_text as comment_2_5_,
        comment0_.commented_at as commente3_5_,
        comment0_.review_id as review_i4_5_ 
    from
        comment comment0_

readOnly 와 JpaRepository -> SimpleJpaRepository

강의에서의 언급으로는 readOnly=true 로 설정 시 FlushModeType 을 MANUAL 로 바꾼다고 합니다. 그것 뿐만이 아니라 JPA 백엔드에 따라 오류가 발생하기도 하는 거 같은데... 아무튼 FlushModeType 은 jakarta쪽께 있고 hibernate 께 있는데 hibernate 쪽 코드에 MANUAL 이 있습니다. readOnly 플래그의 문서에는 이런 언급이 없는 걸로 보아 소스를 읽으신듯.

또한, 리포지터리는 기본적으로 readOnly=true 이나 (SimpleJpaRepository 의 클래스에 어노테이션이 달려있음)

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
생략

save 같은 메소드에는 별도의 @Transaction 이 붙어있어서 readOnly 가 아닌 상태로 동작하게 됩니다.

	@Transactional
	@Override
	public <S extends T> S save(S entity) {

요 점도 참고.

다량의 데이터를 다룰 때에는 readOnly 를 붙이는 방안을 고려해봅시다. 제 생각에는 readOnly 를 붙이는 게 습관이 되어있는게 맞을 거 같아요. 마치 const 를 습관화하는 것처럼요.

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.
패스트캠퍼스: https://bit.ly/37BpXiC

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #한번에끝내는JavaSpring웹개발마스터초격차패키지Online

이런저런 딴짓을 한다고 시간이 예상보다 많이 걸렸네요; 오늘은 양이 적으니 쉬어갈줄 알았는데...