MySQL 커넥션 풀링 캐싱 실패: 프로덕션을 마비시킨 0.001% 버그의 내부
핵심 요약
분석합니다: MySQL 커넥션 풀링 캐싱이 조용히 무너지는 0.001% 버그의 내부와 인코딩 드리프트·프로토콜 불일치의 탐지·해결 방법, 게임 백엔드 풀링·캐싱 5가지 실전 모범 사례를 Cloudflare Hyperdrive 사례를 통해 자세히 제시합니다.
백엔드가 항상 데이터를 반환하는 쿼리에 대해 빈 결과 집합을 반환합니다. 오류율은 0.001% — 대략 10만 건의 요청 중 1건입니다. 텔레메트리에는 아무것도 표시되지 않습니다. 트레이스에도 아무것도 없습니다. 그리고 유일한 단서는 캐싱을 비활성화하면 절대 발생하지 않는다는 것입니다.
이것은 Cloudflare의 Hyperdrive 팀이 커넥션 풀러의 MySQL 지원을 일반 공급(GA)으로 전환하면서 직면한 상황과 정확히 일치합니다. 그들이 발견한 버그는 두 계층이 와이어를 통해 이동하는 바이트 형식에 대해 서로 다른 가정을 할 때 MySQL 커넥션 풀링 캐싱이 어떻게 조용히 무너질 수 있는지를 보여주는 교과서적인 사례입니다. 게임 백엔드를 위해 풀링되고 캐시된 데이터베이스 계층을 운영한다면 — 그리고 운영해야 합니다 — 이 실패 모드는 여러분에게도 적용됩니다.
이 글에서는 버그의 원인, 이를 가능하게 한 프로토콜 세부 사항, 탐지 및 해결 방법, 그리고 여러분의 게임 백엔드 아키텍처에 미치는 영향을 분석합니다.
커넥션 풀링이 존재하는 이유
데이터베이스에 TCP + TLS + MySQL 핸드셰이크를 설정하는 데는 최소 5번의 네트워크 왕복이 필요합니다. 게임 서버가 요청마다 새 커넥션을 연다면 모든 리더보드 조회, 모든 세이브 게임 불러오기, 모든 인벤토리 조회에서 이 비용을 지불하게 됩니다.
풀링은 웜 커넥션을 유지하고 대여해 줌으로써 이 문제를 해결합니다. MySQL은 Postgres보다 이 방식에 더 친화적입니다 — Postgres는 커넥션마다 OS 프로세스 전체를 생성하는 반면, MySQL 커넥션은 비교적 저렴합니다 — 하지만 수천 명의 동시 접속 플레이어가 있는 인기 게임은 여전히 커넥션 한도를 빠르게 소진할 수 있습니다.
하지만 풀링은 그 자체의 문제를 도입합니다: 세션 상태 누수(session state leakage). 플레이어 A의 쿼리가 세션 변수를 설정하고 플레이어 B가 같은 커넥션을 받으면 정확성 버그가 발생합니다. 풀러는 체크아웃 사이에 커넥션 정리를 통해 이를 해결합니다. MySQL은 정확히 이 용도로 가벼운 명령을 제공합니다: COM_RESET_CONNECTION.
그리고 여기에 이 이야기의 첫 번째 미묘한 버그가 있습니다.
버그 #1: 커넥션 리셋 후 인코딩 드리프트
Hyperdrive 팀은 시작 핸드셰이크에서 utf8을 협상했습니다. 플레이어들이 채팅과 캐릭터 이름에 이모지를 사용하기 시작하고 데이터가 깨져서 반환되기 전까지는 잘 작동했습니다.
문제는 다음과 같습니다: MySQL에서 utf8은 utf8mb3의 더 이상 사용되지 않는 별칭이며, 유니코드 코드 포인트당 1~3바이트만 지원합니다. 전체 UTF-8(이모지, CJK 확장, 그리고 글로벌 플레이어 기반이 사용할 대부분의 비라틴 문자 포함)은 코드 포인트당 최대 4바이트가 필요합니다. 해결책은 utf8mb4를 협상하는 것입니다.
하지만 거의 모든 사람을 잡는 함정이 여기 있습니다:
-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.
COM_RESET_CONNECTION은 시작 시 협상한 문자 집합으로 커넥션을 복원하지 않습니다. 서버의 기본 문자 집합으로 리셋됩니다 — 많은 MySQL 인스턴스에서 여전히 latin1입니다. 첫 번째 리셋 이후 모든 풀링된 커넥션은 조용히 1990년대식 인코딩으로 저하됩니다.
해결 방법: 시작 시에만이 아니라 모든 리셋 후에 세션 구성을 다시 적용하세요:
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
경험 법칙: 모든 리셋 후 커넥션을 완전히 구성되지 않은 상태로 취급하고, 중요한 모든 것을 다시 설정하세요. 연결 시 한 번 설정한 것은 결국 사라집니다.
버그 #2: 0.001% 빈 응답 버그
이것은 찾는 데 며칠이 걸린 버그입니다. 대용량 고객이 비어 있지 않은 응답이 보장되는 쿼리에서 작지만 일관된 빈 응답 비율을 보고했습니다. 오류는 약 0.001%의 확률로 발생했습니다 — 엔드투엔드 트레이스를 확실하게 캡처하기에는 너무 드물고, 무시하기에는 너무 빈번했습니다.
팀은 텔레메트리를 샅샅이 뒤지고, 트레이스 비율을 높이고, 네트워크 이상을 검색했습니다. 아무것도 없었습니다. 문제를 해결한 단 하나의 단서: 버그는 캐싱이 활성화된 인스턴스에서만 발생했습니다.
이를 가능하게 한 아키텍처
Hyperdrive는 분할 토폴로지를 사용합니다:
- 클라이언트 노드 — Worker 근처에서 실행되며 로컬 캐싱을 처리하고 쿼리를 전달합니다.
- 엔드포인트 노드 — 원본 데이터베이스에 가장 가까운 데이터 센터에서 실행되며 실제 커넥션 풀을 보유합니다.
두 노드 모두 해당 데이터 센터의 로컬 CDN 캐시를 사용합니다. 같은 데이터 센터에서 실행되면 하나의 캐시를 공유합니다. 이 공유는 성능상 이점입니다 — 하지만 숨겨진 전제 조건이 있습니다: 두 노드는 동일한 쿼리 응답에 대해 정확히 동일한 바이트를 캐시해야 합니다.
프로토콜 불일치
MySQL의 클라이언트/서버 프로토콜에는 클라이언트와 서버가 *캐퍼빌리티(capabilities)*를 협상하는 커넥션 단계가 있습니다 — CLIENT_DEPRECATE_EOF와 같은 선택적 기능으로, 결과 집합이 종료되는 방식을 변경합니다:
CLIENT_DEPRECATE_EOF없음: 컬럼 정의 후 EOF 패킷, 그 다음 결과 집합 끝에 또 다른 EOF.- 있음: 메타데이터 EOF는 생략되고 최종 EOF는 OK 패킷이 됩니다.
Hyperdrive는 이 캐퍼빌리티를 지원하며 원본 MySQL 서버와 협상합니다. 하지만 다른 쪽의 클라이언트 라이브러리 — 예를 들어 현재 CLIENT_DEPRECATE_EOF를 지원하지 않는 Node.js용 mysql2 — 는 지원하지 않을 수 있습니다. 따라서 Hyperdrive는 변환해야 합니다: 결과 집합을 다시 작성하여 EOF 패킷을 삽입하고 OK 패킷을 EOF로 다시 변환하여 Worker의 드라이버가 기대하는 바이트와 일치시킵니다.
결과: Hyperdrive가 클라이언트에 다시 쓰는 바이트는 원본 서버에서 받은 바이트와 다른 형식입니다.
잘못된 가정
Postgres용 클라이언트 캐싱을 구현할 때 팀은 다음과 같은 가정을 내장했습니다: 클라이언트에 다시 쓴 바이트를 그대로 캐시할 수 있다. Postgres의 와이어 프로토콜에서는 사실이었습니다. MySQL에서는 그렇지 않습니다.
이제 실패를 조립해 봅시다:
- 엔드포인트가 쿼리 Q를 처리하고 응답 바이트를 Hyperdrive 캐퍼빌리티 형식으로 공유 캐시에 저장합니다.
- 클라이언트(같은 데이터 센터)가 Q에 대한 요청을 받고 캐시된 항목을 찾아 캐퍼빌리티 변환 없이 Worker에 제공합니다 — 바이트가 이미 Worker를 위해 작성된 것처럼 보이기 때문입니다.
CLIENT_DEPRECATE_EOF를 협상한 적이 없는 Worker의 드라이버가 OK로 종료된 결과 집합을 잘못 파싱하여 빈 결과, 멈춤(hang), 또는 프로토콜 디싱크(desync)를 발생시킵니다.
이 버그는 클라이언트와 엔드포인트가 같은 위치에 있고 한쪽이 다른 쪽의 캐시 항목을 읽을 때만 발생합니다. 그래서 0.001%였고 일반적인 트레이싱으로는 보이지 않았습니다. 이는 공유 상태에 대해 서로 다른 가정을 가진 두 구성 요소의 와이어 프로토콜 버전 디싱크 버그입니다. 클라이언트 예측과 서버 권위 사이의 상태 분기를 디버깅해 본 적이 있다면 이 실패의 형태가 익숙하게 느껴질 것입니다; 상태를 깨뜨리는 Unreal Engine RPC 복제 문제 수정에서 설명된 것과 같은 클래스의 문제이며, 단지 데이터베이스 와이어 계층에서 발생한 것입니다.
개념적 해결책: 표준(canonical) 형식(원본이 보낸 것)으로 캐시하고, 읽을 때 요청하는 클라이언트의 캐퍼빌리티로 변환합니다. 변환이 클라이언트별 협상에 의존하는 경우 변환 후 바이트를 캐시하지 마세요.
런북: 자체 스택에서 캐시 손상 감지하기
MySQL(또는 상태 저장 와이어 프로토콜을 가진 모든 것) 앞에서 풀러나 캐시 계층을 운영한다면, 이 포스트모템을 적용하는 방법은 다음과 같습니다.
탐지
- 오류를 토폴로지와 상관시키세요. 버그는 클라이언트와 엔드포인트가 데이터 센터와 캐시를 공유하는 인스턴스에서만 나타났습니다. 각 응답을 제공한 노드와 캐시에서 왔는지 여부를 로깅하세요. 오류율이 캐시 히트와 상관관계가 있다면 작성자와 읽는 사람 사이의 형식 불일치를 의심하세요.
- 스파이크가 아닌 드물지만 일관된 오류를 추적하세요. 0.001% 오류율은 절대 알림을 트리거하지 않습니다. 오류 추적에서 볼륨과 관계없이 동일한 시그니처의 반복 발생에 대한 쿼리를 설정하세요. 낮은 비율의 일관성은 트래픽 상관 스파이크보다 논리 버그의 더 강력한 신호입니다.
- 캐퍼빌리티 비대칭으로 의도적으로 테스트하세요.
CLIENT_DEPRECATE_EOF가 있는 테스트 클라이언트와 없는 클라이언트를 연결하고, 캐싱을 활성화한 후 동일한 쿼리를 반복 실행하세요. 대부분의 프로토콜 변환 버그는 이 비대칭 상태에서 몇 분 안에 드러납니다.
해결
- 캐시 항목을 표준화하세요. 원본의 원시 응답 형식을 저장하고 읽을 때 변환하세요. 이는 클라이언트가 이기종 캐퍼빌리티를 가진 모든 풀러에 대한 올바른 수정입니다.
- 리셋 후 세션 상태를 다시 설정하세요. 위에서 본 것처럼
COM_RESET_CONNECTION은 협상된 설정이 아닌 서버 기본값을 복원합니다. - 캐시 키에 버전을 지정하세요. 프로토콜 동작(캐퍼빌리티, 인코딩, 응답 프레이밍)을 변경하는 경우 캐시 키에 형식 버전을 포함하여 배포 중에 새 코드가 이전 항목을 제공하지 못하게 하세요.
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
# format_version bumps whenever wire-format handling changes,
# preventing stale-format entries from poisoning new deploys
return sha256(f"{schema_version}:{format_version}:{query_normalized}")
예방
- 하나가 아닌 두 개의 클라이언트 라이브러리로 와이어 프로토콜 적합성 테스트를 작성하세요.
CLIENT_DEPRECATE_EOF격차는 인기 있는 Node.js 드라이버가 이를 지원하지 않았기 때문에만 드러났습니다. - 계층 간 "동일한 바이트"에 대해 아무것도 가정하지 마세요. 메시지를 다시 작성하는 모든 구성 요소는 출력이 입력과 바이트 단위로 동일하다고 가정해서는 안 됩니다.
- 첫날부터 캐시를 활성화한 상태로 부하 테스트를 하세요. Hyperdrive 팀은 캐시 특정 버그가 드러나기 전까지 수개월 동안 안정성을 검증했습니다 — 풀링 × 캐싱 × 캐퍼빌리티 변환의 교차점에 희귀 버그가 살고 있기 때문입니다.
게임 백엔드의 풀링 및 캐싱을 위한 5가지 모범 사례
- 항상
utf8mb4를 협상하고 절대utf8을 사용하지 마세요. 플레이어 이름, 채팅, 세이브 데이터에는 4바이트 문자가 포함됩니다. 그런 다음 모든COM_RESET_CONNECTION후에SET NAMES utf8mb4를 다시 설정하세요. 리셋은 핸드셰이크가 아닌 서버 기본값을 복원하기 때문입니다. - 클라이언트 형식 데이터가 아닌 표준 데이터를 캐시하세요. 어떤 계층이 응답을 보내기 전에 변환한다면, 변환된 바이트는 절대 공유 캐시에 들어가서는 안 됩니다. 원본 형식을 저장하고 읽는 사람별로 변환하세요.
- 캐시 키에 형식 버전을 추가하세요. 한 줄 변경으로 롤링 배포 중 오래된 프로토콜 항목으로부터 여러분을 구해줍니다.
- *오류 비율뿐만 아니라 오류 일관성에 대해 알림을 설정하세요.* 트래픽 상관관계가 없는 0.001% 반복 시그니처는 논리 버그입니다. 트래픽 상관 스파이크는 용량 문제입니다. 다르게 처리하세요.
- 캐퍼빌리티가 일치하지 않는 클라이언트로 테스트하세요. 풀러의 정확성은 팀(또는 SDK 사용자)이 연결할 수 있는 모든 클라이언트 라이브러리를 처리하는 데 달려 있습니다.
백엔드 아키텍처 교훈
여기서 가장 깊은 교훈은 MySQL에 관한 것이 전혀 아닙니다. 피어별 협상이 있는 계층형 시스템에 관한 것입니다. 양쪽에서 프로토콜을 말하는 미들박스 — 풀러, 프록시, 캐시 — 를 삽입하는 순간, 충실하게 변환하고 그리고 보편적으로 유효한 형식만 캐시해야 하는 의무를 물려받습니다. Postgres는 작동했고 MySQL은 깨졌습니다. 캐싱 로직 자체는 다른 프로토콜에서 물려받은 하나의 가정을 제외하고는 잘못된 것이 없었습니다.
게임 스튜디오에게 이것은 자체 풀러-플러스-캐시 계층을 직접 만드는 것에 대한 강력한 반대 논거입니다. 해피 패스를 작동시키는 엔지니어링 노력은 아마 일주일 정도일 것입니다. 희귀 경로를 올바르게 만드는 엔지니어링 노력 — 캐퍼빌리티 협상, 인코딩 드리프트, 캐시 공동 배치 — 은 전담 인프라 팀이 단일 0.001% 버그를 추적하는 데 며칠의 조사가 필요했습니다. Cloudflare 자신의 인정을 반복할 가치가 있습니다: 베타와 GA의 차이는 "트렌치코트를 입은 20개의 버그 수정"에 불과했습니다.
게임 백엔드가 풀러, 캐시 무효화 체계, 프로토콜 변환 계층을 직접 유지하지 않으면서 안정적인 영속성이 필요하다면, 바로 여기서 Backend-as-a-Service가 가치를 발휘합니다. horizOn은 리비전 인식 읽기/쓰기가 가능한 계정 바인딩 Cloud Save를 처리합니다 — 클라이언트가 충돌을 해결하고, 플레이어 데이터에 대한 풀링 커넥션 + 캐시 정확성 지뢰밭 전체를 건너뜁니다. 리더보드 및 원격 구성과 함께 사용하면 이 글에서 논의된 쿼리 지연 시간 문제는 대부분 더 이상 여러분의 문제가 아닙니다.
다음 단계: 오늘 자체 풀러 구성을 점검하세요. 세 가지를 확인하세요 — 협상된 문자 집합, 커넥션 리셋 후 세션 상태가 다시 설정되는지 여부, 캐시가 표준 바이트와 변환된 바이트 중 무엇을 저장하는지. 10분 안에 세 가지 모두에 답할 수 없다면 할 일이 있는 것입니다 — 그리고 아예 하고 싶지 않다면 horizOn을 살펴보고 Cloud Save와 리더보드가 현재 데이터베이스 계층이 수동으로 하는 작업을 커버하는지 확인하세요.