국비지원 (스파르타)

재고 시스템에 동시성 적용하기 (2)

klom 2025. 4. 2. 16:59
반응형

내가 저번에 재고 시스템에 동시성 적용하기 (1) 관련해서 글을 작성적이 있었다.
그때는 재고를 하나만 수정하는 API이기 때문에 데드락이 발생할 가능성은 적었다.
하지만 최근에 API를 추가하게 되었다. 고것은 

{
  "stocks": [
    {
      "id": "1b332d6b-fa61-44fa-99b4-3f1c47bbced8",
      "productQuantity": -5
    } ,
     {
      "id": "8f889c3d-a233-4716-b94a-b854d6265ea8",
      "productQuantity": 10
    } ,
     {
      "id": "c01cccdb-78a9-4328-ba52-70b3edb2f40d",
      "productQuantity": -2
    },
     {
      "id": "f8a32b1c-c096-409e-8ae7-ee99bbecb01f",
      "productQuantity": 15
    } 
  ]
}

요런식으로 재고시스템을 여러개 수정하는 API를 추가하였다.
그런데 고민이 생겼다. 이것을 어떻게 하면 데드락을 걸게 만들 수 있을까? 테코로도 짤 수 도 있었겠지만 뭔가 
k6로 부하테스트를 하고 싶어졌다.
그래서 여차여차해서 다음과 같은 스크립트를 짜게 되었다.

import http from 'k6/http';
import { check, sleep } from 'k6';
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import { Counter } from 'k6/metrics';

// 데드락 카운터 정의
const deadlockCounter = new Counter('deadlocks');
const successCounter = new Counter('successful_updates');

// 테스트 설정 - 매우 공격적인 설정
export const options = {
    scenarios: {
        extreme_deadlock: {
            executor: 'ramping-arrival-rate',
            startRate: 20,         // 초당 20개 요청으로 시작
            timeUnit: '1s',
            preAllocatedVUs: 50,
            maxVUs: 200,
            stages: [
                { duration: '5s', target: 100 },    // 5초 동안 초당 50개 요청으로 증가
                { duration: '10s', target: 200 },  // 10초 동안 초당 100개 요청으로 증가
                { duration: '10s', target: 150 },  // 10초 동안 100개 유지
                { duration: '5s', target: 10 }     // 5초 동안 20개로 감소
            ],
        }
    },
    thresholds: {
        'deadlocks': ['count>0'], // 데드락이 발생해야 성공
    },
};

// 인증 헤더 설정
const headers = {
    'Content-Type': 'application/json',
    'X-User-Email': 'test@example.com',
    'X-User-Name': 'testuser',
    'X-User-Role': 'MASTER'
};

// 기본 URL
const baseUrl = 'http://localhost:19102/api/v1/stock';

// 테스트 초기화 함수
export function setup() {
    const createdIds = [];

    // 필수 테스트용 재고 2개만 생성 (데드락 유발이 더 쉬움)
    for (let i = 0; i < 2; i++) {
        const productId = `deadlock-extreme-${randomString(5)}`;

        const createPayload = JSON.stringify({
            hubId: `hub-${randomString(5)}`,
            productId: productId,
            productQuantity: 10000 // 매우 큰 초기 수량
        });

        const createResponse = http.post(
            `${baseUrl}`,
            createPayload,
            { headers: headers }
        );

        if (createResponse.status === 200) {
            try {
                const stockId = JSON.parse(createResponse.body).id;
                createdIds.push(stockId);
                console.log(`테스트용 재고 ${i+1} 생성: ID = ${stockId}`);
            } catch (e) {
                console.log(`응답 파싱 실패: ${createResponse.body}`);
            }
        }
    }

    if (createdIds.length < 2) {
        console.log('테스트용 재고 생성 실패. 기본값을 사용합니다.');
        return {
            stockIds: [
                'f47ac10b-58cc-4372-a567-0e02b2c3d479',
                '7de88e5a-a91d-4e5b-b1eb-47bcf23adec1'
            ]
        };
    }

    return { stockIds: createdIds };
}

// 메인 테스트 함수
export default function (data) {
    const stockIds = data.stockIds;

    if (!stockIds || stockIds.length < 2) {
        console.log('테스트를 위한 충분한 재고 ID가 없습니다.');
        return;
    }

    // 랜덤하게 둘 중 하나의 업데이트 전략 선택
    const strategyChoice = Math.floor(Math.random() * 2);

    // 전략 1: 서로 반대 순서로 다중 업데이트 두 개를 동시에 실행
    if (strategyChoice === 0) {
        // 순서 1: A -> B
        const updatePayload1 = JSON.stringify({
            stocks: [
                { id: stockIds[0], productQuantity: 1 },
                { id: stockIds[1], productQuantity: 1 }
            ]
        });

        // 순서 2: B -> A (완전히 반대 순서)
        const updatePayload2 = JSON.stringify({
            stocks: [
                { id: stockIds[1], productQuantity: 1 },
                { id: stockIds[0], productQuantity: 1 }
            ]
        });

        // 두 요청을 배치로 동시에 보냄 (데드락 가능성 높음)
        const responses = http.batch([
            {
                method: 'PUT',
                url: `${baseUrl}/multi`,
                body: updatePayload1,
                params: { headers: headers, timeout: 10000 } // 10초 타임아웃
            },
            {
                method: 'PUT',
                url: `${baseUrl}/multi`,
                body: updatePayload2,
                params: { headers: headers, timeout: 10000 }
            }
        ]);

        // 응답 확인
        responses.forEach((response, index) => {
            if (response.status === 200) {
                successCounter.add(1);
            } else {
                checkForDeadlock(response);
            }
        });
    }
    // 전략 2: 개별 업데이트를 동시에 여러 번 시도 (더 작은 단위로 경쟁 발생)
    else {
        // 각 재고에 대해 개별 업데이트 여러 개 생성
        const requests = [];

        // 10개의 개별 요청 생성 (5개 재고 A, 5개 재고 B)
        for (let i = 0; i < 5; i++) {
            // 재고 A 업데이트
            requests.push({
                method: 'PUT',
                url: `${baseUrl}/${stockIds[0]}`,
                body: JSON.stringify({ quantity: 1 }),
                params: { headers: headers, timeout: 10000 }
            });

            // 재고 B 업데이트
            requests.push({
                method: 'PUT',
                url: `${baseUrl}/${stockIds[1]}`,
                body: JSON.stringify({ quantity: 1 }),
                params: { headers: headers, timeout: 10000 }
            });
        }

        // 모든 요청을 동시에 보냄
        const responses = http.batch(requests);

        // 응답 확인
        responses.forEach((response, index) => {
            if (response.status === 200) {
                successCounter.add(1);
            } else {
                checkForDeadlock(response);
            }
        });
    }

    // 짧은 시간 동안 대기
    sleep(Math.random() * 0.1); // 0~100ms 랜덤 대기
}

// 데드락 감지 함수
function checkForDeadlock(response) {
    // 응답 본문이 있고 데드락 관련 메시지가 포함되어 있는지 확인
    if (response.body && (
        response.body.includes('deadlock detected') ||
        response.body.includes('40P01') ||
        response.body.includes('could not serialize access') ||
        response.body.includes('Lock wait timeout') ||
        response.body.includes('transaction was deadlocked')
    )) {
        deadlockCounter.add(1);
        console.log(`데드락 감지됨!: ${response.body}`);
    } else {
        console.log(`요청 실패 (데드락 아님): ${response.status}, ${response.body}`);
    }
}

// 테스트 종료 후 정리
export function teardown(data) {
    console.log(`테스트 완료: 총 ${deadlockCounter.value}개의 데드락 감지, ${successCounter.value}개의 성공적인 업데이트`);

    if (deadlockCounter.value > 0) {
        console.log('데드락 테스트 성공! 데드락이 감지되었습니다.');
    } else {
        console.log('데드락이 발생하지 않았습니다. 더 강한 부하로 다시 시도하세요.');
    }
}

요렇게 짰다. 스크립트는 커서라는 AI를 통해 작성하였다.
그리고 돌렸더니

JDBC exception executing SQL [select s1_0.id,s1_0.create_at,s1_0.create_by,s1_0.delete_at,s1_0.delete_by,s1_0.hub_id,s1_0.product_id,s1_0.product_quantity,s1_0.update_at,s1_0.update_by from p_stock s1_0 where s1_0.id in (?,?) and s1_0.delete_by is null for no key update] [ERROR: deadlock detected
  Detail: Process 540 waits for ShareLock on transaction 101795; blocked by process 536.
Process 536 waits for ExclusiveLock on tuple (1,4) of relation 16385 of database 16384; blocked by process 538.

요런 에러가 발생하였다. 읽어보니 DB쪽에 데드락이 발생했다는 내용이었다.
그리고 혹시 몰라서 한 번더 돌렸더니 데드락이 발생하지 않았고 생각만큼 데드락이라는건 잘 발생하지 않는 느낌이 들었다.
어찌되었든 발생한거니
데드락이 발생하지 않도록 해야 한다. 데드락이 라는게 100%처리되지는 않는다. 하지만 어느정도 해결이 가능하다고 알고 있다.
근데 데드락을 해결하는 방법도 여러가지가 있는걸로 알고 있다. 대부분 정렬을 통해 데드락을 처리했는데 왜 정렬을 사용하는지 의아야 했다. 다른 방법이 있을까 고민도 해보기 위햐 gpt한테 데드락 해결에 대해 소개해달라고 했다.
그랬더니 다음과 같은 답변을 들을 수 있었다.

🔥 1. 데드락 예방 (Deadlock Prevention)

애초에 데드락이 발생하지 않도록 설계하는 방법

내가 제안했던 방법 중 해당되는 것:

  • 트랜잭션 실행 순서 정리 (ORDER BY id ASC)
    • 같은 순서로 데이터를 접근하도록 강제해서 데드락이 발생하지 않도록 함.
  • FOR UPDATE NOWAIT
    • 락을 기다리지 않고 즉시 실패하도록 해서 데드락을 방지.

🔹 장점: 애초에 데드락이 발생하지 않도록 설계
🔹 단점: 트랜잭션이 실패할 수 있음 (재시도 필요)


🔥 2. 데드락 회피 (Deadlock Avoidance)

데드락이 발생할 가능성이 있으면 미리 감지하고 회피하는 방법

내가 제안했던 방법 중 해당되는 것:

  • lock_timeout 설정
    • 트랜잭션이 너무 오래 대기하면 강제 종료하여 데드락 회피.
  • 트랜잭션을 짧게 유지 (Short Transaction)
    • 트랜잭션이 오래 걸릴수록 데드락 위험이 높아지므로 트랜잭션 크기를 최소화.

🔹 장점: 불필요한 대기를 줄여 데드락 위험 감소
🔹 단점: 트랜잭션이 실패할 수 있음


🔥 3. 데드락 탐지 및 해결 (Deadlock Detection & Resolution)

데드락이 발생하면 감지하여 강제로 해결하는 방법

내가 제안했던 방법 중 해당되는 것:

  • PostgreSQL의 자동 데드락 감지 기능 활용
    • PostgreSQL은 자동으로 데드락을 감지하면 한 트랜잭션을 강제 종료하여 해결함.
    • (ERROR: deadlock detected 로그가 발생하는 이유가 바로 이것!)
  • 재시도 로직 추가 (Retry Mechanism)
    • 트랜잭션이 데드락으로 실패하면 재시도하여 다시 실행하는 방식.

🔹 장점: 데드락 발생 후 자동으로 복구 가능
🔹 단점: 특정 트랜잭션이 강제 종료될 수도 있음 (롤백 필요)


🔥 4. 데드락 무시 (Deadlock Ignoring)

락 충돌을 무시하고 가능한 데이터만 처리하는 방법

내가 제안했던 방법 중 해당되는 것:

  • FOR UPDATE SKIP LOCKED
    • 이미 락이 걸린 행을 건너뛰고 처리 가능한 데이터만 선택함.
    • 데드락이 발생하지 않지만, 일부 데이터가 빠질 수 있음.

🔹 장점: 트랜잭션이 데드락 없이 빠르게 실행됨
🔹 단점: 일부 데이터를 처리하지 못할 가능성이 있음

정리하면

  • 가장 쉬운 해결책: FOR UPDATE SKIP LOCKED
    → 일부 데이터가 빠지는 게 허용된다면 가장 빠른 해결 방법!
  • 실패를 허용할 수 있다면: FOR UPDATE NOWAIT + 재시도 로직
    → 락을 못 잡으면 실패하고, 재시도하면 됨.
  • 데드락 확률을 낮추고 싶다면: ORDER BY id ASC
    항상 같은 순서로 접근해서 데드락을 예방
  • 락 대기 시간을 줄이고 싶다면: lock_timeout
    → 일정 시간 내에 해결되지 않으면 강제 종료

라고 한다. 고로면 어떤것을 선택하는 것이 좋을까?
재고 데이터는 굉장히 중요하다. 그렇기 때문에 FOR UPDATE SKIP LOCKED 이 방법을 사용하는것은 좋지 않다.
왜냐하면 제고를 변경했는데 데드락 때문에 반영이 되지 않는건 이것또한 문제가 될 수 있기 때문이다. 
결국 데드락 무시는 절대로 하면 안되는 것 같구
지금 상황에서 사용할 수 있는건  ORDER BY id ASC 정렬을 사용하는 방법이다.
이는 데드락을 예방하는 방법으로 데드락이 걸리는것을 최소화 시킬 수 있다. 하지만 위에서 말했듯이 이것또한 완벽한 방법은 아니다.
그렇기때문에 조금더 데드락을 잡을 수 있을까?
FOR UPDATE NOWAIT 요 방법으로 재시도 로직을 시도하는것이 데드락을 가장 최소화를 시키는 방법이구
나머지 방법을 추가한다고 크게 데드락처리를 해결하지 못할거 같다.

 

반응형