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

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

똑똑 유지보수

목록 보기
3/4
post-thumbnail

조회 수가 안 오를 수도 있다..?

동아리 상세 정보 조회 기능을 리팩토링하며 AI와 대화하던 중 받은 피드백이었다.
조회 수 필드 같은 경우에는 인기 점수 로직과도 관련이 있었기에, 다수의 트래픽이 몰리게 될 경우에는, 인기 동아리 표시에 신빙성이 떨어질 수도 있겠다는 생각이 들었다.

리팩토링 진행하는 김에, 타당한 방향으로 개선해보고자 실험을 진행했다.


실험 설계

실험 시작 전에는 혼자 생각했을 때는 아래 방법들을 고려했었다.

  • 비관적 락
  • 낙관적 락
  • 원자적 업데이트 (update 쿼리)

이 조건에서, 원자적 업데이트 조건에 @Transactional 어노테이션 채택 여부를 분기하여 총 4가지 방식으로 k6 부하테스트를 진행해보기로 결정했다.

실험 조건

항목
부하 도구k6 (grafana/k6:latest), shared-iterations 실행기
부하100 VU, 5,000 요청, think time 없음
경로k6 → nginx → Spring Boot app → PostgreSQL (모두 Docker 컨테이너)
리소스app 2 vCPU / 2GB, db 2 vCPU / 1GB
커넥션 풀HikariCP maximum-pool-size = 10
워밍업매 측정 전 2,000 요청 (결과 폐기)

처음에는 VU와 요청 수가 과하다고 생각했으나, 인프라에 지장이 가지 않으며 조회 수가 올라가지 않는 동시성 이슈를 확실하게 재현하기 위한 스모크 테스트 의도에 맞다고 판단되어 이 수치 그대로 변경 없이 진행했다.


개선 전 (Before)

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));
}

// Club.java
public void updateViewCount() {
    viewCount += 1;   // JVM 메모리 위에서의 증가
}

경쟁 시나리오

view_count = 100 인 상태에서 두 요청이 동시에 들어오면:

시각트랜잭션 A트랜잭션 BDB 상태
t1SELECT → 100 읽음100
t2SELECT → 100 읽음100
t3메모리에서 101 계산100
t4메모리에서 101 계산100
t5UPDATE SET view_count = 101101
t6UPDATE SET view_count = 101101

위 표와 같이 club 엔티티의 viewCount라는 하나의 자원에 두 트랜잭션이 동시 접근했을때, 의도대로라면 viewCount는 102가 되어야 하지만, 둘 다 100일 때 접근했기에, 여기서 updateViewCount() 메서드를 거쳐서 101이 되는 문제가 있었다.

기타

viewCount 필드는 별도로 API 응답에 노출되는 정보가 아니었기에, 아래와 같은 플로우를 거쳐 실제 증가량을 측정했다.

① 대상 행 시드 (전용 UUID, mock 데이터와 겹치지 않게)
② 워밍업 2,000 요청 (결과 폐기)
③ UPDATE clubs SET view_count = 0 WHERE id = '...'   ← 리셋
④ k6 본 측정 5,000 요청
⑤ SELECT view_count FROM clubs WHERE id = '...'      ← 실제 증가분
⑥ 유실 = (2xx 응답 수) - (실제 증가분)

개선 전 지표

유실실패율RPSp50p95p99max
4,507 / 5,000 (90.14%)0%311.7300.1481.5622.41091.1

실패하는 요청은 없었으나, 누락되는 증가분이 매우 많았다.


실험 1 - 비관적 락

아래와 같은 방식으로 적용하여 지표를 측정했다.

// ClubRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Club c WHERE c.id = :clubId")
Optional<Club> findByIdForUpdate(@Param("clubId") String clubId);
유실실패율RPSp50p95p99max
0 (0%)0%245.9401.1480.7503.6517.4

확실히 이전보다 유실이 확 줄어든게 눈에 띈다.
다만 RPS가 좀 줄어들었다는 점이 아쉬웠다.

실험 2 - 낙관적 락

version 컬럼과 재시도 로직(3회)을 추가하여 지표를 측정했다.

// Club.java (version 컬럼 추가)
@Version
private Long version;
// 재시도 횟수
private static final int MAX_RETRY = 3;

public ClubDetailServiceResponse getClubIntroduction(String username, String clubId) {
    for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
        try {
            return transactionTemplate.execute(status -> {
                Club club = clubRepository.findById(clubId)
                        .orElseThrow(ClubNotFoundException::new);
                club.updateViewCount();   // 커밋 시 version 을 함께 검사·증가
                return ClubDetailServiceResponse.from(
                        clubRepository.getClubIntroduction(clubId, username));
            });
        } catch (ObjectOptimisticLockingFailureException e) {
            log.warn("optimistic lock conflict attempt={}", attempt);
        }
    }
    throw new IllegalStateException("조회수 증가 재시도 초과");
}
유실실패율RPSp50p95p99max
0 (0%)67.36%150.8699.61011.51205.11779.1

예상했지만, 충돌이 잦았기에 재시도 3번에도 실패한 요청이 매우 많았다.
특이한 점은 유실이 0 이었다는건데, 성공한 요청 기준으로는 정상작동하지만,
성능 지표도 그렇고 사용자 입장에서는 3번의 요청 중 2번이 실패한다는 점에서는 채택할 이유가 없었다.

실험 3 - 원자적 업데이트

우선 먼저, @Transactional을 적용한 원자적 업데이트 먼저 지표를 측정했다.

// ClubRepository.java
@Modifying
@Query("UPDATE Club c SET c.viewCount = c.viewCount + 1 WHERE c.id = :clubId")
int increaseViewCount(@Param("clubId") String clubId);

트랜잭션(@Transactioal) 적용

@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);
}
유실실패율RPSp50p95p99max
0 (0%)0%601.4157.5244.8325.0479.1

트랜잭션(@Transactioal) 미적용

유실실패율RPSp50p95p99max
0 (0%)0%319.4299.2472.5586.0823.8

이상하게도 트랜잭션을 미적용했을 때보다 적용했을 때 훨씬 성능이 좋았다.

최종 지표

변형유실실패율RPSp50p95p99max스키마 변경
현행90.14%0%311.7300.1481.5622.41091.1
원자적 UPDATE - 트랜잭션 사용0%0%601.4157.5244.8325.0479.1불필요
원자적 UPDATE — 트랜잭션 미사용0%0%319.4299.2472.5586.0823.8불필요
낙관락 (@Version, 재시도 3회)0%67.36%150.8699.61011.51205.11779.1필요 (version 컬럼)
비관락 (SELECT … FOR UPDATE)0%0%245.9401.1480.7503.6517.4불필요

우선 당장의 판단으로는, @Transactional 을 채택한 원자적 UPDATE가 최선으로 보였다.

의문점

실험 진행 후, 복기하며 궁금한 점을 몇 가지 정리해보았다.

  1. 왜 트랜잭션을 적용한 원자적 UPDATE의 지표가 더 양호했는가?
    -> 우선 이 프로젝트의 OSIV 설정이 false 로 되어 있는 것과 관련 있을 수 있다는 생각이 들었다.
  2. DB 관련한 수치는 어떻게 나올까?
    -> 현재 측정했던 수치는 단순하게 웹 - 애플리케이션 계층에서의 응답 속도였기에, DB에 가해지는 부하 등을 고려하지 못했다.
  3. 서버 재실행 시, DB 컨테이너는 그대로 떠있었다. 이로 인한 영향은?
    -> DB 캐시가 적용되는 등의 변수를 고려하지 못했다.

실험 후

의문점 부분들을 고려하고 나니, 살짝 디테일이 부족한 실험이라는 생각이 들었다.
다음엔 이 부분들을 고려하여, 재실험을 진행해보려한다.
물론 빠르게 해결방안을 선택하는 것은 좋지만, 단순히 빠르게 선택한다는 건 나는 잘못됬다고 생각한다.
차라리 타당하게 고민하고 선택하는 방향이 훨 났다고 느낀다.
(물론 빠르고 타당하다면 좋겠지만..아직은 스스로가 미숙한 것 같다.)

profile
인생 망하기 전에 시작합니다

0개의 댓글