
(각 branch에 여러 해결 방법으로 구분)
GitHub - ParkSeYun98/concurrency_example: spring concurrency issue solving example
spring concurrency issue solving example. Contribute to ParkSeYun98/concurrency_example development by creating an account on GitHub.
github.com
간단한 티케팅 사례를 가지고 나타날 수 있는 동시성 문제를 해결하는 방법들을 알아보려 한다.
싱글톤 구조에서 같은 인스턴스를 여러 곳에서 짧은 시간 내에 다룰 때 발생할 수 있을 것이다.
티케팅을 예로 들면 9시에 티케팅이 열리고, 몇초만에 매진되는 상황이 여기에 해당한다.
간단하게 티켓 개수를 감소하는 로직을 짠 후 아래 처럼 테스트 코드를 짜봤다.
0. 순차적 티케팅 / 동시 티케팅
@SpringBootTest
class TicketServiceTest {
@Autowired
private TicketRepository ticketRepository;
@Autowired
private TicketService ticketService;
private int memberCount = 120;
private int ticketAmount = 100;
@BeforeEach
public void init() {
ticketRepository.deleteAll(); // 기존 데이터를 삭제하고
ticketRepository.save(new Ticket(1L, ticketAmount));
}
@Test
@DisplayName("순차적 티케팅")
void Test1() {
// given
Ticket findTicket = ticketRepository.findById(1L)
.orElseThrow(() -> new IllegalArgumentException("해당 아이디에 맞는 티켓을 찾지 못했습니다."));
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (int i=0; i<memberCount; i++) {
try {
ticketService.ticketing(findTicket.getId());
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
}
}
System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);
Ticket updatedTicket = ticketRepository.findById(1L)
.orElseThrow(() -> new IllegalArgumentException("아이디에 해당하는 티켓이 존재하지 않습니다."));
// then
assertThat(updatedTicket.getAmount()).isEqualTo(0);
}
@Test
@DisplayName("멀티쓰레드 환경에서 동시 티켓팅 : Race Condition 발생")
void Test2() throws InterruptedException {
// 시작 시간 기록
long startTime = System.currentTimeMillis();
int threadCnt = 100;
// 스레드 풀 객체 : 32개의 스레드 관리
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 각 스레드의 작업이 종료될 때까지 기다리는 동기화 수행
CountDownLatch countDownLatch = new CountDownLatch(threadCnt);
List<Ticket> ticketList = ticketRepository.findAll();
for(int i=0; i<threadCnt; i++) {
// 동시 티케팅
executorService.submit(() -> {
try {
ticketService.ticketing(ticketList.get(0).getId());
} finally {
// 각 스레드의 작업 종료 명시
countDownLatch.countDown();
}
});
}
// 메인 스레드는 countDownLatch의 count가 0이 될 때 까지 기다린다.
countDownLatch.await();
Ticket ticket = ticketRepository.findById(ticketList.get(0).getId())
.orElseThrow(() -> new IllegalArgumentException("해당하는 티켓이 존재하지 않습니다."));
// 종료 시간 기록
long endTime = System.currentTimeMillis();
// 소요 시간 계산
long duration = endTime - startTime;
System.out.println("Test duration: " + duration + " ms");
assertThat(ticket.getAmount()).isEqualTo(0);
}
}
첫번째 테스트의 경우, 실제 상황과는 다를 수 있지만 순차적으로 번호표 뽑고 티케팅하는 예시다.
두번째가 중요한데, 여러 사람이 제한된 수량의 티켓을 동시에 티케팅하는 경우다.
테스트 결과,

총 100장을 130명이 달려들었는데 남은 티켓이 89장이다.
동시성에 인한 Race Condition 문제가 발생한 것이다.
Race Condition
다중 스레드 또는 다중 프로세스 환경에서 공유된 자원에 대한 동시적인 접근으로 인해 예기치 못한 동작이 발생하는 상황
특정 쓰레드가 Read 후 Write하는 과정에서 다른 쓰레드가 Read해버려 이전 결과 값에 이어서 연산이 되지 않는 현상이다.
이러한 동시성 문제를 해결하기 위해 어떤 방법들이 있을까?
1. 트랜잭션 격리 수준 Serializable로 높히기
@Service
@RequiredArgsConstructor
@Transactional(isolation = Isolation.SERIALIZABLE, readOnly = true)
public class TicketService {
private final TicketRepository ticketRepository;
@Transactional
public void ticketing(Long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("해당 티켓이 존재하지 않습니다."));
ticket.decreaseTicketAmount();
}
}

여전히 실패한다.
Serializable 격리 수준은 트랜잭션이 진행되는 동안 다른 트랜잭션에서 읽기 작업은 가능하지만 쓰기 작업을 수행하지 못하고, 따라서 다른 트랜잭션에서 수정할 수 없다.
최초에 동시에 읽기 작업을 여러 쓰레드가 실행하게 되고, 특정 쓰기 작업을 수행중인 쓰레드가 끝날 때 까지 다른 쓰레드가 기다리긴 하지만. 이미 읽어놓은 쓰레드가 옛 버전이므로 이 동시성 문제를 해결해주지 못하는 것으로 보인다.
2. Synchronized 활용하기
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TicketService {
private final TicketRepository ticketRepository;
@Transactional
public synchronized void ticketing(Long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("해당 티켓이 존재하지 않습니다."));
ticket.decreaseTicketAmount();
}
}

조금 나아졌지만 문제가 있다.
@Transactional이 붙어있으면 원본 객체를 감싸는 껍데기 객체, 프록시 객체를 생성한다.
메서드 호출 전 후로 transaction의 begin, commit이 수행되는데, 이 begin과 commit은 synchronized의 영향을 받지 않아 쓰레드 1이 commit 되기도 전에 쓰레드 2가 작업을 시작할 수 있다.
3. Pessimistic Lock
@Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(value = "select t from Ticket t where t.id = :ticketId")
Optional<Ticket> findByIdOnPessimisticLock(@Param("ticketId") Long ticketId);
}
동시성 문제 해결을 위해 DataBase 자체에 Lock을 걸어버릴 수도 있다.
DataBase에 Lock을 거는 것은 크게 비관적 락 (Pessimistic Lock), 낙관적 락 (Optimistic Lock)으로 나뉜다.
비관적 락은 트랜잭션이 시작될 때 Shared Lock을 걸고 시작하는 방식인데, write를 위해 프로세스는 Exclusive Lock을 얻어야 하는데 Shared Lock이 이를 막아버린다.
따라서 다른 트랜잭션에 의해 작업중이라면 작업중이지 않은 다른 트랜잭션은 업데이트할 수 없다.
수정하고 싶다면 나를 제외한 다른 트랜잭션들이 종료되어야 한다. (Commit)
이처럼 트랜잭션을 이용하여 충돌을 예방하는 방식이 비관적 락이다.

4. Optimistic Lock
@Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query(value = "select t from Ticket t where t.id = :ticketId")
Optional<Ticket> findByIdOnOptimisticLock(@Param("ticketId") Long ticketId);
}
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
private static final int MAX_RETRIES = 1000;
private static final int RETRY_DELAY_MS = 100;
@Pointcut("@annotation(Retry)")
public void retry() {
}
@Around("retry()")
public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable {
Exception exceptionHolder = null;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return joinPoint.proceed();
} catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
exceptionHolder = e;
Thread.sleep(RETRY_DELAY_MS);
}
}
throw exceptionHolder;
}
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
}
@Retry
@Transactional
public void ticketing(Long ticketId) {
Ticket ticket = ticketRepository.findByIdOnOptimisticLock(ticketId)
.orElseThrow(() -> new IllegalArgumentException("해당 티켓이 존재하지 않습니다."));
ticket.decreaseTicketAmount();
}
낙관적 락은 수정할 때 내가 먼저 이 값을 수정했다고 명시를 해서 다른 곳에서 접근 시 동일 조건으로 수정하지 못하도록 방지하는 방식이다.
만약 같은 row에 대하여 각기 다른 2개의 수정 요청이 있다면, 그 중 조금더 빨랐던 하나는 수정에 성공하고, 그 덕에 미리 엔티티에 넣어두었던 version 컬럼이 변경되어 version이 달라져 조금 늦은 곳의 수정 요청은 차단된다.
따라서, 아무 조치 없이 그냥 optimistic Lock을 사용하게 되면 여러 요청들이 실패할 것이고, 그 때문에 요청 실패를 고려한 추가적인 코드 로직이 필요하다.
그것을 위에서는 수정 요청 Retry 로직을 작성하여 적용한 것이다.
대신 그 덕분에 Pessimistic Lock에 비해 소모된 시간이 두배 이상 걸린 모습이다.
이처럼 낙관적 락은 version과 같은 구분용 컬럼을 활용하여 충돌을 예방한 것이다.

5. Redis - Lettuce
@Component
public class RedisRepository {
private final RedisTemplate<String, String> redisTemplate;
public RedisRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key) {
Boolean result = redisTemplate
.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofSeconds(5));
System.out.println("Lock acquired: " + result);
return result;
}
public Boolean unlock(Long key) {
Boolean result = redisTemplate.delete(key.toString());
System.out.println("Lock released: " + result);
return result;
}
}
동시성 문제를 해결하기 위해 NoSQL인 Redis를 활용해볼 수 있다.
Redis의 여러 클라이언트 라이브러리를 활용할 수 있는데, 그 중 Redis의 기본 클라이언트 라이브러리인 Lettuce를 활용하는 방식이다.
Lock을 얻기 전까지 sleelp을 통해 대기한 후, Lock을 얻을 수 있을 환경일 때 획득한다.
따라서 대기 시간도 어느정도 필요하고, redis에 많은 부하가 올 수 있다.
이 때 Lock은 라이브러리에서 제공하지 않고 직접 어느정도 구현해야 한다.

6. Redis - Redisson
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TicketService {
private final TicketRepository ticketRepository;
private final RedissonClient redissonClient;
@Transactional
public void ticketing(Long ticketId) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new IllegalArgumentException("해당 티켓이 존재하지 않습니다."));
ticket.decreaseTicketAmount();
}
@Transactional
public void ticketingWithRedisson(Long ticketId) throws InterruptedException {
RLock rLock = redissonClient.getLock(ticketId.toString());
try {
boolean flag = rLock.tryLock(10, 1, TimeUnit.SECONDS);
if(!flag) {
System.out.println("Lock 실패");
return;
}
ticketing(ticketId);
}
finally {
rLock.unlock();
}
}
}
라이브러리 차원에서 Lock을 제공해 주기 때문에 이를 이용하면 된다.

성공률이 다소 낮은데, 아직 공부중이고 잘 모르겠다 ㅎㅎ..
참조
[Spring] 동시성 이슈를 고려한 자바 프로젝트 설계
개요 트래픽이 많아질수록 동시성 이슈를 철저하게 고려해야 한다. 자바 스프링에서 발생할 수 있는 동시성 이슈를 체크하고, 다양한 해결방법에 대해서 공부해보자. 동시성 문제는 지역변수와
eckrin.tistory.com
[database] 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)
안녕하세요. 오늘은 낙관적 락과 비관적 락의 개념에 대해서 알아보는 시간을 가져보도록 하겠습니다.DB 충돌 상황을 개선할 수 있는 방법database에 접근해서 데이터를 수정할 때 동시에 수정이
sabarada.tistory.com
좋아요 기능을 통해 살펴보는 동시성 이슈 (synchronized)
안녕하세요, 이번 포스팅에서는 동시성(Concurrency)에 대해 살펴보겠습니다. (예제 코드는 깃허브에서 확인하실 수 있습니다.) 동시성(Concurrency) 개념 네이버 사전에 검색해본 동시성은 다음과 같
zzang9ha.tistory.com
[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락)
0. 들어가기 전 이전에는 DB 단의 동시성 처리 방법인 Lock에 대해서 알아봤습니다. https://ksh-coding.tistory.com/121 [DB] DB Lock이란? (feat. Lock 종류, 블로킹, 데드락) 0. 락(Lock)이란? 여러 커넥션에서 동시
ksh-coding.tistory.com
선착순 티켓 예매의 동시성 문제: 잠금으로 안전하게 처리하기
…
tecoble.techcourse.co.kr
'Backend > Spring' 카테고리의 다른 글
[스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술] 1. 웹 어플리케이션 이해 (0) | 2024.01.24 |
---|---|
[스프링 핵심 원리 - 기본편] 9. 빈 스코프 (0) | 2024.01.02 |
[스프링 핵심 원리 - 기본편] 8. 빈 생명주기 콜백 (0) | 2024.01.02 |
[스프링 핵심 원리 - 기본편] 7. 의존관계 자동 주입 (1) | 2024.01.02 |
[스프링 핵심 원리 - 기본편] 6. 컴포넌트 스캔 (0) | 2024.01.02 |
개발자가 되고 싶어요.