PostgreSQL SQL 문법 가이드 - 기초부터 고급까지
SQL 쿼리를 작성할 때 이런 고민 해보신 적 있나요?
-- 이게 맞나? 틀렸나?
SELECT * FROM users WHERE age > 18 AND city = 'Seoul' OR country = 'Korea';
괄호를 어디에 쳐야 하는지, AND와 OR 순서가 맞는지, JOIN은 언제 쓰는지… 저도 처음에는 SQL 문법이 헷갈려서 쿼리 하나 작성하는데 30분씩 걸렸습니다.
하지만 SQL 문법의 기본 규칙과 패턴을 이해하고 나니, 복잡한 쿼리도 자신 있게 작성할 수 있게 되었습니다. 이 문서에서는 PostgreSQL SQL 문법을 체계적으로, 그리고 실전에서 바로 사용할 수 있도록 설명합니다.
목차
- SQL 문법의 기본 구조
- SELECT 문 - 데이터 조회
- WHERE 절 - 조건 필터링
- JOIN - 테이블 결합
- GROUP BY와 집계 함수
- 서브쿼리 (Subquery)
- CTE (Common Table Expressions)
- 윈도우 함수 (Window Functions)
- INSERT, UPDATE, DELETE
- 트랜잭션과 ACID
- 성능을 위한 문법 팁
- 자주 하는 실수와 해결책
SQL 문법의 기본 구조
SQL 문의 실행 순서
SQL을 작성할 때 실행 순서를 이해하는 것이 중요합니다. 작성 순서와 실행 순서가 다르기 때문입니다!
작성 순서 (우리가 쓰는 순서)
┌─────────────────────────────────┐
│ 1. SELECT │
│ 2. FROM │
│ 3. WHERE │
│ 4. GROUP BY │
│ 5. HAVING │
│ 6. ORDER BY │
│ 7. LIMIT │
└─────────────────────────────────┘
실행 순서 (데이터베이스가 처리하는 순서)
┌─────────────────────────────────┐
│ 1. FROM ← 테이블 가져오기 │
│ 2. WHERE ← 행 필터링 │
│ 3. GROUP BY ← 그룹화 │
│ 4. HAVING ← 그룹 필터링 │
│ 5. SELECT ← 컬럼 선택 │
│ 6. ORDER BY ← 정렬 │
│ 7. LIMIT ← 개수 제한 │
└─────────────────────────────────┘
왜 이 순서가 중요한가?
-- ❌ 이 쿼리는 에러가 납니다
SELECT
user_name,
COUNT(*) as post_count
FROM posts
WHERE post_count > 5 -- ❌ 에러! post_count는 아직 존재하지 않음
GROUP BY user_name;
-- ✅ 올바른 방법
SELECT
user_name,
COUNT(*) as post_count
FROM posts
GROUP BY user_name
HAVING COUNT(*) > 5; -- ✅ HAVING은 GROUP BY 이후에 실행됨
WHERE는 SELECT보다 먼저 실행되므로, SELECT에서 만든 별칭을 사용할 수 없습니다. 하지만 HAVING은 SELECT 이후에 실행됩니다!
기본 문법 규칙
-- 1. 세미콜론(;)으로 문 종료
SELECT * FROM users; -- ✅ 좋음
-- 2. 대소문자 구분 안 함 (키워드)
SELECT * FROM users; -- ✅ 권장
select * from users; -- ✅ 동작하지만 읽기 어려움
SeLeCt * FrOm users; -- ✅ 동작하지만 절대 하지 마세요!
-- 3. 테이블명/컬럼명은 대소문자 구분 (따옴표 사용 시)
SELECT name FROM users; -- users, Users, USERS 모두 같음
SELECT name FROM "Users"; -- "Users" 테이블만 찾음
-- 4. 문자열은 작은따옴표(')
SELECT * FROM users WHERE name = 'John'; -- ✅ 올바름
SELECT * FROM users WHERE name = "John"; -- ❌ 에러!
-- 5. 주석
-- 한 줄 주석
/*
여러 줄
주석
*/
SELECT 문 - 데이터 조회
1. 기본 SELECT
-- 모든 컬럼 선택
SELECT * FROM users;
-- ❌ 실무에서는 * 사용 지양
-- 이유: 불필요한 데이터 전송, 성능 저하
-- ✅ 필요한 컬럼만 명시
SELECT id, name, email FROM users;
2. 별칭 (Alias) 사용
-- 컬럼 별칭
SELECT
first_name AS "이름", -- AS 사용 (권장)
last_name "성", -- AS 생략 가능
first_name || ' ' || last_name AS full_name -- 문자열 결합
FROM users;
-- 테이블 별칭
SELECT
u.name,
u.email
FROM users AS u; -- users 테이블을 u로 별칭
-- 별칭에 공백이 있으면 따옴표 필수
SELECT
name AS "사용자 이름" -- ✅ 올바름
FROM users;
3. DISTINCT - 중복 제거
-- 중복된 도시 제거
SELECT DISTINCT city FROM users;
-- 여러 컬럼 조합의 중복 제거
SELECT DISTINCT city, country FROM users;
-- ❌ 잘못된 사용
SELECT DISTINCT city, name FROM users;
-- city만 유니크하게 하려면 GROUP BY 사용
DISTINCT vs GROUP BY 차이:
-- DISTINCT: 단순 중복 제거
SELECT DISTINCT city FROM users;
-- GROUP BY: 그룹화 + 집계
SELECT city, COUNT(*) as user_count
FROM users
GROUP BY city;
4. LIMIT과 OFFSET - 페이지네이션
-- 처음 10개만
SELECT * FROM users LIMIT 10;
-- 11번째부터 10개 (페이지네이션)
SELECT * FROM users
LIMIT 10 OFFSET 10;
-- 더 읽기 쉬운 방법
SELECT * FROM users
LIMIT 10
OFFSET 10;
-- ✅ 실전 페이지네이션
-- 페이지 번호: page (1부터 시작)
-- 페이지 크기: page_size
SELECT * FROM users
LIMIT page_size
OFFSET (page - 1) * page_size;
-- 예: 2페이지, 페이지당 20개
SELECT * FROM users
LIMIT 20
OFFSET 20; -- (2 - 1) * 20
WHERE 절 - 조건 필터링
1. 비교 연산자
-- 같음
SELECT * FROM users WHERE age = 25;
-- 같지 않음
SELECT * FROM users WHERE age != 25; -- ✅ 일반적
SELECT * FROM users WHERE age <> 25; -- ✅ SQL 표준
-- 크다, 작다
SELECT * FROM users WHERE age > 18;
SELECT * FROM users WHERE age >= 18;
SELECT * FROM users WHERE age < 65;
SELECT * FROM users WHERE age <= 65;
2. 논리 연산자 - AND, OR, NOT
-- AND: 모든 조건이 참
SELECT * FROM users
WHERE age >= 18 AND city = 'Seoul';
-- OR: 하나라도 참
SELECT * FROM users
WHERE city = 'Seoul' OR city = 'Busan';
-- NOT: 부정
SELECT * FROM users
WHERE NOT city = 'Seoul'; -- city != 'Seoul'과 동일
-- ⚠️ 연산자 우선순위: NOT > AND > OR
SELECT * FROM users
WHERE age > 18 AND city = 'Seoul' OR country = 'Korea';
-- 해석: (age > 18 AND city = 'Seoul') OR country = 'Korea'
-- ✅ 명확하게 괄호 사용
SELECT * FROM users
WHERE age > 18 AND (city = 'Seoul' OR country = 'Korea');
흔한 실수:
-- ❌ 잘못된 코드
SELECT * FROM users
WHERE city = 'Seoul' OR 'Busan'; -- 에러!
-- ✅ 올바른 코드
SELECT * FROM users
WHERE city = 'Seoul' OR city = 'Busan';
-- ✅ 더 나은 방법: IN 사용
SELECT * FROM users
WHERE city IN ('Seoul', 'Busan');
3. IN, BETWEEN, LIKE
-- IN: 여러 값 중 하나
SELECT * FROM users
WHERE city IN ('Seoul', 'Busan', 'Incheon');
-- NOT IN
SELECT * FROM users
WHERE city NOT IN ('Seoul', 'Busan');
-- BETWEEN: 범위 (양 끝 포함)
SELECT * FROM users
WHERE age BETWEEN 18 AND 65; -- 18 <= age <= 65
-- NOT BETWEEN
SELECT * FROM users
WHERE age NOT BETWEEN 18 AND 65;
-- LIKE: 패턴 매칭
SELECT * FROM users
WHERE name LIKE 'John%'; -- John으로 시작
WHERE name LIKE '%Smith'; -- Smith로 끝남
WHERE name LIKE '%son%'; -- son 포함
WHERE name LIKE 'J_hn'; -- J + 한 글자 + hn
-- ILIKE: 대소문자 구분 없음 (PostgreSQL 전용)
SELECT * FROM users
WHERE name ILIKE 'john%'; -- John, JOHN, john 모두 매칭
4. NULL 처리
-- ❌ 잘못된 NULL 체크
SELECT * FROM users
WHERE email = NULL; -- 항상 false!
-- ✅ 올바른 NULL 체크
SELECT * FROM users
WHERE email IS NULL;
SELECT * FROM users
WHERE email IS NOT NULL;
-- COALESCE: NULL 대체값
SELECT
name,
COALESCE(email, 'No email') as email
FROM users;
-- NULLIF: 특정 값을 NULL로 변환
SELECT NULLIF(age, 0) FROM users; -- age가 0이면 NULL 반환
왜 = NULL이 동작하지 않을까?
NULL은 "알 수 없는 값"이므로
NULL = NULL 도 "알 수 없음" (false가 아님!)
따라서 IS NULL을 사용해야 합니다.
JOIN - 테이블 결합
JOIN의 종류 시각화
users 테이블: posts 테이블:
┌────┬──────┐ ┌────┬─────────┬─────────┐
│ id │ name │ │ id │ user_id │ title │
├────┼──────┤ ├────┼─────────┼─────────┤
│ 1 │ John │ │ 1 │ 1 │ Post A │
│ 2 │ Jane │ │ 2 │ 1 │ Post B │
│ 3 │ Bob │ │ 3 │ 2 │ Post C │
└────┴──────┘ │ 4 │ 99 │ Post D │
└────┴─────────┴─────────┘
INNER JOIN (교집합):
┌──────┬────────┐
│ John │ Post A │
│ John │ Post B │
│ Jane │ Post C │
└──────┴────────┘
LEFT JOIN (왼쪽 모두):
┌──────┬────────┐
│ John │ Post A │
│ John │ Post B │
│ Jane │ Post C │
│ Bob │ NULL │ ← Bob은 게시글 없음
└──────┴────────┘
RIGHT JOIN (오른쪽 모두):
┌──────┬────────┐
│ John │ Post A │
│ John │ Post B │
│ Jane │ Post C │
│ NULL │ Post D │ ← user_id=99는 users에 없음
└──────┴────────┘
FULL OUTER JOIN (합집합):
┌──────┬────────┐
│ John │ Post A │
│ John │ Post B │
│ Jane │ Post C │
│ Bob │ NULL │
│ NULL │ Post D │
└──────┴────────┘
1. INNER JOIN - 가장 많이 사용
-- 기본 문법
SELECT
users.name,
posts.title
FROM users
INNER JOIN posts ON users.id = posts.user_id;
-- ✅ 별칭 사용 (더 읽기 쉬움)
SELECT
u.name,
p.title
FROM users u
INNER JOIN posts p ON u.id = p.user_id;
-- ✅ 여러 테이블 JOIN
SELECT
u.name,
p.title,
c.content AS comment
FROM users u
INNER JOIN posts p ON u.id = p.user_id
INNER JOIN comments c ON p.id = c.post_id;
2. LEFT JOIN - 왼쪽 테이블의 모든 행 유지
-- 게시글이 없는 사용자도 포함
SELECT
u.name,
COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.name;
-- 결과:
-- John | 2 (게시글 2개)
-- Jane | 1 (게시글 1개)
-- Bob | 0 (게시글 없음) ← LEFT JOIN이므로 포함됨
실전 활용:
-- 게시글이 없는 사용자 찾기
SELECT u.name
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
WHERE p.id IS NULL; -- 매칭되는 게시글이 없는 경우
3. RIGHT JOIN - 오른쪽 테이블의 모든 행 유지
-- 거의 사용하지 않음 (LEFT JOIN으로 바꿔 쓸 수 있음)
SELECT
u.name,
p.title
FROM users u
RIGHT JOIN posts p ON u.id = p.user_id;
-- ✅ LEFT JOIN으로 변환 (더 직관적)
SELECT
u.name,
p.title
FROM posts p
LEFT JOIN users u ON p.user_id = u.id;
4. FULL OUTER JOIN - 양쪽 모두 유지
-- 매칭되지 않는 양쪽 데이터 모두 포함
SELECT
u.name,
p.title
FROM users u
FULL OUTER JOIN posts p ON u.id = p.user_id;
5. CROSS JOIN - 카테시안 곱
-- 모든 조합 생성
SELECT
u.name,
p.title
FROM users u
CROSS JOIN posts p;
-- users 3명 × posts 4개 = 12개 행 생성
-- ✅ 실전 활용: 날짜 범위 생성
SELECT
u.name,
d.date
FROM users u
CROSS JOIN generate_series(
'2025-01-01'::date,
'2025-01-07'::date,
'1 day'::interval
) AS d(date);
GROUP BY와 집계 함수
1. 기본 GROUP BY
-- 도시별 사용자 수
SELECT
city,
COUNT(*) as user_count
FROM users
GROUP BY city;
-- ⚠️ GROUP BY 규칙
-- SELECT에 있는 모든 비집계 컬럼은 GROUP BY에 있어야 함
-- ❌ 에러!
SELECT city, country, COUNT(*)
FROM users
GROUP BY city; -- country가 GROUP BY에 없음!
-- ✅ 올바름
SELECT city, country, COUNT(*)
FROM users
GROUP BY city, country;
2. 집계 함수
-- COUNT: 개수
SELECT COUNT(*) FROM users; -- 모든 행
SELECT COUNT(email) FROM users; -- NULL 제외
SELECT COUNT(DISTINCT city) FROM users; -- 중복 제거
-- SUM: 합계
SELECT SUM(price) FROM orders;
-- AVG: 평균
SELECT AVG(age) FROM users;
-- MIN, MAX: 최소/최대
SELECT MIN(age), MAX(age) FROM users;
-- STRING_AGG: 문자열 결합 (PostgreSQL)
SELECT
city,
STRING_AGG(name, ', ') as names
FROM users
GROUP BY city;
-- 결과: Seoul | John, Jane, Bob
3. HAVING - 그룹 필터링
-- WHERE vs HAVING
-- WHERE: 그룹화 전 필터링 (개별 행)
-- HAVING: 그룹화 후 필터링 (그룹)
-- ✅ 게시글 5개 이상인 사용자만
SELECT
user_id,
COUNT(*) as post_count
FROM posts
GROUP BY user_id
HAVING COUNT(*) >= 5;
-- ✅ WHERE + HAVING 함께 사용
SELECT
user_id,
COUNT(*) as post_count
FROM posts
WHERE created_at > '2025-01-01' -- 개별 행 필터
GROUP BY user_id
HAVING COUNT(*) >= 5; -- 그룹 필터
4. 복잡한 집계 예제
-- 월별 매출 통계
SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(*) as order_count,
SUM(amount) as total_amount,
AVG(amount) as avg_amount,
MIN(amount) as min_amount,
MAX(amount) as max_amount
FROM orders
WHERE created_at >= '2025-01-01'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;
서브쿼리 (Subquery)
서브쿼리는 쿼리 안에 있는 또 다른 쿼리입니다.
1. WHERE 절 서브쿼리
-- 평균 나이보다 많은 사용자
SELECT name, age
FROM users
WHERE age > (
SELECT AVG(age) FROM users
);
-- IN을 사용한 서브쿼리
-- 게시글을 작성한 사용자만
SELECT name
FROM users
WHERE id IN (
SELECT DISTINCT user_id FROM posts
);
-- ✅ JOIN으로 변환 가능 (보통 더 빠름)
SELECT DISTINCT u.name
FROM users u
INNER JOIN posts p ON u.id = p.user_id;
2. FROM 절 서브쿼리 (파생 테이블)
-- 서브쿼리를 테이블처럼 사용
SELECT
avg_stats.city,
avg_stats.avg_age
FROM (
SELECT
city,
AVG(age) as avg_age
FROM users
GROUP BY city
) AS avg_stats
WHERE avg_stats.avg_age > 30;
3. SELECT 절 서브쿼리 (스칼라 서브쿼리)
-- 각 사용자의 게시글 수 포함
SELECT
u.name,
u.email,
(
SELECT COUNT(*)
FROM posts p
WHERE p.user_id = u.id
) as post_count
FROM users u;
-- ⚠️ 성능 주의: N+1 문제 발생 가능
-- ✅ LEFT JOIN + GROUP BY가 더 빠름
SELECT
u.name,
u.email,
COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.name, u.email;
4. EXISTS - 존재 여부 확인
-- 게시글이 있는 사용자만
SELECT name
FROM users u
WHERE EXISTS (
SELECT 1 FROM posts p WHERE p.user_id = u.id
);
-- NOT EXISTS: 게시글이 없는 사용자
SELECT name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM posts p WHERE p.user_id = u.id
);
-- ✅ EXISTS vs IN
-- EXISTS: 존재만 확인 (빠름)
-- IN: 실제 값 비교
CTE (Common Table Expressions)
CTE는 “임시 결과 집합”입니다. WITH 절을 사용하며, 쿼리를 더 읽기 쉽게 만듭니다.
1. 기본 CTE
-- 서브쿼리 방식 (읽기 어려움)
SELECT *
FROM (
SELECT city, AVG(age) as avg_age
FROM users
GROUP BY city
) AS city_stats
WHERE avg_age > 30;
-- ✅ CTE 방식 (읽기 쉬움)
WITH city_stats AS (
SELECT
city,
AVG(age) as avg_age
FROM users
GROUP BY city
)
SELECT *
FROM city_stats
WHERE avg_age > 30;
2. 여러 CTE 사용
-- 여러 단계로 나누어 작성
WITH
-- 1단계: 활성 사용자
active_users AS (
SELECT id, name
FROM users
WHERE last_login > NOW() - INTERVAL '30 days'
),
-- 2단계: 최근 게시글
recent_posts AS (
SELECT user_id, title, created_at
FROM posts
WHERE created_at > NOW() - INTERVAL '7 days'
)
-- 3단계: 결합
SELECT
au.name,
COUNT(rp.title) as recent_post_count
FROM active_users au
LEFT JOIN recent_posts rp ON au.id = rp.user_id
GROUP BY au.id, au.name;
3. Recursive CTE - 재귀 쿼리
-- 계층 구조 조회 (댓글의 댓글의 댓글...)
WITH RECURSIVE comment_tree AS (
-- Base case: 최상위 댓글
SELECT
id,
parent_id,
content,
1 as depth
FROM comments
WHERE parent_id IS NULL
UNION ALL
-- Recursive case: 대댓글
SELECT
c.id,
c.parent_id,
c.content,
ct.depth + 1
FROM comments c
INNER JOIN comment_tree ct ON c.parent_id = ct.id
)
SELECT * FROM comment_tree
ORDER BY depth, id;
-- 1부터 10까지 숫자 생성
WITH RECURSIVE numbers AS (
SELECT 1 as n
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;
윈도우 함수 (Window Functions)
윈도우 함수는 행의 그룹에 대해 계산하지만, 행을 유지합니다. (GROUP BY는 행을 축소)
윈도우 함수 vs GROUP BY
GROUP BY (행 축소):
users: 결과:
┌──────┬──────┐ ┌────────┬───────┐
│ city │ age │ │ city │ count │
├──────┼──────┤ ├────────┼───────┤
│ Seoul│ 25 │ → │ Seoul │ 2 │
│ Seoul│ 30 │ │ Busan │ 1 │
│ Busan│ 28 │ └────────┴───────┘
└──────┴──────┘
Window Function (행 유지):
users: 결과:
┌──────┬─────┐ ┌──────┬─────┬──────────┐
│ city │ age │ │ city │ age │ avg_age │
├──────┼─────┤ ├──────┼─────┼──────────┤
│ Seoul│ 25 │ → │ Seoul│ 25 │ 27.5 │
│ Seoul│ 30 │ │ Seoul│ 30 │ 27.5 │
│ Busan│ 28 │ │ Busan│ 28 │ 28 │
└──────┴─────┘ └──────┴─────┴──────────┘
1. 기본 윈도우 함수
-- ROW_NUMBER: 순번 부여
SELECT
name,
age,
ROW_NUMBER() OVER (ORDER BY age DESC) as rank
FROM users;
-- 결과: 1, 2, 3, 4... (중복 없음)
-- RANK: 순위 (동점 허용, 건너뜀)
SELECT
name,
score,
RANK() OVER (ORDER BY score DESC) as rank
FROM exam_results;
-- 결과: 1, 2, 2, 4... (동점 2명이면 3은 건너뜀)
-- DENSE_RANK: 순위 (동점 허용, 건너뛰지 않음)
SELECT
name,
score,
DENSE_RANK() OVER (ORDER BY score DESC) as rank
FROM exam_results;
-- 결과: 1, 2, 2, 3... (3도 나옴)
2. PARTITION BY - 그룹별 윈도우
-- 도시별로 나이 순위 매기기
SELECT
name,
city,
age,
RANK() OVER (
PARTITION BY city -- 도시별로 나눔
ORDER BY age DESC
) as city_rank
FROM users;
-- 결과:
-- John | Seoul | 30 | 1 ← Seoul에서 1등
-- Jane | Seoul | 25 | 2 ← Seoul에서 2등
-- Bob | Busan | 28 | 1 ← Busan에서 1등
3. 집계 윈도우 함수
-- 누적 합계
SELECT
date,
amount,
SUM(amount) OVER (ORDER BY date) as running_total
FROM sales
ORDER BY date;
-- 이동 평균 (최근 7일)
SELECT
date,
amount,
AVG(amount) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as moving_avg_7days
FROM sales;
-- 부서별 평균과 비교
SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) as dept_avg,
salary - AVG(salary) OVER (PARTITION BY department) as diff
FROM employees;
4. LAG, LEAD - 이전/다음 행 참조
-- 전일 대비 증감
SELECT
date,
amount,
LAG(amount) OVER (ORDER BY date) as prev_amount,
amount - LAG(amount) OVER (ORDER BY date) as diff
FROM sales;
-- 다음 주문까지 시간
SELECT
user_id,
order_date,
LEAD(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
) - order_date as days_until_next_order
FROM orders;
5. FIRST_VALUE, LAST_VALUE
-- 각 그룹의 첫 번째/마지막 값
SELECT
department,
name,
salary,
FIRST_VALUE(name) OVER (
PARTITION BY department
ORDER BY salary DESC
) as highest_paid,
LAST_VALUE(name) OVER (
PARTITION BY department
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) as lowest_paid
FROM employees;
INSERT, UPDATE, DELETE
1. INSERT - 데이터 삽입
-- 단일 행 삽입
INSERT INTO users (name, email, age)
VALUES ('John', 'john@example.com', 25);
-- 여러 행 한 번에 삽입
INSERT INTO users (name, email, age)
VALUES
('John', 'john@example.com', 25),
('Jane', 'jane@example.com', 30),
('Bob', 'bob@example.com', 28);
-- 모든 컬럼에 값을 넣는 경우 (컬럼명 생략 가능)
INSERT INTO users
VALUES (1, 'John', 'john@example.com', 25, NOW());
-- ⚠️ 하지만 명시하는 것이 더 안전
INSERT INTO users (id, name, email, age, created_at)
VALUES (1, 'John', 'john@example.com', 25, NOW());
-- SELECT 결과를 INSERT
INSERT INTO users_backup (name, email)
SELECT name, email FROM users WHERE age > 30;
-- RETURNING: 삽입된 데이터 반환 (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('John', 'john@example.com')
RETURNING id, created_at;
2. UPDATE - 데이터 수정
-- 특정 행 수정
UPDATE users
SET age = 26
WHERE id = 1;
-- 여러 컬럼 수정
UPDATE users
SET
age = 26,
email = 'newemail@example.com',
updated_at = NOW()
WHERE id = 1;
-- ⚠️ WHERE 절 없으면 모든 행이 수정됨!
UPDATE users
SET age = 30; -- 모든 사용자의 나이가 30으로!
-- ✅ 안전한 UPDATE (트랜잭션 사용)
BEGIN;
UPDATE users SET age = 30 WHERE id = 1;
SELECT * FROM users WHERE id = 1; -- 확인
-- COMMIT; 또는 ROLLBACK;
-- 다른 테이블 참조하여 UPDATE
UPDATE users u
SET post_count = (
SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id
);
-- RETURNING: 수정된 데이터 반환
UPDATE users
SET age = age + 1
WHERE city = 'Seoul'
RETURNING id, name, age;
3. DELETE - 데이터 삭제
-- 특정 행 삭제
DELETE FROM users
WHERE id = 1;
-- 조건에 맞는 행 삭제
DELETE FROM users
WHERE last_login < NOW() - INTERVAL '1 year';
-- ⚠️ WHERE 절 없으면 모든 행이 삭제됨!
DELETE FROM users; -- 모든 사용자 삭제!
-- TRUNCATE: 테이블 비우기 (더 빠름)
TRUNCATE TABLE users; -- DELETE FROM users보다 빠름
TRUNCATE TABLE users RESTART IDENTITY; -- AUTO_INCREMENT도 초기화
-- RETURNING: 삭제된 데이터 반환
DELETE FROM users
WHERE age < 18
RETURNING id, name;
4. UPSERT - INSERT … ON CONFLICT (PostgreSQL)
-- 중복 시 업데이트 (UPSERT)
INSERT INTO users (id, name, email)
VALUES (1, 'John', 'john@example.com')
ON CONFLICT (id) DO UPDATE
SET
name = EXCLUDED.name,
email = EXCLUDED.email,
updated_at = NOW();
-- 중복 시 무시
INSERT INTO users (id, name, email)
VALUES (1, 'John', 'john@example.com')
ON CONFLICT (id) DO NOTHING;
-- 고유 제약 조건에 대한 UPSERT
INSERT INTO users (email, name)
VALUES ('john@example.com', 'John')
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name;
트랜잭션과 ACID
트랜잭션은 여러 SQL 문을 하나의 작업 단위로 묶는 것입니다.
트랜잭션의 필요성
-- ❌ 트랜잭션 없이 (위험!)
-- 1. A의 잔액 감소
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
-- 💥 여기서 에러 발생하면?
-- 2. B의 잔액 증가
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'B';
-- → A의 돈만 사라짐!
-- ✅ 트랜잭션 사용 (안전!)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'B';
COMMIT;
-- 모두 성공하거나, 모두 취소됨
기본 트랜잭션
-- 트랜잭션 시작
BEGIN; -- 또는 START TRANSACTION;
-- 여러 SQL 문 실행
INSERT INTO orders (user_id, amount) VALUES (1, 100);
UPDATE users SET total_spent = total_spent + 100 WHERE id = 1;
-- 성공 시 커밋
COMMIT;
-- 또는 실패 시 롤백
ROLLBACK;
SAVEPOINT - 중간 저장점
BEGIN;
INSERT INTO users (name) VALUES ('John');
SAVEPOINT sp1; -- 저장점 생성
UPDATE users SET age = 25 WHERE name = 'John';
SAVEPOINT sp2;
DELETE FROM users WHERE name = 'Jane';
-- sp2로 롤백 (DELETE 취소)
ROLLBACK TO sp2;
-- sp1로 롤백 (UPDATE도 취소)
ROLLBACK TO sp1;
COMMIT;
트랜잭션 격리 수준
-- 격리 수준 설정
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- READ UNCOMMITTED (거의 사용 안 함)
-- READ COMMITTED (PostgreSQL 기본값)
-- REPEATABLE READ
-- SERIALIZABLE (가장 엄격)
성능을 위한 문법 팁
1. SELECT * 지양
-- ❌ 나쁨: 불필요한 데이터 전송
SELECT * FROM users;
-- ✅ 좋음: 필요한 컬럼만
SELECT id, name, email FROM users;
2. WHERE 절 최적화
-- ❌ 느림: 함수 사용 시 인덱스 무용
SELECT * FROM users
WHERE LOWER(email) = 'john@example.com';
-- ✅ 빠름: 함수 없이 비교
SELECT * FROM users
WHERE email = 'john@example.com';
-- 또는 함수 인덱스 생성
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
3. JOIN vs 서브쿼리
-- ❌ 느림: SELECT 절 서브쿼리 (N+1)
SELECT
u.name,
(SELECT COUNT(*) FROM posts WHERE user_id = u.id) as post_count
FROM users u;
-- ✅ 빠름: LEFT JOIN
SELECT
u.name,
COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.name;
4. LIMIT 활용
-- ❌ 느림: 모든 데이터 가져온 후 필터
SELECT * FROM users; -- 애플리케이션에서 10개만 사용
-- ✅ 빠름: 데이터베이스에서 10개만
SELECT * FROM users LIMIT 10;
5. EXISTS vs IN
-- 큰 테이블에서는 EXISTS가 더 빠름
-- ✅ 빠름: 존재만 확인
SELECT name FROM users u
WHERE EXISTS (
SELECT 1 FROM posts WHERE user_id = u.id
);
-- ⚠️ 느릴 수 있음: 모든 값 비교
SELECT name FROM users
WHERE id IN (
SELECT user_id FROM posts
);
자주 하는 실수와 해결책
1. GROUP BY 컬럼 누락
-- ❌ 에러!
SELECT city, country, COUNT(*)
FROM users
GROUP BY city; -- country가 누락!
-- ✅ 해결
SELECT city, country, COUNT(*)
FROM users
GROUP BY city, country;
2. NULL 비교 실수
-- ❌ 동작 안 함
SELECT * FROM users WHERE email = NULL;
-- ✅ 올바름
SELECT * FROM users WHERE email IS NULL;
3. 문자열 따옴표 혼동
-- ❌ 에러!
SELECT * FROM users WHERE name = "John"; -- 큰따옴표 X
-- ✅ 올바름
SELECT * FROM users WHERE name = 'John'; -- 작은따옴표 O
4. AND/OR 우선순위
-- ❌ 의도와 다른 결과
SELECT * FROM users
WHERE age > 18 AND city = 'Seoul' OR city = 'Busan';
-- 해석: (age > 18 AND city = 'Seoul') OR city = 'Busan'
-- Busan의 모든 사용자 포함 (나이 무관!)
-- ✅ 명확한 괄호 사용
SELECT * FROM users
WHERE age > 18 AND (city = 'Seoul' OR city = 'Busan');
5. JOIN 조건 누락 (CROSS JOIN 발생)
-- ❌ WHERE 절에서 조인 조건 (읽기 어려움)
SELECT u.name, p.title
FROM users u, posts p
WHERE u.id = p.user_id;
-- ✅ 명시적 JOIN (더 명확함)
SELECT u.name, p.title
FROM users u
INNER JOIN posts p ON u.id = p.user_id;
6. LIMIT 없이 큰 테이블 조회
-- ❌ 위험: 백만 개 행 반환
SELECT * FROM logs;
-- ✅ 안전: 페이지네이션
SELECT * FROM logs
ORDER BY created_at DESC
LIMIT 100;
7. UPDATE/DELETE에서 WHERE 절 누락
-- ⚠️ 치명적 실수!
DELETE FROM users; -- 모든 사용자 삭제!
-- ✅ 항상 WHERE 절 확인
DELETE FROM users WHERE id = 1;
-- ✅ 안전한 방법: 트랜잭션에서 확인 후 커밋
BEGIN;
DELETE FROM users WHERE last_login < '2024-01-01';
SELECT COUNT(*) FROM users; -- 확인
-- 맞으면 COMMIT, 틀리면 ROLLBACK
8. 날짜/시간 비교 실수
-- ❌ 시간 무시됨
SELECT * FROM orders
WHERE created_at = '2025-01-15';
-- created_at이 '2025-01-15 10:30:00'이면 매칭 안 됨!
-- ✅ 날짜 범위로 비교
SELECT * FROM orders
WHERE created_at >= '2025-01-15'
AND created_at < '2025-01-16';
-- ✅ 또는 DATE 함수 사용
SELECT * FROM orders
WHERE DATE(created_at) = '2025-01-15';
참고 자료
공식 문서
학습 도구
- SQL Zoo - 인터랙티브 SQL 학습
- DB Fiddle - 온라인 SQL 실습
- Explain.dalibo.com - 쿼리 플랜 시각화
추가 학습
- Use The Index, Luke - SQL 인덱스 최적화
- Modern SQL - 최신 SQL 기능
SQL 문법을 처음 배울 때는 복잡해 보이지만, 기본 규칙과 패턴을 이해하면 훨씬 쉬워집니다.
핵심은:
- 실행 순서 이해하기 (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY)
- 괄호로 명확하게 표현하기
- 별칭으로 가독성 높이기
- WHERE 절 항상 확인하기 (특히 UPDATE/DELETE)
- 트랜잭션으로 안전하게 작업하기
이 문서의 예제들을 직접 실행해보며 익히면, 어떤 복잡한 쿼리도 자신 있게 작성할 수 있게 될 것입니다! 🚀
댓글