R2DBC Connection Pool 실종 사건

R2DBC Connection Pool 실종 사건

요약: 도리는 R2DBC 커넥션 풀이 애플리케이션 시작 시 초기화되지 않은 문제를 해결하는 과정을 공유합니다. 당연하게 생각했던 커넥션 풀이 기대와는 다르게 동작하는 것을 발견했고, r2dbc-pool 내부 코드 분석으로 사건 조사를 시작했습니다. 이 과정에서 설정을 통해 명시적으로 r2dbc-pool의 warmup 메서드를 구현해 원하는 동작을 이뤄낼 수 있는 사실을 깨달았습니다. 개발 편의성과 생산성을 위해 IntelliJ를 활용하는 것도 좋지만, 우리가 원하는 대로 동작하는지 보다 꼼꼼하게 체크하는 습관의 중요성을 전달합니다.

시작하며

안녕하세요, 카카오페이 채널서버파트 도리입니다.

채널서버파트에서는 유저들이 카카오페이의 다양한 서비스에 빠르고 쉽게 접근할 수 있도록 많은 마이크로서비스들을 연동하여 정보를 제공하는 서버를 개발하고 있습니다. 사용자와 가장 맞닿아 있는 위치에서 여러 마이크로서비스와 연동하는 서버를 개발하다 보니, 높은 피크 유저 트래픽에서도 안정적으로 동작할 수 있도록 Spring WebFlux, R2DBC를 자주 활용하고 있습니다.

이번 글에서는 R2DBC 커넥션 풀이 애플리케이션 시작 시 초기화되지 않았던 문제를 해결한 과정과, 그 과정에서 IntelliJ가 미쳤던 영향에 대해 공유하려 합니다.

해당 아티클에서 활용한 모든 예제 코드는 여기에서 확인해 보실 수 있습니다.

사건 발생

R2DBC 커넥션 풀 실종 제보

이 문제를 최초에 인지하게 된 것은 꼼꼼한 팀원의 제보로부터였습니다.

팀원의 제보
팀원의 제보

곧바로 IntelliJ에서 해당 프로젝트를 구동하니, 제 환경에선 애플리케이션 시작과 동시에 커넥션 풀이 정상적으로 생성되는 것을 확인할 수 있었습니다. 디버그 로그도 정상적으로 찍히고 lsof -i 명령어로 확인했을 때에도 정상적으로 ESTABLISHED 된 것을 확인했습니다.

IntelliJ에서 애플리케이션을 구동한 후 lsof -i로 확인
IntelliJ에서 애플리케이션을 구동한 후 lsof -i로 확인

하지만 개발 환경에 배포한 이후 커넥션 풀이 초기화되지 않는다는 팀원의 추가 제보를 받고 gradle에서 직접 빌드하여 jar 파일을 실행하니 정말 커넥션 풀이 초기화되지 않았고, 실제로 커넥션도 생성되지 않았습니다.

팀원의 추가 제보, 현상 확인
팀원의 추가 제보, 현상 확인

무슨 일이 벌어지고 있는걸까요? IntelliJ가 커넥션 풀을 초기화하는 특정 환경변수를 애플리케이션에 주입하고 있는데, 우리가 그 사실을 놓치고 있는 것일까요? 아니면 원래 구현 자체가 커넥션 풀을 초기화하지 않게 되어 있는 것일까요?

앞서 마주한 현상과 팀원들의 제보를 바탕으로 이 사건을 좀 더 들여다보기로 했습니다.

커넥션 풀이란?

백엔드 애플리케이션을 개발하다 보면 데이터베이스 커넥션 관리를 위해 커넥션 풀을 자주 활용하게 됩니다. 커넥션을 생성하는 데에는 통신, 인증/인가, 세션 설정 등 다양한 오버헤드가 존재하기 때문에 이런 비용을 절약하기 위해 미리 커넥션을 생성한 다음 풀에 저장해 두었다가 필요할 때 할당을 받아서 사용하는 것이죠.

커넥션 풀이 없다면 모든 요청마다 비싼 비용으로 커넥션을 생성해야 하기 때문에 백엔드 애플리케이션 입장에서 응답 속도가 느려질 뿐 아니라 데이터베이스 입장에서는 트래픽에 따라 최대 동시 처리 커넥션 한계에 빠르게 다다를 수도 있습니다. 따라서 커넥션 풀을 활용하는 것은 성능이나 자원 관리의 입장에서 현대 백엔드 애플리케이션들에게 사실상 필수적이라 볼 수 있습니다.

커넥션 풀의 구현체는 다양하게 찾아볼 수 있습니다. JDBC를 사용할 때엔 HikariCP를 주로 사용하게 되는데요, R2DBC에서는 커넥션 풀을 사용하기 위해 r2dbc-pool을 주로 사용하게 됩니다.

커넥션 풀에 기대하는 동작

상술한 것처럼, 커넥션 풀은 커넥션을 미리 생성해 풀에 저장해두고 필요할 때마다 할당받아 사용하는 것을 기본적인 사용 방법으로 상정하고 있습니다. 따라서 우리가 실제 트래픽을 받아 데이터베이스를 활용할 때는, 커넥션 풀에 커넥션이 미리 생성되어 언제든지 사용할 수 있는 상태이길 기대한다는 것과 같습니다.

실제로 Spring 프로젝트에서 HikariCP를 활용하여 프로젝트를 구성할 경우 Spring Web과 Spring WebFlux에 무관하게 애플리케이션 시작과 동시에 커넥션 풀이 생성되는 것을 로그와 lsof -i 명령어를 통해 확인할 수 있습니다.

Spring Web + JDBC 프로젝트 lsof 결과
Spring Web + JDBC 프로젝트 lsof 결과

Spring WebFlux + JDBC 프로젝트 lsof 결과
Spring WebFlux + JDBC 프로젝트 lsof 결과

커넥션 풀이 이와 같이 동작한다는 것이 보장될 때, 우리는 서버가 트래픽을 받을 준비가 되었음을 담보받을 수 있고 또한 애플리케이션을 시작하는 시점에 우리가 설정한 커넥션만큼 데이터베이스가 수용할 수 있는지도 확인할 수 있습니다. 이런 담보가 없다면 런타임에 데이터베이스의 최대 커넥션 수를 넘어서는 커넥션을 얻으려고 시도하게 될 수도 있고, 커넥션이 부족한 서버들이 생기면서 장애로 이어질 수도 있습니다.

기대와는 다른 R2DBC 커넥션 풀의 동작

그런데 Spring WebFlux와 r2dbc-pool로 구성한 애플리케이션에서는 커넥션 풀이 정상적으로 초기화되지 않는 문제를 발견했습니다. 아무리 커넥션 풀의 최초 사이즈를 설정해도 애플리케이션 시작 시 커넥션 풀이 생성되지 않았습니다.

Spring WebFlux + R2DBC 프로젝트 lsof 결과
Spring WebFlux + R2DBC 프로젝트 lsof 결과

아래와 같은 컨트롤러가 있다고 할 때, /api/ping을 호출한 이후 netstat으로 확인했을 때엔 데이터베이스와의 커넥션이 없다가, /api/db를 호출한 이후엔 데이터베이스 커넥션이 생성되어 있는 모습을 확인할 수 있었습니다.

@RestController
class TestController(private val r2dbcEntityTemplate: R2dbcEntityTemplate) {

    @GetMapping("/api/ping")
    fun ping() = "PONG"

    @GetMapping("/api/db")
    fun db() = r2dbcEntityTemplate.databaseClient.sql("SELECT  1").then()
}

DB 사용 요청 이후 초기화 되는 커넥션 풀
DB 사용 요청 이후 초기화 되는 커넥션 풀

이러한 동작은 방금 말씀드린 우리가 커넥션 풀에 기대하는 동작과는 사뭇 다른 것을 알 수 있습니다.

이대로면 백엔드 애플리케이션이 구동된 이후 초반에 인입되는 트래픽에 대해서는 커넥션 풀이 생성되어 커넥션이 필요한 곳에 할당되는 시간 동안 지연이 발생할 수밖에 없고, 커넥션 풀의 최초 사이즈를 크게 구성했을 경우엔 그 지연이 더 길어질 수도 있습니다. 또한 위에 언급한 바와 같이 커넥션 부족으로 인한 장애로 이어질 수도 있습니다.

혹시나 R2DBC 구현체에 따라 발생하는 문제인가 싶어 저희가 사용하는 jasync1 대신 다른 구현체인 asyncer-io2 역시 활용해 보았지만 동일하게 애플리케이션 시작 시에 커넥션 풀이 생성되지 않았습니다.

사건 조사

현상 파악

먼저, R2DBC 커넥션 풀이 실종된 현장을 다시 살펴보기로 했습니다. 우리가 커넥션 풀에 기대했던 동작과 현실에 대한 Flow Chart를 간략히 살펴보면 다음과 같습니다.

기대 vs 현실
기대 vs 현실

주로 커넥션 풀이 초기화되는 시점이 달랐고, 또한 실행 환경이 IntelliJ인가 혹은 jar 직접 실행인가에 따라 결과도 달랐습니다.

탐문 계획

문제가 발생했던 R2DBC 구현체인 jasync, asyncer-io 모두 내부적으로 r2dbc-pool을 사용하고 있었기에, r2dbc-pool에서 문제의 원인을 먼저 찾아보기로 했습니다. 그리고 도대체 왜 IntelliJ에서 애플리케이션을 실행하면 커넥션 풀이 생성되는지, IntelliJ가 어떤 동작을 숨기고 있는지 알아보기로 했습니다.

현장 방문

r2dbc-pool 내부 코드 분석

r2dbc-pool의 내부 코드를 조사하는 시간을 가져보겠습니다.

r2dbc-pool의 실제 커넥션 풀 구현 코드인 io.r2dbc.pool 패키지의 ConnectionPool부터 살펴봅시다. 커넥션 풀을 초기화하는 과정은 생성자에서 일어날 것이고, 실제 데이터베이스 커넥션을 생성하는 과정 또한 생성자에서 일어날 것이라고 짐작할 수 있습니다. ConnectionPool 생성자의 첫 번째 줄에서 바로 커넥션 풀을 생성하는 네이밍의 메서드를 확인할 수 있습니다.

public ConnectionPool(ConnectionPoolConfiguration configuration) {
    // 커넥션 풀을 생성하는 메서드 호출
    this.connectionPool = createConnectionPool(Assert.requireNonNull(configuration, "ConnectionPoolConfiguration must not be null"));

    // ...
    // 나머지 설정 코드
}

이 메서드의 구현3을 좀 더 살펴봅시다.

@SuppressWarnings("unchecked")
private InstrumentedPool<Connection> createConnectionPool(ConnectionPoolConfiguration configuration) {

    ConnectionFactory factory = configuration.getConnectionFactory();
    Duration maxCreateConnectionTime = configuration.getMaxCreateConnectionTime();

    // 설정으로부터 커넥션의 최초 사이즈 불러오는 부분
    int initialSize = configuration.getInitialSize();
    int maxSize = configuration.getMaxSize();
    Duration maxIdleTime = configuration.getMaxIdleTime();
    Duration maxLifeTime = configuration.getMaxLifeTime();

    // ...
    // 나머지 커넥션 풀 설정 코드
}

코드의 초반부에서 initialSize를 발견할 수 있는데요, 이 코드가 사용되는 곳4으로 이동해 보면 빌더에 커넥션 풀의 size가 최소 크기와 최대 크기 사이에서 유지될 수 있도록 설정하는 부분을 확인할 수 있습니다.

@SuppressWarnings("unchecked")
private InstrumentedPool<Connection> createConnectionPool(ConnectionPoolConfiguration configuration) {
    // ...
    // 커넥션 풀 설정 코드

    // initialSize에 따라 커넥션 풀의 크기를 builder에 설정
    if (maxSize == -1 || initialSize > 0) {
        builder.sizeBetween(Math.max(configuration.getMinIdle(), initialSize), maxSize == -1 ? Integer.MAX_VALUE : maxSize);
    } else {
        builder.sizeBetween(Math.max(configuration.getMinIdle(), initialSize), maxSize);
    }

    // ...
    // 나머지 커넥션 풀 설정 코드

    // 빌더로부터 실제 커넥션 풀 생성
    return builder.buildPool();
}

분명히 이 메서드 내 커넥션 풀을 초기화하는 코드가 있다면 initialSize를 사용해 최초 크기 설정과 함께 커넥션을 생성해야 할 텐데, 빌더에 설정을 하는 코드 이외엔 별다른 동작을 찾을 수 없었습니다. 메서드 내의 다른 코드들을 살펴보아도 다양한 설정을 적용하고, 설정으로부터 builder를 생성하는 코드들이 대부분이었습니다. 그렇다면 실제로 커넥션 풀을 생성할 것으로 기대가 되는 builder.buildPool()5을 살펴봅시다.

/**
 * Construct a default reactor pool with the builder's configuration.
 * -> 빌더 설정을 토대로 default reactor pool을 생성합니다.
 *
 * @return an {@link InstrumentedPool}
 */
public InstrumentedPool<T> buildPool() {
    return new SimpleDequePool<>(this.buildConfig());
}

인터페이스인 InstrumentedPool에 대해 구현체인 SimpleDequePool을 생성하여 반환하는 것을 확인할 수 있습니다. 만약 우리가 커넥션 풀의 초기화 시점에 커넥션들이 생성될 것을 기대하려면, 저 생성자에서 커넥션들이 생성되리라 기대할 수 있을 듯 합니다. 두근거리는 마음으로 생성자의 구현6을 열어봅시다.

SimpleDequePool(PoolConfig<POOLABLE> poolConfig) {
    super(poolConfig, Loggers.getLogger(SimpleDequePool.class));
    this.idleResourceLeastRecentlyUsed = poolConfig.reuseIdleResourcesInLruOrder();
    this.pending = new ConcurrentLinkedDeque<>(); //unbounded
    this.idleResources = new ConcurrentLinkedDeque<>();
    recordInteractionTimestamp();

    scheduleEviction();
}

리소스를 관리하기 위한 Deque 생성 코드나 설정 코드만 존재하고, 실제로 커넥션을 생성하는 부분이 없습니다. 숨이 가빠집니다. super를 통해 부모 클래스 생성자를 호출하는 부분이 눈에 띕니다. 기회가 얼마 남지 않은 듯 합니다. 반드시 부모 클래스의 생성자7에서 실마리를 찾아내야 합니다.

AbstractPool(PoolConfig<POOLABLE> poolConfig, Logger logger) {
    this.poolConfig = poolConfig;
    this.logger = logger;
    this.metricsRecorder = poolConfig.metricsRecorder();
    this.clock = poolConfig.clock();
    this.lastInteractionTimestamp = clock.millis();
}

두근대며 부모 클래스 생성자를 들춰 보았지만 그저 로깅, 메트릭 등의 설정을 적용하는 코드밖에 없습니다. 허탈합니다. 그렇다면 대체 커넥션을 생성해서 커넥션 풀에 할당하는 로직은 어디로 간 것일까요? 이 로직은 SimpleDequePooldoAcquire 메서드8에서 찾아볼 수 있었습니다.

@Override
void doAcquire(Borrower<POOLABLE> borrower) {
    if (isDisposed()) {
        borrower.fail(new PoolShutdownException());
        return;
    }

    pendingOffer(borrower);
    drain(); // 아래 drain() 호출
}

void drain() {
    if (WIP.getAndIncrement(this) == 0) {
        drainLoop(); // 아래 drainLoop() 호출
    }
}

private void drainLoop() {
    //we mark the interaction timestamp twice (beginning and end) rather than continuously on each iteration
    recordInteractionTimestamp();
    int maxPending = poolConfig.maxPending();

    // drainLoop 로직 수행
    // ...
}

doAcquire -> drain -> drainLoop로 호출하는 흐름을 발견할 수 있는데요, 이 drainLoop9에서는 조건에 따라 Poolable한 객체를 얻고자 하는 요청자인 Borrower들에게 적절히 리소스를 할당하는 역할을 수행하고 있습니다. 그중 커넥션이 필요한 Borrower가 있는 상태에서 커넥션을 추가로 풀에 할당, 즉 Allocate 해줄 수 있을 때의 상황을 봐야 하는데요.

일단은 primary Mono로부터 커넥션을 생성하여 Borrower에서 전달하도록 설정하고, 반드시 생성되어야 하는 커넥션의 수인 permits에 따라 조건 처리를 하고 있습니다. 이 조건 처리에서 커넥션이 1개만 필요한 경우, primarysubscribe하여 그 하나의 커넥션만 Borrower에 전달하게 되고, 풀의 크기가 1보다 클 경우엔 primary 이외 웜업해야 하는 커넥션까지 추가로 생성하게 됩니다.

else {
    /*=======================*
     * ... and CAN ALLOCATE  => Subscribe to allocator + Warmup *
     * 위 조건들에 만족하지 않아 실제로 커넥션을 할당할 수 있는 경우
     *=======================*/
    Borrower<POOLABLE> borrower = pendingPoll(borrowers);
    if (borrower == null) {
        continue; //we expect to detect pool is shut down in next round
    }
    if (isDisposed()) {
        borrower.fail(new PoolShutdownException());
        return;
    }
    borrower.stopPendingCountdown();
    long start = clock.millis();
    Mono<POOLABLE> allocator = allocatorWithScheduler();

    Mono<POOLABLE> primary = allocator.doOnEach(sig -> {
        // allocator의 sig(signal)에 따라 동작 설정
        // ...
    }).contextWrite(borrower.currentContext());

    if (permits == 1) {
        // subscribe to the primary, which will directly feed to the borrower
        // 허용된 커넥션이 1개인 경우, 추가적인 커넥션 생성이 필요없기 때문에 primary Mono에 subscribe 처리
        primary.subscribe(alreadyPropagated -> { }, alreadyPropagatedOrLogged -> drain(), this::drain);
    }
    else {
        /*=============================================*
         * (warm up in sequence to primary allocation) *
         *=============================================*/
        // primary에서 1개를 담당하기 때문에, 나머지 커넥션을 웜업하도록 설정
        int toWarmup = permits - 1;
        logger.debug("should warm up {} extra resources", toWarmup);

        final long startWarmupIteration = clock.millis();
        // flatMap will eagerly subscribe to the allocator from the current thread, but the concurrency
        // can be controlled from configuration
        final int mergeConcurrency = Math.min(poolConfig.allocationStrategy().warmupParallelism(), toWarmup + 1);
        // 실제 커넥션 생성 수행
        Flux.range(1, toWarmup)
                .map(i -> warmupMono(i, toWarmup, startWarmupIteration, allocator).doOnSuccess(__ -> drain()))
                .startWith(primary.doOnSuccess(__ -> drain()).then())
                .flatMap(Function.identity(), mergeConcurrency, 1) // since we dont store anything the inner buffer can be simplified
                .onErrorResume(e -> Mono.empty())
                .subscribe(aVoid -> { }, alreadyPropagatedOrLogged -> drain(), this::drain);
    }
}

지금까지 알아본 내용을 다이어그램으로 간단히 정리해보면 다음과 같다고 볼 수 있습니다.

r2dbc-pool 동작 Flow
r2dbc-pool 동작 Flow

r2dbc-pool은 커넥션을 담을 수 있는 풀 그 자체는 만들긴 하지만 실제로 풀에 커넥션을 생성해서 할당하는 것은 생성 시점이 아닌 커넥션이 필요한 시점에 해준다는 것을 알 수 있습니다.

그에 따라 application.propertiesinitialSize를 설정하더라도 커넥션 풀 생성 시점에 사용하지 않고 크기 범위 설정에만 사용한다는 점도 함께 확인할 수 있었습니다.

JDBC 기반 커넥션 풀 HikariCP와 차이점

이에 반해 HikariCP를 사용할 때에는 문제없이 우리가 기대하는 대로 커넥션 풀이 초기화되고 커넥션들이 생성되고 할당되는 것을 확인할 수 있습니다. 그렇다면 HikariCP는 커넥션 풀의 초기화 시점에 커넥션들을 생성하도록 코드가 작성되어 있는 것일까요? HikariCP도 간단히 조사해 봅시다.

Spring Boot에서 DataSourceAutoConfiguration을 통해 우리가 HikariCP를 사용할 경우 DataSource 초기화 시에 HikariDataSource를 사용하는데요. 이 HikariDataSource 생성자의 구현10에 커넥션 풀을 생성하는 부분이 있습니다.

public HikariDataSource(HikariConfig configuration)
{
    configuration.validate();
    configuration.copyStateTo(this);

    LOGGER.info("{} - Starting...", configuration.getPoolName());
    // Hikari 커넥션 풀 생성자 호출
    pool = fastPathPool = new HikariPool(this);
    LOGGER.info("{} - Start completed.", configuration.getPoolName());

    this.seal();
}

HikariPool의 생성자11를 호출하고 있는데요, 여기를 좀 더 살펴봅시다.

/**
 * Construct a HikariPool with the specified configuration.
 *
 * @param config a HikariConfig instance
 */
public HikariPool(final HikariConfig config)
{
    super(config);

    // 동시성 및 메트릭 관련 설정
    // ...

    final int maxPoolSize = config.getMaximumPoolSize();
    // 최대 풀 사이즈로 큐 생성
    LinkedBlockingQueue<Runnable> addConnectionQueue = new LinkedBlockingQueue<>(maxPoolSize);
    // 커넥션 생성 Executor 생성
    this.addConnectionExecutor = createThreadPoolExecutor(addConnectionQueue, poolName + " connection adder", threadFactory, new CustomDiscardPolicy());
    this.closeConnectionExecutor = createThreadPoolExecutor(maxPoolSize, poolName + " connection closer", threadFactory, new ThreadPoolExecutor.CallerRunsPolicy());

    // 필요한 나머지 작업 및 기본값 처리
    // ...
}

HikariPool의 생성자에서 addConnectionExecutor를 통해 비동기로 생성 시점에 커넥션을 커넥션 풀에 할당하도록 되어 있는 것을 확인할 수 있고, 이를 통해 애플리케이션 시작 시 커넥션 풀을 초기화하게 됩니다.

사건 해결

범인 추리

범인은 바로 너!…가 아니라 나…?

코드로부터 알아본 내용에 따르면, HikariCP와는 다르게 r2dbc-pool은 생성 시점에 커넥션 풀에 커넥션을 생성하여 할당하지 않는다는 사실을 알 수 있습니다. 아하! 그러면 r2dbc-pool이 범인이군요!

… 라고 생각하던 차에, r2dbc-pool 코드에 있는 warmup 메서드를 발견하게 됐습니다. 이 메서드와 리액티브 프로그래밍과 커넥션 풀과의 관계를 생각하다 보니 오히려 r2dbc-pool이 기반하고 있는 reactor-pool, 그리고 이것의 기반인 리액티브 프로그래밍에 대한 철학과 개념을 무시하고 그저 원하는 대로 동작하길 바라는 제가 실종 사건의 진범이라는 것을 곧 깨달을 수 있었습니다.

리액티브 프로그래밍과 커넥션 풀, 그리고 warmup

r2dbc-pool 커밋 히스토리를 살펴보면 스스로가 진범임을 깨달을 수 있는 재밌는 내용을 찾아볼 수 있습니다. warmup 메서드가 명시적으로 구현된 커밋이 있는데요, 해당 커밋의 메시지엔 다음과 같은 내용이 있습니다.

Adapt to reactor-pool API changes introducing warmup().
Warmup needs to be called explicitly. PoolingConnectionFactoryProvider does not accept a minSize as we cannot issue a warmup due to imperative API usage.

PoolingConnectionFactoryProviderminSize를 설정으로 받아들이지 않고, 그 이유로 warmup이 명령형 API 사용이기 때문에 사용할 수 없다고 되어 있습니다. 그리고 이것은 reactor-pool API의 변경에 맞춘 것이라고 되어 있습니다.

reactor-pool은 Reactor에 속한 서브 프로젝트로, 리액티브 환경에서 풀을 구성할 수 있게 도와주는 도구입니다. 사실 위 코드 분석에서 InstrumentedPool, SimpleDequePool 모두 r2dbc-pool의 구현이 아닌 reactor-pool의 구현입니다.

이 reactor-pool을 warmup 하는 동작, 우리 상황에 맞추어 말하면 풀에 커넥션을 생성하여 할당하는 동작이 왜 명령형 API 사용이라는 것일까요?

리액티브 프로그래밍에서는 전체 시스템이 비동기적으로 동작하는 것을 상정하고 있기 때문에 커넥션과 같은 자원의 생성과 관리도 비동기적으로 이루어져야 합니다. 하지만 커넥션을 미리 생성해두고 대기하고 있는 것은 해당 자원이 필요한 이벤트가 일어나지 않았음에도 대기하고 있도록 하는 명령형 작업을 수행하는 것과 같습니다.

따라서 리액티브 프로그래밍 패러다임을 따르고 있는 reactor-pool은 커넥션 풀을 명시적으로 미리 웜업한 상태로 대기하는 동작을 지원하지 않습니다. 대신 이 부분을 극복하기 위해 명시적으로 warmup 메서드를 인터페이스에 명시12해두고 있습니다.

SimpleDequePool은 이 warmup 메서드를 구현13하고 있고, 우리가 사용하는 r2dbc-pool은 SimpleDequePool의 warmup을 호출하는 메서드를 제공하고 있습니다. reactor-pool, r2dbc-pool 모두 범인이 아니었던 것입니다.

/**
 * Warms up the {@link ConnectionPool}, if needed. This instructs the pool to check for a minimum size and allocate
 * necessary connections when the minimum is not reached.
 *
 * @return a cold {@link Mono} that triggers resource warmup and emits the number of warmed up resources.
 */
public Mono<Integer> warmup() {
    // SimpleDequePool의 warmup 구현 호출
    return this.connectionPool.warmup();
}

설정을 통한 명시적 해결

앞에서 알아본 바와 같이, r2dbc-pool은 명시적인 warmup 메서드를 제공합니다. 따라서 아래와 같이 실제 ConnectionFactory를 생성하는 과정에서 명시적으로 r2dbc-pool을 웜업하여 문제를 해결할 수 있었습니다.

@Configuration
class DataSourceConfig : AbstractR2dbcConfiguration() {
    @Bean
    override fun connectionFactory(): ConnectionFactory {
        val mySQLConnectionFactory = MySQLConnectionFactory(mySQLConnectionConfig())
        val jasyncConnectionFactory = JasyncConnectionFactory(mySQLConnectionFactory)
        val r2dbcPoolConfig = connectionPoolConfiguration(jasyncConnectionFactory)
        val r2dbcPool = ConnectionPool(r2dbcPoolConfig)

        // Application 시작 전에 DB Connection Pool 초기화
        r2dbcPool.warmup().block()

        return r2dbcPool
    }

    private fun connectionPoolConfiguration(connectionFactory: ConnectionFactory) =
        ConnectionPoolConfiguration.builder()
            .connectionFactory(connectionFactory)
            .initialSize(25)
            .minIdle(25)
            .maxSize(25)
            .build()

    private fun mySQLConnectionConfig() =
        com.github.jasync.sql.db.Configuration(
            host = "db",
            username = "root",
            password = "root",
            port = 3306,
            database = "sample",
            connectionTimeout = 2_000,
        )

}

참고로 위 코드에서는 r2dbcPool.warmup().block()을 통해서 wamrup이 완료될 때까지 기다리고 있지만, 경우에 따라 r2dbc.warmup().subscribe()로 처리하여 애플리케이션 시작 중에 다른 작업들을 수행하는 동시에 커넥션을 웜업할 수도 있습니다.

IntelliJ로 인해 가리어진 문제

r2dbc-pool이 커넥션 풀 생성 시에 초기화되지 않는 이유와 그 과정에서 warmup 메서드까지 찾을 수 있었지만, 그래도 풀리지 않는 의문이 있습니다. 처음 말씀드린 것처럼 IntelliJ에서는 커넥션이 정상적으로 웜업되는 것으로 보였다는 것인데요. 이 부분은 제공하는 Spring Actuator와의 통합 기능 때문에 HealthCheck 과정에서 R2DBC의 DB 연결을 확인하는 로직이 동작해 커넥션 풀이 정상적으로 생성되는 것처럼 보이는 것이라는 가설을 세웠습니다.

R2DBC의 상태를 보여주는 HealthCheck 탭
R2DBC의 상태를 보여주는 HealthCheck 탭

교차 검증을 통해 가설을 검증해 보기로 했습니다.

먼저 r2dbc-pool이 기대한 대로 웜업되지 않던 코드를 gradle을 활용하여 빌드한 다음 애플리케이션을 구동하고, Spring Actuator HealthCheck API를 호출해 보았습니다.

HealthCheck API 호출 이후 커넥션 풀이 생성되는 로그
HealthCheck API 호출 이후 커넥션 풀이 생성되는 로그

역시 예상한 대로 커넥션 풀이 생성이 됩니다. 그렇다면 Spring Boot Actuator 의존성을 제거한 다음 IntelliJ에서 해당 애플리케이션을 실행해 보겠습니다.

커넥션 할당 없이 시작하는 애플리케이션
커넥션 할당 없이 시작하는 애플리케이션

예상한 대로 커넥션 풀이 생성되는 동작이 일어나지 않습니다. 하지만 로그로부터 흥미로운 부분을 포착할 수 있는데요. rmi를 통해 JMX 데이터를 요청한 로그가 남아 있습니다. gradle로 실행했을 때에는 이런 로그를 찾아볼 수 없었다는 점에서, IntelliJ는 rmi를 통해 JMX 데이터를 조회하고 있음을 알 수 있습니다.

그렇다면 Spring Actuator의 노출 옵션을 제어하여 HealthCheck의 Web, JMX 접근을 막고 IntelliJ에서 실행하면 어떻게 동작할까요?

management.endpoints.jmx.exposure.exclude = *
management.endpoints.web.exposure.exclude = *

JMX를 통한 HealthCheck 조회
JMX를 통한 HealthCheck 조회

HealthCheck 탭에 나타나는 JMX 에러
HealthCheck 탭에 나타나는 JMX 에러

JMX 데이터를 조회할 수 없게 되면서 HealthCheck를 수행할 수 없게 되고, 그에 따라 커넥션 풀을 웜업하는 동작이 발생하지 않았습니다.

저희가 개발한 프로젝트들은 보안 상의 이유로 Actuator 접근을 통제하고 있는데, JMX 접근의 경우 내부 네트워크를 통하지 않는 한 외부에서는 접근할 수 없는 포트에 열려있다 보니 별도로 비활성화하지 않고 있었는데요. 그에 따라 IntelliJ에서 애플리케이션을 실행할 경우 HealthCheck를 수행하며 커넥션 풀을 애플리케이션 시작과 동시에 웜업하는 것으로 보였던 것입니다.

JMX를 활성화해두었던 점, 그리고 IntelliJ의 JMX를 활용한 편리한 유틸 기능이 오히려 최초의 문제 인식 자체를 어렵게 하고 있었던 것입니다.

사건 회고

지금까지 우리가 알아본 내용을 토대로 현상 파악 때 생각했던 Flow Chart를 업데이트해보도록 합시다.

기본 동작 vs 웜업 사용
기본 동작 vs 웜업 사용

코드 레벨을 통해 자세히 살펴보기도 했지만 핵심은 리액티브 프로그래밍의 특성에 의한 부분이었고, 설정을 통해 명시적으로 warmup 해줄 경우 우리가 원하는 동작을 이뤄낼 수 있다는 사실을 알 수 있었습니다.

저는 애플리케이션이 최초 트래픽을 받는 시점에 이미 처리할 유저 트래픽이 있을 것이라는 점 그리고 애플리케이션의 시작 시점에 데이터베이스와의 커넥션이 정상적이라는 것을 담보받을 수 있다는 점 때문에 커넥션 풀을 명시적으로 웜업하는 방안을 택하기로 했습니다.

마치며

r2dbc-pool을 활용할 때 커넥션 풀이 애플리케이션 시작 시점에 초기화되지 않는 이슈에 대해서 그 원인과 해결책을 알아봤습니다.

오히려 r2dbc-pool엔 어떠한 잘못도 없었고, r2dbc-pool이 의존하고 있는 reactor-pool은 풀이라는 개념에 대해 리액티브 프로그래밍 철학을 충실하게 따라 구현하고 있을 뿐이었습니다. 따라서 이 글에서 다룬 해결책은 리액티브 프로그래밍 관점에서는 다소 자연스럽지 않은 해결책일 수 있습니다.

그럼에도 불구하고 실제 서버 애플리케이션을 운영하는 입장에서 저는 사용자의 요청을 받기 전 커넥션 풀이 생성되어 있는 것, 그리고 커넥션 풀을 정상적으로 생성하지 못하면 애플리케이션 시작 자체가 실패하는 것이 서버의 운영 안정성을 높일 수 있는 방법이라 생각합니다.

JVM 환경에서 서버 개발을 하는 많은 분들이 IntelliJ를 활용하고 계실 것으로 생각이 되는데요. Java, Kotlin과의 뛰어난 궁합과 자동 완성, 여러 편의기능을 통해 개발 생산성을 높이는 데에 크게 도움을 주지만 반대급부로 애플리케이션의 설정을 꼼꼼히 챙기지 못할 경우 이런 이슈 파악을 놓치게 되는 문제도 동반하게 되는 것 같습니다.

항상 운영 환경과 같은 빌드/실행 절차로 디버그 로그나 데이터베이스 커넥션 유무 등을 체크하고, 특히 새로운 기술을 도입할 때 한번 더 우리가 원하는 대로 동작하는지 검증하는 과정을 가지는 것이 중요하다 느꼈습니다.

이 글이 R2DBC를 프로젝트에 도입하려는, 또 이미 사용하고 계신 개발자 분들께 도움이 되었으면 합니다.

감사합니다.

참고 자료

각주

Footnotes

  1. https://github.com/jasync-sql/jasync-sql

  2. https://github.com/asyncer-io/r2dbc-mysql

  3. https://github.com/r2dbc/r2dbc-pool/blob/db4ee7e6823b720fb27bcc5f20d226e9016a1569/src/main/java/io/r2dbc/pool/ConnectionPool.java#L212

  4. https://github.com/r2dbc/r2dbc-pool/blob/db4ee7e6823b720fb27bcc5f20d226e9016a1569/src/main/java/io/r2dbc/pool/ConnectionPool.java#L278

  5. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/PoolBuilder.java#L467

  6. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/SimpleDequePool.java#L102

  7. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/AbstractPool.java#L63

  8. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/SimpleDequePool.java#L295

  9. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/SimpleDequePool.java#L406

  10. https://github.com/brettwooldridge/HikariCP/blob/ddf32467f45279280917f17be30c1758de23d398/src/main/java/com/zaxxer/hikari/HikariDataSource.java#L80

  11. https://github.com/brettwooldridge/HikariCP/blob/0a6ccdb334b2ecde25ae090034669d534736a0de/src/main/java/com/zaxxer/hikari/pool/HikariPool.java#L115

  12. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/Pool.java#L51

  13. https://github.com/reactor/reactor-pool/blob/31f8aaca041cbb41def37d09c4e0086d89594923/reactor-pool/src/main/java/reactor/pool/SimpleDequePool.java#L226

dory.m
dory.m

카카오페이 서버 개발자 도리입니다. 설계 관점에서 나무보다 숲을 보는 것을 더 즐기지만, 숲을 구성하는 나무들의 중요성을 역시 잊지 않고자 노력하고 있습니다.

태그