SQL 3회차 : 정렬, 제한, 중복 제거 완전 정복 (ORDER BY / LIMIT·OFFSET·FETCH / DISTINCT 함정 + 실습 2개)


SQL 3회차 : 정렬, 제한, 중복 제거 완전 정복 (ORDER BY / LIMIT·OFFSET·FETCH / DISTINCT 함정 + 실습 2개)

한눈에 보는 요약

이번 회차는 SELECT/WHERE/GROUP BY를 어느 정도 이해한 뒤 “결과를 보기 좋게 다듬는 마무리 기술”을 다룹니다. ORDER BY로 원하는 순서로 정렬하고, LIMIT/OFFSET(또는 FETCH)로 필요한 만큼만 가져오며, DISTINCT로 중복을 제거합니다.

초보자분들이 가장 많이 헷갈리는 포인트는 2가지입니다. (1) LIMIT는 “정렬과 세트”이며, ORDER BY 없이 LIMIT를 쓰면 결과가 매번 달라질 수 있습니다. (2) DISTINCT는 “특정 컬럼 하나”가 아니라 “SELECT에 적은 컬럼 조합 전체” 기준으로 중복을 제거합니다.

글 마지막에는 실습 쿼리 2개(“매출 상위 상품 Top 10”, “최근 가입자 20명”)를 바로 실행할 수 있게 예시 스키마와 함께 제공합니다. 그대로 복사해 연습하시고, 본인 DB 컬럼명만 바꾸시면 됩니다.

목차


핵심 포인트

  • LIMIT는 ORDER BY와 함께 쓰는 것이 기본입니다. 정렬 기준 없이 “상위 10개”는 의미가 흔들립니다.
  • 다중 정렬은 ‘우선순위’입니다. ORDER BY A DESC, B ASC는 A로 먼저 정렬한 뒤 동률에서 B로 정리합니다.
  • OFFSET 페이지네이션은 커질수록 느려질 수 있습니다. 큰 페이지에서는 “마지막 키 기반(Seek)” 방식이 유리합니다.
  • DISTINCT는 ‘행(컬럼 조합)’ 기준입니다. SELECT 컬럼을 하나 추가하는 순간 중복 제거 기준이 바뀝니다.
  • 안정적인 결과를 원하면 ‘타이브레이커’를 넣습니다. 예: ORDER BY created_at DESC, user_id DESC

상세 설명

1) ORDER BY: 정렬은 결과의 “표정”을 바꿉니다

ORDER BY는 조회된 결과를 원하는 순서로 정리해 주는 구문입니다. 기본은 오름차순(ASC)이며, 내림차순은 DESC를 붙입니다. 숫자는 작→큰(ASC), 날짜는 오래된→최신(ASC), 문자열은 사전순(ASC)으로 생각하시면 이해가 빠릅니다.

(1) ASC/DESC 기본형
예를 들어 최근 가입자를 보고 싶다면 created_at을 내림차순(DESC)으로 정렬합니다. “최신이 위로” 오도록 만드는 가장 흔한 패턴입니다.

(2) 다중 정렬(동률 정리)
ORDER BY를 2개 이상 쓰면 “우선순위 정렬”이 됩니다. 예를 들어 매출이 같은 상품이 여러 개라면, 상품명을 오름차순으로 정리해 화면이 흔들리지 않게 만들 수 있습니다.

(3) 안정적인 결과를 위한 타이브레이커
초보자 실수 중 하나가 ORDER BY에 동률이 발생할 수 있는 컬럼만 넣는 것입니다. 예: ORDER BY created_at DESC만 쓰면 같은 시각에 가입한 사용자의 내부 순서는 DB가 임의로 결정할 수 있습니다. 이럴 때는 user_id 같은 고유 키를 추가해 결과를 고정합니다.

2) LIMIT/OFFSET(또는 FETCH): 필요한 만큼만 가져오기

LIMIT은 결과 행 수를 제한합니다. “Top 10”, “최근 20명” 같은 요구사항은 거의 모두 LIMIT(또는 DB별 유사 문법)으로 해결됩니다. OFFSET은 “앞에서부터 몇 행을 건너뛸지”를 의미하며, 보통 페이지네이션에 사용합니다.

(1) LIMIT 단독(Top N)
정렬(ORDER BY)로 기준을 만든 뒤, LIMIT로 상위 N개를 자릅니다. 이 순서를 습관처럼 고정해두면 쿼리 품질이 올라갑니다.

(2) OFFSET 페이지네이션(주의점 포함)
OFFSET 0은 1페이지, OFFSET 20은 2페이지(페이지 크기 20일 때)처럼 계산합니다. 다만 OFFSET이 커질수록 DB가 “버릴 행”을 많이 스캔해야 할 수 있어, 매우 큰 페이지에서는 느려질 수 있습니다.

(3) 대안: 마지막 키 기반(Seek) 페이지네이션
큰 데이터에서 성능이 중요하다면, “이전 페이지의 마지막 created_at/user_id 이후부터 가져오기”처럼 조건으로 범위를 좁혀 LIMIT를 거는 방식이 실무에서 자주 쓰입니다.

3) DISTINCT의 함정: ‘컬럼 하나’가 아니라 ‘조합 전체’입니다

DISTINCT는 중복 제거를 해주는 편리한 키워드지만, 초보자가 가장 자주 오해하는 지점이 있습니다. DISTINCT는 “특정 컬럼의 중복”이 아니라, SELECT에 나열한 컬럼들의 조합(행)이 완전히 같은 경우만 제거합니다.

예시로 바로 이해하기
“상품별로 한 번만 보고 싶다”는 의도로 DISTINCT를 썼는데, unit_price까지 SELECT에 포함시키면 같은 상품이라도 가격이 다르면 다른 행으로 남습니다. 결과적으로 ‘중복 제거가 안 된 것처럼’ 보이게 됩니다.

안전한 대안 3가지

  • 원하는 기준으로 GROUP BY: “상품별 1행”이 목적이면 product_id로 그룹화하고 SUM/MAX/MIN 같은 집계를 선택합니다.
  • 윈도우 함수로 1행 선택: “사용자별 최신 1건”처럼 ‘그룹당 대표 행’이 필요하면 ROW_NUMBER()로 1등만 고릅니다.
  • DB 전용 문법은 마지막에: PostgreSQL의 DISTINCT ON 같은 기능은 편하지만, 이식성이 떨어질 수 있어 팀/프로젝트 기준에 맞춰 선택합니다.

코드 예시

-- (실습용 예시 테이블)
-- users(user_id, email, created_at)
-- orders(order_id, user_id, ordered_at)
-- order_items(order_item_id, order_id, product_id, quantity, unit_price)
-- products(product_id, product_name, category)

-- 1) 최근 가입자 20명 (가장 많이 쓰는 패턴)
SELECT
  user_id,
  email,
  created_at
FROM users
ORDER BY created_at DESC, user_id DESC
LIMIT 20;

-- 2) 최근 가입자 20명: 2페이지(페이지 크기 20) = OFFSET 20
SELECT
  user_id,
  email,
  created_at
FROM users
ORDER BY created_at DESC, user_id DESC
LIMIT 20 OFFSET 20;

-- 3) (표준에 가까운 형태) FETCH 사용 예시
-- 일부 DB(예: Oracle 12c+, PostgreSQL, DB2 등)는 아래 형식을 지원합니다.
SELECT
  user_id,
  email,
  created_at
FROM users
ORDER BY created_at DESC, user_id DESC
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;

-- 4) 매출 상위 상품 Top 10 (총매출 = 수량 * 단가 합)
-- 주문 단위가 아닌 '상품 단위'로 보고 싶으므로 GROUP BY가 핵심입니다.
SELECT
  p.product_id,
  p.product_name,
  SUM(oi.quantity * oi.unit_price) AS total_sales,
  SUM(oi.quantity) AS total_qty
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
JOIN orders o ON o.order_id = oi.order_id
-- 기간 조건이 필요하면 아래처럼 추가하세요.
-- WHERE o.ordered_at >= '2025-12-01' AND o.ordered_at < '2026-01-01'
GROUP BY
  p.product_id,
  p.product_name
ORDER BY
  total_sales DESC,
  p.product_id ASC
LIMIT 10;

위 예시는 “정렬 → 제한”의 흐름을 체화하기 위한 최소 실습 세트입니다. 최근 가입자 조회는 ORDER BY의 타이브레이커(user_id)를 함께 넣어 결과를 안정화했고, 매출 Top 10은 GROUP BY로 상품 단위 집계를 만든 뒤 ORDER BY total_sales DESC로 정렬하고 LIMIT 10으로 자릅니다. 날짜/DB 문법은 프로젝트 환경에 맞춰 조정하시되, 핵심 구조(집계 → 정렬 → 제한)는 그대로 유지하시면 됩니다.

-- DISTINCT의 함정 데모: "상품 1개당 1행"을 원했는데, 컬럼을 추가해서 실패하는 상황

-- 의도: 상품 목록을 중복 없이 보고 싶다
SELECT DISTINCT
  product_id
FROM order_items;

-- 실수: 가격(unit_price)까지 같이 보고 싶어서 컬럼을 추가함
-- 결과: 같은 product_id라도 unit_price가 다르면 '다른 행'으로 남습니다.
SELECT DISTINCT
  product_id,
  unit_price
FROM order_items;

-- 대안: 상품 1개당 1행 + 대표값(예: 최대 단가)까지 보고 싶다면 GROUP BY 사용
SELECT
  product_id,
  MAX(unit_price) AS max_unit_price
FROM order_items
GROUP BY product_id;

-- 대안: 사용자별 "최신 1행" 같은 문제는 윈도우 함수로 해결(대표행 선택)
-- (DB에 따라 윈도우 함수 지원 여부가 다를 수 있습니다.)
SELECT
  user_id,
  email,
  created_at
FROM (
  SELECT
    u.*,
    ROW_NUMBER() OVER (PARTITION BY u.user_id ORDER BY u.created_at DESC) AS rn
  FROM users u
) t
WHERE t.rn = 1
ORDER BY created_at DESC;

DISTINCT는 “추가로 보고 싶은 컬럼”을 하나 붙이는 순간, 중복 제거 기준이 바뀌는 것이 핵심 함정입니다. 그래서 실무에서는 “중복 제거”가 목표인지, “그룹당 대표 행 1개”가 목표인지 먼저 정의하고, 그에 맞춰 GROUP BY나 윈도우 함수를 선택하는 편이 안전합니다.

DB별 문법 차이 비교 표

아래 표는 “Top N / 페이지네이션”에서 자주 마주치는 문법 차이를 빠르게 비교할 수 있도록 정리한 것입니다. 프로젝트 DB가 무엇인지 모를 때는, 일단 MySQL/PostgreSQL 스타일(LIMIT/OFFSET)부터 확인하면 대체로 빠릅니다.

DB Top N 기본 문법 페이지네이션 메모(초보자 주의)
MySQL / PostgreSQL / SQLite ORDER BY ... LIMIT N LIMIT N OFFSET M ORDER BY 없이 LIMIT만 쓰면 결과가 비결정적일 수 있습니다.
Oracle(12c+) ... FETCH FIRST N ROWS ONLY OFFSET M ROWS FETCH NEXT N ROWS ONLY FETCH는 ORDER BY와 함께 써야 “Top N” 의미가 명확합니다.
SQL Server SELECT TOP (N) ... ORDER BY ... ORDER BY ... OFFSET M ROWS FETCH NEXT N ROWS ONLY TOP만 쓰면 “정렬된 Top”이 아닐 수 있으니 ORDER BY를 습관화하세요.

따라하기: 15분 실습 루틴

  1. (1) 기준 테이블을 정합니다. users(created_at)와 order_items/products(매출 집계)가 있으면 충분합니다. 컬럼명이 다르면 메모장에 대응표를 먼저 만들어두세요(예: created_at ↔ join_date).

  2. (2) 최근 가입자 20명 쿼리를 먼저 실행합니다. ORDER BY created_at DESC만 쓰지 말고, user_id 같은 고유 키를 추가해 “결과가 흔들리지 않는지” 확인합니다.

  3. (3) OFFSET으로 2페이지를 뽑아봅니다. LIMIT 20 OFFSET 20을 실행해 1페이지와 겹치지 않는지 확인하세요. 겹친다면 정렬 기준(타이브레이커)이 부족한 경우가 많습니다.

  4. (4) 매출 Top 10을 만들어봅니다. SUM(quantity * unit_price)로 total_sales를 만들고, GROUP BY는 “상품 1행”이 되도록 product_id/product_name으로 묶습니다. 그 다음 ORDER BY total_sales DESC, product_id ASC를 넣어 안정화합니다.

  5. (5) DISTINCT 함정을 일부러 밟아봅니다. DISTINCT product_id는 잘 되는데 DISTINCT product_id, unit_price로 바꾸면 결과가 늘어나는 것을 직접 확인하세요. “왜 늘어났는지” 설명할 수 있으면 DISTINCT를 졸업한 것입니다.

자주 하는 실수 체크리스트

  • ORDER BY 없이 LIMIT만 사용: “상위”가 아니라 “임의의 N개”가 될 수 있습니다.
  • 동률이 생기는데 타이브레이커 누락: 새로고침할 때 순서가 바뀌면 사용자가 혼란을 겪습니다.
  • OFFSET이 커지는데도 계속 OFFSET 방식 유지: 큰 페이지에서 느려지면 마지막 키 기반(Seek) 방식으로 전환을 검토하세요.
  • DISTINCT를 ‘특정 컬럼 중복 제거’로 오해: SELECT 컬럼을 늘리는 순간 기준이 바뀝니다.
  • 집계 쿼리에서 GROUP BY 누락 또는 컬럼 불일치: “상품 Top 10”이면 상품 기준으로 묶고, ORDER BY는 집계 컬럼 기준으로 하세요.

추가로 생각해볼 점

  • 정렬은 인덱스와 함께 고민하면 성능이 달라집니다. 조회가 잦은 정렬 키(created_at 등)는 인덱스 전략과 같이 설계하는 편이 좋습니다.
  • Top N의 정의를 문장으로 고정하세요. “매출 Top 10”이 ‘총매출(금액)’인지 ‘판매량(수량)’인지부터 합의하면, 쿼리도 흔들리지 않습니다.
  • DISTINCT보다 ‘의도 표현’이 우선입니다. 중복 제거가 아니라 “대표 행 1개”가 목적이라면 GROUP BY/윈도우 함수가 더 정확한 도구입니다.


이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Reactions

댓글 쓰기

0 댓글