MySQL에서 제공하는 잠금 방식으로 잠금의 대상이 테이블이나 레코드와 같은 데이터베이스 객체가 아닌, 임의의 문자열에 대해 잠금을 설정할 수 있다.
RELEASE_LOCK() 함수를 사용해서 명시적으로 해제해야 한다.GET_LOCK()을 통해 락이 획득된 적이 없거나, 이전에 이미 해제된 경우일 수 있다.)RELEASE_ALL_LOCKS() 을 통해서 한 번에 모두 해제할 수도 있다.다음과 같이 JPA를 통해 네임드 락을 획득 및 반납할 수 있었지만, 이후 레디스를 통한 분산 락 방식도 구현할 것이기 때문에 분산 락 인터페이스를 만들었다.
@Query 를 통한 잠금 획득 및 반납interface TicketRepository : JpaRepository<Ticket, Long> {
@Query("SELECT GET_LOCK(:key, :timeout)", nativeQuery = true)
fun lock(@Param("key") key: String, @Param("timeout") timeout: Int): Long?
@Query("SELECT RELEASE_ALL_LOCKS()", nativeQuery = true)
fun unLock()
}분산 락 인터페이스는 다음과 같다.
interface DistributedLock {
fun lock(key: String, timeOut: Int): Boolean
fun unLock(): Boolean
}
네임드 락의 구현체는 다음과 같다.
@Component
class DatabaseNamedLock(
private val entityManager: EntityManager
) : DistributedLock {
override fun lock(key: String, timeOut: Int): Boolean {
val result = entityManager.createNativeQuery(GET_LOCK)
.setParameter(1, key)
.setParameter(2, timeOut)
.singleResult as? Long
if (result == null) return false
return result == 1L
}
override fun unLock(): Boolean {
val result = entityManager.createNativeQuery(RELEASE_LOCK)
.singleResult as? Long
if (result == null) return false
return result >= 1
}
companion object {
private const val GET_LOCK = "SELECT GET_LOCK(?, ?)"
private const val RELEASE_LOCK = "SELECT RELEASE_ALL_LOCKS()"
}
}
EntityManager를 통해서 네임드 락 쿼리를 작성했다.RELEASE_ALL_LOCKS()을 통해서 모두 반납하고, 반납의 성공 여부를 반환한다. 이 때, RELEASE_ALL_LOCKS()의 반환 값은 반납한 잠금의 개수이므로 한 세션에서 여러 번 잠금을 획득했다면 1 이상의 값이 나오는 것을 주의하자.네임드 락을 적용한 코드는 다음과 같다.
@Service
class TicketService(
private val ticketRepository: TicketRepository,
private val ticketUserRepository: TicketUserRepository,
private val distributedLock: DistributedLock,
transactionManager: PlatformTransactionManager
) {
private val transactionTemplate = TransactionTemplate(transactionManager)
fun issueTicketWithNamedLock(ticketId: Long, name: String) {
val ticket = findTicketById(ticketId)
val lockAcquired = distributedLock.lock(ticketId.toString(), 10)
if (!lockAcquired) {
println("잠금을 획득하지 못했습니다.")
return
}
transactionTemplate.execute {
val ticketCount = countTicket(ticketId)
if (ticketCount >= 100) {
distributedLock.unLock()
throw IllegalStateException("티켓 소진")
}
val ticketUser = TicketUser(ticket = ticket, name = name)
ticketUserRepository.save(ticketUser)
}
distributedLock.unLock()
}
}
잠금 획득 → 트랜잭션 시작 순으로 진행하기 위해서 프로그래밍 방식으로 트랜잭션을 관리한다. @Test
fun `티켓 요청을 300번 요청했을 때, 100개의 티켓만 생성되어야 한다`() {
val numberOfThread = 300
val executorService = Executors.newFixedThreadPool(numberOfThread)
val latch = CountDownLatch(numberOfThread)
repeat(numberOfThread) { idx ->
executorService.submit {
try {
ticketService.issueTicketWithNamedLock(1L, "USER $idx")
} finally {
latch.countDown();
}
}
}
latch.await()
val result1 = ticketService.countTicket(1L)
assertThat(result1).isEqualTo(100)
}

결과적으로 300개의 요청이 동시에 들어 올 때, 성공적으로 100개의 티켓만 발급하는 것을 볼 수 있다.
그렇다면 아래와 같이 여러 티켓이 동시에 요청이 들어올 때는 어떻게 될까?
@Test
fun `3종류의 티켓 요청을 동시에 900번 요청했을 때, 각 티켓 당 100개의 티켓만 생성되어야 한다`() {
val numberOfThread = 900
val executorService = Executors.newFixedThreadPool(numberOfThread)
val latch = CountDownLatch(numberOfThread)
repeat(numberOfThread) { idx ->
executorService.submit {
try {
ticketService.issueTicketWithSynchronized((idx % 3) + 1L, "USER $idx")
} finally {
latch.countDown();
}
}
}
latch.await()
val result1 = ticketService.countTicket(1L)
val result2 = ticketService.countTicket(2L)
val result3 = ticketService.countTicket(3L)
assertThat(result1).isEqualTo(100)
assertThat(result2).isEqualTo(100)
assertThat(result3).isEqualTo(100)
}
이전 글의 주제였던 synchronized 보다 요청을 빨리 수행할 것이라 생각하여 테스트를 실행했지만 다음과 같은 에러가 생기면서 데드락이 발생했다.
SQL Error: 3058, SQLState: HY000
Deadlock found when trying to get user-level lock; try rolling back transaction/releasing locks and restarting lock acquisition.
데드락 문제를 파악하기 전에 스프링의 커넥션 풀에 대해 간단하게 설명하고자 한다.
스프링은 시작과 동시에 데이터베이스와 연결된 커넥션을 미리 생성해서 커넥션 풀을 만들어 두고, 쿼리를 실행할 때 해당 커넥션 풀에서 커넥션을 꺼내 쓰고 있다.
데이터베이스 서버에서는 각 커넥션 별로 세션을 만들고, 이후 커넥션을 통한 모든 요청이 해당 세션을 통해서 실행된다.
스프링의 기본 커넥션의 개수는 10개이기 때문에 데이터베이스 서버에도 총 10개의 세션이 있다.
다시 데드락 문제로 넘어가자. 해당 데드락 발생 원인은 아래와 같다.
“A” 잠금을 가지고 있는 세션 1과 “B” 잠금을 가지고 있는 세션 2가 있을 때, 세션 1은 “2”를, 세션 2는 “1”의 잠금을 획득하려 해서 데드락이 발생했다.
하지만, 서비스 로직을 보면, 잠금을 딱 한번 획득한 후에 트랜잭션이 끝나고 반납을 하고 있는데 왜 데드락이 생겼을까?
MySQL 로그 파일을 보고 원인을 알아낼 수 있었다.
2486 Query SELECT GET_LOCK('2', 10)
2490 Query SELECT GET_LOCK('3', 10)
2484 Query SELECT GET_LOCK('2', 10)
2487 Query SELECT GET_LOCK('3', 10)
2489 Query SELECT GET_LOCK('2', 10)
2488 Query SELECT GET_LOCK('1', 10)
2485 Query SELECT GET_LOCK('3', 10)
2482 Query SELECT GET_LOCK('2', 10)
2491 Query SELECT GET_LOCK('1', 10)
2483 Query SELECT GET_LOCK('3', 10)
2487 Query SELECT GET_LOCK('1', 10) <- 여기서 부터 동일한 세션을 사용한다.
2488 Query SELECT GET_LOCK('1', 10)
2482 Query SELECT GET_LOCK('3', 10)
2488 Query SELECT GET_LOCK('1', 10)
2488 Query SELECT GET_LOCK('2', 10)
2488 Query SELECT GET_LOCK('1', 10)
2488 Query SELECT GET_LOCK('3', 10)
2488 Query SELECT GET_LOCK('1', 10)
위 테스트 코드를 실행한 결과, 10개의 세션에서 락을 점유한 후에도 해당 세션의 작업이 끝날 때까지 대기하지 않고, 동일한 세션에서 추가로 잠금을 획득하려 시도하는 것을 알 수 있었다.
이로 인해 데드락이 발생했다.
해당 문제를 해결해보려 2일을 시도해봤지만, 해결하지 못했다.. 시간을 오래 잡아서 나중에 다시 알아보려 한다…
이렇게 한동안 머리 아팠던 네임드 락 부분이 끝이 났다. 학습을 하면서 네임드 락의 아쉬운 점이 다음과 같았다.
데이터베이스 커넥션 관리가 어렵다.
위 데드락이 발생한 것도 그렇고, 네임드 락을 트랜잭션 중간에 사용할 때, 트랜잭션을 최소 2개를 사용해야 한다. (자세한 내용은 해당 블로그를 확인하자)
또한, synchronized와 마찬가지로 분산 DB 환경에서 잠금이 어느 DB에 획득했는지 알 수 없기 때문에 잠금을 획득 및 반납이 어려워 진다.
위와 같은 이유로 네임드 락을 통한 분산 락은 지양할 것 같다.
다음에는 Redis를 이용한 분산 락 방식에 대해 정리해보려 한다.
Real MySql 8.0 (1권) 05. 트랜잭션과 잠금