
Redis 대신 MySQL 을 이용해 Lock 구현
- MySQL 로 Lock 을 구현할 경우 장단점이 무엇인지 꼭! 확인하기
💡 Hint.
- JPA 비관적 Lock
- MySQL Exclusive Lock

네임드 락 (GET_LOCK, RELEASE_LOCK)
SELECT GET_LOCK('lock_key', timeout) → 결과 1(성공), 0(실패), NULL(에러)SELECT RELEASE_LOCK('lock_key') → 결과 1(성공), 0(실패), NULL(존재하지 않음)테이블 락: LOCK TABLES, 잘 사용하지 않음
| 항목 | Redis | MySQL |
|---|---|---|
| 락 레벨 | 서비스 레벨 (분산락) | DB 레벨 (스토리지/엔진 락) |
| 구현 방식 | SETNX, Lua 스크립트 | GET_LOCK, RELEASE_LOCK, 배타적 락(Exclusive Lock, 비관적 락 전략) |
| 인프라 요구 | Redis 별도 구축 필요 | 기존 DB 사용 가능 |
| 성능 | 메모리 기반이라 빠름 | 성능 병목 우려 있음 (연결 수 제한 등) |
| 유지보수 | Redis 인프라 관리 필요 | 별도 관리 불필요 |
| 사용 목적 | 분산 시스템에서의 경량 락 | DB 트랜잭션 보호 및 동시성 제어 |
🎟️ 제한된 수량의 쿠폰을 발급 받으려 다수의 사용자가 몰려드는 상황.
쿠폰 수량: 1000개
발급 받으려는 유저: 1200명
발급된 쿠폰 수량(원래 수량 - 남은 수량) = 유저에게 발급된 쿠폰 수량 (발급된 userCoupon 개수)
@Slf4j
@Component
@RequiredArgsConstructor
public class NameLockWithJdbcTemplate {
private static final String GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)";
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(:userLockName)";
private final NamedParameterJdbcTemplate jdbcTemplate;
public <T> T executeWithLock(
String userLockName, int timeoutSeconds, Supplier<T> supplier) {
try {
getLock(userLockName, timeoutSeconds);
return supplier.get();
} finally {
releaseLock(userLockName);
}
}
private void getLock(String userLockName, int timeoutSeconds) {
Map<String, Object> params = new HashMap<>();
params.put("userLockName", userLockName);
params.put("timeoutSeconds", timeoutSeconds);
log.info("LOCK 획득: " + userLockName + " TIME: " + timeoutSeconds);
Integer result = jdbcTemplate.queryForObject(GET_LOCK, params, Integer.class);
checkResult(result, userLockName, "GetLock");
}
private void releaseLock(String userLockName) {
Map<String, Object> params = new HashMap<>();
params.put("userLockName", userLockName);
log.info("LOCK 해제: " + userLockName);
Integer result = jdbcTemplate.queryForObject(RELEASE_LOCK, params, Integer.class);
checkResult(result, userLockName, "GetLock");
}
private void checkResult(Integer result, String userLockName, String type) {
if (result == null) {
log.info("USER LEVEL LOCK 쿼리 결과값이 없습니다. type = " + type, " userLockName: ",
userLockName);
throw new BaseException(ExceptionCode.INTERNAL_SERVER_ERROR);
}
if (result != 1) {
log.info("USER LEVEL LOCK 쿼리 결과값이 1이 아닙니다. type = " + type, " userLockName: ",
userLockName);
throw new BaseException(ExceptionCode.INTERNAL_SERVER_ERROR);
}
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class NameLockWithDataSource {
private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
private final DataSource dataSource;
public <T> T executeWithLock(String userLockName, int timeoutSeconds, Supplier<T> supplier) {
try (Connection connection = dataSource.getConnection()) {
try {
log.info("LOCK 획득 시작 KEY: " + userLockName + " __ CONNECTION: " + connection);
getLock(connection, userLockName, timeoutSeconds);
log.info("LOCK 획득 성공 KEY: " + userLockName + " __ CONNECTION: " + connection);
return supplier.get();
} finally {
releaseLock(connection, userLockName);
log.info("LOCK 해제 성공 KEY: " + userLockName + " __ CONNECTION: " + connection);
}
} catch (SQLException | RuntimeException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private void getLock(Connection connection, String userLockName, int timeoutSeconds) {
try (PreparedStatement preparedStatement = connection.prepareStatement(GET_LOCK)) {
preparedStatement.setString(1, userLockName);
preparedStatement.setInt(2, timeoutSeconds);
checkResult(userLockName, preparedStatement, "GetLock_");
} catch (SQLException | RuntimeException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private void releaseLock(Connection connection, String userLockName) {
try (PreparedStatement preparedStatement = connection.prepareStatement(RELEASE_LOCK)) {
preparedStatement.setString(1, userLockName);
checkResult(userLockName, preparedStatement, "ReleaseLock");
} catch (SQLException | RuntimeException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private void checkResult(String userLockName, PreparedStatement ps, String type)
throws SQLException {
try (ResultSet resultSet = ps.executeQuery()) {
if (!resultSet.next()) {
log.error(
"USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = " + type
+ "_ userLockName = " + userLockName
+ " _ connection: " + ps.getConnection());
throw new RuntimeException("USER LEVEL LOCK 쿼리 결과 값이 없습니다.");
}
int result = resultSet.getInt(1);
if (result != 1) {
log.error(
"USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = " + type
+ " _ result = " + result + "_ userLockName = " + userLockName
+ " _ connection: " + ps.getConnection());
throw new RuntimeException("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다.");
}
}
}
}
issueCouponWithNamedLockAndJdbc()issueCoupon() 자체가 호출되지 않음issueCouponWithNamedLockAndJdbc()에 직접 걸려 있었고, 내부에서 비즈니스 로직이 비동기 혹은 별도 트랜잭션 경계로 인해 호출되지 않는 상황 발생issueCoupon()에만 적용하고, 외부에서는 락만 획득하도록 분리락 획득 → issueCoupon 호출 (트랜잭션) → 락 해제[TEST END] 남은 쿠폰 수량: 0
유저 쿠폰 개수: 1000
성공 횟수: 1000
실패 횟수: 200
DataSource 기반 네임 락 구현
시도한 쿼리:
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의 비관적 락을 사용할 때는 충돌 빈도가 상대적으로 낮았고, 이 차이는 락 방식 자체의 구조적 차이에서 비롯됨.
: 데이터를 다른 트랜잭션이 건드릴 수 없도록 선제적으로 잠금을 거는 방식.
: 데이터에 쓰기 작업을 하는 동안, 다른 트랜잭션이 읽거나 쓰지 못하게 하는 락. 주로 데이터베이스에서 내부적으로 자동 적용되며, 쓰기(write) 연산을 위한 락
| 항목 | MySQL 배타적 락 (USER-LEVEL LOCK 등) | JPA 비관적 락 (@Lock(PESSIMISTIC_WRITE)) |
|---|---|---|
| 락 대상 | DB 연결(Connection) 단위. 세션 기반 이름 지정 잠금 | 실제 행(Row)에 대한 DB 락 (SELECT FOR UPDATE) |
| 충돌 발생 시점 | 이름 중복 시 GET_LOCK 실패 → 클라이언트가 직접 재시도 필요 | 쿼리 실행 시 락 대기 가능 → 트랜잭션 내부에서 자동으로 순서 조정됨 |
| 동시성 대처 방식 | 락 실패 시 즉시 실패(반환값 0) → 충돌률 높고 재시도 어려움 | DB 레벨에서 트랜잭션 스케줄링 수행 → 대기 큐에 의해 순서 보장 가능성 있음 |
| 락 보장 범위 | 락 이름만 동일하면 전역적 → 행 단위 제어가 아님 | 특정 엔티티(행)에 한정된 락 제어 가능 |
| 사용자 제어 | 락 획득, 해제를 모두 수동으로 제어해야 함 | 트랜잭션과 함께 자동 관리 (rollback 시 해제 등) |
GET_LOCK() 방식은 락 획득 실패를 직접 핸들링해야 하며, 기본적으로 락 대기 큐를 보장하지 않음userLockName으로 접근 시, 거의 동시에 실패하거나 재시도 충돌이 반복됨GET_LOCK() 기반보다 @Lock(PESSIMISTIC_WRITE) 또는 Redis 기반 접근이 효율적일 수 있음