ID는 데이터를 구분하는 번호가 아니다

vx_developer·2026년 9월 24일

개발하다가

목록 보기
33/39
post-thumbnail

프로그래밍을 처음 배울 때 ID는 보통 여러 데이터를 구분하기 위해 붙이는 고유한 번호라고 배운다.

const products = [
  { id: 1, name: "Mechanical Keyboard" },
  { id: 2, name: "Wireless Mouse" },
];

const product = products.find(
  (product) => product.id === 2
);

각 상품에 서로 다른 번호가 있으므로 이름이나 배열의 위치에 의존하지 않고 원하는 상품을 찾을 수 있다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 ID에 더 많은 판단이 필요하다.

  • 상품명이 변경되어도 같은 상품으로 인식할 수 있어야 하는가?
  • 데이터베이스에서 사용하는 ID를 URL에 그대로 공개해도 되는가?
  • 주문 번호와 데이터베이스 기본 키는 같은 값이어야 하는가?
  • 결제 시스템이 전달한 ID는 어디에 저장해야 하는가?
  • 삭제된 데이터의 ID를 새로운 데이터에 다시 사용해도 되는가?
  • 여러 시스템에서 동시에 데이터를 생성한다면 누가 ID를 만드는가?
  • 요청에 포함된 사용자 ID만으로 해당 데이터에 접근하도록 허용해도 되는가?

ID는 단순히 데이터에 붙이는 번호가 아니다.

ID는 데이터가 변경되고 이동하며 다른 데이터와 연결되는 동안에도 같은 대상을 가리킬 수 있도록 정체성을 유지하는 기준이다.


값이 같다는 것과 같은 대상이라는 것은 다르다

크리스가 쇼핑몰의 장바구니 기능을 개발한다고 생각해 보자.

처음에는 상품 이름으로 장바구니 항목을 찾을 수 있다.

type CartItem = {
  productName: string;
  quantity: number;
};

const cartItems: CartItem[] = [
  {
    productName: "Mechanical Keyboard",
    quantity: 1,
  },
];

function increaseQuantity(productName: string) {
  const item = cartItems.find(
    (item) => item.productName === productName
  );

  if (item) {
    item.quantity += 1;
  }
}

이 코드는 상품 이름이 고유하고 변하지 않는 동안에는 동작한다.

하지만 쇼핑몰에서는 다음과 같은 변화가 발생할 수 있다.

  • 운영자가 상품명을 수정한다.
  • 서로 다른 판매자가 같은 상품명을 사용한다.
  • 상품명이 번역되거나 지역별로 다르게 표시된다.
  • 상품명에 오타가 있어 수정된다.
  • 같은 상품에 색상이나 크기 옵션이 추가된다.

상품명은 상품의 속성이다. 속성은 변경될 수 있으며 다른 상품과 같은 값을 가질 수도 있다.

type CartItem = {
  productId: string;
  productName: string;
  quantity: number;
};

function increaseQuantity(productId: string) {
  const item = cartItems.find(
    (item) => item.productId === productId
  );

  if (item) {
    item.quantity += 1;
  }
}

이제 상품명이 변경되더라도 productId가 같으면 같은 상품을 가리킨다.

이 차이를 이해하려면 데이터의 속성과 정체성을 구분해야 한다.

  • 상품명은 상품을 설명한다.
  • 가격은 현재 판매 조건을 설명한다.
  • 재고 수량은 현재 보유 상태를 설명한다.
  • ID는 어떤 상품에 관한 정보인지 나타낸다.

속성은 변할 수 있지만 정체성은 그 변화를 같은 대상의 이력으로 연결한다.


배열의 위치는 데이터의 정체성이 될 수 없다

상품 목록에서 배열 인덱스를 ID처럼 사용하는 코드도 자주 볼 수 있다.

function ProductList({
  products,
}: {
  products: Product[];
}) {
  return products.map((product, index) => (
    <ProductCard
      key={index}
      product={product}
    />
  ));
}

현재 순서가 유지되는 동안에는 각 카드가 화면에 표시된다. 그러나 상품이 정렬되거나 삭제되면 인덱스가 가리키는 대상이 바뀐다.

const products = [
  { id: "product-a", name: "Keyboard" },
  { id: "product-b", name: "Mouse" },
];

// product-b의 현재 인덱스는 1이다.
const sortedProducts = products.toSorted(
  (a, b) => b.name.localeCompare(a.name)
);

// 정렬 후 product-b의 인덱스는 0이 될 수 있다.

인덱스는 목록 안의 현재 위치를 나타낼 뿐이다. 상품 자체의 정체성을 나타내지 않는다.

React에서도 안정적인 상품 ID를 key로 사용해야 한다.

function ProductList({
  products,
}: {
  products: Product[];
}) {
  return products.map((product) => (
    <ProductCard
      key={product.id}
      product={product}
    />
  ));
}

상품의 위치가 달라져도 product.id는 같은 컴포넌트와 같은 상품을 연결한다.

이 원칙은 프론트엔드 목록에만 적용되지 않는다.

  • 페이지네이션을 이동해도 같은 상품을 찾아야 한다.
  • 검색 결과의 순서가 바뀌어도 같은 상품을 가리켜야 한다.
  • 캐시에 저장된 데이터와 서버 응답을 연결해야 한다.
  • 주문 항목과 상품을 연결해야 한다.
  • 로그와 이벤트에서 같은 대상을 추적해야 한다.

정체성은 현재 위치나 표시 순서와 분리되어야 한다.


변경 가능한 속성을 ID로 사용하면 변화가 관계를 끊는다

크리스는 상품 URL에 상품 코드를 사용하기로 한다.

/products/KEYBOARD-BLACK

데이터베이스에서도 이 값을 기본 키로 사용할 수 있다.

CREATE TABLE products (
  product_code TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  price_cents INTEGER NOT NULL
);

CREATE TABLE order_items (
  order_id UUID NOT NULL,
  product_code TEXT NOT NULL
    REFERENCES products(product_code),
  quantity INTEGER NOT NULL
);

상품 코드가 영구적으로 유지되는 비즈니스 식별자라면 이 구조가 적절할 수 있다.

하지만 운영 정책에 따라 상품 코드가 변경될 수 있다면 문제가 달라진다.

KEYBOARD-BLACK
        ↓
KEYBOARD-BLACK-AU

상품 코드를 변경하는 순간 주문 항목, 재고, 리뷰, 할인 정책 등 해당 값을 참조하는 관계도 함께 변경해야 한다. 외부에 공유된 기존 URL이나 분석 이벤트도 과거 상품을 찾지 못할 수 있다.

변경 가능한 비즈니스 속성과 내부 정체성을 분리하면 이러한 영향을 줄일 수 있다.

CREATE TABLE products (
  id UUID PRIMARY KEY,
  product_code TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  price_cents INTEGER NOT NULL
);

CREATE TABLE order_items (
  order_id UUID NOT NULL,
  product_id UUID NOT NULL
    REFERENCES products(id),
  quantity INTEGER NOT NULL
);

이제 product_code가 변경되어도 products.id는 유지된다. 주문 항목과 상품의 관계도 끊어지지 않는다.

그렇다고 모든 비즈니스 식별자를 별도의 내부 ID로 교체해야 하는 것은 아니다. 주민등록번호, 국제표준도서번호, 국가 코드처럼 외부에서 정의된 식별자가 특정 도메인에서 중요한 역할을 할 수도 있다.

판단의 핵심은 값의 형태가 아니라 그 값이 따르는 규칙이다.

  • 누가 이 식별자를 발급하는가?
  • 변경될 수 있는가?
  • 중복 가능성이 있는가?
  • 다른 시스템에서도 같은 의미를 가지는가?
  • 형식이 바뀌면 내부 관계까지 변경되어도 되는가?
  • 서비스가 식별자의 생명주기를 통제할 수 있는가?

변경될 수 있는 값은 좋은 설명이나 검색 기준이 될 수 있지만, 장기적인 관계를 유지하는 정체성으로 사용하기에는 신중해야 한다.


하나의 대상도 목적에 따라 여러 ID를 가질 수 있다

주문 하나에 반드시 하나의 ID만 존재해야 하는 것은 아니다.

type Order = {
  id: string;
  orderNumber: string;
  paymentIntentId: string | null;
  shipmentTrackingNumber: string | null;
};

각 값은 같은 주문과 관련되지만 역할은 다르다.

식별자발급 주체주요 목적
id주문 서비스데이터베이스 관계와 내부 참조
orderNumber주문 서비스고객 문의와 주문 조회
paymentIntentId결제 서비스외부 결제 내역 연결
shipmentTrackingNumber배송사배송 상태 조회

내부 ID를 고객에게 보여주기 어려울 수 있다.

550e8400-e29b-41d4-a716-446655440000

고객 지원에서는 읽고 전달하기 쉬운 주문 번호가 더 적합하다.

AU-20260923-10482

반대로 주문 번호에 날짜나 순번이 포함되어 있다고 해서 데이터베이스의 모든 관계가 그 형식에 의존해야 하는 것은 아니다. 주문 번호 정책이 바뀌어도 주문의 내부 정체성은 유지되어야 할 수 있다.

외부 결제 ID도 내부 주문 ID를 대신하지 않는다.

await orderRepository.create({
  id: crypto.randomUUID(),
  orderNumber: generateOrderNumber(),
  paymentIntentId: paymentIntent.id,
});

이 코드는 주문 서비스가 관리하는 정체성과 결제 서비스가 관리하는 정체성을 함께 저장한다.

외부 ID는 발급한 시스템의 규칙을 따른다. 결제 제공업체를 변경하거나 하나의 주문에 여러 번의 결제 시도가 생기면 주문과 결제가 일대일 관계가 아닐 수도 있다.

CREATE TABLE orders (
  id UUID PRIMARY KEY,
  order_number TEXT NOT NULL UNIQUE
);

CREATE TABLE payment_attempts (
  id UUID PRIMARY KEY,
  order_id UUID NOT NULL
    REFERENCES orders(id),
  provider TEXT NOT NULL,
  provider_payment_id TEXT NOT NULL,
  status TEXT NOT NULL,
  UNIQUE (provider, provider_payment_id)
);

이 구조에서는 주문의 정체성과 결제 시도의 정체성이 분리된다. 같은 주문에 결제 재시도가 발생해도 기존 주문을 새로운 주문으로 오해하지 않는다.

하나의 ID로 모든 목적을 해결하려 하기보다 각 식별자의 소유자와 사용 범위를 명확히 하는 편이 중요하다.


ID가 고유하려면 어느 범위에서 고유한지도 정해야 한다

id라는 이름만으로는 고유성의 범위를 알 수 없다.

여러 판매자가 입점한 쇼핑몰에서 각 판매자가 자체 상품 코드를 관리한다고 생각해 보자.

Seller A: BASIC-T-SHIRT
Seller B: BASIC-T-SHIRT

각 판매자 안에서는 고유하지만 쇼핑몰 전체에서는 중복될 수 있다.

다음 제약 조건은 상품 코드가 쇼핑몰 전체에서 하나만 존재하도록 만든다.

CREATE TABLE products (
  id UUID PRIMARY KEY,
  seller_id UUID NOT NULL,
  product_code TEXT NOT NULL UNIQUE
);

판매자별로 같은 코드를 사용할 수 있어야 한다면 고유성의 범위를 함께 표현해야 한다.

CREATE TABLE products (
  id UUID PRIMARY KEY,
  seller_id UUID NOT NULL,
  product_code TEXT NOT NULL,
  UNIQUE (seller_id, product_code)
);

이제 상품 코드는 seller_id 안에서만 고유하다.

애플리케이션에서도 같은 범위를 사용해야 한다.

async function findSellerProduct(
  sellerId: string,
  productCode: string
) {
  return productRepository.findBySellerAndCode({
    sellerId,
    productCode,
  });
}

productCode만으로 상품을 조회하면 다른 판매자의 상품을 잘못 가져올 수 있다.

ID 설계에서 “고유하다”는 표현만으로는 부족하다. 다음 중 어느 범위인지 분명해야 한다.

  • 하나의 배열 안에서 고유한가?
  • 한 사용자 계정 안에서 고유한가?
  • 한 판매자 안에서 고유한가?
  • 하나의 데이터베이스에서 고유한가?
  • 여러 지역과 서비스 전체에서 고유한가?
  • 외부 제공업체와 내부 시스템을 합쳐서 고유한가?

고유성은 값 하나의 성질이 아니라 시스템이 보장하는 범위에 관한 규칙이다.


ID 생성 방식은 데이터가 만들어지는 구조를 따라야 한다

하나의 데이터베이스에서만 데이터를 생성한다면 자동 증가 정수를 사용할 수 있다.

CREATE TABLE products (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

구조가 단순하고 인덱스 크기도 비교적 작으며 데이터베이스가 중복 없이 값을 생성해 준다.

하지만 여러 서버나 지역에서 독립적으로 주문을 생성하는 서비스라면 중앙 데이터베이스의 다음 번호에만 의존하기 어려울 수 있다.

const orderId = crypto.randomUUID();

UUID는 애플리케이션이 데이터베이스에 저장하기 전에도 ID를 만들 수 있게 한다. 여러 서버가 독립적으로 생성하더라도 충돌 가능성이 매우 낮다.

그렇다고 UUID가 모든 상황에서 자동으로 더 나은 것은 아니다.

  • 값이 길어 로그와 고객 문의에서 다루기 어렵다.
  • 무작위 값의 인덱스 특성이 저장 성능에 영향을 줄 수 있다.
  • 생성 규칙과 데이터베이스 타입을 일관되게 관리해야 한다.
  • 사람이 읽어야 하는 업무 번호로는 적합하지 않을 수 있다.

시간 순서 특성을 가진 UUID나 ULID 같은 방식을 검토할 수도 있지만, 도입 전에 사용 중인 데이터베이스와 라이브러리가 해당 형식을 올바르게 처리하는지 확인해야 한다.

중요한 것은 유행하는 형식을 선택하는 일이 아니다. 데이터가 생성되는 구조와 ID에 기대하는 성질을 먼저 정하는 일이다.

상황검토할 수 있는 방식
하나의 데이터베이스가 모든 데이터를 생성자동 증가 정수
여러 애플리케이션 서버에서 독립적으로 생성UUID
생성 시각에 가까운 정렬이 필요시간 순서형 UUID 또는 ULID
고객이 직접 읽고 전달해야 함별도의 업무용 번호
외부 시스템에서 이미 식별자를 발급외부 ID를 별도 필드에 저장

ID 형식은 정체성의 본질이 아니다. 형식은 정체성을 안정적으로 발급하고 저장하기 위한 구현 선택이다.


ID는 한 번 발급한 뒤 의미를 바꾸지 않아야 한다

상품이 삭제되었다고 해서 해당 상품의 ID를 새로운 상품에 다시 사용하는 것은 위험하다.

product_id = 101

처음에는 키보드를 가리켰지만, 삭제 후 같은 ID가 모니터에 할당되었다고 생각해 보자.

과거 주문, 로그, 캐시, 분석 이벤트, 고객 문의에 남은 product_id = 101이 이제 전혀 다른 상품을 가리키게 된다.

type ProductDeletedEvent = {
  productId: string;
  deletedAt: string;
};

이벤트를 받은 다른 시스템이 이미 해당 ID를 저장했다면 재사용 사실을 즉시 알기도 어렵다.

삭제된 데이터의 처리 방식은 서비스 요구사항에 따라 달라질 수 있다.

ALTER TABLE products
ADD COLUMN deleted_at TIMESTAMPTZ;

소프트 삭제를 사용하면 데이터는 남겨 두고 일반 조회에서 제외할 수 있다.

SELECT *
FROM products
WHERE id = $1
  AND deleted_at IS NULL;

반드시 소프트 삭제를 사용해야 한다는 뜻은 아니다. 개인정보 삭제처럼 실제 제거가 필요한 경우도 있다. 중요한 것은 데이터가 삭제된 이후에도 이미 발행된 ID의 의미를 다른 대상에 넘기지 않는 것이다.

ID는 데이터가 존재하는 동안만 필요한 값이 아니다. 다른 시스템의 기록, 로그, 백업, 이벤트에 남아 있는 동안에도 과거의 의미를 유지해야 한다.


ID는 관계를 연결하지만 데이터의 과거 상태까지 보존하지는 않는다

주문 항목은 상품 ID를 통해 상품과 연결할 수 있다.

CREATE TABLE order_items (
  order_id UUID NOT NULL
    REFERENCES orders(id),
  product_id UUID NOT NULL
    REFERENCES products(id),
  quantity INTEGER NOT NULL
);

하지만 상품 ID만 저장하면 현재 상품은 찾을 수 있어도 주문 당시의 가격이나 이름은 보존되지 않는다.

상품 가격이 변경된 뒤 과거 주문서를 조회하면 현재 가격이 표시될 수 있다.

주문 당시의 거래 사실을 보존해야 한다면 스냅숏이 필요하다.

CREATE TABLE order_items (
  order_id UUID NOT NULL
    REFERENCES orders(id),
  product_id UUID NOT NULL
    REFERENCES products(id),
  product_name_snapshot TEXT NOT NULL,
  unit_price_cents INTEGER NOT NULL,
  quantity INTEGER NOT NULL
);

각 값은 서로 다른 역할을 한다.

  • product_id는 어떤 상품과 관련된 주문인지 연결한다.
  • product_name_snapshot은 주문 당시 고객에게 표시된 상품명을 보존한다.
  • unit_price_cents는 주문 시점에 확정된 거래 가격을 보존한다.

ID는 대상의 정체성을 유지하지만, 그 대상의 모든 속성을 과거 시점 그대로 고정하지는 않는다.

현재 상품 정보가 Source of Truth인 질문과 주문 당시의 거래 내역이 Source of Truth인 질문은 다르다.

  • 현재 상품 페이지에는 products의 현재 정보가 필요하다.
  • 과거 주문서에는 order_items에 확정된 스냅숏이 필요하다.

ID와 스냅숏의 책임을 구분해야 관계와 이력을 모두 올바르게 유지할 수 있다.


URL에 포함된 ID는 접근 권한을 증명하지 않는다

크리스가 주문 상세 API를 만든다고 생각해 보자.

GET /api/orders/550e8400-e29b-41d4-a716-446655440000

UUID는 추측하기 어렵기 때문에 자동 증가 번호보다 안전해 보일 수 있다. 그러나 ID를 알고 있다는 사실은 해당 주문을 볼 권한이 있다는 뜻이 아니다.

다음 코드는 요청받은 ID에 해당하는 주문을 그대로 반환한다.

async function getOrder(orderId: string) {
  return orderRepository.findById(orderId);
}

다른 사용자의 주문 ID가 로그, 공유 URL, 브라우저 기록 또는 외부 분석 도구를 통해 노출되면 권한 없이 주문을 조회할 수 있다.

현재 사용자의 범위를 함께 확인해야 한다.

async function getCustomerOrder(
  orderId: string,
  currentUserId: string
) {
  const order =
    await orderRepository.findById(orderId);

  if (!order || order.customerId !== currentUserId) {
    throw new OrderNotFoundError();
  }

  return order;
}

관리자, 판매자, 고객처럼 역할이 나뉜 서비스라면 더 구체적인 정책이 필요하다.

const canReadOrder = authorizeOrderAccess({
  actor: currentUser,
  action: "read",
  order,
});

if (!canReadOrder) {
  throw new OrderNotFoundError();
}

외부 입력은 검증 전까지 신뢰할 수 없다. URL이나 요청 본문으로 전달된 ID도 외부 입력이다.

형식도 먼저 검증해야 한다.

import { z } from "zod";

const OrderIdSchema = z.string().uuid();

const orderId = OrderIdSchema.parse(
  request.params.orderId
);

하지만 형식 검증과 권한 검사는 서로 다른 문제다.

  • 형식 검증은 ID가 허용된 형태인지 확인한다.
  • 존재 확인은 해당 ID의 데이터가 실제로 있는지 확인한다.
  • 권한 검사는 현재 사용자가 그 데이터에 행동할 수 있는지 확인한다.

길고 무작위인 ID는 추측을 어렵게 만들 수 있지만 접근 제어를 대신하지 않는다.


같은 요청을 다시 처리할 때도 ID가 필요하다

ID는 저장된 데이터만 구분하는 데 사용되지 않는다. 처리하려는 작업의 정체성을 표현할 수도 있다.

사용자가 결제 버튼을 누른 직후 네트워크가 끊겼다고 생각해 보자. 프론트엔드는 응답을 받지 못했기 때문에 같은 요청을 다시 보낼 수 있다.

await fetch("/api/orders", {
  method: "POST",
  body: JSON.stringify(checkoutInput),
});

서버가 첫 번째 요청을 이미 처리했다면 두 번째 요청으로 동일한 주문이나 결제가 중복 생성될 수 있다.

클라이언트가 작업을 식별하는 키를 함께 전달할 수 있다.

const idempotencyKey = crypto.randomUUID();

await fetch("/api/orders", {
  method: "POST",
  headers: {
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(checkoutInput),
});

서버는 동일한 사용자와 동일한 키의 처리 결과를 저장한다.

CREATE TABLE idempotency_records (
  user_id UUID NOT NULL,
  idempotency_key UUID NOT NULL,
  order_id UUID NOT NULL
    REFERENCES orders(id),
  created_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (user_id, idempotency_key)
);

같은 키의 요청이 다시 들어오면 새 주문을 만들지 않고 기존 결과를 반환할 수 있다.

const existingRecord =
  await idempotencyRepository.find({
    userId: currentUser.id,
    key: idempotencyKey,
  });

if (existingRecord) {
  return orderRepository.findById(
    existingRecord.orderId
  );
}

여기서 idempotencyKey는 주문의 ID가 아니다. 한 번 처리되어야 하는 요청의 정체성을 나타낸다.

이처럼 서비스에는 여러 종류의 정체성이 존재한다.

  • 사용자의 정체성
  • 상품의 정체성
  • 주문의 정체성
  • 결제 시도의 정체성
  • 한 번만 처리되어야 하는 요청의 정체성

어떤 대상을 식별하려는지 분명해야 적절한 ID와 고유성 범위를 정할 수 있다.


ID는 서비스 전체에서 데이터의 연결을 유지한다

상품이 주문에 담기고 결제와 배송으로 이어지는 흐름을 살펴보면 여러 식별자가 각 단계의 관계를 유지한다.

flowchart LR
    A[상품 선택<br/>productId] --> B[장바구니 항목<br/>cartItemId]
    B --> C[주문 생성<br/>orderId]
    C --> D[주문 항목<br/>orderItemId]
    C --> E[결제 시도<br/>paymentAttemptId]
    E --> F[외부 결제<br/>providerPaymentId]
    C --> G[배송<br/>shipmentId]
    G --> H[배송사 조회<br/>trackingNumber]

각 단계는 이전 단계와 관련되지만 독립적인 생명주기를 가진다.

  • 상품이 품절되어도 이미 생성된 주문은 유지된다.
  • 결제가 실패해도 주문 시도 자체는 기록될 수 있다.
  • 하나의 주문에서 결제를 다시 시도할 수 있다.
  • 주문 하나가 여러 배송으로 나뉠 수 있다.
  • 배송 번호의 형식은 배송사마다 다를 수 있다.

하나의 주문 번호를 모든 데이터의 ID로 사용하면 서로 다른 생명주기와 관계를 표현하기 어렵다.

ID 설계는 번호를 만드는 작업이 아니라 서비스 안에서 무엇을 독립적인 대상으로 볼 것인지 결정하는 작업과 연결된다.


ID를 설계하기 전에 물어봐야 할 질문

무엇의 정체성을 표현하는가

  1. 이 ID는 사용자, 상품, 주문, 결제, 요청 중 무엇을 식별하는가?
  2. 같은 속성을 가진 두 데이터도 서로 다른 대상으로 존재할 수 있는가?
  3. 속성이 변경되어도 같은 대상으로 유지되어야 하는가?
  4. 삭제 이후에도 로그나 이력에서 과거 대상을 구분해야 하는가?
  5. 독립적인 생명주기가 없는 값에 불필요한 ID를 붙이고 있지 않은가?

누가 발급하고 관리하는가

  1. 애플리케이션과 데이터베이스 중 누가 ID를 생성하는가?
  2. 외부 시스템이 발급한 ID인가?
  3. 여러 서버가 동시에 생성해도 충돌하지 않는가?
  4. ID 생성에 실패하면 데이터를 저장할 수 있는가?
  5. 생성 규칙을 서비스가 장기적으로 통제할 수 있는가?

어디까지 고유해야 하는가

  1. 한 사용자 안에서만 고유한가?
  2. 한 판매자나 조직 안에서 고유한가?
  3. 데이터베이스 전체에서 고유한가?
  4. 여러 서비스와 지역에서도 고유해야 하는가?
  5. 복합 고유 조건을 코드와 데이터베이스가 동일하게 이해하는가?

변경과 삭제를 견딜 수 있는가

  1. 이름, 이메일, 상품 코드 같은 변경 가능한 속성을 ID로 사용하고 있지 않은가?
  2. ID 형식이 변경되면 모든 참조도 함께 변경해야 하는가?
  3. 삭제된 ID가 새로운 데이터에 다시 할당될 수 있는가?
  4. 소프트 삭제와 실제 삭제 중 어떤 정책이 필요한가?
  5. 외부에 공유된 링크와 이벤트가 장기간 같은 대상을 가리키는가?

내부 ID와 외부 ID를 구분했는가

  1. 데이터베이스 기본 키와 고객용 번호가 같은 값이어야 하는가?
  2. 외부 결제나 배송 ID를 내부 기본 키처럼 사용하고 있지 않은가?
  3. 외부 시스템이 바뀌어도 내부 관계를 유지할 수 있는가?
  4. 외부 ID의 발급 주체와 형식이 명시되어 있는가?
  5. 하나의 내부 대상에 여러 외부 ID가 연결될 가능성이 있는가?

보안과 검증을 적용했는가

  1. 요청으로 받은 ID의 형식을 검증하는가?
  2. 데이터가 존재하는지 확인하는가?
  3. 현재 사용자가 해당 데이터에 접근할 권한이 있는가?
  4. 추측하기 어려운 ID를 권한 검사 대신 사용하고 있지 않은가?
  5. 로그와 오류 메시지에서 민감한 관계가 노출되지 않는가?

흔한 실수는 ID의 모양만 정하고 의미를 정하지 않는다

이름을 ID처럼 사용한다

findProductByName("Mechanical Keyboard");

이름은 수정되거나 중복될 수 있다.

변경 가능한 이름은 표시와 검색에 사용하고, 장기적인 관계는 안정적인 상품 ID로 유지해야 한다.


배열 인덱스를 데이터의 ID로 사용한다

<ProductCard key={index} />

정렬이나 삭제가 발생하면 같은 인덱스가 다른 상품을 가리킬 수 있다.

목록의 위치와 데이터의 정체성을 분리해야 한다.


모든 종류의 ID를 하나로 취급한다

type Order = {
  id: string;
};

실제 주문에는 내부 기본 키, 고객용 주문 번호, 외부 결제 ID, 배송 번호가 각각 필요할 수 있다.

각 식별자의 발급 주체와 목적을 이름으로 드러내야 한다.


외부 서비스의 ID를 내부 정체성으로 사용한다

CREATE TABLE orders (
  payment_intent_id TEXT PRIMARY KEY
);

결제 제공업체를 변경하거나 한 주문에 여러 결제 시도가 생기면 주문 정체성이 결제 시스템의 구조에 묶인다.

내부 주문 ID와 외부 결제 ID를 분리해야 한다.


UUID를 사용하면 권한 검사가 필요 없다고 생각한다

return orderRepository.findById(
  request.params.orderId
);

UUID를 추측하기 어렵다는 사실은 사용자의 접근 권한을 증명하지 않는다.

데이터를 조회할 때 현재 사용자의 소유권과 역할을 함께 확인해야 한다.


삭제된 ID를 재사용한다

과거 로그와 주문이 가리키던 ID를 새로운 상품에 할당하면 하나의 ID가 시간에 따라 서로 다른 의미를 가지게 된다.

발행된 ID는 대상이 삭제된 뒤에도 다른 대상에 재할당하지 않는 편이 안전하다.


ID만 저장하면 과거 상태도 보존된다고 생각한다

상품 ID는 어떤 상품인지 알려주지만 주문 당시의 가격과 이름까지 고정하지 않는다.

거래 당시의 사실이 필요하다면 ID와 함께 명시적인 스냅숏을 저장해야 한다.


ID의 핵심은 데이터의 정체성과 관계를 시간 속에서 유지하는 데 있다

프로그래밍을 처음 배울 때는 서로 다른 번호를 붙이는 것으로 ID의 기본 역할을 이해할 수 있다.

const product = {
  id: 1,
  name: "Mechanical Keyboard",
};

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

하지만 실제 서비스에서 ID를 설계할 때는 번호의 형식보다 그 번호가 유지해야 하는 의미가 중요하다.

  • ID는 무엇의 정체성을 표현하는가?
  • 속성이 변경되어도 같은 대상을 가리키는가?
  • 배열의 위치나 화면의 순서와 분리되어 있는가?
  • 어느 범위에서 고유한가?
  • 누가 발급하고 생명주기를 관리하는가?
  • 내부 ID와 고객용 번호, 외부 ID가 구분되어 있는가?
  • 삭제 이후에도 과거의 의미를 유지하는가?
  • 현재 데이터와 거래 당시의 스냅숏을 구분했는가?
  • 형식 검증, 존재 확인, 권한 검사를 각각 수행하는가?
  • 여러 시스템과 이벤트에서도 같은 대상을 추적할 수 있는가?

ID는 데이터를 구분하는 번호가 아니다.

ID는 데이터가 변경되고 시스템 사이를 이동하며 다른 데이터와 관계를 맺는 동안에도 같은 대상을 일관되게 가리키도록 정체성을 유지하는 기준이다.

profile
Vision eXperience Developer

0개의 댓글