13. DB 커넥션 풀의 유휴 커넥션 사일런트 종료 (stale connection)
난이도 하해설 — 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도 반납 시점에 검증하지 않으므로, 애초에 죽은 커넥션이 유휴 큐에 섞여 들어가는 경로를 막지 못한다.
- (A)
수정안
빌려줄 때(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(지연 증가, 정확성 우선)와 백그라운드 헬스체크(지연 없음, 창구가 남음) 사이의 트레이드오프, 그리고 두 방식을 병행하는 실무적 절충을 설명할 수 있어야 한다.
해설 — DB 커넥션 풀의 유휴 커넥션 사일런트 종료 (stale connection)
난이도: 하
요약
ConnectionPool::Rent(A)는 유휴 스택에 커넥션이 남아있으면 생존 여부를 확인하지 않고 곧바로 반환한다. Return(B) 역시 반납되는 커넥션을 검증 없이 그대로 스택에 넣는다. DB 서버나 방화벽이 idle timeout 으로 TCP 연결을 이미 끊었어도, IDbConnection 객체의 로컬 상태(IsOpen())는 소켓이 실제로 끊겼다는 사실을 즉시 반영하지 못하는 경우가 많다(TCP 는 상대가 정상적으로 FIN 을 보내지 않고 그냥 끊거나 중간 장비가 조용히 드롭하면, 로컬에서는 다음 송수신 시도 전까지 연결이 끊긴 것을 알 수 없다). 그 결과 오랜만에 빌려간 워커의 첫 쿼리가 연결 오류로 실패한다.
문제점
- 분류: 검증 없는 자원 재사용 (health-check / validation-on-borrow 부재)
- 증상: 트래픽이 뜸한 시간대 이후 첫 요청들이 간헐적인 DB 연결 오류로 실패한다. 재시도하면 대개 성공하는데, 실패 처리 경로에서 죽은 커넥션을 폐기하고 풀이 새 커넥션으로 교체하기 때문이다 — 증상이 간헐적이고 "재시도하면 낫는" 형태라 원인 파악이 늦어지기 쉽다.
- 재현 조건: 커넥션을 풀에 반납한 뒤 DB/방화벽의 idle timeout 보다 길게 아무도 빌려가지 않다가, 그 후
Rent()로 빌려가 즉시 쿼리를 실행하는 경우. - 근본 원인:
- (A)
Rent가 유휴 스택에서 꺼낸 커넥션을 검증 없이 그대로 반환한다.IsOpen()같은 로컬 상태만으로는 원격(서버/방화벽) 쪽에서 이미 끊은 연결인지 알 수 없다 — 실제 생존 여부는 가벼운 쿼리를 보내봐야 확인된다. - (B)
Return도 반납 시점에 검증하지 않으므로, 애초에 죽은 커넥션이 유휴 스택에 섞여 들어가는 경로를 막지 못한다.
- (A)
수정안
빌려줄 때(validation-on-borrow) 유휴 시간을 기록해두고, 일정 시간 이상 쉬었던 커넥션만 가벼운 검증 쿼리로 생존을 확인한다. 검증에 실패하면 폐기 후 다음 유휴 커넥션을 시도하거나 새로 생성한다.
#include <chrono>
#include <functional>
#include <memory>
#include <stack>
#include <string>
class IDbConnection
{
public:
virtual bool IsOpen() const = 0;
virtual int Execute(const std::string& sql) = 0; // 실패 시 예외를 던진다고 가정
virtual void Close() = 0;
virtual ~IDbConnection() = default;
};
class ConnectionPool
{
public:
using ConnectionFactory = std::function<std::shared_ptr<IDbConnection>()>;
using Clock = std::chrono::steady_clock;
explicit ConnectionPool(ConnectionFactory factory,
std::chrono::seconds maxIdleBeforeValidate = std::chrono::seconds(30))
: factory_(std::move(factory)), maxIdleBeforeValidate_(maxIdleBeforeValidate) {}
std::shared_ptr<IDbConnection> Rent()
{
while (!idle_.empty())
{
PooledEntry entry = std::move(idle_.top());
idle_.pop();
bool needsValidation = (Clock::now() - entry.idleSince) > maxIdleBeforeValidate_;
if (!needsValidation)
return entry.connection;
if (IsAlive(entry.connection))
return entry.connection;
// 죽은 커넥션은 폐기하고 다음 유휴 커넥션을 계속 시도한다.
SafeClose(entry.connection);
}
return factory_();
}
void Return(std::shared_ptr<IDbConnection> conn)
{
idle_.push(PooledEntry{std::move(conn), Clock::now()});
}
private:
struct PooledEntry
{
std::shared_ptr<IDbConnection> connection;
Clock::time_point idleSince;
};
static bool IsAlive(const std::shared_ptr<IDbConnection>& conn)
{
try
{
conn->Execute("SELECT 1");
return true;
}
catch (...)
{
return false;
}
}
static void SafeClose(const std::shared_ptr<IDbConnection>& conn)
{
try { conn->Close(); } catch (...) { /* 이미 끊어진 경우 무시 */ }
}
std::stack<PooledEntry> idle_;
ConnectionFactory factory_;
std::chrono::seconds maxIdleBeforeValidate_;
};
더 나은 설계
- Validation-on-borrow(채택안): 빌려줄 때만 검증하므로 구현이 단순하고, 유휴 시간 임계값을 두어 매 대여마다 검증하는 오버헤드를 피할 수 있다. 다만 검증 쿼리 자체가 왕복 1회 레이턴시를 추가하므로 트래픽이 매우 높은 풀에서는 누적 비용이 있다.
- 백그라운드 헬스체크 스레드: 별도 타이머 스레드가 주기적으로 유휴 스택을 순회하며 검증하고 죽은 커넥션을 미리 제거·교체한다. 워커의
Rent호출에는 검증 지연이 없다는 장점이 있지만, 유휴 커넥션이 많으면 백그라운드 검증 자체가 DB 부하를 늘릴 수 있고 스택 대신 순회 가능한 자료구조(리스트 등)로 바꿔야 하는 구현 변경이 필요하다. - TCP keepalive 옵션 조정: 소켓 레벨에서
SO_KEEPALIVE및 관련 간격을 idle timeout 보다 짧게 설정하면, OS 가 주기적으로 연결 생존을 확인해 애플리케이션 레벨 검증 빈도를 줄일 수 있다. 다만 중간 방화벽/로드밸런서가 keepalive 패킷 자체를 무시하거나 유휴로 오인하는 환경도 있어 완전한 해결책은 아니며, 애플리케이션 레벨 검증과 함께 쓰는 것이 안전하다. - 트레이드오프: validation-on-borrow 와 백그라운드 헬스체크를 함께 쓰는 것이 실무에서 가장 흔한 절충이다 — 백그라운드가 대부분의 죽은 커넥션을 미리 걸러내 borrow 시점 검증 비용을 최소화하면서도, borrow 시점 검증을 안전망으로 남겨 백그라운드 주기 사이의 틈을 메운다.
면접 포인트
- 커넥션 풀(또는 임의의 재사용 가능한 네트워크 자원 풀)을 설계할 때 "빌려주기 전에 그 자원이 실제로 유효한지 확인하는가"는 기본적으로 물어야 할 질문이다 — TCP 는 상대가 갑자기 끊으면 로컬에서 즉시 알 수 없는 프로토콜이라는 점을 짚을 수 있어야 한다.
- 실제 커넥션 풀 라이브러리(HikariCP 의
connectionTestQuery, libpq 의PQstatus/재연결 로직 등)가 이런 문제를 어떻게 다루는지와 이 시나리오를 연결해서 설명할 수 있어야 한다. - validation-on-borrow(지연 증가, 정확성 우선) vs 백그라운드 헬스체크(지연 없음, 검증 공백 존재) vs TCP keepalive(OS 레벨 보완) 각각의 트레이드오프와 병행 사용의 이점을 설명할 수 있어야 한다.