ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA 연관관계 Paging 최적화
    프로젝트/미디어 스트리밍 서버 프로젝트 2022. 10. 6. 00:01
    728x90

    개요

    @OneToOne과 @Embedded를  고민하던 중 만약 하나의 Column에서 업데이트가 빈번하게 일어난다면 row lock이 발생할 수 있기 때문에 그 칼럼을 다른 테이블로 분리하는 방법이 존재한다는 것을 알게 되었습니다.

     

    Row Lock

    row lock은 테이블의 각 행에 걸리며 read를 위한 S-lock, select.. for update, update, delete 등의 wirte에 걸리는 X-lock으로 구분됩니다.

     

    트랜잭션의 동시성을 최대한 보장하기 위해서 Lock을 활용합니다.

     

    이때 S lock을 사용하는 쿼리끼리는 같은 row에 접근 가능합니다.

    반면, X lock이 걸린 row는 다른 어떠한 쿼리도 접근 불가능합니다.

     

    Video Entity

    Video Entity는 비디오에 대한 정보들을 가지고 있으며 hit이라는 조회수 Column을 가지고 있었습니다.

    여기서 다른 Column들은 초기에 비디오가 저장된 이후에 거의 갱신되지 않습니다.

    하지만 hit Column은 사용자가 조회할 때마다 수정이 일어나게 되고 수정을 위해 X-lock이 걸려서 다른 어떤 쿼리도 접근 불가능하게 됩니다.

     

    따라서 hit Column은 따로 @OneToOne으로 분리하여 관리하고자 합니다.

     

    @OneToOne

    @OneToOne(cascade = [CascadeType.ALL])
    @JoinColumn(name = "hit_id")
    var hit: Hit = Hit(),

    Hit table의 경우에는 Video table과 생명주기를 같이 하기 때문에 cascadeType.ALL으로 관리하고자 했습니다.

    또한 Nullable하지 않게 초기값으로 Hit객체를 생성하여 넣어줍니다.

     

    Hit Entity

    @Entity
    class Hit(
        @Column(name = "hit_count")
        var hitCount: Int = 0,
    ) {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "hit_id")
        val id: Long = 0
    }

    Hit table은 id와 hitCount를 가지며 초기값으로 각각 0을 가집니다.

     

     

    update 로직 쿼리 수정

    Service

    fun plusHitCount(videoId: Long) {
            val videoInfo = videoRepository.findByIdOrNull(videoId) ?: throw IllegalArgumentException()
            videoRepository.plusHitCountById(videoInfo.hit.id)
            //기존 로직 : videoRepository.plusHitCountById(videoId)
        }

    Repository

    @Modifying
    //기존로직 : @Query(value = "update Video v set v.hit = v.hit + 1 where v.id = :videoId")
    @Query(value = "update Hit t set t.hitCount = t.hitCount + 1 where t.id =:hitId")
    fun plusHitCountById(hitId: Long)
     

     

    기존 로직은 videoId를 받아서 바로 단일 테이블에서 갱신합니다.

     

    하지만 이제는 1:1 관계를 맺고 있기 때문에 select 쿼리를 한번 날려서 hitId를 조회하고 해당 hitId를 통해 update 하기 때문에 2번의. 쿼리가 발생하게 됩니다.

     

    이 부분은 조회를 위해서 업데이트 시 살짝 손해를 보는 느낌입니다.

     

     

    문제가 발생하는 부분

    전체 비디오를 조회하게 되면 1:1 연관관계를 맺고 있기 때문에 비디오의 개수만큼 조회수를 조회하는 추가 쿼리가 나가게 됩니다.

    비디오가 3개라면 hit_id로부터 조회하는 쿼리가 3개 나가게 됩니다.

     

    현재 Service 로직

    @Transactional(readOnly = true)
        fun findUploadedVideos(pageable: Pageable): PageResponse {
            val result = videoRepository.findByStatus(Status.FINISHED, pageable)
            return PageResponse.toPageResponse(result)
        }

    Repository

     

    fun findByStatus(status: Status, pageable: Pageable): Page<Video>

    PageResponse.toPageResponse 내부 로직

    video.hit.hitCount

     

    Paging 된 값을 받아와서 변환하는 과정에서 hit_id로부터 hitCount를 조회하게 되고 추가 쿼리가 발생하게 됩니다.

     

    해결법

    fetch join, @EntityGraph를 통해서 hit값들을 미리 다 받아오려고 합니다.

    fetch join과 @EntityGraph의 차이는 inner join과 left outer join의 차이가 있습니다.

    현재 1:1 연관관계로 null값은 따로 존재하지 않기 둘의 차이는 없을 것으로 고려됩니다.

    따라서 사용하기 편한 @EntityGraph를 사용하고자 합니다.

     

    변경된 Repository

    @EntityGraph(attributePaths = ["Hit"])
    fun findByStatus(status: Status, pageable: Pageable): Page<Video>

    코틀린에서는 {} 대신에 []를 활용해야 합니다

     

    결과

    Hibernate: select video0_.video_id as video_id1_2_0_, hit1_.hit_id as hit_id1_1_1_, video0_.created_at as created_2_2_0_, video0_.updated_at as updated_3_2_0_, video0_.content as content4_2_0_, video0_.hit_id as hit_id12_2_0_, video0_.duration as duration5_2_0_, video0_.height as height6_2_0_, video0_.width as width7_2_0_, video0_.status as status8_2_0_, video0_.subject as subject9_2_0_, video0_.thumbnail_url as thumbna10_2_0_, video0_.video_url as video_u11_2_0_, hit1_.hit_count as hit_coun2_1_1_ from video video0_ left outer join hit hit1_ on video0_.hit_id=hit1_.hit_id where video0_.status=? limit ?
    Hibernate: select count(video0_.video_id) as col_0_0_ from video video0_ where video0_.status=?

    left outer join을 활용해서 쿼리가 1번 그리고 count쿼리가 1번 나가게 됩니다.

    count query에 join이 나가는 경우 최적화가 필요할 것 같지만 현재는 join이 들어가지 않아서 그대로 두어도 될 것 같습니다.

     

     

    마찬가지로 단일 비디오를 조회할 때도 select query가 2번 나가게 되고 @EntityGraph를 통해 해결할 수 있습니다.

    @EntityGraph(attributePaths = ["hit"])
    @Query(value = "select v from Video v where v.id =:videoId")
    fun findByIdWithHit(videoId : Long) : Optional<Video>

     

    여기서 성능을 더 올리고 싶다면 요구사항에 따른 제약조건이 있지만  빠른  No offset 방법을 사용할 수 있습니다.

    https://jojoldu.tistory.com/528

     

    1. 페이징 성능 개선하기 - No Offset 사용하기

    일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방

    jojoldu.tistory.com

     

    댓글

Designed by Tistory.