포스트

[트랜잭션 편] @Transactional은 Redis를 책임지지 않는다

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

개요

현재 조회수 증가 로직은 DB 부하를 줄이기 위해, Redis를 버퍼로 두고 일정 주기로 DB에 반영하는 구조이다.

  1. 사용자가 게시글 조회
  2. Redis post:view:count:{postId} 값을 증가
  3. 5분마다 실행되는 스케줄러가 Redis의 count 값을 DB에 반영 후 키 삭제

하지만 특정 상황에서 문제가 발생했다.
스케줄러가 실행되는 시점에 DB 장애가 발생하고, 이후 Redis까지 장애가 이어지면
누적된 조회수가 어디에도 반영되지 않은 채 사라지는 상황이 생겼다.

문제 상황

문제 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Transactional
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
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); // Redis 삭제
        if (value == null) continue;

        try {
            Long postId = extractPostId(key);
            long viewCount = Long.parseLong(value);
            postCommandRepository.incrementViewCount(postId, viewCount); // DB 쓰기
        } catch (Exception e) {
            log.error("조회수 DB 반영 실패 - key: {}, count: {} 복원 시도", key, value, e);
            redisTemplate.opsForValue().increment(key, Long.parseLong(value)); // Redis 복원
        }
    }
}

해당 코드는 5분마다 Redis에 누적된 조회수를 DB에 반영하는 스케줄러이다.
전체 메서드는 @Transactional로 감싸져 있으며, 각 key에 대해 다음과 같은 흐름으로 처리된다.

  1. Redis에서 조회수를 조회하면서 동시에 삭제한다 (getAndDelete)
  2. 삭제한 값을 기반으로 DB에 조회수를 반영한다
  3. DB 쓰기가 실패할 경우, catch 블록에서 Redis 값을 복원한다

표면적으로 보면, 실패 시 Redis 값을 다시 복구하기 때문에 별 문제가 없을 줄 알았다.
하지만 catch 블록에서 Redis 복원이 실패하면서 문제가 발생했다.

문제 재현 테스트

아래 테스트로 버그를 재현했다.

1. 단일 게시글에 대한 조회수 flush 처리 테스트

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
@Test
@DisplayName("DB 쓰기 실패 + Redis 복원 실패 시 조회수가 영구 소실된다")
void 조회수_영구_소실_증명() {
    // given - 사용자 5명이 조회하여 Redis에 누적된 상황
    Post post = postCommandRepository.save(
            Post.createPost(1L, "테스트 포스트", "내용", PostStatus.PUBLISHED, List.of("Java"), "thumb.jpg", List.of())
    );
    String redisKey = "post:view:count:" + post.getId();
    redisTemplate.opsForValue().set(redisKey, "5");

    // DB 쓰기 실패
    doThrow(new RuntimeException("DB 장애 시뮬레이션"))
            .when(postCommandRepository).incrementViewCount(eq(post.getId()), anyLong());

    // Redis 복원(catch 블록의 increment)도 실패
    @SuppressWarnings("unchecked")
    ValueOperations<String, String> spyOps = spy(redisTemplate.opsForValue());
    doReturn(spyOps).when(redisTemplate).opsForValue();
    doThrow(new RuntimeException("Redis 장애 시뮬레이션 - increment 실패"))
            .when(spyOps).increment(eq(redisKey), anyLong());

    // when - Redis 복원 실패 예외가 전파됨
    assertThatThrownBy(() -> viewCountScheduler.flushViewCountToDB())
            .isInstanceOf(RuntimeException.class);

    // then
    reset(redisTemplate);
    assertThat(redisTemplate.opsForValue().get(redisKey)).isNull(); // Redis: 소실
    assertThat(postCommandRepository.findById(post.getId()).orElseThrow().getViewCount()).isEqualTo(0L); // DB: 0
}

조회수가 5가 Redis, DB 둘 다 남지 않는다.

2. 여러개의 게시글에 대한 조회수 flush 처리 테스트

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
@Test
@DisplayName("나중에 처리된 포스트의 예외가 앞선 포스트의 DB 업데이트까지 롤백시킨다")
void 앞선_포스트_DB_업데이트_롤백_증명() {
    // given
    Post post1 = postCommandRepository.save(...);
    Post post2 = postCommandRepository.save(...);

    // 처리 순서 고정 
    doReturn(new LinkedHashSet<>(List.of(key1, key2)))
            .when(redisTemplate).keys("post:view:count:*");

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

    // when
    assertThatThrownBy(() -> viewCountScheduler.flushViewCountToDB())
            .isInstanceOf(RuntimeException.class);

    reset(redisTemplate);

    // then - post1도 조회 수 소실됨
    assertThat(redisTemplate.opsForValue().get(key1)).isNull();         // Redis 소실
    assertThat(post1Repository.findById(post1.getId()).get().getViewCount()).isEqualTo(0L); // DB 롤백
}

post2의 장애가 post1의 DB 쓰기까지 롤백시켰다.

실제 발생 시나리오

정상적인 경우와 버그 발생시의 실제 서비스 흐름도를 작성해봤다.

평상시

1
2
3
4
5
6
7
사용자 A → "Spring 입문 가이드" 조회
  → Redis: post:view:count:{id} = 1

사용자 B, C, D → 같은 포스트 조회
  → Redis: post:view:count:{id} = 4

5분마다 스케줄러 실행 → Redis 값을 DB에 반영 → 정상

버그 발생 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Redis에 누적된 여러 포스트의 조회수를 DB에 flush 시작

"포스트1" 조회수 5
  → getAndDelete (Redis에서 삭제)
  → DB 쓰기 성공 ← 트랜잭션 안에서 커밋 대기 중

"포스트2" 조회수 3
  → getAndDelete (Redis에서 삭제)
  → DB 쓰기 시도
    ↳ DB 순간 장애 (커넥션 풀 고갈, 네트워크 지연 등) → 예외 발생
    ↳ catch 블록: Redis 복원(increment) 시도
      ↳ 이 시점에 Redis도 순간 장애 → increment 실패
      ↳ 예외가 메서드 밖으로 전파

@Transactional 롤백
  └─ "포스트1"의 DB 쓰기도 함께 롤백됨

최종 결과

1
2
3
4
"포스트1": Redis → null (getAndDelete 완료), DB → 0 (롤백)  ← 조회수 5 소실
"포스트2": Redis → null (getAndDelete 완료), DB → 0 (실패)  ← 조회수 3 소실

사용자 A, B, C, D가 읽은 기록이 어디에도 남지 않음

포스트1은 정상적으로 DB 쓰기까지 완료했다.
그런데 포스트2에서 발생한 장애로 인하여 함께 롤백되었다.

원인 분석

문제의 원인은 @Transactional의 범위와 getAndDelete의 비트랜잭셔널 특성의 충돌이다.
현재 메서드에 @Transactional을 선언하여, 포스트 10개를 처리하는 도중 마지막 포스트에서 예외가 발생되면, 앞서 성공한 9개의 DB 쓰기가 모두 롤백된다. 하지만 Redis는 getAndDelete로 키를 삭제하는 순간, 그 연산은 즉시 반영된다.

또한 catch 블록의 복원도 실패할 수 있다. DB 장애와 Redis 장애가 같은 시점에 겹치는 경우 catch 블록에서 increment를 호출해도 예외가 발생한다. 이 예외는 catch 블록 안에 있지 않으므로 예외가 발생해 @Transactional을 전부 롤백 시킨다.

결과적으로 Redis 삭제는 완료했지만, DB 반영은 롤백된 상태가 발생해 데이터가 어디에도 존재하지 않는 상태가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
[post1] getAndDelete → Redis 삭제 완료
[post1] incrementViewCount → DB 쓰기 성공 (트랜잭션 안에서 대기)

[post2] getAndDelete → Redis 삭제 완료
[post2] incrementViewCount → DB 장애 → 예외 발생
         catch → Redis increment(복원) → Redis 장애 → 예외 전파

@Transactional 감지 → 전체 트랜잭션 롤백
  └ post1의 DB 쓰기까지 롤백

최종 상태:
  post1: Redis null, DB 0 → 조회수 5 소실
  post2: Redis null, DB 0 → 조회수 3 소실
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.