[DB 아키텍처] 1초에 1000명 이상 트래픽이 몰리면 DB는 어떻게 정합성을 유지할 수 있을까? (optimistic-lock, pessimistic-lock, redis-lock)
[MySQL] ACID 개념과 Procedure를 활용한 Transaction 실습하기
[MySQL] ACID 개념과 Procedure를 활용한 Transaction 실습하기
[MySQL] 데이터베이스 ERD 설계하는 법(drawSQL) [MySQL] 데이터베이스 ERD 설계하는 법(drawSQL)[Express] MySQL로 깔끔한 로그인 인증 만들기 [Express] MySQL로 깔끔한 로그인 인증 만들기🚩소개안녕하세요! 대
blog.juyear.dev
이전 글 읽으러 가기!
👋 소개
안녕하세요! 대학생 개발자 주이어입니다.
오늘은 제가 "쿠폰 서비스"라는 특정 상황을 가정하고 DB 정합성과 그에 관한 여러가지 lock에 대해서 실습한 부분을 정리하고 공유해보려고 합니다!
저는 대표적으로 no-lock, pessimistic-lock, optimistic-lock에 대해서 실습을 해보았고, 추가로 간단하게 redis를 적용해서 그 차이점을 비교해봤습니다.
그럼 재밌게 읽어주시길 바랍니다!
🧪 기존 문제점은 무엇일까? (no-lock)
no-lock의 문제점
먼저 다양한 lock을 적용시켜 보기전에 lock을 적용시키지 않았을 때 어떤 문제점이 생기는지 알아야 합니다.
그래야 정확한 차이점과 문제점이 해결됐는지 확인할 수 있으니까요.
이 부분에 대해서는 저번에 올린 ACID와 트랜잭션 글을 읽고 오시면 더 도움이 될 수 있습니다.
먼저 상황을 하나 가정해보도록 하겠습니다.
사용자가 많이 몰리는 쿠팡같은 서비스에서 10000원 할인 쿠폰을 선착순 100명에게 뿌리는 상황이라고 생각해볼게요.
쿠팡의 사용자가 얼마나 모일지는 모르지만... 아마 적어도 1000명 이상이 동시에 쿠폰 발급 버튼을 누르게 될 것입니다.

그럴 때 생기는 문제 중 하나가 데이터 정합성이 깨진다는 것입니다.
위 사진을 보면 2개의 쿠폰이 발급되었지만 stock은 99인 것을 볼 수 있습니다.
그럼 왜 위 사진과 같은 문제가 발생할까요?
우선 비즈니스 로직이 Coupon DB에서 coupon 정보를 읽어와 발급 절차를 거치고 stcok을 하나 줄이는 것이라고 간단하게 생각해보겠습니다.
동시에 들어온 2개의 요청은 모두 같은 데이터(stock=100)를 읽어올 것이고, stock은 100에서 1을 뺀 99로 DB에 저장할 것 입니다. 즉, 각각의 요청은 로직에 맞게 발급 절차를 거치고 stock을 하나 줄였습니다.
문제는 데이터의 최신 상태가 반영되지 않았다는 점입니다.
2개의 요청 중 하나가 먼저 stock(99)로 DB에 저장했다면, 나머지 요청은 stock(99)에서 1을 줄여야하지만 이미 stock(100)일 때 데이터를 읽어왔기 때문에 똑같이 stock(99)로 저장됩니다.
이러한 문제를 해결하기 위해 가장 쉽게 생각할 수 있는 방법은 하나의 요청이 DB에 대해서 작업을 수행 중일 때 다른 요청은 작업이 끝날 때 까지 기다리거나 서버에서 그냥 실패처리 하는 것입니다.
이러한 방법을 도와주는 것이 lock이라고 생각하면 쉬울 것 같습니다.
jmeter를 사용한 테스트
참고로 오늘 실습은 모두 아래 툴을 사용하였습니다.
- Backend : NestJS
- ORM : TypeORM
- Database : MySQL(local), Redis(local)
- Server : Docker(Redis container)
- Test : JMeter
async issueWithNoLock(id: number) {
const couponEntity = await this.couponRepository.findOneBy({ id });
console.log(couponEntity);
if (!couponEntity) {
throw new NotFoundException('해당 쿠폰을 찾을 수 없습니다.');
}
if (couponEntity.stock <= 0) {
throw new BadRequestException('해당 쿠폰의 재고가 모두 소진되었습니다.');
}
couponEntity.stock -= 1;
return await this.couponRepository.save(couponEntity);
}
먼저 위와 같이 간단하게 no-lock 서비스 로직을 작성해주었습니다.
가장 기본적인 형태로 작성해야 문제점을 이해할 수 있기 때문에 가장 기본적인 쿠폰 조회 및 stock 체크 로직만 추가해 주었습니다.
코드만 봤을 때는 정상적으로 로직이 처리될 것 처럼 보입니다.


위 사진은 JMeter 테스트를 위해 설정한 값입니다.
간단하게 설명하면 0초에 100번의 요청을 보내겠다는 뜻이고, 그 요청의 경로를 오른쪽 사진에서 설정해주었습니다.

실행시킨 후 결과를 확인해보면 100개의 요청이 모두 성공했다고 나옵니다.
근데 남은 stock 개수는 98개 입니다. 쿠폰이 100개가 있는데 100개를 발급한 이후에 98개가 남아있다니... 실서비스였다면 심각한 문제로 이어졌을 것입니다.
즉, 위에서 설명한 것 처럼 100개의 요청 대부분이 과거의 데이터에서 비즈니스 로직을 처리했고 stock 차감이 제대로 이루어지지 않은 것을 알 수 있습니다.
아마 약 50개의 요청이 stock(100)을 읽어와 1을 차감 후 stock(99)로 DB에 저장했을 것입니다.
🧪 optimistic-lock을 사용하여 데이터 정합성을 지켜보자!
optimistic-lock 작동 방식

optimistic lock은 위와 같이 작동합니다.
그림으로 시각화 하려고하니 조금 애매하게 그려진 부분이 있는데,
실제로는 version 비교 및 stock 차감 로직과 동시에 DB에 반영됩니다.
즉, 처음에 쿠폰 데이터를 가져오고, 가져온 데이터의 version으로 바로 DB에 반영하게 됩니다.
const result = await this.couponRepository
.createQueryBuilder()
.update(Coupon)
.set({ stock: () => 'stock - 1', version: () => 'version + 1' })
.where('id = :id AND version = :version', {
id: couponEntity.id,
version: couponEntity.version,
})
.execute();
if (result.affected === 0) {
throw new ConflictException(
'다른 사용자가 먼저 쿠폰을 발급받았습니다. 다시 시도해 주세요',
);
}
이렇게만 설명하면 이해가 잘 안될 것 같아 코드를 가져와 봤습니다.
result 부분을 보면 알 수 있는데, version을 비교한 후 업데이트를 하는 것이 아니라 현재 가지고 있는 version을 기반으로 일단 업데이트를 시도합니다.
만약 DB version이 이미 2로 업데이트 되어 현재 데이터 version과 다르다면 애초에 version이 같은 데이터를 찾지 못해 업데이트가 되지 않을 것입니다.
정리해보면, optimistic lock은 일단 DB 업데이트를 시도하고, version이 다르다면 자동으로 걸러지고, version 같다면 해당 데이터를 업데이트 하는 식으로 작동합니다.
version이 lock의 역할을 하는 것이죠.
optimistic-lock 테스트 (JMeter)
async issueWithOptimisticLock(id: number) {
const couponEntity = await this.couponRepository.findOne({
where: { id },
});
if (!couponEntity) {
throw new NotFoundException('해당 쿠폰을 찾을 수 없습니다.');
}
if (couponEntity.stock <= 0) {
throw new BadRequestException('해당 쿠폰의 재고가 모두 소진되었습니다.');
}
const result = await this.couponRepository
.createQueryBuilder()
.update(Coupon)
.set({ stock: () => 'stock - 1', version: () => 'version + 1' })
.where('id = :id AND version = :version', {
id: couponEntity.id,
version: couponEntity.version,
})
.execute();
if (result.affected === 0) {
throw new ConflictException(
'다른 사용자가 먼저 쿠폰을 발급받았습니다. 다시 시도해 주세요',
);
}
const updatedCoupon = await this.couponRepository.findOne({
where: { id },
});
return updatedCoupon;
}
위와 같이 optimistic-lock 서비스 로직을 작성해주었습니다.
간단한 쿠폰 검증 로직과 optimistic-lock을 위한 로직을 넣어주었습니다.
또한 쿠폰이 발급되었는지를 확인하기 위한 로직을 간단히 넣어주었습니다. (쿠폰이 업데이트 되었다면 발급되었다는 뜻)


똑같이 100번의 요청을 보냈고, 98%가 실패했으니 2개의 요청만 성공했다는 것을 알 수 있습니다.
오른쪽 사진을 보면 대부분의 요청이 실패했고, 성공한 요청에 대해서만 stock 1줄고 version이 증가하는 것을 알 수 있습니다.
(version이 13인 이유는 여러번 테스트하면서 version이 계속 올라갔습니다.)
즉, 성공한 요청에 대해서만 쿠폰을 발급했으니 데이터 정합성도 유지했고 실제 존재하는 쿠폰 수보다 더 많은 쿠폰이 발급되는 문제도 해결했습니다.
근데 뭔가 우리가 실제로 서비스에서 사용하는 쿠폰 로직과는 조금 다른 것 같습니다.
쿠폰 발급 버튼을 눌렀을 때, 늦게 눌러서 실패한 적은 있어도 다른 사람이 데이터를 사용 중이라 쿠폰 발급을 받지 못한 적은 없는 것 같습니다.
지금 방법으로 진행한다면 빨리 누른 사람이 쿠폰을 받아가는 것이 아니라 운좋게 데이터 lock이 딱 풀리는 타이밍에 요청을 보낸 사람이 쿠폰을 받아갑니다.
이러한 문제는 어떻게 해결할 수 있을까요?
🧪 pessimistic-lock을 사용하여 데이터를 처리해보자!
pessimistic-lock 작동 방식

pessimistic-lock은 위와 같이 작동합니다.
어느정도 감이 오실거라 생각하는데, 간단하게 정리하자면 특정 요청이 DB에 관한 작업을 수행하기 전에 lock이 걸려있는지 확인합니다.
만약 lock이 걸려있지 않다면 lock을 걸고 DB 작업을 수행합니다.
만약 lock이 걸려있다면, lock이 해제될 때 까지 기다렸다가 lock이 해제되면 다음 요청이 다시 lock을 걸고 작업을 수행합니다.
즉, 한 순간에 하나의 요청만 DB에 접근할 수 있는 것이죠.
이 방법을 사용한다면 optimistic-lock에서 얘기했던 쿠폰 발급 로직 문제도 해결할 수 있을 것 같습니다.
pessmistic-lock 테스트 (JMeter)
async issueWithPessimisticLock(id: number): Promise<Coupon> {
return await this.dataSource.transaction(async (manager) => {
const couponEntity = await manager.findOne(Coupon, {
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!couponEntity) {
throw new NotFoundException('해당 쿠폰을 찾을 수 없습니다.');
}
if (couponEntity.stock <= 0) {
throw new BadRequestException(
'해당 쿠폰의 재고가 모두 소진되었습니다.',
);
}
couponEntity.stock -= 1;
console.log(couponEntity);
return await manager.save(couponEntity);
});
}
위와 같이 pessimistic-lock 서비스 로직을 작성해보았습니다.
쿠폰 검증 로직 전에 lock을 설정하는 로직을 추가하여 구현해보았습니다.
TypeORM에서 지원하는 DataSource와 transaction을 사용하였습니다.


100개의 요청이 모두 성공한 것을 알 수 있고, no-lock과 달리 각 요청은 모두 데이터 정합성이 유지된 상태로 실행되었습니다.
즉, 데이터 정합성도 유지했고, 쿠폰 발급 로직도 정상적으로 만들어진 것 같습니다.
이제 쿠폰은 빨리 발급 버튼을 누른 사용자가 가져가게 될 것 입니다.
그럼 pessimistic-lock은 만능인걸까요?
그렇지 않습니다. 이미 어느정도 예상하실 수 있지만, 한 순간의 하나의 요청만 DB에 접근할 수 있기 때문에 속도에서 큰 차이가 납니다.
사실 0초(거의 동시)에 100개 가까운 요청이 들어오는 경우는 많지 않습니다.
또한 그러한 요청이 절대 손실되면 안되는 요청이 아니라면 optimistic-lock을 쓰는 경우도 많이 있는 것 같습니다.
🧪 redis-lock을 사용하여 DB 부하를 줄여보자!
redis-lock 작동 방식
살짝 번외긴 하지만, optimistic-lock에서 생기는 문제는 사실 하나 더 있습니다.
바로 DB에 의미없는 부하를 준다는 것 입니다.
optimistic-lock 작동 원리를 보면, 일단 업데이트를 시도하고 version이 다르면 업데이트가 되지 않는 것을 알 수 있습니다.
근데 어차피 업데이트가 되지 않을 거라면, 굳이 업데이트 시도를 하면서 DB에 부하를 줄 필요가 있을까요?
이러한 문제를 해결하기 위해 사용할 수 있는 방법 중 하나가 redis-lock 입니다.
redis-lock은 DB 안에서 정합성을 검사하는 optimistic-lock과 달리 DB 밖에서 lock 검사를 통해 정합성을 유지합니다.
제가 느끼기에는 약간 pessimistic-lock 방법으로 정합성을 유지하면서 결과는 optimistic-lock인 것 같은..? 느낌이 듭니다.

위의 그림을 보면 설명했던 부분이 무엇인지 바로 감이 오실거라 생각합니다.
DB는 데이터 수정이라는 역할만 담당하고, lock검증과 처리는 redis에서 진행합니다.
따라서 optimistic-lock의 문제였던 의미없는 DB 부하를 해결할 수 있는 것이죠.
lock을 설정하는 것을 보면 pessimistic-lock같지만, lock을 걸 수 없다면 바로 실패 처리하는 것이 optimistic-lock 같습니다.
redis-lock 테스트 (JMeter)
async issueWithRedisLock(id: number): Promise<Coupon> {
const lockKey = `lock:coupon:${id}`;
const ttl = 5000;
const acquireTime = Date.now() + ttl;
const result = await this.redis.set(
lockKey,
acquireTime.toString(),
'PX',
ttl,
'NX',
);
const isLocked = result === 'OK';
if (!isLocked) {
throw new ConflictException(
'현재 다른 사용자가 쿠폰을 발급받고 있습니다. 잠시 후 다시 시도해 주세요.',
);
}
try {
const couponEntity = await this.couponRepository.findOne({
where: { id },
});
if (!couponEntity) {
throw new NotFoundException('해당 쿠폰을 찾을 수 없습니다.');
}
if (couponEntity.stock <= 0) {
throw new BadRequestException(
'해당 쿠폰의 재고가 모두 소진되었습니다.',
);
}
couponEntity.stock -= 1;
console.log(couponEntity);
return await this.couponRepository.save(couponEntity);
} finally {
await this.redis.del(lockKey);
}
}
위와 같이 redis-lock 서비스 로직을 구현하였습니다.
간단하게 로직을 설명하자면, redis-lock을 걸 수 있는지 확인하고, 가능하다면 DB에 접근하여 수정하는 로직입니다.
작업이 끝나면 redis-lock을 해제하는 로직도 꼭 추가해주어야 합니다.
*lock에 ttl을 설정하는 이유
잠깐만 설명하자면, redis-lock을 사용할 때 ttl을 설정하는 이유는 서버 장애나 로직 처리 지연 등으로 인해 lock을 가지고 있는 요청이 lock을 해제하지 못한 상태로 종료되면, lock이 영원히 풀리지 않는 Deadlock 상태가 발생할 수 있기 때문입니다.
이때 ttl을 설정하면 일정 시간이 지난 후 자동으로 lock이 만료되어 해제되므로, 시스템의 무한 대기 문제를 방지하고 다음 요청이 안전하게 lock을 걸 수 있게 됩니다.

참고로 redis는 docker에 local로 띄워주었습니다.


테스트 결과를 확인해보겠습니다.
왼쪽 사진을 보면 100개의 요청 중 99개의 요청이 실패하고 1개의 요청만 성공한 것을 알 수 있습니다.
오른쪽 사진을 보면 실제로 1개의 요청만 반영되어 stock이 99인 것을 알 수 있습니다.
그럼 DB에 부하가 줄었는지는 어떻게 확인할 수 있을까요?
이 부분은 서버에 찍히는 로그를 보면 알 수 있습니다.

글자가 작아서 잘 안보일 수 있지만, 대충 파란색 글씨가 DB에 어떠한 일이 일어나고 있다는 로그입니다.
보이는 것과 같이 redis-lock을 사용했을 때는 로그가 많지 않은 것을 알 수 있습니다.

반면 optimistic-lock을 사용했을 때는 무수히 많은 로그가 찍히는 것을 알 수 있습니다.
실제로는 사진에 보이는 것 보다도 훨씬 많은 로그가 찍힙니다.
이를 통해 redis-lock과 optimistic-lock의 차이를 확실히 알 수 있습니다.
redis-lock은 외부에서 lock을 체크하고 DB 수정이 가능할 때만 DB에 접근하지만,
optimistic-lock은 일단 DB에 접근하고 version 기반으로 일단 업데이트를 진행해봅니다.
이 과정에서 DB는 여러 작업을 수행하게 되고, 의미없는 부하가 생기는 것이죠.
😊 마무리
좀 길고 복잡했지만, 데이터 정합성을 지키기 위한 다양한 lock에 대해서 정리해보았습니다.
각 lock의 특징과 작동 방식 마지막으로 문제점까지 정리해보면서 각 lock의 대한 이해도를 높일 수 있었습니다.
저도 아직 실무 경험이 없어 딱 단정지어 말할 수는 없지만, 아마 실무에서는 DB에 단순히 접근할 수 없을 것 입니다. (분명 다양한 lock 방식이 적용되어 있지 않을까 생각합니다.)
그만큼 오늘 적은 글이 언젠가는 도움이 될 것이라고 생각합니다.
그럼 지금까지 읽어주셔서 감사드리며, 다음에 더 유익한 글로 찾아오도록 하겠습니다.
by. 대학생 개발자 주이어
KYT CODING COMMUNITY Discord 서버에 가입하세요!
Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 25명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.
discord.com
KYT CODING COMMUNITY 가입하기!