백엔드 데이터 인프라 29편. SELECT 절의 깊이 — 컬럼·표현식·별칭·DISTINCT·집계의 표현 능력 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 29편이에요. 28편 쿼리 개요 의 9개 절 중 — "무엇을 가져올지" 정하는 SELECT 절의 깊이.
SELECT 절이 받는 것
SELECT [DISTINCT [ON (...)]] 표현식1 [AS 별칭1], 표현식2, ...
각 표현식은 20편 SQL 표현식 에서 다룬 상수·컬럼·함수 호출·산술과 문자열과 논리 연산·CASE 표현식·서브쿼리 모두를 받는다.
컬럼 선택 패턴
* — 모든 컬럼
SELECT * FROM users; -- 모든 컬럼
SELECT users.* FROM users; -- 명시적
SELECT u.* FROM users u; -- 별칭 *
운영 코드라면 * 는 피한다. 11편 SELECT 의 함정 참고.
명시적 컬럼
SELECT id, name, email FROM users;
SELECT u.id, u.name, o.amount
FROM users u JOIN orders o ON ...;
별칭 (AS)
SELECT
id AS user_id,
name AS user_name,
created_at AS signed_up_at
FROM users;
AS 는 생략할 수 있지만 (id user_id 형태) 명시하는 편이 낫다. 그리고 공백이 들어간 별칭은 큰따옴표로 묶는다.
SELECT id AS "User ID" FROM users;
표현식 컬럼
산술
SELECT
amount,
amount * 1.1 AS with_tax,
amount + shipping_fee AS total
FROM orders;
문자열 조작
SELECT
UPPER(name) AS upper_name,
name || ' (' || email || ')' AS display,
SUBSTRING(description FROM 1 FOR 100) AS summary
FROM users;
CASE
SELECT
name,
CASE
WHEN age < 18 THEN '미성년'
WHEN age < 65 THEN '성인'
ELSE '시니어'
END AS age_group
FROM users;
COALESCE·NULLIF
SELECT
name,
COALESCE(nickname, name, 'Anonymous') AS display,
NULLIF(score, 0) AS score_or_null
FROM users;
함수
SELECT
NOW() AS current_time,
DATE_TRUNC('day', created_at) AS day,
EXTRACT(YEAR FROM birthday) AS birth_year,
AGE(birthday) AS age
FROM users;
서브쿼리 컬럼 — 스칼라
SELECT
name,
(SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count,
(SELECT MAX(amount) FROM orders WHERE user_id = u.id) AS max_amount
FROM users u;
각 행마다 "한 값" 을 서브쿼리로 가져온다. 단, 데이터가 크면 성능 위험이 따른다 — 행 수만큼 서브쿼리가 도는 N+1(쿼리 한 번이 행 수만큼 쪼개지는 문제) 이슈다. LEFT JOIN + GROUP BY 또는 LATERAL(각 행에 서브쿼리를 옆에 붙이는 JOIN) 이 더 효율적이다.
ROW·복합 컬럼
SELECT ROW(id, name, email) FROM users;
-- (1,Alice,alice@example.com)
-- 복합 타입으로
SELECT (id, name)::user_summary FROM users;
JSON 응답에 객체를 묶어 박을 때처럼 특수한 시나리오에서 쓴다.
배열·JSON 컬럼
SELECT
name,
ARRAY_AGG(tag) AS tags, -- 그룹 내 배열로
JSONB_AGG(JSONB_BUILD_OBJECT(
'id', id, 'name', name
)) AS items
FROM users
GROUP BY name;
API 응답을 SQL 한 방으로 만들 때 강력하다.
DISTINCT 깊이
단순 DISTINCT
SELECT DISTINCT city FROM users;
SELECT DISTINCT city, country FROM users;
DISTINCT ON — PG 특별
SELECT DISTINCT ON (user_id) *
FROM orders
ORDER BY user_id, created_at DESC;
"각 user_id 별 첫 행" 을 가져온다. ORDER BY 의 첫 컬럼이 DISTINCT ON 과 일치해야 한다.
활용 — 각 카테고리 최신 가격
SELECT DISTINCT ON (category) category, name, price, updated_at
FROM products
ORDER BY category, updated_at DESC;
GROUP BY + window function 을 깔끔하게 단축한 형태다.
집계 표현식
SELECT
user_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'PAID') AS paid,
SUM(amount) AS total_amount,
AVG(amount)::INTEGER AS avg_amount,
STRING_AGG(product_name, ', ') AS products,
ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids
FROM orders
GROUP BY user_id;
자주 쓰는 집계 5종:
- COUNT(*) · COUNT(DISTINCT col)
- SUM·AVG·MIN·MAX
- STRING_AGG(text, separator) — 문자열 연결
- ARRAY_AGG(col) — 배열로
- JSONB_AGG(expr) — JSON 배열로
윈도우 함수 컬럼
18편 윈도우 함수 의 OVER 절.
SELECT
user_id, order_id, amount,
RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rank_in_user,
SUM(amount) OVER (PARTITION BY user_id) AS user_total,
LAG(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_amount
FROM orders;
각 행의 결과에 그룹 컨텍스트가 함께 따라붙는다.
VALUES 절 — 임시 행 만들기
SELECT 단독이 아니라 VALUES 로 임시 데이터를 만든다.
SELECT * FROM (VALUES
(1, 'Alice'),
(2, 'Bob'),
(3, 'Charlie')
) AS t(id, name);
테스트나 소량 마스터 데이터 매핑에 자주 쓴다.
-- 매핑 테이블 안 만들고 한 SQL로
SELECT
u.name,
cat.label
FROM users u
JOIN (VALUES
('ADMIN', '관리자'),
('USER', '일반'),
('GUEST', '게스트')
) AS cat(code, label) ON u.role = cat.code;
컬럼 순서·중복
위치로 ORDER BY
SELECT name, COUNT(*) FROM users GROUP BY name ORDER BY 2 DESC;
-- 2 = SELECT 의 2번째 컬럼 (COUNT(*))
가독성이 떨어지므로 명시하는 편이 낫다.
같은 컬럼 여러 번
SELECT id, id * 2, id + 100 FROM users;
-- 한 컬럼을 여러 표현식으로
TABLE 명령 — 단축
TABLE users;
-- = SELECT * FROM users;
거의 안 쓴다. 명시적 SELECT 를 권한다.
함정 5가지
(1) SELECT 별칭을 WHERE 에서
28편 의 실행 순서 함정.
(2) 스칼라 서브쿼리 N+1
SELECT name, (SELECT ... FROM orders WHERE user_id = u.id) FROM users u;
-- 사용자 수만큼 서브쿼리 실행
LEFT JOIN + GROUP BY 또는 LATERAL 을 권한다.
(3) * 운영 코드
새 컬럼을 추가하면 깨진다. 명시한다.
(4) STRING_AGG 순서
STRING_AGG(name, ', ') -- 순서 비결정
STRING_AGG(name, ', ' ORDER BY created_at) -- 명시
ORDER BY 를 명시한다.
(5) 큰 따옴표 별칭
SELECT id AS "User Id" FROM users; -- "User Id" 공백 포함 별칭
SELECT id FROM users WHERE "User Id" = 1; -- ERROR — 컬럼명이 "User Id"가 아님
큰따옴표 별칭은 클라이언트나 도구 호환성에서 함정이 된다.
(1) 명시적 컬럼 + AS 별칭. (2) 표현식·CASE·COALESCE 자유롭게. (3) 스칼라 서브쿼리 N+1 주의. (4) STRING_AGG·ARRAY_AGG·JSONB_AGG 로 API 응답 직접. (5) DISTINCT ON 으로 그룹별 첫.
한 줄 정리 — SELECT 절 = 컬럼·표현식·서브쿼리·집계·윈도우 함수 + 별칭. ARRAY_AGG·JSONB_AGG 가 API 응답 직접 도구. DISTINCT ON 그룹별 첫. VALUES 임시 데이터. 스칼라 서브쿼리는 LATERAL·JOIN 우선.
시험 직전 한 번 더 — SELECT 절 깊이 입문자가 매번 헷갈리는 것
- SELECT 가 받는 표현식 = 모든 20편 표현식
*운영 Xtable.*명시- AS 별칭 = 명시 권장
- 큰 따옴표 별칭 = 공백 포함 (호환성 위험)
- 표현식 = 산술·문자열·CASE·COALESCE·함수
- 스칼라 서브쿼리 = 각 행마다 한 값 (N+1 주의)
- LATERAL 또는 LEFT JOIN 권장
- ROW(...) = 복합 컬럼
- ARRAY_AGG = 그룹 → 배열
- JSONB_AGG + JSONB_BUILD_OBJECT = API 응답 직접
- STRING_AGG = 문자열 연결 집계
- STRING_AGG 순서 =
ORDER BY 명시 - DISTINCT col = 단순 중복 제거
- DISTINCT ON (col) = 그룹별 첫 (PG 특별)
- DISTINCT ON + ORDER BY 컬럼 일치
- 집계 5종 = COUNT·SUM·AVG·MIN·MAX
- FILTER 집계 = 조건부 카운트
- 윈도우 함수 = OVER (PARTITION BY ORDER BY)
- VALUES 절 = 임시 행
- VALUES + JOIN = 매핑 테이블 안 만들고
- 위치 ORDER BY =
ORDER BY 2(가독성 X) - TABLE 명령 = 단축
- SELECT 별칭을 WHERE X (실행 순서)
- ORDER BY 에 SELECT 별칭 OK
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 24편 — DML 개요 4가지 동사 + MVCC 통합
- 25편 — INSERT 깊이 Bulk·COPY·UPSERT 전략
- 26편 — UPDATE 깊이 HOT·fillfactor·FROM JOIN
- 27편 — DELETE 깊이 bloat·VACUUM·파티션 DROP
- 28편 — 쿼리 개요 SELECT 전체 구조
다음 글: