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

안동민 개발노트

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

서브쿼리 연산자

스칼라·다중 행·다중 컬럼 서브쿼리의 결과 모양을 먼저 예측하고 비교 연산자를 정확히 선택합니다.

서브쿼리는 SQL 안에 SELECT를 하나 더 넣는 문법이지만, 핵심은 괄호가 아니라 안쪽 쿼리가 반환하는 모양입니다.

한 값인지, 한 열의 목록인지, 여러 열로 이루어진 행 목록인지에 따라 바깥 연산자가 달라집니다.

이 규칙을 확인하지 않으면 샘플 데이터에서는 한 행이라 성공하다가 데이터가 늘어난 순간 오류가 납니다.

회원 게시판에서는 게시된 글의 평균보다 조회수가 높은 글, 활성 태그가 붙은 글, 회원마다 가장 먼저 작성한 글을 차례로 찾습니다.

세 질문은 모두 ‘먼저 값을 구한 뒤 사용한다’는 구조지만 안쪽 결과의 행·열 수가 서로 다릅니다.


서브쿼리 예제 데이터

앞 장의 실행 여부나 운영 테이블의 누적 행 수에 기대지 않도록 이 문서에서 사용할 게시글과 태그를 고정합니다.

게시된 글의 평균 조회수는 70회이고, 활성 태그는 databasejava 두 개입니다.

ch6-1 서브쿼리 fixture
DROP TABLE IF EXISTS ch6_subquery_post_tag_demo;
DROP TABLE IF EXISTS ch6_subquery_tag_demo;
DROP TABLE IF EXISTS ch6_subquery_post_demo;

CREATE TABLE ch6_subquery_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,
  created_at DATETIME(6) NOT NULL
) ENGINE = InnoDB;

CREATE TABLE ch6_subquery_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;

CREATE TABLE ch6_subquery_post_tag_demo (
  post_id BIGINT UNSIGNED NOT NULL,
  tag_id BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  CONSTRAINT fk_ch6_subquery_post_tag_post
    FOREIGN KEY (post_id) REFERENCES ch6_subquery_post_demo (id),
  CONSTRAINT fk_ch6_subquery_post_tag_tag
    FOREIGN KEY (tag_id) REFERENCES ch6_subquery_tag_demo (id)
) ENGINE = InnoDB;

INSERT INTO ch6_subquery_post_demo
  (id, author_id, title, status, view_count, created_at)
VALUES
  (1, 1, 'SELECT 기초', 'PUBLISHED', 40, '2026-07-01 09:00:00'),
  (2, 1, 'JOIN 실습', 'PUBLISHED', 100, '2026-07-02 09:00:00'),
  (3, 2, '초안 작성', 'DRAFT', 200, '2026-07-01 10:00:00'),
  (4, 2, 'EXISTS 복습', 'PUBLISHED', 60, '2026-07-03 09:00:00'),
  (5, 3, 'VIEW 정리', 'PUBLISHED', 80, '2026-07-01 11:00:00');

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

INSERT INTO ch6_subquery_post_tag_demo (post_id, tag_id)
VALUES
  (1, 1),
  (2, 1),
  (2, 2),
  (3, 3),
  (4, 3),
  (5, 2);

단일 값 비교의 오류

게시된 글의 조회수 목록을 하나의 값처럼 = 오른쪽에 둡니다.

현재 게시된 글이 우연히 한 건이면 실행되지만 두 건째가 들어오는 즉시 스칼라 위치의 규칙을 위반합니다.

LIMIT 1로 오류만 숨기면 어느 행을 선택하는지 정의되지 않아 더 위험합니다.

다중 행 결과를 스칼라처럼 비교
SELECT id, title, view_count
FROM ch6_subquery_post_demo
WHERE view_count = (
  SELECT view_count
  FROM ch6_subquery_post_demo
  WHERE status = 'PUBLISHED'
);

안쪽 SELECT가 게시된 글마다 한 값을 내므로 두 행 이상에서 MySQL은 비교를 계속하지 못합니다.

오류 실행 결과
ERROR 1242 (21000): Subquery returns more than 1 row

바깥의 =는 오른쪽에 값 하나를 요구합니다.

안쪽 쿼리는 열은 하나지만 행 수를 제한하는 집계나 유일 조건이 없습니다.

이 오류는 데이터 양의 문제가 아니라 결과 카디널리티 규칙의 문제입니다.

임의의 LIMIT 대신 질문이 평균 한 값인지, 허용 목록인지, 특정 행 하나인지부터 다시 말해야 합니다.


서브쿼리 결과의 모양

집계로 한 행·한 열을 보장한 스칼라 서브쿼리는 =, >, <= 같은 단일 값 연산자와 쓸 수 있습니다.

한 열의 여러 행은 IN, ANY, ALL처럼 집합을 받는 연산자가 필요합니다.

두 열 이상의 행 목록은 (a, b) IN (SELECT x, y ...)처럼 같은 개수와 호환 타입의 행 생성자로 비교합니다.

바깥 열 두 개가 안쪽 열 두 개와 같은 순서·의미를 가져야 하며 단지 타입만 같다고 올바른 비교가 되지는 않습니다.

스칼라·IN·EXISTS·행 값 비교는 SQL 표준 개념입니다.

MySQL 8.4는 = ANYIN을 지원하지만 팀에서 익숙한 표현을 정합니다.

> ALL은 빈 집합에서 TRUE가 될 수 있고 스칼라 집계는 입력이 없어도 NULL 한 행을 반환할 수 있으므로 빈 입력의 결과까지 확인합니다.

다중 컬럼 IN은 편리하지만 인덱스와 NULL 의미를 실제 실행 계획에서 검증합니다.


결과 모양과 연산자

전체 평균은 집계로 한 값, 활성 태그는 ID 목록, 회원별 최초 게시글은 (author_id, created_at) 쌍 목록으로 만듭니다.

각 바깥 연산자가 받는 모양이 SQL에서 보이도록 작성합니다.

스칼라·목록·행 서브쿼리를 구분한 조회
-- 한 값: 게시된 글의 평균 조회수보다 높은 게시글
SELECT id, title, view_count
FROM ch6_subquery_post_demo
WHERE status = 'PUBLISHED'
  AND view_count > (
    SELECT AVG(view_count)
    FROM ch6_subquery_post_demo
    WHERE status = 'PUBLISHED'
  )
ORDER BY view_count DESC, id;

-- 한 열 목록: 활성 태그가 붙은 게시글
SELECT id, title
FROM ch6_subquery_post_demo
WHERE id IN (
  SELECT pt.post_id
  FROM ch6_subquery_post_tag_demo AS pt
  WHERE pt.tag_id IN (
    SELECT id FROM ch6_subquery_tag_demo WHERE is_active = TRUE
  )
)
ORDER BY id;

-- 두 열 행 목록: 회원마다 가장 먼저 시작한 게시글
SELECT id, author_id, title, created_at
FROM ch6_subquery_post_demo
WHERE (author_id, created_at) IN (
  SELECT author_id, MIN(created_at)
  FROM ch6_subquery_post_demo
  GROUP BY author_id
)
ORDER BY author_id, id;

평균 쿼리는 기준값 하나를, 활성 태그 쿼리는 허용 ID 집합을, 최초 게시글 쿼리는 회원–시각 쌍을 정확히 비교합니다.

같은 최소 시각의 게시글이 둘이면 둘 다 나오는 것도 결과 규칙에 포함됩니다.

개선 실행 결과
query            | id | title
above average    | 2  | JOIN 실습
above average    | 5  | VIEW 정리
active tags      | 1  | SELECT 기초
active tags      | 2  | JOIN 실습
active tags      | 5  | VIEW 정리
first per member | 1  | SELECT 기초
first per member | 3  | 초안 작성
first per member | 5  | VIEW 정리

최초 게시글을 정확히 한 행만 원한다면 최소 시각만으로는 부족합니다.

같은 시각의 동률을 모두 허용하거나, id까지 포함한 순위를 정해야 합니다.

서브쿼리 모양을 맞춘 뒤에도 결과 한 행의 유일성을 별도로 판단합니다.

평균이 NULL이면 비교는 알 수 없음이 되어 행이 나오지 않으므로 게시된 글이 0건인 화면의 빈 상태도 정의합니다.


내부 쿼리 단독 확인

긴 SQL을 한 번에 디버깅하지 않고 각 서브쿼리를 독립 실행해 열 이름, 행 수, NULL 포함 여부를 확인합니다.

아래 진단은 규칙별 실제 행 수를 한 표로 만듭니다.

서브쿼리 결과 cardinality 확인
WITH
average_value AS (
  SELECT AVG(view_count) AS value
  FROM ch6_subquery_post_demo
  WHERE status = 'PUBLISHED'
),
active_tag_ids AS (
  SELECT id AS tag_id FROM ch6_subquery_tag_demo WHERE is_active = TRUE
),
first_pairs AS (
  SELECT author_id, MIN(created_at) AS created_at
  FROM ch6_subquery_post_demo
  GROUP BY author_id
)
SELECT 'scalar average' AS contract, COUNT(*) AS rows_found, 1 AS columns_found
FROM average_value
UNION ALL
SELECT 'active id list', COUNT(*), 1 FROM active_tag_ids
UNION ALL
SELECT 'members-time rows', COUNT(*), 2 FROM first_pairs;

평균은 입력이 없어도 행 1개·열 1개이며 값은 NULL일 수 있습니다.

활성 태그와 회원 쌍은 데이터에 따라 0개 이상입니다.

예상과 다르면 바깥 쿼리 전에 안쪽의 WHERE와 GROUP BY부터 고칩니다.

IN= ANY는 동등 비교에서 비슷하지만 ANY·ALL은 부등호와 결합할 수 있습니다.

view_count > ALL (...)은 모든 값보다 커야 하고 > ANY (...)는 하나보다만 커도 됩니다.

실제 의도가 최솟값·최댓값과 비교라면 MIN·MAX가 더 읽기 쉬운지 검토합니다.

연산자 이름보다 자연어 양화사가 명확한 표현을 선택합니다.

다중 컬럼 비교에서는 어느 한 열에 NULL이 있으면 TRUE가 아닌 알 수 없음이 될 수 있으므로 후보 키처럼 NOT NULL인 조합이 안전합니다.

각 안쪽 SELECT를 먼저 실행해 결과를 종이에 1×1, N×1, N×2로 적습니다.

게시된 글을 0건, 1건, 2건으로 바꾸며 나쁜 쿼리가 언제 우연히 성공하는지 관찰하세요.

최초 시각 동률 두 건을 넣어 다중 컬럼 IN이 둘 다 반환하는지 확인하고, 정확히 하나가 필요하면 ROW_NUMBER() 또는 추가 키가 필요한 이유를 설명합니다.

IN 목록에 NULL을 한 건 넣은 경우도 다음 문서의 NOT IN 문제와 연결해 기록합니다.

애플리케이션이 서브쿼리의 기준 시각과 바깥 조회를 두 번의 별도 요청으로 실행하면 그 사이 데이터가 바뀔 수 있습니다.

한 SQL 구문 안의 서브쿼리는 MySQL의 구문 스냅샷 규칙 아래 같은 읽기 시점을 공유하지만 트랜잭션 격리와 잠금 요구는 별도입니다.

대량 목록을 애플리케이션으로 가져와 IN 매개변수로 다시 보내는 방식은 네트워크·파라미터 크기·시점 차이를 늘립니다.

DB 안에서 표현 가능한 관계는 한 구문으로 두고 실행 계획을 측정합니다.

스칼라 집계가 NULL일 때 COALESCE로 0을 넣는 것이 항상 맞지는 않습니다.

게시된 글이 없음과 평균 조회수 0회는 다른 상태입니다.

문자열·숫자 타입을 서브쿼리 비교에서 암묵 변환하면 인덱스와 결과가 달라질 수 있으므로 FK와 후보 키 타입을 동일하게 유지합니다.

동일 최소 시각처럼 동률이 가능한 질문에는 ‘모두 반환’과 ‘하나 선택’을 명시합니다.

서브쿼리에 ORDER BY만 넣어도 LIMIT이 없으면 집합의 의미가 바뀌지 않으며, 임의 한 행을 고르기 위해 ORDER BY 없이 LIMIT을 사용하지 않습니다.

이번 문서의 결과 모양 표기는 다음 EXISTS 문서와 실행 계획 장의 공통 준비입니다.

EXISTS는 안쪽 열 값이 아니라 행 존재 여부만 소비하므로 SELECT 목록을 1로 적어 의도를 드러냅니다.

인덱스 장에서는 같은 SQL이 구체화, 세미-조인, 종속 서브쿼리 중 어떤 물리 전략으로 바뀌는지 확인하되, 먼저 여기서 정의한 결과 규칙이 유지되는지 검증합니다.

회원 게시판의 활성 태그·최초 게시글 쿼리는 뒤의 뷰와 통계에서도 재사용되므로 동률·NULL·빈 집합 기준을 쿼리 옆에 남깁니다.


서브쿼리 연산자 선택

판단 축확인할 질문
질문안쪽 결과를 자연어로 한 값·목록·행 집합 중 무엇이라 말하는가?
행 수집계·유일 조건이 스칼라 한 행을 실제로 보장하는가?
열 수바깥 행 생성자와 안쪽 열 개수·순서·타입이 같은가?
빈 입력0행 또는 NULL 한 값일 때 최종 결과를 정의했는가?
동률최솟값·최댓값에 여러 행이 연결될 때 모두 반환할지 정했는가?

괄호 안 SQL을 가린 채 바깥 연산자가 기대하는 모양부터 적고, 이어서 안쪽 SELECT만 실행해 실제 모양과 대조합니다.

둘이 다르면 LIMIT으로 누르지 말고 질문이나 연산자를 수정합니다.


연습 문제

게시된 글의 전체 평균보다 조회수가 높고, 현재 활성 태그가 붙은 게시글만 조회하세요.

id, title, view_count를 보여 주고 조회수와 큰 ID 순으로 정렬하세요.

게시된 글이 없을 때 임의로 평균을 0으로 바꾸지 마세요.

해설과 예시 답안

평균은 한 값이므로 >, 활성 태그가 연결된 게시글 ID는 목록이므로 IN을 사용합니다.

바깥에서도 게시 상태를 제한해 평균 모집단과 결과 모집단의 뜻을 맞춥니다.

SELECT id, title, view_count
FROM ch6_subquery_post_demo
WHERE status = 'PUBLISHED'
  AND view_count > (
    SELECT AVG(view_count)
    FROM ch6_subquery_post_demo
    WHERE status = 'PUBLISHED'
  )
  AND id IN (
    SELECT pt.post_id
    FROM ch6_subquery_post_tag_demo AS pt
    JOIN ch6_subquery_tag_demo AS t ON t.id = pt.tag_id
    WHERE t.is_active = TRUE
  )
ORDER BY view_count DESC, id DESC;

평균 서브쿼리가 1×1인지, 활성 태그의 게시글 ID 서브쿼리가 N×1인지 먼저 확인합니다.

게시된 글 0건에서는 평균이 NULL이고 최종 결과도 0행이어야 합니다.


핵심 정리

  • 서브쿼리는 문법 위치보다 반환 행 수와 열 수를 먼저 봅니다.
  • 한 값은 단일 비교, 한 열 목록은 IN·ANY·ALL, 여러 열은 같은 모양의 행 비교를 사용합니다.
  • LIMIT으로 카디널리티 오류를 숨기지 않고 유일성과 동률 정책을 정의합니다.
  • 빈 집합과 NULL 한 값의 차이를 실제 데이터로 검증합니다.

다음 문서에서는 바깥 행마다 관계 존재를 묻는 상관 서브쿼리와 EXISTS를 다룹니다.