회사에서 개발했던 어드민 사이트의 코드를 리팩토링했다..아니 해야만 했다..
이유는…정말 너무너무 느려서,,,,^^,,,
회원들의 출석내역을 월별로 다운로드하는 기능이 있는데 데이터가 늘어나면 늘어날수록..정말 엄청난 시간이 걸린다..
수정 전
기존 코드에서 대량의 출석 데이터를 처리할 때 성능 문제가 발생했다.
각 회원의 출석 횟수를 개별적으로 조회하여 병렬로 처리하더라도 데이터베이스 호출 횟수가 많아져 전체 성능이 저하되었다.
데이터 약 7만 3천 개 기준 2시간 이상,,,이면 말 다했다.
(아래의 모든 코드는 실제 사용한 코드를 수정한 예제 코드입니다.)
@Transactional
public List<AttendanceExportDto> exportMonthlyAttendanceList(int year, int month){
// 엑셀 다운로드에 필요한 데이터 조회
List<AttendanceExportDto> data = attendanceRepository.findExportList(year, month);
// 해당 연도/월 출석 정보 조회
AttendanceInfo attendanceInfo = attendanceRepository.findAttendanceInfo(year, month);
long infoIdx = attendanceInfo.getId();
int dailyReward = attendanceInfo.getDailyReward();
int continuationReward = attendanceInfo.getContinuationReward();
int monthlyReward = attendanceInfo.getMonthlyReward();
// 출석 참여 횟수, 연속 출석 횟수, 해당 월 지급 포인트 업데이트
List<AttendanceExportDto> updatedData = data.parallelStream().map(d -> {
AttendanceCountDto attendanceCountDto = attendanceRepository.countAttendanceByMember(d.getMemberId(), infoIdx);
long dailyCount = attendanceCountDto.getDailyCount();
long continuationCount = attendanceCountDto.getContinuationCount();
long monthlyCount = attendanceCountDto.getMonthlyCount();
long totalPoint = (dailyCount * dailyReward)
+ (continuationCount * continuationReward)
+ (monthlyCount * monthlyReward);
d.setAdditionalData(dailyCount, continuationCount, totalPoint);
return d;
}).collect(Collectors.toList());
return updatedData;
}
public AttendanceCountDto countAttendanceByMember(String memberId, long idx){
TypedQuery<AttendanceCountDto> query;
query = em.createQuery("select new com.test.~~~.AttendanceCountDto" +
"((select ~~~" + // 일일 출석 횟수
" ~~~)," +
" (select ~~~" + // 연속 출석 횟수
" ~~~)," +
" (select ~~~" + // 월 개근 횟수
" ~~~)", AttendanceCountDto.class);
return query
.setParameter("memberId", memberId)
.setParameter("idx", idx)
.getSingleResult();
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class AttendanceCountDto {
private long dailyCount;
private long continuationCount;
private long monthlyCount;
}
첫번째 코드를 보면 각 회원에 대해 개별적으로 출석 횟수를 조회해 데이터베이스 호출 횟수가 많아진다.
이로 인해 병렬 처리를 사용하지만, 데이터베이스 통신이 병목 현상을 일으켜 전체 성능이 저하되는 것으로 보인다.
실제로 코드 실행 시간을 분석해보아도 이 병렬 처리 부분에서 DB와 통신하는 부분에서 가장 오랜 시간이 걸린다.
해결 과정
이렇게 시간이 많이 소요되는 경우, 성능을 최적화하기 위해 다음 방법을 고려할 수 있다.
(1) DB 호출 줄이기(Reduce Database Calls)
가능한 데이터베이스 호출 횟수를 줄이는 것이 좋다.
다양한 방법을 통해 DB 호출을 줄일 수 있는데 대표적으로 3가지 방법이 있다.
1) 배치 처리/쿼리(Batch Processing/Query)
데이터베이스에서 데이터를 한 번에 조회하거나 업데이트하는 방식을 의미한다.
여러 개의 데이터베이스 요청을 하나의 큰 요청으로 묶어 한 번에 처리함으로써 성능을 최적화한다.
2) 캐싱
자주 사용하는 데이터를 메모리나 캐시 시스템에 저장하여 데이터베이스 호출을 줄이는 것이다.
3) 지연 로딩 (Lazy Loading) 대신 즉시 로딩 (Eager Loading)
필요할 때마다 데이터를 불러오는 지연 로딩 대신, 한 번에 필요한 모든 데이터를 불러오는 즉시 로딩을 사용하는 것이다.
(2) 쿼리 최적화(Optimize Queries)
데이터베이스 쿼리를 최적화 해 필요한 데이터를 더 빠르게 가져오도록 한다.
1) 적절한 JOIN 사용
INNER JOIN, LEFT JOIN, RIGHT JOIN 등의 조인 방식을 상황에 맞게 사용하여 데이터를 효율적으로 조회한다.
JOIN을 통해 서브쿼리를 대체할 수 있는 경우, 조인이 성능상 더 유리할 수 있다.
2) 서브쿼리 최소화
서브쿼리를 반드시 사용해야 하는 경우를 제외하고는, JOIN을 사용하여 쿼리를 단순화하고 성능을 향상시키는 것이 좋다.
3) 인덱스 사용
필요한 열에 인덱스를 추가해 조회 속도를 향상시킬 수 있다.
적절한 인덱스를 사용하면 검색, 조인, 정렬 성능이 올라간다.
(3) 병렬 처리(Concurrency)
여러 데이터베이스 작업을 동시에 실행하여 전체 처리 시간을 줄이는 것을 말한다.
병렬 처리의 핵심 요소 중 하나는 데이터베이스 연결 풀이다.
데이터베이스 연결 풀(Connection Pool)에 대해 간단히 설명하자면,
연결 풀은 애플리케이션과 데이터베이스 간의 연결을 관리하는 메커니즘이다.
연결 풀이 없으면 애플리케이션은 각 쿼리를 실행할 때마다 새로운 DB 연결을 생성하고 종료해야 하는데, 이것은 성능 저하와 리소스 낭비를 초래할 수 있다.
그래서 연결 풀은 일정 수의 DB 연결을 미리 생성해두고, 필요할 때 재사용 해 이러한 문제를 해결한다.
이 연결 풀의 크기를 조정하면 애플리케이션이 동시에 처리할 수 있는 병렬 쿼리의 수를 조절할 수 있다.
이렇게 하면 병렬 처리를 최적화하고, DB 리소스를 효율적으로 사용할 수 있다.
이미 3번째 방법인 병렬 처리는 일단 하고 있고..
일단 시도해볼 수 있는 게 배치 쿼리를 통한 DB 호출 줄이기, 쿼리 최적화다!
수정 후
- 현재 코드에서는 각 memberId에 대해 별도의 데이터베이스 호출을 하고 있어 이 부분을 배치로 처리하도록 변경할 수 있다.
배치 쿼리를 이용해 여러 회원의 출석 데이터를 한 번에 조회함으로써 DB 호출 횟수를 줄인 것이다!
- 서브쿼리를 사용하던 구간을 JOIN을 사용하는 것으로 변경해 쿼리 최적화를 한다.
- 그 다음은 원래의 코드대로 병렬 처리를 유지해 전체 처리 성능을 향상시켰다.
다음은 위의 방법을 모두 적용한 수정 후 코드다.
(아래의 모든 코드는 실제 사용한 코드를 수정한 예제 코드입니다.)
@Transactional
public List<AttendanceExportDto> exportMonthlyAttendanceList(int year, int month){
// 엑셀 다운로드에 필요한 데이터 조회
List<AttendanceExportDto> data = attendanceRepository.findExportList(year, month);
// 해당 연도/월 출석 정보 조회
AttendanceInfo attendanceInfo = attendanceRepository.findAttendanceInfo(year, month);
long infoIdx = attendanceInfo.getId();
int dailyReward = attendanceInfo.getDailyReward();
int continuationReward = attendanceInfo.getContinuationReward();
int monthlyReward = attendanceInfo.getMonthlyReward();
// Step 1: 모든 회원들의 memberId 가져오기
List<String> memberIds = data.stream()
.map(AttendanceExportDto::getMemberId)
.collect(Collectors.toList());
// Step 2: 배치 쿼리로 모든 회원에 대한 출석 횟수 가져오기(배치 쿼리로 DB 통신 횟수 줄이기)
Map<String, AttendanceCountDto> attendanceCountMap = attendanceRepository.countAttendanceByMembers(memberIds, infoIdx)
// Step 3: 데이터 병렬 처리
List<AttendanceExportDto> updatedData = data.parallelStream().map(d -> {
AttendanceCountDto attendanceCountDto = attendanceCountMap.get(d.getMemberId());
long dailyCount = attendanceCountDto.getDailyCount();
long continuationCount = attendanceCountDto.getContinuationCount();
long monthlyCount = attendanceCountDto.getMonthlyCount();
long totalPoint = (dailyCount * dailyReward)
+ (continuationCount * continuationReward)
+ (monthlyCount * monthlyReward);
d.setAdditionalData(dailyCount, continuationCount, totalPoint);
return d;
}).collect(Collectors.toList());
return updatedData;
}
public Map<String, AttendanceCountDto> countAttendanceByMembers(List<String> memberIds, long infoIdx){
TypedQuery<AttendanceCountDto> query;
query = em.createQuery("SELECT new com.test.~~~.AttendanceCountDto(" +
"memberId," + // 추가된 필드 : memberId
" ~~~," + // 일일 출석 횟수
" ~~~," + // 연속 출석 횟수, 지급 회수된 건 제외
" ~~~)" + // 월 개근 횟수
" FROM ~~" +
" LEFT JOIN ~~~" +
" ON ~~~" +
" AND ~~~" +
" AND ~~~" +
" WHERE memberId IN :memberIds" +
" ~~~" +
" ~~~" +
" GROUP BY memberId", AttendanceCountDto.class);
List<AttendanceCountDto> resultList = query
.setParameter("memberIds", memberIds)
.setParameter("infoIdx", infoIdx)
.getResultList();
return resultList.stream()
.collect(Collectors.toMap(AttendanceCountDto::getMemberId, dto -> dto));
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class AttendanceCountDto {
private String memberId; // 추가한 필드
private long dailyCount;
private long continuationCount;
private long monthlyCount;
}
이렇게 수정하고 테스트를 해보니 데이터 약 7만 3천 개 기준으로
수정 전 : 2시간 이상 → 수정 후 : 약 30초 정도 걸린다!
대략 240배 이상 빨라진 것..!
이제 여기서 추가적으로 더 개선할 수 있도록 방법을 또 생각해봐야겠다!