Step 14 — 뷰와 생성 컬럼

학습 목표

  • 뷰(VIEW)로 복잡한 쿼리를 이름 붙여 재사용하고, 업데이트 가능한 뷰의 조건을 안다
  • WITH CHECK OPTION (LOCAL / CASCADED)으로 뷰를 통한 변경을 통제한다
  • ALGORITHM MERGE 와 TEMPTABLE 의 성능 차이를 실행 계획으로 확인한다
  • 생성 컬럼(Generated Column)의 VIRTUAL / STORED 를 구분하고 자동 계산에 활용한다
  • 생성 컬럼 + 인덱스, 그리고 8.0.13+ 함수 기반 인덱스로 "함수 인덱스"를 만든다
  • 인비저블 컬럼(8.0.23+)으로 SELECT * 로부터 컬럼을 숨긴다

선행 스텝: Step 13 — 제약 조건과 정규화 예상 소요: 70분


14-0. 실습 준비

이 스텝도 DDL 을 다룹니다. 공용 테이블에는 뷰(읽기)만 만들고, 데이터를 바꾸는 실습은 s14_ 사본 테이블에서 합니다.

USE shop;
CREATE TABLE s14_customers AS SELECT * FROM customers;
ALTER TABLE s14_customers ADD PRIMARY KEY (customer_id);

⚠️ 뷰 자체는 데이터를 저장하지 않으므로 공용 테이블 위에 만들어도 안전합니다. 하지만 업데이트 가능한 뷰를 통해 UPDATE 하면 원본이 바뀝니다. 그래서 변경 실습은 반드시 사본 위에서 합니다.


14-1. 뷰(VIEW)란

뷰는 저장된 SELECT 문입니다. 데이터를 복사해 두는 게 아니라, 조회할 때마다 정의된 쿼리를 실행합니다. "가상 테이블"이라고 부릅니다.

CREATE VIEW v14_order_summary AS
SELECT o.order_id, o.order_date, o.status,
       c.name AS customer_name, c.grade, o.total_amount
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status <> 'CANCELLED';

-- 이제 복잡한 JOIN 을 몰라도 테이블처럼 조회할 수 있다
SELECT * FROM v14_order_summary WHERE grade = 'VIP' ORDER BY total_amount DESC LIMIT 5;

결과

+----------+---------------------+-----------+---------------+-------+--------------+
| order_id | order_date          | status    | customer_name | grade | total_amount |
+----------+---------------------+-----------+---------------+-------+--------------+
|      240 | 2024-04-30 00:00:00 | DELIVERED | 김민수        | VIP   |   4380000.00 |
|      480 | 2024-08-28 00:00:00 | DELIVERED | 김민수        | VIP   |   4380000.00 |
|      360 | 2024-06-29 00:00:00 | DELIVERED | 김민수        | VIP   |   4380000.00 |
|      120 | 2024-03-01 00:00:00 | DELIVERED | 김민수        | VIP   |   4380000.00 |
|      600 | 2024-10-27 00:00:00 | DELIVERED | 김민수        | VIP   |   4380000.00 |
+----------+---------------------+-----------+---------------+-------+--------------+

뷰의 용도:

  • 복잡성 은닉 — 5개 테이블 JOIN 을 뷰 하나로 감춘다
  • 권한 제어 — 민감 컬럼을 뺀 뷰만 특정 사용자에게 노출한다 (행/열 수준 보안)
  • 인터페이스 고정 — 테이블 구조가 바뀌어도 뷰가 같은 컬럼을 제공하면 애플리케이션은 그대로

뷰 관리 명령:

CREATE OR REPLACE VIEW v14_order_summary AS ... ;   -- 재정의 (있으면 교체)
ALTER VIEW v14_order_summary AS ... ;               -- 재정의
DROP VIEW IF EXISTS v14_order_summary;              -- 삭제
SHOW CREATE VIEW v14_order_summary;                 -- 정의 확인

뷰 목록과 정의는 information_schema.VIEWS 에서 볼 수 있습니다.

SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME LIKE 'v14%';

💡 실무 팁 — 뷰는 성능 도구가 아니다 뷰는 매번 원본 쿼리를 실행합니다. 느린 쿼리를 뷰로 감싼다고 빨라지지 않습니다. "매번 계산하기 싫다"면 뷰가 아니라 구체화(materialized) 개념이 필요합니다. MySQL 은 구체화 뷰를 기본 지원하지 않으므로, 요약 테이블을 만들어 배치로 채우거나(반정규화, Step 13), 생성 컬럼(아래)을 쓰거나, 캐시를 씁니다.


14-2. 업데이트 가능한 뷰

뷰가 단순히 원본 행과 1:1 로 대응되면, 그 뷰를 통해 INSERT/UPDATE/DELETE 를 할 수 있습니다.

CREATE VIEW v14_vip AS
SELECT customer_id, name, grade, city, points
FROM s14_customers
WHERE grade = 'VIP';

SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME='v14_vip';

결과

+------------+--------------+
| TABLE_NAME | IS_UPDATABLE |
+------------+--------------+
| v14_vip    | YES          |
+------------+--------------+
UPDATE v14_vip SET points = points + 1000 WHERE customer_id = 1;
SELECT customer_id, name, points FROM s14_customers WHERE customer_id = 1;

결과 (원본 테이블이 바뀐다)

+-------------+-----------+--------+
| customer_id | name      | points |
+-------------+-----------+--------+
|           1 | 김민수    |  13500 |
+-------------+-----------+--------+

업데이트 불가능해지는 조건

뷰가 아래 중 하나라도 포함하면 업데이트할 수 없습니다. 원본의 어느 행을 바꿔야 할지 1:1 로 특정할 수 없기 때문입니다.

포함하면 업데이트 불가
집계 함수 (SUM, COUNT, AVG ...)
GROUP BY / HAVING
DISTINCT
UNION / UNION ALL
대부분의 서브쿼리(SELECT 목록·WHERE 상관 등)
윈도우 함수 (OVER(...))
파생 컬럼만 있고 원본 컬럼이 없는 경우
CREATE VIEW v14_grouped AS
SELECT grade, COUNT(*) AS cnt, AVG(points) AS avg_points
FROM s14_customers GROUP BY grade;

SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME='v14_grouped';

결과

+-------------+--------------+
| TABLE_NAME  | IS_UPDATABLE |
+-------------+--------------+
| v14_grouped | NO           |
+-------------+--------------+
UPDATE v14_grouped SET cnt = 99 WHERE grade = 'VIP';

결과

ERROR 1288 (HY000): The target table v14_grouped of the UPDATE is not updatable

14-3. WITH CHECK OPTION

업데이트 가능한 뷰에는 위험한 함정이 있습니다. 뷰의 WHERE 조건을 벗어나게 만드는 UPDATE 가 그냥 통과한다는 것입니다.

-- CHECK OPTION 이 없는 v14_vip (WHERE grade='VIP')
UPDATE v14_vip SET grade = 'GOLD' WHERE customer_id = 1;   -- VIP 뷰에서 GOLD 로 바꿈?!
SELECT customer_id, name, grade FROM s14_customers WHERE customer_id = 1;

결과

+-------------+-----------+-------+
| customer_id | name      | grade |
+-------------+-----------+-------+
|           1 | 김민수    | GOLD  |     ← 바뀌었다
+-------------+-----------+-------+
SELECT COUNT(*) AS still_in_view FROM v14_vip WHERE customer_id = 1;

결과

+---------------+
| still_in_view |
+---------------+
|             0 |     ← 방금 수정한 행이 뷰에서 사라졌다!
+---------------+

"VIP 뷰"를 통해 수정했는데 그 행이 VIP 가 아니게 되어 뷰에서 증발했습니다. 마치 수정한 데이터가 사라진 것처럼 보입니다.

WITH CHECK OPTION 을 걸면 이런 변경을 거부합니다.

CREATE OR REPLACE VIEW v14_vip AS
SELECT customer_id, name, grade, city, points
FROM s14_customers WHERE grade = 'VIP'
WITH CHECK OPTION;

UPDATE v14_vip SET grade = 'GOLD' WHERE customer_id = 1;

결과

ERROR 1369 (HY000): CHECK OPTION failed 'shop.v14_vip'

INSERT 도 검사합니다. 뷰의 조건을 만족하지 않는 행은 애초에 넣을 수 없습니다.

CREATE VIEW v14_high AS
SELECT id, name, score FROM s14_scores WHERE score >= 60 WITH CHECK OPTION;

INSERT INTO v14_high (name, score) VALUES ('통과', 80);   -- OK
INSERT INTO v14_high (name, score) VALUES ('탈락', 40);   -- score < 60

결과

ERROR 1369 (HY000): CHECK OPTION failed 'shop.v14_high'

LOCAL vs CASCADED

뷰 위에 뷰를 얹었을 때, CHECK OPTION 이 어느 범위까지 검사할지 정합니다.

  • CASCADED (기본값): 이 뷰 + 그 아래 모든 부모 뷰의 조건을 전부 검사
  • LOCAL: 이 뷰 자신의 WHERE 조건만 검사
CREATE OR REPLACE VIEW v14_vip AS                    -- 부모: grade='VIP'
SELECT customer_id, name, grade, city, points FROM s14_customers WHERE grade = 'VIP';

CREATE OR REPLACE VIEW v14_vip_seoul AS              -- 자식: city='서울', LOCAL
SELECT * FROM v14_vip WHERE city = '서울'
WITH LOCAL CHECK OPTION;

-- LOCAL 은 자기 조건(city)만 검사한다. 부모 조건(grade)은 안 본다 → 통과!
UPDATE v14_vip_seoul SET grade = 'GOLD' WHERE customer_id = 1;
SELECT customer_id, grade, city FROM s14_customers WHERE customer_id = 1;

결과 (LOCAL 은 grade 변경을 허용 — city 조건만 봄)

+-------------+-------+--------+
| customer_id | grade | city   |
+-------------+-------+--------+
|           1 | GOLD  | 서울   |
+-------------+-------+--------+
CREATE OR REPLACE VIEW v14_vip_seoul AS
SELECT * FROM v14_vip WHERE city = '서울'
WITH CASCADED CHECK OPTION;

-- CASCADED 는 부모 뷰의 grade='VIP' 조건까지 검사한다 → 거부!
UPDATE v14_vip_seoul SET grade = 'GOLD' WHERE customer_id = 1;

결과

ERROR 1369 (HY000): CHECK OPTION failed 'shop.v14_vip_seoul'

⚠️ 함정 — 기본값은 CASCADED 다 WITH CHECK OPTION 만 쓰면 CASCADED 입니다. 중첩 뷰에서 예상보다 엄격하게 막힐 수 있습니다. 자기 조건만 검사하고 싶으면 명시적으로 WITH LOCAL CHECK OPTION 을 쓰세요.


14-4. ALGORITHM — MERGE vs TEMPTABLE

뷰를 조회할 때 MySQL 이 뷰를 처리하는 방식이 두 가지입니다.

MERGE     : 뷰를 바깥 쿼리에 "펼쳐 넣어" 하나의 쿼리로 합친다.
            → 바깥 WHERE 조건이 원본 테이블까지 전달되어 인덱스를 쓸 수 있다.

TEMPTABLE : 뷰의 SELECT 를 먼저 실행해 "임시 테이블"로 구체화한 뒤,
            그 임시 테이블에 바깥 조건을 적용한다.
            → 원본 인덱스를 못 쓴다. 임시 테이블은 인덱스가 없으니까.

같은 정의의 뷰를 두 알고리즘으로 만들어 비교합니다.

CREATE ALGORITHM=MERGE VIEW v14_merge AS
SELECT log_id, customer_id, path, status_code, logged_at FROM access_logs;

CREATE ALGORITHM=TEMPTABLE VIEW v14_temptable AS
SELECT log_id, customer_id, path, status_code, logged_at FROM access_logs;

PK 로 한 행을 찾는 쿼리의 실행 계획:

EXPLAIN SELECT * FROM v14_merge WHERE log_id = 1;

결과 (MERGE — PK 조건이 원본까지 전달되어 const)

+----+-------------+-------------+-------+---------------+---------+---------+-------+------+-------+
| id | select_type | table       | type  | possible_keys | key     | key_len | ref   | rows | Extra |
+----+-------------+-------------+-------+---------------+---------+---------+-------+------+-------+
|  1 | SIMPLE      | access_logs | const | PRIMARY       | PRIMARY | 8       | const |    1 | NULL  |
+----+-------------+-------------+-------+---------------+---------+---------+-------+------+-------+
EXPLAIN SELECT * FROM v14_temptable WHERE log_id = 1;

결과 (TEMPTABLE — <derived2> 임시테이블을 거친다)

+----+-------------+-------------+--------+---------------+---------+---------+-------+------+
| id | select_type | table       | type   | possible_keys | key     | key_len | ref   | rows |
+----+-------------+-------------+--------+---------------+---------+---------+-------+------+
|  1 | PRIMARY     | <derived2>  | system | NULL          | NULL    | NULL    | NULL  |    1 |
|  2 | DERIVED     | access_logs | const  | PRIMARY       | PRIMARY | 8       | const |    1 |
+----+-------------+-------------+--------+---------------+---------+---------+-------+------+

<derived2> 라는 파생 테이블이 등장합니다. 이 경우는 옵티마이저가 영리해서 결국 빠르지만, 조건이 임시 테이블 밖에서만 적용될 때는 이야기가 다릅니다. status_code = 500 을 세는 쿼리로 실제 시간을 재봅니다(아직 status_code 인덱스는 없습니다).

SELECT COUNT(*) FROM v14_merge     WHERE status_code = 500;   -- (0.096 sec)
SELECT COUNT(*) FROM v14_temptable WHERE status_code = 500;   -- (0.136 sec)

TEMPTABLE 은 100만 행을 임시 테이블로 구체화하는 비용이 더해져 약 40% 느립니다. 데이터가 크고 조건이 선택적일수록 격차는 더 벌어집니다.

💡 실무 팁 특별한 이유가 없으면 뷰는 MERGE(기본) 로 두세요. ALGORITHM=UNDEFINED(기본)면 MySQL 이 알아서 고릅니다. 그런데 뷰 정의에 위 14-2 의 "업데이트 불가 요소"(집계·DISTINCT·UNION·윈도우 함수 등)가 들어가면 MERGE 가 불가능해서 자동으로 TEMPTABLE 로 떨어집니다. "왜 이 뷰만 느리지?" 의 흔한 원인입니다.

⚠️ 함정 — TEMPTABLE 뷰는 업데이트도 불가능하다 임시 테이블을 거치므로 원본 행과의 1:1 대응이 끊깁니다. ALGORITHM=TEMPTABLE 뷰는 항상 읽기 전용입니다.


14-5. 생성 컬럼 (Generated Column)

생성 컬럼은 다른 컬럼으로부터 계산되는 컬럼입니다. 값을 직접 넣지 않고, 표현식이 자동으로 채웁니다.

CREATE TABLE s14_gen (
  product_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name       VARCHAR(50) NOT NULL,
  price      DECIMAL(10,2) NOT NULL,
  cost       DECIMAL(10,2) NOT NULL,
  margin     DECIMAL(10,2) AS (price - cost) VIRTUAL,                      -- 가상
  margin_pct DECIMAL(5,2)  AS (ROUND((price-cost)/price*100, 2)) STORED,   -- 저장
  name_upper VARCHAR(50)   AS (UPPER(name)) VIRTUAL,
  PRIMARY KEY (product_id)
) ENGINE=InnoDB;

INSERT INTO s14_gen (name, price, cost) VALUES ('shirt', 39000, 18000), ('laptop', 1290000, 980000);
SELECT product_id, name, price, cost, margin, margin_pct, name_upper FROM s14_gen;

결과

+------------+--------+------------+-----------+-----------+------------+------------+
| product_id | name   | price      | cost      | margin    | margin_pct | name_upper |
+------------+--------+------------+-----------+-----------+------------+------------+
|          1 | shirt  |   39000.00 |  18000.00 |  21000.00 |      53.85 | SHIRT      |
|          2 | laptop | 1290000.00 | 980000.00 | 310000.00 |      24.03 | LAPTOP     |
+------------+--------+------------+-----------+-----------+------------+------------+

VIRTUAL vs STORED

VIRTUAL (기본)STORED
값 저장저장 안 함, 읽을 때 계산디스크에 저장
저장 공간안 씀
INSERT/UPDATE 비용거의 없음계산해서 써야 함
인덱스가능 (세컨더리)가능
언제대부분의 경우계산이 비싸고 자주 읽을 때, 또는 PK 로 쓸 때

💡 기본은 VIRTUAL 로 두세요. 저장 공간을 쓰지 않고, 값이 필요할 때만 계산합니다. 인덱스도 걸 수 있으니 대부분 VIRTUAL 로 충분합니다. STORED 는 표현식이 복잡해서 매 조회마다 계산하기 아까울 때만 씁니다.

생성 컬럼에는 직접 값을 넣을 수 없습니다.

INSERT INTO s14_gen (name, price, cost, margin) VALUES ('x', 100, 50, 999);

결과

ERROR 3105 (HY000): The value specified for generated column 'margin' in table 's14_gen' is not allowed.

원본 컬럼이 바뀌면 생성 컬럼은 자동으로 다시 계산됩니다.

UPDATE s14_gen SET price = 45000 WHERE name = 'shirt';
SELECT product_id, name, price, cost, margin, margin_pct FROM s14_gen WHERE name = 'shirt';

결과 (margin, margin_pct 가 알아서 갱신됨)

+------------+-------+----------+----------+----------+------------+
| product_id | name  | price    | cost     | margin   | margin_pct |
+------------+-------+----------+----------+----------+------------+
|          1 | shirt | 45000.00 | 18000.00 | 27000.00 |      60.00 |
+------------+-------+----------+----------+----------+------------+

💡 생성 컬럼은 "안전한 반정규화"다 Step 13 에서 반정규화의 위험은 "동기화를 개발자가 책임져야 한다"는 것이었습니다. 생성 컬럼은 DB 가 동기화를 보장합니다. marginprice - cost 와 어긋날 방법이 없습니다. 계산식으로 표현 가능한 파생값이라면, 수동 반정규화 컬럼보다 생성 컬럼이 훨씬 안전합니다.


14-6. 함수 인덱스 만들기

Step 15/16 에서 자세히 다루지만, 컬럼에 함수를 씌우면 인덱스를 못 탑니다. WHERE UPPER(name) = ...name 인덱스를 무용지물로 만듭니다.

두 가지 해결책이 있습니다.

방법 1 — 생성 컬럼 + 인덱스

-- name_upper 는 위에서 만든 VIRTUAL 생성 컬럼 (UPPER(name))
ALTER TABLE s14_gen ADD INDEX idx_name_upper (name_upper);

이제 UPPER(name) = 'SHIRT' 라고 써도 옵티마이저가 생성 컬럼 인덱스로 자동 매핑합니다(생성 컬럼 이름을 직접 쓰지 않아도 됩니다).

EXPLAIN SELECT * FROM s14_gen WHERE UPPER(name) = 'SHIRT';

결과 (type: ref, 인덱스를 탄다)

+----+---------+------+----------------+----------------+---------+-------+------+-------+
| id | table   | type | possible_keys  | key            | key_len | ref   | rows | Extra |
+----+---------+------+----------------+----------------+---------+-------+------+-------+
|  1 | s14_gen | ref  | idx_name_upper | idx_name_upper | 203     | const |    1 | NULL  |
+----+---------+------+----------------+----------------+---------+-------+------+-------+

방법 2 — 함수 기반 인덱스 (MySQL 8.0.13+)

8.0.13 부터는 생성 컬럼을 따로 만들지 않고 표현식에 직접 인덱스를 걸 수 있습니다. 이때 표현식은 괄호로 감쌉니다.

ALTER TABLE s14_gen ADD INDEX idx_func_margin ((price - cost));
ANALYZE TABLE s14_gen;   -- 통계를 갱신해야 옵티마이저가 선택도를 제대로 판단한다
EXPLAIN SELECT product_id, name FROM s14_gen WHERE (price - cost) = 3500.00;

결과 (type: ref)

+----+---------+------+-----------------+-----------------+---------+-------+------+
| id | table   | type | possible_keys   | key             | key_len | ref   | rows |
+----+---------+------+-----------------+-----------------+---------+-------+------+
|  1 | s14_gen | ref  | idx_func_margin | idx_func_margin | 5       | const |    1 |
+----+---------+------+-----------------+-----------------+---------+-------+------+

SHOW INDEX 에 표현식이 그대로 보입니다.

| Key_name        | Column_name | Expression         |
| idx_func_margin | NULL        | (`price` - `cost`) |

⚠️ 함정 1 — 리터럴 타입이 표현식 타입과 맞아야 한다 price, cost 가 DECIMAL 이라 (price - cost) 는 DECIMAL 입니다. 그런데 WHERE (price - cost) = 3500 (정수)로 쓰면 인덱스를 못 탑니다. = 3500.00 처럼 DECIMAL 리터럴로 맞춰야 매핑됩니다. 실제로 이 차이로 인덱스가 죽는 것을 자주 봅니다.

⚠️ 함정 2 — 쿼리의 표현식이 인덱스 정의와 "글자까지" 같아야 한다 인덱스가 (price - cost) 인데 쿼리에 (cost - price)(price - cost + 0) 을 쓰면 안 탑니다. 옵티마이저는 수학이 아니라 표현식 문자열을 매칭합니다.

⚠️ 함정 3 — 통계가 없으면 안 쓸 수 있다 방금 인덱스를 만든 직후에는 카디널리티 통계가 부실해서 옵티마이저가 풀스캔을 고르기도 합니다. ANALYZE TABLE 로 통계를 갱신하세요(Step 16 에서 자세히).

💡 JSON 함수 인덱스 — 8.0 은 JSON 컬럼의 값을 뽑는 표현식에도 인덱스를 걸 수 있습니다.

ALTER TABLE products ADD INDEX idx_ram ((CAST(attrs->>'$.ram_gb' AS UNSIGNED)));

JSON 을 자주 조건으로 쓴다면 필수입니다. (Step 18 JSON 에서 다룹니다.)


14-7. 인비저블 컬럼 (MySQL 8.0.23+)

인비저블 컬럼은 존재하지만 SELECT * 결과에 나오지 않는 컬럼입니다. 명시적으로 이름을 적어야만 보입니다.

CREATE TABLE s14_inv (
  id     INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name   VARCHAR(50) NOT NULL,
  secret VARCHAR(50) NOT NULL INVISIBLE,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

INSERT INTO s14_inv (id, name, secret) VALUES (1, '홍길동', 'password123');

SELECT * FROM s14_inv;              -- secret 이 안 보인다

결과

+----+-----------+
| id | name      |
+----+-----------+
|  1 | 홍길동    |
+----+-----------+
SELECT id, name, secret FROM s14_inv;   -- 명시하면 보인다

결과

+----+-----------+-------------+
| id | name      | secret      |
+----+-----------+-------------+
|  1 | 홍길동    | password123 |
+----+-----------+-------------+

비저블/인비저블은 언제든 토글할 수 있습니다.

ALTER TABLE s14_inv ALTER COLUMN secret SET VISIBLE;    -- 다시 보이게
ALTER TABLE s14_inv ALTER COLUMN secret SET INVISIBLE;  -- 다시 숨기게

💡 실무 용도

  • 컬럼 안전 삭제 리허설: 컬럼을 지우기 전에 INVISIBLE 로 바꿔 두면, SELECT * 에 의존하던 코드가 있는지 실제로 지우지 않고 확인할 수 있습니다. 문제 없으면 그때 DROP 합니다.
  • 새 컬럼 점진 도입: 새 컬럼을 INVISIBLE 로 추가하면 기존 INSERT ... SELECT * ... 배치를 깨지 않고 준비할 수 있습니다.

⚠️ 함정 — SELECT * 에 의존하지 마라 (INSERT ... SELECT * 특히) 인비저블 컬럼은 SELECT * 에서 빠지므로, INSERT INTO t2 SELECT * FROM t1 같은 코드가 컬럼 수 불일치로 깨질 수 있습니다. 이건 인비저블 컬럼의 문제가 아니라 SELECT * 에 의존한 코드의 문제입니다. 애초에 컬럼을 명시하는 습관이 답입니다.

참고: 인비저블 인덱스(8.0.0+)는 다른 기능입니다 — 옵티마이저가 특정 인덱스를 무시하게 만들어 "이 인덱스를 지워도 안전한가"를 테스트합니다. Step 15 에서 다룹니다.


정리

주제핵심
저장된 SELECT. 데이터를 복사하지 않고 매번 실행한다
뷰와 성능뷰로 감싼다고 빨라지지 않는다. 성능 도구가 아니다
업데이트 가능 뷰집계/GROUP BY/DISTINCT/UNION/윈도우 함수가 있으면 불가
WITH CHECK OPTION뷰 조건을 벗어나는 INSERT/UPDATE 를 거부. 기본은 CASCADED
LOCAL vs CASCADEDLOCAL=자기 조건만, CASCADED=부모 뷰 조건까지
ALGORITHM MERGE뷰를 펼쳐 원본 인덱스 활용. 기본이자 권장
ALGORITHM TEMPTABLE임시테이블로 구체화 → 느리고 읽기 전용
생성 컬럼 VIRTUAL저장 안 함, 읽을 때 계산. 기본이자 권장
생성 컬럼 STORED디스크에 저장. 계산이 비쌀 때만
생성 컬럼DB 가 동기화를 보장하는 "안전한 반정규화"
함수 인덱스 (8.0.13)표현식에 직접 인덱스. 리터럴 타입·표현식 문자열이 일치해야 함
인비저블 컬럼 (8.0.23)SELECT * 에서 숨김. 컬럼 삭제 리허설·점진 도입에 유용

연습문제

exercise.sql 에 6문제가 있습니다. 정답은 solution.sql.

  1. 고객별 주문 통계 뷰 만들기 (JOIN + 집계)
  2. 주어진 뷰가 업데이트 가능한지 판정하고 이유 대기
  3. WITH CHECK OPTION 으로 "재고 있는 상품만" 뷰 통제하기
  4. LOCAL vs CASCADED 결과 예측하기
  5. full_name 생성 컬럼 + 인덱스로 검색 최적화하기
  6. 함수 기반 인덱스가 안 먹는 쿼리 3개를 진단하고 고치기

다음 단계

지금까지 인덱스를 "결과"로만 봤습니다(생성 컬럼 인덱스, 함수 인덱스가 type: ref 로 바뀌는 것). 다음 스텝에서는 그 인덱스가 왜, 어떻게 동작하는지 — B+Tree 구조부터 복합 인덱스 컬럼 순서, 커버링 인덱스, 그리고 100만 행 access_logs 로 인덱스 전후 실행시간을 직접 재는 것까지 파고듭니다.

Step 15 — 인덱스


실습 파일

이 스텝은 SQL 스크립트 세 개로 구성됩니다. 먼저 practice.sql 을 통째로 실행해 14-1 ~ 14-7 본문의 모든 예제(뷰, CHECK OPTION, MERGE/TEMPTABLE, 생성 컬럼, 함수 인덱스, 인비저블 컬럼)를 눈으로 확인하고, 그다음 exercise.sql 의 빈칸 6문제를 직접 채운 뒤, solution.sql 로 답과 해설을 대조하는 순서입니다. 세 스크립트 모두 USE shop; 으로 시작하며, 공용 테이블에는 뷰(읽기)만 만들고 데이터를 바꾸는 실습은 전부 s14_ / s14_ex_ 사본 테이블 위에서 수행하도록 설계되어 있습니다.

practice.sql

본문 14-0 ~ 14-7 을 그대로 스크립트로 옮긴 따라하기용 데모입니다. 파일 머리말의 실행 명령이 핵심입니다.

  • 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 --force shop < practice.sql. --force 가 반드시 필요합니다. 이 스크립트에는 UPDATE v14_grouped ...(ERROR 1288), WITH CHECK OPTION 위반(ERROR 1369), 생성 컬럼 직접 INSERT(ERROR 3105)처럼 일부러 에러를 내는 줄이 들어 있어서, --force 가 없으면 첫 에러에서 스크립트가 중단됩니다. 즉 에러 메시지 자체가 학습 결과물입니다.
  • 맨 앞과 맨 뒤에서 DROP VIEW IF EXISTS v14_vip_seoul, v14_vip, ... / DROP TABLE IF EXISTS s14_customers, s14_gen, s14_inv, s14_scores 를 실행하므로 몇 번을 다시 돌려도 같은 결과가 나옵니다. 뒤처리까지 하므로 실습 흔적이 DB 에 남지 않습니다.
  • 14-3 구간을 보면 UPDATE v14_vip SET grade='GOLD' ... 로 행을 뷰에서 "증발"시킨 직후 UPDATE s14_customers SET grade='VIP' WHERE customer_id=1;원복합니다. LOCAL/CASCADED 비교를 같은 1번 고객으로 이어서 하기 때문에 이 원복 줄을 빼먹으면 뒤의 결과가 달라집니다.
  • 14-6 의 INSERT INTO s14_gen (name, price, cost) SELECT CONCAT('item', n), 1000 + n, 500 FROM tally WHERE n <= 5000;함수 인덱스가 실제로 선택되게 만들기 위한 장치입니다. 행이 2개뿐이면 옵티마이저가 인덱스를 무시하고 풀스캔을 고르므로 5,000행을 채워 카디널리티를 만들고, 인덱스 생성 직후 ANALYZE TABLE s14_gen; 으로 통계를 갱신합니다(본문 "함정 3").
  • EXPLAIN ... WHERE (price - cost) = 3500.00; 의 리터럴이 3500 이 아니라 3500.00 인 것도 의도된 것입니다(본문 "함정 1" — DECIMAL 표현식에는 DECIMAL 리터럴).
  • access_logs(100만 행)와 tally 테이블에 의존하므로, 시드 데이터가 적재된 shop 스키마에서 실행해야 합니다.
-- =====================================================================
-- Step 14 — 뷰와 생성 컬럼 : practice.sql
-- ---------------------------------------------------------------------
-- 실행:  mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 --force shop < practice.sql
--        ( --force : 일부러 에러를 내는 예제가 있어서 계속 진행 )
--
-- ⚠️ 공용 테이블에는 "뷰(읽기)"만 만듭니다. 변경 실습은 s14_ 사본에서.
-- =====================================================================
USE shop;

DROP VIEW IF EXISTS v14_vip_seoul, v14_vip, v14_grouped, v14_order_summary,
                    v14_high, v14_merge, v14_temptable;
DROP TABLE IF EXISTS s14_customers, s14_gen, s14_inv, s14_scores;


-- ---------------------------------------------------------------------
-- [14-0] 실습 준비 — 변경 실습용 사본
-- ---------------------------------------------------------------------
CREATE TABLE s14_customers AS SELECT * FROM customers;
ALTER TABLE s14_customers ADD PRIMARY KEY (customer_id);


-- ---------------------------------------------------------------------
-- [14-1] 뷰 기본 — 복잡한 JOIN 을 이름 붙여 감춘다
-- ---------------------------------------------------------------------
CREATE VIEW v14_order_summary AS
SELECT o.order_id, o.order_date, o.status,
       c.name AS customer_name, c.grade, o.total_amount
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status <> 'CANCELLED';

SELECT * FROM v14_order_summary WHERE grade = 'VIP' ORDER BY total_amount DESC LIMIT 5;

-- 뷰 정의와 업데이트 가능 여부 확인
SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME LIKE 'v14%';

-- 뷰 관리 명령 (정의 확인). 대화형 클라이언트에서는 \G 로 세로 출력하면 보기 좋다.
SHOW CREATE VIEW v14_order_summary;


-- ---------------------------------------------------------------------
-- [14-2] 업데이트 가능한 뷰
-- ---------------------------------------------------------------------
CREATE VIEW v14_vip AS
SELECT customer_id, name, grade, city, points
FROM s14_customers
WHERE grade = 'VIP';

SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME='v14_vip';       -- YES

-- 뷰를 통한 UPDATE → 원본(s14_customers)이 바뀐다
UPDATE v14_vip SET points = points + 1000 WHERE customer_id = 1;
SELECT customer_id, name, points FROM s14_customers WHERE customer_id = 1;   -- 13500

-- 집계가 있는 뷰는 업데이트 불가
CREATE VIEW v14_grouped AS
SELECT grade, COUNT(*) AS cnt, AVG(points) AS avg_points
FROM s14_customers GROUP BY grade;

SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME='v14_grouped';   -- NO

UPDATE v14_grouped SET cnt = 99 WHERE grade = 'VIP';      -- ERROR 1288


-- ---------------------------------------------------------------------
-- [14-3] WITH CHECK OPTION
-- ---------------------------------------------------------------------
-- CHECK OPTION 없는 뷰: 조건을 벗어나는 UPDATE 가 통과 → 행이 뷰에서 사라진다
UPDATE v14_vip SET grade = 'GOLD' WHERE customer_id = 1;
SELECT customer_id, name, grade FROM s14_customers WHERE customer_id = 1;    -- GOLD 로 바뀜
SELECT COUNT(*) AS still_in_view FROM v14_vip WHERE customer_id = 1;         -- 0 (증발!)
UPDATE s14_customers SET grade = 'VIP' WHERE customer_id = 1;                -- 원복

-- WITH CHECK OPTION 을 걸면 그런 변경을 거부
CREATE OR REPLACE VIEW v14_vip AS
SELECT customer_id, name, grade, city, points
FROM s14_customers WHERE grade = 'VIP'
WITH CHECK OPTION;

UPDATE v14_vip SET grade = 'GOLD' WHERE customer_id = 1;   -- ERROR 1369 CHECK OPTION failed

-- INSERT 도 검사한다 (깨끗한 데모용 테이블)
DROP TABLE IF EXISTS s14_scores;
CREATE TABLE s14_scores (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(20) NOT NULL,
  score INT NOT NULL
) ENGINE=InnoDB;
CREATE VIEW v14_high AS
SELECT id, name, score FROM s14_scores WHERE score >= 60 WITH CHECK OPTION;

INSERT INTO v14_high (name, score) VALUES ('통과', 80);    -- OK
INSERT INTO v14_high (name, score) VALUES ('탈락', 40);    -- ERROR 1369 (score < 60)
SELECT * FROM s14_scores;

-- LOCAL vs CASCADED
CREATE OR REPLACE VIEW v14_vip AS
SELECT customer_id, name, grade, city, points FROM s14_customers WHERE grade = 'VIP';

-- LOCAL: 자기 조건(city)만 검사, 부모 조건(grade)은 안 봄 → grade 변경 통과
CREATE OR REPLACE VIEW v14_vip_seoul AS
SELECT * FROM v14_vip WHERE city = '서울' WITH LOCAL CHECK OPTION;
UPDATE v14_vip_seoul SET grade = 'GOLD' WHERE customer_id = 1;
SELECT customer_id, grade, city FROM s14_customers WHERE customer_id = 1;    -- GOLD (통과)
UPDATE s14_customers SET grade = 'VIP' WHERE customer_id = 1;

-- CASCADED: 부모 뷰의 grade='VIP' 조건까지 검사 → 거부
CREATE OR REPLACE VIEW v14_vip_seoul AS
SELECT * FROM v14_vip WHERE city = '서울' WITH CASCADED CHECK OPTION;
UPDATE v14_vip_seoul SET grade = 'GOLD' WHERE customer_id = 1;   -- ERROR 1369


-- ---------------------------------------------------------------------
-- [14-4] ALGORITHM MERGE vs TEMPTABLE
-- ---------------------------------------------------------------------
CREATE ALGORITHM=MERGE VIEW v14_merge AS
SELECT log_id, customer_id, path, status_code, logged_at FROM access_logs;

CREATE ALGORITHM=TEMPTABLE VIEW v14_temptable AS
SELECT log_id, customer_id, path, status_code, logged_at FROM access_logs;

-- MERGE: PK 조건이 원본까지 전달됨 → type: const, rows=1
EXPLAIN SELECT * FROM v14_merge WHERE log_id = 1;

-- TEMPTABLE: <derived2> 임시테이블을 거친다
EXPLAIN SELECT * FROM v14_temptable WHERE log_id = 1;

-- 실제 시간 비교 (status_code 인덱스는 아직 없음)
SELECT COUNT(*) FROM v14_merge     WHERE status_code = 500;   -- 약 0.096 sec
SELECT COUNT(*) FROM v14_temptable WHERE status_code = 500;   -- 약 0.136 sec (임시테이블 구체화)


-- ---------------------------------------------------------------------
-- [14-5] 생성 컬럼 (VIRTUAL vs STORED)
-- ---------------------------------------------------------------------
CREATE TABLE s14_gen (
  product_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name       VARCHAR(50) NOT NULL,
  price      DECIMAL(10,2) NOT NULL,
  cost       DECIMAL(10,2) NOT NULL,
  margin     DECIMAL(10,2) AS (price - cost) VIRTUAL,                      -- 읽을 때 계산
  margin_pct DECIMAL(5,2)  AS (ROUND((price-cost)/price*100, 2)) STORED,   -- 디스크에 저장
  name_upper VARCHAR(50)   AS (UPPER(name)) VIRTUAL,
  PRIMARY KEY (product_id)
) ENGINE=InnoDB;

INSERT INTO s14_gen (name, price, cost) VALUES ('shirt', 39000, 18000), ('laptop', 1290000, 980000);
SELECT product_id, name, price, cost, margin, margin_pct, name_upper FROM s14_gen;

-- 생성 컬럼에 직접 값 INSERT 불가 → ERROR 3105
INSERT INTO s14_gen (name, price, cost, margin) VALUES ('x', 100, 50, 999);

-- 원본이 바뀌면 생성 컬럼 자동 갱신
UPDATE s14_gen SET price = 45000 WHERE name = 'shirt';
SELECT product_id, name, price, cost, margin, margin_pct FROM s14_gen WHERE name = 'shirt';


-- ---------------------------------------------------------------------
-- [14-6] 함수 인덱스 만들기
-- ---------------------------------------------------------------------
-- 함수 인덱스가 실제로 사용되는 걸 보려면 데이터가 충분해야 한다 → 5000행 추가
INSERT INTO s14_gen (name, price, cost)
SELECT CONCAT('item', n), 1000 + n, 500 FROM tally WHERE n <= 5000;

-- 방법 1) 생성 컬럼 + 인덱스
--   인덱스 없이 UPPER(name) 조회 → type: ALL (풀스캔)
EXPLAIN SELECT * FROM s14_gen WHERE UPPER(name) = 'SHIRT';

ALTER TABLE s14_gen ADD INDEX idx_name_upper (name_upper);
--   생성 컬럼에 인덱스 → UPPER(name) 을 옵티마이저가 자동 매핑 → type: ref
EXPLAIN SELECT * FROM s14_gen WHERE UPPER(name) = 'SHIRT';

-- 방법 2) 함수 기반 인덱스 (8.0.13+) — 표현식에 직접, 괄호로 감싼다
ALTER TABLE s14_gen ADD INDEX idx_func_margin ((price - cost));
ANALYZE TABLE s14_gen;                                          -- 통계 갱신 필수!

--   함정: 리터럴 타입이 표현식 타입(DECIMAL)과 맞아야 인덱스를 탄다
--   3500 (정수) 이 아니라 3500.00 (DECIMAL) 으로 써야 한다
EXPLAIN SELECT product_id, name FROM s14_gen WHERE (price - cost) = 3500.00;    -- type: ref
SELECT product_id, name, margin FROM s14_gen WHERE (price - cost) = 3500.00;

-- 인덱스에 표현식이 그대로 보인다
SHOW INDEX FROM s14_gen;


-- ---------------------------------------------------------------------
-- [14-7] 인비저블 컬럼 (8.0.23+)
-- ---------------------------------------------------------------------
CREATE TABLE s14_inv (
  id     INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name   VARCHAR(50) NOT NULL,
  secret VARCHAR(50) NOT NULL INVISIBLE,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

INSERT INTO s14_inv (id, name, secret) VALUES (1, '홍길동', 'password123');

SELECT * FROM s14_inv;                    -- secret 안 보임
SELECT id, name, secret FROM s14_inv;     -- 명시하면 보임

-- 토글
ALTER TABLE s14_inv ALTER COLUMN secret SET VISIBLE;
SELECT * FROM s14_inv;                     -- 이제 secret 보임
ALTER TABLE s14_inv ALTER COLUMN secret SET INVISIBLE;


-- ---------------------------------------------------------------------
-- 정리
-- ---------------------------------------------------------------------
DROP VIEW IF EXISTS v14_vip_seoul, v14_vip, v14_grouped, v14_order_summary,
                    v14_high, v14_merge, v14_temptable;
DROP TABLE IF EXISTS s14_customers, s14_gen, s14_inv, s14_scores;

SELECT 'Step 14 practice 완료' AS msg;

exercise.sql

본문을 다 읽은 뒤 푸는 연습문제 6개입니다. 각 문제는 "여기에 작성:" 주석으로 끝나며, 그 아래에 직접 SQL 을 채워 넣으면 됩니다. 새로 만드는 객체는 v14_ex_ / s14_ex_ 접두사를 쓰라는 규칙이 머리말에 명시되어 있습니다.

  • 문제 1v14_ex_customer_stats 뷰. "주문이 한 건도 없는 고객도 포함(order_cnt = 0)"이라는 조건이 함정입니다. LEFT JOIN + CANCELLED 제외 조건을 어디에 두느냐(ON 절 vs WHERE 절)를 묻습니다.
  • 문제 2s14_ex_prod 사본 위에 네 개 뷰(v14_ex_a 단순 필터, v14_ex_b GROUP BY, v14_ex_c DISTINCT, v14_ex_d 파생 컬럼 price * 1.1)를 미리 만들어 두고 IS_UPDATABLE 을 예측하게 합니다. (2-3) 은 "뷰가 업데이트 가능한 것"과 "그 뷰의 모든 컬럼이 업데이트 가능한 것"이 다르다는 점을 직접 부딪혀 보게 하는 문항입니다.
  • 문제 3WITH CHECK OPTION 으로 "재고 있는 상품 뷰를 통해 재고를 0 으로 만드는" 모순을 막습니다. stock = 1 은 성공, stock = 0 은 거부되어야 합니다.
  • 문제 4s14_ex_emp(개발 2명, 영업 1명)와 부모 뷰 v14_ex_dev(dept='개발', CHECK OPTION 없음) 위에 v14_ex_dev_highpay(salary >= 4000, LOCAL)를 얹어 둡니다. UPDATE ... SET dept='영업' WHERE emp_id=1 이 LOCAL 에서는 통과하고 CASCADED 에서는 거부되는 차이를 예측하게 합니다.
  • 문제 5s14_ex_cust 사본을 만든 뒤 UPDATE ... SET email = REPLACE(email, 'example.com', 'test.org') WHERE customer_id % 3 = 0;도메인 분포를 인위적으로 3분의 1만 바꿔 둡니다. 모든 이메일이 같은 도메인이면 선택도가 0 이라 인덱스를 타지 않기 때문입니다.
  • 문제 6s14_ex_log 에 함수 기반 인덱스 INDEX idx_ym ((YEAR(logged_at))) 만 걸어 두고, tally 로 2022-01-01 부터 1,000일치 데이터를 넣습니다. YEAR(logged_at) = 2023, YEAR(logged_at) + 0 = 2023, logged_at >= '2023-01-01' AND ... 세 쿼리 중 무엇이 인덱스를 못 타는지 EXPLAIN 으로 진단하는 문제입니다.
-- =====================================================================
-- Step 14 — 뷰와 생성 컬럼 : exercise.sql  (문제 6개)
-- ---------------------------------------------------------------------
-- 정답은 solution.sql. 새로 만드는 객체는 v14_ex_ / s14_ex_ 접두사를 쓰세요.
-- 공용 테이블에는 뷰(읽기)만 만들고, 변경 실습은 사본에서 하세요.
-- =====================================================================
USE shop;


-- ---------------------------------------------------------------------
-- [문제 1] 고객별 주문 통계 뷰
-- ---------------------------------------------------------------------
-- v14_ex_customer_stats 뷰를 만드세요. 컬럼:
--   customer_id, name, grade,
--   order_cnt      : 그 고객의 (취소 아닌) 주문 수
--   total_spent    : (취소 아닌) 주문 total_amount 합계
--   last_order_date: 마지막 주문일
-- CANCELLED 주문은 제외합니다.
-- 주문이 한 건도 없는 고객도 결과에 포함되어야 합니다 (order_cnt = 0).
--
-- 만든 뒤 total_spent 상위 5명을 조회하세요.

-- 여기에 작성:



-- ---------------------------------------------------------------------
-- [문제 2] 이 뷰들은 업데이트 가능한가?
-- ---------------------------------------------------------------------
-- 아래 각 뷰가 업데이트 가능한지(IS_UPDATABLE) 예측하고, 이유를 주석으로 쓰세요.
-- 그런 다음 information_schema.VIEWS 로 실제로 확인하세요.

DROP TABLE IF EXISTS s14_ex_prod;
CREATE TABLE s14_ex_prod AS SELECT product_id, name, price, cost, stock, status FROM products;
ALTER TABLE s14_ex_prod ADD PRIMARY KEY (product_id);

CREATE OR REPLACE VIEW v14_ex_a AS
  SELECT product_id, name, price FROM s14_ex_prod WHERE status = 'ON_SALE';

CREATE OR REPLACE VIEW v14_ex_b AS
  SELECT status, COUNT(*) AS cnt FROM s14_ex_prod GROUP BY status;

CREATE OR REPLACE VIEW v14_ex_c AS
  SELECT DISTINCT status FROM s14_ex_prod;

CREATE OR REPLACE VIEW v14_ex_d AS
  SELECT product_id, name, price, price * 1.1 AS price_with_vat FROM s14_ex_prod;

-- (2-1) 예측을 주석으로:  a=?, b=?, c=?, d=?
-- (2-2) 확인 쿼리 작성:
-- (2-3) v14_ex_d 는 업데이트 가능하다면, price_with_vat 컬럼을 UPDATE 할 수 있을까요?
--       직접 시도해서 결과를 주석으로 남기세요.

-- 여기에 작성:



-- ---------------------------------------------------------------------
-- [문제 3] WITH CHECK OPTION 으로 재고 통제
-- ---------------------------------------------------------------------
-- "재고가 있는 상품만" 보여주는 v14_ex_instock 뷰를 만들되,
-- 이 뷰를 통해서는 stock 을 0 이하로 만들 수 없도록 하세요.
-- (즉, 뷰를 통한 UPDATE 로 재고를 다 소진시켜 뷰에서 사라지게 하는 것을 막습니다)
--
-- 검증:
--   (a) stock 을 1 로 줄이는 UPDATE → 성공해야 함
--   (b) stock 을 0 으로 줄이는 UPDATE → 거부되어야 함
-- (s14_ex_prod 를 대상으로 하세요)

-- 여기에 작성:



-- ---------------------------------------------------------------------
-- [문제 4] LOCAL vs CASCADED 결과 예측
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS s14_ex_emp;
CREATE TABLE s14_ex_emp (
  emp_id INT PRIMARY KEY,
  dept   VARCHAR(20) NOT NULL,
  salary INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO s14_ex_emp VALUES (1,'개발',5000),(2,'개발',3000),(3,'영업',4000);

CREATE OR REPLACE VIEW v14_ex_dev AS
  SELECT * FROM s14_ex_emp WHERE dept = '개발';                    -- CHECK OPTION 없음

CREATE OR REPLACE VIEW v14_ex_dev_highpay AS
  SELECT * FROM v14_ex_dev WHERE salary >= 4000 WITH LOCAL CHECK OPTION;

-- (4-1) 다음 UPDATE 는 성공할까 실패할까? 예측 후 실행:
--       UPDATE v14_ex_dev_highpay SET dept = '영업' WHERE emp_id = 1;
--       (dept 를 바꾸면 부모 뷰 v14_ex_dev 의 조건에서 벗어남. 하지만 LOCAL 이라면?)
-- (4-2) WITH LOCAL 을 WITH CASCADED 로 바꾸면 결과가 달라지나요? 확인하세요.

-- 여기에 작성:



-- ---------------------------------------------------------------------
-- [문제 5] 생성 컬럼 + 인덱스로 이메일 도메인 검색
-- ---------------------------------------------------------------------
-- 고객 이메일에서 "@ 뒤 도메인"으로 검색을 자주 한다고 합시다.
--   WHERE SUBSTRING_INDEX(email, '@', -1) = 'example.com'
-- 이 조건은 email 인덱스를 못 탑니다.
--
-- s14_ex_cust 사본에 email_domain 생성 컬럼을 추가하고 인덱스를 걸어,
-- 도메인 검색이 인덱스를 타도록 만드세요. EXPLAIN 으로 type 이 바뀌는지 확인하세요.

DROP TABLE IF EXISTS s14_ex_cust;
CREATE TABLE s14_ex_cust AS SELECT customer_id, email, name FROM customers;
ALTER TABLE s14_ex_cust ADD PRIMARY KEY (customer_id);
-- (도메인 분포를 만들기 위해 일부 이메일을 다른 도메인으로 바꿔둡니다)
UPDATE s14_ex_cust SET email = REPLACE(email, 'example.com', 'test.org') WHERE customer_id % 3 = 0;

-- 여기에 작성 (ALTER TABLE ... ADD COLUMN ... AS (...) / ADD INDEX / EXPLAIN):



-- ---------------------------------------------------------------------
-- [문제 6] 함수 기반 인덱스가 안 먹는 쿼리 진단
-- ---------------------------------------------------------------------
-- 아래 테이블에 함수 기반 인덱스 idx_ym ((YEAR(logged_at))) 이 있습니다.
DROP TABLE IF EXISTS s14_ex_log;
CREATE TABLE s14_ex_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  logged_at DATETIME NOT NULL,
  amount INT NOT NULL,
  INDEX idx_ym ((YEAR(logged_at)))
) ENGINE=InnoDB;
INSERT INTO s14_ex_log (logged_at, amount)
SELECT TIMESTAMPADD(DAY, n, '2022-01-01'), n FROM tally WHERE n <= 1000;
ANALYZE TABLE s14_ex_log;

-- 아래 세 쿼리 중 idx_ym 을 "타는" 것과 "못 타는" 것을 EXPLAIN 으로 구분하고,
-- 못 타는 것은 왜 그런지 주석으로 설명하세요. (고칠 수 있으면 고쳐보세요)
--
--   (a) SELECT * FROM s14_ex_log WHERE YEAR(logged_at) = 2023;
--   (b) SELECT * FROM s14_ex_log WHERE YEAR(logged_at) + 0 = 2023;
--   (c) SELECT * FROM s14_ex_log WHERE logged_at >= '2023-01-01' AND logged_at < '2024-01-01';

-- 여기에 작성:

solution.sql

exercise.sql 6문제의 정답과 해설입니다. 스스로 풀어본 뒤에 열어보세요. 각 정답 아래에 "왜 그런가"를 설명하는 주석이 길게 붙어 있어서, 답만 베끼는 것보다 해설을 읽는 것이 핵심입니다.

  • 정답 1 의 요지는 ON o.customer_id = c.customer_id AND o.status <> 'CANCELLED' 처럼 취소 제외 조건을 ON 절에 두는 것입니다. WHERE 로 내리면 주문이 없는 고객의 o.status 가 NULL 이라 함께 걸러져 LEFT JOIN 이 INNER JOIN 으로 퇴화합니다. 또 COUNT(*) 가 아니라 COUNT(o.order_id) 를 써야 주문 없는 고객이 0 으로 나옵니다.
  • 정답 2 는 a=YES, b=NO(GROUP BY), c=NO(DISTINCT), d=YES 입니다. 그런데 UPDATE v14_ex_d SET price_with_vat = 999ERROR 1348: Column 'price_with_vat' is not updatable 로 거부되고, 같은 뷰의 UPDATE ... SET price = 50000 은 성공합니다 — price * 1.1 을 역산해 원본에 무엇을 쓸지 알 수 없기 때문입니다.
  • 정답 3 은 WHERE stock > 0 WITH CHECK OPTIONv14_ex_instock 을 만든 뒤, UPDATE ... SET stock = 1 은 통과시키고 UPDATE ... SET stock = 0ERROR 1369 로 거부하는 것을 보여줍니다. CHECK OPTION 이 없었다면 stock=0 UPDATE 가 통과하면서 그 상품이 뷰에서 조용히 증발했을 것입니다.
  • 정답 4 는 같은 UPDATE 문이 LOCAL 이면 성공, CASCADED 면 ERROR 1369 로 실패하는 것을 나란히 보여줍니다. LOCAL 성공 뒤에는 UPDATE s14_ex_emp SET dept='개발' WHERE emp_id=1; 로 원복하고 나서 CASCADED 를 시험하므로 순서를 건너뛰면 결과가 달라집니다.
  • 정답 5 의 하이라이트는 "보너스" 구간입니다. email_domain 생성 컬럼에 idx_domain 을 걸면, 애플리케이션 코드를 고치지 않고 기존의 WHERE SUBSTRING_INDEX(email,'@',-1) = 'example.com' 조건 그대로도 옵티마이저가 인덱스로 자동 매핑합니다. 무중단 인덱스 도입 기법입니다.
  • 정답 6 은 (b) 가 + 0 하나 때문에 표현식 문자열 매칭에 실패해 풀스캔이 되고, (c) 는 함수 인덱스로는 원본 컬럼 범위 검색을 커버할 수 없어 ALTER TABLE s14_ex_log ADD INDEX idx_logged (logged_at); 가 필요함을 보여줍니다. 결론은 "함수 인덱스를 남발하기보다 원본 컬럼에 범위(sargable)로 접근하는 쿼리가 더 범용적이다" 입니다.
  • 스크립트 끝에서 DROP VIEW / DROP TABLE 로 만든 객체를 모두 정리합니다.
-- =====================================================================
-- Step 14 — 뷰와 생성 컬럼 : solution.sql  (정답 + 해설)
-- =====================================================================
USE shop;


-- ---------------------------------------------------------------------
-- [정답 1] 고객별 주문 통계 뷰
-- ---------------------------------------------------------------------
DROP VIEW IF EXISTS v14_ex_customer_stats;
CREATE VIEW v14_ex_customer_stats AS
SELECT c.customer_id, c.name, c.grade,
       COUNT(o.order_id)                AS order_cnt,       -- LEFT JOIN 이므로 NULL 은 안 셈
       COALESCE(SUM(o.total_amount), 0) AS total_spent,
       MAX(o.order_date)                AS last_order_date
FROM customers c
LEFT JOIN orders o                                          -- 주문 없는 고객도 살리려면 LEFT
       ON o.customer_id = c.customer_id
      AND o.status <> 'CANCELLED'                           -- 취소 제외는 "ON 절"에 둔다!
GROUP BY c.customer_id, c.name, c.grade;

SELECT * FROM v14_ex_customer_stats ORDER BY total_spent DESC LIMIT 5;

-- 해설
--   * CANCELLED 제외 조건을 WHERE 에 두면 안 됩니다. WHERE o.status<>'CANCELLED' 는
--     주문이 아예 없는 고객(o.status = NULL)까지 걸러내서 LEFT JOIN 이 INNER JOIN 처럼 됩니다.
--     "자식 테이블 조건은 ON 절, 부모 테이블 조건은 WHERE 절" 이 LEFT JOIN 의 철칙입니다(Step 07).
--   * COUNT(o.order_id) 는 NULL 을 세지 않으므로 주문 없는 고객은 0 이 됩니다.
--     COUNT(*) 로 쓰면 LEFT JOIN 이 만든 NULL 행까지 1 로 세어 틀립니다.


-- ---------------------------------------------------------------------
-- [정답 2] 이 뷰들은 업데이트 가능한가?
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS s14_ex_prod;
CREATE TABLE s14_ex_prod AS SELECT product_id, name, price, cost, stock, status FROM products;
ALTER TABLE s14_ex_prod ADD PRIMARY KEY (product_id);

CREATE OR REPLACE VIEW v14_ex_a AS
  SELECT product_id, name, price FROM s14_ex_prod WHERE status = 'ON_SALE';
CREATE OR REPLACE VIEW v14_ex_b AS
  SELECT status, COUNT(*) AS cnt FROM s14_ex_prod GROUP BY status;
CREATE OR REPLACE VIEW v14_ex_c AS
  SELECT DISTINCT status FROM s14_ex_prod;
CREATE OR REPLACE VIEW v14_ex_d AS
  SELECT product_id, name, price, price * 1.1 AS price_with_vat FROM s14_ex_prod;

-- (2-1) 예측:
--   a = YES  : 단순 WHERE 필터. 원본 행과 1:1 대응 → 업데이트 가능
--   b = NO   : GROUP BY + COUNT 집계 → 어느 원본 행을 바꿀지 특정 불가
--   c = NO   : DISTINCT → 여러 원본 행이 한 결과 행으로 접힘 → 특정 불가
--   d = YES  : 파생 컬럼(price_with_vat)이 있지만 원본 컬럼도 있어 행 대응이 유지됨 → 뷰 자체는 업데이트 가능

-- (2-2) 확인
SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS
WHERE TABLE_SCHEMA='shop' AND TABLE_NAME IN ('v14_ex_a','v14_ex_b','v14_ex_c','v14_ex_d')
ORDER BY TABLE_NAME;
-- +------------+--------------+
-- | v14_ex_a   | YES          |
-- | v14_ex_b   | NO           |
-- | v14_ex_c   | NO           |
-- | v14_ex_d   | YES          |
-- +------------+--------------+

-- (2-3) v14_ex_d 는 업데이트 가능하지만, "파생 컬럼 자체"는 UPDATE 할 수 없다:
UPDATE v14_ex_d SET price_with_vat = 999 WHERE product_id = 1;
--   → ERROR 1348 (HY000): Column 'price_with_vat' is not updatable
--
-- 해설: 뷰가 업데이트 가능하다는 것과, 그 뷰의 "모든 컬럼"이 업데이트 가능하다는 것은 다릅니다.
--   price 처럼 원본 컬럼을 그대로 노출한 컬럼은 UPDATE 되지만,
--   price*1.1 같은 계산 컬럼은 "역으로 원본에 무엇을 써야 하는지" 알 수 없어 UPDATE 불가입니다.
UPDATE v14_ex_d SET price = 50000 WHERE product_id = 1;    -- 이건 성공 (원본 컬럼이라서)


-- ---------------------------------------------------------------------
-- [정답 3] WITH CHECK OPTION 으로 재고 통제
-- ---------------------------------------------------------------------
CREATE OR REPLACE VIEW v14_ex_instock AS
SELECT product_id, name, stock, status
FROM s14_ex_prod
WHERE stock > 0
WITH CHECK OPTION;

-- 재고가 있는 상품 하나를 고른다
SELECT product_id, stock FROM s14_ex_prod WHERE stock > 0 ORDER BY product_id LIMIT 1;

-- (a) stock 을 1 로 → 여전히 stock > 0 이므로 성공
UPDATE v14_ex_instock SET stock = 1 WHERE product_id = 1;
SELECT product_id, stock FROM s14_ex_prod WHERE product_id = 1;

-- (b) stock 을 0 으로 → 뷰 조건(stock > 0) 위반 → 거부
UPDATE v14_ex_instock SET stock = 0 WHERE product_id = 1;
--   → ERROR 1369 (HY000): CHECK OPTION failed 'shop.v14_ex_instock'
--
-- 해설: CHECK OPTION 이 없었다면 stock=0 UPDATE 가 통과하면서 그 상품이 뷰에서 사라졌을 것입니다.
--   "재고 있는 상품 뷰"를 통해 재고를 0 으로 만드는 모순을 DB 가 막아줍니다.


-- ---------------------------------------------------------------------
-- [정답 4] LOCAL vs CASCADED 결과 예측
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS s14_ex_emp;
CREATE TABLE s14_ex_emp (
  emp_id INT PRIMARY KEY, dept VARCHAR(20) NOT NULL, salary INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO s14_ex_emp VALUES (1,'개발',5000),(2,'개발',3000),(3,'영업',4000);

CREATE OR REPLACE VIEW v14_ex_dev AS
  SELECT * FROM s14_ex_emp WHERE dept = '개발';                    -- CHECK OPTION 없음

-- (4-1) LOCAL 버전
CREATE OR REPLACE VIEW v14_ex_dev_highpay AS
  SELECT * FROM v14_ex_dev WHERE salary >= 4000 WITH LOCAL CHECK OPTION;

UPDATE v14_ex_dev_highpay SET dept = '영업' WHERE emp_id = 1;
--   → 성공! emp_id=1 이 dept='영업' 이 된다.
--   해설: LOCAL 은 "이 뷰 자신의 조건(salary >= 4000)"만 검사합니다.
--         dept 를 바꿔도 salary 는 그대로 5000 이라 자기 조건은 만족합니다.
--         부모 뷰 v14_ex_dev 의 조건(dept='개발')은 LOCAL 이라 검사하지 않으므로 통과합니다.
--         (단, dept='영업'이 되었으니 이제 이 행은 부모 뷰에서 사라집니다.)
SELECT emp_id, dept, salary FROM s14_ex_emp WHERE emp_id = 1;      -- 영업, 5000
UPDATE s14_ex_emp SET dept = '개발' WHERE emp_id = 1;              -- 원복

-- (4-2) CASCADED 버전
CREATE OR REPLACE VIEW v14_ex_dev_highpay AS
  SELECT * FROM v14_ex_dev WHERE salary >= 4000 WITH CASCADED CHECK OPTION;

UPDATE v14_ex_dev_highpay SET dept = '영업' WHERE emp_id = 1;
--   → ERROR 1369 CHECK OPTION failed
--   해설: CASCADED 는 부모 뷰 v14_ex_dev 의 조건(dept='개발')까지 검사합니다.
--         dept 를 '영업'으로 바꾸면 부모 조건을 위반하므로 거부됩니다.
--
-- 결론: 같은 UPDATE 가 LOCAL 이면 성공, CASCADED 면 실패. 기본값은 CASCADED 입니다.


-- ---------------------------------------------------------------------
-- [정답 5] 생성 컬럼 + 인덱스로 이메일 도메인 검색
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS s14_ex_cust;
CREATE TABLE s14_ex_cust AS SELECT customer_id, email, name FROM customers;
ALTER TABLE s14_ex_cust ADD PRIMARY KEY (customer_id);
UPDATE s14_ex_cust SET email = REPLACE(email, 'example.com', 'test.org') WHERE customer_id % 3 = 0;

-- Before: 함수를 씌운 조건은 인덱스를 못 탄다 → type: ALL
EXPLAIN SELECT * FROM s14_ex_cust WHERE SUBSTRING_INDEX(email, '@', -1) = 'example.com';

-- 생성 컬럼 추가 + 인덱스
ALTER TABLE s14_ex_cust
  ADD COLUMN email_domain VARCHAR(120) AS (SUBSTRING_INDEX(email, '@', -1)) VIRTUAL;
ALTER TABLE s14_ex_cust ADD INDEX idx_domain (email_domain);
ANALYZE TABLE s14_ex_cust;

-- After: 생성 컬럼으로 조회 → type: ref
EXPLAIN SELECT * FROM s14_ex_cust WHERE email_domain = 'example.com';

-- 보너스: 원래의 함수 표현식으로 그대로 써도 옵티마이저가 생성 컬럼 인덱스로 자동 매핑한다!
--         (표현식이 생성 컬럼 정의와 글자까지 일치하면 됨)
EXPLAIN SELECT * FROM s14_ex_cust WHERE SUBSTRING_INDEX(email, '@', -1) = 'example.com';
-- → 둘 다 type: ref, key: idx_domain 으로 나옵니다.
--
-- 해설: 애플리케이션 코드를 email_domain 으로 고치지 않아도 됩니다.
--   원래 쓰던 SUBSTRING_INDEX(...) 조건 그대로 두면, MySQL 이 "아, 이건 생성 컬럼과 같네" 하고
--   인덱스를 씁니다. 무중단으로 인덱스를 도입하는 좋은 방법입니다.


-- ---------------------------------------------------------------------
-- [정답 6] 함수 기반 인덱스가 안 먹는 쿼리 진단
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS s14_ex_log;
CREATE TABLE s14_ex_log (
  id INT AUTO_INCREMENT PRIMARY KEY,
  logged_at DATETIME NOT NULL,
  amount INT NOT NULL,
  INDEX idx_ym ((YEAR(logged_at)))
) ENGINE=InnoDB;
INSERT INTO s14_ex_log (logged_at, amount)
SELECT TIMESTAMPADD(DAY, n, '2022-01-01'), n FROM tally WHERE n <= 1000;
ANALYZE TABLE s14_ex_log;

-- (a) YEAR(logged_at) = 2023  →  인덱스를 탄다 (type: ref, key: idx_ym)
EXPLAIN SELECT * FROM s14_ex_log WHERE YEAR(logged_at) = 2023;
--   표현식이 인덱스 정의 YEAR(logged_at) 와 정확히 일치 → 매핑 성공.

-- (b) YEAR(logged_at) + 0 = 2023  →  못 탄다 (type: ALL)
EXPLAIN SELECT * FROM s14_ex_log WHERE YEAR(logged_at) + 0 = 2023;
--   이유: 인덱스 표현식은 "YEAR(logged_at)" 인데 쿼리 표현식은 "YEAR(logged_at) + 0" 입니다.
--         옵티마이저는 수학적으로 같은지 따지지 않고 표현식을 "문자 그대로" 매칭합니다.
--         + 0 하나 붙었다고 인덱스가 죽습니다.
--   고치기: + 0 을 빼서 (a) 형태로 쓴다.

-- (c) logged_at >= '2023-01-01' AND logged_at < '2024-01-01'  →  못 탄다 (type: ALL)
EXPLAIN SELECT * FROM s14_ex_log WHERE logged_at >= '2023-01-01' AND logged_at < '2024-01-01';
--   이유: 이 조건은 logged_at "원본 컬럼"에 대한 범위 검색인데,
--         우리에겐 YEAR(logged_at) 함수 인덱스만 있고 logged_at 자체 인덱스는 없습니다.
--         함수 인덱스 YEAR(logged_at) 로는 원본 컬럼의 범위 검색을 처리할 수 없습니다.
--   고치기: logged_at 원본에 일반 인덱스를 만든다.
ALTER TABLE s14_ex_log ADD INDEX idx_logged (logged_at);
ANALYZE TABLE s14_ex_log;
EXPLAIN SELECT * FROM s14_ex_log WHERE logged_at >= '2023-01-01' AND logged_at < '2024-01-01';
--   → 이제 type: range, key: idx_logged 로 바뀝니다.
--
-- 💡 중요한 교훈:
--   (a) YEAR(logged_at)=2023 과 (c) 범위 검색은 "같은 데이터"를 찾지만,
--   실무에서는 (c) 의 범위 형태(sargable)가 더 낫습니다.
--   YEAR() 함수 인덱스는 "연도 단위" 조회에만 쓸 수 있지만,
--   logged_at 원본 인덱스는 "임의 기간"(지난 7일, 이번 분기 등) 조회를 전부 커버합니다.
--   즉 함수 인덱스를 남발하기보다, 원본 컬럼에 범위로 접근하도록 쿼리를 짜는 편이
--   더 범용적입니다. (Step 15/16 의 "인덱스를 못 타는 패턴"에서 다시 다룹니다.)


-- ---------------------------------------------------------------------
-- 정리
-- ---------------------------------------------------------------------
DROP VIEW IF EXISTS v14_ex_customer_stats, v14_ex_a, v14_ex_b, v14_ex_c, v14_ex_d,
                    v14_ex_instock, v14_ex_dev_highpay, v14_ex_dev;
DROP TABLE IF EXISTS s14_ex_prod, s14_ex_emp, s14_ex_cust, s14_ex_log;

SELECT 'Step 14 solution 완료' AS msg;