본문 바로가기
TYLER SONGBlog
← Blog index
CS9 min read

SELECT: 관계형 데이터에서 필요한 행과 열을 조회하는 질의

SELECT는 테이블에서 조건에 맞는 행을 찾고 필요한 열, 계산식, 집계 결과를 반환하는 SQL 문이다. PostgreSQL은 선언된 질의를 바탕으로 실행 계획(Query Plan)을 만들며, 인덱스·조인·정렬·집계 방식에 따라 성능이 달라진다. 작성 순서와 논리적 처리 순서의 차이를 이해하면 복잡한 질의를 정확하게 구성할 수 있다.

By 송민성

1. 개념

SELECT는 관계형 데이터베이스에서 데이터를 조회하는 데이터 조작어(DML, Data Manipulation Language)다.

기본 형태는 다음과 같다.

sql
SELECT 열_목록 FROM 테이블 WHERE 조건;

SELECT의 결과도 행(Row)과 열(Column)로 구성된다. 원본 열뿐 아니라 계산식, 함수 호출, 집계 결과를 열로 반환할 수 있다.

sql
SELECT name, price, price * 0.9 AS discounted_price FROM products;

주요 절의 역할은 다음과 같다.

  • SELECT: 반환할 열과 표현식 지정
  • FROM: 데이터를 읽을 테이블 지정
  • JOIN: 여러 테이블 결합
  • WHERE: 개별 행 필터링
  • GROUP BY: 행을 그룹으로 묶음
  • HAVING: 집계된 그룹 필터링
  • ORDER BY: 결과 정렬
  • LIMIT, OFFSET: 반환 범위 제한

2. 왜 사용하는가

애플리케이션은 전체 데이터를 가져온 뒤 직접 처리하는 대신 데이터베이스에 필터링, 결합, 정렬, 집계를 요청할 수 있다.

sql
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 문의 일반적인 작성 순서는 다음과 같다.

sql
SELECT FROM JOIN WHERE GROUP BY HAVING ORDER BY LIMIT

개념적인 논리적 처리 순서는 다음과 같다.

  1. FROM, JOIN: 원본 행 집합 생성
  2. WHERE: 조건에 맞지 않는 행 제거
  3. GROUP BY: 남은 행을 그룹화
  4. HAVING: 조건에 맞지 않는 그룹 제거
  5. SELECT: 반환할 열과 표현식 계산
  6. DISTINCT: 중복 행 제거
  7. ORDER BY: 결과 정렬
  8. LIMIT, OFFSET: 반환 범위 제한

이 순서 때문에 SELECT 목록에서 만든 별칭(Alias)을 같은 질의의 WHERE에서 사용할 수 없다.

sql
-- 오류: WHERE 처리 시점에는 total_price 별칭이 존재하지 않는다. SELECT price * quantity AS total_price FROM order_items WHERE total_price >= 10000;

표현식을 반복하거나 하위 질의(Subquery)를 사용해야 한다.

sql
SELECT price * quantity AS total_price FROM order_items WHERE price * quantity >= 10000;
sql
SELECT total_price FROM ( SELECT price * quantity AS total_price FROM order_items ) AS calculated_items WHERE total_price >= 10000;

WHERE와 HAVING

WHERE는 그룹화 전 개별 행을 제거한다. HAVING은 그룹화 후 집계 결과를 검사한다.

sql
SELECT customer_id, SUM(total_amount) AS total_spent FROM orders WHERE status = 'paid' GROUP BY customer_id HAVING SUM(total_amount) >= 100000;

처리 흐름은 다음과 같다.

  1. 결제 완료 행만 선택
  2. 고객별로 그룹화
  3. 고객별 주문 금액 합계 계산
  4. 합계가 100000 이상인 그룹만 반환

조인

조인(Join)은 관련 열을 기준으로 여러 테이블의 행을 결합한다.

sql
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으로 확인한다.

sql
EXPLAIN SELECT id, total_amount FROM orders WHERE customer_id = 1 ORDER BY ordered_at DESC LIMIT 10;

실제 실행 시간과 처리 행 수는 EXPLAIN ANALYZE로 확인한다.

sql
EXPLAIN ANALYZE SELECT id, total_amount FROM orders WHERE customer_id = 1 ORDER BY ordered_at DESC LIMIT 10;

EXPLAIN ANALYZE는 질의를 실제로 실행한다.

4. 코드 예제

다음 SQL은 PostgreSQL에서 순서대로 실행할 수 있다.

sql
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);

조건 조회와 정렬

sql
SELECT id, total_amount, ordered_at FROM orders WHERE customer_id = 1 AND status = 'paid' ORDER BY ordered_at DESC LIMIT 10;

조인과 집계

sql
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의 효과가 달라진다.

집계 결과 필터링

sql
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;

커서 기반 페이지네이션

sql
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;

해당 조회를 지원하는 인덱스는 다음과 같다.

sql
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 순서와 일치하는 인덱스를 사용할 수 있으면 별도 정렬을 생략할 수 있다.

sql
CREATE INDEX orders_customer_ordered_idx ON orders (customer_id, ordered_at DESC);

다음 질의는 인덱스 선두 열인 customer_id를 동등 조건으로 제한하고 ordered_at DESC로 정렬한다.

sql
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은 건너뛸 행도 읽어야 한다.

sql
SELECT id, ordered_at FROM orders ORDER BY ordered_at DESC LIMIT 20 OFFSET 100000;

깊은 페이지에서는 커서 기반 페이지네이션(Keyset Pagination)이 더 안정적인 성능을 제공한다.

6. 실무 사용 사례

목록 API

sql
SELECT id, title, created_at FROM posts WHERE author_id = $1 AND created_at < $2 ORDER BY created_at DESC, id DESC LIMIT $3;

필요한 열만 반환하고 커서 조건으로 다음 페이지를 조회한다.

통계 대시보드

sql
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를 사용한다.

sql
SELECT EXISTS ( SELECT 1 FROM orders WHERE customer_id = $1 AND status = 'paid' );

EXISTS는 조건에 맞는 행을 찾으면 결과를 확정할 수 있다.

조건부 집계

sql
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 * 남용

sql
SELECT * FROM orders;

불필요한 열까지 전송하며 스키마 변경에 따라 결과 구조가 달라질 수 있다. 필요한 열을 명시한다.

sql
SELECT id, status, total_amount FROM orders;

NULL 비교

NULL은 알 수 없는 값이므로 = 또는 <>로 비교하지 않는다.

sql
SELECT id FROM orders WHERE cancelled_at IS NULL;
sql
SELECT id FROM orders WHERE cancelled_at IS NOT NULL;

WHERE는 조건 결과가 TRUE인 행만 유지한다. 결과가 FALSE나 UNKNOWN인 행은 제거된다.

LEFT JOIN 조건 위치

다음 질의는 주문이 없는 고객을 제거한다.

sql
SELECT customers.name, orders.id FROM customers LEFT JOIN orders ON orders.customer_id = customers.id WHERE orders.status = 'paid';

주문이 없는 고객도 유지하려면 오른쪽 테이블 조건을 ON에 둔다.

sql
SELECT customers.name, orders.id FROM customers LEFT JOIN orders ON orders.customer_id = customers.id AND orders.status = 'paid';

결과 순서

ORDER BY가 없으면 결과 순서는 보장되지 않는다. 현재 실행에서 같은 순서가 나와도 실행 계획, 병렬 처리, 데이터 배치가 바뀌면 달라질 수 있다.

페이지네이션에는 고유한 열을 정렬 조건에 포함한다.

sql
SELECT id, ordered_at FROM orders ORDER BY ordered_at DESC, id DESC;

함수가 적용된 인덱스 열

다음 조건은 일반적인 ordered_at 인덱스를 효율적으로 사용하지 못할 수 있다.

sql
SELECT id FROM orders WHERE ordered_at::date = DATE '2026-09-01';

범위 조건으로 작성한다.

sql
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로 조인 오류 숨기기

sql
SELECT DISTINCT customers.id, customers.name FROM customers JOIN orders ON orders.customer_id = customers.id;

중복이 조인 관계에서 정상적으로 발생했는지, 잘못된 조인 조건 때문인지 먼저 확인해야 한다. 존재 여부만 필요하다면 EXISTS가 의도를 더 정확하게 표현한다.

sql
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)을 사용한다.

sql
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를 통한 실제 계획 검증이 중요하다.