이번 문서의 목표: 이 파일을 다 읽으면 UNION, UNION ALL, INTERSECT, MINUS(EXCEPT)의 결과 건수와 정렬 여부를 데이터로 직접 계산하고, 두 SELECT를 언제 결합할 수 있는지 판단할 수 있다.
집합 연산자가 왜 필요한가
16편·17편에서는 하나의 조인이나 서브쿼리로 여러 테이블의 정보를 옆으로 이어 붙였다. 그런데 때로는 이어 붙이는 것이 아니라, 서로 다른 두 SELECT 문의 결과를 위아래로 합치거나, 겹치는 부분만 남기거나, 한쪽에만 있는 것만 골라내야 할 때가 있다. 예를 들어 “개발부 소속 사원 명단”과 “급여 4000 이상인 사원 명단”을 각각 조회한 뒤, 이 둘을 합집합·교집합·차집합처럼 다루고 싶은 상황이다.
이렇게 두 개(또는 그 이상)의 SELECT 문 결과를 집합(set)처럼 다뤄 합치거나 비교하는 연산자가 집합 연산자다. 수학 시간에 배운 합집합, 교집합, 차집합 개념을 SQL 문법으로 그대로 옮긴 것이라고 생각하면 된다.
쉽게 말하면: 집합 연산자는 두 조회 결과를 “합쳐라”, “겹치는 것만 남겨라”, “빼라”라고 시키는 명령이다.
실습용 데이터 — 16, 17편의 EMP 테이블을 그대로 쓴다
17편에서 SALARY(급여)를 추가한 EMP 테이블을 그대로 사용한다.
| EMP_ID | NAME | DEPT_ID | SALARY |
|---|---|---|---|
| 1 | 김민준 | 10 | 5000 |
| 2 | 이서연 | 20 | 4500 |
| 3 | 박도윤 | 20 | 3000 |
| 4 | 최지우 | (NULL) | 3500 |
| 5 | 정하은 | 50 | 4000 |
두 개의 SELECT 문을 정의해두고, 아래에서 이 둘을 계속 조합해본다.
쿼리 A — 개발부(DEPT_ID 20) 소속 사원의 이름
SELECT NAME FROM EMP WHERE DEPT_ID = 20;결과: 이서연, 박도윤 (2행)
쿼리 B — 급여가 4000 이상인 사원의 이름
SELECT NAME FROM EMP WHERE SALARY >= 4000;결과: 김민준, 이서연, 정하은 (3행)
두 결과의 교집합은 이서연 한 명뿐이라는 것을 미리 확인해둔다. A에만 있는 사람은 박도윤, B에만 있는 사람은 김민준과 정하은이다. 이 사실을 기억해두고 아래 각 연산자의 결과와 맞춰본다.
집합 연산자를 쓰기 위한 조건
두 SELECT 문을 집합 연산자로 결합하려면 다음 두 조건을 반드시 만족해야 한다.
- SELECT 절의 컬럼 개수가 같아야 한다. 쿼리 A가 컬럼 1개(NAME)를 반환한다면 쿼리 B도 정확히 1개를 반환해야 한다.
- 같은 위치의 컬럼끼리 데이터 타입이 호환되어야 한다. 문자형과 문자형, 숫자형과 숫자형처럼 서로 변환 가능한 타입이어야 하며, 컬럼 이름 자체가 같을 필요는 없다(결과의 컬럼 이름은 첫 번째 SELECT의 이름을 따른다).
이 두 조건을 어기면 “컬럼 수가 일치하지 않는다”거나 “데이터 타입이 일치하지 않는다”는 에러가 발생하며 아예 실행되지 않는다.
UNION — 합집합, 중복 제거하고 정렬까지 한다
UNION은 두 SELECT 결과를 위아래로 합친 뒤, 중복된 행을 제거하고 결과를 정렬해서 보여준다.
SELECT NAME FROM EMP WHERE DEPT_ID = 20
UNION
SELECT NAME FROM EMP WHERE SALARY >= 4000;쿼리 A(이서연, 박도윤)와 쿼리 B(김민준, 이서연, 정하은)를 그대로 합치면 이서연, 박도윤, 김민준, 이서연, 정하은으로 5개지만, 이서연이 두 번 겹치므로 하나로 합쳐 4행만 남는다.
| NAME |
|---|
| 김민준 |
| 박도윤 |
| 이서연 |
| 정하은 |
결과가 가나다순으로 정렬되어 나온 것도 확인해두자. UNION은 중복 제거를 위해 내부적으로 정렬(또는 그에 준하는 비교) 과정을 거치기 때문에, 별도로 ORDER BY를 쓰지 않아도 정렬된 것처럼 보이는 경우가 많다. 다만 정렬 순서를 확실히 보장하려면 마지막에 ORDER BY를 명시하는 것이 안전하다.
UNION ALL — 합집합, 중복도 정렬도 그대로 둔다
UNION ALL은 UNION과 달리 중복 제거도, 정렬도 하지 않고 두 결과를 있는 그대로 이어 붙인다.
SELECT NAME FROM EMP WHERE DEPT_ID = 20
UNION ALL
SELECT NAME FROM EMP WHERE SALARY >= 4000;쿼리 A의 2행(이서연, 박도윤)에 쿼리 B의 3행(김민준, 이서연, 정하은)을 그대로 이어 붙이므로 결과는 5행이고, 이서연이 두 번 나온다.
| NAME |
|---|
| 이서연 |
| 박도윤 |
| 김민준 |
| 이서연 |
| 정하은 |
중복을 제거하지 않으므로 정렬이나 비교 작업이 필요 없고, UNION보다 처리 비용이 훨씬 낮다. 두 결과에 애초에 중복이 없다는 것을 이미 알고 있거나, 중복 여부 자체가 중요하지 않은 경우(예: 로그를 그냥 이어 붙이는 경우)에는 UNION 대신 UNION ALL을 쓰는 것이 성능상 유리하다.
INTERSECT — 교집합, 양쪽에 다 있는 것만
INTERSECT는 두 결과에 공통으로 존재하는 행만 남긴다.
SELECT NAME FROM EMP WHERE DEPT_ID = 20
INTERSECT
SELECT NAME FROM EMP WHERE SALARY >= 4000;쿼리 A(이서연, 박도윤)와 쿼리 B(김민준, 이서연, 정하은) 양쪽에 모두 들어 있는 이름은 이서연뿐이다. 결과는 1행이다.
| NAME |
|---|
| 이서연 |
INTERSECT도 UNION처럼 결과에서 중복을 제거하고 정렬해서 보여준다.
MINUS(Oracle) / EXCEPT(표준 SQL) — 차집합, 앞에서 뒤를 뺀다
MINUS는 첫 번째 SELECT 결과에서 두 번째 SELECT 결과와 겹치는 행을 제외하고 남은 것만 보여준다. 뺄셈처럼 순서가 결과에 그대로 영향을 준다는 점이 UNION·INTERSECT와 다르다.
SELECT NAME FROM EMP WHERE DEPT_ID = 20
MINUS
SELECT NAME FROM EMP WHERE SALARY >= 4000;쿼리 A(이서연, 박도윤)에서 쿼리 B에도 있는 이서연을 빼면 박도윤만 남는다. 결과는 1행(박도윤)이다.
반대로 순서를 바꿔보면 결과가 완전히 달라진다.
SELECT NAME FROM EMP WHERE SALARY >= 4000
MINUS
SELECT NAME FROM EMP WHERE DEPT_ID = 20;쿼리 B(김민준, 이서연, 정하은)에서 쿼리 A에도 있는 이서연을 빼면 김민준과 정하은이 남는다. 결과는 2행(김민준, 정하은)이다.
MINUS와 EXCEPT는 이름만 다를 뿐 완전히 같은 연산이다. Oracle은 MINUS라는 이름을 쓰고, 표준 SQL과 대부분의 다른 DBMS(SQL Server, PostgreSQL 등)는 EXCEPT라는 이름을 쓴다. SQLD는 Oracle 문법을 기준으로 출제되므로 MINUS로 익히되, “표준 SQL에서는 EXCEPT라고 부른다”는 이름 대응 관계를 함께 기억해둔다.
네 연산자 한눈에 비교
| 연산자 | 의미 | 중복 제거 | 정렬 발생 | 순서 영향 |
|---|---|---|---|---|
| UNION | 합집합 | 한다 | 한다 | 없음 |
| UNION ALL | 합집합(중복 포함) | 안 한다 | 안 한다 | 없음 |
| INTERSECT | 교집합 | 한다 | 한다 | 없음 |
| MINUS(EXCEPT) | 차집합(앞에서 뒤를 뺌) | 한다 | 한다 | 있음(첫 번째-두 번째 ≠ 두 번째-첫 번째) |
이 표에서 가장 중요하게 봐야 할 두 축은 “중복을 제거하는가”와 “순서가 결과에 영향을 주는가”다. UNION ALL만 유일하게 중복을 제거하지 않아 처리 비용이 낮고, MINUS(EXCEPT)만 유일하게 두 SELECT의 순서를 바꾸면 결과가 달라진다.
직접 해보기
00편에서 만든 실습 환경(Docker + Oracle + DBeaver)에서 진행합니다. 아직 안 만들었다면 00편을 먼저 보세요. 이 편 예제는 seed.sql의 EMP 테이블을 그대로 씁니다.
UNION과 UNION ALL이 실제로 몇 행을 돌려주는지 COUNT(*)로 직접 세어본다.
SELECT COUNT(*) FROM (
SELECT NAME FROM EMP WHERE DEPT_ID = 20
UNION
SELECT NAME FROM EMP WHERE SALARY >= 4000
);
SELECT COUNT(*) FROM (
SELECT NAME FROM EMP WHERE DEPT_ID = 20
UNION ALL
SELECT NAME FROM EMP WHERE SALARY >= 4000
);무엇을 보아야 하나: 두 COUNT(*) 값이 서로 다르게 나오는지, 그 차이가 정확히 중복된 행(이서연) 개수만큼인지 확인한다.
왜 이걸 해보나: UNION과 UNION ALL의 결과 건수를 암산으로 헷갈리는 것이 이 편 기출의 단골 함정이다. 직접 세어보면 계산 공식이 몸에 남는다.
직접 해보기 2
MINUS는 순서를 바꾸면 결과가 달라진다. 두 방향을 나란히 실행해 비교한다.
SELECT NAME FROM EMP WHERE DEPT_ID = 20
MINUS
SELECT NAME FROM EMP WHERE SALARY >= 4000;
SELECT NAME FROM EMP WHERE SALARY >= 4000
MINUS
SELECT NAME FROM EMP WHERE DEPT_ID = 20;무엇을 보아야 하나: 두 결과가 서로 다른 이름을 돌려주는지 확인한다. 여유가 있다면 아래처럼 컬럼 개수를 일부러 다르게 맞춰 INTERSECT를 실행해 어떤 오류가 나는지도 확인해보자.
SELECT NAME, SALARY FROM EMP WHERE DEPT_ID = 20
INTERSECT
SELECT NAME FROM EMP WHERE SALARY >= 4000;왜 이걸 해보나: “A MINUS B와 B MINUS A는 같다”는 착각, 그리고 집합 연산자에 컬럼 개수가 다른 SELECT를 섞는 실수는 둘 다 이 편 기출의 반복 함정이다.
자주 틀리는 점
- UNION과 UNION ALL의 결과 건수를 헷갈리는 경우가 가장 많다. 중복된 행이 몇 개인지 먼저 세어보고, UNION은 그 중복분을 제외한 건수, UNION ALL은 단순히 두 결과 행 수를 더한 건수라고 계산하면 정확하다.
- MINUS의 순서를 바꿔도 결과가 같다고 착각하는 실수가 잦다. 차집합은 뺄셈과 같아서
A - B와B - A는 일반적으로 다른 결과를 낸다. 이 편의 예시처럼 순서를 바꾼 두 결과를 나란히 비교해보는 연습이 효과적이다. - 컬럼 개수나 타입이 다른 두 SELECT를 집합 연산자로 결합하려는 실수도 나온다. 집합 연산자를 쓰기 전에는 항상 양쪽 SELECT 절의 컬럼 개수와 타입부터 맞춰야 한다.
- MINUS와 EXCEPT를 서로 다른 연산으로 착각하는 경우가 있다. 둘은 이름만 다를 뿐 동작이 완전히 같은 차집합 연산이며, 어떤 DBMS를 쓰느냐에 따라 이름만 달라진다.
- INTERSECT를 조인과 혼동하는 경우도 있다. INTERSECT는 컬럼 구조가 같은 두 결과 집합 사이의 행 단위 비교이고, 조인은 서로 다른 구조의 두 테이블을 컬럼 값 기준으로 옆으로 결합하는 것이라는 차이를 구분해야 한다.
핵심 정리
- 집합 연산자를 쓰려면 두 SELECT의 컬럼 개수가 같고 같은 위치의 데이터 타입이 호환되어야 한다.
- UNION은 합치고 중복 제거와 정렬까지 하며, UNION ALL은 중복 제거도 정렬도 없이 그대로 이어 붙인다.
- INTERSECT는 두 결과에 공통으로 있는 행만 남긴다.
- MINUS(Oracle)는 표준 SQL의 EXCEPT와 이름만 다른 같은 연산으로, 첫 번째 결과에서 두 번째 결과를 빼며 순서를 바꾸면 결과도 달라진다.
- 중복 여부가 중요하지 않다면 UNION ALL이 UNION보다 처리 비용이 낮아 유리하다.
마무리 복습
참고 자료
- Oracle Database SQL Language Reference - SELECT (Set Operators) — UNION·UNION ALL·INTERSECT·MINUS의 공식 문법과 사용 조건