
네임드 락 (GET_LOCK, RELEASE_LOCK)
SELECT GET_LOCK('lock_key', timeout) → 결과 1(성공), 0(실패), NULL(에러)SELECT RELEASE_LOCK('lock_key') → 결과 1(성공), 0(실패), NULL(존재하지 않음)테이블 락: LOCK TABLES, 잘 사용하지 않음
제한된 수량의 쿠폰을 발급 받기 위해 다수의 사용자 요청이 몰리는 상황
쿠폰 수량: 1000개
발급 받으려는 유저: 1200명
발급된 쿠폰 수량(원래 수량 - 남은 수량) = 유저에게 발급된 쿠폰 수량 (발급된 userCoupon 개수)
issueCouponWithNamedLockAndJdbc()issueCoupon() 자체가 호출되지 않음issueCouponWithNamedLockAndJdbc()에 직접 걸려 있었고, 내부에서 비즈니스 로직이 비동기 혹은 별도 트랜잭션 경계로 인해 호출되지 않는 상황 발생issueCoupon()에만 적용하고, 외부에서는 락만 획득하도록 분리락 획득 → issueCoupon 호출 (트랜잭션) → 락 해제트랜잭션 분리 적용 후:
[TEST END] 남은 쿠폰 수량: 0
유저 쿠폰 개수: 1000
성공 횟수: 1000
실패 횟수: 200
테스트 환경: Named Lock 구현 시 JDBCTemplate 사용
시도한 쿼리:
private static final String GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)";
발생한 예외:
Parameter index out of range (1 > number of parameters, which is 0)
PreparedStatement는 ? 기반 positional parameter만 지원하며 :paramName 구문은 사용 불가아래와 같이 쿼리를 수정하여 positional 방식으로 변경
private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
preparedStatement.setString(1, userLockName);
preparedStatement.setInt(2, timeoutSeconds);
주의: 파라미터 index는 1부터 시작 (0부터 아님)
USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다.
GET_LOCK 결과가 1이 아닌 경우(= 락 획득 실패)Supplier vs Runnable
Runnable: 반환값 없음Supplier<T>: 실행 후 결과 반환 → 락 내 로직에서 값 반환 필요 시 사용| 비교 항목 | JDBC Template 기반 | DataSource 직접 구현 방식 |
|---|---|---|
| 트랜잭션 경계 관리 | 트랜잭션 분리 어려움 → @Transactional 적용 시 동작 이상 발생 | 명시적 Connection 사용으로 트랜잭션과 락을 하나의 범위에서 제어 가능 |
| 커넥션 공유 | GET_LOCK과 RELEASE_LOCK에 서로 다른 커넥션 사용 가능성 높음 | 같은 커넥션에서 GET/RELEASE 처리 가능 → 일관성 보장 |
| 문제 사례 | 트랜잭션 미적용 시, RELEASE 실패 또는 락 유실 가능성 있음 | 커넥션 수동 관리로 락 획득/해제 정확히 처리 가능 |
| 코드 복잡도 | 상대적으로 간단 (템플릿 기반) | 구현 복잡하지만 명확한 흐름 제어 가능 |
결론적으로, 분산락 정확성 확보가 중요할 경우 DataSource 기반 직접 구현이 더 안정적이며, JDBCTemplate은 테스트나 간단한 구조에서만 권장됨
쿠폰 발급 동시성 제어 테스트
시나리오: 쿠폰 수량 1000개, 유저 발급 시도 1200건
구현 방식:
| 비교 항목 | MySQL Named Lock | Redis Distributed Lock |
|---|---|---|
| 속도 | 느림 (디스크 기반, 커넥션 점유) | 빠름 (메모리 기반 연산) |
| 트랜잭션 연동 | 자연스럽게 연동 (DB 내 일관성 확보 용이) | 연동 어려움 (트랜잭션 외부에서 관리해야 함) |
| 인프라 구성 | 단일 DB로 충분 (추가 설치 X) | 별도 Redis 인프라 필요 (클러스터 포함 가능성) |
| 동시성 처리 | 커넥션 수 제한 및 블로킹 가능성 있음 | TTL, 재시도 기반 유연한 처리 가능 (Redisson 활용 시 효과적) |
| 해제 안정성 | 커넥션 종료 시 자동 해제 (편리하나 의도치 않게 해제될 수 있음) | TTL 및 Lua 스크립트 기반 명시적 제어 가능 (설정에 따라 안전성 확보) |
| 운영 편의성 | 단순하나 커넥션 관리 필요 | 운영 난이도 있음 (TTL 조정, 멱등성 확보 등 추가 고려사항) |
| 분산 환경 대응 | 한 DB 내에서만 유효 | 다양한 인스턴스 간 락 공유 가능 (서비스 확장 유리) |
| 성공률 | 테스트 결과: 성공 1000, 실패 200 / 또는 성공 143, 실패 1057 (락 충돌 많음) | 대부분 성공률 높음 (TTL + 재시도 조합 활용 가능) |
MySQL 기반 분산락 테스트에서 충돌이 빈번하게 발생함. 같은 코드에서 JPA의 비관적 락을 사용할 때는 충돌 빈도가 상대적으로 낮았고, 이 차이는 락 방식 자체의 구조적 차이에서 비롯됨.
| 구분 | 설명 |
|---|---|
| 네임드 락 (Named Lock) | MySQL 엔진 레벨에서 사용자가 지정한 문자열을 잠그는 방식. 데이터 존재 여부와 무관하게 로직 실행 권한을 제어하는 분산락 용도로 주로 사용됨. |
| 비관적 락 (Pessimistic Lock) | JPA에서 제공하는 전략으로, DB의 배타적 락(Exclusive Lock)을 활용해 실제 데이터 행(Row)에 대한 접근을 직접 막는 방식. |
| 항목 | MySQL 네임드 락 (GET_LOCK) | JPA 비관적 락 (@Lock) |
|---|---|---|
| 락 대상 | DB 연결(Connection) 단위. 세션 기반의 가상 이름 잠금 | 실제 행(Row) 단위. 물리적 레코드 잠금 (SELECT FOR UPDATE) |
| 충돌 발생 시점 | 이름 중복 시 GET_LOCK 즉시 실패 (0 반환) | 쿼리 실행 시 DB 대기 큐에서 대기 (순차 처리) |
| 동시성 대처 | 클라이언트가 직접 재시도 로직을 구현해야 함 | DB 엔진이 트랜잭션 스케줄링을 통해 순서 보장 |
| 락 보장 범위 | 동일한 '이름'에 대해 전역적 제어 | 특정 엔티티(행) 데이터에 한정된 제어 |
| 사용자 제어 | 사용자가 수동으로 획득/해제 (DataSource 관리 필요) | 트랜잭션 종료 시 자동으로 해제/롤백 관리 |
GET_LOCK() 방식은 락 획득 실패를 직접 핸들링해야 하며, 기본적으로 락 대기 큐를 보장하지 않음userLockName으로 접근 시, 거의 동시에 실패하거나 재시도 충돌이 반복됨@Lock(PESSIMISTIC_WRITE) 기반으로 처리하는 것이 충돌 최소화에 더 효과적임.최종 선택은 환경과 요구 사항에 따라 달라지며, 각 방식의 단점은 설정 및 아키텍처 설계로 일정 부분 보완 가능함