며칠전 회사 서비스 중 한 곳에서 타임어택 한정수량 이벤트를 진행했습니다.
그런데 생각보다 많은 사람들이 몰려 서버가 처참히 뻗어버렸습니다.
동시접속자 수가 10만을 훌쩍 넘어버려 서버가 버티질 못한 것이죠. (아니 우리 사이트가 이렇게 인기가 많았나? 😓)
상황의 예시를 들자면 아래와 같습니다.
- 제한 수량 300
- 1 고객 1 지급 제한
서버가 죽는 데에는 이런저런 원인들이 있었지만 역시나 데이터베이스 과부하가 주요 이슈였습니다.
(나로선 여태 이론상으로만 들어오던) 데드락 구경도 실컷 할 수 있었습니다 ^^
이용 고객에게는 짜증나고, 시니어들 입장에서는 골치 아프겠지만.....
응애 개발자 입장에서는 참 좋은 스터디 경험이 아닐 수 없습니다!
express와 sequelize를 활용해 위 로직을 심플하게 구현했습니다.
DB 트랜잭션 처리까지 되어 있어서 어지간해서는 문제가 없어 보입니다만... 앞서 언급한 것처럼 대량의 요청이 들어올 경우 직접적으로 데이터베이스에 접근하는 시도가 많아져 데이터베이스 과부하가 우려되며, 데드락 발생 위험도 높아지기 때문에 개선이 필요합니다.
app.use('/', async (req, res) => {
const transaction = await sequelize.transaction();
const { userId, productId } = req.body;
try {
const order = await orders.findOne({
where: { userId, productId },
transaction,
});
if (order) {
throw new Error('욕심쟁이');
}
await orders.create({ userId, productId }, { transaction });
const product = await products.findOne({ where: productId, transaction });
if (product.dataValues.quantity <= 0) {
throw new Error('품절');
}
await product.update(
{ quantity: product.dataValues.quantity - 1 },
{ transaction }
);
const createdOrder = await orders.findOne({
where: { userId, productId },
transaction,
});
await transaction.commit();
return res.send(createdOrder.dataValues);
} catch (err) {
if (transaction) {
await transaction.rollback();
}
return res.send('뭔가 잘못됨');
}
});
우선 데이터베이스로 전달되는 요청 수를 최소한으로 하는 방안을 고려해야 합니다. 사실 300명 한정으로 판매하는 이벤트라면, 위 SQL 쿼리도 300번만 실행되면 되지 않을까요? 이 때 Redis를 활용하면 여러 문제를 해결할 수 있습니다.
레디스에 대해 간단하게 설명하자면, 키-값 형태로 데이터를 저장하는 비관계형 데이터베이스로, 메모리를 사용하기 때문에 흔히 디스크를 사용하는 다른 데이터베이스보다 훨씬 뛰어난 I/O 성능을 보입니다.
이런 레디스의 장점을 활용하면 아래 기능들에서 향상된 퍼포먼스를 기대할 수 있을 것 같습니다.
1. 레디스를 활용한 재고 카운트
2. 레디스를 활용한 고객 필터링 (중복 지급을 막기 위한)
3. 등등...?
한정수량을 가진 상품(or 쿠폰)의 재고를 레디스로 관리하는 방법은, 여기저기 알아보니 이런 상황에서 가장 많이들 사용하는 방법으로 보였습니다. 해당 상품에 정보를 key로, 재고를 value로 저장하고, 이벤트 상품(or 쿠폰)이 지급될 때마다 카운트를 매기는 것이죠.
우선 특가상품들의 총수량을 레디스에 설정해줍니다. 세 개의 특가 상품이 있다는 가정으로 진행했습니다.
127.0.0.1:6379> SET flash-sales:product:1:quantity 10
OK
127.0.0.1:6379> SET flash-sales:product:2:quantity 100
OK
127.0.0.1:6379> SET flash-sales:product:3:quantity 200
OK
이제 상품재고 체크 및 업데이트 쿼리는 실행할 필요가 없어졌습니다. 레디스에서 재고 조회 & 재고 업데이트가 가능해졌기 때문입니다.
Express 서버 코드도 대응되도록 해줍니다. 여기서는 node-redis npm 패키지를 활용해 redis 클라이언트를 생성했습니다.
decr(레디스 커맨드로는 DECR)라는 명령어는 해당 키의 숫자를 1씩 감소시킵니다. 따라서 재고가 0이 되면 자연스럽게 품절 처리가 될 것으로 기대해볼 수 있습니다.
const { createClient } = require('redis');
const redisClient = createClient();
app.use('/', async (req, res) => {
await redisClient.connect(); // 일단 여기서 connect..
const transaction = await sequelize.transaction();
const { userId, productId } = req.body;
try {
const order = await orders.findOne({
where: { userId, productId },
transaction,
});
if (order) {
throw new Error('욕심쟁이');
}
await orders.create({ userId, productId }, { transaction });
// 수정된 부분 ///////////////////////////
const quantity = await redisClient.get(
`flash-sales:product:${productId}:quantity`
);
if (quantity <= 0) {
throw new Error('품절');
}
await redisClient.decr(`flash-sales:product:${productId}:quantity`);
///////////////////////////////////////
const createdOrder = await orders.findOne({
where: { userId, productId },
transaction,
});
await transaction.commit();
return res.send(createdOrder.dataValues);
} catch (err) {
if (transaction) {
await transaction.rollback();
}
return res.send('뭔가 잘못됨');
}
});
하지만 여전히 불필요하게 데이터베이스에 의존하고 있는 부분이 있습니다. 바로 해당 고객과 상품에 대한 주문이 있는지 확인하는 쿼리인데요, 이 부분도 레디스로 대체할 수 있어 보입니다.
SADD, SISMEMBER 활용
이벤트 상품을 주문한 고객을 Sets 자료구조에 저장하려 합니다. 만일 동일한 고객이 동일한 상품을 또다시 구매하려 한다면 해당 Sets에 이미 고객 정보가 저장되어 있을 것이므로 에러를 throw하게 됩니다.
여기에는 레디스의 SADD와 SISMEMBER 명령어를 활용할 수 있습니다. SADD 명령어는 Sets 자료구조에 값을 추가하는 것이며, SISMEMBER는 Sets 자료구조에서 특정 값을 찾는 기능입니다. 두 명령어 모두 O(1) 복잡도를 가지고 있어 효율성도 뛰어난 편입니다.
const { createClient } = require('redis');
const redisClient = createClient();
app.use('/', async (req, res) => {
await redisClient.connect(); // 일단 여기서 connect..
const transaction = await sequelize.transaction();
const { userId, productId } = req.body;
try {
await orders.create({ userId, productId }, { transaction });
// 수정된 부분 ///////////////////////////
const isAlreadyOrderedUser = await redisClient.sIsMember(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
);
if (isAlreadyOrderedUser) {
throw new Error('욕심쟁이');
}
///////////////////////////////////////
const quantity = await redisClient.get(
`flash-sales:product:${productId}:quantity`
);
if (quantity <= 0) {
throw new Error('품절');
}
await redisClient.decr(`flash-sales:product:${productId}:quantity`);
// 수정된 부분 ///////////////////////////
await redisClient.sAdd(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
);
///////////////////////////////////////
const createdOrder = await orders.findOne({
where: { userId, productId },
transaction,
});
await transaction.commit();
return res.send(createdOrder.dataValues);
} catch (err) {
if (transaction) {
await transaction.rollback();
}
return res.send('뭔가 잘못됨');
}
});
이제 데이터베이스에 의존하던 많은 부분들을 덜어냈습니다! 하지만 문제가 완전히 해결된 건 아닙니다. 레디스라고 해서 동시성 이슈에서 자유롭다고 할 수는 없으니까요.
레디스는 자체적으로 지원하는 트랜잭션 기능이 존재합니다. 다만 관계형 데이터베이스 트랜잭션에 익숙하다면 레디스의 트랜잭션 방식은 좀 낯설 수도 있습니다.
트랜잭션의 시작을 알리는 명령어는 MULTI, 트랜잭션 끝을 알리는 명령어는 EXEC입니다.
MULTI 명령어 뒤의 레디스 명령어들은 큐에 쌓이게 됩니다. 그러다 EXEC을 실행하면 해당 큐에 있는 명령어들을 한번에 batch 실행합니다. 기본적으로 파이프라인을 활용한 트랜잭션을 사용한다고 이해하면 될 것 같습니다.
따라서 트랜잭션 중간에 특정 키의 값을 확인하고, 해당 값에 대한 별도 로직을 처리하고 싶어도 (ex: 트랜잭션 중간에 상품 재고를 확인한다든가...) 불가능합니다. 또한 MULTI, EXEC 조합으로는 롤백도 지원되지 않으므로 좀 더 나은 방안을 찾아봐야겠다는 생각이 들었습니다.
그리고 이런저런 실험을 해본 결과 관계형 데이터베이스의 트랜잭션에서 기대하는 lock이 동작하지 않는 것 같습니다! (내가 잘못한건가?) 해서 좀 더 찾아보니 Optimistic lock을 위한 별개의 기능이 있더군요. 바로 아래 서술할 WATCH라는 기능입니다.
레디스에는 WATCH라는 기능이 있습니다. WATCH('random:key')를 실행하면 'random:key' 값의 변화를 감시하기 시작합니다. 그 이후부터는 MULTI-EXEC 구간 내에서 'random:key' 키의 값은 딱 한 번만 변경 가능합니다. 그 이후로 변경을 시도하는 모든 트랜잭션은 모두 무효로 간주합니다.
다만 npm 패키지들 중 watch에 대한 설명이 명확하게 있는 것이 없어서... ㅎㅎ;; 이걸 Node 환경에서는 어떻게 구현해야 하나 싶은데 그나마 node-redis가 isolated 환경에서의 transaction을 지원하는 듯 합니다.
https://github.com/redis/node-redis/blob/HEAD/docs/isolated-execution.md
const { createClient } = require('redis');
const redisClient = createClient();
app.use('/', async (req, res) => {
await redisClient.connect(); // 일단 여기서 connect..
const transaction = await sequelize.transaction();
const { userId, productId } = req.body;
try {
await orders.create({ userId, productId }, { transaction });
// 수정된 부분 ///////////////////////////
await redisClient.executeIsolated(async (client) => {
await client.watch(`flash-sales:product:${productId}:quantity`);
const isAlreadyOrderedUser = await client.sIsMember(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
);
if (isAlreadyOrderedUser) {
throw new Error('욕심쟁이');
}
const quantity = await client.get(
`flash-sales:product:${productId}:quantity`
);
if (quantity <= 0) {
throw new Error('품절!');
}
await client
.multi()
.sAdd(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
)
.decr(`flash-sales:product:${productId}:quantity`)
.exec();
});
///////////////////////////////////////
const createdOrder = await orders.findOne({
where: { userId, productId },
transaction,
});
await transaction.commit();
return res.send(createdOrder.dataValues);
} catch (err) {
if (transaction) {
await transaction.rollback();
}
return res.send('뭔가 잘못됨');
}
});
다만 이 경우 롤백시 로직을 별도로 처리해주어야 하기 때문에 아래와 같이 바꾸었습니다.
(트랜잭션 내 명령어들이 실행되지 않았다면 재시도)
const { createClient } = require('redis');
const redisClient = createClient();
app.use('/', async (req, res) => {
await redisClient.connect(); // 일단 여기서 connect..
const transaction = await sequelize.transaction();
const { userId, productId } = req.body;
try {
await orders.create({ userId, productId }, { transaction });
// 수정된 부분 ///////////////////////////
await redisClient.executeIsolated(async (client) => {
const result = await executeTransaction(productId, userId);
if (result.includes(null)) {
await executeTransaction();
}
});
///////////////////////////////////////
const createdOrder = await orders.findOne({
where: { userId, productId },
transaction,
});
await transaction.commit();
return res.send(createdOrder.dataValues);
} catch (err) {
if (transaction) {
await transaction.rollback();
}
return res.send('뭔가 잘못됨');
}
});
/**@private */
const executeTransaction = async (productId, userId) => {
await client.watch(`flash-sales:product:${productId}:quantity`);
const isAlreadyOrderedUser = await client.sIsMember(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
);
if (isAlreadyOrderedUser) {
throw new Error('욕심쟁이');
}
const quantity = await client.get(
`flash-sales:product:${productId}:quantity`
);
if (quantity <= 0) {
throw new Error('품절!');
}
const result = await client
.multi()
.sAdd(`flash-sales:product:${productId}:requested-user-ids`, `${userId}`)
.decr(`flash-sales:product:${productId}:quantity`)
.exec();
};
쟁점
- 설명을 읽어보니 각 트랜잭션마다 독립적인 커넥션 풀을 이용하는 것 같은데... 과연 커넥션풀이 얼마나 생성이 될지....? 🤔
위와 같은 이유와, 롤백시 구현을 별도 처리해주어야 한다는 점 등... 레디스에서의 트랜잭션 구현이 꽤나 복잡하다는 생각이 들었습니다. 따라서 레디스에 간단하게 자물쇠 키를 추가하고, 해당 자물쇠 키를 가진 클라이언트만 RDB에 접근할 수 있도록 하는 방법도 시도해보도록 하겠습니다. (일종의 sleep 활용)
가장 popular한 레디스 락 구현 패키지는 redlock인데, redlock은 ioredis 패키지로 구현된 레디스 클라이언트에 절대적으로 의존하고 있어서 💦💦💦💦💦💦💦 node-redis 클라이언트가 사용가능한 패키지로 맛만 보겠습니다.
https://www.npmjs.com/package/redis-lock
락 해제시까지 에러가 발생하지 않았다면 문제가 없다는 뜻이므로, 그 후에 주문을 생성합니다.
const locker = require('redis-lock')(redisClient, 1000); // retry delay 1초
app.use('/', async (req, res) => {
await redisClient.connect();
const transaction = await sequelize.transaction();
const { userId, productId } = req.body;
try {
////////////// 🔒 lock 시작!!!! ///////////////
const locked = await locker(`lock:flash-sales:product:${productId}`);
const quantity = await redisClient.get(
`flash-sales:product:${productId}:quantity`
);
if (quantity <= 0) {
throw new Error('품절');
}
const isAlreadyOrderedUser = await redisClient.sIsMember(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
);
if (isAlreadyOrderedUser) {
throw new Error('욕심쟁이');
}
await redisClient.decr(`flash-sales:product:${productId}:quantity`);
await redisClient.sAdd(
`flash-sales:product:${productId}:requested-user-ids`,
`${userId}`
);
await locked();
////////////// 🔓 lock 해제 완료!!!! //////////////
await orders.create({ userId, productId }, { transaction });
const createdOrder = await orders.findOne({
where: { userId, productId },
transaction,
});
await transaction.commit();
return res.send(createdOrder.dataValues);
} catch (err) {
if (transaction) {
await transaction.rollback();
}
return res.send('뭔가 잘못됨');
}
});
쟁점
- ioredis 패키지에만 의존하는 redlock과 node-redis 패키지에만 의존하는 redis-lock... 아슬아슬한 오픈소스 의존성을 가지게 될 듯
- redlock은 retry counter (락을 획득하지 못했을때 시행할 맥시멈 재시도횟수) 기능이 있지만 redis-lock은 없음
- 지속적으로 락을 획득하려 레디스에 접근하는데, 레디스에 부하가 가지 않는가?
위 코드에서 아쉬운 점이 있다면 바로 주문을 생성하는 부분인데요, 해당 부분도 레디스로 처리할 수 있지 않을까? 싶은 생각이 들었습니다.
아예 주문 생성에 필요한 정보들을 stringify된 객체 형태로 레디스에 담아두고, 모든 한정수량의 재고가 떨어지면 다른 worker 서버에게 주문 데이터를 일괄 생성하게끔 하는 방법이죠.
다만 이 방법은 관리해야 할 인프라 소스가 늘어나기 때문에 상황에 맞게 적용시키는게 맞을 것 같습니다.
[Redis]패턴 조건을 활용해 key 집합 추출하기(SCAN) (1) | 2023.07.10 |
---|---|
mongoose로 relation 설정하기 (populate 이용하기) (1) | 2020.09.26 |
MongoDB Atlas를 사용해 Node.js로 데이터베이스 연결하기 (0) | 2020.09.16 |
(MacOS)mongoDB 설치 + 인증 설정 +mongoDB Compass 접속 (3) | 2020.09.06 |
댓글 영역