Devin.KR

SQLP · 기본

SQLP를 위한 SQL·성능 기초

성능을 생각하는 SQL 작성 습관

부분범위 처리, 불필요한 정렬·DISTINCT 줄이기, EXISTS 와 IN 선택, 스칼라 서브쿼리 반복 호출 비용, 함수 호출 최소화

개발자KR · 원고 갱신

이 장에서 배우는 것

지금까지 인덱스 구조와 스캔 방식, 실행계획을 읽는 법, 조인 방식, 옵티마이저 통계, 트랜잭션과 락을 차례로 살펴봤다. 이 지식은 실행계획을 "해석"하는 데는 충분하지만, 좋은 실행계획은 저절로 나오지 않는다. 옵티마이저는 SQL 문장에 적힌 조건과 구조만 보고 판단하므로, SQL을 쓰는 사람이 옵티마이저가 좋은 선택을 할 수 있게 단서를 남겨야 한다. 이 장은 그 단서를 남기는 습관을 다룬다. 결과 집합을 필요한 만큼만 가져오는 방법, 불필요한 정렬과 DISTINCT를 줄이는 방법, EXISTS와 IN을 상황에 맞게 고르는 기준, SELECT 절에 넣은 서브쿼리가 반복 실행되는 구조, WHERE 절에서 컬럼에 함수를 씌워 인덱스를 무력화하는 문제를 순서대로 정리한다.

  • 부분범위 처리를 이용해 정렬 결과의 앞부분만 빠르게 가져오는 SQL을 작성한다
  • 결과에 중복이 생기는 원인을 찾아 DISTINCT에 기대지 않고 문제를 해결한다
  • 서브쿼리 결과 크기와 상관관계 구조에 따라 EXISTS와 IN 중 알맞은 것을 고른다
  • 스칼라 서브쿼리가 행마다 반복 실행되는 구조를 이해하고 조인으로 바꿔 비용을 줄인다
  • WHERE 절에서 컬럼에 함수를 씌우면 인덱스를 못 쓰게 되는 이유를 이해하고 피한다

문제 상황

온라인서점의 주문 테이블은 1천만 건 규모다. 운영팀이 관리자 화면에 "최근 주문 10건"을 보여주는 기능을 추가했는데, 화면 하나 여는 데 3초 넘게 걸린다는 문의가 들어왔다. 쿼리를 열어 보니 주문 테이블 전체를 날짜로 정렬한 뒤 앞에서 10건만 잘라 쓰고 있었다. 비슷한 시기에 다른 화면에서는 "최근 주문한 회원 목록"을 보여주는 쿼리에 DISTINCT가 붙어 있었는데, 회원과 주문을 조인하면서 생긴 중복 행을 DISTINCT로 지우고 있었다. 도서 상세 화면에서는 "이 책을 주문한 적이 있는가"를 확인하는 조건에 IN 서브쿼리가 쓰였는데, 주문상세 테이블 전체에서 도서 번호 집합을 만드는 방식이라 오래 걸렸다. 주문 목록에 회원 이름을 붙이는 쿼리는 SELECT 절에 서브쿼리를 넣어 처리했는데, 조회 행이 늘어날수록 체감 속도가 눈에 띄게 느려졌다. 회원 이메일로 검색하는 쿼리는 WHERE UPPER(email) = ... 형태였는데, 이메일 컬럼에 인덱스가 있는데도 실행계획에는 인덱스가 보이지 않았다. 다섯 가지 모두 문법적으로는 틀리지 않지만, 옵티마이저가 좋은 실행계획을 세울 단서를 주지 못한 사례다. 이 장에서 쓰는 표와 인덱스는 다음과 같다.

이 장에서 쓰는 표와 인덱스
테이블주요 컬럼인덱스규모
membermember_id(PK), email, namePK, ix_member_email(email)회원 120만 건
bookbook_id(PK), title, category, pricePK도서 8만 종
ordersorder_id(PK), member_id, order_date, statusPK, ix_orders_member_date(member_id, order_date), ix_orders_date(order_date)주문 1천만 건
order_itemorder_id, line_no, book_id, quantityPK(order_id, line_no), ix_order_item_book(book_id)평균 2.3행/주문
reviewreview_id(PK), book_id, member_id, ratingPK, ix_review_book(book_id)리뷰 350만 건

부분범위 처리로 앞부분만 빠르게 가져오기

화면에 몇 건만 보여줄 때 전체 결과를 다 만들고 나서 앞부분만 자르는 것과, 애초에 필요한 만큼만 읽고 멈추는 것은 비용이 크게 다르다. 후자를 부분범위 처리라고 부른다. Oracle 19c에서는 FETCH FIRST n ROWS ONLY 구문이나 ROWNUM 조건으로 표현하는데, 이 구문 자체가 성능을 보장하지는 않는다. 핵심은 ORDER BY 기준 컬럼이 이미 정렬된 순서로 읽히는 인덱스가 있는지다. 인덱스 순서와 ORDER BY 순서가 같으면 옵티마이저는 정렬 연산 없이 인덱스를 필요한 만큼만 읽고 멈추는 STOPKEY 방식을 쓴다. 반대로 정렬 기준에 맞는 인덱스가 없으면, 조건에 맞는 행을 모두 모아 소트 영역에서 정렬한 뒤에야 앞 몇 건을 골라낼 수 있다. 결과로 화면에 보이는 행 수는 같아도 서버가 실제로 처리한 일의 양은 전혀 다르다.

인덱스로 정렬 순서를 맞추면 전체 정렬 없이 상위 몇 건만 읽고 멈출 수 있다

MySQL 8의 LIMIT도 같은 원리로 동작한다. ORDER BY 컬럼에 인덱스가 있으면 인덱스 순서대로 읽다가 LIMIT 건수만큼 채우고 멈추고, 그렇지 않으면 파일소트로 전체를 정렬한 뒤 잘라낸다. 두 DBMS 모두 "몇 건만 보여준다"는 문법보다 "정렬 순서와 인덱스가 맞는가"가 성능을 좌우한다.

정렬·DISTINCT를 줄이고 EXISTS와 IN을 고르는 기준

ORDER BY는 호출하는 쪽이 실제로 순서를 필요로 할 때만 붙인다. 화면이나 다음 처리 단계에서 순서를 신경 쓰지 않는데도 습관적으로 ORDER BY를 붙이면 매번 정렬 비용을 낸다. DISTINCT도 비슷하다. 회원과 주문을 조인하면 회원 한 명이 여러 번 나타날 수 있는데, 이 중복을 DISTINCT로 지우는 방식은 일단 조인 결과를 다 만들고 정렬해서 중복을 제거하는 비용을 낸다. 애초에 "이 회원이 조건에 맞는 주문을 가지고 있는가"만 확인하면 되는 문제라면 EXISTS로 바꾸는 편이 낫다. 조인으로 행을 부풀리지 않고, 조건에 맞는 주문을 하나 찾는 순간 해당 회원에 대한 확인을 멈출 수 있기 때문이다.

EXISTS와 IN 중 어느 쪽이 나은지는 서브쿼리가 참조하는 테이블의 크기와 상관관계 여부로 판단한다. Oracle 19c 옵티마이저는 많은 경우 IN 서브쿼리를 세미조인(semi join)으로 바꿔 EXISTS와 비슷하게 처리하지만, 이 변환이 항상 일어나는 것은 아니므로 실행계획으로 확인해야 한다. 중요한 것은 NOT IN과 NOT EXISTS의 차이다. NOT IN의 서브쿼리 결과에 NULL이 하나라도 섞이면 비교 자체가 알 수 없음(UNKNOWN)이 되어 바깥 쿼리가 예상과 달리 빈 결과를 반환할 수 있다. NOT EXISTS는 상관 조건으로 존재 여부만 확인하므로 이런 문제가 없다.

IN과 EXISTS를 고르는 기준
상황추천이유주의사항
서브쿼리 테이블이 크고 상관 조건이 자연스러움EXISTS일치하는 행 하나만 찾으면 확인을 멈춤상관 조건에 인덱스가 있어야 효과가 있음
서브쿼리 결과 집합이 작고 고정적임IN결과 집합을 한 번만 만들면 재사용하기 쉬움집합이 커지면 EXISTS보다 불리해질 수 있음
부정 조건(제외)을 표현할 때NOT EXISTSNULL로 인한 오판단이 없음NOT IN은 서브쿼리 컬럼이 NULL 허용이면 위험함

스칼라 서브쿼리와 함수 호출 비용

SELECT 절에 넣는 스칼라 서브쿼리(scalar subquery)는 바깥 쿼리가 반환하는 행마다 한 번씩 실행되는 구조다. Oracle은 같은 실행 안에서 동일한 키 값으로 스칼라 서브쿼리를 다시 호출하면 이전 결과를 재사용하는 캐싱을 하지만, 서로 다른 회원 번호가 수만 개 나온다면 캐시가 있어도 서로 다른 키만큼은 실제로 조회가 일어난다. 반면 같은 조회를 조인으로 바꾸면 옵티마이저가 해시 조인이든 정렬 병합 조인이든 한 번의 조인 연산으로 필요한 데이터를 붙인다. 행 수가 적을 때는 차이가 눈에 띄지 않지만, 조회 대상이 수만 건을 넘어가면 반복 호출 횟수만큼 누적되는 비용이 커진다.

스칼라 서브쿼리는 결과 행마다 반복 실행되지만 조인은 한 번만 실행된다

WHERE 절에서 컬럼에 함수를 씌우는 것도 비슷한 문제를 낳는다. 일반 B*Tree 인덱스는 컬럼 값 자체를 저장하므로, WHERE UPPER(email) = 'X'처럼 컬럼을 변형해서 비교하면 인덱스에 저장된 값과 검색 조건의 형태가 달라져 인덱스를 그대로 쓸 수 없다. 가장 간단한 해법은 검색값 쪽을 미리 다듬어서 컬럼은 원래 형태 그대로 비교하는 것이다. 저장 형식을 통일할 수 없는 경우에는 함수 기반 인덱스(function-based index)를 만들어 컬럼에 적용한 함수 결과를 그대로 인덱싱하는 방법이 있다.

완성 코드

-- =========================================================
-- ① 이 장에서 사용하는 표와 인덱스
-- =========================================================
CREATE TABLE member (
  member_id   NUMBER        PRIMARY KEY,
  email       VARCHAR2(100) NOT NULL,
  name        VARCHAR2(50)  NOT NULL,
  joined_date DATE          NOT NULL
);

CREATE TABLE book (
  book_id  NUMBER        PRIMARY KEY,
  title    VARCHAR2(200) NOT NULL,
  category VARCHAR2(30)  NOT NULL,
  price    NUMBER        NOT NULL
);

CREATE TABLE orders (
  order_id   NUMBER       PRIMARY KEY,
  member_id  NUMBER       NOT NULL REFERENCES member(member_id),
  order_date DATE         NOT NULL,
  status     VARCHAR2(10) NOT NULL
);

CREATE TABLE order_item (
  order_id NUMBER NOT NULL REFERENCES orders(order_id),
  line_no  NUMBER NOT NULL,
  book_id  NUMBER NOT NULL REFERENCES book(book_id),
  quantity NUMBER NOT NULL,
  CONSTRAINT pk_order_item PRIMARY KEY (order_id, line_no)
);

CREATE TABLE review (
  review_id NUMBER    PRIMARY KEY,
  book_id   NUMBER    NOT NULL REFERENCES book(book_id),
  member_id NUMBER    NOT NULL REFERENCES member(member_id),
  rating    NUMBER(1) NOT NULL
);

CREATE INDEX ix_member_email      ON member(email);
CREATE INDEX ix_orders_member_date ON orders(member_id, order_date DESC);
CREATE INDEX ix_orders_date       ON orders(order_date DESC);
CREATE INDEX ix_order_item_book   ON order_item(book_id);
CREATE INDEX ix_review_book       ON review(book_id);

-- =========================================================
-- ② 부분범위 처리: 최근 주문 10건만 빠르게 가져오기
-- =========================================================
SELECT order_id, member_id, order_date
FROM   orders
ORDER  BY order_date DESC
FETCH FIRST 10 ROWS ONLY;

-- =========================================================
-- ③ 불필요한 DISTINCT 없애기: 최근 주문한 회원 목록
-- =========================================================
-- 흔한 방식 (조인 결과에 중복이 생겨 DISTINCT로 지움)
SELECT DISTINCT m.member_id, m.name
FROM   member m
JOIN   orders o ON o.member_id = m.member_id
WHERE  o.order_date >= DATE '2026-09-01';

-- 고친 방식 (EXISTS로 중복 자체를 만들지 않음)
SELECT m.member_id, m.name
FROM   member m
WHERE  EXISTS (
         SELECT 1
         FROM   orders o
         WHERE  o.member_id  = m.member_id
         AND    o.order_date >= DATE '2026-09-01'
       );

-- =========================================================
-- ④ IN과 EXISTS 고르기: 주문된 적 있는 도서 목록
-- =========================================================
SELECT b.book_id, b.title
FROM   book b
WHERE  EXISTS (
         SELECT 1
         FROM   order_item oi
         WHERE  oi.book_id = b.book_id
       );

-- =========================================================
-- ⑤ 스칼라 서브쿼리 반복 호출 없애기
-- =========================================================
-- 흔한 방식 (행마다 서브쿼리 실행)
SELECT o.order_id, o.order_date,
       (SELECT m.name FROM member m WHERE m.member_id = o.member_id) AS member_name
FROM   orders o
WHERE  o.order_date >= DATE '2026-09-01';

-- 고친 방식 (조인 1회)
SELECT o.order_id, o.order_date, m.name AS member_name
FROM   orders o
JOIN   member m ON m.member_id = o.member_id
WHERE  o.order_date >= DATE '2026-09-01';

-- =========================================================
-- ⑥ 함수 호출 최소화: 이메일로 회원 찾기
-- =========================================================
-- 흔한 방식 (컬럼에 함수를 씌워 인덱스를 못 씀)
SELECT member_id, name
FROM   member
WHERE  UPPER(email) = 'READER@EXAMPLE.COM';

-- 고친 방식 1: 검색값을 미리 다듬어 컬럼은 그대로 비교
SELECT member_id, name
FROM   member
WHERE  email = LOWER('READER@EXAMPLE.COM');

-- 고친 방식 2: 함수 형태를 유지해야 한다면 함수 기반 인덱스를 맞춘다
CREATE INDEX ix_member_email_upper ON member (UPPER(email));

-- =========================================================
-- ⑦ 실행계획 확인
-- =========================================================
EXPLAIN PLAN FOR
SELECT order_id, member_id, order_date
FROM   orders
ORDER  BY order_date DESC
FETCH FIRST 10 ROWS ONLY;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, NULL, 'BASIC +PREDICATE'));

줄별 해설

  • ① 이 장 예제가 공통으로 쓰는 다섯 개 테이블과 인덱스를 만든다. orders에는 정렬 기준인 order_date에 내림차순 인덱스를 따로 두었다.
  • ② FETCH FIRST로 상위 10건만 요청한다. ORDER BY order_date DESC가 ix_orders_date(order_date DESC)와 방향까지 일치하므로 정렬 없이 인덱스를 역순으로 읽다가 10건에서 멈출 수 있다.
  • ③ 위쪽 쿼리는 조인으로 생긴 중복 회원 행을 DISTINCT로 지운다. 아래쪽 쿼리는 EXISTS로 "조건에 맞는 주문이 하나라도 있는가"만 확인해 애초에 중복을 만들지 않는다.
  • ④ 도서 8만 종 중에서 주문상세에 한 건이라도 있는 도서를 찾는다. book이 바깥, order_item이 안쪽인 상관 서브쿼리 구조라 EXISTS가 자연스럽고, ix_order_item_book(book_id) 인덱스로 건마다 존재 여부만 빠르게 확인한다.
  • ⑤ 위쪽 쿼리는 orders 행마다 member를 한 번씩 조회한다. 아래쪽 쿼리는 두 테이블을 한 번의 조인으로 묶어 같은 결과를 만든다.
  • ⑥ WHERE UPPER(email) = ...는 email 컬럼에 함수를 씌워 ix_member_email을 못 쓴다. 고친 방식 1은 검색값을 LOWER로 미리 맞춰 컬럼 비교를 그대로 유지하고, 고친 방식 2는 UPPER(email) 표현식 자체를 인덱싱해 함수 형태를 유지해야 하는 경우에 대비한다.
  • ⑦ ②의 실행계획을 DBMS_XPLAN으로 확인해 STOPKEY가 실제로 적용되는지 검증한다.

실행 결과

②의 EXPLAIN PLAN 결과는 다음과 같이 나온다.

SQL> SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, NULL, 'BASIC +PREDICATE'));

--------------------------------------------------------------------------------
| Id  | Operation                     | Name           | Rows  | Cost (%CPU)|
--------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |                |       |     4   (0)|
|*  1 |  VIEW                         |                |    10 |     4   (0)|
|*  2 |   WINDOW NOSORT STOPKEY       |                |    10 |     4   (0)|
|   3 |    INDEX FULL SCAN DESCENDING | IX_ORDERS_DATE |   10M |     4   (0)|
--------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
   1 - filter("from$_subquery$_002"."rowlimit_$$_rownumber"<=10)
   2 - filter(ROW_NUMBER() OVER ( ORDER BY "ORDER_DATE" DESC )<=10)

Rows 열의 10M은 orders 전체 행 수를 가리키지만, 실제로 읽는 블록 수는 인덱스에서 10건에 해당하는 앞부분뿐이다. Cost가 4로 매우 낮은 것도 그 때문이다. ORDER BY 기준에 맞는 인덱스가 없었다면 Id 2 자리에 WINDOW NOSORT STOPKEY 대신 SORT ORDER BY STOPKEY가 나타나고, Cost도 훨씬 커진다.

⑤의 두 쿼리를 같은 조건(2026년 9월 이후 주문 약 5만 건)으로 실행하면 소요된 논리 읽기 수 차이가 드러난다.

-- 흔한 방식 (행마다 서브쿼리 실행)
Statistics
----------------------------------------------------------
          0  recursive calls
     153482  consistent gets
          0  physical reads
      48213  rows processed

-- 고친 방식 (조인 1회)
Statistics
----------------------------------------------------------
          0  recursive calls
       3015  consistent gets
          0  physical reads
      48213  rows processed

반환된 행 수(48213)는 같지만, 논리 읽기(consistent gets)는 스칼라 서브쿼리 방식이 조인 방식의 약 50배다. member 조회가 회원마다 반복된 결과다.

실무에서 자주 틀리는 것

ORDER BY 없이 FETCH FIRST만 쓰기

-- 틀린 코드: 정렬 기준이 없어 "최근 10건"이 보장되지 않음
SELECT order_id, order_date
FROM   orders
FETCH FIRST 10 ROWS ONLY;

-- 고친 코드: 정렬 기준을 명시하고 인덱스 방향과 맞춤
SELECT order_id, order_date
FROM   orders
ORDER  BY order_date DESC
FETCH FIRST 10 ROWS ONLY;

ORDER BY가 없으면 옵티마이저가 어떤 10건을 돌려줄지는 실행계획이 바뀔 때마다 달라질 수 있다. "최근 10건"이라는 요구사항은 반드시 ORDER BY로 표현해야 한다.

DISTINCT로 조인 중복을 덮기

-- 틀린 코드: 주문상세까지 조인해 중복이 커진 뒤 DISTINCT로 지움
SELECT DISTINCT m.member_id, m.name
FROM   member m
JOIN   orders o     ON o.member_id = m.member_id
JOIN   order_item oi ON oi.order_id = o.order_id
WHERE  oi.book_id = 1001;

-- 고친 코드: 존재 여부만 확인
SELECT m.member_id, m.name
FROM   member m
WHERE  EXISTS (
         SELECT 1
         FROM   orders o
         JOIN   order_item oi ON oi.order_id = o.order_id
         WHERE  o.member_id = m.member_id
         AND    oi.book_id  = 1001
       );

조인 단계가 늘어날수록 한 회원이 나타나는 횟수도 늘어난다. DISTINCT는 이 부풀린 행을 나중에 정렬해서 지우는 방식이라 조인 단계가 늘수록 더 비싸진다.

NULL 가능성이 있는 서브쿼리에 NOT IN 쓰기

-- 틀린 코드: cancel_reason이 NULL 허용 컬럼이면 결과가 통째로 비어버릴 수 있음
SELECT member_id, name
FROM   member
WHERE  member_id NOT IN (
         SELECT member_id FROM orders WHERE cancel_reason IS NOT NULL
       );

-- 고친 코드: NOT EXISTS는 NULL에 영향받지 않음
SELECT m.member_id, m.name
FROM   member m
WHERE  NOT EXISTS (
         SELECT 1 FROM orders o
         WHERE  o.member_id = m.member_id
         AND    o.cancel_reason IS NOT NULL
       );

NOT IN의 서브쿼리가 반환하는 목록에 NULL이 하나라도 섞이면, "NULL과 같지 않다"는 비교가 참도 거짓도 아닌 알 수 없음이 되어 바깥 조건 전체가 거짓으로 취급된다. NOT EXISTS는 상관 조건으로 존재 여부만 확인하므로 이런 함정이 없다.

WHERE 절에서 날짜 컬럼에 함수 씌우기

-- 틀린 코드: order_date에 TRUNC를 씌워 인덱스를 못 씀
SELECT order_id, member_id
FROM   orders
WHERE  TRUNC(order_date) = DATE '2026-09-01';

-- 고친 코드: 범위 비교로 바꿔 컬럼은 그대로 둠
SELECT order_id, member_id
FROM   orders
WHERE  order_date >= DATE '2026-09-01'
AND    order_date <  DATE '2026-09-02';

TRUNC(order_date)는 컬럼 값을 변형한 결과이므로 ix_orders_date에 저장된 원래 값과 형태가 달라 인덱스를 그대로 쓸 수 없다. 하루 단위 범위 비교로 바꾸면 컬럼을 가공하지 않고도 같은 결과를 얻으면서 인덱스 레인지 스캔을 쓸 수 있다.

Oracle 19c와 MySQL 8의 차이
항목Oracle 19cMySQL 8비고
상위 N건 문법FETCH FIRST n ROWS ONLY, ROWNUMLIMIT n정렬 인덱스와 맞으면 둘 다 조기 종료 가능
IN/EXISTS 최적화서브쿼리 언네스팅으로 세미조인 변환세미조인 전략(FirstMatch 등) 지원버전과 조건에 따라 변환 여부가 달라 실행계획 확인 필요
스칼라 서브쿼리 캐싱같은 실행 안에서 동일 키 결과 재사용별도 캐싱 없이 행마다 재실행MySQL에서는 조인 전환 효과가 더 크게 나타남
함수 기반 인덱스CREATE INDEX ...(표현식)8.0.13 이상 함수형 인덱스(내부적으로 가상 컬럼)문법은 달라도 개념은 동일

한눈에 보기

이 장에서 다룬 습관 요약
기법언제 쓰나효과
부분범위 처리(FETCH FIRST + 정렬 인덱스)화면에 몇 건만 보여줄 때정렬 생략, 인덱스에서 조기 종료
DISTINCT 대신 EXISTS조인으로 중복이 생겼을 때중복 생성과 정렬 제거 비용을 없앰
EXISTS와 IN, NOT EXISTS 구분상관 조건과 NULL 가능성을 따질 때세미조인 활용, NOT IN 함정 회피
조인으로 스칼라 서브쿼리 대체SELECT 절에 반복 조회가 있을 때행마다 반복 실행을 조인 1회로 줄임
WHERE 절 함수 호출 최소화인덱스 컬럼을 가공해서 비교할 때인덱스를 정상적으로 사용

연습 문제

  1. 다음 쿼리는 최근 주문 20건을 보여주려는 의도로 작성됐다. 문제점을 지적하고 고쳐 써라.
    SELECT order_id, order_date
    FROM   orders
    FETCH FIRST 20 ROWS ONLY;
  2. review 테이블에 nullable 컬럼 comment_flag가 있다고 가정하자. 다음 쿼리가 예상과 다르게 빈 결과를 반환할 수 있는 이유를 설명하고 고쳐 써라.
    SELECT member_id, name
    FROM   member
    WHERE  member_id NOT IN (
             SELECT member_id FROM review WHERE comment_flag IS NULL
           );
  3. book 테이블(8만 종)에서 order_item(수천만 건)에 존재하는 도서만 찾는 조건을 IN과 EXISTS 두 가지로 각각 작성하고, 어느 쪽이 이 상황에 더 알맞은지 이유와 함께 설명하라.
  4. 다음 쿼리를 조인을 이용한 형태로 바꾸고, 바꾸면 어떤 지표가 줄어드는지 설명하라.
    SELECT o.order_id,
           (SELECT b.title FROM book b WHERE b.book_id =
             (SELECT oi.book_id FROM order_item oi
              WHERE oi.order_id = o.order_id AND oi.line_no = 1)
           ) AS first_book_title
    FROM   orders o;

정답과 해설

1. ORDER BY가 없어 "최근 20건"이 보장되지 않는다. 옵티마이저가 임의의 접근 경로로 앞 20건을 반환할 수 있으므로 정렬 기준을 명시해야 한다.

SELECT order_id, order_date
FROM   orders
ORDER  BY order_date DESC
FETCH FIRST 20 ROWS ONLY;

2. comment_flag가 NULL인 행이 서브쿼리 결과에 섞이면, NOT IN 비교에서 "NULL과 같지 않다"가 알 수 없음으로 처리되어 바깥 쿼리 전체가 빈 결과를 반환할 수 있다. NOT EXISTS로 바꾸면 이 문제가 사라진다.

SELECT m.member_id, m.name
FROM   member m
WHERE  NOT EXISTS (
         SELECT 1 FROM review r
         WHERE  r.member_id = m.member_id
         AND    r.comment_flag IS NULL
       );

3. IN으로 쓰면 order_item 전체에서 book_id 집합을 먼저 만들어야 한다.

SELECT b.book_id, b.title
FROM   book b
WHERE  b.book_id IN (SELECT oi.book_id FROM order_item oi);

EXISTS로 쓰면 book 8만 종을 기준으로, 도서마다 order_item에 일치하는 행이 하나라도 있는지만 확인한다.

SELECT b.book_id, b.title
FROM   book b
WHERE  EXISTS (
         SELECT 1 FROM order_item oi WHERE oi.book_id = b.book_id
       );

바깥 테이블(book)이 훨씬 작고 order_item에 book_id 인덱스가 있으므로, 도서마다 인덱스로 존재 여부만 확인하는 EXISTS 쪽이 이 상황에 알맞다. IN은 order_item 전체를 훑어 큰 중간 집합을 만들어야 하므로 불리하다.

4. 중첩된 스칼라 서브쿼리 두 겹을 조인으로 바꾸면 다음과 같다.

SELECT o.order_id, b.title AS first_book_title
FROM   orders o
JOIN   order_item oi ON oi.order_id = o.order_id AND oi.line_no = 1
JOIN   book b        ON b.book_id   = oi.book_id;

원래 쿼리는 orders 행마다 order_item 조회와 book 조회를 각각 한 번씩, 총 두 번의 추가 조회를 반복한다. 조인으로 바꾸면 옵티마이저가 세 테이블을 각각 한 번씩 읽어 결합하므로, 반복 실행 횟수와 그에 따른 논리 읽기 수가 줄어든다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.