프로그래밍을 처음 배울 때 데이터베이스 테이블은 보통 행과 열로 데이터를 정리하는 표라고 배운다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
customer_name TEXT,
reservation_date DATE
);
하나의 행은 하나의 예약을 나타내고, 각 열에는 고객 이름과 예약 날짜 같은 값이 들어간다.
테이블의 기본 구조를 이해하기에는 충분한 설명이다. 하지만 실제 예약 서비스를 개발하면 데이터를 표 형태로 저장하는 것보다 더 많은 판단이 필요하다.
테이블은 단순히 데이터를 행과 열로 정리한 표가 아니다.
테이블은 서비스가 구분해서 관리해야 하는 핵심 개념과 그 개념이 지켜야 할 규칙을 구조화하는 경계다.
크리스가 회의실 예약 화면을 개발한다고 생각해 보자.
화면에는 다음 정보가 한 번에 표시된다.
화면에 보이는 구조를 그대로 테이블로 옮기면 다음과 같이 만들 수 있다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
customer_name TEXT,
customer_email TEXT,
room_name TEXT,
room_location TEXT,
reservation_date TEXT,
start_time TEXT,
end_time TEXT,
participant_1_email TEXT,
participant_2_email TEXT,
participant_3_email TEXT,
status TEXT
);
한 행만 조회해도 예약 화면에 필요한 정보를 대부분 얻을 수 있다. 초기 개발 단계에서는 간단하고 편리해 보인다.
하지만 이 테이블에는 서로 다른 개념이 하나의 행에 섞여 있다.
이 개념들은 한 화면에 함께 표시되지만, 같은 생명주기를 가지지는 않는다.
사용자가 이름을 변경했다고 해서 과거의 모든 예약 행을 각각 수정하고 싶지는 않다. 회의실 위치가 변경되어도 예약 자체의 정체성이 달라지는 것은 아니다. 참가자 수가 늘어날 때마다 participant_4_email, participant_5_email과 같은 열을 추가해서도 안 된다.
화면은 여러 개념을 조합해 보여주는 결과다. 테이블은 그 결과를 그대로 복사하는 곳이 아니라 결과를 만드는 서비스의 개념을 구분해서 관리하는 곳이다.
예약 서비스의 데이터를 개념에 따라 나누면 다음과 같이 표현할 수 있다.
CREATE TABLE users (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE rooms (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
location TEXT NOT NULL,
capacity INTEGER NOT NULL
CHECK (capacity > 0)
);
CREATE TABLE reservations (
id UUID PRIMARY KEY,
user_id UUID NOT NULL
REFERENCES users(id),
room_id UUID NOT NULL
REFERENCES rooms(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE reservation_participants (
reservation_id UUID NOT NULL
REFERENCES reservations(id),
participant_email TEXT NOT NULL,
PRIMARY KEY (
reservation_id,
participant_email
)
);
이 구조에서는 각 테이블의 책임을 한 문장으로 설명할 수 있다.
| 테이블 | 책임지는 개념 |
|---|---|
users | 서비스를 사용하는 사람 |
rooms | 예약할 수 있는 공간 |
reservations | 사용자가 특정 공간과 시간을 확보한 사실 |
reservation_participants | 특정 예약에 참여하는 사람 |
테이블을 나누는 목적은 단순히 파일을 여러 개로 분리하는 것이 아니다. 서로 다른 이유로 생성되고 변경되고 삭제되는 개념을 구분하는 데 있다.
예를 들어 사용자의 이메일은 계정 관리 과정에서 변경될 수 있다. 회의실 수용 인원은 운영자가 변경한다. 예약 시간은 예약 변경 기능을 통해 수정된다. 참가자는 초대와 취소에 따라 추가되거나 제거된다.
변경 이유가 서로 다르다면 같은 행에서 함께 관리할 이유가 있는지 검토해야 한다.
반대로 항상 함께 생성되고 같은 규칙으로 변경되며 독립적으로 조회할 의미가 없는 값까지 무조건 별도 테이블로 나눌 필요는 없다. 테이블을 많이 만드는 것이 좋은 설계의 목적은 아니다.
핵심 질문은 테이블 수가 아니라 책임의 경계다.
이 데이터들은 하나의 개념을 설명하는가, 아니면 화면에서 우연히 함께 보이는 서로 다른 개념들인가?
테이블의 열은 화면 입력창과 일대일로 대응하지 않는다.
크리스가 예약 시간을 다음과 같이 저장한다고 생각해 보자.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
schedule_text TEXT NOT NULL
);
await reservationRepository.create({
scheduleText:
"22 September 2026, 10:00 AM - 11:30 AM",
});
사람이 읽기에는 편하지만 프로그램이 이 문자열의 의미를 안정적으로 다루기는 어렵다.
표시용 문자열에는 여러 사실이 섞여 있다. 서비스가 계산하고 검증해야 하는 사실은 구분해서 저장해야 한다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
CHECK (ends_at > starts_at)
);
이제 시작 시각과 종료 시각은 각각 독립적인 의미를 가진다. 데이터베이스도 종료 시각이 시작 시각보다 늦어야 한다는 최소 규칙을 보호할 수 있다.
화면에 표시할 문자열은 저장된 사실을 이용해 만들 수 있다.
function formatReservationSchedule(
startsAt: Date,
endsAt: Date,
timeZone: string
) {
return `${formatInTimeZone(
startsAt,
timeZone
)} - ${formatInTimeZone(
endsAt,
timeZone
)}`;
}
이 코드는 저장 형식과 표시 형식을 분리한다. 데이터베이스는 비교와 계산이 가능한 시각을 관리하고, 프론트엔드는 사용자의 지역과 언어에 맞는 문자열을 만든다.
열을 설계할 때는 “화면에 어떤 입력창이 있는가”보다 다음 질문이 먼저다.
한 예약에 여러 참가자가 있다고 생각해 보자.
처음에는 최대 세 명만 초대할 수 있어서 다음 구조를 사용할 수 있다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
participant_1_email TEXT,
participant_2_email TEXT,
participant_3_email TEXT
);
이 구조에서는 참가자 수가 데이터가 아니라 테이블 구조에 반영된다.
네 번째 참가자를 지원하려면 데이터를 추가하는 것이 아니라 스키마를 변경해야 한다.
ALTER TABLE reservations
ADD COLUMN participant_4_email TEXT;
참가자가 없는 열에는 NULL이 반복되고, 특정 이메일이 초대된 예약을 찾으려면 모든 참가자 열을 비교해야 한다.
SELECT *
FROM reservations
WHERE participant_1_email = $1
OR participant_2_email = $1
OR participant_3_email = $1
OR participant_4_email = $1;
참가자는 예약마다 개수가 달라질 수 있는 반복 데이터다. 반복되는 값을 열로 확장하기보다 행으로 표현하는 편이 자연스럽다.
CREATE TABLE reservation_participants (
reservation_id UUID NOT NULL
REFERENCES reservations(id),
participant_email TEXT NOT NULL,
invitation_status TEXT NOT NULL,
invited_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (
reservation_id,
participant_email
)
);
새로운 참가자는 새로운 행으로 추가된다.
INSERT INTO reservation_participants (
reservation_id,
participant_email,
invitation_status,
invited_at
)
VALUES (
$1,
$2,
'pending',
NOW()
);
이제 참가자 수가 늘어나도 테이블 구조는 바뀌지 않는다. 참가자에게 초대 상태나 초대 시각 같은 속성을 추가하는 것도 가능하다.
이 차이는 단순한 데이터베이스 기법이 아니다.
테이블 구조가 데이터 개수에 따라 계속 바뀐다면, 데이터와 스키마의 책임이 뒤섞였는지 살펴봐야 한다.
크리스가 예약 정보를 한 테이블에 모두 저장하면 회의실 정보가 여러 행에 반복된다.
reservation_101 | Chris | Harbour Room | Level 3
reservation_102 | Alex | Harbour Room | Level 3
reservation_103 | Victoria | Harbour Room | Level 3
같은 문자열이 반복되는 것 자체가 항상 문제인 것은 아니다. 거래 당시 정보를 남기기 위한 스냅숏처럼 의도적인 중복도 필요하다.
문제는 같은 사실을 여러 행에서 각각 수정할 수 있을 때 발생한다.
회의실이 3층에서 5층으로 이동했고 일부 행만 수정되면 다음과 같은 상태가 생길 수 있다.
reservation_101 | Chris | Harbour Room | Level 3
reservation_102 | Alex | Harbour Room | Level 5
reservation_103 | Victoria | Harbour Room | Level 3
이때 현재 회의실 위치가 어디인지 판단하기 어렵다.
회의실의 현재 위치가 rooms 테이블의 책임이라면 예약은 회의실의 식별자를 참조할 수 있다.
CREATE TABLE rooms (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
location TEXT NOT NULL
);
CREATE TABLE reservations (
id UUID PRIMARY KEY,
room_id UUID NOT NULL
REFERENCES rooms(id)
);
현재 예약 화면은 두 테이블의 정보를 조합해 만든다.
SELECT
reservations.id,
reservations.starts_at,
reservations.ends_at,
rooms.name AS room_name,
rooms.location AS room_location
FROM reservations
JOIN rooms
ON rooms.id = reservations.room_id
WHERE reservations.id = $1;
이 구조에서는 회의실의 현재 위치를 한 곳에서 관리할 수 있다.
그러나 과거 예약 확인서에 당시 위치를 그대로 보여줘야 한다면 별도의 스냅숏이 필요할 수 있다.
ALTER TABLE reservations
ADD COLUMN room_location_snapshot TEXT;
이 경우 중복은 실수가 아니라 다른 사실을 표현한다.
rooms.location은 회의실의 현재 위치다.reservations.room_location_snapshot은 예약이 확정될 당시 안내된 위치다.중복을 제거하는 것 자체가 목적은 아니다. 같은 의미의 사실이 여러 곳에서 독립적으로 변경되어 충돌하는 것을 막는 것이 목적이다.
따라서 값을 중복 저장할 때는 반드시 질문해야 한다.
이 값은 같은 사실의 불필요한 복사본인가, 아니면 특정 시점의 사실을 보존하는 스냅숏인가?
다음과 같이 모든 열을 TEXT로 만들 수도 있다.
CREATE TABLE reservations (
id TEXT,
room_id TEXT,
starts_at TEXT,
ends_at TEXT,
attendee_count TEXT,
status TEXT
);
값을 저장하는 것만 보면 동작한다. 하지만 각 값이 어떤 의미를 가져야 하는지는 보호하지 못한다.
"many"가 들어갈 수 있다."almost confirmed" 같은 임의의 문자열이 들어갈 수 있다.타입과 제약 조건은 데이터베이스가 이해할 수 있는 범위 안에서 서비스 규칙을 표현한다.
CREATE TYPE reservation_status AS ENUM (
'pending',
'confirmed',
'cancelled',
'completed'
);
CREATE TABLE reservations (
id UUID PRIMARY KEY,
room_id UUID NOT NULL
REFERENCES rooms(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
attendee_count INTEGER NOT NULL
CHECK (attendee_count > 0),
status reservation_status NOT NULL
DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL
DEFAULT NOW(),
CHECK (ends_at > starts_at)
);
이 구조는 다음 사실을 보호한다.
그렇다고 모든 비즈니스 규칙을 테이블 제약 조건 하나로 표현할 수 있는 것은 아니다.
예를 들어 참가자 수가 회의실 수용 인원보다 적어야 한다는 규칙은 다른 테이블의 현재 값과 운영 정책을 함께 확인해야 할 수 있다. 예약 시간 중복도 취소 상태를 제외할 것인지, 준비 시간을 포함할 것인지에 따라 규칙이 달라진다.
이러한 규칙은 애플리케이션의 도메인 로직과 트랜잭션에서 처리할 수 있다.
async function createReservation(
input: CreateReservationInput
) {
const room = await roomRepository.findById(
input.roomId
);
if (!room) {
throw new RoomNotFoundError();
}
if (input.attendeeCount > room.capacity) {
throw new RoomCapacityExceededError();
}
const isAvailable =
await reservationRepository.isAvailable({
roomId: input.roomId,
startsAt: input.startsAt,
endsAt: input.endsAt,
});
if (!isAvailable) {
throw new ReservationConflictError();
}
return reservationRepository.create(input);
}
이 코드는 회의실의 현재 수용 인원과 기존 예약을 확인한다.
애플리케이션 검증과 데이터베이스 제약 조건은 서로 경쟁하지 않는다.
외부 입력은 검증 전까지 신뢰할 수 없다. 입력값은 API 경계에서 검증하고, 저장된 데이터는 테이블의 타입과 제약 조건으로 다시 보호해야 한다.
예약 서비스에 회의실 점검 기능이 추가된다고 생각해 보자.
회의실 테이블에 다음 필드를 추가할 수 있다.
ALTER TABLE rooms
ADD COLUMN maintenance_reason TEXT,
ADD COLUMN maintenance_starts_at TIMESTAMPTZ,
ADD COLUMN maintenance_ends_at TIMESTAMPTZ;
한 회의실에 점검 일정이 하나만 있고 이전 기록이 필요 없다면 충분할 수 있다.
하지만 점검이 반복되고, 점검 담당자와 상태를 기록하며, 과거 점검 이력을 조회해야 한다면 점검은 회의실의 단순 속성보다 독립적인 개념에 가깝다.
CREATE TABLE room_maintenance_periods (
id UUID PRIMARY KEY,
room_id UUID NOT NULL
REFERENCES rooms(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
reason TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CHECK (ends_at > starts_at)
);
이제 한 회의실은 여러 점검 기간을 가질 수 있다. 점검 기록은 회의실 이름이나 위치와 다른 이유로 생성되고 변경된다.
새로운 요구사항이 생길 때마다 기존 테이블에 열을 추가하는 방식은 처음에는 빠르다. 그러나 한 테이블이 계정, 예약, 결제, 알림, 이력까지 모두 책임지기 시작하면 작은 변경도 넓은 범위에 영향을 준다.
반대로 값을 지나치게 잘게 나누면 하나의 예약을 이해하기 위해 너무 많은 테이블을 조합해야 하고, 함께 지켜야 하는 규칙이 여러 위치로 흩어질 수 있다.
테이블 경계에는 하나의 공식이 존재하지 않는다. 다음 단서를 함께 살펴봐야 한다.
독립적인 정체성, 생명주기, 규칙을 가지기 시작한 데이터는 별도 테이블이 될 가능성이 높다.
예약 상세 화면에서 다음 형태의 데이터가 필요할 수 있다.
type ReservationDetailView = {
reservationId: string;
customerName: string;
roomLabel: string;
scheduleLabel: string;
participantCount: number;
canCancel: boolean;
};
이 구조는 화면을 렌더링하기에 적합하다. 그렇다고 같은 형태의 테이블을 만들어야 하는 것은 아니다.
CREATE TABLE reservation_detail_views (
reservation_id TEXT,
customer_name TEXT,
room_label TEXT,
schedule_label TEXT,
participant_count INTEGER,
can_cancel BOOLEAN
);
roomLabel과 scheduleLabel은 표시용 값이다. participantCount는 참가자 행에서 계산할 수 있다. canCancel은 현재 시각, 예약 상태, 사용자 권한, 취소 정책에 따라 달라지는 판단 결과다.
이 값을 영구적인 원본처럼 저장하면 실제 데이터가 변경될 때 서로 어긋날 수 있다.
화면 모델은 원본 테이블의 사실을 조합해 만들 수 있다.
async function getReservationDetail(
reservationId: string,
currentUserId: string
): Promise<ReservationDetailView> {
const reservation =
await reservationRepository.findById(
reservationId
);
const [user, room, participants] =
await Promise.all([
userRepository.findById(
reservation.userId
),
roomRepository.findById(
reservation.roomId
),
participantRepository.findByReservationId(
reservation.id
),
]);
return {
reservationId: reservation.id,
customerName: user.name,
roomLabel: `${room.name} · ${room.location}`,
scheduleLabel: formatSchedule(
reservation.startsAt,
reservation.endsAt
),
participantCount: participants.length,
canCancel: canCancelReservation({
reservation,
currentUserId,
}),
};
}
이 코드는 사용자, 회의실, 예약, 참가자 정보를 조합해 현재 화면에 필요한 형태를 만든다.
서비스의 핵심 테이블과 화면 모델은 서로 다른 변화의 이유를 가진다.
이 경계를 구분하면 화면 개편이 곧바로 데이터베이스 전체 재설계로 이어지는 일을 줄일 수 있다.
테이블을 분리한다는 것은 데이터가 서로 단절된다는 뜻이 아니다. 각 개념이 자기 책임을 가지면서 하나의 흐름에 참여한다는 뜻이다.
flowchart LR
A[예약 폼 입력] --> B[입력 검증]
B --> C[사용자 확인]
C --> D[회의실 조회]
D --> E[수용 인원 확인]
E --> F[시간 중복 확인]
F --> G[예약 생성]
G --> H[참가자 저장]
H --> I[알림 발송]
G --> J[예약 상세 화면]
D --> J
H --> J
각 단계에서 서로 다른 데이터와 규칙이 사용된다.
테이블을 잘 나눈다는 것은 조회 한 번으로 모든 정보를 얻는다는 의미가 아니다. 서비스의 개념을 잃지 않으면서 필요한 조회와 변경을 명확하게 만들 수 있다는 의미다.
NULL은 값이 없다는 뜻인지, 아직 정해지지 않았다는 뜻인지 분명한가?participant_1, participant_2처럼 번호가 붙은 열이 있는가?NOT NULL이 적용되어 있는가?CHECK로 보호할 수 있는가?CREATE TABLE reservation_pages (
customer_name TEXT,
room_name TEXT,
room_location TEXT,
schedule_label TEXT,
participant_emails TEXT,
can_cancel BOOLEAN
);
화면은 빠르게 만들 수 있지만 사용자, 회의실, 예약, 참가자의 책임이 섞인다.
화면은 여러 테이블의 사실을 조합해 만들고, 테이블은 서비스 개념을 중심으로 설계해야 한다.
participant_1_email TEXT,
participant_2_email TEXT,
participant_3_email TEXT
참가자 수가 늘어날 때마다 스키마를 변경해야 한다.
개수가 달라지는 참가자는 별도의 테이블에서 행으로 관리하는 편이 적절하다.
starts_at TEXT,
attendee_count TEXT,
status TEXT
값은 들어가지만 비교, 계산, 검증이 어려워진다.
시간에는 시간 타입을, 수량에는 숫자 타입을 사용하고 허용 범위는 제약 조건으로 표현해야 한다.
과거 예약 확인서에 당시 회의실 위치를 보존해야 하는데 현재 rooms.location만 참조하면 장소 변경 이후 과거 안내 내용까지 달라질 수 있다.
현재 사실과 거래 당시 스냅숏은 같은 출처에서 시작했더라도 서로 다른 책임을 가진다.
ALTER TABLE rooms
ADD COLUMN maintenance_reason TEXT;
일회성 속성이라면 충분할 수 있다. 그러나 반복되는 점검 이력과 상태를 관리해야 한다면 점검은 별도의 개념으로 분리해야 한다.
새로운 열이 필요한 것인지 새로운 생명주기가 생긴 것인지 먼저 판단해야 한다.
type Reservation = {
attendeeCount: number;
};
TypeScript의 number는 음수나 소수를 막지 못하며, 애플리케이션을 거치지 않는 저장도 보호하지 못한다.
API 검증과 데이터베이스 제약 조건을 함께 사용해야 한다.
프로그래밍을 처음 배울 때는 행과 열이라는 설명으로 테이블의 기본 구조를 이해할 수 있다.
| id | room | starts_at | status |
|----|------|-----------|--------|
| 1 | A | 10:00 | booked |
입문 단계에서는 충분한 설명이다.
하지만 실제 서비스에서는 표의 모양보다 그 표가 어떤 개념을 책임지는지가 중요하다.
테이블은 엑셀과 비슷한 표가 아니다.
테이블은 서비스가 구분해서 관리해야 하는 핵심 개념에 경계를 만들고, 각 개념의 속성·규칙·생명주기를 일관된 구조로 표현하는 방법이다.