2024. 1. 14. 22:45ㆍBackend/JPA
혹시 코드나 설계에 대해 피드백해 주시면 정말 소중하게 여기고 반영하겠습니다.
감사합니다.
모두의 랜덤 디펜스는 코딩 테스트 실전 모의고사 서비스에서 사용자들이 모의시험을 치르게 되면, 4개에서 7문제로 이루어진 시험 한 세트가 출제됩니다.
모두의 랜덤 디펜스 사용자들은 마이페이지에서 최근 4개의 시험 기록밖에 확인할 수 없었습니다.
사용자들이 새롭게 시험을 치면 4개 이전의 시험 내용을 조회할 수 없게 되는 것입니다.
이러한 문제로 사용자들은 시험 기록에 대한 문제점을 늘 언급해 주셨고, 시험 목록을 간단하게 확인할 수 있는 시험 기록 API를 개발하기로 계획했습니다.
개발하고자 하는 API는 다음과 같습니다.
- 페이지마다 10개씩의 Test 목록과 Test에 포함된 AttemptProblem들을 전부 가져온다.
ERD 분석
모두의 랜덤 디펜스에서 사용자의 요청으로 시험이 시작되면
test 테이블에 레코드가 생성되고, 레코드의 PK를 FK로 갖는 문제들이 attempt_problem 테이블에 4~7개씩 생성되게 됩니다.
초기에 모두의 랜덤 디펜스를 개발할 당시에는
양방향 연관관계의 필요성을 느끼지 못했고, Test의 PK를 엮어주는 최소한의 연관관계만 이용하고 있었습니다.
하지만 간단하게 페이징 API를 개발하면 끝날 것 같던 문제는 단방향 뿐인 연관관계로 API를 설계하는 데 많은 고민을 하게 되었습니다.
제가 구상해본 설계 방법은
1. 연관관계를 추가하지 않고 두 번에 걸친 조회로 페이징을 구현하는 방법과,
2. 연관관계를 추가하고, 예상되는 N+1 문제를 위해 배치 처리를 추가하여 해결하는 방법
두 가지가 있었습니다. 더 자세히 알아보겠습니다.
1. 연관관계 매핑을 변경하지 않고 두 번에 걸친 조회로 페이징을 구현하는 방법
- test들만 페이징하여 LAZY 로딩하여 가져오고,
- 가져온 test PK들을 모아서 findAll 시 페치 조인을 이용해서 attempt_problem, problem을 한 번에 가져온다.
이렇게 구현하면 2번의 쿼리만으로 해결할 수 있고, 각 조회 로직에 대해 세세하게 최적화할 수 있지만, 코드 복잡성이 증가하게 됩니다.
또한 코드에서 두 엔티티간의 관계를 쉽게 파악하기 힘든 점이 존재했습니다.
2. @OneToMany를 추가로 사용하고, 배치 처리를 추가하여 해결하는 방법
- 기존, 단방향이던 관계를 양방향으로 추가하면서 편의 메서드를 만든다
- Test 기준으로 Paging 후 getAttemptProblems를 통해 관련된 엔티티를 조회한다.
- application.yml에 배치 처리를 위한 default_batch_fetch_size 옵션을 준다.
이렇게 구현하면 test, attemptProblem, Problem들을 가져오는 쿼리가 따로 실행되고, 배치 처리로 발생할 수 있는 성능 이슈가 존재하만, 비즈니스 로직을 더 잘 표현할 수 있을 것이라고 판단했습니다.
저희는 두 가지 방법을 검토한 결과 @OneToMany 매핑을 추가하는 것을 통한 방법이 가독성 좋은 코드작성에 도움을 줄 것이고 유지보수성에 더욱 도움이 될 것이라 판단, 또한 배치 처리로 성능도 대체적으로 큰 문제가 없을 것으로 생각했습니다.
@OneToMany 적용 후, fetch join을 통해 paging하게 되면 더 간단하게 보일 수 있지만,
Spring 로그에 firstResult/maxResults specified with collection fetch; applying in memory 로그가 발생합니다.
이 방법은 모든 데이터를 읽어와서 메모리상에서 pagination을 진행하기 때문에 매우 치명적인 성능 문제를 야기할 수 있습니다.
양방향 연관관계 설정
배치 처리는 가장 일반적인 100으로 설정해 주었는데, Hibernate는 효율적인 캐시 전략을 위해 100으로 설정하더라도 일반적으로 그 아래 숫자인 1-10, 12, 25, 50, 100에 맞는 크기에 맞추어 배치 크기를 적응적으로 조절하기 때문에 현재 비즈니스 로직상 문제가 없을 것으로 생각했습니다.
코드 수정을 완료하고, 배치 처리 옵션을 추가하면서
배치 처리가 얼마나 성능 향상에 얼마나 도움이 되었는지에 대해 알고 싶었습니다.
그래서 jMeter를 이용하여 배치 처리 여부에 따라 발생하는 성능차이에 대해 실험해 보았습니다.
만약 배치 처리가 되어있지 않다면 시험(Tests)을 기준으로 페이징 후 가져올 때, 풀어본 문제 (AttemptProblem)과 문제 상세(Problem)가 지연 로딩 설정되어 있어 N+1문제가 발생할 것인데, 이런 문제가 얼마나 성능에 영향을 끼칠지 관심이 생겼습니다.
만약 N+1문제를 방치하면 API 가 한 번 실행될 때마다 최소 문제 수가 4개이기에 최소 9개 이상의 쿼리가 발생할 것입니다.
- 문제 n개 기준 (test, attemptProblem n개, problem n개) = 2n+1
측정
배포 환경에서 테스트를 진행할 수 없는 상황이기에, 로컬 환경에서 jMeter를 이용하여 부하 테스트를 진행해 보았습니다.
N+1문제를 방치했을 때
(N+1)Response Time 측정 결과 | (N+1)TPS 측정 결과 |
로컬 환경임에도 불구하고 Response Time 최대 390ms까지 불규칙한 응답속도를 확인할 수 있습니다.
테스트 환경이라 데이터가 없고 로컬 환경이기 때문에 100ms이하의 응답 속도를 기대했지만, 테스트 결과 생각보다 더 불규칙적인 응답 속도를 확인할 수 있었습니다.
실제 배포 환경에서 배치 처리 없이 배포되었다면 네트워크 딜레이와, DB 인스턴스 성능이 훨씬 낮아 더 큰 응답 속도가 소요되어 사용자 불편을 야기했을 것으로 예상됩니다.
이후에는 같은 조건으로 배치 처리로 N+1문제를 해결한 결과를 jMeter를 이용하여 측정해 보았습니다.
배치 처리로 N+1문제를 해결했을 때
(Batch Option)1회차 Response Time | (Batch Option)2회차 Response Time |
(Batch Option)1회차 TPS | (Batch Option)2회차 TPS |
결과를 보면 기존에 비해 배치 처리가 들어갔을 때 평균 응답시간이 대폭 감소한 것을 확인할 수 있었습니다.
정리
@OneToMany를 이용하여 양방향 연관관계가 매핑된 상황에서 페이징 API를 작성할 때에는 단순하게 작성 후 배치 처리를 이용하는 방법으로 해결해 왔는데, 이번 사례에서는 단방향 연관관계뿐인 상황에서 어떻게 해결할 수 있을지에 대한 방법을 생각해 보는 계기가 되었습니다.
또한 N+1 문제가 발생하는 상황을 의도적으로 발생시켜 비교를 해보았고, N+1 문제가 성능 문제에 얼마나 치명적으로 다가올 수 있는 지에 대해 체감할 수 있는 계기가 되었습니다.
이 외에도 배치 처리 옵션 사용 시 batch_size를 100으로 설정하더라도 Hibernate 내부 최적화 기법으로 IN 쿼리에 이용되는 PK 수를 자동으로 조절하여 사용한다는 것을 알게 되었습니다.
비고
위와 유사한 상황에서 어떤 최적화 기법이 더 존재할지 궁금해서 링크를 찾아보았는데, 참고하시면 좋을 것 같습니다.
https://vladmihalcea.com/join-fetch-pagination-spring/