동시성 제어는 여러 `스레드`나 `프로세스`가 공유 자원에 접근할 때 발생할 수 있는 문제를 방지하고, 데이터의 무결성을 유지하기 위한 방법입니다. 이를 해결하기 위한 여러 가지 접근법이 있으며, 각 방법마다 특정한 상황에서 효과적인 성능을 낼 수 있습니다.
이번 포스팅에서는 다양한 동시성 제어 기법에 대한 개념과 예제를 통해 동시성 문제를 해결하는 방법을 알아보도록 하겠습니다.
동시성 제어에 대해 알아보기 전 레이스 컨디션`Race Condition`, 데드락`DeadLock`이란 무엇인지 먼저 알아보도록 하겠습니다.
레이스 컨디션(Race Condition)이란?
레이스 컨디션은 여러 스레드나 프로세스가 동시에 공유 자원을 액세스하고자 할 때 발생하는 문제로, 이때 자원에 대한 경쟁 상태`race`가 발생하게 됩니다. 경쟁 상태란, 여러 프로세스나 스레드가 실행 순서에 따라 결과가 달라지는 상황을 의미합니다. 이로 인해 예상치 못한 동작이나 오류가 발생할 수 있습니다.
다수의 사용자가 동시에 쿠폰 발급을 요청할 때, 각각의 스레드가 쿠폰 발급을 위해 자원을 요구할 경우 각 스레드가 쿠폰 수량을 업데이트하는 과정에서 충돌이 발생할 수 있습니다. 이로 인해 쿠폰의 수량이 정상적으로 감소되지 않을 수 있습니다.
예를 들어, 300개의 쿠폰이 있고 300명의 사용자가 동시에 쿠폰 발급을 요청하면 원래라면 쿠폰 수량이 0개가 되어야 하지만, 레이스 컨디션으로 인해 50개만 감소되는 현상이 발생할 수 있습니다. 이럴 경우 데이터 일관성 문제를 초래할 수 있어 동시성 제어 기법이 필요합니다.
→ 스레드 1 ~ 300 순으로 빠르게 작업 수행
→ 스레드에서 모두 같은 쿠폰 발급에 접근
→ 스레드에서 쿠폰 감소 로직 처리하는 중에는 아직 쿠폰 수량이 300개
→ ex) 요청이 한번에 처리되며 스레드 30에서 처리 완료되어 쿠폰 수량이 299개로 업데이트
이러한 레이스 컨디션`Race Condition`문제를 해결하기 위해서는 동시성 제어가 필요합니다.
데드락(DeadLock)이란?
데드락은 동시성 제어에서 발생할 수 있는 문제로 두 개 이상의 스레드가 서로 필요한 자원을 기다리며 `무기한 대기` 상태에 빠지는 상황을 의미합니다.
즉, 한 스레드가 다른 스레드에서 사용 중인 자원을 기다리면서 자신의 자원을 다른 스레드에게 넘기지 못해 발생하게 됩니다.
데드락이 발생하면 시스템 성능이 저하되거나 시스템이 응답하지 않게 될 수 있습니다. 일반적으로 네 가지 조건이 충족될 때 발생하게 됩니다.
- 상호 배제`Mutual Exclusion`: 시스템의 자원을 공유될 수 없으며, 한 스레드가 자원을 점유할 때 다른 스레드가 그 자원은 사용할 수 없습니다.
- 점유 대기`Hold and Wait`: 스레드가 이미 점유 중인 자원을 놓지 않고 다른 자원을 기다리는 상황을 의미합니다.
- 비선점`Non-Preemption`: 점유된 자원을 다른 스레드로 강제로 선점할 수 없는 상황을 의미합니다.
- 환형 대기`Circular Wait`: 스레드들이 자원을 서로 기다리는 순환 형태로 연결된 상태를 의미합니다.
데드락을 방지하거나 해결하기 위해 다른 접근 방법이 사용될 수 있으며, 대표적인 방법으로는 자원 요청 순서에 대한 규칙 설정, 스레드가 자원을 할당하기 전에 다른 스레드의 자원 확보를 기다리게 하는 방법 등이 있습니다.
예제 환경 구성
동시성 제어에는 여러 가지 방법이 존재합니다. 이를 이해하기 위해 `레이스 컨디션`과 `데드락` 상황을 재현할 수 있는 예제 환경을 먼저 구성하고, 각 방식의 개념과 예제를 통해 동시성 문제를 해결하는 방법에 대해 알아보겠습니다.
Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Long quantity;
public Coupon(String name, Long quantity) {
this.name = name;
this.quantity = quantity;
}
public void decrease() {
validateDecrease();
this.quantity--;
}
private void validateDecrease() {
if (this.quantity < 1) {
throw new IllegalStateException("쿠폰이 모두 소진되었습니다.");
}
}
}
쿠폰 ID와 수량을 가지는 `Coupon 엔티티`와 `재고 감소 로직`을 작성하였습니다.
`Spring Data JPA사용`으로 Entity에 해당하는 `JpaRepository`도 함께 생성합니다.
Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CouponService {
private final CouponRepository couponRepository;
@Transactional
public void decrease(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
coupon.decrease();
}
}
Test
@SpringBootTest
class CouponServiceTest {
@Autowired
private CouponRepository couponRepository;
@Autowired
private CouponService couponService;
@Test
@DisplayName("300개의 스레드에서 동시에 300번 쿠폰 수량 감소 요청 시 남은 쿠폰 수량은 0이다.")
public void couponDecreaseTest() throws InterruptedException {
// given
Coupon coupon = new Coupon("COUPON_001", 300L);
couponRepository.save(coupon);
int threadCount = 300;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
try {
couponService.decrease(coupon.getId());
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Coupon persistedCoupon = couponRepository.findById(coupon.getId()).orElseThrow();
Assertions.assertThat(persistedCoupon.getQuantity()).isZero();
}
}
300개의 수량을 가진 쿠폰을 생성하고, 쿠폰수량만큼 요청하는 테스트코드 입니다.
- 테스트 코드 실행 전 300개의 수량을 가진 쿠폰 생성
- 설정한 스레드카운트만큼 리소스 요청
- 쿠폰 수량 isZero 체크
해당 예제를 바탕으로 테스트 코드 실행 후 결과를 보면
예상과는 다르게 `0개`로 감소했어야 할 쿠폰 수량이 `265개`가 남아있는 것을 확인할 수 있습니다. 이는 `레이스 컨디션` 문제로 인해 업데이트에 충돌이 발생해 300개의 요청에 대해 정상적인 수량 감소가 이루어지지 않은 것입니다.
이러한 동시성 문제를 해결하기 위해 다양한 방법이 존재합니다. 각 방법들의 개념과 예제를 통해 알아보도록 하겠습니다.
1. Java - Synchronized
`Synchronized`는 Java에서 제공하는 동기화입니다.
특정 객체에 대한 Lock을 획득하고, 해당 Lock이 해제될 때까지 다른 스레드들이 Lock을 획득하려고 하는 것을 방지하며, main memory와 스레드가 작업하는 local memory 사이 일관성이 보장됩니다.
이는 `Synchronized` 블록에 진입 혹은 빠져나올 때, 모든 local cache`스레드가 보유한 변수 복사본`이 main memory와 동기화 되도록 하여 스레드가 최신 데이터를 볼 수 있도록 하기 때문입니다.
하지만 이러한 특성때문에 `Scale-out`시, 즉 서버가 여러 대일 때 동시성이 보장되지 않는 치명적인 단점이 존재합니다.
@Service
@RequiredArgsConstructor
public class CouponSynchronizedService {
private final CouponRepository couponRepository;
private final CouponService couponService;
@Transactional
public synchronized void decreaseWithSynchronized(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
coupon.decrease();
}
}
`Synchronized`블록 메서드를 생성하여 쿠폰 수량 감소 로직을 작성하였습니다.
테스트 실패
테스트 실행 시 예상과는 다르게 테스트를 실패하게 되는데, 그 이유는 Transactional에 있습니다.
`선언적 Transactional`애플리케이션 레벨에서 Transaction을 관리하는 방식으로, 실제 데이터베이스에서 commit되기 전에 여러 스레드가 동시에 요청할 때 발생하는 동시성 문제를 해결하는 데에는 한계가 있습니다.
`Transaction`은 애플리케이션의 실행 흐름에서 시작되지만, 실제 데이터베이스에서는 Transaction이 `commit` 될 때 데이터가 반영됩니다. 이때, 여러 스레드가 동시에 데이터베이스에 접근하고, Transaction의 커밋이 이루어지기 전에 다른 스레드가 동일한 데이터를 수정하려고 하면 `레이스 컨디션`이 발생할 수 있습니다.
이는 애플리케이션에서의 Transaction 처리와 실제 데이터베이스의 Transaction 처리 사이에 시간 차이가 존재하기 때문에 발생합니다.
해결 방안
이 문제를 해결하기 위한 방법에는 두가지가 존재합니다.
1. 선언적 Transactional 제거
// 선언적 Transactional 제거
public synchronized void decreaseWithoutTransactional(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
coupon.decrease();
couponRepository.save(coupon);
}
`선언적 Transactional을 제거`하고 `synchronized`키워드를 사용하면 메서드에 대한 동기화 처리가 이루어집니다. 이로 인해 여러 스레드가 동시에 해당 메서드를 실행할 수 없게 되어, 동시성 문제가 발생하지 않도록 할 수 있습니다.
하지만 더이상 JPA의 영속화 기능을 사용할 수 없어 성능이 저하될 수 있습니다.
2. 외부 메서드 호출
// 외부 메서드 호출
public synchronized void decreaseWithExternalCall(Long couponId) {
couponService.decrease(couponId); // 외부 메서드 호출
}
외부에서 선언적 Transactional을 메서드에 사용하여 호출하는 방식입니다.
코드의 복잡도가 높아질 수 있지만, JPA의 영속화 기능을 사용하여 선언적 Transactional 제거 방식보다 성능을 개선할 수 있습니다.
2. Java - Reentrant Lock
`ReentrantLock`은 Java의 `java.uril.concurrent.locks` 패키지에서 제공하는 `Lock`으로, Synchronized 보다 더욱 세밀하게 Lock을 제어할 수 있습니다.
- Lock 획득 시도를 위한 시간 제한 설정 가능하며, `Condition`을 적용하여 대기중인 스레드를 선별적으로 깨울 수 있습니다.
- Lock 획득 시 `main memory`에서 최신 데이터를 읽고 업데이트 시 반영하며, Lock 방출 시 변경 사항을 main memory에 반영합니다.
- Synchronized와 마찬가지로 `선언적 Transactional이 제한`적입니다.
- Scale-out 환경에서도 동시성 문제를 어느정도 해결할 수 있지만, 완벽한 동시성 보장이 되지는 않습니다. Lock을 획득하려는 스레드 수가 많아지면, 성능 저하가 발생하거나 데드락이 발생할 위험이 있어 완벽한 동시성 보장을 기대하기 어렵습니다.
@Service
@RequiredArgsConstructor
public class CouponReentrantLockService {
private final CouponService couponService;
private final ReentrantLock reentrantLock = new ReentrantLock();
public void decreaseWithReentrantLock(Long couponId) {
reentrantLock.lock();
try {
couponService.decrease(couponId);
} finally {
reentrantLock.unlock();
}
}
}
`Lock`, `unLock`을 사용한 `기본 처리방식`을 적용한 메서드입니다.
기본 처리 방식으로 lock, unlock만 사용 시 여러 스레드가 동시에 Lock 획득을 시도하면서 `데드락`에 빠질 위험이 존재합니다.
@Service
@RequiredArgsConstructor
public class CouponReentrantLockService {
private final CouponService couponService;
private final ReentrantLock reentrantLock = new ReentrantLock();
private final Condition condition = reentrantLock.newCondition();
public boolean tryDecreaseWithReentrantLock(Long couponId) {
try {
if (reentrantLock.tryLock(1, TimeUnit.SECONDS)) {
try {
couponService.decrease(couponId);
return true;
} finally {
reentrantLock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return false;
}
public void awaitConditionToDecrease(Long couponId) {
reentrantLock.lock();
try {
while (!tryDecreaseWithReentrantLock(couponId)) {
condition.await();
}
condition.signalAll();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
reentrantLock.unlock();
}
}
}
`tryDecreaseWithReentrantLock`
`데드락`을 방지하기 위해 위와 같이 `tryLock`을 사용하여 Lock 획득 시도에 대한 `타임아웃`을 적용할 수 있습니다.
이 방식 역시 여러 스레드가 동시에 Lock 획득 시도 시 데드락에 빠질 위험은 줄어들지만, Lock 획득에 실패하고 false가 반환되어 요청을 처리하지 못하는 문제가 생길 수 있습니다.
`awaitConditionToDecrease`
`Condition`을 사용하여 특정 조건이 만족될 때까지 대기하고, `signal`을 통해 스레드를 깨워 Lock 획득 시도에 대한 실패를 최소화 시키고 안정적인 요청 처리를 할 수 있습니다. (lockpooling)
이는 Lock을 계속해서 시도하지 않고 대기 상태에 있도록 하여 `데드락`을 방지하고, 더욱 `안정적인 요청 수행`이 가능합니다.
reentrantLock.tryLock(0, TimeUnit.SECONDS)
타임아웃을 0으로 설정하여 테스트 실패를 유도하여 테스트를 실행해 보도록 하겠습니다.
ReentrantLock 테스트의 경우 tryLock과 관계가 없는 메서드라 테스트가 통과되지만 tryLock만 적용한 메서드의 경우 테스트에 실패하는 모습을 볼 수 있습니다.
reentrantLock.tryLock(1, TimeUnit.SECONDS)
타임아웃 설정을 정상적으로 설정 후 테스트 시 모두 통과되는 모습을 볼 수 있습니다.
`tryLock`을 사용하여 데드락에 빠지는 가능성을 줄이고, `Condition`을 사용하여 더욱 안정적으로 요청을 처리할 수 있습니다.
3. 낙관적 락(Optimistic Lock)
낙관적 락은 DataBase에 실제 Lock을 설정하지 않고, Version을 관리하는 컬럼을 테이블에 추가해 데이터 수정 시 마다 맞는 Version의 데이터를 수정하는지 판단하는 방식입니다.
DB에서 값을 읽고 update를 할 때, where 절에 바꾸려는 Version 정보를 함께 보내며 다른 스레드에서 값을 수정했다면 Version이 바뀌었을 것이고 그럼 update하려는 row를 찾지 못해 예외가 발생하게 됩니다.
쉽게 말해, 자원에 Lock을 걸지않고, 동시성 문제가 발생했을 때 처리됩니다.
@Version
private Long version;
먼저 Entity에 해당 컬럼을 추가합니다.
@Service
@RequiredArgsConstructor
public class CouponOptimisticService {
private final OptimisticCouponDecreaseService optimisticCouponDecreaseService;
public void decreaseWithOptimisticLock(Long couponId) {
for (int i = 0; i < 100; i++) {
try {
optimisticCouponDecreaseService.decrease(couponId);
return;
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 100));
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new IllegalArgumentException("재시도 중 스레드가 중단되었습니다.");
}
}
}
throw new IllegalArgumentException("재시도 횟수를 초과했습니다.");
}
}
`스핀락` 방식을 사용하여 로직이 구성되는데, 이 방식은 충돌이 빈번한 경우 성능이 크게 저하됩니다.
즉 `낙관적 락`은 `데이터 충돌이 드물다는 가정하에 동작`하여야 합니다. 충돌이 빈번하게 발생하면 롤백 retry 횟수가 증가하여 성능이 크게 저하됩니다.
Transaction 실패 시 재시도를 구현해야 하며, 재시도를 위한 추가적인 로직 및 처리 비용이 발생됩니다. 재시도가 증가할수록 스레드 대기 시간이 길어지고, 전체 처리 시간이 증가될 수 있습니다.
낙관적 락은 Transaction 경계 안에서 @Version 필드가 업데이트 되어 잘못된 Transaction 경계 설정은 충돌 검출 실패나 데이터 무결성 문제가 발생할 수 있습니다.
정상적으로 동시성 제어가 완료되고 DB 확인 시 Version이 300까지 올라가 있는 것을 확인할 수 있습니다.
필자의 경우 for문을 사용해 세세한 검증을 처리하지 않고 재시도 횟수를 100회로 고정하였는데, 필요 시 추가적인 조건 및 while문을 사용하여 구현이 가능합니다.
그런데 검증이 늘어날수록 성능이 저하돼 테스트 시간이 크게 늘어날 수 있습니다. (필자는 최대 5분까지 테스트 검증 시도)
스레드를 150으로 설정하고 테스트를 실행해 보면 Version이 150까지 올라가고 수량 또한 정확하게 감소하는 것을 확인할 수 있습니다.
4. 비관적 락(Pessimistic Lock)
비관적 락은 동시성 문제가 발생할 가능성이 높은 환경에서 데이터를 보호하기 위해 `트랜잭션` 시작 시 공유락`Shared Lock` 또는 배타적 락`Exclusive Lock`을 사용하여 자원에 접근을 차단합니다.
- 공유락`Shared Lock`: Read Lock이라고도 부르며, 데이터를 읽을 때는 같은 공유락끼리 접근을 허용하지만 write 작업은 차단합니다.
- 배타적락`Exclusive Lock`: Write Lock 이라고도 부르며, Transaction이 완료될 때까지 유지되면서 배타락이 끝나기 전까진 read, write 작업을 모두 차단합니다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findByIdWithPessimisticLock(@Param("id") Long id);
}
먼저 Repository에 `@Lock`을 사용한 쿼리메서드를 작성합니다.
- LockModeType
- PESSIMISTIC_READ: dirty read가 발생하지 않을 때마다 공유 락을 획득하여 데이터가 `수정`, `삭제` 되는 것을 방지합니다.
- PESSIMISTIC_WRITE: 배타적 락을 획득하여 다른 Transactional에서 `조회`, `수정`, `삭제` 되는 것을 방지합니다.
- PESSIMISTIC_FORCE_INCREMENT: `PESSIMISTIC_WRITE`와 비슷하지만 @Version 어노테이션이 있는 Entity와 협력하기 위해 도입되었으며, Lock을 획득하면 Version을 업데이트합니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CouponPessimisticService {
private final CouponRepository couponRepository;
@Transactional
public void decrease(Long couponId) {
Coupon pessimisticCoupon = validateCoupon(couponId);
pessimisticCoupon.decrease();
}
private Coupon validateCoupon(Long couponId) {
return couponRepository.findByIdWithPessimisticLock(couponId).orElseThrow(
() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다.")
);
}
}
`for update`로 배타적 락을 획득하고 commit 후 락이 해제되기 전까지 다른 스레드에서 조회, 수정, 삭제가 차단됩니다.
테스트 실행 시 정상적으로 쿠폰이 감소하는 모습을 볼 수 있습니다.
`비관적 락`은 `Race Condition`이 빈번하게 일어난다면 `낙관적 락`보다 높은 성능을 가지며, DB단의 Lock을 통해 동시성을 제어하기 때문에 확실한 데이터 정합성이 보장됩니다.
그러나 DB단의 Lock을 설정하기 때문에 한 트랜잭션 작업이 정상적으로 끝나지 않으면 다른 트랜잭션 작업들이 대기해야 하므로 성능이 저하될 수 있으며 두 개 이상의 트랜잭션이 서로 다른 리소스를 Lock한 상태에서 상대방의 리소스를 기다리면 데드락이 발생할 수 있으므로 주의가 필요합니다.
충돌이 많이 발생할 수 있는 환경이라면 낙관적 락 보다 비관적 락이 적합 합니다.
5. 네임드 락(Named Lock)
네임드 락은 특정 테이블이나 레코드가 아니라 별도의 Lock 공간`name`에 설정하여 특정 작업 간의 충돌을 방지할 수 있으며, 여러 서버 간에 공유 락을 설정하고 관리할 수 있습니다.
그러나 트랜잭션 종료 시에 Lock 해제, 세션 관리 등을 수동으로 처리해야 하기 때문에 구현이 복잡할 수 있다는 단점이 존재합니다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
}
Repository에 Lock 획득, 해제에 대한 쿼리메서드를 작성합니다.
네임드 락은 각 서버의 메모리 공간에서 별도의 Lock을 설정하여, Lock 이름이 같더라도 각 서버에서 독립적으로 관리됩니다.
예를 들어, GET_LOCK을 사용하여 특정 이름의 Lock을 획득하고, RELEASE_LOCK을 사용하여 Lock을 해제할 수 있습니다.
- GET_LOCK(name, timeout)
- 입력받은 name으로 timeout(초 단위)동안 잠금 획득을 시도합니다.
- 한 스레드에서 잠금을 유지하고 있는 동안에는 다른 스레드에서 동일한 이름의 잠금을 획득할 수 없습니다.
- get_lock을 사용하여 획득한 Lock은 트랜잭션 커밋 또는 롤백되더라도 스스로 해제되지 않습니다.
- 결과 값은 1`true`, 0`false`, null`Lock 획득 중 error 발생`을 반환합니다.
- MySQL 5.7 이상의 버전에서는 동시에 여러개의 Lock 획득이 가능하고, Lock 이름의 글자 수는 60자로 제한되어있습니다.
- RELEASE_LOCK(name)
- 입력받은 이름의 잠금을 해제합니다.
- 결과 값은 get_lock과 동일합니다.
- release_all_locks명령을 사용하면 현재 유지되고 있는 모든 잠금을 해제하고 해제한 잠금 개수를 반환받을 수 있습니다.
@Service
@RequiredArgsConstructor
public class CouponNamedService {
private final CouponRepository couponRepository;
private final CouponService couponService;
public void decreaseWithNamedLock(Long couponId) {
try {
couponRepository.getLock(couponId.toString()); // Lock 획득 시도
couponService.decrease(couponId);
} finally {
couponRepository.releaseLock(couponId.toString()); // Lock 해제
}
}
}
Lock 획득 및 해제에 대한 간단한 로직을 작성하였습니다.
Lock을 획득한 후 작업을 완료한 후에는 반드시 Lock을 해제해야 합니다. Lock 해제를 생략하면 계속 유지되며, 다른 트랜잭션이 해당 리소스에 접근하지 못하게 됩니다.
네임드 락을 사용할 때는 트랜잭션이 성공적으로 종료된 후에도 Lock 해제가 이루어지도록 하는 로직이 필요합니다.
테스트 실행 시 정상적으로 쿠폰이 감소하는 모습을 볼 수 있습니다.
6. Redis - Lettuce Lock
- Redis는 메모리 내에서 `key-value` 형태로 데이터를 저장하고 관리할 수 있는 NoSQL 데이터베이스입니다.
- `Lettuce`는 `Netty`기반의 `Redis Client`이며, 요청을 논블로킹으로 처리하여 높은 성능을 가집니다.
- Lettuce는 사용하기 편리하지만, `스핀락` 방식을 사용하여 `Redis에 많은 부하`가 발생할 수 있습니다.
- Redis에 부하를 줄이기 위해 `Thread.sleep`을 사용하여 요청 간의 간격을 두어야 하지만, 부하 문제를 완전히 해결할 수는 없습니다.
- Lettuce는 자체적인 timeout 처리가 없어 데드락에 빠질 위험이 있습니다. 이를 해결하기 위해서는 직접 timeout을 구현해야 합니다.
- Lettuce를 사용할 때 Lock을 획득하는 순서가 보장되지 않습니다. Redis는 여러 클라이언트가 동시에 Lock을 요청할 때 Lock을 획득한 순서를 보장하지 않기 때문에, 순차적으로 처리해야 할 작업이 있는 경우 문제가 발생할 수 있습니다.
스핀락?
스핀락`Spinlock`은 Lock을 구현하는 방식 중 하나로, Lock을 획득할 수 있을 때까지 반복적으로 확인`스핀`하는 방식입니다.
스핀락은 기본적으로 다른 스레드가 Lock을 풀기를 기다리면서 Lock을 얻을 때까지 CPU를 계속 사용하는 방식을 의미합니다.
@Repository
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long lockKey) {
return redisTemplate
.opsForValue()
.setIfAbsent(lockKey.toString(),
"lock",
300L,
TimeUnit.MILLISECONDS);
}
public Boolean unlock(Long lockKey) {
return redisTemplate.delete(lockKey.toString());
}
}
- lock: Redis의 setIfAbsent`SETNX`를 사용하여 Lock 획득, Lock의 만료 시간을 설정하여 유실되는 락을 방지합니다.
- unlock: Redis의 delete 명령어로 Lock을 해제합니다. Lock을 획득한 클라이언트만 해제할 수 있도록 Lock Key의 소유 여부를 확인해야 합니다. 소유 확인 없이 진행 시 다른 클라이언트의 Lock을 해제할 가능성이 존재하므로 주의가 필요합니다.
@Service
@RequiredArgsConstructor
public class CouponLettuceService {
private final RedisLockRepository redisLockRepository;
private final CouponService couponService;
public void decreaseWithLettuceLock(Long id) {
while (!redisLockRepository.lock(id)) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
try {
couponService.decrease(id);
} finally {
redisLockRepository.unlock(id);
}
}
}
구현한 `RedisLockRepository`를 사용하여 Lock을 획득하고 해제하는 로직입니다.
테스트 실행 시 정상적으로 쿠폰이 감소하는 모습을 볼 수 있습니다.
우측은 Redis에 cli로 접속하여 monitor로 테스트 시 발생되는 로그입니다.
SET "key" "lock" "PX" "300" "NX"
`SET` 명령어는 `key`를 설정하고, 해당 key가 이미 존재하지 않는 경우에만 `NX` 옵션을 사용해서 Lock을 획득하려고 시도합니다.
`PX 300`은 300`밀리초` 동안 잠금을 유지한다는 것을 의미합니다. 이 옵션을 사용하여 Lock이 자동으로 만료되며, Redis에서 소유하지 않은 `key`에 대해 자동으로 해제됩니다.
`DEL "key"` 명령어는 삭제를 의미합니다. 예제를 구현하며 key는 `couponId`로 입력하였습니다.
여러 스레드가 Lock을 획득을 시도하여 동시에 `SET`을 시도하는 로그가 여러 개 나타나게 됩니다. `DEL
` 명령어가 중간에 발생하며, 다른 스레드가 Lock을 다시 획득하려고 시도하고 있다는 것을 보여줍니다. 이는 스레드가 `key`를 경쟁적으로 사용할 때 발생하게 됩니다.
이와 같은 상황에서 Lock 획득에 충돌이 발생할 수 있으며, Lock 획득 실패와 반복적인 SET 시도가 Redis에 많은 부하를 줄 수 있습니다.
추가로 소유권 검증 로직을 작성하여 테스트를 실행한 결과입니다.
스레드 300개로 검증 시 너무 오래걸려 50개로 줄여 테스트한 결과인데도 16초 이상이 소요되었습니다.
`Lock 해제`와 `소유권 검증` 과정이 `Lock 획득 실패율`을 높이고 `Redis 요청 경쟁을 심화`시키면서 `성능 저하`가 유발되는데, 소유권 검증이 추가되며 `GET` 명령이 명시적으로 호출되며, 이로 인해 추가적인 부하가 발생해 성능이 저하된 것을 예상해 볼 수 있습니다.
Redisson - RLock
`Redisson`은 Java 환경에서 Redis를 활용하여 분산 데이터 구조와 Lock을 지원하는 라이브러리 입니다.
`Lettuce`가 `스핀락 방식`을 사용했다면 `Redisson`에서는 `Pub/Sub 기반`의 분산 Lock을 제공하며, 멀티스레드 및 분산 환경에서 데이터의 무결성을 유지하고, 효율적인 동기화를 지원하는 데 초점을 맞추고 있습니다.
RLock`Reentrant Lock`은 Redisson에서 제공하는 Lock으로, 동일한 스레드가 이미 획득한 Lock을 다시 획득할 수 있도록 허용하는 구조입니다. 일반적인 Lock과 달리 재진입성을 제공하여 특정 상황에서 `데드락`을 방지할 수 있습니다.
- 재진입 가능: 동일한 스레드가 이미 락을 보유하고 있는 경우, RLock은 다시 Lock을 획득할 수 있습니다.
RLock lock = redissonClient.getLock("testLock");
lock.lock(); // 첫 번째 획득
lock.lock(); // 두 번째 획득
lock.unlock(); // 해제 1회
lock.unlock(); // 해제 2회 (완전히 해제됨)
- Lock 획득 횟수 관리: Lock을 획득한 스레드는 이를 획득한 횟수만큼 해제해야 완전히 Lock이 풀립니다.
- 분산 환경 지원: 여러 노드에서 동기화를 유지할 수 있는 분산 락으로 활용이 가능합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CouponRedissonLockService {
private final RedissonClient redissonClient;
private final CouponService couponService;
public void decreaseWithRedissonLock(Long couponId) {
RLock lock = redissonClient.getLock(couponId.toString());
try {
boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS); // Lock 획득 시도
if (!acquireLock) {
throw new InterruptedException("Lock 획득에 실패했습니다.");
}
couponService.decrease(couponId);
} catch (InterruptedException e) {
log.error(e.getMessage(), e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
- tryLock
- `tryLock(10, 1, TimeUnit.SECONDS)`은 최대 10초간 Lock 획득을 시도하고, Lock이 획득되면 1초 동안 유지합니다.
- `timeout`을 설정해 `데드락` 발생 가능성을 줄이며, Lock이 영구적으로 점유되지 않도록 합니다.
- 락 해제 조건
- `lock.isHeldByCurrentThread()`를 통해 현재 스레드가 락을 소유하고 있는지 확인 후 해제하여 안정성을 높입니다.
테스트 실행 시 정상적으로 쿠폰 수량이 감소하는 모습을 볼 수 있습니다.
테스트 실행 시 Redis Cli에서 통해 monitor를 통해 확인해 동작과정을 확인할 수 있습니다.
- 락 상태 확인
- `redis.call('get', KEYS[1])` 명령을 사용하여 Lock 해제 상태를 확인합니다.
- 값이 `nil`이면 현재 Lock이 해제되지 않은 상태임을 의미합니다.
- 소유자 확인
- `redis.call('hexists', KEYS[1], ARGV[3])`명령을 사용하여 현재 Lock이 특정 소유자에게 속하는지 확인합니다.
- 존재하지 않으면 다른 소유자에 의해 Lock이 유지 중임을 알 수 있습니다.
- Lock 횟수 감소
- `redis.call('hincrby', KEYS[1], ARGV[3], -1)`명령을 사용하여 Lock 횟수를 감소시킵니다.
- 이는 Lock의 재진입 특성에 의해 횟수가 존재하는 것이며, 횟수가 0이 되면 Lock이 완전히 해제됩니다.
- 만료 시간 연장
- `redis.call('pexpire', KEYS[1], ARGV[2])`명령을 사용하여 Lock의 만료 시간을 연장합니다.
- Lock의 유효 기간을 연장하여 분산 환경에서 일관성을 유지하는 데 사용됩니다.
- Lock 해제
- Lock의 횟수가 0이 되면 `del`명령을 사용하여 `Lock Key`를 삭제합니다.
- Lock이 완전히 해제되고 더 이상 다른 스레드가 접근할 수 없도록 합니다.
- Pub/Sub 메세지 전송
- `PUBLISH`명령을 사용하여 Lock 해제를 알리는 메세지를 Pub/Sub 채널에 전송합니다.
- 다른 스레드가 이 메세지를 수신하고, Lock 해제 상태를 즉시 인지합니다.
- Lock 해제 상태 업데이트
- `set` 명령을 사용하여 Lock 해제 상태를 나타내는 Key에 값을 설정합니다.
- TTL`만료시간`을 추가하여 Lock 해제 상태가 자동으로 만료되도록 관리합니다.
- Lock 해제 상태 추적 Key 삭제
- `DEL` 명령을 사용하여 Lock 해제 상태를 추적하던 Key를 삭제합니다.
- 메모리 사용 효율성을 높이고, 과도한 데이트 저장을 방지합니다.
Java에서 제공하는 `ReentrantLock`에서도 `tryLock`을 사용하는데 얼핏보면 `Redisson`의 `tryLock`과 비슷한 동작과정이라고 생각할 수 있는데 이 둘에는 명확한 차이점이 존재합니다.
- ReentrantLock.tryLock
- ReentrantLock은 Java의 기본 Lock 구현으로, 주로 로컬 멀티스레드 환경에서 사용됩니다.
- Lock 획득 시도 후 성공하면 true, 실패하면 false를 반환합니다.
- Lock 해제를 명시적으로 unlock 호출로 관리해야 합니다.
- RLock.tryLock
- RLock은 Redisson에서 제공하는 분산 락으로, 멀티스레드 환경에서 분산 데이터 일관성을 보장합니다.
- Redis의 EVAL 스크립트를 사용하여 Lock 상태를 원자적으로 관리하며, Lock의 재진입성을 지원합니다.
- Lock 해제 상태와 만료 시간은 Redis에서 관리되며, 자동으로 만료되어 Lock 해제 처리를 원활하게 합니다
마무리
이번 포스팅에서는 동시성 제어와 데이터 일관성을 유지하기 위한 다양한 방법들이 존재하는데, 그 중 Lock을 활용한 다양한 동시성 제어에 대해 개념과 예제를 통해 알아보았습니다.
각각의 방식마다의 장단점을 살펴보고 환경에 따라 적합한 방법을 선택하여 성능과 데이터 무결성을 최적화할 수 있습니다.
'Spring' 카테고리의 다른 글
[SpringBoot] Prometheus, Grafana를 이용한 모니터링 (0) | 2024.12.10 |
---|---|
[Spring] Redis를 사용한Session 로그인 구현, Security없이 인증, 인가 구현 (26) | 2024.11.14 |
[Spring] offset, no offset 차이점과 페이지네이션 구현예제 (6) | 2024.10.23 |
[Spring] RequestBody, Response의 값이 null 일 때, JSON의 직렬화 역직렬화 (0) | 2024.10.10 |
[Spring]@DataJpaTest, @SpringBootTest 차이 (2) | 2024.08.28 |