2024.11.19 - [Spring/동시성 & Lock] - [캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화
이전 포스팅에서 우리는 `Distributed Lock`이나 `DB Lock`을 사용하지 않고
`Redis`를 도입하여 `Set`, `String` 같은 자료구조의 `INCR`, `SADD` 같은 원자적 작업 방법,
싱글 스레드의 특성 등을 이용해 멀티 스레드 환경에서도 티켓 예매 개수의 값을 보장하고
중복 사용자에 관한 검증 문제를 해결할 수 있었다.
이렇게 해서 기존의 `DB Lock`을 사용하는 방식을 사용하지 않으므로, `Lock` 관련된 DB의 부하를 줄일 수 있었다.
하지만, 아직 완벽히 DB 부하 이슈가 해결된 것은 아니다.
왜일까?
남은 DB 부하 이슈
현재 티켓 예매 로직을 살펴보자.
- `Redis`에 현재 예매된 티켓의 개수를 조회(항상 최근의 개수를 조회하도록 보장 - INCR(원자적연산))
- 예매가 가능한지 확인(100개 이하, 중복 사용자)
- 예매가 가능하면 데이터베이스에 티켓을 생성해 저장
public void purchaseTicketWithRedisAndSet(PurchaseTicketRequest request) {
Long isAdd = ticket2RedisRepository.add(request.getEventId(), request.getUserId());
if(isAdd != 1){
return;
}
Long count = ticket2RedisRepository.increment(request.getEventId());
if(count > 100){
return;
}
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new RuntimeException("No user"));
Event event = eventRepository.findById(request.getEventId())
.orElseThrow(() -> new RuntimeException("No event"));
Ticket2 ticket2 = Ticket2.builder()
.user(user)
.event(event)
.name(request.getName())
.phone(request.getPhone())
.build();
// 예매가 가능하면 티켓을 생성해 MySQL DB에 저장
ticket2Repository.save(ticket2);
}
성공적으로 `100개 이하`, `중복 사용자` 확인 작업이 끝나면 `MySQL`에 티켓과 관련된 내용(이름, 전화번호)를 저장한다.
이때, 데이터베이스에 직접 티켓을 저장하게 되는데 이 경우 DB와 관련된 부하 이슈가 발생할 수 있다.
선착순으로 티켓을 예매하게 되면 짧은 시간에 순간적으로 엄청난 트래픽들이 몰릴 것이다.
그리고 발급하는 쿠폰의 개수가 많다면 그에 따라 데이터베이스에 부하를 주게 된다.
만일, 다양한 서비스에 운영 중인 `RDB`에 티켓 작업이 일어난다면 다른 서비스로 장애가 전파될 수도 있다.
좀 더 자세하게 설명하자면
예시 케이스
- MySQL은 1분에 100개의 `insert`가 가능한 성능이라고 가정
- 13:00 -> 10000개의 티켓 생성 요청
- 13:01 -> 티켓 철회 요청
- 13:02 -> 회원가입 요청
위와 같은 상황이 있다고 가정했을 때 `DB`가 모든 요청을 잘 처리할 수 있을까?
그렇지 않다.
첫 번째로 발생가능한 문제는 티켓 생성 요청이 시간이 오래 걸리고 타임아웃 정책에 따라 일부는 아예 생성이 누락될 수 있다.
현재 가정한 `DB`의 스펙은 1분에 100개의 `Insert`만 가능하므로 10000개를 생성하려면
단순 계산으로 100분은 소요될 것이다.
또한, 티켓 생성 요청 이후에 들어온 티켓 철회 요청과 회원가입 요청은 무려 100분 뒤에 처리될 것이다.
심지어 타임아웃 정책에 의해 누락돼버릴 수도 있다.
결과적으로 짧은 시간에 순간적으로 많은 요청이 전달되면 DB의 부하로 이어지며, 서비스 지연 혹은 오류로 이어질 것이다.
부하 테스트
실제로 DB에 부하가 발생하는지를 확인하기 위해 `K6`를 활용하여 부하 테스트를 진행해보았다.
이때, DB 부하를 증가시키기 위해서 티켓 예매 개수를 30000으로 늘렸다.
`K6` 스크립트
import http from 'k6/http';
import {check, sleep} from 'k6';
export const options = {
stages: [
// {duration: '1s', target: 1},
{duration: '10s', target: 10000},
{duration: '10s', target: 9000},
{duration: '10s', target: 8000},
{duration: '10s', target: 7000},
{duration: '10s', target: 6000},
{duration: '10s', target: 5000},
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이내에 응답
http_req_failed: ['rate<0.01'], // 에러율 1% 미만
},
};
function generatePhoneNumber(){
return '010-' + String(Math.floor(1000 + Math.random() * 9000)) + '-' + String(Math.floor(1000 + Math.random() * 9000));
}
function generateUserId(){
return Math.floor(Math.random() * 30000) + 1;
}
export default function (){
// 요청 본문 데이터
// const userId = Number(__VU); // 현재 가상 사용자 ID를 사용해 고유 ID로 설정
const userId = generateUserId();
// console.log(`User ID: ${userId}`); // 로그 출력
const payload = JSON.stringify({
userId: userId,
eventId: 1,
name: 'name' + userId,
phone: generatePhoneNumber(),
});
// POST 요청 헤더
const headers = {
'Content-Type': 'application/json',
};
// POST 요청 전송
const response = http.post('http://localhost:8080/api/v10/tickets/purchase', payload, {headers: headers});
check(response, {
'status is 200': (r) => r.status === 200,
'response contains success message': (r) => typeof r.body === 'string',
});
sleep(1);
}
- `options`
- `stages`: 부하 테스트의 단계를 정의
- `{duration: '10s', target: 10000}`
- 10초 동안 가상 사용자 수를 10,000명까지 증가
- `duration`: 지속 시간
- `target`: 목표 가상 사용자 수
- `{duration: '10s', target: 10000}`
- thresholds: 테스트 결과가 만족해야 하는 성능 기준을 설정
- `http_req_duration`: 응답 시간이 500ms 미만이어야 하는 요청의 비율(95% 이상)
- `http_req_failed`: 요청 실패율이 1% 미만이어야 함
- `stages`: 부하 테스트의 단계를 정의
- `__VU`
- 가상 사용자의 `ID`를 의미하는데 `target`에서 설정한 숫자로
- 알아서 1 ~ 30000 사이의 값을 전송
- 가상 사용자의 `ID`를 의미하는데 `target`에서 설정한 숫자로
실제 티켓 예매 상황을 가정해, 트래픽 급증과 감속 패턴을 시뮬레이션하기 위해 `stages`를 아래와 같이 설정했다.
stages: [
// {duration: '1s', target: 1},
{duration: '10s', target: 10000},
{duration: '10s', target: 9000},
{duration: '10s', target: 8000},
{duration: '10s', target: 7000},
{duration: '10s', target: 6000},
{duration: '10s', target: 5000},
],
부하 테스트 결과
주요 분석 포인트
- 에러 로그
- `can't assign requested address`
- 소켓 고갈 문제
- `K6`에서 동시에 많은 요청을 생성해 사용 가능한 소켓 포트가 부족할 때 발생
- 특히 로컬에서 부하 테스트 실행 시 제한이 걸릴 수 있음
- 해결 방법
- 클라이언트 파일 디스크립터 제한 증가
- 소켓 고갈 문제
- `i/o timeout`
- `Spring` 서버 처리 속도 부족
- 서버가 요청을 처리하는 데 시간이 오래 걸려 타임아웃 발생
- 네트워크 대역폭 제한
- 서버와 클라이언트 간의 네트워크 속도가 느림이 원인
- 해결방법
- `Tomcat` 스레드 풀과 대기열 크기를 늘림
- `Spring`에서 요청 시간 연장
- 서버에서 타임아웃을 늘려 요청 처리 시간 확보
- `Spring` 서버 처리 속도 부족
- `request timeout`
- `Spring` 서버 과부화
- 요청이 몰리면서 서버가 응답을 지연하거나 누락
- `K6` 요청 타임아웃 설정 부족
- `Spring` 서버 과부화
- Spring 서버가 요청을 거부
- 과도한 요청으로 인해 서버가 `CPU`, `Memory`, `데이터베이스 연결 풀`을 소진
- 데이터베이스 연결 풀 크기 조정
- 과도한 요청으로 인해 서버가 `CPU`, `Memory`, `데이터베이스 연결 풀`을 소진
- `can't assign requested address`
- 성공률과 에러율
- 성공률: 99%의 성공률
- 에러율(http_req_failed): 에러율은 약 0.6%로, 여러 요인(DB 요청 과부화, Spring 거부)에서 실패된 것으로 추측
- 응답 시간 (http_req_duration)
- 상위 95% 응답 시간
- 대략, `2.6s` 이내에 응답합을 확인
- 일부 요청에서는 최대 5.8초까지 대기 시간 발생
- 트래픽 증가로 인해 서버가 부하를 감당하지 못한 것으로 예상
- 상위 95% 응답 시간
즉, 테스트 결과를 통해 서버나 데이터베이스에서 병목 현상이 발생할 가능성이 고려된다.
(에러 로그에서 살펴본 것처럼 `Tomcat`의 스레드 풀을 증가시키거나 데이터베이스 커넥션 풀을 증가시켜볼 수도 있겠지만,
이번 포스팅의 목표는 `DB`의 부하이기 때문에 관련 수정은 하지 않도록 하겠다.)
그렇다면 해볼 수 있는 방법은 무엇이 있을까?
티켓 예매 정보를 우선 `Redis`에 저장하고 `Write Back` 전략처럼
스케줄러를 활용해 새벽 시간에 `Redis`에 저장된 데이터를 `DB`로 옮기는 방식을 사용하면
DB 부하가 줄지 않을까?
코드와 함께 알아보자.
Ticket2RedisRepository
@Slf4j
@Repository
@RequiredArgsConstructor
public class Ticket2RedisRepository {
private static final String EVENT_TICKET_COUNT_PREFIX = "EVENT:TICKET:COUNT:";
private static final String EVENT_KEY_PREFIX = "EVENT:";
private static final String USER_KEY_PREFIX = "USER:";
private final RedisTemplate<String, Object> redisTemplate;
...
public void savePurchaseTicket(PurchaseTicketRequest request) {
String ticketKey = EVENT_KEY_PREFIX + request.getEventId() + USER_KEY_PREFIX + request.getUserId();
redisTemplate.opsForHash().put(ticketKey, "userId", request.getUserId().toString());
redisTemplate.opsForHash().put(ticketKey, "eventId", request.getEventId().toString());
redisTemplate.opsForHash().put(ticketKey, "name", request.getName());
redisTemplate.opsForHash().put(ticketKey, "phone", request.getPhone());
}
public void deleteTicketKeys(Long eventId, Long userId) {
String userTicketKey = EVENT_KEY_PREFIX + eventId + USER_KEY_PREFIX + userId;
String ticketCountKey = EVENT_TICKET_COUNT_PREFIX + eventId;
redisTemplate.delete(userTicketKey);
redisTemplate.delete(ticketCountKey);
log.info("Deleted Redis keys: {}, {}", userTicketKey, ticketCountKey);
}
}
TicketV8Service
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV8Service {
private final Ticket2Repository ticket2Repository;
private final Ticket2RedisRepository ticket2RedisRepository;
private final EventRepository eventRepository;
private final UserRepository userRepository;
...
public void purchaseTicketOnlyRedis(PurchaseTicketRequest request) {
Long isAdd = ticket2RedisRepository.add(request.getEventId(), request.getUserId());
if(isAdd != 1){
return;
}
Long count = ticket2RedisRepository.increment(request.getEventId());
if(count > 30000){
return;
}
ticket2RedisRepository.savePurchaseTicket(request);
}
}
TicketScheduler
@Slf4j
@Component
@RequiredArgsConstructor
public class TicketScheduler {
private final RedisTemplate<String, Object> redisTemplate;
private final Ticket2Repository ticket2Repository;
private final Ticket2RedisRepository ticket2RedisRepository;
private final UserRepository userRepository;
private final EventRepository eventRepository;
@Scheduled(cron = "0 0 4 * * *")
@Transactional
public void saveRedisDataToDatabase(){
Set<String> keys = redisTemplate.keys("EVENT:*USER:*");
if(keys != null){
for(String key : keys){
Map<Object, Object> data = redisTemplate.opsForHash().entries(key); // <key, value>
try{
Long eventId = extractEventId(key);
Long userId = extractUserId(key);
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User Not Found"));
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new RuntimeException("Event Not Found"));
Ticket2 ticket2 = Ticket2.builder()
.user(user)
.event(event)
.name((String) data.get("name"))
.phone((String) data.get("phone"))
.build();
ticket2Repository.save(ticket2);
ticket2RedisRepository.deleteTicketKeys(eventId, userId);
}catch (Exception e){
log.error("[saveRedisDataToDatabase] Error processing key {}: {}", key, e.getMessage());
throw e;
}
}
}
}
private Long extractEventId(String key) {
String[] parts = key.split("EVENT:|USER:");
if (parts.length < 3) {
throw new IllegalArgumentException("Invalid key format: " + key);
}
return Long.parseLong(parts[1]);
}
private Long extractUserId(String key) {
String[] parts = key.split("EVENT:|USER:");
if (parts.length < 3) {
throw new IllegalArgumentException("Invalid key format: " + key);
}
return Long.parseLong(parts[2]);
}
}
부하 테스트
위와 같이 `Redis`에 데이터를 저장하는 것으로 수정한 후 다시 부하 테스트를 진행했다.
성공률이 99%로 동일하지만, 전체 호출횟수 자체가 4만이나 늘었고,
(즉, 누락이 상대적으로 덜 일어났다는 것)
상위 95%의 응답시간이 `2.6s` -> `932.12ms`로 줄었다.(약, 36% 성능 개선)
또한, 최대 기다린 시간도 `5.8s`에서 `1.88s`로 줄은 것을 확인할 수 있다.(약, 31% 성능 개선)
로컬로 진행했기 때문에 네트워크, 하드웨어, 메모리, 소켓 개수 등 많은 요인에 의해 영향을 받았을 것이라 생각한다.
하지만, 비약적이지만 성능 향상이 있음을 확인했다.
처음에는 아래와 같이 부하 테스트를 진행했다.
stages: [
// {duration: '1s', target: 1},
{duration: '10s', target: 30000},
{duration: '10s', target: 30000},
{duration: '10s', target: 20000},
{duration: '10s', target: 10000},
{duration: '10s', target: 5000},
{duration: '10s', target: 5000},
],
하지만, 너무 많은 사용자의 접근으로 제대로된 테스트가 이뤄지지 않았다.
그래서, 소켓 에러가 발생하지 않을 수준으로 부하테스트 값을 내렸고,
결과적으로 1분간 약 31만의 사용자의 요청을 처리할 수 있게 되었다.
(DB보다 더 빠른 응답 속도로)
정리
이번 포스팅을 통해
- `Lock` 대신 `Redis`의 자료구조를 적절히 사용해서 동시성 문제를 해결하는 방법
- 많은 요청으로 인해 DB 쓰기 시 발생할 수 있는 지연
- Redis에 쓰기 후 Write Back을 사용한 내구성 증가 과정
을 살펴보았다.
부하테스트와 관련해서는 앞으로 더 지식을 쌓아야 할 것 같다.
'Spring > 동시성 & Lock' 카테고리의 다른 글
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 1 (0) | 2024.11.19 |
---|---|
[Spring Data] Redis Lock을 적용해보자 - 2 (0) | 2024.10.17 |
[Spring Data] Redis Lock을 적용해보자 - 1 (0) | 2024.10.16 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3 (0) | 2024.09.27 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 2 (0) | 2024.09.25 |