동시성 관리하기 2 : 네임드 락

Woody·2024년 9월 6일

TIL

목록 보기
12/19

네임드 락이란

MySQL에서 제공하는 잠금 방식으로 잠금의 대상이 테이블이나 레코드와 같은 데이터베이스 객체가 아닌, 임의의 문자열에 대해 잠금을 설정할 수 있다.

네임드 락 관련 함수

  • GET_LOCK(str, timeout)
    • 문자열 str에 대해 잠금을 획득한다. 이미 잠금을 사용 중이라면, timeout 동안 대기하고, 만일 timeout이 음수라면 무한정 대기한다.
    • 특정 세션에서 여러 개의 네임드 락을 중첩해서 사용할 수 있기 때문에 데드락이 발생할 수 있다.
    • 여러 클라이언트가 락을 기다리는 경우, 락을 얻는 순서는 정의되어 있지 않기 때문에 락 요청이 발행된 순서대로 락이 얻어진다고 가정해서는 안된다.
    • 네임드 락은 트랜잭션이 커밋되거나 롤백될 때 해제되지 않는다. 따라서 RELEASE_LOCK() 함수를 사용해서 명시적으로 해제해야 한다.
    • 함수의 반환 값은 다음과 같다.
      • 1: 잠금을 성공적으로 얻은 경우
      • 0: 타임아웃된 경우
      • NULL: 오류가 발생한 경우(ex. 메모리 부족, 스레드 종료 등)
  • RELEASE_LOCK(str)
    • 현재 세션에서 설정된 락만 해제할 수 있으며, 다른 세션에서 설정한 락을 해제할 수 없다.
    • 함수의 반환 값은 다음과 같다.
      • 1: 락이 성공적으로 해제된 경우
      • 0: 락이 현재 스레드에 의해 설정된 것이 아닌 경우 (이 경우 락이 해제되지 않는다).
      • NULL: 지정한 이름의 락이 존재하지 않는 경우. (ex. 해당 문자열로 GET_LOCK()을 통해 락이 획득된 적이 없거나, 이전에 이미 해제된 경우일 수 있다.)
  • RELEASE_ALL_LOCKS()
    • 해당 세션에서 획득한 네임드 락을 RELEASE_ALL_LOCKS() 을 통해서 한 번에 모두 해제할 수도 있다.
    • 함수의 반환 값은 다음과 같다.
      • 해제된 잠금 수를 반환한다. (없는 경우 0을 반환한다.)
  • SELECT IS_FREE_LOCK(str)
    • 해당 문자열에 대해 잠금이 설정돼 있는지 확인한다.
    • 이 함수의 반환 값은 다음과 같다.
      • 1: 사용 가능한 문자열인 경우
      • 0: 다른 세션에서 사용 중인 문자열인 경우
      • NULL: 오류가 발생한 경우

스프링에서 네임드 락 사용하기

네임드 락 로직 구현

다음과 같이 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를 통해서 네임드 락 쿼리를 작성했다.
  • 잠금 획득: key 값에 대한 잠금 획득을 timeout 동안 시도하고, 잠금 획득의 성공 여부를 반환한다.
  • 잠금 반납: 해당 세션에서 얻었던 잠금을 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()
    }
}
  • synchronized를 통해 잠금을 걸 때와 마찬가지로 잠금 획득 → 트랜잭션 시작 순으로 진행하기 위해서 프로그래밍 방식으로 트랜잭션을 관리한다.
  • 존재하는 티켓인지 확인한 후, 해당 티켓 id에 대한 잠금을 획득한다.
  • 잠금을 획득한 후, 트랜잭션을 시작해서 쿠폰 발급 로직을 수행한다.
  • 로직이 끝난 후에 잠금을 반납한다.
  • 로직 수행 중에 만약 에러가 발생하면 별도로 잠금을 반납]한다.
    @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. 트랜잭션과 잠금

14.14 Locking Functions

[E-commerce] 동시성 문제 해결하기 (비관적 락, 네임드 락, 분산 락)

MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리

0개의 댓글