백엔드 개발 일지 (4) — 조회수 하나 올리는 데 이렇게까지

Junyoung·2026년 9월 15일

아카이빙

목록 보기
13/15

이번 편은 "조회수 +1"이라는, 요구사항 한 줄짜리 기능이다.
그런데 이 한 줄이 트랜잭션·비동기·백프레셔를 전부 소환했다. 그 과정을 남긴다.

순진한 버전에서 시작

첫 구현은 누구나 떠올리는 그대로였다. 상세 조회 트랜잭션 안에서 UPDATE.

상세 조회 트랜잭션 {
    콘텐츠 SELECT
    viewCount UPDATE +1   ← 여기
}

동작한다. 그런데 곱씹을수록 이상했다.

첫째, 조회 API에 쓰기가 섞인다. 읽기 트랜잭션이 쓰기 락을 잡는다.
같은 콘텐츠를 여럿이 동시에 열면 같은 행의 UPDATE 락을 두고 줄을 선다.
인기 콘텐츠일수록 상세 조회가 느려지는, 이상한 역설이 생긴다.

둘째, 주객이 전도된다. 조회수 UPDATE가 실패하면 상세 조회까지 실패한다.
사용자 입장에선 "화면이 안 뜨는" 대형 문제가, 고작 카운트 때문에 생기는 거다.

조회수의 본질을 정리하면 이렇다.
정확성보다 응답 속도가 중요하고, 하나쯤 유실돼도 아무도 안 죽는 데이터.
그렇다면 설계도 그 본질을 따라가야 한다.

분리 1단계: 이벤트로 떼어내기

메인 로직에서 카운트를 떼어내는 도구로 Spring의 이벤트를 썼다.
그냥 @EventListener가 아니라 @TransactionalEventListener다.

@Async("viewCountExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(ContentViewedEvent event) {
    viewCountService.increment(event.contentType(), event.contentId());
}

AFTER_COMMIT이 핵심이다. 메인 트랜잭션이 커밋된 뒤에만 리스너가 돈다.

  • 메인이 롤백되면? → 이벤트도 안 돈다. 실패한 조회에 카운트가 오르는 정합성 깨짐 방지.
  • 그냥 @EventListener였다면? → 메인 트랜잭션 안에서 동기 실행. 떼어낸 의미가 없다.

분리 2단계: 스레드까지 떼어내기

AFTER_COMMIT만으론 부족하다. 커밋 후라도 같은 스레드에서 돌면
카운트 UPDATE가 끝나야 응답이 나간다. 그래서 @Async를 얹었다.

여기서 결정 포인트 — 전용 Executor를 만들었다.

@Bean(name = "viewCountExecutor")
public Executor viewCountExecutor() {
    executor.setCorePoolSize(2);       // 카운트 작업은 짧고 가볍다
    executor.setMaxPoolSize(5);
    executor.setQueueCapacity(500);
    executor.setRejectedExecutionHandler(new CallerRunsPolicy());
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(10);
}

숫자마다 이유를 붙이면:

core 2 / max 5 — 카운트는 UPDATE 한 방짜리 초경량 작업이다. 큰 풀은 낭비고,
무엇보다 이 스레드들도 결국 DB 커넥션을 쓴다. 인프라 일지 5편에서 계산한
태스크당 커넥션 10개 예산 안에서, 비동기 풀이 커넥션을 얼마나 점유할 수 있는지가
상한을 정한다. 비동기 풀 크기는 사실 DB 커넥션 계산의 연장선이다.

queue 500 + CallerRunsPolicy — 큐가 가득 차면 어떻게 할 것인가.
기본 정책(AbortPolicy)은 예외를 던진다 → 카운트 유실.
그 대신 CallerRunsPolicy는 요청 스레드가 직접 그 작업을 실행한다.
순간적으로 응답이 조금 느려지는 대가로, 유실 없이 밀린 속도를 따라잡는다.
생산 속도를 소비 속도에 맞춰 끌어내리는 자연스러운 백프레셔다.

shutdown 대기 10초 — 배포로 SIGTERM을 받았을 때 큐에 남은 카운트를
버리지 않고 최대 10초 기다린다. 이 10초는 다음 편에서 다룰
graceful shutdown 예산(20초) 안에 들어가도록 맞춘 숫자다.

조용히 죽는 예외 잡기

@Async + void 반환의 함정이 하나 있다. 예외가 조용히 사라진다.
호출자는 이미 응답하고 떠났으니 예외를 받을 사람이 없다.

이중 안전망을 깔았다.

// 1차: 리스너 안에서 직접 catch — 개별 실패는 로그만 남기고 흡수
catch (Exception e) {
    log.error("viewCount increment failed contentType={} contentId={}", ...);
}

// 2차: AsyncUncaughtExceptionHandler — 1차를 새는 예외의 최종 안전망

카운트 하나의 실패는 흡수하되, 로그는 반드시 남긴다.
"유실돼도 되는 데이터"와 "유실을 몰라도 되는 데이터"는 다르다.
로그가 있으면 단발 유실인지, 뭔가 구조적으로 깨진 건지 구분할 수 있다.

남은 조각: 집계는 스케줄러로

원본 카운트가 쌓이면 일간 인기 목록 같은 집계가 필요해진다.
집계는 요청 시점이 아니라 스케줄러가 주기적으로 계산해 Redis에 얹는다.

여기서 인프라 일지 5편의 복선이 회수된다. 태스크가 2~4대로 늘어나는
환경에서 스케줄러가 대마다 돌면 집계가 중복 실행된다.
ShedLock(Redis 분산락)으로 "한 시점에 한 대만"을 보장했다.
스케줄 코드에 애너테이션 하나지만, 이게 없으면 오토스케일링이
스케줄러의 버그 트리거가 된다.

그리고 집계 조회엔 DB fallback을 뒀다. Redis에 집계가 없으면(만료·장애)
그 자리에서 DB로 계산해 응답하고 다시 캐싱한다. 1편의
"캐시 실패는 miss로 강등" 원칙이 여기서도 반복된다.

배운 것

1. 데이터의 본질이 설계를 정한다.
조회수는 "빠르고 대충"이 맞는 데이터다. 본질이 그런데 구현이
"느리고 정확"하면 설계가 틀린 거다.

2. AFTER_COMMIT과 @Async는 분리의 축이 다르다.
전자는 트랜잭션 결과와의 분리(롤백 시 미실행), 후자는 응답 시간과의 분리.
하나만 쓰면 반쪽짜리 분리가 된다.

3. 비동기 풀의 거부 정책은 "실패 모드 선택"이다.
Abort는 유실, CallerRuns는 지연. 어느 쪽이 덜 아픈지는 데이터마다 다르다.
조회수는 "잠깐 느려도 유실 없음"을 골랐다.


다음 편이 백엔드 일지 마지막이다. application.yml에 박힌 숫자들 —
3초, 20초, 100, 10 — 하나하나에 붙은 이유를 전부 푼다.

profile
라곰

0개의 댓글