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

반응형
반응형

내가 저번에 재고 시스템에 동시성 적용하기 (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 요 방법으로 재시도 로직을 시도하는것이 데드락을 가장 최소화를 시키는 방법이구
나머지 방법을 추가한다고 크게 데드락처리를 해결하지 못할거 같다.

 

반응형

'국비지원 (스파르타)' 카테고리의 다른 글

[SA문서작성] 3차 프로젝트  (0) 2025.04.04
[기획] 3차 프로젝트  (0) 2025.04.03
[학습] 데드락이란 무엇일까?  (0) 2025.04.02
[학습] 클린코드  (0) 2025.03.31
[학습] sql injection  (0) 2025.03.28

댓글

Designed by JB FACTORY