들어가며

안녕하세요, 트렌비 백엔드 개발자 도현입니다.

이 글에서는 트렌비 리뷰 서비스 (review-service) 의 성능을 개선하게 된 이야기를 해보고자 합니다.

문제 상황

작년 11월경 리뷰 서비스가 런칭되고 시간이 지남에 따라 비즈니스가 점점 고도화되었고, 사용자와 트래픽이 증가함에 따라 아래와 같은 시스템 알럿이 발생하기 시작했습니다.

발생하는 원인은 다양했는데, 다음 2가지 유형이 가장 빈번했습니다.

  • DB 커넥션풀 고갈 이슈
  • API 타임아웃 이슈

보통 이런 알럿들은 5분 이내로 안정화가 되었지만 반복적으로 발생하며 사용자 경험을 저해시켜 개선이 필요했습니다.

게다가, 리뷰 서비스의 API 응답 속도도 그닥 빠르지 않았는데 p90 API 응답 속도는 약 200ms ~ 250ms 을 보여주고 있었습니다. (아래 그림 참조)

앱푸시 등으로 인해 트래픽이 증가하면 더 느려지기도 했습니다.

200ms ~ 250ms 의 응답속도가 적절한 수준인지 판단하기 위해 “Acceptable API Response Time” 라는 키워드로 조사를 해봤습니다.

아래 문서에 따르면 0.1초 (100ms) 이내의 응답 속도가 즉각적인 응답으로 인식이 된다고 합니다. 이를 기준으로 리뷰 서비스는 즉각적인 응답을 주지 못하고 있던 상태라고 판단할 수 있었습니다.

A response time of about 0.1 seconds offers users an “instant” response, with no interruption. A one-second response time is generally the maximum acceptable limit, as users still likely won’t notice a delay. Anything more than one second is problematic… https://www.dnsstuff.com/response-time-monitoring

목표

위와 같은 문제를 해결하기 위해 2가지 목표를 설정했습니다.

  • API 응답 속도를 100ms 이하로 줄이자!
  • 리뷰 서비스에서 발생하는 알럿을 해결하자!

설정한 2가지 목표를 달성하기 위해 진행했던 다섯가지 이야기를 해보고자 합니다.

이야기 1. n번의 API 호출을 1번으로 줄일 수 있을까?

리뷰 서비스에는 다음과 같은 로직이 있었습니다.

public List<String> getPurchaseOptions(List<String> orderItemIds) {
	return orderItemIds.stream()
            .map(id -> orderService.getOption(id))
            .collect(Collectors.toList())
}

n 개의 주문 ID (orderItemIds) 에 대한 구매 옵션 (purchaseOption) 을 조회하기 위해 주문 서비스 (order-service) 를 n 번 호출하는 로직입니다.

한번의 API 호출은 약 1 ~ 2ms 정도의 통신 비용이 소요되었는데, 조회하고자 하는 주문 ID 가 많아지면 많아질수록 통신 비용이 증가하게 되었습니다.

보통 10개정도의 주문 ID 에 대한 조회를 하고 있었고 따라서 기본적으로 10 ~ 20ms 의 통신 비용이 발생했습니다.

API 응답 속도를 100ms 이하로 줄이고자 하는 목표에서 20ms 은 작지 않은 비중이었습니다.

이를 해결하기 위해 주문 서비스에는 n 개의 주문 ID 를 파라미터로 받는 별도 API 를 만들고, 리뷰 서비스에서는 한번의 API 호출만 하도록 변경했습니다.

변경된 로직은 아래와 같습니다.

public List<String> getPurchaseOptions(List<String> orderItemIds) {
	return orderService.getOptions(orderItemIds)
}

위와 같이 n 번의 호출이 발생하던 부분을 1번으로 개선함으로써 200ms ~ 250ms 을 보이던 p90 응답 속도는 100ms 정도로 빨라졌습니다.

이야기 2. 굳이 order-service 를 호출해야 할까?

이야기 1 에서 공유했던 성능 개선을 하고 나서 이런 생각이 들었습니다.

주문 서비스로부터 어떤 데이터를 호출하는걸까? 리뷰 서비스 내부적으로 관리할 수 있는 데이터라면 굳이 호출을 하지 않아도 될 것 같은데…

확인 결과, 주문 ID 에 맵핑된 구매 옵션 데이터만을 조회하고 있었습니다. (구매 옵션 데이터는 리뷰 도메인에서 관리하는 데이터가 아니었습니다.)

하지만 이 데이터만 조회하기 위해 주문 서비스를 호출하는 것은 불필요하다고 판단했습니다.

그 이유는 하나의 리뷰가 작성될 때 구매 옵션 데이터를 파라미터로 함께 전달받게 되는데, 이 때 구매 옵션 데이터를 리뷰 도메인에 저장할 수 있기 때문입니다.

따라서 리뷰 서비스 내부에서 구매 옵션 데이터도 저장 및 관리함으로써 주문 서비스로의 API 호출을 줄일 수 있었습니다.

이 개선을 통해 100ms 을 보이던 p90 응답 속도는 30ms ~ 40ms 까지 빨라졌습니다.

외부 서비스와의 의존성을 끊음으로써 API 의 응답 속도를 빠르게 개선할 수 있었습니다.

이야기 3. “Could not open JPA EntityManager” 에러 해결하기

앱푸시나 카톡 채널 메시지 등으로 인해 짧은 시간 내에 많은 트래픽이 발생하는 상황에서 리뷰 서비스 역시 부하를 받게 되어 아래와 같이 많은 시스템 에러 알럿이 발생했습니다.

보통 시스템 에러 알럿이 발생하게 되면 로그를 가장 먼저 확인해보는데요.

대부분의 로그는 아래와 같이 “Could not open JPA EntityManager for transaction” 라는 에러였습니다.

Unknown Exception.
message=**Could not open JPA EntityManager for transaction;**
nested exception is org.hibernate.exception.**JDBCConnectionException: Unable to acquire JDBC Connection**

동시간대의 시스템 메트릭도 같이 확인을 해보았는데, 아래 그림과 같이 “DB 커넥션 풀” 에 대한 시스템 메트릭이 튀는 것을 확인할 수 있었습니다.

에러 로그와 시스템 메트릭을 함께 살펴보았을 때, DB 와 관련된 트랜잭션 이슈라는 것을 인지할 수 있었습니다.

확인 결과, 리뷰 서비스에서는 @Transactional 이 걸린 하나의 메소드 내에서 외부 API 호출과 내부 DB 접근이 동시에 수행되고 있었습니다.

아래는 예제 코드입니다.

@Transactional(readOnly=true)
public String getData(String id) {
    // 1. 외부 API 호출
    String externalId = externalService.getExternalDataById(id);
    
    // 2. 내부 DB 접근
    String internalId = internalRepository.getInternalDataById(externalId);
    
    // 3. 기타 비즈니스 로직 수행
    return convert(internalId);
}

@Transactional 어노테이션은 메소드가 수행될 때 DB 커넥션 풀로부터 커넥션을 하나 가져오게 되고 완료되면 커넥션을 반납을 하게 됩니다.

@Transactional 이 걸린 메소드가 빠르게 수행된다면 문제가 되지 않지만, 메소드 내에서 수행 시간이 오래 걸린다면 가져온 커넥션을 반납하지 못하는 상황이 됩니다.

따라서 커넥션이 고갈될 수 있고 커넥션을 필요로하는 그 다음 요청부터는 실패하게 됩니다.

리뷰 서비스에서도 외부 API 의 지연이 종종 발생하고 있었습니다.

커넥션을 가져온 상태에서 외부 API 의 응답을 기다리는 상황이 발생했고, 결국 커넥션이 고갈되어 그 다음 요청부터는 위와 같은 “Unable to acquire JDBC connection” 과 같은 에러가 발생하게 되었습니다.

관련 내용에 대해서 알아보던 중 아래와 같은 가이드 글을 찾을 수 있었습니다.

  • 하나의 트랜잭션 내에서 DB I/O 와 기타 I/O 가 함께 있는 경우는 bad smell 이다.

Mixing the database I/O with other types of I/O in a transactional context is a bad smell. So, the first solution for these sorts of problems is to separate these types of I/O altogether . If for whatever reason we can’t separate them, we can still use Spring APIs to manage transactions manually.

https://www.baeldung.com/spring-programmatic-transaction-management

가이드 글에서 안내된 것처럼 가장 먼저 시도해볼 수 있는 해결책은 트랜잭션을 분리하는 것입니다.

따라서 아래와 같이 트랜잭션을 분리하였고 외부 API 에서 지연이 발생하더라도 DB 커넥션이 고갈되지 않도록 개선했습니다.

트랜잭션의 범위를 명확히 구분하기 위해 “Transaction Template” 을 활용했습니다.

public String getData(id) {
    // 1. 외부 API 호출
    String externalId = getExternalData(id);

    // 2. 내부 DB 접근
    String internalId = transactionTemplate.execute(status -> getInternalData(externalId));
    
    // 3. 기타 비즈니스 로직 수행
    return convert(internalId)
}

public String getExternalData(String id) {
    return externalService.getExternalDataById(id);
}

public String getInternalData(String id) {
    return internalRepository.getInternalDataById(id);
}

위와 같이 트랜잭션을 분리했더니 트래픽이 많이 증가해도 DB 커넥션 풀 고갈은 더 이상 발생되지 않았습니다.

이를 통해 시스템을 좀 더 안정적으로 유지할 수 있었습니다.

이야기 4. 성능이 안 좋은 외부 API 에 의존하고 있으면 어떻게 해야 할까?

리뷰 서비스는 중고 상품에 대한 데이터를 조회하기 위해 상품 서비스를 지속적으로 호출하고 있었습니다.

상품 서비스는 종종 API 타임아웃이 발생했고 리뷰 서비스는 응답을 제대로 받지 못했습니다.

리뷰 서비스에서는 별다른 정책이 설정되어 있지 않아 이런 경우엔 똑같이 API 타임아웃을 발생시키고 있었고 프론트까지 영향을 받았습니다.

이를 해결하기 위해 저희는 캐시를 적용해보기로 했습니다.

그 이유는 리뷰 서비스에서는 거의 동일한 중고 상품에 대한 데이터를 필요로 했고, 그 상품 데이터는 빈번하게 변경되는 데이터가 아니었습니다.

따라서 리뷰 서비스에서 중고 상품에 대한 캐시 레이어를 설정하고 불안정한 상품 서비스로의 호출을 최소화하는 방향으로 개선했습니다.

아래처럼 레디스 캐시를 적용하여 매번 상품 서비스를 호출하는 것이 아니라 캐시 데이터를 참조하도록 변경했습니다.

아래 그림은 캐시를 적용하기 전/후의 모습인데, 노란색 선으로 표시된 부분이 상품 서비스의 API 응답 속도입니다.

적용하기 전에는 노란색 선의 Spike 가 종종 발생했고 이로 인해 리뷰 서비스에서도 API 타임아웃 에러가 발생했습니다.

적용 후에는 기존에 발생하던 Spike 는 거의 발생하지 않게 되었고 보다 안정적으로 시스템을 운영할 수 있게 되었습니다.

이야기 5. 간단한 쿼리들이 왜 느리지?

트렌비에서는 시스템의 메트릭을 모니터링하는 툴 중 하나로 “핀포인트”를 활용하고 있습니다.

자세한 내용은 아래 링크를 참고해주세요.

핀포인트를 활용하면 하나의 API 요청이 어떻게 구성되어 있는지 확인할 수 있고 각각의 구성 요소에 대한 응답 속도를 파악할 수 있습니다.

아래 그림은 핀포인트 화면 중 일부분을 캡쳐한 화면인데, getProductDetailBy(Long goodsno) 라는 메소드가 28ms 이 소요된 점을 확인할 수 있습니다.

핀포인트를 활용하여 간단한 역할을 하는 API 요청이 생각보다 느린 경우를 발견할 수 있었습니다.

간단한 조건에 대한 조회 쿼리였는데도 불구하고 조회 속도가 약 600ms 의 쿼리 수행 시간이 소요되고 있었습니다.

문제의 쿼리는 다음과 같았습니다.

SELECT *
FROM review
WHERE order_item_id = ?

해당 쿼리는 리뷰를 작성하는 API 에서 주문 상품에 대해 이미 리뷰가 작성되었는지를 판단하기 위해 활용하는 쿼리입니다.

주문 상품에 대해 이미 리뷰가 작성되었다면, 다시 작성할 수 없기 때문입니다.

원인을 파악해본 결과 order_item_id 컬럼에 인덱스가 걸려있지 않아서 조회 속도가 느렸습니다.

보통 데이터를 DB 로부터 조회할 때, 조회 조건에 대한 인덱스를 걸게 되는데 이 경우에는 누락되어 있었습니다.

order_item_id 컬럼에 대한 인덱스를 추가함으로써 간단하게 성능을 개선할 수 있었습니다.

정리하며

지금까지 트렌비 리뷰 서비스의 API 응답 속도를 빠르게 개선하고 시스템 알럿을 줄이는 방법들에 대해서 알아보았습니다.

200ms 에서 250ms 를 보이던 p90 응답 속도는 현재 30ms 에서 40ms 수준에서 안정적으로 유지되고 있습니다.

또한, DB 커넥션 풀 고갈로 인한 시스템 알럿은 더 이상 발생하고 있지 않습니다.

일반적인 방법은 아닐 수 있지만 위에서 소개했던 방법들을 참고해서 시스템 성능 개선의 첫 단계로 활용할 수 있으면 좋겠습니다.

Reference