15강: INSERT, UPDATE, DELETE
14강에서 UNION으로 여러 쿼리 결과를 합치는 법을 배웠습니다. 지금까지는 SELECT로 데이터를 읽기만 했습니다. 이번에는 데이터를 직접 추가(INSERT), 수정(UPDATE), 삭제(DELETE)하는 방법을 배웁니다. 0강에서 배운 CRUD 중 C, U, D에 해당합니다.
이미 알고 계신다면
INSERT, UPDATE, DELETE에 익숙하다면 16강: DDL로 건너뛰세요.
DML(Data Manipulation Language, 데이터 조작 언어) 문은 테이블의 데이터를 변경합니다. SELECT와 달리 이 문장들은 영구적으로 반영됩니다 — UPDATE나 DELETE를 실행하기 전에 WHERE 절을 반드시 다시 확인하세요.
대부분의 DML은 표준 SQL이므로 모든 데이터베이스에서 동일하게 동작합니다. 차이가 있는 부분(날짜 함수, UPSERT 등)만 탭으로 표시합니다.
DML은 데이터를 조작합니다. INSERT(추가), UPDATE(수정), DELETE(삭제)가 있습니다.
안전 수칙:
UPDATE나DELETE를 실행하기 전에, 동일한WHERE조건으로 먼저SELECT를 실행하여 영향받을 행을 정확히 확인하세요.
INSERT INTO
단일 행 삽입
칼럼 이름을 명시적으로 나열하세요 — 쿼리가 자기 문서화되고, 테이블 구조 변경에도 안전합니다.
실행 후 확인:
여러 행 한 번에 삽입
-- 여러 쿠폰 코드 한 번에 추가
INSERT INTO coupons (code, type, discount_value, min_order_amount, is_active, expires_at)
VALUES
('SAVE10', 'percentage', 10, 50.00, 1, '2025-12-31'),
('FLAT20', 'fixed', 20, 100.00, 1, '2025-06-30'),
('VIP50', 'percentage', 50, 200.00, 1, '2025-03-31');
SELECT를 활용한 INSERT
다른 테이블에서 데이터를 복사하거나 오래된 레코드를 아카이브할 때 사용합니다.
-- (가정) 기존 상품을 기반으로 리퍼비시 상품 추가
INSERT INTO products (sku, name, category_id, supplier_id, price, stock_qty, is_active, created_at, updated_at)
SELECT
'SKU-' || CAST(id + 10000 AS TEXT),
name || ' (리퍼비시)',
category_id,
supplier_id,
ROUND(price * 0.7, 2),
10,
1,
datetime('now'),
datetime('now')
FROM products
WHERE sku = 'SKU-0001';
UPDATE SET
특정 행 업데이트
-- 카테고리 3의 모든 활성 상품 가격 15% 인상
UPDATE products
SET
price = ROUND(price * 1.15, 2),
updated_at = datetime('now')
WHERE category_id = 3
AND is_active = 1;
실행 전 확인:
SELECT id, name, price FROM products WHERE category_id = 3 AND is_active = 1;
단일 행 업데이트
-- 수동 검토 후 고객 등급 변경
UPDATE customers
SET
grade = 'GOLD',
updated_at = datetime('now')
WHERE id = 1042;
서브쿼리를 활용한 UPDATE
-- 한 번도 주문되지 않은 상품 비활성화
UPDATE products
SET
is_active = 0,
updated_at = datetime('now')
WHERE id NOT IN (
SELECT DISTINCT product_id FROM order_items
)
AND is_active = 1;
DELETE FROM
특정 행 삭제
실행 전 확인:
SELECT COUNT(*) FROM orders WHERE status = 'cancelled' AND cancelled_at < DATE('now', '-3 years');
서브쿼리를 활용한 DELETE
-- 더 이상 존재하지 않는 상품의 위시리스트 항목 삭제
DELETE FROM wishlists
WHERE product_id NOT IN (
SELECT id FROM products
);
트랜잭션 — 모두 성공하거나 모두 취소하거나
관련된 DML 문들을 트랜잭션으로 묶으면 모두 성공하거나 모두 롤백됩니다.
BEGIN TRANSACTION;
-- 1단계: 재고 차감
UPDATE products
SET stock_qty = stock_qty - 2,
updated_at = datetime('now')
WHERE id = 5;
-- 2단계: 재고 거래 내역 기록
INSERT INTO inventory_transactions (product_id, change_qty, reason, created_at)
VALUES (5, -2, 'manual_adjustment', datetime('now'));
-- 모두 정상이면:
COMMIT;
-- 문제가 생겼다면:
-- ROLLBACK;
자주 하는 실수
| 실수 | 결과 | 예방법 |
|---|---|---|
WHERE 없이 UPDATE table SET col = val |
모든 행이 업데이트됨 | 항상 먼저 SELECT로 확인 |
WHERE 없이 DELETE FROM table |
모든 행이 삭제됨 | 트랜잭션 사용; 먼저 COUNT 확인 |
updated_at 누락 |
감사 추적 정보가 낡아짐 | 모든 UPDATE에 updated_at = datetime('now') 포함 |
| 중복 기본 키 삽입 | 제약 조건 오류 | SQLite: INSERT OR IGNORE / MySQL: INSERT IGNORE / PG: ON CONFLICT DO NOTHING |
UPSERT (INSERT 또는 UPDATE)
실무에서 흔한 패턴입니다: 행이 이미 존재하면 UPDATE, 없으면 INSERT. 이를 UPSERT라고 부릅니다. 문제는 데이터베이스마다 문법이 완전히 다르다는 것입니다.
기본 문법
SQLite는 두 가지 방식을 지원합니다.
방법 1: INSERT OR REPLACE — 충돌 시 기존 행을 삭제하고 새로 삽입합니다. 명시하지 않은 칼럼은 기본값으로 초기화되므로 주의하세요.
INSERT OR REPLACE INTO customers (id, name, email, point_balance, updated_at)
VALUES (100, '홍길동', 'hong@testmail.kr', 1500, datetime('now'));
방법 2: ON CONFLICT ... DO UPDATE — 더 세밀한 제어가 가능합니다. 기존 행의 다른 칼럼은 그대로 유지됩니다.
INSERT INTO customers (id, name, email, point_balance, updated_at)
VALUES (100, '홍길동', 'hong@testmail.kr', 1500, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
point_balance = excluded.point_balance,
updated_at = excluded.updated_at;
excluded는 삽입하려던 값을 참조하는 특수 키워드입니다.
MySQL은 ON DUPLICATE KEY UPDATE 구문을 사용합니다.
INSERT INTO customers (id, name, email, point_balance, updated_at)
VALUES (100, '홍길동', 'hong@testmail.kr', 1500, NOW())
ON DUPLICATE KEY UPDATE
point_balance = VALUES(point_balance),
updated_at = VALUES(updated_at);
VALUES(칼럼명)은 삽입하려던 값을 참조합니다. MySQL 8.0.20+에서는AS new별칭 방식도 지원합니다.
PostgreSQL은 SQLite와 유사한 ON CONFLICT 구문을 사용합니다.
INSERT INTO customers (id, name, email, point_balance, updated_at)
VALUES (100, '홍길동', 'hong@testmail.kr', 1500, NOW())
ON CONFLICT(id) DO UPDATE SET
point_balance = EXCLUDED.point_balance,
updated_at = EXCLUDED.updated_at;
EXCLUDED는 삽입하려던 값을 참조하는 특수 키워드입니다.
예제: 상품 재고 동기화
외부 시스템에서 받은 재고 데이터를 동기화할 때, SKU가 이미 있으면 재고를 갱신하고 없으면 새로 삽입합니다.
참고: SQL 표준 MERGE
SQL 표준에는 MERGE 문이 정의되어 있습니다. MERGE는 UPSERT보다 더 범용적인 구문으로, 원본 테이블과 대상 테이블을 비교하여 일치 여부에 따라 INSERT/UPDATE/DELETE를 모두 수행할 수 있습니다.
-- SQL 표준 MERGE (참고용 — SQLite, MySQL에서는 사용 불가)
MERGE INTO target_table t
USING source_table s ON t.id = s.id
WHEN MATCHED THEN
UPDATE SET t.value = s.value
WHEN NOT MATCHED THEN
INSERT (id, value) VALUES (s.id, s.value);
그러나 지원 현황은 제한적입니다:
| DB | MERGE 지원 |
|---|---|
| SQLite | 미지원 |
| MySQL | 미지원 |
| PostgreSQL | 15+ 지원 |
실무에서는 위에서 배운 UPSERT 패턴을 훨씬 더 많이 사용합니다. 대부분의 경우 UPSERT로 충분하며, 모든 주요 데이터베이스에서 동작합니다.
정리
| 개념 | 설명 | 예시 |
|---|---|---|
레슨 복습 문제
이 레슨에서 배운 개념을 바로 확인하는 간단한 문제입니다. 여러 개념을 종합하는 실전 연습은 연습 문제 섹션을 참고하세요.
문제 1
products 테이블에 다음 3개의 상품을 한 번에 삽입하세요. category_id = 9(키보드), supplier_id = 1, is_active = 1, stock_qty = 30은 모두 동일합니다.
| sku | name | price |
|-----|------|------:|
| SKU-TEST-101 | 무선 키보드 A | 59.99 |
| SKU-TEST-102 | 무선 키보드 B | 79.99 |
| SKU-TEST-103 | 무선 키보드 C | 99.99 |
정답
INSERT INTO products (sku, name, brand, category_id, supplier_id, price, cost_price, stock_qty, is_active, created_at, updated_at)
VALUES
('SKU-TEST-101', '무선 키보드 A', 'Logitech', 9, 1, 59.99, 35.00, 30, 1, datetime('now'), datetime('now')),
('SKU-TEST-102', '무선 키보드 B', 'Logitech', 9, 1, 79.99, 45.00, 30, 1, datetime('now'), datetime('now')),
('SKU-TEST-103', '무선 키보드 C', 'Logitech', 9, 1, 99.99, 55.00, 30, 1, datetime('now'), datetime('now'));
문제 2
다음 시나리오에서 WHERE 절을 빠뜨리면 어떤 일이 발생하는지 설명하고, 올바른 UPDATE를 작성하세요: "고객 ID 500의 전화번호를 '020-0555-1234'로 변경"
문제 3
고객 ID 300의 포인트 잔액을 갱신하는 UPSERT를 작성하세요. 고객이 존재하면 point_balance를 2000으로, updated_at을 현재 시각으로 갱신합니다. 존재하지 않으면 name '이서윤', email 'lee.sy@testmail.kr', phone '020-0300-0001', grade 'BRONZE', point_balance = 2000, is_active = 1로 새로 삽입합니다.
정답
INSERT INTO customers (id, name, email, password_hash, phone, grade, point_balance, is_active, created_at, updated_at)
VALUES (300, '이서윤', 'lee.sy@testmail.kr', 'hash_placeholder', '020-0300-0001', 'BRONZE', 2000, 1, datetime('now'), datetime('now'))
ON CONFLICT(id) DO UPDATE SET
point_balance = excluded.point_balance,
updated_at = excluded.updated_at;
문제 4
외부 재고 시스템에서 SKU 'SKU-0099' 상품의 재고가 150개라는 데이터를 받았습니다. 해당 SKU가 이미 존재하면 stock_qty를 150으로 갱신하되, 가격이 현재보다 낮아지지 않도록 기존 가격을 유지하세요. 존재하지 않으면 name 'USB-C 허브', category_id = 10, supplier_id = 2, price = 35.00, stock_qty = 150, is_active = 1로 삽입합니다.
정답
INSERT INTO products (sku, name, brand, category_id, supplier_id, price, cost_price, stock_qty, is_active, created_at, updated_at)
VALUES ('SKU-0099', 'USB-C 허브', 'Anker', 10, 2, 35.00, 20.00, 150, 1, datetime('now'), datetime('now'))
ON CONFLICT(sku) DO UPDATE SET
stock_qty = excluded.stock_qty,
price = MAX(products.price, excluded.price),
updated_at = excluded.updated_at;
문제 5
공급업체가 납품 가격을 변경했습니다. supplier_id = 7인 모든 활성 상품의 price를 8% 인상하고 updated_at도 갱신하세요. 먼저 어떤 행이 변경될지 확인하는 SELECT를 작성하고, 그 다음 UPDATE를 작성하세요.
정답
문제 6
비활성(is_active = 0)이고 단종된(discontinued_at IS NOT NULL) 상품의 stock_qty를 0으로, updated_at을 현재 시각으로 갱신하세요.
정답
문제 7
BRONZE 등급이고 포인트 잔액(point_balance)이 0인 활성 고객의 등급을 'SILVER'로, 포인트를 500으로 변경하세요. updated_at도 갱신하세요.
정답
문제 8
reviews 테이블에서 rating이 1이고 content가 NULL인 리뷰를 삭제하세요. 먼저 SELECT로 삭제 대상 건수를 확인하세요.
정답
문제 9
가격이 1000 이상이고 활성인 상품을 기반으로, 가격을 60%로 할인한 리퍼비시 상품을 INSERT ... SELECT로 삽입하세요. sku는 원래 SKU 앞에 'REF-'를 붙이고, name 뒤에 ' (리퍼비시)'를 추가하세요. stock_qty는 5로 설정합니다.
정답
INSERT INTO products (sku, name, brand, category_id, supplier_id, price, cost_price, stock_qty, is_active, created_at, updated_at)
SELECT
'REF-' || sku,
name || ' (리퍼비시)',
brand,
category_id,
supplier_id,
ROUND(price * 0.6, 2),
ROUND(cost_price * 0.6, 2),
5,
1,
datetime('now'),
datetime('now')
FROM products
WHERE price >= 1000
AND is_active = 1;
문제 10
한 번도 주문되지 않은 상품의 재고(stock_qty)를 확인하는 SELECT를 작성한 뒤, 해당 상품들을 비활성(is_active = 0)으로 변경하는 UPDATE를 작성하세요. 서브쿼리를 사용하세요.