들어가며
분산 락을 구현하는 방법은 Zookeeper, Redis 등 여러가지 방법들이 있습니다. 다만, Zookeeper나 Redis를 이용하여 분산 락을 구현하게 되면 인프라 구축에 대한 비용도 무시할 수 없습니다.
하지만 MySQL에서는 분산 환경에서의 락을 구현하기 위한 자체 락을 제공합니다. 이는 네임드 락(Named Lock)이라고 불리며, MySQL에서만 지원되는 락입니다. 분산 락의 사용량이 추가적인 비용을 들일만큼 크지 않다면 MySQL이 제공하는 락을 사용하는게 적절하다고 생각합니다.
Named Lock 살펴보기
MySQL이 제공하는 네임드 락을 사용하기 위해서는 관련 함수를 사용해야 합니다. 함수의 종류와 설명은 다음과 같습니다.
GET_LOCK(String, timeout)
- 입력받은 이름(String)으로 timeout(단위: 초) 동안 잠금 획득을 시도한다. timeout에 음수를 입력하면 잠금을 획득할 때 까지 무한대기하게 된다.
- 한 세션에서 잠금을 유지하고 있는 동안에는 다른 세션에서 동일한 이름의 잠금을 획득할 수 없다.
- GET_LOCK()을 이용하여 획득한 잠금은 트랜잭션(Transaction)이 커밋(Commit)되거나 롤백(Rollback)되어도 해제되지 않는다.
- GET_LOCK()의 결괏값은 1, 0, null을 반환한다.
- 1 : 잠금 획득 성공
- 0 : 잠금 획득 실패
- null : 잠금 획득 대기 중 에러 발생
RELEASE_LOCK(String)
- 입력받은 이름(String)의 잠금을 해제한다.
- RELEASE_LOCK()의 결괏값은 1, 0, null을 반환한다.
- 1 : 잠금 해제 성공
- 0 : 잠금 해제 실패
- null : 잠금이 존재하지 않을 때
- IS_FREE_LOCK(String)
- 입력한 이름(String)에 해당하는 잠금이 획득 가능한지 확인한다.
- 1 : 잠금을 얻을 수 있을 때
- 0 : 잠금을 얻을 수 없을 때
- null : 에러가 발생했을 때
- IS_USED_LOCK(String)
- 입력한 이름(String)의 잠금이 사용중인지 확인한다.
- 입력받은 이름의 잠금이 존재하면 connection id를 반환하고, 없으면 null을 반환한다.
네임드 락으로 분산 락 구현하기
먼저 기존 코드를 살펴본 뒤, JPA에서의 사용자 정의 레파지토리를 통해 GET_LOCK()과 RELEASE_LOCK을 이용하여 분산 락을 구현해보겠습니다.
@Transactional
public void updatePetFood(Long petFoodId, final String name) {
PetFood petFood = petFoodRepository.getById(petFoodId);
petFoodRepository.updateName();
}
위 메서드의 문제는 동시 요청이 들어올 때 N 번의 갱신 분실 문제가 발생한다는 점입니다. 분산 환경에서 이를 해결하기 위해 네임드 락을 사용할 수 있는데, 아래와 같이 적용해볼 수 있을 것입니다.
public class AdminService {
@Transactional
public void updatePetFood(Long petFoodId, final String name) {
PetFood petFood = petFoodRepository.getById(petFoodId);
petFoodRepository.updateWithNamedLock(
"Lock Name is " + petFood.getId(),
5000,
() -> {
petFoodRepository.updateName(name);
return null;
}
);
}
}
public interface PetFoodRepository extends JpaRepository<PetFood, Long>, LockPetFoodRepository {
default PetFood getById(Long id) {
return findById(id)
.orElseThrow(() -> new PetFoodNotFoundException(id));
}
}
public interface LockPetFoodRepository<T> {
void updateWithNamedLock(final String lockName, int timeout, Supplier<T> supplier);
void updateName(final String name);
}
@Slf4j
@RequiredArgsConstructor
public class NamedLockPetFoodRepository implements LockPetFoodRepository {
private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
private static final String EXCEPTION_MESSAGE = "LOCK을 수행하는 중에 오류가 발생하였습니다.";
private final JdbcTemplate jdbcTemplate;
@Override
public void updateWithNamedLock(final String lockName, final int timeoutSeconds, final Supplier supplier) {
try {
log.info("start getLock={}, timeoutSeconds={}, connection={}", lockName, timeoutSeconds);
getLock(lockName, timeoutSeconds);
log.info("success getLock={}, timeoutSeconds={}, connection={}", lockName, timeoutSeconds);
supplier.get();
} finally {
releaseLock(lockName);
}
}
private void releaseLock(final String lockName) {
Object[] params = new Object[]{lockName};
log.info("ReleaseLock!! userLockName={}", lockName);
Integer result = jdbcTemplate.queryForObject(GET_LOCK, params, Integer.class);
checkResult(result, lockName, "ReleaseLock");
}
private void getLock(final String lockName, final int timeoutSeconds) {
Object[] params = new Object[]{lockName, timeoutSeconds};
log.info("GetLock!! userLockName={}, timeoutSeconds={}", lockName, timeoutSeconds);
int result = jdbcTemplate.update(RELEASE_LOCK, params);
checkResult(result, lockName, "GetLock");
}
private void checkResult(Integer result, String userLockName, String type) {
if (result == null) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = {}, userLockName={}", type, userLockName);
throw new RuntimeException(EXCEPTION_MESSAGE);
}
if (result != 1) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = {}, result = {}, userLockName={}", type, result, userLockName);
throw new RuntimeException(EXCEPTION_MESSAGE);
}
}
}
Consumer, Supplier와 같은 함수형 인터페이스를 사용하여 동시성 이슈가 발생하는 기능을 네임드 락 내부 트랜잭션에서 실행시킬 수 있습니다.
물론 네임드 락을 관리하는 레파지토리에서 다른 레파지토리를 의존하여 데이터를 수정할 수도 있겠지만, 좋은 방법은 아니라고 생각합니다.
중복 트랜잭션으로 인한 동시성 이슈 재발
위 코드에는 치명적인 오류가 숨어있습니다. 바로 중복된 트랜잭션으로 인한 새로운 동시성 이슈가 발생합니다.
네임드 락을 통해 동시성 문제를 해결하려고 했지만, 새로운 동시성이 생겨버린 상황입니다. 이는 Service 계층에서 Repository를 호출할 때 동일한 트랜잭션을 공유하여 사용할 때 발생할 수 있는 문제입니다.
이를 해결하는 방법은 간단합니다. 바로 트랜잭션을 분리하면 됩니다. 트랜잭션을 분리하게 된다면 잠금을 얻고, 동시성이 발생할 수 있는 메서드를 실행하고, 락을 해제하는 순서대로 진행한 다음 곧바로 커밋(Commit)이 이루어집니다.
즉, 데이터가 데이터베이스에 반영되기 전에 다른 트랜잭션이 같은 데이터를 변경할 일이 없다는 것이죠. 다들 아시다시피 트랜잭션을 분리하는 @Transaction(propagation = REQUIRES_NEW)라는 유용한 속성이 존재합니다.
@Slf4j
@RequiredArgsConstructor
public class NamedLockPetFoodRepository implements LockPetFoodRepository {
private final JdbcTemplate jdbcTemplate;
@Override
@Transaction(propagation = REQUIRES_NEW)
public void updateWithNamedLock(final String lockName, final int timeoutSeconds, final Supplier supplier) {
try {
log.info("start getLock={}, timeoutSeconds={}, connection={}", lockName, timeoutSeconds);
getLock(lockName, timeoutSeconds);
log.info("success getLock={}, timeoutSeconds={}, connection={}", lockName, timeoutSeconds);
supplier.get();
} finally {
releaseLock(lockName);
}
}
...
}
하지만, 여기서 고려해야할 부분이 한 가지 더 있습니다. 바로 데이터베이스 커넥션(DBCP) 부족으로 인한 데드락 문제입니다. 만약 DBCP가 10(default value)로 설정되어 있고, 동시 요청이 10개가 발생했다고 가정해보겠습니다.
동시에 10개의 요청이 들어온다면 Service @Transaction에서 DBCP 10을 다 써버린 상태이지만, Repository의 @Transaction(propagation = REQUIRES_NEW)로 인해 새로운 커넥션을 받으려고 할 것입니다.
이는 서로 다른 요청들이 하나의 자원을 두고 경쟁하는 Race Condition이 발생한 상태며, 데드락으로 이어질 것입니다. 이를 해결하기 위해 새로운 트랜잭션을 얻을 때 타임아웃(timeout)을 설정해주는게 중요합니다.
@Slf4j
@RequiredArgsConstructor
public class NamedLockPetFoodRepository implements LockPetFoodRepository {
private final JdbcTemplate jdbcTemplate;
@Override
@Transaction(propagation = REQUIRES_NEW, timeout = 1)
public void updateWithNamedLock(final String lockName, final int timeoutSeconds, final Supplier supplier) {
try {
log.info("start getLock={}, timeoutSeconds={}, connection={}", lockName, timeoutSeconds);
getLock(lockName, timeoutSeconds);
log.info("success getLock={}, timeoutSeconds={}, connection={}", lockName, timeoutSeconds);
supplier.get();
} finally {
releaseLock(lockName);
}
}
...
}
네임드 락으로 구현한 분산 락의 코드입니다.
Pessimistic Lock으로의 처리는 불가능할까?
여기서 문득 떠오르는 생각은 Pessimistic Lock(비관적 락)으로 처리하면 되지 않을까요? 많은 분들이 비관적 락에는 타임아웃(timeout)을 걸 수 없어서 데드락의 위험이 있다고 하지만 비관적 락에도 @QueryHint 또는 em.persist에 타임아웃 속성을 걸 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout",value = "1000")})
@Query("update PetFood set name = :name where id = 1")
void update(@Param("name") String name);
이 질문에 대답하려면 비관적 락과 네임드 락의 차이점을 다시 살펴봐야 합니다. 우선 비관적 락은 for update 구문으로 레코드 레벨에 잠금을 겁니다. 이렇게 레코드레벨에 락을 걸어버리면 해당 태스크의 트랜잭션 뿐만 아니라 다른 태스크들을 처리하기 위한 커넥션들도 락이 걸린 레코드에 접근하지 못합니다.
반면에 네임드 락의 경우 락의 대상이 테이블이나 레코드 또는 auto_increment와 같은 데이터베이스 객체가 아니라는 점입니다. 네임드 락은 단순히 사용자가 지정한 문자열(String)에 대해 획득하고 반납(해제)하는 잠금입니다.
그렇기 때문에 동시성이 발생하는 트랜잭션에 대해서 동시성 이슈를 안전하게 처리할 수 있으면서, 다른 태스크들을 처리하기 위한 커넥션에는 전혀 영향을 주지 않을 수 있습니다.
마무리하며
여담으로 우아한기술블로그의 분산 락 글을 보고 많이 생각해보고 학습했는데, 잘 이해가 가지 않는 부분도 존재했습니다. 네임드 락의 릴리즈 시점과 트랜잭션의 커밋 시점 사이에 들어오는 요청을 막기위해 DataSource로 커넥션을 만들어 사용하는 방법을 선택했는데, 이는 오히려 개발자가 고려할 사항이 많아지는 것이라고 생각합니다.
우선 스프링에서 빈으로 관리해주고 있는 DataSource 외에 새로운 DataSource를 만들어야 하기 때문에 2개의 DataSource와 Connection Pool을 관리해야 합니다. 벌크 헤드를 고려하지 않은 상태였다면 불필요한 자원 하나를 더 관리해야합니다.
또한 Connection, PrepareStatement와 같은 JDBC 자원들을 직접 꺼내 사용하기 때문에 불필요한 try-catch-resoureses 구문을 사용해야합니다.
물론 특이한 요구사항이나 제가 생각해보지 못한 부분이 있을 수 있지만, 대부분의 상황에서는 DataSource를 만들기 보다 @Transaction(propagation=REQUIRES_NEW)를 사용하는게 훨씬 간편할거라고 생각합니다. 여러분은 어떻게 생각하시나요?
'Database' 카테고리의 다른 글
[DB] HikariCP 최적화 알아보기 (0) | 2023.10.14 |
---|---|
[Database] 트랜잭션, InnoDB 락(Lock) - Real MySQL 8.0 (0) | 2023.06.21 |
[DB] MySQL 아키텍쳐 파헤치기 (1) | 2023.03.02 |
[DB] 가용성과 데이터의 복제 (0) | 2023.02.09 |
[Spring] Spring Boot + Redis 제대로 활용하기(1) (0) | 2023.01.18 |