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

Table: 행과 열, 제약 조건으로 관계형 데이터를 구성하는 기본 단위

테이블(Table)은 관계형 데이터베이스에서 같은 구조를 가진 행(Row)의 집합이다. 열(Column)은 데이터의 의미와 자료형을 정의하고, 기본 키(Primary Key)와 외래 키(Foreign Key), 제약 조건(Constraint)은 데이터 무결성을 유지한다. 테이블 성능은 행 수뿐 아니라 인덱스(Index), 행 크기, 통계 정보, 조회 조건에

By 송민성

1. 개념

테이블(Table)은 관계형 데이터베이스 관리 시스템(RDBMS)에서 데이터를 행(Row)과 열(Column)로 표현하는 구조다.

text
users 테이블

┌────┬───────────────────┬──────────┬────────────┐
│ id │ email             │ name     │ created_at │
├────┼───────────────────┼──────────┼────────────┤
│ 1  │ kim@example.com   │ 김개발   │ 2026-01-01 │
│ 2  │ lee@example.com   │ 이데이터 │ 2026-01-02 │
└────┴───────────────────┴──────────┴────────────┘
  • 행(Row, Tuple): 하나의 개체나 사건을 나타내는 데이터 묶음
  • 열(Column, Attribute): 각 값의 의미와 자료형을 정의하는 속성
  • 스키마(Schema): 열, 자료형, 기본값, 제약 조건 등 테이블의 구조
  • 기본 키(Primary Key): 각 행을 고유하게 식별하는 열 또는 열의 조합
  • 외래 키(Foreign Key): 다른 테이블의 행을 참조하는 열 또는 열의 조합
  • 제약 조건(Constraint): 저장 가능한 데이터를 제한하는 규칙

관계형 모델(Relational Model)에서 테이블에 대응하는 개념은 관계(Relation)다. 관계는 중복 없는 튜플의 집합이지만, SQL 테이블은 기본적으로 중복 행을 허용한다. 중복을 제거하려면 기본 키나 고유 제약 조건(UNIQUE Constraint)을 정의해야 한다.

테이블의 행에는 논리적인 순서가 없다. ORDER BY가 없는 조회 결과의 순서는 보장되지 않는다.

2. 왜 사용하는가

구조화된 데이터 표현

같은 종류의 데이터를 일관된 열 구조로 저장한다. 사용자, 주문, 상품처럼 속성이 명확한 데이터를 표현하기 적합하다.

데이터 무결성 보장

자료형과 제약 조건을 데이터베이스가 직접 검사한다.

text
NOT NULL      → 값 누락 방지
UNIQUE        → 중복 방지
CHECK         → 허용 범위 제한
PRIMARY KEY   → 행의 고유 식별
FOREIGN KEY   → 존재하지 않는 대상 참조 방지

애플리케이션 검증만 사용하면 다른 프로그램이나 직접 실행한 SQL이 규칙을 우회할 수 있다. 테이블 제약 조건은 모든 쓰기 경로에 동일하게 적용된다.

데이터 관계 표현

외래 키를 사용하면 여러 테이블 사이의 관계를 표현할 수 있다.

text
users 1 ─── N orders

사용자 한 명은 여러 주문을 가질 수 있다.
각 주문은 한 사용자를 참조한다.

선언적 질의

데이터가 저장된 위치를 직접 탐색하지 않고 원하는 결과를 SQL로 선언한다. 데이터베이스의 질의 최적화기(Query Optimizer)가 순차 스캔(Sequential Scan), 인덱스 스캔(Index Scan), 조인(Join) 방식 등을 선택한다.

3. 동작 원리

3.1 논리 구조와 물리 저장

테이블은 논리적으로 행과 열로 보이지만 PostgreSQL은 테이블 데이터를 일정 크기의 페이지(Page)에 저장한다. 각 페이지에는 여러 행 버전(Row Version)이 들어간다.

text
테이블
 ├── 페이지 1
 │    ├── 행 버전 A
 │    └── 행 버전 B
 ├── 페이지 2
 │    ├── 행 버전 C
 │    └── 행 버전 D
 └── 페이지 3
      └── 행 버전 E

행의 물리적 위치나 조회 순서는 고정되지 않는다. 갱신, 삭제, 정리 작업에 따라 저장 상태가 달라질 수 있다.

3.2 스키마와 자료형

각 열은 저장할 값의 자료형(Data Type)을 가진다.

sql
CREATE TABLE products ( id bigint, name text, price numeric(12, 2), stock integer, available boolean, created_at timestamptz );

자료형은 저장 형식과 허용 연산을 결정한다.

  • bigint: 8바이트 부호 있는 정수
  • text: 길이 제한이 없는 문자열
  • numeric(12, 2): 전체 최대 12자리, 소수점 이하 2자리인 정확한 십진수
  • boolean: TRUE, FALSE, NULL
  • timestamptz: 시간대 정보를 반영해 저장하고 표시하는 시각

3.3 키와 제약 조건

기본 키는 NOT NULL과 고유성을 함께 보장한다.

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

외래 키는 참조 대상이 존재하는지 검사한다.

sql
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) );

orders.user_id에는 users.id에 존재하는 값만 저장할 수 있다. 기본 설정에서는 참조 중인 사용자를 삭제하려 하면 오류가 발생한다.

3.4 다중 버전 동시성 제어

PostgreSQL은 다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)를 사용한다. 행을 갱신할 때 기존 행을 즉시 덮어쓰기보다 새 행 버전을 만든다.

text
갱신 전: 가격 10,000원
갱신 후: 기존 버전 + 가격 12,000원인 새 버전

트랜잭션(Transaction)은 자신의 스냅샷(Snapshot)에 따라 볼 수 있는 행 버전을 선택한다. 읽기 작업과 쓰기 작업이 불필요하게 서로 막는 상황을 줄일 수 있다.

더 이상 필요 없는 행 버전은 자동 정리(Autovacuum) 또는 VACUUM이 정리한다.

3.5 인덱스와 테이블의 관계

인덱스는 테이블과 별도로 관리되는 탐색 구조다.

text
B-트리 인덱스
이메일 값 → 테이블 행의 위치

인덱스가 있어도 실제 열 값과 행은 테이블에 저장된다. 조회 조건에 맞는 행이 많으면 인덱스를 따라 여러 페이지를 읽는 것보다 테이블 전체를 순차적으로 읽는 편이 빠를 수 있다. 최적화기는 통계 정보를 이용해 실행 계획(Execution Plan)을 선택한다.

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, status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked')), created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE RESTRICT, amount numeric(12, 2) NOT NULL CHECK (amount >= 0), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'cancelled')), ordered_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX orders_user_id_idx ON orders (user_id); INSERT INTO users (email, name) VALUES ('kim@example.com', '김개발'), ('lee@example.com', '이데이터'); INSERT INTO orders (user_id, amount, status) SELECT id, 15000.00, 'paid' FROM users WHERE email = 'kim@example.com'; INSERT INTO orders (user_id, amount) SELECT id, 32000.00 FROM users WHERE email = 'lee@example.com'; SELECT u.id, u.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_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;

예상 결과:

text
 id |   name   | order_count | total_amount
----+----------+-------------+-------------
  1 | 김개발   |           1 |    15000.00
  2 | 이데이터 |           1 |    32000.00

트랜잭션으로 여러 테이블을 함께 변경할 수 있다.

sql
BEGIN; UPDATE users SET status = 'blocked' WHERE email = 'lee@example.com'; UPDATE orders SET status = 'cancelled' WHERE user_id = ( SELECT id FROM users WHERE email = 'lee@example.com' ) AND status = 'pending'; COMMIT;

두 UPDATE 중 하나라도 실패했을 때 전체 변경을 취소하려면 COMMIT 대신 ROLLBACK을 실행한다.

실행 계획은 EXPLAIN으로 확인한다.

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1;

실제 실행 시간과 처리한 행 수까지 확인하려면 다음 명령을 사용한다. ANALYZE가 포함되므로 질의를 실제로 실행한다.

sql
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 1;

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

데이터베이스 연산은 메모리 자료구조처럼 행 수만으로 정확한 비용을 표현하기 어렵다. 디스크와 캐시 상태, 페이지 수, 행 크기, 인덱스 종류, 조건 선택도(Selectivity)가 함께 영향을 준다.

전체 테이블 조회

인덱스를 사용하지 않고 모든 행을 검사하는 순차 스캔은 행이 N개일 때 행 검사 기준으로 O(N)이다.

sql
SELECT * FROM orders WHERE amount > 10000;

조건에 맞는 행이 테이블 대부분이라면 순차 스캔이 효율적일 수 있다.

기본 키와 B-트리 인덱스 조회

PostgreSQL의 기본 키와 고유 제약 조건은 일반적으로 B-트리(B-tree) 인덱스를 만든다. 균형 트리 탐색 비용은 인덱스 항목 수를 N이라 할 때 O(log N)이다.

sql
SELECT * FROM users WHERE id = 100;

실제 조회에는 인덱스 페이지 접근과 테이블 페이지 접근 비용이 추가될 수 있다. 인덱스만으로 필요한 값을 반환할 수 있고 가시성 조건을 만족하면 인덱스 전용 스캔(Index-Only Scan)이 선택될 수 있다.

삽입과 갱신

인덱스가 없는 테이블에 행을 추가하는 핵심 저장 작업은 상수 개수의 행을 기록하지만, 실제 비용은 페이지 확보와 로그 기록 등에 영향을 받는다.

B-트리 인덱스가 K개라면 삽입 시 각 관련 인덱스도 갱신해야 한다. 각 인덱스 갱신의 탐색 비용은 일반적으로 O(log N)이다. 인덱스가 많을수록 읽기는 빨라질 수 있지만 쓰기 비용과 저장 공간은 증가한다.

인덱스 열을 변경하면 테이블의 새 행 버전뿐 아니라 관련 인덱스 항목도 갱신해야 한다.

조인

조인 비용은 선택된 알고리즘에 따라 달라진다.

  • 중첩 루프 조인(Nested Loop Join): 단순 비교 기준 최악 O(N × M)
  • 해시 조인(Hash Join): 적절한 메모리 조건에서 평균 O(N + M)
  • 병합 조인(Merge Join): 입력이 정렬되어 있으면 병합 단계 O(N + M)

정렬 비용과 인덱스 탐색 비용은 별도로 추가될 수 있다. PostgreSQL 최적화기는 테이블 통계와 예상 행 수를 바탕으로 조인 방식을 결정한다.

6. 실무 사용 사례

사용자와 주문 분리

사용자 정보와 주문 정보를 별도 테이블로 나누면 한 사용자의 정보를 주문마다 반복 저장하지 않아도 된다.

text
users
- id
- email
- name

orders
- id
- user_id
- amount

중복을 줄이고 갱신 이상(Update Anomaly)을 막는 설계 과정이 정규화(Normalization)다.

다대다 관계 표현

게시글 하나가 여러 태그를 가지고, 태그 하나가 여러 게시글에 사용된다면 연결 테이블(Junction Table)을 둔다.

sql
CREATE TABLE posts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL ); CREATE TABLE tags ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE ); CREATE TABLE post_tags ( post_id bigint NOT NULL REFERENCES posts(id) ON DELETE CASCADE, tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (post_id, tag_id) );

post_tags의 복합 기본 키(Composite Primary Key)는 같은 게시글과 태그 조합이 중복 저장되는 것을 막는다.

상태값 제한

주문 상태처럼 허용 값이 정해진 열은 CHECK 제약 조건으로 잘못된 값을 차단할 수 있다.

sql
ALTER TABLE orders ADD CONSTRAINT orders_status_check CHECK (status IN ('pending', 'paid', 'cancelled'));

상태 종류와 추가 속성을 별도로 관리해야 한다면 상태 테이블과 외래 키를 사용하는 편이 적합하다.

시간 기반 데이터 관리

로그나 이벤트처럼 계속 증가하는 큰 테이블은 파티셔닝(Partitioning)을 사용할 수 있다.

sql
CREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY, occurred_at timestamptz NOT NULL, payload jsonb NOT NULL ) PARTITION BY RANGE (occurred_at); CREATE TABLE events_2026_09 PARTITION OF events FOR VALUES FROM ('2026-09-01 00:00:00+00') TO ('2026-10-01 00:00:00+00');

조회 조건에 파티션 키가 포함되면 PostgreSQL이 불필요한 파티션을 제외하는 파티션 가지치기(Partition Pruning)를 적용할 수 있다.

7. 주의할 점

ORDER BY 없는 결과 순서를 믿지 않는다

sql
SELECT * FROM users;

현재 기본 키 순서처럼 보여도 다음 실행에서 같은 순서가 보장되지 않는다.

sql
SELECT * FROM users ORDER BY id;

NULL을 일반 값처럼 비교하지 않는다

NULL은 값이 없거나 알 수 없음을 나타낸다. = NULL 비교는 참이 되지 않는다.

sql
-- 잘못된 조건 SELECT * FROM users WHERE name = NULL; -- 올바른 조건 SELECT * FROM users WHERE name IS NULL;

외래 키 열의 인덱스를 따로 검토한다

PostgreSQL은 외래 키를 선언해도 참조하는 열에 인덱스를 자동 생성하지 않는다.

sql
CREATE INDEX orders_user_id_idx ON orders (user_id);

외래 키로 조인하거나 부모 행 삭제·갱신을 검사하는 작업이 많다면 해당 인덱스가 유용하다.

의미 없는 인덱스를 늘리지 않는다

인덱스마다 저장 공간이 필요하고 INSERT, UPDATE, DELETE 비용이 증가한다. 실제 조회 조건과 실행 계획을 확인해 추가한다.

무제한 문자열 열로 규칙을 숨기지 않는다

모든 값을 text로 저장하면 날짜, 숫자, 참·거짓에 대한 자료형 검사를 사용할 수 없다.

sql
-- 계산과 검증이 어려운 설계 price text -- 금액을 정확하게 저장하는 설계 price numeric(12, 2)

스키마 변경의 잠금 영향을 확인한다

ALTER TABLE 작업은 종류와 PostgreSQL 버전에 따라 강한 테이블 잠금(Table Lock)을 획득하거나 전체 행을 다시 기록할 수 있다. 큰 운영 테이블에서는 예상 잠금 시간, 재작성 여부, 배포 순서를 사전에 검증해야 한다.

8. 핵심 정리

  • 테이블(Table)은 같은 스키마를 따르는 행(Row)의 집합이다.
  • 열(Column)의 자료형과 제약 조건(Constraint)이 데이터 무결성을 보장한다.
  • 기본 키(Primary Key)는 행을 식별하고, 외래 키(Foreign Key)는 테이블 사이의 참조 관계를 보호한다.
  • PostgreSQL은 다중 버전 동시성 제어(MVCC)로 여러 행 버전을 관리한다.
  • 테이블 행에는 기본 순서가 없으며 결과 순서가 필요하면 ORDER BY를 사용한다.
  • 인덱스(Index)는 조회를 빠르게 만들 수 있지만 쓰기 비용과 저장 공간을 늘리므로 실행 계획을 기준으로 선택한다.