🍃 Spring

[Spring] 동시성(Concurrency) 이슈 - 그 외(3)

loose 2024. 11. 5. 10:55
반응형

ThreadLocal

ThreadLocal은 동시성 문제가 발생해서 쓰는 기능은 아닙니다.

ThreadLocal은 각 스레드마다 고유의 변수를 저장할 수 있게 해주는 클래스입니다.

주로 트랜잭션에서 컨텍스트를 유지하는 데 사용됩니다.

스프링 프레임워크나 Hibernate 같은 ORM에서는 데이터베이스 트랜잭션이 여러 메서드 호출이나 계층을 넘나드는 동안에도 같은 트랜잭션 상태를 유지해야 합니다.

이때 ThreadLocal이 중요한 역할을 합니다.

 

트랜잭션에 동기화에 관한 클래스를 살펴보면 ThreadLocal로 구현된 것을 확인할 수 있습니다. 

 

자신의 스레드에 고유의 변수를 저장하는 것 뿐인데, 왜 동시성 문제가 관련 있을까요?

ThreadLocal은 동시성 문제가 발생하지 않도록 회피하는 방식입니다.

여러 스레드가 사용하는 변수를 사용하지 않도록, 즉, 동시성 문제를 처음부터 차단하는 것을 목표로 하는 기술입니다.

Redis에서의 동시성 문제

이전에 Database 주제를 다룰 때 조회수 연산은 INCR 연산, 원자적 연산을 통해 조회수 증감을 올바르게 처리할 수 있었습니다.

반대로 현재 재고를 확인하는 재고 처리 시스템이라고 하면 어떨까요?

일단 간단하게 증감 연산을 동시성 문제가 발생하도록 만들어보겠습니다.

  public void incrementUsingRedisWithSleep(Long id) throws InterruptedException {
    String key = VIEW_COUNT_KEY_PREFIX + id;
    Long currentValue = redisTemplate.opsForValue().get(key); // 값을 가져와서
    // currentValue가 null이면 0으로 초기화
    if (currentValue == null) {
      currentValue = 0L;
    }
    Thread.sleep(30000); // 지연 발생
    redisTemplate.opsForValue().set(key, currentValue + 1); // 직접 증분한 값을 저장하며 갱신 분실 문제 발생
  }

  public void incrementUsingRedisWithoutSleep(Long id) {
    String key = VIEW_COUNT_KEY_PREFIX + id;
    redisTemplate.opsForValue().increment(key);
  }

여태까지와 마찬가지로 첫번째 메소드와 두번째 메소드를 순서대로 실행시킵니다.

RDBMS에서는 첫번째 연산은 Rollback 처리가 되었는데, Redis에서는 첫번째 메소드가 실행되어 덮어쓰기 현상 즉, 갱신 분실 문제가 발생합니다. 

Redis를 사용하더라도 위처럼 트랜잭션으로 데이터의 정합성이 필요한 영역이 있을 수 있습니다.

 

해결 방법 1. 분산 락(Distributed Lock)

 

이럴때는 분산 락을 이용해야 합니다.

분산 락은 분산된 환경에서도 락을 이용할 수 있는 방법 입니다.

서버가 2대 이상인 경우에 백엔드에서 synchronized를 사용하면 2대에서 동시에 실행될 여지가 있기 때문에 DB 레벨에서 락을 거는 것이라고 볼 수 있습니다.

  public void incrementUsingLock(Long id) throws InterruptedException {
    String key = VIEW_COUNT_KEY_PREFIX + id;
    String lockKey = key + ":lock";
    // 분산 락 설정
    Boolean lockAcquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "lock", Duration.ofSeconds(35));
    if (lockAcquired != null && lockAcquired) {
      try {
        Long currentValue = stringRedisTemplate.opsForValue().get(key) != null ?
          Long.parseLong(stringRedisTemplate.opsForValue().get(key)) : 0L;
        Thread.sleep(30000); // 지연 발생
        stringRedisTemplate.opsForValue().set(key, String.valueOf(currentValue + 1));
      } finally {
        // 락 해제
        stringRedisTemplate.delete(lockKey);
      }
    } else {
      System.out.println("Unable to acquire lock, another process is handling the increment.");
    }
  }

위와 같이 lock을 획득한 lockAcquried를 통해서 접근하지 못하게 차단할 수 있습니다.

위와 같이 구현하면 Redis에서도 복잡한 트랜잭션이 필요한 경우 유용하게 사용할 수 있습니다.

 

728x90