Introduce
본 게시글에서 다루는 모든 테스트 코드는 아래 레포지토리에서 확인하실 수 있습니다.
GitHub - WooJJam/concurrency-deep-dive
Contribute to WooJJam/concurrency-deep-dive development by creating an account on GitHub.
github.com
풋살 매치 참가 신청 서비스를 개발하던 중, 매치의 정원을 초과하여 참가자가 등록되는 문제를 발견하였다. 실제로 매치에는 최대 12명이 참여 가능했으나, 동시 요청이 몰릴 경우 12명을 훌쩍 넘는 인원이 등록 되었다.
단일 사용자의 신청 흐름은 단순하다.
- 풋살 매치 조회
- 현재 참가자 수 확인
- 참여 가능하다면 참가자 Insert
- PG사 결제 API 호출
- 결제 성공 및 실패시 상태 update

문제는 참여 인원을 확인하고, 새로운 참가자를 등록할 때 발생한다. TX 1에서 현재 참가자 수를 확인하고, 새로운 참가자를 insert 한 뒤에 commit을 진행한다. 이때 TX 2가 TX 1의 commit되기 전 데이터를 조회하여 새로운 참가자를 추가하기에 실제로 매치에 참여한 인원은 12명이 되어야 하지만 데이터베이스에는 11명으로 저장되는 문제가 발생할 수 있다.
이 글에서는 해당 문제를 해결하기 위해 거쳤던 여러 방법들과 한계, 그리고 최종 선택지인 MySQL로 분산 락을 구축한 과정을 다룬다.
Requirements
매치 참가 신청을 설계할 때 두 가지 시나리오를 고려하였다.
- 인원 확인 -> 참여 인원 추가 -> 결제 API 호출
- 인원 확인 -> 결제 API 호출 -> 결제 완료 시 참여 인원 추가
시나리오 2는 결제가 완료된 후 참여 인원을 추가한다. 이때 인원 확인 후 참여 가능하다면 결제 API를 바로 호출하는데, TX1과 TX2과 동시에 진행 될 경우 결제가 완료된 후 동시성을 검증해야 한다. 이 경우 사용자 입장에서는 결제가 완료되었다가 취소되거나 결제창은 호출되었지만 결제는 실패하는 등 혼란을 줄 수 있다. 따라서 인원을 먼저 확인한 뒤, 자리를 선점하고 결제를 진행한다면 정원 초과로 인한 결제 실패는 사전에 차단되게 된다.
// MatchService.java
@Transactional
public void joinMatch(final Long matchId, final Long userId) {
FutsalMatch futsalMatch = matchRepository.findById(matchId)
.orElseThrow(() -> new IllegalArgumentException("매치를 찾을 수 없습니다."));
if (futsalMatch.isApply()) { // ← check
matchParticipantRepository.save( // ← act
MatchParticipant.builder()
.status(ParticipantStatus.PENDING)
.matchId(matchId)
.userId(userId)
.build()
);
}
}
고전적인 check-then-act 경쟁 조건이다. 여러개의 요청이 동시어 `isApply()` 를 통과하면서 정원 미달이라고 판단하고, 각자 insert를 진행하게 된다. 결과적으로 정원을 초과한 참가자가 등록될 수 있는 것이다
Concurrency Strategy
그렇다면 이러한 동시성 문제를 해결할 방식을 찾아야 한다. 이와 관련하여 아래글을 읽고 오는것이 도움이 될 것이다.
동시성 문제에 대한 고찰, 점진적으로 접근하기
🔎 본 글을 읽기 앞서 해당 게시글을 먼저 참고 해주시길 바랍니다.해당 게시글은 추후 내용이 변경될 수 있습니다. 해당 게시글에서 설명하지 않는 사전 지식들은 이미 알고있다고 가정하고
woojjam.tistory.com
📌 Synchronized, ReentrantLock
`synchronized` 는 JVM 내부의 모니터 락을 기반으로 동작하는 동기화 메커니즘이다. 그리고 `ReentrantLock`은 `java.util.concurrent.locks`에서 제공하는 명시적 락이다. 두 방식 모두 스레드 동기화를 위한 메커니즘이라는 것은 동일하지만 ReentrantLock은 좀 더 세밀한 제어가 가능하다는 특징이 있다.
하지만 두 방식 모두 JVM 내부에서 스레드 제어를 위한 메커니즘이므로 서버가 2대 이상이면 각 JVM이 독립적인 락을 가지기 때문에 서버 간 동기화가 불가능하다라는 한계가 있다.
현재 우리 서버는 ALB를 통해 2개의 가용영역으로 인스턴스가 분리되어 있기 때문에 해당 방식으로는 동시성을 제어할 수 없다.
📌 Optimistic Lock, Pessimistic Lock
Optimistic Lock은 낙관적 락으로 대부분의 경우 충돌이 발생하지 않을 것이라는 전제 하에 락을 걸지 않고 작업을 진행한 뒤 최종적으로 version을 통해 충돌을 확인하는 방식이다. 그리고 Pessimistic Lock은 비관적 락으로 데이터를 조회하거나 수정할 때 다른 트랜잭션이 해당 레코드에 접근하지 못하도록 락을 설정한다. 이로 인해 레코드를 수정하는 동안에는 다른 트랜잭션이 해당 데이터에 접근할 수 없어 동시성을 제어할 수 있는 방식이다.
두 가지 방식 모두 레코드 기반 락이다. 따라서 동시성을 제어하기 위해서는 잠글 레코드가 있어야 한다. 하지만 insert에 대해서 동시성으 제어할 경우 락을 적용할 레코드가 아직 존재하지 않는다. 따라서 삽입 연산에 대한 동시성을 제어하고자 할 때는 레코드 기반 락으로는 해결할 수가 없다. 하지만 그렇다고 아예 구현이 불가능한 것은 아니다. currentCount와 같이 비교할 칼럼을 명시적으로 두거나 다른 entity의 레코드를 잠궈 직렬화 하는 방식으로 구현은 할 수 있다.
현재 개발하고자 하는 기능은 풋살 매치에 사용자들이 참여를 신청하는 기능이다. 서비스의 특성상 풋살 매치는 마감 임박 시 일시적으로 신청이 몰릴 수 있다. 낙관적 락은 충돌이 드물다는 전제 하에 유효한 전략인데 만약 충돌이 잦아지면 재시도가 증가하고, 재시도 로직을 직접 작성해야 하는 등 복잡도도 증가하게 된다. 또한 비관적 락의 경우 서비스 로직이 복잡해지고, 락이 많아질수록 데드락 발생 가능성이 증가하며 해당 레코드에 대해서 잠금을 걸기 때문에 다른 트랜잭션에세 해당 레코드에 접근할 경우 블로킹이 발생할 수도 있다.
이러한 이유들로 레코드 기반 락 대신 분산락을 적용하였다.
What Is Distributed Lock?

JVM 락은 단일 인스턴스에서만 유효했고, 레코드 기반 락은 구조적인 문제가 있었다. 최종적으로 현재 필요한 것은 여러 서버 인스턴스가 공유할 수 있으면서, 데이터의 정합성을 보장하는 방식이 필요하다.
분산락이란 여러 인스턴스가 분산 환경에서 동시에 공유 자원에 접근할 때, 특정 시점에 하나의 인스턴스만 공유 자원에 접근하거나 작업을 수행할 수 있도록 외부 시스템을 통해 제어하는 동기화 메커니즘이다. 대표적인 구현 방식으로 Reids의 Redisson, Lettuce와 ZooKeeper, MySQL Named Lock이 있다.
분산락을 구현하는 가장 흔한 방식은 Redis의 Redisson이다. 이는 Pub/Sub 기반으로 락 대기를 처리하기 때문에 효율적이고, TTL을 제공하므로 안정적인 락 관리가 가능하다는 장점이 있다. 실제로 분산락 도입 사례를 찾아보면 Redisson을 활용한 경우가 대부분이다.
물론 Lettuce로 구현하는 방법도 있다. 하지만 나의 개인적인 견해로는 Lettuce는 스핀락 방식으로 직접 구현해야 하며, 락 획득에 실패할 때마다 재시도를 반복하는 구조이기에 불필요한 부하가 생긴다. 그렇기에 Redisson을 선택하는 경우가 많은 것 같다. (직접 사용하지는 않았기에 단정할 수는 없다.)
📌 MySQL Named Lock을 선택한 이유
그럼에도 불구하고 나는 MySQL의 Named Lock을 분산락으로 선택하였다. 물론 Redis를 사용하는게 대중적인 방식인 것은 맞다. 하지만 이는 결국 Redis라는 외부 인프라스트럭처를 추가적으로 구축하고, 관리해주어야 한다. 그에 반해 MySQL은 이미 현재 서비스에서 사용하고 있기도 하였고, 구현하고자 하는 기능을 고려하였을 때 Redis가 제공하는 이점보다 인프라 도입과 운영 비용이 더 크다고 판단하였기에 Named Lock을 선택하였다.
Named Lock Implementation
Named Lock이란 MySQL의 테이블이나 레코드 자체에 거는 락이 아니라, 사용자가 지정한 문자열 키를 기준으로 획득하는 MySQL 분산락이다. 따라서 여러 서버가 동일한 MySQL를 공유하는 환경에서는 분산 락처럼 활용할 수 있다.
SELECT GET_LOCK('match_apply_42', 3); -- 1: 성공, 0: 타임아웃, NULL: 오류
SELECT RELEASE_LOCK('match_apply_42'); -- 1: 성공, 0: 보유하지 않음, NULL: 오류
`GET_LOCK('이름', 타임아웃)`을 호출하면 그 이름에 해당하는 락을 획드한다.
📌 특징
네임드 락을 구현하기 위해서는 여러가지 주요 특징이 있다.
- GET과 RELEASE는 반드시 같은 커넥션에서 실행해야 한다.
- 트랜잭션과 무관하므로 commit, rollback으로 자동 해제되지 않는다. 반드시 release 해야한다.
- 커넥션(세션) 단위이므로 커넥션이 아예 끊어쟈애 락이 자동해제 된다.
- 총 2개의 커넥션을 점유한다. (락 점유용 커넥션 + 비즈니스 커넥션)
- 인스턴스 전체가 같은 MySQL을 사용하므로 같은 락 이름을 공유한다. 따라서 락 이름 설정시 규칙을 반드시 정의해야 한다.
이러한 특징 중에서도 가장 중요한 것은 락이 커넥션(세션) 단위로 귀속된다는 것이다. 즉, GET_LOCK을 실행한 커넥션이 RELEASE_LOCK도 같이 실행해야 한다. 이 특성은 구현 시 반드시 주의해야 한다.
내가 네임드락을 구현하면서 몇 가지의 문제점들을 마주하였다.
📌 문제 1) Named Lock이 커넥션을 보유하지 않을 경우
처음에는 Spring Data JPA의 nativeQuery를 사용하여 쿼리문을 직관적으로 작성하는 것이 좋겠다라고 생각하였다.
@Query(value = "SELECT GET_LOCK(:lockName, :timeout)", nativeQuery = true)
Integer getLock(@Param("lockName") String lockName, @Param("timeout") int timeout);
하지만 `@Query(nativeQuery = true)`도 결국 JDBC Connection 위에서 동작한다. 그렇기에 `@Transactional`이 없는 컨텍스트일 경우 쿼리 실행이 끝나는 순간 즉시 커넥션을 풀에 반납한다. 이러한 특성에 의해 크게 2가지 시나리오가 발생할 수 있다.

- 스레드 A가 getConnection으로 conn1을 획득
- GET_LOCK을 통해 락을 획득한다.
- conn1을 풀에 반납한다. (conn1의 MySQL 세션은 유지됨)
- 스레드 B가 getConnection으로 conn1을 재획득한다.
- GET_LOCK을 통해 락을 획득한다.
- 두 스레드가 같이 임계 구역에 진입한다.

- 스레드 A가 getConnection으로 conn1을 획득
- GET_LOCK을 통해 락을 획득한다.
- conn1을 풀에 반납한다. (conn1의 MySQL 세션은 유지됨)
- 비즈니스 로직 실행 후 getConnection으로 conn2를 획득
- RELEASE_LOCK을 통해 락을 반납한다.
- conn2는 LOCK이 없으므로 0을 반환하고 반납을 실패한다.
@Nested
class NamedLockProblem1 {
@Test
@Transactional
@DisplayName("문제1-1: 같은 커넥션(세션)에서 GET_LOCK을 두 번 호출하면 재진입으로 두 번째도 성공한다")
void 문제1_재진입() {
String lockName = "reentrance_test";
// @Transactional로 conn1을 트랜잭션 전체에 바인딩 → 두 쿼리 모두 같은 커넥션 사용 강제
// 첫 번째 GET_LOCK: conn1 세션이 락 획득
Integer first = matchRepository.getNamedLockByNativeQuery(lockName, 0);
// 두 번째 GET_LOCK: 같은 conn1 세션 → MySQL Named Lock 재진입 → 획득 성공
Integer second = matchRepository.getNamedLockByNativeQuery(lockName, 0);
assertThat(first).isEqualTo(1);
assertThat(second).isEqualTo(1); // 락이 있어도 같은 세션이면 통과 (문제의 핵심)
}
@Test
@DisplayName("문제1-2: GET_LOCK과 RELEASE_LOCK이 다른 커넥션에서 실행되면 락이 해제되지 않는다")
void 문제1_RELEASE_LOCK_다른_커넥션() throws Exception {
String lockName = "release_mismatch_test";
// GET_LOCK: conn1 사용 후 즉시 풀에 반납 (lock은 conn1 세션에 귀속)
Integer getLockResult = matchRepository.getNamedLockByNativeQuery(lockName, 0);
assertThat(getLockResult).isEqualTo(1);
// conn1을 수동으로 점유 → RELEASE_LOCK이 다른 커넥션(conn2)을 사용하도록 강제
Connection conn1 = dataSource.getConnection();
try {
// RELEASE_LOCK: conn2 세션은 lockName 락을 보유하지 않음 → 해제 실패(0)
Integer releaseResult = matchRepository.releaseNamedLockByNativeQuery(lockName);
assertThat(releaseResult).isEqualTo(0); // 다른 커넥션 → 락 해제 실패
} finally {
conn1.close(); // conn1 반납 시 MySQL 세션 종료 → 락 자동 해제
}
}
}
테스트 코드를 통해 2가지 경우에 대해서 테스트해보자.
첫번째의 경우 `@Transactional`을 통해 같은 커넥션을 사용하도록 강제하여 락을 반납하기 전에 다시 같은 커넥션으로 GET_LOCK으로 락을 획득하는 시나리오이다.
두번째의 경우 conn1 커넥션으로 락을 획득하고, conn2 커넥션으로 락을 반납할려는 시나리오이다.

테스트 결과 예상과 동일하게 2가지 모두 성공하는 것을 확인할 수 있다. 따라서 이러한 문제를 방지하기 위해서는 반드시 GET_LOCK과 RELEASE_LOCK이 같은 커넥션에서 실행되도록 생명주기를 직접 제어해주어야 한다.
📌 문제 2) @Transactional과 RELEASE_LOCK의 타이밍
`@Transactional`은 AOP 프록시 기반으로 동작한다. 따라서 메서드를 반환한 이후에 commit을 실행하게 된다.

문제는 여기서 발생한다. Named Lock과 `@Transational`을 같이 사용할 경우 트랜잭션이 commit되기 이전에 RELEASE_LOCK에 의해 락이 반납된다. 이 타이밍에 다른 스레드에서 GET_LOCK을 호출하면 락을 획득하게 되어, `DIRTY_READ` 현상이 발생할 수 있다.
@Test
@DisplayName("@Transactional과 네임드 락을 같이 사용할 경우 12명이 초과된다.")
void 문제2_Transactional_네임드_락_동시성_테스트() throws InterruptedException {
// given
Long matchId = futsalMatch.getId();
Long userId = user.getId();
// when
ConcurrencyExecutor.execute(50, 10, () -> matchApplyFacade.joinMatchWithNamedLockTransactional(matchId, userId));
// then
int size = matchParticipantRepository.findAllByMatchIdAndUserId(matchId, userId).size();
assertThat(size).isEqualTo(12);
}
다음 테스트 코드를 통해 실제로 실제 매치에 참여한 인원이 12명을 초과하였는지 확인해보자.

테스트 결과를 살펴보면 기대한 매치 참여 인원은 12명임에도 불구하고 총 25명이 매치에 신청된걸 확인할 수 있다. 따라서 GET_LOCK을 `@Transactional` 바깥에서 실행하거나 락을 점유한 뒤 메인 비즈니스 로직에 `@Transactional(propagation = Propagation.REQUIRES_NEW)` 을 추가하여 별도의 새 트랜잭션에서 실행되도록 제어해주어야 한다.
📌 문제 3) 락과 비즈니스 로직이 같은 커넥션 풀을 사용할 경우
GET_LOCK을 기다리는 스레드들은 커넥션을 점유한 채 계속 대기할 수 있다. 따라서 락 대기와 일반 비즈니스 요청이 같은 커넥션 풀을 공유할 경우 락 경합이 심한 순간 락 대기 스레드들이 커넥션 풀을 모두 점유하여 다른 API 요청의 DB 커넥션 작업까지 지연시키거나 timeout을 유발할 수 있다.
해당 문제의 가능성을 줄이기 위해서는 락 점유용 커넥션과 비즈니스 트랜잭션용 커넥션을 분리하여 관리하는 것이 안전하다. 물론 HikariCP pool의 size를 늘리는 방법도 가능하지만, 트래픽 증가 시 다시 한계에 부딪히기 쉽고, 락 경합이 일반 요청에 전파되는 문제를 근본적으로 막지는 못한다. 따라서 나는 락 전용 DataSource를 별도로 두는 방식을 선택하였다.
spring:
datasource: # JPA / 비즈니스 트랜잭션용
url: ${DATABASE_URL}
lock-datasource: # Named Lock 전용
url: ${DATABASE_URL}
hikari:
pool-name: lock-pool
락 점유용 datasource이므로 pool-name을 lock-pool로 지정하여 application.yml에 작성하였다.
@Configuration
public class LockDataSourceConfig {
@Bean
@ConfigurationProperties("spring.lock-datasource")
public DataSourceProperties lockDataSourceProperties() {
return new DataSourceProperties();
}
@Bean(name = "lockDataSource")
@ConfigurationProperties("spring.lock-datasource.hikari")
public DataSource lockDataSource(
@Qualifier("lockDataSourceProperties") DataSourceProperties lockDataSourceProperties
) {
return lockDataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
}
락 전용 설정 DataSource는 `LockDataSourceConfig`에서 별도로 등록하였다. spring.lock-datasource 프로퍼티를 DataSourceProperties에 바인딩하고, 이를 바탕으로 lockDataSource라는 이름의 Hikari DataSource를 생성했다. 이후 네임드 락을 사용할 repository에는 이 lockDataSource만 주입하여 GET_LOCK과 RELEASE_LOCK이 항상 락 전용 커넥션에서 실행할 수 있도록 분리해주었다.
관리해야할 DataSource가 2개이므로 비즈니스 트랜잭션을 실행할 커넥션을 반드시 지정해주어야 한다.
@Configuration
public class JpaConfig {
@Primary
@Bean
@ConfigurationProperties("spring.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Primary
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public DataSource dataSource(
@Qualifier("dataSourceProperties") DataSourceProperties dataSourceProperties
) {
return dataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
}
따러서 `JpaConfig` 파일에 `@Primary` 빈으로 DataSource를 등록해주었다.
Conclusion
지금까지 풋살 매치 신청에서 마주했던 동시성 문제를 MySQL Named Lock으로 분산 락을 구현하여 해결했던 과정을 다루었다.
나는 MySQL Named Lock이 다른 분산 락보다 성능이 우수하다거나 기능이 많아서 선택한 것이 아니다. 오히려 순수 락 성능이나 옵션들은 Redis 쪽이 더 유리하다. 그럼에도 불구하고 내가 MySQL Named Lock을 선택했던 핵심 이유는 현재 서비스의 환경과 요구사항에 더 잘맞는 선택지였기 때문이다.
가장 큰 이유는 두 가지다.
- 별도 인프라를 추가하지 않고도 다중 서버 환경에서 동시성을 제어할 수 있었다.
- 이미 사용 중인 MySQL을 그대로 활용할 수 있어 구축 비용과 학습 비용이 모두 낮았다.
현재 서비스 규모와 복잡도를 고려했을 때, Redis를 새로 도입하는 것보다 MySQL Named Lock으로 문제를 해결하는 편이 훨씬 현실적이었다.
결국 이러한 기술적 선택에는 절대적인 정답이 있는 것이 아니라, 현재 시스템의 구조와 트래픽 특성, 운영 비용, 복잡도 등을 함께 고려한 선택이 중요하다고 생각한다. 동시 충돌이 많더라도 서비스 구조가 단순하다면 비관적 락이 더 적절할 수 있고, 단일 인스턴스 환경이라면 `ReentrantLock` 역시 충분히 합리적인 선택이 될 수 있다. 중요한 것은 특정 기술이 무조건 더 좋아서가 아니라, 왜 이 상황에서 그 방법을 선택했는지를 분명하게 설명할 수 있는가이다.
현재는 샤딩되지 않은 단일 MySQL 환경이기 때문에 MySQL Named Lock이 충분히 현실적인 선택이었다. 하지만 이후 트래픽 증가로 데이터베이스를 샤딩하거나 분산 구조로 확장하게 된다면, 특정 DB 인스턴스에 종속되는 Named Lock보다 Redis 기반 분산락이 더 적절할 수 있다.
추후에는Redis 기반 분산락도 직접 적용해보고, 그 차이점과 트레이드오프를 별도의 글로 정리해볼 예정이다.
'Backend > Spring Boot' 카테고리의 다른 글
| Virtual Thread로 병렬 호출하여 Thread Pool 고갈 문제 해결하기 (0) | 2026.03.13 |
|---|---|
| RestClient는 어떻게 생성이 될까? (1) | 2026.01.31 |
| 자바와 스프링의 비동기 처리 - 2편: CompletableFuture의 예외 처리와 타임 아웃 (4) | 2025.08.01 |
| 자바와 스프링의 비동기 처리 - 1편: CompletableFuture 톺아보기 (3) | 2025.07.11 |
| 스프링 이벤트를 발행하여 트랜잭션과 관심사 분리하기 (2) | 2025.04.29 |