https://github.com/livable-final/server/pull/159
프로젝트를 진행하며 특정 목표를 달성하면 포인트를 지급하는 기능을 구현했다.
해당 기능은 총 4번의 유효성 검사를 필요로 하는데 각 검사는 아래와 같다.
- 토큰으로 추출된 유저 식별자가 유효한가
- 금일 이미 목표 달성 포인트를 받았는가
- 포인트를 받을 수 있는 목표를 달성했는가
- 포인트를 받을 수 있는 당일에 요청을 했는가
기존의 코드는 유효성검사를 진행하기 위해서만 총 4번의 DB I/O Cost가 발생하기 때문에 이를 최적화 하려 한다.
1번과 2,3,4번을 묶어 두개의 쿼리로 작성하고, 2번의 I/O Cost로 유효성 검사 로직을 처리할 수 있다.
현재 년-월 범위의 본인이 얻은 리뷰 포인트를 리스트로 가져온다.
리스트에서 금일 포인트 로그에서 목표달성 포인트 코드가 포함되어 있는 로그가 있는지 확인한다.
리스트에서 리뷰 포인트 로그만 추출하여 포인트를 받을 수 있는 목표를 달성했는지 확인한다.
리스트에서 리뷰 포인트의 마지막 로그만 추출하여 요청 날짜와 비교하여 포인트를 받을 수 있는 당일인지 확인한다.
코드 개선
우선 기존의 코드이다.
@Transactional
public void getAchievementPoint(Long memberId, LocalDateTime requestDateTime) {
System.out.println("requestDateTime:" + requestDateTime);
// 회원 정보가 유효한지 검증 IO발생
Point point = pointRepository.findByMemberId(memberId).orElseThrow(() ->
new GlobalRuntimeException(PointErrorCode.POINT_NOT_EXIST));
// 금일 이미 목표 달성 포인트를 지급받았는지 확인 IO발생
LocalDate requestDate = dateFactory.getPureDate(requestDateTime);
List<PointLog> logsByDate = pointLogRepository.findLogsByDate(requestDate);
// 검증로직
// 현재의 년-월 범위에 해당하는 리뷰를 조회 IO발생
DateRange requestedDateOfMonthRange = dateFactory.getMonthRangeOf(requestDateTime);
List<PointProjection.ReviewAndDateDTO> reviewAndDates = pointRepository.findReviewAndDateById(
point.getId(),
requestedDateOfMonthRange.getStartDate(),
requestedDateOfMonthRange.getEndDate(),
PointCode.getReviewPointCodes()
);
//...
// 리뷰 개수가 목표 달성으로 치환되는지 확인
try {
pointAchievement = PointAchievement.valueOf(count);
} catch (InputMismatchException exception) {
throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_NOT_MATCHED);
}
// 목표 포인트 지급 요청 날짜가 지급받을 수 있는 날짜인지 확인 IO발생
if (lastCreatedDate.getDayOfMonth() != requestDateTime.getDayOfMonth()) {
throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_PAID_FAILED);
}
// 포인트 지급
this.paidPoints(point, pointAchievement, review);
}
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE)
public void paidPoints(Point point, PointAchievement pointAchievement, Review review) {
// 포인트 지급 로직
}
검증이 필요할 때마다 필요한 데이터를 DB에 요청하고 이를 받아서 검증을 진행한다.
개선된 코드이다.
@Transactional
public void getAchievementPoint(Long memberId, LocalDateTime requestDateTime) {
// 회원 정보가 유효한지 검증 IO발생
final Point point = pointRepository.findByMemberId(memberId).orElseThrow(() ->
new GlobalRuntimeException(PointErrorCode.POINT_NOT_EXIST));
// 회원의 요청 날짜에 대한 한달 범위의 포인트 로그를 검색 IO발생
final List<PointLog> pointLogs = this.getPointLogPerMonthBy(requestDateTime, point);
PointLog recentPointLog = this.getRecentPointLogFrom(pointLogs);
LocalDate requestDate = dateFactory.getPureDate(requestDateTime);
// 금일 이미 목표 달성 포인트를 지급받았는지 확인
this.validationAchievementPointAlreadyPaid(pointLogs, requestDate);
// 리뷰 개수가 목표 달성으로 치환되는지 확인 (목표를 달성했는지 확인)
PointAchievement pointAchievement = this.getPointAchievementFrom(pointLogs);
// 목표 포인트 지급 요청 날짜가 포인트를 지급받을 수 있는 날짜인지 확인
if (!recentPointLog.isPaid(requestDate)) {
throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_PAID_FAILED);
}
// 포인트 지급
this.paidPoints(point, pointAchievement, recentPointLog.getReview());
}
개선된 코드는 2, 3, 4 번째의 검증 로직을 처리하기 위해 필요한 데이터를 하나의 컬렉션으로 받아온다.
그리고 해당 컬렉션을 탐색하며 유효성을 검증한다.
결과
기존의 로직 결과이다 포인트 지급까지 포함하여 IO가 총 6번 발생한다.
한번의 요청을 처리하는데 469ms
개선된 로직 결과이다. 포인트 지급까지 포함하여 IO가 총 3번 발생한다.
한번의 요청을 처리하는데 498ms
오잉 오히려 개선된 코드가 더 느리다..
개선된 코드의 검증 로직은 이렇게 리스트를 스트림으로 탐색하며 진행된다.
/**
* 최신 순으로 정렬된 PointLog 리스트 중 가장 최근의 포인트 로그를 반환한다.<br>
* 포인트 로그 리스트가 비어있다면 예외를 발생한다.
*
* @param pointLogs
* @return PointLog
*/
private PointLog getRecentPointLogFrom(List<PointLog> pointLogs) throws GlobalRuntimeException {
return pointLogs.stream().findFirst()
.orElseThrow(() -> new GlobalRuntimeException(PointErrorCode.POINT_NOT_EXIST_FOR_CURRENT_MONTH));
}
/**
* PointLog 리스트 중 requestDate에 목표달성 포인트를 지급 받은 이력이 있는지 확인한다.
*
* @param pointLogs List
* @param requestDate LocalDate
*/
private void validationAchievementPointAlreadyPaid(List<PointLog> pointLogs, LocalDate requestDate) {
pointLogs.stream()
.filter(pointLog -> pointLog.isPaid(requestDate))
.forEach(pointLog -> {
if (PointAchievement.POINT_CODES.contains(pointLog.getCode())) {
throw new GlobalRuntimeException(PointErrorCode.ACHIEVEMENT_POINT_PAID_ALREADY);
}
});
}
느낀점
포인트 로그를 싹 긁어와서 스트림을 돌며 유효성을 검증하는 방식
각 유효성 검증을 위한 쿼리를 날리는 방식의 성능은 놀랍게도 거의 동일했다. (아이들 상태에서)
스트림의 성능은 정말 안좋다는 것을 깨달았다. IO와 비등하다니,,
트래픽이 몰려서 DB커넥션을 바로바로 할당받을 수 없는 상황이라면
리스트 하나 받아와서 스트림으로 돌리면서 모든 검증을 시도하는게 더 좋은 효율을 가질 것이라고 예상된다.
따라서 결국 성능이 비슷하다면 IO가 여러번 발생하는 것보다는
애플리케이션 코드단에서 처리하는 게 더 좋겠다고 생각한다.
따라서 개선된 코드를 적용하였다.
'의문과 실험' 카테고리의 다른 글
Monitor와 Synchronized 동작 알아보기 (1) | 2024.01.01 |
---|---|
상속 시, 오버라이딩된 메서드의 접근제어자는 왜 확장만을 허용할까 (0) | 2023.12.19 |
[Spring] JPA 엔티티에는 접근자 메서드외의 다른 메서드가 선언되어도 되는가 (0) | 2023.08.15 |
Model이 DTO의 존재를 모르게 하라? Model과 DTO (0) | 2023.07.11 |
[Java] 싱글톤과 Static은 뭐가 다를까? (0) | 2023.07.03 |