Skip to content
7월 26, 2026chattiboyDjango, kubernetes

Django `CONN_HEALTH_CHECKS`는 왜 필요한가 — persistent connection의 함정과 처방

Django CONN_HEALTH_CHECKS는 왜 필요한가 — persistent connection의 함정과 처방

2026-07-26 · 실제 진단에서 출발한 정리 / 포스팅용
키워드: Django, PostgreSQL, persistent connection, CONN_MAX_AGE, CONN_HEALTH_CHECKS, DB 재시작, 커넥션 복원력

한 줄 요약

CONN_HEALTH_CHECKS는 "항상 켜는 안전장치"가 아니다. CONN_MAX_AGE > 0(persistent connection)을 쓰는 순간 생기는 함정 — 재사용하는 커넥션이 몰래 죽어 있는 상황 — 을 막기 위한 필수 짝이다. 왜 그 함정이 생기고, 이 옵션이 정확히 무엇을 해주는지 정리한다.


출발점: "장애"인 줄 알았던 자동 복구

공유 PostgreSQL을 쓰는 서비스에서 DB가 잠깐 재시작되자 API가 500을 뱉었다.

psycopg.OperationalError: connection to server at "db-host", port 5432 failed:
server closed the connection unexpectedly

처음엔 "커넥션이 고착돼 자동 복구를 못 한다"고 의심했지만, 확인해보니 이 서비스는 스스로 복구돼 있었다. 이유는 하나 — 이 서비스는 CONN_MAX_AGE = 0, 즉 요청마다 새 커넥션을 열고 닫는 기본 설정이었기 때문이다. DB만 살아나면 다음 요청이 새 커넥션을 맺으므로 복구에 아무 장치도 필요 없었다.

그러면 자연히 질문이 남는다. 커넥션을 재사용하지 않는데도 이 소동이 났다면, 재사용하는(CONN_MAX_AGE > 0) 서비스는 도대체 어떻게 되는 걸까? 바로 거기서 CONN_HEALTH_CHECKS가 필요해진다.

왜 이 500은 진단이 헷갈렸나 — 요청이 지나온 길

이게 단순한 "앱 → DB" 구조였다면 원인이 금방 보였을 것이다. 하지만 실제 요청 경로는 여러 겹이다.

flowchart LR
    B[브라우저] --> DNS[DNS 해석]
    DNS --> KONG[API 게이트웨이 Kong]
    KONG --> ING[쿠버네티스 진입 Ingress]
    ING --> MESH[서비스 메시 istio-proxy]
    MESH --> APP[Django BE Pod]
    APP --> PG[(공유 PostgreSQL - 여러 서비스 공유)]
    KONG -. 자기 설정만 저장 .-> KDB[(Kong 전용 PostgreSQL - 별개 인스턴스)]

브라우저가 화면 하나를 그리려고 categories/·tasks/·summary/를 부르면, 각 요청은 DNS → 게이트웨이(Kong) → 쿠버네티스 진입(Ingress) → 서비스 메시(istio) → 앱 파드 → DB를 순서대로 통과한다. 그리고 이 경로 위의 어느 홉이든 500의 원인이 될 수 있다. 층이 많을수록 "어디가 범인인가"를 좁히는 일 자체가 일이 된다.

계층 커넥션/요청을 끊는 흔한 원인 이번 건과 관계
DNS 잘못된 해석·TTL 캐시 무관 (해석 정상)
게이트웨이 (Kong) proxy idle timeout, 재시작, 라우트 오류 의심했으나 무관 (아래)
서비스 메시 (istio) 사이드카 idle timeout·재시작 무관
앱 ↔ DB DB 재시작·DB idle timeout·네트워크 순단 여기였다

함정: "게이트웨이도 DB를 쓰니, 그것 때문 아닌가?"

이 복잡성이 실제로 잘못된 의심을 낳았다. "같은 시간대에 게이트웨이(Kong) 쪽도 시끄러웠으니, 그게 DB를 막은 것 아니냐"는 것이다. 확인해보면:

  • 게이트웨이(Kong)는 자기 전용 로컬 DB(kong DB)에만 자기 라우트·플러그인 설정을 저장한다.
  • 앱 서비스들은 완전히 다른 호스트의 공유 DB에 데이터를 넣는다.

다른 호스트, 다른 인스턴스. 게이트웨이 DB의 부하가 앱↔DB 커넥션을 직접 막을 경로는 없다. 위 그림에서 Kong ⇢ Kong 전용 DB 선과 앱 → 공유 DB 선이 서로 닿지 않는 것이 핵심이다. "같은 시간대에 겹쳐 보인다"가 곧 인과는 아니다 — 상관과 인과를 구분하려면 경로가 실제로 연결돼 있는지를 그림에서 확인해야 한다.

그래서 얻는 결론 두 가지

  1. 층이 많은 구조일수록 "어느 층인가"를 먼저 좁혀라. 이번엔 앞단(DNS·게이트웨이·메시)을 하나씩 배제하고 나서야 맨 끝의 앱 ↔ DB 커넥션이 남았다.
  2. 홉이 많다는 건 커넥션을 끊을 수 있는 주체도 많다는 뜻이다. 그래서 복잡한 인프라일수록 각 커넥션의 자가 복원력(재사용 전 생존 확인 → 재연결)이 더 중요해진다. CONN_HEALTH_CHECKS가 다루는 게 바로 이 경로의 맨 끝 구간이다.

1단계 — 왜 persistent connection(CONN_MAX_AGE > 0)을 쓰나

새 DB 커넥션 하나를 여는 건 공짜가 아니다. 매번:

  • TCP 핸드셰이크
  • (TLS를 쓴다면) TLS 협상
  • PostgreSQL 인증·세션 초기화

이 비용이 붙는다. 요청량이 많은 서비스에서 요청마다 이걸 반복하면 지연과 DB 부하가 쌓인다. 그래서 Django는 CONN_MAX_AGE로 커넥션을 일정 시간 재사용하게 해준다.

DATABASES = {
    "default": {
        # ...
        "CONN_MAX_AGE": 60,   # 커넥션을 60초 동안 재사용 (매 요청마다 새로 안 연다)
    }
}

성능상 합리적인 선택이다. 그런데 여기서 함정이 생긴다.

2단계 — persistent connection의 함정: 커넥션은 "몰래" 죽는다

앱이 커넥션을 60초 재사용하려고 손에 쥐고 있는 동안, 반대편(서버·네트워크)은 그 커넥션을 언제든 끊을 수 있다. 앱에게 통보하지 않고.

  • DB 재시작 — 서버가 죽으면 열려 있던 커넥션은 전부 무효가 된다.
  • DB의 idle 타임아웃 — 유휴 커넥션을 서버가 정리한다.
  • 로드밸런서/방화벽의 idle cut — 중간 장비가 조용히 끊는다.
  • 네트워크 순단 — TCP 커넥션이 깨진다.

문제는 앱은 이걸 모른다는 것. 앱 입장에선 여전히 "쓸 수 있는 커넥션"을 쥐고 있다고 믿는다. 그리고 다음 요청이 오면 그 이미 죽은 커넥션(stale/broken connection)으로 쿼리를 던진다.

결과:

OperationalError: server closed the connection unexpectedly

CONN_MAX_AGE = 0이었다면 "어차피 새로 여니까" 문제가 안 됐을 것이, CONN_MAX_AGE > 0이 되는 순간 "죽은 커넥션을 재사용하려다 터지는" 실패 모드로 바뀐다. persistent connection의 성능 이득에는 이 리스크가 딸려 온다.

같은 "DB 재시작" 사건이 health check 유무에 따라 어떻게 갈리는지를 시퀀스로 보면 이렇다.

sequenceDiagram
    participant App as Django 앱
    participant Conn as 재사용 커넥션
    participant DB as PostgreSQL

    Note over App,DB: 정상 - 커넥션을 재사용 중
    App->>Conn: 재사용 커넥션 획득
    Conn->>DB: 쿼리
    DB-->>App: 결과

    Note over DB: DB 재시작 - 커넥션이 조용히 끊김
    DB--xConn: 커넥션 무효화 (앱은 모름)

    rect rgb(255, 235, 235)
    Note over App,DB: CONN_HEALTH_CHECKS 꺼짐 (기본값)
    App->>Conn: 죽은 커넥션을 그대로 재사용
    Conn->>DB: 쿼리 시도
    DB-->>App: OperationalError 500
    end

    rect rgb(235, 245, 255)
    Note over App,DB: CONN_HEALTH_CHECKS 켜짐
    App->>Conn: 재사용 전 생존 확인
    Conn--xApp: 죽음 감지, 폐기
    App->>DB: 새 커넥션 수립
    DB-->>App: 결과, 자동 복구
    end

3단계 — health check 없이는 어떻게 되나

CONN_HEALTH_CHECKS가 꺼져 있으면(기본값 False), Django는 재사용할 커넥션이 살아있는지 확인하지 않는다. 그냥 쿼리를 던지고, 터지면 그때 안다. 시나리오는 둘 중 하나다.

  • 최선의 경우: 그 요청 하나가 500으로 희생되고, Django가 커넥션을 버린 뒤 다음 요청에서 재연결된다. → 사용자 한 명이 에러를 본다.
  • 나쁜 경우: 커넥션이 여러 개 풀에 broken 상태로 남아 있거나, 백그라운드 스레드/스케줄러가 죽은 커넥션을 계속 물고 있으면 — 에러가 반복되며 좀처럼 저절로 낫지 않는다. DB는 멀쩡한데 앱만 계속 500을 뱉는, 가장 헷갈리는 장애가 된다.

즉 "DB가 재시작됐다 살아났는데 특정 앱만 계속 죽어 있다"는 증상의 상당수가 이 조합(CONN_MAX_AGE > 0 + CONN_HEALTH_CHECKS 꺼짐)에서 나온다.

4단계 — CONN_HEALTH_CHECKS = True가 하는 일

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,   # ← 재사용 전에 "너 살아있니?" 확인
    }
}

이 옵션이 켜지면, Django는 재사용하려는 커넥션을 실제로 쓰기 직전에 살아있는지 가볍게 확인한다. 죽어 있으면 조용히 버리고 새로 연결한 다음 쿼리를 던진다. (Django 4.1+에서 지원.)

핵심은 비용 구조다:

  • 확인 비용은 작다 — 요청당 가벼운 커넥션 헬스 체크 한 번.
  • 얻는 것은 — DB가 내려갔다 올라와도, 유휴 타임아웃에 끊겨도, 앱이 broken 커넥션을 감지해 스스로 재연결한다. 사용자에게 노출되는 에러가 사실상 사라진다.

다시 말해 CONN_HEALTH_CHECKSpersistent connection의 성능 이득은 유지하면서, 그것이 데려온 "죽은 커넥션" 리스크만 상쇄하는 옵션이다. 그래서 둘은 짝이다.

앞의 요청 경로에서 맨 끝 구간, 즉 앱 파드가 요청 하나를 처리할 때 DB 커넥션을 어떻게 고르는지를 흐름도로 보면 이 옵션이 어디서 끼어드는지가 분명해진다.

flowchart TD
    A[요청 도착] --> B{CONN_MAX_AGE 가 0보다 큰가}
    B -->|아니오 0| N[새 커넥션 수립]
    B -->|예| R{재사용할 커넥션이 있나}
    R -->|없음| N
    R -->|있음| H{CONN_HEALTH_CHECKS 켜짐}
    H -->|꺼짐| U[생존 확인 없이 그대로 사용]
    H -->|켜짐| C{커넥션이 살아있나}
    C -->|예| Q[쿼리 실행]
    C -->|아니오| N
    N --> Q
    U --> Q
    U -.죽어 있으면.-> F[OperationalError 500]

CONN_MAX_AGE = 0인 경로(왼쪽)는 항상 새 커넥션으로 흘러 함정을 만나지 않는다. CONN_MAX_AGE > 0이면서 health check가 꺼진 경로만 죽은 커넥션 → 500으로 빠진다. CONN_HEALTH_CHECKS를 켜면 그 지점에서 "살아있나?"를 물어 죽었으면 새 커넥션으로 우회한다 — 함정으로 가는 유일한 길을 막는 셈이다.

결론: 언제 켜야 하는가

CONN_MAX_AGE DB 재시작/커넥션 끊김 후 CONN_HEALTH_CHECKS
0 (요청마다 새 커넥션, 기본값) 다음 요청이 새 커넥션 → 자동 복구 불필요 (개입할 여지 없음)
> 0 (persistent connection) 죽은 커넥션을 재사용 → 실패/고착 사실상 필수

규칙은 단순하다: 성능을 위해 CONN_MAX_AGE를 올리는 순간, CONN_HEALTH_CHECKS = True도 함께 켜라. 하나만 켜는 건 "재사용은 하는데 재사용해도 되는지는 안 보는" 상태 — 함정을 열어두고 방비만 뺀 셈이다.

부록: 진단할 때 헷갈리지 않으려면

이번 사례에서 실제로 걸려 넘어진 지점.

  1. 로그의 에러는 시점을 확인하기 전까진 현재 상태가 아니다. "마지막 에러 시각"과 "최근 N분 에러 빈도"부터 봐라. 과거의 traceback을 현재 장애로 읽으면 없는 병에 처방하게 된다.
  2. pod의 RESTARTS 0은 양면적이다. "일시 장애 후 정상 가동"일 수도, "고장난 채 떠 있음"일 수도 있다. 커넥션 상태를 함께 봐야 갈린다.
  3. 옵션은 "필요한 조건"과 함께 외워라. CONN_HEALTH_CHECKS를 "항상 켜는 것"으로 외우면 CONN_MAX_AGE = 0인 곳에 불필요하게 손대고, "몰라도 되는 것"으로 외우면 persistent connection 도입 때 함정에 빠진다. "CONN_MAX_AGE > 0의 필수 짝" — 이게 정확한 앵커다.

참고

  • Django docs — Databases: CONN_MAX_AGE, CONN_HEALTH_CHECKS (4.1+), Persistent connections
  • django.db.close_old_connections — 백그라운드 스레드/스케줄러에서의 커넥션 정리 (요청 사이클 밖에서 같은 문제를 다루는 짝)