포스트

[동시성 편] GET과 DEL 사이, 그 사이 사라진 조회수

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

이유가 뭘까?

문제의 원인은 GETDEL이 서로 다른 명령어이며 원자적 연산이 이루어지지 않았기 때문에 이 두 명령 사이에는 다른 요청이 끼어들 수 있게 되어 동시성 환경에서 조회수가 유실된 것이다.

해결 방법

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 라이센스를 따릅니다.