1. 들어가며
패키에서는 선물박스를 받게 되면 Receiver 테이블에 받은 유저 ID와 선물박스 ID를 저장한다.
지금은 하나의 선물박스를 1명만 받을 수 있지만(PRIVATE), 나중에 하나의 선물박스를 여러 유저가 받을 수 있는 상황(SEMI_PUBLIC, PUBLIC)을 고려하여 다대일로 관계를 설정하였다.
만약 여러 유저가 동시에 선물박스 열기(=선물박스 받기)를 시도할 경우 어떻게 될까? 테스트 코드로 상황을 시뮬레이션 해보자.
2. 선물박스 열기 테스트 코드 작성
여러 유저가 동시에 선물박스 열기를 시도하는 상황을 어떻게 구현할 수 있을까?
Spring Boot에서는 내장된 서블릿 컨테이너(TomCat)에서 다중 요청을 처리한다.
- 톰캣은 부팅할 때 스레드의 컬렉션인 Thread Pool을 생성
- 유저 요청(HttpServletRequest)가 들어오면 Thread Pool에서 하나씩 Thread를 할당
- 작업을 모두 수행하고 Thread는 Thread Pool로 반환
그러므로 여러 스레드를 사용해서 유저가 동시에 접근하는 상황을 구현하면 된다. 코드는 아래와 같다.
💥 giftBoxType은 PRIVATE이라고 가정한다
참고로 나는 서비스 레이어 테스트 코드에 @Transactional 어노테이션을 추가하여 진행했는데 어노테이션을 사용할 경우 데이터가 의도한 것처럼 저장되지 않아 기존 GiftBoxServiceTest에 테스트 메서드를 작성하는 대신 @Transactional을 사용하지 않는 GiftBoxConcurrencyTest 테스트 클래스를 만들어 테스트를 진행하였다.
class GiftBoxConcurrencyTest {
// 의존성 주입, BeforeEach 중략 ...
@DisplayName("선물박스 열기 동시성 테스트")
@Test
void multipleUserOpenGiftBox() throws InterruptedException {
// given
int memberCount = 10;
int giftBoxAmount = 1;
List<Member> receivers = new ArrayList<>();
Long lastMemberId = memberReader.count();
for (long i = lastMemberId + 1; i <= lastMemberId + memberCount; i++) {
Member member = memberWriter.save(NORMAL_MEMBER_RECEIVER.createMember());
receivers.add(member);
}
GiftBox giftBox = giftBoxWriter.save(
sendGiftBoxFixtureWithGift(MEMBER_SENDER, letter));
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch countDownLatch = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
Long receiverBefore = receiverReader.countByGiftBox(giftBox);
// when
for (int i = 0; i < memberCount; i++) {
Member member = receivers.get(i);
Long memberId = member.getId();
executorService.submit(() -> {
try {
createSecurityContextWithMockUser(memberId.toString());
giftBoxService.openGiftBox(giftBox.getId());
successCount.incrementAndGet();
} catch (Exception e) {
e.printStackTrace();
failCount.incrementAndGet();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
Long receiverAfter = receiverReader.countByGiftBox(giftBox);
List<Receiver> receiverList = receiverReader.findByGiftBox(giftBox);
System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);
countDownLatch.await();
// then
assertThat(successCount.get()).isEqualTo(giftBoxAmount);
assertThat(receiverAfter).isEqualTo(giftBoxAmount);
}
}
ExecutorService를 사용하여 ThreadPool을 이용한 작업을 진행하고, CountDownLatch를 필요한 스레드 수의 값으로 초기화한 뒤 스레드 작업이 완료되는 것을 기다릴 수 있다.
선물박스 열기를 시도한 모든 유저가 RECEIVER로 변경되었다.
1명만 열 수 있는 선물박스를 10명이 모두 열 수 있게 저장되는 문제가 발생하였다.
3. 어떻게 해결해야 할까?
synchronized
synchronized는 Java에서 제공하는 키워드로, 모니터 기반 상호 배제(mutual exclusion) 기능을 제공한다.
하지만 synchronized를 사용하는 것에는 2가지 문제가 있다.
- 같은 프로세스에서만 상호 배제를 보장하기 때문에 분산 환경에서는 보장되지 않을 수 있다.
- @Transactional 어노테이션과 함께 사용할 경우 문제가 발생할 수 있다.
@Transactional 어노테이션이 붙은 메서드가 호출되면, Spring은 프록시 객체를 생성한다.
해당 프록시 객체는 원복 객체를 감싸며, 메서드 전후로 transaction begin과 transaction commit을 수행한다.
transaction begin, commit은 synchronize의 영향을 받지 않기 때문에 위 그림처럼 T1 스레드에서 commit되기 전 T2 스레드가 시작하는 상황이 발생할 수 있다.
Lock
락(Lock)은 데이터가 읽힌 후 사용될 때까지 데이터가 변경되는 것을 방지하기 위한 조치이다.
데이터베이스에서 제공하는 락(S-lock, X-lock 등...)도 있지만 어플리케이션 레벨에서의 락 기법도 존재한다.
동시성 문제 해결을 위해 대표적으로 사용되는 락은 낙관적 락, 비관적 락, 분산 락이 있다.
3가지 락의 특징에 대해 간단하게 알아보자.
낙관적 락
여러 트랜잭션 간 충돌이 일어나지 않을 것이라고 '낙관적'으로 가정하고, 실제 충돌이 발생했을 때만 대응한다.
데이터베이스에 대한 변경이 드물고, 충돌 가능성이 낮은 환경에서 유용하다.
테이블에 version 컬럼을 추가하여 버전 불일치 여부를 확인하는 방식으로 동작한다. 버전 불일치로 인한 실패 처리는 어플리케이션 레벨에서 처리해주어야 한다. (예외 처리 & 실패 후 재시도 로직)
비관적 락
여러 트랜잭션 간 충돌이 일어날 것이라고 '비관적'으로 가정하고, 데이터를 처음부터 잠그는 방식으로 충돌을 방지한다.
데이터 변경이 자주 발생하고, 충돌 가능성이 높은 환경에서 유용하다.
단일 DB 환경에만 적용 가능하고, 많은 대기 시간이 발생한다는 단점이 있다.
동시에 많은 요청이 들어왔을 때, 사실상 하나의 요청씩 작업할 수 있기 때문에 요청이 많아지면 대기 시간이 길어진다.
분산 락
분산 환경에서 상호 배제를 구현하여 동시성 문제를 다루기 위해 등장한 방법이다.
락에 대한 정보를 어딘가에 공통적으로 보관하고, 여러 대의 서버는 공통된 어딘가를 바라보며 자신이 임계 영역에 접근할 수 있는지 확인하는 방법으로 동작한다.
'어딘가'는 MySQL 네임드 락, Redis, Zookeeper 등의 기술이 될 수 있다.
어떤 락을 사용해야 할까?
3가지 락을 비교해보았을 때, 낙관적 락과 비관적 락은 특정 엔티티에 대한 동시 접근 제어에 관심이 있다. 즉, 이미 존재하는 엔티티에 동시 접근하는 상황(ex. update 쿼리)에 적합한 방법이다.
그러나 현재 문제 상황에서는 이미 존재하는 엔티티가 아니라 Receiver라는 새로운 엔티티의 생성 개수 제한에 문제가 발생가 발생하므로 낙관적 락과 비관적 락은 적합하지 않다.
그러므로 분산 락을 최종적으로 선택하여 동시성 문제를 해결하려고 한다.
4. 분산 락을 구현하는 2가지 방식
분산 락을 구현하는 방법은 1️⃣ SENTX를 활용해 스핀 락을 구현하는 방법과 2️⃣ Message Broker를 사용하는 2가지 방법이 있다.
SENTX를 활용한 스핀 락 구현
SENTX는 Redis의 명령어로 키에 값이 존재하지 않을 때만 값을 설정할 수 있도록 하는 연산이다.
해당 연산을 사용하여 스핀 락 방식을 구현할 수 있다.
스핀 락은 락을 사용할 수 있을 때까지 지속적으로 확인하며 기다리는 방식이다.
// 락을 획득할 때까지 대기하는 예시 코드
while(!redisLockRepository.lock(giftBoxId)) {
Thread.sleep(100);
}
기본 제공되는 Redis Client인 Lettuce만으로 간단하게 구현 가능하다는 장점이 있다.
하지만 락을 사용할 수 있을 때까지 계속 접근을 시도하므로 Reids 서버에 불필요한 부하를 주게 된다.
만약 애플리케이션 장애로 인해 락을 획득하지 못한다면 무한 루프를 돌거나, 락을 잡고 있던 서버가 죽어 락 해제를 하지 못하는 상황도 발생할 수 있다.
Message Broker 사용
Redis는 캐시뿐만 아니라 Pub/Sub 구조의 Message Broker로도 사용이 가능하다.
subscribe라는 명령어로 subscriber는 특정 channel을 구독할수 있다.
publisher는 publish 명령으로 특정 channel에 메시지를 발행할 수 있고, 해당 channel을 구독한 subscriber는 메시지를 받을 수 있다.
분산 락에서는 락을 해제하는 측이 락을 대기하는 프로세스에게 💬 이제 락 획득 시도 해제해도 돼~ 💬 라고 메시지를 발행할 수 있다.
Redisson이라는 라이브러리에 분산 락 메커니즘과 재시도 로직이 구현되어 있기 때문에 해당 라이브러리를 사용하여 분산 락을 구현할 수 있다. 우리는 락 획득 시도, 락 획득 성공 후 처리, 락 획득 실패 로직을 구현하면 된다.
두 가지 방식을 비교해보았을 때, 스핀 락은 락을 획득하지 못하는 상황이나 장애 발생 상황에 대해 추가적으로 구현할 로직이 많고 이를 빼먹을 경우 선물박스 열기라는 핵심 비즈니스 로직이 아예 동작하지 않는 심각한 상황이 발생할 수 있어 더 안정적인 Message Broker 방식을 사용하려고 한다.
5. 마치며
이번 포스트에서는 선물박스 열기 로직에 동시성 제어를 도입하려는 이유를 소개하고 테스트 코드를 통한 문제 상황 파악하였다.
그 후 synchronized 키워드와 3가지 락에 대한 개념을 기본적으로 알아보고, 최종적으로 Redis의 Message Broker 방식을 활용한 분산 락으로 해결할 것을 결정 지었다.
Redisson을 이용한 구체적인 구현 과정을 이번 포스트에서 적으면 글이 너무 길어질 것 같아 다음 포스트(아래 링크 참고)에서 작성하려고 한다.
Reference
- https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests
- https://hudi.blog/distributed-lock-with-redis/
- https://tecoble.techcourse.co.kr/post/2023-08-16-concurrency-managing/?fbclid=IwAR2jTEluMZ7aRZDR45ryDgao0Gw7TiA9LX4yFf6Mn51iUqS3jnS8tdnndoo_aem_Ab6XHScx9dCEKmt-gZcJrH3NptNkqCJmb2thytRUzegpL6H017gfOfiLNODaPqp3M_M&mibextid=Zxz2cZ