SQL 기초: SELECT·WHERE·ORDER BY 와 NULL 함정
SQL 강좌 1(초급). SQLite 3.51 로 5분 만에 첫 테이블을 만들고 WHERE·ORDER BY·LIMIT, = NULL 이 0건인 이유, 타입 친화성의 함정, 100만 행 실측까지 실행 결과로 설명.
시리즈: SQL 강좌 1/6 (초급). SQL 을 한 번도 직접 쳐 본 적 없는 분, 또는 선배가 준 쿼리를 복사해서 쓰면서도 정확히 무슨 일을 하는지 확신이 없는 분을 위한 글입니다. 다 읽으면 테이블을 만들고, 원하는 행과 열만 꺼내고, 정렬하고, 틀린 결과의 대부분을 만드는 두 가지 함정인 NULL 과 타입을 피할 수 있습니다.
국비 과정이나 학교에서 오라클로 SQL 을 배웠다면 문법은 낯익겠지만, 실무에서 진짜 무서운 건 오류 메시지가 아닙니다. 오류 없이 실행되면서 조용히 틀린 행을 돌려주는 쿼리입니다. 정산 리포트의 숫자가 몇 건 모자라는데 아무도 이유를 모르는 상황은 대개 이 글에 나오는 NULL 처리에서 시작합니다. 아래 출력은 전부 Apple Silicon Mac(M1 Pro)의 sqlite3 3.51.0 명령줄 셸에서 직접 실행해 붙여 넣은 것입니다.
왜 SQLite 냐면, 서버 설치 없이 파일 하나로 동작하는 완전한 SQL 데이터베이스이고 맥에는 처음부터 들어 있기 때문입니다(아이폰·안드로이드 폰 안에도 들어 있습니다). 여기서 배운 내용은 MySQL·PostgreSQL·오라클에도 그대로 통하고, 다른 부분은 "여기서는 실행하지 않음"이라고 표시해 따로 적었습니다.
5분 만에 첫 쿼리
터미널을 열고 파일 이름을 붙여 SQLite 셸을 시작합니다. 파일이 없으면 새로 만들어집니다.
$ sqlite3 shop.db
SQLite version 3.51.0 2025-06-12 13:14:41
Enter ".help" for usage hints.
sqlite>
윈도우는 sqlite.org 에서 "command-line tools" 묶음을 받고, 리눅스는 sqlite3 패키지를 설치합니다(둘 다 여기서는 실행하지 않음). .headers on 처럼 점으로 시작하는 줄은 SQL 이 아니라 셸 자체에 주는 명령입니다. 컬럼 이름과 표 모양 출력을 켭니다.
sqlite> .headers on
sqlite> .mode table
이제 아래를 붙여 넣습니다. CREATE TABLE 은 테이블(행과 열로 된 표)과 각 열의 타입을 정의하고, INSERT 는 행을 추가합니다. 예시는 작은 문구·전자 소품 가게의 상품 목록입니다.
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category TEXT,
price INTEGER,
stock INTEGER
);
INSERT INTO products (name, category, price, stock) VALUES
('Notebook A5', 'stationery', 350, 120),
('Gel pen', 'stationery', 120, 300),
('USB-C cable', 'electronics', 980, 45),
('Mouse', 'electronics', 2480, 0),
('Desk lamp', 'furniture', 4200, 12),
('Sticky notes', 'stationery', 200, NULL),
('Mystery box', NULL, 1500, 5),
('Keyboard', 'electronics', 5980, 8);
INTEGER PRIMARY KEY 는 "행마다 겹치지 않는 번호"이고, 값을 주지 않았으니 SQLite 가 1, 2, 3… 을 채웠습니다. NULL 은 "값 없음 / 모름"입니다(포스트잇 재고는 모르고, 미스터리 박스는 분류가 없음). 첫 쿼리 SELECT *("모든 열")입니다.
SELECT * FROM products;
+----+--------------+-------------+-------+-------+
| id | name | category | price | stock |
+----+--------------+-------------+-------+-------+
| 1 | Notebook A5 | stationery | 350 | 120 |
| 2 | Gel pen | stationery | 120 | 300 |
| 3 | USB-C cable | electronics | 980 | 45 |
| 4 | Mouse | electronics | 2480 | 0 |
| 5 | Desk lamp | furniture | 4200 | 12 |
| 6 | Sticky notes | stationery | 200 | |
| 7 | Mystery box | | 1500 | 5 |
| 8 | Keyboard | electronics | 5980 | 8 |
+----+--------------+-------------+-------+-------+
NULL 이 빈칸으로 찍혀서 빈 문자열 '' 과 구별이 안 됩니다. .nullvalue NULL 을 입력하면 보이게 되고, 이후 출력은 그 상태입니다. SQL 을 파일로 저장해 sqlite3 shop.db < setup.sql 로 실행해도 됩니다.
SELECT 와 WHERE: 열과 행 고르기
SELECT 는 열을, WHERE 는 행을 고릅니다. 조건이 참인 행만 남습니다.
SELECT name, price FROM products WHERE price < 1000;
+--------------+-------+
| name | price |
+--------------+-------+
| Notebook A5 | 350 |
| Gel pen | 120 |
| USB-C cable | 980 |
| Sticky notes | 200 |
+--------------+-------+
문자열은 작은따옴표로 씁니다: WHERE category = 'electronics'. 자주 쓰는 연산자는 =, <>(같지 않음, != 도 됨), <, >=, BETWEEN 200 AND 980(양 끝 포함), IN ('a', 'b'), LIKE 'mo%'(% 는 아무 글자 여러 개, _ 는 한 글자)입니다. 직접 확인한 점: SQLite 의 LIKE 는 영문 대소문자를 구분하지 않아 LIKE 'mo%' 가 Mouse 를 찾았지만, = 는 구분해서 name = 'mouse' 는 0건이었습니다.
AND 가 OR 보다 먼저 묶인다
오류 없이 실행되지만 틀린 행이 나오는 대표 사례입니다. 의도는 "문구 또는 전자 제품 중 300원 초과"였습니다.
SELECT name, category, price FROM products
WHERE category = 'stationery' OR category = 'electronics' AND price > 300;
+--------------+-------------+-------+
| name | category | price |
+--------------+-------------+-------+
| Notebook A5 | stationery | 350 |
| Gel pen | stationery | 120 |
| USB-C cable | electronics | 980 |
| Mouse | electronics | 2480 |
| Sticky notes | stationery | 200 |
| Keyboard | electronics | 5980 |
+--------------+-------------+-------+
젤펜(120)과 포스트잇(200)이 섞여 들어왔습니다. SQL 이 stationery OR (electronics AND price > 300) 으로 읽었기 때문입니다. 괄호를 친 WHERE (category = 'stationery' OR category = 'electronics') AND price > 300 은 Notebook A5, USB-C cable, Mouse, Keyboard 의 정확한 4건을 돌려줬습니다. AND 와 OR 를 섞을 때는 무조건 괄호를 칩시다.
ORDER BY 와 LIMIT
ORDER BY 가 없으면 데이터베이스는 행을 어떤 순서로 돌려줘도 됩니다. 넣은 순서처럼 보일 때가 많지만 보장은 없고, 3강에서 JOIN 결과가 실제로 다른 순서로 나오는 것을 보여 줍니다. 그러니 정렬은 명시하고, 큰 값부터는 DESC 를 붙입니다.
SELECT name, price FROM products ORDER BY price DESC LIMIT 3;
SELECT name, price FROM products ORDER BY price DESC LIMIT 3 OFFSET 3;
+-----------+-------+
| name | price |
+-----------+-------+
| Keyboard | 5980 |
| Desk lamp | 4200 |
| Mouse | 2480 |
+-----------+-------+
+-------------+-------+
| name | price |
+-------------+-------+
| Mystery box | 1500 |
| USB-C cable | 980 |
| Notebook A5 | 350 |
+-------------+-------+
LIMIT 3 OFFSET 3 은 "한 페이지 3건일 때 2페이지"입니다. 오라클을 배웠다면 ROWNUM 대신 이것을 쓴다고 생각하면 됩니다. 여러 열로 정렬하려면 ORDER BY category, price DESC 처럼 씁니다. 절은 정해진 순서(SELECT … FROM … WHERE … ORDER BY … LIMIT)로 써야 하지만, 데이터베이스가 평가하는 순서는 아래 그림처럼 다릅니다. 그래서 SELECT 에서 붙인 별칭을 ORDER BY 에서는 쓸 수 있고 WHERE 에서는 못 씁니다.
NULL: 자기 자신과도 같지 않은 값
NULL 은 "모름"입니다. 모르는 값과의 비교 결과도 모름(NULL)이고, WHERE 는 참이 아닌 것을 모두 버립니다.
SELECT name, stock FROM products WHERE stock = NULL; -- 0건!
SELECT name, stock FROM products WHERE stock IS NULL;
SELECT NULL = NULL, NULL IS NULL, NULL + 1, 'a' || NULL, coalesce(NULL, 0);
+--------------+-------+
| name | stock |
+--------------+-------+
| Sticky notes | NULL |
+--------------+-------+
+-------------+--------------+----------+-------------+-------------------+
| NULL = NULL | NULL IS NULL | NULL + 1 | 'a' || NULL | coalesce(NULL, 0) |
+-------------+--------------+----------+-------------+-------------------+
| NULL | 1 | NULL | NULL | 0 |
+-------------+--------------+----------+-------------+-------------------+
첫 쿼리는 오류도 없이 아무것도 출력하지 않았습니다. NULL 과의 덧셈이나 문자열 연결(||)도 NULL 이 됩니다. coalesce(x, 0) 은 "x, 단 NULL 이면 0"입니다(오라클의 NVL 에 해당).
같은 테이블에서 NULL 이 문제를 일으킨 곳이 세 군데 더 있었습니다.
WHERE stock <> 0은 6건이었습니다. 재고 0인 마우스는 의도대로 빠졌지만,NULL <> 0이 참이 아니라서 포스트잇까지 빠졌습니다.count(*)는 행 수,count(stock)은 NULL 이 아닌 값의 수입니다. 결과는8,7이었습니다.WHERE category NOT IN ('furniture', NULL)은 0건이었습니다.NOT IN은category <> 'furniture' AND category <> NULL로 풀리는데 뒤쪽이 절대 참이 될 수 없습니다. 목록에서NULL을 빼면 6건이었고, 분류가 NULL 인 미스터리 박스는 같은 이유로 여전히 빠졌습니다.
정렬: SQLite 는 오름차순에서 NULL 을 맨 앞에 둡니다. SQLite 3.30 부터는 위치를 지정할 수 있습니다.
SELECT name, stock FROM products ORDER BY stock NULLS LAST;
그냥 ORDER BY stock 은 첫 행이 Sticky notes | NULL 이었고, NULLS LAST 를 붙이자 Gel pen | 300 다음 맨 끝으로 갔습니다. PostgreSQL 과 오라클은 기본이 반대(오름차순에서 NULL 이 뒤)라서(여기서는 실행하지 않음), 순서가 중요하면 항상 NULLS FIRST/LAST 를 써 두는 편이 안전합니다.
타입 친화성: 'ten' > 5 가 참인 이유
대부분의 데이터베이스는 숫자 열에 글자를 넣으면 거부합니다. 일반 SQLite 테이블은 다릅니다. 열의 타입은 타입 친화성(type affinity) 이라는 "선호"일 뿐이라, 변환할 수 있으면 변환하고 아니면 들어온 그대로 저장합니다. 일부러 지저분한 데이터를 넣어 봤습니다.
CREATE TABLE items (code TEXT, qty INTEGER, price REAL);
INSERT INTO items VALUES ('9', '10', '1.50'), ('10', 'ten', 2), ('2', 3.0, '1e2'), (007, '007', NULL);
SELECT code, typeof(code), qty, typeof(qty), price, typeof(price) FROM items;
+------+--------------+-----+-------------+-------+---------------+
| code | typeof(code) | qty | typeof(qty) | price | typeof(price) |
+------+--------------+-----+-------------+-------+---------------+
| 9 | text | 10 | integer | 1.5 | real |
| 10 | text | ten | text | 2.0 | real |
| 2 | text | 3 | integer | 100.0 | real |
| 7 | text | 7 | integer | | null |
+------+--------------+-----+-------------+-------+---------------+
'10' 은 정수 10, 3.0 은 3, '1e2' 는 100.0 이 됐고, 숫자 007 은 텍스트 '7' 이 됐습니다(앞의 0 이 사라짐: 사원번호·우편번호를 숫자로 넣으면 생기는 일). 그런데 'ten' 은 INTEGER 열 안에 텍스트로 남았습니다. 그 결과는 이렇습니다.
ORDER BY code는10, 2, 7, 9. 텍스트는 한 글자씩 비교합니다.ORDER BY CAST(code AS INTEGER)는2, 7, 9, 10.WHERE qty > 5는10,ten,7을 돌려줬습니다. SQLite 에서 텍스트는 어떤 숫자보다도 크므로'ten' > 5가 참입니다.7 / 2는3(정수 나눗셈),7 / 2.0은3.5.'3' + '4'는7,'3' || '4'는'34','abc' + 1은 오류 없이1.0.1 + 0.2 = 0.3은0(거짓)이었습니다.0.1 + 0.2자체는0.3으로 출력되는데도 말이죠. 금액은 REAL 이 아니라 정수(원 단위)로 저장합니다.
해결책은 타입을 강제하는 STRICT 테이블(SQLite 3.37+)입니다.
CREATE TABLE items_strict (code TEXT, qty INTEGER, price REAL) STRICT;
INSERT INTO items_strict VALUES ('9', '10', '1.50'); -- 변환됨: qty 정수, price 실수
INSERT INTO items_strict VALUES ('10', 'ten', 2);
CREATE TABLE bad (d DATETIME) STRICT;
Runtime error near line 14: cannot store TEXT value in INTEGER column items_strict.qty (19)
Parse error near line 15: unknown datatype for bad.d: "DATETIME"
(줄 번호는 우리 스크립트 파일 기준입니다.) STRICT 테이블은 INT, INTEGER, REAL, TEXT, BLOB, ANY 만 받으므로 날짜는 '2026-11-12' 같은 ISO 형식 텍스트로 넣습니다. 끝의 (19) 는 제약 조건 위반을 뜻하는 SQLite 오류 코드로, 2강에서 또 만납니다.
흔한 실수: 실제로 무슨 일이 일어나고 어떻게 고치나
신입이 실제로 보게 되는 메시지입니다. 전부 위 상품 테이블에서 나온 것이고, ^--- error here 표시는 셸이 찍어 줍니다.
| 입력 | 실제 결과 | 해결 |
|---|---|---|
SELCT * FROM products; |
Parse error: near "SELCT": syntax error |
키워드 오타 |
SELECT name, FROM products; |
Parse error: near "FROM": syntax error |
FROM 앞의 쉼표 |
SELECT * FROM product; |
Parse error: no such table: product |
.tables 로 이름 확인 |
… ORDER BY price LIMIT 2 WHERE price > 100; |
Parse error: near "WHERE": syntax error |
절 순서: WHERE 가 ORDER BY 앞 |
WHERE category = 'stationery; |
오류 없음: 프롬프트가 ' ...> 로 바뀌고 대기 |
따옴표가 열린 상태. '; 로 닫고 다시 작성 |
SELECT name price FROM products |
오류 없음: 제목이 price 인 열에 상품명이 나옴 |
쉼표 누락으로 price 가 별칭이 됨 |
WHERE category = "category" |
오류 없음: 0건이어야 할 count 가 7 | 큰따옴표는 열 이름. '…' 를 쓴다 |
위험한 것은 마지막 두 개입니다. SQL 에서 큰따옴표는 식별자(테이블·열 이름)용입니다. 그래서 "category" 는 열 자체를 가리켰고, 각 행을 자기 자신과 비교했으니 NULL 이 아닌 7건이 모두 걸렸습니다. 작은따옴표로 쓰면 0건입니다. 그런 이름의 열이 없을 때 SQLite 는 "Mouse" 를 문자열로 봐 주는데, 우리가 쓴 CLI 와 파이썬 sqlite3 모듈(둘 다 SQLite 3.51.0) 모두 그랬습니다. 즉 누군가 같은 이름의 열을 추가하는 날까지는 멀쩡히 돌아갑니다. MySQL 은 기본적으로 큰따옴표를 문자열로, PostgreSQL 과 오라클은 식별자로 취급합니다(여기서는 실행하지 않음). 문자열은 언제나 작은따옴표입니다.
닫히지 않은 따옴표에 대해 한마디 덧붙이면, 셸이 멈춘 게 아니라 문자열이 다음 줄로 이어진다고 생각하는 것입니다. 같은 쿼리를 파일이나 명령줄 인자로 실행하면 대신 unrecognized token: "'stationery;" 가 나옵니다. 파일로 실행(sqlite3 shop.db < setup.sql)할 때 오류는 Parse error near line 1: … 형태이고 종료 코드가 1 이라서 스크립트에서 실패를 잡을 수 있습니다.
실측: 100만 행이면 얼마나 걸리나
"느리다"는 게 어느 정도인지 직접 재 봤습니다. 재귀 쿼리로 주문 1,000,000건을 만들었습니다. 난수를 쓰지 않아 매번 같은 데이터가 나옵니다. 그리고 파이썬 sqlite3 모듈(SQLite 3.51.0)로 쿼리를 한 번 데운 뒤 7번 실행한 중앙값을 쟀습니다. 시간에는 fetchall() 까지 포함되고, 기기는 M1 Pro 입니다.
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, price INTEGER, status TEXT);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 1000000)
INSERT INTO orders (id, customer_id, price, status)
SELECT i, (i * 7919) % 50000, (i * 104729) % 10000,
CASE i % 10 WHEN 0 THEN 'cancelled' ELSE 'paid' END
FROM n;
생성에 0.47초, 파일 크기는 20MB 였습니다.
| 쿼리 | 반환 행 | 중앙값 |
|---|---|---|
WHERE id = 500000 (기본 키) |
1 | 0.01 ms |
SELECT count(*) FROM orders |
1 | 5.9 ms |
WHERE price > 9000 의 count(*) |
1 | 34.8 ms |
WHERE customer_id = 4242 |
20 | 38.5 ms |
ORDER BY price DESC LIMIT 10 |
10 | 43.0 ms |
ORDER BY price DESC (전체) |
1,000,000 | 792.6 ms |
초보자가 여기서 가져갈 것:
- 기본 키 조회가 다른 열 검색보다 약 3,000배 빠릅니다.
EXPLAIN QUERY PLAN을 붙이면 차이가 보입니다.SEARCH orders USING INTEGER PRIMARY KEY (rowid=?)대SCAN orders(모든 행 읽기). 4강에서 인덱스로 이 스캔을 없앱니다. LIMIT 10을 붙여도 검색이 공짜가 되지는 않습니다. 상위 10건을 찾으려고 결국 100만 행을 다 읽었고, 나머지를 정렬·전송하는 비용만 줄었습니다.- 100만 행을 프로그램으로 받아 오는 것이 가장 느립니다(793ms 의 대부분은 파이썬 객체 생성). 데이터를 통째로 받아서 세지 말고
count,sum,LIMIT으로 답을 데이터베이스에 물어보세요.
전체를 한 번 더 돌렸을 때도 경향은 같았습니다(스캔·정렬 네 쿼리가 33.7 / 35.9 / 45.4 / 799.3 ms).
한계와 버전 메모
- SQLite 3.51.0 에서만 실행했습니다(CLI 와 파이썬 3.9
sqlite3모듈 둘 다 3.51.0). SQLite 릴리스 노트 기준으로NULLS FIRST/LAST는 3.30.0, STRICT 테이블은 3.37.0 에 추가됐습니다. 오래된 안드로이드 기기나 서버의 SQLite 에는 없을 수 있습니다. - SQLite 의 대소문자 무시
LIKE는 ASCII 문자에만 적용됩니다. 한글은 대소문자가 없으니 상관없지만'É' LIKE 'é'같은 경우는 ICU 확장이 필요합니다. MySQL 은 콜레이션에 따라 다르고, PostgreSQL 의LIKE는 대소문자를 구분하며 무시하려면ILIKE를 씁니다(여기서는 실행하지 않음). - 타입 친화성은 SQLite 고유 동작입니다. strict 모드의 MySQL 이나 PostgreSQL 은 정수 열에
'ten'을 넣으면 거부합니다(여기서는 실행하지 않음). - 시간은 노트북 한 대에서, 파일이 OS 캐시에 올라온 상태로 잰 값입니다. 절대값은 기기마다 다르니 비율을 보세요.
연습 문제
- 300원 미만인 문구(stationery) 상품을 싼 순서로 나열하세요.
- 분류가 정해지지 않은 상품을 찾으세요.
- 실행하기 전에 예상해 보세요:
SELECT count(*) FROM products WHERE stock > 10 OR stock <= 10;의 결과는 8 일까요?
정답
1.
SELECT name, price FROM products
WHERE category = 'stationery' AND price < 300
ORDER BY price;
실제 출력: Gel pen | 120, Sticky notes | 200.
2. SELECT name, category FROM products WHERE category IS NULL; 은 Mystery box 를 돌려줬습니다. = NULL 로 쓰면 0건입니다.
3. 8 이 아니라 7 입니다. 포스트잇은 NULL > 10 도 NULL <= 10 도 모름이라 두 조건 모두 통과하지 못합니다. "모든 행은 10 초과이거나 10 이하"라는 상식이 NULL 앞에서는 성립하지 않습니다.
다음 강좌
다음 강좌는 데이터를 읽는 대신 바꿉니다. INSERT, UPDATE, DELETE, 그리고 잘못된 데이터를 막아 주는 제약 조건(NOT NULL, UNIQUE, CHECK, FOREIGN KEY)을 다루고, "WHERE 없는 UPDATE" 사고를 트랜잭션으로 되돌리는 과정도 보여 줍니다.
- 다음: SQL 강좌 2/6: INSERT·UPDATE·DELETE 와 제약 조건
- 시리즈 이후: JOIN·GROUP BY·서브쿼리, 인덱스와 EXPLAIN QUERY PLAN, 트랜잭션과 잠금, 윈도 함수와 CTE
- 전체 학습 순서: 신입 개발자를 위한 프로그래밍 기초 학습 로드맵
- 관련 글: 파이썬 강좌 1: 타입과 가변성, 다른 언어에서 만나는 "타입이 뒤통수 치는" 이야기
자주 묻는 질문
WHERE 컬럼 = NULL 은 왜 한 건도 안 나오나요?
NULL 은 '모르는 값'이라서, 무엇과 비교해도 결과가 참이 아니라 NULL 입니다. WHERE 는 조건이 참인 행만 남기므로 실제로 NULL 인 행까지 전부 빠집니다. IS NULL / IS NOT NULL 을 써야 합니다. SQLite 3.51 에서 SELECT NULL = NULL 은 NULL, SELECT NULL IS NULL 은 1 이었습니다.
NOT IN 목록에 NULL 이 섞이면 왜 결과가 0건이 되나요?
x NOT IN (a, NULL) 은 x <> a AND x <> NULL 과 같고, 뒤쪽이 항상 NULL 이라 전체가 참이 될 수 없습니다. 실제로 WHERE category NOT IN ('furniture', NULL) 은 0건이었습니다. 목록에서 NULL 을 빼거나 NOT EXISTS 를 씁니다.
SQLite 에서 '10' 이 '9' 보다 앞에 정렬되는 이유는?
컬럼에 문자열로 들어 있어서 한 글자씩 비교하기 때문입니다('1' < '9'). 숫자는 INTEGER 컬럼에 넣거나 ORDER BY CAST(code AS INTEGER) 로 정렬합니다. SQLite 3.37 부터 쓸 수 있는 STRICT 테이블은 잘못된 타입을 아예 거부합니다.