Skip to content

(메신저개발) 메시지 좋아요 카운트, 레디스 락 대신 Set 자료구조로 잡기

(메신저개발) 메시지 좋아요 카운트, 레디스 락 대신 Set 자료구조로 잡기

TL;DR

  • 증상: QA 스트레스 테스트에서 좋아요 카운트가 4~8% 적게 나옴
  • 원인: ① DB에서 읽으면 복제 지연(Replication Lag)으로 옛 값을 봄 ② Redis 숫자 카운터(INCR)는 “누가 눌렀는지”를 몰라서 같은 사람의 중복 클릭을 못 막음
  • 해결: 숫자를 세지 않고, 누가 눌렀는지를 Set(집합)에 저장. 카운트는 집합 크기, 중복은 자료구조가 알아서 막음
  • 효과: 오차 0%, 락도 별도 검증 로직도 필요 없어져서 코드가 오히려 단순해짐
  • 한계: Set 메모리 관리, DB 영속화는 비동기로 따로 챙겨야 함

환경

  • 서비스: 협업 메신저 서비스 (B2B SaaS)
  • 규모: DAU 수십만, 실시간 메시징 위주의 높은 동시성
  • 인프라: WAS 여러 대 (분산 환경)
  • 캐시: 단일 Redis (Cluster 아님)
  • DB: MySQL 8.0, InnoDB, Master-Slave 복제 구성

문제 상황: “좋아요 누르면 카운트가 늘어나잖아요?”

이전 회사에서 협업 메신저 서비스를 개발할 때의 이야기입니다.

메시지에 리액션(좋아요 같은)을 달 수 있는 기능을 만들고 있었습니다. 동시 접속이 많았고, 인기 공지에는 수백 명이 거의 같은 순간에 리액션을 누르는 경우가 흔했습니다.

기획자: “좋아요 누르면 카운트가 늘어나잖아요? 간단하죠?” 나: “네, 근데… 여러 명이 동시에 누르면 꼬일 수 있어서 좀 신경 써야 해요.”

단순해 보이는 기능이지만, 동시성 이슈는 트래픽이 몰리면 반드시 터진다는 걸 알고 있었습니다. 문제는, 제가 그 “신경 써야 하는 지점”을 처음엔 엉뚱한 데서 찾았다는 겁니다.


1차 시도: DB에 그냥 저장했더니

처음엔 가장 단순하게 갔습니다. 리액션이 들어오면 DB 카운트 컬럼을 올리고, 화면에는 DB에서 읽은 값을 보여주는 방식입니다.

로컬에서는 잘 됐습니다. 그런데 개발 환경에 올리니 카운트가 미묘하게 안 맞았습니다.

원인은 복제 지연(Replication Lag)이었습니다.

복제 지연이 뭐냐면

저희 DB는 쓰기 담당(Master)과 읽기 담당(Slave)이 나뉘어 있었습니다. 쓰기는 Master에 하고, 조회는 Slave에서 합니다.

문제는 Master에 적은 내용이 Slave로 베껴지는 데 시간이 걸린다는 겁니다. 방금 누른 좋아요를 바로 조회하면, Slave는 아직 그 값을 못 받아서 옛날 카운트를 돌려줍니다.

숫자 자체가 틀린 게 아니라, 읽는 시점에 최신값이 아직 안 와 있던 겁니다. 실시간으로 카운트가 보여야 하는 기능에는 치명적이었습니다.

그래서 빠르게 읽고 쓸 수 있는 Redis를 앞단에 두기로 했습니다.


2차 시도: Redis INCR — 빠른데 여전히 안 맞는다

Redis의 INCR(1 증가), DECR(1 감소)은 그 자체로 안전한 연산입니다. 한 번의 증가는 중간에 다른 요청이 끼어들 수 없습니다.

// 리액션 추가
public void addReaction(Long messageId, String emoji) {
    String key = "reaction:" + messageId + ":" + emoji;
    redisTemplate.opsForValue().increment(key);  // 원자적 증가
}

// 리액션 취소
public void removeReaction(Long messageId, String emoji) {
    String key = "reaction:" + messageId + ":" + emoji;
    redisTemplate.opsForValue().decrement(key);  // 원자적 감소
}

복제 지연 문제는 사라졌습니다. Redis에서 바로 읽으니까요. 그런데 스트레스 테스트를 돌리니 여전히 오차가 났습니다.

JMeter로 측정한 결과입니다.

  • 동시 접속 500명 → 예상 500, 실제 약 490 (2% 오차)
  • 동시 접속 1000명 → 예상 1000, 실제 약 960 (4% 오차)

확실히 줄긴 했는데, 0이 되질 않았습니다. (원인 파악 3시간, 수정 3분이었습니다.)

동료와 같이 들여다보다가 원인을 찾았습니다.

동료: “취소했다가 바로 다시 누르는 경우는 어때?” 나: “아… 그거 문제네요.”

INCR의 진짜 약점은 “누가 눌렀는지를 기억하지 못한다”는 데 있었습니다.

숫자만 세니까, 같은 사용자가 좋아요를 취소(감소)하고 곧바로 다시 누르면(증가) 그 사이에 다른 요청이 끼어들면서 값이 꼬였습니다. 게다가 한 사람이 빠르게 두 번 누르는 걸 막을 방법도 없었습니다. 숫자에는 “이미 이 사람은 눌렀음”이라는 정보가 없으니까요.

여기서 보통은 락(Lock)을 떠올립니다. “내가 카운트 고치는 동안 다른 사람 손대지 마” 하고 막는 거죠. Redis SETNX로 분산 락을 거는 게 교과서적인 답입니다.

그런데 락을 붙이면 한 번에 한 명씩만 처리되니 느려지고, 락 타임아웃·데드락·해제 보장 같은 새 문제들이 줄줄이 따라옵니다. 인기 메시지처럼 경합이 심한 곳에서는 부담이 컸습니다.

“숫자를 세는 방식 자체가 틀린 거 아닐까?” 싶었습니다.


3차 시도(해결): 숫자를 세지 말고, 명단을 만들자

핵심 발상의 전환은 이거였습니다.

“카운트를 세려고 하지 말고, 누가 눌렀는지를 저장하자.”

Redis에는 Set(집합) 자료구조가 있습니다. 집합의 성질은 단순합니다.

  • 같은 값을 여러 번 넣어도 한 번만 들어간다 (중복 자동 제거)
  • 카운트가 필요하면 집합의 크기(SCARD)를 세면 된다

좋아요를 누른 사용자 ID를 집합에 넣는 방식으로 바꿨습니다.

// 리액션 추가 — 사용자 ID를 집합에 넣는다
public void addReaction(Long messageId, String emoji, Long userId) {
    String key = "reaction:" + messageId + ":" + emoji;
    redisTemplate.opsForSet().add(key, userId.toString());  // SADD
}

// 리액션 취소 — 집합에서 뺀다
public void removeReaction(Long messageId, String emoji, Long userId) {
    String key = "reaction:" + messageId + ":" + emoji;
    redisTemplate.opsForSet().remove(key, userId.toString());  // SREM
}

// 카운트 — 집합 크기를 센다
public Long getReactionCount(Long messageId, String emoji) {
    String key = "reaction:" + messageId + ":" + emoji;
    return redisTemplate.opsForSet().size(key);  // SCARD
}

이렇게 하니 동시성 문제가 자료구조 차원에서 그냥 사라졌습니다.

같은 사용자가 좋아요를 백 번 연타해도, 집합에는 그 사람 ID가 한 번만 들어갑니다. 카운트(집합 크기)는 절대 부풀지 않습니다. 취소했다가 다시 눌러도, “넣기/빼기”가 멱등하게 동작하니 꼬일 일이 없습니다.

flowchart LR
    A["사용자 A 좋아요"] --> S["Set: {A}"]
    B["사용자 A 또 좋아요"] --> S
    C["사용자 B 좋아요"] --> S2["Set: {A, B}"]
    S --> S2
    S2 --> N["카운트 = 집합 크기 = 2"]

    style S2 fill:#eff6ff,stroke:#3b82f6
    style N fill:#f0fdf4,stroke:#22c55e

멱등성(idempotency)이 뭐냐면

“같은 동작을 여러 번 해도 결과가 한 번 한 것과 같다”는 성질입니다. 엘리베이터 버튼을 열 번 눌러도 한 번 누른 것과 같은 것처럼요.

INCR는 누를 때마다 숫자가 오르니 멱등하지 않습니다. 반면 집합에 “이 사람 눌렀음”을 넣는 건, 몇 번을 넣어도 한 명입니다. 자료구조 자체가 멱등성을 보장하는 셈입니다.

여기서 가장 좋았던 건, 락이 필요 없어졌다는 점입니다.

INCR 방식에서는 “중복을 어떻게 막지?”, “락을 걸어야 하나?”를 고민해야 했는데, 집합으로 바꾸니 그 고민 자체가 통째로 사라졌습니다. 중복 검증 로직도, 락 관리 코드도 없어서 오히려 코드가 더 단순해졌습니다.

복제 지연 대비도 같이 챙겼습니다. 평소엔 Redis 집합을 진실의 원천으로 쓰고, DB에는 비동기로 반영하는 Write-Through 구조를 뒀습니다. Redis가 잠깐 흔들려도 DB로 폴백할 수 있게요.


이런 선택지도 있었습니다

집합으로 푼 게 저희 상황엔 가장 깔끔했지만, 길이 이것만 있었던 건 아닙니다. 당시 같이 검토했던 선택지들입니다.

방식어떻게장점단점
DB Unique 제약(메시지, 사용자, 이모지)에 유니크 키를 걸고 COUNT(*)멱등성을 DB가 강제, 영속성 확실카운트마다 DB 조회 → 느림, 복제 지연 문제 다시 등장
낙관적 락버전 컬럼을 두고 충돌 시 재시도락을 점유하지 않아 가벼움경합이 심하면 재시도가 폭증
분산 락 (SETNX/Redisson)“수정 중” 표시로 한 명씩 직렬화어떤 카운트 로직이든 정확락 오버헤드·타임아웃·데드락 관리 부담
Redis Set (선택)사용자 ID를 집합에 저장, 크기로 카운트락 없이 멱등성, 코드 단순, 복제 지연 무관집합 메모리 관리, DB 영속화 별도

특히 분산 락은 가장 먼저 떠오르는 답이었지만, “문제를 락으로 누르는 것”과 “자료구조를 바꿔서 문제 자체를 없애는 것”은 결이 다릅니다. 락은 정확하지만 비용을 계속 냅니다. 집합은 한 번 잘 고르면 그 뒤로 신경 쓸 게 없습니다.

언제 락이 더 나은가

리액션처럼 “누가 했는지로 셀 수 있는” 카운트는 집합이 잘 맞습니다. 하지만 재고 차감, 잔액 변경처럼 누적 수치 자체를 정확히 더하고 빼야 하는 경우는 집합으로 표현하기 어렵습니다. 그땐 낙관적 락이나 분산 락, 혹은 DB 트랜잭션이 정답에 가깝습니다.


결과: 오차도 사라지고 코드도 단순해졌다

개발 환경 스트레스 테스트 결과입니다.

방식동시 500명동시 1000명비고
DB 직접복제 지연으로 부정확복제 지연으로 부정확실시간성 부족
Redis INCR약 490 (2% 오차)약 960 (4% 오차)중복 클릭에 취약
Redis Set500 (0%)1000 (0%)멱등·단순

극단 시나리오(대규모 팀 공지에 동시 다발 리액션)에서도 오차 0%였습니다. QA 승인을 받고 배포했고, 이후 운영에서도 카운트 불일치 신고는 없었습니다.

가장 만족스러웠던 건 성능이 아니라 코드가 줄었다는 점이었습니다. 락 획득·해제·재시도·타임아웃을 다루던 코드가 통째로 사라지고, 집합에 넣고 빼고 크기를 세는 세 줄짜리 메서드만 남았습니다.


시스템 점검 체크리스트

비슷한 카운트 동시성 문제를 만났을 때 저는 이 순서로 점검합니다.

  • 이 카운트는 “누가 했는지”로 셀 수 있는가? (그렇다면 집합을 먼저 검토)
  • DB에서 읽는다면, 복제 지연으로 옛 값을 보고 있지는 않은가?
  • 숫자 카운터(INCR)를 쓴다면, 같은 사용자의 중복을 막을 방법이 있는가?
  • 락을 붙이기 전에, 자료구조를 바꿔서 문제를 없앨 수 있는지 먼저 따져봤는가?
  • Redis를 진실의 원천으로 쓴다면, 장애 시 DB 폴백·영속화 전략이 있는가?

마무리

처음엔 “락을 어떻게 잘 걸지”만 고민했습니다. 분산 락 코드를 다 짜놓고 데드락·타임아웃까지 챙기다가, 동료의 한마디로 방향을 틀었습니다. “근데 이거 숫자를 세야 하는 문제가 맞아?”

리액션은 결국 “누가 눌렀는가”의 모음이고, 그걸 집합으로 표현하니 멱등성·중복 방지·카운트가 한 번에 풀렸습니다. 락으로 누르려던 문제가 자료구조를 바꾸자 처음부터 없던 문제가 됐습니다.

그 뒤로 동시성 이슈를 만나면 락부터 꺼내기 전에 한 번 물어봅니다. “이거 자료구조를 바꾸면 그냥 사라지는 문제 아닌가?”

다음 글에서는 같은 Redis로 겪었던 다른 삽질, TTL을 빠뜨려서 캐시가 영원히 안 죽던 이야기를 다뤄보겠습니다.


읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site