안녕하세요, 올리브영에서 라이브관과 매거진관을 담당하고 있는 코드의 요정 지니 🧞입니다.
올리브영에서는 팀 간의 경계를 넘나들며 협력하고, 필요한 부분에 대해 개선점을 찾기 위해 함께 고민합니다. 이러한 협업을 통해 더 나은 해결책을 찾아내고, 지속적으로 발전하기 위해 노력하고 있습니다. 이번 글에서는 애플리케이션 성능을 개선하기 위해 저희가 어떻게 웜업 로직을 활용했는지 소개해 드리겠습니다. 🚀
웜업 로직의 지속적인 진화
이번에 소개할 웜업 로직은 단순히 애플리케이션 성능을 높이는 것을 넘어, 보다 편리하고 효율적으로 사용할 수 있도록 개선된 결과물입니다. 내부적으로 계속해서 피드백을 받고 여러 차례의 시도와 개선을 거친 끝에 지금의 형태에 도달했습니다.
기존 웜업 로직을 분석한 결과, 실제로 리소스 접근 시 지연이 발생한다는 것을 확인하였고, 이를 개선에 포함시켰습니다. 특히 Redis 접근이나 외부 통신 시 발생하는 지연은 초기 사용자 경험에 부정적인 영향을 미칠 수 있기에, 해당 부분의 개선이 필요하다고 판단하였습니다.
이번 개선 작업에서는 @WarmUp
어노테이션을 활용하여 메서드에 간편하게 적용할 수 있도록 했고, 동적 파라미터 설정과 반복 호출을 통해 다양한 시나리오에 대응할 수 있는 구조로 만들었습니다. 현재까지의 가시적인 성과를 여러분께 소개하며, 일반적인 웜업 이론보다는 우리가 실제로 겪고 개선해 온 과정과 개선점을 중점적으로 설명드리겠습니다.
문제 정의
Spring Boot 애플리케이션이 시작되면 외부 리소스(예: 데이터베이스, 서드 파티 API, 내부 마이크로서비스)에 대한 첫 번째 요청이 이후의 요청보다 오래 걸리는 경우가 많습니다. 이러한 콜드 스타트 문제는 초기 사용자에게 높은 대기 시간을 유발할 수 있으며, 최악의 경우 타임아웃이 발생할 수 있습니다. 우리가 해결하려고 했던 주요 문제는 다음과 같습니다.
- 콜드 스타트 지연: 초기 요청 시 높은 지연 시간이 발생하는 문제입니다. 이를 통해 초기 사용자들이 예상보다 긴 대기 시간을 경험할 수 있습니다.
- 리소스 준비 & 연결 풀 초기화: 데이터베이스, Redis와 같은 외부 리소스나 연결 풀이 준비되지 않은 경우 첫 번째 요청 시 지연이 발생할 수 있습니다. 이로 인해 애플리케이션의 초기 성능이 저하되며, 사용자가 처음 요청을 보낼 때 긴 대기 시간을 경험하게 됩니다.
- 일관된 애플리케이션 상태 관리: 애플리케이션이 완전히 준비되기 전에 Actuator의 Health 상태가 'UP'으로 표시되는 문제입니다. 이는 초기 요청 시 정상 상태로 오해할 수 있어 사용자 경험에 영향을 줄 수 있습니다.
기존 방식의 한계와 개선 필요성
기존에 사용했던 WarmUpRunner
는 각 서비스에 대한 웜업 로직을 수동으로 관리하며, 이를 일일이 호출하는 방식이었습니다. 이 방식은 다음과 같은 한계를 가지고 있었습니다.
- 코드의 중복성: 각 웜업 작업을 수동으로 정의하고 호출해야 했기 때문에 코드의 중복이 많았습니다. 서비스가 추가될 때마다 수동으로 웜업 코드를 추가해야 했습니다.
- 유지보수의 어려움: 서비스의 웜업 방식이 변경되거나 새로운 로직이 필요할 때 모든 관련 코드를 수정해야 했고, 이로 인해 유지보수가 어렵고 오류가 발생할 가능성이 높았습니다.
- 확장성 부족: 새로운 서비스나 기능이 추가될 때마다 해당 로직을 추가하고 관리해야 했기 때문에 확장성이 부족했습니다.
- 기존 웜업 로직의 동기화 처리로 인한 속도 저하: 기존의 WarmUpRunner는 동기화 방식으로 구현되어 있어, 웜업 작업이 많아질수록 애플리케이션 시작 시간이 길어지고 전체적인 성능 저하를 초래했습니다. 특히, 각 웜업 작업이 순차적으로 실행되면서 병목 현상이 발생하여 초기화 시간이 비효율적으로 증가하는 문제가 있었습니다.
이를 해결하기 위해 AppStartupWarmupRunner
와 @WarmUp
어노테이션을 사용하여 웜업 로직을 자동화하고, 비동기적으로 수행할 수 있는 구조로 개선하였습니다. 이로 인해 코드의 중복을 줄이고 유지보수가 쉬워졌으며, 확장성 또한 확보할 수 있었습니다.
기존 WarmUpRunner
예시 코드
기존의 WarmUpRunner
는 ApplicationRunner
인터페이스를 구현하여 각 서비스의 웜업 작업을 직접 호출하는 방식으로 작성되었습니다. 예를 들면 아래와 같은 코드 구조였습니다.
@Component
class WarmUpRunner(
private val exampleServiceA: ExampleServiceA,
private val exampleServiceB: ExampleServiceB
) : ApplicationRunner {
override fun run(args: ApplicationArguments?) {
exampleServiceA.initializeData()
exampleServiceB.loadInitialData()
// 각 서비스마다 수동으로 호출
}
}
이 방식은 새로운 서비스가 추가될 때마다 직접 호출해야 하는 불편함이 있었고, 코드의 중복성이 높아 유지보수가 어려웠습니다.
AppStartupWarmupRunner 소개
AppStartupWarmupRunner
는 Spring Boot 애플리케이션 시작 시 웜업 로직을 수행하여 초기 요청의 지연을 줄이는 Kotlin 기반의 ApplicationRunner
구현체입니다. 이 Runner는 @WarmUp
어노테이션이 붙은 메서드를 자동으로 탐지하고, 코루틴을 활용한 비동기 병렬 처리를 통해 웜업 작업을 효율적으로 실행합니다. 이를 통해 애플리케이션이 초기화되는 동안 여러 웜업 작업을 동시에 처리하여 초기화 시간을 단축시키고, 전체적인 성능을 향상시킬 수 있습니다.
주요 기능
- AS-IS: 기존에는 각 웜업 작업을 수동으로 관리하고, 모든 로직을 직접 호출하는 방식으로 운영되었습니다. 이로 인해 코드 중복이 발생하고, 새로운 서비스 추가 시마다 웜업 로직을 개별적으로 작성해야 했습니다.
- TO-BE:
@WarmUp
어노테이션을 통해 웜업이 필요한 메서드를 자동으로 탐색하고,AppStartupWarmupRunner
에서 이를 비동기적으로 실행하도록 개선되었습니다. 코드 중복을 줄이고 유지보수성을 높였으며, 필요한 리소스를 자동으로 웜업함으로써 운영 효율성을 극대화했습니다.
@WarmUp
어노테이션을 활용한 웜업 로직
@WarmUp
어노테이션은 다음과 같은 형식으로 정의되어 있습니다.
@Repeatable
@Retention(RUNTIME)
@Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER)
annotation class WarmUp(
val enabled: Boolean = true, // 웜업 수행 여부
val type: Array<WarmUpType> = [WarmUpType.DEFAULT], // 웜업 유형
val params: Array<String> = [] // 인자 전달 (선택 사항)
)
enum class WarmUpType(
val value: String,
val description: String = ""
) {
DEFAULT("DEFAULT", "기본 웜업 유형"),
CONDITION_1("CONDITION_1", "조건 1에 해당하는 웜업 유형"),
CONDITION_2("CONDITION_2", "조건 2에 해당하는 웜업 유형"),
...
}
WarmUpType은 웜업 수행 조건을 나타내는 열거형(enum)으로, 필요에 따라 새로운 조건을 정의할 수 있습니다.
AppStartupWarmupRunner는 각 웜업 메서드의 실행 여부를 결정하기 위해 kotlin all 함수를 활용한 타입 배열 검사를 수행합니다. 이를 통해 웜업 유형에 따라 다른 로직을 수행할 수 있습니다.
kotlin all 함수: 모든 요소가 주어진 조건을 만족하는지 여부를 반환하는 함수
@WarmUp 어노테이션 활용 예시
@WarmUp
fun prepareCache() {
// 로직..
}
위와 같이 파라미터 없이 기본적인 웜업 작업을 수행하는 메서드에도 @WarmUp
어노테이션을 달아 간편하게 사용할 수 있습니다.
@WarmUp(type = [WarmUpType.DEFAULT], params = ["param1", "param2"])
fun initializeResource(param1: String, param2: Long) {
// 로직..
}
위와 같은 방식으로 원하는 메서드에 @WarmUp
어노테이션을 달아 손쉽게 웜업 대상 메서드를 지정할 수 있습니다.(parameter type 은 자동으로 추론되어 적용됩니다.)
리플렉션 사용
AppStartupWarmupRunner는 Spring 컨텍스트 내의 빈을 검색하고, 리플렉션을 사용하여 @WarmUp 어노테이션이 있는 메서드를 찾아 비동기적으로 호출합니다.
리플렉션을 활용하면 새로운 서비스를 추가하거나 변경할 때 직접적인 코드 수정 없이 어노테이션만 추가해도 웜업 로직이 자동으로 적용되므로, 코드의 확장성과 유지보수성을 크게 향상시킬 수 있습니다.
리플렉션(Reflection)은 프로그램이 실행 중에 클래스, 메서드, 필드 등의 정보를 동적으로 조사하고 접근할 수 있는 기능입니다. 이를 통해 코드 수정 없이 실행 시점에 메서드를 호출하거나 객체를 생성할 수 있어, 런타임 환경에서 동적 처리가 필요할 때 유용합니다.
플로우 차트로 보는 AppStartupWarmupRunner
아래는 AppStartupWarmupRunner
의 전체 웜업 로직을 설명하는 간략한 플로우차트입니다. 이 차트를 통해 웜업 로직이 어떻게 동작하는지 쉽게 이해할 수 있습니다.
플로우차트 설명
- 애플리케이션 시작: Spring Boot 애플리케이션이 시작됩니다.
- AppStartupWarmupRunner 실행: ApplicationRunner를 구현한 AppStartupWarmupRunner가 실행됩니다.
- 빈 스캔 및 웜업 메서드 탐색: Spring 컨텍스트의 모든 빈을 스캔하여 @WarmUp 어노테이션이 붙은 메서드를 찾아냅니다.
- 웜업 메서드 비동기 실행: 발견된 웜업 메서드들을 비동기적으로 실행하여 리소스를 미리 초기화합니다.
- 성공/실패 기록 및 제한 시간 관리: 각 웜업 작업의 성공 및 실패를 기록하고, 설정된 제한 시간 내에 작업이 완료되도록 관리합니다. 성공/실패 결과는 데이터독을 활용한 모니터링 시스템을 통해 실시간으로 확인할 수 있습니다.
- 웜업 완료 처리: 모든 웜업 작업이 완료되면, Health 상태를 'UP'으로 변경하고 슬랙 알림을 전송합니다. 📢
구현 개요
- 비동기 작업 관리:
CoroutineScope
와ExecutorService
를 활용하여 웜업 작업을 비동기적으로 실행합니다. 이로 인해 애플리케이션이 시작되는 동안 여러 웜업 작업이 병렬로 실행되어 시간이 단축됩니다.- CoroutineScope: 코루틴을 활용해 웜업 작업을 관리합니다. 코루틴은 가볍고, 비동기 작업을 효율적으로 처리해 스레드 수를 최소화하면서도 대량의 작업을 동시에 실행할 수 있습니다.
- ExecutorService: 코루틴 실행을 위한 스레드 풀을 관리하며, 스케일 아웃 시 RDB 접근과 같은 상황에서 최대 접근 가능한 스레드 수를 제한하여 시스템의 안정성을 높입니다. 이를 통해 병렬 작업이 자원을 효율적으로 사용하도록 조정합니다.
- 웜업 메서드 필터링: Spring 빈 중
@WarmUp
어노테이션이 달린 메서드만 웜업 대상으로 선택합니다. 이렇게 함으로써 불필요한 메서드 호출을 방지하고 필요한 리소스만 초기화할 수 있습니다. - 스레드 풀 관리 및 SupervisorJob 사용:
- 스레드 풀 크기 설정: WarmUpThreadPoolSizes 객체를 통해 각 환경별로 최적화된 스레드 풀 크기를 관리합니다. 스레드 풀 크기의 절반 값을 설정하여 자원 낭비를 방지하고, 안정적인 성능을 유지합니다.
- 슈퍼바이저 잡(SupervisorJob): SupervisorJob을 도입하여 웜업 작업 중 하나의 실패가 전체 초기화 과정에 영향을 미치지 않도록 하였습니다. 이를 통해 각 웜업 작업의 독립성을 보장하고, 전체 초기화의 안정성을 향상시켰습니다.
- 성공 및 실패 횟수 기록: 각 클래스와 메서드별로 웜업 작업의 성공 및 실패 횟수를 기록하여 이후에 이를 로깅하고, 슬랙 메시지로 전달합니다. 이를 통해 초기화 과정에서 발생한 문제를 쉽게 파악할 수 있습니다.
- 자원 정리: 웜업 작업이 완료된 후에는
CoroutineScope
와ExecutorService
를 정리하여 자원 누수를 방지합니다.
실제 적용 중인 웜업 로직 사례
- Redis 연결 미리 준비: 웜업 리소스 수행 메서드를 통해 Redis 서버와의 연결을 미리 설정하여 첫 번째 요청 시 발생할 수 있는 지연을 방지합니다.
- 외부 서비스 연결 준비: 외부 API나 Redis와 같은 리소스의 연결을 미리 설정하여 첫 요청의 지연을 줄입니다.
- 외부 API 연결 테스트: 다양한 WebClient를 사용해 외부 API와의 연결을 테스트하고 이를 미리 준비하여, 외부 시스템과의 통신 지연을 최소화합니다.
- 데이터베이스 초기화: 데이터베이스 연결 및 초기화 작업을 수행하여, 데이터베이스와의 연결을 미리 설정합니다.
- 초기 데이터 로딩: 초기 데이터를 미리 로딩하여, 첫 요청 시 데이터 로딩 지연을 방지합니다.
성능 테스트 결과
웜업 로직 적용 전후의 응답 시간을 비교한 결과, Redis와 외부 API 연결에서 초기 응답 속도가 최소 67%에서 최대 81%까지 개선되어 사용자 경험이 크게 향상되었습니다. 이를 통해 초기 요청 지연을 현저히 줄여 안정적인 사용자 경험을 제공할 수 있었습니다.
웜업 적용 전후의 성능 테스트 결과를 요약한 표입니다.
마치며
AppStartupWarmupRunner
는 애플리케이션 시작 시 발생하는 초기화 지연을 줄이고, 필요한 리소스를 미리 준비하여 더 빠르고 안정적인 서비스를 제공하는 데 큰 도움이 됩니다. 이를 통해 콜드 스타트 문제를 해결하고, 초기 사용자 경험을 크게 개선할 수 있었습니다.
이번 웜업 로직은 외부 리소스와의 연결을 미리 준비해 초기 지연 시간을 줄이는 데 매우 효과적이었습니다. 또한, JVM 최적화도 중요한 요소로, Tier 3 JIT 컴파일러를 웜업 옵션에 추가하여 테스트 중입니다. 이 최적화는 런타임 성능을 더욱 향상시키고, 애플리케이션이 안정화된 이후에도 지속적인 성능 개선을 가능하게 할 것입니다. (고도화가 진행 중인 건 안비밀😋)
결론적으로, 웜업 로직은 시작 시점의 지연을 줄이는 것뿐만 아니라, 시스템 안정성과 사용자 경험을 개선하는 데 중요한 역할을 합니다. 앞으로도 성능 최적화를 위한 다양한 방법을 모색하며 더 나은 서비스를 제공할 수 있도록 노력하겠습니다.
끝으로, 이번 프로젝트를 도와주신 모든 팀원분들께 깊은 감사를 드립니다. 특히, 함께 코드 리뷰를 해주신 개발팀, 안정적인 운영을 지원해주신 운영팀, 그리고 웜업 코드를 적용할 수 있도록 아낌없는 응원을 보내주신 팀장님께 진심으로 감사드립니다. 앞으로도 함께 성장하며, 더 나은 서비스를 제공하기 위해 꾸준히 노력하겠습니다. 감사합니다. 🙏