SQL 기본 문법

src/content/documents/data-analysis/sql-basic-syntax.json

한 줄 정의

SQL(Structured Query Language)은 관계형 데이터베이스에 어떤 데이터를 읽거나 바꿀지 선언하는 언어다. 처리 방법을 한 단계씩 지시하기보다 원하는 결과를 테이블 형태로 표현한다.

이 글은 PostgreSQL 문법을 기준으로 한다. 표준 SQL의 핵심은 여러 DBMS에서 비슷하지만 행 제한은 PostgreSQL·MySQL의 LIMIT, SQL Server의 TOP처럼 다를 수 있다. 예시는 특정 게임의 실제 구현이 아닌 작은 RPG 서비스의 가상 데이터다.

먼저 질문을 결과표로 바꾼다

SQL을 키워드 암기로 시작하면 절의 위치가 섞인다. 먼저 “레벨 10 이상인 활성 플레이어를 높은 레벨순으로 3명 보여 줘”를 입력 테이블과 출력 열로 나눈다.

질문의 조각

SQL 절

역할

플레이어에서

FROM players

입력 테이블

활성이고 레벨 10 이상

WHERE

행 필터

닉네임과 레벨을

SELECT nickname, level

출력 열

높은 레벨순 3명

ORDER BY ... DESC LIMIT 3

정렬과 행 제한

실습 데이터 준비

아래 스크립트를 PostgreSQL의 빈 데이터베이스에서 실행하면 이후 예제를 그대로 따라갈 수 있다. NULL 예제를 위해 guild_name이 없는 플레이어도 둔다.

DROP TABLE IF EXISTS purchases;
DROP TABLE IF EXISTS players;

CREATE TABLE players (
  player_id integer PRIMARY KEY,
  nickname text NOT NULL UNIQUE,
  level integer NOT NULL CHECK (level >= 1),
  status text NOT NULL CHECK (status IN ('active', 'resting')),
  guild_name text
);

CREATE TABLE purchases (
  purchase_id integer PRIMARY KEY,
  player_id integer NOT NULL REFERENCES players(player_id),
  item_name text NOT NULL,
  quantity integer NOT NULL CHECK (quantity > 0),
  unit_price integer NOT NULL CHECK (unit_price >= 0),
  purchased_at date NOT NULL
);

INSERT INTO players VALUES
  (1, '달빛검사', 12, 'active',  '새벽별'),
  (2, '회복술사',  7, 'active',  NULL),
  (3, '숲의궁수', 15, 'resting', '새벽별'),
  (4, '철벽기사', 20, 'active',  '백야'),
  (5, '초보마법사', 3, 'active', NULL);

INSERT INTO purchases VALUES
  (101, 1, '회복 물약', 2, 150, DATE '2026-08-10'),
  (102, 1, '강철 검',   1, 900, DATE '2026-08-11'),
  (103, 2, '회복 물약', 5, 150, DATE '2026-08-11'),
  (104, 4, '방패',      1, 700, DATE '2026-08-12');

SELECT와 FROM: 열을 골라 읽기

SELECT는 결과에 표시할 식이나 열을 고르고 FROM은 데이터를 가져올 테이블을 정한다. 별칭 AS는 결과 열의 의미를 드러낸다. 빠른 탐색을 제외하면 SELECT *보다 필요한 열을 적는 편이 결과 계약과 개인정보 범위를 분명하게 한다.

SELECT player_id, nickname, level, level + 1 AS next_level
FROM players;
player_id | nickname   | level | next_level
----------+------------+-------+-----------
1         | 달빛검사    | 12    | 13
2         | 회복술사    | 7     | 8
3         | 숲의궁수    | 15    | 16
4         | 철벽기사    | 20    | 21
5         | 초보마법사  | 3     | 4

WHERE: 행을 조건으로 거르기

WHERE에는 각 행이 참인지 판단할 조건을 쓴다. =, <>, <, <=, >, >= 비교와 AND·OR·NOT을 조합하며, IN은 후보 목록, BETWEEN은 양끝을 포함한 범위, LIKE는 문자열 패턴을 검사한다. AND와 OR을 섞을 때는 의도를 괄호로 고정한다.

SELECT nickname, level
FROM players
WHERE status = 'active'
  AND level >= 10
ORDER BY level DESC, player_id ASC
LIMIT 3;
nickname | level
---------+------
철벽기사  | 20
달빛검사  | 12

ORDER BY가 없으면 행 순서는 보장되지 않는다. “상위 3명”처럼 순서가 의미를 가지면 정렬 기준과 동점 해소 기준을 함께 적는다.

NULL은 빈 문자열이나 0이 아니다

NULL은 값이 없거나 알려지지 않았음을 나타낸다. NULL과의 일반 비교 결과는 참·거짓이 아니라 UNKNOWN이므로 WHERE guild_name = NULL은 원하는 행을 찾지 못한다. IS NULL 또는 IS NOT NULL을 사용한다.

SELECT nickname, guild_name
FROM players
WHERE guild_name IS NULL;

SELECT nickname, COALESCE(guild_name, '무소속') AS guild
FROM players
ORDER BY player_id;

COALESCE는 왼쪽부터 첫 번째 NULL이 아닌 값을 반환한다. 출력에서 ‘무소속’으로 보이게 할 뿐 원본 값을 바꾸지는 않는다. 집계에서도 COUNT(*)는 행을 세지만 COUNT(guild_name)은 NULL이 아닌 값만 센다.

JOIN: 관련된 두 테이블 합치기

players와 purchases를 player_id로 조인할 때 INNER JOIN은 구매가 있는 플레이어만 남기고 LEFT JOIN은 구매가 없는 플레이어도 오른쪽 값을 NULL로 채워 유지하는 비교 도식

INNER JOIN은 ON 조건이 양쪽에서 일치하는 조합만 남긴다. LEFT JOIN은 왼쪽 행을 모두 보존하고 일치하는 오른쪽 행이 없으면 오른쪽 열을 NULL로 채운다. 한 플레이어에게 구매가 여러 건이면 플레이어 행도 구매 건수만큼 반복된다. JOIN은 열만 붙이는 것이 아니라 결과 행의 개수도 바꾼다.

SELECT p.nickname, o.item_name,
       o.quantity * o.unit_price AS amount
FROM players AS p
JOIN purchases AS o ON o.player_id = p.player_id
ORDER BY o.purchase_id;
nickname | item_name  | amount
---------+------------+-------
달빛검사  | 회복 물약   | 300
달빛검사  | 강철 검     | 900
회복술사  | 회복 물약   | 750
철벽기사  | 방패        | 700

GROUP BY와 집계: 상세 행을 요약 행으로

COUNT·SUM·AVG·MIN·MAX는 여러 행을 하나의 요약값으로 만든다. GROUP BY는 같은 값을 가진 행을 그룹으로 나눈다. SELECT에는 그룹 기준 열과 집계식만 두어 “결과 한 행이 무엇을 뜻하는가”를 명확히 한다.

SELECT p.nickname,
       COUNT(o.purchase_id) AS purchase_count,
       COALESCE(SUM(o.quantity * o.unit_price), 0) AS total_spent
FROM players AS p
LEFT JOIN purchases AS o ON o.player_id = p.player_id
GROUP BY p.player_id, p.nickname
HAVING COALESCE(SUM(o.quantity * o.unit_price), 0) >= 700
ORDER BY total_spent DESC, p.player_id;
nickname | purchase_count | total_spent
---------+----------------+------------
달빛검사  | 2              | 1200
회복술사  | 1              | 750
철벽기사  | 1              | 700

WHERE는 그룹을 만들기 전 상세 행을 거르고 HAVING은 집계가 끝난 그룹을 거른다. LEFT JOIN에서 구매 0건을 세려면 COUNT(*)가 아니라 COUNT(o.purchase_id)를 사용한다. COUNT(*)는 오른쪽이 NULL로 채워진 보존 행도 한 행으로 세기 때문이다.

작성 순서와 논리 처리 순서는 다르다

SQL은 SELECT, FROM, JOIN ON, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT 순으로 작성하지만 논리적으로는 FROM과 JOIN, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, LIMIT 순으로 처리한다

논리 처리 순서는 결과 의미를 설명하는 모델이며 실제 실행 계획의 물리 연산 순서와 같다는 뜻은 아니다. 이 흐름을 알면 SELECT에서 만든 별칭을 보통 WHERE에서 쓸 수 없는 이유와 집계 조건이 HAVING에 들어가는 이유를 이해할 수 있다.

DISTINCT는 중복 결과를 제거한다

SELECT DISTINCT guild_name은 선택한 열 조합이 같은 결과 행을 하나로 줄인다. 잘못된 JOIN으로 행이 불어난 원인을 숨기기 위해 DISTINCT를 붙이지 않는다. 먼저 관계가 1:1인지 1:N인지, ON 조건이 충분한지 확인한다.

SELECT DISTINCT guild_name
FROM players
WHERE guild_name IS NOT NULL
ORDER BY guild_name;

INSERT·UPDATE·DELETE: 데이터를 바꾸기

INSERT는 새 행을 추가하고 UPDATE는 기존 행의 값을 바꾸며 DELETE는 행을 제거한다. INSERT의 열 목록은 생략하지 않아 값의 의미와 스키마 변경 영향을 분명히 한다. UPDATE와 DELETE는 WHERE가 없으면 모든 행이 대상이다.

BEGIN;

INSERT INTO players
  (player_id, nickname, level, status, guild_name)
VALUES
  (6, '별빛도적', 9, 'active', '백야');

-- 변경 전에 같은 조건으로 대상을 확인한다.
SELECT player_id, nickname, status
FROM players
WHERE player_id = 6;

UPDATE players
SET status = 'resting'
WHERE player_id = 6
RETURNING player_id, nickname, status;

DELETE FROM players
WHERE player_id = 6
RETURNING player_id, nickname;

ROLLBACK;  -- 학습 예제이므로 원상 복구

운영 데이터 변경 전에는 SELECT로 대상과 건수를 확인하고, 트랜잭션과 백업·권한 정책 안에서 실행한다. RETURNING은 PostgreSQL 기능이며 다른 DBMS에서는 문법이 다를 수 있다.

한 쿼리를 단계별로 완성하기

“2026년 8월 11일 이후 활성 플레이어의 구매액을 구하고, 총액 700 이상만 큰 순서로 보여 줘”를 한 번에 쓰지 말고 중간 결과의 행 수를 확인하며 확장한다.

  1. FROM players로 기준 행과 키를 확인한다.

  2. JOIN purchases ON player_id로 구매 행을 붙이고 행 수가 왜 늘었는지 확인한다.

  3. WHERE로 활성 상태와 날짜를 상세 행 단계에서 거른다.

  4. GROUP BY로 플레이어 한 명당 한 행을 만들고 SUM으로 총액을 계산한다.

  5. HAVING으로 요약 행을 거른 뒤 ORDER BY로 최종 결과를 정렬한다.

SELECT p.player_id, p.nickname,
       SUM(o.quantity * o.unit_price) AS total_spent
FROM players AS p
JOIN purchases AS o ON o.player_id = p.player_id
WHERE p.status = 'active'
  AND o.purchased_at >= DATE '2026-08-11'
GROUP BY p.player_id, p.nickname
HAVING SUM(o.quantity * o.unit_price) >= 700
ORDER BY total_spent DESC, p.player_id;
player_id | nickname | total_spent
----------+----------+------------
1         | 달빛검사  | 900
2         | 회복술사  | 750
4         | 철벽기사  | 700

초보자가 자주 만드는 오류

오류

원인

수정

= NULL

비교 결과가 UNKNOWN

IS NULL

WHERE COUNT(*) > 1

WHERE는 집계 전

HAVING COUNT(*) > 1

LEFT JOIN 후 행 누락

오른쪽 조건을 WHERE에 둠

보존 의도에 따라 ON으로 이동

UPDATE/DELETE 전체 적용

WHERE 누락

SELECT 확인 후 트랜잭션 실행

문법 빠른 참조

SELECT [DISTINCT] 출력_열이나_식
FROM 기준_테이블 [AS 별칭]
[JOIN 다른_테이블 [AS 별칭] ON 조인_조건]
[WHERE 상세_행_조건]
[GROUP BY 그룹_기준]
[HAVING 그룹_조건]
[ORDER BY 정렬_기준 [ASC | DESC]]
[LIMIT 행_수];

INSERT INTO 테이블 (열1, 열2) VALUES (값1, 값2);
UPDATE 테이블 SET 열1 = 값1 WHERE 대상_조건;
DELETE FROM 테이블 WHERE 대상_조건;

참고 자료

2026-08-12에 국내외 블로그·교육 자료 30편 이상을 본문까지 확인했다. 아래에는 글의 구성과 사실 검증에 직접 사용한 대표 자료를 남긴다.

댓글 0

댓글을 불러오는 중…