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(
kongDB)에만 자기 라우트·플러그인 설정을 저장한다. - 앱 서비스들은 완전히 다른 호스트의 공유 DB에 데이터를 넣는다.
다른 호스트, 다른 인스턴스. 게이트웨이 DB의 부하가 앱↔DB 커넥션을 직접 막을 경로는 없다. 위 그림에서 Kong ⇢ Kong 전용 DB 선과 앱 → 공유 DB 선이 서로 닿지 않는 것이 핵심이다. "같은 시간대에 겹쳐 보인다"가 곧 인과는 아니다 — 상관과 인과를 구분하려면 경로가 실제로 연결돼 있는지를 그림에서 확인해야 한다.
그래서 얻는 결론 두 가지
- 층이 많은 구조일수록 "어느 층인가"를 먼저 좁혀라. 이번엔 앞단(DNS·게이트웨이·메시)을 하나씩 배제하고 나서야 맨 끝의
앱 ↔ DB커넥션이 남았다. - 홉이 많다는 건 커넥션을 끊을 수 있는 주체도 많다는 뜻이다. 그래서 복잡한 인프라일수록 각 커넥션의 자가 복원력(재사용 전 생존 확인 → 재연결)이 더 중요해진다.
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_CHECKS는 persistent 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도 함께 켜라. 하나만 켜는 건 "재사용은 하는데 재사용해도 되는지는 안 보는" 상태 — 함정을 열어두고 방비만 뺀 셈이다.
부록: 진단할 때 헷갈리지 않으려면
이번 사례에서 실제로 걸려 넘어진 지점.
- 로그의 에러는 시점을 확인하기 전까진 현재 상태가 아니다. "마지막 에러 시각"과 "최근 N분 에러 빈도"부터 봐라. 과거의 traceback을 현재 장애로 읽으면 없는 병에 처방하게 된다.
- pod의
RESTARTS 0은 양면적이다. "일시 장애 후 정상 가동"일 수도, "고장난 채 떠 있음"일 수도 있다. 커넥션 상태를 함께 봐야 갈린다. - 옵션은 "필요한 조건"과 함께 외워라.
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— 백그라운드 스레드/스케줄러에서의 커넥션 정리 (요청 사이클 밖에서 같은 문제를 다루는 짝)
