MySQL Named 락이란?
MySQL에서 제공하는 이름 기반의 사용자 락이다.
동시성 제어를 위해 DB 안에서 간단히 분산락처럼 사용할 수 있는 기능이다.
주요 함수
GET_LOCK(name, timeout)
입력 받은 name으로 timeout 초 동안 잠금 획득을 시도한다.
timeout에 음수를 입력하면 잠금을 획득할 때까지 무한대로 대기한다.
한 세션에서 잠금을 유지하고 있는 동안 다른 세션에서 동일한 이름의 잠금 획득이 불가능하다.
GET_LOCK()을 이용하여 획득한 잠금은 트랜잭션이 커밋/롤백 되어도 풀리지 않는다.
즉, 락을 잡은 커넥션에서 직접 해제하거나 DB 커넥션이 종료되어야 락이 풀린다.
RELEASE_LOCK(name)
입력 받은 name의 잠금을 해제한다.
RELEASE_ALL_LOCKS()
현재 세션에서 유지되고 있는 모든 잠금을 해제하고 해제한 잠금 개수를 반환한다.
장점
간단한 분산락 구현 가능
같은 DB를 공유하면 여러 대의 서버 간에도 락 공유가 가능하다.
쉬운 구현 난이도
복잡한 인프라(Redis, Zookeeper 등) 없이 SQL 한 줄로 락을 구현할 수 있다.
트랜잭션과 무관하게 사용 가능
트랜잭션의 바깥에서도 사용이 가능하다.
락 자동 해제
커넥션이 종료되면 락이 자동으로 해제되어 뒷처리의 부담이 다소 적다.
단점
세션 기반
락을 잡은 커넥션이 죽으면 자동으로 해제된다. 비즈니스 로직 수행 중 예기치 않게 해제될 가능성이 존재한다.
커넥션 풀 주의
커넥션 풀에서 락용 커넥션과 비즈니스 커넥션을 분리하지 않으면 중간에 커넥션이 반환될 수 있어 락이 풀릴 수 있다.
MySQL에 의존
MySQL에 종속적인 기능이기 때문에 DB를 바꾸면 코드의 전면 수정이 필요하다.
커넥션 풀 분리의 필요성
MySQL Named Lock을 안정적으로 사용하기 위해서는 커넥션 풀의 분리가 필요하다.
GET_LOCK()으로 획득한 락은 명시적으로 RELEASE_LOCK()을 호출하거나, 락을 보유한 커넥션이 종료될 때에만 해제된다.
따라서, 비즈니스 로직과 락 로직이 동일한 커넥션 풀을 사용할 경우, Spring의 커넥션 관리 방식에 따라 락이 예기치 않게 해제될 수 있는 위험이 존재한다.
이를 방지하기 위해서는 락 전용 커넥션 풀과 비즈니스 로직 전용 커넥션 풀로 분리하는 것이 좋다.
예시 코드
📝 application.yaml
비즈니스 로직 전용 커넥션 풀과 네임드 락 전용 커넥션 풀에 대한 설정을 추가해준다.
spring:
datasource: # 비즈니스 로직 전용 커넥션 풀
hikari:
maximum-pool-size: 51
driver-class-name: org.mariadb.jdbc.Driver
jdbc-url: jdbc:mariadb://localhost:13306/concurrency_control
username: root
password: 1234
named-lock-datasource: # 네임드 락 전용 커넥션 풀
hikari:
driver-class-name: org.mariadb.jdbc.Driver
jdbc-url: jdbc:mariadb://localhost:13306/concurrency_control
username: root
password: 1234
📝 PrimaryDatsSourceConfig
비즈니스 로직 전용 커넥션 풀을 설정하는 클래스이다.
@Primary 어노테이션을 사용함으로써, 비즈니스 로직에서 별도의 설정 없이 이 커넥션 풀이 기본적으로 사용되도록 구성한다.
이를 통해 여러 DataSource가 존재하더라도, 명시하지 않은 경우에는 해당 커넥션 풀이 자동으로 주입된다.
@Configuration
class PrimaryDataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.hikari")
fun hikariConfig(): HikariConfig {
return HikariConfig()
}
@Primary
@Bean
fun primaryDataSource(
hikariConfig: HikariConfig
): DataSource {
return HikariDataSource(hikariConfig)
}
}
📝 NamedLockDataSourceConfig
네임드 락 전용 커넥션 풀을 설정하는 클래스이다.
@Configuration
class NamedLockDataSourceConfig {
@Bean
@ConfigurationProperties("spring.named-lock-datasource.hikari")
fun namedLockHikariConfig(): HikariConfig {
return HikariConfig()
}
@Bean
fun namedLockDataSource(
@Qualifier("namedLockHikariConfig") namedLockHikariConfig: HikariConfig
): DataSource {
return HikariDataSource(namedLockHikariConfig)
}
}
📝 NamedLock
Named Lock에 사용할 키와 timeout을 지정하는 어노테이션이다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class NamedLock(
val key: DynamicKey,
val timeout: Int = 5000,
)
📝 DynamicKey
키 값을 더 유연하게 설정할 수 있는 어노테이션이다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DynamicKey(
val prefix: String,
val key: String
)
📝 AopForTransaction
실제 비즈니스 로직은 복잡해질 가능성이 높다.
그렇기 때문에 락의 범위를 최소화하고, 트랜잭션의 영향을 분리하기 위해 REQUIRES_NEW 전파 옵션을 사용해 독립적인 트랜잭션으로 비즈니스 로직을 수행하도록 처리한다.
@Component
class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun proceed(
joinPoint: ProceedingJoinPoint
): Any? {
return joinPoint.proceed()
}
}
📝 NamedLockAop
메소드에 @NamedLock 어노테이션이 붙어 있을 때 해당 메소드 전후로 AOP 로직을 감싸서 실행한다.
즉, 락 획득 -> 비즈니스 로직 실행 -> 락 해제가 되는 것이다.
@Aspect
@Component
class NamedLockAop(
@Qualifier("namedLockDataSource") private val dataSource: DataSource,
private val aopForTransaction: AopForTransaction
) {
private val log = KotlinLogging.logger { }
companion object {
private const val GET_LOCK = "SELECT GET_LOCK(?, ?)"
private const val RELEASE_LOCK = "SELECT RELEASE_LOCK(?)"
}
@Around("@annotation(potatowoong.namedlock.global.lock.NamedLock)")
fun lock(
joinPoint: ProceedingJoinPoint
): Any? {
val namedLockAnnotation = getNamedLockAnnotation(joinPoint)
val key = getLockKey(joinPoint)
try {
dataSource.connection.use {
try {
getLock(it, key, namedLockAnnotation.timeout)
return aopForTransaction.proceed(joinPoint)
} finally {
releaseLock(it, key)
}
}
} catch (e: SQLException) {
log.error { "NamedLock SQLException : $key, timeoutSeconds : ${namedLockAnnotation.timeout}, connection : ${e.message}" }
throw NamedLockException()
} catch (e: RuntimeException) {
log.error { "NamedLock RuntimeException : $key, timeoutSeconds : ${namedLockAnnotation.timeout}, connection : ${e.message}" }
throw NamedLockException()
}
}
/**
* Named Lock 획득
*
* @param connection Connection
* @param key Named Lock Key
* @param timeout Timeout (seconds)
*/
@Throws(SQLException::class)
private fun getLock(
connection: Connection,
key: String,
timeout: Int
) {
connection.prepareStatement(GET_LOCK).use {
it.setString(1, key)
it.setInt(2, timeout)
execute(it)
}
}
/**
* Named Lock 반납
*
* @param connection Connection
* @param key Named Lock Key
*/
@Throws(SQLException::class)
private fun releaseLock(
connection: Connection,
key: String
) {
connection.prepareStatement(RELEASE_LOCK).use {
it.setString(1, key)
execute(it)
}
}
/**
* Named Lock 쿼리 실행
*
* @param preparedStatement PreparedStatement
*/
private fun execute(
preparedStatement: PreparedStatement,
) {
preparedStatement.executeQuery().use { resultSet ->
if (!resultSet.next()) {
log.error { "NamedLock 쿼리 결과 값이 없습니다." }
throw NamedLockException()
}
val result = resultSet.getInt(1)
if (result != 1) {
log.error { "NamedLock 쿼리 결과 값이 1이 아닙니다. result : $result" }
throw NamedLockException()
}
}
}
/**
* NamedLockAnnotation 가져오기
*
* @param joinPoint ProceedingJoinPoint
* @return NamedLockAnnotation
*/
private fun getNamedLockAnnotation(
joinPoint: ProceedingJoinPoint
): NamedLock {
val methodSignature = joinPoint.signature as MethodSignature
val method = methodSignature.method
return method.getAnnotation(NamedLock::class.java)
}
/**
* Lock Key 파싱
*
* @param joinPoint ProceedingJoinPoint
* @return Lock Key
*/
private fun getLockKey(
joinPoint: ProceedingJoinPoint
): String {
val methodSignature = joinPoint.signature as MethodSignature
val method = methodSignature.method
val namedLockAnnotation = method.getAnnotation(NamedLock::class.java)
val dynamicKeyAnnotation = namedLockAnnotation.key
return methodSignature.parameterNames
.mapIndexed { index, parameterName -> "#$parameterName" to joinPoint.args[index] }
.firstOrNull { it.first == dynamicKeyAnnotation.key }
?.let { dynamicKeyAnnotation.prefix + it.second }
?: throw NamedLockException()
}
}
📝 ProductService
네임드 락을 사용할 메소드에 @NamedLock 어노테이션을 붙여주면 된다.
key에 들어가는 "#productId"는 파라미터로 명시한 productId와 일치해야 한다.
@NamedLock(key = DynamicKey(prefix = "Product", key = "#productId"))
fun decreaseStockQuantity(
productId: Long,
quantity: Int
) {
// 상품 조회
val product = productRepository.findByIdOrNull(productId)
?: throw IllegalArgumentException("Product not found with id: $productId")
// 재고 감소
product.decreaseStockQuantity(quantity)
}
결론
MySQL의 Named Lock은 비관적 락에 비해 데드락 발생 가능성이 훨씬 낮은 편이다.
이름 기반으로 단일 자원에만 락을 걸기 때문에 충돌이 단순하고 예측 가능하며, 또한 Redis나 Zookeeper 같은 외부 인프라 없이 간단하게 동시성 제어를 구현할 수 있다는 장점이 있다.
다만, 커넥션이 닫히면 락이 해제되는 특성 때문에 락 전용 커넥션 풀을 별도로 분리해서 관리해야 한다는 점은 주의가 필요하다.
'Programming > SpringBoot' 카테고리의 다른 글
REQUIRES_NEW 사용 시 주의할 점 (1) | 2025.04.11 |
---|---|
Spring Boot 동시성 제어 (3) - 비관적 락 (0) | 2025.04.08 |
Spring Boot 동시성 제어 (2) - 낙관적 락 (0) | 2025.04.08 |
Spring Boot 동시성 제어 (1) - synchronized (0) | 2025.04.08 |
[SpringBoot] AMQP란? (0) | 2025.01.26 |