본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
6장 : 서브쿼리·집합·조건식

CASE와 VIEW

CASE의 평가 순서와 조건부 집계를 검증하고 재사용할 회원 게시판 요약을 안전한 뷰 규칙으로 정의합니다.

CASE는 저장된 값을 바꾸지 않고 결과에 분류·상태·조건부 숫자를 만듭니다. 강력한 만큼 WHEN 순서와 ELSE가 업무 규칙이 되며, 집계 안에서는 0과 NULL 선택이 건수를 바꿉니다. 반복되는 검증된 SELECT는 뷰로 이름 붙일 수 있지만, 짧은 이름이 원본 비용과 권한을 없애 주지는 않습니다.

회원별 게시판 요약에 전체 게시글 수, 게시된 글 수, 게시된 글의 총 조회수, 활동 등급을 제공합니다. LEFT JOIN으로 게시글 0건 회원도 남기고 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, 'andongmin@example.com', '안동민', 'ACTIVE', '2026-07-01 09:00:00'),
  (2, 'hana@example.com', '이하나', 'ACTIVE', '2026-07-02 09:00:00'),
  (3, 'park@example.com', '박준', '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 조건의 오류

건수는 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;

published_count가 전체 게시글 수와 같고 조회수 300회 이상인 회원도 첫 TRUE인 STEADY에서 평가가 끝납니다.

오류 실행 결과
author_id | all_posts | published_count | total_views | activity_grade
1         | 5         | 5               | 420         | STEADY
expected  | 5         | 3               | 420         | FOCUSED

COUNT(CASE ... ELSE 0 END)은 TRUE일 때 1, FALSE일 때 0을 만들지만 둘 다 NULL이 아닙니다. 조건부 건수는 거짓일 때 NULL을 반환하거나 SUM(CASE ... THEN 1 ELSE 0 END)를 사용합니다. 검색 CASE는 위에서 아래로 첫 TRUE만 선택하므로 범위는 보통 높은·좁은 조건부터 배치합니다. ELSE가 없으면 어떤 조건도 맞지 않는 행은 NULL입니다.


CASE의 평가 순서

단순 CASE는 하나의 식을 동등 비교하고 검색 CASE는 WHEN마다 독립 조건식을 평가합니다. 겹치는 범위라면 가장 구체적인 조건을 먼저 둡니다. 조건부 집계는 SUM(CASE WHEN ... THEN value ELSE 0 END)로 포함 값과 제외 값을 정하거나 COUNT(CASE WHEN ... THEN 1 END)로 TRUE 행만 셉니다. LEFT JOIN의 게시글 0건 회원에게 합계 0이 맞는 경우에만 COALESCE합니다.

CASE와 CREATE 뷰는 표준 SQL 개념이지만 CREATE OR REPLACE, SQL SECURITY INVOKER, 뷰의 갱신 가능성 세부 규칙은 제품별 차이가 있습니다. MySQL 뷰는 기본적으로 결과를 저장한 구체화된 뷰가 아니라 조회 정의를 저장합니다. 조회할 때 원본 SQL 비용이 발생하고 중첩 뷰는 계획을 숨길 수 있으므로 EXPLAIN으로 펼쳐진 계획을 확인합니다.


조건부 집계 VIEW

안쪽 집계에서 안정적인 원시 지표를 만들고 바깥 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;

게시글이 없는 회원은 0건·조회수 0회·STARTING으로 남고, 게시된 글의 조회수가 300회 이상이면 FOCUSED가 먼저 선택됩니다. 초안 게시글은 전체 건수에는 포함되지만 게시 건수와 게시된 글의 조회수에는 포함되지 않습니다.

개선 실행 결과
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

등급 경계 60과 300을 데이터 규칙으로 문서화하고 바로 아래·같음·위 값을 검증합니다. 뷰에 ORDER BY를 넣어 기본 순서를 기대하지 않고 소비 SELECT에서 완전한 정렬을 지정합니다. name 변경은 뷰에 즉시 반영되지만, 과거 보고서가 당시 이름을 보존해야 한다면 별도 스냅샷 사실이 필요합니다.


VIEW의 생성과 변경

뷰의 기본 생명주기는 테이블과 비슷합니다. CREATE VIEW로 조회 정의를 저장하고, CREATE OR REPLACE VIEWALTER VIEW로 정의를 바꾸며, 더 이상 사용하지 않을 때 DROP VIEW로 제거합니다. 뷰를 삭제해도 원본 테이블과 행은 삭제되지 않습니다. 반대로 원본 열의 이름이나 타입을 바꾸면 뷰가 깨질 수 있으므로 원본 DDL과 뷰 변경을 같은 마이그레이션에서 관리합니다.

먼저 집계가 없는 단순 뷰를 만듭니다. 한 테이블의 행과 열을 직접 보여 주는 형태는 원본의 한 행을 분명히 찾을 수 있어 갱신 가능한 경우가 많습니다.

갱신 가능한 활성 회원 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;

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

SELECT id, name
FROM ch6_case_member_demo
WHERE id = 1;

뷰를 통한 UPDATE가 성공하면 원본 members의 같은 행도 바뀝니다. 뷰가 별도 복사본을 저장한 것이 아니라 원본 행으로 이어지는 창이라는 뜻입니다. WITH CASCADED CHECK OPTION은 변경 결과가 status = 'ACTIVE' 조건 밖으로 사라지는 쓰기를 막습니다.

CHECK OPTION이 View 조건 밖의 변경을 거절
UPDATE v_active_member
SET status = 'SUSPENDED'
WHERE id = 1;
예상 실패 결과
ERROR 1369: CHECK OPTION failed 'board_lab.v_active_member'

DISTINCT, 집계, GROUP BY, UNION처럼 여러 원본 행을 한 결과 행으로 바꾸는 뷰는 일반적으로 직접 갱신할 수 없습니다. 예를 들어 v_member_post_summarypublished_views 한 값을 어느 posts 행에 어떻게 나눠 써야 하는지 결정할 수 없습니다.

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

뷰 열을 추가하거나 필터를 바꿀 때는 소비자가 기대하는 열 이름과 의미를 먼저 확인합니다. 아래 변경은 활성 회원의 가입 시각을 추가합니다. 기존 열을 제거하는 변경은 소비자 전환이 끝난 뒤에 수행합니다.

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, 원본 제약, 권한, 트리거와 변경 이력까지 함께 검토합니다.


VIEW 결과 검증

뷰가 짧게 보인다는 이유로 내부를 잊지 않도록 저장된 정의, 행 수, 게시된 글의 조회수 총합을 원본과 일치하는지 확인합니다.

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_rowsview_rows, 두 조회수 합계가 각각 같아야 합니다. 계획에서는 뷰 이름 뒤에 숨은 회원 LEFT JOIN과 집계를 확인하고 대표 데이터에서 실제 행 수·반복 횟수·임시 사용을 기록합니다.

조건부 집계는 한 번의 그룹 스캔에서 여러 지표를 만들지만 지표마다 모집단이 다르면 CASE가 길어질 수 있습니다. ‘전체’, ‘게시’, ‘최근 30일 게시’의 날짜·상태 조건식을 표로 관리하고 복사 과정에서 경계가 달라지지 않게 합니다. SUM(condition)은 MySQL에서 불리언 0·1을 이용한 축약이지만 이식성을 위해 CASE를 쓰는 기준을 선택할 수 있습니다. 같은 CASE를 SELECT와 GROUP BY에 반복하면 수정 누락이 생기므로 파생 테이블·CTE로 분류 단계를 이름 붙입니다.

게시된 글의 총 조회수를 59·60·299·300으로 만들어 경계 라벨을 확인합니다. 초안 게시글만 있는 회원과 게시글 없는 회원을 분리해 post_count, published_count, published_views가 각각 어떤 값이어야 하는지 적습니다. 나쁜 건수에서 ELSE 0을 제거하거나 SUM으로 바꾸어 결과가 같은지 비교하세요. 뷰 정의의 기준을 바꾼 뒤 기존 소비 쿼리가 새 의미를 자동으로 받는다는 장점과 위험도 기록합니다.

뷰는 애플리케이션 권한을 단순화할 수 있지만 행-수준 권한 검사를 자동으로 제공하지 않습니다. INVOKER가 원본 접근 권한을 가져야 하는지, 별도 제한 뷰를 DEFINER로 제공할지 최소 권한 모델을 설계하고 DEFINER 계정 수명도 관리합니다. 테넌트 조건식이 없는 전역 요약 뷰를 사용자 요청에 노출하면 다른 조직 통계가 섞일 수 있습니다. 파라미터가 필요한 조회는 뷰에 억지로 넣지 말고 안전한 쿼리나 프로시저 경계를 검토합니다.

집계 뷰는 대체로 직접 UPDATE할 수 없으므로 읽기 모델로 취급합니다. 원본 테이블·컬럼 이름 변경은 뷰를 무효화하거나 의미를 바꿀 수 있어 마이그레이션에서 종속 뷰를 확인합니다. CASE 결과 문자열 길이와 정렬 규칙도 최종 타입에 영향을 줍니다. ELSE를 빠뜨린 NULL 등급이 API 열거형과 호환되는지 확인하고, 숫자 범위에 틈이나 겹침이 없는지 경계표로 검토합니다. 뷰 중첩이 많아지면 어느 층에서 필터가 적용되는지 EXPLAIN과 정의 추적으로 확인합니다.

v_member_post_summary는 뒤의 인덱스·통계 장에서 기준 쿼리로 사용합니다. 먼저 원본 집계와 같은 숫자를 만드는 정확성 기준선으로 두고, 성능 문제가 측정된 뒤 구체화된 요약 테이블이나 캐시를 검토합니다. 뷰 이름을 API처럼 버전 관리하고 열 제거·의미 변경에는 소비자 전환 기간을 둡니다. 감사 장에서는 등급 결과만 저장하지 않고 계산에 사용한 조회수와 기준 버전을 함께 남겨 과거 판단을 재현합니다. CASE와 뷰는 표시 편의가 아니라 지속되는 조회 규칙이므로 데이터 사전과 마이그레이션 이력에 포함합니다.


CASE와 VIEW 적용 기준

판단 축확인할 질문
순서겹치는 WHEN을 구체적·높은 기준부터 평가하는가?
집계건수의 NULL 규칙과 SUM의 ELSE 값을 의도했는가?
0건자식 없음의 NULL을 0으로 바꾸는 것이 업무상 맞는가?
권한SQL SECURITY와 테넌트 범위가 최소 권한에 맞는가?
비용뷰 내부 JOIN·집계를 펼친 실행 계획을 측정했는가?

CASE는 경계값 표로 먼저 리뷰하고 뷰는 열 이름·뜻·권한·비용을 API처럼 리뷰합니다. 짧은 소비 쿼리가 내부 비용이 작다는 증거는 아니므로 원본 일치 검사와 실행 계획을 함께 보존합니다.


연습 문제

활성 태그별 전체 게시글 수, 게시된 글 수, 게시된 글의 총 조회수를 제공하는 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;

게시글 0건, 초안만 있음, 게시된 글의 조회수 합계가 59·60·179·180회인 태그를 준비해 건수와 등급 경계를 확인합니다. 활성 태그 수와 뷰 행 수가 일치하는지도 확인합니다.


핵심 정리

  • CASE는 위에서 아래로 첫 TRUE를 선택하므로 겹치는 범위 순서가 결과 규칙입니다.
  • 조건부 건수는 NULL, 조건부 SUM은 ELSE 값을 의도적으로 사용합니다.
  • 뷰는 SELECT 정의를 저장하며 원본 비용과 권한 검토를 없애지 않습니다.
  • 집계 뷰는 원본 행 수·합계와 일치 검사하고 경계값을 별도 검증합니다.

다음 장에서는 지금까지 만든 조회의 실제 비용을 인덱스와 EXPLAIN ANALYZE로 측정합니다.