top of page

첫 손님에게도 재깍 응답하기






들어가기에 앞서

안녕하세요. 저는 이용권&정산 개발팀에서 이용권 서버 업무를 담당하고 있는 Lacti (최재영)입니다.

저희 팀에서는 이용권 기능의 빠르고 안정적인 개발과 운영을 위해 다양한 노력을 기울이고 있는데요.

서비스를 배포하는 도중 일시적인 응답 지연 현상이 발생하는 문제를 해결하기 위해, 문제 분석부터 해결까지 고민했던 내용들을 공유해보려 합니다.


FLO 백엔드는 Spring Framework(이하 Spring) 기반으로 작성되어 있습니다. 이는 간단한 구조로 RESTful API를 만들 수 있고 다양한 라이브러리를 연동하여 데이터베이스, 캐시 혹은 외부의 다른 시스템과 통신하는 서비스 로직을 쉽게 구현할 수 있는 프레임워크입니다. 그리고 비지니스 로직이 아주 복잡하지 않다면 효과적인 성능을 발휘할 수 있는 구조를 잘 제공하고 있습니다.


그리고 백엔드는 Microservice 구조로 작성되어 있습니다. 끊임없이 확장하는 서비스에 대응하기 위해서는 빠르게 개발하고 자주 배포하는 작업이 필요한데요. 이를 위해 하나의 서비스 서버에 모든 기능을 넣는 Monolithic 구조보다는 도메인 단위의 서버로 나누어 시스템을 구성하는 Microservice 구조가 더 유리하다고 판단했습니다. 이 구조를 지탱하기 위해 Spring Framework만으로는 조금 부족한 부분이 있었지만 Spring Cloud Netflix OSS 라이브러리의 도움을 받아 각 도메인 서버의 주소 관리나 서로의 호출 또한 쉽게 구현할 수 있었습니다.


이 글에서는 Microservice 내 서버들 사이에서 주고 받는 요청들의 응답 시간이 배포 직후 느려지는 현상에 대한 탐구와 이를 해결해 나가는 과정을 소개해보려 합니다.






마이크로서비스 구조

Microservice의 구조는 각 도메인 별로 서비스를 나누는 부분을 제외하면 큰 틀에서는 다음의 구조를 따른다고 볼 수 있습니다.


사용자가 서비스에 어떤 요청을 하게 되면 L4 switch를 통해 Gateway에 전달됩니다. Gateway는 service registry(이하 registry)를 통해 요청을 처리해야 할 서버의 주소를 확인한 뒤 해당 서버에게 요청을 전달합니다. 각 도메인에 맞게 구현된 서비스 서버는 데이터베이스나 캐시에 접근하여 응답에 필요한 정보를 가공합니다. 또한 필요하다면 registry를 통해 다른 도메인 서버의 주소를 확인하여 기능을 요청합니다.


Spring Cloud Netflix OSS 라이브러리를 사용한다면 Gateway를 위해 Zuul을, Service registration과 discovery를 위해 Eureka를, 각 서비스간의 통신에는 OpenFeign을 사용할 것 입니다. 그리고 데이터베이스는 JDBC를 사용하고, DBCPHikariCP 등으로 Connection Pool을 관리할 것입니다.





서비스 간 호출

서비스는 여러 도메인의 기능을 호출하면서 사용자의 요청을 처리합니다. 예를 들어 FLO의 경우 사용자가 음악 재생을 요청했을 때, 음악의 제목이나 가사와 같은 메타 정보를 조회하고 음악의 재생 여부를 판단하기 위한 이용권의 적합성을 확인합니다. 설명을 위해 각각의 도메인을 스트리밍, 메타, 이용권으로 구분지어보도록 하겠습니다.


Monolithic 구조에서는 이러한 도메인들의 기능들이 함수나 클래스로 나뉘어 있지만 하나의 프로세스 내에 존재하게 됩니다. 따라서 각 도메인의 함수를 호출하여 요청을 처리하게 됩니다. Microservice 구조도 이와 같이 각 도메인 기능의 호출이 필요한데 도메인 별 서비스가 다른 서버로 분리되어 있기 때문에 서버 사이의 호출이 필요합니다.


서버 사이의 호출이 원활히 수행되려면 각 서버들이 언제든 요청을 받아도 빠르게 응답할 수 있는 준비가 되어 있어야 합니다. 다만 배포 시에는 서버가 잠시 응답을 할 수 없는 상태가 되므로 이에 대한 대비가 필요한데요. 따라서 무중단 배포가 가능하도록 각 서비스마다 서버를 여러 대두어 서버 군을 구성하고, Eureka와 Ribbon 같은 Service discovery와 Load balancing을 고려해 응답이 가능한 서버를 호출할 수 있도록 구성해야 합니다. OpenFeign은 이 모든 것을 고려한 호출을 할 수 있도록 도와주는 라이브러리인데요. 이 라이브러리들은 기본적으로 지연 초기화(lazy initialize) 정책을 사용하고 있기 때문에 첫 번째 요청에 대한 응답에 대해 다소 긴 수행시간을 보여주었고, 이 때문에 다음과 같은 기둥 현상을 관측할 수 있었습니다.






기둥 현상

무중단 배포를 위해 서버군을 구성하고 서버를 rolling update 형식으로 배포하면 배포가 오래 걸리거나 실패하더라도 응답 불가 수준의 장애는 발생하지 않습니다. 하지만 배포 시점에 모니터링을 해본 결과 일부 요청에 대한 응답 지연 현상이 발생하는 것을 발견할 수 있었습니다.


아래 스크린샷은 FLO에서 사용 중인 APM 도구 Jennifer에서 API 응답 시간을 확인할 수 있는 X-View 화면의 일부입니다.


X축은 요청이 발생한 시각, Y축은 요청이 처리되어 응답할 때까지 걸린 시간을 뜻합니다. 위 그래프에서 외부 시스템 연동으로 인해 원래 응답이 느린 경우를 제외하면, 빨간 박스와 같이 주기적으로 응답 지연이 발생하는 것을 확인 할 수 있습니다. 위 그래프에서 빨간 박스의 대부분은 서버 배포로 인해 새로 기동된 서버에서 발생했습니다. 특히 사용자의 요청이 많은 시간에 더 뚜렷이 관측되는 이 현상은 막 기동된 서버가 요청을 처리하는 과정에서 평소보다 느린 응답을 보이고 있다는 의미로 해석할 수 있습니다. 때문에 이 현상은 서버가 기동된 이후 곧바로 많은 요청을 동시에 처리하려 할 경우 잠시동안 발생하는 지연 현상이라고 가정하고 문제를 확인하기 시작했습니다.






느린 응답의 사례 확인

이와 같이 첫 요청에 대한 처리가 오래 걸리는 경우는 일반적으로 서버가 요청을 처리하기 위해 필요한 자원을 미리 생성하지 않고, 첫 요청이 들어오는 시점에 관련 자원을 생성하는 경우에 발생합니다. 주로 외부와 연결되어 통신하는 자원들이 아직 할당되지 않아 발생하는 경우가 이에 해당 하는데요. 데이터베이스, 캐시 혹은 외부 시스템 연동을 위한 HttpConnection 등의 자원들은 미리 너무 많이 할당해두면 자원 낭비가 될 수 있으므로 요청 즉시 할당되어 Pool로 관리되는 경우가 많습니다. 물론 데이터베이스와 같이 설정에 따라 어느 정도는 미리 할당해두는 경우도 있지만 배포 시마다 지속적으로 해당 패턴이 관측되었기 때문에 여전히 미리 할당되지 않는 자원이 있다는 가정을 두고 데이터를 확인하기 시작했습니다.


다행히 예상했던대로 외부 시스템과 통신하는 부분의 수행 구간이 평소보다 오래 걸리는 것을 확인했습니다. 해당 구간의 함수 호출들을 확인한 결과, 다음 항목들로 인해 지연이 발생한 것을 확인할 수 있었습니다.


FeignClient로 외부 시스템을 요청할 때 HystrixThread를 초기화하는 부분

gRPC로 외부 시스템을 요청할 때 관련 Context의 초기화를 기다리는 부분



물론 자바로 작성된 시스템이므로 JIT 컴파일이 덜 진행되어 발생하는 성능 저하와 같이 어쩔 수 없는 부분도 있습니다. 하지만 위와 같은 자원들은 서버가 여러 사용자의 요청을 동시에 처리하기 위해 배포 후 기동되는 즉시 자원을 할당해야 할 것 같지만, 실제로는 자원이 바로 할당되지 않습니다.


서버가 사용자의 요청을 받을 수 있는 상태가 되기 전에 모든 준비를 마쳐야할 것 같은데 그렇지 않은 것 같았습니다. 이를 알아보기 위해서는 각 자원이 언제 어떻게 할당되는지와 언제 서버가 요청을 받을 수 있는 상태가 되는지, 즉 언제 registry에 등록되는지 확인할 필요가 있었습니다. 그리고 필요한 각 자원이 미리 할당될 수 있도록 사전에 어느 정도 요청을 통해 Pool을 확보하는 작업과 모든 준비가 완료되었을 때 서버를 registry에 등록하는 작업을 진행하기로 했고 이 작업을 Warm up이라고 부르기로 했습니다.






FeignClient를 사용한 첫 요청

다른 도메인 서버에서 제공하는 기능을 호출하기 위해 OpenFeign를 사용한다고 했는데요. 요청 수행의 효율성을 위해 Eureka, Ribbon, Hystrix를 조합해서 사용하는데 각각은 다음과 같이 정리할 수 있습니다.


OpenFeign은 RESTful 기반의 서비스를 호출하기 위한 라이브러리로 클라이언트는 인터페이스만 정의

하면 Spring이 런타임에 그 구현체를 제공해줍니다. 또한 Fallback과 Configuration 처리 역시 쉽게 할

수 있어 편리합니다.


Eureka는 각 서버들의 상태를 관리하는 registry로 Spring 런타임에 서버의 가용 상태를 등록하거나

다른 가용 서버의 목록을 쉽게 조회할 수 있게 해주는 라이브러리를 제공합니다.


Ribbon은 IPC 호출을 위한 라이브러리로 Load balancer 기능을 포함하고 있어, 한 서버에 너무 많은

요청이 몰리지 않도록 요청을 분산해주는 역할을 합니다.


Hystrix는 요청에 대한 지연과 장애에 대응하기 위해 각 요청을 격리하고 Circuit breaker로 장애 전파 를 막아주는 라이브러리입니다.




연동하는 모듈이 많을 수록 초기화 단계에서 할 일이 많아집니다. 위와 같이 OpenFeign에 EurekaClient, Ribbon, Hystrix를 연동하고 FeignClient로 정의한 인터페이스를 통해 첫 번째 요청을 진행할 경우 다음과 같은 초기화 과정을 수행합니다.



1. 정의한 FeignClient 인터페이스의 요청을 수행할 Proxy 객체를 만듭니다.


2. HystrixCommand를 처리하는 HystrixThread와 context를 초기화합니다.


3. EurekaServer로부터 도메인 서버 주소를 가져와 DiscoveryClient를 초기화하고,


4. EurekaServer로부터 제공받은 서버 목록을 바탕으로 RibbonLoadBalancer를 초기화합니다.


5. 이후 HttpClient가 LoadBalancer에 의해 선택된 서버로 API call을 진행하게 됩니다.


1번 에서 4번 단계는 조금씩은 다르지만 대체적으로 처음 한 번만 초기화를 해주면 그 이후부터는 수행할

필요가 없는 작업입니다. 해당 자원에 대한 준비가 완료된 상태에서는 5번의 과정만 진행해서 외부 요청을 빠르게 수행할 수 있기 때문에 첫 요청이 처리된 이후 들어오는 요청을 빨리 처리할 수 있습니다.


또한 초기화 과정은 동시성을 제어하는 로직을 포함하고 있습니다. 이는 서버에서 동시에 여러요청을 처리하면서 위 자원에 접근할 수 있기 때문인데요. 자원에 처음 접근한 스레드는 자원을 할당하기 위한 과정을 수행하게 되고, 다른 스레드들은 그 과정이 끝나기까지 대기하게 됩니다.


결국 초기화는 한 스레드가 진행하지만 그 시간에 자원에 접근하고 있는 스레드 모두가 다같이 응답할 수 없게 되므로 여러 요청의 응답 지연이 발생하는 것입니다.


이 부분을 개선하기 위해 서버가 요청 가능 상태로 전환되어 사용자의 요청을 받기 전에 FeignClient에 대한 요청을 수행해 필요한 자원을 미리 할당해두기로 했습니다. 물론 실제 사용하는 API를 호출해 다른 서버에 부담을 주는 것을 피해야 하므로 아무 일도 하지 않는 Ping API를 만들어 호출하도록 코드를 작성했습니다.





gRPC를 사용한 첫 요청

FLO 백엔드에서는 제휴사와 같은 외부 시스템의 연동을 중계하는 부분을 효율적으로 구현하기 위해 일부 Go 언어로 서비스를 구현했습니다. 각기 다른 지연 요소를 가지고 있는 많은 수의 요청을 중계하려면 효율적으로 비동기를 다루어야 하기 때문에 Go 언어를 선택했습니다.


Spring 기반으로 구현된 서버들은 Netflix OSS를 기반으로 Eureka와 OpenFeign을 통해 서로 호출할 수 있지만 Go 언어로 구현된 서버들은 이에 대한 공식 라이브러리가 존재하지 않았습니다. 때문에 Spring 기반으로 구현된 서버와 Go 언어로 구현된 서버들 간의 통신을 위해 gRPC를 사용하고, 서버들의 주소를 관리하기 위해 ZooKeeper를 registry로 사용하기로 했습니다.


gRPC의 Java 공식 라이브러리는 Netty 기반으로 작성되어 있습니다. 때문에 첫 요청을 처리하기 위해 준비해야 하는 것에는 gRPC 자원 뿐만 아니라 Netty 자원도 포함됩니다. 이를 정리하면 다음과 같습니다.



1. 연결 대상을 찾기 위한 적합한 NameResolver를 구성합니다.


2. gRPC 요청을 처리할 Worker thread pool을 생성합니다.


3. LoadBalancer를 통해 요청할 서버를 선택합니다.


4. 통신을 위해 Netty 내에서 사용할 BufferPool을 생성합니다.


5. 비동기로 요청을 처리하여 Future로 반환합니다.

1번 은 미리 생성해둘 수 있지만 2, 3, 4번은 실제 요청이 수행될 때 초기화를 시작합니다. 사실 2번ThreadPool은 처음 요청 시 한 번만 생성되고 4번 BufferPool은 통신 과정에서 필요할 때 마다 추가로 할당되어 관리되는 부분인데 통신 빈도수가 아주 높은 경우가 아니라면 이로 인한 지연을 상상하기 어려운 상황이었습니다. 때문에 통신할 대상 서버를 찾아오는 과정인 3번이 느릴 것이라고 가정했습니다.


이 부분을 개선하기 위해 역시 동일한 방법으로 protocol에 Ping을 추가했습니다. 그리고 서버가 요청 가능 상태로 전환되기 전에 미리 Ping을 요청해 NameResolver에서 필요한 정보를 미리 Zookeeper로부터 가져올 수 있도록 코드를 작성했습니다.





서버가 요청 가능 상태 로 바뀌는 시점

서버가 요청 가능 상태가 되었다는 것은 사용자의 요청을 받아 응답할 수 있는 상태가 되었다는 뜻입니다. Spring으로 작성된 단일 서버는 SpringContext인 ApplicationContext가 초기화 된 이 후, EmbeddedTomcat이 지정된 Port로 Listen을 시작하면서 사용자의 요청을 받을 수 있는 상태가 됩니다.


하지만 Eureka와 같은 registry를 사용하는 경우에는 위 작업을 모두 수행하는 것 뿐만 아니라 registry에 자신이 서버의 요청을 받을 수 있는 상태라고 알려주어야 합니다. 그래야 gateway를 포함한 다른 서버들이 registry를 통해 이 서버의 주소를 조회하고 요청할 수 있기 때문입니다.

Eureka를 사용할 경우 이 과정은 EurekaServer에 자신의 주소를 UP 으로 등록하는 것에 해당합니다. 따로 설정하지 않을 경우 이 과정은 EurekaAutoServiceRegistration에 의해 ApplicationContext가 초기화되었을 때 자동으로 수행하게 되는데요. 이때 보내는 상태 값은 eureka.instance.initial-status에 설정된 값을 사용하고 기본 값은 UP 입니다. 때문에 별도의 설정이 없는 경우에는 서버의 ApplicationContext 초기화가 완료되는 즉시 요청 가능 상태로 registry에 등록되는 것입니다.


하지만 일부 자원을 다른 Thread나 비동기로 초기화하는 경우에는 ApplicationContext의 초기화가 끝났다고 생각하는 시점에도 아직 완료되지 않았을 수 있습니다. 이 경우 준비할 것이 남아있지만 이미 registry에는 요청 가능 상태로 등록되었기 때문에 사용자의 요청을 받게되는 문제가 발생합니다.


이 문제를 해결하기 위해


1. eureka.instance.initial-status 값을 STARTING으로 변경하고,

2. 필요한 자원들에 대한 Warm up을 마친 뒤

3. EurekaServer에 UP으로 등록할 수 있도록 코드를 작성했습니다.



@Autowired
private com.netflix.appinfo.ApplicationInfoManager applicationInfoManager;

public void doWarmUp() {
// Execute all ping APIs.
applicationInfoManager.setInstanceStatus(InstanceInfo.InstanceStatus.UP);
}





Spring Framework의 Lifecycle

Spring Bean의 초기화를 수행하려면 적어도 DI가 완료된 이후에 해야 하므로 InitializingBean

을 구현하거나 PostConstruct annotation을 사용합니다. 혹은 SmartLifecycle에서 필요한

부분을 구현하는 경우도 있고, ApplicationRunner나 CommandLineRunner를 사용하기도 합

니다.

참조하는 모든 라이브러리가 초기화를 마쳐야 그들을 사용하여 수행하는 Warm up을 제대로 구

성할 수 있습니다. 하지만 초기화를 수행할 수 있는 단계가 너무 다양하기 때문에 개발자가 제어

할 수 있는 이벤트 중 가장 마지막에 수행되는 이벤트를 찾아야 했습니다. 다행히 위에서 존재하

는 모든 상황보다 늦게 수행되는 이벤트인 ApplicationReadyEvent가 있어서 그 시점에 Warm

up을 수행하도록 코드를 구성할 수 있었습니다.



@EventListener(ApplicationReadyEvent.class)
public void doWarmUp() {
// ...





실험

사실 직관에 의한 가정으로 작업을 진행하고 있었고 이것이 문제를 해결해줄 수 있다는 뚜렷한

증거는 없었습니다. 또한 얼마나, 어떻게 Warm up을 진행해야 한다는 것 또한 확신할 수 있는

방법이 없었습니다. 그렇다고 상용 환경에서 Warm up을 위한 다양한 방법을 실험하기에는 잦

은 배포에 대한 위험 부담이 있었습니다.


때문에 다음과 같이 SpringBootTest로 서버를 띄운 뒤 다른 서버 호출이 필요한 API를 호출하

고 그 수행 시간을 측정하는 테스트 코드를 작성하여 실험을 진행했습니다.



@RunWith(SpringRunner.class)
@ActiveProfiles("local")
@SpringBootTest
public class FirstResponseLatencyTest {
  @Autowired
  private TargetController targetController;

  @Test
  public void measure() {
    Stopwatch watch
    targetController.someAPI(...);
    System.out.println(watch.elapsed(TimeUnit.MILLISECONDS);
  }
}
  

그리고 Warm up에서 어떤 작업을 할지 몇 가지 시나리오를 만들고 각각의 효과를 비교하기 위

해 코드를 변경했습니다. 검증하려고 했던 부분은 다음과 같았습니다.


ㆍ외부 서버에 대한 Ping을 부르지 않고 Local에서 가짜 서버를 띄우고 그에 대한 FeignClient를 호출해도

효과가 있을까?

아니면 꼭 외부 서버에 대한 Ping을 호출하는 FeignClient를 사용해야 효과가 있을까?


ㆍFeignClient가 여럿일 때 하나만 Ping을 호출해도 될까?

아니면 각각에 대해 다 호출해주는 것이 좋을까?


ㆍgRPC에 대해서 Ping을 부르는 것이 효과가 있을까?


개발 환경에서 테스트를 진행했기 때문에 상용 환경보다는 지연이 좀 더 크게 발생했지만 상대적인 지연폭은 동일할 것으로 가정했습니다.






Warm up의 설계

각 자원을 미리 할당하기 위해서 Ping을 부를 때에도 전략이 필요했습니다. 대부분 Pool 형태로 관리되는 자원이므로 단순히 순차적으로 Ping을 여러 번 부른다고 해서 자원이 충분히 준비된다고 생각하지 않았기 때문입니다. 그래서 다음과 같이 동시에 Ping을 요청할 수 있는 기반을 만들어 사용했습니다.


또한 ExecutorService에 넣은 Ping 요청들을 최대한 동시에 실행하여 Pool 내에 자원이 재사용되지 않고 최대한 기대 값만큼 할당될 수 있도록 코드를 작성했습니다.



void doWarmUpConcurrently(final String name, final Runnable work, final int repeatCount) {
    final CountDownLatch startSignal = new CountDownLatch(1);
    final CountDownLatch doneSignal = new CountDownLatch(repeatCount);

    for (int index = 0; index < repeatCount; ++index) {
        executor.execute(() -> {
            try {
                startSignal.await();
                final Stopwatch watch = Stopwatch.createStarted();
                work.run();
           } catch (Exception e) {}
           doneSignal.countDown();
        });
    }
    startSignal.countDown();
    doneSignal.await();
}





실험 결과 분석

외부 서버에 대한 Ping을 부르지 않고 Local 가짜 서버의 FeignClient 호출만 할 경우 생각보다 Warm up의 효과가 미비했습니다. 이는 Local 가짜 서버는 Eureka를 통한 Service discovery를 수행할 일이 없어 DiscoveryClient가 초기화되지 않았기 때문입니다. 그래서 이후 실제 요청을 처리하는 과정에서 이 자원을 초기화하는 시간이 필요했고 때문에 여전히 느린 응답 속도를 보여주게 되었습니다. 결국 외부 서버에 대한 Ping을 호출하는 것이 해답에 가까웠습니다.


FeignClient가 여럿이어도 DiscoveryClient는 하나만 생성됩니다. 따라서 FeignClient로 정의된 외부 API가 여러 개 있어도 하나에 대해서만 Ping을 호출해주면 될 것이라고 생각했습니다. 하지만 실험 결과, 준비되지 않은 FeignClient로 첫 요청이 수행될 때 Hystrix 자원을 할당하는 곳에서 지연이 발생하는 것을 발견했습니다. 때문에 모든 FeignClient에 대한 Ping 요청을 수행하는 방향으로 결정을 내렸습니다.


gRPC 역시 대부분의 과정은 굉장히 빠르게 처리되어 굳이 Ping을 호출할 필요는 없을 것으로 생각했습니다. 하지만 실험 결과 Zookeeper로부터 NameResolver를 위한 서버 주소를 받는 과정이 생각보다 오래 걸리는 것을 발견할 수 있었습니다. 때문에 gRPC 서비스에 대한 Ping 요청도 Warm up 과정에 추가했습니다.



실험으로 Warm up에 의한 응답 속도 개선을 확인할 수는 있었지만 기대한 수준에는 미치지 못했고 생각보다 Pool 내의 자원이 준비되지 않았습니다. 동시에 Ping을 요청하면 그만큼 자원이 준비될 것으로 생각했는데 초기화 중 동시성을 제어하는 부분에서의 문제로 인해 충분한 요청이 진행되지 않았기 때문입니다.






동시성 초기화

초기화 과정에서 할당하는 자원 중에서는 여러 개를 만들어두는 것도 있고 하나만 만들어두는 것도 있습니다. 예를 들어 DBCP는 데이터베이스 연결을 여러 개 만들어두어 동시에 여러 쿼리 요청이 들어와도 수행될 수 있도록 만들어둡니다. 하지만 EurekaClient는 EurekaServer로 부터 서버 주소를 받기 위한DiscoveryClient가 여럿일 필요가 없기 때문에 하나만 만들어두게 됩니다.


초기화 과정 중 단일 생성을 보장하기 위해 동시성을 제어할 경우, 동시에 Ping 요청을 해도 결국 하나를 제외하고는 모두 대기 상태가 됩니다. 게다가 초기화 과정이 의도치 않게 오래 걸리는경우에는 일부 Ping 요청이 Timeout으로 실패하기도 합니다. 이 때문에 이후 자원의 할당을 위한 요청의 수가 초기 값보다 줄게 되었고 Pool 내에 할당된 자원의 수가 기대 값보다 작았던 것 입니다.

초기화 과정을 다음과 같이 단일 자원을 생성하는 구간과 각 요청마다 필요한 자원을 생성하는 구간으로 나눌 수 있습니다. 예를 들어 DiscoveryClient의 초기화는 앞 쪽에 해당하고, HystrixThread를 초기화하는 부분은 뒤 쪽에 해당합니다. 동시에 Ping을 많이 요청해도 앞 쪽 구간의 초기화 동안에는 하나 빼고는 모두 대기를 하고, 심지어는 Timeout으로 인해 뒤 쪽으로 그 요청이 모두 도달하지 못하는 경우도 발생을 하니 단순히 요청 수를 조절하는 것 만으로는 문제 해결이 되지 않았습니다.

따라서 Ping 요청을 두 차례 진행했습니다. 첫 번째 Ping 요청 단계에서는 적은 수로 요청하여DiscoveryClient 등의 단일 자원이 무난하게 초기화되도록 하고, 두 번째 Ping 요청 단계에서는 많은 수로 요청하여 ThreadPool 등의 요청마다 필요한 자원이 확보되도록 설계했습니다. 이 후 기대한 수준의 Pool이 할당되는 것을 확인할 수 있었습니다.






상용 반영

실험을 마친 뒤 상용 환경에 반영했고 기존 대비 꽤나 유의미한 결과를 확인할 수 있었습니다. 이 글의 처음에 대조를 위해 넣어놓은 기둥 패턴은 접속량이 많지 않은 시간대에서도 관측되고 있었는데요. Warm up 수행 이후에는 다음과 같이 상대적으로 사용량이 많은 시간대임에도 불구하고 훨씬 더 안정적인 응답 속도를 보여주게 되었습니다.


배포 직후 발생하는 응답 지연 현상이 완전히 사라진 것은 아니었기 때문에 추가로 확인을 진행했습니다. 그 결과 대부분 다음의 사유로 응답이 느려진 것을 확인할 수 있었습니다.



1. 아직 코드가 충분히 JIT 컴파일 되지 않아 속도가 전반적으로 수행이 느려진 경우

2. 준비한 Pool보다 더 많은 자원 요청이 발생해 추가 자원을 생성하기 위해 시간이 소요되는 경우



대부분의 경우 잠깐 동안만 지연이 발생하고 금방 사라진다는 점으로 볼 때, 1번 현상으로 인해 Warm up으로 준비한 자원이 금새 부족해졌고 때문에 2번 문제가 야기되는 것으로 추정했습니다. 이는 Warm up 단계에서 좀 더 많은 자원을 확보할 수 있도록 조정하는 것으로 문제를 해결 할 수도 있다는 생각을 했습니다. 하지만 지연이 발생하는 응답 수가 많지 않고, 대부분은 금새 할당된 자원 내에서 응답할 수 있는 상황이 되기 때문에 더 많은 자원을 미리 할당하는 것은 낭비라고 판단하였습니다.


오히려 Tomcat의 minSpareThread가 너무 커서 Pool 내에 할당된 자원보다 더 많은 양을 초기에 요청하는 것은 아닐까 하여 이 수를 조절하는 실험을 진행했으나 유의미한 결과를 얻지는 못했습니다. 결국 충분히 JIT 컴파일 되기 전에 너무 많은 요청이 들어오기 때문에 발생하는 문제가 아닐까 추정하고 있습니다. 이는 RibbonClient의 기본 LoadBalancer가 RoundRobin 기반으로 작동하기에, 방금 합류한 서버에도 충분히 Warm up 되어있는 다른 서버들과 동일한 요청이 들어가기 때문에 발생하는 현상으로 생각됩니다. 개선을 위해 최근에 기동된 서버로 들어가는 요청은 그 수를 처음부터 다른 서버와 동일하게 두지 않고, 차차 증가할 수 있도록 Rule을 개선하는 실험을 계획하고 있습니다.






마무리

위 문제를 해결하기 위해 서버를 증설했어도 분명 효과가 있었을 것입니다. 각 서버로 들어가는 요청량이 줄어들기 때문에 첫 동시 요청 수가 줄어 발생하는 지연 폭이 줄어들 수 있기 때문입니다. 하지만 이 방법은 요청량이 증가했을 때에는 여전히 문제가 재발할 수 있기 때문에 근본적인 해결책이라고 보기는 어렵습니다.


마침 좋은 기회를 얻어 서버의 시작 과정을 자세히 살펴볼 수 있었고 어떤 자원이 사용되며 언제 준비되는 지를 깊게 탐구할 수 있었습니다. 그 과정에서 문제가 될 수 있는 부분을 가정하고 실험으로 증명하며, 동료들과 리뷰를 통해 검증하고 상용 환경에 반영했습니다. 그리고 운이 좋게도 유의미한 결과를 볼 수 있었습니다. 이제 다음 실험을 더 진행하기 전에 그 결과를 기록으로 남겨 추후 더 좋은 방법을 탐구하는데 도움이 될 수 있기를 희망하고 있습니다. 이 글이 비슷한 현상을 겪는 분들께 조금이라도 도움이 되고, 나아가 더 나은 해결책을 찾는 토론의 기반이 될 수 있기를 기원합니다. 감사합니다! :)




bottom of page