게시물의 좋아요 개수는 무조건 캐시에 쓰고 이를 5분 마다 DB에 스케쥴링으로 반영하고 있다
근데 현재 코드에서는 동시에 2개의 thread가 같은 좋아요 요청을 한다면 둘 다 잘 실행되어서 캐시에 좋아요 기록이 2개 남고, 좋아요 개수도 2개 증가한다
둘 중 하나만 실행되고, 나머지 하나는 이미 좋아요를 눌렀습니다~라는 에러를 던져야 한다
내가 공부했을 때, Redis는 Single Thread로 동작하기 때문에 동시성 이슈가 안생길 거라고 착각했다.
사실 반은 맞고 반은 틀린 말이였다
예를 들어 4번 post의 좋아요 개수가 10개라고 하자
이때 동시에 10개의 thread가 4번 post의 좋아요 개수를 1씩 증가시킨다고 하면
single thread이기에 race condition 없이 10개 모두 증가해서 결국 좋아요 개수가 20개가 된다
이렇게 보면 동시성 이슈가 없는 것 같다
하지만 내 로직에서는 발생하는 이유가 뭘까...
@Transactional(isolation = Isolation.READ_COMMITTED)
public void increaseLike(Long postId, Long userId) {
// Redis에 게시물에 대한 좋아요 개수 캐싱하기(없으면 DB에서 좋아요 개수 가져와서 캐싱하고 있으면 pass)
loadLikeCountIfAbsent(postId); // 게시물 존재 여부도 검증함
String likeCountKey = LIKE_COUNT_PREFIX + postId;
String userLikeKey = USER_LIKE_PREFIX + userId;
// 캐시(2) or DB(1)에 좋아요 기록 있음 -> 중복 좋아요 방지
if (likeService.existsLike(userId, postId) != NOT_FOUND) {
throw new ApiException(ErrorCode.DUPLICATED_LIKE);
}
// 캐시와 DB 모두 좋아요 기록 없음
// 캐시에 좋아요 누른 기록 추가하고 좋아요 개수 1 증가
LikeRecordDTO likeRecord = new LikeRecordDTO(postId, LocalDateTime.now());
redisTemplate.opsForList().leftPush(userLikeKey, likeRecord);
redisTemplate.expire(userLikeKey, Duration.ofHours(1)); // TTL 1시간
redisTemplate.opsForValue().increment(likeCountKey, 1);
}
여기서 A thread, B thread가 동시에 postId = 4, userId = 10 으로 increaseLike() 메서드를 호출했다고 가정하자
또한, 아직 10번 유저는 4번 게시물을 좋아요 누른 적이 없다고 가정
A, B 모두 if (likeService.existsLike(userId, postId) != NOT_FOUND) 이 if문을 통과하게 된다
그러면 A, B 모두 if문 아래에 있는 로직들을 수행할 수 있게 된다
그럼 똑같은 좋아요 기록이 2개 생기고 좋아요 개수도 2개 증가한다
하지만 우리가 원하는 건 하나의 요청만 실행되고 나머지 요청은 중복 좋아요 요청입니다. 등의 에러를 던지길 원함...
둘 중 하나의 요청만 실행되길 원한다면 아래 operation들이 ATOMIC하게 수행되면 된다
중복 좋아요 요청입니다 에러 던지기이전에는 이 operation들이 atomic 하게 묶여 있지 않아서 A, B thread 모두 첫 operation을 통과하니깐 둘 다 2, 3번 operation을 실행한 것
그럼 이 3개 operation을 어떻게 원자적으로 묶냐??! -> Redis에서는 lua script를 지원한다
경량 프로그래밍 언어로, 임베디드 스크립팅 언어로 많이 사용
Redis에서 lua script를 지원하는데 그 이유는 아래와 같다
---@diagnostic disable: undefined-global
local likeCountKey = KEYS[1]
local userLikeKey = KEYS[2]
local likeRecord = ARGV[1]
local decodedLikeRecord = cjson.decode(likeRecord)
-- 사용자 좋아요 기록을 확인
local userLikes = redis.call('lrange', userLikeKey, 0, -1)
for i, v in ipairs(userLikes) do
local decodedValue = cjson.decode(v)
if decodedValue.postId == decodedLikeRecord.postId then
return redis.error_reply('DUPLICATED_LIKE')
end
end
-- 사용자 좋아요 기록 추가 및 좋아요 수 증가를 원자적으로 처리
redis.call('lpush', userLikeKey, likeRecord)
redis.call('incr', likeCountKey) -- 좋아요 개수 증가
return 'OK'
@Transactional(isolation = Isolation.READ_COMMITTED)
public void increaseLike(Long postId, Long userId) {
// Redis에 게시물에 대한 좋아요 개수 캐싱하기(없으면 DB에서 좋아요 개수 가져와서 캐싱하고 있으면 pass)
loadLikeCountIfAbsent(postId); // 게시물 존재 여부도 검증함
String likeCountKey = LIKE_COUNT_PREFIX + postId;
String userLikeKey = USER_LIKE_PREFIX + userId;
// 캐시(2) or DB(1)에 좋아요 기록 있음 -> 중복 좋아요 방지
if (likeService.existsLike(userId, postId) != NOT_FOUND) {
throw new ApiException(ErrorCode.DUPLICATED_LIKE);
}
LikeRecordDTO likeRecord = new LikeRecordDTO(postId, LocalDateTime.now());
// likeRecord 직렬화
String likeRecordString;
try {
likeRecordString = objectMapper.writeValueAsString(likeRecord);
} catch (JsonProcessingException e) {
throw new ApiException(ErrorCode.JSON_CONVERTING_ERROR, "JSON 직렬화 오류");
}
RedisSerializer<String> stringSerializer = redisTemplate.getStringSerializer();
// lua script execute
try {
redisTemplate.execute(increaseLikeScript, stringSerializer, stringSerializer,
Arrays.asList(likeCountKey, userLikeKey), likeRecordString);
} catch (RedisSystemException e) {
throw new ApiException(e, ErrorCode.DUPLICATED_LIKE);
}
}
A, B thread가 거의 동시에 if (likeService.existsLike(userId, postId) != NOT_FOUND) 이 if문을 통과해도
Redis는 한번에 하나의 operation만 처리할 수 있기에 둘 중 하나의 thread가 먼저 lua script를 수행할 것이다 (lua script를 하나의 operation으로 간주함)
예를 들어 A thread가 먼저 lua script를 수행한다면 좋아요 기록 없기에 A thread가 좋아요 기록 남기고 좋아요 개수 1 증가 시킨다
직후에 B thread가 들어와서 lua script를 실행하면 좋아요 기록이 이미 캐시에 있기에 에러를 던지면서 실행이 끝난다.
해결~~
캐시에 좋아요 기록 있는지 있다면 기록 및 개수 증가하고 없다면 에러를 뿜도록

위 로직을 lua script를 통해 원자적으로 묶음으로써 동시성 이슈를 해결함