1. 개요
운영 과정에서 프론트에서 쿠폰 관련 API 호출 시 간헐적으로 타임아웃이 발생한다는 리포트가 들어왔다.
APM을 통해 좀 더 면밀히 살펴보니 오라클 쿼리가 비정상적으로 많이 발생하고 있음을 확인할 수 있었다.
발급된 쿠폰을 조회하는 과정에서 N+1 문제와 leftjoin의 잘못된 사용으로 인한 데이터 뻥튀기가 발생하면서 쿠폰 정책에 대한 조회가 불필요하게 이뤄진 게 화근이었다.
데이터 뻥튀기가 되는 문제는 원인이 분명했지만, N+1 문제는 의아했다. 분명 Lazy Loading을 걸어놨는데, 왠지 모르게 Eager Loading이 발생했다. 물론 Lazy Loading을 건다고 해서 N+1 문제가 모두 해결되는 것은 아니지만, 적어도 쿠폰 정책을 참조하지 않는 경우엔 N+1이 발생하지 않았어야 했다.
코틀린은 기본적으로 모든 클래스가 final로 선언되어 있어서 상속이 불가능하다. 따라서 프록시를 만들기 위해선 all open 플러그인에 어떤 클래스의 상속을 허용할 것인지 명시해줘야 한다. 바로 이 부분에서 설정이 빠져 있어서 Lazy Loading이 아닌, Eager Loading으로 동작한 것이다.
// build.gradle
allOpen {
annotation("javax.persistence.Entity") // 누락된 부분
...
}
해당 설정을 추가하니 쿼리가 확연하게 줄어드는 것을 확인할 수 있었다. 무려 80% 이상 줄어드는 것이었다..!!!
여기까진 좋았다... 문제는 그 뒤에 LazyInitializationException가 함께 발생한 것이었다.
org.hibernate.LazyInitializationException: could not initialize proxy
at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:170)
...
해당 프로젝트는 spring webflux + kotlin coroutine + JPA로 구성되어 있는 상황이었는데, R2DBC가 아닌 JDBC를 사용해서 새로운 코루틴 스코프를 열면 트랜잭션이 먹히지 않았다. 이는 JPA 트랜잭션 매니저는 트랜잭션의 상태를 쓰레드 로컬에 넣는 반면, 코루틴은 트랜잭션의 상태를 코루틴 컨텍스트에 넣기 때문이다. 좀 더 자세한 내용은 아래 링크들을 참고하되, 간단하게 요약하자면 세션이 종료된 상태에서 JPA Lazy Loading이 발생하면서 LazyInitializationException이 발생했다고 할 수 있다.
- https://stackoverflow.com/questions/68590209/spring-transactional-on-suspend-function
- https://codeinlife.tistory.com/73
2. 해결
1) 도메인 해체
근본적인 문제의 해결을 위해서 쿠폰 도메인을 이루고 있는 JPA Entity의 연관 관계가 무분별하게 사용된 것을 제거하고, 적절히 DTO 조회를 수행하고자 했다. 다음은 기존 JPA Entity의 관계를 간략하게 표현한 것이다.
AS-IS
쓰이지 않는 Entity는 제거했고, 비즈니스적으로는 연관이 없으나 조회의 편리성을 위해 연관 관계가 맺어진 경우도 끊어냈다. 어느 정도 연관 관계가 정리된 후에는 서비스 레벨에서 Lazy Loading이 발생하지 않도록 Repository단에서 연관 객체를 함께 조회하게끔 쿼리를 수정했다(단 실제 연관 객체가 사용되는 경우에 한해서).
TO-BE
2) 쿼리 수정
정책, 발급, 결제수단 등을 한꺼번에 묶어서 조회해야 하는 등 좀 더 복잡한 쿼리는 DTO를 통해서 조회하는 방식으로 쿼리를 수정했다. 기존엔 아래와 같은 형태로 페치 조인을 사용해 연관 객체를 한꺼번에 조회하고 있었는데, 이런 방식으로 조회를 하면 실제론 연관 객체가 담기지 않게 될 뿐더러 데이터도 뻥튀기가 된다(사실상 이 녀석이 문제의 주범이었다!). 이러한 이유로 DTO로 조회하도록 수정해서 연관된 객체도 담아주고, 부모 객체 하나에 자식 객체들이 리스트로 담기게 해서 데이터 뻥튀기도 없앴다.
AS-IS
@Repository
class CouponPolicyRepository() {
// 쿠폰정책 + 쿠폰정책옵션 + 쿠폰세부옵션 + 쿠폰결제수단을 페치 조인으로 한 번에 조회
// But 실제론 한 번에 조회가 안 됨..
// 별도의 distinct 기준도 없기 때문에 데이터도 뻥튀기가 됨..
fun findAllFetch(): List<CouponPolicy>? {
return queryFactory
.selectFrom(couponPolicy)
.innerJoin(couponPolicy.couponPolicyOption).fetchJoin()
.leftJoin(couponPolicy.couponDetailOption).fetchJoin()
.leftJoin(couponPolicy.couponPaymentMethod).fetchJoin()
.fetch()
}
...
}
TO-BE
@Repository
class CouponPolicyRepository(...) {
// 쿠폰정책 + 쿠폰정책옵션 + 쿠폰세부옵션 + 쿠폰결제수단을 DTO로 한 번에 조회
fun findAllDto(): List<CouponPolicyDto> {
return queryFactory
.from(couponPolicy)
.leftJoin(couponPolicyOption)
.on(couponPolicyOptionEq())
.leftJoin(couponDetailOption)
.on(couponDetailOptionEq())
.leftJoin(couponPaymentMethod)
.on(couponPaymentMethodEq())
.transform(
groupBy(couponPolicy.id).list(
couponPolicyDto()
)
) ?: emptyList()
}
...
}
couponPolicy의 Id를 기준으로 연관 객체들을 매핑해주기 위해 QueryDSL transform과 list를 사용했다. 이 기능들은 이번에 처음 사용해봤는데, 무척 편리했다. 사실 QueryDSL을 사용할 일이 많이 없었지만 이번에 새롭게 쿼리를 짜면서 많은 공부가 됐던 것 같다. JPA는 뭔가 알다가도 깜빡깜빡하곤 하는데, 그럴 때마다 영한님 강의가 큰 도움이 된다..ㅋㅋㅋ
3) 결과
위 과정을 통해서 N+1 문제를 해결하면서 JPA LazyInitializationException도 함께 잡을 수 있었다.
부수효과로 쿼리도 큰 폭으로 감소하고 성능 향상도 이루어졌다.
AS-IS
TO-BE
쿼리 호출량
- 72.8M → 31.6M (-56.59%)
- 53.1M → 22.2M (-58.24%)
- 52.3M → 17.8M (-65.9%)
성능 향상
- 쿠폰함 등에서 사용되는 발급된 쿠폰 List 조회 쿼리의 성능이 매우 빨라졌다
- Average Latency 23.6ms -> 3.7ms (-84.32%)
- 약간의 성능 개선이 전체적으로 이루어졌으며, 특히 쿠폰함 API의 성능 향상이 돋보였다
- DB 기반의 쿠폰함 API)
- Average Latency 38.9ms -> 21.5ms (-44.62%)
- 캐시 기반의 쿠폰함 API)
- Average Latency 58.1ms -> 40.1ms (-31.00%)
- DB 기반의 쿠폰함 API)
확실히 캐시 기반으로 조회를 수행하는 API보다 DB 기반으로 조회를 수행하는 API에서 성능 향상이 두드러졌다.
아무래도 쿠폰 정책보다는 발급된 쿠폰 데이터가 많다보니 관련한 쿼리/API에서 많은 개선이 이뤄진 것 같다.
사실 이 작업을 처음 진행할 때 팀원 몇분께서 작업 영향이나 변경도에서 큰 우려를 표하셨는데, 다행히도 생각보다 짧은 시간 내에 안전하게 작업을 마칠 수 있었다. 업무 시간 외에 틈틈히 작업한 것 치고는 성과가 나름 괜찮아서 굉장히 뿌듯한 경험이었다 :)
'JPA' 카테고리의 다른 글
[JPA] StoredProcedureQuery execute와 executeUpdate 차이 및 사용법 (0) | 2022.11.23 |
---|---|
[JPA] 스프링 데이터 JPA의 JpaRepository 구현체 분석 (0) | 2022.07.26 |
[JPA] 스프링 데이터 JPA에서 조인 컬럼(FK)을 조건으로 쿼리 메서드 만들 때 주의사항 (0) | 2022.07.22 |