[kotlin] Redis 로 게시물 좋아요 구현하기 - 1

dustle·2025년 9월 2일

kotlog

목록 보기
11/11

저번에 이어 Redis 를 통해 게시물 좋아요를 구현해보겠습니다.

좋아요 기능은 여러 사용자가 동시에 좋아요를 눌렀을 때 트랜잭션 격리 수준에 따라 업데이트가 일부 반영되지 않는 문제가 발생할 수 있습니다.
그렇다고 DB 의 락을 강하게 잡아버리면 같은 row 에 대한 요청을 처리하는데 락 경합이 생겨 속도가 느려집니다.

그래서 성능과 동시성 문제를 해결하기 위해 Redis 를 사용하게 되었습니다.


Redis 카운트

Redis는 자체적으로 카운트 연산(INCRBY, HINCRBY)을 지원합니다.
이 연산들은 원자적으로 실행되며, 인메모리 기반이어서 속도도 매우 빠릅니다.

  • INCRBY key increment
    • String 타입 value를 증가시킵니다.
    • 예시:
❯ 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
    • Hash 타입 field의 값을 증가시킵니다.
    • 예시:
❯ 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 로 관리할 수 있다는 점입니다.
게시물이 많아질수록 해시 구조가 훨씬 관리하기 편리합니다.


Total + Delta 구조

원래는 Redis 에 total 좋아요 수만 저장하고 조회도 Redis 만 사용하려 했습니다.
하지만 Redis 특성상 서버가 죽거나 영속화 옵션을 꺼둔 상태라면 데이터가 날아갈 수 있습니다.

그래서 totaldelta 를 나눠 관리하는 방식을 선택했습니다.

  • total : 현재 Redis가 가지고 있는 총 좋아요 수 (조회 시 사용)
  • delta : 마지막 DB 반영 이후로 변경된 변화량 (주기적 DB 업데이트 시 사용)

스케줄러가 5분마다 실행되어
DB 좋아요 수 + delta → DB 업데이트 → delta 초기화
로 처리 되도록 설계 했습니다.


LikeCountStore 구현

@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 (이미 있음, 카운트 변화 없음)

LikeStateStore 구현

@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
}

LikeStateStoreTest

	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
    }

PostLikeService

좋아요 상태를 확인하고, 신규 좋아요/취소 일 때만 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 에 반영하는 코드도 구현해보겠습니다.

0개의 댓글