본문 바로가기
TYLER SONGBlog
← Blog index
CS10 min read

INSERT: 새 행을 추가하고 무결성을 검증하는 SQL 문

INSERT는 테이블에 하나 이상의 행을 추가하는 데이터 조작어(DML)다. PostgreSQL은 입력값을 저장하기 전에 기본값, 제약 조건, 트리거, 인덱스 충돌을 검사한다. `RETURNING`과 `ON CONFLICT`를 이용하면 생성 결과 반환과 충돌 처리를 하나의 원자적 연산(Atomic Operation)으로 수행할 수 있다.

By 송민성

1. 개념

INSERT는 관계형 데이터베이스의 테이블에 새로운 행(Row)을 추가하는 데이터 조작어(DML, Data Manipulation Language)다.

기본 형태는 다음과 같다.

sql
INSERT INTO 테이블명 (열1, 열2) VALUES (값1, 값2);

열 목록을 생략할 수도 있지만, 테이블에 정의된 열 순서와 값을 정확히 맞춰야 한다.

sql
INSERT INTO users VALUES (1, 'kim@example.com', '김개발');

테이블 구조 변경에 취약하므로 실무에서는 열 목록을 명시한다.

sql
INSERT INTO users (email, name) VALUES ('kim@example.com', '김개발');

주요 입력 방식은 세 가지다.

  • VALUES: 직접 지정한 값 삽입
  • INSERT ... SELECT: 조회 결과 삽입
  • DEFAULT VALUES: 모든 열에 기본값(Default Value) 적용

PostgreSQL의 RETURNING 절을 사용하면 삽입된 행의 값을 즉시 반환할 수 있다.

sql
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)으로 묶어 전부 성공하거나 전부 실패하게 만들 수 있다.

sql
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 처리는 개념적으로 다음 순서로 진행된다.

  1. 대상 테이블과 입력 열을 확인한다.
  2. 입력 표현식을 계산하고 데이터 형식을 변환한다.
  3. 생략된 열에 기본값을 적용한다.
  4. 삽입 전 트리거(BEFORE Trigger)를 실행한다.
  5. 제약 조건과 충돌 여부를 검사한다.
  6. 새 튜플(Tuple)을 힙(Heap)에 기록하고 인덱스를 갱신한다.
  7. 삽입 후 트리거(AFTER Trigger)를 실행한다.
  8. RETURNING이 있으면 최종 행을 반환한다.

기본값과 자동 생성 열

sql
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을 생략한다.

sql
INSERT INTO users (email, name) VALUES ('park@example.com', '박개발');

PostgreSQL이 식별자와 현재 시각을 생성한다. CURRENT_TIMESTAMP는 현재 트랜잭션 시작 시각을 반환한다.

원자성과 트랜잭션

하나의 INSERT 문은 원자적(Atomic)이다. 여러 행을 한 문장으로 삽입하다가 한 행에서 제약 조건 오류가 발생하면 문장 전체가 실패한다.

sql
INSERT INTO users (email, name) VALUES ('a@example.com', '사용자 A'), ('a@example.com', '사용자 B');

email에 고유 제약 조건이 있으므로 두 번째 행에서 충돌한다. 첫 번째 행만 남는 것이 아니라 INSERT 전체가 취소된다.

여러 SQL 문을 하나의 작업으로 묶으려면 명시적 트랜잭션을 사용한다.

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) 충돌을 처리한다.

충돌한 입력을 무시할 수 있다.

sql
INSERT INTO users (email, name) VALUES ('kim@example.com', '김개발') ON CONFLICT (email) DO NOTHING;

충돌한 기존 행을 갱신할 수도 있다.

sql
INSERT INTO users (email, name) VALUES ('kim@example.com', '김개발') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;

EXCLUDED는 충돌 때문에 삽입되지 못한 입력 행을 나타낸다. 이 방식은 삽입 후 실패하면 다시 갱신하는 두 단계 처리보다 경쟁 상태(Race Condition)를 줄이고 하나의 원자적 문장으로 실행된다.

4. 코드 예제

다음 SQL은 PostgreSQL에서 순서대로 실행할 수 있다.

테이블 생성

sql
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) );

단일 행 삽입과 결과 반환

sql
INSERT INTO products (sku, name, price, stock) VALUES ('KEYBOARD-001', '기계식 키보드', 120000, 10) RETURNING id, sku, price, stock, created_at;

여러 행 삽입

sql
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를 전송하는 방식보다 네트워크 왕복과 문장 처리 비용을 줄일 수 있다.

조회 결과 삽입

sql
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)는 행이 없으면 삽입하고, 고유 키가 충돌하면 기존 행을 갱신하는 방식이다.

sql
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을 결합하면 생성된 식별자를 같은 문장 안에서 사용할 수 있다.

sql
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개 행을 처리해야 하므로 작업량은 입력 행 수에 비례한다. 각 행마다 제약 조건 검사와 인덱스 갱신도 수행된다.

sql
INSERT INTO products (sku, name, price) VALUES ('A', '상품 A', 1000), ('B', '상품 B', 2000), ('C', '상품 C', 3000);

여러 행을 한 문장으로 보내면 다음 고정 비용을 줄일 수 있다.

  • 클라이언트와 서버 사이의 네트워크 왕복
  • SQL 구문 분석(Parsing)
  • 실행 계획 준비
  • 트랜잭션 커밋 횟수

대량 데이터 적재에서는 거대한 단일 INSERT보다 PostgreSQL의 COPY가 더 적합한 경우가 많다.

sql
COPY products (sku, name, price, stock) FROM STDIN WITH (FORMAT csv, HEADER true);

커밋 비용

각 INSERT를 개별 트랜잭션으로 커밋하면 미리 쓰기 로그(WAL, Write-Ahead Logging)의 동기화 비용이 반복된다. 여러 행을 적절한 크기의 트랜잭션으로 묶으면 처리량을 높일 수 있다.

트랜잭션이 지나치게 크면 다음 문제가 생길 수 있다.

  • 잠금과 행 버전의 유지 시간 증가
  • 실패 시 재처리 범위 증가
  • WAL과 복제 지연 증가
  • 메모리와 임시 저장 공간 사용 증가

적절한 배치 크기는 행 크기, 인덱스 수, 디스크 성능, 복제 구성에 따라 달라지므로 실제 부하에서 측정해야 한다.

6. 실무 사용 사례

생성된 식별자 받기

삽입 후 별도 SELECT로 최신 식별자를 찾으면 동시 요청 때문에 잘못된 행을 읽을 수 있다. RETURNING을 사용한다.

sql
INSERT INTO users (email, name) VALUES ('choi@example.com', '최개발') RETURNING id;

중복 요청 방지

결제나 주문 생성 API는 같은 요청이 재전송될 수 있다. 멱등성 키(Idempotency Key)에 고유 제약 조건을 두고 충돌을 처리한다.

sql
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 );
sql
INSERT INTO payments (idempotency_key, amount, status) VALUES ('request-20261001-001', 50000, 'pending') ON CONFLICT (idempotency_key) DO NOTHING RETURNING id, status;

RETURNING 결과가 없으면 같은 키의 행이 이미 존재한다. 필요한 경우 기존 행을 별도로 조회한다.

감사 로그 기록

sql
INSERT INTO audit_logs ( actor_id, action, target_type, target_id, occurred_at ) VALUES ( 42, 'UPDATE', 'order', '1001', CURRENT_TIMESTAMP );

감사 로그(Audit Log)는 원본 데이터를 직접 수정하는 대신 사건을 새 행으로 누적한다.

데이터 이관

sql
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. 주의할 점

열 목록 생략

sql
INSERT INTO products VALUES (1, 'SKU-001', '상품', 1000, 10, CURRENT_TIMESTAMP);

테이블에 열이 추가되거나 순서가 바뀌면 실패하거나 잘못된 열에 값이 들어갈 수 있다. 열 목록을 명시한다.

sql
INSERT INTO products (sku, name, price, stock) VALUES ('SKU-001', '상품', 1000, 10);

문자열 조합으로 SQL 생성

사용자 입력을 SQL 문자열에 직접 결합하면 SQL 주입(SQL Injection)이 발생할 수 있다.

typescript
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 구조와 분리할 수 있다.

사전 조회 후 삽입

다음 두 단계 처리는 동시 요청에 취약하다.

sql
SELECT id FROM users WHERE email = 'kim@example.com'; INSERT INTO users (email, name) VALUES ('kim@example.com', '김개발');

두 트랜잭션이 동시에 SELECT를 실행하면 둘 다 행이 없다고 판단할 수 있다. 고유 제약 조건과 ON CONFLICT를 사용한다.

sql
INSERT INTO users (email, name) VALUES ('kim@example.com', '김개발') ON CONFLICT (email) DO NOTHING;

DO UPDATE의 의도하지 않은 덮어쓰기

모든 열을 무조건 새 값으로 바꾸면 더 최신인 기존 데이터를 오래된 요청이 덮어쓸 수 있다. 필요한 열만 갱신하거나 조건을 추가한다.

sql
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 문을 계속 실행할 수 없다.

sql
BEGIN; INSERT INTO users (email, name) VALUES ('duplicate@example.com', '첫 번째 사용자'); INSERT INTO users (email, name) VALUES ('duplicate@example.com', '두 번째 사용자'); ROLLBACK;

부분 실패를 복구해야 한다면 저장점(Savepoint)을 사용할 수 있다.

sql
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를 검토한다.
  • 정확성과 동시성은 사전 조회보다 데이터베이스 제약 조건으로 보장한다.