2024.09.27 - [Spring/동시성 & Lock] - [Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3
우리는 위 포스팅에서 `PESSIMISTIC_WRITE`의 `Database Lock`를 활용해 동시성 문제를 해결했다.
하지만, 이는 아직 생각해야봐야 할 것이 많이 남아있다.
우선 `Database Lock`의 장점과 단점에 관해 알아보자.
DB 락(예: `MySQL`의 `SELECT FOR UPDATE`, row-level locking)
- 장점
- 데이터베이스에서 락을 관리
- 다중 서버 환경에서도 락이 잘 유지 됨
- 일반적인 트랜잭션과 함께 사용할 수 있어 일관성 유지가 쉬움
- 데이터베이스에서 락을 관리
- 단점
- 데이터베이스에 추가적인 락 부하를 발생시킴
- 성능 저하 우려
- 락 경쟁이 많아지면, DB의 성능에 직접적인 영향을 미침
- 데이터베이스에 추가적인 락 부하를 발생시킴
즉, 쉽게 구현할 수 있다는 장점은 있지만,
만약에 선착순 티켓 시스템에서 엄청나게 많은 트래픽이 몰린다면?
위 포스팅처럼 재고 차감 형식이 아닌 티켓 정보(이름, 전화)와 함께 새로운 데이터를 삽입할 때,
생성 가능 개수가 100개의 제한이라면?
DB Lock에서는 관련 문제가 발생한다.(관련 내용은 이번 포스팅과 다음 포스팅에서 계속 다룬다.)
우선, 선착순 티켓 시스템의 고도화를 위해 아래처럼 선착순 티켓 시스템의 요구사항 및 목표를 설정해보자.
선착순 티켓 시스템 요구사항 고도화
- 회원은 이벤트 티켓 예매 페이지에 접속 후 이름을 입력 해 선착순 100명 예매 시도
- 사용자는 중복 예매 불가
- 특정 시간에 엄청난 트래픽이 몰린다고 가정
즉, 티켓 100명 예매를 보장하고, 중복 예매가 불가능하며, 높은 트래픽을 견디는 시스템 구축이 요구사항.
(기존 - `DB Lock으로 해결, 재고 차감 형식` -> 새로운 데이터를 생성하지는 않음)
우선 관련된 작업을 할 `Entity` 부터 생성해보자.
Ticket2
@Getter
@Setter
@Entity
public class Ticket2 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String phone;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id")
private Event event;
}
User
@Getter
@Setter
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
Event
@Getter
@Setter
@Entity
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
Entity 생성 후 한번 아무런 Lock도 사용하지 않은 관련 `TicketV8Service`와 `TicketV8ServiceTest`를 각각 생성해보자.
TicketV8Service(아무런 락도 적용하지 않은 상태)
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV8Service {
private final Ticket2Repository ticket2Repository;
private final EventRepository eventRepository;
private final UserRepository userRepository;
public void purchaseTicketNoLock(PurchaseTicketRequest request) {
Long count = ticket2Repository.count();
if(count > 100){
return;
}
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new RuntimeException("No user"));
Event event = eventRepository.findById(request.getEventId())
.orElseThrow(() -> new RuntimeException("No event"));
Ticket2 ticket2 = Ticket2.builder()
.user(user)
.event(event)
.name(request.getName())
.phone(request.getPhone())
.build();
ticket2Repository.save(ticket2);
}
}
아무런 `Lock`도 적용하지 않은 상태로 개수가 100개 이상이면 그냥 반환한다.
TicketV8ServiceTest
@SpringBootTest(classes = ConcurrencyControlApplication.class)
@TestPropertySource(locations = "classpath:application.properties")
public class TicketV8ServiceTest {
private static final int THREAD_COUNT = 1000;
@Autowired
private TicketV8Service ticketV8Service;
@Autowired
private Ticket2Repository ticket2Repository;
@Autowired
private UserRepository userRepository;
@Autowired
private EventRepository eventRepository;
private ExecutorService executorService;
private CountDownLatch countDownLatch;
private Ticket2 ticket;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(32);
countDownLatch = new CountDownLatch(THREAD_COUNT);
for(int i = 0; i < 1000; i++){
User user = new User();
userRepository.save(user);
}
Event event = new Event();
eventRepository.save(event);
}
@Test
public void test_1000명의_참가자가_티켓을_구입() throws InterruptedException {
for (int i = 0; i < THREAD_COUNT; i++) {
Long userId = (long) (1 + i);
Long eventId = 1L;
executorService.submit(() -> {
try {
PurchaseTicketRequest purchaseTicketRequest = new PurchaseTicketRequest(userId, eventId, "name" + userId, "phone" + userId);
ticketV8Service.purchaseTicketNoLock(purchaseTicketRequest);
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
Thread.sleep(2000);
long count = ticket2Repository.count();
assertThat(count).isEqualTo(100);
}
}
테스트 결과
당연하게도 아무런 `Lock`을 적용하지 않았을 때는 데이터 조회와 데이터 생성 시 데이터 불일치가 일어난다.
하지만, 위에서 설명한 것처럼 `DB Lock`에는 데이터베이스 부하 증가라는 단점이 존재한다.
이때 우리는 `Redis`의 `분산 락`이 아닌 `Redis`의 자료구조들로 쉽게 해결이 가능하다.
(지금처럼 String이나, Set과 같은 자료구조로 해결할 수 있는 티켓팅과 관련된 경우에 유용하다) <- 중요
(오히려 자료구조로 해결할 수 있으면 분산 락을 사용하는 것보다 성능이 높을 수 있다)
그래서 Redis로 어떻게 해결하는데?
1. 선착순 100명 예매 시도
`Redis`는 `INCR` 혹은 `DECR` 명령어를 통해 특정 키의 값에 관한 증감 기능을 제공한다.
`INCR`, `DECR`은 원자적 연산으로 설계되어있기에 여러 클라이언트가 동시에 이 명령을 호출하더라도,
`Redis`가 단일 스레드로 동작하기 때문에 데이터 손실이나 충돌이 발생하지 않는다.
또한, 시간 복잡도가 `O(1)`로 매우 빠르다.
따라서, `Redis`의 `INCR` 혹은 `DECR` 명령어를 사용해 티켓 개수를 제어하면,
높은 성능을 유지하면서도 데이터의 정합성을 보장할 수 있다.
아래는 위에 맞게 코드를 변경한 내용이다.
Ticket2RedisRepository
@Repository
@RequiredArgsConstructor
public class Ticket2RedisRepository {
private static final String EVENT_TICKET_PREFIX = "EVENT:TICKET:COUNT:";
private static final String EVENT_KEY_PREFIX = "EVENT_KEY_";
private final RedisTemplate<String, Object> redisTemplate;
public Long increment(Long eventId) {
String eventKey = EVENT_TICKET_PREFIX + eventId;
return redisTemplate
.opsForValue()
.increment(eventKey);
}
}
TicketV8Service
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV8Service {
private final Ticket2Repository ticket2Repository;
private final Ticket2RedisRepository ticket2RedisRepository;
private final EventRepository eventRepository;
private final UserRepository userRepository;
...
public void purchaseTicketWithRedis(PurchaseTicketRequest request) {
Long count = ticket2RedisRepository.increment(request.getEventId());
if(count > 100){
return;
}
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new RuntimeException("No user"));
Event event = eventRepository.findById(request.getEventId())
.orElseThrow(() -> new RuntimeException("No event"));
Ticket2 ticket2 = Ticket2.builder()
.user(user)
.event(event)
.name(request.getName())
.phone(request.getPhone())
.build();
ticket2Repository.save(ticket2);
}
}
TicketV8ServiceTest
@SpringBootTest(classes = ConcurrencyControlApplication.class)
@TestPropertySource(locations = "classpath:application.properties")
public class TicketV8ServiceTest {
private static final int THREAD_COUNT = 1000;
@Autowired
private TicketV8Service ticketV8Service;
@Autowired
private Ticket2Repository ticket2Repository;
@Autowired
private UserRepository userRepository;
@Autowired
private EventRepository eventRepository;
private ExecutorService executorService;
private CountDownLatch countDownLatch;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(32);
countDownLatch = new CountDownLatch(THREAD_COUNT);
for(int i = 0; i < 1000; i++){
User user = new User();
userRepository.save(user);
}
Event event = new Event();
eventRepository.save(event);
}
...
@Test
public void test_1000명의_참가자가_티켓을_구입_레디스_INCR() throws InterruptedException {
for (int i = 0; i < THREAD_COUNT; i++) {
Long userId = (long) (1 + i);
Long eventId = 1L;
executorService.submit(() -> {
try {
PurchaseTicketRequest purchaseTicketRequest = new PurchaseTicketRequest(userId, eventId, "name" + userId, "phone" + userId);
ticketV8Service.purchaseTicketWithRedis(purchaseTicketRequest);
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
Thread.sleep(2000);
long count = ticket2Repository.count();
assertThat(count).isEqualTo(100);
}
}
실제로 테스트를 잘 통과하는 것을 확인할 수 있다.
지금까지 위에서 언급했던 요구사항인,
"회원은 티켓 예매 페이지에 접속 후 이름을 입력 해 선착순 100명 예매 시도"에 관해
`Redis`의 `INCR`를 활용해 구현했다.
다음은 동일 사용자가 티켓을 2번 구입하지 못하게 하는 요구사항을 구현해보자.
2. 중복 사용자 검증
지금 코드에서는 동일 사용자가 한 이벤트를 여러 번 예매했을 때,
관련된 처리를 할 수가 없다.
Ticket2RedisRepository
TicketV8ServiceTest
@SpringBootTest(classes = ConcurrencyControlApplication.class)
@TestPropertySource(locations = "classpath:application.properties")
public class TicketV8ServiceTest {
private static final int THREAD_COUNT = 1000;
@Autowired
private TicketV8Service ticketV8Service;
@Autowired
private Ticket2Repository ticket2Repository;
@Autowired
private UserRepository userRepository;
@Autowired
private EventRepository eventRepository;
private ExecutorService executorService;
private CountDownLatch countDownLatch;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(32);
countDownLatch = new CountDownLatch(THREAD_COUNT);
for(int i = 0; i < 1000; i++){
User user = new User();
userRepository.save(user);
}
Event event = new Event();
eventRepository.save(event);
}
...
@Test
public void test_1명의_참가자가_티켓을_여러번_구입() throws InterruptedException {
for (int i = 0; i < THREAD_COUNT; i++) {
Long userId = 1L;
Long eventId = 1L;
executorService.submit(() -> {
try {
PurchaseTicketRequest purchaseTicketRequest = new PurchaseTicketRequest(userId, eventId, "name" + userId, "phone" + userId);
ticketV8Service.purchaseTicketWithRedis(purchaseTicketRequest);
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
Thread.sleep(2000);
long count = ticket2Repository.count();
assertThat(count).isEqualTo(1);
}
}
`userId`를 `1`로 고정했으면 1개만 구입되어야 하는데 100개가 구입되어져 있다.
즉, 중복 처리를 못하는 것이다.
이때, 우리는 대충 글을 읽었으면 예상했겠지만 `Redis`에서 지원하는 `Set` 자료구조를 활용할 수 있다.
Redis Set 자료구조 활용
`Set` 자료구조 특성상 중복된 값이 들어가도 하나의 값만 저장한다.
그리고 `Redis`의 `Set` 자료구조에 값을 넣는 `sadd` 명령어는 `O(1)`의 시간 복잡도를 갖는다.
즉, 성능 저하를 고려할 필요가 없다.
아래는 `Set` 자료구조를 적용한 코드이다.
Ticket2RedisRepository
@Repository
@RequiredArgsConstructor
public class Ticket2RedisRepository {
private static final String EVENT_TICKET_PREFIX = "EVENT:TICKET:COUNT:";
private static final String EVENT_KEY_PREFIX = "EVENT_KEY_";
private final RedisTemplate<String, Object> redisTemplate;
...
public Long add(Long eventId, Long userId){
String eventKey = EVENT_KEY_PREFIX + eventId;
return redisTemplate
.opsForSet()
.add(eventKey, userId.toString());
}
}
TicketV8Service
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV8Service {
private final Ticket2Repository ticket2Repository;
private final Ticket2RedisRepository ticket2RedisRepository;
private final EventRepository eventRepository;
private final UserRepository userRepository;
...
public void purchaseTicketWithRedisAndSet(PurchaseTicketRequest request) {
Long isAdd = ticket2RedisRepository.add(request.getEventId(), request.getUserId());
if(isAdd != 1){
return;
}
Long count = ticket2RedisRepository.increment(request.getEventId());
if(count > 100){
return;
}
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new RuntimeException("No user"));
Event event = eventRepository.findById(request.getEventId())
.orElseThrow(() -> new RuntimeException("No event"));
Ticket2 ticket2 = Ticket2.builder()
.user(user)
.event(event)
.name(request.getName())
.phone(request.getPhone())
.build();
ticket2Repository.save(ticket2);
}
}
TicketV8ServiceTest
@SpringBootTest(classes = ConcurrencyControlApplication.class)
@TestPropertySource(locations = "classpath:application.properties")
public class TicketV8ServiceTest {
private static final int THREAD_COUNT = 1000;
@Autowired
private TicketV8Service ticketV8Service;
@Autowired
private Ticket2Repository ticket2Repository;
@Autowired
private UserRepository userRepository;
@Autowired
private EventRepository eventRepository;
private ExecutorService executorService;
private CountDownLatch countDownLatch;
@BeforeEach
void setUp() {
executorService = Executors.newFixedThreadPool(32);
countDownLatch = new CountDownLatch(THREAD_COUNT);
for(int i = 0; i < 1000; i++){
User user = new User();
userRepository.save(user);
}
Event event = new Event();
eventRepository.save(event);
}
...
@Test
public void test_1명의_참가자가_티켓을_여러번_구입2() throws InterruptedException {
for (int i = 0; i < THREAD_COUNT; i++) {
Long userId = 1L;
Long eventId = 1L;
executorService.submit(() -> {
try {
PurchaseTicketRequest purchaseTicketRequest = new PurchaseTicketRequest(userId, eventId, "name" + userId, "phone" + userId);
ticketV8Service.purchaseTicketWithRedisAndSet(purchaseTicketRequest);
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
countDownLatch.await();
Thread.sleep(2000);
long count = ticket2Repository.count();
assertThat(count).isEqualTo(1);
}
}
다음과 같이 코드를 작성하면 문제 없이 중복 체크가 되는 것을 확인할 수 있다.
'Spring > 동시성 & Lock' 카테고리의 다른 글
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 2 (0) | 2024.11.21 |
---|---|
[Spring Data] Redis Lock을 적용해보자 - 2 (0) | 2024.10.17 |
[Spring Data] Redis Lock을 적용해보자 - 1 (0) | 2024.10.16 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3 (0) | 2024.09.27 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 2 (0) | 2024.09.25 |