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

목차
QA 스트레스 테스트에서 좋아요 카운트가 예상보다 적게 나왔습니다. DB에서 읽으면 복제 지연(Replication Lag)으로 옛 값을 보았고, Redis 숫자 카운터(INCR)로 바꾸고 나서도 동시 500명에서 2%, 1000명에서 4% 오차가 남았습니다. 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이 되질 않았습니다.
동료와 같이 들여다보다가 원인을 찾았습니다.
동료: “취소했다가 바로 다시 누르는 경우는 어때?” 나: “아… 그거 문제네요.”
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-Behind(Write-Back) 구조를 뒀습니다. Redis가 잠깐 흔들려도 DB로 폴백할 수 있게요.