JMeter를 이용하여 선착순 구매와 같이 갑작스럽게 트래픽이 몰렸을 때의 성능을 측정
Thread Group 설정
초기의 시나리오
- 사용자가 구매할 게임 ID 리스트를 받고 FeignClient를 이용해 game-service에 게임 ID에 대한 게임 정보 반환
- 게임의 재고가 1이상이라면 원래 가격의 20%를 할인하고 모든 게임의 가격을 더해 totalPrice로 저장
- 주문 테이블(orders) 저장
- 주문과 게임의 중간 테이블(OrderGame) 저장
- 할인을 하는 게임의 ID만 리스트로 추출
- FeignClient를 이용하여 game-service에 게임 ID 리스트를 보내고, game-service에서는 재고 1 감소
- FeignClient를 이용하여 payment-service에 보내 결제 진행
- 결제 상태에 따른 로직 실행
- 결제 성공 시 주문 상태를 구매(PURCHASE)로 변경
- 결제 실패 시 주문 상태를 취소(CANCEL)로 변경하고, FeignClinet를 이용하여 game-service에 재고를 감소했던 게임 ID 리스트를 보내어 재고 1 증가
@Transactional
public boolean requestOrder(GameIdListDto gameIdList, HttpServletRequest req) {
List<GameDto> gameDtoList = gameApi.subFind(gameIdList.getGameIdList());
int totalPrice = gameDtoList.stream()
.peek(gameDto -> {
if (gameDto.getEventStock() > 0) {
gameDto.setPrice((int) (gameDto.getPrice() * 0.8));
}
})
.mapToInt(GameDto::getPrice)
.sum();
Order order = orderRepository.save(Order.toEntity(totalPrice, getMemberId(req)));
List<OrderGame> orderGameList = gameDtoList.stream()
.map(gameDto -> OrderGame.builder()
.price(gameDto.getPrice())
.gameId(gameDto.getGameId())
.orderId(order.getOrderId())
.build()
)
.collect(Collectors.toList());
orderGameRepository.saveAll(orderGameList);
List<Long> eventGameList = gameDtoList.stream()
.filter(gameDto -> gameDto.getEventStock() > 0)
.map(GameDto::getGameId)
.toList();
gameApi.stockDecrease(eventGameList);
PaymentRequestDto paymentRequestDto = PaymentRequestDto.builder()
.totalPrice(order.getTotalPrice())
.paymentWay(PaymentWay.CARD_PAYMENT)
.orderId(order.getOrderId())
.build();
PaymentResponseDto paymentResponseDto = paymentApi.payment(paymentRequestDto);
if(paymentResponseDto.isSuccess()){
order.setOrderStatus(OrderStatus.PURCHASE);
return true;
} else {
order.setOrderStatus(OrderStatus.CANCEL);
gameApi.stockIncrease(eventGameList);
return false;
}
}
- 사용자가 위시리스트에 담은 게임들을 주문하기 버튼을 눌렀을 때 주문이 이루어지는데, 여기서 모든 게임의 정보를 담을 수 없기 때문에 사용자가 주문할 게임들의 ID 리스트만 받음
→ 즉, FeignClient를 이용하여 gameApi.subFind() 메서드로 game-service를 호출하는 것은 불가피 - 하지만 주문할 때 재고를 감소하고 결제에 실패했을 때 재고를 다시 늘릴 때도 game-service를 호출하고 game-service에서 DB connection을 결과를 동기적으로 기다리는 것은 매우 비효율적
- 따라서 다음과 같은 Redis 캐시 전략을 이용
- 캐시 읽기 전략은 Cache-Aside 방식으로 Redis에 해당 게임의 재고를 확인하고 해당 게임 재고가 없을 경우 Database에 접근하여 재고를 읽어오고 Redis에 업데이트
- 캐시 쓰기 전략은 Write-Through 방식으로 Redis와 DB의 재고를 동시에 증감하는데, DB의 재고는 비동기 방식으로 하기로 결정
변경된 시나리오
- Order Service에 주문 요청
- Game Service에 FeignClient 요청 (게임 ID 리스트에 대한 정보 반환)
- Redis 캐시에서 재고 확인
- Redis 캐시에 해당 재고가 있으면 20% 할인
- Redis 캐시에 해당 재고가 없으면 gameDto의 eventStock이 1이상인 경우 Redis에 추가
- Redis 캐시 재고 1 감소
- 비동기 DB 재고 1 감소
- 결제 API 호출
- 결제 성공 시 주문 상태를 구매(PURCHASE)로 변경
- 결제 실패 시 주문 상태를 취소(CANCEL)로 변경하고, Redis 재고 1증가하고 비동기 DB 재고 1증가
@Transactional
public boolean requestOrder(GameIdListDto gameIdList, HttpServletRequest req) {
List<GameDto> gameDtoList = gameApi.subFind(gameIdList.getGameIdList());
gameDtoList
.forEach(gameDto -> {
String key = "eventStock" + gameDto.getGameId();
// 레디스 재고 확인
if(redisService.getEventStock(key) == null){
redisService.setEventStock(key, gameDto.getEventStock());
}
});
int totalPrice = gameDtoList.stream()
.peek(gameDto -> {
if (redisService.getEventStock("eventStock" + gameDto.getGameId()) > 0) {
gameDto.setPrice((int) (gameDto.getPrice() * 0.8));
}
})
.mapToInt(GameDto::getPrice)
.sum();
Order order = orderRepository.save(Order.toEntity(totalPrice, getMemberId(req)));
List<OrderGame> orderGameList = gameDtoList.stream()
.map(gameDto -> OrderGame.builder()
.price(gameDto.getPrice())
.gameId(gameDto.getGameId())
.orderId(order.getOrderId())
.build()
)
.collect(Collectors.toList());
orderGameRepository.saveAll(orderGameList);
// 레디스에서 재고가 1이상인 게임들의 ID만 리스트로 생성
List<Long> eventGameList = gameDtoList.stream()
.map(GameDto::getGameId)
.filter(gameId -> redisService.getEventStock("eventStock" + gameId) > 0)
.toList();
// Redis 재고 1 감소
eventGameList.forEach(gameId -> redisService.stockDecrease("eventStock" + gameId));
// 비동기로 DB의 재고 1 감소
stockDecrease(eventGameList);
PaymentRequestDto paymentRequestDto = PaymentRequestDto.builder()
.totalPrice(order.getTotalPrice())
.paymentWay(PaymentWay.CARD_PAYMENT)
.orderId(order.getOrderId())
.build();
PaymentResponseDto paymentResponseDto = paymentApi.payment(paymentRequestDto);
if(paymentResponseDto.isSuccess()){
order.setOrderStatus(OrderStatus.PURCHASE);
return true;
} else {
order.setOrderStatus(OrderStatus.CANCEL);
// 결제 실패 시 레디스 재고 1 증가
eventGameList.forEach(gameId -> redisService.stockIncrease("eventStock" + gameId));
// 비동기로 DB의 재고 1 증가
stockIncrease(eventGameList);
return false;
}
}
@Async
protected void stockDecrease(List<Long> evnetGameList){
gameApi.stockDecrease(evnetGameList);
}
@Async
protected void stockIncrease(List<Long> evnetGameList){
gameApi.stockIncrease(evnetGameList);
}
@Async 어노테이션 사용 시 BindException 발생
java.net.BindException: Address already in use: no further information at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na] at java.base/sun.nio.ch.Net.pollConnectNow(Net.java:672) ~[na:na] at java.base/sun.nio.ch.NioSocketImpl.timedFinishConnect(NioSocketImpl.java:547) ~[na:na] at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:602) ~[na:na] at java.base/java.net.Socket.connect(Socket.java:633) ~[na:na] at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178) ~[na:na] at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:534) ~[na:na] at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:639) ~[na:na] at java.base/sun.net.www.http.HttpClient.<init>(HttpClient.java:282) ~[na:na] at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:387) ~[na:na] at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:409) ~[na:na] at
(생략)
- 위 에러에서 @Async 어노테이션이 있는 stockDecrease와 stockIncrease에서 에러가 발생했다고 나왔고, 이에 대한 에러 해결 방법을 구글링을 통해 찾아봄
- 이 에러는 기본 최대 접속 가능 포트가 5000개이기 때문에 5000개를 초과할 경우 발생하는 에러
- 최대 접속 가능 포트를 크게 늘려주면 해결이 가능
- 윈도우 검색 버튼에 regedit을 입력하여 레지스트 편집기 열기
- HKEY_LOCAL_MACHINE → SYSTEM →CurrentControlSet →Services →Tcpip →Parameters 경로로 이동
- 마우스 우클릭 → 새로 만들기 → DWORD(32비트 값) 클릭
- 생성된 파일의 이름 MaxUserPort로 지정
- MaxUserPort 우클릭 → 수정 → 단위 10진수 → 값 데이터 65534 입력 후 확인
- 컴퓨터 재부팅
참고 자료
https://m.blog.naver.com/pcmola/222131361178
SQLTransientConnectionException 에러 발생
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30260ms (total=140, active=140, idle=0, waiting=58) at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:686) ~[HikariCP-5.1.0.jar:na] at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:179) ~[HikariCP-5.1.0.jar:na] at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:144) ~[HikariCP-5.1.0.jar:na] at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:127) ~[HikariCP-5.1.0.jar:na] at org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl.getConnection(DatasourceConnectionProviderImpl.java:122) ~[hibernate-core-6.5.2.Final.jar:6.5.2.Final] at org.hibernate.internal.NonContextualJdbcConnectionAccess.obtainConnection(NonContextualJdbcConnectionAccess.java:46) ~[hibernate-core-6.5.2.Final.jar:6.5.2.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.acquireConnectionIfNeeded(LogicalConnectionManagedImpl.java:113) ~[hibernate-core-6.5.2.Final.jar:6.5.2.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.getPhysicalConnection(LogicalConnectionManagedImpl.java:143) ~[hibernate-core-6.5.2.Final.jar:6.5.2.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.getConnectionForTransactionManagement(LogicalConnectionManagedImpl.java:273) ~[hibernate-core-6.5.2.Final.jar:6.5.2.Final] at org.hibernate.resource.jdbc.internal.LogicalConnectionManagedImpl.begin(LogicalConnectionManagedImpl.java:281) ~[hibernate-core-6.5.2.Final.jar:6.5.2.Final]
- 해당 에러의 해결책을 찾기 위해 정말 많은 테스트를 시도하였지만 모두 실패
- application.yml 파일에서 hikari의 maximumPoolSize, minimumIdle, connectionTimeout, idleTimeout, maxLifetime 등 여러 설정들을 ChatGPT의 도움을 받아가며 계속 수정을 하였는데, maximumPoolSize와 minimumIdle을 10, 5부터 시작해서 계속 늘리더라도 에러 발생하는 것이 조금 지연 될 뿐 결국에는 Hikari Pool 에러가 발생
- 해당 문제에 대한 구글링을 한 결과 maximumPoolSize를 늘리거나, connection timeout을 늘리는 방식으로 해결을 할 수는 있지만 일시적인 해결일 뿐, 결국 순간적으로 트래픽이 증가하게 된다면 결국 커넥션이 금방 고갈되어 근본적인 해결 방식이 아님
- 해결책으로는 비즈니스 로직을 수정하여 트랜잭션을 짧게 가져가는 방법
- 기존의 코드에서는 결제 성공, 실패 여부에 따라 orderStatus를 set으로 변경함으로써 더티 체킹 기능을 이용하여 트랜잭션 종료 후 자동으로 update 쿼리가 발생하는 것을 기대하여 @Transaction 어노테이션을 사용하였지만 이 어노테이션에 의해서 트랜잭션을 길게 가져가게 되어 발생한 문제였던 것으로 예상 됨
- 따라서, @Transaction 어노테이션을 제거하게 되면 Repository.save() 메서드 작업이 끝나자마자 커넥션을 반납하기 때문에 connection-timeout이 발생하지 않을 것
→ 단, 더티 체킹이 발생하지 않기 때문에 setOrderStatus로 상태를 변경한 뒤 Repository.save() 메서드를 한 번 더 사용
// @Transactional 어노테이션 삭제
public boolean requestOrder(GameIdListDto gameIdList, HttpServletRequest req) {
List<GameDto> gameDtoList = gameApi.subFind(gameIdList.getGameIdList());
Map<String, Integer> eventStockMap = redisService.getMultiEventStock(gameDtoList);
int totalPrice = gameDtoList.stream()
.peek(gameDto -> {
Integer eventStock = eventStockMap.get("eventStock" + gameDto.getGameId());
if (eventStock != null && eventStock > 0) {
gameDto.setPrice((int) (gameDto.getPrice() * 0.8));
}
})
.mapToInt(GameDto::getPrice)
.sum();
Order order = orderRepository.save(Order.toEntity(totalPrice, getMemberId(req)));
List<OrderGame> orderGameList = gameDtoList.stream()
.map(gameDto -> OrderGame.builder()
.price(gameDto.getPrice())
.gameId(gameDto.getGameId())
.orderId(order.getOrderId())
.build()
)
.collect(Collectors.toList());
orderGameRepository.saveAll(orderGameList);
List<Long> eventGameList = gameDtoList.stream()
.filter(gameDto -> {
Integer eventStock = eventStockMap.get("eventStock" + gameDto.getGameId());
return eventStock != null && eventStock > 0;
})
.map(GameDto::getGameId)
.toList();
redisService.bulkStockDecrease(eventGameList);
stockDecrease(eventGameList);
PaymentRequestDto paymentRequestDto = PaymentRequestDto.builder()
.totalPrice(order.getTotalPrice())
.paymentWay(PaymentWay.CARD_PAYMENT)
.orderId(order.getOrderId())
.build();
PaymentResponseDto paymentResponseDto = paymentApi.payment(paymentRequestDto);
if(paymentResponseDto.isSuccess()){
order.setOrderStatus(OrderStatus.PURCHASE);
// 더티 체킹이 되지 않아 수정된 order를 다시 저장
orderRepository.save(order);
return true;
} else {
order.setOrderStatus(OrderStatus.CANCEL);
// 더티 체킹이 되지 않아 수정된 order를 다시 저장
orderRepository.save(order);
redisService.bulkStockIncrease(eventGameList);
stockIncrease(eventGameList);
return false;
}
}
참고 자료
아직 해결되지 않은 문제
- 위의 방법들로 JMeter에서 에러율을 대폭 감소시킬 수 있었으나, 여전히 2~3%정도 에러가 발생
feign.FeignException$InternalServerError: [500 Internal Server Error] during [POST] to [http://localhost:9999/game-service/v1/subFind] [GameApi#subFind(List)]: [{"timestamp":"2024-09-17T21:37:52.237+00:00","status":500,"error":"Internal Server Error","path":"/v1/subFind"}] at feign.FeignException.serverErrorStatus(FeignException.java:259) ~[feign-core-13.3.jar:na] at feign.FeignException.errorStatus(FeignException.java:206) ~[feign-core-13.3.jar:na] at feign.FeignException.errorStatus(FeignException.java:194) ~[feign-core-13.3.jar:na]
(생략)
- 대규모 트래픽 발생 시 FeignClient에서 위와 같은 에러가 발생한 문제가 있었음
- 해당 문제에 대해서는 FeignConfig에서 Request.Option()으로 연결 및 읽기 타임아웃을 조절하는 방법으로 시도해 보았지만
- 오히려 SocketTimeoutException이 발생하게 됨
java.net.SocketTimeoutException: Read timed out at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:288) ~[na:na] at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:314) ~[na:na] at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:355) ~[na:na] at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:808) ~[na:na] at java.base/java.net.Socket$SocketInputStream.read(Socket.java:966) ~[na:na]
(생략)
- 해당 문제에 대해서는 서킷 브레이커를 도입하여 과도한 요청으로 인해 서비스가 중단되는 것을 방지할 수 있고, Resilience4j와 같은 라이브러리를 사용하여 서킷 브레이커를 쉽게 적용할 수 있다고 함
- 하지만, 이 문제보다는 현재 동시다발적인 요청에 의해 10000개의 요청이 있을 경우 재고의 증감이 원활하게 되지 않기 때문에 락에 대한 것을 먼저 알아보고 동시성 제어를 하는 것이 목표
- 낙관적 락, 비관적 락, 분산락, 데드락에 대해 공부하고 적용할 예정
'Tools' 카테고리의 다른 글
[JMeter] JWT 토큰 추출 및 헤더 추가 (2) | 2024.09.04 |
---|---|
[JMeter] JMeter 개념, 사용 방법 (0) | 2024.09.04 |