📕 목차
1. 동시성 문제
2. 낙관적 락과 비관적 락
3. 분산락
1. 동시성 문제
📌 Single Process & Single Thread
- 모든 요청을 하나의 애플리케이션, 그리고 하나의 Thread가 처리한다면 동시성 문제는 발생하지 않는다.
- 일을 한 명이 처리하면 애초에 공유 자원이라는 개념 자체가 성립이 되지 않으므로 문제는 해결할 수 있다.
- 하지만 요청에 대한 응답 속도가 현저하게 떨어지므로 사용자 경험에 악영향을 주게 된다.
- Node.js라면 모를까 Spring은 기본적으로 Thread Pool을 10개로 할당하는 것으로 알고 있다. (정확한 값은 모름. 설정이 가능하다.)
📌 Single Process & Multi Thread
- Application이 하나만 존재해도 동시성 문제는 충분히 발생할 수 있다.
- Spring은 Tomcat은 기본적으로 Muti Thread 환경에서 동작한다.
- 가변 객체를 빈으로 등록하면 thread-safe하지 않게 동작한다.
- Transactional은 원자성을 보장해주긴 해도 동시성을 제어해주지는 않는다.
- 클라이언트 측에서 디바운스 혹은 쓰로틀링으로 어느정도 제어해줄 수 있다 하더라도 동시성 문제를 해결해주진 않는다.
- 사용자가 브라우저 혹은 다른 기기에서 동시에 요청을 보내는 경우엔?
예를 들어, 익명의 유저가 계정을 생성하려는 경우를 생각해보자.
설령 유저를 식별하기 위해 전화번호, 이메일 등의 중복 검사를 수행한다 하더라도 의미가 없다.
초기에는 전화번호 중복 검사, 이메일 중복 검사, 닉네임 중복 검사 무엇을 하든 기존에 기록된 정보만 없다면 모두 통과할 수 있기 때문이다.
악랄한 유저가 서비스에 계정을 생성하려 한다고 가정해보자.
브라우저 2개를 동시에 키고, 모바일 앱도 두 개나 동원해서 계정 입력 폼을 모두 작성한 후 동시에 요청을 시도한다.
과연 이 경우에 아무런 문제가 발생하지 않으리라 장담할 수 있는가?
@Transactional(readOnly = true)
public boolean isExistsByUsername(String username) {
return userRepository.findByUsername(username);
}
@Transactional
public User createUser(User user) {
return userRepository.save(user);
}
두 개의 메서드를 선언하고 상위 서비스에서 다음과 같이 호출한다.
@Transactional
public User signUp(Request request) {
if (userService.isExistsByUsername(request.username())) {
throw new IllegalStateException();
}
return userService.createUser(request.toUser());
}
위의 로직은 타당해 보이므로, 하나의 요청만이 통과되고 다른 요청은 IllegalStateException으로 처리될 것이라 생각할 수 있다.
하지만 운이 나쁘면 완전히 예상치 못한 에러가 발생할 수 있다.
읽기 시점에는 두 개의 Thread가 모두 유효하다는 응답을 받게 되므로, insert를 수행하려 할 것이다.
하지만 여기서 Thread1은 성공하지만, Thread2는 실패한다.
중요한 건 여기서 실패하는 이유는 조건문에 걸렸기 때문이 아니다.
DB의 users 테이블의 username 필드에 걸려있는 unique 속성에 의해 실패하게 될 것이다.
즉, 에러의 발생 원인이 완전히 뒤틀린다.
✒️ 해결 방법
@Transactional
public synchronized User signUp(Request request) {
if (userService.isExistsByUsername(request.username())) {
throw new IllegalStateException();
}
return userService.createUser(request.toUser());
}
싱글 프로세스 환경에서 위 문제를 해결하는 건 간단하다.
동시성 문제가 발생할 것 같은 시점에 synchronized를 걸어버리면 그만이다.
이렇게만 해도 멀티 스레드 테스트 코드는 무사히 통과할 수 있으므로 안심할 수 있다.
하지만 과연 그럴까?
📌 Multi Process (다중 컨테이너 환경)
docker-compose up -d --scale was=2
- 모종의 이유로 Scale-out의 필요성을 느껴 위와 같은 환경을 구성하는 순간 동시성 문제는 다시 나타난다.
- synchronized는 하나의 JVM, 즉 하나의 서버에선 동시성이 제어가 가능하지만 다중화된 서버 환경에선 그렇지 않기 때문
🤔 해결 방법???
약간 미친 발상으로 DB에 접근하는 애플리케이션을 하나 두고, Presentation 요청을 하는 로직을 담은 애플리케이션만 다중화하는 방법을 생각해볼 수도 있다.
구조가 복잡해지고, Presentation 계층의 애플리케이션 개수가 증가할 수록 병목 현상 문제가 증가할 것이다.
📌 Multi Process (다중 서버 환경)
- 컨테이너가 아니라 서버를 하나 더 늘려보면 문제는 더 심오해진다.
- 위의 방법대로 해결하려면 EC2를 하나 더 생성하는 비용과 환경을 구축하는 비용을 충분히 고려해보았는가?
가벼운 프로젝트라면 몰라도 실제 런칭할 프로젝트라면 이런 문제에 대해 고민해볼 필요가 있다.
해결책을 알아보기 전에 낙관적 락과 비관적 락에 대한 개념을 잠시 짚어보고 가자.
2. 낙관적 락과 비관적 락
📌 낙관적 락(Optimistic Lock)
낙관적 락은 데이터의 버전을 기록하는 컬럼을 추가하는 방법이다.
@Version 어노테이션을 제공해주고 있다.
대충 동작 방식을 정리해보면, 트랜잭션이 엔티티를 수정할 때마다 현재 버전 번호가 자동으로 업데이트된다.
다른 Tx에서 동일한 엔티티를 수정하려고 시도하면 버전 번호를 확인하는데, 이 때 Tx1과 Tx2가 동시에 조회를 하고 Tx1이 먼저 수정을 해버렸다면 Tx2는 조회 시점과 수정 시점의 버전이 달라서 롤백이 발생한다.
참고로 분산 락 이야기 나올 때 다시 언급할 거지만 아래 TestCoupon 관련 테스트는 컬리 기술 블로그를 참고했다.
단순한 기능이므로 코드만 올려놓고 자세한 설명은 생략.
쿠폰을 N개 발급하고 N명의 사용자가 한 장씩 발급받는 내용이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TestCoupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Long version; // version 추가
/**
* 사용 가능 재고수량
*/
private long availableStock;
public TestCoupon(String name, long availableStock) {
this.name = name;
this.availableStock = availableStock;
}
public void decreaseStock() {
validateStock();
this.availableStock--;
}
private void validateStock() {
if (availableStock < 1) {
throw new IllegalArgumentException("재고가 부족합니다.");
}
}
}
public interface TestCouponRepository extends JpaRepository<TestCoupon, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT c FROM TestCoupon c WHERE c.id = :id")
TestCoupon findByIdWithOLock(Long id);
}
@Component
@RequiredArgsConstructor
public class TestCouponDecreaseService {
private final TestCouponRepository couponRepository;
@Transactional
public void decreaseStockWithOLock(Long couponId) {
try {
TestCoupon coupon = couponRepository.findByIdWithOLock(couponId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
coupon.decreaseStock();
couponRepository.saveAndFlush(coupon);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
System.out.println("OptimisticLockException 발생");
}
}
}
이제 테스트 코드를 작성해보자.
@Test
void 쿠폰차감_낙관적락_적용_동시성_300명_테스트() throws InterruptedException {
// given
int threadCount = 300;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
testCouponDecreaseService.decreaseStockWithOLock(coupon.getId());
} finally {
latch.countDown();
}
});
}
latch.await();
// then
TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new);
assertThat(persistedCoupon.getAvailableStock()).isZero();
log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock());
}
300개의 쿠폰에 300개의 스레드가 접근했으므로 결과는 0이 나와야 하지만 실제론 31개밖에 차감이 되지 않았다.
낙관적 락은 롤백에 대한 핸들링이 구현되어 있지 않기 때문에 직접 로직을 구현해줘야 하는 단점이 있다.
장점이라고 하면 DB Lock을 걸지 않기 때문에 보다 자유롭게 데이터 조회가 가능하다는 점이다.
📌 비관적 락(Pessimistic Lock)
비관적 락은 좀 더 보수적인 접근 방법을 취한다.
Tx의 충돌이 발생한다고 가정하고 우선 Lock을 걸고 비지니스 로직을 수행한다.
버전도 무려 세 가지나 있다
- perssimistic_read: dirty read가 발생하지 않을 때마다 공유 락을 획득하여 데이터가 수정/삭제 됨을 방지한다.
- perssimistic_write: 배타적 락을 획득하여 다른 Tx에서 조회/수정/삭제하는 것을 방지한다.
- perssimistic_force_increment: perssimistic_write와 비슷하지만 @Version 어노테이션이 있는 Entity와 협력하기 위해 도입되었다. Lock을 획득하면 버전이 업데이트 된다.
비관적 락은 DB에서 제공하는 Lock을 사용한다.
public interface TestCouponRepository extends JpaRepository<TestCoupon, Long> {
@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
@Query("SELECT c FROM TestCoupon c WHERE c.id = :id")
Optional<TestCoupon> findByIdWithPLock(Long id);
}
@Transactional
public void decreaseStockWithPLock(Long couponId) {
TestCoupon coupon = couponRepository.findByIdWithPLock(couponId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다."));
coupon.decreaseStock();
couponRepository.saveAndFlush(coupon);
}
상당히 보수적으로 접근했기 때문인지 동시성 테스트에 성공한다.
실행 시간은 2초 정도가 소요되었다.
낙관적 락이 1초 정도밖에 소요되지 않았었는데 2배나 뛰었다.
비관적 락은 동시성 문제 처리에는 안정성이 매우 높지만, 하나씩 순차적으로 처리해야 하기 때문에 속도가 비교적 늦어진다는 문제점이 있다.
그리고 가장 큰 문제는 조회까지 막아버리기 때문에 전혀 상관없는 작업에서도 병목 현상이 발생할 수 있다는 점이다.
3. 분산 락
📌 What is Redisson?
Redisson은 Redis에 접근하는 다양한 인터페이스 중 하나다.
분산 락을 구현할 때 Redisson을 많이 사용하는 이유는 Pub/Sub 기능을 제공하기 때문이다.
뭔 소린고 하니, 일반적으로 많이 사용하는 Lettuce는 스핀락 기법을 사용해 Lock을 획득한다.
즉, 락 요청 후 획득에 실패하면 일정 시간 기다렸다가 다시 요청하는 방식으로 동작한다.
하지만 Redisson을 사용하면 락 획득에 실패하면 특정 key를 구독해두고 대기한다.
그러다 Lock을 얻을 수 있게 되었다는 이벤트가 발생하면 다시 Lock 선점하기 위해 시도한다.
당연히 Redis의 부하가 줄어들기 때문에 유용하다.
✒️ DB Lock을 쓰면 되지 않나?
비관적 락에서도 언급했듯 DB의 Lock을 활용할 수도 있다.
하지만 데이터베이스는 안 그래도 바쁜 친군데 이런 일로 괴롭히기엔 좀 그렇지 않을까?
그리고 메모리 기반의 Redis는 훨씬 접근이 용이하고 빠르기 때문에 DB에 접근하는 Lock을 Redis에 두는 것을 선호한다.
📌 Architecture
이미 컬리 기술 블로그에 너무 친절하게 설명되어 있기에 내가 더 적을 게 없다.
적혀있는 그대로 가져다가 실행해보면 동작한다.
메서드 단위로 락을 걸 수 있어서 낙관적 락보다 안정성을 보장하면서, 비관적 락처럼 다른 작업까지 병목 현상을 야기할 일이 없어서 좋다.
📌 자원에 접근할 때는 반드시 상태를 먼저 변경하라
컬리 블로그 안에도 적혀있는 내용이지만 반드시 Tx가 실행되기 전에 Lock을 먼저 걸어야 하고,
Tx 커밋 이후에 Lock이 해제되어야 한다.
물론 적혀있는 코드 복붙만 해도 순서야 지켜지겠지만, 문제는 초기 값으로 설정되어 있는 Lock 방출 시간이 너무 짧게 잡혀있다.
임의의 Tx가 Lock을 획득하고 자원의 정보를 수정했다고 가정하자.
그런데 이 작업이 지연되어 방출 시간이 지나는 바람에, 변경 사항이 DB에 반영되기 전에 Lock을 놓치게 되면 Lock을 얻은 다른 Tx가 잘못된 정보를 조회하게 된다.
그리고 락 획득 대기 시간도 너무 짧아서 처음에 동시성 300명 테스트 하니까 기다리다가 Thread가 종료되어 버려서 실패하는 케이스도 있었다.
너무 길게 잡으면 나처럼 테스트 시간만 6초 가까이 걸리니 적절한 시간을 찾아보는 것이 좋다.