테이블은 엑셀과 비슷한 표가 아니다

vx_developer·2026년 9월 22일

개발하다가

목록 보기
32/41
post-thumbnail

프로그래밍을 처음 배울 때 데이터베이스 테이블은 보통 행과 열로 데이터를 정리하는 표라고 배운다.

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

각 단계에서 서로 다른 데이터와 규칙이 사용된다.

  • 사용자는 예약을 생성한 사람의 신원을 나타낸다.
  • 회의실은 예약 가능한 공간과 수용 인원을 나타낸다.
  • 예약은 특정 사용자, 공간, 시간을 연결한다.
  • 참가자는 해당 예약에 초대된 사람을 나타낸다.
  • 알림은 예약 생성 결과를 외부에 전달한다.
  • 상세 화면은 여러 사실을 조합해 사용자에게 보여준다.

테이블을 잘 나눈다는 것은 조회 한 번으로 모든 정보를 얻는다는 의미가 아니다. 서비스의 개념을 잃지 않으면서 필요한 조회와 변경을 명확하게 만들 수 있다는 의미다.


테이블을 설계하기 전에 물어봐야 할 질문

어떤 개념을 표현하는 테이블인가

  1. 이 테이블을 한 문장으로 설명할 수 있는가?
  2. 하나의 행은 현실 세계의 어떤 사실이나 대상을 나타내는가?
  3. 화면에서 함께 보인다는 이유만으로 서로 다른 개념을 합치고 있지 않은가?
  4. 같은 테이블의 열들이 비슷한 생명주기를 가지는가?
  5. 하나의 테이블이 지나치게 많은 변경 이유를 가지고 있지 않은가?

각 열은 하나의 의미를 가지는가

  1. 하나의 문자열 안에 날짜, 시간, 상태 같은 여러 사실이 섞여 있지 않은가?
  2. 비교하거나 계산할 값에 적절한 타입을 사용했는가?
  3. 표시용 문자열을 원본 데이터처럼 저장하고 있지 않은가?
  4. NULL은 값이 없다는 뜻인지, 아직 정해지지 않았다는 뜻인지 분명한가?
  5. 열 이름만 보고 비즈니스 의미를 이해할 수 있는가?

반복 데이터가 구조를 바꾸고 있지 않은가

  1. participant_1, participant_2처럼 번호가 붙은 열이 있는가?
  2. 데이터 개수가 늘어날 때 스키마를 수정해야 하는가?
  3. 반복되는 항목이 자체적인 속성이나 상태를 가지는가?
  4. 반복 항목을 별도의 행으로 관리하는 편이 자연스럽지 않은가?
  5. 배열이나 JSON에 넣은 데이터도 독립적으로 검색하고 변경해야 하는가?

중복과 Source of Truth가 명확한가

  1. 같은 의미의 값이 여러 행이나 테이블에 반복되는가?
  2. 값이 서로 다를 때 어느 위치를 신뢰해야 하는가?
  3. 중복된 값은 불필요한 복사본인가, 의도적인 스냅숏인가?
  4. 계산된 값을 저장한다면 누가 언제 다시 계산하는가?
  5. 화면에 표시된 값을 서비스의 원본 사실로 오해하고 있지 않은가?

규칙을 데이터베이스에서도 보호하는가

  1. 반드시 존재해야 하는 값에 NOT NULL이 적용되어 있는가?
  2. 중복되면 안 되는 값에 고유 제약 조건이 있는가?
  3. 숫자의 범위나 시간의 순서를 CHECK로 보호할 수 있는가?
  4. 존재하지 않는 데이터를 참조하지 않도록 보호하는가?
  5. 애플리케이션 검증을 우회해도 기본 규칙이 유지되는가?

앞으로의 변경을 감당할 수 있는가

  1. 요구사항이 추가될 때 열 하나를 추가할 문제인가, 새로운 개념이 생긴 것인가?
  2. 새 테이블로 분리했을 때 독립적인 생명주기와 규칙이 분명해지는가?
  3. 현재 화면이 바뀌어도 테이블의 의미가 유지되는가?
  4. 기존 데이터를 새로운 구조로 옮길 방법이 있는가?
  5. 테이블을 합치거나 나눈 이유를 다른 개발자에게 설명할 수 있는가?

흔한 실수는 테이블을 서비스 모델이 아니라 저장 양식으로 만든다

화면의 모든 값을 한 테이블에 넣는다

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 |

입문 단계에서는 충분한 설명이다.

하지만 실제 서비스에서는 표의 모양보다 그 표가 어떤 개념을 책임지는지가 중요하다.

  • 하나의 행은 현실의 어떤 사실을 나타내는가?
  • 이 테이블의 데이터는 같은 이유로 생성되고 변경되는가?
  • 하나의 열에 여러 의미가 섞여 있지 않은가?
  • 반복 데이터가 열의 개수를 늘리고 있지 않은가?
  • 중복된 값의 Source of Truth가 분명한가?
  • 현재 값과 특정 시점의 스냅숏을 구분했는가?
  • 타입과 제약 조건으로 기본 규칙을 보호하는가?
  • 화면 모델과 저장 모델을 구분했는가?
  • 새로운 열이 필요한 것인지 새로운 개념이 생긴 것인지 판단했는가?
  • 현재 화면이 바뀌어도 테이블이 서비스의 언어를 유지하는가?

테이블은 엑셀과 비슷한 표가 아니다.

테이블은 서비스가 구분해서 관리해야 하는 핵심 개념에 경계를 만들고, 각 개념의 속성·규칙·생명주기를 일관된 구조로 표현하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글