Skip to main content
Summary

테이블 설계는 '큰 표 하나'를 잘게 나누는 일입니다

처음 데이터베이스를 만들 때 가장 흔한 실수는 엑셀 시트 하나에 모든 것을 넣는 것입니다.

예를 들어 온라인 쇼핑몰 업무를 한 표로 만든다고 생각해 봅시다.

주문번호주문일고객명고객전화고객주소상품명상품가격주문수량결제상태
10012026-06-01김민지010-1111-2222서울무선마우스250002결제완료
10022026-06-02김민지010-1111-2222서울키보드450001결제완료

이 방식은 처음에는 편합니다. 한 화면에서 다 보이기 때문입니다. 하지만 시간이 지나면 문제가 생깁니다.

김민지 고객의 전화번호가 바뀌면 어떻게 해야 할까요? 김민지가 주문한 모든 행을 찾아서 전화번호를 전부 고쳐야 합니다. 한 줄이라도 빠뜨리면 같은 고객인데 전화번호가 두 개가 됩니다.

상품 가격이 바뀌어도 비슷합니다. 지금 상품의 판매가인지, 과거 주문 당시의 가격인지 구분하기 어려워집니다. 과거 주문 내역은 그때의 가격으로 남아 있어야 하는데, 현재 상품 가격을 고치면서 예전 주문까지 함께 흔들리면 곤란합니다.

데이터베이스의 테이블 설계는 이 문제를 줄이는 일입니다. 모든 정보를 큰 표 하나에 넣는 대신, "무엇에 대한 정보인가?"를 기준으로 장부를 나눕니다.

테이블을 설계하는 가장 쉬운 생각법AI 생성 이미지

먼저 '무엇에 대한 정보인가'를 묻습니다

테이블을 나눌 때 처음부터 어려운 용어를 외울 필요는 없습니다. 아래 질문 하나로 시작하면 됩니다.

이 행 한 줄은 무엇 하나를 설명하는가?

고객 한 명을 설명하면 고객 테이블입니다. 상품 하나를 설명하면 상품 테이블입니다. 주문 한 건을 설명하면 주문 테이블입니다.

쇼핑몰 업무라면 보통 이렇게 나눌 수 있습니다.

테이블한 행의 의미들어갈 만한 컬럼
customers고객 한 명고객번호, 이름, 전화번호, 이메일, 가입일
products상품 하나상품번호, 상품명, 판매가, 재고수량, 판매여부
orders주문 한 건주문번호, 고객번호, 주문일, 결제상태
order_items주문 안의 상품 한 줄주문상세번호, 주문번호, 상품번호, 수량, 주문당시가격

여기서 중요한 점은 "반복되는 정보"를 따로 빼는 것입니다.

고객 이름과 전화번호는 주문할 때마다 반복됩니다. 그래서 orders에 고객 이름을 계속 적기보다, customers에 고객 정보를 한 번만 저장하고 orders에는 고객을 가리키는 customer_id만 둡니다.

상품명과 현재 판매가도 여러 주문에서 반복됩니다. 그래서 products에 상품 정보를 저장하고, 주문 상세에는 어떤 상품을 몇 개 샀는지 적습니다. 특히 주문 당시의 가격은 order_items 같은 주문 상세 테이블에 따로 남겨 두는 편이 안전합니다. 상품의 현재 판매가는 나중에 바뀔 수 있지만, 이미 끝난 주문의 가격은 그대로 보존되어야 하기 때문입니다.

이렇게 나누면 나중에 찾고 고치기가 쉬워집니다. 고객 전화번호는 고객 테이블 한 곳만 고치면 됩니다. 상품 재고도 상품 테이블 한 곳에서 관리하면 됩니다.

컬럼은 '업무 항목'이고, 타입은 '칸의 성격'입니다

테이블을 정했다면 다음은 컬럼입니다. 컬럼은 장부의 항목입니다. 고객 장부라면 이름, 전화번호, 이메일, 가입일 같은 항목이 컬럼입니다.

PostgreSQL에서는 각 컬럼에 데이터 타입을 정합니다. 데이터 타입은 "이 칸에는 어떤 종류의 값을 넣을 수 있는가"를 정하는 규칙입니다.

엑셀로 비유하면, 어떤 칸은 글자를 적는 칸이고, 어떤 칸은 금액을 계산하는 칸이며, 어떤 칸은 날짜를 적는 칸입니다. PostgreSQL은 이 구분을 더 엄격하게 지킵니다.

자주 쓰는 타입은 다음 정도만 먼저 알아도 충분합니다.

타입쉽게 말하면업무 예시
text긴 글자 또는 짧은 글자고객명, 주소, 상품설명, 결제상태
varchar(50)길이 제한이 있는 글자상품코드, 고객등급코드, 짧은 분류명
integer정수수량, 재고수량, 포인트
bigint큰 정수아주 많은 행을 다루는 ID
numeric(12,2)정확한 숫자, 소수 가능금액, 단가, 할인율
date날짜가입일, 배송예정일
timestamp날짜와 시간주문일시, 수정일시
boolean참 또는 거짓판매중 여부, 탈퇴 여부
uuid긴 고유 식별값외부 시스템과 주고받는 고객번호, 주문번호

글자 타입은 처음에는 text를 많이 써도 괜찮습니다. 다만 상품코드처럼 길이를 제한하고 싶은 값이라면 varchar(50)처럼 최대 길이를 정할 수 있습니다.

금액에는 보통 numeric을 씁니다. 금액은 100원, 1000원처럼 정확해야 하기 때문입니다. numeric(12,2)는 전체 12자리까지, 그중 소수점 아래 2자리까지 저장할 수 있다는 뜻입니다.

날짜만 필요하면 date, 시간까지 필요하면 timestamp를 씁니다. "2026-06-26"이면 날짜입니다. "2026-06-26 14:30:00"이면 날짜와 시간입니다.

boolean은 참과 거짓입니다. 예를 들어 상품이 판매 중인지 나타내는 is_active 컬럼에는 true 또는 false를 넣을 수 있습니다.

uuid는 사람이 외우기 위한 번호가 아닙니다. 시스템이 안전하게 구분하기 위해 쓰는 긴 이름표에 가깝습니다. 여러 시스템이 데이터를 주고받거나, 번호가 쉽게 추측되면 안 되는 경우에 자주 씁니다.

기본키는 주민등록번호가 아니라 안전한 이름표입니다

테이블에는 각 행을 구분하는 기준이 필요합니다. 고객 테이블에서 동명이인이 있을 수 있습니다. 상품명도 나중에 바뀔 수 있습니다. 이메일도 변경될 수 있습니다.

그래서 데이터베이스에서는 보통 별도의 ID 컬럼을 둡니다.

예를 들어 고객 테이블에는 customer_id, 상품 테이블에는 product_id, 주문 테이블에는 order_id를 둡니다. 이것이 기본키, 즉 PRIMARY KEY가 됩니다.

기본키는 한 행을 정확히 가리키는 이름표입니다. PostgreSQL에서 기본키는 중복될 수 없고 비어 있을 수도 없습니다. 기본키 규칙은 한 테이블에 하나만 둘 수 있습니다. 다만 필요하면 여러 컬럼을 묶어서 "하나의 기본키"로 만들 수도 있습니다.1 초보 단계에서는 일단 자동으로 만들어지는 ID 컬럼 하나를 기본키로 두는 방식부터 익히면 충분합니다.

중요한 점이 있습니다. 기본키를 주민등록번호처럼 생각하면 안 됩니다. 주민등록번호, 전화번호, 이메일은 실제 사람의 민감한 정보이거나 바뀔 수 있는 정보입니다. 기본키는 업무상 의미가 거의 없는 안전한 내부 번호로 두는 편이 좋습니다.

예를 들어 이런 식입니다.

무엇을 하려는 문장인지 먼저 풀어 쓰면 다음과 같습니다.

고객 한 명마다 자동으로 고객번호가 붙는 고객 테이블을 만든다. 고객번호는 각 고객을 구분하는 기본키로 사용한다.

CREATE TABLE customers (
    customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_name text NOT NULL,
    phone text,
    email text UNIQUE,
    joined_at date DEFAULT CURRENT_DATE
);

결과 해석:

  • customer_id는 고객 한 명마다 자동으로 만들어지는 번호입니다.

  • PRIMARY KEY이므로 같은 번호가 두 번 들어갈 수 없고, 비어 있을 수도 없습니다.

  • customer_name은 반드시 입력해야 합니다.

  • email은 중복을 허용하지 않습니다. 이메일을 반드시 받는 업무라면 email text NOT NULL UNIQUE처럼 쓸 수 있습니다.

  • joined_at을 따로 입력하지 않으면 오늘 날짜가 기본으로 들어갑니다.

예전 PostgreSQL 예제에서는 SERIAL이라는 표현을 자주 볼 수 있습니다.

customer_id SERIAL PRIMARY KEY

SERIAL도 자동 번호를 만들 때 많이 쓰였습니다. 다만 최근에는 표준 SQL 방식에 가까운 GENERATED ... AS IDENTITY를 쓰는 예가 늘고 있습니다. identity 컬럼은 값을 생략하면 PostgreSQL이 자동으로 번호를 만들어 줍니다. 다만 자동으로 번호가 만들어진다는 사실만으로는 완전한 기본키가 되는 것은 아니므로, 안전한 식별자로 쓰려면 PRIMARY KEY나 UNIQUE 같은 제약 조건을 함께 두는 것이 좋습니다.2

초보자라면 이렇게 기억하면 충분합니다.

새 테이블을 만들 때는 테이블이름_id 형태의 자동 번호 컬럼을 만들고, 거기에 PRIMARY KEY를 붙인다.

제약 조건은 데이터 입력 실수를 막는 자동 검문소입니다

데이터 타입은 첫 번째 검문입니다. 숫자 칸에 글자를 넣지 못하게 막습니다.

제약 조건은 두 번째 검문입니다. 숫자는 숫자인데, 업무 규칙에 맞지 않는 숫자를 막습니다. 예를 들어 재고수량은 숫자이지만 음수가 되면 이상합니다. 이메일은 글자이지만 같은 이메일이 여러 고객에게 반복되면 문제가 될 수 있습니다.

PostgreSQL의 제약 조건은 데이터를 넣거나 고칠 때 규칙을 검사하고, 맞지 않으면 저장을 거절합니다.1

초보자가 먼저 알아야 할 제약 조건은 네 가지입니다.

제약 조건뜻업무 예시
NOT NULL반드시 값이 있어야 함고객명, 상품명, 주문일
UNIQUE같은 값이 반복되면 안 됨이메일, 상품코드
DEFAULT값이 없으면 기본값을 넣음가입일 기본값 오늘, 판매여부 기본값 true
CHECK정해 둔 조건을 통과해야 함가격은 0보다 커야 함, 재고는 0 이상이어야 함

상품 테이블 예시로 보겠습니다.

무엇을 하려는 문장인지 먼저 풀어 쓰면 다음과 같습니다.

상품번호가 자동으로 붙고, 상품코드는 중복되지 않으며, 가격과 재고가 이상한 값으로 들어가지 못하게 상품 테이블을 만든다.

CREATE TABLE products (
    product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_code text NOT NULL UNIQUE,
    product_name text NOT NULL,
    price numeric(12,2) NOT NULL CHECK (price > 0),
    stock_quantity integer NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0),
    is_active boolean NOT NULL DEFAULT true,
    created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

결과 해석:

  • product_id는 상품을 구분하는 내부 이름표입니다.

  • product_code는 업무에서 쓰는 상품코드이며, 비어 있을 수 없고 중복될 수 없습니다.

  • product_name은 반드시 입력해야 합니다.

  • price는 금액이고, 0보다 커야 합니다. 무료 상품을 허용하는 업무라면 조건을 price >= 0으로 바꿀 수도 있습니다.

  • stock_quantity는 재고수량이고, 입력하지 않으면 0으로 시작합니다.

  • stock_quantity >= 0 조건 때문에 음수 재고는 들어갈 수 없습니다.

  • is_active는 판매 중 여부이며, 기본값은 true입니다.

  • created_at은 행이 만들어진 시각을 자동으로 기록합니다.

이런 제약 조건은 귀찮은 장식이 아닙니다. 업무 실수를 줄이는 안전장치입니다.

엑셀에서는 누군가 실수로 가격에 -1000을 입력해도 표 안에 그대로 남을 수 있습니다. 하지만 위와 같이 CHECK (price > 0)를 걸어 두면 PostgreSQL이 저장 전에 막아 줍니다.

UNIQUE와 PRIMARY KEY는 역할이 다릅니다

PRIMARY KEY와 UNIQUE는 둘 다 중복을 막는다는 점에서 비슷해 보입니다. 하지만 역할이 다릅니다.

PRIMARY KEY는 그 테이블의 대표 이름표입니다. 고객 테이블이라면 customer_id, 상품 테이블이라면 product_id가 보통 기본키입니다.

UNIQUE는 "이 값도 중복되면 안 된다"는 추가 규칙입니다. 예를 들어 고객 이메일, 상품코드, 사업자등록번호 같은 값에 붙일 수 있습니다.

정리하면 이렇습니다.

구분PRIMARY KEYUNIQUE
역할행을 대표하는 기본 이름표중복을 막는 추가 규칙
비어 있음허용하지 않음설정에 따라 다르게 다룰 수 있으므로 필요하면 NOT NULL도 함께 사용
개수한 테이블에 하나의 기본키 규칙여러 컬럼에 여러 개 가능
예시customer_idemail, product_code

초보자에게 가장 안전한 습관은 이것입니다.

  • 내부 식별 번호에는 PRIMARY KEY

  • 업무상 중복되면 안 되는 값에는 UNIQUE

  • 반드시 입력해야 하는 값에는 NOT NULL

주문 테이블은 연결을 생각하며 설계합니다

고객과 상품은 각각 독립적인 장부입니다. 주문은 조금 다릅니다. 주문은 고객과 연결됩니다. 주문 상세는 주문과 상품을 연결합니다.

이번 장에서는 조인까지 깊게 들어가지는 않겠습니다. 다음 장에서 여러 테이블을 연결해 조회하는 방법을 다룹니다. 여기서는 설계 감각만 잡으면 됩니다.

무엇을 하려는 문장인지 먼저 풀어 쓰면 다음과 같습니다.

주문 한 건을 저장하는 테이블을 만든다. 주문은 어떤 고객의 주문인지 알아야 하므로 고객번호를 함께 저장한다. 이 고객번호는 실제 고객 테이블에 있는 번호여야 한다.

CREATE TABLE orders (
    order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL REFERENCES customers(customer_id),
    order_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    order_status text NOT NULL DEFAULT '접수'
        CHECK (order_status IN ('접수', '결제완료', '배송중', '완료', '취소'))
);

결과 해석:

  • order_id는 주문 한 건을 구분하는 번호입니다.

  • customer_id는 이 주문이 어떤 고객의 주문인지 알려 주는 연결 번호입니다.

  • REFERENCES customers(customer_id)는 고객 테이블에 실제로 있는 고객번호만 주문에 들어갈 수 있게 합니다.

  • order_at은 주문 시각입니다.

  • order_status는 주문 상태이며, 정해 둔 다섯 가지 값만 들어갈 수 있습니다.

여기서 customer_id처럼 다른 테이블의 기본키를 가리키는 컬럼을 외래키라고 부릅니다. 외래키는 "없는 고객번호로 주문을 만들지 못하게 하는 규칙"입니다. 조인을 할 때도 이런 연결 컬럼을 사용하지만, 외래키 자체는 조회 문법이 아니라 데이터를 안전하게 지키는 제약 조건입니다.1

주문에 상품이 하나만 들어간다면 orders에 상품번호를 바로 넣어도 될 것처럼 보입니다. 하지만 실제 업무에서는 한 주문에 여러 상품이 들어가는 경우가 많습니다. 그래서 주문 한 건은 orders에 저장하고, 그 주문 안에 들어간 상품들은 order_items에 여러 줄로 저장하는 편이 자연스럽습니다.

무엇을 하려는 문장인지 먼저 풀어 쓰면 다음과 같습니다.

주문 안에 들어간 상품 한 줄을 저장한다. 어떤 주문의 어떤 상품인지 연결하고, 수량과 주문 당시 가격을 함께 남긴다.

CREATE TABLE order_items (
    order_item_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id bigint NOT NULL REFERENCES orders(order_id),
    product_id bigint NOT NULL REFERENCES products(product_id),
    quantity integer NOT NULL CHECK (quantity > 0),
    unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0)
);

결과 해석:

  • order_item_id는 주문 상세 한 줄을 구분하는 번호입니다.

  • order_id는 이 상품 줄이 어느 주문에 속하는지 알려 줍니다.

  • product_id는 어떤 상품을 주문했는지 알려 줍니다.

  • quantity는 주문 수량이며, 1개 이상이어야 합니다.

  • unit_price는 주문 당시의 단가입니다. 나중에 상품 가격이 바뀌어도 과거 주문 금액을 확인할 수 있게 해 줍니다.

이 예시에서 중요한 것은 SQL 문법을 모두 외우는 것이 아닙니다. 고객, 상품, 주문, 주문상세가 각각 어떤 장부인지 구분하고, 서로 필요한 번호로 연결한다는 감각입니다.

테이블 설계 전에 적어 볼 작은 점검표

처음부터 완벽한 설계를 하려고 하면 어렵습니다. 대신 테이블을 만들기 전에 아래 질문을 적어 보세요.

  1. 이 테이블의 한 행은 무엇 하나를 의미하는가?

  2. 같은 정보가 여러 행에 반복되고 있지는 않은가?

  3. 이 행을 구분할 안전한 ID 컬럼이 있는가?

  4. 반드시 입력해야 하는 컬럼은 무엇인가?

  5. 중복되면 안 되는 값은 무엇인가?

  6. 기본값이 있으면 편한 컬럼은 무엇인가?

  7. 음수, 이상한 상태값, 미래 날짜처럼 막아야 할 값은 무엇인가?

  8. 다른 테이블과 연결되어야 한다면 어떤 ID 컬럼을 저장해야 하는가?

예를 들어 상품 테이블이라면 이렇게 답할 수 있습니다.

  • 한 행은 상품 하나를 의미한다.

  • 상품코드와 상품명은 상품 테이블에 한 번만 저장한다.

  • product_id를 기본키로 둔다.

  • 상품코드, 상품명, 가격은 반드시 입력한다.

  • 상품코드는 중복되면 안 된다.

  • 재고수량 기본값은 0으로 한다.

  • 가격은 0보다 커야 하고, 재고수량은 0 이상이어야 한다.

이 정도만 해도 무작정 큰 표 하나에 모든 데이터를 넣는 방식에서 벗어날 수 있습니다.

좋은 테이블 설계는 멋진 기술 용어에서 시작하지 않습니다. 업무 장부를 자연스럽게 나누는 감각에서 시작합니다. 고객은 고객 장부에, 상품은 상품 장부에, 주문은 주문 장부에 둡니다. 그리고 각 장부에는 안전한 이름표와 자동 검문소를 붙입니다.

그렇게 만든 구조는 나중에 데이터를 찾을 때도, 고칠 때도, 보고서를 만들 때도 훨씬 덜 흔들립니다.

참고

1PostgreSQL Constraints 공식 문서

2PostgreSQL Identity Columns 공식 문서

책 목록

비개발자를 위한 PostgreSQL 입문: 업무 데이터를 안전하게 다루는 가장 쉬운 방법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기