본문 바로가기

Tools

[JMeter] JMeter를 이용한 대용량 트래픽 측정

JMeter를 이용하여 선착순 구매와 같이 갑작스럽게 트래픽이 몰렸을 때의 성능을 측정

Thread Group 설정

 

초기의 시나리오

  1. 사용자가 구매할 게임 ID 리스트를 받고 FeignClient를 이용해 game-service에 게임 ID에 대한 게임 정보 반환
  2. 게임의 재고가 1이상이라면 원래 가격의 20%를 할인하고 모든 게임의 가격을 더해 totalPrice로 저장
  3. 주문 테이블(orders) 저장
  4. 주문과 게임의 중간 테이블(OrderGame) 저장
  5. 할인을 하는 게임의 ID만 리스트로 추출
  6. FeignClient를 이용하여 game-service에 게임 ID 리스트를 보내고, game-service에서는 재고 1 감소
  7. FeignClient를 이용하여 payment-service에 보내 결제 진행
  8. 결제 상태에 따른 로직 실행
    • 결제 성공 시 주문 상태를 구매(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의 재고는 비동기 방식으로 하기로 결정

변경된 시나리오

  1. Order Service에 주문 요청
  2. Game Service에 FeignClient 요청 (게임 ID 리스트에 대한 정보 반환)
  3. Redis 캐시에서 재고 확인
    • Redis 캐시에 해당 재고가 있으면 20% 할인
    • Redis 캐시에 해당 재고가 없으면 gameDto의 eventStock이 1이상인 경우 Redis에 추가
  4. Redis 캐시 재고 1 감소
  5. 비동기 DB 재고 1 감소
  6. 결제 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개를 초과할 경우 발생하는 에러
  • 최대 접속 가능 포트를 크게 늘려주면 해결이 가능
  1. 윈도우 검색 버튼에 regedit을 입력하여 레지스트 편집기 열기
  2. HKEY_LOCAL_MACHINE → SYSTEM →CurrentControlSet →Services →Tcpip →Parameters 경로로 이동
  3. 마우스 우클릭 → 새로 만들기 → DWORD(32비트 값) 클릭
  4. 생성된 파일의 이름 MaxUserPort로 지정
  5. MaxUserPort 우클릭 → 수정 → 단위 10진수 값 데이터 65534 입력 후 확인
  6. 컴퓨터 재부팅

참고 자료

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;
    }
}
참고 자료

https://jgrammer.tistory.com/entry/Spring-Boot-Hikari-Connection-Pool-%EC%97%90%EB%9F%AC-%ED%95%B8%EB%93%A4%EB%A7%81


아직 해결되지 않은 문제
  • 위의 방법들로 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