프로그래밍을 처음 배울 때 데이터베이스 관계는 보통 외래 키를 사용해 두 테이블을 연결하는 방법이라고 배운다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
customer_id UUID REFERENCES users(id)
);
reservations.customer_id가 users.id를 참조하므로 예약과 사용자 사이에 관계가 만들어진다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 예약 서비스를 개발하면 테이블을 연결하는 것보다 더 많은 판단이 필요하다.
관계는 단순히 테이블을 연결하는 기능이 아니다.
관계는 현실 세계의 소유·포함·참조 규칙과 각 데이터의 생명주기를 서비스 안에 표현하는 방법이다.
크리스가 상담 예약 서비스를 만든다고 생각해 보자. 사용자는 상담 시간을 선택해 예약할 수 있다.
처음에는 예약에 사용자 ID를 추가하는 것만으로 충분해 보인다.
type Reservation = {
id: string;
customerId: string;
startsAt: string;
};
이 구조는 예약이 어떤 사용자와 관련되어 있는지 알려준다. 하지만 customerId라는 필드만으로는 관계의 규칙을 모두 알 수 없다.
코드에 ID 하나를 추가하는 것은 관계의 구현일 뿐이다. 구현 전에 현실에서 두 대상이 어떻게 연결되는지를 정의해야 한다.
현재 서비스의 규칙이 다음과 같다고 가정해 보자.
이는 사용자와 예약 사이의 일대다 관계다.
CREATE TABLE users (
id UUID PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE reservations (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL
REFERENCES users(id),
starts_at TIMESTAMPTZ NOT NULL
);
NOT NULL은 예약에 고객이 반드시 필요하다는 규칙을 표현한다. 외래 키는 존재하지 않는 사용자를 참조하지 못하게 한다.
이 관계는 단순히 두 테이블이 연결되었다는 사실보다 더 많은 의미를 가진다.
관계의 종류를 고르는 일은 데이터베이스 문법을 선택하는 일이 아니라 현실의 규칙을 명확하게 만드는 일이다.
예약 상세 화면에 담당 상담사 한 명만 표시된다고 생각해 보자.
type ReservationDetails = {
consultantName: string;
};
현재 화면만 보면 예약과 상담사의 관계는 일대일처럼 보인다. 그러나 다음 요구사항이 추가될 수 있다.
화면에 한 명만 보인다는 이유로 데이터 관계를 일대일로 정하면 이후 요구사항을 표현하기 어렵다.
먼저 현재 비즈니스 규칙이 “예약 하나에는 현재 담당 상담사 한 명이 배정되고, 상담사 한 명은 여러 예약을 담당할 수 있다”라고 정의되었다면 다음처럼 표현할 수 있다.
ALTER TABLE reservations
ADD COLUMN consultant_id UUID
REFERENCES consultants(id);
consultant_id에 NOT NULL이 없으므로 상담사 배정 전에도 예약을 만들 수 있다. 이는 관계의 선택 가능성을 데이터베이스에 표현한 것이다.
TypeScript 타입도 같은 규칙을 따라야 한다.
type Reservation = {
id: string;
customerId: string;
consultantId: string | null;
startsAt: string;
};
string | null은 아직 상담사가 배정되지 않은 상태가 정상적으로 존재할 수 있다는 의미다.
다음처럼 타입과 데이터베이스가 서로 다른 규칙을 표현하면 문제가 생긴다.
type Reservation = {
consultantId: string;
};
코드는 상담사가 항상 있다고 가정하지만 데이터베이스에는 NULL이 들어갈 수 있다. 결국 조회 결과를 처리하는 과정에서 예상하지 못한 오류가 발생한다.
관계의 수와 필수 여부는 현재 UI 모양이 아니라 서비스가 허용하는 상태를 기준으로 결정해야 한다.
예약은 사용자와 상담사를 모두 참조한다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL
REFERENCES users(id),
consultant_id UUID
REFERENCES consultants(id),
starts_at TIMESTAMPTZ NOT NULL
);
형식만 보면 두 필드 모두 외래 키다. 하지만 관계의 의미는 다르다.
customer_id는 예약의 소유 주체를 나타낸다.consultant_id는 현재 예약에 배정된 담당자를 나타낸다.소유 관계는 보통 접근 권한, 조회 범위, 데이터 생명주기와 연결된다. 참조 관계는 다른 데이터에 대한 연결만 나타낼 수 있다.
예를 들어 고객이 자신의 예약을 조회할 때는 소유 관계를 사용한다.
async function getCustomerReservation(
reservationId: string,
currentUserId: string
) {
return reservationRepository.findOne({
id: reservationId,
customerId: currentUserId,
});
}
이 코드는 예약 ID뿐 아니라 현재 사용자 ID도 조회 조건에 포함한다. 예약이 존재하더라도 다른 사용자가 소유한 예약이라면 반환하지 않는다.
반면 상담사 정보는 예약 화면에 담당자를 표시하기 위해 참조할 수 있다.
const reservation =
await reservationRepository.findById(reservationId);
const consultant = reservation.consultantId
? await consultantRepository.findById(
reservation.consultantId
)
: null;
상담사를 참조한다는 사실만으로 현재 사용자가 예약을 수정할 권한까지 얻는 것은 아니다.
같은 외래 키 구조라도 관계가 표현하는 책임은 다르다. 필드 이름과 도메인 로직에서 그 차이가 드러나야 한다.
예약에는 고객이 작성한 특별 요청 사항이 여러 개 포함될 수 있다.
CREATE TABLE reservation_requests (
id UUID PRIMARY KEY,
reservation_id UUID NOT NULL
REFERENCES reservations(id),
request_type TEXT NOT NULL,
details TEXT NOT NULL
);
특별 요청 사항은 독립적으로 존재하기 어렵다. 어떤 예약에도 속하지 않는 요청 사항은 서비스에서 의미가 없을 수 있다.
이때 예약이 삭제되면 요청 사항도 함께 삭제하도록 설정할 수 있다.
CREATE TABLE reservation_requests (
id UUID PRIMARY KEY,
reservation_id UUID NOT NULL
REFERENCES reservations(id)
ON DELETE CASCADE,
request_type TEXT NOT NULL,
details TEXT NOT NULL
);
ON DELETE CASCADE는 편리한 삭제 옵션이기 전에 생명주기 규칙이다.
예약 요청 사항은 예약에 포함되며, 예약이 사라지면 독립적으로 유지되지 않는다.
그러나 같은 설정을 모든 관계에 적용하면 안 된다.
customer_id UUID NOT NULL
REFERENCES users(id)
ON DELETE CASCADE
이 설정은 사용자가 삭제될 때 해당 사용자의 예약까지 모두 삭제할 수 있다. 이미 완료된 상담 기록이나 정산 자료를 보존해야 한다면 위험한 정책이다.
이 경우에는 삭제를 제한하거나, 사용자와 예약 기록을 분리해 보존하는 방법을 고려해야 한다.
customer_id UUID
REFERENCES users(id)
ON DELETE SET NULL
다만 고객 ID를 NULL로 바꾸는 것만으로 요구사항이 해결되지는 않는다. 예약 당시의 고객 이름이나 연락처를 법적·운영상 보존해야 한다면 별도의 스냅숏 또는 익명화 정책이 필요하다.
삭제 정책은 다음 질문에서 출발해야 한다.
관계는 현재 데이터의 연결뿐 아니라 한쪽 데이터가 사라질 때 다른 데이터가 어떻게 되는지도 정의한다.
처음에는 예약 하나에 사용자 한 명만 참석한다고 가정할 수 있다. 이후 단체 예약 기능이 추가되면 한 예약에 여러 참가자가 참여하고, 한 사용자는 여러 예약에 참여할 수 있다.
배열 필드 하나로 참가자를 저장하고 싶을 수 있다.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
participant_ids UUID[] NOT NULL
);
이 구조는 ID 목록을 저장할 수 있지만 데이터베이스가 각 참가자의 존재 여부를 외래 키로 보장하기 어렵다. 참가자마다 다른 상태나 역할을 저장하기도 어렵다.
연결 테이블을 사용하면 관계를 독립적으로 표현할 수 있다.
CREATE TABLE reservation_participants (
reservation_id UUID NOT NULL
REFERENCES reservations(id),
user_id UUID NOT NULL
REFERENCES users(id),
role TEXT NOT NULL,
status TEXT NOT NULL,
joined_at TIMESTAMPTZ,
PRIMARY KEY (reservation_id, user_id)
);
이 테이블은 예약과 사용자를 연결할 뿐 아니라 연결 자체의 정보도 저장한다.
role은 예약자, 일반 참가자, 보호자 같은 역할을 나타낸다.status는 초대됨, 수락함, 거절함 같은 참여 상태를 나타낸다.joined_at은 실제 참여 시점을 나타낸다.관계가 자체 속성을 가지기 시작하면 단순한 연결선이 아니라 도메인의 중요한 데이터가 된다.
TypeScript에서도 연결 정보를 명시적으로 표현하는 편이 낫다.
type ReservationParticipant = {
reservationId: string;
userId: string;
role: "organizer" | "attendee" | "guardian";
status: "invited" | "accepted" | "declined";
joinedAt: string | null;
};
이 타입은 “누가 어떤 예약과 연결되어 있는가”뿐 아니라 “어떤 방식으로 연결되어 있는가”까지 설명한다.
다대다 관계를 발견했을 때는 두 테이블을 어떻게 연결할지만 묻지 않아야 한다. 그 관계에 이름, 상태, 역할, 생성 시점이 필요한지도 함께 확인해야 한다.
크리스가 참가자 추가 기능을 구현한다고 생각해 보자.
const existingParticipant =
await participantRepository.findOne({
reservationId,
userId,
});
if (!existingParticipant) {
await participantRepository.create({
reservationId,
userId,
role: "attendee",
status: "invited",
});
}
코드는 이미 참가자가 있는지 확인한 뒤 새로운 관계를 만든다. 하지만 거의 동시에 두 요청이 실행되면 두 요청 모두 기존 참가자가 없다고 판단할 수 있다.
관계의 고유성은 데이터베이스에서도 보장해야 한다.
CREATE TABLE reservation_participants (
reservation_id UUID NOT NULL
REFERENCES reservations(id),
user_id UUID NOT NULL
REFERENCES users(id),
role TEXT NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (reservation_id, user_id)
);
복합 기본 키는 같은 사용자가 같은 예약에 두 번 등록되지 못하도록 한다.
만약 한 사용자가 같은 예약에서 여러 역할을 가질 수 있다면 고유성 규칙도 달라진다.
UNIQUE (reservation_id, user_id, role)
어떤 제약 조건이 맞는지는 SQL 문법이 아니라 비즈니스 규칙에 따라 결정된다.
관계의 중복 여부도 “무엇을 같은 관계로 볼 것인가”를 정의해야 판단할 수 있다.
예약의 상담사를 변경하는 코드는 단순해 보인다.
await reservationRepository.update(reservationId, {
consultantId: newConsultantId,
});
그러나 실제 서비스에서는 관계 변경이 다른 규칙과 연결될 수 있다.
따라서 외부에서 받은 ID를 바로 저장해서는 안 된다. 외부 입력은 검증 전까지 신뢰할 수 없다.
import { z } from "zod";
const AssignConsultantSchema = z.object({
consultantId: z.string().uuid(),
});
const input = AssignConsultantSchema.parse(
request.body
);
형식 검증 후에는 실제 관계를 만들 수 있는지도 확인해야 한다.
async function assignConsultant(
reservationId: string,
consultantId: string
) {
const reservation =
await reservationRepository.findById(
reservationId
);
if (!reservation) {
throw new ReservationNotFoundError();
}
const consultant =
await consultantRepository.findById(
consultantId
);
if (!consultant) {
throw new ConsultantNotFoundError();
}
const canAccept =
await scheduleService.canAcceptReservation({
consultantId,
startsAt: reservation.startsAt,
durationMinutes: reservation.durationMinutes,
});
if (!canAccept) {
throw new ConsultantUnavailableError();
}
await reservationRepository.assignConsultant({
reservationId,
consultantId,
});
}
이 코드는 두 데이터가 존재하는지만 확인하지 않는다. 현재 시간과 업무 규칙 안에서 유효한 관계인지도 확인한다.
관계 변경이 중요한 이력이라면 현재 외래 키만 저장해서는 과거를 알 수 없다.
CREATE TABLE consultant_assignments (
id UUID PRIMARY KEY,
reservation_id UUID NOT NULL
REFERENCES reservations(id),
consultant_id UUID NOT NULL
REFERENCES consultants(id),
assigned_at TIMESTAMPTZ NOT NULL,
unassigned_at TIMESTAMPTZ
);
현재 관계와 관계의 변경 이력은 서로 다른 질문에 답한다.
서비스가 과거의 관계까지 알아야 한다면 단순한 외래 키 변경보다 명시적인 관계 이력이 필요하다.
사용자가 예약의 참가자로 등록되어 있다고 해서 모든 행동을 할 수 있는 것은 아니다.
const participant =
await participantRepository.findOne({
reservationId,
userId: currentUser.id,
});
이 관계는 현재 사용자가 예약에 참여한다는 사실을 알려준다. 하지만 예약 취소, 참가자 초대, 상담사 변경 권한까지 자동으로 의미하지는 않는다.
역할과 행동을 함께 확인해야 한다.
const canCancelReservation =
participant?.role === "organizer" &&
participant.status === "accepted";
if (!canCancelReservation) {
throw new ForbiddenError();
}
관리자나 담당 상담사에게 별도의 권한이 있다면 정책을 한곳에서 관리할 수 있다.
const canManageReservation =
authorizationService.can({
actor: currentUser,
action: "manage",
resource: reservation,
participant,
});
관계는 권한 판단에 필요한 정보가 될 수 있지만 권한 그 자체는 아니다.
ID가 존재한다고 접근 권한이 증명되지 않는 것처럼, 두 데이터가 연결되어 있다는 사실만으로 허용되는 행동이 결정되지는 않는다.
예약 목록에 고객과 상담사 정보를 함께 표시하기 위해 각 예약마다 추가 조회를 실행할 수 있다.
const reservations =
await reservationRepository.findUpcoming();
for (const reservation of reservations) {
reservation.customer =
await userRepository.findById(
reservation.customerId
);
reservation.consultant =
reservation.consultantId
? await consultantRepository.findById(
reservation.consultantId
)
: null;
}
예약이 100개라면 첫 조회 이후 최대 200번의 추가 조회가 발생할 수 있다. 관계가 올바르게 설계되어 있어도 불러오는 방식이 비효율적이면 서비스가 느려진다.
필요한 관계를 한 번의 조회로 가져올 수 있다.
SELECT
r.id,
r.starts_at,
u.name AS customer_name,
c.name AS consultant_name
FROM reservations r
JOIN users u
ON u.id = r.customer_id
LEFT JOIN consultants c
ON c.id = r.consultant_id
WHERE r.starts_at >= NOW();
고객은 반드시 존재하므로 JOIN을 사용하고, 상담사는 아직 배정되지 않을 수 있으므로 LEFT JOIN을 사용한다. 조회 방식에도 관계의 필수 여부가 반영된다.
그렇다고 모든 관계를 항상 함께 불러와야 하는 것은 아니다.
관계를 정의하는 것과 관계를 언제 조회하는 것은 다른 설계 판단이다. 화면과 작업에 필요한 데이터만 명시적으로 선택해야 한다.
예약에 참가자 수를 함께 저장한다고 생각해 보자.
CREATE TABLE reservations (
id UUID PRIMARY KEY,
participant_count INTEGER NOT NULL DEFAULT 0
);
실제 참가자 관계는 reservation_participants에 저장되어 있다.
SELECT COUNT(*)
FROM reservation_participants
WHERE reservation_id = $1
AND status = 'accepted';
이제 참가자 수를 알 수 있는 값이 두 곳에 존재한다.
reservation_participants의 실제 관계reservations.participant_count의 저장된 숫자참가자가 추가되었지만 숫자 갱신이 실패하면 두 값이 달라질 수 있다.
참가자 수가 자주 필요하지 않다면 관계 테이블에서 계산하는 편이 단순하다. 성능 때문에 숫자를 별도로 저장해야 한다면 어떤 값이 원본인지 명확히 해야 한다.
await database.transaction(async (tx) => {
await tx.reservationParticipants.create({
reservationId,
userId,
status: "accepted",
});
await tx.reservations.incrementParticipantCount(
reservationId
);
});
이 코드는 관계 생성과 계산된 숫자 갱신을 하나의 트랜잭션으로 처리한다.
그래도 participant_count는 관계에서 파생된 값이다. 불일치가 발생했을 때는 관계 테이블을 기준으로 다시 계산할 수 있어야 한다.
관계 데이터를 여러 위치에 복제할 때는 다음을 정해야 한다.
관계를 편리하게 조회하기 위해 복제한 값이 새로운 원본처럼 취급되지 않도록 해야 한다.
예약 서비스에서 관계는 데이터베이스 설계 단계에서 한 번 정하고 끝나는 구조가 아니다.
flowchart LR
A[고객이 예약 생성] --> B[예약이 고객에게 소속됨]
B --> C[상담사가 배정됨]
C --> D[참가자가 초대됨]
D --> E[참가 상태가 변경됨]
E --> F[상담사가 재배정될 수 있음]
F --> G[완료된 관계와 이력이 보존됨]
각 단계에서 서로 다른 관계 규칙이 적용된다.
관계 설계는 테이블 사이에 선을 긋는 작업이 아니다. 서비스에서 데이터가 만나고, 떨어지고, 역할을 바꾸는 과정을 정의하는 작업이다.
NULL 허용 여부와 TypeScript 타입이 일치하는가?CASCADE, SET NULL, RESTRICT가 실제 정책과 일치하는가?현재 한 명만 표시되더라도 재배정, 공동 담당, 이력 보존 요구가 생길 수 있다.
관계의 수는 UI가 아니라 서비스가 허용하는 상태로 결정해야 한다.
데이터베이스에는 NULL이 가능하지만 타입은 항상 값이 있다고 가정하면 런타임 오류가 발생한다.
데이터베이스 제약 조건과 애플리케이션 타입이 같은 규칙을 표현해야 한다.
ON DELETE CASCADE를 사용한다포함 데이터에는 적절할 수 있지만 완료된 예약이나 정산 기록까지 삭제할 수 있다.
삭제 편의성보다 데이터의 생명주기와 보존 의무를 먼저 확인해야 한다.
배열은 관계의 존재, 중복, 역할, 상태를 구조적으로 관리하기 어렵다.
관계가 의미를 가진다면 연결 테이블을 독립적인 도메인 데이터로 다뤄야 한다.
참가자라는 사실은 조회나 수정 권한을 판단하는 입력 중 하나일 뿐이다.
소유권, 역할, 상태, 요청한 행동을 함께 검사해야 한다.
담당자 ID를 덮어쓰면 이전 담당자와 변경 시점을 알 수 없다.
과거 관계가 필요하다면 별도의 관계 이력을 저장해야 한다.
목록의 각 항목마다 관련 데이터를 조회하면 N+1 문제가 발생할 수 있다.
필요한 관계와 데이터 크기에 따라 조인, 일괄 조회, 지연 조회를 선택해야 한다.
프로그래밍을 처음 배울 때는 외래 키가 두 테이블을 연결한다고 이해해도 충분하다.
customer_id UUID REFERENCES users(id)
하지만 실제 서비스에서 관계를 설계할 때는 연결 방법보다 연결이 의미하는 규칙을 먼저 정의해야 한다.
관계는 테이블을 연결하는 기능이 아니다.
관계는 현실 세계의 소유·포함·참조 규칙과 각 데이터의 생명주기를 서비스 안에 표현하는 방법이다.