SELECT: 관계형 데이터에서 필요한 행과 열을 조회하는 질의
SELECT는 테이블에서 조건에 맞는 행을 찾고 필요한 열, 계산식, 집계 결과를 반환하는 SQL 문이다. PostgreSQL은 선언된 질의를 바탕으로 실행 계획(Query Plan)을 만들며, 인덱스·조인·정렬·집계 방식에 따라 성능이 달라진다. 작성 순서와 논리적 처리 순서의 차이를 이해하면 복잡한 질의를 정확하게 구성할 수 있다.
By 송민성
1. 개념
SELECT는 관계형 데이터베이스에서 데이터를 조회하는 데이터 조작어(DML, Data Manipulation Language)다.
기본 형태는 다음과 같다.
SELECT 열_목록
FROM 테이블
WHERE 조건;SELECT의 결과도 행(Row)과 열(Column)로 구성된다. 원본 열뿐 아니라 계산식, 함수 호출, 집계 결과를 열로 반환할 수 있다.
SELECT
name,
price,
price * 0.9 AS discounted_price
FROM products;주요 절의 역할은 다음과 같다.
SELECT: 반환할 열과 표현식 지정FROM: 데이터를 읽을 테이블 지정JOIN: 여러 테이블 결합WHERE: 개별 행 필터링GROUP BY: 행을 그룹으로 묶음HAVING: 집계된 그룹 필터링ORDER BY: 결과 정렬LIMIT,OFFSET: 반환 범위 제한
2. 왜 사용하는가
애플리케이션은 전체 데이터를 가져온 뒤 직접 처리하는 대신 데이터베이스에 필터링, 결합, 정렬, 집계를 요청할 수 있다.
SELECT id, title
FROM posts
WHERE author_id = 42
ORDER BY created_at DESC
LIMIT 20;이 질의는 다음 작업을 데이터베이스에서 수행한다.
- 작성자가 42번인 게시물 검색
- 최신순 정렬
- 최대 20행 반환
- 화면에 필요한
id,title만 반환
필요한 데이터만 조회하면 네트워크 전송량과 애플리케이션 메모리 사용량을 줄일 수 있다. PostgreSQL은 인덱스(Index), 통계 정보(Statistics), 질의 최적화기(Query Optimizer)를 이용해 실행 방법을 선택한다.
3. 동작 원리
작성 순서와 논리적 처리 순서
SELECT 문의 일반적인 작성 순서는 다음과 같다.
SELECT
FROM
JOIN
WHERE
GROUP BY
HAVING
ORDER BY
LIMIT개념적인 논리적 처리 순서는 다음과 같다.
FROM,JOIN: 원본 행 집합 생성WHERE: 조건에 맞지 않는 행 제거GROUP BY: 남은 행을 그룹화HAVING: 조건에 맞지 않는 그룹 제거SELECT: 반환할 열과 표현식 계산DISTINCT: 중복 행 제거ORDER BY: 결과 정렬LIMIT,OFFSET: 반환 범위 제한
이 순서 때문에 SELECT 목록에서 만든 별칭(Alias)을 같은 질의의 WHERE에서 사용할 수 없다.
-- 오류: WHERE 처리 시점에는 total_price 별칭이 존재하지 않는다.
SELECT price * quantity AS total_price
FROM order_items
WHERE total_price >= 10000;표현식을 반복하거나 하위 질의(Subquery)를 사용해야 한다.
SELECT price * quantity AS total_price
FROM order_items
WHERE price * quantity >= 10000;SELECT total_price
FROM (
SELECT price * quantity AS total_price
FROM order_items
) AS calculated_items
WHERE total_price >= 10000;WHERE와 HAVING
WHERE는 그룹화 전 개별 행을 제거한다. HAVING은 그룹화 후 집계 결과를 검사한다.
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING SUM(total_amount) >= 100000;처리 흐름은 다음과 같다.
- 결제 완료 행만 선택
- 고객별로 그룹화
- 고객별 주문 금액 합계 계산
- 합계가 100000 이상인 그룹만 반환
조인
조인(Join)은 관련 열을 기준으로 여러 테이블의 행을 결합한다.
SELECT
customers.name,
orders.id,
orders.total_amount
FROM customers
JOIN orders
ON orders.customer_id = customers.id;INNER JOIN은 양쪽에 대응하는 행이 있을 때만 반환한다. LEFT JOIN은 왼쪽 테이블의 모든 행을 유지하며, 오른쪽에 대응하는 행이 없으면 해당 열에 NULL을 반환한다.
실행 계획
PostgreSQL은 SQL의 작성 순서를 그대로 실행하지 않는다. 구문 분석 후 가능한 실행 방식의 비용을 추정하고 실행 계획을 선택한다.
대표적인 실행 연산은 다음과 같다.
- 순차 스캔(Sequential Scan): 테이블을 처음부터 읽음
- 인덱스 스캔(Index Scan): 인덱스로 행 위치를 찾은 뒤 테이블 접근
- 인덱스 전용 스캔(Index Only Scan): 필요한 값을 인덱스에서 반환
- 중첩 루프 조인(Nested Loop Join): 한쪽 행마다 다른 쪽을 탐색
- 해시 조인(Hash Join): 해시 테이블을 만들어 동등 조건으로 결합
- 병합 조인(Merge Join): 정렬된 두 입력을 순서대로 결합
- 정렬(Sort):
ORDER BY등에 필요한 순서 생성 - 집계(Aggregate):
COUNT,SUM,AVG등의 결과 계산
실행 계획은 EXPLAIN으로 확인한다.
EXPLAIN
SELECT id, total_amount
FROM orders
WHERE customer_id = 1
ORDER BY ordered_at DESC
LIMIT 10;실제 실행 시간과 처리 행 수는 EXPLAIN ANALYZE로 확인한다.
EXPLAIN ANALYZE
SELECT id, total_amount
FROM orders
WHERE customer_id = 1
ORDER BY ordered_at DESC
LIMIT 10;EXPLAIN ANALYZE는 질의를 실제로 실행한다.
4. 코드 예제
다음 SQL은 PostgreSQL에서 순서대로 실행할 수 있다.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL,
total_amount numeric(12, 2) NOT NULL,
ordered_at timestamptz NOT NULL
);
INSERT INTO customers (name)
VALUES
('민수'),
('지수'),
('현우');
INSERT INTO orders (
customer_id,
status,
total_amount,
ordered_at
)
VALUES
(1, 'paid', 70000, '2026-09-01 10:00:00+09'),
(1, 'paid', 50000, '2026-09-03 11:00:00+09'),
(1, 'cancelled', 30000, '2026-09-04 12:00:00+09'),
(2, 'paid', 90000, '2026-09-02 09:00:00+09');
CREATE INDEX orders_customer_ordered_idx
ON orders (customer_id, ordered_at DESC);조건 조회와 정렬
SELECT
id,
total_amount,
ordered_at
FROM orders
WHERE customer_id = 1
AND status = 'paid'
ORDER BY ordered_at DESC
LIMIT 10;조인과 집계
SELECT
customers.id,
customers.name,
COUNT(orders.id) AS paid_order_count,
COALESCE(SUM(orders.total_amount), 0) AS total_spent
FROM customers
LEFT JOIN orders
ON orders.customer_id = customers.id
AND orders.status = 'paid'
GROUP BY customers.id, customers.name
ORDER BY total_spent DESC;orders.status = 'paid' 조건을 ON에 둬야 주문이 없는 고객도 유지된다. 같은 조건을 WHERE에 두면 오른쪽 행이 없는 결과가 제거되어 LEFT JOIN의 효과가 달라진다.
집계 결과 필터링
SELECT
customer_id,
SUM(total_amount) AS total_spent
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING SUM(total_amount) >= 100000
ORDER BY total_spent DESC;커서 기반 페이지네이션
SELECT
id,
customer_id,
total_amount,
ordered_at
FROM orders
WHERE (ordered_at, id) < (
TIMESTAMPTZ '2026-09-03 11:00:00+09',
2
)
ORDER BY ordered_at DESC, id DESC
LIMIT 20;해당 조회를 지원하는 인덱스는 다음과 같다.
CREATE INDEX orders_ordered_id_idx
ON orders (ordered_at DESC, id DESC);ordered_at 값이 같은 행을 안정적으로 구분하기 위해 고유한 id를 함께 사용한다.
5. 시간 복잡도 또는 성능 특성
SELECT의 비용은 문법만으로 하나의 시간복잡도로 표현할 수 없다. 실행 계획, 조건 선택도(Selectivity), 인덱스 구조, 정렬 여부, 조인 입력 크기에 따라 달라진다.
테이블 조회
행 수를 n, 반환 행 수를 k라고 할 때 일반적인 특성은 다음과 같다.
- 순차 스캔: 모든 행을 확인하므로
O(n) - B-tree 인덱스의 단일 값 탐색: 탐색 비용은 일반적으로
O(log n), 결과 접근까지 포함하면O(log n + k) - 인덱스 전용 스캔: 테이블 접근을 줄일 수 있지만 가시성 맵(Visibility Map) 상태에 영향을 받음
인덱스가 있어도 조건에 맞는 행이 테이블 대부분이라면 순차 스캔이 더 저렴할 수 있다.
정렬
일반적인 비교 기반 정렬 비용은 O(n log n)이다. ORDER BY 순서와 일치하는 인덱스를 사용할 수 있으면 별도 정렬을 생략할 수 있다.
CREATE INDEX orders_customer_ordered_idx
ON orders (customer_id, ordered_at DESC);다음 질의는 인덱스 선두 열인 customer_id를 동등 조건으로 제한하고 ordered_at DESC로 정렬한다.
SELECT id, total_amount
FROM orders
WHERE customer_id = 1
ORDER BY ordered_at DESC
LIMIT 20;조인
조인 알고리즘별 대표적인 특성은 다음과 같다.
- 중첩 루프 조인: 단순 형태는
O(n × m), 내부 입력에 적절한 인덱스가 있으면 비용 감소 - 해시 조인: 해시 생성과 탐색의 평균 비용은
O(n + m) - 병합 조인: 입력이 이미 정렬됐다면 병합은
O(n + m), 정렬이 필요하면 정렬 비용 추가
PostgreSQL은 통계 정보를 이용해 예상 행 수와 비용을 계산한 뒤 조인 방식을 선택한다.
LIMIT와 OFFSET
LIMIT은 필요한 행을 찾는 즉시 실행을 멈출 수 있어 비용을 줄일 수 있다. 단, 먼저 전체 정렬이나 집계가 필요하면 입력 처리 비용이 그대로 발생할 수 있다.
큰 OFFSET은 건너뛸 행도 읽어야 한다.
SELECT id, ordered_at
FROM orders
ORDER BY ordered_at DESC
LIMIT 20 OFFSET 100000;깊은 페이지에서는 커서 기반 페이지네이션(Keyset Pagination)이 더 안정적인 성능을 제공한다.
6. 실무 사용 사례
목록 API
SELECT
id,
title,
created_at
FROM posts
WHERE author_id = $1
AND created_at < $2
ORDER BY created_at DESC, id DESC
LIMIT $3;필요한 열만 반환하고 커서 조건으로 다음 페이지를 조회한다.
통계 대시보드
SELECT
date_trunc('day', ordered_at) AS order_day,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid'
AND ordered_at >= $1
AND ordered_at < $2
GROUP BY order_day
ORDER BY order_day;시간 범위를 반열린 구간(Half-open Interval)으로 표현하면 날짜 경계의 중복과 누락을 줄일 수 있다.
존재 여부 확인
행 데이터가 필요하지 않다면 EXISTS를 사용한다.
SELECT EXISTS (
SELECT 1
FROM orders
WHERE customer_id = $1
AND status = 'paid'
);EXISTS는 조건에 맞는 행을 찾으면 결과를 확정할 수 있다.
조건부 집계
SELECT
customer_id,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_count,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_count,
SUM(total_amount) FILTER (WHERE status = 'paid') AS paid_amount
FROM orders
GROUP BY customer_id;한 번의 그룹화로 여러 조건의 통계를 계산한다.
7. 주의할 점
SELECT * 남용
SELECT *
FROM orders;불필요한 열까지 전송하며 스키마 변경에 따라 결과 구조가 달라질 수 있다. 필요한 열을 명시한다.
SELECT id, status, total_amount
FROM orders;NULL 비교
NULL은 알 수 없는 값이므로 = 또는 <>로 비교하지 않는다.
SELECT id
FROM orders
WHERE cancelled_at IS NULL;SELECT id
FROM orders
WHERE cancelled_at IS NOT NULL;WHERE는 조건 결과가 TRUE인 행만 유지한다. 결과가 FALSE나 UNKNOWN인 행은 제거된다.
LEFT JOIN 조건 위치
다음 질의는 주문이 없는 고객을 제거한다.
SELECT customers.name, orders.id
FROM customers
LEFT JOIN orders
ON orders.customer_id = customers.id
WHERE orders.status = 'paid';주문이 없는 고객도 유지하려면 오른쪽 테이블 조건을 ON에 둔다.
SELECT customers.name, orders.id
FROM customers
LEFT JOIN orders
ON orders.customer_id = customers.id
AND orders.status = 'paid';결과 순서
ORDER BY가 없으면 결과 순서는 보장되지 않는다. 현재 실행에서 같은 순서가 나와도 실행 계획, 병렬 처리, 데이터 배치가 바뀌면 달라질 수 있다.
페이지네이션에는 고유한 열을 정렬 조건에 포함한다.
SELECT id, ordered_at
FROM orders
ORDER BY ordered_at DESC, id DESC;함수가 적용된 인덱스 열
다음 조건은 일반적인 ordered_at 인덱스를 효율적으로 사용하지 못할 수 있다.
SELECT id
FROM orders
WHERE ordered_at::date = DATE '2026-09-01';범위 조건으로 작성한다.
SELECT id
FROM orders
WHERE ordered_at >= TIMESTAMPTZ '2026-09-01 00:00:00+09'
AND ordered_at < TIMESTAMPTZ '2026-09-02 00:00:00+09';DISTINCT로 조인 오류 숨기기
SELECT DISTINCT customers.id, customers.name
FROM customers
JOIN orders
ON orders.customer_id = customers.id;중복이 조인 관계에서 정상적으로 발생했는지, 잘못된 조인 조건 때문인지 먼저 확인해야 한다. 존재 여부만 필요하다면 EXISTS가 의도를 더 정확하게 표현한다.
SELECT customers.id, customers.name
FROM customers
WHERE EXISTS (
SELECT 1
FROM orders
WHERE orders.customer_id = customers.id
);EXPLAIN ANALYZE의 변경 질의 실행
EXPLAIN ANALYZE는 대상 SQL을 실제 실행한다. UPDATE, DELETE, INSERT를 분석할 때는 트랜잭션(Transaction)과 롤백(Rollback)을 사용한다.
BEGIN;
EXPLAIN ANALYZE
UPDATE orders
SET status = 'archived'
WHERE ordered_at < TIMESTAMPTZ '2025-01-01 00:00:00+09';
ROLLBACK;8. 핵심 정리
- SELECT는 필요한 행, 열, 계산식, 집계 결과를 반환한다.
- 논리적 처리 순서는
FROM부터 시작하며 SELECT 목록의 별칭은 일반적으로WHERE에서 사용할 수 없다. WHERE는 행을 필터링하고HAVING은 그룹을 필터링한다.- 성능은 실행 계획, 인덱스, 조건 선택도, 정렬, 조인 방식에 따라 달라진다.
- 실무에서는 필요한 열 명시, 안정적인 정렬, 커서 기반 페이지네이션,
EXPLAIN ANALYZE를 통한 실제 계획 검증이 중요하다.