JPA, 낙관적 락(Optimistic Lock) 과 비관적 락(Pessimistic Lock) 동시성 이슈 이해하기

BlackBean99·2023년 7월 30일
2

DB

목록 보기
5/5
post-thumbnail

최근 동시성 관련한 이슈들이 발생해서 해결했던 경험을 포스팅합니다.
그 전에 각 락의 원리에 대해서 간단하게 이해해보고 다음 포스팅에서 실제로 제 Recruit HR 플랫폼 개발에서 어떻게 실전 활용을 하는지 보여드리겠습니다.

일단 Lock 들의 원리를 이해해보자

Pessimistic Lock(비관적 락)

비관적 락은 트랜잭션이 시작될 때 Shared Lock 이나 Exclusive Lock을 걸고 시작합니다.
Shared Lock 을 걸게 되면 Write 할 때, Exclusive Lock을 얻어야 합니다. 다른 트랜젝션이 락을 걸었으면 해당 Lock을 얻지 못해서 업데이트를 하지 못해서 대기 하다가 앞서 걸린 트랜잭션이 종료되고 나서 해당 대기된 연산이 그 뒤에 실행됩니다.

Transaction_1, table의 Id 2번을 읽음 ( name = Karol )

  1. Transaction_2, table의 Id 2번을 읽음 ( name = Karol )
  2. Transaction_2, table의 Id 2번의 name을 Karol2로 update request 요청 ( name = Karol )
  • 하지만 Transaction 1, shared Lock을 잡고 있어 Blocking 대기
  1. Transaction_1 에서 트랜잭션 해제 (commit)
  2. Blocking(대기) 되어있었던 Transaction_2의 update 요청 정상 처리

트랜잭션을 단위로 충돌을 방지하는 것이 비관적 락입니다.

Optimistic Lock(낙관적 락)

낙관적 락은 DB 자체에서 처리하는 것이 아닌 Application Layer에서 처리해주는 것입니다.
version을 이용해서 앞선 요청이 먼저 수정했다고 명시하며 다른 처리를 막는 것입니다.

A가 table의 Id 2번을 읽음 ( name = Karol, version = 1 )

  1. B가 table의 Id 2번을 읽음 ( name = Karol, version = 1 )
  2. B가 table의 Id 2번, version 1인 row의 값 갱신 ( name = Karol2, version = 2 ) 성공
  3. A가 table의 Id 2번, version 1인 row의 값 갱신 ( name = Karol1, version = 2 ) 실패
  4. Id 2번은 이미 version이 2로 업데이트 되었기 때문에 A는 해당 row를 갱신하지 못함

이런 플로우를 보면서 각각의 수정 요청은 앞선 수정 요청에 따라 version이 변경되면서 뒤의 수정 요청은 반영되지 않게 됩니다. version으로 사용할 수는 있지만 hashCode, timeStamp를 이용할 수도 있지만 별도의 데이터로 기록을 해야하기 때문에 용량을 조금 차지할 수도 있습니다.

이 경우는 트랜젝션이 필요로 하지 않기 때문에 비관적 락보다는 성능이 더 좋긴 합니다.
성능이 좋다고 그럼 낙관적 락을 써야하냐? 그건 아닙니다. 낙관적 락은 롤백 처리할 때 다시 수정 연산을 하나하나 하면서 복구해야 합니다. 그래서 충동이 발생할 경우 사용하지 않는다고 하네요.

Named Lock

네임드 락은 Pessimistic lock과 상당히 비슷하지만 row, table 단위로 거는게 아니라 metadata Locking 입니다.

이름을 가진 Lock을 획득한 후에 다른 세션은 락을 획득할 수 없고,Transaction이 종료될 때 자동으로 락이 해제됩니다. ( 따로 해제해주거나, 선점시간이 끝나면 해제된다 )
아래 예제에서는 락을 해제할 때 stockId를 String으로 변환시켜서 key(name)으로 사용합니다.

Named Lock을 사용하기 위해서 Repository를 만들어준다.

get_lock을 이용해서 Lock을 얻어오고 release_lock을 통해서 Lock을 해제해줍니다.

특히 Named Lock은 커넥션을 2개 사용한다. lock 획득에 필요한 connection 1개, transaction에 필요한 커넥션 1개가 필요합니다. 그래서 CP 를 넉넉하게 가져가지 않으면 커넥션이 부족할 수도 있습니다.

  • 클래스를 따로 빼서 락을 얻고 다 명령이 끝나야 락을 해제될 수 있게 작성합니다.
    낙관적 락과는 다르게 세션을 점유중이면 주어진 시간동안 기다리면서 잠금 획득을 시도하기에 재시도를 하지 않습니다.

비동기 처리할 때도 꼭 알아야 하는 개념인데, 앞에서 진행되는 명령에 propagation을 Propagation.REQUIRES_NEW로 바꿔주지 않으면 롤백될 수 있습니다.
부모와 같은 트랜젝션으로 묶이면 synchronized문제가 발생합니다. ( after commit 되기 전에 락이 풀립니다. )

다음과 같이 테스트를 진행해보았을 경우 성공적으로 실행되는 것을 알 수 있습니다.

락을 얻는 방법에 따른 구분

1. 스핀락

락을 얻을 때까지 계속 시도하는 기법입니다.
만약에 락을 얻기위해 오래걸린다면 엄청난 낭비가 될 수 있다는 문제제가 있습니다.
또한 분산락과는 다르게 락을 획득하지 못했을 경우 계속 요청을 일정 시간 간격으로 요청을 합니다.
이 방법은 spring-boot-starter-redis 에서 사용하는 Lettuce 라이브러리에서 많이 사용합니다.

실제로 제가 운영하는 서비스의 Redis Connection맺는 Config 를 보면 Lettuce를 사용하는데요.
redisClient를 LettuceClient를 이용하여 Redis Configuration을 설정해주고 있습니다.

@EnableRedisRepositories(
        basePackages = "com.example",
        enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisConfig =
                new RedisStandaloneConfiguration(redisHost, redisPort);

        LettuceClientConfiguration clientConfig =
                LettuceClientConfiguration.builder()
                        .commandTimeout(Duration.ofSeconds(1))
                        .shutdownTimeout(Duration.ZERO)
                        .build();
        return new LettuceConnectionFactory(redisConfig, clientConfig);
    }
}
    }

스핀락을 실행하는 테스트를 해볼건데 예외를 바로 던지는 것도 중요하겠지만

    fun test() {
        val lockKey = "test"
        val lockTime = "3"
        val command = redisClient.connect().sync()

        try {
            // lock 을 획득하기 전까지 계속해서 Loop 를 순회
            while (!command.setnx(lockKey, lockTime)) {
                // process
            }
        } catch (e: Exception) {
            command.del(lockKey)
        }
    }

이렇게 할 경우 unLock로직은 꼭 finally 코드안에 있어야합니다. 락을 해제할 타이밍이 아닌데도 락을 해제할 수도 있어서 다른 연산에 영향을 줄 수 있거든요

정상적으로 Lock을 해제하지 못하면 현재 스레드에서 계속 락을 획득하려 하기 때문에 서버가 중단될 수 있습니다. 그래서 Lock에 대한 만료 정책이 필요하는데 최대 5번정도만 제한하거나 시간으로 도 제한할 수 있다는 점을 인지하고 있어야겠습니다.

스핀락의 문제점

스핀락을 사용하면 락을 안그래도 비싼 레디스에 하나의 락을 얻기 위해 계속 요청을 보내야 합니다.
300ms 의 동기화된 작업에 동시에 100개가 오면, 처음 요청빼고 나머지는 300ms 동안 594회의 락 획득 요청을 보내게 됩니다.갑자기 bursty 하게 요청이 들어오게 되면 큰 문제가 생깁니다

이런 장기 지연 문제가 발생하기 때문에 우리 프로젝트에서는 Nonblocking IO기반의 Redisson 분산 락(Distributed Lock)을 이용하여 문제를 해결했습니다. 다음 포스팅에!!

reference

profile
like_learning

1개의 댓글

comment-user-thumbnail
2023년 7월 30일

좋은 정보 얻어갑니다, 감사합니다.

답글 달기