Java/Spring

동시성 제어

태감새 2023. 5. 4. 11:44

동시성 문제란?

하나의 자원을 두고 여러 개의 연산들이 경합하는 경우

동시성 이슈의 일반적인 패턴

  1. 공유자원을 조회하는 경우
  2. 공유자원을 갱신하는 경우

동시성 이슈가 어려운 이유

  1. 대부분의 테스트는 싱글 스레드로 수행 → 동시성 이슈 발생 x
  2. 이슈가 발생하더라도 오류가 발생하지 않는다.
  3. 코드에 잘 보이지 않는다.
  4. 항상 발생하지 않는다.

쓰기락과 읽기락 (비관적 락)

락을 사용할 때는 락의 범위를 최소화하는 것이 중요하다. (트랜잭션이 곧 락의 범위)
→ 락의 범위가 넓으면 처리하는 시간을 더 소요
→ 뒤에 기다리는 줄이 더 길어짐

  읽기락(Shared Lock) 쓰기락(Exclusive Lock)
읽기락(Shared Lock) O 대기
쓰기락(Exclusive Lock) 대기 대기

읽기락 (Shared Lock)

SELECT ... FOR SHARE

쓰기락 (Exclusive Lock)

SELECT ... FOR UPDATE or SELECT ... FOR DELETE

Mysql에서의 락 범위
Mysql에서의 락은 row 범위가 아닌 인덱스를 잠금
→ 인덱스가 없는 조건으로 Lock Read를 수행하면 불필요한 데이터가 잠길 수 있다.

예시

  • POST 테이블에 id, memberId, contents 컬럼이 있다고 가정
  • 인덱스는 memberId, contents 각각 하나씩 총 두 개 있음

쿼리 작성

START TRANSACTION;
SELECT * FROM POST WHERE memberId = 1 and CONTENTS = 'test1' FOR UPDATE;

위의 쿼리를 실행하면 트랜잭션이 시작되고 쓰기락(memberId = 1)을 들고 쿼리문을 수행한다. 아직 COMMIT을 하지 않았으므로 다른 트랜잭션은 접근이 불가능하다.

START TRANSACTION;
SELECT * FROM POST WHERE memberId = 1 and CONTENTS = 'test2' FOR UPDATE;

다른 contents를 조회하는 쿼리문이지만 락이걸린 memberId로 조회하기 때문에 대기 상태로 유지된다.

Mysql은 인덱스를 기준으로 락이 걸린다는 것을 명심하자.
만약 인덱스가 없다면 테이블 전체가 락이 걸려버린다.

// 락 상태 확인  
select * from performance_schema.data_locks

// 트랜잭션 상태 확인 
select * from information_shema.innodb_trx;

낙관적 락

위처럼 락을 하는 방법은 비관적 락(Pessimistic Lock) 이라고 한다. 비관적 락은 위에서 봤다시피 불필요한 대기 상태를 만든다. 그래서 이를 DB가 아닌 애플리케이션에서 제어하는 방법도 있다. 이를 낙관적 락(Optimistic Lock) 이라고 한다.
→ CAS (Compare And Set)을 통해 제어

  1. 테이블에 version이라는 컬럼을 추가한다.
  2. 트랜잭션을 수행할 때 version도 같이 조회한다.
  3. 갱신시 where 조건으로 2번에서 가져온 version과 일치하는 조건을 작성한다.

위의 예시에서 TX1이 갱신 후 version이 2가 됐으므로 TX2는 version이 일치하지 않아서 트랜잭션이 실패하게 된다.

문제점

실패에 대한 처리를 개발자가 직접 구현해야 한다. TX2는 일치하는 조건을 찾지 못했으므로 예외를 던지는데 이를 받아서 개발자가 따로 처리를 해줘야 한다.

Named Lock

Redis에 분산락이 있다면 Mysql에는 Named Lock이 있다!
비관적 락과는 달리 인덱스가 아닌 key값으로 락을 결정할 수 있다.

getLock()과 releaseLock()을 통해 락을 획득, 제거할 수 있다.

예시

# 획득
SELECT GET_LOCK(key, time)
# 예시 
SELECT GET_LOCK("ticket", 3000)

# 해제
SELECT RELEASE_KEY(key)
# 예시
SELECT RELEASE_KEY("ticket")

주의사항
트랜잭션이 종료될 떄 자동으로 락이 해지되지 않으므로 해지가 중요하다.

Redis 스핀락

분산락과 스핀락
분산락 좀 더 자세한 설명

Redis의 SETNX활용

→ SETNX = SET if Not eXist
특정 key값에 value가 존재하지 않을 때만 value 입력가능

127.0.0.1:6379> SETNX key1 lock
(integer) 1
127.0.0.1:6379> SETNX key1 lock
(integer) 0
127.0.0.1:6379> DEL key1
(integer) 1
127.0.0.1:6379> SETNX key1 lock
(integer) 1

이런 특성을 이용해서 특정 key의 lock을 한 명만 소유할 수 있게됨
while문안에서 lock을 입력에 성공할 때까지 계속해서 SETNX시도 → Redis에 많은 부하

Redis 분산락

가능할때까지 계속 두드리는 스핀락과는 달리 분산락은 Redis의 Message Broker를 사용한다.
락의 소유자가 락을 해제하면 Message Broker를 통해서 해제 사실을 알리면된다. 그러면 계속해서 두드릴 필요가 없이 메시지가 올 때까지 기다리면 된다.

  • tryLock()이라는 메서드를 사용한다.
    // tryLock(long waitTime, long leaseTime, TimeUnit unit)
    public class Main {
      //...
      public void main(String id) {
          //...
          RLock lock = redisson.getLock(id);
          boolean available = lock.tryLock(10, 1, TimeUnit.SECOND);
      }
    }
  • waitTime동안 락을 받기위해서 대기한다.
  • 락을 얻으면 leaseTime동안 소유하고 있다가 leaseTime이 지나면 자동으로 락이 해제된다.

정리

비관적락

  • 트랜잭션 단위로 락을 건다. (DBMS에서 락을 설정)
    • + DBMS에서 접근을 막아버리므로 확실하게 제어 가능 (쓰기락)
    • - Lock의 단위가 row가 아닌 인덱스
    • - 트랜잭션이 길어지면 대기시간 증가

낙관적락

  • DBMS에 대한 접근을 막지 않음 (Lock을 설정하지 않음)
  • 대신 commit시 version을 체크해서 변경사항을 확인
    • + 락을 걸지않기 때문에 불필요한 대기가 없어짐
    • - commit시 version이 다른 경우 Roll-back되어 버리므로 요청 자체가 무시되어 버림
    • - Roll-back시 대처 방안 구현 필요

Redis

  • Redis의 특정 key의 락을 소유하는 방법
  • 분산 DB를 사용하더라도 락을 구현할수 있음

스핀락

  • lock을 얻을때까지 계속해서 Redis를 체크하는 방식
    • - Redis에 많은 부하 발생

분산락

  • Redis의 Message Broker를 사용해서 구현
  • pubsub을 사용해서 락이 해지되었다는 메세지가 오면 락 획득 시도
    • + 스핀락처럼 반복적인 요청이 없어지니 부하 줄어듬

프로젝트 상황

비관적락 vs 낙관적락

낙관적락은 불필요한 대기가 없어서 성능이 좋긴하지만 실패시 처리방안을 따로 구현해줘야하는 문제가 있었다. 반대로 비관적락은 불필요한 대기시간이 있지만 모든 요청을 순차적으로 처리되기 때문에 실패에 대한 처리를 할 필요가 없다.

 

낙관적락에서 실패할 경우 다시 재요청을 보내는 로직을 작성한다고 가정해도 운이 좋지않으면 연속적으로 실패할 가능성이 있다. 그리고 DB로의 I/O이 증가하게 되므로 현재 프로젝트에서는 비관적락을 사용하는 것이 좋다고 판단했다.

쓰기락 vs 읽기락

비관적락 중에서 읽기락을 사용하는 경우에는 문제가 발생했다.

예를 들어 A라는 데이터를 조회해서 update하는 트랜잭션이 있다고 가정하자. 그렇다면 트랜잭션의 연산은 아래와 같다.

트랜잭션 시작 -> A 조회 -> A 갱신 -> 커밋

이때 B와 C가 동시에 트랜잭션을 시작했다. B가 먼저 A를 조회하고 A의 업데이트를 진행했다고 가정한다. 그리고 B가 A를 업데이트하기 전에 C가 A를 조회하면 읽기락이 적용되어 있으므로 조회가 가능하다. 하지만 update는 하지 못하므로 C는 대기한다. 다시 B차례가 돌아와서 B는 A를 A_1로 변경하고 커밋한다. 그 후 C의 update가 실행되지만 A_1을 update하는 것이 아닌 이전에 조회한 A를 갱신하게 된다.

문제점은 update된 A_1를 C가 조회해서 update를 해야하는데 읽기락을 사용하면 이전에 조회한 A를 update하기 때문에 데이터의 무결성이 깨진다.

쓰기락의 경우는 조회부터 막기때문에 미리 데이터를 조회하는 것을 방지할 수 있다. 그래서 이번 프로젝트에는 쓰기락을 적용하였다.

쓰기락 vs 분산락

쓰기락을 적용해서 동시성을 제어하였다. 하지만 최종적으로는 Redis의 분산락을 사용했다. 그 이유는 다음과 같다.

쓰기락은 DB에서 락을 거는 방식이다. 만약 DB가 하나인 경우에는 문제가 안되지만 분산 DB를 사용하는 경우에는 데드락 문제가 생길 수도있다. 그래서 Redis의 락을 사용하기로 결정했다. Redis는 서버를 하나 띄워서 공동으로 사용하기에 데드락 문제를 피할 수 있다고 판단했다. 

 

분산락은 좀 더 공부가 필요하다. pubsub을 이용하는 방식은 알겠는데 어떻게 작동하는지 흐름을 이해할 필요가 있다.

'Java > Spring' 카테고리의 다른 글

Spring MVC의 예외처리  (0) 2023.05.09
[Spring] AOP  (0) 2023.05.02
[Spring] IoC/DI  (0) 2023.05.01
SpringMVC (3) - SpringMVC  (0) 2023.04.27
JPA 간단정리  (0) 2023.04.25