MySQL 1226 max_connections_per_hour: 공유 호스팅 대처법

Python cron은 MySQL 1226(시간당 500 연결)으로 실패했는데 localhost로 붙는 PHP API는 시간당 약 1,500 연결에도 멀쩡했다. 한도는 user@host 단위. 같은 서버 작업은 localhost로 연결하고 재시도는 좁게.

공유 호스팅에서 앱, cron, PHP 페이지가 MySQL 사용자 하나를 같이 쓰다가 (1226, "User '<db_user>' has exceeded the 'max_connections_per_hour' resource")를 만난 분을 위한 글입니다. 결론부터 말하면 이 한도는 계정 단위, 즉 '사용자@호스트' 단위로 세기 때문에, 이름이 같은 사용자라도 localhost로 들어오느냐 원격 호스트 이름으로 들어오느냐에 따라 다른 한도를 쓸 수 있습니다. 실제로 localhost로 붙는 앱 API는 시간당 약 1,500 연결에도 한 건도 실패하지 않았고, 원격 호스트 이름으로 붙던 cron만 500의 벽에 부딪혔습니다. 핵심 대책은 DB와 같은 서버에서 도는 작업을 localhost로 연결하는 것이고, 좁은 재시도와 연결 로그가 이를 받칩니다.

업데이트 (2026-11-10 저녁): 처음 올린 글에서는 "이 사용자를 쓰는 모든 쪽이 시간당 500 연결짜리 한도 하나를 나눠 쓴다"고 쓰고 앱 사용량 피크를 의심했습니다. 그런데 같은 날 저녁 연결 로그에 앱 API만으로 시간당 약 1,500 연결이 찍혔고, 요청은 그대로 성공하고 있었습니다. 한도가 하나뿐이라면 설명이 안 되는 숫자입니다. 진단이 원격 연결 경로 쪽으로 바뀌어 아래 내용을 고쳤고, 처음 글의 잘못은 해당 부분에서 바로잡았습니다.

아직 모르는 부분도 있고, 그런 곳은 모른다고 적습니다.

무엇이 실패했나

환경은 Hostinger 웹 호스팅(LiteSpeed)이고 서버 시간은 UTC입니다. 다른 웹사이트의 갱신 작업이 Python 3.11 + PyMySQL로 되어 있고, DB와 같은 서버의 cron으로 하루 두 번 UTC 12:00과 21:00에 돕니다. MySQL에서 복권 추첨 결과를 읽어 페이지를 다시 만드는 작업입니다.

2026년 11월 9일 UTC 12:00 회차(일본 시간 21:00, 추첨 직후의 월요일 밤)가 첫 단계인 DB 연결에서 실패했습니다.

pymysql.err.OperationalError: (1226, "User '<db_user>' has exceeded the
'max_connections_per_hour' resource (current value: 500)")

cron 로그에서 확인한 것:

  • 약 한 달 치, 하루 두 번씩의 실행 기록이 있었고 실패는 이번 한 번뿐이었습니다.
  • 다음 회차인 UTC 21:00은 아무것도 바꾸지 않았는데 성공했습니다.

작업 자체가 고장 난 게 아닙니다. UTC 12:00 직전 한 시간 동안 무언가가 500짜리 한도를 다 써 버렸고, 마침 cron이 벽에 부딪혀 로그를 남겼습니다. 물어야 할 건 "어느 한도를, 누가 같이 쓰고 있었나"였습니다.

같은 사용자 이름, 두 갈래 길

같은 MySQL 사용자 이름을 쓰는 건 이 cron만이 아닙니다. 처음 글에서 놓친 건 각각이 어디로 DB에 들어오느냐였습니다.

사용하는 쪽 빈도 연결 경로
모바일 앱 8개의 PHP JSON API 요청마다 연결 1개 localhost
WebView 시절의 오래된 PHP 페이지 구버전 앱이 아직 염 localhost
광고 설정 엔드포인트 앱 16개가 실행할 때마다 호출 같은 서버, 아마도 localhost
푸시 알림 cron 5분마다 아직 확인 안 함
다른 사이트의 갱신 cron (실패한 것) UTC 12:00과 21:00 DB 서버의 원격 호스트 이름 (아래 수정 전까지)
외부 크롤러 PC 원격 MySQL로 결과를 씀 원격 호스트 이름
데이터베이스 하나로 들어가는 두 경로를 그린 구성도. 위 경로는 'localhost, 시간당 약 1,500, 정상'으로, 앱 PHP API와 오래된 WebView 페이지, 같은 서버에서 도는(아마 localhost로 붙는) 광고 설정이 지나간다. 아래 경로는 '원격 호스트 이름, 시간당 500, 1226'으로, 수정 전의 Python cron과, 몫이 확인되지 않아 점선으로 그린 외부 크롤러 PC가 지나간다. cron에서 localhost 경로로 '수정 후'라고 적힌 점선 화살표가 있다. 두 경로 모두 같은 데이터베이스를 가리킨다.
같은 DB, 같은 사용자 이름이라도 들어가는 길은 둘이다. localhost 경유는 시간당 약 1,500 연결에도 오류가 없었고, 원격 호스트 이름으로 붙은 cron은 시간당 500 한도(1226)에 걸렸다. 크롤러의 몫은 확인되지 않았다.

cron은 같은 기계에서 도는 Python 스크립트인데, 설정에는 외부에서 접속할 때 쓰는 원격 호스트 이름이 들어 있었습니다. 흔히 생기는 일입니다. 원격 호스트 이름은 내 노트북에서도 서버에서도 붙으니, 로컬에서 만들어 서버로 복사한 스크립트에는 그대로 남습니다. 처음부터 서버용으로 짠 PHP는 그냥 localhost라고 적혀 있습니다.

진단을 바꾼 숫자

아래 대책 2에서 설명하는 연결 로거는 2026년 11월 10일 일본 시간 16:17부터 돌았습니다. 세는 대상은 localhost로 붙는 앱 JSON API뿐입니다.

시간대 (일본 시간) 기록된 연결 500과 비교
16시대 (16:17부터) 559 넘음
17시대 592 넘음
18시대 491 살짝 아래
19시대 약 1,557 약 3배
20시대 약 1,503 약 3배

모두가 500짜리 한도 하나를 나눠 쓴다면 저녁 두 시간은 1226 투성이였어야 합니다. 그렇지 않았습니다. 20:52에 API 엔드포인트 4개를 골라 호출해 보니 전부 200에 JSON 본문도 온전했습니다. localhost 쪽은 '한도'의 세 배로 멀쩡히 돌고 있었던 겁니다.

남는 건 실패한 게 확실한 연결 하나, 원격 호스트 이름으로 붙은 cron의 연결이고, 메시지에는 current value: 500이 찍혀 있었습니다.

아는 것과 모르는 것

MySQL이 하는 일. MySQL은 계정을 '사용자'@'호스트'로 구분하고, MAX_CONNECTIONS_PER_HOUR 같은 리소스 한도는 계정에 붙습니다. 사용자 이름이 같아도 localhost 로그인과 외부에서 들어온 TCP 로그인이 서로 다른 계정 행에 맞춰질 수 있습니다.

관찰한 것. localhost 경유 연결은 시간당 약 1,500에서도 성공했습니다. 원격 경로 연결은 한도 500에서 1226이 났습니다. 호스트 쪽 계정 설정은 볼 수 없으니, 각 계정이 어떻게 설정됐는지는 주장하지 않습니다. 경로마다 이렇게 동작했다는 것만 말할 수 있습니다.

가장 유력한 설명 (확인되지 않음): 원격 경로 계정('<db_user>'@'%')에 시간당 500 한도가 걸려 있고, 월요일 밤 그걸 다 쓴 건 추첨 전후에 원격 MySQL로 결과를 쓰는 외부 크롤러와 cron 자신이라는 것. 크롤러의 연결 기록이 없어서 그 한 시간에 수백 번 붙었는지는 보여 줄 수 없습니다. 알려진 큰 원격 클라이언트는 그것뿐이고 추첨 직후라는 시점도 맞지만, 어디까지나 추론입니다.

처음 글의 정정. 처음 글은 앱 사용량 피크를 의심했습니다. 위 숫자는 그 반대를 가리킵니다. 앱은 localhost로 붙고, localhost는 500을 한참 넘겨도 오류를 내지 않았습니다. 11월 9일 PHP 오류 로그에 1226이 한 건도 없었던 것도 이제는 수상한 점이 아니라 앞뒤가 맞는 점입니다. 다만 주의할 것 하나는 남습니다. 오래된 연결 함수는

if(!($Conn = mysqli_connect(HOST, USER, PASS))) { die; }

이고, PHP 8.1 이후 실패 시 동작은 mysqli 리포트 모드에 달려 있어서 조용히 실패하면 오류 로그에 남지 않습니다. 오늘 밤 점검으로 localhost 쪽 가능성은 낮아졌지만, 11월 9일 그 시간에 앱을 직접 확인한 건 아닙니다.

배제한 것은 처음 글과 같이 그날 우리가 한 작업입니다. 회귀 테스트는 오후에 돌렸고(20~21시 아님), API 이전으로 요청당 연결 수도 바뀌지 않았습니다.

사용자는 못 나눠도 localhost는 쓸 수 있다

한도가 계정 단위라면 교과서적인 해법은 쓰는 쪽마다 MySQL 사용자를 따로 주는 것입니다. 하지만 Hostinger 웹·클라우드 호스팅의 지금 플랜에서는 DB 하나에 사용자 하나이고, phpMyAdmin에도 CREATE USER나 GRANT 권한이 없어 SQL로 우회할 수도 없습니다. 이 부분은 처음 글 그대로입니다.

놓쳤던 건 새 사용자 없이도 되는 더 싼 방법입니다. 작업이 DB와 같은 서버에서 돈다면 원격 호스트 이름 대신 localhost로 연결한다. 그러면 크롤러 같은 외부 클라이언트와 원격 쪽 한도를 나눠 쓰지 않게 됩니다. 밖에서 도는 크롤러는 이 방법을 쓸 수 없지만, cron은 쓸 수 있습니다.

그보다 먼저 넣은 두 가지 변경, 실패한 작업이 한도 소진을 버티게 하는 재시도와 연결이 어디로 가는지 보는 로그는 그대로 둡니다.

대책 1: cron의 연결만 재시도

2026년 11월 10일에 넣었습니다. 규칙은 이렇습니다.

  • 재시도하는 건 DB 연결뿐, 그것도 cron 진입점에서만.
  • 대상은 MySQL 오류 1226(시간당 한도)과 1040(동시 연결 초과)뿐.
  • 900초 기다리기를 최대 3번. 길어도 45분.
  • 인증 오류(1045)나 호스트에 닿지 않는 오류 같은 나머지는 바로 실패.
  • 같은 헬퍼를 쓰는 수동 도구는 retry=False로 바로 실패시킵니다. 사람이 명령을 치고 있을 때는 말없이 45분 기다리는 것보다 당장 오류가 나는 편이 낫습니다.

실제 코드를 조금 줄인 것입니다.

RETRY_MYSQL_CODES = (1226, 1040)

def lotto_conn(retry=False, waits=(900, 900, 900)):
    import time
    import pymysql
    for attempt in range(len(waits) + 1 if retry else 1):
        try:
            return _lotto_connect(pymysql)
        except pymysql.err.OperationalError as e:
            code = e.args[0] if e.args else None
            if not retry or code not in RETRY_MYSQL_CODES or attempt >= len(waits):
                raise
            print("  DB connection limit (%s) - retry in %ds (%d/%d)"
                  % (code, waits[attempt], attempt + 1, len(waits)), flush=True)
            time.sleep(waits[attempt])
흐름도: 작업이 MySQL에 연결한다. 오류가 없으면 작업을 계속한다. 오류면 코드를 확인해 1226이나 1040이면 15분 기다린 뒤(최대 3번) 다시 연결한다. 다른 코드이거나 세 번의 대기를 다 쓰면 바로 실패한다.
cron의 연결은 1226과 1040일 때만 15분 대기를 최대 3번 재시도한다. 다른 오류는 바로 실패한다.

몇 초가 아니라 15분을 기다리는 이유는 한도가 시간 단위이기 때문입니다. 5초 뒤에 다시 해 봐야 거의 확실히 아직 꽉 차 있습니다. 15분씩 세 번이면 시도가 한 시간의 대부분에 걸쳐 흩어집니다.

기다리게 하면 새 걱정이 생깁니다. 자는 동안 다음 회차가 시작돼 겹칠 수 있습니다. 이건 작업을 감싸는 셸 스크립트가 막습니다. mkdir로 잠금을 잡고, 시작하기 전에 마지막 실행 시각을 기록하며, 앞 실행과 6시간 이상 떨어져 있지 않으면 돌지 않습니다. 늦어진 12:00 회차는 약 45분 뒤에 마지막 연결을 시도하고, 기다리는 동안 두 번째 복사본이 시작될 일은 없습니다.

가짜 오류를 끼워 넣어 4가지 경우를 시험했습니다.

경우 기대 결과
1226 두 번 뒤 성공 세 번째에 연결 연결됨
1226 네 번 포기하고 1226 발생 1226 발생
1045 (인증 오류) 기다리지 않고 즉시 오류 즉시 오류
retry=False에서 1226 즉시 오류 즉시 오류

다만 운영 환경에서 진짜 1226이 났을 때 제대로 동작한다는 건 아직 증명되지 않았습니다. 넣은 뒤로 1226이 나지 않았으니까요. 그때까지는 "테스트한 로직"이지 "검증된 동작"은 아니라고 봅니다.

대책 2: 응답은 그대로 두고 연결 수 세기

2026년 11월 10일에 시작했습니다. 접근 로그를 볼 수 없으니, 공통 PHP 연결 함수에서 연결 한 번마다 한 줄을 웹 루트 밖의 날짜별 파일에 쓰게 했습니다.

function conn_log() {
    $dir = dirname(__FILE__) . '/logs';           // outside the web root
    if (!is_dir($dir)) { @mkdir($dir, 0700); }
    $uri = isset($_SERVER['REQUEST_URI']) ? strtok($_SERVER['REQUEST_URI'], '?') : '-';
    $act = isset($_GET['action']) ? preg_replace('/[^A-Za-z0-9_]/', '', (string)$_GET['action']) : '';
    $line = gmdate('Y-m-d H:i:s') . "\t" . substr((string)$uri, 0, 120) . "\t" . substr($act, 0, 40) . "\n";
    @file_put_contents($dir . '/conn-' . gmdate('Ymd') . '.log', $line, FILE_APPEND | LOCK_EX);
}

설계하면서 정한 것:

  • UTC 시각, 쿼리 문자열을 뺀 경로, action 파라미터만 남깁니다. 검색어 같은 다른 파라미터는 일부러 남기지 않습니다. 사용자가 입력한 내용 그대로라 저장하고 싶지 않고, 파일만 쓸데없이 커집니다. 엔드포인트별·시간대별로 세는 데는 경로와 action이면 충분합니다.
  • 오류는 @로 삼킵니다. 디스크가 꽉 차도, 폴더를 못 만들어도 API는 전과 똑같이 응답합니다. 응답 본문은 바뀌지 않습니다.
  • 빼는 건 한 줄입니다. 연결 함수 안의 호출만 지우면 됩니다.

중요한 한계가 있습니다. 광고 설정 엔드포인트와 WebView 페이지는 공통 함수가 아니라 자기 연결 코드를 쓰기 때문에 세지 않습니다. 원격 경로로 들어오는 연결은 이 PHP 함수를 거치지 않으니 cron이나 외부 클라이언트 몫은 아예 보이지 않습니다. 이 로그의 숫자는 localhost 경로의 하한값일 뿐, 사용자 전체의 합계가 아닙니다.

대책 3: localhost 먼저, 안 되면 원격 호스트 이름

2026년 11월 10일 일본 시간 21:29에 넣었습니다. cron은 먼저 로컬 경로를 시도합니다. Unix 소켓이 있으면 그걸, 다음으로 127.0.0.1을, 각각 타임아웃 5초로. 로컬이 안 될 때만 타임아웃 20초로 원격 호스트 이름에 붙습니다. 어느 경로를 썼는지(local 또는 remote)는 로그에 남기지만 주소는 남기지 않습니다. 실제 코드를 단순하게 줄인 것입니다.

def connect():
    tries = [("local (socket)", {"unix_socket": s}) for s in SOCKETS if os.path.exists(s)]
    tries.append(("local (127.0.0.1)", {"host": "127.0.0.1", "port": 3306}))
    tries.append(("remote", {"host": REMOTE_HOST, "port": 3306}))
    for i, (label, where) in enumerate(tries):
        last = i == len(tries) - 1
        try:
            conn = pymysql.connect(**where, user=USER, password=PASSWORD, database=DB,
                                   connect_timeout=20 if last else 5)
            print("DB path:", label)          # the label only, never the address
            return conn
        except pymysql.err.OperationalError:
            if last:
                raise

SOCKETS는 흔한 소켓 위치를 나열한 짧은 목록이고, 실제로 있는 것만 시도합니다. 대책 1의 1226/1040 재시도는 이 함수 전체를 그대로 감싸고 있어서, 마지막에 쓴 경로에서 한도가 차 있어도 긴 대기는 예전처럼 동작합니다.

localhost로 못 박지 않고 원격을 남겨 둔 건 같은 헬퍼가 개발용 PC에서도 돌기 때문입니다. 거기엔 MySQL이 없습니다. 이 경우는 직접 시험했습니다. PC에서는 로컬 시도가 오류 2003(연결 불가)으로 바로 실패하고 원격으로 붙습니다. 로컬 타임아웃을 짧게 잡아 두어서 이 우회는 시간을 거의 잡아먹지 않습니다.

경로 이름을 로그에 남기는 건 cron 로그만 보고도 "서버에서 정말 local로 갔는지" 확인하기 위해서입니다. 결과 (2026년 11월 11일 추가): 서버 첫 실행(2026년 11월 11일 일본 시간 6:00)은 local(소켓) 경로 라벨을 남기고 정상 종료했습니다. 이 서버에는 로컬 MySQL 유닉스 소켓이 있고, 같은 사용자 이름으로 비밀번호나 권한을 바꾸지 않고도 로그인된다는 뜻입니다. 이제 cron은 원격 쪽 시간당 한도를 크롤러와 나눠 쓰지 않습니다.

테스트의 300 연결은 다른 길이었다

로거를 넣은 직후, 바뀐 게 없는지 확인하려고 회귀 테스트를 한 번 돌렸습니다. 엔드포인트 약 60개 API 스냅샷 비교, 새 스냅샷 저장, 간단한 URL 점검이었습니다.

이것만으로 약 2분 동안 약 300 연결, 16시대 한 시간 전체로는 559 연결이었습니다. 처음 글에서는 여기서 "테스트 한 번이 500 한도의 절반 넘게 먹는다. 저녁에 테스트했다면 우리가 원인이었을 것"이라고 결론 내렸습니다.

그 결론은 틀렸습니다. 테스트가 부르는 건 API이고, API는 localhost로 붙습니다. 300 연결은 실제로 일어났지만, 실패한 한도에는 들어가지 않았습니다. 저녁의 localhost 쪽은 그 세 배를 소화하고 있었습니다. 저녁 피크(일본 시간 20~22시)에는 전체 회귀 테스트를 돌리지 않는다, 같은 한 시간에 두 번 돌리지 않는다는 규칙은 남기지만, 이유는 공유 서버 부하에 대한 일반적인 조심이지 1226의 원인이어서가 아닙니다.

숫자 자체는 여전히 쓸모가 있습니다. 전체 회귀 테스트 한 번은 약 2분에 약 300 연결. 누군가 이걸 반복문으로 돌리기 전에 알아 둘 만한 크기입니다.

같은 테스트에서 하나 더. 하루가 지나자 스냅샷 비교가 응답 약 30개를 "다름"으로 표시했습니다. 오늘의 운세, 새 추첨 결과처럼 날짜에 따라 바뀌는 내용입니다. 이걸 해시로 판단하지 않았습니다. 상태 코드, Content-Type, JSON 구조(키와 값의 타입)를 비교했고, 표시된 응답 모두 세 가지가 같았습니다. 해시는 "뭔가 바뀌었다"만 알려 줍니다. 구조를 봐야 "약속이 바뀌었는지"를 알 수 있습니다.

어떤 대책을 언제 쓰나

대책 맞는 상황 주의할 점
같은 서버의 작업은 localhost로 연결 DB와 같은 서버의 cron·워커가 지금 원격 호스트 이름으로 붙고 있을 때 가장 쌈. 원격은 대체 경로로 남기고 쓴 경로를 로그로. 서버 결과는 아직
배치에서 긴 대기 재시도 늦게 돌아도 괜찮은 cron이나 워커 1226/1040에만 한정. 늦어진 회차가 겹치지 않게 잠금 필요
DB 사용자 나누기 VPS, 또는 DB마다 사용자를 여러 개 만들 수 있는 관리 패널 한도가 계정 단위라 정석. 지금 플랜에선 불가
읽기 전용 GET 캐시 하루 몇 번만 바뀌는 데이터를 주는 무거운 엔드포인트 부하는 줄지만, 우리 구성에선 1226이 난 원격 쪽 한도와 무관
지속 연결(persistent connection) 공유 PHP 호스팅에서는 드묾 쓸 수 없거나 권장되지 않는 경우가 많음. 기본 선택지로 두지 않기
호스트에 한도 문의 한 경로의 실측 피크가 한도에 가까울 때 호스트와 플랜에 따라 다름. 어느 계정·경로의 한도인지 물을 것

다음 단계

2026년 11월 11일 일본 시간 6:00 실행은 위에 적은 대로 local(소켓) 경로를 탔습니다. 남은 건 시간을 두고 보는 일입니다. 첫 실패가 났던 월요일 밤 추첨 전후를 포함해 이후에도 1226이 나지 않는지 봅니다. 한 번의 성공은 좋은 신호일 뿐 증명은 아닙니다.

무거운 읽기 전용 GET 엔드포인트 캐시는 계획에 남아 있지만, 이 엔드포인트들은 localhost 경로라서 목적은 1226이 아니라 서버 부하 줄이기로 바뀌었습니다. cron의 경로, 다음 일주일의 시간대별 숫자, 크롤러 몫을 보여 줄 단서가 생기면 이 글에 덧붙이겠습니다.

자주 묻는 질문

MySQL 1226 'max_connections_per_hour' 오류는 무슨 뜻인가요?

해당 MySQL 계정의 '시간당 연결 횟수' 한도를 다 썼다는 뜻입니다. MySQL은 리소스 한도를 계정 단위로 세고, 계정은 '사용자 이름 + 호스트'로 구분됩니다. '<db_user>'@'localhost'와 '<db_user>'@'%'는 서로 다른 계정입니다. 저희 경우에는 원격 호스트 이름으로 붙은 연결이 한도 500에 걸렸고, localhost 경유 연결은 시간당 약 1,500에도 오류가 없었습니다.

PHP API는 잘 되는데 cron만 1226이 나는 이유는 뭔가요?

연결 경로가 달라서일 가능성이 가장 큽니다. PHP API는 호스트 localhost로 붙고, 실패한 cron은 DB 서버의 원격 호스트 이름, 즉 외부 크롤러와 같은 경로로 붙고 있었습니다. 한도에 걸린 건 원격 경로뿐입니다. 다만 크롤러의 연결 기록이 없어서 확정이 아니라 가장 유력한 설명입니다.

1226으로 연결이 실패하면 재시도해야 하나요?

배치 작업이라면 1226과 1040에 한해 재시도할 만합니다. 한도가 시간 단위라 몇 초 뒤 재시도는 의미가 없어서 900초 대기를 최대 3번으로 했습니다. 인증이나 호스트 오류는 바로 실패시키고, 사용자에게 응답하는 요청 처리에서는 몇 분씩 기다리지 않습니다.

Hostinger 웹 호스팅에서 MySQL 사용자를 나눠 한도를 분산할 수 있나요?

저희 플랜에서는 안 됩니다(DB 하나에 사용자 하나, phpMyAdmin에 CREATE USER·GRANT 권한 없음). 대신 작업이 DB와 같은 서버에서 돈다면 원격 호스트 이름 대신 localhost로 연결하는 싼 방법이 있습니다. 그러면 외부 클라이언트와 원격 쪽 한도를 나눠 쓰지 않습니다.

  • #MySQL
  • #공유호스팅
  • #PHP
  • #Python
  • #cron
  • #레이트리밋
  • #Hostinger

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑