TIL

웹 어플리케이션에서의 동시성 제어 (feat. Lock)

 

화면에서 사용자가 버튼을 N번 연달아 클릭했다고 생각해보자. API가 연달아 호출된다. 만약 DB에 insert, update, delete하는 로직이 있었다면 같은 데이터가 N개 insert되는 등의 동시성 이슈가 발생한다. 물론 화면에서 더블클릭을 막는 등 여러 방법이 있겠지만 이번 포스팅에서는 백엔드 서버에서 Lock을 통해 해결하는 방식에 대해 알아본다.

 

 

사실 Lock에 대해서는 이전에 운영체제(OS)를 다루면서 포스팅한 적이 있다.

https://simsim231.tistory.com/134

 

[OS] Process 동기화(1)

[OS] Process 동기화(1) Process 동기화란 여러 프로세스가 공유하는 자원의 일관성을 유지하는 것 임계구역(Critical Section) 문제 do { entry section critical section exit section remainder section } while(TRUE); 임계구역

simsim231.tistory.com

 

여기서 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은 단 하나만 존재한다.

 

그 외에 프로그래밍에서도 비관적 락 구현을 제공한다.

 

  1. JPA에서는 Annotation으로 간단하게 비관적 락을 적용할 수 있다. 
    @Lock(LockModeType.PESSIMISTIC_WRITE)
  2. 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/

728x90
반응형