서비스 로직 최적화 (N+1 문제 해결)

Hoony·2023년 8월 13일
1

새롭게 알게된 점

목록 보기
2/3
post-thumbnail

잔여액 체크 로직 최적화 (50초 → 2.64초)

고객사가 서비스 대금을 초과해서 결제를 하면, 잔여액으로 남은 금액이 넘어가게 된다.

이전까지는 (총 입금 금액) - (총 사용 금액) 방식으로 잔여액을 구해서 매 결제마다 적용하고 있었다.

그러나 해당 방식은 매번 모든 입금 내역과 서비스 로그를 조회해서 필터링하고 계산하는 과정을 거쳐야하기 때문에 비효율적이었다. (아직 서비스의 규모가 작아서 해당 방식이 문제가 없었다. 하지만 서비스 규모가 커진다면 해당 방식은 문제가 생긴다.)

그래서 잔여액 필드를 추가해서 이를 따로 관리하기로 했다.

하지만 해당 방식을 적용하면 모든 결제에 잔여액을 갱신하는 로직을 추가해야 한다.

서비스 최종 대금은 여러 변수(할인율, 부담금, 지원금 etc…)들이 있긴때문에 새로 개발한 잔여액 갱신 로직의 안정성에 문제가 있다.

그래서 한동안 안정화를 위해 잔여액 필드가 정확한지 체크하는 배치 작업을 생성해서 특정 시간마다 실행시키려고 했다.
체크 로직 은 이전에 이미 사용하던 로직을 사용했다. -> 신뢰 가능



💣 문제 발생

그러나 여기서 문제가 생겼다. 모든 고객사마다 2개의 조회 SQL 쿼리문이 나가서 해당 작업 완료까지 너무나 많은 시간이 걸렸다. 즉, N+1 문제로 인해 서버에서 Database로 나가는 SQL 쿼리문이 산술급수적으로 증가했다.


다음과 같은 순서로 로직이 진행이 된다.

  1. 모든 고객사 정보 조회
  2. 고객사의 서비스 로그 & 입금 내역 조회
  3. 이후 조회한 내용을 토대로 (총 입금 금액) - (총 사용 금액) 로직을 사용하여 잔여액 산출
  4. 해당 고객사의 잔여액 필드와 기존 로직을 통해 나온 잔여액 결과 비교
  5. 2,3,4 반복 - 모든 고객사 순회
  6. 만약 다른 경우가 있을시, Slack으로 개발팀에게 알림 전송 (배치 작업 - 특정 시간마다 매번 전송)

실제 코드 (자세한 부분은 보안 문제로 가렸습니다.)

async checkCompanyBalance() {
		const companyInfos = await this.companyInfoRepository.getAllCompanyInfo();

		const resultMsgs: string[] = [];

		for (const companyInfo of companyInfos) {
			// 고객사 사용 내역(서비스 로그) 전체 조회 로직 (SQL 쿼리문 나감)
			...

			// 고객사 입금 내역 전체 조회 로직 (SQL 쿼리문 나감)
			...

			// 고객사 총 사용 금액 계산 로직
			...

			// 고객사 총 입금 금액 계산 로직
			...

			// 고객사 잔액 계산
			const balance = totalReceivedPrice - totalUsedPrice;

			// 잔액이 다르면 메시지 추가
			if (companyInfo.balance != balance) {
				resultMsgs.push(
					`${companyInfo.companyName} : DB 잔액 ${companyInfo.balance}원 | 실제 잔액 ${balance} 불일치 (총 사용금액: ${totalUsedPrice}원, 총 결제금액: ${totalReceivedPrice}원)`,
				);
			}
		}

		await this.slackUtils.sendSlackMessage(resultMsgs.join("\n"), "retention", "잔액 오류 확인");
	}

위와 같이 코드를 작성해서 돌려보았다.

그러나 해당 코드는 N+1 문제로 인해 작업 완료까지 약 50초의 시간이 걸렸다.

물론 해당 방법을 사용하려면 할 수 있었지만 추후에 서비스 규모가 커지면 해당 문제는 더욱 더 심해진다.

즉, 서비스 확장성을 저하시키는 문제로 발전할 수 있기때문에 해당 부분은 잡고 가기로 했다.



⭐ 문제 해결 ⭐

기존 로직을 살펴보면서 비효율적인 부분을 찾아서, 수정하여 N+1 문제를 해결하기로 했다.

살펴보니, 입금 내역 테이블 & 서비스 로그 테이블 을 모든 고객사마다 SQL 쿼리문을 통해 조회하는 것을 확인했다. (N+1 문제)

해당 부분을 수정하여 처음에 입금 내역 테이블 & 서비스 로그 테이블 에서 모든 정보를 가져와서 메모리에 올린 후 처리하기로 했다.

해당 방법대로 하면, 고객사 수 * 2 만큼 나가던 SQL 쿼리문을 한번으로 줄여 더 빠른 실행이 가능하다.

고객사 수를 N이라고 가정하자.

그러면 기존에 나가던 쿼리문 수는 1(전체 고객사 조회) + N(고객사 수) * 2(입금 내역 & 서비스 로그 조회) = 1 + 2N 이다.

하지만 바꾼 방법으로 하면 나가는 쿼리문 수는 1(전체 고객사 조회) + 1(전체 입금 내역 조회) + 1(서비스 로그 조회) = 3 으로 비약적으로 줄게 된다.

바뀐 로직은 같은 순서로 진행이 된다.

  1. 모든 고객사 정보 조회 & 모든 입금 내역 조회 & 모든 서비스 로그 조회 (기존에 1+2N 개의 쿼리문이 나가던 부분이 3개 쿼리문으로 줆)
  2. 이후 해당 고객사별로 입금 내역 & 서비스 로그 분류 진행 (계산 복잡도 최소화)
  3. 분류된 데이터 토대로 해당 고객사의 (총 입금 금액) - (총 사용 금액) 로직을 사용하여 잔여액 산출
  4. 해당 고객사의 잔여액 필드와 기존 로직을 통해 나온 잔여액 결과 비교
  5. 3,4 반복 - 모든 고객사 순회
  6. 만약 다른 경우가 있을시, Slack으로 개발팀에게 알림 전송 (배치 작업 - 특정 시간마다 매번 전송)

실제 코드 (자세한 부분은 보안 문제로 가렸습니다.)

async checkCompanyBalance() {
		// 모든 companyInfo 가져오기
		...

		// 모든 로그와 트랜잭션 정보를 한 번에 가져오기
		...

		// companyInfoUuid를 기준으로 로그 데이터 분류
		const logsByCompany = allLogs.reduce((acc, log) => {
			...
			return acc;
		}, {});

		// companyInfoUuid를 기준으로 거래 데이터 분류
		const transactionsByCompany = allTransactions.reduce((acc, transaction) => {
			...
			return acc;
		}, {});

		const resultMsgs: string[] = [];
		for (const companyInfo of companyInfos) {
			// 고객사 사용 내역(서비스 로그) 전체 조회 로직
			...

			// 고객사 입금 내역 전체 조회 로직
			...

			// 고객사 총 사용 금액 계산 로직
			...

			// 고객사 총 입금 금액 계산 로직
			...

			// 고객사 잔액 계산
			const balance = totalReceivedPrice - totalUsedPrice;

			// 잔액이 다르면 메시지 추가
			if (companyInfo.balance != balance) {
				resultMsgs.push(
					`${companyInfo.companyName} : DB 잔액 ${companyInfo.balance}원 | 실제 잔액 ${balance} 불일치 (총 사용금액: ${totalUsedPrice}원, 총 결제금액: ${totalReceivedPrice}원)`,
				);
			}
		}

		await this.slackUtils.sendSlackMessage(resultMsgs.join("\n"), "retention", "잔액 오류 확인");
	}

위와 같이 코드를 작성해서 돌려보았다.

그랬더니 기존에 약 50초 걸리던 로직이 2.64초로 크게 줄어든 것을 확인할 수 있다.

DB로 나가는 쿼리문의 수를 비약적으로 줄여서 가능한 결과다.

대부분의 응답 시간을 차지하는 부분은 서버와 DB 통신하는 시간이다.



⚠️ 문제점

위의 방법은 사실 정확한 해결책이 아니다. 해당 방법도 서비스 규모가 커지면서 문제가 발생할 수 있다.

어떻게 보면, DB 쿼리문으로 처리하던 부분을 메모리에 모든 정보를 올려서 처리하는 방식으로 해결했다고 볼 수 있다. 하지만 이와 같은 방식은 서버 리소스를 많이 잡아먹어서, 결국 서버의 메모리가 터지는 결과를 초래할 수 있다.

하지만 이는 해당 로직을 조금만 수정하면 해결할 수 있다. (확장성 좋음)

  1. 필요한 정보만 조회

    사실 고객사/입금내역/서비스로그 테이블의 모든 정보를 메모리에 올릴 필요가 없다.

    잔여액 체크에 필요한 정보만 추출해서 메모리에 올리면 사용되는 리소스를 줄일 수 있다.

  2. 특정 기간 내의 정보만 조회

    잔여액 체크는 배치 작업으로 특정 기간을 텀으로 두고 일어난다.

    이는 모든 기간의 정보를 조회할 필요없이 이전에 체크했던 날짜 이후의 정보만 조회해서 처리하면 된다는 것이다.

    이런 식으로 이전 체크 일자 이후의 정보만 조회해서 잔여액을 체크하면 사용되는 리소스를 크게 줄일 수 있다.



⛳ 결론

백엔드에서 N+1 문제는 서비스 장애를 이어질 수 있는 큰 문제이다.

물론 이번은 운좋게 실제 서비스 배포하기 전에 해당 부분을 체크해서 해결할 수 있었다.

서버 응답시간는 서비스에 핵심적인 부분이므로 매번 인지하면서 개발을 해야한다.

즉, 응답시간의 대부분을 차지하는 서버-DB 통신시간을 줄이는 습관을 들여야 된다. (특히 N+1 문제!!!)

profile
Just Do it!

0개의 댓글