코드는 Github에서 보실 수 있습니다.
이전에는 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 통한 분산락을 구현하여 단일 서버는 물론 다중 서버에서도 동시성 문제를 해결하였습니다.
이번 글에서는 비관적 락과 비슷한 MYSQL의 USER-LEVEL Lock(Named Lock)을 적용해보겠습니다.
MySQL에서 제공하는 User-Level Lock(Named Lock)은 애플리케이션 레벨에서 데이터에 대한 락을 관리할 수 있는 기능입니다. 이를 통해 데이터베이스 테이블 레벨의 락 이외에 사용자 정의 잠금을 설정할 수 있습니다.
Named Lock:
다중 세션 간 락 공유:
비동기 락 획득:
락 timout 설정:
GET_LOCK('lock_name', timeout)
RELEASE_LOCK('lock_name')
IS_FREE_LOCK('lock_name')
RELEASE_ALL_LOCKS()
LOCK TABLES tbl1 READ, tbl2 WRITE
하나의 세션이 다른 이름의 Lock은 물론, 동일한 이름의 Lock을 여러개 획득하는 것도 가능합니다.
# 세션 1
SELECT GET_LOCK('hello', 100);
SELECT GET_LOCK('hello', 100);
# 세션 2
SELECT GET_LOCK('hello', -1); # 대기 중
# 세션 1
SELECT RELEASE_LOCK('hello'); # 세션 2 대기 중
SELECT RELEASE_LOCK('hello'); # 세션 2에서 Lock 획득
1. 세션1
2. 세션2
세션2는 대기를 합니다.
3. 세션1
세션1에서 'hello' 에 대한 락 해제를 2회 시도했습니다.
4. 세션2
그 순간 세션2에서 'hello' 에 대한 락을 획득합니다.
GET_LOCK()을 통해 여러 개의 잠금을 획득할 수 있으므로, 이로 인해 데드락이 발생할 수 있습니다.
# 세션 1
SELECT GET_LOCK('hello', 100);
# 세션 2
SELECT GET_LOCK('bye', 100);
# 세션 1
SELECT GET_LOCK('bye', 100); # 세션 2가 bye 에 대한 Lock을 가지고 있으므로 무한 대기
# 세션 2
SELECT GET_LOCK('hello', 100); # 세션 1이 hello 에 대한 Lock을 가지고 있으므로 무한 대기
# 데드락 발생
위와 같이 데드락이 발생하면, MYSQL은 ERROR 3058을 발생시켜 락 획득을 종료합니다. 이로 인해 트랜잭션이 롤백되지는 않습니다.
여러 클라이언트에서 락 획득을 기다리는 경우, 락을 획득하게 되는 순서는 정의되지 않습니다.
따라서 잠금 요청 순서대로 락을 획득할 것이라고 가정하고 사용해서는 안됩니다.
이전 글을 따라하셨다면 Product 클래스에서 @Version과 버전 필드를 제거해줍니다.
USER-LEVEL Lock의 획득과 반환을 수행하는 LockRepository를 구현합니다.
@Repository
public interface LockRepository extends JpaRepository<Product, Long> {
@Query(value = "SELECT GET_LOCK(:key, 300)", nativeQuery = true)
Integer getLock(String key);
@Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
Integer releaseLock(String key);
}
이제 재고 감소 로직 전 후로 락을 획득하고 반환해주는 작업을 처리하기 위한 클래스를 생성해줍니다.
@Service
@RequiredArgsConstructor
public class NamedLockFacade {
private final LockRepository lockRepository;
private final ProductService productService;
@Transactional
public void decrease(Long id) {
try {
int acquiredLock = lockRepository.getLock(String.valueOf(id));
if (acquiredLock != 1) {
throw new RuntimeException(String.format("Lock 획득에 실패했습니다. [id: %d]", id));
}
productService.decrease(id);
} finally {
lockRepository.releaseLock(String.valueOf(id));
}
}
}
USER-LEVEL Lock은 트랜잭션 종료시 반환되지 않기 때문에, RELEASE_LOCK()을 호출하여 명시적으로 해제해야 합니다.
이제 ProductService를 다음과 같이 변경합니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id) {
Product product = productRepository.getById(id);
product.decrease();
productRepository.saveAndFlush(product);
}
}
propagation을 REQUIRES_NEW로 설정하여 새로운 트랜잭션에서 동작하도록 구현하였습니다.
만약 락 획득과 재고감소 로직이 동일한 트랜잭션에서 발생한다면 여전히 동시성 문제가 발생하게 됩니다.
테스트 코드는 아래와 같습니다.
@SpringBootTest
class NamedLockFacadeTest {
@Autowired private NamedLockFacade namedLockFacade;
@Autowired private ProductRepository productRepository;
@Test
void 네임드_락_테스트() throws InterruptedException {
// given
Long id = productRepository.saveAndFlush(new Product(1L, 100)).getId();
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
//when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
namedLockFacade.decrease(id);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Product product = productRepository.getById(id);
assertThat(product.getQuantity()).isEqualTo(0);
productRepository.deleteAll();
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW) 을 사용하지 않고 위 테스트 코드를 실행한다면 아래와 같이 실패하게 됩니다.
하지만 지금 이 상황에서 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 붙이고 실행을 해도 결과가 마찬가지 입니다.
그 이유는 REQUIRES_NEW로 인해서 커넥션의 소모가 증가하여 올바르게 작동하지 않습니다.
일반적인 애플리케이션이라면 커넥션풀(DataSource)를 분리해주는 것이 좋지만 이는 학습을 위한 테스트이므로 간단히 커넥션 풀을 늘려서 처리하도록 하겠습니다.
application.yml 설정에 다음과 같이 커넥션 풀 사이즈를 추가해줍니다.
server:
port: 8080
spring:
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
highlight_sql: true
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/springconcurrent
username: root
password: 1234
hikari:
maximum-pool-size: 40
logging:
level:
org:
hibernate:
SQL: DEBUG
type:
descriptor:
sql:
BasicBinder: TRACE
다시 테스트를 실행해보면 성공하는걸 확인할 수 있습니다.
락이 반환되는 시점과 트랜잭션이 커밋되는 시점 사이에 새로운 요청이 들어와 완료된다면 USER-LEVEL Lock을 사용하기 전과 마찬가지로 동시성 문제가 발생하므로, 두 트랜잭션을 분리되어야 합니다.
만약 Lock을 획득, 반환하는 로직과 재고 감소하는 로직이 같은 트랜잭션에 묶여있다면 아래와 같은 상황이 발생할 것입니다.
락을 해제하는 시점과 트랜잭션이 커밋되는 시점 사이에 새로운 요청이 들어와 데이터를 조회한다면 동시성 문제가 발생하므로, 두 트랜잭션은 분리되어야 합니다.
위의 코드들은 위와 같은 그림으로 이루어져 있습니다.
따라서 커밋이 된 후에 락을 반환하기 때문에 동시성 문제를 해결할 수 있습니다.
장점 - 세부적인 락 제어로 성능 향상 및 데이터 무결성을 보장할 수 있으며, 애플리케이션 요구사항에 맞춘 유연한 동시성 제어가 가능합니다. 또한 클라우드 Redis를 사용하지 않아 비용 문제가 발생하지 않습니다.
단점 - 사용자 정의 락 구현에 복잡성이 있어 개발 난이도가 높아질 수 있습니다. 또한 락 관리 오류로 인한 데드락 발생 등의 문제가 발생할 수 있습니다. 클라우드 Redis 사용 시 얻을 수 있는 확장성 및 가용성의 이점을 누릴 수 없습니다.
다음 글에선 Redis를 활용한 분산 락을 적용해보겠습니다.
참고
https://ttl-blog.tistory.com/1569
https://techblog.woowahan.com/2631/
https://seungjjun.tistory.com/332?category=1065281