저번에 이어 Redis 를 통해 게시물 좋아요를 구현해보겠습니다.
좋아요 기능은 여러 사용자가 동시에 좋아요를 눌렀을 때 트랜잭션 격리 수준에 따라 업데이트가 일부 반영되지 않는 문제가 발생할 수 있습니다.
그렇다고 DB 의 락을 강하게 잡아버리면 같은 row 에 대한 요청을 처리하는데 락 경합이 생겨 속도가 느려집니다.
그래서 성능과 동시성 문제를 해결하기 위해 Redis 를 사용하게 되었습니다.
Redis는 자체적으로 카운트 연산(INCRBY, HINCRBY)을 지원합니다.
이 연산들은 원자적으로 실행되며, 인메모리 기반이어서 속도도 매우 빠릅니다.
INCRBY key increment❯ redis-cli INCRBY post:1:like 1
(integer) 1
❯ redis-cli INCRBY post:1:like 5
(integer) 6
Redis 내부 구조 (Key-Value):
post:like:1 -> 10
post:like:2 -> 25
post:like:3 -> 7
HINCRBY key field increment❯ redis-cli HSET post:likes 1 0
(integer) 1
❯ redis-cli HINCRBY post:likes 1 1
(integer) 1
❯ redis-cli HINCRBY post:likes 1 3
(integer) 4
❯ redis-cli HINCRBY post:likes 2 1 // (field=2 새로 생성)
(integer) 1
레디스 내부 구조 (Hash: post:likes):
post:likes {
"1" -> 10
"2" -> 25
"3" -> 7
}
차이점은 INCRBY 는 게시물마다 별도의 key 를 관리해야 하지만,
HINCRBY 는 하나의 해시 key 안에 여러 게시물을 field 로 관리할 수 있다는 점입니다.
게시물이 많아질수록 해시 구조가 훨씬 관리하기 편리합니다.
원래는 Redis 에 total 좋아요 수만 저장하고 조회도 Redis 만 사용하려 했습니다.
하지만 Redis 특성상 서버가 죽거나 영속화 옵션을 꺼둔 상태라면 데이터가 날아갈 수 있습니다.
그래서 total 과 delta 를 나눠 관리하는 방식을 선택했습니다.
total : 현재 Redis가 가지고 있는 총 좋아요 수 (조회 시 사용)delta : 마지막 DB 반영 이후로 변경된 변화량 (주기적 DB 업데이트 시 사용)스케줄러가 5분마다 실행되어
DB 좋아요 수 + delta → DB 업데이트 → delta 초기화
로 처리 되도록 설계 했습니다.
@Component
class LikeCountStore(
private val redis: StringRedisTemplate,
) {
private val totalKey = "post:like:total"
private val deltaKey = "post:like:delta"
private val hashOps = redis.opsForHash<String, String>()
fun increment(postId: Long): Long {
hashOps.increment(deltaKey, postId.toString(), 1L)
return hashOps.increment(totalKey, postId.toString(), 1L)
}
fun decrement(postId: Long): Long {
hashOps.increment(deltaKey, postId.toString(), -1L)
return hashOps.increment(totalKey, postId.toString(), -1L)
}
fun getTotal(postId: Long): Long =
hashOps.get(totalKey, postId.toString())?.toLong() ?: 0
}
개인별 좋아요는 단순 카운트와 달리 멱등성이 유지되어야합니다.
한 사용자가 여러 번 눌러도 실제 좋아요 횟수는 한 번만 반영되어야 하기 때문입니다.
그래서 Redis의 Set 자료구조를 사용했습니다.
SADD는 중복된 값은 무시되므로 멱등성을 보장할 수 있습니다.
❯ SADD post:1:liked_user 10
(integer) 1 (신규 추가, 카운트 증가)
❯ SADD post:1:liked_user 10
(integer) 0 (이미 있음, 카운트 변화 없음)
@Component
class LikeStateStore(
private val redis: StringRedisTemplate,
) {
private fun setKey(postId: Long) = "post:$postId:liked_user"
private val setOps = redis.opsForSet()
fun like(
postId: Long,
userId: Long,
): Boolean = (setOps.add(setKey(postId), userId.toString()) ?: 0L) > 0L
//최초 좋아요면 증가 후 true 반환
//이미 좋아요가 눌러져있으면 변화 없음 -> false
fun unlike(
postId: Long,
userId: Long,
): Boolean = (setOps.remove(setKey(postId), userId.toString()) ?: 0L) > 0L
//좋아요 눌러져 있는 상태에서 좋아요를 취소하면 true 반환
//이미 취소된 상태면 변화 없음 -> false
fun isLiked(
postId: Long,
userId: Long,
): Boolean = setOps.isMember(setKey(postId), userId.toString()) == true
}
test("사용자가 좋아요 누르기 성공") {
val like = likeStateStore.like(1, 1)
like shouldBe true
}
test("사용자가 좋아요 중복으로 눌러서 false 반환") {
likeStateStore.like(1, 1)
val like = likeStateStore.like(1, 1)
like shouldBe false
}
test("사용자가 좋아요 취소 성공") {
likeStateStore.like(1, 1)
val unlike = likeStateStore.unlike(1, 1)
unlike shouldBe true
}
test("사용자가 좋아요 중복으로 취소해서 false 반환") {
val unlike = likeStateStore.unlike(1, 1)
unlike shouldBe false
}
test("사용자 좋아요 누른 상태 반환 성공") {
likeStateStore.like(1, 1)
val liked = likeStateStore.isLiked(1, 1)
liked shouldBe true
}
test("사용자 좋아요 안누른 상태 반환 성공") {
val liked = likeStateStore.isLiked(1, 1)
liked shouldBe false
}
좋아요 상태를 확인하고, 신규 좋아요/취소 일 때만 like 카운트를 증감하도록 했습니다.
fun getLikeStatus(
postId: Long,
userId: Long,
): Pair<Boolean, Long> = likeStateStore.isLiked(postId, userId) to likeCountStore.getTotal(postId)
fun like(
postId: Long,
userId: Long,
) {
if (likeStateStore.like(postId, userId)) {
likeCountStore.increment(postId)
}
}
fun unlike(
postId: Long,
userId: Long,
) {
if (likeStateStore.unlike(postId, userId)) {
likeCountStore.decrement(postId)
}
}
단순하게 좋아요 기능을 만드는데도 고려해야할게 많은 것 같습니다..
다음에는 조회수 기능도 같은 방식으로 Redis 를 통해 구현하고, 주기적으로 DB 에 반영하는 코드도 구현해보겠습니다.