Search

SECTION 02. SQL 작성의 기본 규칙

상세 페이지

문법은 알지만 성능까지 신경 쓰지는 못했던 개발자가 가장 먼저 기억할 8개 규칙.
난이도: 초급 후반 · 권장 학습: 각 규칙에서 GOOD SQL을 먼저 실행해 보고 업무 SQL에 적용한다.
이번 SECTION은 깊은 실행계획 분석 대신 BAD SQL → GOOD SQL → 핵심만 학습한다. 각 규칙의 예상 실행계획·기술적 예외는 SECTION 02 원문 백업에 그대로 남아 있다.

REMINDER 02-01. SELECT 절에는 필요한 컬럼만 사용하자

상황: 닉네임이 'Moon'인 회원의 ID와 닉네임만 필요하다.
실습 인덱스 — Supabase startdbpg에서 생성 완료
CREATE INDEX ms_mbr_x51 ON startdbpg.ms_mbr (nick_nm) INCLUDE (mbr_id);
SQL
복사
BAD — 개선·검토 대상
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT m.* FROM startdbpg.ms_mbr m WHERE m.nick_nm = 'Moon';
SQL
복사
실측 실행계획 (BAD)
Index Scan using ms_mbr_x51 on ms_mbr m (actual time=0.664..0.665 rows=1 loops=1) Index Cond: ((nick_nm)::text = 'Moon'::text) Buffers: shared hit=1 read=2 Planning: Buffers: shared hit=102 read=1 Planning Time: 1.955 ms Execution Time: 0.812 ms
Plain Text
복사
GOOD — 권장 또는 비교할 SQL
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT m.mbr_id ,m.nick_nm FROM startdbpg.ms_mbr m WHERE m.nick_nm = 'Moon';
SQL
복사
실측 실행계획 (GOOD)
Index Only Scan using ms_mbr_x51 on ms_mbr m (actual time=0.077..0.079 rows=1 loops=1) Index Cond: (nick_nm = 'Moon'::text) Heap Fetches: 0 Buffers: shared hit=3 Planning: Buffers: shared hit=82 Planning Time: 0.544 ms Execution Time: 0.169 ms
Plain Text
복사
실측 비교: 두 SQL 모두 1건을 반환하고 ms_mbr_x51을 사용했다. BAD는 Index Scan이며 실행 버퍼는 shared hit=1 read=2였다. GOOD은 필요한 컬럼이 모두 인덱스에 있어 Index Only Scan이 실행되었으며 Heap Fetches: 0, 실행 버퍼는 shared hit=3이었다. 실행시간(BAD 0.812 ms, GOOD 0.169 ms)은 단일 측정값이므로 절대적인 성능 차이로 일반화하지 않는다.
핵심: 화면이나 업무에서 사용할 컬럼만 SELECT한다. 편하다는 이유로 SELECT *를 기본값처럼 사용하지 않는다.
꼭 확인할 예외: Index Only Scan이더라도 visibility map 상태에 따라 Heap Fetches가 발생할 수 있다. 선택 컬럼을 줄였다고 반드시 동일한 비율로 I/O가 감소하는 것은 아니다.

REMINDER 02-02. 존재 여부 확인에 전체 COUNT를 사용하지 말자

상황: 회원 'M0035'의 주문이 하나라도 존재하는지 확인한다. 실제 startdbpg.tr_ord에는 총 730,327건의 주문이 있고, 해당 회원의 주문은 2,150건이다. mbr_id에 tr_ord_fk01 인덱스가 있다.
BAD — 개선·검토 대상
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT COUNT(*) > 0 AS has_order FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0035';
SQL
복사
실측 실행계획 (BAD)
Aggregate (actual time=1.122..1.122 rows=1 loops=1) Buffers: shared hit=72 -> Index Only Scan using tr_ord_fk01 on tr_ord o (actual time=0.173..0.926 rows=2150 loops=1) Index Cond: (mbr_id = 'M0035'::text) Heap Fetches: 67 Buffers: shared hit=72 Planning: Buffers: shared hit=116 Planning Time: 0.757 ms Execution Time: 1.285 ms
Plain Text
복사
GOOD — 권장 또는 비교할 SQL
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT EXISTS ( SELECT 1 FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0035' ) AS has_order;
SQL
복사
실측 실행계획 (GOOD)
Result (actual time=0.098..0.098 rows=1 loops=1) Buffers: shared hit=4 InitPlan 1 -> Index Only Scan using tr_ord_fk01 on tr_ord o (actual time=0.096..0.096 rows=1 loops=1) Index Cond: (mbr_id = 'M0035'::text) Heap Fetches: 0 Buffers: shared hit=4 Planning: Buffers: shared hit=110 Planning Time: 0.580 ms Execution Time: 0.205 ms
Plain Text
복사
실측 비교: 두 SQL 모두 존재 여부 true를 반환한다. BAD의 COUNT(*)는 인덱스에서 2,150건을 읽고 Aggregate를 수행했다(shared hit=72, Heap Fetches: 67). GOOD의 EXISTS는 첫 번째 일치 행 1건을 찾은 뒤 종료했다(shared hit=4, Heap Fetches: 0). 실행시간은 BAD 1.285 ms, GOOD 0.205 ms로 측정되었으나 실행환경에 따라 달라지는 단일 측정값이다.
핵심: 필요한 정보가 '한 건이라도 있는가?'라면 전체 건수를 구할 필요가 없다. EXISTS는 첫 일치 행을 발견하면 더 읽지 않아도 된다.
꼭 확인할 예외: 일치 행이 없거나 적으면 차이가 작을 수 있고, 실행계획에 따라 Seq Scan이 선택될 수도 있다. Index Only Scan이더라도 visibility map 상태에 따라 Heap Fetches가 발생할 수 있다. 정확한 주문 건수가 필요하다면 COUNT(*)가 올바른 SQL이다.

REMINDER 02-03. 전체 행 수는 COUNT(*)로 계산하자

상황: 회원 'M0035'의 전체 주문 건수를 구한다. 실제 startdbpg.tr_ord에는 해당 회원의 주문이 2,150건 있으며, ord_no는 기본 키(PK)로 NOT NULL이다. 검색 조건 mbr_id에는 tr_ord_fk01 인덱스가 있다.
BAD — 개선·검토 대상
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT COUNT(o.ord_no) AS order_cnt FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0035';
SQL
복사
실측 실행계획 (BAD)
Aggregate (actual time=9.731..9.732 rows=1 loops=1) Buffers: shared hit=2044 -> Index Scan using tr_ord_fk01 on tr_ord o (actual time=0.068..9.394 rows=2150 loops=1) Index Cond: ((mbr_id)::text = 'M0035'::text) Buffers: shared hit=2044 Planning: Buffers: shared hit=118 read=1 Planning Time: 2.624 ms Execution Time: 9.961 ms
Plain Text
복사
GOOD — 권장 또는 비교할 SQL
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT COUNT(*) AS order_cnt FROM startdbpg.tr_ord o WHERE o.mbr_id = 'M0035';
SQL
복사
실측 실행계획 (GOOD)
Aggregate (actual time=1.582..1.583 rows=1 loops=1) Buffers: shared hit=72 -> Index Only Scan using tr_ord_fk01 on tr_ord o (actual time=0.681..1.388 rows=2150 loops=1) Index Cond: (mbr_id = 'M0035'::text) Heap Fetches: 67 Buffers: shared hit=72 Planning: Buffers: shared hit=116 Planning Time: 0.552 ms Execution Time: 1.727 ms
Plain Text
복사
실측 비교: 두 SQL은 모두 2,150건을 반환한다. COUNT(ord_no)는 PK 컬럼의 값을 확인해야 하는 것으로 계획되어, mbr_id 단일 컬럼 인덱스 tr_ord_fk01을 이용한 Index Scan으로 테이블을 방문했다(실행 shared hit=2044). 반면 COUNT(*)는 행 수만 계산하므로 같은 인덱스로 Index Only Scan을 수행했다(실행 shared hit=72, Heap Fetches: 67). 실행시간은 각각 9.961 ms와 1.727 ms였으나 단일 측정값이며 환경에 따라 달라진다.
핵심: 전체 행 수가 필요할 때는 의미가 분명한 COUNT(*)를 사용하자. COUNT(컬럼)은 NULL이 아닌 값의 개수를 계산한다. PK인 ord_no는 NULL이 없으므로 결과는 동일해도 PostgreSQL에서 실행계획·I/O가 달라질 수 있다.
Oracle에서는? Oracle 옵티마이저는 NOT NULL이 보장된 컬럼의 COUNT(컬럼)을 COUNT(*)와 동등하게 변환하는 Count Transformation을 적용할 수 있다. 따라서 COUNT(PK)도 동일한 효율적인 인덱스 접근 계획이 나올 수 있어 이 PostgreSQL 실측 결과를 Oracle에 일반화해서는 안 된다. 다만 Oracle에서도 항상 같은 실행계획이라고 단정할 수 없으므로 실제 계획을 확인한다. 참고: Ask TOM — Select Count(1): How it works.
꼭 확인할 예외: COUNT(컬럼)이 NULL을 제외한다는 의미 차이는 DBMS와 무관하다. 특히 NULL 허용 컬럼이라면 COUNT(*)와 결과가 다를 수 있다. PostgreSQL의 Index Only Scan도 visibility map 상태에 따라 Heap Fetches가 발생할 수 있으며, 전체 스캔/인덱스 선택은 통계·조건·인덱스 구성에 영향을 받는다.

REMINDER 02-04. [삭제] 애플리케이션에서는 바인드 변수를 사용하자

상황: 회원 ID만 바꾸어 주문 목록을 여러 번 조회한다.
BAD — 개선·검토 대상
-- 애플리케이션에서 매 요청마다 아래처럼 SQL 문자열을 생성 SELECT o.ord_no, o.ord_dtm FROM tr_ord_big o WHERE o.mbr_id = 'M00019'; SELECT o.ord_no, o.ord_dtm FROM tr_ord_big o WHERE o.mbr_id = 'M05338';
SQL
복사
GOOD — 권장 또는 비교할 SQL
PREPARE member_orders AS SELECT o.ord_no ,o.ord_dtm FROM tr_ord_big o WHERE o.mbr_id = \$1; EXECUTE member_orders('M00019'); EXECUTE member_orders('M05338'); -- 실습 종료 시 DEALLOCATE member_orders;
SQL
복사
핵심: 조회할 때마다 SQL 문자열에 값을 직접 이어 붙이지 말고, 동일한 SQL 문장과 분리된 파라미터 값을 사용한다.
꼭 확인할 예외: PostgreSQL은 실행 조건에 따라 custom plan과 generic plan을 선택할 수 있다. 따라서 바인딩하면 항상 더 빠르거나 항상 동일한 실행계획이 나온다고 단정할 수 없다. 본 예제의 핵심은 실행계획 변경이 아니라 안전하고 일관된 SQL 작성이다.

REMINDER 02-05. 비교 값의 자료형을 컬럼과 맞추자

상황: startdbpg.tr_ord의 주문일자 ord_ymd는 ord_dtm에서 날짜만 추출해 YYYYMMDD로 저장한 문자형 varchar(8) 반정규화 컬럼이다. 현재 730,327건에 값을 채웠으며 기존 날짜와 불일치하는 행은 0건이다. ord_ymd에는 tr_ord_x52 B-tree 인덱스가 있다. 20221221에 해당하는 주문 855건을 조회한다.
자료형 불일치 — 오류 발생
SELECT COUNT(*) AS order_cnt FROM startdbpg.tr_ord o WHERE o.ord_ymd = 20221221;
SQL
복사
실제 오류: SQLSTATE 42883: operator does not exist: character varying = integer. 숫자 리터럴을 문자형 컬럼과 직접 비교할 수 없다.
BAD — 오류를 피하려고 인덱스 컬럼을 숫자로 변환
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT COUNT(*) AS order_cnt FROM startdbpg.tr_ord o WHERE o.ord_ymd::integer = 20221221;
SQL
복사
실측 실행계획 (BAD)
Finalize Aggregate (actual time=558.865..574.348 rows=1 loops=1) Buffers: shared hit=15414 read=1902 -> Gather (actual time=558.688..574.339 rows=2 loops=1) Workers Planned: 1 Workers Launched: 1 Buffers: shared hit=15414 read=1902 -> Partial Aggregate (actual time=550.312..550.313 rows=1 loops=2) Buffers: shared hit=15414 read=1902 -> Parallel Seq Scan on tr_ord o (actual time=479.800..550.261 rows=428 loops=2) Filter: ((ord_ymd)::integer = 20221221) Rows Removed by Filter: 364736 Buffers: shared hit=15414 read=1902 Planning: Buffers: shared hit=126 Planning Time: 0.597 ms Execution Time: 574.576 ms
Plain Text
복사
GOOD — 컬럼 자료형에 맞게 비교값을 문자로 지정
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT COUNT(*) AS order_cnt FROM startdbpg.tr_ord o WHERE o.ord_ymd = '20221221';
SQL
복사
실측 실행계획 (GOOD)
Aggregate (actual time=0.380..0.380 rows=1 loops=1) Buffers: shared hit=5 -> Index Only Scan using tr_ord_x52 on tr_ord o (actual time=0.159..0.304 rows=855 loops=1) Index Cond: (ord_ymd = '20221221'::text) Heap Fetches: 0 Buffers: shared hit=5 Planning: Buffers: shared hit=123 Planning Time: 16.167 ms Execution Time: 3.741 ms
Plain Text
복사
실측 비교: 두 SQL의 집계 결과는 모두 855건이다. BAD는 컬럼에 ::integer 형변환을 적용해 일반적인 ord_ymd 인덱스 탐색 조건을 사용하지 못했고, 실제로 Parallel Seq Scan + Filter가 선택되었다. GOOD은 tr_ord_x52에서 Index Cond로 대상 값만 탐색했다. 실행시간 574.576ms 대 3.741ms는 개별 측정값이며, 통계·캐시·동시 부하 등에 따라 변한다.
핵심: 조건값의 자료형을 컬럼에 맞추자. 형변환이 필요하다면 인덱스 컬럼이 아니라 비교값 쪽에서 처리한다. 특히 컬럼을 가공하면 인덱스 탐색이 Filter나 전체 스캔으로 바뀌어 성능이 저하될 수 있다.
컬럼 자료형 확인 SQL — 조인 없이 바로 조회
SELECT column_name, data_type, character_maximum_length, is_nullable FROM information_schema.columns WHERE table_schema = 'startdbpg' AND table_name = 'tr_ord' ORDER BY ordinal_position;
SQL
복사
⚠️
DBeaver 재검증 필요 — PREPARE 파라미터 자료형 사례
아래 구문은 Supabase SQL 실행 도구에서 SQLSTATE 42883: operator does not exist: timestamp without time zone >= text 오류가 발생해 생성되지 않았다. DBeaver에서 정확히 같은 PREPARE 구문을 독립 실행했을 때의 결과를 추가 확인할 필요가 있다.
PREPARE bad_date(text, text) AS SELECT COUNT(*) FROM startdbpg.tr_ord WHERE ord_dtm >= $1 AND ord_dtm < $2;
SQL
복사
생성 여부는 같은 DB 세션에서 아래 SQL로 확인한다.
SELECT name, parameter_types, statement FROM pg_prepared_statements WHERE name = 'bad_date';
SQL
복사
참고: PREPARE bad_date AS ...처럼 타입 선언을 생략하면 PostgreSQL이 파라미터를 timestamp로 추론할 수 있다. 또한 ord_no = '1001'처럼 타입 미정(unknown) 문자열 리터럴은 정수 컬럼에 맞춰 해석될 수 있으므로, 문자 리터럴이라는 이유만으로 자료형 불일치 오류가 발생하는 것은 아니다.
실습 참고: ord_ymd는 현재 ord_dtm을 기준으로 일괄 채운 값이다. 이후 ord_dtm이 변경될 때 자동으로 동기화되도록 하는 트리거나 생성 컬럼은 구성하지 않았다. 표현식 인덱스를 별도 설계한 경우는 컬럼 형변환 시의 접근 경로가 달라질 수 있다.

REMINDER 02-06. NULL은 일반 값과 다르게 비교하자

상황: 픽업일시 pkup_dtm이 NULL인 주문을 찾는다. startdbpg.tr_ord 730,327건 중 실제 NULL 데이터는 20건이다. 여기서는 NULL이면 픽업 전이라는 업무 규칙이 성립한다는 전제를 둔다.
BAD — 잘못된 NULL 비교
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT o.ord_no ,o.pkup_dtm FROM startdbpg.tr_ord o WHERE o.pkup_dtm = NULL;
SQL
복사
실측 결과 (BAD): 0건
Result (actual time=0.001..0.001 rows=0 loops=1) One-Time Filter: false Planning: Buffers: shared hit=105 Planning Time: 0.574 ms Execution Time: 0.135 ms
Plain Text
복사
GOOD — 올바른 NULL 비교
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT o.ord_no ,o.pkup_dtm FROM startdbpg.tr_ord o WHERE o.pkup_dtm IS NULL;
SQL
복사
실측 결과 (GOOD): 20건
Gather (actual time=473.025..551.952 rows=20 loops=1) Workers Planned: 1 Workers Launched: 1 Buffers: shared hit=15630 read=1686 -> Parallel Seq Scan on tr_ord o (actual time=461.138..461.141 rows=10 loops=2) Filter: (pkup_dtm IS NULL) Rows Removed by Filter: 365154 Buffers: shared hit=15630 read=1686 Planning: Buffers: shared hit=112 dirtied=1 Planning Time: 9.445 ms Execution Time: 552.673 ms
Plain Text
복사
실측 비교: BAD는 One-Time Filter: false로 테이블을 읽지 않고 0건을 반환했다. GOOD은 pkup_dtm IS NULL 조건으로 20건을 정상 조회했다. GOOD이 더 오래 걸리는 것은 실제 테이블을 조회했기 때문이며, BAD가 빠르다고 해서 올바르거나 우수한 SQL인 것은 아니다.
핵심: NULL 여부는 = NULL이 아니라 IS NULL 또는 IS NOT NULL로 비교하자. 일반 비교 연산자(=, <> 등)와 NULL을 비교한 결과는 TRUE가 아닌 UNKNOWN이다.
꼭 확인할 예외: IS DISTINCT FROM / IS NOT DISTINCT FROM은 NULL을 비교 대상으로 포함한다. 업무에서 NULL의 의미를 먼저 확인해야 하며, 해당 컬럼의 실제 NULL 허용 여부는 DDL로 검증한다.

REMINDER 02-07. 테이블 별칭과 SQL ID로 SQL을 명확하게 작성하자

상황: 주문번호·회원ID·회원 닉네임을 조회한다.
BAD — 개선·검토 대상
SELECT o.ord_no ,mbr_id ,m.nick_nm FROM tr_ord_big o JOIN ms_mbr_big m ON m.mbr_id = o.mbr_id WHERE mbr_id = 'M00019';
SQL
복사
GOOD — 권장 또는 비교할 SQL
/* SQL_ID: ORD-MBR-001 */ SELECT o.ord_no ,o.mbr_id ,m.nick_nm FROM tr_ord_big o JOIN ms_mbr_big m ON m.mbr_id = o.mbr_id WHERE o.mbr_id = 'M00019';
SQL
복사
핵심: JOIN이 있는 SQL은 컬럼마다 테이블 별칭을 지정한다. 테이블 별칭에는 AS를 쓰지 않는다(Oracle·MySQL·PostgreSQL 공통 문법). 프로젝트의 SQL 식별자 규칙도 따른다.
꼭 확인할 예외: 별칭이나 주석의 SQL ID 자체가 성능을 개선하는 것은 아니다. 실제 계획은 Hash Join, Nested Loop 등으로 달라질 수 있다. SQL ID 형식은 프로젝트 규칙에 맞게 결정하며 위 ORD-MBR-001은 예시 식별자다.

REMINDER 02-08. 페이징에는 반드시 ORDER BY를 사용하자

상황: 주문 내역을 1,000건씩 나누어 조회한다. ORDER BY를 생략한 채 LIMIT / OFFSET만 사용하면 연속된 페이지에 동일한 주문이 다시 나타날 수 있다. 실제 startdbpg.tr_ord와 startdbpg.ms_mbr를 조인하여, 데이터 변경 없이 실행계획 차이만으로 중복이 발생한 사례를 확인했다.
BAD — 16페이지 (1,000건, OFFSET 15,000)
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT o.ord_no ,o.mbr_id ,m.nick_nm FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id LIMIT 1000 OFFSET 15000;
SQL
복사
실측 실행계획 (16페이지)
Limit (actual time=26.907..28.017 rows=1000 loops=1) Buffers: shared hit=15358 -> Merge Join (actual time=0.047..26.932 rows=16000 loops=1) Merge Cond: ((o.mbr_id)::text = (m.mbr_id)::text) Buffers: shared hit=15358 -> Index Scan using tr_ord_fk01 on tr_ord o (actual time=0.021..22.227 rows=16000 loops=1) Buffers: shared hit=15355 -> Index Scan using ms_mbr_pk on ms_mbr m (actual time=0.022..0.026 rows=8 loops=1) Buffers: shared hit=3 Planning: Buffers: shared hit=315 Planning Time: 1.507 ms Execution Time: 28.207 ms
Plain Text
복사
BAD — 17페이지 (1,000건, OFFSET 16,000)
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT o.ord_no ,o.mbr_id ,m.nick_nm FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id LIMIT 1000 OFFSET 16000;
SQL
복사
실측 실행계획 (17페이지)
Limit (actual time=54.295..58.859 rows=1000 loops=1) Buffers: shared hit=253 -> Hash Join (actual time=4.538..57.833 rows=17000 loops=1) Hash Cond: ((o.mbr_id)::text = (m.mbr_id)::text) Buffers: shared hit=253 -> Seq Scan on tr_ord o (actual time=0.031..19.386 rows=17000 loops=1) Buffers: shared hit=211 -> Hash (actual time=4.391..4.393 rows=9999 loops=1) Buckets: 16384 Batches: 1 Memory Usage: 583kB Buffers: shared hit=42 -> Index Only Scan using ms_mbr_x51 on ms_mbr m (actual time=0.033..1.690 rows=9999 loops=1) Heap Fetches: 0 Buffers: shared hit=42 Planning: Buffers: shared hit=315 Planning Time: 1.541 ms Execution Time: 59.074 ms
Plain Text
복사
실제 중복을 확인하는 검증 SQL — 두 페이지에서 조회한 주문번호의 교집합을 구한다. 내부 페이지 조회에만 ORDER BY가 없으며, 마지막 정렬은 확인 결과를 보기 좋게 출력하기 위한 것이다.
WITH p16 AS MATERIALIZED ( SELECT o.ord_no, o.mbr_id, m.nick_nm FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id LIMIT 1000 OFFSET 15000 ), p17 AS MATERIALIZED ( SELECT o.ord_no, o.mbr_id, m.nick_nm FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id LIMIT 1000 OFFSET 16000 ) SELECT p16.ord_no FROM p16 JOIN p17 USING (ord_no) ORDER BY p16.ord_no;
SQL
복사
실측 중복 결과: 재검증 실행에서는 48816, 49039, 49292, 49529, 49780 5건이 두 페이지에 중복 등장했다. 앞선 실행에서는 동일 조건으로 8건의 중복이 관측되었다. ORDER BY가 없는 두 쿼리의 반환 순서와 중복 건수는 실행 시점·실행계획 등에 따라 달라질 수 있으므로 특정 주문번호나 정확한 중복 개수가 매번 재현된다고 보장하지 않는다.
GOOD — 두 페이지 모두 동일하고 유일한 정렬 기준 사용
-- 16페이지 SELECT o.ord_no, o.mbr_id, m.nick_nm FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id ORDER BY o.ord_dtm DESC, o.ord_no DESC LIMIT 1000 OFFSET 15000; -- 17페이지 SELECT o.ord_no, o.mbr_id, m.nick_nm FROM startdbpg.tr_ord o JOIN startdbpg.ms_mbr m ON m.mbr_id = o.mbr_id ORDER BY o.ord_dtm DESC, o.ord_no DESC LIMIT 1000 OFFSET 16000;
SQL
복사
GOOD 실측 검증: 위 정렬 조건으로 두 페이지를 각각 조회하여 ord_no 교집합을 비교한 결과 중복 0건이었다(두 페이지 모두 1,000건). 동일 시점의 변하지 않는 데이터에서 ord_dtm DESC, ord_no DESC는 PK를 마지막 기준으로 사용하므로 순서가 확정된다.
핵심: 페이징에는 반드시 ORDER BY를 사용하고, 동률이 없는 유일한 정렬 기준까지 지정하자. 인덱스·물리적 저장 순서·조인 방식이 특정 순서처럼 보이더라도 명시적 정렬이 없으면 페이지 간 중복·누락을 방지할 수 없다.
꼭 확인할 예외: 데이터가 페이지 조회 사이에 추가·삭제·수정되면 ORDER BY를 사용해도 OFFSET 기반 페이징은 중복·누락이 생길 수 있다. 데이터 변경 중의 안정성이 중요하다면 Keyset Pagination 또는 일관된 스냅샷을 별도로 검토한다. 정렬·인덱스·페이징의 상세 성능은 SECTION 09 및 SECTION 10에서 다룬다.

작성 후 간단 점검

SELECT * 대신 필요한 컬럼만 선택했는가?
존재 여부만 필요한데 COUNT(*)를 사용하지 않았는가?
COUNT(*)와 COUNT(컬럼), NULL의 의미를 구분하는가?
바인드 변수·자료형·별칭을 올바르게 사용했는가?
LIMIT의 정렬 기준이 명확한가?
한 줄 기억: 필요한 것만 선택하고, 정확히 비교하고, 필요한 결과만 반환하자.