[트랜잭션 편] @Transactional은 Redis를 책임지지 않는다
- [동시성 편] GET과 DEL 사이, 그 사이 사라진 조회수
- [트랜잭션 편] @Transactional은 Redis를 책임지지 않는다
- [해결 편] 데이터 유실 없는 안전한 조회수 Flush 전략 (Transaction 분리)
개요
현재 조회수 증가 로직은 DB 부하를 줄이기 위해, Redis를 버퍼로 두고 일정 주기로 DB에 반영하는 구조이다.
- 사용자가 게시글 조회
- Redis
post:view:count:{postId}값을 증가 - 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에 대해 다음과 같은 흐름으로 처리된다.
- Redis에서 조회수를 조회하면서 동시에 삭제한다 (getAndDelete)
- 삭제한 값을 기반으로 DB에 조회수를 반영한다
- 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 소실