A→B, B→A 동시 스와이프 시 중복 매칭과 데드락이 발생하는 구조적 문제를 Redisson 분산 락과 ID 정렬 기반 단일 락 키로 해결한 과정.
배경 — 양방향 매칭의 구조
CONTACTO는 프로젝트 기반 협업 매칭 플랫폼입니다. 사용자가 다른 사용자의 포트폴리오를 보고 LIKE/DISLIKE를 선택하면, 양쪽 모두 LIKE일 때 자동으로 매칭이 성립되고 1:1 채팅방이 생성됩니다.
스와이프 요청은 다음과 같은 흐름으로 처리됩니다.
graph LR
Client["Client"] -->|POST /likes| Service["UserLikeService"]
Service --> Match["매칭 판정"]
Match -->|매칭 성립| Chat["채팅방 생성"]
Match -->|매칭 성립| Notify["알림 전송"]
핵심은 매칭 판정 로직입니다.
private boolean shouldMatch(UserLikeJpaEntity current, Long targetId, Long sourceId) {
if (current.getLikeStatus() == LikeStatus.DISLIKE) return false;
if (current.isMatched() && current.getLikeStatus() == LikeStatus.LIKE) {
return false;
}
return userLikeRepository.findOppositeLike(targetId, sourceId, LikeStatus.LIKE) != null;
}
private void applyMatch(UserLikeJpaEntity current, Long targetId, Long sourceId) {
current.setMatched(true);
UserLikeJpaEntity opposite = userLikeRepository
.findOppositeLike(targetId, sourceId, LikeStatus.LIKE);
opposite.setMatched(true);
userLikeRepository.save(opposite);
}
A가 B를 LIKE하면, B→A 방향의 LIKE 레코드가 이미 존재하는지 조회합니다. 존재하면 양쪽 모두 matched = true로 업데이트하고 채팅방을 생성합니다.
단일 요청 환경에서는 문제없이 작동합니다. 문제는 A→B와 B→A가 동시에 들어올 때 발생합니다.
문제 정의 — 동시 스와이프가 만드는 두 가지 함정
문제 1: 중복 매칭 (Race Condition)
A와 B가 거의 동시에 서로를 LIKE한다고 가정합니다.
sequenceDiagram
participant A as Thread A (User A→B)
participant DB as Database
participant B as Thread B (User B→A)
A->>DB: A→B LIKE 저장
B->>DB: B→A LIKE 저장
A->>DB: findOppositeLike(B, A) → B→A LIKE 발견!
B->>DB: findOppositeLike(A, B) → A→B LIKE 발견!
A->>DB: 양쪽 matched = true
B->>DB: 양쪽 matched = true (중복!)
A->>A: 채팅방 생성 #1
B->>B: 채팅방 생성 #2 (중복!)
두 스레드 모두 shouldMatch()에서 상대방의 LIKE를 발견합니다. 매칭이 2번 판정되고, 채팅방이 2개 생성됩니다. 전형적인 check-then-act race condition입니다.
문제 2: 데드락 (Circular Wait)
“그럼 분산 락을 걸면 되지 않나?”라는 생각이 자연스럽게 떠오릅니다. 하지만 단순하게 구현하면 또 다른 함정에 빠집니다.
시도 1 — 방향별 락 키
A→B 스와이프 시 lock(A, B), B→A 스와이프 시 lock(B, A)로 각각 다른 키를 만든다고 가정합니다.
sequenceDiagram
participant A as Thread A (User A→B)
participant Redis as Redis Lock
participant B as Thread B (User B→A)
A->>Redis: lock("lock:A:B") 획득
B->>Redis: lock("lock:B:A") 획득
Note over A,B: 서로 다른 락 - race condition 해결 실패!
두 스레드가 서로 다른 락을 획득하므로 상호 배제가 성립하지 않습니다. Race condition이 그대로 남습니다.
시도 2 — 양쪽 모두 락
그렇다면 두 사용자 모두에 대해 락을 걸면 어떨까요?
sequenceDiagram
participant A as Thread A (User A→B)
participant Redis as Redis Lock
participant B as Thread B (User B→A)
A->>Redis: lock(A) 획득
B->>Redis: lock(B) 획득
A->>Redis: lock(B) 대기 (Thread B가 보유)
B->>Redis: lock(A) 대기 (Thread A가 보유)
Note over A,B: Deadlock - Circular Wait
Thread A는 lock(B)를 기다리고, Thread B는 lock(A)를 기다립니다. 교과서적인 데드락(circular wait) 입니다.
정리하면, 양방향 매칭 시스템의 동시 스와이프는 두 가지 함정을 동시에 만듭니다:
| 함정 | 원인 | 결과 |
|---|---|---|
| 중복 매칭 | check-then-act 사이의 갭 | 채팅방 2개 생성 |
| 데드락 | 락 획득 순서가 요청 방향에 의존 | 두 스레드 무한 대기 |
접근 방법 선택
세 가지 대안을 비교했습니다.
| 대안 | 장점 | 단점 |
|---|---|---|
| DB Unique Constraint | 구현 단순 | 채팅방 생성(Feign 호출)은 DB 제약으로 막을 수 없음 |
| Application-level Lock | 추가 인프라 불필요 | 멀티 인스턴스 환경에서 무효 |
| Distributed Lock (Redisson) | 멀티 인스턴스에서도 작동 | Redis 의존성 추가 |
DB Unique Constraint만으로는 매칭 레코드의 중복은 막을 수 있어도, 매칭 판정 → 채팅방 생성이라는 복합 연산의 원자성을 보장할 수 없습니다. Application-level Lock은 단일 인스턴스에서만 유효합니다. user-service는 멀티 인스턴스로 운영되므로 Redisson 분산 락이 유일한 선택이었습니다.
Ordered Lock Key — 핵심 설계
데드락과 race condition을 동시에 해결하는 열쇠는 항상 같은 순서로 같은 키를 만드는 것입니다.
private String buildLockKey(Long userId1, Long userId2) {
return (userId1 <= userId2)
? "lock:userLike:create:" + userId1 + ":" + userId2
: "lock:userLike:create:" + userId2 + ":" + userId1;
}
두 사용자 ID 중 작은 값을 항상 앞에 놓습니다. A→B든 B→A든 결과는 동일한 락 키입니다.
User 5 → User 12 스와이프: lock:userLike:create:5:12
User 12 → User 5 스와이프: lock:userLike:create:5:12 ← 동일한 키!
이 단순한 정렬 하나로 두 가지 문제가 모두 해결됩니다:
- Race Condition 해결: 양방향 스와이프가 동일한 락을 경합하므로, 한 쪽이 완료될 때까지 다른 쪽은 대기합니다. check-then-act가 원자적으로 실행됩니다.
- 데드락 방지: 모든 스레드가 항상 같은 키로 같은 하나의 락을 요청하므로, circular wait 조건 자체가 성립하지 않습니다.
sequenceDiagram
participant A as Thread A (User A→B)
participant Redis as Redis Lock
participant B as Thread B (User B→A)
Note over Redis: 동일 키: lock:userLike:create:A:B
A->>Redis: lock("lock:userLike:create:A:B") 획득
B->>Redis: lock("lock:userLike:create:A:B") 대기
A->>A: shouldMatch / applyMatch / 채팅방 생성
A->>Redis: unlock
B->>Redis: lock 획득
B->>B: shouldMatch / 이미 matched / skip
B->>Redis: unlock
Thread B가 락을 획득했을 때는 이미 매칭이 완료된 상태입니다. shouldMatch()에서 current.isMatched() == true이므로 중복 매칭이 발생하지 않습니다.
구현 상세
RedissonLockService
@Service
@Slf4j
@RequiredArgsConstructor
public class RedissonLockService {
private final RedissonClient redissonClient;
public boolean tryAcquireLock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(4, 3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
log.warn("락 획득 중 인터럽트 발생: {}", lockKey, e);
return false;
}
}
public void release(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
tryLock(4, 3, TimeUnit.SECONDS) — 최대 4초 동안 락 획득을 시도하고, 획득 후 3초가 지나면 자동으로 해제됩니다.
| 파라미터 | 값 | 의미 |
|---|---|---|
| waitTime | 4초 | 상대 스레드의 매칭 처리 완료를 기다리는 시간 |
| leaseTime | 3초 | 비정상 종료 시 락이 영원히 남지 않도록 하는 안전장치 |
isHeldByCurrentThread() 체크는 중요합니다. leaseTime 만료로 이미 해제된 락을 다른 스레드가 보유 중일 때, 잘못 unlock하는 것을 방지합니다.
UserLikeService — 전체 플로우
public LikeResponse sendLikeOrDislike(Long userId, Long likedUserId, LikeStatus status) {
// 1. 스와이프 제한 검증
boolean hasPortfolioImage = userPortfolioRepository.existsByUserId(userId);
if (!hasPortfolioImage) {
if (!userSwipeLimitUseCaseService.canSwipe(userId)) {
throw new BadRequestException(FailureCode.SWIPE_LIMIT_EXCEEDED);
}
}
// 2. 분산 락 획득 — ordered key
String lockKey = buildLockKey(userId, likedUserId);
boolean lockAcquired = redissonLockService.tryAcquireLock(lockKey);
if (!lockAcquired) throw new ConflictException(FailureCode.DUPLICATE_LOCK);
// 3. 매칭 판정 + 후속 처리
try {
UserLike userLike = sendLikeOrDislikeUseCase
.sendLikeOrDislike(userId, likedUserId, status);
if (!hasPortfolioImage) {
userSwipeLimitUseCaseService.recordSwipe(userId);
}
if (userLike.isMatched()) {
return matchedPortfolioAndChatRoom(userId, likedUserId);
} else if (userLike.getLikeStatus().equals(LikeStatus.LIKE)) {
likeNotificationUseCase.likeAlarm(userId, likedUserId);
}
} finally {
// 4. 반드시 락 해제
redissonLockService.release(lockKey);
}
return LikeResponse.of(false, null, null);
}
세 가지 설계 포인트가 있습니다.
try-finally 패턴. 매칭 판정, 채팅방 생성, 알림 전송 중 어디서 예외가 발생하든 finally 블록에서 반드시 락을 해제합니다. 이 패턴이 없으면 예외 발생 시 락이 leaseTime(3초)까지 남아 다른 요청을 불필요하게 차단합니다.
락 범위 설계. 락은 매칭 판정뿐만 아니라 채팅방 생성(Feign 호출)과 알림 전송까지 감싸고 있습니다. 매칭 판정만 보호하면 채팅방이 중복 생성될 수 있기 때문입니다. 복합 연산 전체가 분산 락 안에서 원자적으로 실행됩니다.
Fail-fast. 4초 동안 락을 획득하지 못하면 409 Conflict를 반환합니다.
if (!lockAcquired) throw new ConflictException(FailureCode.DUPLICATE_LOCK);
무한 대기 대신 빠른 실패를 선택한 이유는, 사용자 경험 관점에서 4초 이상 응답이 없는 것보다 재시도 유도가 낫기 때문입니다.
결과
| 항목 | Before | After |
|---|---|---|
| 중복 매칭 | 발생 가능 | 0건 |
| 데드락 | 발생 가능 | 0건 |
| 중복 채팅방 | 발생 가능 | 0건 |
| 최대 대기 시간 | - | 4초 (waitTime) |
| 락 보유 시간 | - | 최대 3초 (leaseTime) |
스와이프 → 매칭 → 채팅방 생성이라는 복합 연산 전체가 분산 락 안에서 원자적으로 실행됩니다. 실제 매칭 처리 시간은 waitTime보다 훨씬 짧으므로 사용자 체감 영향은 미미합니다.
되돌아보며
양방향 관계에서의 동시성 문제는 매칭 시스템뿐만 아니라 팔로우/팔로잉, 친구 추가, 좋아요 등 다양한 도메인에서 동일한 패턴으로 나타납니다.
Ordered Lock Key 패턴: 양방향 관계에서 두 엔티티의 ID를 정렬하여 단일 락 키를 만들면, race condition과 데드락을 동시에 방지할 수 있다.
이 패턴이 작동하는 이유는 데드락의 4가지 필요조건 중 circular wait를 원천적으로 제거하기 때문입니다. 모든 스레드가 항상 같은 순서(min ID → max ID)로 리소스를 요청하면, 순환 대기는 구조적으로 불가능합니다.
코드 한 줄의 정렬(userId1 <= userId2)이 두 가지 동시성 문제를 한 번에 해결했습니다. 때로는 가장 단순한 해법이 가장 견고합니다.