0%

MySQL 네임드락 알고 쓰자

서론

MySQL을 사용하다 보면 insert나 집계 쿼리를 실행할 때 동시성 이슈가 발생하는 경우가 있습니다. 하지만 존재하지 않는 컬럼에 락을 걸 수 없고 그렇다고 테이블 레벨에서 락을 걸면 성능 이슈가 발생하게 됩니다.

이런 상황을 효과적으로 해결할 수 있는 방법 중 하나가 네임드락(Named Lock)입니다. MySQL 네임드락을 사용할 때 특징과 주의해야할 점을 살펴보겠습니다.

네임드락(Named Lock)이란?

네임드락은 MySQL에서 특정 이름에 락을 걸어 다른 세션에서 해당 이름의 락을 획득하려고 할 때 대기하게 하는 기능입니다.

중요한 점은 락을 관리하는 단위가 세션 단위라는 것입니다. 이는 모든 명령어의 기준이 세션에 의해 결정되며, 따라서 세션이 종료되면 해당 세션이 보유한 모든 락도 자동으로 해제된다는 것을 의미합니다.

자세한 내용은 간단한 명령어를 알아보고 주의해야할 점과 함께 살펴보겠습니다.

GET_LOCK

GET_LOCK() 함수를 사용하여 네임드락을 획득할 수 있습니다.

1
2
3
-- 'lock_name'이라는 이름의 락을 최대 10초 동안 시도하여 획득
SELECT GET_LOCK('lock_name', 10);
-- 성공: 1, 실패: 0

만일 명령을 실행한 세션에서 이미 락을 획득한 상태라면 1(성공)을 반환합니다.

RELEASE_LOCK

RELEASE_LOCK() 함수를 사용하여 네임드락을 해제할 수 있습니다.

1
2
3
-- 'lock_name'이라는 이름의 락을 해제
SELECT RELEASE_LOCK('lock_name');
-- 성공: 1, 실패: 0, 락 존재X: NULL

IS_FREE_LOCK

IS_FREE_LOCK() 함수를 사용하여 네임드락이 해제되었는지 확인할 수 있습니다.

1
2
-- 락 사용가능: 1, 락 사용중: 0
SELECT IS_FREE_LOCK('lock_name');

세션

MySQL의 세션은 연결된 클라이언트를 의미합니다. 만일 Spring Boot 같은 프레임워크를 사용하고 있다면, 세션은 커넥션 풀에서 가져온 커넥션을 의미합니다.

즉 커넥션 풀에서 커넥션을 가져오고 반납하는 과정을 제대로 관리하지 않으면 락이 의도하는 대로 동작하지 않을 수 있습니다.


세팅

먼저 간단하게 세팅을 하겠습니다. 제가 사용한 MySQL버전은 8.3.0입니다.

Users

1
2
3
4
5
6
7
8
@Entity
@Table(name = "users")
public class User {
@Id
private int id;

private String name;
}

UserRepository

간단하게 사용하기 위해서 native query를 사용합니다.

1
2
3
4
5
6
7
8
public interface UserRepository extends JpaRepository<User, Integer> {

@Query(value = "SELECT GET_LOCK(:lockName, :timeout)", nativeQuery = true)
Integer acquireNamedLock(@Param("lockName") String lockName, @Param("timeout") int timeout);

@Query(value = "SELECT RELEASE_LOCK(:lockName)", nativeQuery = true)
Integer releaseNamedLock(@Param("lockName") String lockName);
}

문제점1 (세션)

먼저 락이 잘 동작하는지 확인하겠습니다. 반복문을 돌면서 lock이라는 이름의 락을 획득하고 성공한 횟수를 세어보겠습니다.

1
2
3
4
5
6
7
8
9
10
@Test
public void test() {
int success = 0;
for (int i = 0; i < 10; i++) {
if(userRepository.acquireNamedLock("lock", 1) == 1) {
success++;
}
}
assertThat(success).isEqualTo(1);
}

lock

예상과 다르게 10번 모두 성공했습니다. 락을 해제하는 코드가 없는데도 불구하고 모두 성공한 이유는 무엇일까요?

그 이유는 커넥션(세션)을 재활용 하기 때문입니다.

  1. 처음 락을 획득하고 커넥션을 커넥션 풀에 반납을 합니다.
  2. 다시 락을 획득하려고 할 때 커넥션 풀에서 동일한 커넥션을 가져옵니다.
  3. 해당 커넥션(세션)은 이미 락을 획득한 상태이기 때문에 get_lock()은 1을 반환합니다.

문제점2 (세션)

이번에는 락을 해제 할 때 문제가 발생하는 상황을 살펴보겠습니다. 여러 쓰레드에서 락을 획득하고 해제하는 과정을 간단하게 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void release_lock_test() throws Exception {
userRepository.acquireNamedLock("lock", 1);

new Thread(() -> {
// select sleep(10);
userRepository.sleep(10);
}).start();

new Thread(() -> {
userRepository.releaseNamedLock("lock");
}).start();

Thread.sleep(1000);

// select IS_FREE_LOCK('lock');
assertThat(userRepository.isFreeLock("lock")).isEqualTo(1);
}

lock

이번에도 예상과 다르게 락을 해제 했음에도 불구하고, 락을 사용 중인 상태(0)로 나옵니다. 이유는 무엇일까요?

락을 획득한 커넥션(세션)에서 락을 해제하려고 하지 않기 때문에 락 해제에 실패합니다.

  1. 락을 획득합니다.
  2. 락을 획득한 커넥션을 가져와 sleep(10)을 실행해 커넥션을 사용 중인 상태로 만듭니다.
  3. 새로운 커넥션을 가져와 락을 해제하려고 합니다.
  4. 하지만 락을 획득한 커넥션에서 해제하지 않았기 때문에 락 해제가 되지 않습니다. 따라서 락을 사용 중인 상태로 나옵니다.

따라서 락을 획득하고 해제하는 과정은 동일한 커넥션(세션)에서 이루어져야 합니다.

트랜잭션

커넥션을 커넥션풀에 반납하는 문제를 해결하기 위해서 @Transactional을 사용할 수 있습니다. @Transactional을 사용하면 트랜잭션이 종료되기 전까지 커넥션을 커넥션풀에 반납하지 않고 계속 사용할 수 있습니다.

@Transactional을 사용하면 커넥션의 문제는 해결이 되지만 동시성 이슈가 발생하게 됩니다. 왜냐하면 네임드락은 트랜잭션 종료 때 해제되지 않기 때문에 명시적으로 락을 해제 해야 하기 때문입니다.

하지만 트랜잭션이 끝나기 전에 락을 해제하면 동시성 이슈가 발생할 수 있습니다.

아래와 같은 상황이 동시성 이슈가 발생하는 상황입니다.

lock

따라서 동시성 문제를 해결하기 위해 네임드락을 사용하기 때문에 트랜잭션으로 커넥션 재활용 문제를 해결하면 안됩니다.

해결?

해결 방법은 커넥션을 2개 사용하는 방법입니다. 하나는 락을 획득하고 해제하는 커넥션, 다른 하나는 트랜잭션을 사용하는 커넥션입니다. 19년도에 우아한 블로그에서 이와 관련된 글을 잘 정리해 주셨습니다.

하지만 락을 획득하고 해제하기까지 2개의 DB커넥션을 사용하고 코드 복잡도가 높아지는 것은 트레이드 오프가 될 수 있습니다.


반면 Redis를 활용하면 2개의 DB커넥션을 사용하지 않고 해결을 할 수 있습니다. kurly 블로그에서 AOP와 Redis를 사용한 락을 구현하는 방법을 소개해주었습니다.

하지만 Redis를 사용하면 추가적인 인프라 비용이 발생하게 됩니다.

마치며

MySQL 네임드락은 동시성 문제를 해결할 수 있는 유용한 도구입니다. 그러나 세션 관리에 주의해야 할 점이 많아 신중한 사용이 필요합니다. Redis와 같은 대안도 고려할 수 있지만, 추가 인프라 비용이 발생할 수 있습니다. 결국, 프로젝트의 요구사항과 환경에 맞는 적절한 락 관리 방식을 선택하는 것이 중요합니다.