[MetalWallet] 동시성 이슈와 문제 해결(1) - 비관적 락 적용
멀티스레드 환경에서, 여러 사용자가 동일한 좌석에 동시에 티켓팅을 할 경우 여러 원인으로 동시성 이슈가 발생한다. 따라서 진행하고 있는 티켓팅 서비스 프로젝트의 동시성 이슈에 대해 원인을 분석하고, 문제를 해결할 예정이다.
📌동시성
동시성이란 동시에 실행되는 것처럼 보이는 것을 말한다.
티켓팅하는 상황에서 여러 사용자가 동일한 좌석을 예매할 때, 멀티스레드로 동작하는데 이에 대해 경쟁상태가 발생한다. 이러한 이유는 원자성(MutualExclusion)을 보장하지 못했기 때문이다.
멀티스레드
Spring Framework는 기본적으로 멀티스레드 기반 웹 서버(Tomcat) 위에서 실행된다.
각 HTTP 요청은 개별 스레드에서 처리된다.
ex) 사용자가 동시에 여러 요청을 보내면 웹 서버는 각 요청을 다른 스레드로 처리하도록 ThreadPool을 사용한다.
원자성
공유 자원에 대한 작업 단위가 더이상 쪼갤 수 없는 하나의 연산인 것 처럼 동작하는 것을 말한다.
티켓팅 서비스에서 원자성은
- 남은 좌석 수가 정상적으로 반영된다 ➡️ 티켓팅을 성공한 후 남은 좌석 수 정보가 담겨있는 Section을 통해 남은 좌석 수를 업데이트 했을 때 카운팅이 정상적으로 반영되는 것을 말한다.
- 하나의 티켓 예매 요청이 처리된다 ➡️ 여러 명의 사용자가 동시에 티켓을 예매하려고 할 때, 중복 예매나 티켓 상태 불일치를 방지
📌동시성 이슈 확인
이를 확인해보자
🧑🤝🧑 🪑 Scenario 1. 100명의 사용자가 1개의 좌석을 동시에 예매한다.
먼저, 동시성 이슈가 발생하는지 확인하는 테스트 코드를 작성할 것이다.
ExecutorService
, CountDownLatch
를 사용해서 멀티 스레드 환경의 동시성 이슈가 발생할 수 있도록 환경을 조성한다.
ExecutorService
여러 Task를 ThreadPool을 이용해 관리한다. 그 중에서도 고정된 크기의 ThreadPool을 사용했다.
CountDownLatch
지정된 횟수의 요청이 전부 다 수행될 때까지 기다리는 역할
TicketServiceConcurrencyTest.java
접기/펼치기
package com.kb.wallet.ticket.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.kb.wallet.global.config.AppConfig;
import com.kb.wallet.member.domain.Member;
import com.kb.wallet.member.repository.MemberRepository;
import com.kb.wallet.musical.domain.Musical;
import com.kb.wallet.musical.repository.MusicalRepository;
import com.kb.wallet.seat.constant.Grade;
import com.kb.wallet.seat.domain.Seat;
import com.kb.wallet.seat.domain.Section;
import com.kb.wallet.seat.repository.SeatRepository;
import com.kb.wallet.seat.repository.SectionRepository;
import com.kb.wallet.ticket.domain.*;
import com.kb.wallet.ticket.dto.request.TicketRequest;
import com.kb.wallet.ticket.repository.*;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import javax.transaction.Transactional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = AppConfig.class)
@WebAppConfiguration
@ActiveProfiles("test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS) //의존성 주입된 인스턴스 변수를 static없이 사용 가능
class TicketServiceConcurrencyTest {
@Autowired
private TicketService ticketService;
@Autowired
private MemberRepository memberRepository;
@Autowired
private MusicalRepository musicalRepository;
@Autowired
private ScheduleRepository scheduleRepository;
@Autowired
private SectionRepository sectionRepository;
@Autowired
private SeatRepository seatRepository;
@Autowired
private TicketRepository ticketRepository;
private Long testSeatId;
private Long testSectionId;
private final int testMemberCnt = 100;
private final int testAvailableSeats = 100;
Section section;
Schedule schedule;
Musical musical;
@BeforeEach
void setUpBeforeEach() {
ticketRepository.deleteAll();
seatRepository.deleteAll();
sectionRepository.deleteAll();
scheduleRepository.deleteAll();
musicalRepository.deleteAll();
musical = musicalRepository.save(new Musical(
1L,
"킹키부츠",
1,
"서울",
"서울 아트센터",
LocalDate.parse("2024-10-01"),
LocalDate.parse("2024-10-16"),
150,
null,
null,
null,
null
));
schedule = scheduleRepository.save(new Schedule(
null,
LocalDate.parse("2024-10-17"),
musical,
LocalTime.parse("10:00:00"),
LocalTime.parse("12:30:00")
));
section = sectionRepository.save(new Section(
1L,
musical,
schedule,
Grade.R,
19000,
testAvailableSeats
));
testSectionId = section.getId();
}
@BeforeAll
void setUp() {
insertTestData();
}
@AfterEach
void tearDown() {
ticketRepository.deleteAll();
// cleanUpAll();
}
@Transactional
void insertTestData() {
for (int i = 1; i <= testMemberCnt; i++) {
memberRepository.save(new Member(
"test" + i + "@gmail.com",
"password" + i,
"test" + i,
"0100000000" + i,
String.format("%06d", i)
));
}
}
@Test
@DisplayName("100명이 동시에 티켓 예매 시 단 1명만 성공")
void testBookTicket_multipleUsersSingleSeatSuccess() throws InterruptedException {
//given
Seat seat1 = seatRepository.save(new Seat(
1L,
1,
section,
schedule,
true
));
testSeatId = seat1.getId();
int threadCount = testMemberCnt;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
List<Future<BookResult>> results = new ArrayList<>();
//when
for (int i = 1; i <= threadCount; i++) {
String email = "test" + i + "@gmail.com";
TicketRequest request = new TicketRequest();
request.setDeviceId("deviceID" + i);
request.setSeatId(Collections.singletonList(testSeatId));
results.add(executor.submit(() -> {
try {
ticketService.bookTicket(email, request);
return new BookResult(email, true, "success");
} catch (Exception e) {
return new BookResult(email, false, e.getMessage());
} finally {
latch.countDown();
}
}));
}
latch.await();
executor.shutdown();
int successCount = 0;
for(Future<BookResult> future: results) {
try {
BookResult result = future.get();
if(result.isSuccess())
successCount++;
else
System.err.println(result.getEmail() + ": "+ "Exception: " + result.getMessage());
} catch (ExecutionException e) {
System.err.println("ExecutionException: " + e.getMessage());
}
}
System.out.println("successCount: " + successCount);
int availableSeats = sectionRepository.findById(testSectionId).get().getAvailableSeats();
//then
assertEquals(99, availableSeats, "가용 가능한 좌석 수는 99이여야 합니다.");
assertEquals(1, ticketRepository.count(), "티켓 테이블에 단 1건의 데이터만 존재해야 합니다.");
}
}
Test Result
Expected: 1
Actual: 1~5
📌동시성 문제 해결
시도해본것
syncronized
, unique key
, 트랜잭션 격리수준 변경, 비관적 락
☑️1) synchronized
여러 스레드가 동시에 같은 객체의 특정 부분을 접근하는 것을 제한하여, 동시성 문제를 방지하는 역할을 한다. 이 키워드를 사용하면 하나의 스레드가 진행될 때, 해당 블록의 코드가 실행되는 동안 다른 스레드는 접근할 수 없다.
실패한 이유
- 티켓 서비스는
@Transactional
어노테이션에 의해서 트랜잭션 단위로 동작하는데,synchronized
는 스레드 접근만 제어하며, JPA의 트랜잭션 경계를 인지하지 못한다. - 애플리케이션 수준인 JVM내에서만 스레드의 접근을 제어한다. 데이터베이스 수준에서 이루어지는 다중 트랜잭션 제어를 해결할 수 없다.
- 따라서 애플리케이션이 여러 프로세스로 배포되거나, 다중 서버 환경에서 동작하는 경우에는 동시성 문제를 해결할 수 없다.
다음으로 단일 서버 환경에서 일어날 수 있는 동시성을 제어하기 위해 애플리케이션 수준에서 데이터베이스 락 중에서 비관적 락을 적용하여 해결해봤다.
낙관적 락을 적용하지 않은 이유는 재시도가 많이 발생하게 되면 티켓 서비스 특성상 동시 접근이 많고, 수정이 잦게 일어날 것으로 예상해서 성능이 좋지 않을 거라고 생각했다.
☑️2.1) 비관적 락
비관적 락 조회 시점에 락을 걸어 다른 트랜잭션이 동일 데이터를 수정하지 못하게 하는 방식
순차적으로 처리한다.
Write Lock(FOR UPDATE) 요청1 트랜잭션이 락이 걸려있는 동안 다른 요청2 트랜잭션은 같은 데이터를FOR UPDATE
로 조회하려고 하면 대기 상태에 들어감트랜잭션이 종료되면서 락이 해제된다.
SeatRepository.java
접기/펼치기
package com.kb.wallet.seat.repository;
import com.kb.wallet.seat.domain.Seat;
import java.util.List;
import java.util.Optional;
import javax.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
@Query("SELECT s FROM Seat s JOIN FETCH s.schedule sch JOIN FETCH sch.musical JOIN FETCH s.section sec WHERE sch.id = :scheduleId AND s.isAvailable = true")
List<Seat> findAvailableSeatsByScheduleId(@Param("scheduleId") Long scheduleId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :seatId")
Optional<Seat> findByIdWithLock(@Param("seatId") Long seatId);
}
이론적으로는 동시성 문제 해결이 가능하다!
- Seat 조회 시 비관적 락이 걸리므로 동일한 좌석에 대한 트랜잭션이 순차적으로 처리된다.
seatService.getSeatByIdWithLock(seatId)
에서FOR UPDATE
를 통해 트랜잭션 1이 완료되기 전에 트랜잭션 2는 동일한 seatId에 접근하지 못하게 된다.- 따라서
seat.checkSeatAvailability()
와 이후의 로직이 직렬화되며, 동시성 문제가 없어야 한다.
- 비관적 락이 트랜잭션 단위로 유지되기 때문에, Seat를 통해 접근하는 Section의 변경도 트랜잭션 범위 내에서 안전하게 이루어져야 한다.
- 결과적으로,
seatService.updateSeatToBooked(seat)
에서 Section의 남은 좌석 수 감소 로직도 안전하게 처리될 수 있다.
- 결과적으로,
그렇지만 되지 않고 있다..
로그를 확인해보니 트랜잭션 단위에 따라 순차적으로 실행되지 않고 있다.
Ticket 발급이후 Seat 상태 업데이트 전에 Lock이 해제된다.
트러블 슈팅
ticketRepository.save(ticket)
을 호출하면 JPA는 새로운 엔티티라고 판단하여 즉시INSERT
쿼리를 실행한다.- 반면
seat.updateSeatAvailability()
는 단순히 엔티티의 상태를 변경하는 것이므로, JPA는 이를 영속성 컨텍스트에 반영만 하고, 실제UPDATE
쿼리는 트랙잭션이 커밋될 때 실행된다. (=@Transactional
이 끝나는 시점)
해결 방법
- 좌석상태를 변경한 이후
flush()
를 명시적으로 호출하여UPDATE
를 먼저 실행하도록 강제하였다. saveTicket()
메서드를 분리하여ticketRepository.save(ticket)
를 맨 마지막에 배치했다.
☑️2.2) flush()
영속성 컨텍스트의 변경 사항을 데이터베이스에 반영하지만, 트랜잭션은 여전히 종료되지 않는다.
Test Result
Expected: 1
Actual: 1
성공했다❗
남은 좌석 수량과 동일한 좌석에 대한 동시성 이슈를 해결했다.
그러나 이 방법은 단점이 있다.
단점
비관적 락으로 인해 대기하는 커넥션으로 인해 커넥션 풀의 가용성이 낮아진다. 커넥션 풀을 효과적으로 사용하려면, 커넥션을 빠르게 사용하고 반납해야 한다. 하지만 비관적 락으로 인해 대기하는 커넥션 때문에 커넥션 풀이 고갈되는 상황이 발생한다.
또한 Pessimistic Lock의 작동 원리를 이해하면서 여러 명이 여러 좌석을 예매할 때 좌석 잔여 수가 정상적인지 의문이 들었다. 그래서 다음 장에서는 재고 수량에 대한 동시성 이슈와 성능에 최적화된 또다른 방법이 있을지 비교해볼 것이다.
헷갈리는 것
해야 할 것
- Section 비관락 적용 or 다중 서버 환경의 분산락 적용
- 락 동작방식 그림으로 그려볼 것
- 성능 결과값 비교
새로 알게된 것