[Node.js / Express 5] SQL 기초 문법과 정규화
SQL 분류
SQL은 기능에 따라 네 가지로 분류된다. 각 분류가 다루는 대상이 다르므로 구분해서 이해하는 것이 중요하다.
| 분류 | 전체 이름 | 대상 | 주요 명령 |
|---|---|---|---|
| DDL | Data Definition Language | 테이블·스키마 구조 | CREATE, ALTER, DROP, TRUNCATE |
| DML | Data Manipulation Language | 테이블 안의 데이터 | SELECT, INSERT, UPDATE, DELETE |
| DCL | Data Control Language | 사용자 권한 | GRANT, REVOKE |
| TCL | Transaction Control Language | 트랜잭션 흐름 | COMMIT, ROLLBACK, SAVEPOINT |
DDL — 구조 정의
DDL(Data Definition Language)은 테이블 구조를 정의하거나 수정하는 문법이다.
CREATE DATABASE my_db DEFAULT CHARACTER SET utf8mb4;
USE my_db;
ALTER TABLE member ADD COLUMN age INT;
ALTER TABLE member DROP COLUMN age;
ALTER TABLE member CHANGE name full_name VARCHAR(30);
ALTER TABLE member MODIFY COLUMN status VARCHAR(20);
DROP TABLE IF EXISTS member;
TRUNCATE TABLE member;TRUNCATE는 데이터만 전체 삭제하며 복구가 불가능하다. DROP과 달리 테이블 구조는 남는다.
DML — 데이터 조작
DML(Data Manipulation Language)은 데이터를 조회하고 변경하는 문법이다.
INSERT 구문
테이블에 새 행을 삽입한다. 칼럼 목록을 명시하면 순서가 달라도 안전하고, 생략하면 테이블 정의 순서대로 값을 지정해야 한다.
INSERT INTO member (name, email, status)
VALUES ('홍길동', 'hong@example.com', 'ACTIVE');
INSERT INTO member (name, email, status)
VALUES
('김철수', 'chul@example.com', 'ACTIVE'),
('이영희', 'young@example.com', 'INACTIVE');여러 행을 한 번에 삽입할 때는 VALUES 뒤에 콤마로 구분하여 나열한다. 단건 INSERT를 반복하는 것보다 성능이 좋다.
SELECT 실행 순서
SQL은 작성 순서와 실행 순서가 다르다.
FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BYSELECT가 FROM보다 나중에 실행되므로, WHERE 절에서는 SELECT에서 지정한 별칭(alias)을 사용할 수 없다.
주요 조회 패턴
SELECT name, email
FROM member
WHERE status = 'ACTIVE'
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
SELECT category_id, COUNT(*) AS book_count
FROM book
GROUP BY category_id
HAVING COUNT(*) > 5;
SELECT * FROM member WHERE phone IS NULL;
SELECT DISTINCT category_id FROM book;NULL 검사는 = NULL이 아니라 IS NULL을 사용한다. WHERE phone = NULL은 항상 false를 반환한다.
UPDATE 구문
UPDATE member SET name = '홍길순' WHERE id = 1;
UPDATE member
SET status = 'INACTIVE', updated_at = NOW()
WHERE last_login_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);UPDATE에서 WHERE 절을 빠뜨리면 테이블 전체 행이 수정된다. 주의가 필요한 부분이다.
DELETE 구문
특정 조건에 맞는 행을 삭제한다. WHERE 절 없이 실행하면 테이블의 모든 행이 삭제된다.
DELETE FROM member WHERE id = 1;
DELETE FROM member
WHERE status = 'INACTIVE'
AND last_login_at < DATE_SUB(NOW(), INTERVAL 2 YEAR);TRUNCATE와 달리 DELETE는 트랜잭션 안에서 실행하면 롤백이 가능하다. FK 제약이 있는 경우 참조되는 행은 삭제가 거부된다.
집계 함수
GROUP BY와 함께 자주 쓰이는 집계 함수다.
| 함수 | 설명 | NULL 처리 |
|---|---|---|
COUNT(*) |
전체 행 수 | NULL 포함 |
COUNT(col) |
해당 칼럼이 NULL이 아닌 행 수 | NULL 제외 |
SUM(col) |
합계 | NULL 제외 |
AVG(col) |
평균 | NULL 제외 |
MAX(col) |
최댓값 | NULL 제외 |
MIN(col) |
최솟값 | NULL 제외 |
SELECT
category_id,
COUNT(*) AS total_books,
AVG(price) AS avg_price,
MAX(price) AS max_price,
MIN(price) AS min_price
FROM book
GROUP BY category_id;WHERE vs HAVING 차이
둘 다 필터링 조건이지만 동작하는 시점이 다르다.
| 구분 | WHERE | HAVING |
|---|---|---|
| 실행 시점 | GROUP BY 이전 | GROUP BY 이후 |
| 필터 대상 | 개별 행 | 그룹(집계 결과) |
| 집계 함수 사용 | 불가 | 가능 |
| 사용 예 | WHERE status = 'ACTIVE' |
HAVING COUNT(*) > 5 |
SELECT category_id, COUNT(*) AS book_count
FROM book
WHERE price > 10000
GROUP BY category_id
HAVING COUNT(*) >= 3;WHERE price > 10000으로 먼저 행을 줄이고, GROUP BY 후 HAVING COUNT(*) >= 3으로 그룹을 필터링한다. 성능 최적화를 위해 가능하면 WHERE로 먼저 걸러내는 것이 좋다.
JOIN — 테이블 합치기
JOIN은 두 개 이상의 테이블을 연결해 데이터를 조회하는 방법이다.
INNER JOIN
두 테이블 모두에 일치하는 행만 반환한다. 교집합이다.
SELECT m.name, b.title, mb.rental_date
FROM member m
INNER JOIN member_book mb ON m.id = mb.member_id
INNER JOIN book b ON mb.book_id = b.id;LEFT OUTER JOIN
왼쪽 테이블을 기준으로 오른쪽 테이블과 연결한다. 오른쪽에 매칭되는 행이 없으면 NULL로 채운다.
SELECT m.name, b.title
FROM member m
LEFT JOIN member_book mb ON m.id = mb.member_id
LEFT JOIN book b ON mb.book_id = b.id;대여 이력이 없는 회원도 결과에 포함된다.
EXCLUSIVE JOIN
LEFT JOIN 후 오른쪽 테이블이 NULL인 행만 필터링한다. 한쪽에만 존재하는 데이터를 찾을 때 사용한다.
SELECT m.*
FROM member m
LEFT JOIN member_book mb ON m.id = mb.member_id
WHERE mb.member_id IS NULL;대여 이력이 없는 회원만 조회된다.
SELF JOIN
같은 테이블을 두 번 조인한다. 추천인처럼 같은 테이블 내에 참조 관계가 있을 때 사용한다.
SELECT m.name AS '회원', r.name AS '추천인'
FROM member m
LEFT JOIN member r ON m.referrer_id = r.id;FULL OUTER JOIN
두 테이블 중 한쪽에만 있는 행도 모두 포함한다. 합집합이다. MySQL은 FULL OUTER JOIN 문법을 지원하지 않으므로 LEFT JOIN과 RIGHT JOIN을 UNION으로 합쳐서 구현한다.
SELECT m.name, b.title
FROM member m
LEFT JOIN member_book mb ON m.id = mb.member_id
LEFT JOIN book b ON mb.book_id = b.id
UNION
SELECT m.name, b.title
FROM member m
RIGHT JOIN member_book mb ON m.id = mb.member_id
RIGHT JOIN book b ON mb.book_id = b.id;UNION은 기본적으로 중복을 제거한다. 중복을 포함하고 싶다면 UNION ALL을 사용한다.
서브쿼리 3가지 유형
서브쿼리는 쿼리 안에 포함된 또 다른 쿼리다. 서브쿼리가 먼저 실행되고 메인쿼리가 나중에 실행된다. 반드시 ()로 감싸야 한다.
| 유형 | 위치 | 특징 |
|---|---|---|
| 중첩 서브쿼리 | WHERE 절 | 조건값을 SELECT로 가져옴 |
| 인라인 뷰 | FROM 절 | 테이블처럼 사용, 별칭 필수 |
| 스칼라 서브쿼리 | SELECT 절 | 반드시 단일 값 반환 |
SELECT name, height FROM member
WHERE height > (SELECT AVG(height) FROM member);
SELECT name FROM member
WHERE height > ANY (SELECT height FROM member WHERE gender = 'MALE');SELECT active_members.name
FROM (
SELECT * FROM member WHERE status = 'ACTIVE'
) active_members
WHERE active_members.id IN (SELECT DISTINCT member_id FROM member_book);FROM 절의 서브쿼리(인라인 뷰)는 반드시 별칭을 붙여야 한다.
SELECT
m.name,
(SELECT COUNT(*) FROM member_book mb WHERE mb.member_id = m.id) AS rental_count
FROM member m;스칼라 서브쿼리는 SELECT 절에 위치하며 반드시 단일 값을 반환해야 한다.
JOIN vs 서브쿼리 비교
같은 결과를 JOIN으로도 서브쿼리로도 작성할 수 있다. 어느 쪽을 선택할지는 가독성, 데이터 규모, 실행 계획을 함께 고려해야 한다.
| 기준 | JOIN | 서브쿼리 |
|---|---|---|
| 가독성 | 관계가 명확하게 드러남 | 단계별 의도를 분리해서 표현 가능 |
| 성능 | 옵티마이저가 최적화하기 유리 | 스칼라 서브쿼리는 행마다 실행되어 느릴 수 있음 |
| 중복 행 | 1:N 관계면 중복 발생 가능 | EXISTS로 중복 없이 존재 여부만 확인 가능 |
| 적합한 상황 | 여러 칼럼을 함께 조회할 때 | 집계값을 조건으로 쓸 때, 존재 여부만 확인할 때 |
SELECT m.name
FROM member m
WHERE EXISTS (
SELECT 1 FROM member_book mb WHERE mb.member_id = m.id
);EXISTS는 서브쿼리가 한 건이라도 결과를 반환하면 true를 반환하고 탐색을 멈춘다. JOIN보다 중복 없이 존재 여부를 확인할 때 적합하다.
정규화
정규화는 데이터 중복을 제거하고 무결성을 유지하기 위해 테이블 구조를 분리하는 설계 원칙이다.
정규화가 필요한 이유 — 이상(Anomaly) 현상
| 이상 종류 | 문제 |
|---|---|
| 삽입 이상 | 특정 데이터가 없으면 다른 데이터도 삽입 불가 |
| 삭제 이상 | 한 데이터를 지우면 다른 데이터도 사라짐 |
| 수정 이상 | 한 가지를 바꾸려면 여러 행을 수정해야 함 |
1NF — 원자값 (반복 그룹 제거)
하나의 칸에는 하나의 값만 저장해야 한다.
-- 위반: 하나의 칸에 여러 값
book_id | tags
1 | "자기계발, 경제, 투자"
-- 1NF 적용 후
book_id | tag
1 | 자기계발
1 | 경제
1 | 투자2NF — 부분 함수 종속 제거
복합 PK가 있을 때, PK 일부에만 종속되는 칼럼은 별도 테이블로 분리한다.
-- 위반: PK (order_id, product_id)인데 product_name은 product_id에만 종속
order_id | product_id | product_name | quantity
-- 2NF 적용 후
order_item: order_id, product_id, quantity
product: product_id, product_name3NF — 이행 함수 종속 제거
PK가 아닌 칼럼이 다른 PK가 아닌 칼럼을 결정하는 경우 분리한다.
-- 위반: order_id -> customer_id -> customer_phone (이행 종속)
order: order_id, customer_id, customer_phone
-- 3NF 적용 후
order: order_id, customer_id
customer: customer_id, customer_phone트랜잭션
트랜잭션은 하나의 논리적 작업 단위로 묶인 SQL 명령들의 집합이다. 전체가 성공하거나 전체가 실패해야 한다. 은행 이체처럼 출금과 입금이 반드시 함께 처리되어야 하는 경우가 대표적인 예다.
ACID 속성
| 속성 | 영문 | 의미 |
|---|---|---|
| 원자성 | Atomicity | 트랜잭션 내 작업은 전부 성공하거나 전부 실패한다 |
| 일관성 | Consistency | 트랜잭션 전후 DB는 항상 유효한 상태를 유지한다 |
| 격리성 | Isolation | 동시에 실행되는 트랜잭션은 서로 영향을 주지 않는다 |
| 지속성 | Durability | 커밋된 결과는 장애가 발생해도 유지된다 |
START TRANSACTION / COMMIT / ROLLBACK
START TRANSACTION;
UPDATE account SET balance = balance - 10000 WHERE id = 1;
UPDATE account SET balance = balance + 10000 WHERE id = 2;
COMMIT;START TRANSACTION;
UPDATE account SET balance = balance - 10000 WHERE id = 1;
ROLLBACK;COMMIT을 실행하면 변경 내용이 확정되어 다른 세션에서도 조회된다. ROLLBACK을 실행하면 트랜잭션 시작 이후의 모든 변경이 취소된다. MySQL InnoDB는 기본적으로 autocommit = 1이므로, 명시적으로 트랜잭션을 제어하려면 START TRANSACTION을 선언해야 한다.
SAVEPOINT를 활용하면 트랜잭션 중간 지점을 저장하고, 일부만 롤백하는 것도 가능하다.
START TRANSACTION;
INSERT INTO order_log (member_id, action) VALUES (1, 'ORDER_START');
SAVEPOINT before_payment;
UPDATE account SET balance = balance - 5000 WHERE id = 1;
ROLLBACK TO before_payment;
COMMIT;중복 요청 방지
같은 요청이 두 번 실행됐을 때 데이터가 중복 삽입되는 것을 방지하는 방법이 있다.
| 방법 | 구현 | 안전도 |
|---|---|---|
| 프론트 디바운스 | 버튼 disabled 처리 | 낮음 (우회 가능) |
| DB UNIQUE 제약 | UNIQUE KEY uq_member_mission (member_id, mission_id) | 높음 |
| 멱등키 | 헤더에 UUID 포함, 서버에서 중복 감지 | 높음 |
| 비관적 락 | SELECT FOR UPDATE | 높음 (성능 주의) |
가장 안전하고 단순한 방법은 DB UNIQUE 제약이다. 중복 삽입 자체를 데이터베이스 레벨에서 막는다.
CREATE TABLE mission_attempt (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
member_id BIGINT NOT NULL,
mission_id BIGINT NOT NULL,
status VARCHAR(15) NOT NULL DEFAULT 'CHALLENGING',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_member_mission (member_id, mission_id),
FOREIGN KEY (member_id) REFERENCES member(id),
FOREIGN KEY (mission_id) REFERENCES mission(id)
);(member_id, mission_id) 조합이 유일하도록 제약을 걸면, 같은 회원이 같은 미션에 두 번 도전하려고 할 때 DB가 에러를 반환한다. 프론트 처리와 무관하게 데이터 정합성이 보장된다.