icon

안동민 개발노트

4장 : SQL 기초 — DML

INSERT, UPDATE, DELETE


데이터베이스에 저장된 데이터를 실제로 조작하는 명령이 DML(Data Manipulation Language)입니다. 테이블을 만드는 것은 DDL의 역할이고, 만들어진 테이블에 데이터를 넣고(INSERT), 고치고(UPDATE), 지우는(DELETE) 것은 DML의 역할입니다. 이 세 명령과 SELECT를 합쳐서 CRUD(Create, Read, Update, Delete)라 부르며, 모든 애플리케이션의 데이터 처리는 결국 이 네 가지 동작의 조합입니다.

DML 명령은 DDL과 달리 자동 커밋되지 않습니다. INSERT, UPDATE, DELETE를 실행한 뒤에는 반드시 COMMIT이나 ROLLBACK을 통해 트랜잭션을 마무리해야 합니다. 물론 MySQL처럼 기본적으로 AUTOCOMMIT이 켜져 있는 DBMS도 있지만, 실무에서는 명시적으로 트랜잭션을 관리하는 것이 안전합니다.

이 절에서는 INSERT, UPDATE, DELETE 각 명령의 기본 문법부터 DBMS별 차이, 실무에서 자주 쓰는 패턴, 대량 데이터 처리, 그리고 실수를 방지하는 안전한 DML 작성법까지 깊이 있게 다룹니다.


INSERT — 데이터 삽입

INSERT는 테이블에 새로운 행(row)을 추가하는 명령입니다. 단순해 보이지만, 컬럼 순서, NULL 처리, 기본값, 자동 생성 키 등 고려할 사항이 많습니다.

기본 문법

INSERT 문의 구조는 어떤 테이블의 어떤 컬럼에 어떤 값을 넣겠다입니다.

INSERT 기본 구조
INSERT INTO 테이블명 (컬럼1, 컬럼2, ...)
VALUES (값1, 값2, ...);

컬럼 리스트를 명시하면 VALUES의 값이 해당 컬럼 순서대로 매핑됩니다. 컬럼 리스트를 생략하면 테이블 정의 순서대로 모든 컬럼에 값을 제공해야 합니다.

컬럼 리스트 명시 vs 생략
-- 컬럼 리스트 명시 (권장)
INSERT INTO users (user_id, username, email, password)
VALUES (1, 'kim', 'kim@example.com', 'hashed_pw');

-- 컬럼 리스트 생략 (비권장)
INSERT INTO users
VALUES (1, 'kim', 'kim@example.com', 'hashed_pw', SYSDATE);

컬럼 리스트를 생략하면 나중에 테이블에 컬럼이 추가되었을 때 기존 INSERT 문이 깨질 수 있습니다. 실무에서는 항상 컬럼 리스트를 명시하는 것이 안전합니다. 코드 리뷰에서 컬럼 리스트 없는 INSERT는 거의 항상 지적 대상이 됩니다.

여러 행 삽입

한 번에 여러 행을 삽입하는 방법은 DBMS마다 다릅니다.

MySQL/PostgreSQL — 다건 삽입
INSERT INTO users (user_id, username, email, password) VALUES
    (2, 'park', 'park@example.com', 'pw_hash_2'),
    (3, 'choi', 'choi@example.com', 'pw_hash_3'),
    (4, 'lee', 'lee@example.com', 'pw_hash_4');

MySQL과 PostgreSQL은 VALUES 뒤에 여러 행을 쉼표로 구분하여 나열할 수 있습니다. 이 방식은 행마다 INSERT를 따로 실행하는 것보다 훨씬 빠릅니다. 네트워크 왕복이 한 번이고, 트랜잭션 로그 기록도 한 번에 처리되기 때문입니다.

Oracle — INSERT ALL
INSERT ALL
    INTO users (user_id, username, email, password)
        VALUES (2, 'park', 'park@example.com', 'pw_hash_2')
    INTO users (user_id, username, email, password)
        VALUES (3, 'choi', 'choi@example.com', 'pw_hash_3')
    INTO users (user_id, username, email, password)
        VALUES (4, 'lee', 'lee@example.com', 'pw_hash_4')
SELECT 1 FROM DUAL;

Oracle은 INSERT ALL 구문을 사용합니다. 마지막에 SELECT 1 FROM DUAL이 필요한데, 이는 Oracle의 INSERT ALL 문법이 SELECT를 기반으로 동작하기 때문입니다. 또한 INSERT ALL은 서로 다른 테이블에 동시에 삽입할 수도 있습니다.

Oracle — 조건부 INSERT ALL
INSERT ALL
    WHEN salary > 5000 THEN
        INTO high_salary_emp (emp_id, name, salary)
        VALUES (emp_id, emp_name, salary)
    WHEN salary <= 5000 THEN
        INTO low_salary_emp (emp_id, name, salary)
        VALUES (emp_id, emp_name, salary)
SELECT emp_id, emp_name, salary FROM employees;

이처럼 WHEN 조건을 붙이면 조건에 따라 다른 테이블에 분배 삽입할 수 있습니다.

SELECT 결과를 INSERT (INSERT INTO ... SELECT)

다른 테이블의 데이터를 조회하여 그 결과를 삽입하는 패턴입니다. 데이터 마이그레이션, 백업, 집계 테이블 적재 등에 자주 사용됩니다.

SELECT 결과를 INSERT
-- 올해 이전 회원을 백업 테이블에 복사
INSERT INTO users_backup (user_id, username, email)
SELECT user_id, username, email
FROM users
WHERE created_at < DATE '2024-01-01';

이 구문에서 VALUES 키워드는 사용하지 않습니다. SELECT가 반환하는 컬럼 수와 타입이 INSERT INTO의 컬럼 리스트와 일치해야 합니다. 실무에서 야간 배치로 일별 집계 데이터를 적재할 때 이 패턴을 자주 볼 수 있습니다.

집계 결과 삽입 예시
-- 일별 매출 집계를 summary 테이블에 적재
INSERT INTO daily_sales_summary (sale_date, total_amount, order_count)
SELECT DATE(order_date),
       SUM(total_amount),
       COUNT(*)
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '1' DAY
  AND order_date < CURRENT_DATE
GROUP BY DATE(order_date);

DEFAULT 값과 NULL 처리

INSERT에서 특정 컬럼을 생략하면, 그 컬럼에는 DEFAULT 값이 들어갑니다. DEFAULT가 정의되어 있지 않으면 NULL이 들어가고, NOT NULL 제약이 있으면 에러가 발생합니다.

DEFAULT 활용
CREATE TABLE products (
    product_id   INT PRIMARY KEY,
    name         VARCHAR(100) NOT NULL,
    price        INT DEFAULT 0,
    stock        INT DEFAULT 0,
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- price, stock, created_at 생략 → DEFAULT 값 적용
INSERT INTO products (product_id, name)
VALUES (1, '테스트 상품');
-- 결과: price=0, stock=0, created_at=현재시각

명시적으로 DEFAULT 키워드를 사용할 수도 있습니다.

명시적 DEFAULT
INSERT INTO products (product_id, name, price, stock, created_at)
VALUES (2, '신상품', DEFAULT, 100, DEFAULT);

자동 생성 키 (AUTO_INCREMENT, SEQUENCE, IDENTITY)

기본 키를 자동으로 생성하는 방식은 DBMS마다 크게 다릅니다.

MySQL — AUTO_INCREMENT
CREATE TABLE orders (
    order_id   INT AUTO_INCREMENT PRIMARY KEY,
    user_id    INT NOT NULL,
    order_date DATE NOT NULL
);

INSERT INTO orders (user_id, order_date) VALUES (1, '2024-03-15');
-- order_id는 자동으로 1, 2, 3... 증가
Oracle — SEQUENCE
CREATE SEQUENCE order_seq START WITH 1 INCREMENT BY 1;

INSERT INTO orders (order_id, user_id, order_date)
VALUES (order_seq.NEXTVAL, 1, DATE '2024-03-15');
PostgreSQL — SERIAL / IDENTITY
-- SERIAL (레거시 방식)
CREATE TABLE orders (
    order_id   SERIAL PRIMARY KEY,
    user_id    INT NOT NULL
);

-- IDENTITY (SQL 표준, PostgreSQL 10+)
CREATE TABLE orders (
    order_id   INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    INT NOT NULL
);

Oracle 12c부터는 IDENTITY 컬럼도 지원합니다. SEQUENCE를 명시적으로 만들 필요 없이 CREATE TABLE 안에서 GENERATED ALWAYS AS IDENTITY를 사용할 수 있습니다.

DBMS방식특징
MySQLAUTO_INCREMENT테이블당 하나, 항상 PK 또는 UNIQUE 필요
OracleSEQUENCE여러 테이블에서 공유 가능, 유연함
PostgreSQLSERIAL / IDENTITYSERIAL은 내부적으로 SEQUENCE 생성
SQL ServerIDENTITYIDENTITY(1,1) 형태로 시작값/증가값 지정

RETURNING / OUTPUT — 삽입된 값 확인

INSERT 후 자동 생성된 키나 DEFAULT 값을 바로 확인하고 싶을 때 사용합니다. 애플리케이션에서 INSERT 직후 생성된 ID를 알아야 할 때 매우 유용합니다.

PostgreSQL — RETURNING
INSERT INTO orders (user_id, order_date)
VALUES (1, CURRENT_DATE)
RETURNING order_id, order_date;
-- 결과: order_id=42, order_date=2024-03-15
SQL Server — OUTPUT
INSERT INTO orders (user_id, order_date)
OUTPUT inserted.order_id, inserted.order_date
VALUES (1, GETDATE());

MySQL에서는 RETURNING을 지원하지 않지만, LAST_INSERT_ID() 함수로 마지막 AUTO_INCREMENT 값을 가져올 수 있습니다. Oracle은 RETURNING INTO 절로 PL/SQL 변수에 값을 받을 수 있습니다.


UPDATE — 데이터 수정

UPDATE는 기존 행의 컬럼 값을 변경하는 명령입니다. WHERE 절로 수정 대상을 특정하고, SET 절에서 새로운 값을 지정합니다.

기본 문법

UPDATE 기본 구조
UPDATE 테이블명
SET 컬럼1 = 값1, 컬럼2 = 값2, ...
WHERE 조건;

SET 절에서 여러 컬럼을 동시에 수정할 수 있습니다. 각 컬럼의 새 값은 상수, 수식, 함수 호출, 서브쿼리 등 다양한 형태가 가능합니다.

다양한 SET 표현
-- 상수 값 할당
UPDATE products SET price = 15000 WHERE product_id = 1;

-- 기존 값 기반 수식
UPDATE products
SET price = price * 1.1,     -- 10% 인상
    stock = stock - 1         -- 재고 1 감소
WHERE product_id = 1;

-- 함수 사용
UPDATE users
SET username = UPPER(username),
    updated_at = CURRENT_TIMESTAMP
WHERE user_id = 100;

UPDATE 문에서 SET 절의 오른쪽에는 해당 행의 현재 값을 참조할 수 있습니다. price = price * 1.1은 현재 가격에 1.1을 곱한 결과로 바꿔라라는 뜻입니다. 이 점을 이용하면 카운터 증가, 잔액 차감 등의 원자적 연산을 SQL 한 문장으로 처리할 수 있습니다.

서브쿼리를 이용한 UPDATE

다른 테이블의 값을 참조하여 수정할 때 서브쿼리를 사용합니다.

서브쿼리 UPDATE
-- 특정 회원의 주문을 모두 취소
UPDATE orders
SET status = 'CANCELLED'
WHERE user_id IN (
    SELECT user_id FROM users WHERE username = 'kim'
);

-- 상품별 평균 평점을 상품 테이블에 반영
UPDATE products p
SET avg_rating = (
    SELECT AVG(rating) FROM reviews r WHERE r.product_id = p.product_id
)
WHERE EXISTS (
    SELECT 1 FROM reviews r WHERE r.product_id = p.product_id
);

두 번째 예시에서 WHERE EXISTS를 빼면, 리뷰가 없는 상품의 avg_rating이 NULL로 덮어씌워집니다. 의도하지 않은 NULL 업데이트를 방지하려면 EXISTS 조건을 함께 사용하는 것이 좋습니다.

조인을 이용한 UPDATE (DBMS별 차이)

테이블을 조인하면서 UPDATE하는 문법은 DBMS마다 다릅니다.

MySQL — JOIN UPDATE
UPDATE orders o
JOIN users u ON o.user_id = u.user_id
SET o.status = 'VIP_ORDER'
WHERE u.grade = 'VIP';
PostgreSQL — FROM 절 UPDATE
UPDATE orders o
SET status = 'VIP_ORDER'
FROM users u
WHERE o.user_id = u.user_id
  AND u.grade = 'VIP';
Oracle — 서브쿼리 UPDATE 또는 MERGE
UPDATE orders
SET status = 'VIP_ORDER'
WHERE user_id IN (
    SELECT user_id FROM users WHERE grade = 'VIP'
);

MySQL은 UPDATE 문에서 직접 JOIN을 사용하고, PostgreSQL은 FROM 절을 사용합니다. Oracle은 전통적으로 서브쿼리나 MERGE 문을 사용합니다. 같은 결과를 내는 SQL이지만 문법이 다르므로, 사용하는 DBMS에 맞춰 작성해야 합니다.

CASE를 활용한 조건부 UPDATE

하나의 UPDATE 문으로 조건에 따라 서로 다른 값을 설정할 수 있습니다.

조건부 UPDATE
-- 카테고리별 할인율 적용
UPDATE products
SET price = CASE
    WHEN category = '식품'     THEN price * 0.9    -- 10% 할인
    WHEN category = '전자기기' THEN price * 0.95   -- 5% 할인
    WHEN category = '의류'     THEN price * 0.8    -- 20% 할인
    ELSE price                                     -- 변경 없음
END
WHERE category IN ('식품', '전자기기', '의류');

CASE 표현식을 사용하면 카테고리별로 세 번의 UPDATE를 실행하는 대신, 한 번의 UPDATE로 처리할 수 있습니다. 트랜잭션 로그도 한 번만 기록되고, 테이블 스캔도 한 번이므로 성능상 유리합니다.


DELETE — 데이터 삭제

DELETE는 테이블에서 행을 제거하는 명령입니다. 삭제한 행은 COMMIT 전까지는 ROLLBACK으로 복구할 수 있지만, COMMIT 후에는 되돌릴 수 없습니다.

기본 문법

DELETE 기본 구조
DELETE FROM 테이블명
WHERE 조건;
다양한 DELETE 예시
-- 특정 행 삭제
DELETE FROM order_items WHERE item_id = 1;

-- 조건부 삭제
DELETE FROM products WHERE stock = 0 AND category = '식품';

-- 날짜 기반 삭제 (오래된 로그 정리)
DELETE FROM access_logs
WHERE log_date < CURRENT_DATE - INTERVAL '90' DAY;

서브쿼리를 이용한 DELETE

다른 테이블의 조건에 따라 삭제할 때 서브쿼리를 사용합니다.

서브쿼리 DELETE
-- 탈퇴한 회원의 주문 삭제
DELETE FROM orders
WHERE user_id IN (
    SELECT user_id FROM users WHERE status = 'WITHDRAWN'
);

-- 리뷰가 하나도 없는 상품 삭제
DELETE FROM products
WHERE NOT EXISTS (
    SELECT 1 FROM reviews WHERE reviews.product_id = products.product_id
);

DELETE vs TRUNCATE vs DROP

데이터를 지우는 세 가지 방법의 차이를 정확히 이해해야 합니다.

구분DELETETRUNCATEDROP
분류DMLDDLDDL
대상조건에 맞는 행테이블의 모든 행테이블 자체
WHERE사용 가능사용 불가해당 없음
ROLLBACK가능불가 (대부분)불가
로그 기록행별로 기록최소한의 로그최소한의 로그
속도느림 (행별 삭제)빠름 (페이지 할당 해제)빠름
테이블 구조유지유지삭제
AUTO_INCREMENT유지 (이어서)초기화해당 없음
트리거 발생아니오아니오
외래키 참조 시참조 무결성 검사실패 (참조 있으면)CASCADE 필요

TRUNCATE가 DELETE보다 빠른 이유는 동작 방식이 근본적으로 다르기 때문입니다. DELETE는 행을 하나씩 찾아서 삭제하면서 각 행의 삭제 로그를 기록합니다. 반면 TRUNCATE는 테이블이 사용하는 데이터 페이지 전체를 한 번에 해제합니다. 100만 행 테이블에서 DELETE FROM은 100만 번의 로그를 쓰지만, TRUNCATE는 단 몇 번의 페이지 해제 로그만 씁니다.

상황별 선택
-- 조건부로 일부 행만 삭제: DELETE
DELETE FROM logs WHERE created_at < '2023-01-01';

-- 테이블 비우기 (초기화): TRUNCATE
TRUNCATE TABLE temp_import_data;

-- 테이블 자체를 제거: DROP
DROP TABLE temp_import_data;

외래키와 DELETE — 참조 무결성

부모 테이블의 행을 삭제하려 할 때, 자식 테이블에서 참조하고 있으면 에러가 발생합니다. 이를 처리하는 방법은 외래키 정의 시 ON DELETE 옵션으로 결정합니다.

ON DELETE 옵션
-- 자식 행도 함께 삭제 (CASCADE)
CREATE TABLE order_items (
    item_id    INT PRIMARY KEY,
    order_id   INT REFERENCES orders(order_id) ON DELETE CASCADE,
    product_id INT NOT NULL,
    quantity   INT NOT NULL
);

-- 부모 삭제 시 자식의 FK를 NULL로 변경 (SET NULL)
CREATE TABLE comments (
    comment_id INT PRIMARY KEY,
    user_id    INT REFERENCES users(user_id) ON DELETE SET NULL,
    content    TEXT NOT NULL
);

-- 부모 삭제 자체를 거부 (RESTRICT / NO ACTION, 기본값)
CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    user_id  INT REFERENCES users(user_id) ON DELETE RESTRICT
);

CASCADE는 편리하지만 위험할 수 있습니다. 회원을 삭제하면 그 회원의 주문, 리뷰, 게시글, 댓글이 연쇄적으로 삭제될 수 있습니다. 실무에서는 CASCADE 대신 소프트 삭제(논리적 삭제)를 선호하는 경우가 많습니다.


WHERE 없는 UPDATE/DELETE의 위험

DML에서 가장 치명적인 실수는 WHERE 절을 빠뜨리는 것입니다. 이 실수 하나로 전체 데이터가 변경되거나 삭제될 수 있습니다.

실수 사례
⚠️ 절대 주의!

UPDATE products SET price = 0;
→ 전체 상품의 가격이 0원이 됨

DELETE FROM users;
→ 전체 회원 데이터가 삭제됨

예방 방법

방법 1: 먼저 SELECT로 영향 범위 확인

UPDATE나 DELETE를 실행하기 전에, 같은 WHERE 조건으로 SELECT COUNT(*)를 실행하여 영향받을 행 수를 확인합니다.

영향 범위 확인
-- 1단계: 확인
SELECT COUNT(*) FROM products WHERE stock = 0;
-- 결과: 15건

-- 2단계: 확인 후 실행
DELETE FROM products WHERE stock = 0;
방법 2: 트랜잭션 안에서 실행
트랜잭션 활용
BEGIN;  -- 또는 START TRANSACTION (MySQL)

DELETE FROM products WHERE stock = 0;

-- 영향받은 행 수 확인 (DBMS가 알려줌)
-- "15 rows deleted" 같은 메시지 확인

-- 예상대로라면 확정
COMMIT;
-- 잘못되었다면 취소
-- ROLLBACK;
방법 3: MySQL의 Safe Updates 모드
MySQL Safe Updates
SET sql_safe_updates = 1;

-- WHERE 없는 UPDATE/DELETE는 에러 발생
DELETE FROM users;
-- ERROR 1175: You are using safe update mode
-- and you tried to update a table without a WHERE that uses a KEY column.

-- WHERE가 있으면 정상 실행
DELETE FROM users WHERE user_id = 1;
방법 4: LIMIT 절로 최대 삭제 건수 제한 (MySQL)
LIMIT으로 안전장치
-- 최대 100건만 삭제 (MySQL)
DELETE FROM logs WHERE created_at < '2023-01-01' LIMIT 100;

MERGE / UPSERT — 있으면 수정, 없으면 삽입

실무에서는 데이터가 이미 있으면 UPDATE하고, 없으면 INSERT하라는 요구가 매우 흔합니다. 이를 UPSERT라 부르며, DBMS마다 구현 방식이 다릅니다.

Oracle / SQL Server — MERGE

MERGE 구문
MERGE INTO products t          -- 대상 테이블
USING new_products s           -- 소스 테이블
ON (t.product_id = s.product_id)  -- 매칭 조건
WHEN MATCHED THEN
    UPDATE SET
        t.name = s.name,
        t.price = s.price,
        t.stock = s.stock
WHEN NOT MATCHED THEN
    INSERT (product_id, name, price, stock)
    VALUES (s.product_id, s.name, s.price, s.stock);

MERGE는 SQL 표준(SQL:2003)에 정의된 구문으로, 하나의 문장으로 INSERT와 UPDATE를 동시에 처리합니다. Oracle, SQL Server, PostgreSQL 15+ 에서 사용할 수 있습니다.

MySQL — ON DUPLICATE KEY UPDATE

MySQL의 UPSERT
INSERT INTO products (product_id, name, price, stock)
VALUES (1, '노트북', 1500000, 50)
ON DUPLICATE KEY UPDATE
    name = VALUES(name),
    price = VALUES(price),
    stock = VALUES(stock);

product_id가 이미 존재하면(PK 또는 UNIQUE 제약 위반 시) UPDATE를 실행하고, 존재하지 않으면 INSERT를 실행합니다. MySQL 8.0.19부터는 VALUES() 대신 AS 별칭을 사용할 수 있습니다.

MySQL 8.0.19+ 새 문법
INSERT INTO products (product_id, name, price, stock)
VALUES (1, '노트북', 1500000, 50) AS new_data
ON DUPLICATE KEY UPDATE
    name = new_data.name,
    price = new_data.price,
    stock = new_data.stock;

PostgreSQL — ON CONFLICT

PostgreSQL의 UPSERT
INSERT INTO products (product_id, name, price, stock)
VALUES (1, '노트북', 1500000, 50)
ON CONFLICT (product_id) DO UPDATE
SET name = EXCLUDED.name,
    price = EXCLUDED.price,
    stock = EXCLUDED.stock;

-- 충돌 시 아무것도 하지 않으려면
INSERT INTO products (product_id, name, price, stock)
VALUES (1, '노트북', 1500000, 50)
ON CONFLICT (product_id) DO NOTHING;

PostgreSQL의 ON CONFLICT는 충돌 대상을 명시적으로 지정할 수 있어, PK뿐 아니라 특정 UNIQUE 인덱스를 기준으로도 UPSERT를 수행할 수 있습니다. EXCLUDED는 삽입하려 했던 행의 값을 참조하는 특별한 키워드입니다.


DML과 트랜잭션

DML 명령은 트랜잭션과 밀접한 관계가 있습니다. INSERT, UPDATE, DELETE는 모두 트랜잭션 안에서 실행되며, COMMIT 전까지는 다른 세션에서 변경 내용을 볼 수 없습니다(격리 수준에 따라 다름).

트랜잭션 기본 패턴
BEGIN;

-- 1. 계좌 A에서 출금
UPDATE accounts SET balance = balance - 50000
WHERE account_id = 'A001';

-- 2. 계좌 B에 입금
UPDATE accounts SET balance = balance + 50000
WHERE account_id = 'B001';

-- 3. 이체 이력 기록
INSERT INTO transfer_history (from_account, to_account, amount, transfer_date)
VALUES ('A001', 'B001', 50000, CURRENT_TIMESTAMP);

-- 모두 성공하면 확정
COMMIT;
-- 하나라도 실패하면 전체 취소: ROLLBACK;

만약 1번에서 출금은 성공하고 2번에서 입금이 실패하면, ROLLBACK으로 1번의 출금도 취소해야 합니다. 이것이 트랜잭션의 원자성(Atomicity)입니다. DML은 항상 트랜잭션의 맥락에서 이해해야 합니다.

AUTOCOMMIT 설정

DBMS기본값변경 방법
MySQLON (자동 커밋)SET AUTOCOMMIT = 0;
PostgreSQLONBEGIN; 으로 명시적 트랜잭션 시작
OracleOFF (수동 커밋)SET AUTOCOMMIT ON; (SQL*Plus)
SQL ServerONSET IMPLICIT_TRANSACTIONS ON;

Oracle은 기본적으로 자동 커밋하지 않으므로, DML 실행 후 반드시 COMMIT이나 ROLLBACK을 해야 합니다. MySQL은 기본적으로 자동 커밋이므로, 트랜잭션이 필요하면 START TRANSACTION 또는 BEGIN으로 명시적으로 시작해야 합니다.


대량 데이터 처리

수백만 건의 데이터를 INSERT, UPDATE, DELETE할 때는 일반적인 방법으로는 성능 문제가 발생합니다. 대량 처리를 위한 전용 기법이 있습니다.

대량 INSERT — 벌크 로딩

MySQL — LOAD DATA
LOAD DATA INFILE '/tmp/users.csv'
INTO TABLE users
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS
(username, email, phone);
PostgreSQL — COPY
COPY users (username, email, phone)
FROM '/tmp/users.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');
Oracle — SQL*Loader 또는 외부 테이블
-- SQL*Loader 제어 파일
-- LOAD DATA
-- INFILE 'users.csv'
-- INTO TABLE users
-- FIELDS TERMINATED BY ','
-- (username, email, phone)

벌크 로딩은 INSERT 문보다 10~100배 빠릅니다. SQL 파싱, 로그 기록, 인덱스 유지 등의 오버헤드를 최소화하기 때문입니다.

대량 DELETE — 배치 삭제

한 번에 수백만 건을 DELETE하면 트랜잭션 로그가 가득 차고, 테이블에 장시간 락이 걸립니다. 배치로 나누어 삭제하는 것이 안전합니다.

MySQL — 배치 삭제
-- 1만 건씩 반복 삭제
DELETE FROM access_logs
WHERE log_date < '2023-01-01'
LIMIT 10000;
-- 영향받은 행이 0이 될 때까지 반복 실행
PostgreSQL — CTE를 이용한 배치 삭제
WITH to_delete AS (
    SELECT log_id FROM access_logs
    WHERE log_date < '2023-01-01'
    LIMIT 10000
)
DELETE FROM access_logs
WHERE log_id IN (SELECT log_id FROM to_delete);

배치 삭제의 핵심은 각 배치 사이에 COMMIT을 하여 트랜잭션 로그를 해제하고, 다른 트랜잭션이 테이블에 접근할 수 있게 하는 것입니다.

대량 UPDATE — 배치 업데이트

대량 UPDATE도 같은 원리로 배치 처리합니다.

배치 UPDATE 예시
-- 1만 건씩 상태 변경
UPDATE orders SET status = 'ARCHIVED'
WHERE status = 'COMPLETED'
  AND order_date < '2023-01-01'
  AND ROWNUM <= 10000;  -- Oracle
-- MySQL: LIMIT 10000
-- PostgreSQL: CTE + LIMIT 활용

DML 성능 최적화

인덱스와 DML의 관계

인덱스는 SELECT 성능을 높이지만, INSERT/UPDATE/DELETE 성능은 낮춥니다. DML이 실행될 때마다 관련된 모든 인덱스도 함께 갱신해야 하기 때문입니다.

인덱스가 DML에 미치는 영향
테이블에 인덱스가 5개 있다면:

INSERT 1건 실행 시:
  → 테이블에 행 1개 삽입
  → 인덱스 5개 각각에 항목 추가
  → 총 6번의 쓰기 발생

DELETE 1건 실행 시:
  → 테이블에서 행 1개 삭제
  → 인덱스 5개 각각에서 항목 제거
  → 총 6번의 쓰기 발생

따라서 대량 INSERT가 예상되는 테이블에 불필요한 인덱스를 만들면 성능이 크게 떨어집니다. 대량 적재 시에는 인덱스를 먼저 제거(DROP INDEX)하고, 데이터 적재 후 인덱스를 다시 생성(CREATE INDEX)하는 전략을 쓰기도 합니다.

INSERT 성능 팁

방법효과
다건 INSERT (VALUES 여러 행)네트워크 왕복 감소
벌크 로딩 (LOAD DATA, COPY)파싱/로그 오버헤드 최소화
인덱스 임시 제거 후 재생성인덱스 유지 비용 제거
외래키 검사 비활성화 (SET FOREIGN_KEY_CHECKS=0)FK 검사 비용 제거
AUTOCOMMIT OFF + 배치 COMMIT트랜잭션 로그 효율화

UPDATE/DELETE 성능 팁

방법효과
인덱스가 있는 컬럼으로 WHERE 조건풀스캔 방지
배치 처리 (LIMIT/ROWNUM)락 시간 단축
서브쿼리 대신 JOIN UPDATE서브쿼리 반복 실행 방지
파티션 테이블에서 파티션 DROP대량 삭제 대체

실무 패턴

소프트 삭제 (Soft Delete)

데이터를 실제로 DELETE하지 않고, 삭제 여부를 나타내는 컬럼으로 관리하는 패턴입니다. 실무에서 매우 많이 사용됩니다.

소프트 삭제 구현
-- 테이블에 삭제 표시 컬럼 추가
ALTER TABLE users ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;

-- 삭제 대신 상태 변경
UPDATE users
SET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP
WHERE user_id = 100;

-- 조회 시 삭제된 행 제외
SELECT * FROM users WHERE is_deleted = FALSE;

소프트 삭제의 장점은 데이터 복구가 쉽고, 삭제 이력을 추적할 수 있으며, 외래키 참조 문제가 발생하지 않는다는 것입니다. 단점은 모든 쿼리에 WHERE is_deleted = FALSE 조건을 붙여야 하고, 시간이 지나면 삭제된 데이터가 쌓여 테이블 크기가 커진다는 것입니다.

이력 테이블 (History Table)

중요한 데이터는 변경 이력을 별도 테이블에 기록합니다. 트리거나 애플리케이션 코드에서 구현할 수 있습니다.

이력 테이블 패턴
CREATE TABLE products_history (
    history_id   INT PRIMARY KEY AUTO_INCREMENT,
    product_id   INT NOT NULL,
    name         VARCHAR(100),
    price        INT,
    changed_by   VARCHAR(50),
    changed_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    change_type  VARCHAR(10)  -- 'INSERT', 'UPDATE', 'DELETE'
);

-- 트리거로 자동 기록 (MySQL)
CREATE TRIGGER trg_products_update
AFTER UPDATE ON products
FOR EACH ROW
INSERT INTO products_history (product_id, name, price, changed_by, change_type)
VALUES (OLD.product_id, OLD.name, OLD.price, CURRENT_USER(), 'UPDATE');

감사 로그 (Audit Log)

누가, 언제, 어떤 데이터를 변경했는지 추적하는 패턴입니다. 개인정보 처리, 금융 데이터 등 규정 준수가 필요한 시스템에서 필수적입니다.

감사 로그 패턴
CREATE TABLE audit_log (
    log_id      INT PRIMARY KEY AUTO_INCREMENT,
    table_name  VARCHAR(50),
    operation   VARCHAR(10),  -- INSERT, UPDATE, DELETE
    row_id      INT,
    old_values  JSON,          -- 변경 전 값
    new_values  JSON,          -- 변경 후 값
    user_id     INT,
    ip_address  VARCHAR(45),
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

DML 작성 체크리스트

실무에서 DML을 작성할 때 확인해야 할 사항을 정리합니다.

DML 체크리스트
✅ INSERT 체크리스트
  □ 컬럼 리스트를 명시했는가?
  □ NOT NULL 컬럼에 값을 모두 제공했는가?
  □ PK/UNIQUE 중복은 없는가?
  □ FK 참조 무결성을 만족하는가?
  □ 데이터 타입과 길이는 맞는가?

✅ UPDATE 체크리스트
  □ WHERE 조건을 빠뜨리지 않았는가?
  □ SELECT로 영향 범위를 먼저 확인했는가?
  □ SET 절의 수식이 올바른가?
  □ 서브쿼리가 단일 행을 반환하는가?
  □ 동시성 이슈는 없는가?

✅ DELETE 체크리스트
  □ WHERE 조건을 빠뜨리지 않았는가?
  □ 외래키로 참조하는 자식 행은 없는가?
  □ 삭제 대신 소프트 삭제가 적절하지 않은가?
  □ 삭제 전 백업이 필요하지 않은가?
  □ 대량 삭제라면 배치 처리를 고려했는가?

요약 정리

명령역할핵심 주의점
INSERT행 추가컬럼 리스트 명시, 제약 조건 확인
UPDATE행 수정WHERE 필수, 영향 범위 사전 확인
DELETE행 삭제WHERE 필수, CASCADE 주의
MERGE조건부 INSERT/UPDATEDBMS별 문법 차이
TRUNCATE전체 행 삭제 (DDL)ROLLBACK 불가, 트리거 미발생

DML은 데이터를 직접 변경하는 명령이므로, 실행 전에 항상 영향 범위를 확인하고 트랜잭션 안에서 실행하는 습관을 들여야 합니다. 특히 운영 환경에서는 한 번의 실수가 복구 불가능한 결과를 초래할 수 있으므로, SELECT로 먼저 확인하고, 트랜잭션으로 보호하고, 배치로 나누어 처리하는 세 가지 원칙을 반드시 지켜야 합니다.

다음 절에서는 지금까지 배운 SQL을 종합하여 실습 프로젝트를 진행하겠습니다.

목차