포인트 보정 배치? 트랜잭션 최적화? 너 꿈꿨니?
- -
꿈 아니었구요.
안녕하세요.
문제는 그렇게 시작됐습니다.
저희 서비스는 포인트 획득 / 소비 기능을 비관적 락인 네임드 락을 통해 관리하고 있는데요
Facade 계층으로 코드 갈아엎기
안녕하세요. 문제는 그렇게 시작됐습니다. @Transactionalpublic void saveComment(String userId, Long diaryId, CommentRequest commentRequest) { Member member = getMember(userId); Diary diary = getDiary(diaryId); List acceptedMembers = friendCh
dockerel.tistory.com
내용이 궁금하시면 위 글을 읽어보시면 됩니다.
근데 저는 이 네임드 락과 이벤트 기반 비동기 처리된 포인트 처리 로직이 좀 의심스럽더라구요
포인트와 같은 중요한 자원에 대해서는 이중, 삼중, 사중 확인은 좀 리소스 아깝고 한 이중 확인 정도까지는 해도 나쁘지 않다고 생각하거덩요.
그래서 포인트 보정 배치 작업을 만들었습니다. 같이 보시죠
우선 포인트 조정 대상 유저들을 골라내어 보겠습니다.
@Query("SELECT DISTINCT pl.member.id FROM PointLog pl WHERE pl.createdAt BETWEEN :start AND :end")
List<Long> findMemberIdsWithActivityBetween(@Param("start") Instant start, @Param("end") Instant end);
하루동안 포인트 로그가 생긴 즉 포인트 변동이 있는 유저들만 골라냈습니다. 깔끔하죠?
그리고 멤버 Id로 포인트 로그들을 전부 합해서 포인트 값을 계산해주는 쿼리를 만들어봤습니다.
@Query("""
SELECT COALESCE(SUM(CASE WHEN pl.pointStatus = 'EARNED' THEN pl.point ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN pl.pointStatus = 'CONSUMED' THEN pl.point ELSE 0 END), 0)
FROM PointLog pl
WHERE pl.member.id = :memberId
""")
Optional<Integer> sumPointsByMemberId(@Param("memberId") Long memberId);
이번에 SQLD 준비하면서 배운 COALESCE를 한번 써봤습니다. 깔쌈하죠?
이제 배치 작업입니다.
@Scheduled(cron = "0 0 4 * * *")
public void reconcilePoints() {
log.info("포인트 정합성 보정 작업 시작");
LocalDateTime start = LocalDate.now().minusDays(1).atStartOfDay();
LocalDateTime end = LocalDateTime.now().plusDays(1).toLocalDate().atStartOfDay().minusNanos(1);
List<Long> memberIds = pointReconciliationService.getTargetMembers(start, end);
memberIds.forEach(memberId -> reconcilePointWithLock(memberId));
log.info("포인트 정합성 보정 작업 완료");
}
우선 포인트 조정 대상 유저들의 id를 가져옵니다. 그리고 각 id에 대해 정합성 작업을 진행해줍니다.
근데 자세히 보면 트랜잭션이 안걸려있습니다. 데이터 변화가 일어나니까 @Transactional 걸어야 하는거 아니냐구요?
맞는 말이긴 한데 그럴 필요가 없습니다.
reconcilePoints는 하나의 큰 명령을 내리는 작업이라 보면 됩니다. 그리고 트랜잭션은 잘게 나눠서 조회용 트랜잭션, 포인트 보정용 트랜잭션으로 나눌겁니다. 왜 이렇게 잘게 나누냐고요? 트랜잭션을 길게 가져가면 쓸대없이 커넥션풀만 길게 잡아먹게 되고 아주 리소스 낭비입니다.
우린 이러지 말고 최대한 아껴봅시다.
@Transactional(readOnly = true)
public List<Long> getTargetMembers(LocalDateTime start, LocalDateTime end) {
ZoneId zone = ZoneId.systemDefault();
LocalDate yesterday = LocalDate.now(zone).minusDays(1);
LocalDateTime startOfDay = yesterday.atStartOfDay();
LocalDateTime endOfDay = yesterday.atTime(LocalTime.MAX);
Instant startInstant = startOfDay.atZone(zone).toInstant();
Instant endInstant = endOfDay.atZone(zone).toInstant();
return pointLogRepository.findMemberIdsWithActivityBetween(startInstant, endInstant);
}
@Transactional
public void reconcilePoint(Member member) {
Long memberId = member.getId();
int calculatedTotalPoint = pointLogRepository.sumPointsByMemberId(memberId).orElse(0);
Point findPoint = pointRepository.findByMemberId(memberId);
int currentPoint = findPoint.getPoint();
if (calculatedTotalPoint != currentPoint) {
log.warn("포인트 불일치 | Member ID: {}, DB 저장값: {}, 로그 계산값: {}", memberId, currentPoint, calculatedTotalPoint);
findPoint.updatePoint(calculatedTotalPoint);
}else{
log.info("포인트 일치 | Member ID: {}, DB 저장값: {}, 로그 계산값: {}", memberId, currentPoint, calculatedTotalPoint);
}
}
조회에는 조회용 트랜잭션, 그 외에 포인트 조정은 각 멤버마다 끊어서 트랜잭션이 실행되게 해줬습니다.
이제 신나는 성능 측정 타임입니다.
씡나게 프로메테우스와 그라파나를 켰는데요, 트랜잭션 관련 metric이 없네요? 어쩌죠?
어쩌긴 뭘 어째 직접 만들어야죠
@Aspect
@Component
public class TransactionTimerAop {
@Around("@annotation(org.springframework.transaction.annotation.Transactional)")
public Object measureTransactionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long duration = System.currentTimeMillis() - startTime;
Metrics.timer("transaction.duration", "method", joinPoint.getSignature().getName())
.record(duration, TimeUnit.MILLISECONDS);
}
}
}
@Transactional 어노테이션 실행 시간을 측정하는 AOP를 만들어 프로메테우스에 값을 기록해줬습니다.
이제 로그를 보면
# HELP transaction_duration_seconds
# TYPE transaction_duration_seconds summary
transaction_duration_seconds_count{application="Todak",method="getTargetMembers"} 1
transaction_duration_seconds_sum{application="Todak",method="getTargetMembers"} 15.981
transaction_duration_seconds_count{application="Todak",method="reconcilePoint"} 1000
transaction_duration_seconds_sum{application="Todak",method="reconcilePoint"} 75.906
# HELP transaction_duration_seconds_max
# TYPE transaction_duration_seconds_max gauge
transaction_duration_seconds_max{application="Todak",method="getTargetMembers"} 0.0
transaction_duration_seconds_max{application="Todak",method="reconcilePoint"} 0.161
개별 평균 트랜잭션 점유 시간은 75906(ms) / 1000 = 76(ms) 정도로 나왔네요.
놀랍게도 씨퓨와 메모리 사용률도 최대 5% 정도 밖에 증가하지 않았습니다.
전체 배치 실행시간은
http_server_requests_seconds_sum{application="Todak",error="IOException",exception="IOException",method="GET",outcome="SUCCESS",status="200",uri="/api/v1/study/point-recon"} 1113.435428042
1113초 = 18분 정도밖에 안나왔습니다. 전체 멤버 1000명에 포인트 로그가 10000개씩 있으니까 천만개 포인트 로그가 있다는 것을 감안하면 생각보다 성능이 잘 나온 것 같습니다.
정리하자면
포인트 정합성을 위한 스케줄러 트랜잭션 최적화
- 포인트 정합성 유지를 위해 매일 대용량(1000만 건) 데이터 집계/보정 배치에서 트랜잭션 구간이 길고 시스템 리소스가 급증하는 문제 발생
- APM, Prometheus 등 실시간 모니터링을 통해 병목을 진단하고, 조회·수정 로직의 트랜잭션을 분리해 DB 커넥션 점유 시간을 단축
- 개별 보정 트랜잭션의 평균 점유시간을 80ms 미만으로 최적화하고, 보정 작업 중 발생 가능한 동시 변동은 네임드락(named lock)으로 데이터 불일치 가능성을 차단
- 배치 실행 동안 시스템 CPU, 메모리 사용률 증가 폭을 5% 이내로 제어하여, 서비스 운영에 미치는 영향을 최소화
- 데이터 불일치 발견 및 자동 복구율 100% 달성
오늘도 성공~~~~~~
네
마치겠습니다
'스프링' 카테고리의 다른 글
직접 재시도 로직 구현했는데 알고 보니 이미 있는 리얼 허거덩거덩스한 상황 (6) | 2025.08.01 |
---|---|
Facade 계층으로 코드 갈아엎기 (4) | 2025.07.30 |
커스텀 어노테이션과 AOP 사용해서 공통기능 처리하기 (2) | 2025.06.30 |
[매일메일] private 메서드에 @Transactional 선언하면 트랜잭션이 동작할까? (0) | 2025.02.07 |
[매일메일] 단위 테스트 vs 통합 테스트 (0) | 2025.01.27 |
소중한 공감 감사합니다