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

Row: 하나의 개체를 표현하는 열 값의 묶음

행(Row)은 관계형 데이터베이스 테이블에서 하나의 개체나 사건을 나타내는 값의 묶음이다. 각 값은 테이블 스키마에 정의된 열(Column), 자료형(Data Type), 제약 조건(Constraint)에 대응한다. PostgreSQL은 다중 버전 동시성 제어(MVCC)를 위해 행을 수정할 때 기존 행을 덮어쓰지 않고 새 행 버전을 만든다.

By 송민성

1. 개념

행(Row)은 테이블을 구성하는 하나의 데이터 단위다. 사용자 테이블에서는 사용자 한 명, 주문 테이블에서는 주문 한 건을 표현한다.

text
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. 왜 사용하는가

하나의 개체를 표현한다

관련된 값을 하나의 행으로 묶는다.

text
id = 1
email = kim@example.com
active = true

세 값은 각각 독립된 데이터가 아니라 같은 사용자의 상태를 나타낸다.

개체를 고유하게 식별한다

기본 키를 사용하면 각 행을 안정적으로 식별할 수 있다.

sql
CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, active boolean NOT NULL DEFAULT true );

id는 행의 논리적 식별자다. 이메일이 바뀌어도 같은 사용자를 계속 가리킬 수 있다.

무결성을 행 단위로 검사한다

데이터베이스는 행을 삽입하거나 수정할 때 자료형과 제약 조건을 검사한다.

sql
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)는 한 행이 다른 테이블의 행을 참조하게 한다.

sql
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은 입력값을 열에 대응시킨 뒤 다음 항목을 검사한다.

  1. 값이 열의 자료형으로 변환 가능한가
  2. NOT NULL, CHECK, UNIQUE 조건을 만족하는가
  3. 외래 키가 참조하는 행이 존재하는가
  4. 기본값이나 자동 생성 값이 필요한가

검사를 통과한 행은 테이블의 물리 저장 공간에 기록된다.

3.2 널(NULL)

널(NULL)은 빈 문자열이나 숫자 0이 아니라 값이 없거나 알려지지 않았음을 나타낸다.

sql
CREATE TABLE profiles ( user_id bigint PRIMARY KEY, nickname text, birth_date date );

nickname이 NULL인 행은 별명이 빈 문자열이라는 뜻이 아니라 별명 값이 없다는 뜻이다.

널과의 비교에는 = 대신 IS NULL을 사용한다.

sql
SELECT user_id FROM profiles WHERE nickname IS NULL;

SQL은 삼값 논리(Three-Valued Logic)를 사용한다. 조건식 결과는 TRUE, FALSE, UNKNOWN 중 하나이며, WHERE는 TRUE인 행만 반환한다.

3.3 행 식별

행은 기본 키로 식별한다.

sql
SELECT id, email FROM users WHERE id = 1;

PostgreSQL의 ctid는 현재 행 버전의 물리적 위치를 나타내는 시스템 열(System Column)이다. 갱신이나 테이블 재구성으로 바뀔 수 있으므로 기본 키 대신 사용하면 안 된다.

3.4 다중 버전 동시성 제어

PostgreSQL은 다중 버전 동시성 제어(Multi-Version Concurrency Control, MVCC)를 사용한다. UPDATE는 보통 기존 행을 제자리에서 덮어쓰지 않는다. 기존 행 버전을 만료 상태로 만들고 새 행 버전을 추가한다.

text
UPDATE 전
행 버전 A: active = true

UPDATE 후
행 버전 A: 이전 트랜잭션에서 보일 수 있음
행 버전 B: active = false

각 트랜잭션은 자신의 스냅숏(Snapshot)에 따라 볼 수 있는 행 버전을 선택한다. 읽기 작업이 쓰기 작업의 중간 상태를 보지 않는 이유다.

더 이상 어떤 트랜잭션에서도 필요하지 않은 이전 행 버전은 정리 대상이 된다. PostgreSQL의 자동 정리(Autovacuum)는 이런 죽은 튜플(Dead Tuple)을 관리한다.

3.5 행 잠금

여러 트랜잭션이 같은 행을 동시에 수정하면 충돌이 발생한다. SELECT ... FOR UPDATE는 선택한 행을 잠가 다른 트랜잭션의 충돌하는 변경을 대기시킨다.

sql
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에서 실행할 수 있다.

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

조건을 만족하는 행만 안전하게 수정할 수 있다.

sql
UPDATE orders SET status = 'paid' WHERE id = 2 AND status = 'pending' RETURNING id, user_id, amount, status;

RETURNING은 실제로 변경된 행을 반환한다. 이미 결제됐거나 취소된 주문이면 결과 행 수가 0이므로 애플리케이션에서 상태 충돌을 감지할 수 있다.

행을 삭제하면 해당 행은 현재 트랜잭션의 변경 결과에서 제외된다.

sql
DELETE FROM orders WHERE status = 'cancelled' AND created_at < now() - INTERVAL '1 year' RETURNING id;

PostgreSQL의 MVCC 환경에서는 삭제된 행 버전이 즉시 파일에서 제거되는 것은 아니다. 이후 정리 작업이 재사용 가능한 공간으로 처리한다.

5. 시간 복잡도 또는 성능 특성

행 조회 비용은 행 자체보다 접근 경로에 크게 좌우된다.

인덱스가 없는 조건 검색

조건에 맞는 행을 찾기 위해 테이블 전체를 순차 스캔(Sequential Scan)하면 행 수를 N이라고 할 때 검사 비용은 O(N)이다.

sql
SELECT * FROM users WHERE name = '김개발';

name에 인덱스가 없으면 모든 행을 확인할 수 있다.

비트리 인덱스 검색

PostgreSQL의 비트리(B-tree) 인덱스에서 검색 위치를 찾는 비용은 일반적으로 O(log N)으로 설명할 수 있다. 결과 행이 K개라면 인덱스 항목과 테이블 행을 읽는 추가 비용이 발생한다.

sql
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. 실무 사용 사례

사용자 상태 변경

조건부 갱신으로 현재 상태를 확인하면서 행을 변경한다.

sql
UPDATE users SET active = false WHERE id = 1 AND active = true RETURNING id;

반환된 행이 없으면 사용자가 없거나 이미 비활성 상태다.

재고 차감

조회 후 애플리케이션에서 계산하기보다 하나의 SQL 문으로 조건 검사와 변경을 묶는다.

sql
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1 RETURNING id, stock;

동시에 여러 요청이 실행돼도 각 문장은 잠긴 최신 행을 기준으로 조건을 다시 확인한다. 반환 행이 없으면 재고 부족으로 처리한다.

작업 큐 처리

여러 작업자가 같은 행을 중복 처리하지 않도록 행 잠금과 SKIP LOCKED를 사용한다.

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

이미 다른 작업자가 잠근 행은 기다리지 않고 건너뛴다.

낙관적 잠금

버전 열을 조건에 포함해 다른 요청의 선행 변경을 감지한다.

sql
UPDATE documents SET content = '수정된 내용', version = version + 1 WHERE id = 10 AND version = 3 RETURNING id, version;

반환 행이 없다면 조회 이후 다른 트랜잭션이 해당 행을 변경한 것이다.

7. 주의할 점

기본 키 없는 행

기본 키가 없으면 같은 내용을 가진 행을 구분하기 어렵다. 수정이나 삭제 조건이 여러 행에 적용될 위험도 커진다.

sql
DELETE FROM logs WHERE message = '실패';

동일한 메시지를 가진 모든 행이 삭제된다. 개별 행을 다뤄야 한다면 안정적인 식별자를 둔다.

SELECT * 남용

행에 큰 열이 추가되면 필요하지 않은 데이터까지 읽게 된다.

sql
SELECT id, email FROM users WHERE id = 1;

필요한 열만 지정하면 전송량과 디코딩 비용을 줄이고 코드가 의존하는 스키마도 명확해진다.

넓은 행에 서로 다른 수명 주기 혼합

자주 조회하는 작은 값과 드물게 조회하는 큰 값을 한 행에 모두 넣으면 페이지 효율이 낮아질 수 있다. 큰 본문이나 바이너리 데이터의 접근 패턴이 다르면 별도 테이블 분리를 검토한다.

행 순서 가정

삽입 순서나 기본 키 순서로 반환될 것이라 가정하면 안 된다.

sql
SELECT id, email FROM users ORDER BY id;

순서가 필요하면 항상 ORDER BY를 명시한다.

과도한 행 잠금

트랜잭션에서 행을 잠근 뒤 외부 API 호출처럼 오래 걸리는 작업을 수행하면 다른 트랜잭션의 대기 시간이 길어진다. 잠금 트랜잭션은 필요한 데이터베이스 작업만 포함하고 짧게 유지한다.

큰 범위의 갱신과 삭제

많은 행을 한 번에 변경하면 잠금 유지 시간, WAL 양, 죽은 튜플 수가 증가한다. 운영 환경에서는 실행 계획과 영향 행 수를 먼저 확인한다.

sql
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)에 따라 행 버전을 관리한다.
  • 조회 성능은 행 수뿐 아니라 인덱스, 조건 선택도, 행 크기, 읽는 열에 따라 달라진다.
  • 실무에서는 조건부 갱신, 제약 조건, 짧은 트랜잭션으로 행의 무결성과 동시성을 관리한다.