Step 04 — SELECT 기본
학습 목표
SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT 의 뼈대를 손에 익힌다
- 컬럼/테이블 별칭(AS) 을 쓰고, 별칭이 통하는 절과 통하지 않는 절을 구분한다
DISTINCT 가 "컬럼 하나"가 아니라 SELECT 목록 전체에 걸린다는 것을 이해한다
ORDER BY 에서 NULL 이 어디로 가는지, LIMIT 이 왜 ORDER BY 없이는 위험한지 안다
- SQL 의 논리적 실행 순서를 이해하고, 그 결과로 생기는 에러를 스스로 고친다
선행 스텝: Step 03
예상 소요: 40분
이 스텝의 모든 예제는 shop 데이터베이스를 사용합니다. 스키마는 01_schema.sql 을, 데이터는 02_seed_master.sql / 03_seed_orders.sql 를 참고하세요 — 전문은 공용 SQL 스크립트 페이지에 있습니다.
mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop
4-1. SELECT 은 "값을 만들어 내는" 명령이다
많은 사람이 SELECT 를 "테이블에서 데이터를 꺼내는 명령"으로 배웁니다. 반은 맞고 반은 틀립니다. SELECT 는 표현식을 평가해서 결과 표(result set)를 만드는 명령이고, 그 표현식이 우연히 테이블 컬럼일 뿐입니다.
그래서 테이블이 없어도 SELECT 는 동작합니다.
결과
+-------+
| 1 + 1 |
+-------+
| 2 |
+-------+
컬럼 이름이 1 + 1 인 것이 보이나요? 별칭을 주지 않으면 표현식 텍스트가 그대로 컬럼명이 됩니다.
여러 표현식을 콤마로 나열할 수 있습니다.
SELECT 1 + 1, NOW(), VERSION(), 'hello';
결과
+-------+---------------------+-----------+-------+
| 1 + 1 | NOW() | VERSION() | hello |
+-------+---------------------+-----------+-------+
| 2 | 2026-07-13 10:25:02 | 8.0.46 | hello |
+-------+---------------------+-----------+-------+
💡 실무 팁: SELECT NOW();, SELECT VERSION();, SELECT @@sql_mode; 는 DB 에 붙자마자 상태를 확인할 때 쓰는 3종 세트입니다. FROM 이 필요 없다는 점을 기억하세요.
4-2. FROM — 어느 테이블에서 가져올 것인가
FROM 을 붙이면 그 테이블의 모든 행에 대해 SELECT 목록이 평가됩니다. * 는 "모든 컬럼"이라는 뜻입니다.
SELECT * FROM categories;
결과
+-------------+-----------+--------------+------------+
| category_id | parent_id | name | sort_order |
+-------------+-----------+--------------+------------+
| 1 | NULL | 패션 | 1 |
| 2 | NULL | 디지털 | 2 |
| 3 | NULL | 식품 | 3 |
| 4 | NULL | 리빙 | 4 |
| 5 | NULL | 도서 | 5 |
| 11 | 1 | 남성의류 | 1 |
| 12 | 1 | 여성의류 | 2 |
| 13 | 1 | 신발 | 3 |
| 21 | 2 | 노트북 | 1 |
| 22 | 2 | 스마트폰 | 2 |
+-------------+-----------+--------------+------------+
... (총 17행)
parent_id 가 NULL 인 5개는 최상위 카테고리, 나머지는 하위 카테고리입니다. NULL 은 "값이 없음"이 아니라 "값을 모름" 에 가깝습니다 — Step 05 에서 이것 때문에 어떤 함정이 생기는지 자세히 다룹니다.
필요한 컬럼만 골라 쓰는 것이 실무의 기본입니다.
SELECT customer_id, name, grade, city FROM customers;
결과
+-------------+-----------+--------+--------+
| customer_id | name | grade | city |
+-------------+-----------+--------+--------+
| 1 | 김민수 | VIP | 서울 |
| 2 | 이지은 | GOLD | 서울 |
| 3 | 박철수 | SILVER | 부산 |
| 4 | 최영희 | BRONZE | 대구 |
| 5 | 정 훈 | GOLD | 인천 |
| 6 | 강소라 | VIP | 서울 |
| 7 | 윤대현 | BRONZE | 광주 |
+-------------+-----------+--------+--------+
... (총 30행)
⚠️ 함정: SELECT * 를 애플리케이션 코드에 넣지 마세요. 세 가지 이유가 있습니다.
- 누가 테이블에 컬럼을 추가하면 네트워크로 오가는 데이터가 조용히 늘어납니다. TEXT/BLOB 컬럼이 추가되면 재앙입니다.
- 커버링 인덱스(Step 20)를 쓸 기회를 날립니다. 필요한 컬럼만 SELECT 하면 인덱스만 읽고 끝날 쿼리가,
* 때문에 테이블 본문까지 읽게 됩니다.
- 컬럼 순서에 의존하는 코드(
row[3])가 컬럼 추가 한 번에 깨집니다.
* 는 탐색할 때만 쓰세요.
4-3. 별칭(AS) — 결과 컬럼에 이름 붙이기
계산 결과나 긴 컬럼명에는 이름을 붙여야 읽을 수 있습니다. AS 를 씁니다.
SELECT
product_id AS id,
name AS 상품명,
price AS 판매가,
price - cost AS `마진(원)`
FROM products;
결과
+----+---------------------------------+------------+-------------+
| id | 상품명 | 판매가 | 마진(원) |
+----+---------------------------------+------------+-------------+
| 1 | 베이직 옥스퍼드 셔츠 | 39000.00 | 21000.00 |
| 2 | 슬림핏 치노 팬츠 | 49000.00 | 27000.00 |
| 3 | 라이트 다운 재킷 | 159000.00 | 88000.00 |
| 4 | 울 니트 스웨터 | 79000.00 | 44000.00 |
| 5 | 플리츠 롱스커트 | 59000.00 | 33000.00 |
+----+---------------------------------+------------+-------------+
... (총 40행)
별칭에 공백이나 괄호, 예약어가 들어가면 백틱(`)으로 감싸야 합니다. 마진(원) 이 그 예입니다. 한글만 있는 별칭은 백틱 없이도 됩니다.
AS 는 생략할 수 있습니다. 하지만 생략을 권하지 않는 아주 실용적인 이유가 있습니다.
-- 콤마를 하나 빠뜨렸다. name 과 price 사이에 콤마가 없다.
SELECT product_id, name price FROM products;
결과
+------------+---------------------------------+
| product_id | price |
+------------+---------------------------------+
| 1 | 베이직 옥스퍼드 셔츠 |
| 2 | 슬림핏 치노 팬츠 |
| 3 | 라이트 다운 재킷 |
+------------+---------------------------------+
... (총 40행)
⚠️ 함정: 위 쿼리는 에러가 나지 않습니다. MySQL 은 name price 를 "name 컬럼에 price 라는 별칭을 붙인 것"으로 해석합니다. 그래서 price 라는 컬럼명 아래에 상품명이 들어앉습니다. 리포트 쿼리에서 컬럼이 하나 사라졌는데 에러도 안 나면 원인을 찾는 데 한참 걸립니다. AS 를 항상 명시하면 이 사고가 눈에 띕니다 (name AS price 는 딱 봐도 이상하니까요).
4-4. 주석 — 세 가지 방식
SELECT 1 AS a; -- 한 줄 주석 (-- 뒤에 반드시 공백!)
# MySQL 전용 한 줄 주석
/* 여러 줄
주석 */
SELECT 2 AS b;
결과
+---+ +---+
| a | | b |
+---+ +---+
| 1 | | 2 |
+---+ +---+
-- : 표준 SQL. 뒤에 공백이나 제어문자가 반드시 와야 합니다. --this 는 주석이 아니라 문법 에러이고, SELECT 1--2; 는 1 - (-2) = 3 이 됩니다.
# : MySQL 전용. 표준이 아니라서 다른 DB 로 옮길 때 깨집니다.
/* */ : 여러 줄. MySQL 은 /*! ... */ 형태의 버전 힌트 주석도 지원하는데, 이건 주석처럼 생겼지만 MySQL 에서는 실제로 실행됩니다 (/*!80000 ... */ 는 8.0 이상에서만 실행).
4-5. WHERE — 행 걸러내기
WHERE 는 조건이 참(TRUE) 인 행만 남깁니다. 거짓(FALSE)뿐 아니라 NULL(unknown) 인 행도 버립니다 — 이 3값 논리는 Step 05 의 핵심 주제입니다.
SELECT product_id, name, price, stock
FROM products
WHERE price >= 1000000;
결과
+------------+-----------------------------+------------+-------+
| product_id | name | price | stock |
+------------+-----------------------------+------------+-------+
| 12 | 울트라북 14 i5/16GB | 1290000.00 | 18 |
| 13 | 울트라북 14 i7/32GB | 1790000.00 | 9 |
| 14 | 게이밍 노트북 RTX4060 | 2190000.00 | 6 |
| 16 | 스마트폰 X20 256GB | 1150000.00 | 50 |
| 17 | 스마트폰 X20 Pro 512GB | 1490000.00 | 22 |
+------------+-----------------------------+------------+-------+
5 rows in set
AND 로 조건을 겹칠 수 있습니다.
SELECT customer_id, name, grade, city, points
FROM customers
WHERE grade = 'VIP' AND city = '서울';
결과
+-------------+-----------+-------+--------+--------+
| customer_id | name | grade | city | points |
+-------------+-----------+-------+--------+--------+
| 1 | 김민수 | VIP | 서울 | 12500 |
| 6 | 강소라 | VIP | 서울 | 20100 |
| 13 | 장혜원 | VIP | 서울 | 15800 |
| 19 | 송민지 | VIP | 서울 | 17300 |
| 25 | 양현우 | VIP | 서울 | 22400 |
+-------------+-----------+-------+--------+--------+
5 rows in set
VIP 고객은 5명이고 전원 서울 거주입니다. 연산자의 종류(BETWEEN, IN, LIKE, REGEXP ...)는 Step 05 에서 본격적으로 다룹니다.
4-6. ORDER BY — 정렬
SQL 의 결과에는 기본 순서가 없습니다. 테이블은 "행의 집합"이지 "리스트"가 아닙니다. 순서를 원하면 반드시 ORDER BY 를 써야 합니다.
SELECT product_id, name, price
FROM products
ORDER BY price DESC;
결과
+------------+---------------------------------+------------+
| product_id | name | price |
+------------+---------------------------------+------------+
| 14 | 게이밍 노트북 RTX4060 | 2190000.00 |
| 13 | 울트라북 14 i7/32GB | 1790000.00 |
| 17 | 스마트폰 X20 Pro 512GB | 1490000.00 |
| 12 | 울트라북 14 i5/16GB | 1290000.00 |
| 16 | 스마트폰 X20 256GB | 1150000.00 |
| 15 | 보급형 노트북 15 | 690000.00 |
| 34 | 원목 4인 식탁 | 459000.00 |
| 21 | 27인치 4K 모니터 | 459000.00 |
+------------+---------------------------------+------------+
... (총 40행)
기본은 ASC(오름차순)이고 DESC 로 내림차순을 지정합니다. 정렬 키마다 따로 지정한다는 점에 주의하세요 — ORDER BY a, b DESC 는 a ASC, b DESC 입니다.
ORDER BY 는 여러 컬럼을 순서대로 적용합니다.
SELECT customer_id, name, grade, points
FROM customers
ORDER BY grade DESC, points DESC;
결과
+-------------+-----------+--------+--------+
| customer_id | name | grade | points |
+-------------+-----------+--------+--------+
| 25 | 양현우 | VIP | 22400 |
| 6 | 강소라 | VIP | 20100 |
| 19 | 송민지 | VIP | 17300 |
| 13 | 장혜원 | VIP | 15800 |
| 1 | 김민수 | VIP | 12500 |
| 10 | 오세영 | GOLD | 9900 |
| 30 | 하준서 | GOLD | 9200 |
| 18 | 류하나 | GOLD | 8800 |
+-------------+-----------+--------+--------+
... (총 30행)
💡 실무 팁: grade 는 ENUM('BRONZE','SILVER','GOLD','VIP') 입니다. ENUM 은 사전순이 아니라 정의된 순서(내부 정수값) 로 정렬됩니다. 그래서 grade DESC 가 VIP → GOLD → SILVER → BRONZE 라는, 우리가 원하는 "등급 높은 순"이 됩니다. 사전순이었다면 VIP → SILVER → GOLD → BRONZE 라는 이상한 순서가 나왔을 겁니다. ENUM 컬럼을 문자열처럼 비교할 때 이 차이를 잊지 마세요.
ORDER BY 에서는 SELECT 별칭을 쓸 수 있습니다 (곧 볼 4-9 에서 이유를 설명합니다).
SELECT product_id, name, price - cost AS margin
FROM products
ORDER BY margin DESC
LIMIT 5;
결과
+------------+-----------------------------+-----------+
| product_id | name | margin |
+------------+-----------------------------+-----------+
| 14 | 게이밍 노트북 RTX4060 | 470000.00 |
| 13 | 울트라북 14 i7/32GB | 440000.00 |
| 17 | 스마트폰 X20 Pro 512GB | 340000.00 |
| 12 | 울트라북 14 i5/16GB | 310000.00 |
| 16 | 스마트폰 X20 256GB | 260000.00 |
+------------+-----------------------------+-----------+
5 rows in set
NULL 은 어디로 정렬되는가
MySQL 은 NULL 을 가장 작은 값으로 취급합니다. 즉 ASC 면 맨 앞, DESC 면 맨 뒤입니다.
SELECT customer_id, name, phone
FROM customers
ORDER BY phone ASC
LIMIT 6;
결과
+-------------+-----------+---------------+
| customer_id | name | phone |
+-------------+-----------+---------------+
| 28 | 심준호 | NULL |
| 14 | 남규리 | NULL |
| 7 | 윤대현 | NULL |
| 1 | 김민수 | 010-1000-0001 |
| 2 | 이지은 | 010-1000-0002 |
| 3 | 박철수 | 010-1000-0003 |
+-------------+-----------+---------------+
6 rows in set
⚠️ 함정: MySQL 에는 NULLS LAST 문법이 없습니다 (PostgreSQL/Oracle 에는 있습니다). "NULL 은 항상 뒤로" 를 원하면 정렬 키를 하나 더 만들어야 합니다.
SELECT customer_id, name, phone
FROM customers
ORDER BY (phone IS NULL) ASC, phone ASC -- IS NULL 은 0 또는 1 을 돌려준다
LIMIT 6;
결과
+-------------+-----------+---------------+
| customer_id | name | phone |
+-------------+-----------+---------------+
| 1 | 김민수 | 010-1000-0001 |
| 2 | 이지은 | 010-1000-0002 |
| 3 | 박철수 | 010-1000-0003 |
| 4 | 최영희 | 010-1000-0004 |
| 5 | 정 훈 | 010-1000-0005 |
| 6 | 강소라 | 010-1000-0006 |
+-------------+-----------+---------------+
6 rows in set
phone IS NULL 은 참이면 1, 거짓이면 0 을 돌려주는 불리언 표현식입니다. 0 이 먼저 오니까 값이 있는 행이 앞으로 옵니다.
4-7. LIMIT — 몇 건만
SELECT product_id, name, price
FROM products
ORDER BY price DESC
LIMIT 5;
결과
+------------+-----------------------------+------------+
| product_id | name | price |
+------------+-----------------------------+------------+
| 14 | 게이밍 노트북 RTX4060 | 2190000.00 |
| 13 | 울트라북 14 i7/32GB | 1790000.00 |
| 17 | 스마트폰 X20 Pro 512GB | 1490000.00 |
| 12 | 울트라북 14 i5/16GB | 1290000.00 |
| 16 | 스마트폰 X20 256GB | 1150000.00 |
+------------+-----------------------------+------------+
5 rows in set
건너뛰려면 OFFSET 을 씁니다. "가격 6~10위"는 5건을 건너뛴 뒤 5건입니다.
SELECT product_id, name, price
FROM products
ORDER BY price DESC
LIMIT 5 OFFSET 5; -- LIMIT 5, 5 와 동일 (MySQL 전용 축약형)
결과
+------------+------------------------------+-----------+
| product_id | name | price |
+------------+------------------------------+-----------+
| 15 | 보급형 노트북 15 | 690000.00 |
| 21 | 27인치 4K 모니터 | 459000.00 |
| 34 | 원목 4인 식탁 | 459000.00 |
| 18 | 스마트폰 A5 128GB | 399000.00 |
| 23 | 노이즈캔슬링 헤드폰 | 329000.00 |
+------------+------------------------------+-----------+
5 rows in set
⚠️ 함정 (아주 중요): ORDER BY 없는 LIMIT 은 아무것도 보장하지 않습니다.
SELECT order_id, customer_id, total_amount FROM orders LIMIT 3;
+----------+-------------+--------------+
| order_id | customer_id | total_amount |
+----------+-------------+--------------+
| 1 | 18 | 1836000.00 |
| 2 | 5 | 6663900.00 |
| 3 | 22 | 658000.00 |
+----------+-------------+--------------+
지금은 1, 2, 3 이 나왔지만 이건 우연히 InnoDB 가 PK 순으로 읽었기 때문입니다. 인덱스가 추가되거나 옵티마이저가 실행 계획을 바꾸면 순서가 바뀝니다. "아무 3건" 이 아니라 "특정 3건" 을 원한다면 반드시 ORDER BY 를 쓰세요.
더 나쁜 경우는 정렬 키에 동점(tie)이 있는 페이징입니다. 위 결과에서 price = 459000 인 상품이 2개(21번, 34번)죠? 1페이지 끝과 2페이지 시작이 이 동점 구간에 걸치면, 같은 행이 두 페이지에 나오거나 아예 누락될 수 있습니다. 정렬 키 뒤에 항상 PK 같은 유일한 컬럼을 붙여 tie-break 하세요: ORDER BY price DESC, product_id DESC.
💡 실무 팁: LIMIT 100000, 20 같은 깊은 OFFSET 은 재앙입니다. MySQL 은 100,020 행을 읽어서 100,000 행을 버립니다. 뒷페이지로 갈수록 선형으로 느려집니다. 해법은 커서 페이징(keyset pagination) 이고, Step 05 에서 다룹니다.
4-8. DISTINCT — 중복 제거
SELECT DISTINCT city FROM customers;
결과
+--------+
| city |
+--------+
| 서울 |
| 부산 |
| 대구 |
| 인천 |
| 광주 |
| 대전 |
| 수원 |
| 울산 |
+--------+
8 rows in set
30명의 고객이 8개 도시에 흩어져 있습니다.
⚠️ 함정: DISTINCT 는 함수가 아닙니다. DISTINCT(city) 라고 써도 동작하지만, 그건 DISTINCT (city) — 즉 괄호가 그냥 무시된 것일 뿐입니다. DISTINCT 는 SELECT 목록 전체에 걸립니다.
SELECT DISTINCT grade, city FROM customers ORDER BY grade, city;
결과
+--------+--------+
| grade | city |
+--------+--------+
| BRONZE | 광주 |
| BRONZE | 대구 |
| BRONZE | 대전 |
| BRONZE | 부산 |
| BRONZE | 서울 |
| BRONZE | 수원 |
| BRONZE | 울산 |
| SILVER | 광주 |
| SILVER | 대구 |
| SILVER | 대전 |
+--------+--------+
... (총 18행)
이건 "grade 의 DISTINCT" 도 "city 의 DISTINCT" 도 아닌 (grade, city) 조합의 DISTINCT 입니다. DISTINCT(grade), city 라고 써도 결과는 똑같습니다. 헷갈릴 여지를 없애려면 괄호를 쓰지 마세요.
💡 실무 팁: SELECT DISTINCT 가 쿼리에 등장한다면 "혹시 조인 때문에 행이 뻥튀기된 걸 덮으려는 건 아닌가?" 를 먼저 의심하세요. 실무에서 마주치는 DISTINCT 의 절반 이상은 잘못된 조인의 증상을 가리는 반창고입니다. 원인을 고치면 DISTINCT 는 필요 없어집니다 (Step 07).
4-9. 논리적 실행 순서 — 왜 WHERE 에서는 별칭을 못 쓸까
SQL 을 우리는 이 순서로 씁니다:
SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT
하지만 DB 는 이 순서로 평가합니다:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
① ② ③ ④ ⑤ ⑥ ⑦
SELECT 는 거의 마지막입니다. 그래서 SELECT 에서 만든 별칭은 WHERE 시점에는 아직 존재하지 않습니다.
SELECT product_id, price - cost AS margin
FROM products
WHERE margin > 300000; -- 에러!
결과
ERROR 1054 (42S22): Unknown column 'margin' in 'where clause'
반대로 ORDER BY 는 SELECT 다음이라 별칭을 알고 있습니다. 그래서 4-6 의 ORDER BY margin DESC 는 잘 동작했던 거죠. HAVING 도 별칭을 씁니다(Step 06).
해결책 두 가지입니다.
-- 해결책 ① 식을 그대로 반복 (가장 흔한 방법)
SELECT product_id, name, price - cost AS margin
FROM products
WHERE price - cost > 300000
ORDER BY margin DESC;
결과
+------------+-----------------------------+-----------+
| product_id | name | margin |
+------------+-----------------------------+-----------+
| 14 | 게이밍 노트북 RTX4060 | 470000.00 |
| 13 | 울트라북 14 i7/32GB | 440000.00 |
| 17 | 스마트폰 X20 Pro 512GB | 340000.00 |
| 12 | 울트라북 14 i5/16GB | 310000.00 |
+------------+-----------------------------+-----------+
4 rows in set
-- 해결책 ② 파생 테이블로 한 번 감싼다 (식이 길거나 여러 번 쓸 때)
SELECT *
FROM (
SELECT product_id, name, price - cost AS margin
FROM products
) AS t
WHERE t.margin > 300000
ORDER BY t.margin DESC;
결과 (①과 동일)
+------------+-----------------------------+-----------+
| product_id | name | margin |
+------------+-----------------------------+-----------+
| 14 | 게이밍 노트북 RTX4060 | 470000.00 |
| 13 | 울트라북 14 i7/32GB | 440000.00 |
| 17 | 스마트폰 X20 Pro 512GB | 340000.00 |
| 12 | 울트라북 14 i5/16GB | 310000.00 |
+------------+-----------------------------+-----------+
4 rows in set
파생 테이블에는 반드시 별칭(AS t)이 필요합니다. 빼면 ERROR 1248: Every derived table must have its own alias 가 납니다. (MySQL 8 부터는 CTE WITH 로 더 읽기 좋게 쓸 수 있습니다 — Step 09.)
테이블 별칭
조인을 배우기 전에도 테이블 별칭 습관을 들여두면 좋습니다.
SELECT p.product_id, p.name, p.price
FROM products AS p
WHERE p.status = 'ON_SALE'
ORDER BY p.price
LIMIT 3;
결과
+------------+-------------------------------+---------+
| product_id | name | price |
+------------+-------------------------------+---------+
| 30 | 다크초콜릿 72% 100g | 4900.00 |
| 29 | 프리미엄 라면 5입 | 6900.00 |
| 26 | 유기농 방울토마토 1kg | 8900.00 |
+------------+-------------------------------+---------+
3 rows in set
⚠️ 함정: 테이블에 별칭을 붙이면 원래 테이블 이름은 더 이상 못 씁니다. FROM products AS p WHERE products.price > 100 은 ERROR 1054: Unknown column 'products.price' 입니다. 별칭이 원래 이름을 완전히 대체합니다.
4-10. 종합 예제
지금까지 배운 것을 전부 씁니다. "판매 중이고 재고가 있는 상품 중 마진율 상위 5개".
SELECT
p.product_id AS id,
p.name AS 상품명,
p.price AS 판매가,
p.cost AS 원가,
ROUND((p.price - p.cost) / p.price * 100, 1) AS 마진율_pct
FROM products AS p
WHERE p.status = 'ON_SALE'
AND p.stock > 0
ORDER BY 마진율_pct DESC, p.price DESC
LIMIT 5;
결과
+----+--------------------------+-----------+----------+---------------+
| id | 상품명 | 판매가 | 원가 | 마진율_pct |
+----+--------------------------+-----------+----------+---------------+
| 30 | 다크초콜릿 72% 100g | 4900.00 | 1900.00 | 61.2 |
| 22 | USB-C 허브 8in1 | 59000.00 | 24000.00 | 59.3 |
| 20 | 무선 마우스 프로 | 79000.00 | 33000.00 | 58.2 |
| 28 | 콜드브루 원액 1L | 15900.00 | 6800.00 | 57.2 |
| 33 | 전기 주전자 1.7L | 49000.00 | 21000.00 | 57.1 |
+----+--------------------------+-----------+----------+---------------+
5 rows in set
재미있죠? 4-6 의 마진 금액 상위는 전부 노트북/스마트폰이었는데, 마진율 상위는 전부 저가 상품입니다. "무엇으로 정렬하는가" 가 답을 바꿉니다.
ORDER BY 마진율_pct DESC, p.price DESC 에서 두 번째 키는 tie-break 용입니다. 마진율이 같은 상품이 있을 때 순서가 흔들리지 않게 합니다.
정리
| 문법 | 용도 | 예시 |
|---|
SELECT 식 | 표현식을 평가해 결과 표를 만든다 (FROM 없어도 됨) | SELECT NOW(), 1+1; |
FROM 테이블 | 대상 테이블 지정 | FROM products |
SELECT * | 모든 컬럼. 탐색용으로만 | SELECT * FROM categories; |
AS 별칭 | 결과 컬럼/테이블에 이름. 공백·기호 포함 시 백틱 | price - cost AS \마진(원)`` |
WHERE 조건 | 조건이 TRUE 인 행만 남김 (FALSE·NULL 은 버림) | WHERE price >= 1000000 |
ORDER BY 키 [ASC|DESC] | 정렬. 키마다 따로 방향 지정 | ORDER BY grade DESC, points DESC |
ORDER BY (col IS NULL), col | NULL 을 뒤로 (MySQL 엔 NULLS LAST 없음) | ORDER BY (phone IS NULL), phone |
LIMIT n | 상위 n 건. ORDER BY 와 짝으로 | ORDER BY price DESC LIMIT 5 |
LIMIT n OFFSET m | m 건 건너뛰고 n 건 | LIMIT 5 OFFSET 5 |
DISTINCT | SELECT 목록 전체의 중복 제거 (함수 아님) | SELECT DISTINCT grade, city |
-- , #, /* */ | 주석 (-- 뒤엔 공백 필수) | -- 설명 |
논리적 실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
→ SELECT 별칭은 WHERE 에선 못 쓰고, HAVING/ORDER BY 에선 쓸 수 있다.
연습문제
products 에서 status 가 'SOLD_OUT' 인 상품의 product_id, name, stock 을 조회하세요.
customers 에서 points 가 높은 순으로 상위 3명의 name, grade, points 를 조회하되, 컬럼 별칭을 각각 이름, 등급, 포인트 로 붙이세요.
orders 의 shipping_city 를 중복 없이 조회하세요. (몇 개 나오나요? 4-8 의 customers.city 결과와 비교해 보세요.)
products 에서 가격이 10만원 이상이고 재고가 30개 미만인 상품을 가격 내림차순으로 조회하세요.
employees 에서 salary 가 4번째로 높은 사원 1명을 조회하세요. (힌트: LIMIT ... OFFSET ...)
products 에서 product_id, name, price * stock (재고 자산가치) 를 조회하되, 재고 자산가치 상위 5개만 보이세요. 별칭은 stock_value 로 하고, 동점 시 product_id 오름차순으로 tie-break 하세요.
customers 에서 phone 이 없는 고객을 맨 뒤로 보내면서, 나머지는 created_at 최신순으로 정렬해 상위 6명을 조회하세요.
- (생각해보기) 다음 쿼리는 왜 에러가 날까요? 두 가지 방법으로 고쳐 보세요.
SELECT customer_id, name, points * 2 AS bonus
FROM customers
WHERE bonus > 20000;
문제만 담긴 파일은 exercise.sql, 정답과 해설은 solution.sql 입니다. 두 파일의 전문은 아래 실습 파일 섹션에 있습니다.
다음 단계
→ Step 05 — 연산자와 조건
실습 파일
이 스텝은 SQL 파일 3개로 이루어집니다. 먼저 practice.sql 로 본문 4-1 ~ 4-10 의 예제를 그대로 따라 실행해 보고, 그다음 exercise.sql 의 빈칸을 스스로 채워 8개 문제를 풀고, 마지막으로 solution.sql 로 답을 맞춰 보는 순서입니다. 세 파일 모두 첫 줄에 USE shop; 이 있으므로 Step 03 에서 만든 shop 데이터베이스가 이미 적재되어 있어야 합니다.
practice.sql
본문에 나온 모든 예제 쿼리를 절 번호([4-1] ~ [4-10]) 주석과 함께 한 파일에 모아둔 따라치기용 스크립트입니다. 강의를 읽으며 한 덩어리씩 복사해 실행해도 되고, 통째로 흘려보내며 결과를 훑어봐도 됩니다.
- 실행 방법은 파일 상단 주석에 있습니다.
mysql ... shop -t < practice.sql 의 -t 옵션이 핵심입니다. 파이프 입력일 때 MySQL 은 기본적으로 탭 구분 텍스트를 뱉는데, -t 를 줘야 본문에 나온 것과 같은 +---+ 표 형식으로 출력됩니다. 접속 후 source practice.sql 로 실행해도 됩니다.
SELECT product_id, name price FROM products; (32번째 줄) 는 일부러 콤마를 빠뜨린 사고 재현용입니다. 에러 없이 조용히 통과하면서 price 컬럼 자리에 상품명이 들어앉는 것을 직접 확인하는 것이 목적입니다 (4-3).
-- SELECT product_id, price - cost AS margin FROM products WHERE margin > 300000; 는 주석 처리된 채로 들어 있습니다. ERROR 1054 로 스크립트 전체가 중단되지 않게 하려는 배려이니, 이 에러를 눈으로 보고 싶다면 주석을 풀지 말고 별도 세션에 직접 붙여넣어 실행하세요 (4-9).
- 마지막
[4-10] 블록의 ORDER BY 마진율_pct DESC, p.price DESC 는 SELECT 에서 만든 한글 별칭을 ORDER BY 가 알아본다는 것과, 두 번째 키가 tie-break 라는 것을 동시에 보여주는 종합 예제입니다.
-- =====================================================================
-- Step 04 — SELECT 기본 : practice.sql
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < practice.sql
-- 또는 mysql 접속 후: source practice.sql
-- =====================================================================
USE shop;
-- [4-1] 테이블 없이 SELECT — 계산기처럼 쓰기
SELECT 1 + 1;
-- [4-1] 여러 표현식을 한 번에
SELECT 1 + 1, NOW(), VERSION(), 'hello';
-- [4-2] FROM: 테이블의 모든 컬럼 (* 는 학습/탐색용)
SELECT * FROM categories;
-- [4-2] 필요한 컬럼만 명시 (실무의 기본)
SELECT customer_id, name, grade, city FROM customers;
-- [4-3] 컬럼 별칭 (AS). 공백/한글 별칭은 백틱으로 감싼다
SELECT
product_id AS id,
name AS 상품명,
price AS 판매가,
price - cost AS `마진(원)`
FROM products;
-- [4-3] AS 생략 가능 (권장하지 않음 — 콤마 빠뜨리면 별칭이 되어버린다)
SELECT product_id id, name 상품명 FROM products;
-- [4-3] 콤마를 빠뜨린 사고: name 이 price 의 별칭이 되어 컬럼이 사라진다
SELECT product_id, name price FROM products;
-- [4-4] 주석 3종
SELECT 1 AS a; -- 한 줄 주석 (-- 뒤에 반드시 공백)
# MySQL 전용 한 줄 주석
/* 여러 줄
주석 */
SELECT 2 AS b;
-- [4-5] WHERE: 행 걸러내기
SELECT product_id, name, price, stock
FROM products
WHERE price >= 1000000;
-- [4-5] WHERE + 여러 조건
SELECT customer_id, name, grade, city, points
FROM customers
WHERE grade = 'VIP' AND city = '서울';
-- [4-6] ORDER BY: 정렬 (기본 ASC)
SELECT product_id, name, price
FROM products
ORDER BY price DESC;
-- [4-6] 다중 정렬 키
SELECT customer_id, name, grade, points
FROM customers
ORDER BY grade DESC, points DESC;
-- [4-6] 별칭으로 정렬 — ORDER BY 에서는 허용된다
SELECT product_id, name, price - cost AS margin
FROM products
ORDER BY margin DESC
LIMIT 5;
-- [4-6] NULL 의 정렬 위치: ASC 면 맨 앞, DESC 면 맨 뒤
SELECT customer_id, name, phone
FROM customers
ORDER BY phone ASC
LIMIT 6;
-- [4-6] NULL 을 항상 뒤로 보내기
SELECT customer_id, name, phone
FROM customers
ORDER BY (phone IS NULL) ASC, phone ASC
LIMIT 6;
-- [4-7] LIMIT: 상위 N건
SELECT product_id, name, price
FROM products
ORDER BY price DESC
LIMIT 5;
-- [4-7] LIMIT offset, count — 2페이지(6~10위)
SELECT product_id, name, price
FROM products
ORDER BY price DESC
LIMIT 5 OFFSET 5;
-- [4-7] ORDER BY 없는 LIMIT 은 순서를 보장하지 않는다 (나쁜 예)
SELECT order_id, customer_id, total_amount FROM orders LIMIT 3;
-- [4-8] DISTINCT: 중복 제거
SELECT DISTINCT city FROM customers;
-- [4-8] DISTINCT 는 SELECT 목록 "전체"에 걸린다 (조합의 유일성)
SELECT DISTINCT grade, city FROM customers ORDER BY grade, city;
-- [4-8] DISTINCT 는 함수가 아니다 — 이건 (grade, city) 조합의 DISTINCT 다
SELECT DISTINCT grade, city FROM customers WHERE city = '서울';
-- [4-9] 논리적 실행 순서 확인 ①: WHERE 에서는 SELECT 별칭을 못 쓴다 (에러)
-- SELECT product_id, price - cost AS margin FROM products WHERE margin > 300000;
-- [4-9] 해결책 ①: 식을 그대로 반복
SELECT product_id, name, price - cost AS margin
FROM products
WHERE price - cost > 300000
ORDER BY margin DESC;
-- [4-9] 해결책 ②: 파생 테이블(서브쿼리)로 한 번 감싸기
SELECT *
FROM (
SELECT product_id, name, price - cost AS margin
FROM products
) AS t
WHERE t.margin > 300000
ORDER BY t.margin DESC;
-- [4-9] 테이블 별칭 — 조인 전에도 습관을 들여두면 좋다
SELECT p.product_id, p.name, p.price
FROM products AS p
WHERE p.status = 'ON_SALE'
ORDER BY p.price
LIMIT 3;
-- [4-10] 종합: "재고가 있는 판매중 상품 중 마진율 상위 5개"
SELECT
p.product_id AS id,
p.name AS 상품명,
p.price AS 판매가,
p.cost AS 원가,
ROUND((p.price - p.cost) / p.price * 100, 1) AS 마진율_pct
FROM products AS p
WHERE p.status = 'ON_SALE'
AND p.stock > 0
ORDER BY 마진율_pct DESC, p.price DESC
LIMIT 5;
exercise.sql
연습문제 8개를 문제 설명 주석만 남기고 답안 자리를 비워둔 파일입니다. 본문 "연습문제" 절과 내용이 1:1로 대응하므로, 이 파일을 편집기로 열어 각 블록 아래에 직접 쿼리를 써 넣은 뒤 실행해 보세요.
- 문제 1
4 는 WHERE / 별칭 / DISTINCT / AND 같은 기본기를, 문제 57 은 LIMIT ... OFFSET, tie-break, NULL 정렬을 묻습니다.
- 문제 5 "salary 가 4번째로 높은 사원" 은 힌트대로
ORDER BY salary DESC 뒤에 LIMIT 1 OFFSET 3 을 붙이는 문제입니다. "N번째"는 OFFSET N-1 이라는 점을 헷갈리기 쉽습니다.
- 문제 8 은 답을 쓰는 문제가 아니라 에러를 설명하는 문제입니다. 주석 안에 들어 있는
WHERE bonus > 20000 쿼리는 4-9 의 논리적 실행 순서 때문에 ERROR 1054 가 나며, "고친 쿼리 ①(식 반복)" 과 "고친 쿼리 ②(파생 테이블)" 두 자리를 채우게 되어 있습니다.
- 이 파일을 그대로 실행하면
USE shop; 만 수행되고 아무 결과도 나오지 않는 것이 정상입니다. 답안을 채운 뒤에 실행하세요.
-- =====================================================================
-- Step 04 — SELECT 기본 : exercise.sql (문제)
-- 정답은 solution.sql 을 보기 전에 스스로 먼저 작성해 보세요.
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < exercise.sql
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [문제 1] 품절 상품 찾기
-- products 에서 status 가 'SOLD_OUT' 인 상품의
-- product_id, name, stock 을 조회하세요.
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 2] 포인트 상위 3명 (한글 별칭)
-- customers 에서 points 가 높은 순으로 상위 3명의 name, grade, points 를
-- 조회하되, 컬럼 별칭을 각각 이름 / 등급 / 포인트 로 붙이세요.
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 3] 배송 도시 목록
-- orders 의 shipping_city 를 중복 없이 조회하세요.
-- (몇 개가 나오나요? customers.city 의 DISTINCT 결과와 비교해 보세요.)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 4] 고가 + 재고부족 상품
-- products 에서 가격이 100,000 이상이고 재고가 30 미만인 상품을
-- 가격 내림차순으로 조회하세요. (product_id, name, price, stock)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 5] 연봉 4위 사원
-- employees 에서 salary 가 4번째로 높은 사원 1명을 조회하세요.
-- 힌트: ORDER BY + LIMIT ... OFFSET ...
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 6] 재고 자산가치 TOP 5
-- products 에서 product_id, name, price * stock 을 조회하세요.
-- - price * stock 의 별칭은 stock_value
-- - stock_value 내림차순 상위 5개
-- - 동점이면 product_id 오름차순으로 tie-break
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 7] NULL 을 뒤로 보내는 정렬
-- customers 에서 phone 이 NULL 인 고객을 맨 뒤로 보내면서,
-- 나머지는 created_at 최신순으로 정렬해 상위 6명을 조회하세요.
-- (customer_id, name, phone, created_at)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 8] 에러 고치기 (생각해보기)
-- 아래 쿼리는 왜 에러가 날까요? 두 가지 방법으로 고쳐 보세요.
--
-- SELECT customer_id, name, points * 2 AS bonus
-- FROM customers
-- WHERE bonus > 20000;
--
-- 고친 쿼리 ① (식 반복):
--
-- 고친 쿼리 ② (파생 테이블):
-- ---------------------------------------------------------------------
solution.sql
exercise.sql 의 8개 문제에 대한 정답 쿼리 + 해설 주석입니다. 먼저 스스로 풀어 본 뒤에 여세요.
- 각 정답 블록의 주석에 기대 결과까지 적혀 있습니다. 예를 들어 정답 1 은 "4번(울 니트 스웨터), 27번(노르웨이 연어 필렛) — 둘 다 stock = 0", 정답 6 은 "1위: 스마트폰 X20 256GB (1,150,000 x 50 = 57,500,000)" 이므로, 내 쿼리 결과와 눈으로 대조할 수 있습니다.
- 정답 5 의 주석은
LIMIT 1 OFFSET 3 이 동점(tie)이 있으면 위험하다고 경고하며 RANK() / DENSE_RANK() 윈도우 함수를 권합니다. 여기서는 employees.salary 가 전부 달라서 우연히 안전할 뿐입니다. 윈도우 함수는 Step 17 에서 다룹니다.
- 정답 7 의
ORDER BY (phone IS NULL) ASC, created_at DESC 는 이 스텝의 하이라이트입니다. 주석이 짚듯 심준호는 created_at 이 2024-03-03 이라 최신순만 보면 2위지만, phone 이 NULL 이라 첫 번째 정렬 키가 1 이 되어 맨 뒤로 밀립니다. 상위 6명에 안 보이는 것이 버그가 아니라 정답입니다.
- 정답 8 은
WHERE points * 2 > 20000 로 식을 반복하는 방법과, 파생 테이블 AS t 로 감싸 WHERE t.bonus > 20000 을 쓰는 방법을 나란히 보여줍니다. 두 쿼리의 결과는 동일합니다.
-- =====================================================================
-- Step 04 — SELECT 기본 : solution.sql (정답 + 해설)
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < solution.sql
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [정답 1] 품절 상품 찾기
-- ENUM 컬럼도 문자열 리터럴로 비교합니다. 따옴표를 잊지 마세요.
-- 결과: 4번(울 니트 스웨터), 27번(노르웨이 연어 필렛) — 둘 다 stock = 0
-- ---------------------------------------------------------------------
SELECT product_id, name, stock
FROM products
WHERE status = 'SOLD_OUT';
-- ---------------------------------------------------------------------
-- [정답 2] 포인트 상위 3명 (한글 별칭)
-- 한글 별칭은 백틱 없이도 되지만, 공백/기호가 섞이면 백틱이 필요합니다.
-- 결과: 양현우(22400) / 강소라(20100) / 송민지(17300) — 셋 다 VIP
-- ---------------------------------------------------------------------
SELECT
name AS 이름,
grade AS 등급,
points AS 포인트
FROM customers
ORDER BY points DESC
LIMIT 3;
-- ---------------------------------------------------------------------
-- [정답 3] 배송 도시 목록
-- 8개 나옵니다. customers.city 와 동일합니다.
-- 03_seed_orders.sql 이 shipping_city 를 고객의 city 로 그대로 복사했기
-- 때문입니다. "주문 데이터의 도시 목록"과 "고객 마스터의 도시 목록"이
-- 같은지 확인하는 것은 실무에서 흔한 데이터 정합성 점검입니다.
-- ---------------------------------------------------------------------
SELECT DISTINCT shipping_city FROM orders;
-- ---------------------------------------------------------------------
-- [정답 4] 고가 + 재고부족 상품
-- AND 로 두 조건을 겹칩니다. 9건이 나옵니다.
-- (게이밍노트북 / 울트라북 i7·i5 / 스마트폰 X20 Pro / 원목식탁 /
-- 인체공학 의자 / 트렌치코트 / 첼시부츠 / 라이트 다운 재킷)
-- 고가 상품일수록 재고를 적게 잡아둔다는 사실이 한눈에 보입니다.
-- ---------------------------------------------------------------------
SELECT product_id, name, price, stock
FROM products
WHERE price >= 100000
AND stock < 30
ORDER BY price DESC;
-- ---------------------------------------------------------------------
-- [정답 5] 연봉 4위 사원
-- "N번째"는 (N-1)건을 건너뛰고 1건 = LIMIT 1 OFFSET 3.
-- 결과: 오지원(경영지원 본부장, 8,800,000)
--
-- [주의] 동점(tie)이 있으면 OFFSET 방식은 위험합니다. 여기서는 salary 가
-- 전부 다르지만, 실무에선 RANK()/DENSE_RANK() 윈도우 함수(Step 17)를
-- 쓰는 편이 훨씬 안전합니다.
-- ---------------------------------------------------------------------
SELECT employee_id, name, dept, position, salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 3;
-- ---------------------------------------------------------------------
-- [정답 6] 재고 자산가치 TOP 5
-- ORDER BY 에서는 SELECT 별칭(stock_value)을 쓸 수 있습니다.
-- product_id 를 두 번째 정렬 키로 붙여 동점 시 순서를 고정합니다.
-- 결과 1위: 스마트폰 X20 256GB (1,150,000 x 50 = 57,500,000)
-- ---------------------------------------------------------------------
SELECT
product_id,
name,
price * stock AS stock_value
FROM products
ORDER BY stock_value DESC, product_id ASC
LIMIT 5;
-- ---------------------------------------------------------------------
-- [정답 7] NULL 을 뒤로 보내는 정렬
-- MySQL 에는 NULLS LAST 가 없으므로 (phone IS NULL) 을 첫 번째 정렬 키로
-- 씁니다. 값이 있으면 0, NULL 이면 1 → ASC 정렬 시 값 있는 행이 앞으로.
-- 그 다음 created_at DESC 로 최신순.
-- 결과: 하준서 → 우예린 → 전소민 → 배채영 → 양현우 → 구세진
-- [핵심] 심준호는 created_at 이 2024-03-03 이라 "최신순"만 보면 2위입니다.
-- 하지만 phone 이 NULL 이라 첫 번째 정렬 키가 1 이 되어 맨 뒤로 밀립니다.
-- → 상위 6명 안에 안 보이는 것이 정상입니다.
-- ---------------------------------------------------------------------
SELECT customer_id, name, phone, created_at
FROM customers
ORDER BY (phone IS NULL) ASC, created_at DESC
LIMIT 6;
-- ---------------------------------------------------------------------
-- [정답 8] 에러 고치기
-- 원인: 논리적 실행 순서가 FROM → WHERE → ... → SELECT 이므로,
-- WHERE 가 평가될 때 SELECT 의 별칭 bonus 는 아직 존재하지 않습니다.
-- → ERROR 1054 (42S22): Unknown column 'bonus' in 'where clause'
--
-- [고친 쿼리 ①] 식을 그대로 반복
-- ---------------------------------------------------------------------
SELECT customer_id, name, points * 2 AS bonus
FROM customers
WHERE points * 2 > 20000
ORDER BY bonus DESC;
-- ---------------------------------------------------------------------
-- [고친 쿼리 ②] 파생 테이블로 감싸기
-- 바깥 쿼리 입장에서 bonus 는 이미 "t 라는 테이블의 컬럼" 이므로
-- WHERE 에서 자유롭게 쓸 수 있습니다. 파생 테이블에는 별칭이 필수입니다.
-- 두 쿼리 모두 결과 동일: 양현우(44800) / 강소라(40200) / 송민지(34600) /
-- 장혜원(31600) / 김민수(25000)
-- ---------------------------------------------------------------------
SELECT *
FROM (
SELECT customer_id, name, points * 2 AS bonus
FROM customers
) AS t
WHERE t.bonus > 20000
ORDER BY t.bonus DESC;