결제서비스에 장바구니 담기 기능 추가하기

안녕하세요! 세미나허브 토이프로젝트를 진행하고 있는 PassionFruit200 입니다.

두번째 글에 연속하여 세번쨰 글로 찾아뵙게 되었습니다!

 

첫번쨰 글에서는, 세미나 서비스 결제 기능의 기획을 정의하고, Spring Framework를 활용하여 DB 설계, 코딩을 진행하였습니다.

두번쨰 글에서는, 결제서비스에서 동시성 처리와 데이터 정합성을 고려하기 위하여 트랜잭션의 격리레벨과 트랜잭션의 동작방식 그리고 동시성 처리를 위한 배타락을 알아보고 구현해보았습니다. 

지금까지의 결제서비스에서는 하나의 세미나를 구입하는 경우만 다루었었습니다.

 

1편   [Spring][Seminar-hub] 1편. 세미나허브에 결제서비스 개발해보기  : https://passionfruit200.tistory.com/1031

2편 [Spring][Seminar-hub] 2편. 세미나 허브 결제서비스에 1000명이 동시에 세미나 신청한다면? ( 트랜잭션 격리레벨, 배타락, 언두로그 ) : https://passionfruit200.tistory.com/1055

3편 [Spring][Seminar-hub] 3편. 장바구니 결제 기능에서 발생하는 DeadLock 170.27% 개선시키기 ( 원형대기예방 )   https://passionfruit200.tistory.com/1056 

 

 

이번 세번째 글에서는 하나의 세미나를 구매하는 것이 아닌, 장바구니 기능을 만들어놓고 발생할 수 있는 문제점을 파악하고 그 문제사항을 해결하기 위해 알아본 점들을 정리해보았습니다.

누구나 이해할 수 있도록 쉽게 작성하는데 주안점을 두고 작성했으니, 도움이 됐으면 좋겠습니다.

이번 개발을 진행하면서

  • 결제서비스 장바구니 기능에서 데드락 발생을 피할 수 있어, 불필요한 데드락을 피하여 성능을 올리고, 매출상향에 기대가 됩니다.
  • 결제서비스에서의 교착상태(DeadLock) 문제점을 정의하고, 이해하기 쉽게 표현할 수 있습니다.
  • 운영체제 기준에서의 교착상태를 이해하고, 프로세스와 자원의 관계를 트랜잭션과 레코드로 치환하여 이해할 수 있습니다. 
  • 운영체제 기준에서의 교착상태 해결방법인, 교착상태 예방, 교착상태 회피, 교착상태 검출, 교착상태 회복에 대해 이해하게 되었습니다. 
  • 교착상태 검출 부분에서 MySQL 8.0의 타임아웃 설정기능과 자원할당그래프를 활용한 교착상태 검출 기능의 ON/OFF 방식에 대해서 알게되었습니다.
  • 완전탐색(재귀함수)를 활용하여 모든 경우에 대한 테스트케이스 세팅을 원활하게 진행할 수 있었습니다.
  • 1000쓰레드를 활용하여 동시성 테스트를 진행합니다.

시작

이전의 두번째 글에서는 단순하게 결제서비스를 하나만 구매할 수 있었습니다. 

이번에는 여러개의 상품을 장바구니에 담고 한번에 구매하도록 처리하여야했는데요, 이떄 Transaction의 ACID에서 Atomic의 특성인 "All or Nothing" 이 중요했습니다. 그렇게 ACID를 지키면서 장바구니 기능을 개발하며 발생했던 데드락 문제를 어떻게 해결했는지에 대해 작성해보려고 합니다!

문제상황 발생!

기존의 결제서비스에 장바구니 담기 기능을 사용하는것은 크게 어렵지 않아, 단순히 List 형태로 상품들을 받은뒤 결제를 진행하는 방식으로 진행하였습니다.

곧바로, 코딩을 완료하고 테스트케이스까지 모두 작업한뒤 모든 작업을 끝냈습니다.

하지만, 테스트의 횟수를 더 증가시키면서 아래와 같은 오류가 콘솔에 발생하는 것을 알 수 있었습니다.

위의 오류 중 가장 핵심 부분은 아래 오류코드입니다.

Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: 
Deadlock found when trying to get lock; try restarting transaction

org.springframework.dao.CannotAcquireLockException: 
JDBC exception executing 
SQL [select 
s1_0.seminar_no,
s1_0.del_dt,
s1_0.inst_dt,s1_0.seminar_explanation,
s1_0.seminar_max_participants,
s1_0.seminar_name,s1_0.seminar_participants_cnt,
s1_0.seminar_price,s1_0.updt_dt 
from seminar s1_0 
where s1_0.seminar_name=? and s1_0.del_dt is null for update]; 
SQL [n/a]

 

가장 눈에 띄는 오류문구가 있습니다.

" Deadlock found when trying to get lock; try restarting transaction", "CannotAcquireLockException",

즉 저희가 이전글에서 데이터 정합성을 지키고자 사용했던 배타락이 락을 얻지 못하고 데드락이 발생했다는 오류메시지가 발생합니다. 

 

오류메시지 그대로 데드락이 발생하였다는 이야기인데, 이런 데드락이 무엇인지 그리고 운영체제 개념에서의 교착상태 이론을 토대로 해결방안을 알아보고, 코드로써 구현해보았습니다.

 

또한 추가로, 실제로 MySQL에서 제공하는 데드락 상태를 확인해보면, 데드락이 존재하고 있습니다.

mysql > SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b
ON b.trx_id = w.blocking_engine_transaction_id
INNER JOIN information_schema.innodb_trx r
ON r.trx_id = w.requesting_engine_transaction_id;

  • SELECT 하면서 발생하는 것을 확인할 수 있습니다.
  • 빨간원 1번을 보면 waiting_trx_id : 43746 으로 락을 얻기 위해 트랜잭션이 기다리고 있습니다. 
  • 2번을 보면, blocking_trx_id : 43739 의 트랜잭션 ID를 가지고 있는 락을 기다리고 있습니다.
  • 3번을 보면, waiting_trx_id : 43739 으로 이 트랜잭션이 끝나야만 다른 트랜잭션이 연이어 락을 얻습니다.

쓰레드 500개로 1000번의 호출 시 평균적으로 몇번의 데드락이 발견할까?

쓰레드로 테스트를 진행하기전에 테스트케이스를 만들필요가 있습니다.

이떄, 4개의 세미나가 존재한다고 생각하고, Permutation을 통해 만들고자하는 모든 경우(4! + 3! + 2! + 1!)를 만들었습니다.

그 이후에 각각의 스레드는 만들어진 경우를 Random으로 할당되어 실행합니다.

 

정확히 500개의 쓰레드로 1000번의 호출을 해보았습니다.

[ com/seminarhub/service/Member_SeminarServiceTests.java ]

@SpringBootTest
public class Member_SeminarServiceTests {

    @Autowired
    private SeminarService seminarService;
    @Autowired
    private MemberService memberService;
    @Autowired
    private Member_SeminarRepository member_seminarRepository;
    @Autowired
    private SeminarQuerydslRepository seminarQuerydslRepository;
    @Autowired
    private Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;
    @Autowired
    private Member_SeminarService memberSeminarService;
    
	Long[] seminar_no_arr = new Long[] { 11L, 12L, 13L, 14L};
    boolean[] visited = new boolean[seminar_no_arr.length];
    public List<String> memberSeminarRegisterRequestDTOList = new ArrayList<>();
    int[] answer;
    public void seminar_no_Permutation(int level, int size, int maxSize){
        if(level == maxSize){
            StringBuilder sb = new StringBuilder();
            for(int i=0;i<answer.length;i++){
                sb.append(answer[i]+" ");
            }
            memberSeminarRegisterRequestDTOList.add(sb.toString());
            return ;
        }
        for(int i=0;i<seminar_no_arr.length;i++){
            if(visited[i] == false){
                visited[i] = true;
                answer[level] = i;
                seminar_no_Permutation(level + 1, size + 1, maxSize);
                visited[i] = false;
            }
        }

    }
    @DisplayName("Member_Seminar Service RegisterForSeminar Test")
    @Test
    public void testWithThreadsRegisterForSeminarList() throws SeminarRegistrationFullException {
        String member_id = "passionfruit200@naver.com";

        for(int i=1;i<=seminar_no_arr.length;i++){
            answer = new int[i];
            seminar_no_Permutation(0, 0, i);
        }

        Random random = new Random();
        //총 몇번의 트랜잭션이 시작되었는지 counting 합니다. 이를 통해 올바르게 롤백되었는지도 확인할 수 있습니다.
        AtomicInteger allNumber = new AtomicInteger();
        AtomicInteger successNumber = new AtomicInteger();
        AtomicInteger failedNumber = new AtomicInteger();
        final int executeNumber = 1000;
        final ExecutorService executorService = Executors.newFixedThreadPool(500);
        final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
        for(int i=0;i<executeNumber; i++){
            executorService.execute( () -> {
                try{
                    int randomIndex = random.nextInt(memberSeminarRegisterRequestDTOList.size()); // memberSeminarRegisterRequestDTOList에서 랜덤한 인덱스를 선택합니다.
                    String[] info = memberSeminarRegisterRequestDTOList.get( (randomIndex) ).split(" ");

                    List<MemberSeminarRegisterRequestDTO> MemberSeminarRegisterRequestDTOLIST = new ArrayList<>();
                    for(int j=0;j<info.length;j++){
                        MemberSeminarRegisterRequestDTOLIST.add(new MemberSeminarRegisterRequestDTO(member_id, seminar_no_arr[ Integer.parseInt(info[j])]));
                    }

                    try {
                        memberSeminarService.registerForSeminarWithList(MemberSeminarRegisterRequestDTOLIST);
                        allNumber.addAndGet(info.length);
                        successNumber.addAndGet(1);
                    }catch (Exception e){
                        failedNumber.addAndGet(1);
                        throw e;
                    }
                }catch(Exception e){
                    System.out.println(e.getMessage());
                }finally {
                    countDownLatch.countDown();
                }
            });
        }

        try {
            // 모든 스레드가 종료될 때까지 대기
            countDownLatch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("Thread interrupted while waiting for completion.");
        } finally {
            // ExecutorService 종료
            executorService.shutdown();
        }

        System.out.println("allNumber:"+allNumber);
        System.out.println("successNumber:"+successNumber);
        System.out.println("failedNumber:"+failedNumber);

    }

}

 

데드락 발생확률

약 1000번의 호출을 3번정도 반복하였는데,

성공률은 37%, 실패율은 63% 정도 로 알 수 있습니다.

 

이제 데드락이 발생하는 것을 확인했습니다!

그렇다면, 제 서비스에서의 구체적인 예시와 함께 다시 살펴보겠습니다.

문제사항 정의

현재 발생하는 문제는 아래의 그림과 같습니다.

 

트랜잭션이 시작하면, 각 사용자들의 트랜잭션들은 각자 본인이 원하는 세미나 자원을 선점하고자 합니다.

하지만, 이떄, 각 사용자가 원하는 세미나 자원이 이미 다른 트랜잭션에 의하여 선점된 상황이기에 데드락이 발생하는 상황입니다.

 

위의 그림은 조금 복잡하다고 느끼실 수 있을 것 같습니다.

아래의 그림으로 더 명확하게 알 수 있습니다.

 

이제, 왜 데드락이 발생하는지 명확하게 이해할 수 있습니다. 

이제는 어떻게 데드락(교착상황)을 해결할 수 있을지, 어떤 과정을 거치면서 알아내었는지 알아보겠습니다.

학부시절에 배운 운영체제 책을 꺼내보자 (프로세스가 자원 사용시에 발생하는 교착상태에서 아이디어 얻어보기)

먼저, 교착상태의 정의에 대해 알아보았습니다.

교착상태란, 2개 이상의 프로세스가 다른 프로세스의 작업이 끝나기만 기다리며 작업을 더 이상 진행하지 못하는 상태를 교착상태(dead lock)이라고 한다라고 정리되어있습니다.

이러한 교착상태 개념이 나오게 된 이유를 이해하면 더 도움이 될텐데요,

  • 교착상태가 나오게 된 이유는 프로세스간 통신 및 동기화 문제에서 프로세스간 통신 중 시스템 내의 임계구역이 존재할시 프로세스 간 상호배제를 보장해야한다는 점에서 나온 개념입니다.

위의 개념을 현재 저의 상황에 대입해보면, 프로세스는 하나의 API Request, 즉 트랜잭션이 되고, 시스템 내의 임계구역은 세미나의 레코드 ( 현재 인원수(seminar_participants_cnt) )가 될 수 있습니다. 임계구역 내에서의 프로세스 간 상호배제를 보장한다면 문제를 해결할 수 있는 것 입니다.

 

교착상태에 대해 알아보는데 가장 유명한 예시는 "식사하는 철학자 문제의 자원 할당 그래프"입니다.

  1. 철학자인 PassionFruit200은 왼쪽 포크인 "2024년 테란 세미나"를 할당받았습니다.
  2. 철학자인 PassionFruit200은 오른쪽 포크인 "2024년 저그 세미나"를 기다립니다.
  3. 철학자인 사용자2는 왼쪽 포크인 "2024년 저그 세미나"를 할당받았습니다.
  4. 철학자인 사용자2는 오른쪽 포크인 "2024년 프로토스 세미나"를 기다립니다.
  5. 철학자인 사용자3은 왼쪽 포크인 "2024년 프로토스 세미나"를 할당받았습니다.
  6. 철학자인 사용자3은 오른쪽 포크인 "2024년 테란 세미나"를 기다립니다.

이제 교착상태가 무엇인지에 대하여 어느정도 이해하게되었으니, 어떤 상황에서 교착상태가 발생하는지 알아보겠습니다.

교착상태 필요조건

교착상태 필요조건에는 아래의 4가지로, 하나라도 충족하지 않으면 발생하지 않습니다.

즉, 하나의 조건만 해제시키면 교착상태가 발생하지 않는다는 의미입니다.

  • 상호배제 : 한 프로세스가 사용하는 자원은 다른 프로세스와 공유할 수 없는 배타적인 자원이어야 한다.
  • 비선점 : 한 프로세스가 사용중인 자원은 중간에 다른 프로세스가 뺴앗을 수 없는 비선점 자원이어야 한다. 어떤 자원을 빼앗을 수 없으면 공유할수도 없으므로 교착상태가 발생한다.
  • 점유와 대기 : 프로세스가 어떤 자원을 할당받은 상태에서 다른 자원을 기다리는 상태여야 한다. 다른 프로세스의 작업 진행을 방해하는 교착 상태가 발생하려면 다른 프로세스가 필요로 하는 자원을 점유하고 있으면서 또 다른 자원을 기다리는 상태가 되어야 한다.
  • 원형 대기 : 점유와 대기를 하는 프로세스 간의 관계가 원을 이루어야 한다. 프로세스가 특정 자원에 대해 점유와 대기를 한다고 해서 모두 교착상태에 빠지는것은 아니다. 점유와 대기를 하는 프로세스들이 서로 방해하는 방향이 원을 이루면 프로세스들이 서로 양보하지 않기 때문에 교착 상태에 빠진다.

교착상태 해결 방법

교착상태를 해결하는 방법에는 크게

  • 교착상태 예방
  • 교착상태 회피
  • 교착상태 검출
  • 교착상태 회복

네가지 카테고리가 있습니다.

 

이제 각 해결방법에서 어떤 것을 사용하였는지 설명하고, 사용하지 않았던 이유에 대해서도 정리해보았습니다.

결론부터 말씀드리면, 저는 교착상태 예방에서 원형대기 예방을 통해 문제사항을 해결했습니다.

결론부터 알아보고자 하시는분은, 바로 해당 Part를 확인하시기 바랍니다.

교착상태 예방

교착상태를 유발하는 네가지 조건을 미리 예방하는 방법입니다.   ( 운영체제 상에서는 교착상태를 해결할때 이 방법은 실효성이 적어 잘 사용되지 않는다고 합니다. 하지만, 프로세스를 관리하는 운영체제 상에서는 적용하기 어렵지만 )

  1. 상호배제 예방
  2. 비선점 예방
  3. 점유와 대기 예방
  4. 원형대기 예방

1. 상호 배제 예방 같은경우 시스템 내에 있는 상호 배타적인 모든 자원, 즉 독점적으로 사용할 수 있는 자원을 없애버리는 방식입니다. 즉, 배타락 ( SQL . SELECT .. FOR UPDATE ) 을 사용하지 말라는 의미입니다. 하지만, 배타락을 사용하지 않을경우 동시성 처리가 저의 코드 상에서는 불가합니다. 해결방법으로 적합하지 않습니다.

 

2. 비선점 예방 같은경우 모든 자원을 뺴앗을 수 있도록 만드는 방법입니다. 이 방식은 1번 상호배제 예방방식과 유사합니다. 1번 상호배제 예방방법과 같은 이유로 해결방법으로 적합하지 않습니다.

 

3. 점유와 대기 예방은 프로세스가 자원을 점유한 상태에서 다른 자원을 기다리지 못하게 하는 방법입니다. 다시 말해 '전부 할당하거나 아니면 아예 할당하지 않는 (all or nothing)' 방식을 적용하는 것 입니다. 이를 위해 프로세스는 시작 초기에 자신이 사용하려는 모든 자원을 한꺼번에 점유하거나, 그렇지 못할 경우 자원을 모두 반납해야 합니다.

앞선 예방방식인 상호배제 예방과 비선점 예방은 자원에 대한 제약을 풀어버리는 것 입니다. 그러나 임계구역으로 보호받는 자원에 대한 제약을 풀기는 어렵습니다. 프로세스의 자원 사용 방식을 변화시켜 교착 상태를 처리한다는 점에서 의미가 있습니다.

 

이러한 방식은, 사용자가 API 호출, 즉 WebServer에 Request를 보낼때마다 스레드가 해당 Request를 받아서 처리하는 방식에서는 다른 스레드가 해당 API를 점유하고 있다면 호출 자체를 하지 못하기에 WebServer 구조상 구현하는데에 문제가 있기도 하며, 동시성이 매우 떨어질 것 으로 느껴졌습니다. 이러한 이유로 해결방법으로 생각해볼 수는 있으나 더 나은 방식인 원형대기 예방 방식을 적용하고자 적합하지 않다고 느꼈습니다.

 

4. 원형대기 예방은 점유와 대기를 하는 프로세스들이 원형을 이루지 못하도록 막는 방법입니다. 즉, 자원에 대한 번호를 매기는 것 입니다. 제 시스템에서 자원이란 "스타크래프트 테란 세미나", "스타크래프트 저그 세미나", "스타크래프트 프로토스 세미나"가 될 것 입니다. 이렇게 각 자원에 순서를 두어서 자원을 한 방향으로만 사용하도록 설정한다면 원형대기를 예방할 수 있습니다. 즉, 모든 자원에 숫자를 부여하고 숫자가 큰 방향으로만 자원을 할당하는 것 입니다. 다시 말해 숫자가 작은 자원을 잡은 상태에서 큰 숫자를 잡는 것은 허용하지만, 숫자가 큰 자원을 잡은 상태에서 작은 숫자를 잡는 것은 허용하지 않습니다.

 

운영체제 측면에서 시스템 자원에 숫자를 부여한 것을 통해 이해해보겠습니다. (실제로 운영체제에서 이러한 방식을 채태하는 것은 아닙니다, 운영체제에서는 타임아웃 방식을 사용합니다. )

아래와 같이 운영체제에서는 자원번호의 숫자가 이미 잡은 자원의 자원번호보다 작다면 잡지 못합니다.

만약, 마우스를 잡은 상태에서는 키보드를 잡을 수 있습니다. 하지만 마우스를 잡은 상태에서 하드디스크를 잡고 난 후 키보드를 잡지는 못합니다.

만약, 키보드를 잡은 상태에서는 마우스를 잡을 수 없습니다.

 

 

위의 예시와 같이, 저의 서비스로 예를 들어보면, 아래의 그림과 같습니다.

이번에는 자원번호를 Seminar Entity의 seminar_no, Primary Key로 설정하였습니다. 번호가 낮을수록 높은 우선순위를 가지도록 처리합니다. 

 

 

이러한 방식을 알아보고, 저의 시스템에 충분히 적용하기에 적합하다고 느꼈습니다. 해당 방식을 적용하여 문제를 해결하고자 하였고 단순히 자원번호를 선언하여 해당 순서로 접근하도록 하여 발생하던 데드락 발생을 0%로 줄였습니다.

 

교착상태 회피

교착상태 회피는 프로세스에 자원을 할당할 떄 어느 수준 이상의 자원을 나누어주면 교착상태가 발생하는지 파악하여 그 수준 이하로 자원을 나누어주는 방법입니다. 교착 상태가 발생하지 않는 범위 내에서만 자원을 할당하고, 교착 상태가 발생하는 범위에 있으면 프로세스를 대기시킵니다.

즉, 교착 상태 회피는 자원의 총수와 현재 할당된 자원의 수를 기준으로 시스템을 안정상태(safe state)와 불안정 상태(unsafe state)로 나누고 시스템이 안정상태를 유지하도록 자원을 할당합니다.

할당된 자원이 적으면 안정상태가 크고, 할당된 자원이 늘어날수록 불안정상태가 커집니다. 그렇다고 불안정 상태에서 항상 교착상태가 발생하는것은 아닙니다. 교착 상태는 불안정 상태의 일부분이며, 불안정 상태가 커질수록 교착상태가 발생할 가능성이 높아질 뿐입니다.

 

이러한 방식 중 가장 유명한 예시는 "은행원 알고리즘"이 존재합니다.

"은행원 알고리즘"은 대출금액이 대출 가능한 범위 내이면(안정 상태이면) 허용되지만 그렇지않으면 거부되는것과 유사하기에 가장 대표적인 방식이라 볼 수 있습니다.

 

이러한 교착상태 회피는 저의 결제서비스에 적용하기에는 적합하지 않습니다. 

이유를 원활히 이해하기 위해  여기서 프로세스는 트랜잭션(Transaction)이고, 자원은 각 사용자가 신청한 세미나(Seminar, EX: "2024년 저그 세미나", "2024년 테란 세미나", "2024년 프로토스 세미나") 으로 이해하시면 편합니다.

이유는 다음과 같습니다.

1. 프로세스가 자신이 사용할 모든 자원을 미리 선언해야합니다.

  • 저의 경우에는 프로세스가 트랜잭션인데, 일반적인 웹서비스에서는 사용자의 선택에 따라서 유동적으로 사용할 자원이 정해지기에 사용하지 못합니다.

교착상태 검출

교착상태 검출은 운영체제가 프로세스의 작업을 관찰하면서 교착 상태 발생 여부를 계속 주시하는 방식입니다. 만약 교착 상태가 발견되면 이를 해결하기 위해 교착 상태 회복 단계를 밟습니다. 

교착 상태 검출은 타임아웃을 이용하는 방법과 자원할당 그래프를 이용하는 방법이 있습니다.

 

1. 타임아웃을 이용한 교착 상태 검출

타임아웃을 이용한 교착상태 검출은 일정시간동안 작업이 진행되지 않은 프로세스를 교착 상태가 발생한것으로 간주하여 처리하는 방법입니다. 

타임아웃은 이미 대부분의 데이터베이스와 운영체제에서 많이 선호하는 방식입니다. 이미 제가 사용하는 DB인 MySQL에서도 해당 타임아웃을 사용하기에, 이미 적용되는 방식이라고 볼 수 있습니다.

 

단점으로는 무엇이 있을까요?

1-1. 엉뚱한 프로세스가 강제종료될 수 있습니다.

타임아웃 시간 동안 작업이 진행되지 않은 모든 프로세스가 교착 상태 때문에 작업이 이루어지지 않은것은 아닙니다. 타임아웃을 이용하면 교착 상태 외의 다른 이유로 작업이 진행되지 못하는 모든 프로세스가 강제 종료될 수 있습니다.

 

1-2. 모든 시스템에 적용할 수 없습니다.

하나의 운영체제 내에서 동작하는 프로세스들은 그 상태를 운영체제가 감시하기 때문에 타임아웃 방법을 적용할 수 있습니다. 그러나 여러 군데에 데이터가 나뉘어 있는 분산 데이터베이스의 경우에는 타임아웃을 이용하는 방법을 적용하기가 어렵습니다. 분산 데이터베이스는 데이터가 여러 시스템에 나뉘어있고 각 시스템이 네트워크로 연결되어 있습니다.이러한 시스템에서는 원격지에 있는 프로세스의 응답이 없는 것이 교착 상태 떄문인지, 네트워크 문제 때문인지, 단순히 처리가 늦어지는 것인지 정확히 알수가 없습니다. 그러므로 타임아웃 방법을 적용하여 교착 상태를 파악하기 어렵습니다. 

 

2. 자원 할당 그래프를 이용한 교착상태 검출

교착상태를 검출하는 또다른 방법은 자원할당 그래프를 이용하는 것 입니다. 자원할당 그래프를 보면 시스템 내의 프로세스가 어떤 자원을 사용하고 있는지 혹은 기다리고 있는지를 알 수 있습니다.

 

위의 교착상태 검출 부분에서 MySQL 8.0 은 어떻게 지원할까에 대하여 떠오르는 책 내용이 있어서 가져와보았습니다.

MySQL에서 교착상태 검출을 지원하는 방법. 자동 데드락 감지!

이러한 교착상태에 대해 알아보면서, 이전에 읽었었던 Real MySQL 8.0 의 한 부분이 떠올라서 가져와보았습니다. 위의 운영체제에서 지원하는 교착상태 검출의 타임아웃 방식과 자원 할당 그래프를 이용한 교착상태 검출 방식을 모두 지원하고 있었습니다. 

 

InnoDB 스토리지 엔진은 내부적으로 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프(Wait-for List) 형태로 관리합니다. InnoDB 스토리지 엔진은 데드락 감지 스레드를 가지고 있어서 데드락 감지 스레드가 주기적으로 잠금 대기 그래프를 검사해 교착 상태에 빠진 트랜잭션들을 찾아서 그 중 하나를 강제 종료합니다. 이떄 어느 트랜잭션을 먼저 강제 종료할 것인지를 판단하는 기준은 트랜잭션의 언두 로그 양이며, 언두 로그 레코드를 더 적게 가진 트랜잭션이 일반적으로 롤백의 대상이 됩니다. 트랜잭션이 언두 레코드를 적게 가졌다는 이야기는 롤백을 해도 언두 처리를 해야할 내용이 적다는 것이며, 트랜잭션이 강제 롤백으로 인한 MySQL 서버의 부하도 덜 유발하기 때문입니다.

 

InnoDB 스토리지 엔진은 상위 레이어인 MySQL 엔진에서 관리되는 테이블 잠금(LOCK TABLES 명령으로 잠긴 테이블)은 볼 수가 없어서 데드락 감지가 불확실할 수도 있는데, innodb_table_locks 시스템 변수를 활성화하면 InnoDB 스토리지 엔진 내부의 레코드 잠금 뿐만 아니라 테이블 레벨의 잠금까지 감지할 수 있게 됩니다. 특별한 이유가 없다면, innodb_table_locks 시스템 변수를 활성화하는 것도 고려하면 좋습니다.

 

일반적인 서비스에서는 데드락 감지 스레드가 트랜잭션의 잠금 목록을 검사해서 데드락을 찾아내는 작업은 크게 부담되지 않습니다. 하지만 동시 처리 스레드가 매우 많아지거나 각 트랜잭션이 가진 잠금의 개수가 많아지면 데드락 감지 스레드가 느려집니다. 데드락 감지 스레드는 잠금 목록을 검사해야하기 떄문에 잠금 상태가 변경되지 않도록 잠금 목록이 저장된 리스트(잠금 테이블)에 새로운 잠금을 걸로 데드락 스레드를 찾게 됩니다. 데드락 감지 스레드가 느려지면 서비스 쿼리를 처리 주인 스레드는 더는 작업을 진행하지 못하고 대기하면서 서비스에 악영향을 미치게 됩니다.  이렇게 동시 처리 스레드가 매우 많은 경우 데드락 감지 스레드는 더 많은 CPU 자원을 소모할 수도 있다.

 

이런 문제점을 해결하기 위해 MySQL 서버는 innodb_deadlock_detect 시스템 변수를 제공하며, innodb_deadlock_detect를 OFF로 설정하면 데드락 감지 스레드는 더는 작동하지 않게 할 수 있습니다. 데드락 감지 스레드 가 작동하지 않으면 InnoDB 스토리지 엔진 내부에서 2개 이상의 트랜잭셔니 상대방이 가진 잠금을 요구하는 상황(데드락 상황)이 발생해도 누군가가 중재를 하지 않기 때문에 무한정 대기하게 될 것 입니다. 하지만 innodb_lock_wait_timeout 시스템 변수를 활성화하면 이런 데드락 상황에서 일정 시간이 지나면 자동으로 요청이 실패하고 에러메시지를 반환하게 된다. innodb_lock_wait_timeout은 초 단위로 설정할 수 있으며, 잠금을 설정한 시간 동안 획득하지 못하면 쿼리는 실패하고 에러를 반환합니다. 데드락 감지 스레드가 부담되어 innodb_deadlock_detect를 OFF로 설정해서 비활성화하는 경우라면 innodb_lock_wait_timeout을 기본값인 50초 보다 훨씬 낮은 시간으로 변경해서 사용할 것을 권장합니다.

 

추가로, 구글(google.com)에서는 프라이머리 키 기반의 조회 및 변경이 아주 높은 빈도로 실행되는 서비스가 많았는데, 이런 서비스는 매우 많은 트랜잭션을 동시에 실행하기 때문에 데드락 감지 스레드가 상당히 성능을 저하시킨다는것을 알아냈다고 합니다. 그리고 MySQL 서버의 소스코드를 변경해 데드락 감지 스레드를 활성화 또는 비활성화할 수 있게 변경해서 사용했습니다. 이 기능의 필요성을 인지하고 오라클에 이 기능을 요청해서 MySQL 서버에 추가된 것 입니다. 만약 PK 또는 세컨더리 인덱스를 기반으로 매우 높은 동시성 처리를 요구하는 서비스가 있다면 innodb_deadlock_detect를 비활성화해서 성능 비교를 해보는것도 새로운 기회가 될 것 입니다. 

다시 운영체제 책으로 돌아와서, 교착상태 회복

교착상태가 검출되면 교착상태를 푸는 후속작업을 하는데 이를 교착상태 회복이라고 합니다. 교착상태 회복 단계에서는 교착 상태를 유발한 프로세스를 강제로 종료합니다. 프로세스를 강제로 종료하는 방법은 다음과 같이 두가지가 있습니다.

1. 교착 상태를 일으킨 모든 프로세스를 동시에 종료

교착상태를 일으킨 모든 프로세스를 동시에 종료하는 방법입니다. 그런데 이 방법은 종료된 프로세스들이 동시에 작업을 시작하면 다시 교착 상태를 일으킬 가능성이 큽니다. 그러므로 모든 프로세스를 강제로 종료한 후 다시 실행할때는 순차적으로 실행해야 하며, 이떄 어떤 프로세스를 먼저 실행할 것 인지 기준이 필요하다.

 

2. 교착 상태를 일으킨 프로세스 중 하나를 골라 순서대로 종료

교착상태를 일으킨 프로세스 중 하나를 골라 순서대로 종료하면서 나머지 프로세스의 상태를 파악하는 방법입니다. 프로세스를 종료할떄 어떤 프로세스부터 종료할 것인지 다음과 같은 기준이 필요합니다.

  • 우선순위가 낮은 프로세스를 먼저 종료한다.
  • 우선순위가 같은경우 작업시간이 짧은 프로세스를 먼저 종료한다.

위의 두 조건이 같은 경우 자원을 많이 사용하는 프로세스를 먼저 종료합니다. 교착 상태 회복 단계에서는 관련 프로세스를 강제로 종료하는 일 뿐 아니라 강제 종료된 프로세스가 실행되기 전에 시스템을 복구해야하는 일도 해야합니다. 시스템 복구는 명령어가 실행될때마다 체크포인트를 만들어 가장 최근의 검사 시점으로 돌아가는 식으로 합니다. 그런데 이 방법은 작업량이 상당하여 시스템에 부하를 주므로 체크포인트를 무분별하게 사용하지말고 선택적으로 사용해야합니다.

그래서 어떤것을 사용할까?

첫번쨰 방법은, 애초에 장바구니의 기획이 바뀌어야한다는점이 있지만, 장바구니에서 아래 이미지에서 처럼 3개의 장바구니가 하나의 트랜잭션이 아닌 각각 다른 트랜잭션으로 나누어 가능한 부분만 구현하도록 할지.

두번쨰 방법은, 교착상태 예방 방법의 원형대기 예방을 사용방법입니다. 이유는, 기획적인 이유입니다. 하나의 장바구니는 한번에 결제되어야 하는것이 장바구니라고 생각되기 떄문입니다. ( 만약, 기획이 바뀐다면 첫번쨰 방법도 고려해볼 필요가 있습니다. )

이 자원 순서를 부여하기 위해서는 어떻게 해야할까요?

구매하려는 세미나에 대해서 "세미나의 고유번호를 자원번호(PK, seminar_no)로 선언" 하여 자원번호의 오름차순 순으로 낮은 우선순위를 갖도록 합니다.

 

즉, passionfruit200의 트랜잭션이 시작되면 passionfrut200이 가져온 세미나를 seminar_no 순서별로 정렬하여 세미나에 결제서비스를 적용시킵니다. 결제서비스를 사용하는 모든 사용자들은 항상 낮은 자원의 순서대로 자원을 사용하게 되면서 DEADLOCK 상황을 피할 수 있습니다. 

장바구니 기능을 코드로써 구현해보자

이전 두번쨰 글에서의 코드에서 달리진점은,

  • 이제는 Front단에서 List 형태의 JSON 형식을 받는다는 점입니다. 
  • 자원번호에 오름차순을 설정하기 위해서 seminar_name 대신에 seminar_no를 인자로 받습니다. (seminar_name으로도 값을 받아서 자원번호를 설정할 수 있지만, 비효율적이기에 프론트단과 이야기하여 seminar_no로 받도록 하는것이 훨씬 효율적일 것 입니다.)
  • seminar_no 추가로 인하여 MemberSeminarRegisterRequestDTO에 seminar_no 필드를 추가해주어야 하고, 기존에 seminar_name으로 검색하던 로직을 seminar_no 로 seminar 검색과 관련 함수들을 수정해야할 필요가 있습니다.

1. [com/seminarhub/dto/MemberSeminarRegisterRequestDTO.java]

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberSeminarRegisterRequestDTO {
    private String member_id;
    private String seminar_name;

    private Long seminar_no;

    public MemberSeminarRegisterRequestDTO(String memberId, String seminar_name) {
        this.member_id = memberId;
        this.seminar_name = seminar_name;
    }

    public MemberSeminarRegisterRequestDTO(String memberId, Long seminar_no) {
        this.member_id = memberId;
        this.seminar_no = seminar_no;
    }
}

 

2. [ com/seminarhub/service/Member_SeminarServiceImpl.java] ]

실제로 자원번호를 부여하기 위한 코드는 아래코드 5줄 뿐 입니다.

Collections.sort(memberSeminarRegisterRequestDTO, (dto1, dto2) -> {
    if(dto1.getSeminar_no() > dto2.getSeminar_no()) return 1;
    else if(dto1.getSeminar_no() < dto2.getSeminar_no()) return -1;
    else return 0;
});
  • Collections.sort를 사용하기 위해 Comparable을 객체 안에 구현해도되지만 다양하게 정렬에 사용될 수 있으므로 람다식으로 구현했습니다.
@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_UNCOMMITTED)
@Override
public void registerForSeminarWithList(List<MemberSeminarRegisterRequestDTO> memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
//        log.info(memberSeminarRegisterRequestDTO.toString());
    //오름차순 정렬
    Collections.sort(memberSeminarRegisterRequestDTO, (dto1, dto2) -> {
        if(dto1.getSeminar_no() > dto2.getSeminar_no()) return 1;
        else if(dto1.getSeminar_no() < dto2.getSeminar_no()) return -1;
        else return 0;
    });

    for(int i=0;i<memberSeminarRegisterRequestDTO.size();i++) {
        try{
            registerSeminarIndependently(memberSeminarRegisterRequestDTO.get(i));
        }catch (Throwable e){
            e.printStackTrace();
            throw e; // 다시 예외를 던지면서 상위 호출자에게 전달
        }
    }

//        return member_seminar.getMember_seminar_no();
}

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_UNCOMMITTED)
public void registerSeminarIndependently(MemberSeminarRegisterRequestDTO memberSeminarRegisterRequestDTO){
    MemberDTO memberDTO = memberService.getMember_no(memberSeminarRegisterRequestDTO.getMember_id());
    SeminarDTO seminarDTO = seminarService.getBySeminar_NoWithPessimisticLock(memberSeminarRegisterRequestDTO.getSeminar_no());

    if (seminarDTO == null || memberDTO == null) {
        // 예외 처리: 세미나나 멤버가 존재하지 않는 경우
        throw new SeminarRegistrationFullException("There are no Info Of Member || Seminar");
    }

    Long currentParticipateCnt = seminarDTO.getSeminar_participants_cnt();
    if (seminarDTO.getSeminar_max_participants() <= (currentParticipateCnt)) {
        throw new SeminarRegistrationFullException("SeminarInfo:" + seminarDTO.getSeminar_name() + "is already " + currentParticipateCnt + "/" + seminarDTO.getSeminar_max_participants() + " full. Registration failed.");
    }
    seminarService.increaseParticipantsCnt(seminarDTO.getSeminar_no());

    Member_Seminar_Payment_History member_seminar_payment_history = Member_Seminar_Payment_History.builder()
            .member_seminar_payment_history_amount(seminarDTO.getSeminar_price())
            .build();
    member_seminar_payment_historyRepository.save(member_seminar_payment_history);

    Member_SeminarDTO member_seminarDTO = Member_SeminarDTO.builder()
            .member_no(memberDTO.getMember_no())
            .seminar_no(seminarDTO.getSeminar_no())
            .member_seminar_payment_history_no(member_seminar_payment_history.getMember_seminar_payment_history_no())
            .build();
    Member_Seminar member_seminar = dtoToEntity(member_seminarDTO);
    member_seminarRepository.save(member_seminar);
}

위의 코드에서 중요한점은 트랜잭션의 ACID 속성을 지켜주는 것 입니다.

혹시라도, 장바구니에 담은 여러개의 상품 중에서 한개라도 이미 품절되었다거나 존재하지 않는 정보라면 해당 장바구니 상품 전체가 구매에 포함되지 않아야합니다.

 

그렇기에 아래와 같이 트랜잭션의 전파레벨을 Propagation.REQUIRED로 처리합니다. 

물론 다른 전파레벨을 사용하더라도 이 코드내에서는 해당 오류 발생시 상위호출자에게 전달하기에 registerforSeminarwithList가 롤백되기에 ACID 원칙이 지켜집니다.

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_UNCOMMITTED)
@Override
public void registerForSeminarWithList(List<MemberSeminarRegisterRequestDTO> memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
    for(int i=0;i<memberSeminarRegisterRequestDTO.size();i++) {
        try{
            registerSeminarIndependently(memberSeminarRegisterRequestDTO.get(i));
        }catch (Throwable e){
            e.printStackTrace();
            throw e; // 다시 예외를 던지면서 상위 호출자에게 전달
        }
    }
}

 

과연, 성공적으로 교착상태의 자원번호 부여를 통한 원형대기 예방이 이루어져, 여전히 데드락이 발생하는지 안하는지 확인해보겠습니다!

완전탐색(재귀함수) 활용하여 장바구니에 아이템을 담을 수 있는 모든 경우 생성과 쓰레드 100개로 해당 데이터로 테스트 코드 준비

장바구니 담기에서 데드락 발생 조건을 찾기 위해서는 장바구니에 담을 수 있는 모든 경우에 대한 테스트케이스가 필요합니다.

약 4가지 세미나가 존재한다고 판단하고 해당 세미나를 담을 수 있는 모든 조합을 찾아서 테스트해보겠습니다.

개수는 4(4! + 3! + 2! + 1!) 개가 나옵니다.

 

또한 실제로 사용자들을 스레드라고 생각하고 스레드 100개가 순식간에 총합 1000개의 요청을 보내는 작업을 진행했습니다. ( 사실 동시에 많은 인원, 즉 동시에 100명이 신청하는 경우에서 데드락이 발생하는 것을 피하는것이 중요하므로 데이터 시행횟수가 작아도 괜찮습니다. )

@SpringBootTest
public class Member_SeminarServiceTests {
    @Autowired
    private SeminarService seminarService;

    @Autowired
    private MemberService memberService;

    @Autowired
    private Member_SeminarRepository member_seminarRepository;

    @Autowired
    private SeminarQuerydslRepository seminarQuerydslRepository;

    @Autowired
    private Member_Seminar_Payment_HistoryRepository member_seminar_payment_historyRepository;

    @Autowired
    private Member_SeminarService memberSeminarService;
    
    //	처음에는 seminar_name으로 검색했었기에 해당 배열도 냅두었습니다.
//    String[] seminar_name_arr = new String[] { "2024년 상반기 스타크래프트 테란 세미나", "2024년 상반기 스타크래프트 프로토스 세미나", "스타크래프트 세미나", "2024년 상반기 스타크래프트 저그 세미나"};
    Long[] seminar_no_arr = new Long[] { 11L, 12L, 13L, 14L};
    boolean[] visited = new boolean[seminar_no_arr.length];
    public List<String> memberSeminarRegisterRequestDTOList = new ArrayList<>();
    int[] answer;
    public void seminar_no_Permutation(int level, int size, int maxSize){
        if(level == maxSize){
            StringBuilder sb = new StringBuilder();
            for(int i=0;i<answer.length;i++){
                sb.append(answer[i]+" ");
            }
            memberSeminarRegisterRequestDTOList.add(sb.toString());
            return ;
        }
        for(int i=0;i<seminar_no_arr.length;i++){
            if(visited[i] == false){
                visited[i] = true;
                answer[level] = i;
                seminar_no_Permutation(level + 1, size + 1, maxSize);
                visited[i] = false;
            }
        }

    }
    @DisplayName("Member_Seminar Service RegisterForSeminar Test")
    @Test
    public void testWithThreadsRegisterForSeminarList() throws SeminarRegistrationFullException {
        String member_id = "passionfruit200@naver.com";

        for(int i=1;i<=seminar_no_arr.length;i++){
            answer = new int[i];
            seminar_no_Permutation(0, 0, i);
        }

//      생성된 모든 장바구니 담기 조합을 보여줍니다
//        for(int i=0;i<memberSeminarRegisterRequestDTOList.size();i++){
//            System.out.println("KIND --- ");
//            System.out.println(memberSeminarRegisterRequestDTOList.get(i));
//        }

        Random random = new Random();
        //총 몇번의 트랜잭션이 시작되었는지 counting 합니다. 이를 통해 올바르게 롤백되었는지도 확인할 수 있습니다.
        AtomicInteger allNumber = new AtomicInteger();
        final int executeNumber = 1000;
        final ExecutorService executorService = Executors.newFixedThreadPool(100);
        final CountDownLatch countDownLatch = new CountDownLatch(executeNumber);
        for(int i=0;i<executeNumber; i++){
            executorService.execute( () -> {
                try{
                    int randomIndex = random.nextInt(memberSeminarRegisterRequestDTOList.size()); // memberSeminarRegisterRequestDTOList에서 랜덤한 인덱스를 선택합니다.
                    String[] info = memberSeminarRegisterRequestDTOList.get( (randomIndex) ).split(" ");
                    //수동 정렬
                    //Arrays.sort(info);

                    List<MemberSeminarRegisterRequestDTO> MemberSeminarRegisterRequestDTOLIST = new ArrayList<>();
                    for(int j=0;j<info.length;j++){
                        MemberSeminarRegisterRequestDTOLIST.add(new MemberSeminarRegisterRequestDTO(member_id, seminar_no_arr[ Integer.parseInt(info[j])]));
                    }

//					올바르게 랜덤 장바구니 담기 조합이 만들어져서 들어가는지 테스트합니다.
//                    for(int j=0;j<info.length;j++){
//                        System.out.println(MemberSeminarRegisterRequestDTOLIST.toString());
//
//                    }
                    try {
                        memberSeminarService.registerForSeminarWithList(MemberSeminarRegisterRequestDTOLIST);
                        allNumber.addAndGet(info.length);
                    }catch (Exception e){
                        throw e;
                    }
                }catch(Exception e){
                    System.out.println(e.getMessage());
                }finally {
                    countDownLatch.countDown();
                }
            });
        }

        try {
            // 모든 스레드가 종료될 때까지 대기
            countDownLatch.await();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("Thread interrupted while waiting for completion.");
        } finally {
            // ExecutorService 종료
            executorService.shutdown();
        }
        
        System.out.println("allNumber:"+allNumber);

    }
    
}

 

위의 테스트코드로 테스트 해보기

현재 세미나는 4개가 준비되어있고, member_seminar 테이블, member_seminar_payment_history 테이블이 존재합니다.

먼저, 다시 한번 테스트코드가 올바르게 준비되었는지 확인합니다.

 

이제, 기존의 방법과 달리 교착상태 예방에서의 원형대기 예방 구현을 위한 자원번호에 대한 우선순위 부여로 데드락을 피할 수 있는지 확인해보겠습니다.

 

명확한 테스트를 위해 기존 데드락 발생 상황코드 또한 같이 확인해봅니다.

아래의 코드가 데드락 발생코드입니다.

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_UNCOMMITTED)
@Override
public void registerForSeminarWithList(List<MemberSeminarRegisterRequestDTO> memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
//        log.info(memberSeminarRegisterRequestDTO.toString());
    //오름차순 정렬
    //Collections.sort(memberSeminarRegisterRequestDTO, (dto1, dto2) -> {
    //    if(dto1.getSeminar_no() > dto2.getSeminar_no()) return 1;
    //   else if(dto1.getSeminar_no() < dto2.getSeminar_no()) return -1;
    //    else return 0;
    //});

    for(int i=0;i<memberSeminarRegisterRequestDTO.size();i++) {
        try{
            registerSeminarIndependently(memberSeminarRegisterRequestDTO.get(i));
    ...
    ...
}

 

이 코드는 데드락 발생을 피할 수 있는 코드입니다.

즉, 자원에 대한 번호 부여로 원형대기를 예방합니다.

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_UNCOMMITTED)
@Override
public void registerForSeminarWithList(List<MemberSeminarRegisterRequestDTO> memberSeminarRegisterRequestDTO) throws SeminarRegistrationFullException {
    //오름차순 정렬
    Collections.sort(memberSeminarRegisterRequestDTO, (dto1, dto2) -> {
        if(dto1.getSeminar_no() > dto2.getSeminar_no()) return 1;
        else if(dto1.getSeminar_no() < dto2.getSeminar_no()) return -1;
        else return 0;
    });

    for(int i=0;i<memberSeminarRegisterRequestDTO.size();i++) {
        try{
            registerSeminarIndependently(memberSeminarRegisterRequestDTO.get(i));
     ...
     ...
     ...
}

 

코드에서 보시면 아시겠지만, 단순히 아래 코드가 추가되었느냐 아니냐의 차이입니다.

//오름차순 정렬
Collections.sort(memberSeminarRegisterRequestDTO, (dto1, dto2) -> {
    if(dto1.getSeminar_no() > dto2.getSeminar_no()) return 1;
    else if(dto1.getSeminar_no() < dto2.getSeminar_no()) return -1;
    else return 0;
});

 

테스트를 실행합니다. 

테스트가 성공적으로 끝났습니다!

 

테스트 콘솔중에 간간히 RuntimeException이 보입니다. 이 RuntimeException은 장바구니에 담은 아이템이 이미 최대 인원을 채운경우 발생하며 그 경우에는 해당 장바구니를 구매한 사람의 신청 데이터가 롤백됩니다. 

 

DeadLock은 한번도 발생하지 않았으니 테스트는 성공입니다!

 

테스트코드에 트랜잭션에서 구매한 장바구니의 개수를 알아보기 위해 allNumber로 Counting을 했었습니다.

이렇게 한 이유는, ACID에서 A의 All or Nothing 원자성이 잘 지켜지는지 테스트하기 위해 냅두었습니다.

 

한번 Atomicity 가 잘 지켜졌는지 확인해보겠습니다.

allNumber의 개수와 실제 실행된 신청된 개수가 똑같습니다. 즉, Atomicity 가 보장되었습니다!

성장률

처음 데드락 발생에서는 약 1000번의 호출을 3번정도 반복하였는데, 성공률은 37%, 실패율은 63% 정도가 발생하였습니다. 하지만, 올바르게 교착상태를 예방하고 나서는 성공률이 100%입니다.

 

성장률을 계산해보겠습니다.

이전 상태의 성공률 = (이전 성공 횟수 / 전체 호출 수) * 100% = (370 / 1000) * 100% = 37%

새로운 상태에서는 1000번의 호출 중에서 모두 성공했으므로 성공률은 100%입니다.

성장률은 다음과 같이 계산할 수 있습니다.

성장률 = (새로운 성공률 - 이전 성공률) / 이전 성공률 * 100% = (100% - 37%) / 37% * 100% = (63%) / 37% * 100% ≈ 170.27%

 

마무리

이로써, 하나의 아이템만 구매하는것이 아닌 장바구니 담기에서의 결제서비스 기능도 만들어보았습니다.

이번 글에서 교착상태에서의 예방방법에 관하여 자세히 알게되고, 실제로 e-commerce와 같은 결제서비스를 제공하는 시스템에서는 기본적으로 구현되어 있을 법한 기능이라는 생각이 들었습니다. 

실제로 현업에서 저와 같은 방식으로 구현을 했을지 아니면 다른 방안을 적용했을지, 다른 방안 중 가능했던 부분은 애초에 장바구니를 따로따로 트랜잭션을 나누어서 개발을 하여 데드락 자체가 발생하지 않도록 할지 등등 궁금한 점도 있습니다. 

또한, 교착상태 검출 부분에서는 MySQL 8.0 에서 정확하게 같은 방식으로 구현된 것을 보고 아마 MySQL 개발자들도 운영체제의 이론을 토대로 개발을 했을 거라는 생각이 들었습니다.

+ Recent posts