화면에서 사용자가 버튼을 N번 연달아 클릭했다고 생각해보자. API가 연달아 호출된다. 만약 DB에 insert, update, delete하는 로직이 있었다면 같은 데이터가 N개 insert되는 등의 동시성 이슈가 발생한다. 물론 화면에서 더블클릭을 막는 등 여러 방법이 있겠지만 이번 포스팅에서는 백엔드 서버에서 Lock을 통해 해결하는 방식에 대해 알아본다.
사실 Lock에 대해서는 이전에 운영체제(OS)를 다루면서 포스팅한 적이 있다.
https://simsim231.tistory.com/134
여기서 Critical Section, DeadLock 등 공통된 개념들이 나오는데, 이번 포스팅에서는 OS에서의 동시성 제어가 아닌, 웹 어플리케이션에서의 동시성 제어에 좀 더 집중해서 작성한다.
웹 어플리케이션에서 우리는 API가 연달아 호출되면 발생하는 이슈를 Lock을 통해 막아야 한다. 그럼 여러 방식중에 선택을 해야하는데, 바로 생각해볼 수 있는 것은 2가지다. 데이터를 읽는 시점에 락을 거는 비관적 락, 그리고 충돌이 발생했을 때 이를 감지하고 처리하는 낙관적 락. 이 둘은 충돌을 미리 막는지, 혹은 발생했을 때 처리하는지 그 시점의 차이라고 할 수 있다. 이 둘에 대해 자세히 알아보자.
비관적 락
비관적 락은 동시 업데이트가 빈번하게 발생할 것이라고 '비관적으로' 가정하고 데이터를 읽는 시점에 락을 걸어 다른 트랜잭션의 접근을 차단하는 락이다. 이 방식은 데이터 무결성을 강하게 보장하지만, DeadLock이 발생하는 등 동시성 처리 성능이 낮아질 수 있다.
비관적 락은 주로 DBMS에서 제공하는 다양한 메커니즘을 사용해 구현한다.
트랜잭션이 시작(Auto Commit 비활 or BEGIN or START TRANSACTION)될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작한다. 트랜잭션이 커밋되거나 롤백되어 완료가 되면 Lock이 반환된다. 혹은 UNLOCK TABLE 키워드로 명시적으로 해제할 수도 있다.
Shared Lock 공유잠금
- 읽기 잠금이다.
- 리소스를 다른 사용자가 동시에 읽을 수 있지만 변경은 불가하게 하는 것이다.
- 어떤 자원에 Shared lock이 하나라도 걸려있으면 Exclusive lock을 걸 수 없다. (Shared lock은 여러 개 걸릴 수 있음)
- 일반적인 SELECT 쿼리는 락을 사용하지 않고 DB를 읽지만 (그래서 Shared Lock이나 Exclusive Lock이 걸려있는 Row도 그냥 읽을 수 있음), SELECT ... FOR SHARE 등 일부 SELECT 쿼리는 특정 Row를 읽을 때 각각 Row에 Shared Lock을 건다.
Exclusive Lock 배타적 잠금
- 쓰기 잠금이다.
- 리소스를 다른 사용자가 동시에 읽지도, 변경하지도 못하게 한다.
- Exclusive Lock에 걸리면 Shared Lock, 다른 Exclusive Lock을 걸 수 없다.
- SELECT ... FOR UPDATE 나 UPDATE, DELETE 등의 수정 쿼리를 날릴 때 각 Row에 걸리는 Lock이다.
DBMS에서 제공하는 Lock의 종류의 예시는 다음과 같다.
- 글로벌 락: 가장 범위가 큰 락. DB 서버 전체에 락을 거는 것
- 테이블 락(TM): 개별 테이블 단위로 설정되는 락
- 네임드 락: 임의의 문자열에 대한 잠금 설정. 분산락 구현에 활용될 수 있음
- 레코드 락(TX): 레코드 자체만을 잠그는 것
- 갭 락: 레코드 자체가 아닌 레코드와 바로 인접한 레코드 사이의 간격만을 잠그는 것. 레코드와 레코드 사이의 간격에 새로운 레코드가 생성(insert) 되는 것을 제어한다.
- 넥스트 키 락: 레코드 락과 갭 락을 합쳐놓은 형태이다. 바이너리 로그에 기록되는 쿼리가 replica server에서 실행될 때 source server에서 만들어낸 결과와 동일한 결과를 만들어 내도록 보장해주는 것이다.
- Auto Increment Lock: AUTO_INCREMENT 칼럼이 적용된 테이블에 동시에 여러 레코드가 insert되는 경우, 저장되는 각 레코드는 중복되지 않고 순서대로 증가하는 일련번호를 가져야 하기 때문에 내부적으로 auto_increment lock이라는 테이블 수준의 lock을 사용한다. 트랜잭션과 관계 없이 auto_increment 값 가져오는 순간만 락이 걸렸다가 즉시 해제되며, 테이블에서 Lock은 단 하나만 존재한다.
그 외에 프로그래밍에서도 비관적 락 구현을 제공한다.
- JPA에서는 Annotation으로 간단하게 비관적 락을 적용할 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE) - Java에서 제공하는 synchronized 키워드나 ReentrantLock 클래스를 사용해 비관적 락을 구현할 수 있다.
/** Synchronized 예시 **/
public synchronized void criticalSection() {
// 이 코드 블록은 한 번에 하나의 스레드만 접근 가능
}
/** ReentrantLock 예시 **/
ReentrantLock lock = new ReentrantLock();
lock.lock(); // 락 획득
try {
// critical section
} finally {
lock.unlock(); // 락 해제
}
낙관적 락
낙관적 락은 데이터 수정 시 충돌이 발생하지 않을 것이라고 이름 그대로 '낙관적으로' 가정하고, 충돌이 발생했을 때 이를 감지하고 처리한다. 이는 직접적으로 DB에 락을 거는 방법이 아니기에 간단하고 성능에 유리하지만, 높은 동시성 환경에서는 오히려 재시도 비용이 증가하는 등 한계가 있다.
낙관적 락은 어플리케이션에서 version과 같은 컬럼값을 통해 제어한다. 내가 먼저 이 값을 수정했다고 명시해서 다른 사람이 동일한 조건으로 값을 수정할 수 없게 하는 것이다. (Hashcode나 Timestamp를 이용하기도 한다.)
예를 들어, A였던 데이터를 B로 바꿔주면서 Version2로 바꿔주고 다른 트랜잭션에서 C로 바꿔주면서 Version2로 바꿔준다면 이미 Version2로 바껴있기 때문에 업데이트가 되지 않을 것이다.
비관적 락 vs 낙관적 락
- 이 두 개념은 데이터베이스 또는 동시성 제어에서 충돌 해결 전략에 관한 것이다.
- 낙관적 락은 데이터 업데이트 전의 조회와 Lock 작업, 그리고 트랜잭션이 필요없다. 성능적으로 비관적 락보다 좋다.
- 낙관적 락은 롤백이 어렵다. 개발자가 수동으로 롤백처리 해줘야 한다. 비관적 락은 트랜잭션을 롤백하면 된다.
- 결론적으로 낙관적 락은 충돌이 많이 예상되거나 충돌이 발생했을 때 비용이 많이 들 것이라고 판단되는 곳에서는 사용하지 않는 것이 좋다.
분산 락
이러한 락들 중에서 '분산 락'이라는 것이 있다. 분산 락은 스케일 아웃된 DB 확경 또는 여러대의 서버가 있을 때 사용하는 락이다. 즉, 비관적 락의 확장된 형태로 볼 수 있다. 분산 락은 락을 획득한 프로세스 혹은 스레드만이 공유 자원 혹은 Critical Section에 접근할 수 있도록 한다. 구현하는 방법으로는 ZooKeeper, MySQL, Redis 등이 있다.
MySQL로 구현하는 방법
MySQL의 네임드락: 락을 자동으로 반납할 수 없어 명시적으로 락을 release 시켜야하는 방식. DB에서 락을 관리하기 때문에 부담이 있음. 락 획득 시도는 스핀락(Race Condition 상황에서 Lock이 반환될 때까지 프로세스가 재시도하며 대기하는 상태)으로 구현해야하기 때문에 WAS에도 부담이 있음. 그리고 connection pool 따로 관리 필요.
- GET_LOCK(str, timeout): 1 (잠금 획득 성공), 0 (timeout초 동안 잠금 획득 실패), null (잠금 획득 중 에러)
- RELEASE_LOCK(str): 1 (잠금 해제 성공), 0 (잠금 해제되지 않음. 현재 쓰레드에서 획득한 잠금이 아닌 경우), null (잠금 존재하지 않음)
- RELEASE_ALL_LOCKS(): 현재 세션에서 유지되고 있는 모든 잠금을 해제하고 해제한 잠금 갯수를 반환
- IS_FREE_LOCK(str): 잠금 획득 가능여부 확인. 1 (입력한 이름의 잠금이 없음), 0 (입력한 이름의 잠금이 있음). null (에러 발생)
- IS_USED_LOCK(str): 잠금이 사용중인지 확인. 잠금 존재시 connection id를 반환하고 없으면 null 반환
구현 예제
public class UserLevelLockFinal {
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 DataSource dataSource;
public UserLevelLockFinal(DataSource dataSource) {
this.dataSource = dataSource;
}
public <T> T executeWithLock(String userLockName,
int timeoutSeconds,
Supplier<T> supplier) {
try (Connection connection = dataSource.getConnection()) {
try {
log.info("start getLock=[], timeoutSeconds ], connection=[]", userLockName, timeoutSeconds, connection);
getLock(connection, userLockName, timeoutSeconds);
log.info("success getLock=[], timeoutSeconds ], connection=[]", userLockName, timeoutSeconds, connection);
return supplier.get();
} finally {
log.info("start releaseLock=[], connection=[]", userLockName, connection);
releaseLock(connection, userLockName);
log.info("success releaseLock=[], connection=[]", userLockName, connection);
}
} catch (SQLException | RuntimeException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private void getLock(Connection connection,
String userLockName,
int timeoutseconds) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement(GET_LOCK)) {
preparedStatement.setString(1, userLockName);
preparedStatement.setInt(2, timeoutseconds);
checkResultSet(userLockName, preparedStatement, "GetLock_");
}
}
private void releaseLock(Connection connection,
String userLockName) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement(RELEASE_LOCK)) {
preparedStatement.setString(1, userLockName);
checkResultSet(userLockName, preparedStatement, "ReleaseLock_");
}
}
private void checkResultSet(String userLockName,
PreparedStatement preparedStatement,
String type) throws SQLException {
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = [], userLockName ], connection=[]", type, userLockName, preparedStatement.getConnection());
throw new RuntimeException(EXCEPTION_MESSAGE);
}
int result = resultSet.getInt(1);
if (result != 1) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = [], result ] userLockName ], connection=[]", type, result, userLockName, preparedStatement.getConnection());
throw new RuntimeException(EXCEPTION_MESSAGE);
}
}
}
}
- DataSource 를 주입받아 JDBC 를 이용하여 직접 구현하여 Connection 을 직접 관리
→ GET_LOCK() 과 REALSE_LOCK() 모두 동일한 Connection 을 사용 - 위 코드에서, Lock을 획득하여 작업한 스레드의 트랜잭션이 커밋되기 전에 lock이 해제되고 lock이 해제되어 다음 스레드가 트랜잭션에서 작업을 시작하면 문제가 되기 때문에 트랜잭션 커밋 후 lock을 해제해야함
→ AOP로 Lock을 걸고 푸는 것의 관심사를 분리하고, Transaction의 전파수준을 REQUIRES_NEW로 설정해서 분산 락의 트랜잭션을 분리하는 것으로 개선할 수 있음
Redis로 구현하는 방법
별도의 인프라를 구축해야하지만 인메모리 DB로 속도가 빠르다는 장점이 있음. 싱글스레드 방식으로 동시성 문제가 현저히 적다. 캐시 저장소로 활용이 가능하다.
Redis 구현체
- Jedis: 성능이 좋지 않음
- Lettuce: setnx(SET if Not eXists 값이 존재하는지 확인 → 없으면 값 세팅. 레디스에서 제공하는 원자적 연산)를 활용한 스핀락 사용
- Redisson: pubsub 방식 사용. 리소스 절약할 수 있음
Redis 사용하는 이유?
- Lock을 구현하는 여러 기능들을 제공한다.
- 간단하게 Redis의 명령어를 사용하여 Lock을 구현할 수도 있다.
- Pub/Sub 구조로 Lock을 구현할 수도 있다.
- retry, timeout과 같은 부가 기능들을 제공한다.
- MySQL에서도 네임드 락으로 분산 락을 구현할 수 있지만, Redis에 비해 단점이 존재한다.
- 다른 중요 비즈니스 데이터가 존재하는 DB에서 Lock을 관리하기 때문에 부담이 있다.
- Lock을 자동으로 해제할 수 없어서 명시적으로 해제해줘야 한다.
- Redis의 구조가 싱글 스레드의 Multiplexing 기술을 사용해서 원자성 보장에 적합하다.
- 클라이언트의 요청은 여러 스레드를 사용하여 전달받고, 실제로 작업을 처리하는 부분은 싱글 스레드로 동작하여 원자성을 보장한다.
Reference
https://juno-juno.tistory.com/111
https://sabarada.tistory.com/175
https://velog.io/@a01021039107/분산락으로-해결하는-동시성-문제이론편
https://techblog.woowahan.com/2631/
https://ksh-coding.tistory.com/150
https://helloworld.kurly.com/blog/distributed-redisson-lock/
'TIL' 카테고리의 다른 글
헥사고날 아키텍처 Hexagonal Architecture 란? (0) | 2025.01.17 |
---|---|
EDA 기반의 MSA 환경에서 서비스간 데이터 정합성 맞추기 (SAGA Pattern, Outbox Pattern) (0) | 2024.10.07 |
Kafka Consumer의 commit을 제어하는 enable.auto.commit과 AckMode (0) | 2024.08.20 |
Servlet부터 Filter와 Interceptor까지 (0) | 2024.07.24 |
무중단 배포 전략 - 롤링, 블루-그린, 카나리 배포 (0) | 2024.01.21 |