이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • SELECT, WHERE, JOIN, GROUP BY를 실행 비용 관점에서 어떻게 읽어야 하는가?
  • 조건과 조인은 인덱스 설계와 어떻게 연결되는가?
  • 집계 쿼리는 언제 운영 DB에 부담이 되는가?

개요

SQL 기본 문법은 결과를 얻는 방법처럼 보이지만, 실무에서는 실행 비용을 함께 읽어야 한다. SELECT는 가져올 컬럼을 정하고, WHERE는 읽을 행을 줄이며, JOIN은 관계를 합치고, GROUP BY는 많은 행을 요약한다.

백엔드 개발자는 SQL을 작성할 때 “맞는 결과”와 “운영에서 감당 가능한 실행”을 함께 봐야 한다.

원리

SQL을 실행 비용 관점에서 읽는다는 것은 “결과 row”가 아니라 “그 결과를 만들기 위해 DB가 읽은 row, 접근한 page, 정렬한 중간 결과, 반환한 byte”를 보는 것이다. WHERE는 후보 row를 줄이고, JOIN은 중간 row 수를 바꾸며, GROUP BY는 정렬이나 해시 집계를 만들 수 있다.

백엔드에서는 이 비용이 API p95/p99, connection 점유 시간, pool pending, DB CPU/I/O, slow query로 드러난다. 그래서 SQL 리뷰는 문법 리뷰가 아니라 운영 비용 리뷰여야 한다.

SELECT

필요한 컬럼만 조회한다.

SELECT id, title, created_at
FROM posts
WHERE board_id = 10;

SELECT *는 편하지만 불필요한 전송과 매핑 비용을 만든다. 컬럼이 추가되면 의도하지 않은 데이터가 API나 로그에 섞일 수도 있다.

-- 학습이나 ad-hoc 확인 외에는 조심
SELECT *
FROM members;

WHERE

WHERE는 필터이면서 인덱스 사용 가능성을 결정한다.

SELECT id, email
FROM members
WHERE email = 'joseph@example.com';

이 쿼리는 email Unique 인덱스와 잘 맞는다.

CREATE UNIQUE INDEX uq_members_email ON members (email);

컬럼에 함수를 씌우면 일반 인덱스 활용이 어려워질 수 있다.

-- 좋지 않은 냄새
WHERE DATE(created_at) = DATE '2026-06-30'

범위 조건으로 바꾼다.

WHERE created_at >= timestamp '2026-06-30'
  AND created_at < timestamp '2026-07-01'

PostgreSQL의 expression index처럼 DBMS별 기능으로 해결할 수도 있지만, API 조건은 가능하면 컬럼 원형을 보존하는 범위 조건이 더 단순하고 이식성이 좋다. 타임존 기준일이 필요한 경우에는 애플리케이션에서 기간 시작/끝을 명확히 계산해 bind 값으로 넘긴다.

JOIN

JOIN은 테이블 사이의 관계를 합친다.

SELECT o.id, o.created_at, m.email
FROM orders o
JOIN members m ON m.id = o.member_id
WHERE o.member_id = 10;

조인 컬럼과 필터 컬럼에 인덱스가 없으면 비용이 커진다. FK가 있다고 자동으로 모든 조회가 빠른 것은 아니다. 실제 조회 방향에 맞는 인덱스를 둬야 한다.

조인에서는 “몇 번 붙이는가”보다 “붙인 뒤 row 수가 얼마나 불어나는가”가 중요하다. 1:N 조인을 한 뒤 DISTINCT로 중복을 지우는 쿼리는 결과는 맞아도 중간 결과가 커져 정렬과 메모리를 크게 쓸 수 있다.

EXPLAIN
SELECT o.id, o.created_at, m.email
FROM orders o
JOIN members m ON m.id = o.member_id
WHERE o.member_id = 10
ORDER BY o.created_at DESC
LIMIT 20;

실행 계획에서는 index scan 여부뿐 아니라 예상 row 수, 실제 row 수, sort 발생 여부, join 방식, filter로 버린 row 수를 함께 본다.

GROUP BY

GROUP BY는 많은 row를 묶어 집계한다.

SELECT member_id, count(*)
FROM orders
WHERE created_at >= timestamp '2026-06-01'
GROUP BY member_id;

운영 DB에서 큰 기간과 많은 행을 매 요청마다 집계하면 장애가 될 수 있다. 통계와 대시보드는 캐시, materialized view, batch 집계, 분석 DB를 검토한다.

정확한 실시간 집계가 정말 제품 요구사항인지도 확인해야 한다. “최근 5분까지 반영되면 충분한 관리자 지표”라면 batch 집계나 캐시가 운영 DB를 훨씬 덜 압박한다.

실전 팁

  • SELECT * 대신 필요한 컬럼을 적는다.
  • WHERE 조건은 인덱스와 타입을 함께 본다.
  • JOIN은 중간 row 수가 얼마나 늘어나는지 실행 계획으로 확인한다.
  • GROUP BY는 실시간 정확도 요구사항을 제품과 다시 확인한다.
  • SQL 리뷰에서는 결과와 비용을 함께 이야기한다.
  • LIMIT이 있어도 정렬 인덱스가 없으면 많은 row를 정렬할 수 있음을 기억한다.
  • 날짜/상태/검색 조건은 bind 값 분포에 따라 실행 계획이 달라질 수 있으므로 대표 케이스와 최악 케이스를 나눠 본다.

위험 신호!

  • 목록 API에서 SELECT *를 사용한다.
  • 날짜 조건에 함수를 씌운다.
  • JOIN 후 DISTINCT로 중복만 숨긴다.
  • 대시보드가 매 요청마다 전체 기간 GROUP BY를 실행한다.
  • FK가 있으니 조인 성능은 괜찮다고 단정한다.
  • DISTINCT로 조인 중복을 숨기고 왜 중복이 생겼는지 모델링을 보지 않는다.
  • 실행 계획에서 반환 row 수만 보고 실제 읽은 row 수와 sort/temp 사용을 보지 않는다.

확인 질문

  • SELECT *가 운영 API에서 위험한 이유는 무엇인가?
    • 불필요한 컬럼 전송과 매핑 비용을 만들고 의도하지 않은 데이터 노출과 스키마 변경 영향을 키울 수 있기 때문이다.
  • WHERE 조건을 인덱스와 함께 봐야 하는 이유는 무엇인가?
    • 조건식 형태와 컬럼 타입이 인덱스 활용 가능성을 결정하기 때문이다.
  • GROUP BY가 운영 DB에 부담이 되는 경우는 언제인가?
    • 많은 행을 매 요청마다 읽고 그룹화해야 하는 대시보드나 통계 API에서 부담이 된다.
  • JOIN 실행 계획에서 반드시 봐야 할 것은 무엇인가?
    • 조인 방식, 각 단계의 예상/실제 row 수, 인덱스 사용 여부, sort 발생 여부, 중간 결과가 불어나는 지점이다.

참고 문서