https://softmoca.tistory.com/346
현재 포스팅은 위 포스팅으로 이어 진다.
동시성 제어가 필요한 이유
현재 kwangsang에서는 마감 시간이 가까워 졌을 때 떨이로 상품을 내놓는 점주 분들과 마감 할인 상품에 대한 정보를 필요로 하는 고객들을 위한 유통 서비스 플랫폼을 제작 중이다. 그리고 당시에는 @@대학교와 협약을 맺어 해당 서비스 플랫폼을 운영하기로 했었고 나는 1000명 이상의 동시 사용자들의 주문 요청에 대한 동시성을 제어하는 API를 개발 하는 역할을 되었다.
해당 서비스에서 동시성 제어를 필요로 하는 부분에 대해 간단히 알아보자.
처음 푸쉬 알림을 통해 고객들에게 마감 할인 상품에 대한 정보가 주어지면 특정 시간에 많은 사용자의 트래픽이 몰리게 된다.
그리고 해당 화면과 같이 주문할 메뉴 항목을 선택하고 결제하기 버튼을 눌러 주문결제를 하게 된다.
하지만 마감 할인 상품의 특성상 갯수가 한정적이며 푸쉬알림을 통해 들어온 사용자들이 한번에 특정 가게의 특정 메뉴를 주문을 하게 될수 밖에 없다.
그러한 상황에서 데이터베이스에 저장된 메뉴의 수량데이터에 자주 접근하게 된다.
즉, DB의 menu테이블의 count값에 자주 접근하며 조회/수정 변경 된다는 것이다.
이때 동시성 제어를 하지 않으면 데이터의 무결성과 일관성이 보장 되지 않아 사용자들이 유효하지 않은 재고를 기반으로 주문을 하게 된다.
데이터 정합성이 깨지는 전형적인 service 로직
async default() {
const id = 3;
const order_count = 3;
const menu = await this.menuRepository.findOne({ where: { id } });
if (menu.count - order_count < 0) {
throw new BadRequestException('재고가 없습니다.');
}
/////////이 과정중 다른 사용자가 요청한 api에서 decrement가 발생////////
await this.menuRepository.decrement(
{
id: id,
},
'count',
3,
);
return menu;
}
예를들어 처음 가게에 고구마 휘낭시에(menu테이블의 음식 이름)가 10(menu테이블의 count)개가 있다는 푸쉬 알림을 받고 5명의 사용자가 해당 메뉴에 대해 3개씩 주문을 할 시 5명 중 처음 선착순으로 주문을 한 3명은 제외한 2명에게는 재고가 없다는 응답을 보내야한다.
하지만 동시에 여러명이 고구마 휘낭시에의 count 값을 수정 하는 과정 에서 해당 메뉴에 대한 정보를 불러 왔을때(findone)의 count값과 수정(decrement)할 때의 count값이 다를수 있다.
실제로 Jmeter로 부하테스트를 진행해 보면 더 확실히 해당 문제를 알수 있다.
(아래 링크로 간단한 Jmeter 설치법과 사용법을 알수 있다.)
https://softmoca.tistory.com/344
위 코드의 상황은 다음과 같다.
menu의 id가 3번인 count값을 주문 수량(order_count)인 3만큼 감소 시키는 간단한 로직이다.
이런 사항에서 menuId가 3인 메뉴의 재고 count가 10개 있을때 10명의 사용자가 동시에 주문 요청을 하게 되는 상황으로 테스트를 진행해보자.
구현되야 하는 상황에서는 3명이 수락이 되어야 하지만 위 초록 체크 표시를 보면 4명이 수락이 되어있다.
사용자의 요청 순서대로 처리는 되지만 데이터베이스의 count값이 -2로 바뀌어져 있다.
즉, 데이터의 정합성이 깨져, 4명의 주문이 이루어 진것이다.
그 이유는 해당 코드의 주석 부분 과정에서 다른 사용자가 동시에 주문 요청을 해서 그렇다.
3번쨰로 주문을 한 사용자가
await this.menuRepository.decrement(
{
id: id,
},
'count',
3,
);
위 쿼리를 실행 하기 이전에
4번째 사용자가
const menu = await this.menuRepository.findOne({ where: { id } });
해당 쿼리를 사용해서 가져왔을 당시에는 여전히 menuId가 3인 count는 4(10-3-3(1,2,번째 사용자의 주문 요청))이기 때문이다.
그럼 본격적으로 동시성을 제어하는 주요 방법들을 직접 적용해보고 특징과 한계에 대해 알아보자.
동시성 제어란 ?
동시성 제어(Concurrency Control)는 데이터베이스에서 여러 작업이 동시에 수행될 때 데이터의 무결성과 일관성을 유지하기 위한 기술이다.
즉, 동시에 실행되는 여러 개의 트랜잭션이 작업을 성공적으로 마칠 수 있도록 트랜잭션의 실행 순서를 제어하는 기법이다.
데이터베이스에서의 동시성 제어
데이터베이스에서 동시성을 제어하기 위해 크게 3가지의 개념을 적용해서 해결할 수 있다.
자세한 설명은 하지 않고 주요한 특징과 개념에 대해서 간단히 알아보자.
1) 트랜잭션
트랜잭션(Transaction)은 데이터베이스 관리 시스템(DBMS)에서 하나의 논리적 작업 단위로, 일련의 데이터베이스 연산을 그룹화한 것이다. 트랜잭션은 모두 성공적으로 완료되거나, 실패하여 아무것도 수행되지 않은 것처럼 보장하는 특징이 있다.
2) 고립성 수준
트랜잭션에 격리 수준에서 발생할 수 있는 문제들
- Dirty Read : 한 트랜잭션이 커밋되지 않은 다른 트랜잭션의 데이터를 읽을 수 있다.
- Non-repeatable Read : 같은 트랜잭션 내에서 같은 쿼리를 두 번 실행할 때, 다른 트랜잭션이 중간에 데이터를 수정해서 결과가 다를 수 있다.
- Phantom Read : 같은 트랜잭션 내에서 같은 쿼리를 두 번 실행할 때, 다른 트랜잭션이 새로운 행을 삽입하면 결과 집합이 달라질 수 있다.
고립성 수준(Isolation Levels)
[1] READ UNCOMMITTED
- 트랜잭션이 커밋되지 않은 변경 사항을 다른 트랜잭션이 읽을 수 있습니다.
- 가장 낮은 고립성 수준.
- Dirty Read 발생.
예시: 트랜잭션 A가 데이터를 변경하고 커밋하지 않았는데, 트랜잭션 B가 그 변경된 데이터를 읽을 수 있습니다.
[2] READ COMMITTED
- 트랜잭션이 커밋된 변경 사항만 다른 트랜잭션이 읽을 수 있습니다.
- 대부분의 데이터베이스 시스템에서 기본 설정.
- Non-repeatable Read 발생
예시: 트랜잭션 A가 데이터를 읽은 후, 트랜잭션 B가 데이터를 수정하고 커밋하면, 트랜잭션 A가 다시 데이터를 읽을 때 다른 값을 볼 수 있다.
[3] REPEATABLE READ
- 트랜잭션이 시작된 이후 읽은 데이터는 트랜잭션이 종료될 때까지 다른 트랜잭션에 의해 수정되거나 삭제될 수 없습니다.
- Non-repeatable Read 방지.
- Phantom Read 발생
예시: 트랜잭션 A가 특정 조건에 맞는 행을 읽은 후, 트랜잭션 B가 새로운 행을 삽입하고 커밋하면, 트랜잭션 A가 다시 쿼리할 때 새로운 행이 나타난다.
[4] SERIALIZABLE
- 가장 높은 고립성 수준으로, 트랜잭션들이 순차적으로 실행되는 것처럼 동작하게 합니다.
- 모든 종류의 일관성 문제를 방지.
- 성능 저하가 극심하여 실제로 사용하기 어려움.
예시: 트랜잭션 A와 트랜잭션 B가 동시에 실행될 때, 하나의 트랜잭션이 완료될 때까지 다른 트랜잭션은 대기해야 한다.
3) 동시성 제어 기법 RDB LOCK
비관적 읽기 락 : REPEATABLE READ 격리 수준으로 데이터를 읽는 동안 다른 트랜잭션이나 스레드가 해당 데이터에 대해 쓰기 작업을 할 수 없도록 막는 락이며
비관적 쓰기 락 : SERIALIZABLE 격리 수준으로데이터를 수정하는 동안 다른 트랜잭션이나 스레드가 해당 데이터에 대해 읽기 또는 쓰기 작업을 할 수 없도록 막는 락
즉, 읽기 락은 쓰기 작업을 막고 쓰기 락은 읽기 쓰기 모두 막는다.
위 3가지 특징을 기반으로 동시성을 얼마나 제어할수 있는지 알아보자.
테스트 시나리오
menu의 id가 3번인 count값을 주문 수량(order_count)인 3만큼 감소 시키는 간단한 로직이다.
이런 사항에서 menuId가 3인 메뉴의 재고 count가 10개 있을때 10, 100, 1000명의 사용자가 동시에 주문 요청을 하게 되는 상황으로 테스트를 진행해보자.
기본 트랜잭션
async qrtransaction() {
const id = 3;
const order_count = 3;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const menu = await queryRunner.manager.findOne(Menu, { where: { id } });
await queryRunner.manager.update(Menu, id, {
count: menu.count - order_count,
});
if (menu.count - order_count < 0) {
throw new BadRequestException('재고가 없습니다.');
}
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
const menu = await this.menuRepository.findOne({ where: { id } });
return menu;
}
구현되야 하는 상황에서는 3명이 수락이 되어야 하지만 위 초록 체크 표시를 보면 7명이 수락이 되어있다.
사용자의 요청 순서대로 처리는 되지만 4,5,6,7번 째 요청에서 같은 데이터를 갱신하는 과정에서 데이터의 무결성 보장이 안되어 재고가 없지만 재고가 있다는 응답을 보낸다. 하지만 트랜잭션으로 인해 RollBack이 되어 최종적인 DB의 정합성만 보장됨.
10명으로 테스트한 결과 위와 같이 요청 초기 부분에 문제가 발생 함을 알 수 있고, 100명 이상의 1000명, 5000명의 경우 DB의 정합성이 더욱 깨지는 것을 아래 화면으로 확인할 수 있다.
위 결과는 100명의 사용자가 동시에 요청하는 시나리오 중 일부로 22번째 사용자 까지만 보아도 응답 시간이 3초 가량이 지나야 응답을 받을수 있다. 결국 시간 측면에서도 매우 좋지 않다는걸 알수 있다.
비관적 읽기 락을 사용한 트랜잭션
async readlock() {
const id = 3;
const order_count = 3;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const menu = await queryRunner.manager.findOne(Menu, { where: { id }, lock: { mode: 'pessimistic_read' } });
if (menu.count - order_count < 0) {
throw new BadRequestException('재고가 없습니다.');
}
await queryRunner.manager.update(Menu, id, {
count: menu.count - order_count,
});
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
const menu = await this.menuRepository.findOne({ where: { id } });
return menu;
}
데이터의 정합성이 지켜지며 원하는 대로 3명이 수락되었다.
하지만 readlock으로 여러개의 요청을 처리 하는 과정에서 Deadlock(교착상태)가 발생하여 요청들이 대기 상태에 빠지거나 timeout이 되어 실패되어 요청의 응답 순서가 지켜지지 않고 띄엄 띄엄 랜덤한 사용자들에게 응답 값을 준다.
또한 DeadLock 상태가 지속되면 전체 서버 성능이 저하 될 수 있고 심하면 서버가 터질(죽을) 가능성이 있어 지양해야 함을 알수 있다.
DeadLock : 두 개 이상의 트랜잭션이 서로가 필요로 하는 자원을 점유하고 있어, 서로가 자원을 해제하지 않으면 진행할 수 없는 상황, 이로 인해 트랜잭션들이 무한히 대기하게 되는 문제
비관적 쓰기 락을 사용한 트랜잭션
async writelock() {
const id = 3;
const order_count = 3;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const menu = await queryRunner.manager.findOne(Menu, {
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (menu.count - order_count < 0) {
throw new BadRequestException('재고가 없습니다.');
}
await queryRunner.manager.update(Menu, id, {
count: menu.count - order_count,
});
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
const menu = await this.menuRepository.findOne({ where: { id } });
return menu;
}
데이터의 정합성이 지켜지며 원하는대로 3명이 수락되었다.
하지만 writelock으로 여러 개의 요청을 처리 하는 과정에서 먼저 Lock을 획득한 요청이 로직을 수행하는 동안 다른 요청들이 Lock을 획득기 위해 대기하는 동안 timeout이 발생하며 트랜잭션 오버헤드가 누적되어 요청의 응답 순서가 지켜지지 않고, 띄엄 띄엄 랜덤한 사용자들에게 응답 값을 준다.
무엇보다 SERIALIZABLE 격리수준으로 인해 모든 요청이 트랜잭션 과정 중 잠금을 가진채 하나하나 수행하며 하나의 수행이 끝나고 이후의 요청 트랜잭션이 수행 되는 과정에서 딜레이 시간이 너무 심했다. 위 결과는 100명, 1000명 으로 부하 테스트를 한 결과로 100명 시나리오에서는 평균 3초 가량 걸렸고, 1000며의 경우 28초 라는 말도 안되는 시간이 걸린다. 여러 이유가 있지만 가장 주된 이유는 데이터 베이스에 과부하가 걸려 전체 처리 시간이 상당히 소비 된것으로 추측이 된다.
또한 주문을 요청하는 사용자로 인해 menu의 count가 lock이 걸려 있어 일반 사용자들이 조회를 하는 과정까지 딜레이가 걸리게 되어 writelock 또한 사용을 할수 없다.
즉, 기본적인 RDBMS로는 100명 조차 동시성을 제어가 사실상 안되며 사용자들이 서비스를 사용하는데 불편함을 못느끼는 효율을 가지기에는 무리가 있었다.
그래서 다른 동시성 제어 방법을 찾게 보게 되었고 인메모리 데이터베이스인 REDIS를 활용해 보았다.
4) 동시성 제어 기법 REDIS LOCK
Redis를 도입한 주요 원인은 속도였다.
사용자의 주문 요청이 오고 요청한 구매를 원하는 갯수와 재고를 비교하여 요청을 수락하는 과정에서 RDB에 있는 count값을 확인 하는 것이 아니라 Redis에 재고에 대한 데이터를 저장하고 확인을 하는 것이다.
주문 요청에 따른 재고 확인 및 재고 수정 플로우
예를 들면 10개의 재고가 있는 메뉴에 100명이 동시에 3개씩 구매 요청을 할 경우 결국에는 처음 요청한 3명만 수락이 되고 나머지 97명은 재고 부족으로 주문 요청 실패 응답을 받게 된다.
이런 상황에서 굳이 100명 모두 RDB까지 가서 count를 확인하는과정을 없애기 위함이다.
즉, 100개의 요청 모두 RDB를 조회 하고 수정하는것이 아닌 실질적으로 필요한 3개의 요청에만 RDB를 조회하고 수정한다는 것이다.
이전에 동시성을 제어 했던 mysql에 lock을 걸어 동시성을 제어 한것 처럼 이젠 redis에서 처음 든 요청에 대한 처리를 해야 하므로 레디스에도 lock을 걸어줘야 한다.
아래는 LOCK 획득과 반납 시나리오이다.
레디스 락의 2가지 방식
두가지 모두 테스트해본 결과 결론적으로는 분산락을 사용해서 해당 API를 개발 하였고 분산락에 대한 포스팅은 다음 포스팅에 이어진다.
스핀락(Spin Lock)
동시성 제어를 위해 잠금이 해제될 때까지 계속해서 잠금 상태를 확인하는 방식
- 짧은 대기 시간 : 잠금 대기 시간이 짧을 것으로 예상될 때 효과적아다.
- 바쁜 대기(Busy Waiting): 잠금이 해제될 때까지 CPU를 계속 사용하여 잠금 상태를 확인한다.
- 간단한 구현: 구현이 비교적 간단하고 추가적인 복잡한 알고리즘이 필요하지 않다.
- CPU 자원 소모: 잠금을 얻기 위해 계속해서 확인하기 때문에 CPU 자원을 많이 소모할 수 있다.
async redislock(idx: number) {
const key = `menu:${idx}:lock`;
while (true) {
const lockAcqired = await this.client.setnx(key, 'LOCKED');
if (lockAcqired) {
await this.client.expire(key, 1);
break;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
let value = parseInt(await this.client.get('menus:10:id'));
if (value <= 0) {
await this.client.del(key);
console.log('에러');
throw new HttpException('메뉴 수량이 부족합니다', HttpStatus.BAD_REQUEST);
}
value = value - 3;
await this.client.set('menus:10:id', value);
const id = 10;
await this.menuRepository.decrement(
{
id: id,
},
'count',
1,
);
await this.client.del(key);
console.log(value);
return { value };
}
재고가 10개가 있는 메뉴에 대해 한명의 사용자가 1개씩 요청을 보내는 코드이며 총 100명이 동시에 API를 요청하는 부하 테스트를 진행 하였다.결론 적으로 데이터 정합성과 사용자의 요청에 대한 순서가 모두 보장 되었지만 과도한 Redis 서버의 과부하로 상당한 딜레이 시간이 걸렸다.
주요 원인은 요약하면 writeLock과 같이 비관적 잠금 매커니즘으로 안그래도 잠금 경쟁 시간과 잠금 유지 시간이 긴데 잠금 획득시도를 무한 반복(while true)하는 과정에서 CPU가 과소비되며 그로 인해 전체적인 레디스 서버가 과부하되었기 때문이다.
물론 잠금 시간과 대기 시간 조금더 최적화 하면 조금이나마 성능이 좋아 지긴 하지만 무의미한 정도의 개선이다.
또한 스핀락은 애초에 애플리케이션 서버 단에서의 동시성 제어가 아닌 운영체제 커널 혹은 하드웨어 인터럽트 핸들링 분야에서 사용를 하는 동시성 제어 방식이다.
분산락(Distributed Lock)
동시성 제어를 위해 여러 인스턴스 또는 서버에서 동시에 접근하는 자원을 보호하기 위해 사용
- 다중 노드 동기화: 여러 서버나 데이터베이스 노드 간의 동기화를 통해 잠금을 관리하여 분산 환경에서의 동시 접근 문제를 해결한다.
- 정확한 TTL(Time-To-Live): 잠금이 영구적으로 유지되지 않도록 시간 제한을 설정할 수 있어 잠금이 의도치 않게 오래 유지되는 것을 방지한다.
- 복잡한 구현: 분산락은 여러 노드 간의 일관성을 유지해야 하기 때문에 구현이 상대적으로 복잡하다.
분산락에 대한 실제 API 코드는 다음 포스팅에 이어진다.
간단한 성능만 확인해 보자.
{
"orders": [
{
"menuId": 1,
"quantity": 3
},
{
"menuId": 2,
"quantity": 2
},
{
"menuId": 3,
"quantity": 3
}
]
}
위와 같이 100명의 사용자가 1,2,3번 menuId에 대해 각각 3,2,3개의 수량을 요청한 경우.
데이터 정합성, 사용자 요청 순서, 서버안정성, 속도 모두 좋은 성능을 확인하였다.
최종 동시성 제어 성능 비교 정리 [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여도 의미가 없다.
참고
트랜잭션
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' 카테고리의 다른 글
주문 재고 요청시 동시성 제어 [REDIS 분산락(Distribution Lock)] (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 |