똑똑 유지보수 일지 - 조회수 동시성 처리하기 (2)

코드싸개 김 씨·2026년 8월 4일

똑똑 유지보수

목록 보기
4/4
post-thumbnail

이전 실험 이야기

똑똑 유지보수 일지 - 조회수 동시성 처리하기(1)

1번 실험에서 아쉬웠던 부분은 이랬다.

  1. 왜 트랜잭션을 적용한 UPDATE의 지표가 더 양호했는가?
  2. DB 관련한 수치 측정의 부재
  3. 서버 재실행 시 DB 컨테이너의 지속적인 활성화로 인한 영향?

위 3가지 부분을 고려하지 못한 점에서 아쉬움을 느꼈고, 두 번째 실험을 설계해서 재측정해보았다.

실험 재설계

기존에 세팅했던 환경에서는 이러한 문제들을 추가로 확인할 수 있었다.

(@Transactional 적용한 원자적 UPDATE 해결안의 RPS)

회차1234567
RPS324.1435.1582.2630.8580.4549.1598.1
  • 회차당 7,000요청(워밍업 2,000 + 본 측정 5,000)이므로 정상 상태까지 약 14,000요청이 필요하다.

  • 평탄 구간에 들어간 뒤에도 회차 간 편차가 ±9% 남는다(4회차 ~ 7회차). 따라서 20% 미만의 차이는 1회 측정으로 판별할 수 없다.

추가 측정 지표

지표출처측정 의도
요청당 커밋 수pg_stat_database.xact_commit 델타트랜잭션 경계가 실제로 몇 개인가
요청당 커넥션 획득 횟수·대기시간hikaricp_connections_acquire_seconds_*커넥션을 몇 번 얻고 얼마나 기다리는가
요청당 커넥션 점유시간hikaricp_connections_usage_seconds_*풀을 얼마나 오래 붙잡는가
요청당 DB 실행시간·문장 수pg_stat_statements지연이 DB 안에서 났는가 밖에서 났는가
획득 대기 큐 깊이hikaricp_connections_pending 250ms 샘플링줄이 실제로 쌓이는가

위에서 작성했던 1, 2번의 부재로 이와 같은 지표를 추가로 측정하기로 했다.


재실험 0 - 현행

기존에 JPA의 더티체킹으로 구현한 로직이다.

@Transactional
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    Club targetClub = clubRepository.findById(clubId)
            .orElseThrow(ClubNotFoundException::new);
    targetClub.updateViewCount();
    return ClubDetailServiceResponse.from(
            clubRepository.getClubIntroduction(clubId, username));
}
유실실패율RPSRPS 구간p50p95p99max
89.9%0%400.5385~411244.1298.9332.7455.1

DB 관련 지표

커밋/req커넥션획득/req점유ms/reqDB실행ms/reqSQL문장/req
1.0124.421.94.0

유실율은 여전히 90%대로, 대다수의 요청에서 조회수 업데이트가 누락되었다.


재실험 01 - 비관적 락

// ClubRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Club c WHERE c.id = :clubId")
Optional<Club> findByIdForUpdate(@Param("clubId") String clubId);
@Transactional
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    Club club = clubRepository.findByIdForUpdate(clubId)   // 행 배타 락을 잡고 읽는다
            .orElseThrow(ClubNotFoundException::new);
    club.updateViewCount();
    return ClubDetailServiceResponse.from(
            clubRepository.getClubIntroduction(clubId, username));
}
유실실패율RPSRPS 구간p50p95p99max
0 (0%)0%346.4331~357286.0330.8354.9400.1

DB 관련 지표

커밋/req커넥션획득/req점유ms/reqDB실행ms/reqSQL문장/req
1.0128.326.04.0

이전과 동일하게 유실율은 없고, 현행에서 약간의 점유 시간과 DB 실행시간이 늘었다.

재실험 02 - 낙관적 락 (@Version + 재시도 3회)

// Club.java — 스키마 변경 필요 (version 컬럼 추가)
@Version
private Long version;
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    for (int attempt = 1; attempt <= 3; attempt++) {
        try {
            // @Transactional 이 붙은 별도 빈
            viewCountUpdater.increaseOnce(clubId);
            break;
        } catch (ObjectOptimisticLockingFailureException e) {
            if (attempt == 3) throw e;               // 3회 소진 → 500
        }
    }
    return ClubDetailServiceResponse.from(
            clubRepository.getClubIntroduction(clubId, username));
}
유실실패율RPSRPS 구간p50p95p99max
0 (0%)*67.6%354.3345~364263.5547.5711.61085.0

DB 관련 지표

커밋/req커넥션획득/req점유ms/reqDB실행ms/reqSQL문장/req
1.03.2526.321.65.85

예상했지만 여전히 재시도 횟수 초과로 인한 실패율이 높아보였다.
요청으로 인한 지연 분포도 높았으며
무엇보다, 커넥션 획득 횟수나 실행되는 SQL 수도 기존보다 높아졌기에 역시 채택이 어려웠다.


재실험 03 - 원자적 UPDATE (@Transactional 적용)

@Transactional
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    ClubDetailQueryResponse detail = clubRepository.getClubIntroduction(clubId, username);
    if (detail == null) {
        throw new ClubNotFoundException();
    }

    clubRepository.increaseViewCount(clubId);

    return ClubDetailServiceResponse.from(detail);
}
유실실패율RPSRPS 구간p50p95p99max
0 (0%)0%589.6549~631165.4221.3328.7518.1

DB 관련 지표

커밋/req커넥션획득/req점유ms/reqDB실행ms/reqSQL문장/req
1.0116.414.23.0

의외로 비관적 락에 비해서 생각보다 지연 분포가 넓었다.
다만 점유 시간이나 DB 실행시간, SQL 실행의 수는 다른 대안들에 비하면 확연히 적음을 확인했다.

재실험 04 - 원자적 UPDATE (@Transactional 미적용)

// @Transactional 없음
public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    ClubDetailQueryResponse detail = clubRepository.getClubIntroduction(clubId, username);
    if (detail == null) {
        throw new ClubNotFoundException();
    }
    viewCountUpdater.increase(clubId);   // @Transactional 이 붙은 별도 빈
    return ClubDetailServiceResponse.from(detail);
}
유실실패율RPSRPS 구간p50p95p99max
0 (0%)0%561.0544~589172.9268.4324.7460.1

DB 관련 지표

커밋/req커넥션획득/req점유ms/reqDB실행ms/reqSQL문장/req
3.0316.315.03.0

생각보다 지표가 좋게 나와서 놀랐다.
트랜잭션 커밋이 3회, 커넥션 획득이 3회인 부분에 조금 의구심이 들어서 더 파헤쳐보았다.

왜 지표가 비슷하게 나온걸까?

OSIV = false

이 설정에 대해 간단하게 이해하자면, 트랜잭션이 끝나도 API 응답이 사용자에게 완료될 때까지 DB 커넥션을 반환하지 않는 설정인데, 트랜잭션 점유 시간으로 인한 성능 문제 때문에 이 설정을 꺼놓았다.

이 설정으로 인해, @Transactional을 미적용한 원자적 UPDATE는 사실상 쿼리를 한 번 호출할 때마다 커넥션을 받고 반납하게 된다.

public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    
    // 리포지토리 내의 커넥션 획득 2회
    ClubDetailQueryResponse detail = clubRepository.getClubIntroduction(clubId, username);
    if (detail == null) {
        throw new ClubNotFoundException();
    }
    
    // 커넥션 획득 3회째
    viewCountUpdater.increase(clubId);   // @Transactional 이 붙은 별도 빈
    return ClubDetailServiceResponse.from(detail);
}

결론적으로는 커넥션이 사용 후 바로 바로 반납되기에 절대적인 커넥션 점유 시간은 당연히 줄어들게 된다.

다만 매 작업마다 커넥션 요청 및 반납 비용이나, 메서드 내의 일부 트랜잭션이 실패할 경우의 정합성 문제
(EX:디테일 조회는 null이 아닌 이상한 값인데, 조회 수는 다른 동아리가 증가함) 등을 고려했을 때 좋은 선택지는 아닌 것 같아, 결국 돌고 돌아 다시 @Transactional을 적용한 원자적 UPDATE를 채택하기로 했다.

마치며

  • 어느 정도의 호기심은 해결할 수 있었다. 락이나 @Transactional 을 넘어서 Redis INCR 이라거나 그 외의 방식도 알게됬지만 아직은 도입할 시기가 아닌 듯 싶어 보류했다.
  • 처음에는 워밍업 자체에 대해서 회의감이 좀 들었었다. DB 캐시로 인해 지표가 지나치게 튀거나 그러면 실험의 신뢰도가 떨어지지 않을까라는 생각을 했었는데, 실제 환경에서는 DB서버가 내려가는 경우는 거의 없다. 워밍업이 진행되고 DB는 계속 살아있는 구조가 조금 더 정확한 지표를 측정할 수 있다고 느끼게 됬다.
profile
인생 망하기 전에 시작합니다

0개의 댓글