(메신저개발) 메시지 좋아요 카운트, 레디스 락 대신 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 Set | 500 (0%) | 1000 (0%) | 멱등·단순 |
극단 시나리오(대규모 팀 공지에 동시 다발 리액션)에서도 오차 0%였습니다. QA 승인을 받고 배포했고, 이후 운영에서도 카운트 불일치 신고는 없었습니다.
가장 만족스러웠던 건 성능이 아니라 코드가 줄었다는 점이었습니다. 락 획득·해제·재시도·타임아웃을 다루던 코드가 통째로 사라지고, 집합에 넣고 빼고 크기를 세는 세 줄짜리 메서드만 남았습니다.
시스템 점검 체크리스트
비슷한 카운트 동시성 문제를 만났을 때 저는 이 순서로 점검합니다.
- 이 카운트는 “누가 했는지”로 셀 수 있는가? (그렇다면 집합을 먼저 검토)
- DB에서 읽는다면, 복제 지연으로 옛 값을 보고 있지는 않은가?
- 숫자 카운터(INCR)를 쓴다면, 같은 사용자의 중복을 막을 방법이 있는가?
- 락을 붙이기 전에, 자료구조를 바꿔서 문제를 없앨 수 있는지 먼저 따져봤는가?
- Redis를 진실의 원천으로 쓴다면, 장애 시 DB 폴백·영속화 전략이 있는가?
마무리
처음엔 “락을 어떻게 잘 걸지”만 고민했습니다. 분산 락 코드를 다 짜놓고 데드락·타임아웃까지 챙기다가, 동료의 한마디로 방향을 틀었습니다. “근데 이거 숫자를 세야 하는 문제가 맞아?”
리액션은 결국 “누가 눌렀는가”의 모음이고, 그걸 집합으로 표현하니 멱등성·중복 방지·카운트가 한 번에 풀렸습니다. 락으로 누르려던 문제가 자료구조를 바꾸자 처음부터 없던 문제가 됐습니다.
그 뒤로 동시성 이슈를 만나면 락부터 꺼내기 전에 한 번 물어봅니다. “이거 자료구조를 바꾸면 그냥 사라지는 문제 아닌가?”
다음 글에서는 같은 Redis로 겪었던 다른 삽질, TTL을 빠뜨려서 캐시가 영원히 안 죽던 이야기를 다뤄보겠습니다.
