본문 바로가기

CASE와 VIEW

CASE로 저장된 값을 바꾸지 않고 결과에 분류와 조건부 숫자를 만들고, 검증한 회원별 게시글 요약을 뷰로 저장합니다. WHEN의 평가 순서와 조건부 집계의 함정, 뷰의 생성과 변경, 갱신할 수 있는 뷰와 CHECK OPTION, 뷰의 권한과 실행 비용을 확인하는 방법을 다룹니다.

CASE는 저장된 값을 바꾸지 않고 결과에 분류와 상태, 조건에 따른 숫자를 만듭니다. 쓰기 쉬운 만큼 WHEN의 순서와 ELSE가 그대로 업무의 규칙이 되고, 집계 안에서는 0과 NULL 가운데 무엇을 고르느냐가 건수를 바꿉니다. 반복해 쓰는 검증된 SELECT에는 뷰(view)로 이름을 붙일 수 있지만, 짧은 이름이 원래 쿼리의 비용과 권한의 문제를 없애 주지는 않습니다.

이 글에서는 회원별 게시판 요약으로 전체 게시글 수와 공개 글 수, 공개 글의 총 조회수, 활동 등급을 만듭니다. LEFT JOIN으로 글이 없는 회원도 남기고 CASE의 조건부 집계로 같은 그룹에서 여러 지표를 계산한 뒤, 최종 SELECT를 v_member_post_summary로 저장해 화면과 검증 쿼리가 같은 규칙을 읽게 합니다.

CASE와 VIEW 예제 데이터

CASE의 기준값과 조건부 집계의 결과를 회원 세 명과 게시글 여덟 개로 고정해 확인합니다. 회원 1의 공개 글 조회수 합은 420회, 회원 2는 110회이며, 회원 3은 글이 없습니다.

ch6-4 CASE와 VIEW fixture
DROP VIEW IF EXISTS v_tag_post_summary;
DROP VIEW IF EXISTS v_member_post_summary;
DROP VIEW IF EXISTS v_active_member;
DROP TABLE IF EXISTS ch6_case_post_tag_demo;
DROP TABLE IF EXISTS ch6_case_tag_demo;
DROP TABLE IF EXISTS ch6_case_post_demo;
DROP TABLE IF EXISTS ch6_case_member_demo;

CREATE TABLE ch6_case_member_demo (
  id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(80) NOT NULL,
  status VARCHAR(16) NOT NULL,
  created_at DATETIME(6) NOT NULL,
  CONSTRAINT ck_ch6_case_member_status
    CHECK (status IN ('ACTIVE', 'SUSPENDED', 'DELETED'))
) ENGINE = InnoDB;

CREATE TABLE ch6_case_post_demo (
  id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  author_id BIGINT UNSIGNED NOT NULL,
  title VARCHAR(80) NOT NULL,
  status VARCHAR(16) NOT NULL,
  view_count BIGINT UNSIGNED NOT NULL,
  CONSTRAINT fk_ch6_case_post_author
    FOREIGN KEY (author_id) REFERENCES ch6_case_member_demo (id)
) ENGINE = InnoDB;

CREATE TABLE ch6_case_tag_demo (
  id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  code VARCHAR(40) NOT NULL UNIQUE,
  is_active BOOLEAN NOT NULL
) ENGINE = InnoDB;

CREATE TABLE ch6_case_post_tag_demo (
  post_id BIGINT UNSIGNED NOT NULL,
  tag_id BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  CONSTRAINT fk_ch6_case_post_tag_post
    FOREIGN KEY (post_id) REFERENCES ch6_case_post_demo (id),
  CONSTRAINT fk_ch6_case_post_tag_tag
    FOREIGN KEY (tag_id) REFERENCES ch6_case_tag_demo (id)
) ENGINE = InnoDB;

INSERT INTO ch6_case_member_demo
  (id, email, name, status, created_at)
VALUES
  (1, '[email protected]', '안동민', 'ACTIVE', '2026-07-01 09:00:00'),
  (2, '[email protected]', '이하나', 'ACTIVE', '2026-07-02 09:00:00'),
  (3, '[email protected]', '박준', 'ACTIVE', '2026-07-03 09:00:00');

INSERT INTO ch6_case_post_demo
  (id, author_id, title, status, view_count)
VALUES
  (1, 1, 'SELECT 기초', 'PUBLISHED', 120),
  (2, 1, 'JOIN 실습', 'PUBLISHED', 140),
  (3, 1, 'VIEW 정리', 'PUBLISHED', 160),
  (4, 1, '서브쿼리 초안', 'DRAFT', 0),
  (5, 1, '숨긴 글', 'HIDDEN', 0),
  (6, 2, 'NULL 복습', 'PUBLISHED', 50),
  (7, 2, '집계 함수', 'PUBLISHED', 60),
  (8, 2, 'CASE 초안', 'DRAFT', 0);

INSERT INTO ch6_case_tag_demo (id, code, is_active)
VALUES
  (1, 'database', TRUE),
  (2, 'java', TRUE),
  (3, 'empty', TRUE),
  (4, 'legacy', FALSE);

INSERT INTO ch6_case_post_tag_demo (post_id, tag_id)
VALUES
  (1, 1),
  (2, 1),
  (3, 4),
  (6, 2),
  (8, 2);

COUNT와 CASE의 순서에서 생기는 오류

다음 쿼리에는 두 가지 오류가 있습니다. COUNT는 NULL이 아닌 값을 세므로 조건이 거짓일 때 0을 돌려주어도 그 행을 셉니다. 또 조회수 60회 이상의 조건을 300회 이상보다 먼저 쓰면, 300회를 넘는 그룹도 첫 조건에서 멈추어 더 높은 등급에 닿지 못합니다.

모든 행을 세고 높은 등급을 가리는 CASE
SELECT
  author_id,
  COUNT(*) AS all_posts,
  COUNT(CASE
    WHEN status = 'PUBLISHED' THEN 1
    ELSE 0
  END) AS published_count,
  SUM(view_count) AS total_views,
  CASE
    WHEN SUM(view_count) >= 60 THEN 'STEADY'
    WHEN SUM(view_count) >= 300 THEN 'FOCUSED'
    ELSE 'STARTING'
  END AS activity_grade
FROM ch6_case_post_demo
GROUP BY author_id
ORDER BY author_id;
author_id | all_posts | published_count | total_views | activity_grade
1         | 5         | 5               | 420         | STEADY
2         | 3         | 3               | 110         | STEADY

두 오류 모두 실행은 성공하고 숫자와 이름표만 그럴듯하게 틀립니다. 회원 1의 공개 글은 3개인데 published_count가 전체 글 수인 5와 같고, 조회수가 420회인데도 첫 번째로 참이 된 STEADY에서 평가가 끝났습니다. 회원 1에게 맞는 결과는 공개 글 3개에 FOCUSED입니다.

검색 CASE는 위에서부터 처음으로 참이 되는 WHEN 하나만 고르므로, 범위가 겹치는 조건은 높고 좁은 조건부터 둡니다. ELSE가 없으면 어떤 조건에도 맞지 않는 행은 NULL이 됩니다.

CONDITIONAL AGGREGATE · 0 / NULL

COUNT는 0도 센다: 제외하려면 NULL을 반환한다

THEN 1인 같은 조건을 비교합니다. 조건이 FALSE·UNKNOWN일 때 COUNT의 ELSE 0은 제외가 아니라 한 건 추가입니다.

COUNT가 세는 값과 SUM이 더하는 값을 구분한다
집계 형식조건이 TRUEFALSE·UNKNOWN
COUNT + ELSE 01을 세어 +10도 세어 +1
COUNT + ELSE 생략1을 세어 +1NULL은 제외: +0
SUM + ELSE 01을 더해 +10을 더해 +0
COUNT + ELSE 0
조건이 TRUE: 1을 세어 +1
FALSE·UNKNOWN: 0도 세어 +1
COUNT + ELSE 생략
조건이 TRUE: 1을 세어 +1
FALSE·UNKNOWN: NULL은 제외: +0
SUM + ELSE 0
조건이 TRUE: 1을 더해 +1
FALSE·UNKNOWN: 0을 더해 +0

원문의 THEN 값은 1입니다. LEFT JOIN에서 자식 없는 회원의 NULL 확장 행은 상태 비교가 UNKNOWN이 됩니다.

회원 1의 공개 글 · 전체 5건 중 3건

COUNT(CASE WHEN 조건 THEN 1 ELSE 0 END) → 5건

COUNT(CASE WHEN 조건 THEN 1 END) → 3건

SUM(CASE WHEN 조건 THEN 1 ELSE 0 END) → 3건

본문에서 조건은 status = 'PUBLISHED'입니다. 박준의 NULL 확장 행도 첫 식은 1건을 세지만 수정한 두 식은 0건입니다.

CASE의 평가 순서와 조건부 집계

단순 CASE는 하나의 식을 여러 값과 같은지 비교하고, 검색 CASE는 WHEN마다 독립된 조건식을 평가합니다. 조건부 집계는 SUM(CASE WHEN ... THEN value ELSE 0 END)로 넣을 값과 뺄 값을 정하거나, COUNT(CASE WHEN ... THEN 1 END)로 조건이 참인 행만 셉니다. 뒤의 형태는 ELSE가 없어 조건이 거짓인 행이 NULL이 되므로 COUNT에서 빠집니다. LEFT JOIN으로 남긴 글 없는 회원에게 합계 0이 맞는 경우에만 COALESCE를 씁니다.

CASE와 CREATE VIEW는 표준 SQL의 개념이지만, CREATE OR REPLACE와 SQL SECURITY INVOKER, 뷰를 갱신할 수 있는 세부 규칙은 제품마다 다릅니다. MySQL의 뷰는 결과를 저장해 두는 구체화된 뷰(materialized view)가 아니라 조회의 정의를 저장합니다. 그래서 뷰를 조회할 때마다 원래 SQL의 비용이 들고, 뷰를 겹겹이 쌓으면 계획이 가려지므로 펼쳐진 계획을 EXPLAIN으로 확인합니다.

조건부 집계를 뷰로 저장한다

안쪽 집계에서 안정적인 원래의 지표를 만들고, 바깥 SELECT에서 높은 기준부터 등급을 계산합니다. 뷰는 호출한 사용자의 권한으로 실행되게 해, 만든 사람의 넓은 권한이 호출하는 사람에게 적용되지 않게 하고, 받는 쪽이 쓸 열의 이름과 뜻을 분명히 적습니다.

회원별 게시글 요약 VIEW
CREATE OR REPLACE SQL SECURITY INVOKER VIEW v_member_post_summary AS
SELECT
  base.member_id,
  base.name,
  base.post_count,
  base.published_count,
  base.published_views,
  CASE
    WHEN base.published_views >= 300 THEN 'FOCUSED'
    WHEN base.published_views >= 60 THEN 'STEADY'
    ELSE 'STARTING'
  END AS activity_grade
FROM (
  SELECT
    m.id AS member_id,
    m.name,
    COUNT(p.id) AS post_count,
    SUM(CASE
      WHEN p.status = 'PUBLISHED' THEN 1
      ELSE 0
    END) AS published_count,
    COALESCE(SUM(CASE
      WHEN p.status = 'PUBLISHED' THEN p.view_count
      ELSE 0
    END), 0) AS published_views
  FROM ch6_case_member_demo AS m
  LEFT JOIN ch6_case_post_demo AS p ON p.author_id = m.id
  GROUP BY m.id, m.name
) AS base;

SELECT *
FROM v_member_post_summary
ORDER BY published_views DESC, member_id;
member_id | name   | post_count | published_count | published_views | activity_grade
1         | 안동민 | 5          | 3               | 420             | FOCUSED
2         | 이하나 | 3          | 2               | 110             | STEADY
3         | 박준   | 0          | 0               | 0               | STARTING

공개 글의 조회수가 300회 이상인 회원 1은 이제 FOCUSED가 먼저 골라지고, 초안과 숨긴 글은 전체 건수에는 들어가지만 공개 건수와 조회수에는 들어가지 않습니다. 글이 없는 박준에게도 LEFT JOIN은 NULL로 채운 행 하나를 만들지만, 그 행의 p.id는 NULL이어서 COUNT는 0이고, p.status = 'PUBLISHED'는 알 수 없음이어서 두 CASE 모두 ELSE의 0을 고릅니다. 연결 결과에 행이 하나 있다는 것과 실제 자식 글이 하나 있다는 것은 다른 뜻입니다.

CASE · FIRST TRUE WINS

300회도 60회 이상이다: 높은 조건을 먼저 확인

검색 CASE는 첫 TRUE에서 멈춥니다. 60 이상을 앞에 두면 300 이상인 입력도 STEADY가 되고 FOCUSED에 도달하지 않습니다.

잘못된 순서

WHEN views ≥ 60 → STEADY
WHEN views ≥ 300 → FOCUSED

높은 기준부터 확인

WHEN views ≥ 300 → FOCUSED
WHEN views ≥ 60 → STEADY
CASE의 조건 순서가 300회 경계의 등급을 바꾼다
게시 조회수60부터 확인한 원문300부터 확인한 개선
59회STARTINGSTARTING
60회STEADYSTEADY
299회STEADYSTEADY
300회STEADYFOCUSED
59회
60부터 확인한 원문: STARTING
300부터 확인한 개선: STARTING
60회
60부터 확인한 원문: STEADY
300부터 확인한 개선: STEADY
299회
60부터 확인한 원문: STEADY
300부터 확인한 개선: STEADY
300회
60부터 확인한 원문: STEADY
300부터 확인한 개선: FOCUSED

59·60·299·300을 두 CASE의 WHEN 조건에 대입한 예상 등급입니다.

위 코드 표기는 조건 순서를 줄여 쓴 것입니다. 실제 SQL은 본문의 WHEN … THEN을 사용합니다. 59·60·299·300은 낮은 경계와 높은 경계의 양쪽을 검증합니다.

뷰에 ORDER BY를 넣어 기본 순서를 기대하지 말고, 뷰를 조회하는 SELECT에서 완전한 정렬을 정합니다. 회원의 name이 바뀌면 뷰에도 곧바로 반영되므로, 지난 보고서가 그때의 이름을 남겨야 한다면 별도의 스냅샷이 필요합니다.

뷰를 만들고 바꾸고 지운다

뷰의 생애는 테이블과 비슷합니다. CREATE VIEW로 조회의 정의를 저장하고, CREATE OR REPLACE VIEW나 ALTER VIEW로 정의를 바꾸며, 더 쓰지 않으면 DROP VIEW로 지웁니다. 뷰를 지워도 원래 테이블과 행은 지워지지 않지만, 반대로 원래 열의 이름이나 타입을 바꾸면 뷰가 깨질 수 있으므로 원래 테이블의 DDL과 뷰의 변경을 같은 마이그레이션에서 관리합니다.

먼저 집계가 없는 단순한 뷰를 만듭니다. 한 테이블의 행과 열을 그대로 보여 주는 뷰는 결과 한 행이 원래 테이블의 어느 행인지 분명하므로, 갱신할 수 있는 경우가 많습니다. 아래 UPDATE는 실습 데이터를 바꾸지 않도록 트랜잭션 안에서 실행하고 되돌립니다.

갱신 가능한 활성 회원 VIEW
DROP VIEW IF EXISTS v_active_member;

CREATE ALGORITHM = MERGE
SQL SECURITY INVOKER
VIEW v_active_member AS
SELECT id, email, name, status
FROM ch6_case_member_demo
WHERE status = 'ACTIVE'
WITH CASCADED CHECK OPTION;

SELECT *
FROM v_active_member
ORDER BY id;

START TRANSACTION;

UPDATE v_active_member
SET name = '안동민 수정'
WHERE id = 1;

SELECT id, name
FROM ch6_case_member_demo
WHERE id = 1;

ROLLBACK;
id | email                 | name   | status
1  | [email protected] | 안동민 | ACTIVE
2  | [email protected]      | 이하나 | ACTIVE
3  | [email protected]      | 박준   | ACTIVE

id | name
1  | 안동민 수정

뷰를 통한 UPDATE가 원래 테이블 ch6_case_member_demo의 같은 행을 바꾸었습니다. 뷰가 따로 복사본을 저장한 것이 아니라, 원래의 행으로 이어지는 창이라는 뜻입니다.

WITH CASCADED CHECK OPTION은 바꾼 결과가 뷰의 조건인 status = 'ACTIVE' 밖으로 사라지는 쓰기를 막습니다.

CHECK OPTION이 View 조건 밖의 변경을 거절
UPDATE v_active_member
SET status = 'SUSPENDED'
WHERE id = 1;
ERROR 1369 (HY000): CHECK OPTION failed 'board_lab.v_active_member'

DISTINCT나 집계, GROUP BY, UNION처럼 여러 원래 행을 결과 한 행으로 바꾸는 뷰는 대개 직접 갱신할 수 없습니다. v_member_post_summary의 published_views 한 값을 어느 게시글 행에 어떻게 나누어 써야 하는지 정할 수 없기 때문입니다.

집계 VIEW를 직접 수정할 수 없는 이유 확인
UPDATE v_member_post_summary
SET published_views = 0
WHERE member_id = 1;
ERROR 1288 (HY000): The target table v_member_post_summary of the UPDATE is not updatable

뷰에 열을 더하거나 필터를 바꿀 때는 받는 쪽이 기대하는 열의 이름과 뜻부터 확인합니다. 아래 변경은 활성 회원의 가입 시각을 더하며, 기존 열을 빼는 변경은 받는 쪽이 모두 옮겨 간 뒤에 합니다. SHOW CREATE VIEW는 저장된 정의를 보여 주는데, MySQL이 열 이름을 정규화하고 DEFINER 같은 속성을 덧붙여 출력하므로 쓴 SQL과 글자 그대로 같지는 않습니다.

ALTER VIEW와 DROP VIEW
ALTER ALGORITHM = MERGE
SQL SECURITY INVOKER
VIEW v_active_member AS
SELECT id, email, name, status, created_at
FROM ch6_case_member_demo
WHERE status = 'ACTIVE'
WITH CASCADED CHECK OPTION;

SHOW CREATE VIEW v_active_member;
DROP VIEW v_active_member;

갱신할 수 있다고 해서 뷰를 쓰기 API로 써야 한다는 뜻은 아닙니다. 여러 테이블의 업무 규칙과 감사 기록이 필요한 변경은 명시적인 서비스와 트랜잭션의 경로가 더 알맞습니다. 뷰를 쓰기 경로로 허용한다면 CHECK OPTION과 원래 테이블의 제약, 권한, 트리거와 변경 이력까지 함께 검토합니다.

더 깊이: 뷰를 실행하는 두 방식, MERGE와 TEMPTABLE

MySQL은 뷰를 참조하는 문장을 두 방식 가운데 하나로 실행합니다. MERGE는 뷰의 정의를 바깥 문장에 합쳐, 뷰의 조건과 바깥의 조건을 함께 가진 쿼리 하나로 바꿉니다. 예를 들어 status = 'ACTIVE'를 조건으로 가진 뷰를 WHERE id = 1로 조회하면, 원래 테이블에 두 조건을 AND로 이은 쿼리로 실행됩니다. TEMPTABLE은 뷰의 결과를 먼저 임시 테이블에 담은 뒤, 그 임시 테이블로 바깥 문장을 실행합니다.

집계 함수나 윈도 함수, DISTINCT, GROUP BY, HAVING, LIMIT, UNION이나 UNION ALL, SELECT 목록의 서브쿼리가 있는 뷰는 바깥 문장에 합칠 수 없어 임시 테이블로 실행됩니다. 그래서 v_member_post_summary처럼 집계하는 뷰는 갱신할 수 없고, 바깥의 조건이 뷰 안으로 들어가는지는 4장 4편에서 본 파생 조건 푸시다운 같은 최적화에 달려 있습니다. 결과를 임시 테이블로 만든 경우에도 옵티마이저는 조인에 도움이 된다면 그 임시 테이블에 인덱스를 더할 수 있습니다.

ALGORITHM을 생략하면 optimizer_switch의 derived_merge 플래그가 기본 방식을 정하고, UNDEFINED이면 MySQL이 가능한 한 MERGE를 고릅니다. MERGE가 보통 더 효율적이고, 임시 테이블을 쓰는 뷰는 갱신할 수 없기 때문입니다. 합칠 수 없는 뷰에 ALGORITHM = MERGE를 적으면 MySQL은 경고를 남기고 UNDEFINED로 바꿉니다. 반대로 MySQL 8.4 매뉴얼은 TEMPTABLE을 일부러 고르는 이유로, 임시 테이블을 만든 뒤에는 원래 테이블의 잠금을 먼저 풀 수 있어 다른 클라이언트가 덜 기다린다는 점을 듭니다.

뷰의 결과를 확인한다

뷰가 짧아 보인다는 이유로 그 안을 잊지 않도록, 행의 수와 공개 글 조회수의 합계가 원래 테이블과 일치하는지 확인합니다. 가운데 SELECT는 스칼라 서브쿼리 네 개로 원래 테이블과 뷰의 값을 한 행에 나란히 놓습니다. COALESCE(..., 0)은 더할 행이 하나도 없어 SUM이 NULL을 돌려줄 때도 두 합계를 0으로 비교할 수 있게 합니다.

SHOW CREATE VIEW와 집계 대사
SHOW CREATE VIEW v_member_post_summary;

SELECT
  (SELECT COUNT(*) FROM ch6_case_member_demo) AS member_rows,
  (SELECT COUNT(*) FROM v_member_post_summary) AS view_rows,
  (SELECT COALESCE(SUM(view_count), 0)
   FROM ch6_case_post_demo
   WHERE status = 'PUBLISHED') AS base_published_views,
  (SELECT COALESCE(SUM(published_views), 0)
   FROM v_member_post_summary) AS view_published_views;

EXPLAIN ANALYZE
SELECT *
FROM v_member_post_summary
WHERE published_views >= 60;
member_rows | view_rows | base_published_views | view_published_views
3           | 3         | 530                  | 530

회원의 수와 뷰의 행 수가 3으로 같고, 공개 글 조회수의 합계도 양쪽 모두 530입니다. EXPLAIN ANALYZE의 출력은 환경에 따라 달라 교재에 옮기지 않지만, 계획에서는 뷰의 이름 뒤에 숨은 LEFT JOIN과 집계를 확인하고, 대표 데이터에서 실제 행 수와 반복 횟수, 임시 테이블의 사용을 기록합니다. 집계가 든 뷰는 바깥 쿼리와 합쳐지지 못하고 임시 테이블로 먼저 계산되는 경우가 많습니다.

CASE와 뷰에서 따질 것

조건부 집계는 그룹을 한 번 읽어 여러 지표를 만들지만, 지표마다 모집단이 다르면 CASE가 길어집니다. "전체", "공개", "최근 30일의 공개"의 날짜와 상태 조건을 표로 관리해, 복사하는 과정에서 기준이 어긋나지 않게 합니다. SUM(조건식)은 MySQL에서 참과 거짓이 1과 0이 되는 성질을 쓴 줄임이므로, 다른 DBMS로 옮길 쿼리라면 CASE로 씁니다. 같은 CASE를 SELECT와 GROUP BY에 되풀이하면 한쪽만 고치는 실수가 생기므로, 파생 테이블이나 CTE로 분류하는 단계에 이름을 붙입니다.

CASE의 결과 문자열의 길이와 정렬 규칙도 최종 타입에 영향을 줍니다. ELSE를 빠뜨려 생긴 NULL 등급이 API의 열거형과 맞는지, 숫자 범위에 틈이나 겹침이 없는지 기준값의 표로 검토합니다.

뷰는 애플리케이션의 권한을 단순하게 만들 수 있지만, 행 단위의 권한 검사를 자동으로 해 주지는 않습니다. 호출하는 사용자가 원래 테이블을 읽을 권한을 가져야 하는 INVOKER로 둘지, 범위를 제한한 뷰를 DEFINER로 내줄지는 최소 권한의 모델로 설계하고, DEFINER 계정의 수명도 관리합니다(8장 6편). 테넌트의 조건이 없는 전체 요약 뷰를 사용자의 요청에 그대로 내주면 다른 조직의 통계가 섞일 수 있으며, 파라미터가 필요한 조회는 뷰에 억지로 넣지 말고 안전한 쿼리나 프로시저를 검토합니다.

v_member_post_summary는 원래의 집계와 같은 숫자를 내는 정확성의 기준으로 두고, 성능 문제가 측정된 뒤에 미리 계산한 요약 테이블이나 캐시를 검토합니다(16장 3편). 뷰의 이름은 API처럼 버전을 관리해, 열을 빼거나 뜻을 바꿀 때는 받는 쪽이 옮겨 갈 기간을 둡니다. 등급처럼 판단한 결과를 저장할 때는 계산에 쓴 조회수와 기준의 버전을 함께 남겨, 지난 판단을 다시 만들 수 있게 합니다.

따져 볼 것질문
순서겹치는 WHEN을 구체적이고 높은 기준부터 평가하는가
집계COUNT의 NULL 규칙과 SUM의 ELSE 값을 의도대로 썼는가
0건자식이 없어서 생긴 NULL을 0으로 바꾸는 것이 업무상 맞는가
권한SQL SECURITY와 테넌트의 범위가 최소 권한에 맞는가
비용뷰 안의 JOIN과 집계를 펼친 실행 계획을 측정했는가

CASE는 기준값의 표로 먼저 리뷰하고, 뷰는 열의 이름과 뜻, 권한, 비용을 API처럼 리뷰합니다. 뷰를 조회하는 쿼리가 짧다는 것은 안의 비용이 작다는 뜻이 아니므로, 원래 데이터와의 일치 확인과 실행 계획을 함께 남깁니다.

정리

  • CASE는 위에서부터 처음으로 참인 WHEN을 고르므로, 겹치는 범위의 순서가 곧 결과의 규칙입니다.
  • 조건부 COUNT는 ELSE 없이 NULL을, 조건부 SUM은 ELSE의 0을 의도해서 씁니다.
  • 뷰는 SELECT의 정의를 저장할 뿐 결과를 저장하지 않으며, 원래 쿼리의 비용과 권한의 검토를 없애지 않습니다.
  • 한 테이블을 그대로 보여 주는 뷰는 갱신할 수 있고 CHECK OPTION으로 조건 밖의 쓰기를 막을 수 있지만, 집계 뷰는 갱신할 수 없습니다.
  • 집계 뷰의 결과는 원래 테이블의 행 수와 합계로 확인하고, 기준값은 따로 시험합니다.

연습 문제

1. 태그별 게시글 요약 뷰

활성 태그마다 전체 게시글 수와 공개 글 수, 공개 글의 총 조회수를 내는 v_tag_post_summary를 만들어 보세요. 글이 없는 활성 태그도 0으로 남기고, 총 조회수가 180회 이상이면 HOT, 60회 이상이면 WARM, 나머지는 COLD로 분류합니다. 높은 기준부터 평가하고, 뷰 안에는 ORDER BY를 넣지 않습니다.

해설 보기

태그를 LEFT JOIN으로 남길 집합으로 두고, 조건부 SUM으로 공개 글의 지표를 계산합니다. 파생 테이블에서 만든 지표에 바깥의 CASE를 적용하면 별칭을 다시 쓰는 문제를 피하고 범위의 순서도 분명해집니다.

CREATE OR REPLACE SQL SECURITY INVOKER VIEW v_tag_post_summary AS
SELECT
  base.tag_id,
  base.code,
  base.post_count,
  base.published_count,
  base.published_views,
  CASE
    WHEN base.published_views >= 180 THEN 'HOT'
    WHEN base.published_views >= 60 THEN 'WARM'
    ELSE 'COLD'
  END AS activity_level
FROM (
  SELECT
    t.id AS tag_id,
    t.code,
    COUNT(p.id) AS post_count,
    SUM(CASE WHEN p.status = 'PUBLISHED' THEN 1 ELSE 0 END)
      AS published_count,
    COALESCE(SUM(CASE
      WHEN p.status = 'PUBLISHED' THEN p.view_count
      ELSE 0
    END), 0) AS published_views
  FROM ch6_case_tag_demo AS t
  LEFT JOIN ch6_case_post_tag_demo AS pt ON pt.tag_id = t.id
  LEFT JOIN ch6_case_post_demo AS p ON p.id = pt.post_id
  WHERE t.is_active = TRUE
  GROUP BY t.id, t.code
) AS base;

SELECT * FROM v_tag_post_summary ORDER BY tag_id;
tag_id | code     | post_count | published_count | published_views | activity_level
1      | database | 2          | 2               | 260             | HOT
2      | java     | 2          | 1               | 50              | COLD
3      | empty    | 0          | 0               | 0               | COLD

database는 글 1과 2를 받아 120과 140을 더한 260회로 HOT이고, java는 공개 글 6의 50회와 초안 8을 받아 COLD이며, empty는 모두 0입니다. 비활성인 legacy는 조회수 160인 글이 연결되어 있어도 WHERE에서 빠지므로, 뷰는 활성 태그 세 행만 돌려줍니다. 공개 글의 조회수 합계가 59, 60, 179, 180회인 태그를 더 만들어 등급의 기준값도 확인합니다.

2. COUNT의 조건부 집계를 고치기

본문의 오류 쿼리에서 published_count를 올바르게 고치는 방법을 두 가지 들어 보세요.

해설 보기

첫째는 ELSE를 지워 COUNT(CASE WHEN status = 'PUBLISHED' THEN 1 END)로 쓰는 것입니다. 조건이 거짓인 행은 NULL이 되어 COUNT에서 빠집니다. 둘째는 COUNT를 SUM으로 바꾸어 SUM(CASE WHEN status = 'PUBLISHED' THEN 1 ELSE 0 END)로 쓰는 것입니다. 거짓인 행은 0을 더하므로 합계가 공개 글의 수가 됩니다. 두 방법 모두 회원 1에게 3, 회원 2에게 2를 돌려주며, 원래의 쿼리처럼 COUNT에 ELSE 0을 함께 쓰면 0도 NULL이 아닌 값이어서 모든 행이 세어집니다.

3. 등급의 기준값

v_member_post_summary의 등급 CASE에 공개 글 조회수 59, 60, 299, 300을 넣으면 각각 어떤 등급이 나오나요? WHEN의 순서를 본문의 오류 쿼리처럼 뒤집으면 어떻게 바뀌나요?

해설 보기

본문의 순서에서는 59가 STARTING, 60과 299가 STEADY, 300이 FOCUSED입니다. >=를 썼으므로 기준값과 같은 60과 300은 더 높은 등급에 들어갑니다. 순서를 뒤집어 60 이상을 먼저 검사하면, 300도 첫 조건에서 STEADY가 되어 FOCUSED는 어떤 값으로도 나오지 않습니다. 범위가 겹치는 CASE는 이렇게 기준값과 그 바로 아래의 값을 넣어 시험해야, 순서의 오류가 드러납니다.

다음 글 관계 모델과 관계대수의 집합 연산에서는 지금까지 쓴 SELECT와 JOIN, UNION을 관계 모델과 관계대수의 연산으로 다시 읽습니다.