페스타고의 핵심 기능은 티켓 예매 기능이다.
티켓 예매는 동시에 많은 사용자의 요청을 받게 되는데, 이때 발생하는 문제가 바로 동시성 문제이다.
동시성 문제가 발생하면 재고가 1인 티켓에서 1+n명이 예매되는 문제가 발생한다.
이는 서비스의 핵심 비즈니스인 티켓팅에 매우 치명적인 문제이다.
따라서 어떤 방법으로 문제를 해결하려고 시도했는지, 최종적으로 선택한 방법은 무엇인지 설명한다.
Ticket 엔티티
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Ticket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private long amount = 0;
public MemberTicket orderTicket(Member member) {
if (this.amount == 0) {
throw new IllegalArgumentException("매진된 티켓입니다.");
}
amount--;
return new MemberTicket(null, this, member);
}
}
MemberTicket 엔티티
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MemberTicket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ticket_id")
private Ticket ticket;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member owner;
}
티켓 예매의 과정은 다음과 같다.
다음과 같이 티켓의 재고를 확인하고, 재고가 0개가 아니면 재고에 -1 값을 갱신한 뒤, 예매에 성공한다.
만약, 재고가 0개이면 갱신하지 않고 예매에 실패한다.
하지만 웹 어플리케이션 서버의 경우 하나의 스레드로 실행되지 않고, 성능을 위해 멀티 스레드로 실행되므로 다음과 같은 상황이 발생한다.
A의 요청 완전히 처리되기 전, B의 요청에서 티켓의 재고가 남아 있는 문제가 발생한다.
따라서 재고는 1개임에도 불구하고, n명이 예매를 할 수 있는 문제가 발생한다.
바로 동시성 문제가 발생하는 것이다.
이 경우 두 번의 갱신 분실 문제라고 생각할 수 있지만 조금 다르다.
문제의 원인은 B의 요청에서 A 요청의 갱신되기 전의 재고가 조회되는 문제이다.
이 문제를 해결하려면 재고를 조회할 때 먼저 들어온 요청이 처리가 완료되기 전까지는 다른 요청은 티켓의 재고를 읽어서는 안 된다.
여기서 재고 조회
를 Critical Section으로 생각하여, 하나의 스레드만 처리할 수 있는 동기화를 적용해야 해결할 수 있다.
가장 간단한 해결 방법으로 synchronized
키워드를 메서드에 붙여서 해결할 수 있다.
public class TicketingService {
...
@Transactional
public synchronized TicketingResponse ticketing(TicktingRequest request) {
...
}
...
}
synchronized
가 붙은 메서드는 여러 스레드에서 접근하려 할 때 단 하나의 스레드만 접근할 수 있고, 나머지 스레드는 대기 상태에 들어간다.
따라서 우리가 원하는 결과를 기대할 수 있다.
하지만 직접 실행해 보면 동시성 문제가 해결되지 않는다.
왜냐하면 이러한 요청은 DB의 트랜잭션을 사용하기 때문이다.
더 정확하게는 스프링이 제공하는 @Transactional
을 사용하기 때문이다.
@Transactional
은 AOP를 사용하여 구현되는데, AOP의 동작 원리인 프록시와 관련이 있기 때문이다.
따라서 @Transactional
어노테이션이 붙은 클래스는 다음과 같이 스프링 빈으로 등록된다.
public class TicketingServiceProxy extends TicketingService {
private TicketingService target;
@Override
public TicketingResponse ticketing(TicktingRequest request) {
// 트랜잭션 로직 (begin)
target.ticketing(request);
// 트랜잭션 로직 (commit or rollback)
}
}
synchronized
메서드를 재정의할 때 기본적으로 synchronized
가 적용되지 않는다.
따라서 target.ticketing()
메서드는 하나의 스레드만 실행하도록 보장받지만, 트랜잭션 처리를 위한 프록시의 경우 보장 받지 못하므로, 다른 요청에서 커밋이 되기 전의 재고를 조회하게 된다.
따라서 자바에서 기본으로 제공하는 synchronized
키워드로는 동시성 문제를 해결할 수 없다.
이 경우 어플리케이션 레벨에서 동시성을 처리할 수 없고 트랜잭션에 관련된 데이터베이스에서 동시성을 처리해야 한다.
즉, 락을 사용해야 한다.
하지만 데이터베이스에 직접적으로 락을 걸지 않고 어플리케이션 레벨에서 데이터베이스를 활용해 동시성을 처리할 방법이 있는데, 바로 낙관적 락
이다.
Spring Data JPA를 사용하고 있다면 낙관적 락을 매우 간편하게 사용할 수 있다.
@Entity
class Ticket {
...
@Version
private long version;
...
}
단순하게 @Version
어노테이션을 숫자 자료형 타입의 변수에 붙이면 낙관적 락이 적용된다.
낙관적 락을 적용하면 다음과 같이 동시에 여러 요청이 와도 동시성 문제가 발생하지 않는다.
@Test
void 낙관적_락을_사용하면_동시성_문제가_발생하지_않는다() {
// given
int tryCount = 100;
Long memberId = memberService.signup("member");
Long ticketId = ticketService.createTicket(3);
// when
List<CompletableFuture<Void>> futures = IntStream.range(1, tryCount)
.mapToObj(i -> CompletableFuture.runAsync(() -> {
ticketService.orderTicket(memberId, ticketId);
}).exceptionally(e -> null))
.toList();
futures.forEach(CompletableFuture::join);
// then
assertThat(memberTicketRepository.count()).isEqualTo(3);
}
하지만 낙관적 락을 사용하면 다음과 같은 문제가 발생한다.
낙관적 락의 구현 방법은 간단하게 다음과 같다.
version
컬럼이 포함된 엔티티를 조회한다.version
+ 1의 값을 업데이트 하는데, where 조건에 version
을 이전에 조회했던 version
의 조건으로 업데이트한다.OptimisticLockException
예외를 발생시킨다.따라서 충돌이 발생하면 예외가 발생하여 트랜잭션이 롤백 되므로, 재시도 처리가 필요하다.
따라서 위의 테스트 코드에서 tryCount
가 작아진다면, 테스트의 실패 가능성이 있다.
갑자기 뜬금없는 데드락 얘기가 나왔다.
낙관적 락은 DB의 락을 사용하지 않고, 어플리케이션 레벨에서 수행하는 동시성 처리 방법이다.
즉, DB에서 발생하는 데드락과는 전혀 연관성을 찾을 수 없다.
해당 이슈는 MySQL 8.0을 사용할 때 기준이다.
우선 티켓 예매를 할 때 DB에서 일어나는 일은 다음과 같다.
여기서 S락이 X락으로 변경되는 부분에서 데드락이 발생하게 된다.
S락과 X락은 서로 공존할 수 없고, 만약 같은 트랜잭션에서 S락이 걸린 row에 X락이 걸리면, 기존의 S락을 대체하여 X락이 걸리기 때문에 충돌이 발생하지 않는다.
하지만 서로 다른 트랜잭션에서 문제가 발생한다.
A 트랜잭션이 먼저 요청되고, 바로 다음 B 트랜잭션이 요청되는 상황이다.
A 트랜잭션에서 MemberTicket을 insert하고 Ticket의 FK에 대한 Ticket의 row에 S락이 걸린다.
B 트랜잭션에서 MemberTicket을 insert하고 Ticket의 FK에 대한 Ticket의 row에 S락이 걸린다.
A 트랜잭션에서 Ticket의 Amount를 update를 해야하지만, B 트랜잭션에 대한 S락이 있으므로, X락을 걸 수 없다.
마찬가지로, B 세션 또한 A 세션의 S락 때문에, X락을 걸 수 없다.
바로 이렇게 데드락이 발생하게 된다.
이 문제를 해결하려면 MemberTicket 테이블에서 Ticket에 대한 FK 제약 조건을 해제하거나, Ticket이 가진 amount 컬럼을 OneToOne 관계의 테이블로 분리해야 한다.
사실 이 문제는 낙관적 락을 사용하기 전에도 발생했다.
위의 문제들은 해결 방법이 있는 비교적 쉬운 문제이다.
하지만 진짜 문제는 성능에 관한 문제이다.
티켓팅은 동시에 수많은 요청이 한 번에 들어온다.
이때 낙관적 락을 적용하게 된다면 발생하는 문제는 다음과 같다.
1,000개의 요청이 들어오는 것을 가정하면 롤백 되는 횟수는 대략 500,500번이다.
이처럼 충돌이 잦은 티켓팅에서 낙관적 락을 적용하는 것은 전혀 효율적인 방법이 아니다.
그렇다면 남은 방법은 하나이다.
바로 데이터베이스에 직접 락을 거는 방법이다.
이때 락은 update를 할 때 적용되는 Exclusive 락을 적용한다.
비관적 락을 사용하는 것과 마찬가지로 사용법은 매우 간단하다.
그저 Repository
의 find 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE)
을 적용하면 끝난다.
public interface TicketRepository extends JpaRepository<Ticket, Long> {
...
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from Ticket t where t.id = :id")
Optional<Ticket> findByIdWithPessimistic(@Param("id") Long id);
...
}
이제 해당 메서드를 호출한 트랜잭션이 끝나기 전까지, 해당 메서드를 호출한 요청(트랜잭션)들은 대기하게 된다.
또한 위에서 발생했던 Ticket의 FK로 인한 데드락 또한 발생하지 않는다.
그렇다면 동시성 문제는 비관적 락을 적용하여 간단히 해결할 수 있는 걸까..?
안타깝게도 비관적 락을 사용하면 발생하는 사이드 이펙트가 있다.
비관적 락으로 인해 대기하는 커넥션으로 인해 커넥션 풀의 가용성이 낮아진다는 것이다.
커넥션 풀을 효과적으로 사용하려면, 커넥션을 빠르게 사용하고 반납해야 한다.
하지만 비관적 락으로 인해 대기하는 커넥션 때문에 커넥션 풀이 고갈되는 상황이 발생한다.
예를 들어 많은 예매 요청이 동시에 들어왔을 때 락을 획득한 요청 외 나머지 요청들은 락을 획득하기 위해 대기하게 된다.
이때 티켓 예매와 상관없는 단순 조회 요청이 들어오면 어떻게 될까?
락을 획득하기 위해 대기하고 있는 요청들과 경쟁하게 되므로, 락이 필요 없는 조회 요청임에도 불구하고 대기 상태에 포함되게 된다.
따라서 비관적 락을 적용하여 동시성 문제를 처리하는 방법은 효과적이지만 최대한 피해야 한다.
데이터베이스의 락을 사용하는 방법을 사용하여 동시성을 제어했지만, 그 대가로 성능을 지불했다.
하지만 성능은 서비스에서 매우 중요한 요소이기 때문에 대가를 치르기엔 손해가 막심하다.
따라서 데이터베이스의 락 말고 다른 방법을 사용해야 한다.
이때 티켓의 재고를 Java의 Atomic 자료형처럼 원자적으로 관리할 수 있다면 애초에 락을 걸 필요도 없을 것이다.
하지만 이러한 값은 메모리에 관리가 되므로 서버가 재시작되거나, 스케일 아웃 등의 상황에서 문제가 발생한다.
데이터베이스처럼 한 곳에 관리되고, 원자적인 자료형을 지원하며 메모리에 관리되어 속도도 빠른 특수한 무언가가 필요하다.
이것을 만족하는 것이 바로 Redis
이다.
레디스는 인메모리 기반의 Key-Value 데이터베이스로 NoSQL의 한 종류이다.
레디스의 특징으로는 싱글 스레드 기반의 이벤트 루프 모델을 사용하므로 동시성 문제를 피할 수 있다는 점이다.
따라서 티켓의 재고를 레디스에 관리하게 하여 동시성과 성능 두 마리 토끼를 잡을 수 있다.
여기서 설명하는 방법은 레디스를 사용해 분산락을 적용한 것이 아닌, 레디스의 원자적인 연산을 활용한 방법이다.
@Service
public class TicketService {
...
@Transactional
public void orderTicket(Long memberId, Long ticketId) {
Member member = findMember(memberId);
Ticket ticket = findTicket(ticketId);
long reserveAmount = redisClient.getAndIncrTicketAmount(ticketId);
if (reserveAmount > ticket.getAmount()) {
throw new IllegalArgumentException("매진된 티켓입니다.");
}
MemberTicket memberTicket = ticket.orderTicket(member);
memberTicketRepository.save(memberTicket);
}
...
}
데이터베이스에서 티켓의 재고를 수정하지 않고, 레디스를 통해 몇 개의 티켓이 예매되었는지 확인한다.
이제 더 이상 락을 위해 대기하는 커넥션은 없고, 레디스의 빠른 성능을 이용하여 고속으로 동시성을 제어할 수 있게 되었다.
하지만 지금까지 그렇듯이 단순히 레디스를 적용했다고 문제가 해결되지는 않는다.
레디스는 MySQL과 다른 별도의 인프라 자원이다.
즉, MySQL의 트랜잭션의 범위에 레디스가 포함되지 않는다.
만약 레디스의 값을 증가시켰는데, 이후 로직에서 예외가 발생한다면?
레디스의 값은 롤백할 수 없다.
기존 MySQL을 사용할 때는 테스트가 매우 쉬웠다.
왜냐하면 테스트 환경에서 H2를 사용하기 때문에 실제 MySQL을 띄울 필요 없이 테스트 코드를 실행할 수 있기 때문이다.
하지만 티켓 예매 로직이 레디스에 매우 강하게 의존하기 때문에 실제 테스트 또한 레디스에 의존적이게 된다.
티켓 예매는 핵심적인 비즈니스 로직이기 때문에, Mock 또는 Fake를 사용하는 단위 테스트로 만족할 수 없고, 인프라에 의존한 통합 테스트를 필수적으로 수행해야 한다.
로컬에서는 Docker를 사용해 레디스를 띄울 수 있어도, CI 환경에서는 처리하기가 까다로워진다.
레디스는 인메모리 기반의 저장소이기 때문에 메모리 자원에 의존한다.
하지만 메모리는 용량에 비해 가격이 비싸고, 보조기억장치에 비해 턱없이 작은 용량을 가지고 있다.
따라서 비용적인 측면에서 추가적 지출이 발생한다.
또한 관리해야 할 인프라 자원이 생긴다는 것은 취약점 또한 같이 생기는 것과 같다.
만약 레디스가 메모리 부족으로 죽는다면?
더 이상 티켓 예매 로직은 작동하지 않는다.
지금까지 많은 방법이 있었고, 우리는 최종적으로 비관적 락을 선택했다.
이유는 다음과 같다.
900개의 재고가 있는 티켓에 예매 요청과 조회를 하는 요청을 랜덤하게 보내봤을 때 벤치마크 결과이다.
대기 시간과 RPS를 보면 성능이 나쁜 수준이 아니었다.
3,000개의 티켓 예매를 했을 때
레디스는 약 7~8s
비관적 락은 약 12~14s
정도의 성능 차이가 있었다.
레디스의 문제점에서 말했듯, 레디스를 감당하기 위해 지불해야 하는 비용들이 너무 컸다.
또한 운영하는 서비스에서 사용자가 불편함을 느낄 정도의 성능 문제가 발생하지 않았다.
레디스를 사용하는 것은 그저 기술적 만족감을 채우기 위한 욕심에 불과했다.
결국 성능을 대가로 치르는 비관적 락을 사용해야 한다.
그렇다면 락을 사용하지 않고 동시성을 제어하는 방법은 정말 없을까?
사실 있다.
티켓 예매를 할 때 사전에 한 가지 과정을 추가하면 된다.
바로 티켓 예약 개념이다.
ReserveTicket 엔티티
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ReserveTicket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ticket_id")
private Ticket ticket;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member owner;
}
기존의 티켓 예매는 1개의 API로 처리됐지만, 이제는 2개의 API로 처리한다.
해당 API를 사용한 티켓 예매 프로세스는 다음과 같다.
@Service
class TicketingSerivce {
...
@Transactional
public Long reserveTicket(Long memberId, Long ticketId) {
Member member = findMember(memberId);
Ticket ticket = findTicket(ticketId);
ReserveTicket reserveTicket = new ReserveTicket(member, ticket);
reserveTicketRepository.save(reserveTicket);
return reserveTicket.getId();
}
@Transactional
public Long orderTicket(Long memberId, Long ticketId, Long reserveTicketId) {
Member member = findMember(memberId);
Ticket ticket = findTicket(ticketId);
long reserveSequence = reserveTicketRepository.getReserveSequence(ticketId, reserveTicketId);
if (ticket.getAmount() < reserveSequence) {
throw new IllegalArgumentException("매진된 티켓입니다.");
}
MemberTicket memberTicket = new MemberTicket(member, ticket);
memberTicketRepository.save(memberTicket);
}
...
}
간결한 표현을 위해 재고 검증 로직만 구현하였다.
여기서 핵심은 reserveTicketRepository.getReserveSequence()
메서드 이다.
해당 메서드는 다음과 같은 JPQL로 작성되어있다.
public interface ReserveTicketRepository extends JpaRepository<ReserveTicket, Long> {
...
@Query("select count(rt) from ReserveTicket rt where rt.ticket.id = :ticketId and rt.id <= :id")
long getReserveSequence(@Param("ticketId") Long ticketId, @Param("id") Long id);
...
}
해당 JPQL은 FK인 ticketId로 reserveTicket의 row 개수를 조회하는데, 여기서 and 조건으로 자신의 PK 이하인 row의 개수만 조회한다.
다음과 같이 5명이 티켓을 예약한 상황에서 티켓의 재고가 최대 3개인 상황을 가정해 보자.
1, 2, 3 Id를 가진 사용자는 성공이고, 나머지는 실패이다.
1으로 getReserveSequence()
메서드를 호출했을 때 1을 반환한다.
2는 2를 반환한다.
3 또한 3을 반환한다.
즉, 자신의 Id를 초과하는 row는 세지 않는다.
따라서 예약에서 동시성 문제가 발생해도, 예매에서는 "이론상" 동시성 문제가 발생하지 않는다.
테스트 코드는 다음과 같이 작성할 수 있다.
@Test
void 락을_사용하지_않아도_동시성_문제가_발생하지_않는다() {
// given
int tryCount = 100;
Long memberId = memberService.signup("member");
Long ticketId = ticketService.createTicket(50);
// when
List<CompletableFuture<Void>> futures = IntStream.range(1, tryCount)
.mapToObj(i -> CompletableFuture.runAsync(() -> {
Long reserveId = ticketService.reserveTicket(memberId, ticketId);
ticketService.orderTicket(memberId, ticketId, reserveId);
})
.exceptionally(e -> null))
.toList();
futures.forEach(CompletableFuture::join);
// then
assertThat(memberTicketRepository.count()).isEqualTo(50);
}
해당 테스트를 실행하면 동시성 문제가 발생하지 않고 성공하는 것을 볼 수 있다.
하지만 테스트를 @RepeatedTest
를 사용해서 여러 번 실행시켜 보면 실패하는 테스트를 볼 수 있다.
테스트를 100번 실행했을 때, 약 한두 개의 테스트에서 동시성 문제가 발생한다.
왜 이러한 현상이 발생할까?
다음과 같이 ReserveTicket
테이블에 DATETIME(6)
타입을 가지는 created_at
컬럼을 추가하고 created_at을 오름차순으로 조회해 보면 다음과 같은 결과가 조회된다.
Id의 값이 created_at
컬럼과 동기화가 되지 않는다는 것을 볼 수 있다.
즉, 26091 다음에 26092가 먼저 커밋되어야 하지만, 26094가 먼저 커밋됬다는 것을 유추할 수 있다.
이러한 현상으로 다음과 같은 문제가 발생한다.
26094가 커밋된 시점에 26092, 26093은 커밋 되지 않았다.
그리고 26094로 getReserveSequence()
메서드를 호출할 때 26092, 26093은 제외한 개수가 반환될 수 있다.
50개의 티켓 재고가 있고, 26094가 51번째 예약이라고 가정했을 때 정상적인 흐름이라면 51이 나와 실패해야 하지만, 위의 문제 때문에 49 또는 50이 반환되어 검증되지 않고 정상적인 요청으로 처리가 되는 것이다.
사실 이 문제는 테스트 상황에서만 일어나고, 실제 환경에서는 일어나지 않는 문제이다.
왜냐하면 API를 호출하는 과정에서 딜레이가 있기 때문이다.
따라서 메소드 사이에 10ms 정도의 딜레이만 주더라도 발생하지 않는다.
...
Long reserveId = ticketService.reserveTicket(memberId, ticketId);
Thread.sleep(10);
ticketService.orderTicket(memberId, ticketId, reserveId);
...
티켓을 예매하려면 API를 두 번 호출해야 하는 단점이 생겼지만, 락을 사용하는 로직이 하나도 없으므로 이제 다른 요청에 영향을 주지 않는다.
동시 요청에서 발생하는 동시성 문제를 해결하기 위해 여러 가지 방법을 고려하고 적용해 보았다.
간단하게 Java에서 제공하는 synchronized
부터 낙관적 락, 비관적 락, 레디스를 사용하여 동시성 제어를 시도했다.
그리고 성능이 좋은 방법을 택하려면, 그 대가로 많은 비용을 지불해야 했다.
따라서 지금 상황에서 레디스를 사용하는 방법보단, 비관적 락을 사용하는 것이 더 효율적이라고 판단했다.
하지만 비관적 락을 사용하지 않고 동시성을 제어할 수 있는 방법을 찾았고, 적용해 보았다.
문제를 해결할 방법들이 여러 가지 일 때, 그 방법으로 발생할 수 있는 사이드 이펙트를 항상 고려하고 유지보수성을 위한 고민도 함께 가져야 한다.
Thanks to 항상 같이 고민해 주고 여러 아이디어를 내어준 페스타고 팀원들.