[동시성 편] GET과 DEL 사이, 그 사이 사라진 조회수
[동시성 편] GET과 DEL 사이, 그 사이 사라진 조회수
시리즈 [블로그 프로젝트] Redis 버퍼 시스템: 동시성부터 트랜잭션까지 3편
- [동시성 편] GET과 DEL 사이, 그 사이 사라진 조회수
- [트랜잭션 편] @Transactional은 Redis를 책임지지 않는다
- [해결 편] 데이터 유실 없는 안전한 조회수 Flush 전략 (Transaction 분리)
조회 수 시스템 구현
현재 프로젝트에서 조회 수는 Redis에 먼저 누적하고 일정 주기마다 DB로 반영하는 구조로 구현했다.
1
2
3
4
5
6
7
조회 발생
↓
Redis INCR
↓
5분마다 flush
↓
DB 반영
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
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES)
public void flushViewCountToDB(){
// 조회수 집계 키는 5분마다 flush되어 개수가 많지 않으므로
// KEYS 기반 전체 조회 사용하였음
Set<String> keys = redisTemplate.keys("post:view:count:*");
if(CollectionUtils.isEmpty(keys)) return;
for(String key : keys){
try{
String value = redisTemplate.opsForValue().get(key);
if(value == null) continue;
Long postId = extractPostId(key);
long viewCount = Long.parseLong(value);
postRepository.incrementViewCount(postId, viewCount);
redisTemplate.delete(key);
}catch (Exception e){
log.error("조회수 DB 반영 실패 - key: {}", key, e);
}
}
}
동시성 테스트 해보기
flush 로직이 제대로 동작하는지 확인하기 위해 테스트 코드를 작성했다.
특히 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
31
32
33
@Test
void 조회수가_DB에_정상적으로_FLUSH_된다() throws Exception {
String key = "post:view:count:1";
redisTemplate.opsForValue().set(key, "10");
CountDownLatch readDone = new CountDownLatch(1);
CountDownLatch incrDone = new CountDownLatch(1);
Thread flushThread = new Thread(() -> {
String value = redisTemplate.opsForValue().get(key);
readDone.countDown();
try { incrDone.await(); } catch (InterruptedException e) {}
redisTemplate.delete(key);
});
Thread incrThread = new Thread(() -> {
try { readDone.await(); } catch (InterruptedException e) {}
redisTemplate.opsForValue().increment(key);
incrDone.countDown();
});
flushThread.start();
incrThread.start();
flushThread.join();
incrThread.join();
String value = redisTemplate.opsForValue().get(key);
assertThat(value).isNull();
}
예상치 못한 결과
테스트 결과 Redis에 남아있어야 할 조회수 증가가 사라지는 상황을 확인했다.
동작 순서를 보면 다음과 같다.
1
2
3
1. flush가 GET -> 10 읽음
2. 조회 요청 발생 -> INCR -> 11
3. flush가 DEL
결과
1
2
Redis 값 삭제
DB에는 +10만 반영
즉 조회수 +1 이 유실된다.
이유가 뭘까?
문제의 원인은 GET과 DEL이 서로 다른 명령어이며 원자적 연산이 이루어지지 않았기 때문에 이 두 명령 사이에는 다른 요청이 끼어들 수 있게 되어 동시성 환경에서 조회수가 유실된 것이다.
해결 방법
Redis 공식문서를 확인해보니, 값 조회와 삭제를 원자적으로 연산할 수 있는 GETDEL 명령어가 있었다.
그래서 flush 로직을 다음과 같이 수정했다.
1
2
3
4
5
6
7
8
9
10
for(String key : keys){
String value = redisTemplate.opsForValue().getAndDelete(key);
if(value == null) continue;
Long postId = extractPostId(key);
long viewCount = Long.parseLong(value);
postRepository.incrementViewCount(postId, viewCount);
}
이제 flush 과정은 아래와 같이 동작한다.
1
2
3
GETDEL
↓
DB 반영
값 조회와 삭제가 하나의 연산으로 처리되기 때문에 조회수 증가 요청이 중간에 끼어들어 조회수가 유실되는 문제를 방지할 수 있다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.