스터디 매칭 플랫폼 Ludo
서비스 출시 후 발생한 장애 대응 포스팅입니다.
상황
Ludo 스터디 매칭 플랫폼
정식 서비스 출시 후, 홍보 과정에서 서비스 장애가 발생했습니다. 팀원에게 슬랙 메시지를 통해 인지했고, Nginx 리버스 프록시
와 백엔드 WAS
GCP 인스턴스는 문제가 없음을 전달 받았습니다. 데이터베이스 서버
인스턴스가 원인으로 추정되어, 인스턴스를 재가동하여 서비스를 복구했습니다.
원인: JPA 1 + N 문제
서비스는 복구했지만 장애 발생의 근본적인 원인을 해결하고자 했습니다. 장애 상황을 복기해보면, Nginx 리버스 프록시
, 백엔드 WAS
는 정상이었고 데이터베이스 서버
에만 문제가 발생했으므로, SQL 요청 로직에 문제가 있을 것으로 생각했습니다.
모집 공고 조회 API에 로그를 찍어본 결과, API 1회 요청 당 93회의 SQL 쿼리가 발생하고 있었습니다. 즉, 아래 그림과 같이 SQL 요청 트래픽이 뻥튀기 되어 데이터베이스 서버 장애 발생 및 전체 서비스에 장애가 전파된 것을 추정해볼 수 있겠습니다.
API 요청, SQL 요청을 1 req로 가정합니다.
모집 공고 조회 API에서 93회의 SQL 쿼리가 추가적으로 발생한 이유는 JPA 1 + N 문제
였습니다. 조금 더 자세히 살펴보기 위해, 도메인 엔티티간의 연관 관계를 먼저 살펴볼 필요가 있겠습니다.
도메인 엔티티 연관 관계
Ludo
서비스에서 모집공고를 조회하는 화면은 아래와 같습니다. 스터디를 의미하는 Study
, 모집공고를 의미하는 Recruitment
그 밖에 User
, Category
, Stack
, Position
도메인이 있습니다.
모집 공고 조회 시 발생하는 93회의 select문을 조금 더 자세히 다뤄보면 아래와 같습니다.
1 (Recruitment) +
// 일대일 양방향 연관관계 1 + N 문제
N (Study): 21회
N (Recruitment): 21회
// 일대다 연관관계 1 + N 문제
N (RecruitmentPosition): 21회
N (RecruitmentStack): 21회
N (Stack): 3회
N (Position): 3회
N (Cateogry): 1회
N (User): 1회
기본적으로 N이 21회씩 발생하는 이유는 21 건씩 페이지네이션 조회하는 쿼리이기 때문입니다.
JPA 1 + N 발생 이유
위의 내용을 정리해보면, 일대일 양방향 연관 관계
와 일대다 연관 관계
에서 1 + N 문제가 발생하고 있습니다. 각각에 대해 왜 1 + N 문제가 발생했는지 알아보고자 합니다.
일대일 양방향 연관 관계
일대일 양방향 연관 관계
는 외래키가 없는 쪽을 조회할 때 1 + N 문제가 발생합니다. 그 이유는 객체지향 메커니즘과 관계형 데이터베이스 메커니즘 차이 때문입니다. 일대일 양방향 연관 관계가 걸려있는 Study
와 Recruitment
를 코드와 DB 테이블로 나타내면 다음과 같습니다.
1 |
|
case1: Recruitment
엔티티 조회 (외래키가 있는 쪽을 조회)
Recruitment
테이블 한 번의 조회만으로 Recruitment
와 Recruitment가 참조하고 있는 Study
를 나타낼 수 있습니다. Recruitment
테이블이 Study
테이블을 외래키로 “참조” 하고 있기 때문입니다.
case2: Study
엔티티 조회 (외래키가 없는 쪽을 조회)
Study
테이블 한 번의 조회만으로는 Study가 참조하고 있는 Recruitment
를 나타낼 방법이 없습니다. Study 테이블엔 Recruitment 테이블과 관련한 어떠한 정보도 없기 때문입니다.
Study
가 참조하고 있는 Recruitment
가 정말로 없는 것인지 (:= Null), 존재는 하는 것인지 (:= Proxy) 객체로 표현해야 하는데, 이를 알 방법이 없으므로 Recruitment 조회 쿼리가 한 번 더 발생하는 것입니다.
일대다 양방향 연관 관계
일대다 양방향 연관 관계에서 일측을 조회하면 다측을 List
로 갖게되는데, 다측을 다시 Lazy Loading 하는 과정에서 자연스레 1 + N 문제가 발생합니다.
1 |
|
Problem Solving
기존 모집 공고 목록 조회 API 성능 테스트
성능 최적화를 진행하기 전에, before & after 에 대한 유의미한 수치를 측정하기 위해 성능 테스트를 진행했습니다. 또한 대규모 데이터가 적재되어 있을 때 예상하지 못했던 고객 Pain Point를 함께 해결해보고 싶었기 때문에, 480만개 테스트 데이터를 마련했음. 대규모 테스트 데이터 생성 방법은 별도의 포스팅으로 다룰 예정입니다.
테스트 환경
테스트 데이터: 480만개
- 스터디 (Study) 테이블: 60만개
- 모집공고 (Recruitment) 테이블: 60만개
- 모집포지션 (RecruitmentPosition) 테이블: 180만개
- 모집기술스택 (RecruitmentStack) 테이블: 180만개
테스팅 툴: Jmeter
모집 공고 목록 조회 (필터 검색 적용) 테스트 결과
Approach
구조적 변경을 통한 해결 방법
일대일 양방향 연관 관계
의 1 + N 문제를 해결하기 위해 다양한 접근 방법을 고려했습니다. 첫 번째는 구조적 변경을 통한 해결 방법입니다.
1)FK 위치 변경
일대일 양방향 연관 관계
에서 외래키가 없는 쪽을 조회할 때 1 + N 문제가 발생한다고 했습니다. 그렇다면 외래키의 위치를 변경하는 방법을 고려해볼 수 있겠습니다. 하지만 현재 상용 중인 서비스에서 물리적 데이터 구조를 변경하기 까다롭다 판단 되었습니다.
2)일대다, 다대일 연관 관계로 변경
이 방법은 Ludo 서비스의 비즈니스 요구사항을 변경해야 했습니다. Ludo 서비스는 한 개의 스터디에서 한 개의 모집 공고를 작성할 수 있는데, 연관 관계를 변경하면 한 개의 스터디에서 여러 개의 모집 공고를 작성할 수 있도록 변경해야 하니다. 이에 따라 기획 / 디자인 / 프론트 등 변경 사항에 대한 코스트가 높다 판단 되었습니다.
성능 최적화를 통한 해결 방법
이러한 이유들로 성능 최적화를 통한 해결 방법을 선택했습니다. 자세한 내용은 아래에서 다루겠습니다.
성능 최적화: 일대일 양방향 연관 관계
모집공고 조회 비즈니스 요구사항
이해를 돕기 위해 모집공고 조회 비즈니스 요구사항과 QueryDSL 로직을 간단히 살펴보겠습니다.
첫째, 모집공고 조회는 다양한 검색 필터 조건이 존재하고, 이에 따라 동적 쿼리
를 구현해야 합니다. 검색 필터 조건에 따라, 모집공고 조회는 최대 7개의 도메인 엔티티와 엮이게 됨. (위의 그림 참고)
둘째, 모집공고 조회는 21건씩 무한 스크롤로 제공해야 하고, 이를 위해 cursor-based pagenation
을 구현해야 합니다.
모집공고 조회 QueryDSL
cursor based pagination, 동적 쿼리
1 |
|
Fetch Join을 통한 성능 최적화
Recruitment
- Study
일대일 양방향 연관 관계 최적화를 위해 fetch Join
을 적용했습니다.
1 |
|
하지만 아직 1 + N 문제가 남아있습니다. isSatisfyJoinToStudy
메서드는 검색 필터 조건의 카테고리
, 진행방식
포함 여부에 따라, Recruitment
테이블과 Study
테이블 Join 여부를 판별하는 조건식입니다.
1 |
|
필터 검색 조건에 카테고리(category)
혹은 진행방법(way)
이 없을 경우 fetchJoin이 안 걸리게 되면서 또 다시 1 + N 문제가 발생함.
곰곰히 생각해보니 모집 공고 목록 조회 시, 스터디 테이블을 반드시 조회한다는 것을 알 수 있었습니다.
위의 두 가지 이유로 모집공고 엔티티와 스터디 엔티티는 검색 필터 조건에 상관 없이 항상 fetch join 하도록 변경했습니다.
1 |
|
필터 검색 조건에 카테고리(category)
혹은 진행방법(way)
이 없는 경우, 기존 3.9초에서 0.5초로 성능 개선 을 확인했습니다.
Fetch Join을 통한 성능 최적화 결과
API 성능 개선
평균 4.3초 → 0.9초 (표본: 100회)
조회 쿼리 트래픽 뻥튀기 개선
93회 → 51회
1 (Recruitment)
+N (Study): 1 회~ x 21~
~+N (Recruitment): 1 회 x 21
+N (RecruitmentPosition): 1 회 x 21
+N (RecruitmentStack): 1 회 x 21
+N (Stack): 총 3회
+N (Position): 총 3회
+N (Category): 총 1회
+N (User): 총 1회
성능 최적화: 일대다 연관 관계
[Pagination 쿼리] 한개의 일대다 연관 관계를 fetch join 시 문제점
Recruitment - RecruitmentPosition
, Recruitment - RecruitmentStack
일대다 연관 관계의 경우 아래 코드처럼 fetch join을 적용할 수 없었습니다.
1 |
|
먼저 한개의 일대다 연관 관계를 fetch join 했을 때의 문제점을 살펴보고자 합니다. 아래와 같이 요청이 들어오면, 조건식에 의해 Recruitment - RecruitmentPosition
한 가지 경우에 대해서만 fetch join이 걸립니다.
정상 동작하지만 시간이 7초로 매우 오래 걸리고, 아래와 같은 경고 메시지를 확인할 수 있습니다.
1 |
|
SQL 로그를 보면 limit
구문이 존재하지 않는 것을 확인할 수 있습니다. 즉, JPA는 컬렉션에 대해 fetch join하는 경우 페이지네이션 처리를 애플리케이션 레벨에서 처리하는 것을 알 수 있습니다.
1 |
|
일대다 연관관계에서 일측을 조회하면 데이터 뻥튀기가 발생할 수도 있는 문제
일대다 연관관계에서 일측을 조회하면 데이터 뻥튀기가 발생할 “수도” 있습니다. 페이지네이션 쿼리를 짤 때, 데이터 뻥튀기가 발생하지 않도록 하는 것이 정말 중요한데, 이번 예시에서는 데이터 뻥튀기가 발생하는 경우에 대해 살펴보고자 합니다.
아래와 같은 테이블이 있다고 했을 때, 위의 쿼리에 대한 Result Sets는 다음과 같습니다.
위에서 살펴본 SQL 로그에서 limit
구문이 없다고 언급했었습니다. 다시말해, Result Sets 레코드들을 애플리케이션 레벨로 퍼올리고 페이지네이션 처리를 수행하는 것입니다.
이 과정에서 JPA가 엔티티에 대한 식별자 정보를 바탕으로 뻥튀기된 중복 데이터를 제거하고, 페이지네이션 처리를 하는 것으로 생각 됩니다. 정리하면, 페이지네이션 처리는 가능하나, 뻥튀기 된 데이터를 애플리케이션 레벨로 퍼올리는 과정에서 메모리 이슈를 경고하는 것입니다.
[Pagination 쿼리] 두개 이상의 일대다 연관 관계를 fetch join 시 문제점
두개 이상의 일대다 연관 관계를 fetch join 했을 때의 문제점을 살펴보고자 합니다. 아래와 같이 요청이 들어오면, 모두 조건식에 걸려서 Recruitment - RecruitmentPosition
, Recruitment - RecruitmentStack
두 가지 경우 모두 fetch join이 걸립니다. 그리고 500 서버 에러와 함께 예외 메시지를 확인할 수 있습니다.
1 |
|
원인은 한 개 이상의 일대다 연관 관계 fetch join에서 살펴본 것과 일맥 상통합니다. 차이점은 데이터 뻥튀기가 너무 많이 되니까 JPA가 페이지네이션 처리 자체를 할 수 없게 되는 것입니다.
Batch Size 설정을 통한 성능 최적화
정리하면 일대다 연관 관계에서 fetch join은 적용이 힘듬. 다른 방법으로 1 + N 문제를 개선해야 합니다. fetch join을 적용하지 않았을 때 SQL 쿼리는 다음과 같습니다.
1 |
|
이걸 그림으로 나타내면 아래와 같습니다.
그럼 여기서 RecruitmentPosition1
~ RecruitmentPositionZ
를 갖고오기 위해, In 절을 사용해볼 수 있지 않을까? 생각이 들 것입니다. 그럼 기존의 21회의 쿼리가 1회로 줄어들 것입니다.
1 |
|
이 개념을 도입한게 Batch Size
입니다. Batch Size
를 적용하면 아래와 같은 쿼리를 확인할 수 있습니다.
1 |
|
이걸 그림으로 표현하면 아래와 같습니다. RecruitmentPosition
에는 Recruitment
가 존재하므로, 어떤 RecruitmentPosition
이 어떤 Recruitment
의 것인지 식별할 수 있습니다.
Batch Size 설정을 통한 성능 최적화 결과
API 성능 개선
평균 0.9초 → 0.9초 (표본: 100회)
조회 쿼리 트래픽 뻥튀기 개선
51회 → 7회
1 (Recruitment)
+N (RecruitmentPosition): 1회 x 21 총 1 회
+N (RecruitmentStack): 1회 x 21 총 1 회
+N (Stack): 총 1회
+N (Position): 총 1회
+N (Category): 총 1회
+N (User): 총 1회
Conclusion
JPA 1 + N 문제 정리
1.일대일 양방향 연관관계에서 외래키가 없는 쪽을 조회할 때, JPA 1 + N 문제가 발생합니다.
발생 이유: 객체지향 메커니즘과 데이터베이스 메커니즘의 차이로 인함입니다.
해결 방법:
1) 구조적 변경을 통한 해결
외래키 위치 변경
, 일대다 다대일 연관 관계 변경
을 고려해볼 수 있습니다. 하지만 변경에 대한 코스트를 고려해야 합니다.
2) 성능 최적화를 통한 해결
fetch join
을 통해 성능을 개선할 수 있습니다.
2.일대다 연관관계에서 일측을 조회할 때, JPA 1 + N 문제가 발생합니다.
발생 이유: 일측에서 다측을 컬렉션
으로 갖고있고, 이를 Lazy Loading
하는 과정에서 발생합니다.
해결 방법:
1) 성능 최적화를 통한 해결
페이지네이션 쿼리의 경우 batch size
설정을 통해 개선할 수 있습니다.
무엇이 개선되었는가?
1.SQL 트래픽을 93회 → 7회로 약 92% 개선 (API 요청, SQL 요청을 1 req로 가정함)