MySQL 성능 최적화를 위한 실용적인 전략

문제 상황 진단

최근 서비스에서 데이터베이스 응답 지연과 CPU 사용률 급증 문제가 발생했다. 모니터링 시스템에서 MySQL 서버의 CPU 사용률이 지속적으로 100%에 도달하며 알림이 빈번하게 발생했고, 원인 분석 결과 특정 인터페이스 요청량 증가와 비효율적인 쿼리 실행이 주요 원인으로 확인되었다.

Nginx 로그를 기반으로 한 요청 분석 결과, 핵심 API 엔드포인트는 하루 수백만 건 이상의 호출을 기록하고 있었다. 단일 저사양 DB 인스턴스로 이러한 부하를 감당하기에는 무리가 있었으나, 인프라 확장 없이도 해결 가능한 내부 최적화 여지가 충분히 존재했다.

구조적 개선 방향

성능 병목은 대개 잘못된 스키마 설계와 비효율적인 쿼리에서 비롯된다. 아래 항목들을 중심으로 점검 및 수정을 진행했다.

NULL 값 사용 억제

모든 컬럼은 가능하면 NOT NULL 제약을 적용하는 것이 바람직하다. 이유는 다음과 같다:

  • NULL 허용 시 인덱스 효율성이 저하되고, 통계 계산 오류 가능성 증가
  • IS NULL 또는 NOT IN 조건 사용 시 예기치 않은 결과 반환
  • 추가 메타 정보 저장을 위해 1바이트 더 필요
  • 비교 연산 시 타입 불일치 문제 유발 가능

따라서 문자열은 빈값(''), 숫자형은 0 등을 기본값으로 설정하여 NULL을 배제한다.

인덱스 전략 수립

자주 조회되는 필드에는 반드시 인덱스를 부여해야 한다. 특히 다음 사항을 고려한다:

  • 기본 키 외에도 자주 WHERE 절에 사용되는 칼럼 인덱싱
  • VARCHAR 타입 인덱스 생성 시 길이 제한 설정 (예: INDEX idx_name (name(64)))
  • 복합 인덱스 구성 시 카디널리티(cardinality)가 높은 컬럼을 앞쪽에 배치
  • LIKE '%keyword' 형태의 접두사 없는 패턴 매칭은 인덱스 미활용 → 풀테이블 스캔 유발
  • 외래키 제약은 애플리케이션 레이어에서 관리 권장. DB 제약은 DML 성능 저하 요인

데이터 타입 최적화

정수형 vs 문자열 비교 연산 성능 차이는 크다. 다음 기준을 적용:

  • 상태 코드 등 소수의 고정 값은 TINYINT 활용 (예: 0=Android, 1=iOS)
  • ENUM은 확장성 부족 및 언어별 처리 이슈 존재 → 정수 맵핑 선호
  • 고정 길이 문자열 (예: 우편번호 5자리) → CHAR 사용
  • ID 칼럼에 BIGINT 남용 금지. INT로 충분한 경우 (최대 약 21억) INT 사용
  • 자주 조인되는 테이블 간 중복 컬럼 삽입으로 조인 회피 (정규화 완화)

대용량 테이블 관리

레코드 수가 수백만 건을 넘는 테이블은 성능 저하가 가속화된다. 초기 설계 단계에서 분할 전략을 고민해야 한다.

  • 수직 분할: 접근 패턴이 다른 컬럼 그룹을 별도 테이블로 분리 (예: 자주 변경되는 필드 vs 읽기 전용 필드)
  • 수평 분할: 해시 또는 범위 기반으로 동일 스키마의 여러 테이블로 나누기

분할 시 가장 중요한 것은 라우팅 키를 명확히 정의하여, 쿼리 실행 전 대상 파티션 결정이 가능해야 한다는 점이다.

쿼리 작성 가이드라인

잘못된 SQL 문법은 심각한 성능 문제를 유발한다. 다음 원칙을 준수한다.

  • SELECT * 금지 → 필요한 컬럼만 명시적 지정
  • OR 조건은 인덱스 미사용 가능성 높음 → UNION ALL로 리팩터링 고려
  • IS NULL 조건은 인덱스 효율 떨어짐 → 디폴트 값 활용 방식 전환
  • 비인덱스 컬럼에서 <>, != 사용 시 풀스캔 발생
  • 함수 감싼 컬럼 조회 (예: WHERE YEAR(created)=2023) → 인덱스 무효화
  • 결과 집합이 소수일 경우 LIMIT 1 추가
  • ORDER BY RAND()는 전체 스캔 후 정렬 → 대체 알고리즘 필요
  • EXPLAIN 명령어로 실행 계획 분석 필수

캐싱 전략 도입

데이터베이스 접근 빈도를 줄이기 위해 캐시 계층을 추가한다.

  • Redis 또는 Memcached를 활용한 분산 캐시
  • 자주 참조되는 설정 데이터, 코드 테이블 등 정적 정보 우선 캐싱
  • 로컬 JVM 힙 캐시 (Caffeine 등)으로 네트워크 왕복 최소화
  • 유효기간(TTL) 설정 및 변경 시 즉시 무효화(Invalidate) 로직 구현

단, 캐시 일관성 유지 비용과 시스템 복잡도 증가는 반드시 고려되어야 한다.

실제 쿼리 리팩터링 사례

아래 쿼리는 다수의 문제점을 포함하고 있다:

SELECT * 
FROM user_action_log 
WHERE user_id = '15298635' 
  AND game_id = '10389' 
  AND app_id = '200'
  AND action_type = 'open' 
  AND source = 'android_sdk' 
  AND metadata = '{"name":"uusama","age":20}'

문제점:

  • 전체 컬럼 조회(SELECT *)
  • 여러 VARCHAR 필드 조건 → 인덱스 설계 난감
  • 복잡한 JSON 값 비교 → 정확도 낮고 인덱스 불가
  • 결과 제한 없음

해결책:

  1. 핵심 조건(user_id)에 인덱스 생성
  2. 간소화된 쿼리로 기준 레코드 추출
  3. 응용 로직에서 나머지 조건 필터링
SELECT id, user_id, metadata
FROM user_action_log 
WHERE user_id = '15298635' 
LIMIT 100

애플리케이션에서 결과를 반복 처리하여 나머지 조건을 평가하면, DB 부하를 크게 줄일 수 있다.

성능 계층 이해

시스템 자원 접근 속도는 다음과 같은 순서로 격차가 크다:

CPU > 메모리 > 디스크 I/O > 네트워크

즉, 디스크나 네트워크 접근보다는 메모리 상에서 연산을 수행하는 것이 훨씬 효율적이다. 따라서 캐싱과 인덱스를 적극 활용하여 디스크 접근을 최소화해야 한다.

태그: MySQL 데이터베이스 최적화 인덱스 설계 쿼리 튜닝 Redis 캐싱

8월 12일 09:12에 게시됨