Загружаем каталог…
Загружаем каталог…
프로그래밍을 처음 배울 때 데이터베이스 관계는 보통 외래 키를 사용해 두 테이블을 연결하는 방법이라고 배운다. 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 문법이 아니라 비즈니스 규칙에 따라 결정된다. 한 사용자는 예약당 하나의 참가자 레코드만 가질 수 있는가? 한 사람이 보호자와 예약자를 동시에 맡을 수 있는가? 거절한 초대를 다시 생성할 것인가, 기존 상태를 변경할 것인가? 취소된 관계도 이력으로 보존해야 하는가? 관계의 중복 여부도 “무엇을 같은 관계로 볼 것인가”를 정의해야 판단할 수 있다. 관계를 변경하는 것은 ID 하나를 바꾸는 작업이 아니다 예약의 상담사를 변경하는 코드는 단순해 보인다. 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 co
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
관계는 테이블을 연결하는 기능이 아니다. 프로그래밍을 처음 배울 때 데이터베이스 관계는 보통 외래 키를 사용해 두 테이블을 연결하는 방법이라고 배운다. CREATE TABLE reservations ( id UUID PRIMARY KEY, customer_id UUID REFERENCES users(id) ); reservations.customer_id 가 users.id 를 참조하므로 예약과 사용자 사이에 관계가 만들어진다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 예약 서비스를 개발하면 테이블을 연결하는 것보다 더 많은 판단이 필요하다. 예약은 반드시 한 명의 사용자에게 속해야 하는가? 회원이 탈퇴하면 예약도 삭제해야 하는가? 한 예약에 여러 참가자가 들어갈 수 있는가? 참가자는 사용자…
Открыть источник