포스트

[해결 편] 데이터 유실 없는 안전한 조회수 Flush 전략 (Transaction 분리)

[해결 편] 데이터 유실 없는 안전한 조회수 Flush 전략 (Transaction 분리)
시리즈 [블로그 프로젝트] Redis 버퍼 시스템: 동시성부터 트랜잭션까지 3편
  1. [동시성 편] GET과 DEL 사이, 그 사이 사라진 조회수
  2. [트랜잭션 편] @Transactional은 Redis를 책임지지 않는다
  3. [해결 편] 데이터 유실 없는 안전한 조회수 Flush 전략 (Transaction 분리)

개요

이전 글에서 분석했던 문제의 원인은 트랜잭션 범위 설계 문제였다.

1
2
3
4
5
[문제]
@Transactional이 루프 전체를 감싼다
→ 포스트N에서 예외가 전파되면 포스트1~N-1의 DB 쓰기까지 롤백
→ 하지만 Redis getAndDelete는 이미 완료된 상태 → 복원 불가
→ 조회수 소실

그래서 트랜잭션의 범위를 포스트 1건 단위로 좁히기로 했다.
이번 글에서 한 포스트의 실패가 다른 포스트에 영향을 미치지 않도록 각 flush 연산을 독립 트랜잭션으로 처리하려고 한다.

해결 방법

트랜잭션 범위를 좁히기 위해 트랜잭션을 적용해야 하는 로직을 별도 Spring 빈으로 추출하여, Spring이 프록시를 통해 호출하도록 했다.

(참고) 같은 클래스 내에서 메서드를 분리하지 않은 이유 (self-invocation)

@Transcational은 Spring AOP 프록시를 통해 동작한다.
외부에서 빈을 호출할 때는 프록시를 거치지만, 같은 클래스 내부에서 메서드를 직접 호출하면 프록시를 거치지 않는다.
결과적으로 @Transactional이 선언돼 있어도 트랜잭션이 시작되지 않는다.

개선 코드

포스트 1건의 flush 책임을 ViewCountFlusher라는 별도 빈으로 추출했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Slf4j
@Component
@RequiredArgsConstructor
public class ViewCountFlusher {

    private final StringRedisTemplate redisTemplate;
    private final PostCommandRepository postCommandRepository;

    @Transactional                                      
    public void flush(String key, String value) {
        try {
            Long postId = extractPostId(key);
            long viewCount = Long.parseLong(value);
            postCommandRepository.incrementViewCount(postId, viewCount);
        } catch (Exception e) {
            log.error("조회수 DB 반영 실패 - key: {}, count: {} 복원 시도", key, value, e);
            try {
                redisTemplate.opsForValue().increment(key, Long.parseLong(value));
            } catch (Exception redisEx) {
                // Redis 복원까지 실패해도 예외를 전파하지 않는다
                // → 해당 포스트만 소실되고, 나머지 포스트 처리에 영향 없음
                log.error("조회수 Redis 복원 실패 - key: {}, count: {} 데이터 소실 발생", key, value, redisEx);
            }
        }
    }

    private Long extractPostId(String key) {
        return Long.parseLong(key.split(":")[3]);
    }
}

기존 스케줄러는 ViewCountFlusher를 호출하게 수정했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@Component
@RequiredArgsConstructor
public class ViewCountScheduler {

    private final StringRedisTemplate redisTemplate;
    private final ViewCountFlusher viewCountFlusher;   

    @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
    // @Transactional 제거 ← 스케줄러는 트랜잭션 없이 순회만 담당
    public void flushViewCountToDB() {
        Set<String> keys = redisTemplate.keys("post:view:count:*");
        if (CollectionUtils.isEmpty(keys)) return;

        for (String key : keys) {
            String value = redisTemplate.opsForValue().getAndDelete(key);
            if (value == null) continue;

            viewCountFlusher.flush(key, value);         // ← 포스트 1건 = 독립 트랜잭션
        }
    }
}
  • 트랜잭션 범위를 포스트 단위로 격리
  • post2에서 예외가 발생해도 post1은 정상적으로 커밋

검증 테스트

수정 후 테스트

동일한 장애 시나리오에서 수정 후 코드의 동작을 검증했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Test
@DisplayName("post2 DB 실패 + Redis 복원 실패 시 post1의 조회수는 정상 반영된다")
void post2_장애_발생_시_post1_조회수_정상_반영() {
    // given
    redisTemplate.opsForValue().set(key1, "5"); // post1: 5명 조회
    redisTemplate.opsForValue().set(key2, "3"); // post2: 3명 조회

    doReturn(new LinkedHashSet<>(List.of(key1, key2)))
            .when(redisTemplate).keys("post:view:count:*");

    doThrow(new RuntimeException("post2 DB 장애"))
            .when(postCommandRepository).incrementViewCount(eq(post2.getId()), anyLong());
    doThrow(new RuntimeException("post2 Redis 복원 실패"))
            .when(spyOps).increment(eq(key2), anyLong());

    // when - 수정 후에는 예외가 전파되지 않음
    viewCountScheduler.flushViewCountToDB();    

    // then
    assertThat(updatedPost1.getViewCount()).isEqualTo(5L); // post1: 정상 커밋 ← 핵심 검증
    assertThat(updatedPost2.getViewCount()).isEqualTo(0L); // post2: 해당 건만 소실
}

@Test
@DisplayName("정상 케이스: 모든 포스트의 조회수가 DB에 반영된다")
void 정상_케이스_모든_조회수_반영() {
    redisTemplate.opsForValue().set(key1, "5");
    redisTemplate.opsForValue().set(key2, "3");

    viewCountScheduler.flushViewCountToDB();

    assertThat(post1.getViewCount()).isEqualTo(5L);
    assertThat(post2.getViewCount()).isEqualTo(3L);

    // Redis 키도 정리됐는지 확인
    assertThat(redisTemplate.opsForValue().get(key1)).isNull();
    assertThat(redisTemplate.opsForValue().get(key2)).isNull();
}

테스트 결과

항목수정 전수정 후
트랜잭션 범위루프 전체 (N개 포스트)포스트 1건 단위
post2 장애 시 post1 영향DB 쓰기 롤백영향 없음 (커밋 유지)
Redis 복원 실패 시 동작예외 전파 → 전체 롤백예외 삼킴 → 해당 건만 소실
데이터 소실 범위전체 flush 배치장애 포스트 1건
루프 중단 여부예외로 중단중단 없이 나머지 처리

트레이드 오프

  1. 데이터 유실 vs 시스템 복잡도
    • 상황 : DB와 Redis가 동시에 장애가 나는 상황에서는 해당 포스트 1건의 조회수가 여전히 소실될 수 있다.
    • 선택 : 해당 오류를 막기 위해 복잡한 복구 로직을 추가하지 않음
    • 이유 :
      • 조회수는 서비스 통계 지표일 뿐, 결제/인증처럼 오차가 절대 허용되지 않는 크리티컬한 데이터가 아님.
      • 전체 조회수가 유실되는게 아니라, 5분동안의 조회수만 유실됨
      • 미미한 오차를 잡으려 오히려 유지보수 비용만 높임.
  2. 커넥션 획득 비용 vs 데이터 정합성
    • 상황 : 포스트마다 독립 트랜잭션을 실행하므로, 루프 횟수만큼 DB 커넥션을 반복적으로 점유하고 반납하는 오버헤드 발생.
    • 선택 : 전체를 하나로 묶어 성능을 챙기기보다, 포스트 단위의 확실한 커밋과 격리를 선택.
    • 이유 :
      • 커넥션 풀(HikariCP) 환경에서 단건 트랜잭션의 반복 획득 비용은 충분히 제어 가능한 수준이라고 판단
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.