프로그래밍을 처음 배울 때 ID는 보통 여러 데이터를 구분하기 위해 붙이는 고유한 번호라고 배운다.
const products = [
{ id: 1, name: "Mechanical Keyboard" },
{ id: 2, name: "Wireless Mouse" },
];
const product = products.find(
(product) => product.id === 2
);
각 상품에 서로 다른 번호가 있으므로 이름이나 배열의 위치에 의존하지 않고 원하는 상품을 찾을 수 있다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 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처럼 사용하는 코드도 자주 볼 수 있다.
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는 같은 컴포넌트와 같은 상품을 연결한다.
이 원칙은 프론트엔드 목록에만 적용되지 않는다.
정체성은 현재 위치나 표시 순서와 분리되어야 한다.
크리스는 상품 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만 존재해야 하는 것은 아니다.
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라는 이름만으로는 고유성의 범위를 알 수 없다.
여러 판매자가 입점한 쇼핑몰에서 각 판매자가 자체 상품 코드를 관리한다고 생각해 보자.
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 설계에서 “고유하다”는 표현만으로는 부족하다. 다음 중 어느 범위인지 분명해야 한다.
고유성은 값 하나의 성질이 아니라 시스템이 보장하는 범위에 관한 규칙이다.
하나의 데이터베이스에서만 데이터를 생성한다면 자동 증가 정수를 사용할 수 있다.
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를 새로운 상품에 다시 사용하는 것은 위험하다.
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를 통해 상품과 연결할 수 있다.
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와 스냅숏의 책임을 구분해야 관계와 이력을 모두 올바르게 유지할 수 있다.
크리스가 주문 상세 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는 저장된 데이터만 구분하는 데 사용되지 않는다. 처리하려는 작업의 정체성을 표현할 수도 있다.
사용자가 결제 버튼을 누른 직후 네트워크가 끊겼다고 생각해 보자. 프론트엔드는 응답을 받지 못했기 때문에 같은 요청을 다시 보낼 수 있다.
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와 고유성 범위를 정할 수 있다.
상품이 주문에 담기고 결제와 배송으로 이어지는 흐름을 살펴보면 여러 식별자가 각 단계의 관계를 유지한다.
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 설계는 번호를 만드는 작업이 아니라 서비스 안에서 무엇을 독립적인 대상으로 볼 것인지 결정하는 작업과 연결된다.
findProductByName("Mechanical Keyboard");
이름은 수정되거나 중복될 수 있다.
변경 가능한 이름은 표시와 검색에 사용하고, 장기적인 관계는 안정적인 상품 ID로 유지해야 한다.
<ProductCard key={index} />
정렬이나 삭제가 발생하면 같은 인덱스가 다른 상품을 가리킬 수 있다.
목록의 위치와 데이터의 정체성을 분리해야 한다.
type Order = {
id: string;
};
실제 주문에는 내부 기본 키, 고객용 주문 번호, 외부 결제 ID, 배송 번호가 각각 필요할 수 있다.
각 식별자의 발급 주체와 목적을 이름으로 드러내야 한다.
CREATE TABLE orders (
payment_intent_id TEXT PRIMARY KEY
);
결제 제공업체를 변경하거나 한 주문에 여러 결제 시도가 생기면 주문 정체성이 결제 시스템의 구조에 묶인다.
내부 주문 ID와 외부 결제 ID를 분리해야 한다.
return orderRepository.findById(
request.params.orderId
);
UUID를 추측하기 어렵다는 사실은 사용자의 접근 권한을 증명하지 않는다.
데이터를 조회할 때 현재 사용자의 소유권과 역할을 함께 확인해야 한다.
과거 로그와 주문이 가리키던 ID를 새로운 상품에 할당하면 하나의 ID가 시간에 따라 서로 다른 의미를 가지게 된다.
발행된 ID는 대상이 삭제된 뒤에도 다른 대상에 재할당하지 않는 편이 안전하다.
상품 ID는 어떤 상품인지 알려주지만 주문 당시의 가격과 이름까지 고정하지 않는다.
거래 당시의 사실이 필요하다면 ID와 함께 명시적인 스냅숏을 저장해야 한다.
프로그래밍을 처음 배울 때는 서로 다른 번호를 붙이는 것으로 ID의 기본 역할을 이해할 수 있다.
const product = {
id: 1,
name: "Mechanical Keyboard",
};
입문 단계에서는 충분한 설명이다.
하지만 실제 서비스에서 ID를 설계할 때는 번호의 형식보다 그 번호가 유지해야 하는 의미가 중요하다.
ID는 데이터를 구분하는 번호가 아니다.
ID는 데이터가 변경되고 시스템 사이를 이동하며 다른 데이터와 관계를 맺는 동안에도 같은 대상을 일관되게 가리키도록 정체성을 유지하는 기준이다.