새소식

스프링

포인트 보정 배치? 트랜잭션 최적화? 너 꿈꿨니?

  • -

 

꿈 아니었구요.

 

안녕하세요.

 

문제는 그렇게 시작됐습니다.

 

저희 서비스는 포인트 획득 / 소비 기능을 비관적 락인 네임드 락을 통해 관리하고 있는데요

 

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% 달성

 

오늘도 성공~~~~~~

 

 

마치겠습니다

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.