서비스 운영 중 데이터베이스 서버 장애 대응

스터디 매칭 플랫폼 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 문제가 발생합니다. 그 이유는 객체지향 메커니즘과 관계형 데이터베이스 메커니즘 차이 때문입니다. 일대일 양방향 연관 관계가 걸려있는 StudyRecruitment 를 코드와 DB 테이블로 나타내면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Study {

	@Id
	@GeneratedValue
	@Column(name = "study_id")
	private Long id;

	@OneToOne(mappedBy = "study", fetch = FetchType.LAZY)
	private Recruitment recruitment;
}

@Entity
public class Recruitment {

	@Id
	@GeneratedValue
	@Column(name = "recruitment_id")
	private Long id;

	@OneToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "id")
	private Study study;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Recruitment {

	@OneToMany(fetch = LAZY, mappedBy = "recruitment")
	private List<RecruitmentStack> recruitmentStacks = new ArrayList<>();

}

@Entity
public class RecruitmentStack {

	@ManyToOne(fetch = LAZY)  
	@MapsId("recruitmentId")  
	@JoinColumn(name = "recruitment_id")  
	private Recruitment recruitment;

}


Problem Solving

기존 모집 공고 목록 조회 API 성능 테스트

성능 최적화를 진행하기 전에, before & after 에 대한 유의미한 수치를 측정하기 위해 성능 테스트를 진행했습니다. 또한 대규모 데이터가 적재되어 있을 때 예상하지 못했던 고객 Pain Point를 함께 해결해보고 싶었기 때문에, 480만개 테스트 데이터를 마련했음. 대규모 테스트 데이터 생성 방법은 별도의 포스팅으로 다룰 예정입니다.

테스트 환경

테스트 데이터: 480만개

  • 스터디 (Study) 테이블: 60만개
  • 모집공고 (Recruitment) 테이블: 60만개
  • 모집포지션 (RecruitmentPosition) 테이블: 180만개
  • 모집기술스택 (RecruitmentStack) 테이블: 180만개

테스팅 툴: Jmeter


모집 공고 목록 조회 (필터 검색 적용) 테스트 결과

평균 4.3초 (표본: 100회)

조회 쿼리 93회 발생


Approach

구조적 변경을 통한 해결 방법

일대일 양방향 연관 관계의 1 + N 문제를 해결하기 위해 다양한 접근 방법을 고려했습니다. 첫 번째는 구조적 변경을 통한 해결 방법입니다.

1)FK 위치 변경
일대일 양방향 연관 관계 에서 외래키가 없는 쪽을 조회할 때 1 + N 문제가 발생한다고 했습니다. 그렇다면 외래키의 위치를 변경하는 방법을 고려해볼 수 있겠습니다. 하지만 현재 상용 중인 서비스에서 물리적 데이터 구조를 변경하기 까다롭다 판단 되었습니다.

2)일대다, 다대일 연관 관계로 변경
이 방법은 Ludo 서비스의 비즈니스 요구사항을 변경해야 했습니다. Ludo 서비스는 한 개의 스터디에서 한 개의 모집 공고를 작성할 수 있는데, 연관 관계를 변경하면 한 개의 스터디에서 여러 개의 모집 공고를 작성할 수 있도록 변경해야 하니다. 이에 따라 기획 / 디자인 / 프론트 등 변경 사항에 대한 코스트가 높다 판단 되었습니다.

성능 최적화를 통한 해결 방법

이러한 이유들로 성능 최적화를 통한 해결 방법을 선택했습니다. 자세한 내용은 아래에서 다루겠습니다.


성능 최적화: 일대일 양방향 연관 관계

모집공고 조회 비즈니스 요구사항

이해를 돕기 위해 모집공고 조회 비즈니스 요구사항과 QueryDSL 로직을 간단히 살펴보겠습니다.

첫째, 모집공고 조회는 다양한 검색 필터 조건이 존재하고, 이에 따라 동적 쿼리를 구현해야 합니다. 검색 필터 조건에 따라, 모집공고 조회는 최대 7개의 도메인 엔티티와 엮이게 됨. (위의 그림 참고)

둘째, 모집공고 조회는 21건씩 무한 스크롤로 제공해야 하고, 이를 위해 cursor-based pagenation을 구현해야 합니다.


모집공고 조회 QueryDSL

cursor based pagination, 동적 쿼리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Repository  
@RequiredArgsConstructor  
public class RecruitmentRepositoryImpl {

	private final JPAQueryFactory q;

	public List<Recruitment> findRecruitments(final RecruitmentFindCursor recruitmentFindCursor,  
	                                 final RecruitmentFindCond recruitmentFindCond) {  
	  
	    JPAQuery<Recruitment> recruitmentTable = q.select(recruitment)  
	          .from(recruitment);  

		// 동적 쿼리 1
		// 검색 필터 조건에 스터디 카테고리, 스터디 진행방식이 포함된 경우 → 스터디 테이블과 조인
	    if (isSatisfyJoinToStudy(recruitmentFindCond)) {  
	       recruitmentTable.innerJoin(recruitment.study, study);  
	    }  

		// 동적 쿼리 2
		// 검색 필터 조건에 모집공고 포지션이 포함된 경우 → 모집공고 포지션 테이블과 조인
	    if (isSatisfyJoinToRecruitmentPosition(recruitmentFindCond)) {  
	       recruitmentTable.innerJoin(recruitment.recruitmentPositions, recruitmentPosition);  
	    }  

		// 동적 쿼리 3
		// 검색 필터 조건에 모집공고 기술스택이 포함된 경우 → 모집공고 기술스택 테이블과 조인
	    if (isSatisfyJoinToRecruitmentStack(recruitmentFindCond)) {  
	       recruitmentTable.innerJoin(recruitment.recruitmentStacks, recruitmentStack);  
	    }  
	  
	    return recruitmentTable  
	          .where(  
	                eqCategory(recruitmentFindCond.categoryId()),  
	                eqWay(recruitmentFindCond.way()),  
	                eqPosition(recruitmentFindCond.positionId()),  
	                eqStack(recruitmentFindCond.stackIds()))  
	          .where(lessThan(recruitmentFindCursor.last()))  
	          .orderBy(recruitment.modifiedDateTime.desc())  // cursor-based pagination
	          .limit(recruitmentFindCursor.count())  
	          .fetch();  
	  
	}

}


Fetch Join을 통한 성능 최적화

Recruitment - Study 일대일 양방향 연관 관계 최적화를 위해 fetch Join을 적용했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Repository  
@RequiredArgsConstructor  
public class RecruitmentRepositoryImpl {

	// 생략

	public List<Recruitment> findRecruitments(final RecruitmentFindCursor recruitmentFindCursor,  
	                                 final RecruitmentFindCond recruitmentFindCond) {  
	  
	    JPAQuery<Recruitment> recruitmentTable = q.select(recruitment)  
	          .from(recruitment);  
	          
	    if (isSatisfyJoinToStudy(recruitmentFindCond)) {  
	       recruitmentTable.innerJoin(recruitment.study, study).fetchJoin;  
	    }
		// 생략
	}
}


하지만 아직 1 + N 문제가 남아있습니다. isSatisfyJoinToStudy 메서드는 검색 필터 조건의 카테고리, 진행방식 포함 여부에 따라, Recruitment 테이블과 Study 테이블 Join 여부를 판별하는 조건식입니다.

1
2
3
4
private boolean isSatisfyJoinToStudy(final RecruitmentFindCond recruitmentFindCond) {  
    return isCategoryCondExist(recruitmentFindCond.categoryId())  
          || isWayCondExist(recruitmentFindCond.way());  
}

필터 검색 조건에 카테고리(category) 혹은 진행방법(way) 이 없을 경우 fetchJoin이 안 걸리게 되면서 또 다시 1 + N 문제가 발생함.


곰곰히 생각해보니 모집 공고 목록 조회 시, 스터디 테이블을 반드시 조회한다는 것을 알 수 있었습니다.


위의 두 가지 이유로 모집공고 엔티티와 스터디 엔티티는 검색 필터 조건에 상관 없이 항상 fetch join 하도록 변경했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Repository  
@RequiredArgsConstructor  
public class RecruitmentRepositoryImpl {

	// 생략

	public List<Recruitment> findRecruitments(final RecruitmentFindCursor recruitmentFindCursor,  
	                                 final RecruitmentFindCond recruitmentFindCond) {  
	  
	    JPAQuery<Recruitment> recruitmentTable = q.select(recruitment)  
	          .from(recruitment);

		recruitmentTabe.innerJoin(recruitment.study, study).fetchJoin;
	          
		// 생략
	}
}


필터 검색 조건에 카테고리(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public List<Recruitment> findRecruitments(final RecruitmentFindCursor recruitmentFindCursor,  
                                 final RecruitmentFindCond recruitmentFindCond) {  
  
	// 생략
	
    if (isSatisfyJoinToRecruitmentPosition(recruitmentFindCond)) {  
       recruitmentTable.innerJoin(recruitment.recruitmentPositions, recruitmentPosition).fetchJoin();  // fetch join
    }  
  
    if (isSatisfyJoinToRecruitmentStack(recruitmentFindCond)) {  
       recruitmentTable.innerJoin(recruitment.recruitmentStacks, recruitmentStack).fetchJoin();  // fetch join
    }  
  
    return recruitmentTable  
          .where(  
                eqCategory(recruitmentFindCond.categoryId()),  
                eqWay(recruitmentFindCond.way()),  
                eqPosition(recruitmentFindCond.positionId()),  
                eqStack(recruitmentFindCond.stackIds()))  
          .where(lessThan(recruitmentFindCursor.last()))  
          .orderBy(recruitment.modifiedDateTime.desc())  
          .limit(recruitmentFindCursor.count())  
          .fetch();  
}


먼저 한개의 일대다 연관 관계를 fetch join 했을 때의 문제점을 살펴보고자 합니다. 아래와 같이 요청이 들어오면, 조건식에 의해 Recruitment - RecruitmentPosition 한 가지 경우에 대해서만 fetch join이 걸립니다.


정상 동작하지만 시간이 7초로 매우 오래 걸리고, 아래와 같은 경고 메시지를 확인할 수 있습니다.

1
2
org.hibernate.orm.query : HHH90003004:
firstResult/maxResults specified with collection fetch; applying in memory


SQL 로그를 보면 limit 구문이 존재하지 않는 것을 확인할 수 있습니다. 즉, JPA는 컬렉션에 대해 fetch join하는 경우 페이지네이션 처리를 애플리케이션 레벨에서 처리하는 것을 알 수 있습니다.

1
2
3
4
5
6
7
8
9
10
select *
from recruitment r
join recruitment_stack_lnk r_s
    on r.recruitment_id=r_s.recruitment_id 
    
where r_s.stack_id=?
	and r.recruitment_id<? 
    
order by r1_0.modified_date_time desc;
-- (해당 내용과 관계 없는 SQL문은 이해를 돕기 위해 생략)


일대다 연관관계에서 일측을 조회하면 데이터 뻥튀기가 발생할 수도 있는 문제

일대다 연관관계에서 일측을 조회하면 데이터 뻥튀기가 발생할 “수도” 있습니다. 페이지네이션 쿼리를 짤 때, 데이터 뻥튀기가 발생하지 않도록 하는 것이 정말 중요한데, 이번 예시에서는 데이터 뻥튀기가 발생하는 경우에 대해 살펴보고자 합니다.

아래와 같은 테이블이 있다고 했을 때, 위의 쿼리에 대한 Result Sets는 다음과 같습니다.


위에서 살펴본 SQL 로그에서 limit 구문이 없다고 언급했었습니다. 다시말해, Result Sets 레코드들을 애플리케이션 레벨로 퍼올리고 페이지네이션 처리를 수행하는 것입니다.

이 과정에서 JPA가 엔티티에 대한 식별자 정보를 바탕으로 뻥튀기된 중복 데이터를 제거하고, 페이지네이션 처리를 하는 것으로 생각 됩니다. 정리하면, 페이지네이션 처리는 가능하나, 뻥튀기 된 데이터를 애플리케이션 레벨로 퍼올리는 과정에서 메모리 이슈를 경고하는 것입니다.


[Pagination 쿼리] 두개 이상의 일대다 연관 관계를 fetch join 시 문제점

두개 이상의 일대다 연관 관계를 fetch join 했을 때의 문제점을 살펴보고자 합니다. 아래와 같이 요청이 들어오면, 모두 조건식에 걸려서 Recruitment - RecruitmentPosition, Recruitment - RecruitmentStack 두 가지 경우 모두 fetch join이 걸립니다. 그리고 500 서버 에러와 함께 예외 메시지를 확인할 수 있습니다.

1
InvalidDataAccessApiUsageException, Message: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [Recruitment.recruitmentPositions, Recruitment.recruitmentStacks]

원인은 한 개 이상의 일대다 연관 관계 fetch join에서 살펴본 것과 일맥 상통합니다. 차이점은 데이터 뻥튀기가 너무 많이 되니까 JPA가 페이지네이션 처리 자체를 할 수 없게 되는 것입니다.


Batch Size 설정을 통한 성능 최적화

정리하면 일대다 연관 관계에서 fetch join은 적용이 힘듬. 다른 방법으로 1 + N 문제를 개선해야 합니다. fetch join을 적용하지 않았을 때 SQL 쿼리는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-- 모집공고 조회
select *
from recruitment r
join recruitment_stack_lnk r_s
    on r.recruitment_id=r_s.recruitment_id 
where r_s.stack_id=?
	and r.recruitment_id<? 
order by r1_0.modified_date_time desc;

-- #1 모집 공고 포지션 조회 (Lazy Loading)
select *
from recruitment_position_lnk rp1_0 
where rp1_0.recruitment_id=?

-- 포지션 조회 (Lazy Loading)
-- 포지션은 총(백엔드, 프론트엔드, 디자이너) 3개라서 총 3번만 조회
select *
from position p1_0 
where p1_0.position_id=?

select *
from position p1_0 
where p1_0.position_id=?

select *
from position p1_0 
where p1_0.position_id=?


-- #2 모집 공고 포지션 조회
select *
from recruitment_position_lnk rp1_0 
where rp1_0.recruitment_id=?

-- #3 모집 공고 포지션 조회
select *
from recruitment_position_lnk rp1_0 
where rp1_0.recruitment_id=?

-- ... #4 ~ #21 동일


이걸 그림으로 나타내면 아래와 같습니다.


그럼 여기서 RecruitmentPosition1 ~ RecruitmentPositionZ 를 갖고오기 위해, In 절을 사용해볼 수 있지 않을까? 생각이 들 것입니다. 그럼 기존의 21회의 쿼리가 1회로 줄어들 것입니다.

1
2
3
select *
from recruitment_position_lnk r_p
where r_p.recruitment_id = (1, 2, 3, 4, 5, .. 21);


이 개념을 도입한게 Batch Size 입니다. Batch Size 를 적용하면 아래와 같은 쿼리를 확인할 수 있습니다.

1
2
3
select *
from recruitment_position_lnk rp1_0 
where rp1_0.recruitment_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ...)


이걸 그림으로 표현하면 아래와 같습니다. 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로 가정함)


2.대규모 데이터가 적재되어 있을 때, API 시간 성능을 4.3초 → 0.9초로 약 79% 개선