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 는 동작합니다.

SELECT 1 + 1;

결과

+-------+
| 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_idNULL 인 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 * 를 애플리케이션 코드에 넣지 마세요. 세 가지 이유가 있습니다.

  1. 누가 테이블에 컬럼을 추가하면 네트워크로 오가는 데이터가 조용히 늘어납니다. TEXT/BLOB 컬럼이 추가되면 재앙입니다.
  2. 커버링 인덱스(Step 20)를 쓸 기회를 날립니다. 필요한 컬럼만 SELECT 하면 인덱스만 읽고 끝날 쿼리가, * 때문에 테이블 본문까지 읽게 됩니다.
  3. 컬럼 순서에 의존하는 코드(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 DESCa 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행)

💡 실무 팁: gradeENUM('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) — 즉 괄호가 그냥 무시된 것일 뿐입니다. DISTINCTSELECT 목록 전체에 걸립니다.

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 > 100ERROR 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), colNULL 을 뒤로 (MySQL 엔 NULLS LAST 없음)ORDER BY (phone IS NULL), phone
LIMIT n상위 n 건. ORDER BY 와 짝으로ORDER BY price DESC LIMIT 5
LIMIT n OFFSET mm 건 건너뛰고 n 건LIMIT 5 OFFSET 5
DISTINCTSELECT 목록 전체의 중복 제거 (함수 아님)SELECT DISTINCT grade, city
-- , #, /* */주석 (-- 뒤엔 공백 필수)-- 설명

논리적 실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT → SELECT 별칭은 WHERE 에선 못 쓰고, HAVING/ORDER BY 에선 쓸 수 있다.


연습문제

  1. products 에서 status'SOLD_OUT' 인 상품의 product_id, name, stock 을 조회하세요.
  2. customers 에서 points 가 높은 순으로 상위 3명의 name, grade, points 를 조회하되, 컬럼 별칭을 각각 이름, 등급, 포인트 로 붙이세요.
  3. ordersshipping_city 를 중복 없이 조회하세요. (몇 개 나오나요? 4-8 의 customers.city 결과와 비교해 보세요.)
  4. products 에서 가격이 10만원 이상이고 재고가 30개 미만인 상품을 가격 내림차순으로 조회하세요.
  5. employees 에서 salary 가 4번째로 높은 사원 1명을 조회하세요. (힌트: LIMIT ... OFFSET ...)
  6. products 에서 product_id, name, price * stock (재고 자산가치) 를 조회하되, 재고 자산가치 상위 5개만 보이세요. 별칭은 stock_value 로 하고, 동점 시 product_id 오름차순으로 tie-break 하세요.
  7. customers 에서 phone 이 없는 고객을 맨 뒤로 보내면서, 나머지는 created_at 최신순으로 정렬해 상위 6명을 조회하세요.
  8. (생각해보기) 다음 쿼리는 왜 에러가 날까요? 두 가지 방법으로 고쳐 보세요.
    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로 대응하므로, 이 파일을 편집기로 열어 각 블록 아래에 직접 쿼리를 써 넣은 뒤 실행해 보세요.

  • 문제 14 는 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;