🍒 앵두랩 파헤치기
스프링 위에서 동시성을 제어해 보자. 망쵸와 감자가 앵두랩이라는 미션 레포지토리를 만들어 두어서 재미있게 공부했다. 우리는 아래와 같은 문제를 해결해야 한다!
1. 계정당 구매 가능 수량 제한 계정당 구매할 수 있는 화성(Mars)석, 금성(Venus)석 티켓을 총 2장으로 제한합니다. 예) 금성석 2장과 화성석 2장(총 4장)은 구매 불가. 금성석 1장과 화성석 1장(총 2장)은 구매 가능. 2. 티켓 초과 판매 방지 보유하고 있는 티켓보다 더 많은 티켓이 판매되지 않도록 재고를 철저히 관리해야 합니다. 매크로를 사용하는 부정 사용자로 인해 하나의 계정에서 수많은 티켓 구매 요청이 올 수 있습니다. 3. 초당 최소 1,000 장의 티켓 구매 처리 성능 요구 5만 장의 티켓이 1분 내에 매진되기 때문에, 초당 최소 1,000장의 티켓 구매가 가능한 시스템을 설계해야 합니다. 구매에 실패한 요청(계좌 잔액 부족, 카드 비밀번호 오류 등)도 발생하기 때문에 초당 1,000개 이상의 구매 요청이 발생할 것을 염두에 둬야 합니다.
아래와 같은 비즈니스 로직이 빠르고 정확하게 돌아가도록 구현해야 한다. 처음부터 문제를 해결하려고 하면 어려우니, 간단한 상황을 제시하고 하나씩 어려운 단계로 들어가 보자. 우선 단일 서버, 단일 DB 상황을 가정하고 문제를 해결하자.
@Transactional public void issue(long memberId, long ticketId) { Member member = getMember(memberId); Ticket ticket = getTicket(ticketId); validateIssuable(member, ticket); memberTicketRepository.save(new MemberTicket(member, ticket)); ticket.decrementQuantity(); }
🪨 데드락도 락이다 (아님)
10개의 티켓을 발급한 뒤 5명의 사람이 동시에 두 개의 티켓을 가져가는 시나리오를 작성하고 테스트를 돌려보자.
org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update ticket set name=?,quantity=? where id=?]; SQL [update ticket set name=?,quantity=? where id=?]
테스트 중 위와 같은 데드락이 발생한다. MySQL에서 show engine innodb status\G;
로 자세한 상황을 확인할 수 있다. 두 개의 서로 다른 트랜잭션이 Lock을 기다리다가 죽어버리고 말았다.
*** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table `ticket`.`ticket` trx id 3040 lock mode S locks rec but not gap Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table `ticket`.`ticket` trx id 3040 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table `ticket`.`ticket` trx id 3045 lock mode S locks rec but not gap Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table `ticket`.`ticket` trx id 3045 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 *** WE ROLL BACK TRANSACTION (2)
MySQL에서 S-Lock
은 동시에 여러 트랜잭션이 가질 수 있다. 말 그대로 읽기를 위한 잠금으로, 빠른 동시성을 위해 제공된다. 만약 자원을 수정하고 싶다면 X-Lock
을 가져야 한다. 배타 잠금으로, 누구도 잠금을 가지지 않아야 한다. 데드락은 두 트랜잭션이 S-Lock
을 획득한 뒤, X-Lock
으로 상승하려고 했으나, 반대쪽 트랜잭션의 S-Lock
으로 인해 대기하는 상황이다.
🔨 어플리케이션에서 데드락 조건을 깨보자
데드락은 다음과 같은 네 가지 요건이 모두 충족되면 발생한다. 한 가지라도 깰 수 있다면 데드락이 발생하지 않는다.
- Mutual Exclusion
- Hold & wait
- Circular wait
- Non-preemptive
Synchronization은 Java에서 제공하는 동시성 제어를 위한 수단이다. JVM 단위에서 MONITORENTER
, MONITOREXIT
instruction을 활용해 모니터 소유권을 제어한다. 모니터 소유권이 다른 스레드에 존재하는 경우, 소유권을 가진 스레드가 놓을 때까지 대기한다. Java는 객체로 굴러간다. 코드 단에서는 동시성 제어를 위해 진행하는 행위들이 Object에 걸리는 경우가 많다. notify
, wait
과 같은 메서드가 이를 활용하는 경우다.
@Transactional public synchronized void issue(long memberId, long ticketId) { // ... }
비즈니스 로직에 synchronization 키워드를 추가함으로써 Hold and wait, Circular wait의 조건을 깰 수 있다. 메서드 내부에 하나의 스레드만이 진입할 수 있으니 Lock을 가지고 다른 스레드를 기다리지 않기 때문이다. 코드상으로는 데드락이 해결됐고, 실제 테스트에서도 데드락은 발생하지 않았다. 하지만…
🎟️ 티켓 개수가 안 맞는데요?
데드락을 해결했으니 손님은 행복하다. 티케팅 시각이 되자 일제히 예매 버튼을 클릭한다. 선착순 10명에게만 제공되는 티켓이었지만 이상하리만치 많은 사람들이 티켓 구매를 인증했다. 티켓을 판매하는 사장님은 경악한다. 판매된 티켓은 10장이었지만, 재고는 아직도 5개가 남아 있었다. 서둘러 사장님은 티켓 판매를 조기 중단했다. 화가 난 사장님은 다음 티켓 예매를 경쟁사에 부탁했다. 고객이 하나둘 떠나기 시작한다. 😱
데드락은 해결했으나 정합성이 어긋났다. 티켓이 복사가 된다고 비즈니스 메서드에 추가한 synchronized
키워드가 하나의 스레드만 접근할 수 있도록 해주는 것처럼 보이지만, 실상은 그렇게 호락호락하지 않다. @Service
가 붙어있는 클래스이기 때문에 프록시 객체로 생성되며, @Transactional
어노테이션은 메서드 전후로 DB 연결, 트랜잭션 설정 및 커밋하는 기능을 제공한다. 단일 스레드만 접근하도록 보장하더라도, 이후 커밋 처리 과정에서는 단일 스레드를 보장할 수 없다. 이 과정에서 정합성이 어긋나고, 하나의 변수에 여러 스레드가 접근한다.
🎫 뭐야 내 티켓 돌려줘요
AOP로 작동하는 @Transactional
을 덜어내면 해결할 수 있을 것처럼 보인다. 그 말은 곧 커밋을 비즈니스 로직이 포함된 메서드에서 수행해야 한다는 점과 같다. 비즈니스 로직에만 집중하기 위해서 스프링의 기능을 활용하고 있는데, 또다시 DB단을 신경써야 한다니! 😋
synchronized
를 사용하면서 정합성을 보장하기 위해서는 커밋 과정까지 단일 스레드로 진행하도록 보장해야 한다. 몇 가지 선택지가 있는데, 가장 간편하게 사용할 수 있을 듯한 해결책은 추가 클래스 분리를 통해 트랜잭션을 메서드 내부에서 실행하도록 하는 거다. 기존 티켓을 발행하던 서비스의 클래스명이 MemberTicketService
였다면, 이를 아래와 같이 수정한다.
@Service @RequiredArgsConstructor public class MemberTicketService { private final TicketIssueService ticketIssueService; public synchronized void issue(long memberId, long ticketId) { ticketIssueService.issue(memberId, ticketId); } }
기존 issue
메서드에 비즈니스 로직이 모두 담겨있었다면, 해당 로직이 외부 클래스인 TicketIssueService
로 넘어간 상태다. 그리고 ticketIssueService.issue
메서드에 @Transactional
을 걸어줌으로써 DB 커밋 로직까지 모두 단일 스레드에서 실행하도록 보장한다.
또다시 원리에 대해 알아야 왜 가능한지를 알 수 있다. 여기서 등장하는 키워드가 @Transactional
의 전파 단계다. 기본적으로 REQUIRED
이고, 이는 트랜잭션이 존재하지 않으면 새로 만든다는 의미다. MemberTicketService.issue
에는 @Transactional
이 존재하지 않지만, TicketIssueService.issue
에는 @Transactional
이 존재하며 기본값으로 REQUIRED
의 전파 단계를 가지고 있으므로 해당 메서드에서 DB 접근에 대한 프록시 객체 작업이 추가로 발생한다.
해당 작업은 synchronized
로부터 출발했으니, 하나의 자원을 둘 이상의 스레드가 경쟁하는 race condition은 발생하지 않는다. 테스트 결과에서도 정합성 문제는 발생하지 않는다.
⏳ 뭐야 내 시간 돌려줘요
INFO 54901 --- [ Test worker] c.aengdulab.ticket.support.TimeMeasure : 수행 시간 : 4106ms
어음.. 일단 1, 2번을 해결했으니 넘어가자
사용자는 여전히 불편함을 겪는다. synchronized
는 어플리케이션 단위에서 대기하기 때문에 효율적으로 시간을 쓴다고 보기 어렵다. 메서드를 한 번에 하나씩, DB 접근도 한 번에 하나씩 진행하다보니 1,000개의 티켓을 판매하는 데에만 4초가 걸렸다. 동시성과 정합성을 어떻게 잘 챙겨나갈 수 있을 지 고민해보자.
😇 어려운 상황에서도 적용할 수 있을까?
서버가 한 대만 더 늘어나더라도 제어할 수 없다. Synchronization은 JVM 위에서 동작하기 때문에, 서로 다른 JVM 위에서 진행되는 분산 시스템에서는 이를 활용할 수 없다.
이제 단일 서버, 단일 DB에서 조금씩 벗어나면서 문제를 해결해 보자!