https://softmoca.tistory.com/345
해당 포스팅은 위 포스팅과 이어 진다.
최종 동시성 제어 성능 비교 정리 [100명 동시 요청 기준]
동시성 제어시 고려해야할 사항 4가지 중요 우선 순위
- 우선 순위중 하나라도 만족을 못하면 이후 순위들은 무의미해진다.
(EX : 아무리 시간이 준수하고, 사용자 요청 순서가 보장되어도 데이터 무결성이 지켜 지지 않으면 시간과 요청 순서 보장은 의미가 없다.)
- 서버 안정성 ( 터지거나 죽지 않게)
- 데이터 무결성과 일관성 보장
- 시간( 요청을 보내고 응답을 받는데 걸리는 시간) + RDB lock 걸려있을시 다른 사용자 조회도 딜레이
- 사용자의 요청 순서(선착순) 보장
[1] 서버안정성 보장 | [2] 데이터 무결성 보장 | [3]시간 | [4]요청 순서 보장 | |
기본 ORM | O | X | 0.2초 (무의미) | X |
기본 트랜잭션 | O | X | 0.5초(무의미) | X |
비관적 읽기 락 | X(데드락) | O | 0.3초(무의미) | X |
비관적 쓰기 락 | X(데드락) | O | 0.5 초(무의미) | X |
레디스 스핀락 | X(Redis 과부하) | O | 15초(무의미) | O |
레디스 분산락 | O | O | 0.2초 | O |
즉, 동시성 제어 방식 중 왼쪽에 X가 하나라도 있으면 이후 오른쪽이 O여도 의미가 없다.
이전 포스팅에서 동시성을 제어 하는 여러 방식들을 테스트 하며 특징과 한계에 대해 파악해 보았다.
그리고 결국 사용해야 하는 동시성 제어 방식을 선정했고 그 방식이 레디스 분산락(Distribution Lock)이다.
구현해야 하는 API 간단 명세서
사용자는 원하는 가게의 메뉴들과 해당 메뉴의 요청 수량과 함께 주문 결제를 한다.
이때 사용자가 요청한 메뉴 중 수량 보다 많은 경우, 해당 메뉴의 id와 초기 요청 수량 그리고 잔여 수량을 반환해 줘야한다.
응답 값은 두개이다.
재고가 충분한 경우 - 랜덤한 5개의 숫자와 랜던함 문자열 3개를 합친 문자인 orderId를 반환
재고가 부족한 경우 - 부족한 재고 메뉴 각각의 menuId와 초기 요청 갯수, 남은 재고 수량 값이다.
RequestBody
{
"orders": [
{
"menuId": 1,
"quantity": 3
},
{
"menuId": 2,
"quantity": 2
},
{
"menuId": 3,
"quantity": 3
}
]
}
Response Body - 재고가 충분한 경우
{
"success": true,
"statusCode": 200,
"data": {
"orderId": "04630UOA"
}
}
Response Body - 재고가 부족한 경우
{
"success": true,
"statusCode": 200,
"data": [
{
"menuId": 1,
"requestedQuantity": 3,
"stockQuantity": 1
},
{
"menuId": 3,
"requestedQuantity": 3,
"stockQuantity": 1
}
]
}
데이터 베이스 및 레디스 값 세팅
우선 menuId가 1,2,3인경우 각각 재고가 10,11,10으로 저장되어 있다.
또한 레디스로 재고확인을 하기 위해 Key인 menu:{menuId}:id 에 재고 값을 저장한다.
레디스 분산락을 위한 라이브러리 설치
npm install --save ioredis nestjs-modules/ioredis redlock
app.module.ts
RedisModule.forRootAsync({
useFactory: () => ({
type: 'single',
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
}),
}),
레디스 모듈 세팅
order.contoroller.ts
@Patch()
async createOrder(@Body() orderRequest: OrderMenuDto) {
return await this.orderService.checkStockAndLock(orderRequest);
}
해당 컨트롤러로 사용자의 주문 정보를 입력 받고 orderService의 checkStockAndLock함수를 호출한다.
분산락을 사용한 주문 재고 동시성 제어 플로우 STEP
Step 1: 모든 메뉴 항목에 대한 잠금 획득
Step 2: 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
Step 3: 메뉴 항목에 재고가 부족한 경우 부족한 재료 메뉴들 응답
Step 4: 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
Step 5: 획득한 모든 잠금 해제
checkStockAndLock함수는 acquireLocks, checkStock, updateStock, rollbackRedis, releaseLocks, generateOrderId 들로 구성이 되어 있고 각 함수들의 역활을 다음과 같다.
acquireLocks : 모든 메뉴 항목에 대한 잠금 획득
checkStock : 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
updateStock : 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
rollbackRedis : 재고가 부족할 시 Redis 데이터 롤백
releaseLocks : 잠금 헤제
generateOrderId : 주문번호 생성
checkStockAndLock
async checkStockAndLock(order: OrderMenuDto) {
const queryRunner = this.dataSource.createQueryRunner();
const locks: Lock[] = []; // 잠금 획들한 데이터
const redisRollbackData: { key: string; value: number }[] = []; // Redis 롤백 데이터
try {
await queryRunner.connect();
await queryRunner.startTransaction();
// Step 1: 모든 메뉴 항목에 대한 잠금 획득
await this.acquireLocks(order, locks);
// Step 2: 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
const insufficientStock = await this.checkStock(order, redisRollbackData);
// Step 3: 메뉴 항목에 재고가 부족한 경우 부족한 재료 메뉴들 응답
if (insufficientStock.length > 0) {
return insufficientStock;
}
// Step 4: 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
await this.updateStock(order, queryRunner, redisRollbackData);
await queryRunner.commitTransaction();
return { orderId: this.generateOrderId() };
} catch (e) {
await queryRunner.rollbackTransaction();
await this.rollbackRedis(redisRollbackData); // Redis 상태 롤백
this.logger.error(e);
if (e instanceof CommonException) {
throw e;
}
throw OrderExceotion.FAIL_ORDER_TRANSACTION;
} finally {
// Step 5: 획득한 모든 잠금 해제
await this.releaseLocks(locks);
await queryRunner.release();
}
}
컨트롤러에서 해당 함수를 호출하면 짜여진 로직에 맞게 동시성을 제어하며 재고를 확인하고 재고의 부족/충분에 맞는 응답값을 반환한다.
처음 함수가 실행되면 트랜잭션 세팅을 시작한다.
Step 1: 모든 메뉴 항목에 대한 잠금 획득
acquireLocks 함수를 통해 요청 받은 모든 menuId를 토대로 lock을 획득한다.
Step 2: 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
checkStock 함수를 통해 redis에 저장된 재고를 확인하여 재고가 충분한지 부족한지 판단하여 부족한 재고를 가진 메뉴가 있으면 해당 메뉴에 대한 데이터들을 반환해 준다.
Step 3: 메뉴 항목에 재고가 부족한 경우 부족한 재료 메뉴들 응답
이후 부족한 메뉴에 대한 데이터를 반환 받은 값이 있다면 그 즉시 return해 준다.
Step 4: 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
updateStock 함수를 통해 재고가 충분하여 주문이 가능한 요청에 대해 DB와 Redis의 재고를 업데이트 한 뒤 주문 번호를 반환해 준다.
Step 5: 획득한 모든 잠금 해제
finally에서 획득한 lock들을 모두 해제 한다.
acquireLocks
// Step 1: 모든 메뉴 항목에 대한 잠금 획득
private async acquireLocks(order: OrderMenuDto, locks: Lock[]): Promise<void> {
for (const item of order.orders) {
const lock = await this.redlock.acquire([`lock:${item.menuId}:id`], 1000);
locks.push(lock);
}
}
요청 dto와 lock을 획득한 정보를 저장할 배열을 인자로 받는다.
이후 반복문을 돌며 요청받은 menuid에 맞게 lock을 1초간 획득하며 locks 배열에 저장한다.
(해당 로직이 수행되면 해제 되기 때문에 1초에는 크게 신경을 쓰지 않아도 된다.)
checkStock
// Step 2: 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
private async checkStock(
order: OrderMenuDto,
redisRollbackData: { key: string; value: number }[],
): Promise<{ menuId: number; requestedQuantity: number; stockQuantity: number }[]> {
const insufficientStock = [];
for (const item of order.orders) {
const stockKey = `menu:${item.menuId}:id`;
const stockQuantity = await this.redis.get(stockKey);
if (stockQuantity === null) {
throw OrderExceotion.REDIS_NOT_FOUND;
}
const stockInt = parseInt(stockQuantity, 10);
redisRollbackData.push({ key: stockKey, value: stockInt }); // 현재 상태를 저장
// if (item.menuId === 3) {
// throw new Error('인위적으로 발생시킨 예외'); // 트랜잭션 테스트
// }
if (item.quantity > stockInt) {
// throw new Error('재고 수량이 부족합니다 !!!'); // jmeter 부하테스트 가독성
insufficientStock.push({
menuId: item.menuId,
requestedQuantity: item.quantity,
stockQuantity: stockInt,
});
}
}
return insufficientStock;
}
요청 dto와 redisRollBackData라는 배열을 인자로 받는다.
redisRollBackData는 이후 로직에서 재고가 부족한 경우 혹은 에러로 인해 redis의 재고를 원래대로 롤백하기 위한 정보를 담는다.
이후 각 메뉴의 갯수와 재고를 비교하며 부족한 재고가 있는 메뉴가 있다면 insufficientStock에 저장을 한다.
updateStock
// Step 4: 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
private async updateStock(order: OrderMenuDto, queryRunner: QueryRunner): Promise<void> {
for (const item of order.orders) {
await queryRunner.manager.decrement(Menu, { id: item.menuId }, 'count', item.quantity);
const stockKey = `menu:${item.menuId}:id`;
await this.redis.decrby(stockKey, item.quantity);
}
}
요청 dto와 트랜잭션을 위한 queryRunner를 인자로 받는다.
또한 현재 updateStock가 실행된다는것은 모두 재고가 있는 유요한 메뉴들이기에 재고를 업데이트를 해준다.
rollbackRedis
private async rollbackRedis(redisRollbackData: { key: string; value: number }[]): Promise<void> {
for (const item of redisRollbackData) {
await this.redis.set(item.key, item.value.toString());
}
}
redisRollbackData를 인자로 받고 checkStock 함수에서 에러가 발생하여 myslq데이터들이 롤백 될경우 redis의 재고 또한 롤백 시켜준다.
releaseLocks
// Step 5: 획득한 모든 잠금 해제
private async releaseLocks(locks: Lock[]): Promise<void> {
for (const lock of locks) {
try {
await lock.release();
} catch (unlockError) {
this.logger.error(unlockError);
throw OrderExceotion.FAIL_UNLOCK_REDIS;
}
}
}
초기 acquireLocks 함수를 통해 lock을 획득한 key들을 lock 해제한다.
generateOrderId
private generateOrderId(): string {
const digits = Array.from({ length: 5 }, () => Math.floor(Math.random() * 10)).join('');
const letters = Array.from({ length: 3 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(
'',
);
return digits + letters;
}
랜덤한 5개의 문자와 랜덤한 3개의 숫자를 생성해서 합친 뒤 반환해 준다.
PostMan API 테스트
재고가 충분할 시 응답
요청 결과 myslq과 redis의 데이터가 동기화되며 데이터 정합성이 보장이 된다.
또한 랜덤한 문자열 또한 잘 반환한다.
재고가 부족할 시 응답
menuId당 재고가 1,5,1씩 남아 있기 때문에 menuId 1,3은 재고가 부족하다.
그래서 응답값으로 부족한 재고로 요청한 메뉴에 대한 ID, 요청 갯수, 재고 갯수를 반환해 준다.
또한 menuID 2번의 경우에는 재고가 있지만 요청한 횟수만큼 재고가 감소 되지 않고 잘 롤백 되는것을 확인할 수 있다.
트랜잭션 에러 처리
트랜잭션 과정 중 고의로 에러를 던져 에러를 잘 처리 하는지 확인.
에러 처리가 잘 된다.
Redis에 존재하지 않는 메뉴 에러 처리
레디스에 존재하지 않는 메뉴 정보인 1111인으로 요청을 보내면 에러가 잘 잡히는것을 확인할 수 있다.
100명이 동시에 요청하는 동시성 테스트
데이터 베이스 값 다시 세팅.
jemter를 사용해서 100명이 동시에 1번씩 주문요청을 하는 과부하 테스트 진행.
https://softmoca.tistory.com/344
(Jemter의 간단한 사용법 포스팅)
View Results in Table 결과 100개의 요청 모두 0.5초 이내에 응답하는 것을 확인할수 있다.
레디스와 Myslq 의 데이터 정합성 역시 깨지지 않았다.
order.service.ts 전체 코드
import { Injectable, Logger } from '@nestjs/common';
import { Redis } from 'ioredis';
import Redlock, { Lock } from 'redlock';
import { Menu } from 'src/menus/entity/menu.entity';
import { DataSource, QueryRunner } from 'typeorm';
import { InjectRedis } from '@nestjs-modules/ioredis';
import { OrderMenuDto } from './dto/order-menu.dto';
import { OrderExceotion } from 'src/global/exception/order-exceptoin';
import { CommonException } from 'src/global/exception/common-exception';
@Injectable()
export class OrderService {
private redlock: Redlock;
private readonly logger = new Logger(OrderService.name);
constructor(
private readonly dataSource: DataSource,
@InjectRedis() private readonly redis: Redis,
) {
this.redlock = new Redlock([redis], {
retryCount: 10, // 재시도 횟수
retryDelay: 200, // 재시도 지연 (밀리초)
});
}
async checkStockAndLock(order: OrderMenuDto) {
const queryRunner = this.dataSource.createQueryRunner();
const locks: Lock[] = []; // 잠금 획들한 데이터
const redisRollbackData: { key: string; value: number }[] = []; // Redis 롤백 데이터
try {
await queryRunner.connect();
await queryRunner.startTransaction();
// Step 1: 모든 메뉴 항목에 대한 잠금 획득
await this.acquireLocks(order, locks);
// Step 2: 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
const insufficientStock = await this.checkStock(order, redisRollbackData);
// Step 3: 메뉴 항목에 재고가 부족한 경우 부족한 재료 메뉴들 응답
if (insufficientStock.length > 0) {
return insufficientStock;
}
// Step 4: 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
await this.updateStock(order, queryRunner, redisRollbackData);
await queryRunner.commitTransaction();
return { orderId: this.generateOrderId() };
} catch (e) {
await queryRunner.rollbackTransaction();
await this.rollbackRedis(redisRollbackData); // Redis 상태 롤백
this.logger.error(e);
if (e instanceof CommonException) {
throw e;
}
throw OrderExceotion.FAIL_ORDER_TRANSACTION;
} finally {
// Step 5: 획득한 모든 잠금 해제
await this.releaseLocks(locks);
await queryRunner.release();
}
}
private generateOrderId(): string {
const digits = Array.from({ length: 5 }, () => Math.floor(Math.random() * 10)).join('');
const letters = Array.from({ length: 3 }, () => String.fromCharCode(65 + Math.floor(Math.random() * 26))).join(
'',
);
return digits + letters;
}
// Step 1: 모든 메뉴 항목에 대한 잠금 획득
private async acquireLocks(order: OrderMenuDto, locks: Lock[]): Promise<void> {
for (const item of order.orders) {
const lock = await this.redlock.acquire([`lock:${item.menuId}:id`], 1000);
locks.push(lock);
}
}
// Step 2: 재고 확인 및 충분하지 않은 항목 insufficientStock에 저장
private async checkStock(
order: OrderMenuDto,
redisRollbackData: { key: string; value: number }[],
): Promise<{ menuId: number; requestedQuantity: number; stockQuantity: number }[]> {
const insufficientStock = [];
for (const item of order.orders) {
const stockKey = `menu:${item.menuId}:id`;
const stockQuantity = await this.redis.get(stockKey);
if (stockQuantity === null) {
throw OrderExceotion.REDIS_NOT_FOUND;
}
const stockInt = parseInt(stockQuantity, 10);
redisRollbackData.push({ key: stockKey, value: stockInt }); // 현재 상태를 저장
// if (item.menuId === 3) {
// throw new Error('인위적으로 발생시킨 예외'); // 트랜잭션 테스트
// }
if (item.quantity > stockInt) {
// throw new Error('재고 수량이 부족합니다 !!!'); // jmeter 부하테스트 가독성
insufficientStock.push({
menuId: item.menuId,
requestedQuantity: item.quantity,
stockQuantity: stockInt,
});
}
}
return insufficientStock;
}
// Step 4: 재고가 충분할 시 MySQL 및 Redis의 재고 업데이트
private async updateStock(
order: OrderMenuDto,
queryRunner: QueryRunner,
redisRollbackData: { key: string; value: number }[],
): Promise<void> {
for (const item of order.orders) {
await queryRunner.manager.decrement(Menu, { id: item.menuId }, 'count', item.quantity);
const stockKey = `menu:${item.menuId}:id`;
const currentStock = await this.redis.get(stockKey);
redisRollbackData.push({ key: stockKey, value: parseInt(currentStock, 10) });
await this.redis.decrby(stockKey, item.quantity);
}
}
private async rollbackRedis(redisRollbackData: { key: string; value: number }[]): Promise<void> {
for (const item of redisRollbackData) {
await this.redis.set(item.key, item.value.toString());
}
}
// Step 5: 획득한 모든 잠금 해제
private async releaseLocks(locks: Lock[]): Promise<void> {
for (const lock of locks) {
try {
await lock.release();
} catch (unlockError) {
this.logger.error(unlockError);
throw OrderExceotion.FAIL_UNLOCK_REDIS;
}
}
}
}
test/order/order.service.spec.ts 전체코드
import { Test, TestingModule } from '@nestjs/testing';
import { OrderService } from 'src/order/order.service';
import { DataSource, QueryRunner } from 'typeorm';
import { Redis } from 'ioredis';
import Redlock, { Lock } from 'redlock';
import { OrderMenuDto } from 'src/order/dto/order-menu.dto';
import { Menu } from 'src/menus/entity/menu.entity';
import { OrderExceotion } from 'src/global/exception/order-exceptoin';
describe('OrderService', () => {
let service: OrderService;
let dataSourceMock: DataSource;
let redisMock: Redis;
let redlockMock: Redlock;
let queryRunnerMock: QueryRunner;
beforeEach(async () => {
queryRunnerMock = {
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: {
decrement: jest.fn(),
},
} as unknown as QueryRunner;
dataSourceMock = {
createQueryRunner: jest.fn().mockReturnValue(queryRunnerMock),
} as unknown as DataSource;
redisMock = {
get: jest.fn(),
set: jest.fn(),
decrby: jest.fn(),
} as unknown as Redis;
redlockMock = {
acquire: jest.fn().mockResolvedValue({
release: jest.fn(),
} as unknown as Lock),
} as unknown as Redlock;
const module: TestingModule = await Test.createTestingModule({
providers: [
OrderService,
{ provide: DataSource, useValue: dataSourceMock },
{ provide: 'default_IORedisModuleConnectionToken', useValue: redisMock },
],
}).compile();
service = module.get<OrderService>(OrderService);
service['redlock'] = redlockMock;
});
it('OrderService 잘 존재하는지 확인', () => {
expect(service).toBeDefined();
});
describe('acquireLocks ', () => {
it('모든 메뉴 항목에 대해 잠금을 획득하는지 테스트', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
const locks: Lock[] = [];
await service['acquireLocks'](orderRequest, locks);
expect(redlockMock.acquire).toHaveBeenCalledTimes(orderRequest.orders.length);
expect(locks.length).toBe(orderRequest.orders.length);
});
});
describe('checkStock ', () => {
it('수량이 충분하지 않으면 재고 부족목록을 반환하는지 테스트 ', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
// Redis mock 설정
(redisMock.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'menu:1:id') return Promise.resolve('5');
if (key === 'menu:2:id') return Promise.resolve('1'); // 재고 부족
if (key === 'menu:3:id') return Promise.resolve('5');
return Promise.resolve(null);
});
const redisRollbackData = [];
const result = await service['checkStock'](orderRequest, redisRollbackData);
expect(result).toEqual([{ menuId: 2, requestedQuantity: 2, stockQuantity: 1 }]);
});
it('재고가 Redis에서 찾을 수 없는 경우 REDIS_NOT_FOUND를 발생하는지 테스트 ', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
// Redis mock 설정
(redisMock.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'menu:1:id') return Promise.resolve('10');
if (key === 'menu:2:id') return Promise.resolve('11');
if (key === 'menu:3:id') return Promise.resolve(null); // Redis에 menu정보가 없음
return Promise.resolve(null);
});
const redisRollbackData = [];
await expect(service['checkStock'](orderRequest, redisRollbackData)).rejects.toThrow(
OrderExceotion.REDIS_NOT_FOUND,
);
});
});
describe('updateStock ', () => {
it('MySQL과 Redis에서 재고를 업데이트하는지 테스트', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
const redisRollbackData = [];
(redisMock.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'menu:1:id') return Promise.resolve('10');
if (key === 'menu:2:id') return Promise.resolve('11');
if (key === 'menu:3:id') return Promise.resolve('10');
return Promise.resolve(null);
});
await service['updateStock'](orderRequest, queryRunnerMock, redisRollbackData);
expect(queryRunnerMock.manager.decrement).toHaveBeenCalledWith(Menu, { id: 1 }, 'count', 3);
expect(queryRunnerMock.manager.decrement).toHaveBeenCalledWith(Menu, { id: 2 }, 'count', 2);
expect(queryRunnerMock.manager.decrement).toHaveBeenCalledWith(Menu, { id: 3 }, 'count', 3);
expect(redisMock.decrby).toHaveBeenCalledWith('menu:1:id', 3);
expect(redisMock.decrby).toHaveBeenCalledWith('menu:2:id', 2);
expect(redisMock.decrby).toHaveBeenCalledWith('menu:3:id', 3);
});
});
describe('rollbackRedis', () => {
it('Redis 데이터를 원래 상태로 롤백하는지 테스트 ', async () => {
const redisRollbackData = [
{ key: 'menu:1:id', value: 10 },
{ key: 'menu:2:id', value: 11 },
{ key: 'menu:3:id', value: 10 },
];
await service['rollbackRedis'](redisRollbackData);
expect(redisMock.set).toHaveBeenCalledWith('menu:1:id', '10');
expect(redisMock.set).toHaveBeenCalledWith('menu:2:id', '11');
expect(redisMock.set).toHaveBeenCalledWith('menu:3:id', '10');
});
});
describe('checkStockAndLock', () => {
it('재고가 충분한 경우 트랜잭션을 커밋하고 랜덤한5개의 숫자와 3개의문자인 orderId를 반환하는지 테스트', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
// Redis mock 설정
(redisMock.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'menu:1:id') return Promise.resolve('10');
if (key === 'menu:2:id') return Promise.resolve('11');
if (key === 'menu:3:id') return Promise.resolve('10');
return Promise.resolve(null);
});
const result = await service.checkStockAndLock(orderRequest);
expect(queryRunnerMock.startTransaction).toHaveBeenCalled();
expect(queryRunnerMock.commitTransaction).toHaveBeenCalled();
expect(result).toHaveProperty('orderId');
if (!Array.isArray(result)) {
expect(result.orderId).toMatch(/^\d{5}[A-Z]{3}$/); // orderId 형식 확인
}
});
it('재고가 부족한 경우 insufficientStock을 반환하는지 테스트', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
// Redis mock 설정
(redisMock.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'menu:1:id') return Promise.resolve('10');
if (key === 'menu:2:id') return Promise.resolve('1'); // 재고 부족
if (key === 'menu:3:id') return Promise.resolve('10');
return Promise.resolve(null);
});
const result = await service.checkStockAndLock(orderRequest);
expect(queryRunnerMock.startTransaction).toHaveBeenCalled();
expect(result).toEqual([{ menuId: 2, requestedQuantity: 2, stockQuantity: 1 }]);
});
it('재고가 Redis에서 찾을 수 없는 경우 트랜잭션을 롤백하고 예외를 발생하는지 테스트', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
(redisMock.get as jest.Mock).mockImplementation((key: string) => {
if (key === 'menu:1:id') return Promise.resolve('10');
if (key === 'menu:2:id') return Promise.resolve('11');
if (key === 'menu:3:id') return Promise.resolve(null); // Redis에 menu정보가 없음
return Promise.resolve(null);
});
await expect(service.checkStockAndLock(orderRequest)).rejects.toThrow(OrderExceotion.REDIS_NOT_FOUND);
expect(queryRunnerMock.startTransaction).toHaveBeenCalled();
expect(queryRunnerMock.rollbackTransaction).toHaveBeenCalled();
});
it('예외가 발생하면 트랜잭션을 롤백하고 Redis 데이터를 롤백하는지 테스트', async () => {
const orderRequest: OrderMenuDto = {
orders: [
{ menuId: 1, quantity: 3 },
{ menuId: 2, quantity: 2 },
{ menuId: 3, quantity: 3 },
],
};
// Redis mock 설정
(redisMock.get as jest.Mock).mockResolvedValue('10');
// 일부러 예외를 발생시키기 위해 acquireLocks에서 예외를 발생시킴
jest.spyOn(service as any, 'acquireLocks').mockImplementation(() => {
throw new Error('acquireLocks failed');
});
await expect(service.checkStockAndLock(orderRequest)).rejects.toThrow(
OrderExceotion.FAIL_ORDER_TRANSACTION,
);
expect(queryRunnerMock.rollbackTransaction).toHaveBeenCalled();
});
});
});
참고
트랜잭션
https://akasai.space/db/about_isolation/
https://velog.io/@c1madang/Concurrency-Control
https://jh-labs.tistory.com/407
https://joont92.github.io/db/트랜잭션-격리-수준-isolation-level/
Redis
https://velog.io/@hkyo96/Redis-분산락을-이용한-동시성-문제-해결
https://redis.io/docs/latest/develop/use/patterns/distributed-locks/
'외부활동 > immersion' 카테고리의 다른 글
주문 재고 요청시 동시성 제어테스트 [트랜잭션/격리수준/LOCK/REDIS] (0) | 2024.05.28 |
---|---|
JMeter 부하 테스트 (0) | 2024.05.28 |
포트원 결제 연동 플로우 정리 [노마드코더] (1) | 2024.04.27 |
AWS와 Slack 간단 연동하고 에러로그 알람 받기 [Cloudwatch, chatbot,Simple Notification Service ] (0) | 2024.04.19 |
Redis와 분산 락(Distributed Lock) (0) | 2024.03.18 |