본문 바로가기

개발 일기

🔒 Redisson 분산락으로 사용자 매칭 중복 생성 문제 해결기

🙋🏻 문제 상황: 동시에 누른 "좋아요"가 만들어낸 버그

저희 서비스는 두 사용자가 서로 좋아요를 눌렀을 때 매칭을 성립시키고, 채팅방을 생성해 연결해주는 구조입니다.

좋아요 요청이 들어오면 서버에서는 아래와 같은 데이터를 처리합니다:

  • UserLike: 좋아요 상태, 매칭 여부
  • UserJoinChatRoom: 유저-채팅방 연결
  • ChatRoom: 채팅방 정보

서버의 매칭 판별 로직은 UserLikeJpaAdapter 에서 다음과 같은 흐름을 따릅니다:

  1. 기존에 좋아요한 적이 있는지 조회 (있다면 수정, 없으면 생성)
  2. 좋아요 상태 업데이트
  3. 상대방 차단 여부 확인
  4. 상대방도 나를 좋아요했는지 확인 → 매칭 여부 결정
  5. 결과를 반환

더보기

UserLikeJpaAdapter 의 메서드 정리

 

findOrCreate()

   - UseLikeService 로부터 sendLikeOrDislike 요청을 수신하면 UserLike 테이블을 조회해서 기존에 동일 사용자를 대상으로 좋아요/싫어요를 누른 적이 있지 확인한다.

   -  있다면 해당 데이터를 불러오고 없다면 새로운 레코드를 생성한다.

 

updateLikeStatus()

   - 수신한 요청을 보고 좋아요 레코드를 업데이트한다.

 

isBlockedByTarget()
   - 상대방에게 차단 당하지 않았는지 확인하고 차단 당한 상태라면 저장 후 Service 로 반환한다.

   - 차단 당하지 않았다면 아래로 계속 진행된다.

 

shouldMatch() -> applyMatch()

   - 상대방이 남긴 좋아요 기록을 조회하여 서로 좋아요 요청을 보낸 상태인지 확인한다.

   - 쌍방 좋아요 상태라면 해당 좋아요 레코드의 is_matched 필드를 true 로 수정한다.

 

 

 

해당 기능 QA 과정에서 좋아요와 채팅방이 중복 생성되는 문제가 발견되었습니다.

 

Swift에서 연속 클릭 방지 처리는 되어 있었지만
두 사용자가 거의 동시에 서로 좋아요를 누르는 경우
서버는 이 요청들을 별개로 처리했고, 결과적으로 같은 사용자 조합으로 매칭 및 채팅방이 여러 개 생성되는 현상이 발생했습니다.

 

🚫 단순 쿼리로는 부족했다

처음에는 JPA DISTINCT를 사용해 중복을 걸러보려 했습니다.

 

그러나 이는 조회 결과에 대한 후처리에 불과했고,
실제로 중복 저장 자체를 방지하지는 못했습니다.

 

또한 일부 비즈니스 로직에서는 단일 결과를 가정하고 동작했기 때문에,
중복 조회가 발생하면 예외가 발생하는 구조적인 불안정성도 있었습니다.


⚠️ 트랜잭션만으로는 부족한 이유

@Transactional 어노테이션이 붙어 있어도,
동시에 들어온 두 요청은 서로의 변경사항을 인식하지 못한 채 각자의 트랜잭션을 독립적으로 수행합니다.

이로 인해:

  • 서로를 좋아요한 상태임에도 is_matched 가 갱신되지 않거나
  • 후처리(채팅방 생성, 알림 등)가 이중 실행되는 문제가 반복적으로 발생했습니다.

 

 

2. Redis 기반 분산락 도입 고민

 

동시 요청을 서버 단에서 제어하기 위한 방법으로 다음과 같은 방법들을 생각해봤습니다.

방법 장점 단점
synchronized 키워드 코드 단에서 간단 클러스터 환경에서 무효
DB Unique 제약 RDB 자체 보장 트랜잭션 충돌, 롤백 부하 발생 우려
Redis 분산락 ✅ 분산 환경에서도 유효 설정 및 락 설계 필요

 

 

- Redis를 활용한 분산락 도입
이미 서비스에서 Redis를 사용하고 있었기 때문에  분산 환경에서도 일관성을 보장할 수 있는 Redis 기반의 락 처리를 선택했습니다.

 

🔍 Redis 클라이언트 비교

클라이언트 특징 단점
RedisTemplate Spring 기본 제공 락 직접 구현 필요
Lettuce 논블로킹, 스핀락 방식 스핀락 방식 → Redis에 지속적인 부하 발생
Redisson ✅ 고수준 API 제공, Pub/Sub 기반 효율적 대기 외부 의존성 추가 필요


➡️ Redisson을 선택한 이유는 다음과 같습니다.

  • 락이 점유된 상황에서도 `SETNX`를 반복 호출하지 않는다
  • Pub/Sub 기반으로 락 해제를 기다리기 때문에 리소스 효율이 높다
  • 클러스터 환경에서도 안정적으로 작동한다

⚙️ Redisson 분산락 적용 방식

1. 사용자 조합 기준으로 Lock Key 생성

lock:userLike:create:{userId1}:{userId2}

 

userId 가 작은 값이 항상 {userId1} 에 들어가고

userId 가 큰 값이 항상 {userId2} 에 들어가도록 하여 동일 사용자 조합에서 항상 동일한 키를 사용하도록 했습니다.

 

2. 분산락 서비스 구현

public boolean getLock(String lockKey) {
    RLock lock = redissonClient.getLock(lockKey);
    return lock.tryLock(4, 3, TimeUnit.SECONDS); // 4초 대기, 3초 유지
}

 

3. 매칭 로직에 분산락 적용

if (!redissonLockService.getLock(lockKey)) {
    throw new ConflictException(FailureCode.DUPLICATE_LOCK);
}

try {
    if (sendLikeOrDislike(...).isMatched()) {
        // 채팅방 생성, 포트폴리오 조회, 알림 비동기 처리
    }
} finally {
    redissonLockService.release(lockKey);
}

 


✅ 테스트 코드로 신뢰성 확보

  • RedissonLockService에 대한 단위 테스트 (락 획득/해제/예외)
  • LikeService에 대해 락 충돌, 매칭 성공/실패 흐름 검증
  • ConflictException 발생 시 프론트에 FailureCode.DUPLICATE_LOCK 응답

락이 항상 try-finally 구조로 해제되도록 설계하여 자원 누수도 방지했습니다.


📘 핵심 용어

용어 설명
SETNX Redis에 해당 데이터가 '존재하지 않을 경우 저장' 명령어
leaseTime 락 유지 시간 (자동 해제 시점)
waitTime 락 획득을 위해 기다릴 최대 시간
스핀락 락이 풀릴 때까지 반복 시도하는 방식 (CPU 사용 높음)
Pub/Sub Redisson이 락 해제 시 알림 받는 구조
ConflictException 락 충돌 시 서버가 프론트로 전달하는 예외

🎯 회고

이번 경험을 통해 단순한 기능 구현을 넘어서,
운영 환경에서 발생할 수 있는 실제 동시성 이슈를 고려하고 해결하는 역량을 키울 수 있었습니다.

 

특히 사용자를 위한 응답 메시지, 프론트와의 명확한 오류 코드 연동,
그리고 테스트 코드까지 포함해서 서비스 품질을 한 단계 더 끌어올렸다는 점이 만족스럽습니다.

 

무심코 넘어가는 기능 하나하나도,
잘 배포된 서버 뒤에는 개발자들의 수많은 고민과 설계가 녹아 있다는 걸 실감하고,
제 코드에 대한 책임감도 더 커졌던 경험이었습니다.