데이터 마이그레이션 정합성 검증(Data Validation), 스토리지 용량 산정, 혹은 레거시 DB 정리를 진행할 때 수십 개의 스키마(Database)에 흩어져 있는 수백~수천 개 테이블의 전체 건수(COUNT(*))를 한 번에 조회해야 하는 상황이 자주 발생합니다.

 

이때 가장 흔히 작성하는 방식이 메타데이터를 조회해 SELECT '테이블명', COUNT(*) FROM 스키마.테이블 UNION ALL ... 형태의 거대한 쿼리를 만들어 실행하는 것입니다. 하지만 이 쿼리를 실무 운영 DB에서 실행해 보면 응답이 수십 분 이상 걸릴 뿐만 아니라, 순간적으로 디스크 I/O가 폭증하며 기존 온라인 트랜잭션(OLTP) 쿼리들까지 전부 느려지는 대형 장애로 이어지곤 합니다.

 

본 포스팅에서는 왜 UNION 기반의 COUNT(*) 쿼리가 치명적으로 느린지 InnoDB 스토리지 엔진 아키텍처 관점에서 분석하고, 실무에서 사용하는 4가지 대량 테이블 고속 카운트 기법과 버퍼 풀(Buffer Pool) 오염 방지 전략을 상세히 다뤄보겠습니다.

 


 

1. 왜 SELECT COUNT(*) ... UNION ALL 방식은 극단적으로 느릴까? (Internal Architecture)

단순히 "데이터가 많아서 느리다"고 생각하기 쉽지만, 여기에는 MySQL 옵티마이저와 InnoDB 엔진의 구조적인 한계 4가지가 복합적으로 작용합니다.

① 단일 스레드 순차 실행 (Single-threaded Serial Execution)의 한계

MySQL에서 UNION ALL로 묶인 수백 개의 서브쿼리는 병렬로 실행되지 않습니다. 기본적으로 하나의 커넥션(세션)은 단일 OS 스레드를 점유하므로, 첫 번째 테이블의 COUNT(*)가 완전히 끝날 때까지 두 번째 테이블은 대기합니다. (MySQL 8.0.14+부터 innodb_parallel_read_threads를 통한 클러스터드 인덱스 병렬 읽기가 도입되었으나, 수백 개 테이블의 UNION 쿼리 전체를 동시에 병렬로 쪼개서 돌리는 것과는 결이 다릅니다.)

② 가장 치명적인 실수: UNION vs UNION ALL

만약 쿼리를 작성할 때 UNION ALL이 아니라 습관적으로 UNION을 사용했다면 성능 저하는 배가 됩니다. UNION은 중복 제거(Deduplication)를 보장하기 위해 내부적으로 임시 테이블(Internal Temporary Table)을 생성하고 정렬(Filesort) 또는 해시 연산을 수행합니다. 단순 건수 취합에는 중복 제거가 전혀 필요 없으므로 반드시 UNION ALL을 써야 합니다.

③ InnoDB MVCC와 세컨더리 인덱스 풀 스캔 (Secondary Index Full Scan)

앞서 다룬 적이 있듯, InnoDB는 MVCC(다중 버전 동시성 제어) 특성상 테이블의 전체 건수를 별도로 들고 있지 않습니다. 따라서 WHERE 조건 없는 COUNT(*)를 실행하면 옵티마이저는 가장 크기가 작은 세컨더리 인덱스(Secondary Index)를 선택해 리프 노드의 처음부터 끝까지 전부 스캔(Full Index Scan)합니다. 만약 적절한 세컨더리 인덱스가 없는 테이블이 섞여 있다면? 거대한 데이터 페이지를 담고 있는 클러스터드 인덱스(PK) 전체를 디스크에서 퍼 올리는 참사(Full Table Scan)가 발생합니다.

④ 버퍼 풀 오염 (Buffer Pool Churning / Pollution)

가장 위험한 아키텍처적 문제입니다. 평소 애플리케이션에서 잘 조회하지 않는 수백 기가바이트짜리 로그성/이력성 테이블들을 COUNT(*)로 연속해서 풀 스캔하면, 디스크에 있던 차가운 데이터 페이지(Cold Pages)들이 메모리(InnoDB Buffer Pool)로 밀려 들어옵니다. 비록 InnoDB가 Midpoint Insertion Strategy(Old Sublist 37% 지점 삽입)로 버퍼 풀을 보호하긴 하지만, 수백 개 테이블을 연속으로 스캔하면 결국 기존에 메모리에 잘 올라와 있던 핵심 서비스의 핫 데이터(Hot Pages)가 메모리에서 쫓겨나고(Eviction), DB 전체의 Buffer Pool Hit Ratio가 급락하며 서비스 장애가 발생합니다.

 

2. 해결책 비교: 상황에 맞는 최적의 카운트 전략 선택하기

다수의 스키마와 테이블 건수를 파악할 때는 "100% 정확한 실시간 건수가 필요한가(정합성 검증)" 아니면 "대략적인 규모 파악이 목적인가(모니터링/용량 산정)"에 따라 접근 방식을 완전히 달리해야 합니다.

방식 정확도 수행 속도 DB 부하 추천 사용 시나리오
1. information_schema.TABLES 조회 낮음 (추정치, 오차 10~50%) 초고속 (0.1초 미만) 없음 (Zero I/O) 전체적인 테이블 볼륨 파악, 빈 테이블 색출, 모니터링
2. mysql.innodb_table_stats 조회 중간 (STATS_SAMPLE_PAGES 비례) 초고속 (0.05초 미만) 없음 information_schema 호출 시의 MDL/통계 갱신 부하조차 피하고 싶을 때
3. 동적 SQL + 커버링 인덱스 힌트 (UNION ALL) 100% 정확 느림 (단일 스레드 순차) 높음 테이블 개수가 적고(50개 미만), 각 테이블 건수가 수백만 건 이하일 때
4. 외부 스크립트 멀티스레드 병렬 처리 (Parallel Count) 100% 정확 빠름 (코어 수 비례 단축) 제어 가능 (Connection Pool 제한) 수억 건 이상 대용량 DB 마이그레이션 후 데이터 정합성 전수 검증 시

3. 실무 구현 가이드: 단계별 쿼리 및 스크립트

방법 1: 0.1초 만에 끝내는 초고속 추정치 조회 (통계 딕셔너리 활용)

1건의 오차도 허용하지 않는 마이그레이션 검증이 아니라면, 직접 COUNT(*)를 날릴 필요가 전혀 없습니다. 앞서 설명드린 InnoDB 영구 통계 테이블(mysql.innodb_table_stats)을 직접 조회하면 디스크 I/O 없이 모든 스키마와 테이블의 건수를 즉시 뽑아낼 수 있습니다.

주의: information_schema.TABLES를 조회할 때 information_schema_stats_expiry = 0(또는 MySQL 5.7의 innodb_stats_on_metadata = ON)으로 되어 있으면 조회 순간 통계 샘플링이 발생해 느려질 수 있으므로, 엔진 내부 통계 테이블인 mysql.innodb_table_stats를 직접 읽는 것이 훨씬 안전하고 빠릅니다.

 

-- 다중 스키마의 모든 테이블 추정 건수 및 인덱스 크기 초고속 조회
SELECT 
    database_name AS schema_name,
    table_name,
    n_rows AS estimated_row_count,
    ROUND((clustered_index_size + sum_of_other_index_sizes) * @@innodb_page_size / 1024 / 1024, 2) AS total_size_mb,
    last_update AS stats_updated_at
FROM mysql.innodb_table_stats
WHERE database_name IN ('service_db_1', 'service_db_2', 'log_db') -- 대상 스키마 지정
ORDER BY database_name, n_rows DESC;

방법 2: 정확한 COUNT(*)가 필요할 때 — 최적화된 동적 SQL 생성기

마이그레이션 검증 등을 위해 반드시 100% 정확한 COUNT(*)가 필요한 경우, 일일이 손으로 쿼리를 짜지 않고 메타데이터를 이용해 최적화된 UNION ALL 쿼리를 자동 생성합니다.

이때 성능을 극대화하는 핵심 튜닝 포인트는 두 가지입니다.

  1. 세션 레벨에서 innodb_parallel_read_threads 값을 높여 클러스터드 인덱스 스캔 시 멀티 스레드 병렬 읽기를 활성화합니다 (MySQL 8.0.14+).
  2. GROUP_CONCAT 잘림 방지를 위해 group_concat_max_len을 최대로 늘린 후 UNION ALL 쿼리를 생성합니다.
 
-- 1. 세션 파라미터 튜닝 (병렬 스캔 활성화 및 GROUP_CONCAT 버퍼 확장)
SET SESSION innodb_parallel_read_threads = 8; -- CPU 코어 여유에 맞춰 4~16 설정 (MySQL 8.0.14+)
SET SESSION group_concat_max_len = 10485760;  -- 10MB로 확장 (쿼리 잘림 방지)

-- 2. 다중 스키마 대상 최적화된 COUNT(*) UNION ALL 쿼리 자동 생성
SELECT 
    GROUP_CONCAT(
        CONCAT(
            'SELECT ''', TABLE_SCHEMA, ''' AS schema_name, ',
            '''', TABLE_NAME, ''' AS table_name, ',
            'COUNT(*) AS exact_count ',
            'FROM `', TABLE_SCHEMA, '`.`', TABLE_NAME, '`'
        ) 
        SEPARATOR '\nUNION ALL\n'
    ) AS generated_sql
FROM information_schema.TABLES
WHERE TABLE_SCHEMA IN ('service_db_1', 'service_db_2')
  AND TABLE_TYPE = 'BASE TABLE';

만약, 복사해서 다시 실행하는 과정조차 자동화하고 싶다면, 아래와 같이 Prepared Statement(동적 SQL 실행)로 묶어 한 번에 결과를 출력할 수 있습니다.


 
-- 동적 SQL 생성 및 즉시 실행 (Stored Procedure 없이 세션 변수 활용)
SET SESSION group_concat_max_len = 10485760;
SET SESSION innodb_parallel_read_threads = 8;

SELECT 
    GROUP_CONCAT(
        CONCAT(
            'SELECT ''', TABLE_SCHEMA, ''' AS schema_name, ',
            '''', TABLE_NAME, ''' AS table_name, ',
            'COUNT(*) AS exact_count ',
            'FROM `', TABLE_SCHEMA, '`.`', TABLE_NAME, '`'
        ) 
        SEPARATOR ' UNION ALL '
    ) INTO @count_sql
FROM information_schema.TABLES
WHERE TABLE_SCHEMA IN ('service_db_1', 'service_db_2')
  AND TABLE_TYPE = 'BASE TABLE';

PREPARE stmt FROM @count_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

방법 3: 대용량 실무 환경의 정석 — 테이블 단위 병렬 실행 (Bash / Python)

테이블이 수백 개이고 전체 데이터가 수 테라바이트(TB)에 달한다면, 앞서 살펴본 UNION ALL 단일 쿼리는 아무리 튜닝해도 직렬 실행(Serial Execution)의 벽에 막혀 수 시간이 소요됩니다. 게다가 중간에 네트워크 타임아웃(net_read_timeout)이나 max_execution_time에 걸리면 처음부터 다시 돌려야 합니다.

따라서 시니어 DBA들은 거대한 UNION ALL 쿼리를 던지는 대신, 테이블 목록을 뽑아 OS 레벨에서 xargs -P(병렬 프로세스)나 Python ThreadPoolExecutor를 이용해 여러 세션에서 동시에 COUNT(*)를 수행합니다.

실무 원라이너 스크립트 (Bash + xargs 병렬 처리)

아래 스크립트는 대상 스키마의 모든 테이블을 추출한 뒤, 동시에 4개의 워커(Worker) 스레드(-P 4)를 띄워 병렬로 COUNT(*)를 집계합니다. 수행 속도가 정직하게 약 4배 빨라지며, 완료되는 테이블부터 실시간으로 화면에 찍힙니다.

#!/bin/bash
# 대상 스키마 목록 및 접속 정보
DB_USER="admin"
DB_PASS="your_password"
DB_HOST="127.0.0.1"
SCHEMAS="'service_db_1', 'service_db_2'"
PARALLEL_WORKERS=4 # DB CPU 여유에 따라 4~8 권장 (너무 높이면 I/O 포화 발생)

# 1. 대상 스키마.테이블 리스트 추출 후 xargs로 병렬 COUNT(*) 실행
mysql -u${DB_USER} -p${DB_PASS} -h${DB_HOST} -N -e "
  SELECT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME)
  FROM information_schema.TABLES
  WHERE TABLE_SCHEMA IN (${SCHEMAS}) AND TABLE_TYPE = 'BASE TABLE';
" | xargs -P ${PARALLEL_WORKERS} -I {} mysql -u${DB_USER} -p${DB_PASS} -h${DB_HOST} -N -e "
  SET SESSION innodb_parallel_read_threads = 4;
  SET SESSION transaction_isolation = 'READ-UNCOMMITTED';
  SELECT '{}', COUNT(*) FROM {};
"

4. 실무 트러블슈팅 & 안전장치 (Deep Dive)

운영 중인 DB(또는 Replica 서버)에서 위와 같이 대량 테이블 COUNT(*)를 수행할 때 장애를 예방하기 위한 3가지 고급 팁입니다.

① 왜 실행 계획(EXPLAIN)에서 느린 테이블이 발생할까? (Secondary Index 유무 확인)

UNION ALL이나 병렬 스크립트를 돌렸을 때 유독 특정 테이블에서만 수십 분씩 멈춰 있는 경우가 있습니다. 해당 테이블의 EXPLAIN SELECT COUNT(*) FROM 테이블을 떠보면 type: index가 아니라 type: ALL 또는 클러스터드 인덱스(PRIMARY)를 스캔하고 있을 확률이 99%입니다.

  • 원인: 테이블에 NOT NULL로 선언된 세컨더리 인덱스가 하나도 없거나, PK만 존재하는 테이블입니다. 클러스터드 인덱스는 리프 노드에 모든 컬럼의 실제 데이터(Row Data)를 품고 있어 세컨더리 인덱스보다 용량이 수십 배 큽니다.
  • 해결: 이런 초거대 이력성 테이블은 정확한 COUNT(*) 대상에서 제외하고 MAX(id) - MIN(id)로 추산하거나, 배치로 미리 집계된 메타 테이블을 활용해야 합니다.

② MVCC Undo Log 탐색 오버헤드 줄이기 (READ UNCOMMITTED)

운영 중에 DML(INSERT, UPDATE, DELETE)이 활발히 일어나는 테이블에 COUNT(*)를 날리면, 기본 격리 수준인 REPEATABLE READ에서는 일관된 읽기(Consistent Read) 스냅샷을 맞추기 위해 변경 중인 페이지마다 Undo Log를 뒤져서 이전 버전을 재조합해야 합니다. 이로 인해 스캔 속도가 수십 배 느려집니다.

약간의 더티 리드(Dirty Read) 오차를 감수할 수 있는 대량 집계라면, 세션 격리 수준을 READ UNCOMMITTED로 낮추고 실행하면 Undo Log 탐색 비용을 완전히 제거하여 훨씬 빠르게 스캔을 마칠 수 있습니다.

-- Undo Log 참조 오버헤드를 제거하여 대량 테이블 COUNT(*) 속도 극대화
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

③ InnoDB 버퍼 풀 오염 완벽 차단 (innodb_old_blocks_time 튜닝)

수백 개의 테이블을 연속으로 풀 스캔할 때 기존 서비스의 핫 데이터(Hot Page)가 메모리에서 밀려나는 현상을 막으려면, 작업 직전에 innodb_old_blocks_time 설정을 확인해야 합니다.

  • 기본값은 1000(1초)입니다. 만약 테이블 스캔 중 동일한 데이터 페이지에 여러 번 접근하더라도 1초 이내에 스캔이 지나가면 LRU 리스트의 Young 영역(Hot 영역)으로 승격되지 않고 즉시 버려집니다.
  • 대량 COUNT(*) 작업 시에는 이 값을 일시적으로 5000(5초) 이상으로 높이거나, 가급적 트래픽이 없는 Replica(Read Only) 장비에서 작업을 수행하여 마스터 DB의 버퍼 풀을 보호하는 것이 정석입니다.

 

5. 요약 정리

  1. 수백 개 테이블을 UNION으로 묶어 COUNT(*)를 실행하면 단일 스레드 직렬 처리 + 임시 테이블 정렬(UNION 사용 시) + 대규모 디스크 I/O가 겹쳐 극단적인 슬로우 쿼리가 발생합니다.
  2. 대략적인 건수 파악이 목적이라면 직접 세지 말고 mysql.innodb_table_stats의 n_rows를 조회하세요. 0.05초 만에 부하 없이 끝납니다.
  3. 마이그레이션 검증 등 100% 정확한 건수가 필요하다면, UNION 대신 UNION ALL 동적 SQL을 생성하되, 대용량 환경에서는 OS 레벨(xargs -P 등)에서 멀티 커넥션으로 테이블을 병렬 처리하고 세션 단위로 innodb_parallel_read_threads와 READ UNCOMMITTED 격리 수준을 조합하는 것이 가장 빠르고 안전합니다.

 

실무에서 대용량 테이블의 쿼리 튜닝을 진행하다 보면 가장 당황스러운 순간 중 하나..

ANALYZE TABLE을 방금 수행했음에도 불구하고, SHOW TABLE STATUS의 Rows 값이나

실행 계획(EXPLAIN)의 rows 예측치가 실제 COUNT(*)와 수백만 건 이상 차이 나는 현상을 마주할 때입니다.

 

심지어 ANALYZE TABLE을 연달아 두 번 실행했는데 카디널리티(Cardinality) 수치가 매번 바뀌어 갑자기 슬로우 쿼리가 발생하기도 합니다.단순히 "MySQL 통계 정보는 원래 부정확하다"고 넘기기에는 옵티마이저의 실행 계획에 미치는 영향이 너무나 치명적입니다.

 

본 포스팅에서는 왜 InnoDB의 통계 정보가 실제 데이터 건수와 불일치하는지 내부 아키텍처(Random Dive Sampling) 관점에서 파헤쳐 보고, 통계 수집 방식의 차이점과 실무에서 옵티마이저의 오판을 방지하는 튜닝 가이드를 정리해 보겠습니다.

 


1. 왜 ANALYZE TABLE을 해도 COUNT 정보는 부정확할까? (Internal Architecture)

결론부터 말씀드리면, InnoDB 스토리지 엔진은 MVCC(다중 버전 동시성 제어) 아키텍처 특성상 테이블의 전체 건수(Total Rows)를 별도의 메타데이터로 실시간 관리하지 않습니다. 트랜잭션 격리 수준(Isolation Level)에 따라 각 세션이 바라보는 '유효한 레코드의 수'가 시시각각 다르기 때문입니다.

따라서 ANALYZE TABLE을 실행하더라도 MySQL은 테이블 풀 스캔을 하며 정확한 건수를 세지 않습니다. 대신 무작위 페이지 샘플링(Random Dive Sampling) 기법을 사용합니다.

 

● B-Tree Random Dive 작동 원리

  1. InnoDB는 대상 인덱스(Clustered Index 및 Secondary Index)의 B-Tree에서 무작위로 $N$개의 리프 페이지(Leaf Page)를 선택해 탐색(Dive)합니다.
  2. 선택된 $N$개의 리프 페이지 안에 들어있는 레코드의 수와 고유한 키 값(Distinct Key)의 개수를 계산하여 평균을 냅니다.
  3. 이 평균값에 테이블 전체 리프 페이지 수를 곱하여 전체 테이블의 Rows와 인덱스 Cardinality를 추정(Estimate)합니다.

● 왜 오차가 심하게 발생할까?

  • 페이지 단편화 (Page Fragmentation)와 Fill Factor: 대량의 DELETE나 UPDATE가 발생한 테이블은 페이지마다 들어있는 레코드의 밀도가 극단적으로 다릅니다. 어떤 페이지는 꽉 차 있고(16KB 기준 수백 건), 어떤 페이지는 텅 비어 있습니다. 우연히 샘플링된 20개의 페이지가 텅 빈 페이지들이라면 전체 건수는 실제보다 턱없이 낮게 계산됩니다.
  • 가변 길이 컬럼 (VARCHAR, TEXT, JSON): 레코드 크기가 일정하지 않으면 페이지당 저장되는 로우 수의 편차가 커져 샘플링 오차율이 급증합니다.
  • 무작위 추출의 비결정성 (Non-deterministic): ANALYZE TABLE을 실행할 때마다 뽑히는 샘플 페이지가 매번 달라집니다. 데이터가 편향(Skewed)되어 있을수록 실행할 때마다 통계 수치가 널뛰기하는 현상이 발생합니다.

 

2. MySQL의 통계 수집 방식 3가지 완벽 비교

MySQL에서 옵티마이저가 참조하는 통계 정보를 갱신하고 제어하는 방식은 크게 세 가지로 나뉩니다.

각 방식의 차이를 정확히 이해해야 장애 없이 통계를 제어할 수 있습니다.

구분 영구 통계 (Persistent Stats) 비영구 통계 (Transient Stats) 옵티마이저 히스토그램 (Histogram)
적용 버전 및 기본값 MySQL 5.6+ 기본값 (ON) 레거시 방식 (OFF 설정 시 동작) MySQL 8.0+ 도입 (수동 생성)
저장 위치 디스크 (mysql.innodb_table_stats, innodb_index_stats) 메모리 (서버 재시작 시 초기화됨) 디스크 (information_schema.COLUMN_STATISTICS)
기본 샘플링 페이지 수 20개 (innodb_stats_persistent_sample_pages) 8개 (innodb_stats_transient_sample_pages) 데이터 볼륨에 따라 자동 샘플링 (BUCKETS 수 지정)
수집 대상 인덱스가 존재하는 컬럼 및 테이블 전체 건수 인덱스가 존재하는 컬럼 및 테이블 전체 건수 인덱스가 없는 일반 컬럼 (주로 데이터 편향 해결용)
통계 특징 카디널리티(평균적인 고유값 분포)만 저장 샘플 수가 적어 오차가 매우 크고 플랜이 자주 흔들림 구간별(Bucket) 실제 데이터 빈도수 및 편향도(Skewness) 저장
자동 갱신 조건 전체 로우의 10% 이상 변경 시 백그라운드 갱신 전체 로우의 16분의 1(약 6.25%) 변경 시 갱신 자동 갱신 안 됨 (ANALYZE TABLE ... UPDATE HISTOGRAM 수동 실행 필요)

3. ANALYZE 명령어 구문별 내부 동작과 잠금(Lock)의 차이

실무에서 무심코 던진 ANALYZE TABLE이 운영 DB에 어떤 영향을 미치는지, 그리고 구문에 따라 무엇이 달라지는지 살펴보겠습니다.

① 기본 인덱스 통계 갱신

ANALYZE TABLE orders;
  • 동작: orders 테이블의 모든 인덱스에 대해 설정된 샘플링 페이지 수(기본 20개)만큼 Random Dive를 수행하여 mysql.innodb_table_stats와 mysql.innodb_index_stats 테이블을 갱신합니다.
  • 주의할 점 (Plan Cache Flush): 자체적인 I/O 부하는 거의 없고 온라인 작업이 가능하지만, 통계 갱신이 완료되는 순간 테이블 정의 캐시(Table Definition Cache)를 플러시하기 위해 순간적으로 MDL(Metadata Lock)을 획득합니다. 만약 해당 테이블에 롱 쿼리(Long-running Query)가 돌고 있다면, ANALYZE TABLE이 MDL을 대기하게 되고, 그 뒤로 들어오는 모든 SELECT, DML 쿼리들이 연쇄적으로 Waiting for table metadata lock 상태에 빠지는 대형 장애로 이어질 수 있습니다.

② 바이너리 로그(Binlog) 전파 방지 옵션

ANALYZE LOCAL TABLE orders;
-- 또는
ANALYZE NO_WRITE_TO_BINLOG TABLE orders;
  • 동작: 기본적으로 ANALYZE TABLE은 바이너리 로그에 기록되어 복제본(Replica) 서버로 전파됩니다. 만약 마스터(Source)와 레플리카의 스펙이 다르거나, 특정 레플리카에서만 통계 테스트를 하고 싶을 때, 혹은 복제 지연을 유발하는 MDL 전파를 막고 싶을 때는 반드시 LOCAL 키워드를 붙여 실행해야 합니다.

③ 히스토그램(Histogram) 수집 (MySQL 8.0+)

-- 특정 컬럼에 대해 히스토그램 생성 및 갱신 (최대 100개 버킷)
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, created_at WITH 100 BUCKETS;

-- 히스토그램 삭제
ANALYZE TABLE orders DROP HISTOGRAM ON status;
  • 일반 ANALYZE와의 결정적 차이: 앞서 설명한 기본 ANALYZE TABLE orders; 명령어는 히스토그램을 갱신하지 않습니다. 기본 인덱스 통계는 "고유값이 대략 몇 개인가(균등 분포 가정)"만 계산하기 때문에, status = 'COMPLETED'가 전체의 99%이고 'PENDING'이 1%인 데이터 편향(Data Skew) 상황을 전혀 인지하지 못합니다.
  • 반면 UPDATE HISTOGRAM을 실행하면 테이블 데이터를 스캔(샘플링)하여 값의 분포도를 등고선(Equi-height) 또는 도수(Singleton) 형태의 버킷으로 나누어 저장합니다. 이를 통해 옵티마이저가 조건절(WHERE)의 실제 필터링 건수(Selectivity)를 기가 막히게 예측할 수 있게 됩니다.

4. 실무 트러블슈팅: 통계 오차로 인한 실행 계획 오염 해결 가이드

통계 정보를 신뢰할 수 없어 실행 계획이 꼬일 때, 시니어 DBA와 데이터 아키텍트가 실무에서 사용하는 4가지 해결책을 소개합니다.

Step 1: 현재 통계 정보와 실제 데이터의 괴리 확인하기

먼저 옵티마이저가 알고 있는 통계 수치가 얼마나 틀렸는지 시스템 테이블을 직접 조회하여 진단합니다.

-- 1. 테이블 전체 추정 건수 및 샘플링된 페이지 크기 확인
SELECT 
    database_name, 
    table_name, 
    last_update, 
    n_rows AS estimated_rows, 
    clustered_index_size, 
    sum_of_other_index_sizes
FROM mysql.innodb_table_stats 
WHERE database_name = 'your_db' AND table_name = 'orders';

-- 2. 인덱스별 상세 카디널리티(stat_value) 및 샘플링된 페이지 수(sample_size) 확인
SELECT 
    index_name, 
    stat_name, 
    stat_value AS estimated_cardinality, 
    sample_size, 
    stat_description
FROM mysql.innodb_index_stats 
WHERE database_name = 'your_db' AND table_name = 'orders';

위 쿼리에서 sample_size가 20으로 고정되어 있는데 테이블 크기가 수천만 건 이상이라면, 샘플링 비율이 너무 낮아 통계가 왜곡되었을 확률이 매우 높습니다.

Step 2: 테이블별로 샘플링 페이지 수(STATS_SAMPLE_PAGES) 늘리기

글로벌 파라미터인 innodb_stats_persistent_sample_pages를 무조건 높이면 모든 테이블의 ANALYZE 시간이 길어집니다. 대신 문제가 되는 대형 테이블이나 데이터 편향이 심한 테이블에만 개별적으로 샘플링 페이지 수를 늘려주는 것이 실무 Best Practice입니다.

-- 해당 테이블만 샘플링 페이지를 기본 20개에서 200개로 증가 (메타데이터만 변경되므로 즉시 완료됨)
ALTER TABLE orders STATS_SAMPLE_PAGES = 200;

-- 변경된 샘플링 기준으로 통계 재수집
ANALYZE LOCAL TABLE orders;

실무 팁: 샘플링 페이지를 100~500 사이로 올려주면 ANALYZE TABLE 수행 시간은 0.1초에서 1~2초 내외로 소폭 증가하지만, 카디널리티와 전체 Rows 추정치의 정확도는 비약적으로 상승하며 통계가 널뛰기하는 현상이 사라집니다.

Step 3: 자동 통계 갱신(STATS_AUTO_RECALC) 비활성화 및 배치 고정

대량 배치 작업 등으로 데이터가 대거 삭제되었다가 다시 들어오는 테이블의 경우, 데이터가 10% 변경될 때마다 백그라운드에서 통계가 자동 갱신(innodb_stats_auto_recalc)되면서 운 나쁘게 비어 있는 페이지들이 샘플링되어 실행 계획이 갑자기 망가지는 경우가 있습니다.

안정적인 플랜 고정이 최우선이라면 자동 갱신을 끄고, 데이터가 가장 안정적인 시간대에만 수동으로 통계를 갱신하거나 아예 통계를 고정해 버리는 전략을 씁니다.

-- 테이블이 가장 정상적인 상태일 때 충분한 샘플로 통계 수집 후 자동 갱신 OFF
ALTER TABLE orders 
    STATS_PERSISTENT = 1, 
    STATS_AUTO_RECALC = 0, 
    STATS_SAMPLE_PAGES = 256;

ANALYZE LOCAL TABLE orders;

Step 4: 파편화가 심한 테이블은 통계를 고쳐도 소용없다 (OPTIMIZE / Rebuild)

만약 STATS_SAMPLE_PAGES를 500 이상으로 늘렸는데도 n_rows 추정치가 실제 COUNT(*)와 2배 이상 차이 난다면, 이는 샘플링의 문제가 아니라 B-Tree 페이지 내부의 단편화(Fragmentation)가 심각한 상태입니다.

이 경우 옵티마이저는 리프 페이지 개수를 기반으로 비용(Cost)을 계산하기 때문에 풀 스캔이나 인덱스 스캔 비용을 비정상적으로 높게 평가합니다. 이때는 ANALYZE가 아니라 온라인 DDL을 통해 테이블을 리빌드(Rebuild)하여 빈 공간을 압축해야 합니다.

-- Online DDL(In-place) 방식으로 테이블 조각 모음 및 통계 재구축
ALTER TABLE orders ENGINE=InnoDB, ALGORITHM=INPLACE, LOCK=NONE;

5. 요약 

  1. 통계 수치를 절대적인 COUNT(*) 대용으로 쓰지 마세요: SHOW TABLE STATUS나 information_schema.TABLES의 TABLE_ROWS는 어디까지나 옵티마이저가 비용 계산을 위해 활용하는 근사치(Approximation)입니다. 비즈니스 로직에서 정확한 카운트가 필요하다면 커버링 인덱스를 타는 COUNT(*)나 별도의 요약(Summary) 테이블을 설계해야 합니다.
  2. 수천만 건 이상의 핵심 테이블은 STATS_SAMPLE_PAGES를 튜닝하세요: 기본값인 20페이지는 수백 기가바이트에 달하는 현대의 DB 환경에서는 턱없이 부족한 샘플 크기입니다. 핵심 도메인 테이블은 STATS_SAMPLE_PAGES를 100~300 수준으로 상향 조정하는 것을 권장합니다.
  3. 데이터 편향(Skewness) 문제는 인덱스 통계로 해결되지 않습니다: 특정 상태값이나 날짜 구간에 데이터가 몰려 있어 옵티마이저가 엉뚱한 인덱스를 선택한다면, ANALYZE TABLE만 반복할 것이 아니라 MySQL 8.0의 히스토그램(UPDATE HISTOGRAM)을 도입하거나 쿼리 힌트(INDEX, JOIN_ORDER)를 통해 플랜을 제어해야 합니다.

 

오라클 데이터베이스를 운영하다 보면 애플리케이션 서비스가 갑자기 중단되거나 배치 작업이 실패하면서 다음과 같은 에러를 마주치는 순간이 있습니다.

ORA-28000: the account is locked
ORA-28001: the password has expired

단순히 ALTER USER 계정명 IDENTIFIED BY 비밀번호; 명령어로 해결되는 단순한 상황도 있지만, 실무 환경에서는 그렇게 단순하지 않습니다.

운영 중인 계정의 기존 패스워드를 모르는 상태에서 잠김(Lock)만 해제해야 하거나, 애플리케이션 소스 코드 수정 없이 패스워드 만료 주기(Lifetime)를 우회하여 계정을 연장해야 하는 상황, 그리고 Oracle 11g/12c 이후 강화된 해시 알고리즘과 대소문자 구분 파라미터가 얽혀 발생하는 사이드 이펙트가 빈번하게 발생하기 때문입니다.

이번 포스팅에서는 오라클의 사용자 인증 및 프로파일(Profile) 내부 메커니즘, 계정 잠김/만료 트러블슈팅, 그리고 기존 비밀번호를 그대로 유지하면서 만료 상태를 초기화하는 실무 팁을 상세히 정리합니다.

 


1. 내부 아키텍처: 계정 상태와 인증 메커니즘

1) 오라클의 계정 상태 머신 (Account Status)

오라클 딕셔너리 뷰 DBA_USERS의 ACCOUNT_STATUS 컬럼은 계정의 현재 라이프사이클을 나타내며, 내부적으로 다음과 같은 우선순위와 전이 규칙을 가집니다.

  • OPEN: 정상적으로 로그인이 가능한 상태.
  • EXPIRED: 패스워드 유효기간(PASSWORD_LIFE_TIME)이 만료된 상태. 신규 세션 연결 시 ORA-28001을 반환하며 접속이 거부됩니다.
  • LOCKED: 로그인 실패 횟수 초과(FAILED_LOGIN_ATTEMPTS) 또는 관리자의 수동 조치로 계정이 잠긴 상태. 접속 시 ORA-28000을 반환합니다.
  • EXPIRED & LOCKED: 만료와 잠김이 동시에 발생한 복합 상태.
       [정상 로그인]
   ┌─────────────────┐
   │      OPEN       │
   └────────┬────────┘
            │
  ┌─────────┴─────────┐
  │                   │
[유효기간 만료]    [로그인 실패 N회 초과]
  │                   │
  ▼                   ▼
┌─────────────┐   ┌─────────────┐
│   EXPIRED   │   │   LOCKED    │
│ (ORA-28001) │   │ (ORA-28000) │
└──────┬──────┘   └──────┬──────┘
       │                 │
       └────────┬────────┘
                ▼
      ┌──────────────────┐
      │ EXPIRED & LOCKED │
      └──────────────────┘

2) Password Hash 저장 구조의 변화

오라클은 보안성 강화를 위해 버전에 따라 패스워드 검증자(Verifier) 체계를 단계적으로 확장해 왔습니다.

  • Oracle 10g 이전: DES 기반의 16진수 대소문자 비구분 해시(DBA_USERS.PASSWORD).
  • Oracle 11g: SHA-1 기반의 대소문자 구분 해시(11G Verifier, PASSWORD_VERSIONS 컬럼에 10G 11G 형태로 표시).
  • Oracle 12c Release 1 (12.1): SHA-512 기반 해시 도입(12C Verifier).
  • Oracle 12c Release 2 (12.2) 이상: PBKDF2 기반의 고강도 파생 키 알고리즘 적용.

 SEC_CASE_SENSITIVE_LOGON 파라미터 주의점

Oracle 11g 이상에서는 기본적으로 SEC_CASE_SENSITIVE_LOGON = TRUE로 설정되어 패스워드 대소문자를 엄격하게 구분합니다. 레거시 시스템 마이그레이션 시 해당 설정 때문에 의도치 않은 로그인 실패가 발생하고 연쇄적으로 ORA-28000 계정 잠김이 발생할 수 있습니다.

2. 프로파일(Profile) 파라미터와 발생 원인 분석

계정이 잠기거나 만료되는 근본 원인은 해당 계정에 매핑된 RESOURCE PROFILE의 보안 파라미터 때문입니다. 별도로 지정하지 않는 한 모든 계정은 기본적으로 DEFAULT 프로파일을 따릅니다.

주요 패스워드 제어 파라미터

파라미터 기본값 (11g/12c 기준) 설명
FAILED_LOGIN_ATTEMPTS 10회 연속 인증 실패 시 계정이 자동 잠김(Lock) 처리되는 한계치
PASSWORD_LIFE_TIME 180일 패스워드 만료까지의 유효 일수 (초과 시 ORA-28001)
PASSWORD_GRACE_TIME 7일 만료 경고 후 비밀번호 변경을 유예해 주는 기간
PASSWORD_REUSE_TIME UNLIMITED 이전 비밀번호를 다시 사용하기까지 대기해야 하는 일수
PASSWORD_REUSE_MAX UNLIMITED 이전 비밀번호 재사용 전 거쳐야 하는 변경 횟수
  • 개발/운영 미분리 환경의 연쇄 Lock: 배치 서버나 WAS의 DB Connection Pool 설정 중 특정 노드 하나에 잘못된 비밀번호가 설정되어 있으면 백그라운드 재접속 시도(Retry)로 인해 순식간에 FAILED_LOGIN_ATTEMPTS를 초과하여 계정이 잠기게 됩니다.
  • 기본값 방치로 인한 돌발 만료: 데이터베이스 설치 후 DEFAULT 프로파일의 PASSWORD_LIFE_TIME(보통 180일)을 UNLIMITED로 전환하지 않아, 정확히 6개월 뒤 실서비스 계정이 정지되는 사고가 발생합니다.

3. 실무 트러블슈팅: 단계별 진단 및 복구

Step 1. 문제 계정의 상태 및 프로파일 점검

장애 발생 즉시 계정의 상태, 사용 중인 프로파일, 잠긴 일시를 조회합니다.

SELECT 
    username,
    account_status,
    profile,
    lock_date,
    expiry_date
FROM 
    dba_users
WHERE 
    username = UPPER('&input_username');

해당 계정에 적용된 프로파일의 상세 보안 정책도 함께 확인합니다.

SELECT 
    profile,
    resource_name,
    limit
FROM 
    dba_profiles
WHERE 
    resource_type = 'PASSWORD'
    AND profile = (SELECT profile FROM dba_users WHERE username = UPPER('&input_username'))
ORDER BY 
    resource_name;

Step 2. 단순 계정 잠김 해제 (비밀번호를 알고 있는 경우)

관리자 권한(SYSDBA)으로 접속하여 계정 잠금을 해제하고, 필요 시 비밀번호를 재설정합니다.

-- 1) 단순 Lock 해제 (비밀번호는 그대로 유지)
ALTER USER app_user ACCOUNT UNLOCK;

-- 2) 만료(EXPIRED) 해제 및 신규 비밀번호 변경
ALTER USER app_user IDENTIFIED BY "NewPassword123#";

Step 3. [고급 실무] 기존 비밀번호를 유지하면서 만료 상태 해제하기

실무에서 가장 난감한 상황은 "WAS 애플리케이션 설정 파일에 암호화되어 있어 기존 비밀번호를 모르는데, 패스워드가 만료되어 접속이 안 되는 상황"입니다. 비밀번호를 바꾸면 운영 중인 수십 대의 WAS 인스턴스를 재기동해야 하는 대형 작업이 됩니다.

이때 오라클 내부의 Password Hash Verifier를 활용하여 비밀번호를 변경하지 않고 만료 상태만 OPEN으로 리셋할 수 있습니다.

① Oracle 11g R2 이전 (패스워드 해시 직접 대입)

-- 기존 10g 해시값 백업 조회
SELECT password FROM sys.user$ WHERE name = 'APP_USER';
-- 결과 예: 9A8B7C6D5E4F3210

-- 백업받은 해시값을 IDENTIFIED BY VALUES 절로 재지정 (만료 상태 초기화)
ALTER USER app_user IDENTIFIED BY VALUES '9A8B7C6D5E4F3210';
ALTER USER app_user ACCOUNT UNLOCK;

② Oracle 12c 이상 (동일 비밀번호 재할당 우회)

Oracle 12c 이상에서는 멀티플 해시 구조로 인해 VALUES 절 문법이 제한되는 경우가 많습니다. 만약 기존 비밀번호를 관리자나 개발팀이 알고 있다면, 단순히 기존 비밀번호를 한 번 더 덮어쓰는 것만으로 ACCOUNT_STATUS가 OPEN으로 돌아오며 만료 카운트다운이 리셋됩니다.

-- 기존 비밀번호와 동일한 값을 대입하여 만료 기한 연장
ALTER USER app_user IDENTIFIED BY "CurrentPassword123#";
ALTER USER app_user ACCOUNT UNLOCK;

Step 4. 무중단 비밀번호 전환 (Oracle 19c 이상: Gradual Database Password Rollover)

Oracle 19c(19.12 RU)부터는 애플리케이션 중단 없이 안전하게 비밀번호를 교체할 수 있는 혁신적인 기능이 제공됩니다. 바로 비밀번호 롤오버 유예 기간(Rollover Period) 설정입니다.

-- 이전 비밀번호와 신규 비밀번호가 7일간 동시에 인증되도록 설정
ALTER USER app_user IDENTIFIED BY "NewSecurePassword456#" 
HTTP_DIGEST_ENABLE 
RETAIN BATCH 
PASSWORD ROLLOVER 7 DAYS;

이 기능을 활용하면 무중단 환경에서 DB 비밀번호를 먼저 변경한 뒤, 운영 시간 동안 순차적으로 WAS 인스턴스들의 설정 파일을 수정 및 재기동할 수 있습니다.

4. 재발 방지를 위한 운영 모범 권고안 (Best Practices)

  1. 서비스 계정 전용 프로파일 분리 생성
    -- 서비스 계정 전용 무제한 프로파일 생성
    CREATE PROFILE prf_app_service LIMIT
        FAILED_LOGIN_ATTEMPTS UNLIMITED
        PASSWORD_LIFE_TIME UNLIMITED
        PASSWORD_GRACE_TIME UNLIMITED
        PASSWORD_REUSE_TIME UNLIMITED
        PASSWORD_REUSE_MAX UNLIMITED;
    
    -- 운영 계정에 할당
    ALTER USER app_user PROFILE prf_app_service;
    
  2. 어플리케이션 연동용 계정에 DEFAULT 프로파일을 부여하는 것은 잠재적 장애 요인입니다. 서비스 계정 전용 프로파일을 별도로 생성하여 만료와 잠김 위험을 원천 차단해야 합니다.
  3. 반복적인 FAILED_LOGIN_ATTEMPTS 발생 시 유입 IP 추적
    -- 로그인 실패 Audit 설정 (전통적 감사 기준)
    AUDIT SESSION WHENEVER NOT SUCCESSFUL;
    
    -- 실패 이력 및 접속 시도 터미널/IP 확인
    SELECT 
        username, 
        os_username, 
        userhost, 
        timestamp, 
        returncode 
    FROM 
        dba_audit_session 
    WHERE 
        returncode IN (1017, 28000) 
    ORDER BY 
        timestamp DESC;
    
  4. 계정이 특정 주기로 자꾸 잠긴다면 내부 보안 사고이거나 잘못 설정된 배치가 도는 것입니다. 오라클 감사(Audit)나 시스템 트리거를 활용해 실패 로그를 수집해야 합니다.

5. 요약

계정 잠김(ORA-28000)과 만료(ORA-28001)는 발생 즉시 비즈니스 서비스 정지로 이어지는 크리티컬한 이슈입니다.

단순한 잠금 해제에 그치지 않고,

1) 원인이 된 유입 세션 추적,

2) 서비스 전용 프로파일 격리(PASSWORD_LIFE_TIME UNLIMITED), 그리고

3) 비밀번호를 모르는 긴급 상황에서의 해시 기반 복구 기법을 숙지해 두는 것이 안정적인 데이터베이스 운영의 핵심입니다.

 

 

+ Recent posts