트리를 처음 배우면 하나의 루트 노드에서 시작해 부모와 자식 관계로 데이터가 연결되는 자료구조라고 설명한다.
const categoryTree = {
name: "전체 상품",
children: [
{
name: "의류",
children: [
{ name: "상의", children: [] },
{ name: "하의", children: [] }
]
}
]
};
전체 상품은 루트이고, 의류는 그 자식이며, 상의와 하의는 의류의 자식이다. 하나의 노드에서 아래로 내려가며 계층을 탐색할 수 있다는 설명은 트리의 기본 구조를 이해하는 데 유용하다.
하지만 실제 쇼핑몰에서 카테고리를 만든다면 객체 안에 children을 넣는 것만으로는 충분하지 않다. 카테고리가 수천 개라면 전체 트리를 매번 불러와야 할까? 운영자가 상위 카테고리를 이동하면 하위 카테고리의 경로는 어떻게 바뀔까? 존재하지 않는 카테고리를 부모로 지정하거나 자신의 하위 카테고리 아래로 이동시키면 어떻게 막아야 할까? 하나의 상품이 여러 카테고리에 속한다면 카테고리와 상품 전체를 하나의 트리로 표현할 수 있을까?
실제 서비스에서 트리는 부모와 자식을 저장하는 모양이 아니라, 데이터가 속한 범위를 계층으로 나누고 필요한 범위만 단계적으로 탐색하기 위한 구조다.
크리스가 운영하는 쇼핑몰에 처음에는 다음과 같은 카테고리만 있다고 해보자.
전체 상품
├─ 의류
│ ├─ 상의
│ └─ 하의
└─ 전자기기
├─ 노트북
└─ 스마트폰
카테고리가 여섯 개뿐이라면 서버에서 모든 카테고리를 가져와 화면에서 트리를 만드는 방식도 충분히 동작한다.
async function getCategoryPage(categoryId: string) {
const categories = await categoryRepository.findAll();
const category = categories.find(
(item) => item.id === categoryId
);
const children = categories.filter(
(item) => item.parentId === categoryId
);
return {
category,
children
};
}
현재 카테고리와 자식 카테고리를 찾기 위해 모든 카테고리를 조회한다. 데이터가 작고 요청이 드물다면 이해하기 쉽고 구현 비용도 낮다.
하지만 카테고리가 50,000개로 늘어나도 사용자가 보고 싶은 것은 보통 전체 트리가 아니다. 사용자가 의류 페이지에 들어왔다면 현재 카테고리와 바로 아래 카테고리, 화면에 표시할 상품만 필요할 가능성이 높다.
async function getCategoryPage(categoryId: string) {
const [category, children] = await Promise.all([
categoryRepository.findById(categoryId),
categoryRepository.findChildren(categoryId)
]);
if (!category || !category.isVisible) {
throw new Error("Category not found");
}
return {
category,
children
};
}
이제 필요한 범위만 조회한다. parent_id에 인덱스가 있다면 특정 부모의 자식들을 전체 카테고리에서 반복해서 찾지 않아도 된다.
CREATE INDEX idx_categories_parent_id
ON categories(parent_id);
트리의 장점은 모든 데이터를 한 번에 보여주는 데 있지 않다. 사용자가 선택한 노드를 기준으로 탐색 범위를 그 노드의 하위 영역으로 좁힐 수 있다는 데 있다.
현실의 카테고리 탐색은 다음과 같이 코드의 입력·상태·출력으로 변환된다.
입력
사용자가 선택한 카테고리 ID
상태
카테고리 ID, 부모 ID, 표시 여부, 정렬 순서
출력
현재 카테고리와 탐색 가능한 하위 카테고리
사용자는 카테고리 이름을 클릭하지만 서버는 이름만으로 탐색하지 않는다. 검증된 카테고리 ID를 기준으로 현재 노드를 찾고, 부모와 자식 관계를 통해 다음 탐색 범위를 결정한다.
메모리에서는 children 배열을 가진 중첩 객체가 자연스럽다.
type CategoryNode = {
id: string;
name: string;
children: CategoryNode[];
};
하지만 관계형 데이터베이스에 이 객체를 그대로 저장하기는 어렵다. 일반적으로는 각 카테고리가 자신의 부모를 가리키도록 저장한다.
CREATE TABLE categories (
id UUID PRIMARY KEY,
store_id UUID NOT NULL,
parent_id UUID NULL REFERENCES categories(id),
name VARCHAR(100) NOT NULL,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0
);
각 행은 하나의 카테고리를 나타내고, parent_id는 바로 위의 부모 카테고리를 가리킨다. 루트 카테고리는 부모가 없으므로 NULL을 가진다.
id name parent_id
category-1 전체 상품 null
category-2 의류 category-1
category-3 상의 category-2
category-4 하의 category-2
이 저장 방식은 인접 리스트라고 부른다. 이름을 몰라도 구조는 이해할 수 있다. 각 노드가 직접 연결된 부모 하나만 저장하는 방식이다.
카테고리 하나를 추가하거나 다른 부모 아래로 이동하는 작업은 비교적 단순하다.
await categoryRepository.create({
storeId: "store-10",
parentId: "category-2",
name: "아우터",
sortOrder: 30
});
이 코드는 의류 아래에 아우터를 추가한다. 데이터베이스에는 전체 트리를 다시 저장하지 않고 새 카테고리와 부모의 관계만 기록한다.
반면 상의의 모든 하위 카테고리를 가져오려면 한 번의 단순 조회만으로는 부족할 수 있다. 자식의 자식까지 계속 따라가야 하기 때문이다.
WITH RECURSIVE category_tree AS (
SELECT id, parent_id, name, 0 AS depth
FROM categories
WHERE id = $1
UNION ALL
SELECT child.id,
child.parent_id,
child.name,
tree.depth + 1
FROM categories child
JOIN category_tree tree
ON child.parent_id = tree.id
)
SELECT *
FROM category_tree;
이 쿼리는 선택한 카테고리에서 시작해 그 아래에 연결된 카테고리를 단계적으로 찾는다. depth는 시작 카테고리에서 몇 단계 아래에 있는지도 나타낸다.
메모리의 트리와 데이터베이스의 저장 구조는 같을 필요가 없다. 데이터베이스에서는 변경하기 쉬운 관계를 저장하고, API 응답에서는 화면이 사용하기 쉬운 중첩 구조로 변환할 수 있다.
type CategoryRow = {
id: string;
parentId: string | null;
name: string;
};
function buildCategoryTree(
rows: CategoryRow[],
rootId: string
): CategoryNode | null {
const nodes = new Map<string, CategoryNode>();
for (const row of rows) {
nodes.set(row.id, {
id: row.id,
name: row.name,
children: []
});
}
for (const row of rows) {
if (!row.parentId) {
continue;
}
nodes
.get(row.parentId)
?.children.push(nodes.get(row.id)!);
}
return nodes.get(rootId) ?? null;
}
데이터베이스의 행이 원본 데이터라면 중첩된 카테고리 객체는 특정 응답을 위해 만들어진 계산 데이터다. 계산 데이터가 오래되었을 때 다시 만들 수 있도록 둘의 책임을 구분해야 한다.
사용자가 쇼핑몰에서 의류 → 남성 의류 → 아우터로 이동했다고 해보자. 사용자가 의류를 선택한 뒤에도 전자기기, 주방용품, 반려동물용품 아래의 모든 카테고리를 계속 검사할 필요는 없다.
async function openCategory(categoryId: string) {
const children =
await categoryRepository.findVisibleChildren(categoryId);
return children.sort(
(a, b) => a.sortOrder - b.sortOrder
);
}
현재 노드의 자식만 가져오면 다음에 선택할 수 있는 범위가 결정된다. 의류 아래를 탐색하는 동안 전혀 관련 없는 전자기기의 하위 구조는 조회하지 않아도 된다.
이것은 트리가 항상 검색을 빠르게 만든다는 뜻은 아니다. 카테고리 이름을 아무 조건 없이 전체 데이터에서 검색한다면 여전히 많은 노드를 확인할 수 있다.
const result = categories.filter(
(category) => category.name.includes(keyword)
);
트리의 탐색 이점을 얻으려면 검색을 시작할 범위가 정해져 있어야 한다.
async function searchWithinCategory(
rootCategoryId: string,
keyword: string
) {
const descendantIds =
await categoryRepository.findDescendantIds(
rootCategoryId
);
return categoryRepository.searchByName({
categoryIds: descendantIds,
keyword
});
}
여성 의류 안에서만 재킷을 검색한다면 다른 카테고리 영역을 제외할 수 있다. 트리는 데이터가 속한 범위를 표현하고, 알고리즘은 그 범위 밖의 후보를 탐색 대상에서 제거한다.
다만 실제 상품 검색에서는 카테고리 이름 검색, 상품명 검색, 가격 필터와 재고 상태가 함께 사용된다. 이때 카테고리 트리는 검색 범위를 결정하는 조건 중 하나이고, 데이터베이스 인덱스나 검색 엔진은 그 범위에서 실제 상품을 빠르게 찾는 책임을 가진다.
자료구조 하나가 검색의 모든 문제를 해결하는 것은 아니다. 트리는 무엇을 같은 영역으로 묶고 어디서 탐색을 시작할지를 제공한다.
운영자 화면에서 크리스가 새 카테고리를 만들며 부모 카테고리를 선택한다고 해보자.
async function createCategory(
input: CreateCategoryInput
) {
return categoryRepository.create({
storeId: input.storeId,
parentId: input.parentId,
name: input.name
});
}
이 코드는 요청에 들어온 storeId와 parentId를 그대로 신뢰한다. 공격자가 다른 쇼핑몰의 카테고리 ID를 보내면 자신이 관리하지 않는 트리 아래에 카테고리를 만들 수 있다. 존재하지 않거나 삭제된 카테고리를 부모로 지정할 수도 있다.
부모와 자식 관계도 외부 입력이므로 저장 전에 검증해야 한다.
async function createCategory(
actorId: string,
input: CreateCategoryInput
) {
const store = await storeRepository.findManagedStore(
actorId,
input.storeId
);
if (!store) {
throw new Error("Store not found");
}
const parent = input.parentId
? await categoryRepository.findById(input.parentId)
: null;
if (
parent &&
(
parent.storeId !== store.id ||
parent.isDeleted
)
) {
throw new Error("Invalid parent category");
}
const name = input.name.trim();
if (name.length === 0 || name.length > 100) {
throw new Error("Invalid category name");
}
return categoryRepository.create({
storeId: store.id,
parentId: parent?.id ?? null,
name,
isVisible: false
});
}
서버는 로그인한 운영자가 해당 쇼핑몰을 관리할 권한이 있는지 확인한다. 부모 카테고리가 같은 쇼핑몰에 속하는지, 삭제되지 않았는지도 검증한다. 새 카테고리는 검토 전까지 숨김 상태로 생성할 수 있다.
트리 구조는 단순한 UI 배치가 아니다. 부모를 잘못 연결하면 데이터의 소속 범위와 접근 권한까지 달라질 수 있다. 따라서 관계를 만드는 API가 트리의 규칙을 지킬 책임을 가져야 한다.
크리스가 의류 카테고리를 아우터 아래로 이동한다고 해보자. 아우터가 이미 의류의 하위 카테고리라면 다음과 같은 관계가 만들어진다.
의류
└─ 아우터
└─ 의류
└─ 아우터
└─ ...
트리에서는 한 노드에서 아래로 내려가 다시 같은 노드로 돌아올 수 없어야 한다. 이런 순환 관계가 생기면 하위 카테고리를 찾는 탐색이 끝나지 않거나 같은 데이터를 반복해서 처리할 수 있다.
단순히 부모 ID만 변경하면 이 문제를 막을 수 없다.
// Bad: 새로운 부모가 자신의 하위인지 확인하지 않는다.
async function moveCategory(
categoryId: string,
newParentId: string
) {
await categoryRepository.updateParent(
categoryId,
newParentId
);
}
이동하기 전에는 다음 조건을 확인해야 한다.
async function moveCategory(
actorId: string,
categoryId: string,
newParentId: string
) {
const category =
await categoryRepository.findManageableBy(
actorId,
categoryId
);
const newParent =
await categoryRepository.findById(newParentId);
if (!category || !newParent) {
throw new Error("Category not found");
}
if (category.id === newParent.id) {
throw new Error(
"A category cannot be its own parent"
);
}
if (category.storeId !== newParent.storeId) {
throw new Error(
"Categories must belong to the same store"
);
}
const descendantIds =
await categoryRepository.findDescendantIds(
category.id
);
if (descendantIds.includes(newParent.id)) {
throw new Error(
"A category cannot move under its descendant"
);
}
await categoryRepository.updateParent(
category.id,
newParent.id
);
}
이 코드는 새 부모가 현재 카테고리의 하위에 포함되는지 확인한다. 포함된다면 이동을 거부하여 순환 관계를 막는다.
다만 검증과 저장 사이에 다른 요청이 동시에 트리를 변경할 수 있다. 두 운영자가 같은 영역을 동시에 이동한다면 각각의 검증 시점에는 문제가 없었지만 두 변경이 합쳐진 뒤 순환이 만들어질 가능성을 고려해야 한다.
중요한 카테고리 이동은 트랜잭션 안에서 처리하고, 관련 노드를 잠그거나 버전 값을 확인하는 방식으로 동시 변경을 통제할 수 있다.
await database.transaction(async (tx) => {
const category = await tx.categories.findForUpdate(
categoryId
);
const newParent = await tx.categories.findForUpdate(
newParentId
);
await assertValidCategoryMove(
tx,
category,
newParent
);
await tx.categories.updateParent(
category.id,
newParent.id
);
});
트리는 연결 관계만 표현하지만 서비스는 그 연결이 항상 유효하도록 유지해야 한다. 순환 방지와 동시성 제어는 트리 객체가 자동으로 해결해 주는 기능이 아니라 변경 로직이 책임져야 하는 비즈니스 규칙이다.
인접 리스트 방식에서는 parent_id 하나를 변경하면 카테고리 이동 자체는 완료된다.
await categoryRepository.updateParent(
"outer-category",
"sale-category"
);
하지만 쇼핑몰이 탐색 속도를 높이기 위해 각 카테고리의 전체 경로를 함께 저장하고 있다면 이야기가 달라진다.
/의류/남성 의류/아우터
아우터를 시즌 할인 아래로 이동하면 자신의 경로뿐 아니라 모든 하위 카테고리의 경로도 바뀐다.
변경 전
/의류/남성 의류/아우터
/의류/남성 의류/아우터/재킷
변경 후
/시즌 할인/아우터
/시즌 할인/아우터/재킷
경로를 저장하면 특정 영역의 하위 데이터를 빠르게 찾을 수 있지만, 이동할 때 수정해야 할 데이터가 늘어난다.
type Category = {
id: string;
parentId: string | null;
path: string;
};
여기서 parentId와 path를 아무 규칙 없이 함께 수정하면 서로 다른 구조를 나타낼 수 있다. 어느 값을 원본으로 볼 것인지 정해야 한다.
예를 들어 parentId 관계를 Source of Truth로 정하고 path는 조회 성능을 위한 계산 데이터로 관리할 수 있다. 이동할 때 트랜잭션 안에서 하위 경로를 함께 갱신하거나, 변경 이벤트를 발행한 뒤 별도 작업으로 경로를 다시 계산할 수 있다.
별도 작업으로 갱신한다면 잠시 동안 오래된 경로가 노출될 수 있다는 점도 받아들여야 한다. 즉시 일관된 경로가 필요한 주문·권한 로직과 화면 탐색을 빠르게 만드는 캐시에는 서로 다른 일관성 기준이 필요할 수 있다.
트리 저장 방법을 선택할 때는 조회 속도만 봐서는 안 된다.
| 저장 방식 | 쉬운 작업 | 비용이 커지는 작업 |
|---|---|---|
| 부모 ID 저장 | 노드 추가, 부모 변경 | 전체 조상·하위 범위 조회 |
| 전체 경로 저장 | 조상 경로 확인, 하위 범위 검색 | 카테고리 이동과 경로 갱신 |
| 전체 트리 문서 저장 | 한 번에 전체 구조 읽기 | 일부 변경, 동시 수정, 대규모 트리 |
| 별도 검색 인덱스·캐시 | 반복 조회와 탐색 | 원본과 동기화, 장애 후 복구 |
읽기가 대부분이고 이동이 드문 쇼핑몰이라면 경로나 캐시를 추가하는 것이 유리할 수 있다. 구조 변경이 빈번하고 트리가 작다면 부모 ID만 저장하는 편이 단순할 수 있다.
자료구조는 코드 안의 모양만으로 선택하지 않는다. 어떤 조회가 자주 일어나고 어떤 변경이 자주 발생하는지에 따라 저장 위치와 계산 비용을 결정해야 한다.
카테고리 구조는 상품 재고나 가격보다 변경 빈도가 낮다. 따라서 전체 카테고리 트리를 캐시에 저장해 빠르게 응답할 수 있다.
async function getStoreCategoryTree(storeId: string) {
const cacheKey = `category-tree:${storeId}`;
const cached = await cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const rows =
await categoryRepository.findAllByStore(storeId);
const tree = buildStoreCategoryTree(rows);
await cache.set(cacheKey, JSON.stringify(tree), {
ttlSeconds: 300
});
return tree;
}
캐시가 있으면 반복되는 카테고리 조회에서 데이터베이스 부하를 줄일 수 있다. 하지만 캐시가 카테고리의 원본이 되어서는 안 된다.
운영자가 카테고리를 이동했는데 이전 캐시가 남아 있으면 사용자는 오래된 구조를 볼 수 있다. 카테고리 변경이 성공한 뒤 관련 캐시를 제거하거나 새 버전으로 교체해야 한다.
async function renameCategory(
categoryId: string,
name: string
) {
const category =
await categoryRepository.updateName(
categoryId,
name
);
await cache.delete(
`category-tree:${category.storeId}`
);
return category;
}
여기에서도 데이터베이스 변경은 성공했지만 캐시 삭제가 실패할 수 있다. 짧은 시간 동안 오래된 값이 허용된다면 만료 시간을 두고 다음 요청에서 복구할 수 있다. 즉시 반영되어야 한다면 변경 이벤트, 버전 키 또는 안정적인 캐시 무효화 전략이 필요하다.
const cacheKey =
`category-tree:${storeId}:v${categoryVersion}`;
카테고리 구조가 바뀔 때 categoryVersion을 증가시키면 새 요청은 새로운 키를 사용한다. 이전 캐시는 남아 있어도 더 이상 현재 버전으로 선택되지 않는다.
원본 데이터와 계산 데이터를 구분하면 장애가 발생했을 때 무엇을 기준으로 복구해야 하는지도 명확해진다.
Source of Truth
데이터베이스의 카테고리 행과 부모 관계
계산 데이터
중첩된 트리 응답, 전체 경로, 하위 카테고리 ID 목록
임시 데이터
메모리 트리, Redis 캐시, 검색 인덱스
트리를 빠르게 읽기 위해 여러 표현을 만들 수 있지만, 모든 표현을 동일한 원본으로 취급하면 서로 다른 구조가 생겼을 때 무엇이 맞는지 결정할 수 없다.
화면에서는 카테고리 아래에 상품이 보이므로 상품을 카테고리의 자식 노드로 넣고 싶을 수 있다.
const tree = {
name: "아우터",
children: [
{
name: "크리스 재킷",
children: []
}
]
};
하지만 하나의 상품이 아우터, 신상품, 시즌 할인에 동시에 노출될 수 있다. 트리에서는 일반적으로 하나의 노드가 부모 하나를 가지지만, 이 상품은 여러 카테고리와 연결된다.
같은 상품을 세 번 복제하면 가격이나 재고가 변경될 때 세 위치의 데이터를 모두 수정해야 한다. 각 복사본이 서로 다른 상태를 가지면 어떤 상품이 원본인지도 불분명해진다.
카테고리 계층과 상품 소속 관계를 분리하는 편이 안전하다.
CREATE TABLE product_categories (
product_id UUID NOT NULL REFERENCES products(id),
category_id UUID NOT NULL REFERENCES categories(id),
PRIMARY KEY (product_id, category_id)
);
카테고리는 트리 구조를 유지하고, 상품과 카테고리는 별도의 다대다 관계로 연결한다.
const product =
await productRepository.findById(productId);
const categoryIds =
await productCategoryRepository.findCategoryIds(
productId
);
상품의 가격, 재고와 판매 상태는 상품 데이터가 원본이다. 카테고리는 상품을 탐색하기 위한 분류 기준이지 상품 자체의 소유자가 아니다.
모든 관계가 계층처럼 보인다고 해서 트리로 만들어서는 안 된다. 하나의 대상이 여러 부모를 가져야 하거나 연결을 따라 다시 이전 대상으로 돌아갈 수 있다면 그래프가 더 적합할 수 있다.
트리를 선택한다는 것은 데이터가 다음 규칙을 가진다고 선언하는 것과 같다.
서비스의 실제 관계가 이 규칙과 맞지 않는다면 화면이 나무 모양이라는 이유만으로 트리를 선택해서는 안 된다.
의류 카테고리를 삭제했을 때 그 아래의 상의, 하의, 아우터를 어떻게 처리할지도 정해야 한다.
단순히 부모만 삭제하면 자식이 존재하지 않는 부모를 가리키는 고아 노드가 될 수 있다. 외래 키가 삭제를 막더라도 서비스가 어떤 동작을 원하는지는 별도로 결정해야 한다.
가능한 정책은 여러 가지다.
쇼핑몰에서는 실수로 대규모 카테고리를 삭제하는 위험이 있으므로 하위 카테고리가 존재하면 먼저 이동하거나 명시적으로 전체 삭제를 확인하게 할 수 있다.
async function deleteCategory(
actorId: string,
categoryId: string
) {
const category =
await categoryRepository.findManageableBy(
actorId,
categoryId
);
if (!category) {
throw new Error("Category not found");
}
const childCount =
await categoryRepository.countChildren(
category.id
);
if (childCount > 0) {
throw new Error(
"Move or delete child categories first"
);
}
const assignedProductCount =
await productCategoryRepository.countProducts(
category.id
);
if (assignedProductCount > 0) {
throw new Error(
"Remove assigned products first"
);
}
await categoryRepository.softDelete(category.id);
}
이 구현은 삭제의 영향을 확인하지 않은 채 트리 관계를 끊지 않는다. 상품 연결과 하위 카테고리를 먼저 정리하도록 요구한다.
다른 정책을 선택할 수도 있지만 중요한 것은 정책이 코드와 데이터베이스 제약 조건에 일관되게 반영되어야 한다는 점이다. 트리에서 부모 하나를 삭제하는 작업은 그 노드만의 상태 변경이 아니라 전체 하위 범위에 영향을 주는 작업일 수 있다.
parent_id처럼 반복 조회에 사용되는 필드에 적절한 인덱스가 있는가?트리의 기본 모양은 부모와 자식의 연결이다. 그러나 실제 쇼핑몰에서 중요한 것은 노드를 선으로 연결하는 방법이 아니다. 카테고리라는 계층을 통해 상품 탐색의 범위를 나누고, 사용자가 선택한 위치에서 필요한 영역만 조회할 수 있게 만드는 것이다.
작은 카테고리 목록은 전체 데이터를 가져와도 충분할 수 있다. 데이터가 커지면 현재 노드의 자식이나 특정 노드의 하위 범위만 조회해야 한다. 이를 위해 부모 ID에 인덱스를 만들고, 필요한 경우 전체 경로나 캐시 같은 계산 데이터를 추가할 수 있다.
대신 구조 변경의 책임도 함께 생긴다. 외부에서 받은 부모 ID를 검증하고, 자신의 하위로 이동해 순환이 생기지 않도록 막아야 한다. 동시 변경을 통제하고, 삭제가 하위 카테고리와 연결된 상품에 미치는 영향도 정의해야 한다. 데이터베이스의 부모 관계를 원본으로 사용할지, 저장된 경로나 중첩 문서를 원본으로 사용할지도 명확해야 한다.
모든 연결 관계가 트리는 아니다. 상품처럼 여러 카테고리에 속할 수 있는 데이터는 별도의 관계로 분리해야 한다. 서비스의 데이터가 하나의 부모와 순환 없는 계층이라는 규칙을 가질 때 트리는 강력한 선택이 된다.
결국 트리를 선택한다는 것은 데이터를 나무 모양으로 그리겠다는 뜻이 아니다. 현실의 복잡한 데이터에 소속 범위와 탐색 경계를 부여하고, 필요한 영역만 단계적으로 확인할 수 있도록 만들겠다는 설계 판단이다.
다음 글에서는 그래프가 단순히 점과 선을 저장하는 구조를 넘어, 사용자·장소·작업처럼 여러 방향으로 연결된 관계를 어떻게 모델링하는지 살펴본다.