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

안동민 개발노트

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

UNION과 집합 결합

UNION과 UNION ALL의 열 규칙·중복 정책·최종 정렬을 이해하고 여러 사건을 하나의 피드로 결합합니다.

JOIN이 관계 있는 행의 열을 옆으로 늘린다면 UNION은 이미 완성된 결과 집합을 아래로 이어 행을 늘립니다.

세로 결합이므로 각 SELECT는 같은 위치에 같은 의미의 열을 내야 합니다.

열 개수만 맞춘 뒤 전혀 다른 뜻을 섞으면 실행은 되어도 소비자가 해석할 수 없는 결과가 됩니다.

회원 게시판의 ‘최근 활동’ 피드에는 회원 가입과 게시글 게시라는 서로 다른 사건이 함께 표시됩니다.

원본 테이블은 다르지만 event_at, event_type, event_key, summary라는 공통 결과 규칙으로 투영하면 UNION ALL로 한 목록을 만들 수 있습니다.


UNION 예제 데이터

피드의 ID와 시각이 앞 장의 실행 순서에 따라 달라지지 않도록 회원 한 명, 게시글 두 개, 태그 세 개를 고정합니다.

회원 가입 한 건과 게시글 게시 두 건을 합치면 최종 피드는 정확히 세 행입니다.

ch6-3 UNION fixture
DROP TABLE IF EXISTS ch6_union_post_demo;
DROP TABLE IF EXISTS ch6_union_tag_demo;
DROP TABLE IF EXISTS ch6_union_member_demo;

CREATE TABLE ch6_union_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
) ENGINE = InnoDB;

CREATE TABLE ch6_union_post_demo (
  id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  title VARCHAR(80) NOT NULL,
  status VARCHAR(16) NOT NULL,
  published_at DATETIME(6) NULL
) ENGINE = InnoDB;

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

INSERT INTO ch6_union_member_demo
  (id, email, name, status, created_at)
VALUES
  (3, 'park@example.com', '박준', 'ACTIVE', '2026-07-13 09:00:00');

INSERT INTO ch6_union_post_demo (id, title, status, published_at)
VALUES
  (1, 'JOIN 실습', 'PUBLISHED', '2026-07-12 18:10:00'),
  (2, 'EXISTS 복습', 'PUBLISHED', '2026-07-13 12:20:00');

INSERT INTO ch6_union_tag_demo (id, code, name, is_active)
VALUES
  (1, 'database', '데이터베이스', TRUE),
  (2, 'java', 'Java', TRUE),
  (3, 'legacy', '이전 분류', FALSE);

열 구성이 다른 집합

회원은 두 열, 태그는 세 열을 선택해 UNION합니다.

UNION은 이름이 비슷한 열을 자동으로 맞추거나 빠진 열을 NULL로 채우지 않습니다.

각 분기의 같은 위치가 하나의 최종 열이므로 개수와 호환 타입을 작성자가 고정해야 합니다.

열 계약이 다른 UNION
SELECT id, email
FROM ch6_union_member_demo
UNION ALL
SELECT id, code, name
FROM ch6_union_tag_demo;

첫 SELECT는 2열, 둘째는 3열이어서 MySQL이 최종 결과 모양을 만들 수 없습니다.

오류 실행 결과
ERROR 1222 (21000): The used SELECT statements have a different number of columns

UNION의 호환성은 테이블 구조가 아니라 SELECT 결과를 기준으로 판단합니다.

열 수를 억지로 맞춰도 첫 위치가 ID, 둘째 위치가 이메일과 이름처럼 다른 의미라면 API 규칙이 모호합니다.

먼저 공통 열 이름·타입·뜻을 정의하고 각 원본을 그 모양으로 변환해야 합니다.


UNION의 열과 중복 규칙

모든 분기는 같은 열 개수를 반환하고 같은 위치의 타입이 호환되어야 합니다.

최종 열 이름은 첫 SELECT의 별칭에서 정해지므로 첫 분기가 공통 규칙을 분명히 보여야 합니다.

UNION은 전체 행의 모든 열이 같은 중복을 제거하고, UNION ALL은 중복을 그대로 유지합니다.

사건 두 종류처럼 분기가 판별자로 서로 겹치지 않는다면 UNION ALL이 의도와 비용을 가장 잘 드러냅니다.

UNION과 UNION ALL은 표준 SQL입니다.

분기별 ORDER BY는 보통 최종 순서를 보장하지 않으며 전체 결합 뒤 마지막 ORDER BY 하나로 결과 규칙을 정합니다.

분기에서 LIMIT을 적용해 후보 자체를 줄이려면 괄호와 명확한 내부 정렬이 필요하고, 그 최적화가 전역 상위 N과 같은지 확인해야 합니다.

MySQL의 타입 강제 변환에 기대지 말고 CAST로 공통 키 타입을 명시합니다.


공통 결과 구조

회원 가입과 게시글 게시를 각각 네 열의 사건으로 바꿉니다.

ID는 문자열로 통일하고 event_type을 넣어 같은 숫자 ID도 다른 사건으로 구분합니다.

중복 제거 요구가 없으므로 UNION ALL을 사용합니다.

회원 가입과 게시글 게시를 합친 활동 피드
SELECT
  m.created_at AS event_at,
  'MEMBER_JOINED' AS event_type,
  CAST(m.id AS CHAR) AS event_key,
  m.name AS summary
FROM ch6_union_member_demo AS m

UNION ALL

SELECT
  p.published_at AS event_at,
  'POST_PUBLISHED' AS event_type,
  CAST(p.id AS CHAR) AS event_key,
  p.title AS summary
FROM ch6_union_post_demo AS p
WHERE p.status = 'PUBLISHED'
  AND p.published_at IS NOT NULL

ORDER BY event_at DESC, event_type, event_key
LIMIT 20;

두 분기가 같은 사건 열을 반환하고 전체 결과에서 한 번만 정렬됩니다.

같은 숫자 키도 event_type과 함께 읽어 충돌하지 않습니다.

개선 실행 결과
event_at            | event_type        | event_key | summary
2026-07-13 12:20:00 | POST_PUBLISHED | 2         | EXISTS 복습
2026-07-13 09:00:00 | MEMBER_JOINED  | 3         | 박준
2026-07-12 18:10:00 | POST_PUBLISHED | 1         | JOIN 실습

event_at이 NULL 허용이면 전체 정렬 위치를 명시하거나 분기에서 제외합니다.

피드의 키는 (event_type, event_key) 조합이므로 event_key 하나만 커서에 넣지 않습니다.

여러 원본을 추가해도 공통 규칙을 지키면 분기만 늘릴 수 있지만, 필드 의미가 달라지기 시작하면 별도 피드 유형이나 이벤트 구조를 검토합니다.


UNION ALL 행 수 확인

중복 제거가 없는 결합은 각 분기 행 수의 합과 최종 행 수가 같아야 합니다.

전체 LIMIT을 적용하기 전 원본 규칙을 확인합니다.

branch count와 feed count 비교
WITH
member_events AS (
  SELECT created_at AS event_at, 'MEMBER_JOINED' AS event_type,
         CAST(id AS CHAR) AS event_key
  FROM ch6_union_member_demo
),
published_events AS (
  SELECT published_at AS event_at, 'POST_PUBLISHED' AS event_type,
         CAST(id AS CHAR) AS event_key
  FROM ch6_union_post_demo
  WHERE status = 'PUBLISHED' AND published_at IS NOT NULL
),
feed AS (
  SELECT * FROM member_events
  UNION ALL
  SELECT * FROM published_events
)
SELECT
  (SELECT COUNT(*) FROM member_events) AS member_rows,
  (SELECT COUNT(*) FROM published_events) AS published_rows,
  (SELECT COUNT(*) FROM feed) AS feed_rows,
  (SELECT COUNT(*) FROM feed) =
    (SELECT COUNT(*) FROM member_events) +
    (SELECT COUNT(*) FROM published_events) AS counts_match;

counts_match가 1이어야 합니다.

UNION으로 바꾸어 수가 줄었다면 세 열이 모두 같은 사건이 양쪽 또는 같은 분기에 있었는지 확인하고, 그 중복이 데이터 오류인지 의도한 제거인지 판단합니다.

UNION의 중복 제거는 ID 한 열만 보는 것이 아니라 최종 선택 열 전체를 비교합니다.

같은 이메일이지만 이름이 다르면 두 행이 남습니다.

특정 키 기준 하나만 남기려면 UNION 뒤에 윈도 함수로 우선순위를 매기거나 원본 정합성을 고쳐야 합니다.

또한 UNION은 결합 집합을 만드는 연산이지 서로 다른 원본의 최신 행을 자동으로 선택하는 도구가 아닙니다.

사건 순서는 마지막 ORDER BY와 유일한 동률 키로 완성합니다.

같은 SELECT를 두 번 UNION과 UNION ALL로 결합해 행 수와 실행 계획을 비교합니다.

이어서 한 분기의 event_key를 숫자, 다른 분기를 문자열로 두었을 때 결과 타입을 확인하고 명시적 CAST를 추가합니다.

분기 순서를 바꾸면 최종 열 이름이 달라질 수 있음을 확인하세요.

각 분기 안 ORDER BY를 넣었다고 최종 피드가 정렬되는지 기대하지 말고 마지막 ORDER BY를 제거한 결과가 무순서임을 기록합니다.

분리 보관된 현재·과거 테이블을 UNION ALL로 읽는 경우 두 원본의 기간이 겹치지 않는다는 수명주기 규칙이 필요합니다.

겹침이 가능하면 중복을 무조건 UNION으로 제거하기보다 원본·버전·이관 ID를 사용해 원인을 추적합니다.

피드 페이지네이션은 분기마다 LIMIT 20을 주는 것과 전체 상위 20을 구하는 것이 항상 같지 않습니다.

각 분기가 같은 정렬 기준에서 최소한 N개 후보를 제공한다는 확인이 있을 때만 푸시다운하고 실제 계획을 측정합니다.

문자열 정렬 규칙이 다른 분기를 합치면 비교·정렬 규칙 선택에서 오류나 암묵 변환이 생길 수 있습니다.

DECIMAL과 문자열 숫자, 시각과 날짜 문자열도 공통 타입을 명시합니다.

UNION 뒤 ORDER BY는 첫 SELECT의 별칭을 사용하며 원본 테이블 별칭은 최종 범위에 없습니다.

NULL은 UNION 중복 비교에서 같은 위치의 NULL끼리 중복으로 취급될 수 있으므로 일반 =의 알 수 없음 규칙과 동일하다고 가정하지 않습니다.

활동 피드는 뒤의 감사 이력과 통계 파이프라인에서 여러 사건 원본을 합치는 기초가 됩니다.

실제 감사 기록은 원본 테이블을 UNION으로 매번 스캔하는 대신 일관된 이벤트 테이블을 설계할 수 있지만, 마이그레이션·백필 과정에서는 같은 공통 규칙으로 일치 여부를 확인합니다.

원본 판별자와 원본 키를 보존하면 어떤 분기가 중복을 만들었는지 추적할 수 있습니다.

7장의 인덱스에서는 분기별 WHERE·ORDER BY 지원을 각각 측정하고, 최종 UNION 정렬의 임시·정렬 비용을 별도로 확인합니다.


집합 결합 검토 기준

판단 축확인할 질문
모든 분기의 열 개수·위치·의미가 같은가?
타입ID·시각·문자열 타입을 명시적으로 호환시켰는가?
중복제거가 업무 요구인가, 아니면 UNION ALL이 맞는가?
출처같은 키를 구분할 원본 판별자가 있는가?
순서마지막 ORDER BY가 동률까지 포함한 전체 순서를 만드는가?

UNION을 작성하기 전에 최종 결과의 열 규칙을 표로 만들고 각 분기가 그 표의 한 행을 생산하는 변환인지 검토합니다.

단순히 열 수만 맞춘 NULL 자리표시자에는 이름과 의미를 기록합니다.


연습 문제

활성 회원과 활성 태그를 하나의 검색 카탈로그로 합치세요.

결과 열은 item_type, item_key, label 세 개이며 회원은 이메일, 태그는 코드를 표시명으로 사용합니다.

중복 제거는 하지 않고 유형·표시명·키 순으로 정렬하세요.

해설과 예시 답안

두 원본은 유형으로 구분되므로 UNION ALL을 사용합니다.

숫자 ID는 공통 문자열 키로 CAST하고 첫 SELECT에서 최종 별칭을 고정합니다.

SELECT
  'MEMBER' AS item_type,
  CAST(id AS CHAR) AS item_key,
  email AS label
FROM ch6_union_member_demo
WHERE status = 'ACTIVE'

UNION ALL

SELECT
  'TAG' AS item_type,
  CAST(id AS CHAR) AS item_key,
  code AS label
FROM ch6_union_tag_demo
WHERE is_active = TRUE

ORDER BY item_type, label, item_key;

활성 회원 수+활성 태그 수가 최종 행 수와 같은지 확인하고, 같은 숫자 ID가 있어도 item_type으로 구분되는지 봅니다.


핵심 정리

  • UNION은 같은 열 모양의 결과를 아래로 이어 행 집합을 만듭니다.
  • UNION은 전체 행 중복을 제거하고 UNION ALL은 원본 행을 보존합니다.
  • 첫 SELECT가 최종 열 이름을 정하고 같은 위치의 의미·타입을 분기마다 맞춥니다.
  • 최종 순서는 결합 뒤 마지막 ORDER BY 하나로 완성합니다.

다음 문서에서는 CASE로 결과에 조건부 의미를 만들고 뷰로 조회 규칙을 저장합니다.