재편집 전 원문 백업 | SECTION 02 | 2026-10-14
원리는 몰라도, 이것만은 기억하자!
SQL은 실행만 되면 끝나는 것이 아니다. 필요한 데이터를 정확히, 불필요한 작업 없이 조회하는 습관부터 시작하자.
이 섹션은 PostgreSQL 프로젝트에 투입되는 개발자가 가장 먼저 기억할 SQL 작성 규칙을 다룬다. 예제는 SECTION 01에서 소개한 BIG 계열 테이블을 사용한다.
실행계획 표기 원칙 — 중요
•
이 페이지의 실행계획은 실제 PostgreSQL에서 실행·측정한 결과가 아닌 예상 실행계획(개념적 예시)이다.
•
실행 노드명은 PostgreSQL 용어를 따르되, cost·actual rows·실행시간·Buffers 등 실측 수치를 작성하지 않는다.
•
실제 계획은 테이블 건수, 통계, 인덱스 구성, PostgreSQL 버전, 바인드 값, visibility map, 설정 등에 따라 달라질 수 있다.
•
실습 환경에 해당 인덱스가 없으면 여기서 제시한 Index Scan / Index Only Scan이 나오지 않을 수 있다. 인덱스 조건은 각 예제에 따로 적는다.
•
BAD는 특정 업무 목적에 맞지 않거나 위험한 작성 방식이고, 모든 상황에서 항상 느리다는 의미는 아니다.
이번 섹션의 규칙
ID | 구분 | 반드시 기억할 규칙 |
02-01 | MUST | SELECT 절에는 필요한 컬럼만 사용하자 |
02-02 | MUST | 존재 여부 확인에 전체 COUNT를 사용하지 말자 |
02-03 | MUST | COUNT(*)와 COUNT(컬럼)을 구분하자 |
02-04 | MUST | 애플리케이션에서는 바인드 변수를 사용하자 |
02-05 | MUST | 비교 값의 자료형을 컬럼과 맞추자 |
02-06 | MUST | NULL은 일반 값과 다르게 비교하자 |
02-07 | MUST | 테이블 별칭과 SQL ID로 SQL을 명확하게 작성하자 |
02-08 | TIP | LIMIT으로 일부 행을 조회할 때는 정렬 기준을 명시하자 |
REMINDER 02-01. SELECT 절에는 필요한 컬럼만 사용하자
기억할 것: 화면이나 업무에서 사용할 컬럼만 SELECT한다. 편하다는 이유로 SELECT *를 기본값처럼 사용하지 않는다.
상황: 닉네임이 'Moon'인 회원의 ID와 닉네임만 필요하다.
BAD — 불필요한 전체 컬럼 조회
SELECT m.*
FROM ms_mbr_big AS m
WHERE m.nick_nm = 'Moon';
SQL
복사
GOOD — 필요한 컬럼만 조회
SELECT m.mbr_id
,m.nick_nm
FROM ms_mbr_big AS m
WHERE m.nick_nm = 'Moon';
SQL
복사
예상 실행계획 — 예시 인덱스가 있을 때
실습 스크립트에는 다음 인덱스 정의가 등장한다.
CREATE INDEX ms_mbr_big_x01
ON ms_mbr_big (nick_nm)
INCLUDE (mbr_id);
SQL
복사
BAD (가능한 계획)
Index Scan using ms_mbr_big_x01 on ms_mbr_big
Index Cond: (nick_nm = 'Moon'::text)
→ SELECT *에 필요한 나머지 컬럼을 테이블에서 읽음
GOOD (가능한 계획)
Index Only Scan using ms_mbr_big_x01 on ms_mbr_big
Index Cond: (nick_nm = 'Moon'::text)
→ 조회 컬럼이 인덱스에 모두 포함됨
Plain Text
복사
왜 중요한가? SELECT 컬럼을 줄이면 데이터 전송량과 테이블 접근 부담을 줄일 수 있다. 예제처럼 커버링 인덱스가 있으면 Index Only Scan도 가능하다.
주의: Index Only Scan이더라도 visibility map 상태에 따라 Heap Fetches가 발생할 수 있다. 선택 컬럼을 줄였다고 반드시 동일한 비율로 I/O가 감소하는 것은 아니다.
출처: MySQL GUIDE 슬라이드 6 / Oracle GUIDE 슬라이드 5 / StartUP Tuning PG PDF 81~82페이지, 실습 SQL SQL-XPERF3-COV2.
REMINDER 02-02. 존재 여부 확인에 전체 COUNT를 사용하지 말자
기억할 것: 필요한 정보가 '한 건이라도 있는가?'라면 전체 건수를 구할 필요가 없다.
상황: 특정 회원의 주문이 하나라도 존재하는지 확인한다.
BAD — 끝까지 세기
SELECT COUNT(*) > 0 AS has_order
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019';
SQL
복사
GOOD — 존재만 확인
SELECT EXISTS (
SELECT 1
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019'
) AS has_order;
SQL
복사
예상 실행계획 — mbr_id 선두 인덱스가 있을 때
실습 스크립트의 tr_ord_big_x11 ON tr_ord_big(mbr_id) 등을 사용할 수 있다고 가정한다.
BAD (가능한 계획)
Aggregate
→ Index Only Scan on tr_ord_big
Index Cond: (mbr_id = 'M00019')
→ 해당 회원의 모든 일치 행을 세어야 함
GOOD (가능한 계획)
Result
→ InitPlan / SubPlan
→ Index Only Scan on tr_ord_big
Index Cond: (mbr_id = 'M00019')
→ 첫 일치 행을 찾으면 존재 여부 판단 가능
Plain Text
복사
왜 중요한가? COUNT(*)는 전체 일치 행의 개수가 목적이므로 이를 집계해야 한다. EXISTS는 참/거짓만 요구하므로 더 일찍 탐색을 끝낼 수 있다.
주의: 실제 실행계획에서 Seq Scan을 선택할 수도 있다. 데이터가 거의 없거나 일치 행이 적다면 차이가 작을 수 있다. 반대로 정확한 주문 건수가 필요하다면 COUNT(*)가 올바른 SQL이다.
출처: MySQL GUIDE 슬라이드 7 / Oracle GUIDE 슬라이드 6 / StartUP Tuning PG PDF 253페이지.
REMINDER 02-03. COUNT(*)와 COUNT(컬럼)을 구분하자
기억할 것: COUNT(*)는 행의 개수이고 COUNT(컬럼)은 해당 컬럼이 NULL이 아닌 행의 개수이다.
상황: 회원의 전체 주문 건수를 구한다. 주문의 픽업일시 pkup_dtm에는 NULL이 있을 수 있다고 가정한다.
BAD — 업무 목적과 다른 카운트
SELECT COUNT(o.pkup_dtm) AS order_cnt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019';
SQL
복사
GOOD — 전체 주문 건수
SELECT COUNT(*) AS order_cnt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019';
SQL
복사
픽업일시가 존재하는 주문만 세려는 경우라면 오히려 다음 SQL이 적합하다.
SELECT COUNT(o.pkup_dtm) AS picked_order_cnt
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019';
SQL
복사
예상 실행계획
두 SQL 모두 (가능한 형태)
Aggregate
→ Index Scan 또는 Index Only Scan / Seq Scan
→ 큰 차이는 실행 노드 이름보다 집계하는 값의 의미에 있음
Plain Text
복사
왜 중요한가? 성능보다 결과 정확성의 문제다. NULL이 섞인 데이터에서 두 식은 다른 값을 반환할 수 있다.
주의: 모든 행을 세기 위한 COUNT(*)는 정상적인 사용이다. 또한 COUNT(1)이 COUNT(*)보다 무조건 빠르다는 규칙은 PostgreSQL의 일반적인 최적화 원칙이 아니다.
출처 구분: 이 규칙은 기존 가이드의 '불필요한 카운트'를 보완하기 위해 추가한 SQL 의미·정확성 교육 항목이다.
REMINDER 02-04. 애플리케이션에서는 바인드 변수를 사용하자
기억할 것: 조회할 때마다 SQL 문자열에 값을 직접 이어 붙이지 말고, 동일한 SQL 문장과 분리된 파라미터 값을 사용한다.
상황: 회원 ID만 바꾸어 주문 목록을 여러 번 조회한다.
BAD — 값마다 SQL 문자열을 새로 조립하는 방식
-- 애플리케이션에서 매 요청마다 아래처럼 SQL 문자열을 생성
SELECT o.ord_no, o.ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019';
SELECT o.ord_no, o.ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M05338';
SQL
복사
GOOD — PostgreSQL PREPARE 예제
PREPARE member_orders AS
SELECT o.ord_no
,o.ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = \$1;
EXECUTE member_orders('M00019');
EXECUTE member_orders('M05338');
-- 실습 종료 시
DEALLOCATE member_orders;
SQL
복사
실제 애플리케이션에서는 PostgreSQL 드라이버, MyBatis 등의 파라미터 바인딩 기능을 사용한다. PREPARE는 서버 측 바인딩 개념을 보여주기 위한 SQL 예제다.
예상 실행계획
BAD / GOOD 모두 가능한 형태
Index Scan on tr_ord_big
Index Cond: (mbr_id = \<회원 ID>)
또는
Seq Scan on tr_ord_big
Filter: (mbr_id = \<회원 ID>)
Plain Text
복사
왜 중요한가? 파라미터 바인딩은 값과 SQL 구조를 분리하므로 문자열 조립에 따른 SQL Injection 위험을 줄이고, SQL 관리와 재사용에 유리하다.
주의: PostgreSQL은 실행 조건에 따라 custom plan과 generic plan을 선택할 수 있다. 따라서 바인딩하면 항상 더 빠르거나 항상 동일한 실행계획이 나온다고 단정할 수 없다. 본 예제의 핵심은 실행계획 변경이 아니라 안전하고 일관된 SQL 작성이다.
출처: Oracle GUIDE 슬라이드 14 (바인드 변수). 실행계획의 generic/custom plan 관련 설명은 PostgreSQL 특성을 반영한 추가 안내이다.
REMINDER 02-05. 비교 값의 자료형을 컬럼과 맞추자
기억할 것: 조회 조건에서 컬럼 쪽을 형변환하지 말고, 비교하는 값이 컬럼의 자료형에 맞도록 작성한다.
상황: 주문번호로 한 건을 찾는다. 실습 SQL에 숫자 주문번호 조건이 사용되므로, 여기서는 ord_no가 숫자형이라고 가정한다.
BAD — 숫자 컬럼을 문자열로 변환
SELECT o.ord_no
,o.ord_dtm
FROM tr_ord_big AS o
WHERE o.ord_no::text = '1001';
SQL
복사
GOOD — 숫자형에 맞게 비교
SELECT o.ord_no
,o.ord_dtm
FROM tr_ord_big AS o
WHERE o.ord_no = 1001;
SQL
복사
예상 실행계획 — ord_no PK의 기본 B-tree 인덱스가 있을 때
BAD (일반 컬럼 인덱스만 있다고 가정)
Seq Scan on tr_ord_big
Filter: ((ord_no)::text = '1001')
GOOD (가능한 계획)
Index Scan using tr_ord_big_pkey on tr_ord_big
Index Cond: (ord_no = 1001)
Plain Text
복사
왜 중요한가? 일반적인 ord_no 인덱스는 원래 컬럼 값으로 구성된다. ord_no::text처럼 컬럼을 변형한 표현식은 별도의 표현식 인덱스가 없다면 해당 인덱스를 그대로 사용하기 어려울 수 있다.
주의: 실제 ord_no 자료형과 인덱스 이름은 DDL로 확인해야 한다. tr_ord_big_pkey는 일반적인 제약조건 기반 인덱스 이름을 가정한 표기다. PostgreSQL이 암묵적으로 값 쪽을 변환하는 경우도 있으므로, 문자 리터럴을 사용했다는 이유만으로 항상 컬럼에 형변환이 발생하는 것은 아니다. 날짜·문자 컬럼 가공의 상세 사례는 SECTION 03에서 다룬다.
출처: MySQL GUIDE 슬라이드 3 / Oracle GUIDE 슬라이드 2 / StartUP Tuning PG PDF 142~143페이지 및 실습 스크립트의 SQL-XPERF3-VARCHAR.
REMINDER 02-06. NULL은 일반 값과 다르게 비교하자
기억할 것: NULL인지 확인할 때는 = NULL이 아니라 IS NULL을 사용한다.
상황: 아직 픽업하지 않은 주문 중 픽업일시가 NULL인 건을 찾는다. 여기서는 NULL이면 픽업 전이라는 업무 규칙이 성립한다는 전제를 둔다.
BAD — NULL을 일반 값처럼 비교
SELECT o.ord_no
,o.pkup_dtm
FROM tr_ord_big AS o
WHERE o.pkup_dtm = NULL;
SQL
복사
GOOD — IS NULL 사용
SELECT o.ord_no
,o.pkup_dtm
FROM tr_ord_big AS o
WHERE o.pkup_dtm IS NULL;
SQL
복사
예상 실행계획
BAD (가능한 단순화)
Result
One-Time Filter: false
→ 일치 행을 반환하지 않음
GOOD (가능한 계획)
Seq Scan on tr_ord_big
Filter: (pkup_dtm IS NULL)
또는 조건에 적합한 인덱스가 있다면
Index Scan / Bitmap Heap Scan 등도 가능
Plain Text
복사
왜 중요한가? SQL에서 NULL은 '알 수 없는 값'이다. = NULL의 결과는 참이 아니라 UNKNOWN이므로 WHERE 조건을 통과하지 않는다.
함께 기억할 것
-- NULL이 아닌 행
WHERE o.pkup_dtm IS NOT NULL
-- 두 값을 NULL까지 포함해 다르게 비교하고 싶을 때
WHERE o.ord_st IS DISTINCT FROM 'PKUP'
SQL
복사
주의: IS DISTINCT FROM은 일반 \<>와 달리 NULL을 비교 대상으로 포함하므로 업무 요구사항에 맞춰 선택해야 한다. 컬럼의 실제 NULL 허용 여부는 DDL 확인이 필요하다.
출처 구분: 기존 가이드의 일반 작성 규칙을 보완하는 PostgreSQL 실무 기본 지식으로 추가했다.
REMINDER 02-07. 테이블 별칭과 SQL ID로 SQL을 명확하게 작성하자
기억할 것: JOIN이 있는 SQL은 컬럼마다 테이블 별칭을 지정한다. 프로젝트의 SQL 식별자 규칙도 따른다.
상황: 주문번호·회원ID·회원 닉네임을 조회한다.
BAD — 어느 테이블의 컬럼인지 불명확
SELECT o.ord_no
,mbr_id
,m.nick_nm
FROM tr_ord_big AS o
JOIN ms_mbr_big AS m
ON m.mbr_id = o.mbr_id
WHERE mbr_id = 'M00019';
SQL
복사
GOOD — 모든 컬럼의 소속 명시
/* SQL_ID: ORD-MBR-001 */
SELECT o.ord_no
,o.mbr_id
,m.nick_nm
FROM tr_ord_big AS o
JOIN ms_mbr_big AS m
ON m.mbr_id = o.mbr_id
WHERE o.mbr_id = 'M00019';
SQL
복사
예상 실행계획 및 오류
BAD
ERROR: column reference "mbr_id" is ambiguous
→ 두 테이블 모두 mbr_id가 있으므로 계획 수립 전 오류
GOOD (가능한 계획 중 하나)
Nested Loop
→ Index Scan on ms_mbr_big
Index Cond: (mbr_id = 'M00019')
→ Index Scan on tr_ord_big
Index Cond: (mbr_id = 'M00019')
Plain Text
복사
왜 중요한가? 별칭은 다중 테이블 SQL의 가독성을 높이고 컬럼 참조 오류를 예방한다. SQL ID는 운영 로그와 APM, 소스 코드 간 추적에 도움이 된다.
주의: 별칭이나 주석의 SQL ID 자체가 성능을 개선하는 것은 아니다. 실제 계획은 Hash Join, Nested Loop 등으로 달라질 수 있다. SQL ID 형식은 프로젝트 규칙에 맞게 결정하며 위 ORD-MBR-001은 예시 식별자다.
출처: MySQL GUIDE 슬라이드 10 (SQL ID) / Oracle GUIDE 슬라이드 20 (테이블·컬럼 별칭).
REMINDER 02-08. LIMIT 조회에서는 정렬 기준을 명시하자
기억할 것: '앞에서 10건'과 '최근 주문 10건'은 다르다. 원하는 순서가 있다면 ORDER BY를 반드시 명시한다.
상황: 회원의 최근 주문 10건을 조회한다.
BAD — 결과 순서가 정의되지 않음
SELECT o.ord_no
,o.ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019'
LIMIT 10;
SQL
복사
GOOD — 최신순과 동률 처리 기준까지 정의
SELECT o.ord_no
,o.ord_dtm
FROM tr_ord_big AS o
WHERE o.mbr_id = 'M00019'
ORDER BY o.ord_dtm DESC
,o.ord_no DESC
LIMIT 10;
SQL
복사
예상 실행계획
BAD (가능한 형태)
Limit
→ Seq Scan / Index Scan on tr_ord_big
→ 읽힌 순서에서 10건 선택 (최신 보장 없음)
GOOD (정렬을 지원하는 인덱스가 없는 경우)
Limit
→ Sort (Top-N 방식으로 최적화될 수 있음)
Sort Key: ord_dtm DESC, ord_no DESC
→ Seq Scan / Bitmap Heap Scan ...
GOOD (조건과 정렬에 맞는 인덱스가 있는 경우)
Limit
→ Index Scan on tr_ord_big
Index Cond: (mbr_id = 'M00019')
→ 정렬 없이 상위 10건을 읽을 가능성
Plain Text
복사
왜 중요한가? ORDER BY가 없다면 PostgreSQL은 행 반환 순서를 보장하지 않는다. 같은 SQL이라도 실행계획이나 데이터 상태에 따라 서로 다른 10건이 반환될 수 있다.
주의: ORDER BY가 있어도 동률이 남으면 순서가 불안정할 수 있으므로 유일한 키를 마지막 정렬 기준에 포함하는 것이 좋다. 성능 관점의 정렬·인덱스·페이징은 SECTION 09 및 SECTION 10에서 자세히 다룬다.
출처 구분: 결과 정확성에 관한 일반 SQL 규칙을 보강한 항목이다. 최신 데이터 검색·정렬 기법은 StartUP Tuning PG PDF의 관련 강의와 연결된다.
02. 실행계획을 직접 확인할 수 있는 환경이 생기면
예상 실행계획을 실측 계획과 바꾸는 기준 절차
-- 1. 실행하지 않고 계획만 확인
EXPLAIN (COSTS OFF)
SELECT m.mbr_id, m.nick_nm
FROM ms_mbr_big AS m
WHERE m.nick_nm = 'Moon';
-- 2. 실습 DB에서 실행 가능한 조회 SQL인 경우 실제 측정
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT m.mbr_id, m.nick_nm
FROM ms_mbr_big AS m
WHERE m.nick_nm = 'Moon';
SQL
복사
EXPLAIN ANALYZE는 SQL을 실제로 실행한다. DML에 적용할 때는 변경·잠금·트리거 등 부작용에 주의해야 한다.
실측 시에는 계획 노드명뿐 아니라 Index Cond, Filter, Heap Fetches, actual rows, loops, Buffers 등을 확인한다. 단, 이 섹션에서는 노드의 의미를 깊이 분석하지 않는다. 심화 분석은 SECTION 12에서 학습한다.
03. 개발 전 체크리스트
필요한 컬럼만 SELECT했는가?
존재 여부만 필요할 때 불필요한 COUNT를 하지 않았는가?
COUNT(*)와 COUNT(컬럼)의 의미를 구분했는가?
조회 파라미터를 안전하게 바인딩했는가?
비교하는 값의 자료형이 컬럼과 일치하는가?
NULL 비교를 IS NULL / IS NOT NULL 등으로 정확히 작성했는가?
테이블 별칭과 컬럼 출처를 명확하게 표시했는가?
프로젝트의 SQL ID 규칙을 따랐는가?
일부 행을 가져올 때 원하는 순서가 정의되어 있는가?
04. 참고한 자료와 예제의 전제
제공 자료에서 직접 확인한 규칙
•
SQL GUIDE for MySQL.pptx: 슬라이드 3(자료형), 6(SELECT 컬럼), 7(존재 확인), 10(SQL ID)
•
002_SQL GUIDE for ORACLE.pptx: 슬라이드 2(자료형), 5(SELECT 컬럼), 6(불필요한 COUNT), 14(바인드), 20(별칭)
•
StartUPTuning_PG_20250616.pdf: 81~82페이지(Index Only Scan), 142~143페이지(컬럼 가공), 253페이지(불필요한 COUNT)
•
StartUPTuning_PG_SQL_Script.sql: SQL-XPERF3-COV2, SQL-XPERF3-VARCHAR 및 관련 CREATE INDEX 예제
•
SECTION 01. SQL REMINDER 소개: 공통 BIG 계열 실습 데이터 모델
자료 외 보강·추론임을 명시하는 부분
•
02-03의 COUNT 의미 차이, 02-06의 NULL 의미, 02-08의 정렬 보장은 SQL 정확성을 위해 추가한 설명이다.
•
실행계획 텍스트 8개 항목 모두는 원본에서 측정·복사한 결과가 아니라, 계획 선택 원리에 따라 작성한 예상 형태다. 실제 결과로 인용하거나 성능 수치처럼 사용해서는 안 된다.
•
ord_no의 실제 데이터 타입, 개별 인덱스 존재 여부, FK 제약조건은 프로젝트 실습 DB의 실제 DDL로 최종 확인해야 한다.
추후 제작할 그림 메모
•
그림 A: SELECT *와 필요한 컬럼만 SELECT할 때 '인덱스 → 테이블' 접근 흐름 비교
•
그림 B: COUNT(*)는 끝까지 세고 EXISTS는 첫 일치 행에서 종료할 수 있음을 비교하는 개념도
•
그림 C: NULL의 TRUE / FALSE / UNKNOWN 3값 논리 요약
•
현재 상태: 실제 그림은 삽입하지 않았으며, SQL 예제와 예상 실행계획을 먼저 완성함
SECTION 02 한 줄 요약: 필요한 것만 조회하고, 목적에 맞게 세고, 타입·NULL·바인드·별칭을 정확히 쓰자.




