PostgreSQL

SQL - 개발자 위한 SELECT 완전 정복

중년개발자
중년개발자

@loxo

약 1개월 전

46

SQL - 개발자를 위한 SELECT 완전 정복

이 문서는 SQL을 처음 접하는 개발자를 대상으로 하되, 단순 문법 소개를 넘어서 개발자 관점의 성능 사고방식까지 자연스럽게 연결하는 것을 목표로 합니다.


1. SQL이란?

SQL(Structured Query Language)은 관계형 데이터베이스(RDBMS) 에 저장된 데이터를 조회, 추가, 수정, 삭제하기 위해 사용하는 표준 질의 언어입니다.

핵심 특징:

  • 선언형 언어(Declarative Language)
    • 어떻게 가져올지가 아니라 무엇을 가져올지를 기술
  • 데이터 중심 언어
    • 로직보다 데이터 구조와 관계가 중요
  • 대부분의 RDBMS에서 공통 사용
    • PostgreSQL, MySQL, Oracle, SQL Server 등

2. SELECT 문의 기본 구조와 예약어 순서

SQL에서 SELECT 문은 정해진 해석 순서를 가집니다.

sql
SELECT FROM WHERE GROUP BY HAVING ORDER BY LIMIT

⚠️ 중요: 작성 순서와 DB 내부 실행(논리적 처리) 순서는 다릅니다.

논리적 실행 순서 (성능 관점에서 매우 중요)

sql
1. FROM 2. WHERE 3. GROUP BY 4. HAVING 5. SELECT 6. ORDER BY 7. LIMIT

이 순서를 이해하지 못하면:

  • 인덱스가 있어도 안 타는 쿼리
  • 불필요한 데이터 스캔
  • GROUP BY 후 필터링 같은 비효율

이 발생합니다.


3. WHERE 절 설명 (성능의 출발점)

WHERE데이터를 걸러내는 가장 중요한 단계입니다.

sql
SELECT * FROM orders WHERE status = 'PAID' AND created_at >= '2025-01-01';

성능 관점 핵심 포인트

  • WHERE 절은 인덱스 사용 여부를 결정
  • 함수로 컬럼을 감싸면 인덱스가 깨질 수 있음

❌ 나쁜 예

sql
WHERE DATE(created_at) = '2025-01-01'

✅ 좋은 예

sql
WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02'

4. GROUP BY 설명 (집계의 본질)

GROUP BY행(row)을 묶어 새로운 의미의 데이터로 재구성합니다.

sql
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;

주의 사항

  • SELECT 절에 있는 컬럼은
    • GROUP BY에 포함되거나
    • 집계 함수(COUNT, SUM 등)여야 함

성능 관점

  • GROUP BY는 정렬 또는 해시 연산을 동반
  • 데이터 양이 크면 매우 비쌈
  • WHERE로 최대한 줄인 뒤 GROUP BY 수행

5. ORDER BY 설명 (가장 마지막에 실행됨)

ORDER BY논리적 실행 순서상 가장 마지막 단계에서 수행됩니다.

sql
SELECT * FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 10;

핵심 강조

  • LIMIT정렬 비용을 줄여주지 않음
  • 정렬은 이미 끝난 뒤 LIMIT가 적용됨
  • 따라서 성능의 핵심은 항상 ORDER BY

❗ 실행의 마지막은 LIMIT가 아니라 ORDER BY 입니다.


ORDER BY가 인덱스를 타는 조건

ORDER BY가 빠르려면 정렬 자체를 하지 않아야 합니다. 즉, 이미 정렬된 상태로 데이터를 읽어야 합니다.

인덱스를 탈 수 있는 조건
  1. ORDER BY 컬럼과 인덱스 컬럼 순서가 동일
  2. 정렬 방향(ASC / DESC)이 동일
  3. WHERE 조건이 인덱스의 선행 컬럼을 침범하지 않음
sql
-- 인덱스 CREATE INDEX idx_orders_created_desc ON orders (created_at DESC); -- 인덱스 정렬 그대로 사용 SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;
복합 인덱스 예시
sql
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC); SELECT * FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 10;
  • WHERE → status 필터링
  • ORDER BY → created_at 정렬
  • 추가 정렬 없이 인덱스 스캔만 발생

BTREE 인덱스는 ASC / DESC 방향 자체 때문에 못 타는 것은 아닙니다. PostgreSQL의 BTREE 인덱스는 양방향 탐색이 가능합니다.

즉 아래는 문제 없음입니다.

sql
-- 인덱스 CREATE INDEX idx_orders_created ON orders (created_at); -- DESC여도 인덱스 사용 가능 SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;

그럼 언제 문제가 되는가?

1️⃣ 복합 인덱스에서 컬럼 "순서"가 다른 경우
sql
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
sql
-- ❌ 인덱스 정렬 효과를 제대로 활용 못함 SELECT * FROM orders ORDER BY created_at;
  • BTREE는 선행 컬럼(status) 기준으로 먼저 정렬됨
  • status 조건이 없으면 created_at 단독 정렬은 보장되지 않음
  • 결과: 인덱스는 스캔하되 추가 Sort 발생 가능

2️⃣ ORDER BY 컬럼 순서가 인덱스 정의와 다른 경우
sql
CREATE INDEX idx_orders_multi ON orders (status, created_at, id);
sql
-- ❌ 컬럼 순서 불일치 ORDER BY created_at, status;
  • BTREE는 왼쪽부터 순차적 정렬 구조
  • 중간 컬럼을 건너뛰면 정렬 보장 불가

3️⃣ 서로 다른 정렬 방향이 섞인 경우 (복합 인덱스)
sql
CREATE INDEX idx_orders_mix ON orders (status ASC, created_at DESC);
sql
-- ❌ 정렬 방향 불일치 ORDER BY status DESC, created_at DESC;
  • PostgreSQL 13+에서는 일부 최적화가 가능하지만
  • 일반적으로는 정렬 재수행 가능성 높음

4️⃣ 표현식 / 함수 기반 ORDER BY
sql
ORDER BY created_at + interval '1 day'
  • 컬럼 그대로가 아니면 BTREE 정렬 정보 사용 불가
  • 함수 인덱스를 별도로 만들지 않는 한 Sort 발생

정리 (정확한 결론)

  • ASC / DESC 방향 차이 때문에 인덱스를 못 타는 것은 아님
  • ✅ BTREE는 양방향 스캔 가능
  • ❗ 진짜 핵심은
    • 복합 인덱스의 컬럼 순서
    • WHERE 조건의 선행 컬럼 사용 여부
    • ORDER BY 컬럼 나열 순서

ORDER BY 성능은 "인덱스가 있느냐"가 아니라 "인덱스의 정렬 의미를 그대로 쓰고 있느냐"의 문제입니다.


6. JOIN 예시 (ORDER BY + 인덱스 관점 포함)

JOIN은 단순히 테이블을 붙이는 문법이 아니라, 결과 집합이 어떻게 만들어지고 정렬되는지까지 함께 고려해야 합니다.


6.1 INNER JOIN

sql
SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id ORDER BY o.created_at DESC LIMIT 10;
  • users ↔ orders 모두에 존재하는 데이터만 반환
ORDER BY 성능 관점

이 쿼리가 빠르려면 다음 조건이 중요합니다.

sql
-- orders 기준 정렬 인덱스 CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
  • JOIN 조건: o.user_id = u.id
  • ORDER BY: o.created_at DESC
  • 인덱스 선행 컬럼(user_id)을 JOIN에서 사용
  • 이후 created_at 정렬을 인덱스 순서 그대로 사용 가능

➡️ JOIN + ORDER BY + LIMIT 조합에서 가장 이상적인 형태


6.2 LEFT JOIN

sql
SELECT u.name, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id ORDER BY u.created_at DESC LIMIT 10;
  • users는 모두 유지
  • orders가 없으면 NULL
ORDER BY 인덱스 전략
sql
CREATE INDEX idx_users_created ON users (created_at DESC);
  • LEFT JOIN에서는 왼쪽 테이블(users) 이 기준
  • ORDER BY가 왼쪽 테이블 컬럼이면
    • JOIN 전에 정렬된 users를 읽을 수 있음

❗ 반대로 아래는 비용이 커질 수 있음

sql
-- LEFT JOIN + 오른쪽 테이블 정렬 ORDER BY o.created_at DESC
  • NULL 처리 필요
  • JOIN 이후 Sort 발생 가능성 높음

JOIN + ORDER BY에서 반드시 기억할 점

  1. ORDER BY 기준 컬럼이 어느 테이블에 있는지
  2. 그 테이블 기준으로 인덱스가 존재하는지
  3. JOIN 조건이 인덱스 선행 컬럼을 만족하는지

JOIN 성능의 핵심은 "JOIN 자체"보다 JOIN 결과를 어떻게 정렬하느냐에 있습니다.


7. 응용편

7.1 Scalar Subquery · LEFT JOIN · 페이징 (정확한 비교)

Scalar Subquery는 흔히 "느리다"고 설명되지만, 항상 그런 것은 아니며 오히려 JOIN보다 빠른 경우도 명확히 존재합니다.

이 섹션에서는 이름 조회 / 페이징이라는 실무에서 가장 흔한 상황을 기준으로 정리합니다.


1️⃣ Scalar Subquery vs LEFT JOIN (이름 조회 예시)

LEFT JOIN 방식
sql
SELECT o.id, u.name FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE o.created_at >= '2026-01-01';
  • JOIN 결과 집합을 먼저 생성
  • orders × users 조합 후 필터링
  • 결과 row 수가 많을수록 비용 증가

Scalar Subquery 방식
sql
SELECT o.id, (SELECT u.name FROM users u WHERE u.id = o.user_id) AS user_name FROM orders o WHERE o.created_at >= '2026-01-01';
왜 더 빠를 수 있는가?
  • users 테이블은 PK(id) 기반 단건 조회
  • PostgreSQL은 이를 Index Lookup으로 처리
  • JOIN처럼 중간 결과 집합을 만들지 않음

✔ 특히 다음 조건에서 유리

  • 조회 컬럼이 1~2개
  • 참조 테이블이 작거나 PK 조회
  • 결과 row 수가 많지 않음

이름, 상태값, 코드명 조회는 Scalar Subquery가 JOIN보다 빠른 경우가 흔함


2️⃣ Scalar Subquery를 SELECT 컬럼으로 사용한 페이징

Scalar Subquery는 페이징과 함께 SELECT 컬럼으로 사용하는 것이 매우 일반적입니다.

sql
SELECT o.id, o.created_at, (SELECT u.name FROM users u WHERE u.id = o.user_id) AS user_name FROM orders o WHERE o.id > :last_id ORDER BY o.id LIMIT 20;

이 방식의 장점

  • 페이징 기준은 orders 단독 인덱스 사용
  • JOIN으로 인한 row 확장 없음
  • 필요한 순간에만 사용자 이름 조회

PostgreSQL은 내부적으로 이를:

  • Nested Loop + Index Scan
  • 매우 예측 가능한 비용 구조

로 실행합니다.


3️⃣ 언제 JOIN이 더 좋은가?

JOIN이 더 적합한 경우도 명확합니다.

  • 여러 컬럼을 동시에 조회
  • GROUP BY / 집계가 필요한 경우
  • 정렬 기준이 JOIN 테이블에 있는 경우
sql
-- 집계 + JOIN은 JOIN이 유리 SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.name;

핵심 정리

  • Scalar Subquery ≠ 항상 느림
  • LEFT JOIN ≠ 항상 빠름

선택 기준은 단 하나입니다.

중간 결과 집합을 키우는가, 단건 조회를 반복하는가

  • 이름 / 코드 / 상태값 조회 → Scalar Subquery
  • 집계 / 다컬럼 / 정렬 필요 → JOIN

Scalar Subquery는 페이징 + 조회 컬럼 용도로 PostgreSQL에서 매우 실전적인 선택입니다.


7.2 Nested Subquery

sql
SELECT * FROM orders WHERE user_id IN ( SELECT id FROM users WHERE status = 'ACTIVE' );
  • 최신 DB는 대부분 최적화 가능
  • 하지만 JOIN으로 바꿀 수 있으면 고려

8. 성능에 중요한 함수와 패턴

EXISTS

sql
SELECT * FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );
  • IN보다 성능이 좋은 경우가 많음
  • 존재 여부만 확인 → 불필요한 데이터 스캔 방지

마무리

내가 가장 중요하게 생각하는 것은 단 하나입니다.

SELECT에는 반드시 지켜야 할 정해진 순서가 존재한다는 점

그리고 그 순서는 단순 문법 순서가 아니라

데이터베이스가 실제로 처리하는 논리적 실행 순서입니다.

이 순서를 이해하면:

  • 왜 WHERE가 먼저 중요한지
  • 왜 ORDER BY가 항상 가장 비싼지
  • 왜 LIMIT가 성능을 보장하지 않는지
  • 왜 인덱스가 있어도 느려질 수 있는지

모두 설명할 수 있습니다.

SELECT는 단순 조회 문장이 아니라

데이터를 어떤 순서로, 얼마나 적게, 어떻게 읽게 할 것인가를 설계하는 언어입니다.

이 문서를 끝까지 이해했다면 이제 ORM 뒤에 숨은 SQL이 보이기 시작할 것입니다.

#SQL#SELECT#RDBMS#쿼리 최적화#데이터베이스

댓글 0

Ctrl + Enter를 눌러 등록할 수 있습니다
※ AI 다듬기는 내용을 정제하는 보조 기능이며, 최종 내용은 사용자가 확인해야 합니다.