영감을 (inspire) 주고픈 개발 블로그

[성능테스트 시리즈 - 4] 성능 테스트를 위한 서버 아키텍처를 통해 성능 테스트 해보기 본문

개발/시스템 안정성 flow

[성능테스트 시리즈 - 4] 성능 테스트를 위한 서버 아키텍처를 통해 성능 테스트 해보기

inspire12 2024. 11. 24. 23:41

성능 테스트는 프로그램(서버 등)에 대한 메타 정보를 확인하는데 굉장히 중요한 방법이지만, 막상 해보려고하면 어떤 식으로 해야할지 막막합니다.

 

처음 서버를 공부할 때는 기능개발에 급급해 이게 더 빠르다, 이게 더 좋다 라고만 보고 그렇구나라고 지식에 대한 의심없이 받아드리곤 했습니다.

 하지만 이런 엄밀하지 않은 접근은 나중에 기술 부채로 크게 다가오게 됩니다.

실제로 2년차 서버 개발자 팀에선 댓글 서버와 비디오 서버에 문제가 있는지 알고 엉뚱한 개선 만을 하다가 나중에야 푸시 서버에 성능 문제가 있다는 걸 알고 개선했습니다.

성능 이슈는 개발 초기에 잘 보이지 않습니다. 사람이 많아지면 발생하게 되고 그 때되면 문제를 파악하기 쉽지 않습니다. 

그래서 서버 매트릭을 기록하고 중앙 모니터링하는 게 중요합니다. 하지만 이 방법은 문제를 빠르게 발견할 수 있는 것이지 문제를 미리 알려면 미리 성능 테스트를 진행해 어느 정도 환경에서 부하를 버틸 수 있는지 알아야 합니다. 그래야 적절히 인프라를 분배해 비용도 아낄 수 있고 사용성도 개선할 수 있습니다. 

 

https://inspire12.tistory.com/326

 

[성능테스트 시리즈 - 1] 간편하게 백엔드 서버 성능 테스트 시스템 세팅하기: docker-compose를 성능

성능테스트란?서버 성능 테스트를 진행하면 서버가 어느 정도 부하에 대해 버틸 수 있는지 눈으로 확인해 볼 수 있다. 라이브 서버에 올리기 전 혹은 사용자가 몰릴 수 있는 이벤트를 진행할 때

inspire12.tistory.com

작년에 docker-compose 를 통해 서버를 테스트를 할 수 있는 글을 적은 적 있는데요 

이 글은 jmeter 를 통해 부하 테스트를 진행했는데 jmeter의 테스트 스크립트는 jmx 형식이라 관리가 어렵고 cui (command) 로 쓰기 어려운 면이 있습니다. 

서버 자체도 그냥 실행하도록 했는데 서버를 고립시키기 어려워 성능테스트에 노이즈가 들어가기 쉽습니다. 

마지막으로 위의 테스트 구조는 하나의 서버만을 테스트 할 수 있는 구조이고 스케일업 구조를 처리하기 어려웠습니다. 

 

그래서 이번에는 아래와 같은 부분이 가능한 아티클을 직접 포팅해서 써보았습니다. 

  • 부하테스트 k6 ( js 를 통한 스크립트 구현,  go 를 통한 내부 구현)
  • docker 를 통해 성능 
  • nginx 를 통한 확장성 

원본 글 

1편은 테스트 결과, 2편은 프로젝트에 대한 설명입니다. 

An epic tale: comparing JDBC and R2DBC in a real-world scenario 1편

 

An epic tale: comparing JDBC and R2DBC in a real-world scenario

The whole quest revolves around a simple but hard-to-answer question: “does it makes any sense to use R2DBC?”

blog.devgenius.io

 

An epic tale: comparing JDBC and R2DBC in a real-world scenario 2편

 

An epic tale: comparing JDBC and R2DBC in a real-world scenario (Part 2/2)

The whole quest revolves around a simple but hard-to-answer question: “does it makes any sense to use R2DBC?”

blog.devgenius.io

 

원본 프로젝트 

https://github.com/GaetanoPiazzolla/spring-boot-jdbc-vs-r2dbc

 

GitHub - GaetanoPiazzolla/spring-boot-jdbc-vs-r2dbc: K6 Performance testing of spring boot JDBC and R2DBC applications.

K6 Performance testing of spring boot JDBC and R2DBC applications. - GaetanoPiazzolla/spring-boot-jdbc-vs-r2dbc

github.com

https://gae-piaz.medium.com/

 

Gaetano Piazzolla – Medium

Read writing from Gaetano Piazzolla on Medium. Tech. Writer and Passionate Developer. Every day, Gaetano Piazzolla and thousands of other voices read, write, and share important stories on Medium.

gae-piaz.medium.com

 

원본 글은 Spring jdbc 와 R2dbc 의 성능을 실제로 비교해보는 아티클입니다. 일독을 권해봅니다. 이 글을 쓰신 저자분한테 양질의 글도 많아서 도움이 많이 됩니다.

 

원본 아티클을 보면 테스트 결과 

Database Items retrieved: 6000 / 3000 / 1500
Troughput:
Case 1: 500 users
- R2DBC: 4083 / 5373 / 10689 - JDBC: 3321 / 5447 / 22765
Case 2: 250 users
- R2DBC: 4385 / 7364 / 15152 - JDBC: 3520 / 6944 / 20255
Case 3: 100 users
- R2DBC: 4057 / 7636 / 15341 - JDBC: 3247 / 8835 / 22079
Case 4: 10 users
- R2DBC: 4541 / 8823 / 16111 - JDBC: 6841 / 12031 / 26349

 

동시 사용자 수가 100명 이상인 3000개 항목을 검색해야 R2DBC가 훨씬 더 나은 것으로 보입니다. 이는 매우 제한적인 시나리오입니다. 동시 사용자 수가 100명이 넘는 한 번에 3000개 이상의 데이터베이스 행이 필요한 애플리케이션이 많이 있나요? 또한 쿼리 페이지화가 옵션이라는 점을 고려하면 응답은 분명합니다. 데이터베이스 집약적인 웹 애플리케이션의 압도적인 대다수에 완전 반응형 스택이 좋은 선택은 아닌 것 같습니다.

 

 


저는 위 프로젝트를 확장해서 테스트 용도의 프로젝트를 추가해보겠습니다.

https://github.com/inspire12/performance-testing-architecture

 

GitHub - inspire12/performance-testing-architecture

Contribute to inspire12/performance-testing-architecture development by creating an account on GitHub.

github.com

 

여러 테이블에서 값을 가져오는 성능이 어떻게 나오는지가 궁금해서 세 가지 케이스의 API를 만들었습니다.

  • 동기적으로 가져오는 것
  • 비동기적으로 가져오는 것
  • join을 통해 한번에 가져오는 것 

 

Spring boot 프로젝트를 하나 만들고 세가지 API 로 구분해서 추가했습니다. 

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    @GetMapping(path = "/{orderId}")
    public OrderDetailsResponse getOrderDetails(@PathVariable Long orderId) {

        return orderService.getOrderDetailsResponse(orderId);
    }

    @GetMapping(path = "/async/{orderId}")
    public OrderDetailsResponse getOrderDetailsAsync(@PathVariable Long orderId) {

        return orderService.getOrderDetailsResponseByAsync(orderId);
    }

    @GetMapping(path = "/join/{orderId}")
    public OrderDetailsResponse getOrderDetailsJoin(@PathVariable Long orderId) {

        return orderService.getOrderDetailsResponseByJoin(orderId);
    }
}

 

Entity를 모두 Dto로 변환해서 주는 형태로 프로젝트 Dto 객체에 from 함수를 통해 변환했습니다. (원본에선 MapStruct를 통해 사용한 부분입니다.)

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class OrderService {
    private final OrderJoinRepository orderJoinRepository;
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final DeliveryRepository deliveryRepository;
    private final RefundRepository refundRepository;

    public OrderDetailsResponse getOrderDetailsResponse(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new RuntimeException("Order not found"));

        List<Product> products = productRepository.findByOrder(order);

        Delivery delivery = deliveryRepository.findById(order.getDeliveryId())
                .orElseThrow(() -> new RuntimeException("Delivery not found"));
        Refund refund = refundRepository.findById(order.getRefundId())
                .orElseThrow(() -> new RuntimeException("Refund not found"));

        List<OrderItemDto> orderItemDto = products.stream()
                .map(product -> OrderItemDto.from(product, delivery, refund))
                .collect(Collectors.toList());
        return new OrderDetailsResponse(orderItemDto);
    }

    public OrderDetailsResponse getOrderDetailsResponseByAsync(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(() -> new RuntimeException("Order not found"));

        CompletableFuture<List<Product>> productsCompleteFuture = CompletableFuture.supplyAsync(() -> productRepository.findByOrder(order));

        CompletableFuture<Delivery> deliveryCompleteFuture = CompletableFuture.supplyAsync(() -> deliveryRepository.findById(order.getDeliveryId())
                .orElseThrow(() -> new RuntimeException("Delivery not found")));
        CompletableFuture<Refund> refundCompleteFuture = CompletableFuture.supplyAsync(() -> refundRepository.findById(order.getRefundId())
                .orElseThrow(() -> new RuntimeException("Refund not found")));

        List<Product> products = productsCompleteFuture.join();
        Delivery delivery = deliveryCompleteFuture.join();
        Refund refund = refundCompleteFuture.join();

        List<OrderItemDto> orderItemDto = products.stream()
                .map(product -> OrderItemDto.from(product, delivery, refund))
                .collect(Collectors.toList());
        return new OrderDetailsResponse(orderItemDto);
    }

    public OrderDetailsResponse getOrderDetailsResponseByJoin(Long orderId) {
        OrderJoin orderJoin = orderJoinRepository.findById(orderId)
            .orElseThrow(() -> new RuntimeException("Order not found"));

        List<OrderItemDto> orderItemDto = orderJoin.getProducts().stream()
          .map(product -> OrderItemDto.from(product, orderJoin.getDelivery(), orderJoin.getRefund()))
          .collect(Collectors.toList());
        return new OrderDetailsResponse(orderItemDto);
    }
}

 

서비스 코드를 보면 첫번째 함수는 클라이언트 요청에서 온 orderId 값을 통해 Order 데이터를 가져온 후 동기로 product, delivery, refund, payment 정보를 가져옵니다. 두번째 함수는 Order 데이터를 가져온 후 비동기로 정보를 가져옵니다. 세번째 함수는 Order 데이터를 Join 된 형태의 Entity를 통해 한 번에 가져옵니다. 

@Getter
@Table(name = "ORDERS")
@Entity
@AllArgsConstructor
public class OrderJoin extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id", nullable = false)
    private Long id;

    @Column(name = "order_number", nullable = false)
    private int orderNumber; // 주문 번호

    @Column(name = "order_status", nullable = false)
    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus; // 주문 상태

    @OneToMany
    @JoinColumn(name = "product_id")
    private Set<Product> products;

    @OneToOne
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    @OneToOne
    @JoinColumn(name = "refund_id")
    private Refund refund;

    @OneToOne
    @JoinColumn(name = "payment_id")
    private Payment payment;

    protected OrderJoin() {
    }
}

 

Hibernate: 
    select
        orderjoin0_.order_id as order_id1_1_0_,
        orderjoin0_.created_at as created_2_1_0_,
        orderjoin0_.updated_at as updated_3_1_0_,
        orderjoin0_.delivery_id as delivery4_1_0_,
        orderjoin0_.order_number as order_nu5_1_0_,
        orderjoin0_.order_status as order_st6_1_0_,
        orderjoin0_.payment_id as payment_7_1_0_,
        orderjoin0_.refund_id as refund_i8_1_0_,
        delivery1_.delivery_id as delivery1_0_1_,
        delivery1_.created_at as created_2_0_1_,
        delivery1_.updated_at as updated_3_0_1_,
        delivery1_.address as address4_0_1_,
        delivery1_.delivery_memo as delivery5_0_1_,
        delivery1_.mobile as mobile6_0_1_,
        delivery1_.recipient_name as recipien7_0_1_,
        delivery1_.store_password as store_pa8_0_1_,
        delivery1_.zip_code as zip_code9_0_1_,
        payment2_.id as id1_2_2_,
        payment2_.created_at as created_2_2_2_,
        payment2_.updated_at as updated_3_2_2_,
        payment2_.payment_amount as payment_4_2_2_,
        payment2_.payment_method as payment_5_2_2_,
        payment2_.payment_method_name as payment_6_2_2_,
        refund3_.refund_id as refund_i1_4_3_,
        refund3_.created_at as created_2_4_3_,
        refund3_.updated_at as updated_3_4_3_,
        refund3_.refund_amount as refund_a4_4_3_,
        refund3_.refund_method_name as refund_m5_4_3_,
        refund3_.refund_status as refund_s6_4_3_ 
    from
        orders orderjoin0_ 
    left outer join
        deliverys delivery1_ 
            on orderjoin0_.delivery_id=delivery1_.delivery_id 
    left outer join
        payments payment2_ 
            on orderjoin0_.payment_id=payment2_.id 
    left outer join
        refunds refund3_ 
            on orderjoin0_.refund_id=refund3_.refund_id 
    where
        orderjoin0_.order_id=?
Hibernate: 
    select
        products0_.product_id as product_1_3_0_,
        products0_.product_id as product_1_3_1_,
        products0_.created_at as created_2_3_1_,
        products0_.updated_at as updated_3_3_1_,
        products0_.bundle_name as bundle_n4_3_1_,
        products0_.bundle_quantity as bundle_q5_3_1_,
        products0_.description as descript6_3_1_,
        products0_.order_id as order_id9_3_1_,
        products0_.price as price7_3_1_,
        products0_.product_name as product_8_3_1_ 
    from
        products products0_ 
    where
        products0_.product_id=?

 

join 된 데이터를 가져올 때 나오는 SQL 을 확인했습니다. 

보니까 OneToOne 데이터들은 Join으로 가져오는데 OneToMay 리스트 데이터는 따로 sql로 가져오네요 

 

다음은 성능테스트를 시스템에 넣는 과정입니다.

Dockerfile은 기존 프로젝트와 같습니다. 

FROM adoptopenjdk/openjdk11:x86_64-alpine-jdk-11.0.5_10-slim AS builder

WORKDIR /src
COPY src ./src/
COPY gradle ./gradle/
COPY build.gradle ./
COPY gradlew ./
COPY settings.gradle ./
#COPY gradle.properties ./
RUN chmod 777 ./gradlew
RUN ./gradlew build --exclude-task testClasses

##
#  Second Stage
##
FROM adoptopenjdk/openjdk11:x86_64-alpine-jdk-11.0.5_10-slim
EXPOSE 8080
COPY --from=builder /src/build/libs/order-perf-optimizer*  /app/spring-boot.jar
ENTRYPOINT ["java","-Duser.timezone=GMT+1","-jar","/app/spring-boot.jar"]

 

deployment/docker-compose.yaml  파일 추가된 내용입니다. 

ports 를 보면 docker container 연결을 8082 포트로 했습니다.


  nginx:
    container_name: nginx
    image: nginx:latest
    volumes:
      - ../nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - spring-jdbc
      - spring-r2dbc
      - spring-order

  spring-order:
    build:
      context: ../java-springboot-order-perf-optimizer
    depends_on:
      - db-service
    restart: always
    environment:
      - POOL_SIZE=20
    ports:
      - "8082:8080"
    deploy:
      replicas: 1
      resources:
        limits:
          cpus: '3'
          memory: 2G
        reservations:
          memory: 2G

  

nginx 에 추가한 내용입니다. 

nginx/nginx.conf

http {
    server {
        ...
        location /spring-order/ {
          proxy_pass http://spring-order:8080/;
        }
    }
}

 

docker-compose로 빌드하고 실행해보겠습니다.

 

마지막으로 실행해보겠습니다. 

 

이제 다음 글에서 실제 성능 테스트를 진행해보겠습니다. 

반응형