고객과 주문을 JOIN하면 조건에 맞는 고객·주문 쌍마다 행이 나옵니다. 고객별로 조건에 맞는 주문이 하나라도 있는지만 묻는 EXISTS는 바깥 고객 행을 남길지 판단합니다. 고객 한 명에게 주문이 세 건이면 JOIN에서 세 행이 나오지만, 그 고객 행에 대한 EXISTS 판정은 한 번입니다.

PostgreSQL 18 문서의 의미를 바탕으로 만든 교육용 표입니다. 행 수 차이를 먼저 이해하고, 어느 쿼리가 더 빠른지는 실제 계획을 보지 않은 상태에서 단정하지 않습니다.

결과 한 행이 무엇을 뜻하는지 정합니다

‘결제한 주문이 있는 고객 목록’과 ‘결제한 주문 목록’은 비슷해 보여도 필요한 행이 다릅니다. 전자는 고객을 기준으로 확인하고 후자는 주문 한 건씩 확인합니다. 쿼리에서 고객 이름만 SELECT했다고 결과가 자동으로 고객당 한 행이 되는 것은 아닙니다. SELECT에 무엇을 보여 주는지와 JOIN이 몇 쌍을 만드는지를 따로 봐야 합니다.

독립된 고객 ID를 유지하는 이유도 중요합니다. 같은 이름을 쓰는 고객 두 명이 있을 수 있으므로, 이름이 같다는 사실과 같은 고객이라는 사실은 다릅니다. 아래에서는 ID 1과 2의 이름을 일부러 같은 ‘가온’으로 정했습니다. 중복을 없애려다가 서로 다른 고객을 합치는 실수를 드러내기 위한 입력입니다.

고객 세 명과 주문 네 건을 결합합니다

직접 만든 고객과 연결 주문 요약
고객 ID이름결제한 주문 ID
1가온51, 52, 53
2가온61
3나래없음
쿼리 A: 일치하는 고객·주문 조합을 모두 출력합니다.
WITH customer(id, name) AS (
  VALUES (1, '가온'), (2, '가온'), (3, '나래')
), orders(id, customer_id, paid) AS (
  VALUES (51, 1, true), (52, 1, true),
         (53, 1, true), (61, 2, true)
)
SELECT c.id, c.name, o.id AS order_id
FROM customer AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE o.paid = true
ORDER BY c.id, o.id;
쿼리 A의 네 행
고객 ID이름주문 ID
1가온51
1가온52
1가온53
2가온61

고객 1과 연결되는 주문은 세 개여서 세 쌍이 만들어집니다. 고객 2는 한 쌍, 고객 3은 연결되는 주문이 없어 0쌍입니다. 합하면 네 행입니다. 마지막 SELECT에서 주문 ID를 지우더라도 그 네 행의 고객 ID와 이름이 남습니다. 같은 값이 여러 번 보인다는 이유만으로 SQL이 오류라고 판단하지 말고, 요구한 결과 단위와 맞는지 먼저 확인하세요.

존재 여부만 확인하면 고객 행을 그대로 고릅니다

쿼리 B: 위 WITH 입력을 그대로 사용하고 SELECT 이하를 바꿉니다.
SELECT c.id, c.name
FROM customer AS c
WHERE EXISTS (
  SELECT 1 FROM orders AS o
  WHERE o.customer_id = c.id AND o.paid = true
)
ORDER BY c.id;

고객 1에 대해 서브쿼리는 조건을 만족하는 주문이 있는지 묻습니다. 세 건이 있어도 판정은 TRUE 하나이므로 해당 고객 행을 한 번 남깁니다. 고객 2도 TRUE라서 남고, 고객 3은 일치하는 주문이 없어 FALSE가 되어 제외됩니다. 결과는 ID 1과 2의 두 행입니다. 이름은 둘 다 가온이지만 고객은 서로 다릅니다.

SELECT 1의 숫자 1은 주문 수를 세거나 고객 번호를 고르는 뜻이 아닙니다. 이 단순한 EXISTS 형태에서는 행의 존재가 중요하므로 SELECT NULL로 바꾸어도 조건에 맞는 행이 있으면 TRUE입니다. 결과 값이 NULL이라는 사실과 결과 행이 없다는 사실을 구별하세요. 집합 연산이 포함된 복잡한 서브쿼리까지 출력 목록이 언제나 무관하다고 일반화하지는 않습니다.

이름만 DISTINCT로 묶으면 다른 고객도 합쳐집니다

쿼리 A에서 ‘SELECT DISTINCT c.name’만 사용하면 결과 이름은 가온 한 행입니다. 주문 중복은 줄었지만 ID 1과 2라는 서로 다른 고객도 합쳐졌습니다. 고객 목록이 목적이었다면 정보가 손실된 것입니다. 반면 고객 ID와 이름을 함께 DISTINCT로 고르면 이 입력에서는 두 고객이 유지됩니다. DISTINCT를 붙일지보다 어떤 열이 고객을 식별하는지 먼저 판단해야 합니다.

같은 입력에서 구별해야 할 결과
질문 또는 식행 수뜻
고객·주문 JOIN4주문과 연결된 네 쌍
고객별 WHERE EXISTS2주문이 있는 고객 두 명
JOIN 뒤 DISTINCT 이름1등장한 이름 종류 한 개
JOIN 뒤 DISTINCT 고객 ID·이름2식별자가 다른 고객 두 명

EXISTS 자체가 바깥 입력의 중복을 제거하는 것도 아닙니다. 앞선 쿼리 때문에 바깥 입력에 같은 고객 행이 두 번 들어 있다면 두 행 모두 TRUE 판정을 받아 남을 수 있습니다. ‘바깥 행마다 최대 한 행’과 ‘고객당 항상 유일한 한 행’은 다른 말입니다. 여기서는 customer의 ID가 서로 다르다는 입력 조건 덕분에 고객당 한 행이 됩니다.

현재 고객과 연결하는 조건을 빠뜨리지 않습니다

비교용 오답: 현재 고객과 주문을 연결하지 않았습니다.
WHERE EXISTS (
  SELECT 1 FROM orders AS o WHERE o.paid = true
)

이 조건은 ‘지금 검사하는 고객의 결제 주문’이 아니라 ‘전체 주문 중 결제한 주문이 하나라도 있는가’를 묻습니다. 표에는 결제 주문이 있으므로 고객 3까지 모두 TRUE가 되어 세 행이 남습니다. 서브쿼리 안에 바깥 고객 ID와 주문 고객 ID를 비교하는 상관조건이 있는지 확인하면 이런 오답을 찾을 수 있습니다.

상관조건을 쓰더라도 비교할 열을 바꾸면 요구가 달라집니다. 주문 자체의 ID와 고객 ID를 비교하거나, 결제 조건을 다른 단계에 두지 않았는지 확인하세요. 숫자가 우연히 같아 작은 표에서 맞아 보이는 경우도 있으므로 고객 ID와 주문 ID의 숫자 범위를 일부러 다르게 만들어 검사하는 편이 좋습니다.

세 가지 입력으로 결과 수를 예측합니다

  1. 조건을 만족하는 주문이 없는 고객을 하나 둡니다.
  2. 주문이 한 건 있는 고객과 여러 건 있는 고객을 각각 둡니다.
  3. 고객 ID는 다르지만 이름은 같은 동명이인을 추가합니다.
  4. JOIN은 일치하는 쌍을, EXISTS는 바깥 행별 TRUE·FALSE를 먼저 적습니다.
  5. SELECT에서 ID를 지우거나 DISTINCT를 붙였을 때 어떤 정보가 없어지는지 확인합니다.
  6. 상관조건을 지운 반례와 비교한 뒤 원래 요구에 맞는 쿼리를 선택합니다.

이 글의 선택 기준은 성능 구호가 아니라 결과의 의미입니다. 주문 상세 열이 필요하면 JOIN 결과가 자연스러울 수 있고, 고객별 존재 여부만 필요하면 EXISTS가 의도를 분명하게 보여 줍니다. 성능은 데이터 양과 인덱스, 옵티마이저가 선택한 계획에 따라 확인할 문제이므로 ‘EXISTS는 항상 빠르다’ 같은 문장을 답안 근거로 삼지 마세요.

정답을 가리고 확인해 보세요

이 글을 위해 만든 학습 예시이며 공식 기출문제나 판매 카드의 인용이 아닙니다.

바깥 고객 표의 ID는 유일합니다. 고객 7에게 결제 주문 두 건이 있고 고객 8에게는 없습니다. 결제 주문과 INNER JOIN해 고객 ID만 출력하면 몇 행인가요? 고객별 상관조건이 있는 WHERE EXISTS는 몇 행인가요?

정답과 해설 확인JOIN은 고객 7이 두 번 나오는 2행, EXISTS는 고객 7만 남는 1행입니다.

JOIN은 일치하는 주문별로 행 쌍을 만듭니다. EXISTS는 바깥 고객 행마다 주문이 있는지 판정하므로 주문 두 건이 고객 행을 두 배로 늘리지 않습니다.