Spring API 성능 최적화 — 병렬 처리, 트랜잭션 분리, 캐시 적용

2024-09-11
#JAVA
3

우리가 현재 개발하고 있는 서비스들은 사용자가 적고, 규모도 작기 때문에 API 성능에 대해 큰 고민을 하지 않고 개발을 하게 된다.

하지만 사용자 수가 증가하고, 규모가 커지면서 원래는 빠르던 API들이 점점 느려질 수 있다고 한다.

 

따라서 우리는 API 성능을 최적화 하는 방법을 고려할 필요도 있다.

 

API 성능에 영향을 미칠 수 있는 요소들은 많겠지만 주로 마주칠 수 있는 문제들은 다음과 같다.

  • 데이터베이스의 느린 쿼리

  • 복잡한 비즈니스 로직

  • 낮은 성능의 코드

  • 부족한 리소스

 

여기서 마지막의 부족한 리소스는 상대적으로 해결하기 쉽다고 한다.

그냥 서버를 추가하면 되는 것이다.

 

또한, 낮은 성능의 코드와 같은 경우에도 그냥 개발자가 코드를 깔끔하게 짜면 되는 것이다.

 

병렬 처리

우리가 온라인 쇼핑몰을 개발한다고 해보자. 해당 서비스에서 상품을 주문하는 기능을 구현한다면

해당 기능은 재고 시스템을 호출하여 재고 검사와 차감을 수행하고, 사용자의 정보도 가져와야한다.

마지막으로 위험 관리 시스템을 호출하여 해당 거래가 안전하다는 것을 확인하는 과정도 필요하다.

 

해당 과정을 우리는 대부분 순차 실행으로 개발을 한다. 해당 내용을 코드로 나타내면 아래와 같다.

실제 코드가 아닌 의사 코드 형식.

Java
public Boolean submitOrder(orderInfo orderInfo) { //재고 확인 stockService.check(); //사용자 정보 가져오기 userService.getByUserId(); //위험 관리 시스템 riskControlService.check(); return doSubmitOrder(orderInfo); }

위의 코드를 더 자세하게 들여다 본다면 각 메서드의 호출간의 강한 의존성이 없는 것을 알 수 있다.

해당 코드에서 사용된 세 서비스의 호출은 시간이 오래 걸린다.

 

만약, 호출 시간을 아래와 같이 가정한다면,

  • stockService.check() - 150ms

  • userService.getByUserId() - 200ms

  • riskControlService.check() - 300ms

 

 

위와 같은 시간으로 서비스들이 순차적으로 호출된다면 전체 실행 시간은

650ms (150ms + 200ms + 300ms) 이다.

만약, 이 기능을 병렬 호출로 변경한다면 전체 실행 시간이 300ms가 되고, 성능이 50%나 향상된다.

 

그래서 해당 코드를 병렬 호출이 가능하도록 작성하면 아래와 같다.

Java
public Boolean submitOrder(orderInfo orderInfo) { //재고 확인 CompletableFuture<Void> stockFuture = CompletableFuture.supplyAsync(() -> { return stockService.check(); }, executor); //사용자 정보 가져오기 CompletableFuture<Void> userFuture = CompletableFuture.supplyAsync(() -> { return userService.getByUserId(); }, executor); //위험 관리 시스템 CompletableFuture<Void> riskFuture = CompletableFuture.supplyAsync(() -> { return riskControlService.check(); }, executor); CompletableFuture.allOf(stockFuture, userFuture, riskFuture); stockFuture.get(); userFuture.get(); riskFuture.get(); return doSubmitOrder(orderInfo); }

 

 

대규모 트랜잭션 피하기

대규모 트랜잭션이란 시간이 오래 걸리는 트랜잭션을 말한다.

우리가 스프링으로 개발을 하면서 자주 하용하는 @Transaction을 사용하여 트랜잭션을 관리하는 경우,

실수로 대규모 트랜잭션을 시작하지 않았는지 주의해야한다.

 

스프링의 트랜잭션 관리 원칙은 여러 트랜잭션을 하나의 실행으로 병합하기 때문에, API 에서 다수의 데이터베이스의 읽기 및 쓰기가 있고, 이 API의 동시 접속량이 상대적으로 높은 경우, 대규모 트랜잭션으로 인해 데이터베이스에 과도한 데이터가 Lock 되고, 많은 수의 block이 발생하여 데이터베이스 연결 풀이 고갈될 수 있다.

데이터베이스 연결 풀이 고갈된다면 새로운 데이터베이스 연결 요청이 대기 상태에 들어가고, 이로 인해 응답 시간이 길어지고 더 많은 클라이언트의 요청이 대기 상태로 전환된다.

 

Java
@Transactional public Boolean submitOrder(orderInfo orderInfo) { //재고 확인 stockService.check(); //사용자 정보 가져오기 userService.getByUserId(); //위험 관리 시스템 riskControlService.check(); orderService.insertOrder(orderInfo); orderDetailService.insertOrderDetail(orderInfo); return true; }

이런 코드가 있다고 할 때, 재고 확인, 사용자 정보 가져오기, 위험 관리 시스템 등과 같은 부분은 굳이 트랜잭션 관리가 필요하지 않은 부분이다.

이런 부분까지 모두 트랜잭션 관리에 포함시킨다면 해당 쓰레드에서 오랜 시간 데이터베이스 연결을 차지하게 되어 수 많은 사용자가 해당 작업은 진행한다면 데이터베이스 연결이 고갈되기 더욱 쉬울 것이다.

또한, 트랜잭션이 롤백되어야 하는 경우, 롤백이 느려서 API 응답이 느려질 것이다.

이때, 우리는 아래와 같이 비즈니스를 축소하는 것을 고려해야 한다.

 

Java
public Boolean submitOrder(OrderInfo orderInfo) { userService.getByUserId(); riskControlService.chekc(); return orderDaoService.doSubmitOrder(orderInfo); } @Service public class OrderDaoService{ @Transactional public Boolean doSubmitOrder(OrderInfo orderInfo) { stockService.check(); orderService.insertOrder(orderInfo); orderDetailService.insertOrderDetail(orderInfo); return true; } }

 

적은 데이터 반환

우리는 많은 데이터를 조회하는 기능을 개발할 때, 페이징을 사용한다.

하지만 만약 데이터의 개수가 10000개가 넘는데 우리가 9981번째부터 20개의 데이터를 가져오고 싶다면?

실제로 기본적인 페이징을 사용해서 데이터를 가져온다면 10000개의 데이터를 모두 찾아내고, 이전의 9980개의 데이터를 버리고 뒤의 20개를 가져오는 형식이다.

우리는 이를 지연된 상관 관계를 사용해 최적화할 수 있다.

성능 향상

기본 원칙은 필요한 레코드만 빠르게 찾아 해당 레코드만 상세하게 조회하는 방식이다.

Java
select * from product where id in (select id from product limit 9980,20);

위와 같은 쿼리문을 사용하면 9980번째 이후의 20개의 Id만 가져오고,

해당 Id를 기반으로 다시 필요한 데이터를 조회하는 방식이다.

이를 통해 필요한 데이터만 조회할 수 있어서 데이터의 양이 많아질수록 성능이 좋아지는 것을 알 수 있다.

 

캐시 사용

캐시를 사용하는 것도 좋은 방법이다.

사용자가 많이 사용하는 데이터를 메모리에 직접 캐싱하는 것이다.

저번에 설명을 들었듯이 HashMap, ConcurrentHashMap 등을 사용해서 간단하게 구현할 수 있다.