개발 기록

> [Spring] Multi Thread - 선착순 티켓 예매로 알아보는 동시성 이슈 1. 동시성 이슈 발견, Synchronized 로 해결 본문

Spring

> [Spring] Multi Thread - 선착순 티켓 예매로 알아보는 동시성 이슈 1. 동시성 이슈 발견, Synchronized 로 해결

1z 2023. 12. 15. 20:26

 

0. 동시성 문제 - Reace Condition  

여러 개의 프로세스(혹은 스레드들이)가 공유된 자원에(객체,필드,데이터 등) 동시 접근 및 수정할 때 발생하는 문제이다.

예를 들면 여러사람이 계산기를 함께 나눠 쓰는 것과 같다.
1. 사람 A가 계산기로 작업중 결과를 메모리을 저장한 뒤 잠시 자리를 비움
2. 사람 B가 계산기를 만져 저장한 값을 다른 값으로 변경 해버림
3. 사람 A가 작업을 마저 처리할려고 했을 때,  사람 A는  본인이 저장한 값이 아닌 엉터리 값을 이용하게 된다.

즉  둘 이상의 입력 또는 조작의 타이밍이나 순서 등이 결과값에 영향을 준 상태(=Race Condition) 가 발생한다.

 


1.  동시성 문제 (데이터 정합성) 해결 예제  

 

선착순으로 10장의 티켓을 30명의 사용자가 예매 하기

= 초기 자원값은 10 이고, 이를 30개의 쓰레드가 각각 개별로 1씩 감소하여 0으로 만드는 테스트를 작성하고, 총 자원 결과값과 작업 성공/실패 한 스레드 수를 알아보자 

 

★ 아래 4가지 방법을 이용하여 동시성 문제를 해결해본다.

1. Application Level 에서 해결 : Synchronzied 키워드 사용 (원문)

2. Database Level 에서 해결 : DataBase Lock

- Pessimistic Lock (비관적 락)

- Optimistic Lock (낙관적 락)

- Named Lock (네임드 락)

 

(1) 문제 파악

@Test
@DisplayName("동시_예매_테스트")
void 티켓_동시_예매_테스트() throws InterruptedException {
    // given
    int userCount = 30;
    int ticketAmount = 10;
    Ticket ticket = ticketingService.add(ticketAmount);

    ExecutorService executorService = Executors.newFixedThreadPool(userCount);
    CountDownLatch latch = new CountDownLatch(userCount);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    // when
    for (int i = 0; i < userCount; i++) {
        executorService.submit(() -> {
            try {
                ticketingService.ticketing(ticket.getId());
                successCount.incrementAndGet();
            } catch (Exception e) {
                System.out.println(e.getMessage());
                failCount.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }

    // 스레드의 모든 작업이 완료된 후 진행되야 하는 쪽에 await() 메서드를 붙이면
    // 그러면 현재 실행중인 스레드는 더이상 진행하지않고 CountDownLatch의 count가 0이 될 때까지 기다린다.
    latch.await();

    // then
    System.out.println("예매 성공 개수 = " + successCount);
    System.out.println("예매 실패 개수 = " + failCount);

    // then
    long reservationCount = ticketReservationRepository.countByTicketId(ticket.getId());
    System.out.println("### synchronized 처리 이후 수량 ###" + (ticketAmount - reservationCount));
    assertThat(ticketAmount - reservationCount).isZero();
}

 

 

테스트 결과 SQL Error 가 발생하면서 10개의 티켓 중 6개만 예매에 성공했다. 

 

1. Error 확인

' SQL Errod 1213  Deadlock found when trying to get lock; try restarting transaction '

Deadlock 즉, 교착 상태가 발생했다는 error 이다.

 

☞ 교착 상태 (공식문서 참고)

A deadlock is a situation where different transactions are unable to proceed because each holds a lock that the other needs. Because both transactions are waiting for a resource to become available, neither ever release the locks it holds.

 

: 서로 다른 트랜잭션이 서로 필요한 잠금을 보유하고 있기 때문에 서로 다른 트랜잭션을 진행할 수 없는 상황이다. 두 트랜잭션 모두 리소스가 사용 가능해질 때까지 대기하고 있으므로 보유하고 있는 잠금을 해제하지도 않기 때문에 무한 대기 상태에 빠진다.

 

2. Error 원인

FOREIGN KEY 테이블에 제약 조건이 정의된 경우,  insert, update, delete 할 때 해당 제약 조건을 위반하는지 확인하기 위해 관련된 레코드들에 공유 잠금(S lock)을 설정한다. 여러 트랜잭션이 동시에 공유 잠금을 얻을 수 있지만 다른 트랜잭션은 공유잠금이 설정된 해당 데이터에 대해 배타적 잠금(X lock)을 얻지 못한다.다른 트랜잭션들은 작업이 끝날 때까지 대기해야한다.

 

☞  공유 잠금

: 다른 트랜잭션의 데이터 변경을 막고 데이터 일관성을 유지하는 잠금 유형이다.

☞  배타적 잠금

: 한 번에 하나의 트랜잭션만이 특정 데이터에 대한 쓰기 작업을 수행할 수 있도록 하는 잠금 유형이다. 

 

1. 트랜잭션 1이 id=1인 데이터에 배타적 잠금 요청, 트랜잭션 2의 해당 데이터 공유 잠금으로 대기

2. 트랜잭션 2가 id=1인 데이터에 배타적 잠금 요청, 트랜잭션 1의 해당 데이터 공유 잠금으로 대기

3. 무한 대기상태(=교착상태) 에 빠짐

 

3. Error 해결

교착 상태의 문제를 해결하기 위해 Ticket과 Reservation간의 외래 키 관계를 제거하고 다시 시도 했지만 실패했다. 

3번 티켓 2장, 4번 티켓 2장이 예매되었다. 즉, 하나의 티켓이 여러 사용자에게 발급되는 문제가 발생했다.

 

멀티 쓰레드 환경에서 스레드 간의 충돌이 일어나,  한 스레드가 데이터를 읽고 이를 갱신하기 전, 다른 스레드에서 데이터를 읽기 때문에 생긴 문제로 테스트를 동작시킬 때마다 output 이 매번 다르다. 

 

2 .   Synchronized 키워드로 임계영역을 지정하여 해결   

 

(1) Synchronized 적용

Java 진영에서 제공하는 synchronized 키워드를 이용하여 멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는

임계영역을 지정할 수 있다. 다음과 같이 메소드 선언에 synchronized 키워드를 붙이면 된다.

 

- 동기화 메소드는 메소드 전체 내용이 임계 영역 이므로 스레드가 동기화메소드를 실행하는 즉시 객체에는 잠금이 일어나고, 스레드가 동기화 메소드를 실행 종료하면 잠금이 풀린다.

 

 

(2) Synchronized TEST : @Transactional 로 인한 실패 

 

@Transactional 적용으로 순차처리가 아닌 병렬처리가 되어, 예매 개수가 맞지 않았다. 

 

1. @Transactional 과 Synchronized 동시적용으로 인한 문제 및 해결 

- 원인 : @Transactional 을 적용하면 프록시 객체가 요청을 먼저받아서 트랜잭션을 처리하는데, 이 과정에서 synchronized의 영향을 받지 않기 때문에 아래 그림과 같이 T1 스레드에서 commit 되기 전 T2 스레드가 메서드를 시작한 것이다.

 

- 해결 : @Transactional 을 제거하고  saveAndFlush () 를 이용하여 수동으로 바로 DB 에 적용 해줬더니 잘 되었다.

ticketRepository.saveAndFlush(ticket);

 

테스트 결과 출력

 

Ticket DB
ticket_reservation_history DB

 

 

(2) Synchronized 단점 

 

한 스레드가 메서드 작업을 완료할 때까지 다른 스레드들은 대기해야 한다. 필요한 부분만 Lock을 거는 다른 기법들에 비해 성능상 오버헤드가 심하므로 프로그램의 성능이 저하될 수 있다.

하나의 프로세스 단위에서만 동시성을 보장한다. 서버가 여러 대인 분산 환경에서는 데이터의 정합성을 보장할 수 없다. 여러 대의 서버를 활용하면 여러 개의 인스턴스가 존재하는 것과 동일하기 때문이다.

 

 

 

참고

https://tecoble.techcourse.co.kr/post/2023-08-16-concurrency-managing/