---
title: "max_connections와 thread_cache_size 설계: 장애를 부르는 과대/과소 설정"
description: "MySQL에서 max_connections와 thread_cache_size를 어떻게 함께 설계해야 연결 폭주, 메모리 고갈, thread 생성 비용을 줄일 수 있는지 운영 관점에서 정리한다."
tags: [ MySQL, 아키텍처, 운영, 성능최적화, DBA ]
image: "mysql-report-bg.png"
published: "2026-06-20"
updated: "2026-06-20"
author: "MySQL 기술 노트"
source_url: ""
---

## 1. 왜 `max_connections`와 `thread_cache_size`를 함께 봐야 하는가

운영 현장에서 `Too many connections` 오류가 발생하면 많은 팀이 먼저 `max_connections`를 올리는 대응을 떠올린다. 반대로 CPU 사용률이 높고 연결 생성이 잦아 보이면 `thread_cache_size`를 크게 잡으면 해결될 것처럼 생각하기도 한다. 그러나 이 두 변수는 독립적인 성능 옵션이 아니라, **MySQL이 동시 접속을 얼마나 받아들일 것인지**와 **끊임없이 생성·종료되는 세션 스레드를 얼마나 재사용할 것인지**를 함께 결정하는 핵심 축이다.

잘못 설계된 `max_connections`는 두 가지 방향으로 장애를 만든다.

- **과소 설정**이면 일시적인 접속 피크를 흡수하지 못하고 애플리케이션이 즉시 `Too many connections`를 만나게 된다.
- **과대 설정**이면 정상 시에는 조용하지만, 피크 시점에 메모리 사용량과 내부 경합이 커지면서 MySQL이 느려지거나 OOM 위험이 올라간다.

`thread_cache_size` 역시 비슷하다.

- **너무 작으면** 짧은 연결이 반복될 때 OS thread 생성/정리 비용이 누적되어 `Threads_created`가 빠르게 증가한다.
- **너무 크면** thread 재사용 자체가 문제를 일으키지는 않더라도, 과도한 연결 허용 정책을 가리는 연막이 될 수 있다. 즉, 애플리케이션이 비정상적으로 많은 연결을 만들고 있다는 근본 원인을 숨긴다.

핵심은 다음과 같다.

> `max_connections`는 수용 한도(capacity ceiling)이고, `thread_cache_size`는 연결 churn 완화 장치다. 둘 중 하나만 조정해서는 접속 폭주 문제를 안정적으로 해결할 수 없다.

특히 MySQL 8.0 이상에서는 단순히 수치만 크게 잡는 접근보다, connection pool 구조, foreground thread 모델, 메모리 예산, Aurora/RDS의 파라미터 특성, failover 시 재접속 폭주를 함께 고려하는 설계가 중요하다.

## 2. 내부 메커니즘: 연결 수와 스레드 캐시는 어떻게 맞물리는가

MySQL Community Server의 전통적인 구조에서는 클라이언트 연결이 들어오면 세션 컨텍스트(`THD`)가 준비되고, 이를 처리할 foreground thread가 배정된다. 연결이 종료되면 해당 thread는 바로 사라질 수도 있고, `thread_cache_size` 한도 내에서 cache에 남았다가 다음 연결에서 재사용될 수도 있다.

```mermaid
flowchart TD
    A[클라이언트 연결 요청] --> B[accept / handshake / authentication]
    B --> C{접속 가능?
max_connections 미만인가}
    C -- 아니오 --> D[Too many connections 또는 연결 오류]
    C -- 예 --> E[세션 THD 생성]
    E --> F{재사용 가능한 cached thread 존재?}
    F -- 예 --> G[기존 thread 재사용]
    F -- 아니오 --> H[새 OS thread 생성]
    G --> I[쿼리 실행]
    H --> I
    I --> J[세션 종료]
    J --> K{cache 여유가 있는가}
    K -- 예 --> L[thread cache에 반환]
    K -- 아니오 --> M[thread 종료]
```

이 구조를 운영 관점에서 해석하면 다음과 같다.

1. `max_connections`는 **동시에 살아 있을 수 있는 세션 수의 상한**이다.
2. `thread_cache_size`는 **세션이 종료된 뒤 thread를 재활용할지 결정하는 버퍼**다.
3. 둘 사이에는 직접적인 1:1 비례 규칙이 없다. `max_connections=1000`이라고 해서 `thread_cache_size=1000`이 필요한 것은 아니다.
4. 그러나 짧은 연결이 매우 많고 connection pool 재사용이 약한 환경에서는 `thread_cache_size`가 너무 작으면 thread 생성 비용이 눈에 띄게 누적된다.

중요한 점은 `max_connections`가 높을수록 최악의 경우 동시에 더 많은 세션 메모리와 작업 컨텍스트를 허용한다는 것이다. 이때 개별 세션이 정렬, 조인, 임시 테이블, 네트워크 버퍼, binlog 캐시 등 추가 메모리를 사용할 수 있으므로, **동시 연결 수 상한은 곧 잠재적 메모리 상한의 일부**가 된다. 따라서 `max_connections`는 “최대 고객 수용량”이면서 동시에 “장애 시 서버가 감당해야 할 최악의 메모리 리스크”이기도 하다.

## 3. `max_connections`를 크게 잡는 것이 왜 위험한가

`max_connections`를 과도하게 키우는 실수는 흔하다. 애플리케이션 팀이 불안해할 때 “일단 1000으로 올려 두자”는 식의 대응은 단기적으로는 경보를 끌 수 있지만, 장기적으로는 더 큰 장애를 부른다.

### 3.1 메모리 예산을 흐리게 만든다

MySQL은 전역 메모리만 사용하는 서버가 아니다. InnoDB buffer pool 같은 큰 전역 메모리 외에도, 세션별로 증가하는 메모리 영역이 있다. 모든 연결이 항상 최대 메모리를 쓰는 것은 아니지만, 동시 세션 수가 많아질수록 다음 리스크가 커진다.

- 대량 정렬/해시/조인 시 세션당 작업 메모리 급증
- 많은 idle connection이 남아 있어도 내부 메타데이터와 네트워크 버퍼 유지
- 장애 시 connection storm가 곧 메모리 압박과 CPU 문맥 전환 증가로 이어짐
- thread 수 증가에 따른 스케줄링 부담과 lock/metadata contention 악화

즉, `Too many connections`를 막기 위해 상한을 무한정 올리면, 접속 거부 대신 **서버 전체가 느려지거나 죽는 방식으로 장애 형태만 바뀔 수 있다.**

### 3.2 애플리케이션의 잘못된 connection pool 정책을 숨긴다

정상적인 애플리케이션은 매 요청마다 새 TCP/MySQL 연결을 만들기보다, 제한된 pool을 재사용한다. 그런데 `max_connections`를 계속 올리면 다음과 같은 구조적 문제를 가리게 된다.

- 각 애플리케이션 인스턴스가 필요 이상으로 큰 pool을 가짐
- worker 수와 pool 수가 곱해져 총 연결 수가 폭증함
- 배치 작업, API 서버, 백그라운드 worker가 서로 독립적으로 상한을 높게 잡음
- 장애 복구 후 모든 인스턴스가 동시에 재접속을 시도함

이 경우 DB에서 보이는 현상은 “연결이 많다”이지만, 실제 원인은 DB가 아니라 애플리케이션 배치 구조와 pool 정책일 수 있다.

### 3.3 Aurora MySQL/RDS에서는 failover 시 더 민감하다

Aurora MySQL이나 RDS MySQL에서는 writer failover, 인스턴스 재기동, 네트워크 단절 후 재접속 폭주가 더 민감하게 나타난다. 평소에는 버티던 `max_connections` 설정이 failover 직후 문제를 드러내는 이유는 다음과 같다.

- 애플리케이션이 동시에 pool refill을 시도함
- DNS 갱신, endpoint 전환, TLS 재수립, 인증 재시도가 짧은 시간에 몰림
- DB가 아직 warm-up 중인데 접속 수요가 한꺼번에 들어옴

따라서 Aurora 환경에서는 단순히 `max_connections` 수치보다, **애플리케이션의 재시도 backoff, pool warm-up 속도, RDS Proxy 사용 여부**가 더 중요해지는 경우가 많다.

## 4. `thread_cache_size`의 역할과 오해

`thread_cache_size`는 성능 튜닝 항목이지만, 본질적으로는 **짧은 연결 churn 비용을 낮추는 완충 장치**다. 연결이 종료될 때 thread를 즉시 파기하지 않고 cache에 보관하면, 다음 연결에서 새 OS thread를 만드는 비용을 줄일 수 있다.

다만 다음 오해를 피해야 한다.

### 4.1 thread cache는 연결 수를 줄여 주지 않는다

`thread_cache_size`를 늘려도 `Threads_connected`가 줄어들지는 않는다. 캐시는 활성 연결 수를 줄이는 기능이 아니라, **끊겼다가 다시 생기는 연결의 생성 비용을 완화하는 기능**이다.

### 4.2 큰 cache가 항상 좋은 것은 아니다

짧은 연결이 적고 pool 재사용이 잘 되는 시스템이라면, thread 생성은 초기에만 조금 발생하고 곧 안정된다. 이때 `thread_cache_size`를 크게 잡아도 얻는 이득은 크지 않다. 오히려 팀이 `Threads_created`를 제대로 읽지 않고 감으로 수치를 키우는 나쁜 습관을 만들 수 있다.

### 4.3 근본 원인은 종종 애플리케이션 쪽이다

`Threads_created`가 빠르게 늘어난다고 해서 반드시 `thread_cache_size`가 작다는 뜻은 아니다. 애플리케이션이 요청마다 새 연결을 열고 닫는 구조라면 cache를 늘려도 churn 자체는 남는다. 이 경우 진짜 해결책은 pool 재사용, 연결 수명 연장, burst 제어다.

## 5. 먼저 확인할 진단 지표

다음 SQL은 현재 서버가 연결 관련해서 어떤 상태에 있는지 빠르게 확인하는 기본 점검 쿼리다.

```sql
SELECT VERSION() AS mysql_version;

SHOW VARIABLES
WHERE Variable_name IN (
  'max_connections',
  'thread_cache_size'
);

SHOW GLOBAL STATUS
WHERE Variable_name IN (
  'Connections',
  'Max_used_connections',
  'Threads_cached',
  'Threads_connected',
  'Threads_created',
  'Threads_running',
  'Connection_errors_max_connections',
  'Aborted_connects'
);
```

실행 결과(MySQL 8.0.46):

```text
mysql> SELECT VERSION() AS mysql_version;

+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
1 row in set (0.00 sec)

mysql> SHOW VARIABLES
    -> WHERE Variable_name IN (
    ->   'max_connections',
    ->   'thread_cache_size'
    -> );

+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| max_connections   | 30    |
| thread_cache_size | 8     |
+-------------------+-------+
2 rows in set (0.00 sec)

mysql> SHOW GLOBAL STATUS
    -> WHERE Variable_name IN (
    ->   'Connections',
    ->   'Max_used_connections',
    ->   'Threads_cached',
    ->   'Threads_connected',
    ->   'Threads_created',
    ->   'Threads_running',
    ->   'Connection_errors_max_connections',
    ->   'Aborted_connects'
    -> );

+-----------------------------------+-------+
| Variable_name                     | Value |
+-----------------------------------+-------+
| Aborted_connects                  | 0     |
| Connection_errors_max_connections | 0     |
| Connections                       | 11    |
| Max_used_connections              | 1     |
| Threads_cached                    | 0     |
| Threads_connected                 | 1     |
| Threads_created                   | 1     |
| Threads_running                   | 2     |
+-----------------------------------+-------+
8 rows in set (0.01 sec)
```

이 결과는 최소한 다음 질문에 답하게 해 준다.

- 현재 `max_connections`와 `thread_cache_size`가 얼마인가
- 실제 최대 사용 연결 수가 상한 근처까지 올라간 적이 있는가
- thread cache에 재사용 가능한 thread가 남아 있는가
- `Threads_created`가 계속 누적되는 구조인가
- `Connection_errors_max_connections`가 실제로 발생했는가

## 6. `Threads_created`를 어떻게 읽어야 하는가

운영자가 자주 오해하는 지표가 `Threads_created`다. 이 값은 MySQL 시작 이후 새로 생성한 thread 수의 누적치다. 중요한 것은 절대값 자체보다 **`Connections` 증가 대비 `Threads_created` 증가 속도**다.

다음과 같이 ratio를 계산해 보면 해석이 쉬워진다.

```sql
SELECT
  MAX(CASE WHEN Variable_name = 'Connections' THEN CAST(Variable_value AS UNSIGNED) END) AS connections_total,
  MAX(CASE WHEN Variable_name = 'Threads_created' THEN CAST(Variable_value AS UNSIGNED) END) AS threads_created_total,
  ROUND(
    MAX(CASE WHEN Variable_name = 'Threads_created' THEN CAST(Variable_value AS UNSIGNED) END)
    / NULLIF(MAX(CASE WHEN Variable_name = 'Connections' THEN CAST(Variable_value AS UNSIGNED) END), 0),
    4
  ) AS threads_created_per_connection
FROM performance_schema.global_status
WHERE Variable_name IN ('Connections', 'Threads_created');
```

실행 결과(MySQL 8.0.46):

```text
mysql> SELECT
    ->   MAX(CASE WHEN Variable_name = 'Connections' THEN CAST(Variable_value AS UNSIGNED) END) AS connections_total,
    ->   MAX(CASE WHEN Variable_name = 'Threads_created' THEN CAST(Variable_value AS UNSIGNED) END) AS threads_created_total,
    ->   ROUND(
    ->     MAX(CASE WHEN Variable_name = 'Threads_created' THEN CAST(Variable_value AS UNSIGNED) END)
    ->     / NULLIF(MAX(CASE WHEN Variable_name = 'Connections' THEN CAST(Variable_value AS UNSIGNED) END), 0),
    ->     4
    ->   ) AS threads_created_per_connection
    -> FROM performance_schema.global_status
    -> WHERE Variable_name IN ('Connections', 'Threads_created');

+-------------------+-----------------------+--------------------------------+
| connections_total | threads_created_total | threads_created_per_connection |
+-------------------+-----------------------+--------------------------------+
|                12 |                     1 |                         0.0833 |
+-------------------+-----------------------+--------------------------------+
1 row in set (0.00 sec)
```

이 ratio는 “연결 하나당 thread를 얼마나 자주 새로 만들었는가”를 대략 보여 준다.

- 값이 **매우 낮고 안정적**이면 thread cache가 충분하거나, 애초에 연결 재사용이 잘 되고 있을 가능성이 높다.
- 값이 **지속적으로 높게 유지**되면 새 연결이 자주 생기고 cache 재사용이 잘 일어나지 않는다는 뜻일 수 있다.
- 단, 서버 재기동 직후에는 누적치가 작아서 해석이 왜곡될 수 있다.

따라서 이 수치는 단발성보다 시계열로 보는 편이 낫다. Prometheus, CloudWatch, PMM 같은 도구가 있다면 `Connections` 증분과 `Threads_created` 증분을 함께 보는 것이 좋다.

## 7. foreground thread와 실제 활성 세션 관찰

다음 쿼리는 현재 foreground thread와 processlist 상태를 간단히 확인하는 예시다.

```sql
SELECT THREAD_ID,
       PROCESSLIST_ID,
       PROCESSLIST_USER,
       PROCESSLIST_HOST,
       PROCESSLIST_COMMAND,
       PROCESSLIST_STATE
FROM performance_schema.threads
WHERE TYPE = 'FOREGROUND'
  AND PROCESSLIST_ID IS NOT NULL
ORDER BY THREAD_ID
LIMIT 10;
```

실행 결과(MySQL 8.0.46):

```text
mysql> SELECT THREAD_ID,
    ->        PROCESSLIST_ID,
    ->        PROCESSLIST_USER,
    ->        PROCESSLIST_HOST,
    ->        PROCESSLIST_COMMAND,
    ->        PROCESSLIST_STATE
    -> FROM performance_schema.threads
    -> WHERE TYPE = 'FOREGROUND'
    ->   AND PROCESSLIST_ID IS NOT NULL
    -> ORDER BY THREAD_ID
    -> LIMIT 10;

+-----------+----------------+------------------+------------------+---------------------+------------------------+
| THREAD_ID | PROCESSLIST_ID | PROCESSLIST_USER | PROCESSLIST_HOST | PROCESSLIST_COMMAND | PROCESSLIST_STATE      |
+-----------+----------------+------------------+------------------+---------------------+------------------------+
|        41 |              5 | event_scheduler  | localhost        | Daemon              | Waiting on empty queue |
|        43 |              7 | NULL             | NULL             | Daemon              | Suspending             |
|        51 |             13 | root             | localhost        | Query               | executing              |
+-----------+----------------+------------------+------------------+---------------------+------------------------+
3 rows in set (0.00 sec)
```

이 쿼리는 다음 상황에서 유용하다.

- 연결 수는 많지만 대부분 `Sleep`인지 확인하고 싶을 때
- 실제로 일하는 thread(`Threads_running`)보다 idle session이 훨씬 많은지 볼 때
- 특정 애플리케이션 호스트가 비정상적으로 많은 세션을 잡고 있는지 점검할 때

실제 운영에서는 이 결과와 `SHOW PROCESSLIST`, 애플리케이션별 pool 설정, 프록시 계층(HikariCP, ProxySQL, RDS Proxy 등)의 연결 모델을 함께 봐야 한다.

## 8. 설계 원칙: 수치를 정하기 전에 먼저 풀어야 할 질문

`max_connections`와 `thread_cache_size`는 고정 공식으로 정하는 값이 아니다. 다음 질문을 먼저 풀어야 한다.

### 8.1 총 연결 예산은 어디에서 오는가

애플리케이션 인스턴스 수, 각 인스턴스의 worker 수, 각 worker의 pool 상한, 배치/관리 도구/ETL 연결, 복제/모니터링 연결을 모두 합산해야 한다. 실무에서는 DBA가 `max_connections=500`으로 잡아 두었지만, 실제로는 애플리케이션 여러 종류의 pool 상한을 모두 더하면 1200이 되는 경우가 많다.

따라서 먼저 다음 표를 채우는 것이 좋다.

- API 서버: 인스턴스 수 × pool 상한
- 백그라운드 worker: 인스턴스 수 × pool 상한
- 배치/ETL: 동시 실행 수 × 연결 수
- 운영/모니터링: DBA 세션, exporter, schema migration 도구
- 복구 버퍼: failover 직후 일시 재접속 여유

이 총합을 기반으로 `max_connections`를 정하되, **DB가 실제로 감당 가능한 메모리/CPU 범위 안에서만** 허용해야 한다.

### 8.2 활성 쿼리 수와 idle 연결 수를 구분했는가

`Threads_connected`가 높다고 해서 모두 문제는 아니다. 수백 개의 idle session은 보기에는 불안하지만, 실제 병목은 20개의 무거운 쿼리일 수 있다. 반대로 idle 연결이 너무 많으면 메모리와 관리 비용을 낭비하므로, pool size 축소 대상일 수 있다.

중요한 것은 다음 구분이다.

- **활성 실행 압력**: `Threads_running`, CPU, row lock wait, I/O wait
- **접속 수용 압력**: `Threads_connected`, `Max_used_connections`, `Connection_errors_max_connections`
- **연결 churn 압력**: `Connections` 증가 속도, `Threads_created` 증가 속도, 인증/handshake 지연

### 8.3 장애 시나리오를 포함했는가

정상 트래픽만 기준으로 잡으면 failover, deploy, connection storm 때 무너진다. 특히 다음 이벤트를 포함해 생각해야 한다.

- 애플리케이션 전체 롤링 재시작
- Aurora/RDS failover 후 pool 동시 재연결
- 배치 시작 시각의 연결 급증
- 잘못된 readiness/liveness 설정으로 인한 인스턴스 재생성 폭주

## 9. 실무적인 튜닝 방향

### 9.1 `max_connections`

- 먼저 애플리케이션 pool 총합과 실제 `Max_used_connections` 추이를 확인한다.
- 피크 시 최대 사용량에 약간의 운영 여유를 더하되, 막연한 공포 때문에 몇 배로 부풀리지 않는다.
- 높은 값이 필요하다면 DB 서버 메모리 예산, 세션당 작업 메모리, 장시간 idle 연결 비중을 함께 재검토한다.
- `Connection_errors_max_connections`가 0인데도 상한을 계속 올리는 습관은 피한다.
- admin/운영 긴급 접속 여지를 고려해 애플리케이션 총합을 상한과 동일하게 꽉 채우지 않는다.

### 9.2 `thread_cache_size`

- `Threads_created`가 연결 증가에 비해 빠르게 늘어나는지 먼저 확인한다.
- 짧은 연결 churn이 많다면 cache를 키워 재생성 비용을 줄인다.
- 이미 `Threads_created` 증가가 완만하고 안정적이라면 무리하게 크게 잡을 필요는 없다.
- cache 조정보다 먼저 애플리케이션의 connection pool 재사용이 정상인지 점검한다.

### 9.3 Aurora MySQL/RDS 추가 고려사항

- 파라미터 변경 방식이 parameter group 기반인지, 즉시 반영인지 재시작 필요인지 운영 절차를 확인한다.
- failover 후 재접속 폭주를 줄이기 위해 exponential backoff와 jitter를 애플리케이션에 적용한다.
- 연결 수가 많고 짧은 세션이 반복된다면 RDS Proxy 같은 중간 계층이 실제 도움이 되는지 검토한다.
- reader endpoint, writer endpoint 전환 시 pool이 어떤 방식으로 연결을 다시 잡는지 테스트한다.

## 10. 흔한 오해와 장애 패턴

### 10.1 `Too many connections`가 곧 DB 성능 부족이라는 오해

이 오류는 종종 애플리케이션 설계 문제다. DB CPU가 낮아도 pool 상한 합계가 `max_connections`를 넘으면 즉시 발생할 수 있다.

### 10.2 `thread_cache_size`를 키우면 접속 폭주가 해결된다는 오해

thread cache는 연결 churn 비용을 줄일 뿐, 과도한 동시 연결 자체를 해결하지 않는다. 상한 관리와 pool 제한이 먼저다.

### 10.3 idle connection이 많으면 무조건 괜찮다는 오해

idle session은 실행 부하는 적을 수 있지만, 메모리와 연결 슬롯을 점유한다. 특히 failover 직후 pool refill과 합쳐지면 큰 문제를 만든다.

### 10.4 관측 지표를 단발성으로만 읽는 실수

`Threads_created`나 `Max_used_connections`는 누적·고점 지표다. 한 번 캡처해서 해석하는 것보다, 재기동 시점과 증분 추이를 고려해야 한다.

## 11. 운영 체크리스트

- [ ] 애플리케이션별 connection pool 상한 총합을 문서화했는가
- [ ] `max_connections`가 그 총합과 운영자 긴급 접속 여유를 함께 반영하는가
- [ ] `Max_used_connections`가 실제로 상한에 얼마나 근접하는지 시계열로 확인했는가
- [ ] `Connection_errors_max_connections`가 최근 발생했는가
- [ ] `Threads_created / Connections` 비율이 비정상적으로 높지 않은가
- [ ] 짧은 연결 churn의 원인이 애플리케이션 설계인지, `thread_cache_size` 부족인지 구분했는가
- [ ] failover 또는 deploy 직후 재접속 폭주 시나리오를 테스트했는가
- [ ] Aurora/RDS라면 parameter group 반영 방식과 RDS Proxy 필요성을 검토했는가
- [ ] idle session이 과도하게 많다면 pool 축소나 connection lifetime 조정을 검토했는가

## 12. 결론

`max_connections`와 `thread_cache_size`는 단순한 “크게 잡으면 안전한 값”이 아니다. 전자는 MySQL이 받아들일 연결의 상한이자 잠재적 메모리 리스크의 상한이며, 후자는 빈번한 연결 생성 비용을 줄이는 보조 장치다. 따라서 운영자는 두 변수를 따로 보지 말고, **애플리케이션 pool 구조, 실제 최대 사용 연결 수, thread 생성 누적 속도, 장애 시 재접속 패턴**을 한 묶음으로 읽어야 한다.

다음 기술노트에서는 이 주제를 이어서, connection pool과 MySQL 서버 상한이 서로 충돌할 때 어떤 병목과 장애 패턴이 발생하는지 더 구체적으로 다룰 수 있다.
