Row: 하나의 개체를 표현하는 열 값의 묶음
행(Row)은 관계형 데이터베이스 테이블에서 하나의 개체나 사건을 나타내는 값의 묶음이다. 각 값은 테이블 스키마에 정의된 열(Column), 자료형(Data Type), 제약 조건(Constraint)에 대응한다. PostgreSQL은 다중 버전 동시성 제어(MVCC)를 위해 행을 수정할 때 기존 행을 덮어쓰지 않고 새 행 버전을 만든다.
By 송민성
1. 개념
행(Row)은 테이블을 구성하는 하나의 데이터 단위다. 사용자 테이블에서는 사용자 한 명, 주문 테이블에서는 주문 한 건을 표현한다.
users 테이블
┌────┬─────────────────┬────────┐
│ id │ email │ active │
├────┼─────────────────┼────────┤
│ 1 │ kim@example.com │ true │ ← 하나의 행
│ 2 │ lee@example.com │ false │ ← 하나의 행
└────┴─────────────────┴────────┘행은 테이블 스키마가 정의한 열에 맞춰 값을 가진다.
- 행(Row): 테이블에 저장된 하나의 데이터 묶음
- 열(Column): 값의 의미와 자료형을 정의하는 속성
- 필드(Field): 특정 행과 열이 만나는 위치의 값
- 튜플(Tuple): 관계형 모델에서 행에 대응하는 개념
- 행 버전(Row Version): 갱신 과정에서 생성되는 특정 시점의 행 상태
관계형 모델의 튜플은 집합의 원소이므로 중복되지 않는다. 반면 SQL 테이블은 기본 키(Primary Key)나 고유 제약 조건(UNIQUE Constraint)이 없으면 내용이 같은 행을 여러 개 저장할 수 있다.
행에는 논리적인 순서가 없다. ORDER BY가 없는 조회 결과의 순서는 보장되지 않는다.
2. 왜 사용하는가
하나의 개체를 표현한다
관련된 값을 하나의 행으로 묶는다.
id = 1
email = kim@example.com
active = true세 값은 각각 독립된 데이터가 아니라 같은 사용자의 상태를 나타낸다.
개체를 고유하게 식별한다
기본 키를 사용하면 각 행을 안정적으로 식별할 수 있다.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
active boolean NOT NULL DEFAULT true
);id는 행의 논리적 식별자다. 이메일이 바뀌어도 같은 사용자를 계속 가리킬 수 있다.
무결성을 행 단위로 검사한다
데이터베이스는 행을 삽입하거나 수정할 때 자료형과 제약 조건을 검사한다.
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
price numeric(12, 2) NOT NULL CHECK (price >= 0),
stock integer NOT NULL CHECK (stock >= 0)
);가격이나 재고가 음수인 행은 저장되지 않는다. 애플리케이션을 거치지 않은 SQL에도 같은 규칙이 적용된다.
테이블 사이의 관계를 만든다
외래 키(Foreign Key)는 한 행이 다른 테이블의 행을 참조하게 한다.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
ordered_at timestamptz NOT NULL DEFAULT now()
);각 주문 행은 user_id를 통해 사용자 행 하나와 연결된다.
3. 동작 원리
3.1 스키마에 맞춘 행 생성
INSERT가 실행되면 PostgreSQL은 입력값을 열에 대응시킨 뒤 다음 항목을 검사한다.
- 값이 열의 자료형으로 변환 가능한가
NOT NULL,CHECK,UNIQUE조건을 만족하는가- 외래 키가 참조하는 행이 존재하는가
- 기본값이나 자동 생성 값이 필요한가
검사를 통과한 행은 테이블의 물리 저장 공간에 기록된다.
3.2 널(NULL)
널(NULL)은 빈 문자열이나 숫자 0이 아니라 값이 없거나 알려지지 않았음을 나타낸다.
CREATE TABLE profiles (
user_id bigint PRIMARY KEY,
nickname text,
birth_date date
);nickname이 NULL인 행은 별명이 빈 문자열이라는 뜻이 아니라 별명 값이 없다는 뜻이다.
널과의 비교에는 = 대신 IS NULL을 사용한다.
SELECT user_id
FROM profiles
WHERE nickname IS NULL;SQL은 삼값 논리(Three-Valued Logic)를 사용한다. 조건식 결과는 TRUE, FALSE, UNKNOWN 중 하나이며, WHERE는 TRUE인 행만 반환한다.
3.3 행 식별
행은 기본 키로 식별한다.
SELECT id, email
FROM users
WHERE id = 1;PostgreSQL의 ctid는 현재 행 버전의 물리적 위치를 나타내는 시스템 열(System Column)이다. 갱신이나 테이블 재구성으로 바뀔 수 있으므로 기본 키 대신 사용하면 안 된다.
3.4 다중 버전 동시성 제어
PostgreSQL은 다중 버전 동시성 제어(Multi-Version Concurrency Control, MVCC)를 사용한다. UPDATE는 보통 기존 행을 제자리에서 덮어쓰지 않는다. 기존 행 버전을 만료 상태로 만들고 새 행 버전을 추가한다.
UPDATE 전
행 버전 A: active = true
UPDATE 후
행 버전 A: 이전 트랜잭션에서 보일 수 있음
행 버전 B: active = false각 트랜잭션은 자신의 스냅숏(Snapshot)에 따라 볼 수 있는 행 버전을 선택한다. 읽기 작업이 쓰기 작업의 중간 상태를 보지 않는 이유다.
더 이상 어떤 트랜잭션에서도 필요하지 않은 이전 행 버전은 정리 대상이 된다. PostgreSQL의 자동 정리(Autovacuum)는 이런 죽은 튜플(Dead Tuple)을 관리한다.
3.5 행 잠금
여러 트랜잭션이 같은 행을 동시에 수정하면 충돌이 발생한다. SELECT ... FOR UPDATE는 선택한 행을 잠가 다른 트랜잭션의 충돌하는 변경을 대기시킨다.
BEGIN;
SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;
UPDATE products
SET stock = stock - 1
WHERE id = 1
AND stock > 0;
COMMIT;행 잠금은 테이블 전체가 아니라 선택한 행의 동시 변경을 조정한다. 단, 잠금 대기와 교착 상태(Deadlock)는 여전히 발생할 수 있다.
4. 코드 예제
다음 SQL은 PostgreSQL에서 실행할 수 있다.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
amount numeric(12, 2) NOT NULL CHECK (amount >= 0),
status text NOT NULL CHECK (
status IN ('pending', 'paid', 'cancelled')
),
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO users (email, name)
VALUES
('kim@example.com', '김개발'),
('lee@example.com', '이데이터');
INSERT INTO orders (user_id, amount, status)
VALUES
(1, 15000.00, 'paid'),
(1, 32000.00, 'pending'),
(2, 9000.00, 'cancelled');
SELECT
u.id,
u.name,
COUNT(o.id) AS order_count,
COALESCE(
SUM(o.amount) FILTER (WHERE o.status = 'paid'),
0
) AS paid_amount
FROM users AS u
LEFT JOIN orders AS o
ON o.user_id = u.id
GROUP BY u.id, u.name
ORDER BY u.id;조건을 만족하는 행만 안전하게 수정할 수 있다.
UPDATE orders
SET status = 'paid'
WHERE id = 2
AND status = 'pending'
RETURNING id, user_id, amount, status;RETURNING은 실제로 변경된 행을 반환한다. 이미 결제됐거나 취소된 주문이면 결과 행 수가 0이므로 애플리케이션에서 상태 충돌을 감지할 수 있다.
행을 삭제하면 해당 행은 현재 트랜잭션의 변경 결과에서 제외된다.
DELETE FROM orders
WHERE status = 'cancelled'
AND created_at < now() - INTERVAL '1 year'
RETURNING id;PostgreSQL의 MVCC 환경에서는 삭제된 행 버전이 즉시 파일에서 제거되는 것은 아니다. 이후 정리 작업이 재사용 가능한 공간으로 처리한다.
5. 시간 복잡도 또는 성능 특성
행 조회 비용은 행 자체보다 접근 경로에 크게 좌우된다.
인덱스가 없는 조건 검색
조건에 맞는 행을 찾기 위해 테이블 전체를 순차 스캔(Sequential Scan)하면 행 수를 N이라고 할 때 검사 비용은 O(N)이다.
SELECT *
FROM users
WHERE name = '김개발';name에 인덱스가 없으면 모든 행을 확인할 수 있다.
비트리 인덱스 검색
PostgreSQL의 비트리(B-tree) 인덱스에서 검색 위치를 찾는 비용은 일반적으로 O(log N)으로 설명할 수 있다. 결과 행이 K개라면 인덱스 항목과 테이블 행을 읽는 추가 비용이 발생한다.
CREATE INDEX users_name_idx ON users(name);
SELECT *
FROM users
WHERE name = '김개발';인덱스가 있어도 질의 최적화기(Query Optimizer)는 조건 선택도(Selectivity)가 낮거나 테이블이 작으면 순차 스캔을 선택할 수 있다.
행 크기
행이 넓을수록 한 페이지에 저장할 수 있는 행 수가 줄어든다. 같은 행 수를 읽더라도 더 많은 페이지 입력·출력(I/O)이 필요할 수 있다.
큰 값을 가진 열은 PostgreSQL의 대용량 속성 저장 기법(TOAST)으로 압축되거나 별도 저장될 수 있다. 이 경우 실제 값을 읽을 때 추가 비용이 생긴다.
갱신 비용
UPDATE는 새 행 버전을 만들기 때문에 다음 비용을 유발할 수 있다.
- 새 행 버전 저장
- 관련 인덱스 갱신
- 쓰기 전 로그(Write-Ahead Logging, WAL) 기록
- 죽은 튜플 증가
- 자동 정리 작업 증가
인덱스 대상 열이 바뀌지 않고 저장 조건을 만족하면 힙 전용 튜플(Heap-Only Tuple, HOT) 갱신이 가능하다. HOT 갱신은 새 인덱스 항목 생성을 피워 갱신 비용과 인덱스 팽창을 줄인다.
6. 실무 사용 사례
사용자 상태 변경
조건부 갱신으로 현재 상태를 확인하면서 행을 변경한다.
UPDATE users
SET active = false
WHERE id = 1
AND active = true
RETURNING id;반환된 행이 없으면 사용자가 없거나 이미 비활성 상태다.
재고 차감
조회 후 애플리케이션에서 계산하기보다 하나의 SQL 문으로 조건 검사와 변경을 묶는다.
UPDATE products
SET stock = stock - 1
WHERE id = 1
AND stock >= 1
RETURNING id, stock;동시에 여러 요청이 실행돼도 각 문장은 잠긴 최신 행을 기준으로 조건을 다시 확인한다. 반환 행이 없으면 재고 부족으로 처리한다.
작업 큐 처리
여러 작업자가 같은 행을 중복 처리하지 않도록 행 잠금과 SKIP LOCKED를 사용한다.
BEGIN;
WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs AS j
SET status = 'processing'
FROM picked
WHERE j.id = picked.id
RETURNING j.*;
COMMIT;이미 다른 작업자가 잠근 행은 기다리지 않고 건너뛴다.
낙관적 잠금
버전 열을 조건에 포함해 다른 요청의 선행 변경을 감지한다.
UPDATE documents
SET
content = '수정된 내용',
version = version + 1
WHERE id = 10
AND version = 3
RETURNING id, version;반환 행이 없다면 조회 이후 다른 트랜잭션이 해당 행을 변경한 것이다.
7. 주의할 점
기본 키 없는 행
기본 키가 없으면 같은 내용을 가진 행을 구분하기 어렵다. 수정이나 삭제 조건이 여러 행에 적용될 위험도 커진다.
DELETE FROM logs
WHERE message = '실패';동일한 메시지를 가진 모든 행이 삭제된다. 개별 행을 다뤄야 한다면 안정적인 식별자를 둔다.
SELECT * 남용
행에 큰 열이 추가되면 필요하지 않은 데이터까지 읽게 된다.
SELECT id, email
FROM users
WHERE id = 1;필요한 열만 지정하면 전송량과 디코딩 비용을 줄이고 코드가 의존하는 스키마도 명확해진다.
넓은 행에 서로 다른 수명 주기 혼합
자주 조회하는 작은 값과 드물게 조회하는 큰 값을 한 행에 모두 넣으면 페이지 효율이 낮아질 수 있다. 큰 본문이나 바이너리 데이터의 접근 패턴이 다르면 별도 테이블 분리를 검토한다.
행 순서 가정
삽입 순서나 기본 키 순서로 반환될 것이라 가정하면 안 된다.
SELECT id, email
FROM users
ORDER BY id;순서가 필요하면 항상 ORDER BY를 명시한다.
과도한 행 잠금
트랜잭션에서 행을 잠근 뒤 외부 API 호출처럼 오래 걸리는 작업을 수행하면 다른 트랜잭션의 대기 시간이 길어진다. 잠금 트랜잭션은 필요한 데이터베이스 작업만 포함하고 짧게 유지한다.
큰 범위의 갱신과 삭제
많은 행을 한 번에 변경하면 잠금 유지 시간, WAL 양, 죽은 튜플 수가 증가한다. 운영 환경에서는 실행 계획과 영향 행 수를 먼저 확인한다.
EXPLAIN
UPDATE orders
SET status = 'cancelled'
WHERE status = 'pending'
AND created_at < now() - INTERVAL '30 days';8. 핵심 정리
- 행(Row)은 테이블에서 하나의 개체나 사건을 표현하는 열 값의 묶음이다.
- 기본 키(Primary Key)는 행의 논리적 식별자이며 물리 위치인
ctid를 대신 사용하면 안 된다. - PostgreSQL의
UPDATE와DELETE는 다중 버전 동시성 제어(MVCC)에 따라 행 버전을 관리한다. - 조회 성능은 행 수뿐 아니라 인덱스, 조건 선택도, 행 크기, 읽는 열에 따라 달라진다.
- 실무에서는 조건부 갱신, 제약 조건, 짧은 트랜잭션으로 행의 무결성과 동시성을 관리한다.