들어가며
이전 글(https://kongdevlog.tistory.com/19)에서는 행 단위 잠금만으로 모든 동시성 문제를 해결할 수 있을 것 같다고 생각을 하였습니다. 하지만 이번 기능을 구현하며, 잘못된 생각이었다는 것을 깨닫게 되었습니다.
문제 상황
사용자 A가 다른 사용자 B에게 매칭을 요청하는 기능을 맡아 구현하게 되었습니다. 따라서 저는 아래와 같은 플로우로 코드를 구현하게 되었습니다.
1. A.id 와 B.id 둘 사이의 진행중인 매칭이 존재하는지 확인.
2 - 1. 매칭이 존재한다면 예외를 반환
2 - 2. 매칭이 존재하지 않다면, 매칭 생성.
이 과정을 단순히 하나의 트랜잭션으로 구현하였습니다.
@Transactional
public void request(Long requesterId, Long responderId, String requestMessage) {
if (existsMutualMatch(requesterId, responderId)) {
throw new ExistsMatchException();
}
Match match = Match.request(requesterId, responderId, Message.from(requestMessage));
matchRepository.save(match);
});
}
위의 코드는 어떤 상황에서 문제를 발생시킬 수 있는 것인지를 그림으로 보여 드리겠습니다.

그림은 두 유저 A,B가 서로에게 매칭을 요청하는 경우입니다. 이때, 두 유저가 결과를 조회하는 시점에 매칭이 존재하지 않기 때문에 두 유저 사이에 매칭이 두 개가 생성되는 문제가 발생하게 됩니다.
해결책 탐색
1. 낙관적 락
유저 B가 변경을 감지할 수만 있다면, 예외를 반환하도록 처리할 수 있지 않을까? 라고 생각하였습니다. 하지만 이 방식은 말 그대로 동일한 행의 변경을 감지하는 것이기 때문에 지금과 같은 삽입 연산에서는 얻기 어려웠습니다.
2. 비관적 락
이 또한 마찬가지로 행 잠금을 통해 동시성을 제어하는 방식이지만, 삽입 연산에서는 행 잠금의 대상이 트랜잭션 시작 시점에서는 존재하지 않기 때문에 효과를 얻을 수 없었습니다.
3. 분산락(Redisson) & 네임드락
따라서 위의 1,2 방법 대신 어플리케이션 레벨의 분산락을 적용하려고 결정하였습니다. 그러나 팀원 중 한분께서는 단일 서버 환경에서 굳이 분산락을 사용할 이유가 있을까요? 라고 말씀을 해주셨고, 저는 그 부분에 어느정도 동의하였습니다. 따라서 MySQL에서 제공하는 네임드락을 최종적으로는 사용하도록 채택하였습니다.
구현
네임드락은 키 값을 기반으로 잠금을 획득하는 방식입니다. 이때 잠금의 키 값으로 요청자ID 와 응답자 ID를 기반으로 생성을 해야하는데,
그림과 같이 사용자 A와 사용자 B가 각각 서로에게 요청하는 상황에서 동일한 키 값을 만들 수 있도록 하기 위하여 아이디 값의 크기 순으로 정렬을 하여 키를 생성하도록 하였습니다.
private String generateKey(Long requesterId, Long responderId) {
return Math.max(requesterId, responderId) + "" + Math.min(requesterId, responderId);
}
또한, 하나의 트랜잭션에서 트랜잭션 시작 - 잠금 - 매칭 조회 - 매칭 저장 - 잠금 해제 - 트랜잭션 종료(커밋) 으로 작업이 이루어지게 된다면 트랜잭션이 종료가 되기도 전에 잠금이 해제되므로 결국 동시성 문제가 다시 발생하게 되었습니다. 따라서 비즈니스 로직 중 매칭 저장 부분을 다른 트랜잭션으로 생성하여 트랜잭션을 작성하였습니다.
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Match match) {
matchJpaRepository.save(match);
}
이를 위해, 전파 옵션을 REQUIRES_NEW 로 설정해주었습니다.
전체 코드는 아래와 같습니다.
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Match match) {
matchJpaRepository.save(match);
}
@Override
public boolean existsActiveMatchBetween(Long idOne, Long idTwo) {
return matchJpaRepository.existsActiveMatchBetween(idOne, idTwo);
}
@Override
public void withNamedLock(String key, Runnable action) {
try {
matchJdbcRepository.getLock(key);
action.run();
} finally {
matchJdbcRepository.releaseLock(key);
}
}
@Transactional
public void request(Long requesterId, Long responderId, String requestMessage) {
String key = generateKey(requesterId, responderId);
matchRepository.withNamedLock(key, () -> {
if (existsMutualMatch(requesterId, responderId)) {
throw new ExistsMatchException();
}
Match match = Match.request(requesterId, responderId, Message.from(requestMessage));
matchRepository.save(match);
});
}
테스트 : 3개의 요청을 동시에 보낸 뒤.

해당 로그를 보시면, 83번 쿼리가 락을 얻은 뒤 매치 생성을 위한 새로운 트랜잭션(84번)을 생성하여 커밋을 한 뒤 락을 해제하여 해당 트랜잭션을 해제하였습니다. 이후 82번, 81번 쿼리는 각각의 트랜잭션에서는 이미 매칭이 존재하기 때문에 락을 해제하며 rollback 이 발생하였습니다.
느낀점
하나의 매칭 요청에 대해서 네임드락을 사용하기 위한 트랜잭션 세션 1개와 비즈니스 로직을 위한 트랜잭션 세션 1개 즉, 최대 총 2개의 MySQL 세션을 사용하게 됩니다. 이 경우에는 잠금을 위해 대기하는 상황이 2개의 요청뿐이기 때문에 큰 문제가 되진 않지만, 만약 여러 요청이 중복된 키에 대해 대기를 하게 되면 세션 풀이 고갈되는 문제가 생길 수도 있을 것 같습니다. => 이 경우에는 Redis 를 사용하는 것이 좋을 것 같습니다!
참고 : https://techblog.woowahan.com/2631/
MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리 | 우아한형제들 기술블로그
안녕하세요. 비즈인프라개발팀 권순규입니다. 현재 광고시스템에서 사용하고 있는 MySQL을 이용한 분산락에 대해 설명드리고자 합니다. 분산락을 적용하게된 원인 현재 테이블은 아래 그림과 같
techblog.woowahan.com
'Back-End' 카테고리의 다른 글
| [DB & JPA] 무한 스크롤 계층형 댓글 구현하기 (0) | 2025.05.24 |
|---|---|
| [팀 프로젝트 & DB] 쿼리 속도 개선하기. (0) | 2025.04.16 |
| [Java] GC에 대해서 살펴보기. (0) | 2025.03.29 |
| [DB & 팀 프로젝트] 실행 계획을 통해 인덱스 걸어보기! (0) | 2025.03.04 |
| [DB] MySQL NamedLock vs Redisson 락 관리. (0) | 2025.02.25 |
댓글