← 문제로

13. DB 커넥션 풀의 유휴 커넥션 사일런트 종료 (stale connection)

난이도 하
내 리뷰 · C#
해설 · C#

해설 — DB 커넥션 풀의 유휴 커넥션 사일런트 종료 (stale connection)

난이도: 하

요약

ConnectionPool.RentAsync(A)는 유휴 큐에 커넥션이 남아있으면 그 커넥션의 실제 생존 여부를 전혀 확인하지 않고 곧바로 빌려준다. Return(B) 역시 반납된 커넥션을 아무 검증 없이 유휴 큐에 다시 넣는다. DB 서버나 중간 방화벽이 유휴 타임아웃으로 TCP 연결을 이미 끊었더라도, 풀 입장에서는 그 사실을 통보받을 방법이 없어 커넥션 객체가 여전히 "열려 있는 것"으로 남아있다. 그 결과 오랜만에 빌려간 워커가 실제로 첫 쿼리를 실행하는 순간에야 연결 오류(예: 이미 끊긴 소켓에 쓰기 시도)로 실패한다.

문제점

  • 분류: 검증 없는 자원 재사용 (health-check / validation-on-borrow 부재)
  • 증상: 트래픽이 뜸한 시간대 이후(예: 새벽) 첫 요청들이 알 수 없는 DB 연결 오류로 잇따라 실패한다. 재시도하면 대개 성공하는데, 이는 실패한 시도가 풀에서 죽은 커넥션을 새 것으로 교체하도록(혹은 예외 처리 경로에서 커넥션을 폐기하도록) 만들었기 때문이다 — 즉 증상은 간헐적이고 "재시도하면 낫는" 형태로 나타나 원인 파악이 늦어지기 쉽다.
  • 재현 조건: 커넥션을 풀에 반납한 뒤, DB/방화벽의 idle timeout 보다 길게 아무도 그 커넥션을 빌려가지 않다가, 그 후 RentAsync 로 빌려가 즉시 쿼리를 실행하는 경우.
  • 근본 원인:
    • (A) RentAsync 가 유휴 큐에서 꺼낸 커넥션을 검증 없이 그대로 반환한다. IDbConnection.IsOpen 같은 로컬 상태 플래그만으로는 서버 쪽에서 이미 끊은 연결인지 알 수 없다(로컬 소켓 객체는 아직 "열려있다"고 착각하고 있을 수 있다) — 실제 생존 여부는 가벼운 쿼리(예: SELECT 1)를 보내봐야 확인할 수 있다.
    • (B) Return 도 반납 시점에 검증하지 않으므로, 애초에 죽은 커넥션이 유휴 큐에 섞여 들어가는 경로를 막지 못한다.

수정안

빌려줄 때(validation-on-borrow) 가벼운 검증 쿼리로 생존을 확인하고, 실패하면 폐기 후 새 커넥션을 생성해 대신 반환한다. 유휴 시간이 너무 길어진 커넥션은 애초에 검증 없이도 선제적으로 폐기하도록 유휴 시각을 함께 기록한다.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public sealed class PooledConnection
{
    public IDbConnection Connection;
    public DateTime IdleSince;
}

public sealed class ConnectionPool
{
    private readonly Stack<PooledConnection> _idle = new();
    private readonly Func<Task<IDbConnection>> _createConnection;
    private readonly TimeSpan _maxIdleBeforeValidate = TimeSpan.FromSeconds(30);

    public ConnectionPool(Func<Task<IDbConnection>> createConnection)
    {
        _createConnection = createConnection;
    }

    public async Task<IDbConnection> RentAsync()
    {
        while (_idle.Count > 0)
        {
            var pooled = _idle.Pop();

            // 유휴 시간이 짧으면(직전에 반납되었으면) 굳이 매번 검증하지
            // 않아 지연을 아끼되, 일정 시간 이상 쉬었던 커넥션은 반드시
            // 검증한다 — idle timeout 이후의 커넥션을 걸러내는 핵심 지점.
            bool needsValidation = DateTime.UtcNow - pooled.IdleSince > _maxIdleBeforeValidate;
            if (!needsValidation)
                return pooled.Connection;

            if (await IsAliveAsync(pooled.Connection))
                return pooled.Connection;

            // 죽은 커넥션은 폐기하고 다음 유휴 커넥션을 계속 시도한다.
            await SafeCloseAsync(pooled.Connection);
        }

        return await _createConnection();
    }

    public void Return(IDbConnection conn)
    {
        _idle.Push(new PooledConnection { Connection = conn, IdleSince = DateTime.UtcNow });
    }

    private static async Task<bool> IsAliveAsync(IDbConnection conn)
    {
        try
        {
            await conn.ExecuteAsync("SELECT 1");
            return true;
        }
        catch
        {
            return false;
        }
    }

    private static async Task SafeCloseAsync(IDbConnection conn)
    {
        try { await conn.CloseAsync(); } catch { /* 이미 끊어진 경우 무시 */ }
    }
}

더 나은 설계

  • Validation-on-borrow(채택안): 빌려줄 때만 검증하므로 구현이 단순하고, 유휴 시간 임계값을 두어 매번 검증하는 오버헤드도 피할 수 있다. 다만 검증 자체가 왕복 1회의 비용(레이턴시)을 추가하므로, 트래픽이 매우 높은 풀에서는 이 비용이 누적될 수 있다.
  • 백그라운드 헬스체크(idle validation): 별도 타이머 스레드가 주기적으로 유휴 큐의 커넥션들을 순회하며 검증하고 죽은 것을 미리 제거·교체해둔다. 워커가 빌리는 시점에는 검증 지연이 없다는 장점이 있지만, 유휴 커넥션이 많을 때 백그라운드 검증 자체가 DB 에 불필요한 부하를 줄 수 있고 구현이 더 복잡하다.
  • 커넥션 최대 유휴 시간을 DB/방화벽의 idle timeout 보다 짧게 설정: 애초에 그 시간을 넘긴 커넥션은 검증 없이 선제적으로 폐기하고 새로 만든다. 검증 쿼리 자체가 필요 없어지지만, 정확한 idle timeout 값을 인프라 쪽과 맞춰 알아내야 하고 그 값이 바뀌면(예: 방화벽 정책 변경) 함께 갱신해야 하는 운영 부담이 있다.
  • 트레이드오프: validation-on-borrow 와 백그라운드 헬스체크를 함께 쓰는 것이 실무에서 가장 흔한 절충이다 — 백그라운드가 대부분의 죽은 커넥션을 미리 걸러내 borrow 시점의 검증 비용을 최소화하면서도, borrow 시점 검증을 안전망으로 남겨 백그라운드 주기 사이의 틈을 메운다.

면접 포인트

  • 커넥션 풀(또는 임의의 재사용 가능한 자원 풀)을 설계할 때 "빌려주기 전에 그 자원이 실제로 유효한지 확인하는가"는 기본적으로 물어야 할 질문이다 — 로컬 상태 플래그(IsOpen)는 원격 자원의 실제 생존을 보장하지 못한다는 점을 짚을 수 있어야 한다.
  • HikariCP, .NET 의 SqlConnection 풀 등 실제 커넥션 풀 구현체들이 validationQuery/TestOnBorrow/ConnectionLifetime 같은 옵션을 제공하는 이유를 이 문제 시나리오와 연결해서 설명할 수 있어야 한다.
  • validation-on-borrow(지연 증가, 정확성 우선)와 백그라운드 헬스체크(지연 없음, 창구가 남음) 사이의 트레이드오프, 그리고 두 방식을 병행하는 실무적 절충을 설명할 수 있어야 한다.