목차
- 낙관적락 (s-lock, 공유락, optimistic lock)
- 비관적락 (x-lock, 베타락, pessimistic lock)
- 낙관적락과 비관적락 성능/효율성 테스트
- 분산락 (Redis)
- Redisson 라이브러리 사용시 주의점
분산 환경에서 동시성 제어를 위해 사용하는 Lock의 종류와 각각의 특징을 정리해보고자한다.
1. 낙관적락 (s-lock, 공유락, optimistic lock)
- 특징
- 트랜잭션 충돌이 발생하지 않을 것이라고 낙관적으로 가정.
- 읽기 잠금 ( select ..from.. where ... for share )으로 한쪽이 커밋 할 때 까지 쓰기 잠금.
- s-lock을 설정한 트랜잭션은 해당 row를 읽기만 가능하다.
- 충돌 빈도가 적을 경우 권장되며, 동시 요청 중 한 건만 성공해야하는 케이스에 적합하다.
- version을 사용하여 충돌을 예방하며, DB Lock 을 설정해 동시성 문제를 제어하는 것이 아니라 읽은 시점과 수정 시점의 데이터 변경 여부를 확인해 제어하는 방식이다.
- 트랜잭션, Lock 설정 없이 데이터 정합성을 보장할 수 있으므로 비관적 락보다 성능적으로 우위에 있다.
- 장점
- 충돌이 자주 발생하지 않는 경우 성능이 좋다.
- 단점
- 충돌이 발생하면 롤백에 대한 처리가 필요하다. 반드시 트랜잭션 커밋 시 버전이 달라 발생하는 예외에 대해 예외처리를 해야한다.
- 구현 복잡도: 낮음
2. 비관적락 (x-lock, 베타락, pessimistic lock)
- 특징
- 트랜잭션 충돌이 발생할 것이라고 비관적으로 가정
- 쓰기 잠금 ( select ..from .. where... for update )으로 특정 자원에 대해 Lock 설정으로 선점해 정합성을 보장하여 하나의 트랜잭션만 읽기/쓰기가 가능하고 다른 트랜잭션은 완료될 때까지 대기한다.
- 충돌 빈도가 많을 경우 권장되며, 동시 요청에서 순차로 진행될 때 성공할 수 있는 요청이라면 성공시키는 케이스에 적합하다.
- 장점
- 충돌이 자주 발생할경우 유리하다.
- 단점
- 대기가 많아질 수 있어 DB 성능 저하와 서로 필요한 자원에 락이 걸려있으므로 데드락이 발생할 수 있다.
- 구현 복잡도: 낮음
3. 낙관적락과 비관적락 성능/효율성 테스트
상세 코드는 GitHub에 있습니다.
포인트 사용 기능에 대해 동일한 조건에서 낙관적락과 비관적락을 각각 적용해보고 동시성 테스트를 해본 결과 다음과 같다.
- 조건
- USER_DEFAULT_AMOUNT(포인트 초기값) = 100000
- THREAD_COUNT(스레드 개수) = 100
- USE_AMOUNT(포인트 사용금액) = 500
3.1 낙관적락
- 코드
- PointTransactionIntegrationTest.java
@Test @DisplayName("동일한 사용자가 동시에 100건의 포인트 사용 요청시 낙관적락 동시성 처리로 대부분 실패") void concurrentPointUseWithOptimisticLockFailure() throws InterruptedException { long beforeTime = System.currentTimeMillis(); // given int THREAD_COUNT = 100; int USE_AMOUNT = 500; ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch latch = new CountDownLatch(THREAD_COUNT); // when for (int i = 0; i < THREAD_COUNT; i++) { executorService.submit(() -> { try { UserPointInfo userPointInfo = pointFacade.pointTransaction(testUser.getUserId(), USE_AMOUNT, TransactionType.USE); System.out.println(userPointInfo); return userPointInfo; } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); long afterTime = System.currentTimeMillis(); long diffTime = afterTime - beforeTime; System.out.println("실행 시간(ms): " + diffTime + "ms"); // then // 마지막 포인트 조회 UserPointInfo finalPoint = pointFacade.getUserPointInfo(testUser.getUserId()); // 예상 총 포인트 = 유저 포인트 - (충전금액 * 스레드 수) int expectedTotalPoint = USER_DEFAULT_AMOUNT - (USE_AMOUNT * THREAD_COUNT); assertThat(finalPoint.point()).isGreaterThan(expectedTotalPoint); assertThat(finalPoint.point()).isGreaterThan(50000); }
- PointJpaRepository.java
@Lock(LockModeType.OPTIMISTIC) @Query("SELECT po FROM Point po " + "WHERE po.user.userId = :userId") Point findByUserPointWithOptimisticLock(@Param("userId") Long userId);
- PointTransactionIntegrationTest.java
- 결과
- 100건 중 14건 성공
- 100000 포인트 중 잔여 93000 포인트
- 총 소요 시간 242ms
3.2 비관적락
- 코드
- PointTransactionIntegrationTest.java
@Test @DisplayName("동일한 사용자가 동시에 100건의 포인트 사용 요청시 비관적락 동시성 처리로 모두 성공") void concurrentPointUseWithPessimisticLockSuccess() throws InterruptedException { long beforeTime = System.currentTimeMillis(); // given int THREAD_COUNT = 100; int USE_AMOUNT = 500; ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch latch = new CountDownLatch(THREAD_COUNT); // when for (int i = 0; i < THREAD_COUNT; i++) { executorService.submit(() -> { try { UserPointInfo userPointInfo = pointFacade.pointTransaction(testUser.getUserId(), USE_AMOUNT, TransactionType.USE); System.out.println(userPointInfo); return userPointInfo; } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); long afterTime = System.currentTimeMillis(); long diffTime = afterTime - beforeTime; System.out.println("실행 시간(ms): " + diffTime + "ms"); // then // 마지막 포인트 조회 UserPointInfo finalPoint = pointFacade.getUserPointInfo(testUser.getUserId()); // 예상 총 포인트 = 유저 초기 포인트 - (충전금액 * 스레드 수) int expectedTotalPoint = USER_DEFAULT_AMOUNT - (USE_AMOUNT * THREAD_COUNT); assertThat(finalPoint.point()).isEqualTo(expectedTotalPoint); assertThat(finalPoint.point()).isEqualTo(50000); }
- PointJpaRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")) @Query("SELECT po FROM Point po " + "WHERE po.user.userId = :userId") Point findByUserPointWithPessimisticLock(@Param("userId") Long userId);
- PointTransactionIntegrationTest.java
- 결과
- 100건 중 100건 모두 성공
- 100000 포인트 중 잔여 50000 포인트
- 총 소요 시간 442ms
3.3 성능/효율성 정리
위의 결과를 통해 다시 정리해보자.
- 낙관적락
- 빠르지만 충돌이 발생해 모든 요청에 성공하지 않는다. (테스트 결과: 100건 중 14건 성공, 242ms)
- 따라서 충돌이 적을 경우 사용하면 성능이 좋다.
- 충돌 시 롤백/예외 처리가 필요하다.
- 비관적락
- Lock을 걸어 선점하고, 다른 트랜잭션은 락이 풀릴 대까지 대기한다. (테스트 결과: 100건 모두 성공, 442ms)
- 모든 요청이 성공하고 정합성이 보장되나 대기 시간이 발생한다.
- 데드락에 주의해야한다.
4. 분산락 (Redis)
- 특징
- 분산 시스템에서 일관된 락을 제공하기 위한 것.
- 트랜잭션 밖에서 락을 적용하며, DB 부하를 최소화할 수 있다.
- “레디스”를 활용한 락에서 락 획득과 트랜잭션의 순서의 중요성
- 락과 트랜잭션은 데이터의 무결성을 보장하기 위해 아래 순서에 맞게 수행됨을 보장해야 한다.
락 획득 → 트랜잭션 시작 → 비지니스 로직 수행 → 트랜잭션 종료 → 락 해제
- 락과 트랜잭션은 데이터의 무결성을 보장하기 위해 아래 순서에 맞게 수행됨을 보장해야 한다.
- 종류
- simple lock
- Lock 획득 실패 시 요청에 대한 비즈니스 로직을 수행하지 않는다.
- 실패 시 재시도 로직에 대해 고려해야하며 요청의 case 에 따라 실패 빈도가 높다.
- spin lock
- Lock 획득 실패 시, 일정 시간/횟수 동안 지속적인 재시도를 하며 이로 인한 네트워크 비용 발생한다.
- 재시도에 지속적으로 실패할 시, 스레드 점유 등의 문제가 발생한다.
- Lettuce 라이브러리 사용
- setnx, setex 등으로 분산락을 직접 구현해야하고, retry, timeout 기능도 구현해야한다.
- 재시도를 하기 때문에 레디스에 부하를 줄 수 있다.
- pub/sub
- 레디스 Pub/Sub 기능을 활용해 락 획득을 실패 했을 시에, “구독” 하고 차례가 될 때까지 이벤트를 기다리는 방식을 이용해 효율적인 Lock 관리가 가능하다.
- “구독” 한 subscriber 들 중 먼저 선점한 작업만 Lock 해제가 가능하므로 안정적으로 원자적 처리가 가능하다.
- 직접 구현, 혹은 라이브러리를 이용할 때 해당 방식의 구현이 달라질 수 있으므로 주의해서 사용해야 한다.
- Redisson 라이브러리 사용
- subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도한다.
- subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도한다.
- simple lock
- 구현 복잡도: 높음
5. Redisson 라이브러리 사용시 주의점
Redis 공식 문서를 보면 분산락을 위해 RedLock 알고리즘을 사용한다. RedLock을 Java에서 사용할 수 있도록 구현한 것이 Redisson 라이브러리다.
RedLock 알고리즘은 분산 환경에서 다음과 같다고 가정한다.
- 단일 Redis 인스턴스 대신 N개의 독립적인 Redis 마스터를 사용 (일반적으로 N=5)
- 과반수(N/2+1) 이상의 Redis 인스턴스에서 락을 획득해야 유효한 것으로 간주
Redis 공식 문서에서는 다음과 같은 문제가 발생할 수 있다고 한다.
1. 시스템 클록에 의한 clock drift 현상
- Redis는 TTL 만료를 위해 monotonic clock 를 사용하지 않는다.
- 서버의 시간이 갑자기 변경될 경우, 여러 프로세스가 동시에 락을 획득할 수 있다.
2. 서버 다운, 네트워크 단절, 프로세스 실행 시간 초과시 동시성 문제
- 다음과 같은 경우 안전성 보장이 불가능하다.
- Redis 서버가 다운 되었다가 복구 되었을 경우
- Redis 서버는 정상이나, 네트워크 문제로 일부 클라이언트와 통신이 끊겨 락을 보유한 클라이언트가 락을 해제하지 못하는 경우
- 락을 보유한 프로세스가 TTL보다 오래 실행될 경우
- 이를 위한 해결책으로 fencing token 구현이 필요하다.
- 동시성 문제
- fencing token 사용시
- 동시성 문제
참고자료
- https://redis.io/docs/latest/develop/use/patterns/distributed-locks/
- https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
관련글
[Spring] Lock을 이용한 동시성 제어 - 낙관적락/비관적락/분산락 (1)개념
[Spring] Lock을 이용한 동시성 제어 - 낙관적락/비관적락/분산락 (2)적용
관련 코드는 아래 GitHub를 참고해주세요.
GitHub - leehanna602/hhplus-server-construction
Contribute to leehanna602/hhplus-server-construction development by creating an account on GitHub.
github.com
728x90
'Spring Boot' 카테고리의 다른 글
[Spring] Redis를 이용한 대기열 관리 (0) | 2024.12.28 |
---|---|
[Spring] Transaction 분리를 통한 성능 개선 (0) | 2024.12.27 |
[Spring] Lock을 이용한 동시성 제어 - 낙관적락/비관적락/분산락 (2)적용 (0) | 2024.12.27 |
[Spring] RedisSerializer 알고 쓰기! 종류 별 특징과 주의 사항 (0) | 2024.12.04 |
[실전! 스프링 부트와 JPA 활용1] 01. 프로젝트 환경설정 (0) | 2024.07.28 |