INSERT: 새 행을 추가하고 무결성을 검증하는 SQL 문
INSERT는 테이블에 하나 이상의 행을 추가하는 데이터 조작어(DML)다. PostgreSQL은 입력값을 저장하기 전에 기본값, 제약 조건, 트리거, 인덱스 충돌을 검사한다. `RETURNING`과 `ON CONFLICT`를 이용하면 생성 결과 반환과 충돌 처리를 하나의 원자적 연산(Atomic Operation)으로 수행할 수 있다.
By 송민성
1. 개념
INSERT는 관계형 데이터베이스의 테이블에 새로운 행(Row)을 추가하는 데이터 조작어(DML, Data Manipulation Language)다.
기본 형태는 다음과 같다.
INSERT INTO 테이블명 (열1, 열2)
VALUES (값1, 값2);열 목록을 생략할 수도 있지만, 테이블에 정의된 열 순서와 값을 정확히 맞춰야 한다.
INSERT INTO users
VALUES (1, 'kim@example.com', '김개발');테이블 구조 변경에 취약하므로 실무에서는 열 목록을 명시한다.
INSERT INTO users (email, name)
VALUES ('kim@example.com', '김개발');주요 입력 방식은 세 가지다.
VALUES: 직접 지정한 값 삽입INSERT ... SELECT: 조회 결과 삽입DEFAULT VALUES: 모든 열에 기본값(Default Value) 적용
PostgreSQL의 RETURNING 절을 사용하면 삽입된 행의 값을 즉시 반환할 수 있다.
INSERT INTO users (email, name)
VALUES ('lee@example.com', '이개발')
RETURNING id, email, created_at;2. 왜 사용하는가
애플리케이션에서 생성된 데이터를 영구 저장할 때 사용한다. 회원 가입, 주문 생성, 로그 기록, 데이터 이관처럼 새로운 사실을 데이터베이스에 기록하는 작업이 대상이다.
INSERT는 행을 저장하는 작업만 수행하지 않는다. PostgreSQL은 같은 문장 안에서 다음 규칙을 적용한다.
- 생략한 열의 기본값 계산
- 데이터 형식(Type) 검사
NOT NULL,CHECK,UNIQUE제약 조건 검사- 외래 키(Foreign Key) 검사
- 인덱스 항목 생성
- 트리거(Trigger) 실행
- 충돌 발생 시 삽입 취소 또는 기존 행 갱신
이 검사를 데이터베이스에서 수행하면 여러 애플리케이션이 같은 테이블을 사용해도 동일한 무결성 규칙을 적용할 수 있다.
여러 관련 행은 트랜잭션(Transaction)으로 묶어 전부 성공하거나 전부 실패하게 만들 수 있다.
BEGIN;
INSERT INTO orders (customer_id, status)
VALUES (1, 'pending');
INSERT INTO order_events (order_id, event_type)
VALUES (currval('orders_id_seq'), 'created');
COMMIT;실무에서는 시퀀스 이름을 직접 참조하기보다 RETURNING으로 생성된 식별자를 받아 다음 문장에 전달하는 방식이 더 명확하다.
3. 동작 원리
PostgreSQL의 INSERT 처리는 개념적으로 다음 순서로 진행된다.
- 대상 테이블과 입력 열을 확인한다.
- 입력 표현식을 계산하고 데이터 형식을 변환한다.
- 생략된 열에 기본값을 적용한다.
- 삽입 전 트리거(BEFORE Trigger)를 실행한다.
- 제약 조건과 충돌 여부를 검사한다.
- 새 튜플(Tuple)을 힙(Heap)에 기록하고 인덱스를 갱신한다.
- 삽입 후 트리거(AFTER Trigger)를 실행한다.
RETURNING이 있으면 최종 행을 반환한다.
기본값과 자동 생성 열
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);다음 INSERT에서는 id와 created_at을 생략한다.
INSERT INTO users (email, name)
VALUES ('park@example.com', '박개발');PostgreSQL이 식별자와 현재 시각을 생성한다. CURRENT_TIMESTAMP는 현재 트랜잭션 시작 시각을 반환한다.
원자성과 트랜잭션
하나의 INSERT 문은 원자적(Atomic)이다. 여러 행을 한 문장으로 삽입하다가 한 행에서 제약 조건 오류가 발생하면 문장 전체가 실패한다.
INSERT INTO users (email, name)
VALUES
('a@example.com', '사용자 A'),
('a@example.com', '사용자 B');email에 고유 제약 조건이 있으므로 두 번째 행에서 충돌한다. 첫 번째 행만 남는 것이 아니라 INSERT 전체가 취소된다.
여러 SQL 문을 하나의 작업으로 묶으려면 명시적 트랜잭션을 사용한다.
BEGIN;
INSERT INTO orders (customer_id, status)
VALUES (1, 'pending');
INSERT INTO payments (order_id, amount)
VALUES (100, 50000);
COMMIT;중간 문장이 실패하면 ROLLBACK으로 트랜잭션 전체를 취소한다.
다중 버전 동시성 제어
PostgreSQL은 다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)를 사용한다. 새 행에는 생성 트랜잭션 정보가 기록된다. 다른 트랜잭션에서 행이 보이는지는 트랜잭션 격리 수준(Isolation Level)과 스냅숏(Snapshot)에 따라 결정된다.
INSERT가 실행됐어도 커밋(Commit) 전에는 일반적으로 다른 트랜잭션에서 해당 행을 조회할 수 없다. 롤백(Rollback)하면 논리적으로 저장되지 않은 것으로 처리된다.
고유 키 충돌 처리
ON CONFLICT는 고유 제약 조건이나 배제 제약 조건(Exclusion Constraint) 충돌을 처리한다.
충돌한 입력을 무시할 수 있다.
INSERT INTO users (email, name)
VALUES ('kim@example.com', '김개발')
ON CONFLICT (email) DO NOTHING;충돌한 기존 행을 갱신할 수도 있다.
INSERT INTO users (email, name)
VALUES ('kim@example.com', '김개발')
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name;EXCLUDED는 충돌 때문에 삽입되지 못한 입력 행을 나타낸다. 이 방식은 삽입 후 실패하면 다시 갱신하는 두 단계 처리보다 경쟁 상태(Race Condition)를 줄이고 하나의 원자적 문장으로 실행된다.
4. 코드 예제
다음 SQL은 PostgreSQL에서 순서대로 실행할 수 있다.
테이블 생성
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL,
price integer NOT NULL CHECK (price >= 0),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL REFERENCES products(id),
quantity integer NOT NULL CHECK (quantity > 0),
unit_price integer NOT NULL CHECK (unit_price >= 0)
);단일 행 삽입과 결과 반환
INSERT INTO products (sku, name, price, stock)
VALUES ('KEYBOARD-001', '기계식 키보드', 120000, 10)
RETURNING id, sku, price, stock, created_at;여러 행 삽입
INSERT INTO products (sku, name, price, stock)
VALUES
('MOUSE-001', '무선 마우스', 45000, 30),
('MONITOR-001', '27인치 모니터', 320000, 8),
('HUB-001', 'USB 허브', 35000, 20)
RETURNING id, sku;한 문장으로 여러 행을 삽입하면 행마다 개별 INSERT를 전송하는 방식보다 네트워크 왕복과 문장 처리 비용을 줄일 수 있다.
조회 결과 삽입
INSERT INTO order_items (product_id, quantity, unit_price)
SELECT id, 2, price
FROM products
WHERE sku = 'MOUSE-001'
RETURNING id, product_id, quantity, unit_price;INSERT ... SELECT는 다른 테이블의 조회 결과를 대상 테이블에 저장한다. 대상 열 개수와 SELECT 결과 열 개수가 같아야 하며, 각 위치의 데이터 형식이 호환돼야 한다.
업서트
업서트(Upsert)는 행이 없으면 삽입하고, 고유 키가 충돌하면 기존 행을 갱신하는 방식이다.
INSERT INTO products (sku, name, price, stock)
VALUES ('MOUSE-001', '무선 마우스 2세대', 49000, 15)
ON CONFLICT (sku)
DO UPDATE SET
name = EXCLUDED.name,
price = EXCLUDED.price,
stock = products.stock + EXCLUDED.stock
RETURNING id, sku, name, price, stock;products.stock은 기존 행의 값이고 EXCLUDED.stock은 새로 입력한 값이다.
CTE로 생성 결과 연결
공통 테이블 표현식(CTE, Common Table Expression)과 RETURNING을 결합하면 생성된 식별자를 같은 문장 안에서 사용할 수 있다.
WITH new_product AS (
INSERT INTO products (sku, name, price, stock)
VALUES ('LAPTOP-001', '개발용 노트북', 1800000, 5)
RETURNING id, price
)
INSERT INTO order_items (product_id, quantity, unit_price)
SELECT id, 1, price
FROM new_product
RETURNING id, product_id, quantity, unit_price;5. 시간 복잡도 또는 성능 특성
INSERT 비용은 행을 힙에 기록하는 비용만으로 결정되지 않는다. 인덱스, 제약 조건, 트리거, 외래 키, 동시 트랜잭션이 전체 비용에 영향을 준다.
단일 행 삽입
인덱스가 없는 단순 테이블에 행을 추가하는 작업은 저장 공간 할당과 행 기록이 중심이다. B-트리(B-tree) 인덱스가 m개라면 각 인덱스에도 항목을 추가해야 한다.
B-트리 인덱스 하나의 탐색과 삽입 비용은 일반적으로 테이블 또는 인덱스 항목 수 n에 대해 O(log n)이다. 인덱스가 많을수록 INSERT의 쓰기 작업과 저장 공간 사용량도 증가한다.
고유 인덱스는 새 키가 기존 키와 충돌하는지 확인해야 한다. 외래 키가 있으면 참조 대상 키의 존재 여부를 검사하며, 해당 키에 적절한 인덱스가 없으면 검사 비용이 커질 수 있다.
다중 행 삽입
k개 행을 삽입하면 최소한 k개 행을 처리해야 하므로 작업량은 입력 행 수에 비례한다. 각 행마다 제약 조건 검사와 인덱스 갱신도 수행된다.
INSERT INTO products (sku, name, price)
VALUES
('A', '상품 A', 1000),
('B', '상품 B', 2000),
('C', '상품 C', 3000);여러 행을 한 문장으로 보내면 다음 고정 비용을 줄일 수 있다.
- 클라이언트와 서버 사이의 네트워크 왕복
- SQL 구문 분석(Parsing)
- 실행 계획 준비
- 트랜잭션 커밋 횟수
대량 데이터 적재에서는 거대한 단일 INSERT보다 PostgreSQL의 COPY가 더 적합한 경우가 많다.
COPY products (sku, name, price, stock)
FROM STDIN
WITH (FORMAT csv, HEADER true);커밋 비용
각 INSERT를 개별 트랜잭션으로 커밋하면 미리 쓰기 로그(WAL, Write-Ahead Logging)의 동기화 비용이 반복된다. 여러 행을 적절한 크기의 트랜잭션으로 묶으면 처리량을 높일 수 있다.
트랜잭션이 지나치게 크면 다음 문제가 생길 수 있다.
- 잠금과 행 버전의 유지 시간 증가
- 실패 시 재처리 범위 증가
- WAL과 복제 지연 증가
- 메모리와 임시 저장 공간 사용 증가
적절한 배치 크기는 행 크기, 인덱스 수, 디스크 성능, 복제 구성에 따라 달라지므로 실제 부하에서 측정해야 한다.
6. 실무 사용 사례
생성된 식별자 받기
삽입 후 별도 SELECT로 최신 식별자를 찾으면 동시 요청 때문에 잘못된 행을 읽을 수 있다. RETURNING을 사용한다.
INSERT INTO users (email, name)
VALUES ('choi@example.com', '최개발')
RETURNING id;중복 요청 방지
결제나 주문 생성 API는 같은 요청이 재전송될 수 있다. 멱등성 키(Idempotency Key)에 고유 제약 조건을 두고 충돌을 처리한다.
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
idempotency_key text NOT NULL UNIQUE,
amount integer NOT NULL CHECK (amount > 0),
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);INSERT INTO payments (idempotency_key, amount, status)
VALUES ('request-20261001-001', 50000, 'pending')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, status;RETURNING 결과가 없으면 같은 키의 행이 이미 존재한다. 필요한 경우 기존 행을 별도로 조회한다.
감사 로그 기록
INSERT INTO audit_logs (
actor_id,
action,
target_type,
target_id,
occurred_at
)
VALUES (
42,
'UPDATE',
'order',
'1001',
CURRENT_TIMESTAMP
);감사 로그(Audit Log)는 원본 데이터를 직접 수정하는 대신 사건을 새 행으로 누적한다.
데이터 이관
INSERT INTO archived_orders (
id,
customer_id,
total_amount,
ordered_at
)
SELECT
id,
customer_id,
total_amount,
ordered_at
FROM orders
WHERE ordered_at < DATE '2025-01-01';이관과 원본 삭제가 하나의 작업이라면 트랜잭션으로 묶어 중간 상태를 방지한다.
7. 주의할 점
열 목록 생략
INSERT INTO products
VALUES (1, 'SKU-001', '상품', 1000, 10, CURRENT_TIMESTAMP);테이블에 열이 추가되거나 순서가 바뀌면 실패하거나 잘못된 열에 값이 들어갈 수 있다. 열 목록을 명시한다.
INSERT INTO products (sku, name, price, stock)
VALUES ('SKU-001', '상품', 1000, 10);문자열 조합으로 SQL 생성
사용자 입력을 SQL 문자열에 직접 결합하면 SQL 주입(SQL Injection)이 발생할 수 있다.
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
async function createUser(email: string, name: string) {
const result = await pool.query(
`
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id, email, name
`,
[email, name],
);
return result.rows[0];
}
const user = await createUser("user@example.com", "김개발");
console.log(user);
await pool.end();매개변수화 질의(Parameterized Query)를 사용하면 입력값을 SQL 구조와 분리할 수 있다.
사전 조회 후 삽입
다음 두 단계 처리는 동시 요청에 취약하다.
SELECT id
FROM users
WHERE email = 'kim@example.com';
INSERT INTO users (email, name)
VALUES ('kim@example.com', '김개발');두 트랜잭션이 동시에 SELECT를 실행하면 둘 다 행이 없다고 판단할 수 있다. 고유 제약 조건과 ON CONFLICT를 사용한다.
INSERT INTO users (email, name)
VALUES ('kim@example.com', '김개발')
ON CONFLICT (email) DO NOTHING;DO UPDATE의 의도하지 않은 덮어쓰기
모든 열을 무조건 새 값으로 바꾸면 더 최신인 기존 데이터를 오래된 요청이 덮어쓸 수 있다. 필요한 열만 갱신하거나 조건을 추가한다.
INSERT INTO products (sku, name, price, stock)
VALUES ('MOUSE-001', '무선 마우스', 49000, 10)
ON CONFLICT (sku)
DO UPDATE SET
price = EXCLUDED.price,
stock = EXCLUDED.stock
WHERE products.price IS DISTINCT FROM EXCLUDED.price
OR products.stock IS DISTINCT FROM EXCLUDED.stock;실패한 트랜잭션 계속 사용
PostgreSQL 트랜잭션 안에서 INSERT가 실패하면 해당 트랜잭션은 중단 상태가 된다. ROLLBACK하기 전에는 일반 SQL 문을 계속 실행할 수 없다.
BEGIN;
INSERT INTO users (email, name)
VALUES ('duplicate@example.com', '첫 번째 사용자');
INSERT INTO users (email, name)
VALUES ('duplicate@example.com', '두 번째 사용자');
ROLLBACK;부분 실패를 복구해야 한다면 저장점(Savepoint)을 사용할 수 있다.
BEGIN;
SAVEPOINT before_insert;
INSERT INTO users (email, name)
VALUES ('duplicate@example.com', '사용자');
ROLLBACK TO SAVEPOINT before_insert;
COMMIT;불필요한 인덱스
인덱스는 조회 성능을 높일 수 있지만 INSERT마다 함께 갱신해야 한다. 사용되지 않는 인덱스가 많으면 쓰기 처리량과 저장 공간이 낭비된다. 실제 조회 패턴과 실행 통계를 근거로 유지 여부를 결정한다.
8. 핵심 정리
- INSERT는 테이블에 하나 이상의 새 행을 추가한다.
- 열 목록을 명시하고 생성된 값은
RETURNING으로 받는다. - PostgreSQL은 기본값, 제약 조건, 외래 키, 트리거, 인덱스를 INSERT 과정에서 처리한다.
ON CONFLICT는 고유 키 충돌을 원자적으로 무시하거나 갱신한다.- 여러 행을 한 문장이나 트랜잭션으로 묶으면 네트워크 왕복과 커밋 비용을 줄일 수 있다.
- 대량 적재에는 다중 행 INSERT와 함께
COPY를 검토한다. - 정확성과 동시성은 사전 조회보다 데이터베이스 제약 조건으로 보장한다.