reservation 예약내역, shows의 quantity, user의 credit의 일관성은 지켜졌다. 하지만show의 quantity보다 더 많이 사는 일이 발생함.
동시성(concurrency): 여러 요청이 동시에 동일한 자원(data)에 접근하고 수정하려는 것
변경 전 코드
router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
const show = await tx.shows.findFirst({
where: { showId: +showId },
});
if (!show) {
return res.status(400).json({ message: '찾는 공연이 없습니다.' });
}
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (show.quantity > 0) {
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
} else {
throw new Error('예매 수량이 부족합니다.');
}
if (user.credit >= show.price) {
await tx.$executeRaw`UPDATE users SET credit = credit - ${show.price} WHERE userId=${userId};`;
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${show.showId});`;
} else {
throw new Error('보유한 credit이 부족합니다.');
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
},
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
console.log(`catch로 빠진 ${error}`);
next(error);
if (transaction) {
await prisma.$executeRaw`ROLLBACK`;
}
}
});
팀원이 코드를 수정하여 show의 quantity 보다 많이 사는 경우는 없어졌다. 하지만 또다른 문제가 생겼다. 현재 진행 중인 트랜잭션 내에서 tx 객체를 통해 데이터를 업데이트한 후에도 최신 데이터를 가져오기 위함. 트랜잭션 내에서 $executeRaw를 사용하여 업데이트 쿼리를 실행했고, 그 이후에 prisma를 사용하여 최신 데이터를 가져온것임.
변경 코드
router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
const show = await tx.shows.findFirst({
where: { showId: +showId },
});
if (!show) {
console.log(`${userId} : 공연없음`);
return res.status(400).json({ message: '찾는 공연이 없습니다.' });
}
const user = await tx.users.findFirst({
where: { userId: +userId },
});
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
let updatedShow = await tx.shows.findFirst({
where: { showId: +showId },
});
if (updatedShow.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
if (user.credit >= show.price) {
await tx.$executeRaw`UPDATE users SET credit = credit - ${show.price} WHERE userId=${userId};`;
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${show.showId});`;
} else {
console.log(`${userId} : credit부족`);
throw new Error('보유한 credit이 부족합니다.');
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
},
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
console.log(`catch로 빠진 ${error}`);
next(error);
if (transaction) {
await prisma.$executeRaw`ROLLBACK`;
}
}
});
duration: 30, arrivalRate: 10, count: 1인 상태에서 shows의 quantity가 101장이 있는 상황에서 user 모두 구매하지 못하는 상황이 발생했다. user는 한 장씩 살 수 있는 credit이 존재하고, user의 인원은 105명이다. 중간에 충돌하여 예외로 빠져서(credit이 부족하다는 에러로) 구매를 못하는것처럼 보였다.



router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
let updatedShow = await prisma.shows.findFirst({
where: { showId: +showId },
});
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (user.credit < updatedShow.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
//return => throw new Error 변경
throw new Error('credit이 부족합니다.');
}
if (updatedShow.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
await tx.$executeRaw`UPDATE users SET credit = credit - ${updatedShow.price} WHERE userId=${userId};`;
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${showId});`;
},
{
//isoltion level 조절해봤음
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
},
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
if (transaction) {
await prisma.$executeRaw`ROLLBACK`;
}
console.log(`catch로 빠진 ${error}`);
next(error);
}
});
RepeatableRead -> Serialzable
router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
let updatedShow = await prisma.shows.findFirst({
where: { showId: +showId },
});
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (!user) {
console.log('유저정보를 찾을 수 없음');
throw new Error('유저 정보를 찾을 수 없습니다.');
}
if (user.credit < updatedShow.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
//return => throw new Error 변경
throw new Error('credit이 부족합니다.');
}
if (updatedShow.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
await tx.$executeRaw`UPDATE users SET credit = credit - ${updatedShow.price} WHERE userId=${userId};`;
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${showId});`;
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
if (transaction) {
await prisma.$executeRaw`ROLLBACK`;
}
console.log(`catch로 빠진 ${error}`);
next(error);
}
});

이상하다. 동시성이 안잡힌다.
여러 사용자가 동시에 같은 공연을 예매하려는 경우, 해당 공연 정보에 대한 FOR UPDATE를 사용하여 잠금을 설정할 수 있는데 이렇게 하면 여러 트랜잭션이 동시에 같은 공연에 접근하는 것을 방지할 수 있다.
// 기존 코드
// let updatedShow = await prisma.shows.findFirst({
// where: { showId: +showId },
// });
// rawquery 로 변경 후 명시적으로 lock 건 코드
let updatedShow =
await prisma.$queryRaw`SELECT * FROM shows WHERE showId = ${showId} FOR UPDATE;`;
// 기존 코드
const user = await tx.users.findFirst({
where: { userId: +userId },
});

shows 테이블에 명시적 lock을 걸었는데 에러가 계속 발생했다. shows에는 credit이 없는데 왜 이런 에러가 발생한걸까. user 정보를 못찾아서null이 뜨는건가 싶어 예외처리를 해줬다.
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (!user) {
console.log('유저정보를 찾을 수 없음');
throw new Error('유저 정보를 찾을 수 없습니다.');
}
그럼에도 에러는 해결되지 않았다.
(updatedShow는 업데이트된 내역을 조회하는 역할로 두고, 기존에 for update를 이용해 테이블을 막는 구문을 코드의 초반에 추가로 작성해줬다.)
알고보니 credit 값을 업데이트할 때 show.price를 사용하고 있는데 show 변수는 FOR UPDATE 구문을 사용하여 잠금을 설정한 채로 사용자 정보를 가져온 것이기 때문에, 해당 사용자의 크레딧이 이미 잠겨있을수있다. 이로 인해 다음 쿼리에서 크레딧을 업데이트하려고 할 때, credit 값이 null인 경우가 발생하여 "Column 'credit' cannot be null" 에러가 발생한것으로 생각된다.
그렇다면 credit을 사용하는 부분에서 show가 아니라 updatedShow로 변경해보도록하자.
수정 전 코드
const show = await tx.$queryRaw`SELECT * FROM shows WHERE showId = ${showId} FOR UPDATE;`;
console.log('전의 쇼', show[0].quantity);
if (user.credit < show.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
//return => throw new Error 변경
throw new Error('credit이 부족합니다.');
}
if (show.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
let updatedShow = await prisma.shows.findFirst({
where: { showId: +showId },
});
await tx.$executeRaw`UPDATE users SET credit = credit - ${show.price} WHERE userId=${userId};`;
수정 후 코드
const show = await tx.$queryRaw`SELECT * FROM shows WHERE showId = ${showId} FOR UPDATE;`;
console.log('전의 쇼', show[0].quantity);
if (user.credit < show.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
//return => throw new Error 변경
throw new Error('credit이 부족합니다.');
}
if (show.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
let updatedShow = await prisma.shows.findFirst({
where: { showId: +showId },
});
await tx.$executeRaw`UPDATE users SET credit = credit - ${updatedShow.price} WHERE userId=${userId};`; //이 부분 수정 show -> updatedShow
다행히 에러는 나오지 않았지만 역시나 동시성 문제가 해결되지 않았다.
그럼 격리수준과 같이 명시적 lock이 사용되어서 문제가 발생한건 아닐까? 격리수준을 삭제해보자.
router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
// let updatedShow = await prisma.shows.findFirst({
// where: { showId: +showId },
// });
let updatedShow =
await prisma.$queryRaw`SELECT * FROM shows WHERE showId = ${showId};`;
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (!user) {
console.log('유저정보를 찾을 수 없음');
throw new Error('유저 정보를 찾을 수 없습니다.');
}
if (user.credit < updatedShow.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
//return => throw new Error 변경
throw new Error('credit이 부족합니다.');
}
if (updatedShow.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
await tx.$executeRaw`UPDATE users SET credit = credit - ${updatedShow.price} WHERE userId=${userId};`;
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${showId});`;
},
// {
// isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
// },
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
if (transaction) {
await prisma.$executeRaw`ROLLBACK`;
}
console.log(`catch로 빠진 ${error}`);
next(error);
}
});
주석 처리를 해줬음에도 불구하고 같은 에러가 계속해서 발생했다.
⚫ 비관적 동시성 제어 : 사용자들이 같은 데이터를 동시에 수정 할 것이라고 가정
: 한사용자가 데이터를 읽는 시점에 Lock을 걸고 조회 또는 갱신 처리가 완료될 때 까지 유지한다. 그러므로 첫번째 사용자가 트랜잭션을 완료하기 전까지 다른 사용자들이 데이터를 수정할수 없기때문에 제어를 잘못하면 동시성을 저해
⚫ 낙관적 동시성 제어 : 사용자들이 같은 데이터를 동시에 수정하지 않을 것이라고 가정
: 데이터를 읽을때는 Lock을 설정하지 않는다. 그러므로 데이터를 수정하고자 하는 시점에 앞서 반드시 읽은데이터가 다른 사용자에 의해 변경 되었는지를 검사해야한다.
router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (!user) {
throw new Error('유저 정보를 찾을 수 없습니다.');
}
//락걸기
await tx.$queryRaw`SELECT * FROM shows WHERE showId = ${showId} FOR UPDATE;`;
const show = await tx.shows.findFirst({
where: { showId: +showId },
});
console.log('전의 쇼', show.quantity);
console.log('쇼의 가격', show.price);
console.log('사용자의 credit', user.credit);
if (user.credit < show.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
throw new Error('credit이 부족합니다.');
}
if (show.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
let updatedShow = await prisma.shows.findFirst({
where: { showId: +showId },
});
await tx.$executeRaw`UPDATE users SET credit = credit - ${show.price} WHERE userId=${userId};`;
if (show.quantity === updatedShow.quantity) {
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${showId});`;
} else {
console.log('후의 쇼', updatedShow.quantity);
throw new Error('앞뒤가 다릅니다.');
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
},
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
console.log(`catch로 빠진 ${error}`);
next(error);
}
});
역시나 동시성이 제어가 안됐다. 더이상 쿼리에서 뭘 할 수 없겠다는 판단이 들었고, 다른 방법에 대해서 찾아보기시작했다. 그때 들어온 키워드 직렬화.
여태 작업들이 병렬적으로 처리됐기때문에 자꾸 동시성 제어가 힘들었던걸로 판단이 된다. 고로 직렬적으로 처리되면 동시성 문제가 해결되지 않을까. 유명한 인메모리 redis에 대해서 찾아보기 시작했다.
/** 공연 예매 **/
router.post('/reservation/:showId', authMiddleware, async (req, res, next) => {
let transaction;
try {
const { showId } = req.params;
const { userId } = req.user;
transaction = await prisma.$transaction(
async (tx) => {
const user = await tx.users.findFirst({
where: { userId: +userId },
});
if (!user) {
throw new Error('유저 정보를 찾을 수 없습니다.');
}
//락걸기
await tx.$queryRaw`SELECT * FROM shows WHERE showId = ${showId} FOR UPDATE;`;
const show = await tx.shows.findFirst({
where: { showId: +showId },
});
console.log('전의 쇼', show.quantity);
console.log('쇼의 가격', show.price);
console.log('사용자의 credit', user.credit);
if (user.credit < show.price || user.credit === 0) {
console.log(`${userId} : credit부족`);
throw new Error('credit이 부족합니다.');
}
if (show.quantity <= 0) {
console.log(`${userId} : 예매수량부족`);
throw new Error('예매 수량이 부족합니다.');
}
await tx.$executeRaw`UPDATE Shows SET quantity = quantity-1 WHERE showId=${showId};`;
let updatedShow = await prisma.shows.findFirst({
where: { showId: +showId },
});
await tx.$executeRaw`UPDATE users SET credit = credit - ${show.price} WHERE userId=${userId};`;
if (show.quantity === updatedShow.quantity) {
await tx.$executeRaw`INSERT INTO reservation(UserId, ShowId) VALUES (${user.userId}, ${showId});`;
} else {
console.log('후의 쇼', updatedShow.quantity);
throw new Error('앞뒤가 다릅니다.');
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);
return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });
} catch (error) {
console.log(`catch로 빠진 ${error}`);
next(error);
}
});

lock 걸었을때 RepeatableRead

lock 안걸었을때 RepeatableRead

같은 캡쳐가 아니다...평균 응답시간이 같고, 가상유저 세션 길이는 lock을 안걸고한게 좀 더 짧다.
lock 걸었을때 Serializable

lock 안걸었을때 Serializable

lock을 안걸었을때가 아무래도 더 낫다. 동시성 제어가 안될바에는 그냥 속도라도 갖고가자.