SQLite에서 SELECT 안에 SELECT를 중첩하는 방법을 정리했습니다. 스칼라 서브쿼리, IN/EXISTS, 인라인 뷰, 상관 서브쿼리, 그리고 JOIN이 더 나은 경우까지 한 번에 살펴봅니다.
▶이 페이지에는 실행 가능한 에디터가 있습니다 — 편집하고 실행하면 결과를 바로 볼 수 있습니다.
May 3, 2026 게시됨
서브쿼리란? SELECT 안에 들어간 또 다른 SELECT
SQLite 서브쿼리는 말 그대로 SELECT 문 안에 또 다른 SELECT 문을 괄호로 감싸 넣은 것입니다. SQLite는 안쪽 쿼리를 먼저 실행한 뒤, 그 결과를 바깥 쿼리에 넘겨서 사용합니다.
앞으로 계속 써먹을 예제 데이터를 먼저 준비해 두겠습니다:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT * FROM orders;
주문 5건, 고객 4명, 그리고 그중 2명은 아직 아무것도 주문하지 않은 상태입니다. 이 데이터를 앞으로 계속 사용할 거예요.
WHERE 절 서브쿼리: 목록으로 필터링하기
가장 흔하게 쓰는 형태입니다. 안쪽 쿼리에서 id 목록을 뽑아낸 다음, 바깥쪽 쿼리에서 그 목록을 기준으로 걸러내는 방식이죠.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name
FROM customers
WHERE id IN (SELECT customer_id FROM orders);
안쪽 쿼리는 orders에 나타나는 모든 customer_id를 뽑아냅니다. 그리고 바깥쪽 쿼리는 그 목록에 id가 포함된 고객만 남기죠. 결과적으로 Cleo, Boris, Ada는 나오지만, 주문이 하나도 없는 Dmitri는 빠집니다.
IN (SELECT ...) 형태는 "B에 매칭되는 행이 있는 A의 행"을 뽑을 때 가장 많이 쓰는 SQLite 서브쿼리 패턴입니다. 머릿속에서는 "이 컬럼의 값이 안쪽 쿼리가 돌려준 값들 중 하나일 때"라고 읽으면 됩니다.
NOT IN: NULL을 조심하세요
반대 질문, 즉 "주문한 적이 없는 고객은 누구인가?"는 한 줄만 바꾸면 됩니다.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name
FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
여기서는 잘 동작합니다. 그런데 NOT IN에는 함정이 하나 있어요. 서브쿼리 결과에 NULL이 단 하나라도 섞이면 NOT IN 전체가 NULL로 평가되고(즉 TRUE가 아니죠), 결과로 아무 행도 돌아오지 않습니다. 에러도 안 나고 조용히 빈 결과가 나오니 당황하기 딱 좋죠.
NULL이 들어 있을 수 있는 컬럼에 NOT IN을 쓸 때는 다음 습관을 들이는 게 안전합니다:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name
FROM customers
WHERE id NOT IN (
SELECT customer_id FROM orders WHERE customer_id IS NOT NULL
);
아니면 NOT EXISTS를 쓰면 이런 문제 자체가 없어요. 이건 뒤에서 다룹니다.
스칼라 서브쿼리: 한 행, 한 컬럼
스칼라 서브쿼리는 단일 값(한 행, 한 컬럼)을 반환하기 때문에, 값이 들어갈 수 있는 자리라면 어디든 쓸 수 있습니다.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name, total
FROM orders
JOIN customers ON customers.id = orders.customer_id
WHERE total = (SELECT MAX(total) FROM orders);
안쪽의 SELECT MAX(total) FROM orders는 200을 반환합니다. 그러면 바깥쪽 쿼리가 이 값과 일치하는 주문만 골라내죠. 집계값과 비교해야 할 때 두루 쓸 수 있는 방식입니다.
스칼라 서브쿼리는 SELECT 절에 넣어서 모든 행에 계산된 값을 함께 붙여 줄 수도 있습니다:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT
name,
(SELECT COUNT(*) FROM orders WHERE customer_id = customers.id) AS order_count
FROM customers;
customers의 각 행마다 안쪽 쿼리가 한 번씩 실행되고, 그때마다 customers.id 값이 끼워 넣어집니다. 이게 바로 상관 서브쿼리(correlated subquery) 인데, 자세한 내용은 뒤에서 다시 살펴보겠습니다. 이렇게 "한 행당 숫자 하나"가 필요한 경우라면 보통 LEFT JOIN과 GROUP BY 조합이 성능 면에서 더 낫지만, 스칼라 서브쿼리 형태는 코드 자체가 깔끔하게 잘 읽힌다는 장점이 있죠.
EXISTS: 매칭되는 행이 있는지만 확인하기
EXISTS는 IN보다 한결 조용한 친척쯤 됩니다. 값이 뭔지는 신경 쓰지 않고, 서브쿼리가 아무 행이라도 반환하는지만 따져보거든요. 그래서 안쪽에는 보통 SELECT 1을 적습니다 — 어차피 컬럼이 뭐가 됐든 의미가 없으니까요.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
AND o.total > 100
);
이 쿼리는 100을 초과하는 주문을 한 번이라도 넣은 고객을 찾아냅니다. 안쪽 쿼리에서 바깥쪽 쿼리의 c.id를 참조하고 있죠. 바로 이 부분 때문에 상관 서브쿼리(correlated subquery)가 됩니다. SQLite는 일치하는 행을 하나라도 찾으면 그 즉시 안쪽 테이블 스캔을 멈추기 때문에, "이 행과 연결된 행이 존재하는가?"를 묻는 상황에서는 EXISTS가 IN보다 더 빠른 경우가 많습니다.
반대로 "연결된 행이 하나도 없는가?"를 물을 때는 NOT EXISTS를 쓰는 게 NULL 안전한 방법입니다:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
FROM 절에 서브쿼리 쓰기: 파생 테이블 만들기
테이블이 올 수 있는 자리라면 어디든 서브쿼리를 넣을 수 있습니다. FROM 절도 예외는 아닙니다. 안쪽 쿼리에 별칭을 붙이면 임시로 만들어진 "파생 테이블(derived table)"이 되고, 이 테이블을 대상으로 다시 조인하거나 필터링하거나 집계할 수 있습니다.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT country, AVG(customer_total) AS avg_per_customer
FROM (
SELECT c.country, c.id, SUM(o.total) AS customer_total
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id
) AS per_customer
GROUP BY country;
안쪽 쿼리는 고객별 총합을 계산하고, 바깥 쿼리는 그 총합을 국가별로 평균 낸다. 이렇게 두 단계로 집계해야 하는 상황이야말로 파생 테이블(derived table)이 필요한 전형적인 케이스다 — 한 번의 GROUP BY만으로는 처리할 수 없을 때 쓰는 방법이다.
이때 AS per_customer 별칭은 필수다. 모든 파생 테이블에는 반드시 이름을 붙여야 한다.
상관 서브쿼리: 바깥 행마다 실행되는 SQLite 서브쿼리
서브쿼리가 바깥 쿼리의 컬럼을 참조하면 상관 서브쿼리(correlated subquery) 가 된다. 이 경우 SQLite는 바깥 쿼리의 행 하나하나마다 안쪽 쿼리를 다시 실행해야 한다. 유연한 만큼 비용이 꽤 커질 수 있다.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT name,
(SELECT MAX(total)
FROM orders
WHERE customer_id = customers.id) AS biggest_order
FROM customers;
각 고객별로 가장 큰 주문 금액을 찾는 예제입니다. 안쪽 쿼리가 customers.id에 의존하기 때문에 고객 한 명마다 한 번씩 실행됩니다. 주문 이력이 없는 고객은 NULL이 나오는데, 보통은 이게 원하는 동작이죠.
상관 서브쿼리(correlated subquery)는 "A 테이블의 각 행마다 B에서 뭔가를 계산해야 할 때" 가장 자연스러운 선택입니다. 테이블이 작거나 조회 컬럼에 인덱스가 잘 걸려 있다면 충분히 쓸 만합니다. 다만 인덱스가 없는 큰 테이블에서는 배포 전에 꼭 성능을 측정해 보세요. JOIN + GROUP BY 조합이 더 빠를 때가 많습니다.
서브쿼리 vs JOIN, 언제 무엇을 쓸까
아래 두 쿼리는 같은 질문에 대한 답을 구합니다:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
-- 서브쿼리
SELECT name
FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE total > 100);
-- JOIN
SELECT DISTINCT c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.total > 100;
두 쿼리는 똑같은 결과를 돌려줍니다. SQLite 옵티마이저는 내부적으로 한 형태를 다른 형태로 자주 다시 써넣기 때문에, 선택 기준은 결국 가독성 입니다.
단순히 행을 걸러내기만 하면 되고, 안쪽 테이블의 컬럼이 결과에 섞여 들어오는 게 싫다면 서브쿼리를 쓰세요.
두 테이블의 컬럼이 결과에 모두 필요하다면 JOIN을 쓰세요.
"관련된 행이 하나라도 있나?"를 묻는 거라면 EXISTS가 가장 명확합니다. IN/NOT IN이 가진 NULL 함정도 피할 수 있고요.
판단이 애매할 땐, 소리 내어 읽었을 때 의미가 그대로 전해지는 쪽을 고르면 됩니다.
자주 빠지는 함정: 여러 행을 돌려주는 서브쿼리
=와 함께 쓰는 서브쿼리는 행을 최대 하나만 반환해야 합니다. 그 이상이 나오면 SQLite는 그중 하나를 (사실상 무작위로) 골라버리고, 에러도 없이 조용히 잘못된 결과를 내놓습니다.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
-- 위험: UK 출신 고객이 한 명보다 많다면 어떻게 될까요?
SELECT * FROM orders
WHERE customer_id = (SELECT id FROM customers WHERE country = 'UK');
안쪽 쿼리가 여러 행을 반환할 수 있다면 IN을 사용하세요:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT,
country TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
total REAL
);
INSERT INTO customers (id, name, country) VALUES
(1, 'Ada', 'UK'),
(2, 'Boris', 'FR'),
(3, 'Cleo', 'UK'),
(4, 'Dmitri','DE');
INSERT INTO orders (customer_id, total) VALUES
(1, 120), (1, 80), (2, 50), (3, 200), (3, 30);
SELECT * FROM orders
WHERE customer_id IN (SELECT id FROM customers WHERE country = 'UK');
정확히 한 행만 나와야 한다면, 최소한 결과가 결정적이도록 LIMIT 1과 ORDER BY를 함께 넣어 주세요. 더 좋은 방법은 데이터 자체로 한 행이 보장되도록 쿼리를 작성하는 것입니다(고유 컬럼으로 필터링).
다음 주제: 공통 테이블 표현식(CTE)
FROM 절 서브쿼리는 금세 다루기 까다로워집니다. 같은 파생 테이블을 두 번 써야 하거나, 중첩이 세 단계까지 깊어질 때가 특히 그렇죠. 공통 테이블 표현식(WITH ... AS (...))을 쓰면 서브쿼리에 미리 이름을 붙여 두고, 쿼리 나머지 부분에서 그 이름으로 참조할 수 있습니다. 다음 페이지에서 자세히 살펴볼게요.
자주 묻는 질문
SQLite에서 서브쿼리란 무엇인가요?
서브쿼리는 다른 SQL 문 안에 괄호로 감싸 넣은 SELECT 문을 말합니다. SQLite는 안쪽 쿼리를 먼저 실행하고 그 결과를 바깥 쿼리에 넘겨줍니다. WHERE, FROM, SELECT 절은 물론 그 외 여러 위치에서도 쓸 수 있습니다.
SQLite의 IN과 EXISTS는 어떤 차이가 있나요?
IN (SELECT ...)은 어떤 값이 서브쿼리가 반환한 행들 중 하나와 일치하는지 확인합니다. 반면 EXISTS (SELECT ...)는 값이 무엇이든 상관없이 서브쿼리가 행을 하나라도 반환하는지만 봅니다. 안쪽 쿼리가 바깥쪽 행을 참조하는 상관 서브쿼리에서는 보통 EXISTS 쪽이 더 자연스럽고 효율적입니다.
서브쿼리와 JOIN 중 어느 쪽을 써야 하나요?
결과에 두 테이블의 컬럼이 모두 필요하면 JOIN이 맞습니다. 단순히 필터링하거나 값 하나만 계산하면 되는 상황이라면 서브쿼리가 깔끔합니다. SQLite 옵티마이저가 내부적으로 두 형태를 서로 변환하는 경우도 많기 때문에, 결국 더 읽기 쉬운 쪽을 고르는 게 정답입니다.
상관 서브쿼리(correlated subquery)란 무엇인가요?
상관 서브쿼리는 바깥쪽 쿼리의 컬럼을 참조하는 서브쿼리로, 바깥 쿼리의 모든 행마다 다시 평가됩니다. 표현력이 좋은 대신 데이터가 많으면 성능이 떨어질 수 있죠. 만약 상관 서브쿼리가 병목 구간으로 잡힌다면 JOIN이나 CTE로 바꿔보면 대부분 해결됩니다.