본문 바로가기
DataBase

MySQL - COUNT와 EXISTS 성능 비교 및 최적화 고민

by 대박플머 2024. 10. 21.

MySQL에서 대규모 데이터셋을 다루는 경우, 성능 최적화는 데이터베이스 관리자(DBA)와 개발자에게 중요한 과제입니다. 특히 데이터를 조회할 때, COUNTEXISTS 함수는 서로 다른 용도로 사용되지만 성능 측면에서는 큰 차이를 보일 수 있습니다. 두 함수의 적절한 선택은 데이터베이스 성능에 큰 영향을 미치며, 잘못된 선택은 성능 저하를 초래할 수 있습니다.

이번 글에서는 COUNTEXISTS 함수의 차이를 분석하고, 각각의 사용 시나리오에 맞는 최적화 전략을 제시합니다. 이를 통해 대규모 데이터셋을 다루는 실무 환경에서 쿼리 성능을 극대화할 수 있는 방법을 알아봅니다.

1. COUNTEXISTS의 기본 개념

1.1 COUNT 함수

COUNT는 테이블에서 특정 조건을 만족하는 행의 개수를 반환합니다. 사용자는 모든 행을 스캔하여 그 수를 세는 방식으로 결과를 얻습니다. COUNT는 단순한 개수 확인을 위해 유용하지만, 데이터셋이 클 경우 성능 저하가 발생할 수 있습니다.

SELECT COUNT(*) FROM users WHERE active = 1;
  • COUNT(*)는 조건에 맞는 모든 행을 카운트하는 방식입니다.
  • 조건을 만족하는 행이 많으면 많을수록 더 많은 시간을 소모하게 됩니다.
  • 상세한 내용은 MySQL -  COUNT 함수를 참고해주세요.

1.2 EXISTS 함수

EXISTS는 조건을 만족하는 행이 있는지 여부만을 판단하는 함수입니다. 행의 개수보다는 조건을 만족하는지 확인하는 것이 목적인 경우, EXISTS는 첫 번째로 조건을 만족하는 행을 찾으면 즉시 쿼리를 종료하므로 COUNT보다 성능이 뛰어날 수 있습니다.

SELECT EXISTS(SELECT 1 FROM users WHERE active = 1);
  • EXISTS는 실제로 반환되는 행의 값과 상관없이 조건을 만족하는 첫 번째 행을 찾으면 결과를 반환합니다.
  • 이 때문에 COUNT보다 빠르게 결과를 도출할 수 있습니다.
  • 상세 내용은 MySQL - EXISTS 함수를 참고해주세요.

2. 성능 비교: COUNT vs EXISTS

성능 차이를 명확히 이해하기 위해, 대규모 데이터를 가진 users 테이블을 기준으로 두 함수의 성능을 비교합니다. 테이블에는 약 100만 개의 레코드가 있으며, active 필드는 사용자 활성 여부를 나타냅니다.

2.1 테스트 환경

  • 데이터베이스: MySQL 8.0
  • 서버 사양: 8 CPU, 32GB RAM
  • 테스트 테이블 구조:
  CREATE TABLE users (
      id INT AUTO_INCREMENT PRIMARY KEY,
      name VARCHAR(100),
      active BOOLEAN
  );


INSERT INTO users (name, active)
SELECT FLOOR(RAND() \* 1000000), 1
FROM
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t1,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t2,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t3,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t4,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t5,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t6
LIMIT 900000;

INSERT INTO users (name, active)
SELECT FLOOR(RAND() \* 1000000), 0
FROM
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t1,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t2,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t3,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t4,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t5,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t6
LIMIT 10000;

INSERT INTO users (name, active)
SELECT FLOOR(RAND() \* 1000000), NULL
FROM
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t1,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t2,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t3,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t4,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t5,
 (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) AS t6
LIMIT 90000;

2.2 테스트 시나리오

  1. 조건을 만족하는 행이 적은 경우: 전체 레코드 중 약 1%만 active = 0인 상황
SELECT COUNT(*) FROM users WHERE active = 0;
SELECT EXISTS(SELECT 1 FROM users WHERE active = 0);
  1. 조건을 만족하는 행이 많은 경우: 전체 레코드 중 약 90%가 active = 1인 상황
SELECT COUNT(*) FROM users WHERE active = 1;
SELECT EXISTS(SELECT 1 FROM users WHERE active = 1);

두 가지 시나리오에서 동일한 조건으로 COUNTEXISTS의 성능을 측정합니다.

2.3 쿼리 실행 시간 측정

MySQL에서 성능을 비교하기 위해, 쿼리 실행 시간을 측정합니다. 이를 위해 COUNTEXISTS를 사용한 각각의 쿼리를 실행하고, EXPLAIN 명령어를 통해 MySQL 옵티마이저가 각 쿼리를 어떻게 처리하는지 확인할 수 있습니다.

2.4 실험 결과 분석

  • 조건을 만족하는 행이 적은 경우:
    • COUNT: 테이블 내 모든 행을 스캔하므로 시간이 오래 걸립니다.
    • EXISTS: 조건을 만족하는 첫 번째 행을 찾으면 바로 종료되므로, 실행 시간이 매우 짧습니다.
      쿼리 실행 시간
      COUNT(*) 90ms
      EXISTS 25ms
  • 조건을 만족하는 행이 많은 경우:
    • COUNT: 여전히 모든 행을 스캔해야 하므로 시간이 많이 걸립니다.
    • EXISTS: 첫 번째로 조건을 만족하는 행이 빠르게 발견되기 때문에 성능이 훨씬 빠릅니다.
      쿼리 실행 시간
      COUNT(*) 100ms
      EXISTS 25ms

2.5 결론

COUNT는 모든 행을 검사해야 하기 때문에 조건을 만족하는 행이 적든 많든 시간이 많이 걸리는 반면, EXISTS는 조건을 만족하는 첫 번째 행을 찾으면 즉시 종료되므로 성능이 훨씬 뛰어납니다. 특히 조건을 만족하는 데이터가 적을수록 EXISTS의 성능이 훨씬 우수합니다.

3. 실무에서의 사용 기준

3.1 EXISTS 사용 시점

  • 존재 여부만 확인할 때: 데이터의 정확한 개수가 필요하지 않고, 조건에 맞는 행이 존재하는지만 확인하면 될 때 EXISTS를 사용하세요.
  • 조건을 만족하는 데이터가 적을 때: EXISTS는 조건을 만족하는 첫 번째 데이터를 찾으면 쿼리를 종료하기 때문에, 빠른 결과가 필요할 때 적합합니다.

3.2 COUNT 사용 시점

  • 정확한 개수를 확인할 때: 데이터의 정확한 개수를 확인해야 한다면 COUNT를 사용해야 합니다. 예를 들어, 특정 조건을 만족하는 데이터의 개수를 UI에 표시해야 하는 경우에 적합합니다.
  • 데이터가 적은 경우: 데이터셋이 상대적으로 작고, 모든 데이터를 조회해도 성능에 큰 영향을 미치지 않는 경우에는 COUNT를 사용할 수 있습니다.

4. 쿼리 최적화 방법

4.1 인덱스 활용

COUNTEXISTS 모두 인덱스를 적절히 활용하면 성능을 크게 향상시킬 수 있습니다. 특히 EXISTS는 인덱스를 사용할 경우 조건을 만족하는 첫 번째 행을 훨씬 더 빠르게 찾을 수 있습니다.

CREATE INDEX idx_active ON users (active);

인덱스가 있는 경우, MySQL 옵티마이저는 조건을 만족하는 첫 번째 데이터를 빠르게 찾기 위해 인덱스를 활용합니다. 인덱스를 사용하면 COUNTEXISTS 모두 성능이 개선됩니다.

4.2 불필요한 조회 컬럼 제거

EXISTS는 결과를 반환할 필요가 없으므로, 불필요한 데이터를 조회하지 않도록 쿼리를 최소화해야 합니다. 예를 들어, SELECT 1처럼 단순한 값을 반환하도록 최적화하면 성능이 더 좋아집니다.

SELECT EXISTS(SELECT 1 FROM users WHERE active = 1);

4.3 테이블 설계 최적화

테이블에 너무 많은 데이터가 쌓이면 성능 저하가 발생할 수 있습니다. 이럴 경우 파티셔닝(partitioning) 또는 샤딩(sharding) 전략을 사용하여 테이블을 분리하면 성능을 개선할 수 있습니다.

5. MySQL 옵티마이저의 역할

MySQL 옵티마이저는 쿼리를 실행할 때 가장 효율적인 경로를 선택하는 역할을 합니다. COUNTEXISTS 쿼리를 실행할 때 옵티마이저가 어떻게 쿼리를 처리하는지 이해하는 것은 쿼리 최적화에 매우 중요합니다.

  • COUNT(*)의 경우: 옵티마이저는 테이블의 모든 행을 검사하며, 조건에 맞는 행을 하나씩 카운트합니다.
  • EXISTS의 경우: 옵티마이저는 조건을 만족하는 첫 번째 행을 찾으면 즉시 쿼리를 종료하므로, 불필요한 행을 스캔하지 않습니다.

6. 결론

COUNTEXISTS는 각각의 장단점이 있으며, 사용 목적과 데이터 상황에 따라 적절하게 선택해야 합니다. EXISTS는 존재 여부를 확인하는 데 매우 효율적이며, 특히 조건을 만족하는 데이터가 적을 때 성능이 뛰어납니다. 반면에 COUNT는 정확한 데이터 개수를 확인해야 할 때 유용하지만, 성능 최적화를 위해 인덱스를 사용하고, 쿼리를 최대한 단순하게 유지하는 것이 중요합니다.

성능이 중요한 프로젝트에서는 두 함수의 성능 차이를 명확히 이해하고, 쿼리 작성 시 상황에 맞는 최적의 선택을 하는 것이 필수적입니다.