Hierarchy ID
SQL Server 2008로 데이터 계층 모델링
Kent Tegels
이 기사는 SQL Server 2008 RC0 시험판 버전을 기준으로 합니다. 여기에 포함된 모든 정보는 변경될 수 있습니다.
이 기사에서는 다음 내용에 대해 설명합니다.
- 계층적 데이터 모델링
- 자재명세서 시스템 만들기
- HierarchyID로 이동
- 시스템 테스트
|
이 기사에서 사용하는 기술:
SQL Server 2008
|

목차
자동차 생산 라인, 국가를 구성하는 주와 군, 시, 그리고 우편 번호와 가정용 엔터테인먼트 시스템에 대한 설명과 같은 것들의 공통점은 무엇일까요? 간단히 말해 이들은 모두 계층으로 구성됩니다.
SQL Server 2008에서는 계층적 정보의 모델링 및 쿼리와 관련된 몇 가지 문제의 해결을 돕는 HierarchyID라는 새로운 데이터 형식을 지원합니다. 여기에서는 제조 분야에서 사용되는 BOM(자재명세서)이라고 하는 잘 알려진 패턴을 통해서 이 데이터 형식을 소개하겠습니다. BOM에 대한 간단한 소개부터 시작하여 이러한 유형의 데이터를 모델링하는 방법을 설명하고 SQL Server 2005에서 이 모델을 구현하는 방법을 살펴본 다음 SQL Server 2008에서 HierarchyID 데이터 형식을 사용하여 모델을 구현하는 방법을 확인할 것입니다.
계층적 데이터
자동차 산업은 엔진, 구동부, 전자 부품 및 스티어링과 같은 여러 부품이 어우러진 집합체입니다. 미국의 행정 구역은 주와 군이라고 하는 관할권으로 구분됩니다. 군은 다양한 조직에 의해 다양한 방향으로 다시 구분됩니다. 미국 인구 조사국을 예로 들면 표준 지역이라고 하는 단위를 사용하며, 미국 우편국에서는 ZIP(Zone Improvement Plan) 코드에 따라 우편물을 배달합니다. GIS(지역 정보 시스템)는 표준 지역과 ZIP 코드를 취합하여 사용자에게 익숙한 지역 정보를 제공합니다.
필자는 최근에 가정용 엔터테인먼트 시스템의 대체 부품을 구하기 위해 주변의 전자 상점에 방문했으며, 여기에서 머리가 아플 만큼 다양한 부품과 옵션의 조합에서 일종의 계층 시스템을 직접 경험했습니다. 이러한 경험은 데이터베이스 시스템에서 이러한 시스템을 모델링하고 구현하는 방법에 대해 생각하는 계기가 되었습니다.
자동차와 엔진 간의 관계는 계층을 나타냅니다. 즉, 자동차는 엔진을 포함합니다. 구동부, 전자 부품 및 스티어링의 관계도 마찬가지이며 포함 관계입니다. 지역 또는 인구 데이터의 여러 그룹화 간의 관계에서도 비슷한 계층을 관찰할 수 있습니다.
계층은 어디에나 존재하지만 이를 관계형 데이터베이스에서 구현하기는 쉽지 않은 경우가 많았습니다. 일반적인 방법은 하나 이상의 테이블로 부모/자식 관계를 사용하여 계층을 나타내는 것입니다. 이 방법은 다양한 경우에 적용이 가능하지만 몇 가지 단점이 있습니다. 먼저, 이러한 솔루션에서는 참조 무결성을 유지하는 방법을 고려해야 합니다. SQL Server 2005에서는 재귀 공용 테이블 식이 도입되어 이러한 테이블의 깊이와 너비를 쿼리하는 작업이 한결 간소화되었지만 이러한 유형의 테이블을 대상으로 쿼리를 작성하는 작업에는 필요한 여러 테이블을 조인하는 데 대한 문제가 여전히 남아 있습니다.
자재명세서 문제
몇 년 전에 필자는 한 제조 회사에서 딜러가 회전식 관수 시스템을 제작하는 데 필요한 부품을 지정할 수 있는 시스템의 개발에 참여했었습니다. 소프트웨어는 원하는 피벗(이 업계에서는 전체 회전식 관수 시스템을 간단히 피벗이라고 부름)을 주문형으로 제작하는 데 필요한 부품 목록을 나열합니다. 필요한 부품은 해당 지역, 토양 유형, 그리고 적용 지역에서 재배할 작물은 물론 장치 자체의 수문학적 및 구조적 고려 사항에 의해 결정됩니다.
솔루션의 기반으로는 SQL Server 데이터베이스가 사용되었습니다. 데이터베이스의 용도는 피벗을 제작하는 데 사용할 수 있는 부품에 대한 정보를 저장하는 것입니다. 그러나 제조를 위한 사양을 생성할 때 이러한 부품을 BOM으로 지정할 필요가 있었습니다.
일부 자재명세서는 조립하여 시스템 부품을 만들기 위한 부속의 모음을 나타냅니다. 예를 들어 모든 피벗에는 우물에서 시스템으로 용수를 끌어올리는 펌프가 필요합니다. 펌프에는 전원을 공급해야 하므로 변압기와 퓨즈 상자도 필요합니다. 펌프가 연료 엔진 방식인 경우에는 연료 탱크와 연료 펌프, 그리고 펌프와 탱크를 연결하는 호스가 필요합니다. 두 경우에 모두 펌프에 필요한 부속이 펌프 자재명세서에 나열됩니다.
전체 피벗을 위한 자재명세서에는 다른 자재명세서의 모음이 포함될 수 있습니다. 예를 들어 표준화된 피벗의 경우 펌프용 자재명세서 트리, 용수를 전달하기 위한 파이프용 자재명세서 트리, 그리고 피벗 시스템을 조립하는 데 필요한 다른 장비용 자재명세서를 포함하여 새 가지 자재명세서로 구성될 수 있습니다.
문제를 이해하기 위해 엔터티 사용
첫 번째로 해야 할 질문은 계층 시스템을 나타내기 위해 필요한 엔터티는 무엇이며 이러한 엔터티에 어떤 특성이 필요한지에 대한 것입니다. 까다로운 것은 모델링하려는 기본 시스템에 있는 개별 요소의 구성도에 그려지지 않습니다.
예를 들어 그림 1에 나와 있는 것과 같은 PC 기반 홈 시어터 시스템의 예를 생각해 보겠습니다. 그림에 나와 있는 각 항목이 엔터티라고 생각하면 이해하기 쉽습니다. 그러나 이렇게 하면 회사에서 판매할 수 있는 모든 홈 시어터 시스템을 나타내기가 어렵습니다. 예를 들어 일부 시스템에서 PC대신 DVR(디지털 비디오 레코더)을 사용할 수 있으며 라디오 튜너를 원하지 않는 소비자도 있을 수 있습니다.
그림 1 홈 시어터 시스템 내의 구성 요소 (더 크게 보려면 이미지를 클릭하십시오.)
계층적인 데이터 모델링 문제 해결을 위한 패턴으로 BOM 시스템이 적절한 것은 바로 이러한 변형 때문입니다. BOM을 사용하면 여러 하위 자재명세서와 이차 하위 자재명세서를 포함하여 트리 데이터 구조에서 최종적인 개별 부속까지 포함할 수 있는 완전한 시스템을 구성할 수 있습니다. 트리의 루트는 단일 자재명세서로 나타낼 수 있는 완전한 시스템이 됩니다. 분기의 첫 번째 수준은 일반적으로 역시 자재명세서로 구성됩니다. 하위 자재명세서 목록은 계층에서 실제 시스템 부품 목록만 포함하는 자재명세서에 이를 때까지 연결되며 이러한 부품은 트리의 리프 노드가 됩니다.
그러면 이 시스템에서 엔터티의 역할은 무엇일까요? 부품만을 포함하는 자재명세서(부품 목록)가 있으며 다른 자재명세서를 표함하는 자재명세서가 있습니다.
부품에는 설명과 가격이 있습니다. 물론 이 밖에도 다양한 특성을 가질 수 있지만 이 예에서는 간단하게 유지하겠습니다. 부품 목록의 특성은 부품 및 필요한 부품의 수량을 포함할 수 있습니다. 특정 부품의 수량과 부품 정보 자체를 분리하는 것이 중요합니다. 그렇게 하지 않으면 단지 수량만 다른 동일한 부품의 여러 인스턴스가 중복될 수 있습니다. 정규화 규칙은 대부분 이러한 경우를 적절하지 않은 디자인으로 지목합니다.
자재명세서는 해당 부모 자재명세서와 연결하는 방법인 설명 및 연결된 부품 목록을 가집니다. 그림 2는 엔터티의 집합과 해당 관계를 나타냅니다.
그림 2 엔터티의 관계 (더 크게 보려면 이미지를 클릭하십시오.)
관계형 데이터베이스는 대부분의 관계를 나타내는 데 유용하지만 다대다 엔터티 모델에 사용하기에는 적합하지 않습니다. 실제 테이블이 데이터를 처리하지 못하기 때문이 아니라 테이블 간의 적절한 참조 무결성 제약 조건을 만들기가 어렵기 때문입니다. 이러한 관계는 또한 쿼리가 복잡해지는 원인이 됩니다. 이 문제를 해결하기는 쉽지만 먼저 테이블의 논리적 디자인을 수정해야 합니다. 즉, 부품 목록 테이블과 자재명세서 테이블 사이에 테이블을 추가해야 합니다. 이 테이블은 단순히 자재명세서에 대한 참조와 부품 목록에 대한 참조를 포함하게 됩니다. 그림 3에는 이 추가 테이블을 포함하는 엔터티 관계 다이어그램이 나와 있습니다.
그림 3 단순화된 자재명세서를 위한 엔터티 관계 다이어그램 (더 크게 보려면 이미지를 클릭하십시오.)
SQL Server 2005에서의 BOM 시스템
지금까지 BOM 시스템의 기본 이론을 살펴보았으므로 다음은 이를 구현해 볼 차례입니다. 과정은 간단합니다.
- 데이터베이스를 만듭니다.
- 엔터티 관계 다이어그램에 나온 테이블을 만듭니다.
- 테이블에 필요한 제약 조건을 추가합니다.
- 테이블에 대한 액세스 권한을 필요에 맞게 구성합니다.
- 테이블을 테스트 데이터로 채웁니다.
- 쿼리를 작성하고 테이블에 대해 실행하여 디자인의 유효성을 검사합니다.
다음은 샘플 데이터베이스를 만들기 위한 T-SQL 코드입니다. 이 코드를 여러분의 시스템에서 사용하려면 filename 경로를 수정해야 합니다.
create database [dbBom] on primary (
name = n'dbBom', filename = n'c:\msdnmag\dbBom.mdf',
size = 50mb , maxsize = unlimited, filegrowth = 2mb )
log on (
name = n'dbBom_log', filename = n'c:\msdnmag\dbBom_log.ldf',
size = 10mb , maxsize = 2048gb , filegrowth = 10%);
이 문을 성공적으로 실행한 다음에는 USE dbBOM 문을 사용하는 등의 방법으로 데이터베이스 범위를 dbBOM으로 설정해야 합니다.
테이블 만들기
다음은 필요한 네 개의 테이블을 만드는 실행 가능 T-SQL 코드입니다.
create table dbo.part(
partID smallint not null,
descr varchar(50) not null,
cost money not null);
create table dbo.partList(
partListID int not null,
partID smallint not null,
quantity smallint not null);
create table dbo.billPartList(
billID int not null,
partListID int not null);
create table dbo.bill(
billID int not null,
parentBillID int null,
descr varchar(50) not null);
이러한 테이블에서 null 값을 가질 수 있는 유일한 열은 dbo.bill의 parentBillID입니다. 이러한 null 값을 통해 완전한 시스템을 나타내는 자재명세서와 다른 자재명세서를 구분할 수 있습니다.
이 구현에서 참조가 올바르게 수행되도록 하기 위한 핵심은 테이블의 제약 조건을 올바르게 적용하는 것입니다. 테이블은 솔루션에서 다양한 기능을 가지므로 다양한 제약 조건을 가지게 됩니다. 그러나 먼저 SQL Server의 모든 테이블에는 다음과 같이 기본 키를 연결해야 합니다.
alter table dbo.part
add constraint pkPart
primary key clustered(partID);
alter table dbo.bill
add constraint pkBill
primary key clustered(billID);
alter table dbo.partList
add constraint pkPartList
primary key clustered(partListID);
alter table dbo.billPartList
add constraint pkBillPartList
primary key clustered(billID,partListID);
이러한 열 중 일부는 항상 부품이나 자재명세서의 설명(descr)과 같은 고유한 값을 가져야 합니다. partID와 필요한 부품의 수량의 조합을 요구함으로써 사용자가 부품 목록 항목을 지정한 양만큼 중복하는 것을 방지할 수도 있습니다.
alter table dbo.part
add constraint uqDescr
unique(descr);
alter table dbo.bill
add constraint uqBill
unique(descr);
alter table dbo.partList
add constraint uqPartList
unique(partID,quantity);
마지막으로 테이블 간의 외래 키 제약 조건을 추가합니다. 첫 번째 제약 조건은 dbo.part 및 dbo.partList 간에 적용됩니다. 이 제약 조건은 부품 목록에 부품을 추가하려면 먼저 dbo.part 테이블에 부품을 정의해야 하도록 요구합니다. dbo.part에서 부품을 삭제하면 이 변경 내용이 부품 목록에 반영되도록 해야 합니다. 제약 조건에 "on delete cascade" 절을 추가하면 간단하게 이를 해결할 수 있습니다. partID는 식별자로 표시되므로 변경될 수 없기 때문에 연쇄 작업이 수행되면 안 됩니다.
alter table dbo.partList
add constraint fkPartList_Part
foreign key(partID)
references dbo.part(partID)
on delete cascade
on update no action;
다음 제약 조건은 parentBillID에 자재명세서가 할당되면 billID가 참조하는 자재명세서가 dbo.bill 테이블에 이미 있어야 함을 지정합니다. 하지만 연쇄 제약 조건은 다릅니다. SQL Server로서는 부모 자재명세서를 삭제하거나 업데이트할 때 수행될 수 있는 무한의 재귀 작업을 보장할 수 없기 때문에 연쇄 제약 조건을 사용하는 것은 금지되어 있습니다. 기본값이 정의되지 않았기 때문에 "set default"를 사용하는 것도 불가능합니다.
alter table dbo.bill
add constraint fkBill_Bill
foreign key(parentBillID)
references dbo.bill(BillID)
on delete no action
on update no action;
billPartList 테이블에는 외래 키 제약 조건 두 개가 필요합니다. 첫째, 자재명세서와 연결되는 부품 목록이 PartList 테이블에 존재해야 합니다. 또한 참조되는 자재명세서가 dbo.bill 테이블 내에 있어야 합니다. dbo.bill의 billID와 dbo.partList의 partListID는 업데이트될 수 없으므로 제약 조건에 대한 연쇄 작업은 불가능합니다. 그러나 부품이나 자재명세서가 삭제되면 이러한 변경 내용이 이 테이블에 영향을 줄 수 있습니다.
alter table dbo.billPartList
add constraint fkBillPartList_PartList
foreign key(partListID)
references dbo.partList(partListID)
on delete cascade
on update no action;
alter table dbo.billPartList
add constraint fkBillPartList_Bill
foreign key(billID)
references dbo.bill(billID)
on delete cascade
on update no action;
이제 테이블에 테스트를 위한 약간의 데이터를 삽입할 차례입니다. 필요한 데이터의 양을 감안하여 여기에서 코드를 살펴보지는 않을 것입니다. 이 기사의 샘플 코드를 다운로드하고 01_data.sql이라는 스크립트 파일을 열고 실행하면 이 샘플 데이터가 생성됩니다.
디자인 유효성 검사를 위한 쿼리
디자인이 예상한 대로 작동하는지 테스트하는 데는 간단한 쿼리를 작성하고 실행하는 방법이 도움이 됩니다. 첫 번째 쿼리에서는 새로운 공용 테이블 식 구문을 사용하여 자재명세서의 계층적 목록을 만듭니다.
with c as (
select billID,parentBillID,descr,0 as [level]
from dbo.bill b
where b.parentBillID is null
union all
select b.billID,b.parentBillID,
b.descr,[level] + 1
from dbo.bill b join c on b.parentBillID =
c.billID)
select descr,[level],billID,parentBillID
as bill
from c
그림 4에 나와 있는 반환된 데이터를 보면 하나의 루트 노드(parentBillID가 null임)가 있는 것을 알 수 있습니다. 이러한 자재명세서가 두 개 이상 있다면 자재명세서 ID를 사용하여 부모를 선택하도록 쿼리를 변경해야 합니다. 공용 테이블 식에 대한 자세한 내용은
msdn.microsoft.com/magazine/cc163346에서 2007년 10월 Data Points 칼럼을 참조하십시오.
그림 4 자재명세서의 계층 쿼리 (더 크게 보려면 이미지를 클릭하십시오.)
다음 쿼리는 먼저 첫 번째 쿼리에서 반환한 시스템 자재명세서에 사용된 각 부품을 나열합니다. 이 쿼리는 테이블 간의 모든 관계를 적용합니다.
with c as (
select billID,parentBillID,descr,0 as [level]
from dbo.bill b
where b.parentBillID is null
union all
select b.billID,b.parentBillID,b.descr,[level] + 1
from dbo.bill b join c on b.parentBillID = c.billID)
select p.partID,p.descr
from c
join dbo.billPartList bpl on c.billID = bpl.billID
left join dbo.partList pl on bpl.partListID = pl.partListID
join dbo.part p on pl.partID = p.partID
group by p.partID,p.descr;
다음 쿼리는 필요한 시스템 부품 가격의 합계를 구하고 2를 곱하여 제조업체의 MSRP(희망 소비자 가격)를 계산합니다.
with c as (
select billID,parentBillID,descr,0 as lvl
from dbo.bill b
where b.parentBillID is null
union all
select b.billID,b.parentBillID,b.descr,lvl + 1
from dbo.bill b join c on b.parentBillID = c.billID)
select SUM(p.cost*pl.quantity) * 2.0
from c
join dbo.billPartList bpl on c.billID = bpl.billID
left join dbo.partList pl on bpl.partListID= pl.partListID
join dbo.part p on pl.partID = p.partID
다음 쿼리는 이전 쿼리의 작업을 반대로 수행합니다. 최상위 부모 자재명세서에서 하향식으로 데이터를 검색하는 것이 아니라 부품에서 시작하여 이를 포함하는 자재명세서를 쿼리하고 이 방식으로 계층에서 부품을 사용하는 모든 자재명세서를 찾습니다.
with c as (
select b.descr,b.billID,b.parentBillID,0 as lvl
from dbo.partList pl
left join dbo.billPartList bpl on pl.partListID = bpl.partListID
left join dbo.bill b on bpl.billID = b.billID
where pl.partID = 19
union all
select b.descr,b.billID,b.parentBillID,lvl+1
from dbo.bill b
join c on c.parentBillID = b.billID)
select * from c;
행 방향의 목록으로 자재명세서가 상호 연관되어 있는 방법을 시각화하기는 어려울 수 있습니다. 이러한 경우에는 다음과 같이 자재명세서를 경로로 구성할 수 있습니다. 이렇게 하면 중첩된 자재명세서를 수월하게 시각화할 수 있습니다.
with c as (
select '/'+cast(billID as varchar(49)) as path,BillID
from dbo.bill b
where b.parentBillID is null
union all
select cast(c.path+'/'+CAST(b.billID as varchar(4)) as varchar(50)), b.billID
from dbo.bill b join c on b.parentBillID = c.billID)
select c.path+'/',b.descr
from c join dbo.bill b on c.billID = b.billID
order by 1;
중첩된 결과가 그림 5에 나와 있습니다.
그림 5 중첩된 BOM 목록 (더 크게 보려면 이미지를 클릭하십시오.)
나머지 쿼리는 시스템에 새로운 자재명세서를 추가하는 방법을 보여 줍니다. 이 예에서는 "여름 보너스 행사"의 일환으로 회사에서 4GB 메모리 스틱을 추가로 제공한다고 가정합니다. 그림 6의 코드는 새로운 부품과 이를 나타내는 자재명세서를 계층에 추가합니다.

그림 6 새로운 부품과 자재명세서 추가
begin transaction
declare @mp int;
declare @mpl int;
declare @mb int;
select @mp = MAX(partID)+10 from dbo.part;
insert into dbo.part(partID,descr,cost)
output inserted.*
values (@mp,'4GB USB 2.0 Memory Stick',20.0);
select @mpl = MAX(partListID)+10 from dbo.partList;
insert into dbo.partList(partListID,partID,quantity)
output inserted.*
values (@mpl,@mp,1);
select @mb = MAX(billID)+10 from dbo.bill;
insert into dbo.bill(billID,parentBillID,descr)
output inserted.*
values (@mb,110,'Summer Bonus Package')
insert into dbo.billPartList(billID,partListID)
output inserted.*
values (@mb,@mpl);
commit;
go
select * from dbo.bill where parentBillID = 110;
select * from dbo.part;
go
다음은 "여름 보너스 행사" 자재명세서의 위치를 전체 시스템 자재명세서 바로 아래가 아니라 디스크 드라이브 모듈 아래로 변경해야 한다고 가정해 보겠습니다. 관계형 데이터베이스에서 간단하게 이러한 변경을 수행할 수 있습니다.
update dbo.bill set parentBillID = 320 where billID = 507
go
select * from dbo.bill where parentBillID = 320;
go
여름 행사 기간이 끝나면 이 자재명세서를 삭제하는 작업도 간단합니다.
begin transaction
delete from dbo.billPartList where billID=507;
delete from dbo.bill where billID=507;
delete from dbo.part where partID=45;
commit;
go
select * from dbo.billPartList;
select * from dbo.bill;
HierarchyID 활용
HierarchyID 데이터 형식은 정렬된 경로의 축소된 이진 표현을 저장하기 위해 디자인된 CLR 기반 이진 표현입니다. 기본 제공 데이터 형식이므로 이를 사용하기 위해 명시적으로 SQL/CLR 기능을 활성화할 필요가 없습니다. HierarchyID는 값 간의 중첩된 관계를 나타내야 하며 이 관계를 정렬된 경로 구문으로 나타낼 수 있는 경우에 유용합니다.
정렬된 경로는 파일 경로와 비슷하게 보이지만 디렉터리와 파일 이름 대신 숫자 값이 사용됩니다. 다른 모든 부모/자식 관계와 마찬가지로 모든 정렬된 경로는 루트 노드로 시작해야 합니다. SQL Server 2008에서는 텍스트 방식으로 루트 노드를 나타내는 데 단일 문자(/)가 사용됩니다. 정렬된 경로의 요소는 일반적으로 정수로 나타내지만 소수점 값도 사용할 수 있습니다. 정렬된 경로는 다른 단일 문자(/)로 종료되어야 합니다.
정렬된 경로는 데이터베이스에 텍스트 형식으로 저장되지는 않으며 수학적으로 해시를 거쳐 이진 형식으로 변환되면 이러한 해시 값이 데이터 페이지에 저장됩니다.
그렇다면 HierarchyID를 사용하기 위해 기존 데이터베이스를 수정하는 이유는 무엇일까요? 그림 7에는 자재명세서 예에 사용되는 몇 가지 정렬된 경로가 나와 있습니다. 필자의 샘플 데이터에서 부모/자식 관계는 bill 테이블에 있으므로 그곳이 시작할 논리적 위치입니다. 필자는 테이블을 추가하는 대신 bill2라는 새로운 테이블을 추가했습니다.
그림 7 샘플 자재명세서에 사용되는 경로 (더 크게 보려면 이미지를 클릭하십시오.)
여기에서는 HierarchyID 형식의 billPath라는 열을 추가하고 이 열에 자재명세서 간의 부모/자식 관계를 정렬된 경로 형식을 사용하여 저장할 것입니다. 이때 부모 행으로 이동하는 데 사용되는 열을 제거할 수 있지만 이 기능을 유지하면 여러 다른 쿼리에 유용하게 사용할 수 있습니다. 부모 ID를 정적 데이터 필드에 저장하기보다는 계층 ID를 기반으로 지속적이고 계산되는 열을 사용하여 이러한 유형의 이동을 수행하는 방법을 살펴보겠습니다. 다음은 업데이트된 테이블 생성 코드입니다.
create table dbo.bill2(
billPath HierarchyID not null,
billID smallint not null,
parentBillPath as billPath.GetAncestor(1) persisted,
descr varchar(50) not null);
Microsoft .NET Framework에서 사용되는 데이터 형식을 사용해본 적이 없다면 parentBillPath 정의에 있는 메서드 호출 방식이 이상하게 보일 것입니다. 형식으로 정의한 메서드를 하나 이상의 선택적 매개 변수를 전달하고 호출하는 기능으로 코드를 크게 간소화할 수 있습니다.
여기에 HierarchyID 형식의 GetAncestor 메서드가 있습니다. HierarchyID는 축소된 이진 형식으로 경로를 저장합니다. 그러나 해당 이진 값의 일부 세그먼트를 참조하여 이를 billID와 같이 취급하거나 자재명세서의 정렬된 경로에서 일부 하위 세그먼트를 직접 가져오는 것은 불가능합니다. 이를 위해 GetAncestor 메서드를 활용할 수 있습니다. 여기에서 매개 변수 값은 "이 노드의 부모로 끝나는 정렬된 경로를 반환하라"라는 의미입니다. 매개 변수 값은 정렬된 경로에서 잘라내기가 수행되는 위치를 제어합니다. 따라서 현재 자재명세서의 부모에 대한 정렬된 경로를 얻게 됩니다. 그림 8에는 HierarchyID 데이터 형식에 사용할 수 있는 메서드와 각 메서드의 일반적인 사용 예가 나와 있습니다.

그림 8 HierarchyID 메서드
| 메서드 |
반환 |
매개 변수 값 |
용도 |
| GetAncestor |
부모 또는 상위 수준 요소의 정렬된 경로를 나타내는 HierarchyID |
현재 정렬된 경로에서 상위로 이동한 수준의 수를 나타내는 정수 |
이 인스턴스의 정렬된 경로에서 부모, 조부모 또는 상위 요소를 찾습니다. |
| GetDescendent |
현재 노드의 자식, 손자 또는 하위 자식의 경로를 나타내는 HierarchyID |
반환되는 자식을 제한하며 둘 중 하나 또는 둘이 모두 null일 수 있는 HierarchyID 인스턴스 두 개 |
현재 정렬된 경로 내의 원하는 깊이에 새로운 요소를 삽입하기 위한 경로를 얻습니다. |
| GetLevel |
정렬된 경로의 전체 깊이를 나타내는 16비트 정수 값 |
없음 |
정렬된 경로 두 개의 깊이가 같은지 확인합니다. |
| GetRoot |
0 요소를 가진 정렬된 경로의 HierarchyID |
없음 |
정렬된 경로의 절대 루트를 찾습니다. |
| IsDescendantOf |
매개 변수로 전달된 정렬된 경로가 호출 인스턴스의 자식일 경우 1 |
HierarchyID 인스턴스 |
지정한 HierarchyID가 다른 인스턴스의 자식인지 확인합니다. |
| Parse |
HierarchyID 인스턴스 |
정렬된 경로의 텍스트 표현 |
지정한 경로로 HierarchyID 인스턴스를 만듭니다. HierarchyID 인스턴스가 문자열로 설정될 때마다 암시적으로 호출됩니다. |
| GetReparentedValue |
현재 항목을 한 경로에서 다른 경로로 이동할 때 완전한 정렬된 경로를 나타내는 HierarchyID |
HierarchyID인 정렬된 현재 경로 및 역시 HierarchyID인 정렬된 대상 경로 |
정렬된 부모 경로에서 다른 정렬된 경로로 행을 한 개 이상 이동합니다. |
| ToString |
HierarchyID의 정렬된 경로의 텍스트 표현 |
없음 |
HierarchyID의 정렬된 경로를 분석합니다. |
다음은 새 테이블의 기본 키와 해당 외래 키 제약 조건을 만들어야 합니다.
alter table dbo.bill2
add constraint pkBill2
primary key(billPath);
alter table dbo.bill2
add constraint fkBill2Parent
foreign key (parentBillPath) references dbo.bill2(billPath);
여기에서 필자는 기본 키와 외래 키에 모두 HierarchyID 값을 사용했습니다. HierarchyID는 깊이 우선 정렬된 경로를 나타냅니다. 따라서 동료 간의 값이 가장 다르며 부모와 자식 경로 간의 값은 이보다는 비슷합니다. 테이블의 클러스터형 인덱스에 HierarchyID 값을 사용하면 같은 경로 상의 노드는 다른 경로 상의 노드보다 가까운 위치에 저장됩니다. 지정한 경로에서 해당하는 모든 자식을 쿼리하기 때문에 일반적으로 이것은 좋은 디자인입니다. 그러나 특정 노드를 쿼리하는 빈도가 더 높은 경우에는 GetLevel에서 파생된 열에 클러스터형 인덱스를 만드는 것이 나을 수 있습니다.
이러한 구현은 미묘한 문제를 일으킵니다. 외래 키 제약 조건이 값이 아닌 경로에 기반을 두기 때문에 테이블에 앵커로 사용할 기존 값이 필요합니다. 모든 자재명세서의 체인에 앵커가 필요하지만 다행스럽게도 모두 같은 앵커를 사용할 수 있습니다. HierarchyID GetRoot 메서드는 이 문제를 해결할 수 있는 완벽한 방법입니다. 다음 문을 살펴보겠습니다.
insert into dbo.bill2(billPath,billID,descr)
values (hierarchyID::GetRoot(),0,'All Bills');
여기에서는 형식의 정적 멤버로서 GetRoot 메서드를 호출하여 임의의 계층에서 가능한 가장 높은 루트를 나타내는 자재명세서를 만듭니다. 이제 첫 번째 수준 노드를 삽입하더라도 여전히 현재 테이블 내의 부모를 참조하게 됩니다.
그렇다면 dbo.bill에서 dbo.bill2로 기존 자재명세서를 마이그레이션하는 방법은 무엇일까요? 여러분이 해야 할 일은 이전에 살펴본 계층 경로를 선택하는 쿼리를 수정하여 경로를 HierarchyID로 캐스트한 다음 dbo.bill2로 데이터를 삽입하는 것입니다. 다음은 이를 위한 T-SQL 코드입니다.
with c(billID,[path],[descr]) as (
select b.billID,'/'+CAST(b.billID as varchar(max)),descr
from dbo.bill b
where parentBillID is null
union all
select b.billID,c.path+'/'+CAST(b.billID as varchar(max)),b.descr
from dbo.bill b
join c on b.parentBillID = c.billID)
insert into dbo.bill2(billpath,billID,descr)
select path+'/' as [path],billID,descr
from c
order by c.path;
그림 9에는 dbo.Bill2의 내용이 나와 있습니다.
그림 9 dbo.bill2의 처음에 있는 15개의 행 (더 크게 보려면 이미지를 클릭하십시오.)
이제 다음과 같이 dbo.BillPartList의 외래 키 관계도 업데이트해야 합니다.
alter table dbo.billPartList
drop constraint fkBillPartList_Bill
go
alter table dbo.billPartList
add constraint fkBillPartList_Bill2
foreign key(billPath)
references dbo.bill2(billPath)
on delete cascade
on update no action;
HierarchyID 구현 테스트
테이블을 생성 및 채우고 제약 조건을 설정했으므로 다음은 새로운 HierarchyID 형식을 사용하도록 이전의 쿼리를 다시 작성할 수 있습니다. 첫 번째 쿼리는 재귀 공용 테이블 식을 사용하여 하향식(자재명세서 110)으로 자재명세서의 계층적 목록을 만듭니다. HierarchyID 데이터 형식을 사용하면 임의의 지정한 자재명세서의 전체 순열이 이 값에 이미 담겨져 있습니다. 예를 들어 계층의 아래쪽에 있는 자재명세서 중 하나로 CPU kit을 나타내는 자재명세서 405가 있습니다. 이 순열을 구성하는 자재명세서는 확인하는 방법은 무엇일까요? 다음 코드로 이를 알아낼 수 있습니다.
select billPath.ToString() from dbo.bill2 where billID=405;
이 쿼리의 결과는 "/110/210/310/405" 값이 포함된 문자열입니다. 남은 문제는 어떻게 하면 이 문자열이 나타내는 자재명세서를 얻을 수 있는가 하는 것입니다. HierarchyID 형식은 값의 목록을 벡터로 변환하는 메서드를 제공하지 않기 때문에 이 작업은 좀 더 까다롭습니다. 그러나 T-SQL이나 SQL/CLR을 사용하여 직접 메서드를 작성하는 것은 가능합니다. 다음 T-SQL 테이블 반환 함수를 사용하여 이 작업을 수행하는 코드입니다.
create function dbo.Vectorize(@i hierarchyID)
returns @t table (position int identity(1,1)
not null,nodeValue int not null)
as begin
declare @list varchar(max) = @i.ToString();
declare @delimit int;
set @list = substring(@list,2,len(@list)-1);
while len(@list) > 1 begin
set @delimit = charindex('/',@list);
insert into @t values (cast(substring(@list,1,@delimit-1) as int));
set @list = substring(@list,@delimit+1,len(@list));
end;
return;
end;
이제 단일, 비재귀 쿼리로 시스템에서 임의의 자재명세서 순열을 조사하는 간단한 방법을 마련했습니다.
declare @anyBill int = 405;
select b1.billPath,b2.billID,b2.descr from dbo.bill2 b1
cross apply dbo.vectorize(b1.billPath) p
join dbo.bill2 b2 on b2.billID = p.nodeValue
where b1.billID = @anyBill;
그렇다면 지정한 자재명세서에서 사용되는 부품을 발견하는 일은 어떨까요? 자재명세서 목록을 재귀적으로 쿼리해야 했던 이전의 구현과는 달리 이 쿼리는 IsDescendantOf 메서드를 통해 정렬된 경로가 다른 경로의 하위 항목인지 확인하는 HierarchyID의 기능을 활용할 수 있습니다.
declare @anyBill int = 110;
declare @sourceBillPath hierarchyID;
select @sourceBillPath = billPath from dbo.bill2 where billID = @anyBill;
select p.partID as 'partID',
p.descr 'partName'
from dbo.bill2 b2
left join dbo.billPartList bpl on b2.billID = bpl.billID
left join dbo.partList pl on bpl.partListID = pl.partListID
left join dbo.part p on pl.partID =p.partID
where b2.billPath.IsDescendantOf(@sourceBillPath)=1
and p.partID is not null
order by p.partid;
이전에 살펴본 기술을 약간만 수정하면 전체 시스템이나 임의의 하위 자재명세서의 MSRP를 계산할 수 있습니다.
declare @anyBill int = 110;
declare @sourceBillPath hierarchyID;
select @sourceBillPath = billPath
from dbo.bill2 where billID = @anyBill;
select SUM(p.cost * pl.quantity)*2.0
from dbo.bill2 b2
left join dbo.billPartList bpl on b2.billID = bpl.billID
left join dbo.partList pl on bpl.partListID = pl.partListID
left join dbo.part p on pl.partID =p.partID
where b2.billPath.IsDescendantOf(@sourceBillPath)=1;
어떤 자재명세서가 특정 부품을 사용하는지는 GetAncestor 메서드를 사용하여 쿼리할 수 있습니다. 이 메서드를 사용하여 계층을 쿼리할 수 있습니다. 여기에서는 자재명세서 목록을 상향으로 검색하기 위해 재귀를 사용해야 합니다.
declare @partID int = 20;
with c(billPath,descr) as (
select b.billPath,b.descr
from dbo.part p
join dbo.partList pl on p.partID = pl.partID
join dbo.billPartList bpl on pl.partListID = bpl.partListID
join dbo.bill2 b on bpl.billID = b.billID
where p.partID = @partID
union all
select b.billPath,b.descr
from dbo.bill2 b
join c on b.billPath = c.billPath.GetAncestor(1))
select distinct descr,billPath
from c
where billPath <> hierarchyID::GetRoot()
order by billPath;
그림 6에서는 새로운 부품과 자재명세서를 추가합니다. 부품을 삽입하거나 새로움 부품 목록을 만들기 위해 코드를 변경할 필요는 없습니다. 그러나 자재명세서를 만드는 작업에는 HierarchyID의 GetDescendant와 GetReparentedValue 메서드를 사용할 수 있는 기회가 있습니다. 다음과 같은 코드의 일부로 처리를 시작할 수 있습니다.
begin
begin transaction
declare @mp int;
declare @mpl int;
select @mp = MAX(partID)+10 from dbo.part;
insert into dbo.part(partID,descr,cost)
output inserted.*
values (@mp,'4GB USB 2.0 Memory Stick',20.0);
select @mpl = MAX(partListID)+10 from dbo.partList;
insert into dbo.partList(partListID,partID,quantity)
output inserted.*
values (@mpl,@mp,1);
부품과 부품 목록을 생성한 다음에는 관련된 자재명세서를 만들 수 있습니다. 개념은 새로운 자재명세서를 계층에서 Super X100 Home Theatre System을 구성하는 다른 모든 자재명세서의 오른쪽에 추가하는 것입니다. 이를 위해서는 부모 자재명세서의 경로와 가장 오른쪽 자식의 경로가 필요합니다.
부모 자재명세서의 경로는 간단한 쿼리로 얻을 수 있습니다. 가장 오른쪽 자식을 찾으려면 다음과 같이 IsDescendantOf와 GetLevel 메서드를 사용해야 합니다.
declare @root hierarchyID;
declare @newBillPath hierarchyID;
declare @newBillID int;
select @root = billPath
from dbo.bill2
where billID = 110;
select @newBillPath = max(billPath)
from dbo.bill2
where billPath.IsDescendantOf(@root) =1
and billPath.GetLevel() = @root.GetLevel()+1;
여기에서는 루트 노드의 모든 직계 하위 노드를 찾고 있습니다. 경로는 값에 따라 정렬되므로 HierarchyID에 최대값을 가진 자재명세서가 가장 오른쪽 인스턴스입니다. 가장 오른쪽 피어 노드를 알아낸 다음에는 이에 인접한 새로운 경로를 만들어야 합니다. 여기에는 GetDescendant를 사용합니다. 이 메서드의 매개 변수는 새로운 경로가 생성되는 위치를 제어합니다. 여기에서 두 번째 매개 변수를 null로 유지하는 것은 "첫 번째 매개 변수에 노드를 만들라"라고 지정하는 의미가 있습니다.
select @newBillPath =@root.GetDescendant(@newBillPath,null);
이제 새로운 자재명세서를 위한 경로가 있고 이 경로가 newBillID의 값을 가지므로 이를 추출하기만 하면 됩니다. 이 작업에는 이전에 추가한 테이블 반환 함수가 도움이 됩니다.
select @newBillID = nodeValue
from dbo.Vectorize(@newBillPath)
where position = @newBillPath.GetLevel();
최종적으로 dbo.bill2 테이블에 새로운 자재명세서를 삽입하고 dbo.billPartList 테이블에 billID와 partListID를 삽입하여 이 트랜잭션을 완료할 수 있습니다.
insert into dbo.bill2
output inserted.*
values (@newBillPath,@newBillID,'Summer Bonus Package');
insert into dbo.billPartList(billID,partListID)
output inserted.*
values (@newBillID,@mpl);
commit;
end;
billID를 변경하지 않는 경우에는 Summer Bonus 자재명세서를 Super X100 Home Theatre System의 직계 자식에서 Disk Drive Module의 자식으로 변경하는 작업도 크게 어렵지 않습니다(그림 10). GetReparentedValue를 사용할 때 기억해야 할 중요한 사실은 이를 호출하더라도 테이블에 지속되는 값에 대한 변경에는 영향을 주거나 받지 않는다는 것입니다. 이러한 변경에 영향을 주려면 업데이트를 사용해야 합니다.

그림 10 자재명세서 이동
begin
begin transaction
declare @oldPath hierarchyID,@newRoot hierarchyID;
declare @newPath hierarchyID;
declare @billIDToMove int = 251;
declare @billIDToMoveTo int = 320;
select @oldPath = billPath
from dbo.bill2 where BillID = @billIDToMove;
select @newRoot = b.billPath
from dbo.bill2 b where billID = @billIDToMoveTo
select @newPath = @oldPath.GetReparentedValue(
@oldPath.GetAncestor(1),@newRoot);
update dbo.bill2
set billPath = @newPath
output inserted.*
where billID = 251;
commit;
end;
부품과 해당 matchingID 자재명세서를 삭제하는 작업은 상당히 단순한 작업일 수도 있으며 복잡한 작업일 수도 있습니다. 어떤 경우에 해당하는지는 삭제하는 자재명세서가 다른 자재명세서의 부모인지 여부에 따라 다릅니다. Summer Bonus 자재명세서와 같이 다른 자재명세서의 부모가 아닌 경우 간단하게 자재명세서와 부품을 삭제할 수 있습니다.
begin
declare @partToDelete int = 45;
begin transaction
delete from dbo.bill2 where billID in
( select billID from dbo.billPartList bpl
join dbo.partList pl on bpl.partListID = pl.partListID
join dbo.part p on pl.partID = p.partID
where p.partID = @partToDelete);
delete from dbo.part where partID = @partToDelete;
commit;
end;
지금까지 설명한 디자인에서 하나 이상의 다른 자재명세서의 부모인 자재명세서를 삭제하려면 더 복잡한 코드가 필요합니다. 여기에서 문제가 되는 것은 각 자재명세서에 직계 부모가 있어야 한다는 외래 키 제약 조건입니다. 부모 자재명세서를 제거하려고 하면 오류가 발생합니다. 이를 해결하려면 모든 자식을 유지가 가능한 다른 자재명세서로 옮겨야 합니다. 이를 위해서는 외래 키 제약 조건을 일시적으로 삭제하고 GetReparentedValue 메서드를 사용하여 새로운 노드를 위한 경로를 계산할 수 있습니다.
요약
SQL Server 2008의 새로운 HierarchyID 형식은 노드를 식별하는 정렬된 경로를 저장 및 작업할 수 있는 간결한 데이터 형식입니다. 이 새로운 데이터 형식을 사용하면 계층적 데이터 모델을 사용하는 거의 모든 시스템을 구현할 수 있습니다.
주요 장점으로는 디스크에 실제로 저장되는 데이터의 양을 줄일 수 있으며 쿼리가 덜 복잡해진다는 것입니다. 이 형식의 경우에도 연쇄 참조 무결성 제약 조건을 위한 기본 SQL Server 지원을 활용할 수 있습니다. 자식의 ID와 부모의 ID에 스칼라 값을 사용하여 계층을 모델링하는 기존의 방법과는 달리 HierarchyID에서는 각 노드가 자체 ID로서 완전한 자체 경로를 포함한다는 것이 여러분이 기억할 요점입니다.