재고 시스템에 동시성 적용하기 (2)
- 국비지원 (스파르타)
- 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 요 방법으로 재시도 로직을 시도하는것이 데드락을 가장 최소화를 시키는 방법이구
나머지 방법을 추가한다고 크게 데드락처리를 해결하지 못할거 같다.
'국비지원 (스파르타)' 카테고리의 다른 글
[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 |