Spring API 성능 최적화 — 병렬 처리, 트랜잭션 분리, 캐시 적용
우리가 현재 개발하고 있는 서비스들은 사용자가 적고, 규모도 작기 때문에 API 성능에 대해 큰 고민을 하지 않고 개발을 하게 된다.
하지만 사용자 수가 증가하고, 규모가 커지면서 원래는 빠르던 API들이 점점 느려질 수 있다고 한다.
따라서 우리는 API 성능을 최적화 하는 방법을 고려할 필요도 있다.
API 성능에 영향을 미칠 수 있는 요소들은 많겠지만 주로 마주칠 수 있는 문제들은 다음과 같다.
- •
데이터베이스의 느린 쿼리
- •
복잡한 비즈니스 로직
- •
낮은 성능의 코드
- •
부족한 리소스
여기서 마지막의 부족한 리소스는 상대적으로 해결하기 쉽다고 한다.
그냥 서버를 추가하면 되는 것이다.
또한, 낮은 성능의 코드와 같은 경우에도 그냥 개발자가 코드를 깔끔하게 짜면 되는 것이다.
병렬 처리
우리가 온라인 쇼핑몰을 개발한다고 해보자. 해당 서비스에서 상품을 주문하는 기능을 구현한다면
해당 기능은 재고 시스템을 호출하여 재고 검사와 차감을 수행하고, 사용자의 정보도 가져와야한다.
마지막으로 위험 관리 시스템을 호출하여 해당 거래가 안전하다는 것을 확인하는 과정도 필요하다.
해당 과정을 우리는 대부분 순차 실행으로 개발을 한다. 해당 내용을 코드로 나타내면 아래와 같다.
실제 코드가 아닌 의사 코드 형식.
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%나 향상된다.
그래서 해당 코드를 병렬 호출이 가능하도록 작성하면 아래와 같다.
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);
}
CompletableFuture
Future
자바에는 원래 Future라는 기능이 있어서 비동기 작업에 대한 결과값을 반환받을 수 있었다.
하지만 Futrue는 아래와 같은 한계가 존재한다.
- •
외부에서 완료시킬 수 없음
- •
get의 타임아웃 설정으로만 완료 가능
- •
블로킹 코드(get)을 통해서만 이후의 결과 처리 가능
- •
여러 Future 조합 불가능 (ex. 회원 정보 가져오고 알림 발송)
- •
여라 작업을 조합하거나 예외 처리 불가
이런 문제점을 해결하고자 나온 것이 CompletableFuture이다.
CompletableFuture
CompletableFuture는 기존의 Future를 기반으로 외부에서 완료시킬 수 있기 때문에 CompletableFuture 라는 이름을 갖게 되었다.
Future 외에도 CompletionStage라는 인터페이스도 구현하고 있는데 이는 작업들을 중첩시키거나 완료 후 콜백을 위한 것이다.
따라서 Future에서는 불가능 했던 몇 초 이내에 응답이 없으면 기본값을 반환한다. 와 같은 작업이 가능해졌다.
CompletableFuture는 여러 메서드를 제공하는데, 그 중에서 우리는 병렬 처리와 가장 근접한 메서드만 알아보도록 하겠다.
runAsync
runAsync는 반환값이 없는 void 타입이다.
아래의 코드를 실행해보면 실제로 future가 별도의 쓰레드에서 실행되는 것을 알 수 있다고 한다.
@Test
void runAsync() throws ExecutionException, InterruptedException {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Thread: " + Thread.currentThread().getName());
});
future.get();
System.out.println("Thread: " + Thread.currentThread().getName());
}
SupplyAsync
supplyAsync는 runAsync와 달리 반환값이 존재한다. 따라서 병렬 처리된 작업의 결과를 받아올 수 있다.
@Test
void supplyAsync() throws ExecutionException, InterruptedException {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "Thread: " + Thread.currentThread().getName();
});
System.out.println(future.get());
System.out.println("Thread: " + Thread.currentThread().getName());
}
runAsync와 supplyAsync는 모두 기본적으로 작업을 실행할 쓰레드를 쓰레드 풀로부터 얻어서 실행시킨다. 만약 개발자가 원하는 쓰레드 풀을 사용하고 싶다면, ExecutorService를 파라미터로 넘기면 된다.
CompletableFuture<Void> riskFuture = CompletableFuture.supplyAsync(() -> {
return riskControlService.check();
}, executor);
위의 코드에서도 executor를 파리미터로 넘겨주면서 executor라는 쓰레드 풀에서 실행되도록 했다.
대규모 트랜잭션 피하기
대규모 트랜잭션이란 시간이 오래 걸리는 트랜잭션을 말한다.
우리가 스프링으로 개발을 하면서 자주 하용하는 @Transaction을 사용하여 트랜잭션을 관리하는 경우,
실수로 대규모 트랜잭션을 시작하지 않았는지 주의해야한다.
스프링의 트랜잭션 관리 원칙은 여러 트랜잭션을 하나의 실행으로 병합하기 때문에, API 에서 다수의 데이터베이스의 읽기 및 쓰기가 있고, 이 API의 동시 접속량이 상대적으로 높은 경우, 대규모 트랜잭션으로 인해 데이터베이스에 과도한 데이터가 Lock 되고, 많은 수의 block이 발생하여 데이터베이스 연결 풀이 고갈될 수 있다.
데이터베이스 연결 풀이 고갈된다면 새로운 데이터베이스 연결 요청이 대기 상태에 들어가고, 이로 인해 응답 시간이 길어지고 더 많은 클라이언트의 요청이 대기 상태로 전환된다.
@Transactional
public Boolean submitOrder(orderInfo orderInfo) {
//재고 확인
stockService.check();
//사용자 정보 가져오기
userService.getByUserId();
//위험 관리 시스템
riskControlService.check();
orderService.insertOrder(orderInfo);
orderDetailService.insertOrderDetail(orderInfo);
return true;
}
이런 코드가 있다고 할 때, 재고 확인, 사용자 정보 가져오기, 위험 관리 시스템 등과 같은 부분은 굳이 트랜잭션 관리가 필요하지 않은 부분이다.
이런 부분까지 모두 트랜잭션 관리에 포함시킨다면 해당 쓰레드에서 오랜 시간 데이터베이스 연결을 차지하게 되어 수 많은 사용자가 해당 작업은 진행한다면 데이터베이스 연결이 고갈되기 더욱 쉬울 것이다.
또한, 트랜잭션이 롤백되어야 하는 경우, 롤백이 느려서 API 응답이 느려질 것이다.
이때, 우리는 아래와 같이 비즈니스를 축소하는 것을 고려해야 한다.
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개를 가져오는 형식이다.
우리는 이를 지연된 상관 관계를 사용해 최적화할 수 있다.
성능 향상
기본 원칙은 필요한 레코드만 빠르게 찾아 해당 레코드만 상세하게 조회하는 방식이다.
select * from product where id in (select id from product limit 9980,20);
위와 같은 쿼리문을 사용하면 9980번째 이후의 20개의 Id만 가져오고,
해당 Id를 기반으로 다시 필요한 데이터를 조회하는 방식이다.
이를 통해 필요한 데이터만 조회할 수 있어서 데이터의 양이 많아질수록 성능이 좋아지는 것을 알 수 있다.
캐시 사용
캐시를 사용하는 것도 좋은 방법이다.
사용자가 많이 사용하는 데이터를 메모리에 직접 캐싱하는 것이다.
저번에 설명을 들었듯이 HashMap, ConcurrentHashMap 등을 사용해서 간단하게 구현할 수 있다.