카테고리 : MySQL/기술노트

max_connections와 thread_cache_size 설계: 장애를 부르는 과대/과소 설정

MySQL에서 max_connections와 thread_cache_size를 어떻게 함께 설계해야 연결 폭주, 메모리 고갈, thread 생성 비용을 줄일 수 있는지 운영 관점에서 정리한다.

저자: MySQL 기술 노트 작성: 2026.06.20 약 12분 6,707자
다운로드

1. 왜 max_connectionsthread_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에 남았다가 다음 연결에서 재사용될 수도 있다.

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은 현재 서버가 연결 관련해서 어떤 상태에 있는지 빠르게 확인하는 기본 점검 쿼리다.

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):

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_connectionsthread_cache_size가 얼마인가
  • 실제 최대 사용 연결 수가 상한 근처까지 올라간 적이 있는가
  • thread cache에 재사용 가능한 thread가 남아 있는가
  • Threads_created가 계속 누적되는 구조인가
  • Connection_errors_max_connections가 실제로 발생했는가

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

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

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

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):

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 상태를 간단히 확인하는 예시다.

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):

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_connectionsthread_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_createdMax_used_connections는 누적·고점 지표다. 한 번 캡처해서 해석하는 것보다, 재기동 시점과 증분 추이를 고려해야 한다.

11. 운영 체크리스트

  • max_connections
  • Max_used_connections
  • Connection_errors_max_connections
  • Threads_created / Connections
  • 짧은 연결 churn의 원인이 애플리케이션 설계인지, thread_cache_size

12. 결론

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

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