NamedLock은 락으로 사용하기에 적절하지 않아! - 1편 Lock

@황창현 · October 12, 2024 · 23 min read

1편 Lock에서는 NamedLock에서 겪은 문제를 말씀드리기 전 사전 지식이 되는 Lock에 대해 설명하도록 하겠습니다.

Lock이란?

여러분은 락에 대해서 자세하게 알고 계신가요?

Lock은 사전에 나오는 것 처럼 잠금이라고 할 수 있습니다. 그럼 소프트웨어 즉, 컴퓨터에서의 잠금은 무엇을 의미하는 것일까요?

우선 잠금을 알아보기 전, 컴퓨터에서 프로그램이 어떻게 실행되는지부터 알아봅시다. 컴퓨터의 프로그램(코드)들은 실행되면 전부 프로세스로 변하게 되는데요. 이때 프로그램을 실행하기 위해 프로세스는 메모리를 할당받게 됩니다. 프로세스는 메모리에 적재되어 실제 프로그램이 실행중인 상태로 있게되는거죠. 이때 메모리를 할당받은 프로세스는 또 여러 개의 영역으로 나눌 수 있는데 그 중 하나가 힙이라는 메모리 영역입니다.

힙의 경우 다른 프로세스와는 공유되는 영역이 아니지만, 하나의 프로세스안에서 여러 개의 쓰레드에 의해 공유되는 영역입니다. (공유된다고 하니까 뭔가 문제가 생길거같지않나요?) 힙 영역은 주로 사용자(클라이언트) 또는 개발자에 의해 새롭게 생성되는 객체를 담고 있는 곳이라고 보면 됩니다.

예를 들어, 저희가 선착순으로 500명만 받는 쿠폰 서비스를 이용한다고 가정해보겠습니다. 이때 프로그램은 500명만 받을 수 있도록 제한을 걸어둘 것이고, 사용자가 천천히 차례차례 들어온다면 모두 쿠폰을 이상 없이 받을 수 있을 것입니다.

하지만, 동시에 500명이 한꺼번에 쿠폰을 얻으려고 버튼을 누른다면 어떠한 일이 발생할까요? 과연 정상적으로 처리가 될까요?

CouponService.java

public class CouponService {

    // Heap 영역
    private Integer couponAmount = 500;

    public void getCoupon() {
        if (couponAmount <= 0) {
            throw new RuntimeException("쿠폰 잔여량이 0입니다.");
        }

        decreaseCouponCount();
    }

    private void decreaseCouponCount() {
        couponAmount -= 1;
    }

    public Integer getCouponAmount() {
        return couponAmount;
    }
}

위 서비스 코드는 Heap 영역에 있는 couponAmount라는 변수를 공유하고있습니다. CouponService의 getCoupon()을 호출하는 경우 couponAmount가 1개씩 감소하며 수량이 0개가 되었을 때는 감소시키지 못하도록 했습니다.

CouponTest.java

class CouponTest {

    CouponService couponService = new CouponService();

    @Test
    void testConcurrency() throws InterruptedException {
        // Given
        int numberOfThreads = 500;
        CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        // When
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                try {
                    couponService.getCoupon();
                } catch (RuntimeException e) {
                    System.out.println(e.getMessage());
                }
                countDownLatch.countDown();
            });
        }

        countDownLatch.await();
        executorService.shutdown();

        // Then
        Assertions.assertEquals(0, couponService.getCouponAmount());
    }
}

위 테스트 코드는 쓰레드를 500개 만들어 반복문을 돌며 최대한 동시에 요청할 수 있도록 하는 코드입니다. 500개의 쿠폰이 있었으니 500명이 getCoupon을 요청한다면 당연히 0개가 남겠죠?

테스트 결과
테스트 결과
하지만 실제 결과 값은 위처럼 3개가 남은 것으로 나옵니다. 왜 이런 문제가 발생하는 걸까요?

앞에서 말씀드렸던 couponAmount 변수는 Heap에 저장되어있기 때문에 CouponService가 싱글톤으로 실행된다는 가정하에 여러 개의 쓰레드에서 공유되게 됩니다. 차례차례 접근하면 전혀 문제가 없지만 위에서는 동시에 접근하려고 했기 때문에 문제가 되는데요. 아래 순서에 의해 동작한다고 보시면 될 것 같습니다.

정상 시나리오

  1. A가 couponAmount 변수를 읽으니 100이 나왔고, 이후의 로직을 실행하여 99개가 되었습니다.
  2. B도 couponAmount 변수를 읽었는데 99가 나왔고, 이후의 로직을 실행하여 98개가 되었습니다.

비정상 시나리오

  1. A와 B가 동시에 공유된 자원인 couponAmount를 읽었습니다.
  2. 동시에 읽다보니 두 사용자 모두 100을 읽게되고, 두 사용자 모두 100에서 -1한 값을 couponAmount에 저장하게 되어 결과 값이 98이 아닌 99가 나오게 됩니다.

위와 같은 현상을 갱신 손실이라 부릅니다. 실제로 컴퓨터에서는 동시라는 개념이 없지만, 아주아주 미세한 차이로 동시에 일어나는 것을 의미해서 동시라고 표현했습니다. 이렇게 굉장히 빠르게 거의 동일하게 공유된 자원에 접근해서 연산을 하는 경우 문제가 발생할 여지가 많습니다. 그래서 힙 처럼 같이 공유하게 되는 자원들 중 중요한 자원들은 Lock이라는 기법을 통해 잠그고 잠금이 해제된 이후 새로운 쓰레드가 진입하도록 만들어 갱신손실을 방지할 수 있습니다.

Lock의 종류

아래에 작성해둔 락은 제가 지금까지 사용해본 Lock들이며, 기존 예제에 어떤식으로 적용하여 문제를 해결할 수 있는지 간단하게 설명드리도록 하겠습니다.

어플리케이션 단계에서의 락

1. Synchronized

  • 가장 단순한 방법으로 Synchronized라고 선언하여 임계구역을 지정하고, 하나의 쓰레드가 임계구역에서 나올때까지 다른 쓰레드는 접근하지 못하도록 할 수 있습니다.
  public class CouponService {

      // Heap 영역
      private Integer couponAmount = 500;

      public synchronized void getCoupon() {
          if (couponAmount <= 0) {
              throw new RuntimeException("쿠폰 잔여량이 0입니다.");
          }

          decreaseCouponCount();
      }

      private void decreaseCouponCount() {
          couponAmount -= 1;
      }

      public Integer getCouponAmount() {
          return couponAmount;
      }
  }
  • 하지만 단점으로는 메서드 자체나 코드 블럭안의 구역을 전부 다 잠구기 때문에, 특정 ID가 가지고 있는 상태 값만 잠그고 싶다라는 조건이 있을 때는 사용이 어렵게됩니다.

2. AtomicInteger

  • AtomicInteger는 원자성을 보장하는 Integer로 Compare And Set이라는 개념을 사용하여 갱신 손실을 방지합니다.
  • 현재 쓰레드에서 읽은 값과 메인 메모리에 있는 값을 비교하여 동일할 때만 변경하기 때문에 공유하는 자원이더라도 문제 없이 처리할 수 있습니다.
 public class CouponService {
     private AtomicInteger couponAmount = new AtomicInteger(500);

     public void getCoupon() {
         if (couponAmount.get() <= 0) {
             throw new RuntimeException("쿠폰 잔여량이 0입니다.");
         }
         decreaseCouponCount();
     }

     private void decreaseCouponCount() {
         couponAmount.decrementAndGet();
     }

     public Integer getCouponAmount() {
         return couponAmount.get();
     }
 }
  • 기존에 값을 가져오는 방식과 값을 설정하는 방식이 기본 연산자를 이용한 것이 아니기 때문에 메서드를 확인해서 사용해야합니다.

3. ReentrantLock

ReentrantLock
ReentrantLock
ReentrantLock
ReentrantLock

  • ReentrantLock은 자바의 Concurrency 패키지에 있는데요. 이 Lock도 Compare And Set이라는 기법을 통해 갱신 손실을 방지합니다.
  • 위 사진을 보면 ReentrantLock도 동일하게 CompareAndSet 메서드를 통해 실제 native 메서드를 호출하게 됩니다.
  • native가 붙은 메서드는 실제로 PC의 커널에 요청하여 처리하는 작업으로 시스템 콜이라고 보면 될 것 같습니다.
 public class CouponService {

     private Integer couponAmount = 500;
     private final ReentrantLock lock = new ReentrantLock();

     public void getCoupon() {
         boolean isLockAcquired = false;
         try {
             // 1초 동안 락을 시도해 보고, 실패 시 예외 처리
             isLockAcquired = lock.tryLock(1, TimeUnit.SECONDS);
             if (!isLockAcquired) {
                 throw new RuntimeException("락을 획득하는 데 실패했습니다.");
             }

             if (couponAmount <= 0) {
                 throw new RuntimeException("쿠폰 잔여량이 0입니다.");
             }
             decreaseCouponCount();
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt(); // 현재 스레드의 인터럽트 상태를 복구
             throw new RuntimeException("락을 획득하는 동안 인터럽트가 발생했습니다.", e);
         } finally {
             // 락이 성공적으로 획득되었을 때만 락을 해제
             if (isLockAcquired) {
                 lock.unlock();
             }
         }
     }

     private void decreaseCouponCount() {
         couponAmount -= 1;
     }

     public Integer getCouponAmount() {
         return couponAmount;
     }
 }
  • ReentrantLock의 장점 중 하나는 조금 더 정교하게 락을 걸 수 있다는 것인데, tryLock 메서드를 이용해서 락을 획득하는데 시간을 설정하여 무한 대기하지 않도록 할 수 있습니다.

DB를 이용해서 처리할 수 있는 락

위 내용들에서는 애플리케이션 단계에서 처리할 수 있는 방법들에 대해서 이야기 해보았습니다. 만약 애플리케이션이 스케일 아웃, 여러 대의 서버로 동작한다면 위의 Lock들은 사용할 수 있을까요? 네. 사용할 수는 있지만 정상적으로 작동하지 않습니다. 동일한 로직을 실행시키는 애플리케이션이지만 여러 대의 서버가 하나의 락을 잡는 것이 아닌 애플리케이션마다 별도의 락으로 동작하기 때문에 데이터의 동시성을 제대로 제어할 수 없게 됩니다. 이럴 때는 보통 DB를 이용한 락을 많이 사용하기도 합니다.

1. OptimisticLock

  • OptimisticLock의 경우 낙관적 락이라고 부르며, 말 그대로 충돌이 적을 것을 가정하고 락을 거는 것을 의미합니다.
  • JPA에서는 직접 구현되어있기도 하지만, 쿼리문을 통해 직접 구현이 가능합니다.
  • 직접 구현시 컬럼에 Version 컬럼을 추가하고, Update시 자기가 처음에 읽었던 버전과 동일한지 확인하여 변경합니다.
  • 하지만 단점으로 재시도에 대한 로직을 직접 구현해야한다는 단점이 있습니다. 쿼리를 날렸을 때 버전이 다른 경우 예외를 던질 것인지 재시도를 할 것인지 판단해야하는데, 보통은 재시도를 통해 처리하곤 합니다. 이때 재시도시 이러한 로직을 직접 구현해야하며, 충돌이 많이 날 경우 쿼리를 계속 요청하게 되어 부하가 생기는 문제가 발생할 수도 있습니다. (이런 것을 보통 스핀 락이라고 부릅니다.)
@Getter
@Entity
public class Coupon {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Integer amount;

    @Version
    private Integer version;

    public void updateAmount(Integer amount){
        this.amount = amount;
    }
}
@Service
@RequiredArgsConstructor
public class CouponService {

    private CouponRepository couponRepository;

    @Transactional
    public void getCoupon(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -> new RuntimeException("쿠폰을 찾을 수 없습니다."));

        if (coupon.getAmount() <= 0) {
           throw new RuntimeException("쿠폰 잔여량이 0입니다.");
        }

        coupon.updateAmount(coupon.getAmount() - 1);
        couponRepository.save(coupon); // JPA가 버전 정보를 자동으로 체크하여 업데이트
    }
}
  • JPA에서는 @Version 어노테이션을 따로 지원하여, update시 버전을 자동으로 비교하여 저장하게 해줍니다.
  • 실패시 OptimisticLockException을 던지므로 해당 예외를 잡아서 재시도 로직을 구현할 수 있습니다.

2. PessimisticLock

  • PessimisticLock은 비관적 락이라고 부르며, 충돌이 많을 것을 가정하고 락을 거는 것을 의미합니다.
  • JPA에서 구현되어있지만, 쿼리문을 직접 작성하여 처리도 가능하며 이때 Select ... for Update 문을 통해 X-Lock을 걸게됩니다.
  • X-Lock의 경우 데이터베이스에서 지원하는 Lock으로 특정 로우에 락을 걸어 락이 해제될 때까지 특정 로우에 대해서 사용할 수 없게 만드는 것을 의미합니다.
  • Lock을 얻기 위한 대기 시간 지정이 어려우며, 교착상태와 같은 문제가 발생될 수 있어 사용할 때 조심히 사용해야합니다.
@Entity
@Getter
public class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Integer amount;
    

    public void updateAmount(Integer amount){
        this.amount = amount;
    }
}
@Service
@RequiredArgsConstructor
public class CouponService {

    private CouponRepository couponRepository;

    @Transactional
    public void getCoupon(Long couponId) {
        Coupon coupon = couponRepository.findByIdWithLock(couponId);

        if (coupon.getAmount() <= 0) {
            throw new RuntimeException("쿠폰 잔여량이 0입니다.");
        }

        coupon.updateAmount(coupon.getAmount() - 1);
        couponRepository.save(coupon); // 변경 사항 저장
    }
}
public interface CouponRepository extends JpaRepository<Coupon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Coupon findByIdWithLock(@Param("id") Long id);
}
  • JPA에서는 위 처럼 Repository에 @Lock 어노테이션을 활용하여 select 조회시 락을 획득해올 수 있습니다.
  • 낙관적 락과 마찬가지로 특정 레코드에 대해서 락을 거는 것 이기 때문에 여러 작업을 처리하는데 사용할 수 있는 락이라고 보기에는 어려움이 있습니다.

3. NamedLock

  • NamedLock은 이름을 가진 락으로써 실제로 락의 이름을 지정하여 해당하는 이름이 락이 생성되어있으면, 다른 쓰레드는 해당 락을 점유하지 못하여 멈추게됩니다.
  • 이것도 데이터베이스에서 지원하는 락으로, Lock을 얻기 위한 대기 시간을 지정할 수 있고 이름을 통해 편하게 Lock을 얻을 수 있어 장점이 있습니다.
  • 하지만 충돌이 많이 발생하는 곳에서 사용하는 경우 Lock을 획득하기 위해 많은 커넥션 풀을 사용하기 때문에 교착상태에 빠질 수 있습니다.

분산 락

1. DistributeLock

  • 분산 락은 특정한 기술이 아니고 개념이며, 분산된 환경에서 쓰이는 락을 주로 의미합니다.
  • 자주 사용되는 것으로 Redis의 setnx를 이용한 것과 pub/sub을 이용한 Redisson Lock이 있습니다.
  • Redis는 명령어가 싱글스레드에 의해 동작하기 때문에 setnx로 처리하면 순서대로 동작하여 Lock이 가능하지만 실패에 대한 재시도 처리를 위하여 스핀락으로 구현해야합니다.
  • Pub/Sub의 Redisson Lock의 경우 특정 토픽에 대해 Subscribe하고, Lock이 해제된 순간 Sub하고 있는 유저에게 Publish 해주는 것을 의미합니다. 이렇게 대기하고 있기 때문에 별도의 재시도 처리를 하지 않아도되며, 락을 얻기까지의 시간과 락을 얻고나서의 시간도 조절할 수 있기 때문에 가장 많이 사용되는 것으로 알고있습니다.

요약 및 결론

이렇게 여러 종류의 Lock에 대해서 알아보는 시간을 가졌습니다. 아래는 제 개인적인 생각으로 만들어본 표입니다.

락 종류 요약

락 종류 설명 장점 단점 사용 적합 상황
Synchronized 코드 블록이나 메서드를 간단히 잠그며, 다른 스레드의 접근을 막음 구현이 쉬우며 기본적인 잠금이 필요할 때 적합 세부적인 제어 불가, 메서드 전체나 블록 전체를 잠금 단일 인스턴스 환경에서, 세부 제어가 필요하지 않은 경우
AtomicInteger CAS(Compare And Set)를 사용하여 정수 연산의 원자성을 보장 별도의 락 없이 갱신 손실을 방지 정수 타입에만 한정, 복잡한 데이터나 작업에는 적합하지 않음 카운터와 같은 단순한 정수 연산이 필요한 경우
ReentrantLock tryLock과 타임아웃 설정을 통해 정밀한 잠금 제어 가능, 복잡한 구현을 요구 타임아웃을 통해 무한 대기 방지 가능, 정밀한 잠금 제어 가능 복잡한 코드, unlock을 잊을 경우 데드락 위험 타임아웃이 필요하거나, 정밀한 제어가 필요한 경우
Optimistic Lock 낮은 충돌을 가정하며, 버전이나 타임스탬프 확인 후 업데이트 낮은 충돌 환경에서 높은 성능, JPA와 통합 시 유용 버전 불일치 시 재시도 필요, 충돌이 많을 경우 성능 저하 발생 충돌이 드문 환경에서, 읽기/쓰기 성능이 중요한 경우
Pessimistic Lock 충돌이 많을 경우를 가정하고 자원을 잠그며, 락이 풀릴 때까지 자원을 독점 데이터 충돌 방지에 탁월, 높은 일관성 유지 자원 잠금 시 대기 시간이 길어질 수 있음, 교착 상태 발생 위험 충돌이 빈번하고, 데이터 일관성이 중요한 경우
Named Lock 특정 이름으로 자원 잠금, 분산 환경에서 사용 가능 분산 환경에서도 쉽게 적용 가능, 응용 프로그램별 잠금 회피 가능 충돌 시 커넥션 풀이 부족할 수 있으며, 관리 어려움, DB가 분산되면 적용 불가함 이름을 기반으로 자원 접근을 잠금해야 하는 분산 시스템 환경
Distributed Lock 분산 시스템의 자원 동시성을 관리하며, Redis 등을 통해 구현 분산 시스템에서 자원 일관성 보장 가능, 다양한 아키텍처에 적용 가능 추가적인 설정 필요, 네트워크 지연이나 장애 대응 시 주의 필요 클라우드나 마이크로서비스 등 분산 시스템에서 자원을 관리할 때

데이터를 공유한다는 것은 결국 갱신 손실이 일어날 수 있는 문제가 있고, 이러한 문제가 중요시 여겨지는 소프트웨어에서는 어떤 락을 선택해서 잘 통제할 것인가가 굉장히 중요합니다. 각 소프트웨어의 요구사항은 다르기 때문에 위의 표를 보고 락을 선택할 때 도움이 되셨으면 좋겠습니다. 네임드 락과 분산 락의 경우 다음 편에서 조금 더 자세하게 설명드리도록 하겠습니다.

감사합니다!

@황창현
함정 카드가 없는 개발자가 되기 위해 노력하고 있습니다