no image
[DB 성능 최적화] 실전 SQL 튜닝
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.유저 이름으로 특정 기간에 작성된 글 검색기본 테이블 생성DROP TABLE IF EXISTS posts;DROP TABLE IF EXISTS users;CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);CREATE TABLE posts ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, create..
2024.08.24
no image
[DB 성능 최적화] 기본 SQL 튜닝
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.많은 데이터를 조회하는 단건 쿼리데이터를 조회할 때 한 번에 너무 많은 데이터를 조회하는 건 아닌지 체크LIMIT, WHERE문 등을 활용해서 한 번에 조회하는 데이터의 수를 줄이는 방법을 고려 테이블 생성DROP TABLE IF EXISTS users; # 기존 테이블 삭제CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), age INT); 100만건의 랜덤 데이터 삽입-- 높은 재귀(반복) 횟수를 허용하도록 설정-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다...
2024.08.23
no image
[DB 성능 최적화] 실행 계획(Explain)
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.실행 계획(Explain) 개념옵티마이저가 SQL문을 어떤 방식으로 어떻게 처리할 지를 계획한 걸 의미한다.이 실행 계획을 보고 비효율적으로 처리하는 방식이 있는 지 점검하고, 비효율적인 부분이 있다면 더 효율적인 방법으로 SQL문을 실행하게끔 튜닝을 하는 게 목표다. 실행 계획(Explain)은 데이터베이스가 SQL 쿼리를 실행할 때 어떤 경로로 데이터에 접근하고 처리하는지를 보여주는 일종의 “설계도”라고 할 수 있다.즉, 쿼리가 어떻게 실행될지에 대한 세부적인 계획을 제공한다. 쉽게 설명하자면 데이터베이스에 어떤 SQL 쿼리를 보내면, 데이터베이스는 그 쿼리를 실행할 때 최..
2024.08.22
no image
[DB 성능 최적화] 인덱스(Index)
이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.MySQL 아키텍쳐MySQL의 아키텍처를 간단하게 표현하자면 위와 같다.클라이언트가 DB에 SQL 요청을 보낸다. MySQL 엔진에서 옵티마이저가 SQL문을 분석한 뒤 빠르고 효율적으로 데이터를 가져올 수 있는 계획을 세운다. 어떤 순서로 테이블에 접근할 지, 인덱스를 사용할 지, 어떤 인덱스를 사용할 지 등을 결정한다.(옵티마이저가 세운 계획은 완벽하지 않다. 따라서 SQL 튜닝이 필요하다.)옵티마이저가 세운 계획을 바탕으로 스토리지 엔진에서 데이터를 가져온다. (DB 성능에 문제가 생기는 대부분의 원인은 스토리지 엔진으로부터 데이터를 가져올 때 발생한다. 데이터를 찾기가 어..
2024.08.21
no image
[MySQL] 인덱스(Index)
이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다. 인덱스(index)는 데이터를 빠르게 찾을 수 있도록 도와주는 도구로, 실무에서는 현실적으로 인덱스 없이 데이터베이스 운영이 불가능하다. 인덱스에는 두 가지 종류가 있다. 클러스터형 인덱스(Clustered Index) 보조 인덱스(Secondary Index) 인덱스의 개념 책을 예로 들어보면 책의 내용 중 'UNIQUE'에 대해서 찾아보고 싶다면 제일 뒤에 수록되어 있는 찾아보기를 찾아보는 것이다. 찾아보기는 ABC 또는 가나다 순으로 이미 정렬되어 있어 'U' 부분을 살펴보면 쉽게 'UNIQUE' 단어를 찾을 수 있고, 단어 옆에 본문의 페이지 번호가 적혀 있어서 원하는 내용으로 빨리..
2023.12.06
no image
[MySQL] 뷰(View)
이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다. 뷰는 데이터베이스 개체 중에 하나이다. 모든 데이터베이스 개체는 테이블과 관련이 있지만, 특히 뷰는 테이블과 아주 밀접하게 연관되어 있다. 뷰는 한 번 생성해 놓으면 테이블이라고 생각해도 될 정도로 사용자들의 입장에서는 테이블과 거의 동일한 개체로 취급한다. 뷰는 테이블처럼 데이터를 가지고 있지 않다. 뷰의 실체는 SELECT 문으로 만들어져 있기 때문에 뷰에 접근하는 순간 SELECT가 실행되고 그 결과가 화면에 출력되는 방식이다. 뷰와 테이블의 관계는 바로 가기 아이콘과 실제 프로그램의 관계와 유사하다. 뷰는 단순 뷰와 복합 뷰로 나뉜다. 단순 뷰 : 하나의 테이블과 연관된 뷰 복합 뷰..
2023.12.05
no image
[MySQL] SQL 테이블 제약조건(기본키, 외래키, 고유키)
이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다. 테이블을 만들 때는 테이블의 구조에 필요한 제약조건을 설정해줘야 한다. 기본 키(Primary Key)는 학번, 아이디, 사번 등과 같은 고유한 번호를 의미하는 column에 지정한다. 외래 키(Foreign Key)는 기본키와 연결되는 column에 지정한다. 이메일, 휴대폰 번호와 같이 중복되지 않는 열에는 고유 키(Unique)를 지정할 수 있다. 회원의 평균 키를 넣는다고 가정할 때, 당연히 평균 키는 2m를 넘지 않을 것이다. 이때 실수로 200cm을 입력하는 것을 방지하는 제약 조건이 체크(Check)이다. 국내에서 서비스하는 프로그램을 만든다고 하면, 회원 테이블에 국적은 대부..
2023.12.04
no image
[MySQL] 테이블 생성하기
이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다. 테이블은 표 형태로 구성된 2차원 구조로, 행과 열로 구성되어 있다. 행은 row나 recode라고 부르며, 열은 column 또는 field라고 부른다. 테이블을 생성하기 전에 테이블의 구조를 정의해야 한다. 데이터 형식을 활용해서 각 열에 가장 적합한 데이터 형식을 지정한다. 회원 테이블 생성 SQL CREATE TABLE member -- 회원 테이블 ( mem_id CHAR(8) NOT NULL PRIMARY KEY, mem_name VARCHAR(10) NOT NULL, mem_number TINYINT NOT NULL, addr CHAR(2) NOT NULL, phone1 CHA..
2023.12.03

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


유저 이름으로 특정 기간에 작성된 글 검색

기본 테이블 생성

DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS users;

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    user_id INT,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

 

더미 데이터 추가

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- users 테이블에 더미 데이터 삽입
INSERT INTO users (name, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- posts 테이블에 더미 데이터 삽입
INSERT INTO posts (title, created_at, user_id)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('Post', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at, -- 최근 10년 내의 임의의 날짜와 시간 생성
    FLOOR(1 + RAND() * 50000) AS user_id -- 1부터 50000 사이의 난수로 급여 생성
FROM cte;

 

기존 SQL문 성능 측정

SELECT p.id, p.title, p.created_at
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE u.name = 'User0000046'
AND p.created_at BETWEEN '2022-01-01' AND '2024-03-07';

약 150ms 정도의 시간이 소요

 

실행 계획 조회

EXPLAIN SELECT p.id, p.title, p.created_at
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE u.name = 'User0000046'
AND p.created_at BETWEEN '2022-01-01' AND '2024-03-07';

풀 테이블 스캔을 하기 때문에 인덱스를 추가해야 한다.

인덱스를 추가할 수 있는 컬럼이 users.name과 posts.created_at이 있다.

일단 둘 다 인덱스로 추가해보자. 

 

성능 개선을 위한 인덱스 생성

CREATE INDEX idx_name ON users (name);
CREATE INDEX idx_created_at ON posts (created_at);

옵티마이저는 posts.created_at 인덱스가 존재하는 걸 알지만 굳이 사용하지 않는 게 효율적이라고 판단했다.

그렇기 때문에 사용하지 않는 인덱스는 삭제해주자. 

ALTER TABLE posts DROP INDEX idx_created_at;

 

다시 성능 측정

150ms에서 20ms로 성능이 많이 개선되었다. 

 

특정 부서에서 최대 연봉을 가진 사용자들 조회

테이블 생성

DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

 

더미 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 100000) AS salary,    -- 1부터 100000 사이의 난수로 나이 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

기존 SQL문 성능 측정

SELECT *
FROM users
WHERE salary = (SELECT MAX(salary) FROM users)
AND department IN ('Sales', 'Marketing', 'IT');

약 280ms 정도 소요

 

실행 계획 조회

type이 ALL -> 풀 테이블 스캔

인덱스를 활용해서 풀 테이블 스캔을 하지 않도록 바꿔보자.

 

인덱스 생성

데이터 액세스 수를 크게 줄일 수 있는 컬럼은 중복 정도가 낮은 컬럼이다. 따라서 salary로 인덱스를 생성

CREATE INDEX idx_salary ON users (salary);

 

성능 측정

SELECT *
FROM users
WHERE salary = (SELECT MAX(salary) FROM users)
AND department IN ('Sales', 'Marketing', 'IT');

280ms에서 25ms 정도로 성능이 향상

 

실행 계획 조회

EXPLAIN SELECT *
FROM users
WHERE salary = (SELECT MAX(salary) FROM users)
AND department IN ('Sales', 'Marketing', 'IT');

인덱스를 활용해서 데이터를 액세스 했고, 액세스 수도 6개로 확 줄었다. 

 

부서별 최대 연봉을 가진 사용자들 조회

테이블 생성

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

 

더미 데이터 생성

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 100000) AS salary,    -- 1부터 100000 사이의 난수로 나이 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

SQL문 성능 체크

SELECT u.id, u.name, u.department, u.salary, u.created_at
FROM users u
JOIN (
    SELECT department, MAX(salary) AS max_salary
    FROM users
    GROUP BY department
) d ON u.department = d.department AND u.salary = d.max_salary;

약 600ms 정도 걸린다. 

 

실행계획 조회

EXPLAIN SELECT u.*
FROM users u
JOIN (
    SELECT department, MAX(salary) AS max_salary
    FROM users
    GROUP BY department
) d ON u.department = d.department AND u.salary = d.max_salary;

JOIN 문 내부에 있는 서브쿼리를 실행시킬 때 풀 테이블 스캔이 이뤄어졌음을 알 수 있다. 

 

성능 개선

GROUP BY department는 department를 기준으로 정렬을 시킨 뒤에 MAX(salary) 값을 구하게 된다. 이 때, MAX(salary)를 구하기 위해 이리저리 찾아다닐 수 밖에 없다. 

이를 해결하기 위해 (department, salary)의 멀티 컬럼 인덱스가 있으면 department를 기준으로 정렬을 시키는 작업을 하지 않아도 되고, 심지어 MAX(salary)도 빠르게 찾을 수 있다. 멀티 컬럼 인덱스를 생성해보자.

CREATE INDEX idx_department_salary ON users (department, salary);

 

다시 성능 측정

SELECT u.*
FROM users u
JOIN (
    SELECT department, MAX(salary) AS max_salary
    FROM users
    GROUP BY department
) d ON u.department = d.department AND u.salary = d.max_salary;

600ms에서 20ms로 30배 정도 성능이 향상됐다. 

 

실행 계획을 조회

실행 계획을 조회해봐도 인덱스를 잘 활용해서 데이터를 찾고 있고, 접근한 rows 자체도 훨씬 적어졌다. 

 

특정 유저의 2023년 주문 데이터 조회

테이블 생성

DROP TABLE IF EXISTS users; 
DROP TABLE IF EXISTS orders; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    ordered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    user_id INT,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

 

더미 데이터 생성

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- users 테이블에 더미 데이터 삽입
INSERT INTO users (name, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- orders 테이블에 더미 데이터 삽입
INSERT INTO orders (ordered_at, user_id)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS ordered_at, -- 최근 10년 내의 임의의 날짜와 시간 생성
    FLOOR(1 + RAND() * 1000000) AS user_id    -- 1부터 1000000 사이의 난수로 급여 생성
FROM cte;

 

기존 SQL문 성능 조회

SELECT *
FROM orders
WHERE YEAR(ordered_at) = 2023
ORDER BY ordered_at
LIMIT 30;

약 180ms 정도가 걸린다. 

 

실행계획 조회

EXPLAIN SELECT *
FROM orders
WHERE YEAR(ordered_at) = 2023
ORDER BY ordered_at
LIMIT 30;

 

 

성능 개선

ordered_at에 인덱스를 추가하면 풀 테이블 스캔을 막을 수 있을 것 같다. 그래서 인덱스를 추가해보자. 

CREATE INDEX idx_ordered_at ON orders (ordered_at);

 

700ms로 더 느려졌다.

 

실행계획을 살펴보면

인덱스 풀 스캔을 했다. 풀 테이블 스캔 대신에 인덱스 풀 스캔을 하면 더 빨라져야 한다.  또한 WHERE문으로 특정 범위의 데이터만 접근하면 인덱스 풀 스캔이 아니라 인덱스 레인지 스캔이 나와야한다.

 

문제는 인덱스의 컬럼을 가공해서 사용했기 때문이다. 

그래서 인덱스를 제대로 활용 하지 못한 것이다. 인덱스의 컬럼을 가공하지 않게 SQL문을 다시 수정해보자. 

 

성능 개선2

SELECT *
FROM orders
WHERE ordered_at >= '2023-01-01 00:00:00' 
  AND ordered_at < '2024-01-01 00:00:00'
ORDER BY ordered_at
LIMIT 30;

180ms에서 20ms로 9배 가량 성능을 향상시켰다. 

 

실행 계획도 인덱스 레인지 스캔으로 바뀌었다.

 

 

2024년 1학기 평균 성적이 100점인 학생 조회

테이블 생성

DROP TABLE IF EXISTS scores;
DROP TABLE IF EXISTS subjects;
DROP TABLE IF EXISTS students;

CREATE TABLE students (
    student_id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);

CREATE TABLE subjects (
    subject_id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100)
);

CREATE TABLE scores (
    score_id INT AUTO_INCREMENT PRIMARY KEY,
    student_id INT,
    subject_id INT,
    year INT,
    semester INT,
    score INT,
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (subject_id) REFERENCES subjects(subject_id)
);

 

더미 데이터 생성

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- students 테이블에 더미 데이터 삽입
INSERT INTO students (name, age)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('Student', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 100) AS age -- 1부터 100 사이의 랜덤한 점수 생성
FROM cte;

-- subjects 테이블에 과목 데이터 삽입
INSERT INTO subjects (name)
VALUES
    ('Mathematics'),
    ('English'),
    ('History'),
    ('Biology'),
    ('Chemistry'),
    ('Physics'),
    ('Computer Science'),
    ('Art'),
    ('Music'),
    ('Physical Education'),
    ('Geography'),
    ('Economics'),
    ('Psychology'),
    ('Philosophy'),
    ('Languages'),
    ('Engineering');

-- scores 테이블에 더미 데이터 삽입
INSERT INTO scores (student_id, subject_id, year, semester, score)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    FLOOR(1 + RAND() * 1000000) AS student_id,  -- 1부터 1000000 사이의 난수로 학생 ID 생성
    FLOOR(1 + RAND() * 16) AS subject_id,             -- 1부터 16 사이의 난수로 과목 ID 생성
    YEAR(NOW()) - FLOOR(RAND() * 5) AS year,   -- 최근 5년 내의 임의의 연도 생성
    FLOOR(1 + RAND() * 2) AS semester,                -- 1 또는 2 중에서 랜덤하게 학기 생성
    FLOOR(1 + RAND() * 100) AS score -- 1부터 100 사이의 랜덤한 점수 생성
FROM cte;

 

SQL문 성능 측정

SELECT 
    st.student_id,
    st.name,
    AVG(sc.score) AS average_score
FROM 
    students st
JOIN 
    scores sc ON st.student_id = sc.student_id
GROUP BY 
    st.student_id,
    st.name,
    sc.year,
    sc.semester
HAVING 
    AVG(sc.score) = 100
    AND sc.year = 2024
    AND sc.semester = 1;

약 4000ms 정도의 시간이 걸린다. 

 

성능 개선

HAVING절에 굳이 있지 않아도 될 조건이 HAVING 절에 포함되어 있다.

WHERE 문으로 옮길 수 있는 조건을 옮긴 뒤 성능을 다시 테스트

SELECT 
    st.student_id,
    st.name,
    AVG(sc.score) AS average_score
FROM 
    students st
JOIN 
    scores sc ON st.student_id = sc.student_id
WHERE 
    sc.year = 2024
    AND sc.semester = 1
GROUP BY 
    st.student_id,
    st.name
HAVING 
    AVG(sc.score) = 100;

WHERE문으로 옮길 수 있는 조건을 옮기면서, 불필요한 GROUP BY 컬럼을 삭제했다. 

450ms 정도로 성능이 향상됐다. 

 

좋아요 많은 순으로 게시글 조회

기본 테이블 생성

DROP TABLE IF EXISTS likes;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS posts;


CREATE TABLE posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE likes (
    id INT AUTO_INCREMENT PRIMARY KEY,
    post_id INT,
    user_id INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (post_id) REFERENCES posts(id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

 

더미 데이터 추가

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- posts 테이블에 더미 데이터 삽입
INSERT INTO posts (title, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('Post', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- users 테이블에 더미 데이터 삽입
INSERT INTO users (name, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- likes 테이블에 더미 데이터 삽입
INSERT INTO likes (post_id, user_id, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    FLOOR(1 + RAND() * 1000000) AS post_id,    -- 1부터 1000000 사이의 난수로 급여 생성
    FLOOR(1 + RAND() * 1000000) AS user_id,    -- 1부터 1000000 사이의 난수로 급여 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

SQL 성능 측정

SELECT
    p.id,
    p.title,
    p.created_at,
    COUNT(l.id) AS like_count
FROM
    posts p
INNER JOIN
    likes l ON p.id = l.post_id
GROUP BY
    p.id, p.title, p.created_at
ORDER BY
    like_count DESC
LIMIT 30;

대략 2600ms 정도의 시간이 소요되고 있다.

 

실행 계획 조회

EXPLAIN SELECT
    p.id,
    p.title,
    p.created_at,
    COUNT(l.id) AS like_count
FROM
    posts p
INNER JOIN
    likes l ON p.id = l.post_id
GROUP BY
    p.id, p.title, p.created_at
ORDER BY
    like_count DESC
LIMIT 30;

 

실행 계획 세부 내용 조회

EXPLAIN ANALYZE SELECT
    p.id,
    p.title,
    p.created_at,
    COUNT(l.id) AS like_count
FROM
    posts p
INNER JOIN
    likes l ON p.id = l.post_id
GROUP BY
    p.id, p.title, p.created_at
ORDER BY
    like_count DESC
LIMIT 30;
-> Limit: 30 row(s)  (actual time=2775..2775 rows=30 loops=1)
    -> Sort: like_count DESC, limit input to 30 row(s) per chunk  (actual time=2775..2775 rows=30 loops=1)
        -> Table scan on <temporary>  (actual time=2675..2745 rows=575582 loops=1)
            -> Aggregate using temporary table  (actual time=2675..2675 rows=575582 loops=1)
                -> Nested loop inner join  (cost=449599 rows=997632) (actual time=0.126..920 rows=1e+6 loops=1)
                    -> Table scan on p  (cost=100428 rows=997632) (actual time=0.0937..115 rows=1e+6 loops=1)
                    -> Covering index lookup on l using post_id (post_id=p.id)  (cost=0.25 rows=1) (actual time=602e-6..703e-6 rows=1 loops=1e+6)

세부 실행 계획을 보니 INNER JOIN과 GROUP BY(Aggreagte using temporary table)에 시간을 많이 사용했다.

이 이유를 추측하면 INNER JOIN, GROUP BY를 수행할 때 풀 테이블 스캔으로 조회한 데이터 100만개를 가지고 처리를 해서 오래 걸렸다고 추측할 수 있다.

 

성능 개선

SELECT p.*, l.like_count
FROM posts p
INNER JOIN
	(SELECT post_id, count(post_id) AS like_count FROM likes l
	GROUP BY l.post_id
	ORDER BY like_count DESC
	LIMIT 30) l
ON p.id = l.post_id;
  1. 먼저 likes 테이블에서 post_id를 기준으로 GROUP BY를 수행하여 각 게시물에 대한 좋아요 수를 집계한다. 이때 GROUP BY는 post_id만을 사용하므로, 인덱스를 활용하여 효율적으로 조회할 수 있다. 즉, 테이블의 모든 데이터를 읽지 않고, 인덱스만으로도 필요한 정보를 얻을 수 있기 때문에 성능이 더 빠르다. 이것을 커버링 인덱스라고 한다.
  2. 그런 다음, 좋아요 수가 많은 30개의 post_id를 찾은 후, 이를 posts 테이블과 INNER JOIN을 통해 결합한다. 이 과정에서 미리 필터링된 30개의 행만을 사용하여 INNER JOIN을 수행하므로 데이터 액세스가 훨씬 줄어들어 성능이 최적화된다.

 

성능도 2500ms에서 170ms로 아주 많이 개선되었다. 

 

성능 개선 후 실행 계획

EXPLAIN SELECT p.*, l.like_count
FROM posts p
INNER JOIN
	(SELECT post_id, count(post_id) AS like_count FROM likes l
	GROUP BY l.post_id
	ORDER BY like_count DESC
	LIMIT 30) l
ON p.id = l.post_id;

풀 테이블 스캔으로 액세스한 데이터의 수가 30으로 줄었다. 그리고 l이라는 테이블에서 인덱스 풀 스캔을 했음을 알 수 있다.

즉, 대부분의 데이터를 원래 풀 테이블 스캔을 하던 걸 풀 인덱스 스캔으로 고친 것이다. 

 

실행 계획 세부 내용 조회

EXPLAIN ANALYZE SELECT p.*, l.like_count
FROM posts p
INNER JOIN
	(SELECT post_id, count(post_id) AS like_count FROM likes l
	GROUP BY l.post_id
	ORDER BY like_count DESC
	LIMIT 30) l
ON p.id = l.post_id;
-> Nested loop inner join  (cost=20.5 rows=30) (actual time=227..227 rows=30 loops=1)
    -> Filter: (l.post_id is not null)  (cost=0.196..5.88 rows=30) (actual time=227..227 rows=30 loops=1)
        -> Table scan on l  (cost=2.5..2.5 rows=0) (actual time=227..227 rows=30 loops=1)
            -> Materialize  (cost=0..0 rows=0) (actual time=227..227 rows=30 loops=1)
                -> Limit: 30 row(s)  (actual time=227..227 rows=30 loops=1)
                    -> Sort: like_count DESC, limit input to 30 row(s) per chunk  (actual time=227..227 rows=30 loops=1)
                        -> Stream results  (cost=200702 rows=573484) (actual time=0.0883..199 rows=575582 loops=1)
                            -> Group aggregate: count(l.post_id)  (cost=200702 rows=573484) (actual time=0.0837..163 rows=575582 loops=1)
                                -> Covering index scan on l using idx_post_id  (cost=100912 rows=997899) (actual time=0.074..101 rows=1e+6 loops=1)
    -> Single-row index lookup on p using PRIMARY (id=l.post_id)  (cost=0.392 rows=1) (actual time=0.0019..0.00192 rows=1 loops=30)

실제 커버링 인덱스를 활용했음을 알 수 있다. 그리고 풀 테이블 스캔의 데이터보다 훨씬 크기가 작은 커버링 인덱스만을 활용해서 GROUP BY를 실행하니 훨씬 속도가 빠른 걸 알 수 있다. 

'데이터베이스 > MySQL' 카테고리의 다른 글

[DB 성능 최적화] 기본 SQL 튜닝  (0) 2024.08.23
[DB 성능 최적화] 실행 계획(Explain)  (0) 2024.08.22
[DB 성능 최적화] 인덱스(Index)  (0) 2024.08.21
[MySQL] 인덱스(Index)  (1) 2023.12.06
[MySQL] 뷰(View)  (1) 2023.12.05

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


많은 데이터를 조회하는 단건 쿼리

  • 데이터를 조회할 때 한 번에 너무 많은 데이터를 조회하는 건 아닌지 체크
  • LIMIT, WHERE문 등을 활용해서 한 번에 조회하는 데이터의 수를 줄이는 방법을 고려

 

테이블 생성

DROP TABLE IF EXISTS users; # 기존 테이블 삭제

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);

 

100만건의 랜덤 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, age)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')),   -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 1000) AS age    -- 1부터 1000 사이의 난수로 나이 생성
FROM cte;

 

데이터 조회

데이터를 만개정도 조회하면 대략 200ms 정도 걸린다.

SELECT * FROM users LIMIT 10000;

 

하지만 데이터를 10개 조회하면 대략 20ms 정도 걸린다.

SELECT * FROM users LIMIT 10;

 

실제 페이스북, 인스타그램의 서비스를 보더라도 한 번에 모든 게시글의 데이터를 불러오지 않는다.

스크롤을 내리면서 필요한 데이터를 그때그때 로딩하는 방식이다. 다른 커뮤니티 서비스의 게시판을 보면 페이지네이션을 적용시켜서 일부 데이터만 조회하려고 한다.

그 이유가 조회하는 데이터의 개수가 성능에 많은 영향을 끼치기 때문이다.

직관적으로 생각해보면 100만개의 데이터에서 1개의 데이터를 찾는 것보다 10,000개의 데이터를 찾는 게 오래 걸릴 수 밖에 없다.

 

WHERE 문 튜닝

  • WHERE문의 부등호(>, <, ≤, ≥, =), IN, BETWEEN, LIKE와 같은 곳에서 사용되는 컬럼은 인덱스를 사용했을 때 성능이 향상될 가능성이 높다. 
  • 데이터 액세스(rows)를 크게 줄일 수 있는 컬럼은 중복 정도가 낮은 컬럼이다. 따라서 중복 정도가 낮은 컬럼을 골라서 인덱스를 생성
  • "단일 컬럼에 설정하는 일반 인덱스"를 설정했을 때와 "멀티 컬럼 인덱스"를 설정했을 때의 성능 차이가 별로 나지 않는다면, 멀티 컬럼 인덱스를 사용하지 말고 일반 인덱스를 활용하자.

 

조건이 한개

 

최근 3일 이내에 가입한 유저 조회

 

테이블 생성

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

 

100만건의 랜덤 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- 잘 생성됐는 지 확인
SELECT COUNT(*) FROM users;
SELECT * FROM users LIMIT 10;

 

성능 측정

 

 

 

SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

대략 200ms 정도의 시간이 걸린다.

 

실행 계획 조회

EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

type이 ALL이고 rows가 997,632인 걸 보니, 전체 데이터를 처음부터 끝까지 다 탐색해서 데이터를 찾아내는 풀 테이블 스캔을 했다.

풀 테이블 스캔은 성능상으로 비효율적이다. 

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

 

성능 개선을 위한 인덱스 추가

created_at이 만약 정렬이 되어 있다면 최근 3일 이내에 가입한 유저를 훨씬 빠르게 조회할 수 있을 것이다.

그래서 created_at을 기준으로 정렬을 시키게끔 도와주는 인덱스를 추가한다.

CREATE INDEX idx_created_at ON users (created_at);

 

다시 실행 계획을 조회하면 인덱스 레인지 스캔을 한 것을 확인할 수 있다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

 

조건이 여러개

 

Sales 부서이면서 최근 3일 이내에 가입한 유저 조회

 

테이블 생성

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

 

100만건의 랜덤 데이터 삽입

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

-- 잘 생성됐는 지 확인
SELECT COUNT(*) FROM users;
SELECT * FROM users LIMIT 10;

 

성능 측정

SELECT * FROM users
WHERE department = 'Sales'
AND created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)

약 200ms 정도의 시간이 걸린다.

 

실행 계획 조회

# 실행 계획
EXPLAIN SELECT * FROM users
WHERE department = 'Sales'
AND created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

# 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
WHERE department = 'Sales'
AND created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY);

type이 ALL인걸 보니 풀 테이블 스캔을 했다. 비효율적이다. rows가 996,810인 걸 보니 전체 데이터를 대부분 액세스 했다는 걸 추측할 수 있다. 

 

-> Filter: ((users.department = 'Sales') and (users.created_at >= <cache>((now() - interval 3 day))))  
(cost=93877 rows=33224) (actual time=12.6..205 rows=114 loops=1)
    -> Table scan on users  (cost=93877 rows=996810) 
    (actual time=0.0994..152 rows=1e+6 loops=1)

EXPLAIN ANLYZE는 아래 화살표부터 위로 읽어나가야 한다.

  1. 풀 테이블 스캔을 했다.
    → 이 때, 액세스한 데이터의 개수는 1e+6(= 10의 6제곱 = 1,000,000)개 이다.
  2. 액세스한 1,000,000개의 데이터 중 department = ‘Sales’와 created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)을 만족하는 데이터를 필터링해온다.
    → 조건을 만족한 데이터의 개수는 rows는 114개이다.

 

성능 개선을 위한 인덱스 추가

인덱스를 추가하는 방법이 3가지가 있다.

  1. created_at 컬럼을 기준으로 인덱스 생성
  2. department 컬럼을 기준으로 인덱스 생성
  3. department, created_at 둘 다 인덱스 생성

결론부터 말하면 created_at 컬럼을 기준으로 인덱스를 생성해야 한다.

이유는 아래와 같다.

  1. department 컬럼을 기준으로 인덱스를 잡는 것보다 created_at 컬럼을 기준으로 인덱스를 잡는 것이 훨씬 적은 데이터를 가져온다.
    -department 컬럼: 고정된 값(예: ‘Sales’, ‘Engineering’)이 반복적으로 등장한다. 즉, 특정 값을 검색할 때 많은 행들이 중복되기 때문에, 인덱스를 사용해도 선택되는 데이터의 양이 많다. 따라서 department에 인덱스를 설정해도, 쿼리에서 너무 많은 데이터를 가져오게 되어 인덱스의 이점이 줄어든다.
    -created_at 컬럼: 시간 순서대로 연속적인 값이 기록된다. 범위 검색(예: 최근 3일, 1주일)을 하면 선택되는 데이터의 양이 훨씬 적다. 그러므로 created_at에 인덱스를 설정하면, 필요한 범위 내에서만 데이터를 가져오므로 성능이 크게 향상된다.
  2. 인덱스는 최소한으로 설정하는 것이 좋다.
    둘 다 인덱스를 설정하면 created_at 컬럼을 인덱스로 하나만 설정한 것과 동일하게 작동한다. 따라서 쓰기 성능을 저하시키지 않기 위해서 필요없는 department 컬럼은 인덱스로 설정할 필요가 없다.

 

또한 멀티 컬럼 인덱스를 고려할 수 있다.

하지만 이 경우에 멀티 컬럼 인덱스와 created_at 인덱스만 걸었을 때와 크게 성능 차이가 없다.

이런 경우에는 굳이 멀티 컬럼 인덱스를 사용하지 않고 단일 컬럼 인덱스만 적용시키는 것이 낫다.

 

인덱스가 작동하지 않는 경우

  • 넓은 범위의 데이터를 조회하는 경우, MySQL은 인덱스를 사용해서 조회하는 것보다 풀 테이블 스캔이 효과적이라고 판단한다. 
  • 인덱스 컬럼을 가공(함수 적용, 산술 연산, 문자역 조작 등)하면, MySQL은 해당 인덱스를 사용하지 못하는 경우가 많다. 따라서 인덱스를 적극 활용하기 위해서는 인덱스 컬럼 자체를 최대한 가공하지 않아야 한다. 



조회 범위가 너무 넓은 경우

DROP TABLE IF EXISTS users; # 기존 테이블 삭제

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);

 

-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, age)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')),   -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 1000) AS age    -- 1부터 1000 사이의 난수로 나이 생성
FROM cte;

 

인덱스 설정

CREATE INDEX idx_name ON users (name);

 

실행 계획 조회

EXPLAIN SELECT * FROM users 
ORDER BY name DESC;

분명 인덱스를 걸었음에도 왜 풀 테이블 스캔으로 데이터를 조회된다. 

그 이유는 옵티마이저가 넓은 범위의 데이터를 조회할 때는 인덱스를 활용하는 것이 비효율적이라고 판단한다. 인덱스를 활용하지 않고 풀 테이블 스캔으로 데이터를 찾을 때 훨씬 효율적이라고 판단한다. 

즉, 굳이 인덱스를 거쳤다가 각 원래 테이블의 데이터를 일일이 하나씩 찾아내는 것보다, 바로 원래 테이블에 접근해서 모든 데이터를 통째로 가져와서 정렬하는 게 효율적이라고 판단한 것이다. 실제 성능상으로도 풀 테이블 스캔을 통해 데이터를 가져오는 게 효율적이다.

 

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

 

 

인덱스 컬럼을 가공한 경우

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- users 테이블에 더미 데이터 삽입
INSERT INTO users (name, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 급여 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

인덱스 설정

CREATE INDEX idx_name ON users (name);
CREATE INDEX idx_salary ON users (salary);

 

실행 계획 조회

# User000000으로 시작하는 이름을 가진 유저 조회
EXPLAIN SELECT * FROM users
WHERE SUBSTRING(name, 1, 10) = 'User000000';

# 2달치 급여(salary)가 1000 이하인 유저 조회
SELECT * FROM users
WHERE salary * 2 < 1000
ORDER BY salary;

위의 실행 계획들을 실행시켜보면 인덱스를 활용하지 않고 풀 테이블 스캔으로 탐색하는 걸 확인할 수 있다. 인덱스를 활용하지 않기 때문에 비효율적으로 데이터를 조회한다. 

 

SQL문을 작성할 인덱스 컬럼을 가공(함수 적용, 산술 연산, 문자역 조작 등)하면, MySQL은 해당 인덱스를 활용하지 못하는 경우가 많다. 따라서 인덱스를 적극 활용하기 위해서는 인덱스 컬럼 자체를 최대한 가공하지 않아야 한다. 

 

인덱스 컬럼을 가공하지 않은 SQL 문

# User000000으로 시작하는 이름을 가진 유저 조회
EXPLAIN SELECT * FROM users
WHERE name LIKE 'User000000%';

# 2달치 급여(salary)가 1000 이하인 유저 조회
EXPLAIN SELECT * FROM users
WHERE salary < 1000 / 2
ORDER BY salary;

인덱스 컬럼을 가공하지 않아야 인덱스를 제대로 활용할 수 있게 된다.

 

ORDER BY문 튜닝

  • ORDER BY는 시간이 오래걸리는 작업이므로 최대한 피해주는 것이 좋다. 인덱스를 사용하면 미리 정렬을 해둔 상태이기 때문에, ORDER BY를 사용해서 정렬해야 하는 번거로운 작업을 피할 수 있다.
  • LIMIT 없이 큰 범위의 데이터를 조회해오는 경우 옵티마이저가 인덱스를 활용하지 않고 풀 테이블 스캔을 해버릴 수도 있다. 따라서 성능 효율을 위해 LIMIT을 통해 작은 데이터의 범위를 조회해오도록 항상 신경쓰자. 

 

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 나이 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

성능 측정

SELECT * FROM users
ORDER BY salary
LIMIT 100;

약 200ms 정도의 시간이 걸린다.

 

실행 계획 조회

# 실행 계획
EXPLAIN SELECT * FROM users
ORDER BY salary
LIMIT 100;

# 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
ORDER BY salary
LIMIT 100;
  • type이 ALL이다. 풀 테이블 스캔을 했음을 뜻한다.
  • ORDER BY는 시간이 오래걸리는 작업이므로 최대한 피해주는 것이 좋다. 왜냐하면 정렬이라는 작업 자체가 다른 작업에 비해서 부담스러운 작업이며 성능에 안 좋은 영향을 끼치는 요소 중 하나이기 때문이다.

그런데 만약 인덱스를 사용하면 미리 정렬을 해둔 상태이기 때문에, ORDER BY를 사용하여 정렬해야 하는 번거로운 작업을 피할 수 있다.

 

성능 개선을 위한 인덱스 추가

CREATE INDEX idx_salary ON users (salary);

 

실행 계획 조회

EXPLAIN SELECT * FROM users
ORDER BY salary
LIMIT 100;

  • 풀 테이블 스캔(type: ALL)이 아닌 인덱스 풀 스캔(type: index)을 활용해서 빠르게 데이터를 정렬해서 조회해왔다.
  • LIMIT 없이 큰 범위의 데이터를 조회해오는 경우 옵티마이저가 인덱스를 활용하지 않고 테이블 풀 스캔을 해버릴 수도 있다. 따라서 성능 효율을 위해 LIMIT을 통해 작은 데이터의 범위를 조회해오도록 항상 신경써야 한다.

 

실행 계획 세부 내용 조회

EXPLAIN ANALYZE SELECT * FROM users
ORDER BY salary
LIMIT 100;
-> Limit: 100 row(s)  
(cost=0.0918 rows=100) (actual time=0.732..0.759 rows=100 loops=1)
    -> Index scan on users using idx_salary
    (cost=0.0918 rows=100) (actual time=0.731..0.752 rows=100 loops=1)
  1. 인덱스 스캔으로 100개의 데이터에 대해서만 액세스 했다.
    (정렬 작업을 따로 하지 않았다. 왜냐하면 인덱스라서 이미 정렬이 되어 있기 때문이다.)
  2. Limit에 의해 100개의 데이터만 조회했다.

 

 

WHERE 문에 인덱스 VS ORDER BY 문에 인덱스

  • ORDER BY의 특징상 모든 데이터를 바탕으로 정렬을 해야 하기 때문에, 인덱스 풀 스캔 또는 테이블 풀 스캔을 활용할 수 밖에 없다. 
    이 때문에 ORDER BY문보다 WHERE문에 있는 컬럼에 인덱스를 걸었을 때 성능이 향상되는 경우가 많다. 
    (항상 그런 것은 아니니까 성능 측정과 실행 계획을 살펴야한다.)

 

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 나이 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

데이터 조회 성능 확인

SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

약 200ms 정도의 시간이 소요

 

실행 계획 조회

EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

type이 ALL인걸 보니 풀 테이블 스캔을 했다. 비효율적이다. 

 

성능 개선을 위한 인덱스 추가

SQL문만 봤을 때는 created_at, department, salary 컬럼에 인덱스를 걸 수 있는 선택지가 있다는 걸 알 수 있다.

어떤 컬럼에 거는 게 효율적인지는 하나씩 걸어보고 SQL문의 성능을 측정해서 판단해도 된다.

 

우선 created_at와 department 컬럼 중에서 인덱스를 걸었을 때 효율적인 컬럼은 created_at이라는 걸 예상할 수 있다.

왜냐하면 위의 SQL 문 조건을 봤을 때 department = ‘Sales’의 조건은 데이터 액세스 수가 많을 수 밖에 없다.

하지만 created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)의 조건은 데이터 액세스 수가 적다.

 

성능을 향상시키는 데 중요한 요소 중 하나는 데이터 액세스 수를 줄이는 것이다.

따라서 department보다 created_at에 인덱스를 생성하는 게 더 좋을 것으로 예상할 수 있다.

 

그렇다면 WHERE 문에 created_at 인덱스를 거는 것과 ORDER BY 문에 있는 salary에 인덱스를 거는 것 둘 중에 어느것이 더 효율적인지 고려해봐야 한다.

결론부터 말하면 WHERE 절에 있는 created_at에 인덱스를 거는 것이 ORDER BY절에 있는 salary에 인덱스를 거는 것 보다 더 효율적이다.

 

salary에 인덱스

CREATE INDEX idx_salary ON users (salary);
-- 성능 측정
SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획
EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

인덱스를 걸기 전보다 약 5배정도 느려진 것을 확인할 수 있다.

 

위의 실행 계획에서 type이 index이면서 salary 인덱스를 사용한 걸로 봐서 인덱스 풀 스캔을 했음을 알 수 있다.

 

-> Limit: 100 row(s) 
 (cost=9.09 rows=3.33) (actual time=23.8..1009 rows=100 loops=1)
    -> Filter: ((users.created_at >= <cache>((now() - interval 3 day))) and (users.department = 'Sales'))  
    (cost=9.09 rows=3.33) (actual time=23.8..1009 rows=100 loops=1)
        -> Index scan on users using idx_salary  
        (cost=9.09 rows=100) (actual time=1.03..943 rows=974891 loops=1)
  1. idx_salary 인덱스를 활용해 인덱스 풀 스캔을 했다. 이 인덱스를 활용했기 때문에 정렬 과정이 따로 필요없다. 
  2. 그런 뒤에 WHERE 조건을 만족시키는 데이터 100개를 필터링한다. 이 때, 인덱스에는 created_at, department 정보가 없기 때문에 실제 테이블에 접근해서 조건을 만족하는 지 확인해야한다. 풀 테이블 스캔과 거의 다를 바가 없다.
  3. 조건을 만족시키는 데이터 100개를 찾는 순간 더 이상 탐색을 하지 않는다. (LIMIT 100)

 

created_at에 인덱스

ALTER TABLE users DROP INDEX idx_salary; -- 기존 인덱스 삭제
CREATE INDEX idx_created_at ON users (created_at);
-- 성능 측정
SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획
EXPLAIN SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

-- 실행 계획 세부 내용
EXPLAIN ANALYZE SELECT * FROM users
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND department = 'Sales'
ORDER BY salary
LIMIT 100;

인덱스를 걸지 않은 200ms보다 약 6~7배 정도 빨라졌음을 알 수 있다. 

 

type이 range인걸 보니 인덱스 레인지 스캔을 활용했음을 알 수 있다. 

 

-> Limit: 100 row(s)  
(cost=504 rows=100) (actual time=8.94..8.96 rows=100 loops=1)
    -> Sort: users.salary, limit input to 100 row(s) per chunk  
    (cost=504 rows=1120) (actual time=8.94..8.95 rows=100 loops=1)
        -> Filter: (users.department = 'Sales')  
        (cost=504 rows=1120) (actual time=0.269..8.84 rows=104 loops=1)
            -> Index range scan on users using idx_created_at over ('2024-06-30 01:21:20' <= created_at), 
            with index condition: (users.created_at >= <cache>((now() - interval 3 day)))  
            (cost=504 rows=1120) (actual time=0.0537..8.54 rows=1120 loops=1)
  1. idx_created_at 인덱스를 활용해 인덱스 레인지 스캔을 했다. 이 때, users.created_at >= ((now() - interval 3 day))을 만족하는 데이터에만 액세스했다. (rows=1120)
  2. 1번 과정에서 액세스한 데이터 중에서 users.department = ‘Sales’을 만족하는 데이터를 필터링했다. (rows=104)
  3. 2번 과정에서 필터링한 104개의 데이터를 users.salary를 기준으로 정렬시켰다.
  4. LIMIT으로 인해 100개의 데이터만 가져왔다.

 

HAVING문 튜닝

  • HAVING문 대신에 WHERE문을 쓸 수 있는지 체크

 

DROP TABLE IF EXISTS users; 

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT,
    department VARCHAR(100),
    salary INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 높은 재귀(반복) 횟수를 허용하도록 설정
-- (아래에서 생성할 더미 데이터의 개수와 맞춰서 작성하면 된다.)
SET SESSION cte_max_recursion_depth = 1000000; 

-- 더미 데이터 삽입 쿼리
INSERT INTO users (name, age, department, salary, created_at)
WITH RECURSIVE cte (n) AS
(
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM cte WHERE n < 1000000 -- 생성하고 싶은 더미 데이터의 개수
)
SELECT 
    CONCAT('User', LPAD(n, 7, '0')) AS name,  -- 'User' 다음에 7자리 숫자로 구성된 이름 생성
    FLOOR(1 + RAND() * 100) AS age, -- 1부터 100 사이의 난수로 생성
    CASE 
        WHEN n % 10 = 1 THEN 'Engineering'
        WHEN n % 10 = 2 THEN 'Marketing'
        WHEN n % 10 = 3 THEN 'Sales'
        WHEN n % 10 = 4 THEN 'Finance'
        WHEN n % 10 = 5 THEN 'HR'
        WHEN n % 10 = 6 THEN 'Operations'
        WHEN n % 10 = 7 THEN 'IT'
        WHEN n % 10 = 8 THEN 'Customer Service'
        WHEN n % 10 = 9 THEN 'Research and Development'
        ELSE 'Product Management'
    END AS department,  -- 의미 있는 단어 조합으로 부서 이름 생성
    FLOOR(1 + RAND() * 1000000) AS salary,    -- 1부터 1000000 사이의 난수로 생성
    TIMESTAMP(DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3650) DAY) + INTERVAL FLOOR(RAND() * 86400) SECOND) AS created_at -- 최근 10년 내의 임의의 날짜와 시간 생성
FROM cte;

 

인덱스 생성

CREATE INDEX idx_age ON users (age);

 

조히 성능 확인

SELECT age, MAX(salary) FROM users
GROUP BY age
HAVING age >= 20 AND age < 30;

약 800ms 정도의 시간이 소요

 

실행 계획 조회

EXPLAIN SELECT age, MAX(salary) FROM users
GROUP BY age
HAVING age >= 20 AND age < 30;

type이 index인걸로 봐서 인덱스 풀 스캔을 하고 있다. 

 

세부 실행 계획 조회

EXPLAIN ANALYZE SELECT age, MAX(salary) FROM users
GROUP BY age
HAVING age >= 20 AND age < 30;
-> Filter: ((users.age >= 20) and (users.age < 30))  (cost=200263 rows=101) (actual time=208..882 rows=10 loops=1)
    -> Group aggregate: max(users.salary)  (cost=200263 rows=101) (actual time=38.4..882 rows=100 loops=1)
        -> Index scan on users using idx_age  (cost=100624 rows=996389) (actual time=1.53..850 rows=1e+6 loops=1)

 

 

성능 개선

SELECT age, MAX(salary) FROM users
WHERE age >= 20 AND age < 30
GROUP BY age;

HAVING 문 대신에 WHERE 문에 조건을 넣으니 약 150ms 정도 시간이 걸린다.

 

실행 계획 조회

EXPLAIN SELECT age, MAX(salary) FROM users
WHERE age >= 20 AND age < 30
GROUP BY age;

 

EXPLAIN ANALYZE SELECT age, MAX(salary) FROM users
WHERE age >= 20 AND age < 30
GROUP BY age;
-> Group aggregate: max(users.salary)  (cost=111397 rows=101) (actual time=75.7..198 rows=10 loops=1)
    -> Index range scan on users using idx_age over (20 <= age < 30), 
    with index condition: ((users.age >= 20) and (users.age < 30))  (cost=91143 rows=202540) (actual time=0.582..193 rows=99990 loops=1)

HAVING 대신에 WHERE문을 사용함으로써 GROUP BY를 처리하기 전에 데이터를 필터링했다.

그런 뒤에 필터링 된 데이터를 기반으로 GROUP BY를 진행했다. 

'데이터베이스 > MySQL' 카테고리의 다른 글

[DB 성능 최적화] 실전 SQL 튜닝  (0) 2024.08.24
[DB 성능 최적화] 실행 계획(Explain)  (0) 2024.08.22
[DB 성능 최적화] 인덱스(Index)  (0) 2024.08.21
[MySQL] 인덱스(Index)  (1) 2023.12.06
[MySQL] 뷰(View)  (1) 2023.12.05

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


실행 계획(Explain) 개념

옵티마이저가 SQL문을 어떤 방식으로 어떻게 처리할 지를 계획한 걸 의미한다.

이 실행 계획을 보고 비효율적으로 처리하는 방식이 있는 지 점검하고, 비효율적인 부분이 있다면 더 효율적인 방법으로 SQL문을 실행하게끔 튜닝을 하는 게 목표다.

 

실행 계획(Explain)은 데이터베이스가 SQL 쿼리를 실행할 때 어떤 경로로 데이터에 접근하고 처리하는지를 보여주는 일종의 “설계도”라고 할 수 있다.

즉, 쿼리가 어떻게 실행될지에 대한 세부적인 계획을 제공한다.

 

쉽게 설명하자면

 

데이터베이스에 어떤 SQL 쿼리를 보내면, 데이터베이스는 그 쿼리를 실행할 때 최적의 경로를 선택해야 한다. 이 경로는 여러 가지 요소에 따라 달라진다. 예를 들어, 테이블이 크다면 인덱스를 사용할지, 아니면 테이블 전체를 스캔할지 등을 결정해야 한다.

실행 계획(Explain)을 사용하면 데이터베이스가 쿼리를 처리하는 과정을 미리 알 수 있고, 이를 통해 성능을 최적화할 수 있다.

예를 들어, 실행 계획을 보면 쿼리가 테이블 전체를 스캔하고 있는지, 아니면 인덱스를 잘 활용하고 있는지를 확인할 수 있다.

 

실행 계획 조회하기

EXPLAIN SELECT * FROM users
WHERE age = 23;

 

  • id : 실행 순서
  • table : 조회한 테이블 명
  • type : 테이블의 데이터를 어떤 방식으로 조회하는지
  • possible keys : 사용할 수 있는 인덱스 목록을 출력 ⭐️
  • key : 데이터 조회할 때 실제로 사용한 인덱스 값 ⭐️
  • ref : 테이블 조인 상황에서 어떤 값을 기준으로 데이터를 조회했는지
  • rows : SQL문 수행을 위해 접근하는 데이터의 모든 행의 수 (= 데이터 액세스 수) ⭐️⭐️⭐️
    → 이 값을 줄이는 게 SQL 튜닝의 핵심
  • filtered : 필터 조건에 따라 어느 정도의 비율로 데이터를 제거했는 지 의미
    → filtered의 값이 30이라면 100개의 데이터를 불러온 뒤 30개의 데이터만 실제로 응답하는데 사용했음을 의미
    → filtered 비율이 낮을 수록 쓸데없는 데이터를 많이 불러온 것.
  • Extra : 부가적인 정보를 제공 ⭐️
    → ex. Using where, Using index
rows, filtered의 값은 정확한 수치가 아닌 추정값이기 때문에 오차가 있을 수 있다.

 

실행 계획에 대한 자세한 정보 조회

EXPLAIN ANALYZE SELECT * FROM users
WHERE age = 23;

  • Table scan on users : users 테이블을 풀 스캔했다.
    -rows : 접근한 데이터의 행의 수
    -actual time=0.0437..0.0502
      ->0.0437 (앞에 있는 숫자) : 첫 번째 데이터에 접근하기까지의 시간
      ->0.0502 (뒤에 있는 숫자) : 마지막 데이터까지 접근한 시간
  • Filter: (users.age = 23) : 필터링을 통해 데이터를 추출했다. (필터링을 할 때의 조건은 users.age = 23)

 

위 작업을 한 번에 이어서 해석해보면 다음과 같다.

  • users 테이블의 모든 데이터(7개)에 접근했다.
  • 그리고 그 데이터 중 age = 23의 조건을 만족하는 데이터만 필터링해서 조회해왔다. 

 

실행 계획에서 Type 의미 분석

실행 계획(EXPLAIN)을 조회했을 때 나오는 결과값 중 하나인 type은 성능 최적화에 있어서 이 값의 의미를 파악하는 게 굉장히 중요하다. 

 

ALL(Full Table Scan)

풀 테이블 스캔(Full Table Scan)이란 인덱스를 활용하지 않고 테이블을 처음부터 끝까지 전부 다 뒤져서 데이터를 찾는 방식이다.

처음부터 끝까지 전부 다 뒤져서 필요한 데이터를 찾는 방식이다보니 비효율적이다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);
INSERT INTO users (name, age) VALUES 
('Alice', 30),
('Bob', 23),
('Charlie', 35);
EXPLAIN SELECT * FROM users WHERE age = 23; # type : ALL

users 테이블의 데이터는 age를 기준으로 정렬되어 있지 않고 id를 기준으로 정렬되어 있다.

그래서 age = 23의 값을 가진 데이터를 찾으려면 테이블의 처음부터 끝까지 다 뒤져봐야 한다.

그래서 실행 계획의 type이 ALL로 나온 것이다. 

 

Index(Full Index Scan)

풀 인덱스 스캔(Full Index Scan)이란 인덱스 테이블을 처음부터 끝까지 다 뒤져서 데이터를 찾는 방식이다.

인덱스의 테이블은 실제 테이블보다 크기가 작기 때문에, 풀 테이블 스캔(Full Table Scan)보다 효율적이다.

하지만 인덱스 테이블 전체를 읽어야 하기 때문에 아주 효율적이라고 볼 수는 없다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100),
    age INT
);
CREATE INDEX idx_name ON users (name);
EXPLAIN SELECT * FROM users 
ORDER BY name 
LIMIT 10;

type이 index인걸로 봐서 풀 인덱스 스캔을 한것을 확인할 수 있다. 즉, 인덱스를 처음부터 끝까지 다 읽어서 필요한 데이터를 뽑아냈다.

 

Const

조회하고자 하는 1건의 데이터를 헤매지 않고 단번에 찾아올 수 있을 때 const가 출력된다. 그러다보니 고유 인덱스 또는 기본 키를 사용해서 1건의 데이터만 조회한 경우에 const가 출력된다.

이 방식은 아주 효율적인 방식이다.

 

  • 인덱스가 없다면 특정 값을 일일이 다 뒤져야 한다. 그래서 1건의 데이터를 바로 찾을 수 없다.
  • 인덱스가 있는데 고유하지 않다면(NOT UNIQUE) 원하는 1건의 데이터를 찾았다고 하더라도, 나머지 데이터에 같은 값이 있을 지도 모르므로 다른 데이터들도 체크해봐야 한다.
  • 고유하다면(UNIQUE) 1건의 데이터를 찾는 순간, 나머지 데이터는 아예 볼 필요가 없어진다. 왜냐하면 찾고자 하는 데이터가 유일한 데이터이기 때문이다. 
    → UNIQUE 제약조건이 있는 컬럼과 기본 키는 전부 UNIQUE한 특성을 가지고 있다.
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    account VARCHAR(100) UNIQUE
);
실행 계획 조회하기
EXPLAIN SELECT * FROM users WHERE id = 3;
EXPLAIN SELECT * FROM users WHERE account = 'user3@example.com';

UNIQUE 속성을 가진 컬럼은 인덱스가 자동으로 생성된다. 

 

Range(Index Range Scan)

인덱스 레인지 스캔(Index Range Scan)은 인덱스를 활용해 범위 형태의 데이터를 조회한 경우를 의미한다.

범위 형태란 BETWEEN, 부등호(<, >, ≤, ≥), IN, LIKE를 활용한 데이터 조회를 뜻한다.

이 방식은 인덱스를 활용하기 때문에 효율적인 방식이다.

하지만 인덱스를 사용하더라도 데이터를 조회하는 범위가 클 경우 성능 저하의 원인이 되기도 한다.

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    age INT
);
CREATE INDEX idx_age ON users(age);
EXPLAIN SELECT * FROM users
WHERE age BETWEEN 10 and 20;

EXPLAIN SELECT * FROM users
WHERE age IN (10, 20, 30);

EXPLAIN SELECT * FROM users
WHERE age < 20;

 

Ref

고유하지 않은 인덱스(비고유 인덱스)를 사용한 경우 (= UNIQUE가 아닌 컬럼의 인덱스를 사용한 경우) type에 ref가 출력된다. 

출처 : 업무에 바로 쓰는 SQL 튜닝 (책)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100)
);
CREATE INDEX idx_name ON users(name);
EXPLAIN SELECT * FROM users WHERE name = '박재성';

 

이외에 eq_ref, index_merge, ref_or_null 등 다양한 타입들이 존재한다.

'데이터베이스 > MySQL' 카테고리의 다른 글

[DB 성능 최적화] 실전 SQL 튜닝  (0) 2024.08.24
[DB 성능 최적화] 기본 SQL 튜닝  (0) 2024.08.23
[DB 성능 최적화] 인덱스(Index)  (0) 2024.08.21
[MySQL] 인덱스(Index)  (1) 2023.12.06
[MySQL] 뷰(View)  (1) 2023.12.05

이 글은 인프런의 MySQL 성능 최적화 입문/실전 (SQL 튜닝편) (박재성) 강의를 듣고 개인적으로 정리하는 글임을 알립니다.


MySQL 아키텍쳐

MySQL의 아키텍처를 간단하게 표현하자면 위와 같다.

  1. 클라이언트가 DB에 SQL 요청을 보낸다. 
  2. MySQL 엔진에서 옵티마이저가 SQL문을 분석한 뒤 빠르고 효율적으로 데이터를 가져올 수 있는 계획을 세운다. 어떤 순서로 테이블에 접근할 지, 인덱스를 사용할 지, 어떤 인덱스를 사용할 지 등을 결정한다.
    (옵티마이저가 세운 계획은 완벽하지 않다. 따라서 SQL 튜닝이 필요하다.)
  3. 옵티마이저가 세운 계획을 바탕으로 스토리지 엔진에서 데이터를 가져온다. 
    (DB 성능에 문제가 생기는 대부분의 원인은 스토리지 엔진으로부터 데이터를 가져올 때 발생한다. 데이터를 찾기가 어려워서 오래 걸리거나, 가져올 데이터가 너무 많아서 오래 걸린다. SQL 튜닝의 핵심은 스토리지 엔진으로부터 되도록이면 데이터를 찾기 쉽게 바꾸고, 적은 데이터를 가져오도록 바꾸는 것을 말한다.)
  4. MySQL 엔진에서 정렬, 필터링 등의 마지막 처리를 한 뒤에 클라이언트에게 SQL 결과를 응답한다.

 

MySQL 튜닝의 핵심

-스토리지 엔진에서 데이터를 찾기 쉽게 바꾸기
-스토리지 엔진으로부터 가져오는 데이터의 양 줄이기

이 두 가지를 해결할 수 있는 방법은 여러가지 방법이 많지만 가장 많이 활용되는 방법이 인덱스 활용이다.

 

인덱스(Index)의 개념

데이터베이스에서 인덱스는 책의 목차와 비슷한 역할을 한다고 보면 된다.

인덱스를 사용하면 특정 데이터를 더 빠르게 찾을 수 있게 된다.

데이터베이스에서 많은 양의 데이터가 저장되어 있을 때, 인덱스가 없다면 데이터베이스는 처음부터 끝까지 하나하나 데이터를 확인하면서 원하는 데이터를 찾아야 한다.

하지만 인덱스를 사용하면 훨씬 적은 데이터를 검색해서 원하는 결과를 빠르게 찾을 수 있다.

인덱스란 "데이터를 빨리 찾기 위해 특정 컬럼을 기준으로 미리 정렬해놓은 표"라고 할 수 있다.

 

인덱스를 직접 생성하게 되면 우리 눈에는 안 보이지만 왼쪽과 같은 표가 시스템 내부적으로 생성된다.

나이를 기준으로 정렬해놓은 표를 가지고 있기 때문에, 나이를 기준으로 데이터를 조회할 때 훨씬 빠르게 찾을 수 있었던 것이다. 

 

인덱스의 개념

  • 인덱스는 데이터베이스 테이블의 특정 열(컬럼)에 대한 추가적인 데이터 구조이다.
  • 이 구조는 해당 열의 값들을 빠르게 조회할 수 있도록 정렬되어 있으며, 해당 값이 저장된 레코드가 테이블의 어느 위치에 있는지 정보를 담고 있다.
  • 즉, 인덱스는 데이터를 조회할 때 사용하며, 특정 조건에 맞는 데이터를 빠르게 찾기 위해 사용된다.

 

 

인덱스의 동작

  • 인덱스 생성: 특정 열에 대해 인덱스를 생성하면, 데이터베이스는 그 열의 값을 기반으로 빠르게 검색할 수 있는 자료 구조(예: B-tree)를 만든다.
  • 데이터 조회 시 인덱스 사용: 데이터를 조회할 때 해당 열에 인덱스가 있다면, 데이터베이스는 인덱스를 먼저 확인해서 그 열에 해당하는 값이 저장된 정확한 위치를 빠르게 찾아간다.
  • 인덱스 없는 조회: 인덱스가 없다면 데이터베이스는 테이블 전체를 처음부터 끝까지 스캔하며 원하는 데이터를 찾아야 한다. 이를 “Full Table Scan”이라고 한다.

 

인덱스의 장점

  • 검색 성능 향상: 데이터를 빠르게 검색할 수 있다. 특히, 큰 테이블에서 특정 조건을 만족하는 데이터를 찾을 때 매우 유용하다.
  • 정렬 및 그룹화 속도 향상: ORDER BY, GROUP BY와 같은 작업을 수행할 때도 인덱스를 활용하면 빠르게 처리할 수 있다.

 

인덱스의 단점

  • 추가적인 저장 공간 필요: 인덱스 자체가 별도의 데이터 구조이기 때문에, 이를 저장할 추가 공간이 필요하다.
  • 데이터 수정 시 성능 저하: 데이터를 INSERT, UPDATE, DELETE할 때, 인덱스도 함께 수정해야 하므로 성능이 떨어질 수 있다. 따라서 너무 많은 인덱스를 사용하면 오히려 성능이 저하될 수 있다.
  • 인덱스는 주로 데이터 검색 시 유리하지만, 자주 변경되는 데이터를 다룰 때는 오히려 성능에 악영향을 미칠 수 있다.

 

언제 인덱스를 사용해야 하는가?

  • 많은 조회가 이루어지는 열: 데이터 조회가 자주 발생하는 열에 인덱스를 사용하는 것이 좋다. 예를 들어, SELECT 쿼리에서 자주 조건으로 사용하는 열(WHERE 절에 자주 등장하는 열).
  • 유일한 값이 많은 열: 인덱스는 중복 값이 많을수록 성능이 떨어질 수 있다. 따라서 유일한 값이 많을수록 인덱스 효과가 크다. 예를 들어, 고유한 ID를 저장하는 primary key 열.
  • 정렬과 그룹화에 자주 사용되는 열: ORDER BY나 GROUP BY 절에서 자주 사용되는 열에도 인덱스를 사용할 수 있다.

 

인덱스 설정 방법

인덱스 생성

# 인덱스 생성
# CREATE INDEX 인덱스명 ON 테이블명 (컬럼명);
CREATE INDEX idx_age ON users(age);

 

인덱스 조회

# SHOW INDEX FROM 테이블명;
SHOW INDEX FROM users;

 

 

인덱스의 종류와 자동 생성

인덱스의 종류

MySQL에서 사용되는 인덱스의 종류는 크게 클러스터형 인덱스와 보조 인덱스로 나뉜다.

이 두 개를 비유하면 클러스터형 인덱스는 영어 사전과 같은 책이다.

영어사전은 단어들이 알파벳 순서로 정렬되어 있다. 이 정렬된 상태 덕분에 우리는 특정 단어를 빠르게 찾을 수 있다. 예를 들어, “apple”을 찾으려면 사전의 앞부분에서부터 찾고, “zebra”는 사전의 뒷부분을 바로 열어 찾으면 된다.

이 사전의 알파벳 순서로 정렬된 방식이 클러스터 인덱스와 비슷하다.

 

보조 인덱스는 책의 목차와 비슷한 개념이다.

목차는 본문의 내용과는 별도로 위치 정보(페이지 번호)를 제공하는데, 목차에서 내용을 찾으면 바로 해당 페이지로 이동할 수 있다. 목차 자체는 본문과 별도로 관리된다.

 

클러스터 인덱스 (Clustered Index)

  • 클러스터 인덱스는 테이블의 데이터 자체가 정렬되는 방식이다.
  • 즉, 테이블의 행(row)들이 인덱스 기준으로 실제 데이터 파일에 정렬되어 저장된다.
  • 하나의 테이블에 하나의 클러스터 인덱스만 존재할 수 있다, 왜냐하면 테이블 자체가 해당 인덱스를 기준으로 정렬되어 저장되기 때문이다.
  • 클러스터 인덱스가 설정된 열(column)에 따라 데이터가 물리적으로 정렬된다. 이로 인해 해당 열로 데이터를 검색할 때 매우 빠르게 찾을 수 있다.
  • Primary Key는 일반적으로 클러스터 인덱스로 사용된다. 예를 들어, 학생 테이블에서 학생 ID가 Primary Key라면, 테이블은 학생 ID 순서대로 정렬되어 저장된다.

 

보조 인덱스 (Non-clustered Index)

  • 보조 인덱스는 테이블 데이터 자체와는 별도로 만들어진 인덱스 구조이다.
  • 보조 인덱스는 테이블의 데이터와는 물리적으로 분리되어 있으며, 실제 데이터의 위치를 가리키는 포인터 역할을 한다.
  • 하나의 테이블에 여러 개의 보조 인덱스를 생성할 수 있다. 각 보조 인덱스는 특정 열에 대해 데이터 조회 속도를 높여준다.
  • 보조 인덱스를 통해 빠르게 검색한 후, 그 인덱스가 가리키는 위치로 가서 실제 데이터를 가져오는 방식이다.
  • Unique  제약 조건이 걸린 컬럼은 일반적으로 보조 인덱스로 사용된다.

 

클러스터 인덱스와 보조 인덱스 비교

클러스터형 인덱스 보조 인덱스
테이블의 데이터 자체가 인덱스 기준으로 정렬됨 인덱스는 데이터와 분리되어 별도로 존재함
한 테이블에 하나만 존재 가능 여러 개의 보조 인덱스를 생성할 수 있음
데이터가 실제로 인덱스 순서대로 저장(정렬)됨 인덱스는 실제 데이터를 가리키는 포인터를 제공
주로 Primary Key에 사용 자주 조회되는 특정 컬럼에 사용

 

 

자동으로 생성되는 인덱스

-테이블 생성 시에 제약 조건 Primary Key 또는 Unique를 사용하면 자동으로 인덱스가 생성된다.
-Primary Key 컬럼에는 클러스터형 인덱스가 생성되고, Unique 컬럼에는 보조 인덱스가 생성된다.

 

 

인덱스를 많이 사용하면 무조건 좋은가?

인덱스의 자료구조는 B-Tree이다.

B-Tree는 인덱스를 구현하는 데 매우 효율적인 자료구조지만, 인덱스를 너무 많이 사용하면 다음과 같은 문제점이 발생할 수 있다

  • 데이터 수정 시 성능 저하: 인덱스가 많을수록 데이터 수정 시 더 많은 B-Tree가 업데이트되어 성능이 떨어짐.
  • 추가적인 저장 공간 필요: 인덱스가 많을수록 더 많은 B-Tree 구조를 저장해야 하므로 디스크 공간이 많이 사용됨.
  • 인덱스 선택 문제: 너무 많은 인덱스가 있으면 최적의 인덱스를 선택하는 데 시간이 소요될 수 있음.
  • Full Table Scan 발생 가능성: 너무 많은 인덱스가 있으면 오히려 인덱스를 사용하지 않고 테이블 전체를 스캔하는 경우도 발생할 수 있음.

 

인덱스는 최소한으로 사용하는 것이 중요하다.

인덱스를 많이 사용할수록 조회 속도는 빨라지나, 쓰기(삽입, 수정, 삭제) 속도는 느려지기 때문이다.

 

 

멀티 컬럼 인덱스(Multiple - Column Index)

멀티 컬럼 인덱스 개념

멀티 컬럼 인덱스는 여러 개의 컬럼(열)을 하나의 인덱스로 묶어서 관리하는 인덱스이다.

이를 통해 여러 컬럼에 걸친 데이터 조회를 최적화할 수 있다. 즉, 하나의 인덱스가 여러 컬럼을 포함함으로써, 특정 쿼리가 여러 컬럼을 동시에 검색할 때 성능을 높이는 역할을 한다.

 

멀티 컬럼 인덱스 생성

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    이름 VARCHAR(100),
    부서 VARCHAR(100),
    나이 INT
);
CREATE INDEX idx_부서_이름 ON users (부서, 이름);

 

멀티 컬럼 인덱스란, 2개 이상의 컬럼을 묶어서 설정하는 인덱스를 뜻한다. 즉, 데이터를 빨리 찾기 위해 2개 이상의 컬럼을 기준으로 미리 정렬해놓은 표이다. 

 

아래와 같은 users 테이블이 있다고 가정하자.

이 때, 부서와 이름의 컬럼을 활용해 멀티 컬럼 인덱스를 만들면 아래와 같은 표가 시스템 내부에 생성된다. 부서를 기준으로 먼저 오름차순으로 정렬한 뒤, 같은 부서의 값을 가진 데이터들 사이에서 이름을 기준으로 오름차순 정렬을 한다.

 

위의 예시에서는 컬럼 2개를 가지고 인덱스를 생성했지만, 2개 이상의 컬럼을 가지고 인덱스를 생성할 수도 있다.

 

멀티 컬럼 인덱스 주의점

일반 인덱스처럼 활용 가능

부서를 기준으로 먼저 정렬이 되어 있고, 그 다음 같은 부서 내에서 이름을 기준으로 정렬되어 있다.

이런 구조로 되어 있기 때문에 부서 컬럼만 놓고 봤을 때는 부서 인덱스와 동일한 정렬 상태를 갖고 있다. 따라서 위의 멀티 컬럼 인덱스의 구조를 활용하면 부서의 인덱스를 활용하듯이 쓸 수도 있다. 

 

SELECT * FROM users
WHERE 부서 = '운영';

위 SQL문을 봤을 때 부서 컬럼으로 인덱스를 생성할 경우 성능이 향상되리라 짐작할 수 있다. 하지만 부서, 이름 순으로 구성된 멀티 컬럼 인덱스를 이미 만들어 뒀기 때문에, 부서 컬럼의 인덱스를 따로 또 만들 필요는 없다.

 

일반 인덱스처럼 활용하지 못하는 경우도 존재

위에서 부서, 이름 순으로 멀티 컬럼 인덱스를 만들어뒀기 때문에, 부서 컬럼의 인덱스를 만든 것과 같은 역할도 같이 수행한다고 했다.

하지만 이 멀티 컬럼 인덱스로는 이름 컬럼의 인덱스처럼 활용할 수는 없다. 

왜인지 아래 인덱스 표를 다시 한 번 확인해보자.

정렬을 자세히 잘 살펴보면 이름 기준으로 정렬이 되어 있지는 않다.

왜냐면 같은 부서를 가진 데이터끼리만 정렬을 시켰기 때문이다. 실제로 아래 SQL문을 실행시킬 때 인덱스를 활용하지 못한다.

SELECT * FROM users
WHERE 이름 = '이재현';

 

따라서 멀티 컬럼 인덱스에서 일반 인덱스처럼 활용할 수 있는 건 처음에 배치된 컬럼들뿐이다.

 

대분류 → 중분류 → 소분류 컬럼순으로 구성

멀티 컬럼 인덱스를 만들 때는 순서에 주의해야 한다.

왜냐하면 순서를 어떻게 정해서 인덱스를 만드느냐에 따라서 성능 차이가 나기 때문이다. 

먼저 직관적으로 이해해보자. 10층짜리 회사에서 박미나를 찾아야 한다고 가정하자. 

일반적으로 부서를 먼저 찾은 뒤에 박미나를 찾는게 훨씬 빠를 것이다. 이를 일반화해서 표현하자면 "대분류를 먼저 탐색한 뒤, 소분류를 탐색하는 게 빠르다."라고 할 수 있다. 컴퓨터도 이 특성이 동일하게 적용된다. 

멀티 컬럼 인덱스에서도 배치한 컬럼의 순서대로 데이터를 탐색한다. (부서, 이름)의 순서대로 멀티 컬럼 인덱스를 구성했다면 먼저 일치하는 부서를 찾은 뒤, 일치하는 부서에서 이름을 찾는 식으로 처리한다. 

따라서 멀티 컬럼 인덱스를 구성할 때는 데이터 중복도가 높은 컬럼이 앞쪽으로 오는 게 좋은 경우가 많다. 데이터 중복도가 높은 컬럼일수록 큼지막하게 분류가 되어 있다는 뜻이기도 하다. 
(항상 그런 건 아니니 실행 계획과 SQL문 실행 속도를 측정해서 판단하도록 하자.)

- 멀티 컬럼 인덱스 컬럼의 순서는 매우 중요하다.
- 멀티 컬럼 인덱스에서 처음에 배치된 컬럼들은 일반 인덱스처럼 활용할 수 있다.
- 멀티 컬럼 인덱스를 구성할 때 데이터 중복도가 높은 컬럼이 앞쪽으로 오는 게 좋다.

 

 

커버링 인덱스(Covering Index)

SQL문을 실행시킬 때 필요한 모든 컬럼을 갖고 있는 인덱스를 커버링 인덱스(Covering Index)라고 한다. 

 

위와 같은 users 테이블이 있고, name 인덱스가 있다고 가정하자. 그리고 아래 2개의 SQL문을 실행해야 한다고 가정하자. 

SELECT id, created_at FROM users;
SELECT id, name FROM users;

1번째 SQL문을 보면 id, created_at라는 컬럼만 조회한다고 하더라도 실제 테이블의 데이터에 접근해야 한다.

하지만 2번째 SQL문에서는 id, name 컬럼은 실제 테이블에 접근하지 않고 인덱스에만 접근해서 알아낼 수 있는 정보들이다. 따라서 실제 테이블에 접근하지 않고 데이터를 조회할 수 있다. 실제 테이블에 접근하는 것 자체가 인덱스에 접근하는 것보다 속도가 느리다.

이 상황에서 SQL문을 실행시킬 때 필요한 모든 컬럼을 갖고 있는 인덱스를 보고 커버링 인덱스(Covering Index)라고 표현한다.

이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다.


인덱스(index)는 데이터를 빠르게 찾을 수 있도록 도와주는 도구로, 실무에서는 현실적으로 인덱스 없이 데이터베이스 운영이 불가능하다.

인덱스에는 두 가지 종류가 있다.

  • 클러스터형 인덱스(Clustered Index)
  • 보조 인덱스(Secondary Index)

 

인덱스의 개념

책을 예로 들어보면 책의 내용 중 'UNIQUE'에 대해서 찾아보고 싶다면 제일 뒤에 수록되어 있는 찾아보기를 찾아보는 것이다.

찾아보기는 ABC 또는 가나다 순으로 이미 정렬되어 있어 'U' 부분을 살펴보면 쉽게 'UNIQUE' 단어를 찾을 수 있고, 단어 옆에 본문의 페이지 번호가 적혀 있어서 원하는 내용으로 빨리 이동할 수 있다.

책 뒤의 찾아보기는 색인, 인덱스라고도 부른다.

실무에서 운영하는 테이블에서는 인덱스의 사용 여부에 따라 성능 차이가 날 수 있다.

대용량의 테이블일 경우에는 더욱 그러하다.

이것이 인덱스를 사용하는 이유이다.

 

인덱스의 문제점

인덱스는 적절히 사용해야 효과가 극대화된다.

필요 없는 인덱스를 만들면 데이터베이스가 차지하는 공간만 늘어나고, 오히려 인덱스를 이용해서 데이터를 찾는 것이 전체 테이블을 찾는 것보다 느려진다.

 

똑똑한 MySQL
데이터베이스에 인덱스를 생성해 놓아도, 인덱스를 사용해서 검색하는 것이 빠를지 아니면 전체 테이블을 검색하는 것이 빠를지 MySQL이 알아서 판단한다.
만약 인덱스를 사용하지 않는다면 사용하지도 않는 인덱스를 만든 것이므로 공간 낭비를 한 셈이다.

 

인덱스의 장점과 단점

장점

  • SELECT 문으로 검색하는 속도가 매우 빨라진다.
  • 적은 처리량으로 요청한 결과를 얻게 되어 여유가 생기고 추가로 더 많은 처리를 할 수 있다.
    즉, 결과적으로 전체 시스템의 성능이 향상된다.

단점

  • 인덱스도 공간을 차지해서 데이터베이스 안에 추가적인(테이블 크기의 약 10%) 공간이 필요하다.
  • 처음에 인덱스를 만드는 데 시간이 오래 걸릴 수 있다.
  • SELECT가 아닌 데이터의 변경 작업(INSERT, UPDATE, DELETE)이 자주 일어나면 오히려 성능이 나빠질 수 있다.

 

인덱스의 종류

인덱스에는 두 가지 종류가 있다.

  • 클러스터형 인덱스(Clustered Index)
  • 보조 인덱스(Secondary Index)

클러스터형 인덱스는 영어사전과 같고, 보조 인덱스는 책의 뒤에 찾아보기가 있는 일반적인 책과 같다.

보조 인덱스는 찾아보기에서 해당 단어를 찾은 후에 옆에 표시된 페이지를 펼쳐야 실제 찾는 내용이 있는 것을 말한다.

클러스터형 인덱스는 영어사전처럼 책의 내용이 이미 알파벳 순서대로 정렬되어 있는 것이다.

 

자동으로 생성되는 인덱스

인덱스는 테이블의 컬럼 단위에 생성되며, 하나의 컬럼에는 하나의 인덱스를 생성할 수 있다.

하나의 컬럼에 여러 개의 인덱스를 생성할 수도 있고, 여러 개의 컬럼을 묶어서 하나의 인덱스를 생성할 수도 있지만 그런 경우는 드물다.

컬럼이 기본키(PK)로 지정되어 있다면 해당 컬럼은 자동적으로 클러스터형 인덱스가 된다.

즉, 해당 열을 기준으로 데이터가 자동으로 정렬된다.

CREATE TABLE table1  (
    col1  INT  PRIMARY KEY,
    col2  INT,
    col3  INT
);
SHOW INDEX FROM table1;

SHOW INDEX 문을 사용하면 인덱스 정보를 알 수 있다.

Key_name 부분을 보면 PRIMARY라고 써져 있다. 이는 기본 키로 설정해서 클러스터형 인덱스라는 뜻이다.

Column_name이 col1로 설정되어 있다는 것은 col1 열에 인덱스가 만들어져 있다는 것이다.

Non_Unique는 '고유하지 않다'라는 의미이다. 즉, 중복이 허용되냐는 뜻이다.

Non_Unique가 0이라는 것은 false, 반대로 1은 true이다. 결론적으로 이 인덱스는 중복이 허용되지 않는 인덱스이다.

고유 인덱스
고유 인덱스(Unique index)는 인덱스의 값이 중복되지 않는다는 의미고, 단순 인덱스(Non-Unique index)는 인덱스의 값이 중복되어도 된다는 의미이다.
Primary Key나 Unique Key로 지정하면 값이 중복되지 않으므로 고유 인덱스가 생성된다. 그 외의 인덱스는 단순 인덱스로 생성된다.

 

기본키와 더불어 고유키(Unique Key)도 인덱스가 자동으로 생성된다.

컬럼이 고유 키로 지정되어 있다면 해당 컬럼은 자동적으로 보조 인덱스가 된다.

클러스터형 인덱스와 다르게 보조 인덱스는 자동으로 정렬되지 않는다. 그냥 순서대로 삽입될 뿐이다.

CREATE TABLE table2  (
    col1  INT  PRIMARY KEY,
    col2  INT  UNIQUE,
    col3  INT  UNIQUE
);
SHOW INDEX FROM table2;

Key_name에 컬럼 이름(col2, col3 등)이 쓰여 있는 것은 보조 인덱스라고 보면 된다.

고유 키 역시 중복값을 허용하지 않기 때문에 Non_Unique가 0으로 되어 있다.

고유 키를 여러 개 지정할 수 있듯이 보조 인덱스도 여러 개 만들 수 있다.

 

인덱스의 내부 작동원리

클러스터형 인덱스와 보조 인덱스는 모두 내부적으로 균형 트리로 만들어진다.

 

균형 트리의 개념

균형 트리는 나무를 거꾸로 표현한 자료 구조로, 트리에서 제일 상단의 뿌리를 루트, 줄기를 중간, 끝에 달린 앞을 리프라고 부른다.

균형 트리 구조에서 데이터가 저장되는 공간을 노드(Node)라고 한다.
루트 노드(Root Node)는 노드의 가장 상위 노드를 말한다. 모든 출발은 루트 노드에서 시작된다. 
리프 노드(Leaf Node)는 제일 마지막에 존재하는 노드를 말한다. 
루트 노드와 리프 노드 사이에 있는 노드를 중간 노드(Internal Node)라고 한다.

그림에선 각 노드에 데이터가 최대 4개 들어가는 것으로 표현했지만 실제로는 훨씬 많은 데이터가 들어간다.

노드라는 용어는 개념적인 설명에서 주로 나오는 용어이며, MySQL에서는 페이지(Page)라고 부른다.

페이지는 최소한의 저장 단위로, 16 KByte 크기를 가진다.

 

인덱스를 사용하지 않고 전체 테이블 검색을 사용한 것을 그림으로 나타내면 아래와 같다.

MMM을 찾는다고 가정

전체 테이블 검색에서는 3페이지 만에 원하는 것을 찾았다.

 

인덱스를 사용하여 균형 트리에서 검색을 한다고 가정하면, 균형 트리는 무조건 루트 페이지부터 검색한다.

인덱스를 사용한 균형트리 검색에서는 2페이지 만에 원하는 것을 찾았다.

AAA -> FFF -> LLL -> LLL -> MMM 이렇게 5건의 데이터를 읽어서 원하는 것을 찾았는데, 몇 건의 데이터를 읽었느냐는 중요하지 않다. 몇 개의 페이지를 읽었느냐가 효율성을 판단한다.

 

균형 트리의 페이지 분할

인덱스는 균형 트리로 이루어져 있기 때문에 SELECT의 속도를 향상할 수 있다. 
다만 인덱스를 구성하면 데이터 변경 작업(INSERT, UPDATE, DELETE) 시 성능이 나빠진다. 
특히 INSERT 작업이 일어날 때 더 느리게 입력될 수 있다.
이유는 페이지 분할이라는 작업이 발생하기 때문인데, 페이지 분할이란 새로운 페이지를 준비해서 데이터를 나누는 작업을 말한다.
페이지 분할이 일어나면 MySQL이 느려지고, 너무 자주 일어나게 되면 성능에 큰 영향을 미친다.

이전 그림에서 III 데이터가 새로 삽입되었다고 가정하면 균형 트리는 아래와 같이 변경된다.

이 상태에서 GGG가 추가적으로 삽입된다고 하면, 두 번째 리프 페이지에서 더 이상 빈 공간이 없기 때문에 페이지 분할이 일어난다.

이 상태에서 PPP와 QQQ가 들어온다고 가정하자.

PPP가 들어올 때는 별 일이 일어나지 않지만, QQQ가 들어올 때 네 번째 리프 페이지에 빈칸이 없어서 또다시 페이지 분할 작업이 일어난다.

페이지 분할 후에 추가된 다섯 번째 리프 페이지를 루트 페이지에 등록을 하려고 하니, 루트 페이지도 이미 꽉 차서 루트 페이지도 페이지 분할을 해야 한다.

여기서 새로 생긴 페이지는 루트 페이지가 되고, 기존의 루트 페이지는 중간 페이지가 된다.

결국 QQQ 하나를 입력하기 위해서 3개의 새로운 페이지가 할당되고 2회의 페이지 분할이 되었다.

데이터 하나를 입력하기 위해 많은 일이 일어난 것이다.

어떤 INSERT는 빠르게 실행되고, 다른 INSERT는 느리게 실행되는 이유가 위와 같은 과정에 있다.

 

인덱스의 구조

클러스터형 인덱스 구조

 

보조 인덱스의 구조

 

인덱스의 실제 사용

인덱스 생성, 제거와 사용

인덱스 생성

인덱스를 생성하기 위해서는 CREATE INDEX 문을 사용해야 한다.
UNIQUE는 중복이 안 되는 고유 인덱스를 만드는 것인데, 생략하면 중복이 허용된다.
CREATE UNIQUE로 인덱스를 생성하려면 기존에 입력된 값들에 중복이 있으면 안 된다.
그리고 인덱스를 생성한 후에 입력되는 데이터와도 중복될 수 없으니 신중해야 한다.
ASC 또는 DESC는 인덱스를 오름차순 또는 내림차순으로 만들어준다.
기본은 ASC로 만들어지며, 일반적으로 DESC로 만드는 경우는 거의 없다.

CREATE [UNIQUE] INDEX 인덱스_이름
    ON 테이블_이름 (열_이름) [ASC | DESC]

 

CREATE INDEX idx_member_addr ON member (addr); --주소로 단순 보조 인덱스 생성
    
SHOW INDEX FROM member;--테이블에 생성된 인덱스를 보여줌

 

CREATE UNIQUE INDEX idx_member_name ON member (mem_name); --멤버 이름으로 고유 보조 인덱스 생성

위 쿼리는 문제점이 있다.

멤버 이름으로 고유 보조 인덱스를 생성하기 때문에 동명이인은 멤버 테이블에 입력이 될 수 없기 때문이다.

따라서 현재 중복된 값이 없다고 무조건 설정하면 안 되며, 절대로 중복되지 않는 컬럼(주민등록번호, 학번, 이메일 주소 등)에만 UNIQUE 옵션을 사용해서 인덱스를 생성해야 한다.

 

ANALYZE TABLE member; -- 지금까지 생성한 인덱스를 실제로 적용
SHOW INDEX FROM member; --테이블에 생성된 인덱스를 보여줌

인덱스를 생성한 후에 ANALYZE TABLE문을 실행해 줘야 실제로 적용된다.

 

인덱스 실제 사용

CREATE INDEX idx_member_mem_number ON member (mem_number); --멤버의 수로 단순 보조 인덱스 생성
ANALYZE TABLE member; -- 인덱스 적용

인덱스를 실질적으로 활용하기 위해서는 WHERE 문을 사용해야 한다.

MySQL의 경우 WHERE 문에 작성된 조건에 따라 그 효율성을 먼저 따져, 만약 인덱스 검색의 효율이 더 좋다면 인덱스 검색을, 그렇지 않다면 테이블 전체 검색을 수행한다.
SELECT mem_id, mem_name, addr FROM member WHERE mem_name = '에이핑크';

 

--멤버 수가 7 이상인 그룹의 이름, 수를 출력
SELECT mem_name, mem_number FROM member WHERE mem_number >= 7;

 

WHERE 문을 사용하지 않으면 Full Table Scan을 하기 때문에 인덱스를 만든 이유가 없어진다.

SELECT * FROM member;

 

또한 되도록 WHERE 절에 작성된 열에는 연산과 같은 가공을 하지 않는 것이 바람직하다.

SELECT mem_name, mem_number FROM member 
 WHERE mem_number*2 >= 14; --전체 테이블 스캔, 이렇게 사용 X
    
SELECT mem_name, mem_number FROM member 
 WHERE mem_number >= 14/2; --인덱스 사용해서 스캔, 이렇게 사용 O

 

WHERE 절에 작성된 열 이름에 연산과 같은 가공을 하면 Full Table Scan이 된다.

 

인덱스 제거

클러스터형 인덱스와 보조 인덱스가 섞여 있을 때는 보조 인덱스를 먼저 제거하는 것이 좋다.

보조 인덱스는 어떤 것을 먼저 제거해도 상관없다.

 

  • 보조 인덱스 삭제
DROP INDEX idx_member_mem_name ON member;
DROP INDEX idx_member_addr ON member;
DROP INDEX idx_member_mem_number ON member;

 

  • 클러스터형 인덱스 삭제(기본키 제약조건 삭제)
ALTER TABLE member DROP PRIMARY KEY;

위 쿼리는 오류가 발생한다. 해당 테이블의 기본키가 buy 테이블의 외래키이기 때문에 제약 조건에 위배된다.

그러므로 기본 키를 제거하기 전에 외래 키 관계를 제거해야 한다.

테이블에는 여러 개의 외래 키가 있을 수 있다.

그래서 먼저 외래 키의 이름을 알아내야 한다.

information_schema 데이터베이스의 referential_constraints 테이블을 조회하면 외래 키의 이름을 알 수 있다.

SELECT table_name, constraint_name
 FROM information_schema.referential_constraints
 WHERE constraint_schema = 'market_db';

외래 키의 이름을 알았으니 외래 키를 먼저 제거하고 기본키를 제거하면 된다.

ALTER TABLE buy DROP FOREIGN KEY buy_ibfk_1;
ALTER TABLE member  DROP PRIMARY KEY;

 

인덱스를 제거한다고 데이터의 내용이 바뀌는 것은 아니다.

지금 인덱스를 제거한 것은 찾아보기를 제거하고 영어사전을 순서가 섞인 단어장으로 변경한 것이며, 내용은 그대로이다.

이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다.


뷰는 데이터베이스 개체 중에 하나이다.

모든 데이터베이스 개체는 테이블과 관련이 있지만, 특히 뷰는 테이블과 아주 밀접하게 연관되어 있다.

뷰는 한 번 생성해 놓으면 테이블이라고 생각해도 될 정도로 사용자들의 입장에서는 테이블과 거의 동일한 개체로 취급한다.

뷰는 테이블처럼 데이터를 가지고 있지 않다.

뷰의 실체는 SELECT 문으로 만들어져 있기 때문에 뷰에 접근하는 순간 SELECT가 실행되고 그 결과가 화면에 출력되는 방식이다.

뷰와 테이블의 관계는 바로 가기 아이콘과 실제 프로그램의 관계와 유사하다.

뷰는 단순 뷰와 복합 뷰로 나뉜다.

  • 단순 뷰 : 하나의 테이블과 연관된 뷰
  • 복합 뷰 : 2개 이상의 테이블과 연관된 뷰

복합 뷰는 주로 두 테이블을 조인한 결과를 뷰로 만들 때 사용한다.

CREATE VIEW v_complex
 AS
 SELECT B.mem_id, M.mem_name, B.prod_name, M.addr
 FROM buy B
 INNER JOIN member M
 ON B.mem_id = M.mem_id;

복합 뷰는 읽기 전용이다. 복합 뷰를 통해 테이블에 데이터를 입력/수정/삭제할 수 없다.

 

뷰의 개념

뷰의 작동

사용자가 뷰에 접근하는 방식은 아래의 그림과 같다.

사용자는 뷰를 테이블이라고 생각하고 접근한다.

그러면 데이터베이스가 뷰 안에 있는 SELECT를 실행해서 그 결과를 사용자에게 보내주므로 사용자 입장에서는 1번과 4번만, 즉 뷰에서 모두 처리된 것으로 이해한다.

뷰는 기본적으로 읽기 전용으로 사용되지만, 뷰를 통해서 원본 테이블의 데이터를 수정할 수도 있다.

하지만 무조건 가능한 것은 아니고 몇 가지 조건을 만족해야 한다.

 

뷰를 사용하는 이유

뷰를 만들면 테이블과 동일하게 접근이 가능하다.

테이블 대신 굳이 뷰를 사용하는 방법은 아래와 같다.

 

  • 보안에 도움이 된다.

뷰에서 사용자의 아이디, 이름, 주소 등 중요하지 않은 정보만 보여주고 민감한 정보인 연락처, 주민등록번호등은 보여주지 않을 수 있다.

데이터베이스에서 보안은 상당히 중요하다.

root 계정은 모든 권한이 있는 관리자로 못하는 작업이 없다.

테이블의 생성, 삭제는 물론 테이블의 데이터를 마음대로 조작할 수 있는 막강한 권한이 있다.

은행을 예로 들면, 은행도 데이터베이스의 테이블에 정보를 저장한다.

통장 테이블에 고객의 예금을 관리한다고 가정하면 은행에서 모든 직원에게 root의 권한을 부여한다면 고의든 실수든 고객의 예금을 마음대로 사용할 수 있다.

아니면 고객의 중요한 정보(통장 비밀번호 등)를 마음대로 볼 수 있다.

이러한 사고를 방지하기 위해 중요 관계자가 아닌 사람에게는 중요한 정보를 보여주지 못하도록 권한을 제한하고, 뷰에만 접근할 수 있도록 권한을 준다면 이러한 문제를 쉽게 해결할 수 있다.

 

  • 복잡한 SQL을 단순하게 만들 수 있다.

아래의 쿼리문은 물건을 구매한 회원들에 대한 SQL이다.

SELECT B.mem_id, M.mem_name, B.prod_name, M.addr, 
 CONCAT(M.phone1, M.phone2) '연락처' 
 FROM buy B
 INNER JOIN member M
 ON B.mem_id = M.mem_id;

내용이 길고 좀 복잡하다. 만약 이 쿼리를 자주 사용해야 한다면 상당히 골치 아플 것이다.

하지만 이 SQL을 아래와 같이 뷰로 생성해 놓고 사용자들은 해당 뷰에만 접근하도록하면 복잡한 SQL을 입력할 필요가 없다.

CREATE VIEW v_memberbuy AS
 SELECT B.mem_id, M.mem_name, B.prod_name, M.addr, 
 CONCAT(M.phone1, M.phone2) '연락처' 
 FROM buy B
 INNER JOIN member M ON B.mem_id = M.mem_id;

이렇게 뷰를 한번 만들어 놓으면 v_memberbuy를 테이블이라고 생각하고 접근하면 된다.

필요하면 WHERE절도 사용할 수 있다.

SELECT * FROM v_memberbuy WHERE mem_name = '블랙핑크';

 

뷰의 기본 생성

이러한 데이터베이스에서 회원 테이블에서 멤버 아이디, 멤버 이름, 멤버 주소만 보고 싶다면 아래의 쿼리문을 작성하면 된다.

SELECT mem_id, mem_name, addr FROM member;

 

뷰로 멤버 아이디, 멤버 이름, 멤버 주소만 보고 싶다면 아래의 쿼리문을 작성하면 된다.

즉, v_member뷰로는 mem_id, mem_name, addr칼럼만 볼 수 있는 것이다.

CREATE VIEW v_member AS SELECT mem_id, mem_name, addr FROM member;

이제 뷰의 모든 컬럼의 값들을 보려면 아래와 같이 컬럼을 작성하면 된다.

SELECT * FROM v_member;

 

필요한 열만 보거나 조건식을 넣을 수 있다.

SELECT mem_name, addr FROM v_member WHERE addr IN ('서울', '경기');

 

뷰의 실제 작동

뷰의 실제 생성, 수정, 삭제

뷰 생성

기본적인 뷰를 생성하면서 뷰에 사용될 열 이름을 테이블과 다르게 지정할 수도 있다.

이는 별칭을 사용하면 되는데, 중간에 띄어쓰기 사용이 가능하다. 별칭은 열 이름 뒤에 작은따옴표 또는 큰따옴표로 묶어주고, 형식상 AS를 붙여준다.

AS를 붙이면 코드가 명확해 보이는 장점이 있다.(다르게 말하면, 별칭을 붙일 때는 AS를 안 붙여도 된다.)

단, 뷰를 조회할 때는 열 이름에 공백이 있으면 백틱(`)으로 묶어줘야 한다.(키보드 느낌표 왼쪽에 존재, (')가 아닌 (`))

CREATE OR REPLACE VIEW v_viewtest1
 AS
 SELECT B.mem_id 'Member ID', M.mem_name 'Member Name', 
 B.prod_name "Product Name", 
 CONCAT(M.phone1, M.phone2) "Office Phone" 
 FROM buy B
 INNER JOIN member M
 ON B.mem_id = M.mem_id;

이제 뷰를 조회하면

SELECT  DISTINCT `Member ID`, `Member Name`, `Product Name`, `Office Phone` FROM v_viewtest1; -- 백틱을 사용

 

뷰 수정

뷰의 수정은 ALTER VIEW 구문을 사용하며, 열 이름에 한글을 사용해도 된다.

ALTER VIEW v_viewtest1
 AS
 SELECT B.mem_id '회원 아이디', M.mem_name AS '회원 이름', 
 B.prod_name "제품 이름", 
 CONCAT(M.phone1, M.phone2) AS "연락처" 
 FROM buy B
 INNER JOIN member M
 ON B.mem_id = M.mem_id;
SELECT  DISTINCT `회원 아이디`, `회원 이름` FROM v_viewtest1;  -- 백틱을 사용

 

뷰 삭제

뷰의 삭제는 DROP VIEW를 사용한다.

DROP VIEW v_viewtest1;

 

뷰의 정보 확인

CREATE OR REPLACE VIEW v_viewtest2
AS SELECT mem_id, mem_name, addr FROM member;
CREATE OR REPLACE VIEW
뷰를 생성할 때 CREATE VIEW는 기존에 뷰가 있으면 오류가 발생하지만
CREATE OR REPLACE VIEW는 기존에 뷰가 있어도 덮어쓰는 효과를 내기 때문에 오류가 발생하지 않는다.
즉, DROP VIEW와 CREATE VIEW를 연속적으로 작성한 효과를 갖는다.

 

DRSCRIBE문으로 기존 뷰의 정보를 확인할 수 있다.

DESCRIBE v_viewtest2;

뷰도 테이블과 동일하게 정보를 보여준다.

하지만 PRIMARY KEY등의 정보는 확인되지 않는다.

뷰를 조회할때는 제약조건 등의 정보는 조회가 안된다.
반면에 테이블을 조회할 때는 제약조건 등의 정보가 조회가 된다.
SHOW CREATE VIEW
SHOW CREATE VIEW 문으로 뷰의 소스코드도 확인할 수 있다.
SHOW 결과는 뷰를 생성할 때보다 복잡하게 나온다.
복잡해 보이지만 핵심적인 코드는 생성할 때 사용한 코드와 동일하다.
SHOW CREATE VIEW v_viewtest2;​

 

뷰를 통한 데이터의 수정/삽입/삭제

수정

뷰를 통해 테이블의 데이터를 수정할 수도 있다.

UPDATE v_member SET addr = '부산' WHERE mem_id='BLK' ;

 

삭제

평균 키가 167 이상인 뷰를 생성하는 쿼리는 아래와 같다.

CREATE VIEW v_height167 AS SELECT * FROM member WHERE height >= 167 ;

평균 키가 167 이상만 조회되었다.

뷰에서 키가 167이하인 데이터를 삭제하는 쿼리는 아래와 같다.

DELETE FROM v_height167 WHERE height <= 167;

167 이하인 데이터를 삭제하고 뷰를 조회해 보면 정상적으로 삭제된 것을 확인할 수 있다.

실제 member 테이블에서는 키가 167인 회원의 키가 삭제되었다.

(필자는 이후 다시 삭제된 데이터를 복구하였다.)

 

삽입

INSERT INTO v_member(mem_id, mem_name, addr) VALUES('BTS','방탄소년단','경기') ;

이 쿼리는 오류가 발생한다.

이유는 뷰가 참조하는 테이블의 컬럼 중에서 특정한 컬럼이 NOT NULL로 설정되어 있어서 반드시 입력해주어야 하기 때문이다.

만약 뷰를 통해서 참조 테이블에 값을 입력하고 싶다면, 뷰에 해당 컬럼을 포함하도록 뷰를 재정의 하거나, 참조 테이블에서 해당 컬럼의 속성을 NULL로 바꾸거나 기본값을 지정해야 한다.

 

평균 키가 167 이상인 뷰를 생성하는 쿼리는 아래와 같다.

CREATE VIEW v_height167 AS SELECT * FROM member WHERE height >= 167 ;

뷰에서 키가 167 미만인 데이터를 입력하는 쿼리는 아래와 같다.

INSERT INTO v_height167 VALUES('TRA','티아라', 6, '서울', NULL, NULL, 159, '2005-01-01') ;

평균 키가 167이상인 데이터만 조회하는 뷰인데 167 미만의 데이터를 넣는다는 것은 논리적으로 올바르지 못하다.

따라서 뷰에는 추가가되지 않았지만, 뷰의 참조 테이블인 member 테이블에는 추가가 되었다.

 

키가 167이상인 뷰이므로 167 이상의 데이터만 입력되도록 하는 것이 논리적으로 바람직하다.

이럴 때 예약어 WITH CHECK OPTION을 통해 뷰에 설정된 값의 범위가 벗어나는 값은 입력되지 않도록 할 수 있다.

ALTER VIEW v_height167 AS SELECT * FROM member WHERE height >= 167
 WITH CHECK OPTION ;

이후 키가 167미만의 데이터를 입력하면 오류가 발생한다.

 

뷰가 참조하는 테이블의 삭제

뷰가 참조하는 테이블을 삭제하면 뷰를 사용할 수 없게 된다.

DROP TABLE IF EXISTS buy, member;

SELECT * FROM v_height167;

위 쿼리를 날리면 당연히 참조하는 테이블이 삭제되었기 때문에 조회할 수 없다는 오류를 마주하게 된다.

 

뷰가 조회되지 않으면 CHECK TABLE 문으로 뷰의 상태를 확인해 볼 수 있다.

 

이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다.


테이블을 만들 때는 테이블의 구조에 필요한 제약조건을 설정해줘야 한다.

기본 키(Primary Key)는 학번, 아이디, 사번 등과 같은 고유한 번호를 의미하는 column에 지정한다.

외래 키(Foreign Key)는 기본키와 연결되는 column에 지정한다.

이메일, 휴대폰 번호와 같이 중복되지 않는 열에는 고유 키(Unique)를 지정할 수 있다.

회원의 평균 키를 넣는다고 가정할 때, 당연히 평균 키는 2m를 넘지 않을 것이다. 이때 실수로 200cm을 입력하는 것을 방지하는 제약 조건이 체크(Check)이다.

국내에서 서비스하는 프로그램을 만든다고 하면, 회원 테이블에 국적은 대부분이 대한민국일 것이다. 이러한 경우에는 국적이 대한민국으로 기본값(Default)을 설정할 수 있다.

또한, 값을 꼭 입력해야 한다면 NOT NULL 제약 조건을 설정할 수도 있다.

 

제약 조건(Constraint)의 기본 개념과 종류

제약조건은 데이터의 무결성을 지키기 위해 제한하는 조건이다.

데이터의 무결성이란 데이터에 결함이 없음을 의미한다.

MySQL에서 제공하는 대표적인 제약조건은아래와 같다.

  • PRIMARY KEY 제약조건
  • FOREIGN KEY 제약조건
  • UNIQUE 제약조건
  • CHECK 제약조건
  • DEFAULT 정의
  • NULL 값 허용

 

기본키 제약조건

테이블에는 많은 행 데이터가 있다. 이 중에서 데이터를 구분할 수 있는 식별자를 기본 키라고 부른다.

기본 키에 입력되는 값은 중복될 수 없으며, NULL 값이 입력될 수 없다.

대부분의 테이블은 기본 키를 가져야 한다. 물론, 기본 키가 없어도 테이블 구성이 가능하지만 실무에서 사용하는 테이블에는 기본 키를 설정해야 중복된 데이터가 입력되지 않는다.

또한 기본 키로 생성한 것은 자동으로 클러스터형 인덱스가 생성된다.

마지막으로 하나의 테이블에서는 기본 키를 1개만 가질 수 있다.

어떠한 열에 기본키를 설정해도 문법상 문제는 없으나 테이블의 특성을 가장 잘 반영하는 열을 선택해야 한다.

 

기본키 지정하는 방법

기본키를 지정하는 방법은 세 가지가 있다.

  • 첫 번째 방법
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL PRIMARY KEY, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL
);
  • 두 번째 방법
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL,
  PRIMARY KEY (mem_id)
);
  • 세 번째 방법(테이블을 만든 후, 제약조건 추가)
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL
);
ALTER TABLE member ADD CONSTRAINT PRIMARY KEY (mem_id);
기본키에 이름 지정하기
기본 키는 별도의 이름이 없으며, DESCRIBE 명령으로 확인하면 그냥 PRI로만 나온다.
필요하다면 기본 키의 이름을 직접 지어줄 수 있다.
예를 들어, PK_member_mem_id와 같은 이름을 붙여주면, 이름 만으로도 'PK가 member 테이블의 mem_id열에 지정됨'이라고 이해할 수 있다.
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL,
  CONSTRAINT PRIMARY KEY PK_member_mem_id (mem_id)
);​

 

외래 키 제약조건

외래 키 제약조건은 두 테이블 사이의 관계를 연결해주고, 그 결과 데이터의 무결성을 보장해 주는 역할을 한다.

외래 키가 설정된 열은 꼭 다른 테이블의 기본키와 연결된다.

회원 테이블과 구매 테이블이 바로 대표적인 기본 키- 외래 키 관계이다.

여기서 기본 키가 있는 회원 테이블을 기준 테이블이라고 부르며, 외래 키가 있는 구매 테이블을 참조 테이블이라고 부른다.

구매 테이블의 아이디(FK)는 반드시 회원 테이블의 아이디(PK)로 존재한다.

쇼핑몰 데이터베이스에 제품을 구매한 기록이 있는 사람은 쇼핑몰 회원이라는 의미이다. 그러므로 구매한 기록은 있으나 구매한 사람이 누군지 모르는 심각한 일은 절대 발생하지 않는다.

구매 테이블의 데이터는 모두 누가 구매했는지 확실히 알 수 있는, 무결한 데이터가 되는 것이다.

또 하나 기억해야할 것은 참조 테이블이 참조하는 기준 테이블의 열은 반드시 기본 키나, 고유 키로 설정되어 있어야 한다.

테이블을 삭제하는 순서
회원 테이블과 구매 테이블은 기본 키-외래 키로 연결되어 있다.
만약, 구매 테이블이 있는데 회원 테이블을 삭제하면 이는 무결성 제약조건에 위배된다.
예를 들어, 구매 테이블에 있는 특정한 회원의 정보를 알고 싶어도 회원 테이블이 삭제되었기 때문에 알 수 있는 방법이 없다. 이러한 경우는 무결한 데이터라고 할 수 없다.
따라서 기본 키-외래 키 관계로 연결된 테이블은 외래 키가 설정된 테이블을 먼저 삭제해야 한다.
회원이 아닌데 구매 테이블을 구매할 수 있는가?
회원 테이블과 구매 테이블은 PK-FK 관계이다.
즉, 회원이 아닌데 구매 테이블에 회원이 입력될 수 없다.
이는 외래키 제약조건에 위반되기 때문이다.

 

외래키 지정하는 방법

외래키를 설정하는 방법은 두 가지가 있다.

외래 키의 형식은 FORIGN KEY(열_이름) REFERENCES 기준_테이블(열_이름) 이다.

  • 첫 번째 방법
CREATE TABLE buy 
(  num         INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
   mem_id      CHAR(8) NOT NULL, 
   prod_name     CHAR(6) NOT NULL, 
   FOREIGN KEY(mem_id) REFERENCES member(mem_id)
);
  • 두 번째 방법(테이블 생성 후, 제약조건 추가)
CREATE TABLE buy 
(  num         INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
   mem_id      CHAR(8) NOT NULL, 
   prod_name     CHAR(6) NOT NULL
);
ALTER TABLE buy ADD CONSTRAINT FOREIGN KEY(mem_id) REFERENCES member(mem_id);

 

기준 테이블의 열이 변경될 경우

만약, 회원 테이블의 BLK가 물품을 2건 구매한 상태에서 회원 아이디를 PINK로 변경하면 두 테이블의 정보가 일치하지 않게 된다.

이 그림을 코드로 확인하면 아래와 같다.

SELECT M.mem_id, M.mem_name, B.prod_name 
 FROM buy B
 INNER JOIN member M
 ON B.mem_id = M.mem_id;

내부 조인을 사용해서 물품 정보 및 사용자 정보를 확인해 보면, 결과가 정상적으로 나왔다.

하지만, BLK의 아이디를 PINK로 변경하면 아래처럼 오류가 발생한다.

PK-FK로 맺어진 후에는 기준 테이블의 열 이름이 변경되지 않는다.

열 이름이 변경되면 참조 테이블의 데이터에 문제가 발생하기 때문이다.

지금은 회원 테이블의 BLK가 물건을 구매한 기록이 존재하기 때문에 변경할 수 없는 것이다.
만약, BLK가 구매한 적이 없다면(구매 테이블에 데이터가 없다면) 회원 테이블의 BLK는 변경 가능하다.

삭제를 시도해 보아도, 같은 오류로 삭제되지 않는다.

 

기준 테이블의 열 이름이 변경될 때 참조 테이블의 열 이름이 자동으로 변경될 수 있도록 하는 방법이 있다.

ON UPDATE CASCADE문을 사용하면 된다.

또한 기준 테이블의 데이터가 삭제되면 참조 테이블의 데이터도 삭제될 수 있도록 하는 방법도 존재한다.

ON DELETE CASCADE문을 사용하면 된다.

buy 테이블을 삭제하고 다시 만든 뒤, 아래의 제약조건을 추가해 준다.

ALTER TABLE buy
 ADD CONSTRAINT 
 FOREIGN KEY(mem_id) REFERENCES member(mem_id)
 ON UPDATE CASCADE
 ON DELETE CASCADE;

 

이제 회원 테이블의 BLK를 PINK로 변경하면 오류 없이 잘 변경이 된다.

다시 내부 조인을 사용해서 물품 정보 및 사용자 정보를 확인해 보면, 기준 테이블과 참조 테이블의 아이디가 모두 변경된 것을 확인할 수 있다.

 

PINK가 탈퇴한 것으로 가정하고 기준 테이블에서 삭제해도 오류 없이 잘 실행되는 것을 확인할 수 있다.

구매 테이블을 확인하면 아무것도 없다.

 

 

기타 제약조건

고유 키 제약조건

고유 키 제약조건은 '중복되지 않는 유일한 값'을 입력해야 하는 조건이다.

기본 키 제약조건과 거의 비슷하지만, 차이점은 고유 키 제약조건은 NULL값을 허용한다는 점이다.

또한 기본 키는 테이블에 1개만 설정해야 하지만, 고유 키는 여러 개를 설정해도 된다.

DROP TABLE IF EXISTS buy, member;
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL PRIMARY KEY, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL,
  email       CHAR(30)  NULL UNIQUE
);

이렇게 기존에 있던 테이블을 삭제하고 고유 키(이메일)를 갖는 멤버 테이블을 새로 만든 후

INSERT INTO member VALUES('BLK', '블랙핑크', 163, 'pink@gmail.com');
INSERT INTO member VALUES('TWC', '트와이스', 167, NULL);
INSERT INTO member VALUES('APN', '에이핑크', 164, 'pink@gmail.com');

같은 이메일을 중복으로 삽입하면 아래와 같이 오류를 내뿜는다.

 

체크 제약조건

체크 제약조건은 입력되는 데이터를 점검하는 기능을 한다.

예를 들어 평균 키에 마이너스 값이 입력되지 않도록 하거나, 연락처의 국번에 02, 031, 041, 055 중 하나만 입력되도록 할 수 있다.

DROP TABLE IF EXISTS member;
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL PRIMARY KEY, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL CHECK (height >= 100),
  phone1      CHAR(3)  NULL
);

이렇게 height에 check 제약조건으로 키가 100 이상이 되도록 설정한 후

INSERT INTO member VALUES('BLK', '블랙핑크', 163, NULL);
INSERT INTO member VALUES('TWC', '트와이스', 99, NULL);

키가 100 미만이면 아래와 같은 오류를 내뿜는다.

이유는 체크 제약조건에 위배되었기 때문이다.

 

필요하다면 테이블을 만든 후에 ALTER TABLE 문으로 제약 조건을 추가해도 된다.

ALTER TABLE member
 ADD CONSTRAINT 
 CHECK  (phone1 IN ('02', '031', '032', '054', '055', '061' )) ;

이렇게 제약조건을 설정하면 02, 031, 032, 054, 055, 061 이외의 phone1 값이 들어오면 오류를 내뿜게 된다.

INSERT INTO member VALUES('TWC', '트와이스', 167, '02');
INSERT INTO member VALUES('OMY', '오마이걸', 167, '010');

 

기본값 정의

기본값 정의는 값을 입력하지 않았을 때 자동으로 입력될 값을 미리 지정해 놓는 방법이다.

예를 들어, 키를 입력하지 않는다면 기본적으로 160이라고 입력되도록 하고 싶다면 아래와 같이 정의한다.

DROP TABLE IF EXISTS member;
CREATE TABLE member 
( mem_id  CHAR(8) NOT NULL PRIMARY KEY, 
  mem_name    VARCHAR(10) NOT NULL, 
  height      TINYINT UNSIGNED NULL DEFAULT 160,
  phone1      CHAR(3)  NULL
);

ALTER TABLE문을 사용해서 기본값을 정의하려면 아래처럼 쿼리를 작성한다.

ALTER TABLE member ALTER COLUMN phone1 SET DEFAULT '02';

 

이제 데이터를 입력하면

INSERT INTO member VALUES('RED', '레드벨벳', 161, '054');
INSERT INTO member VALUES('SPC', '우주소녀', default, default);

default가 들어간 열은 height과 phone1이다.

기본 값을 정의했기 때문에 height에는 160이 들어가고, phone1에는 02가 들어간다.

 

널 값 허용

NULL 값을 허용하지 않으면 생략하거나 NULL을 사용하고, 허용하지 않으려면 NOT NULL을 사용한다.

다만, PRIMARY KEY가 설정된 열에는 NULL 값이 있을 수 없으므로 생략하면 자동으로 NOT NULL이 적용된다.

NULL값은 아무것도 없다는 의미이다. 공백(' ')이나 0과는 다르다.

이 글은 혼자 공부하는 SQL(저자 : 우재남)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다.


테이블은 표 형태로 구성된 2차원 구조로, 행과 열로 구성되어 있다.

행은 row나 recode라고 부르며, 열은 column 또는 field라고 부른다.

 

테이블을 생성하기 전에 테이블의 구조를 정의해야 한다.

데이터 형식을 활용해서 각 열에 가장 적합한 데이터 형식을 지정한다.

 

 

회원 테이블 생성 SQL

CREATE TABLE member -- 회원 테이블
( mem_id        CHAR(8) NOT NULL PRIMARY KEY,
  mem_name      VARCHAR(10) NOT NULL, 
  mem_number    TINYINT NOT NULL, 
  addr          CHAR(2) NOT NULL,
  phone1        CHAR(3) NULL,
  phone2        CHAR(8) NULL,
  height        TINYINT UNSIGNED NULL, 
  debut_date    DATE NULL
);

 

구매 테이블 생성 SQL

CREATE TABLE buy 
(  num         INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
   mem_id      CHAR(8) NOT NULL, 
   prod_name     CHAR(6) NOT NULL, 
   group_name     CHAR(4) NULL ,
   price         INT UNSIGNED NOT NULL,
   amount        SMALLINT UNSIGNED  NOT NULL ,
   FOREIGN KEY(mem_id) REFERENCES member(mem_id)
);