//메모
부하테스트 툴로 선택한 알틀러리를 공부해야한다. 우선 팀원 각자 로컬환경에서 돌려보기로했다. 그러기위해서는 같은 시나리오로 진행을 해야 비교적 객관적인 평가가 이뤄진다고 생각했다. 그렇게 기존적인 시나리오 구성을 했다.
상황 1. 100석 좌석이 있는 1개의 공연이 인기가 많아 예매자 10000명이 몰리는 상황.
상황 2. 100석 좌석이 있는 공연 3개에서 2000명씩 몰리는 상황.
점진적으로 부하를 늘리면서 확인을 해야하기 때문에 더 디테일한 시나리오가 필요했다.
서비스의 평소 트래픽과 최대 트래픽 상황에서 성능이 어떤지 확인합니다. 이 때 기능이 정상 동작하는지도 확인합니다.
애플리케이션 배포 및 인프라 변경(scale out, DB failover 등)시에 성능 변화를 확인합니다.
외부 요인(결제 등)에 따른 예외 상황을 확인합니다.
### **상황 1을 쪼개서**
**로그인 완료**
1-1. 100석 좌석 100명이 몰리는 상황 (1:1)(아무런 문제가 되지않을 것 같다)
1-2. 100석 좌석 500명이 몰리는 상황 (1:5)
1-3. 100석 좌석 1000명이 몰리는 상황 (1:10)
1-4. 100석 좌석 5000명이 몰리는 상황 (1:50)
1-5. 100석 좌석 10000명이 몰리는 상황(1:100)
**회원가입 후 로그인 완료**
1-1. 100석 좌석 100명이 몰리는 상황 (1:1)(아무런 문제가 되지않을 것 같다)
1-2. 100석 좌석 500명이 몰리는 상황 (1:5)
1-3. 100석 좌석 1000명이 몰리는 상황 (1:10)
1-4. 100석 좌석 5000명이 몰리는 상황 (1:50)
1-5. 100석 좌석 10000명이 몰리는 상황(1:100)
### **상황 2를 쪼개서**
**로그인 완료**
2-1. 100석 좌석 3개 공연 100명씩 몰리는 상황 (각 공연당 1:1)
2-2. 100석 좌석 3개 공연 500명씩 몰리는 상황 (각 공연당 1:5)
2-3. 100석 좌석 3개 공연 1000명씩 몰리는 상황 (각 공연당 1:10)
2-4. 100석 좌석 3개 공연 5000명씩 몰리는 상황 (각 공연당 1:50)
2-5. 100석 좌석 3개 공연 10000명씩 몰리는 상황 (각 공연당 1:100)
**회원가입 후 로그인 완료**
2-1. 100석 좌석 3개 공연 100명씩 몰리는 상황 (각 공연당 1:1)
2-2. 100석 좌석 3개 공연 500명씩 몰리는 상황 (각 공연당 1:5)
2-3. 100석 좌석 3개 공연 1000명씩 몰리는 상황 (각 공연당 1:10)
2-4. 100석 좌석 3개 공연 5000명씩 몰리는 상황 (각 공연당 1:50)
2-5. 100석 좌석 3개 공연 10000명씩 몰리는 상황 (각 공연당 1:100)
상황 3. 예매하기 : 다양성 증가(실생활 반영)
메인 페이지 공연 클릭 -> 예매하기 누르기 -> 로그인 페이지 이동하여 로그인 -> 메인화면 공연 클릭 -> 예매하기 누르기
---
**사용자 로그인 및 예매**
사용자 로그인 -> 공연 예매 페이지 공연 선택 -> 예매하기 버튼 클릭
동시 다수의 사용자 로그인 및 예매 시도
1. test
- duration: 100
- arrivalRate: 5
100석 100명
100석 500명
100석 1000명
**대규모 예매 동시 진행**
대규모 이벤트의 예매가 동시에 열리는 상황 가정. 다수의 사용자 동시에 예매 시도
100석 10000명
**다양한 시간대에서 예매 시도?**
사용자 로그인 -> 공연 예매 페이지 공연 선택 -> 예매하기 버튼 클릭
특정 시간대 예매 트래픽 집중 관련(영향이 있는건가?)
config: environments: local: target: 'http://localhost:3334' phases: - duration: 10 arrivalRate: 10 name: Warm up defaults: User-Agent: 'Artillery' payload: - path: 'users.csv' fields: - 'username' - 'password' - path: 'usersToken.csv' fields: - 'userToken' scenarios: - name: '공연 예매' flow: - loop: - post: url: '/api/reservation/3' headers: authorization: '{{userToken}}' count: 1


골때리는건 reservation 테이블에는 이렇게 저장이 되어있었다.
한 사람이 4번씩 예약한 것도 있고, 2번씩 예약한 사람도 있고, 뒤죽박죽이다. 왜이런거지?
=> 내가 코드를 잘못 넣었다! create를 처음과 마지막에 넣는 바람에 오류없이 그냥 create된듯했다. 그래서 코드를 수정하고 db를 리셋하고 다시 실행해봤다.

이번에는 위와 같이 실행이 되었다. 82번의 200번 상태코드. reservataion의 갯수도 82개, users의 credit빠진 횟수도 82회. 다만 숫자가 안맞는건 shows의 quantity였다. 생각컨데
update 중간에 끼어들어서 교착상태가 발생하지 않았나 생각이 들었다.
시도 1 그래서 reservation api 중간에 유효성검사로 400으로 빠지기전에 콘솔로 메시지가 뜨도록 처리해봤다.
/** 공연 예매 **/ router.post('/reservation/:showId', authMiddleware, async (req, res, next) => { let transaction; try { const { showId } = req.params; const { userId } = req.user; const show = await prisma.shows.findFirst({ where: { showId: +showId }, }); if (!show) { console.log('공연없음'); //여기 return res.status(400).json({ message: '찾는 공연이 없습니다.' }); } const user = await prisma.users.findFirst({ where: { userId: +userId }, }); transaction = await prisma.$transaction(async (tx) => { //예매한다면, show의 quantity 하나 줄이기 if (show.quantity > 0) { await tx.shows.update({ where: { showId: +showId }, data: { quantity: --show.quantity }, }); } else { console.log('예매수량부족'); //여기 return res.status(400).json({ message: '예매 수량이 부족합니다.' }); } //계속해서 user의 credit도 줄어들기 if (user.credit > show.price) { await tx.users.update({ where: { userId: +userId }, data: { credit: user.credit - show.price }, }); } else { console.log('credit부족'); //여기 return res.status(400).json({ message: '보유한 credit이 부족합니다.' }); } await tx.reservation.create({ data: { UserId: user.userId, ShowId: show.showId }, }); return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' }); }); } catch (error) { console.log('catch로 빠진 error', error); //여기 if (transaction) { await prisma.$executeRaw`ROLLBACK`; } next(error); } });
아니근데 400상태가 뜬 이유는 뭘까? 시도1을 하면서 오히려 400이 뜬 이유를 알아버렸다.


128번째를 user.credit가 show.price보다 클 때만 update가 되도록 했으니 당연히 credit이 부족하다고 뜰 수밖에!!! 아래와 같이 수정하고 db초기화 후 다시 실행해봤다.

흠 사용자의 credit은 충분했는데 왜 예매가 안된걸까. 위에서 말했던 교착상태에 이른걸까? 아니근데 왜 오류가 안뜨는건데.

수량이 충분한데 트랜잭션 순서가 맞지않아 결제가 안되는것이라 생각됨.
update 중간에 다른 update를 하려고 하니 예매내역과 예매된 수량이 안맞는일이 발생한듯하다.
isolation level을 조절해보도록 하자.
시도 2 isolation level 조절
/** 공연 예매 **/ router.post('/reservation/:showId', authMiddleware, async (req, res, next) => { let transaction; try { const { showId } = req.params; const { userId } = req.user; const show = await prisma.shows.findFirst({ where: { showId: +showId }, }); if (!show) { console.log(`${userId} : 공연없음`); return res.status(400).json({ message: '찾는 공연이 없습니다.' }); } const user = await prisma.users.findFirst({ where: { userId: +userId }, }); transaction = await prisma.$transaction( async (tx) => { //예매한다면, show의 quantity 하나 줄이기 if (show.quantity > 0) { await tx.shows.update({ where: { showId: +showId }, data: { quantity: --show.quantity }, }); } else { console.log(`${userId} : 예매수량부족`); return res.status(400).json({ message: '예매 수량이 부족합니다.' }); } //계속해서 user의 credit도 줄어들기 if (user.credit >= show.price) { await tx.users.update({ where: { userId: +userId }, data: { credit: user.credit - show.price }, }); } else { console.log(`${userId} : credit부족`); return res .status(400) .json({ message: '보유한 credit이 부족합니다.' }); } await tx.reservation.create({ data: { UserId: user.userId, ShowId: show.showId }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, }, ); return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' }); } catch (error) { console.log(`catch로 빠진 ${error}`); if (transaction) { await prisma.$executeRaw`ROLLBACK`; } next(error); } });

똑같은 현상 발견
시도 3 return문 대신 throw new Error를 사용
자료를 찾다가 발견했다. prisma transaction안에서 throw new Error를 사용하면 에러에 걸리면 자동 롤백이 된다고. 홀랑방구 난 몰랐다. 그래서 내부의 return으로 응답을 뺀것을 throw new Error로 대체했다.
/** 공연 예매 **/ router.post('/reservation/:showId', authMiddleware, async (req, res, next) => { let transaction; try { const { showId } = req.params; const { userId } = req.user; const show = await prisma.shows.findFirst({ where: { showId: +showId }, }); if (!show) { console.log(`${userId} : 공연없음`); return res.status(400).json({ message: '찾는 공연이 없습니다.' }); } const user = await prisma.users.findFirst({ where: { userId: +userId }, }); transaction = await prisma.$transaction( async (tx) => { //예매한다면, show의 quantity 하나 줄이기 if (show.quantity > 0) { await tx.shows.update({ where: { showId: +showId }, data: { quantity: --show.quantity }, }); } else { console.log(`${userId} : 예매수량부족`); throw new Error('예매 수량이 부족합니다.'); } //계속해서 user의 credit도 줄어들기 if (user.credit >= show.price) { await tx.users.update({ where: { userId: +userId }, data: { credit: user.credit - show.price }, }); } else { console.log(`${userId} : credit부족`); throw new Error('보유한 credit이 부족합니다.'); } // 예약 내역 기록 await tx.reservation.create({ data: { UserId: user.userId, ShowId: show.showId }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, }, ); return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' }); } catch (error) { console.log(`catch로 빠진 ${error}`); next(error); if (transaction) { await prisma.$executeRaw`ROLLBACK`; } } });
그랬더니 자꾸 에러발생했을때 사라지던 shows의 잔여수량이 드디어 남아있게되었다. 좋아 이제야 드디어 트랜잭션이 제대로 실행이 되는듯하다.! 지금까지는 부하테스트는 아니고, 그냥 오류잡는 일이었다.



config: environments: local: target: 'http://localhost:3334' phases: - duration: 10 arrivalRate: 10 name: Warm up defaults: User-Agent: 'Artillery' payload: - path: 'users.csv' fields: - 'username' - 'password' - path: 'usersToken.csv' fields: - 'userToken' scenarios: - name: '공연 목록 조회 - 상세조회 - 예매' flow: #공연 목록 조회 - get: url: '/api/reservation' #공연 상세 조회 - get: url: '/api/reservation/1' - get: url: '/api/reservation/2' - get: url: '/api/reservation/3' - loop: - post: url: '/api/reservation/3' headers: authorization: '{{userToken}}' count: 1```

400 상태코드 : user의 credit 부족

400 상태코드 : user의 credit 부족

왜 예매수량이 먼저뜨지? 돈이없다고 먼저떠야하는거 아닌가? -> 로직상 예매수량이 유효성 검사가 먼저 있어서 뜬거임.
400 상태코드 : shows의 quantity 부족
config: environments: local: target: 'http://localhost:3334' phases: - duration: 60 arrivalRate: 10 name: Warm up defaults: User-Agent: 'Artillery' payload: - path: 'users.csv' fields: - 'username' - 'password' - path: 'usersToken.csv' fields: - 'userToken' scenarios: - name: '공연 목록 조회 - 상세조회 - 예매' flow: #공연 목록 조회 - get: url: '/api/reservation' #공연 상세 조회 - get: url: '/api/reservation/1' - get: url: '/api/reservation/2' - get: url: '/api/reservation/3' - loop: - post: url: '/api/reservation/3' headers: authorization: '{{userToken}}' count: 10```

문제가 발생했다. shows의 quantity는 47개 줄었고, reservation 예약은 123번되고, user의 credit은 0원이 되었다.
(reservation은 너무 길어서 첨부못하고, 유저별로 예약된 숫자가 달랐다. 10번 예약할수있는 credit을 줬는데 17~18번한 유저도 있었다. 돈이 어디서 나서 했는지...)
잠깐만 나 onUpdate 기능을 스키마에 넣었었나..?

안넣었었다. 그래서 동시성 제어의 문제가 나온걸까?
스키마를 변경해보도록 하자. 이상한게 mysql에는 On Update : CASCADE, On Delete : CASCADE 로 되어있는거 보면 다 되어있는거 같은데 말이지.


스키마로도 수정해서 npx prisma db push 해줬는데도 똑같은 현상이 발생했다.
shows에서는 45개가 줄었고, reservation에는 138개의 예약이 되었고, users에는 100번을 예약할 수 있는 credit이 줄어들었다.
트랜잭션의 원자성(모아니면 도)에 위배되는데 이걸 어떻게 하면 좋을까.
상황은 변하지 않았다. 효과 ZERO



대번에 dead lock이 걸려버렸다.
자꾸 유저가 살 수 있는 갯수보다 더 많이 사는게 문제이니 유저의 credit을 공연의 가격으로 나눈 자연수 값이 0보다 크면 다음 로직(사용자가 가진 credit에서 공연 가격을 빼서 갱신하는 로직과 reservation을 생성하는 로직)을 진행하는 것이다.
if (Math.floor(user.credit / show.price) > 0){}

오! 드디어 shows의 quantity가 0가 나왔다!


이런...실패다. 돈을 덜쓰고 어디서 돈을 가져와서 더 쓰는 사용자가 있다~?~?~?
credit을 줄였는데도 사는 유저는 처음부터 걸러야겠다
3-1에서 사용했던 조건문을 트랜잭션의 코드들을 감싸줬다.
transaction = await prisma.$transaction( async (tx) => { if (Math.floor(user.credit / show.price) > 0) { if (show.quantity > 0) { await tx.shows.update({ where: { showId: +showId }, data: { quantity: --show.quantity }, }); } else { console.log(`${userId} : 예매수량부족`); throw new Error('예매 수량이 부족합니다.'); } if (user.credit >= show.price) { await tx.users.update({ where: { userId: +userId }, data: { credit: user.credit - show.price }, }); await tx.reservation.create({ data: { UserId: user.userId, ShowId: show.showId }, }); } else { console.log(`${userId} : credit부족`); throw new Error('보유한 credit이 부족합니다.'); } } }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }, ); return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' });




현저하게 줄어버린 400 상태코드들에 잠깐 신났다가 shows의 아직 남아있는 갯수를 보고 실망했다. 허나 reservation 테이블을 보아하니 그래도 잘못예매하는 갯수가 줄은건 확실하다.
chatGPT한테 물어보니 단일 트랜잭션으로 원자성과 일관성을 유지할 수 있다는 말을 했다. 그렇다면 일단 모든 것들을 트랜잭션에 때려박아보자.
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 }, }); if (Math.floor(user.credit / show.price) > 0) { if (show.quantity > 0) { await tx.shows.update({ where: { showId: +showId }, data: { quantity: --show.quantity }, }); } else { console.log(`${userId} : 예매수량부족`); throw new Error('예매 수량이 부족합니다.'); } if (user.credit >= show.price) { await tx.users.update({ where: { userId: +userId }, data: { credit: user.credit - show.price }, }); await tx.reservation.create({ data: { UserId: user.userId, ShowId: show.showId }, }); } else { console.log(`${userId} : credit부족`); throw new Error('보유한 credit이 부족합니다.'); } } }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }, ); return res.status(200).json({ message: '좌석 예매가 완료되었습니다.' }); } catch (error) { console.log(`catch로 빠진 ${error}`); next(error); if (transaction) { await prisma.$executeRaw`ROLLBACK`; } } });




다 때려박으니 놀라운 일이 일어났다...! 세상에 shows의 갯수와 users의 credit갯수와 reservation의 갯수가...!!!!!!!!!!! 일관성 대박.



하다보니 놀라운 사실. duration: 10으로 한줄 알았던게 사실 60으로 한거였음.
그럼 여기에는 duration 10으로 한걸 넣겠음~




다음 포스팅은 좀 더 강한 부하를 주는 테스트로 찾아오겠삼