MSSQL LAB
MSSQL 3.9 · 3부. 설계

계층 데이터 모델링

인접 목록·정렬 열·경로 열거·hierarchyid 를 비교합니다. 실습 데이터가 그 하나를 사용하는 까닭을 같은 화면을 네 번 내면서 셈합니다.

예상 학습 시간 22분 난이도 중급
개념 설명

답글에 답글이 달립니다

표는 평평합니다. 행이 있고 열이 있을 뿐 위아래가 없습니다. 그런데 게시판의 글에는 위아래가 있습니다. 답글에 답글이 달리고, 그 답글에 또 답글이 달립니다.

이 구조를 표에 담는 방법은 하나가 아닙니다. 그리고 어느 것을 선택하느냐에 따라 조회와 삽입 가운데 어느 쪽이 비싸지는지가 갈립니다. 실습 데이터베이스가 어떤 선택을 했는지부터 봅니다.

SQL
SELECT num, parent_num, group_num, depth, sort_no, LEFT(title, 24) AS 제목
FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no;
결과
numparent_numgroup_numdepthsort_no제목
6NULL600저장 프로시저 질문드립니다
3526611[답글] 저장 프로시저 질문드립니다
456352622[답글] [답글] 저장 프로시저 질문드립니다
계층을 담는 열이 넷입니다. 실습 데이터에는 이런 덩어리가 350개 있습니다.

parent_num 하나만 있어도 계층은 표현됩니다. 나머지 셋은 무엇 때문에 있는지가 이 단원의 물음입니다. 네 가지 방식을 차례로 보고 같은 화면을 내면서 읽은 페이지를 세어 봅니다.

방식 1

인접 목록 — 부모만 가리킵니다

가장 단순합니다. 열 하나에 부모의 번호를 담고, 원글이면 NULL 입니다. 4바이트면 끝이고, 답글을 넣을 때 계산할 것이 없습니다.

문제는 읽을 때 드러납니다. 게시판 목록은 최신 원글부터, 덩어리 안에서는 차례대로 나와야 합니다. parent_num 만으로 그 순서를 내려면 2.5 의 재귀 CTE 로 전부 펼쳐야 합니다.

SQL
-- 원글에서 시작해 답글을 따라 내려가면서 정렬용 문자열을 만듭니다.
WITH T AS (
    SELECT num, board_num, title, num AS grp,
           CAST(RIGHT('0000000000' + CAST(num AS varchar(10)), 10) AS varchar(200)) AS ord
    FROM Board.POSTS WHERE parent_num IS NULL AND board_num = 2
    UNION ALL
    SELECT p.num, p.board_num, p.title, T.grp,
           CAST(T.ord + '/' + RIGHT('0000000000' + CAST(p.num AS varchar(10)), 10) AS varchar(200))
    FROM Board.POSTS p JOIN T ON p.parent_num = T.num
)
SELECT TOP (20) num, title FROM T ORDER BY grp DESC, ord;
메시지
Table 'POSTS'. Scan count 316, logical reads 854, ... Table 'Worktable'. Scan count 2, logical reads 1508, ...
20건을 내려고 2,362장을 읽었습니다. 자유게시판 원글 210개를 전부 펼친 뒤 잘라 냈기 때문입니다.

20건만 필요한데 전부 펼쳐야 합니다. 재귀는 어디까지 내려가야 할지 미리 알 수 없어 중간에 멈출 수 없고, 정렬은 다 펼친 뒤에야 할 수 있습니다. 글이 늘면 이 비용이 그대로 늡니다.

인접 목록이 나쁜 것은 아닙니다. 부모를 따라 올라가거나 자식 한 단계를 찾는 데는 이보다 싼 방법이 없습니다. 실습 데이터베이스도 parent_num 을 그대로 두고 인덱스까지 걸어 두었습니다(IX_POSTS_parent). 목록 화면이 문제일 뿐입니다.

방식 2

정렬 보조 열 — 실습 데이터베이스의 선택

순서를 조회할 때 계산하지 말고 미리 열에 담아 두자는 것입니다. 덩어리를 묶는 group_num, 덩어리 안의 차례 sort_no, 그리고 화면에 들여쓰기를 그릴 depth 입니다.

SQL
SELECT TOP (20) num, title FROM Board.POSTS
WHERE board_num = 2 ORDER BY group_num DESC, sort_no;
결과 — 읽은 페이지
방식논리적 읽기
인접 목록 + 재귀 CTE2,362
정렬 열2
같은 20건입니다. 1,000배가 넘습니다.

2장인 까닭은 3.5 에서 이미 보았습니다. IX_POSTS_list(board_num, group_num DESC, sort_no) 순서로 놓여 있어 앞에서 20개를 끊어 내면 끝입니다. 정렬도 재귀도 없습니다.

인덱스 하나가 화면 하나를 그대로 담고 있는 셈입니다. 실습 스키마가 계층 열 넷을 둔 것은 이 한 장면을 위해서입니다.

대가

조회를 산 값은 삽입으로 치릅니다

미리 담아 둔 순서는 새 답글이 끼어들 때 다시 매겨야 합니다. 덩어리 가운데에 답글이 달리면 그 뒤의 sort_no 를 전부 밀어야 합니다.

실습 데이터의 덩어리는 가장 큰 것이 3행이라 차이가 드러나지 않습니다. 답글 200개짜리 덩어리를 임시 표에 만들어 재 봅니다.

SQL
CREATE TABLE Board.T_H (
    num int IDENTITY(1,1) PRIMARY KEY,
    parent_num int NULL, group_num int NOT NULL,
    depth smallint NOT NULL, sort_no int NOT NULL,
    title nvarchar(200) NOT NULL);
GO
-- 원글 하나에 답글 200개를 붙입니다(문장은 줄였습니다).
GO
-- (가) 정렬 열: 맨 앞에 끼우려면 뒤를 모두 밀어야 합니다.
BEGIN TRAN;
UPDATE Board.T_H SET sort_no = sort_no + 1 WHERE group_num = 1 AND sort_no >= 1;
INSERT INTO Board.T_H (parent_num, group_num, depth, sort_no, title)
VALUES (1, 1, 1, 1, N'끼워 넣은 답글');
SELECT database_transaction_log_record_count AS 로그레코드,
       database_transaction_log_bytes_used AS 로그바이트
FROM sys.dm_tran_database_transactions WHERE database_id = DB_ID();
ROLLBACK;

-- (나) 인접 목록: 그냥 넣습니다.
결과 — 답글 하나를 넣을 때
방식로그 레코드로그 바이트
정렬 열 (200행을 밀고)20224,292
인접 목록 (그냥 넣기)2292
201행짜리 덩어리 기준입니다. 덩어리가 클수록 그대로 비례합니다.

조회에서 1,000배를 얻고 삽입에서 100배를 잃었습니다. 게시판은 읽는 횟수가 쓰는 횟수보다 압도적으로 많으므로 이 거래가 남습니다. 목록은 방문자마다 열리고 답글은 가끔 달립니다.

반대인 자리도 있습니다. 답글이 초당 여러 건 달리고 목록은 관리자만 가끔 보는 표라면 이 거래는 손해입니다. 계층 모델은 자료의 모양이 아니라 화면의 모양을 보고 고릅니다.

방식 3

경로 열거 — 조상을 문자열로 담습니다

/6/352/456/ 처럼 뿌리에서 자기까지의 번호를 이어 붙여 담습니다. 실습 데이터에 열을 하나 더해 채워 봅니다.

SQL
ALTER TABLE Board.POSTS ADD path varchar(200) NULL;
GO
-- 재귀 CTE 로 한 번만 계산해 채웁니다(2.5).
WITH T AS (
    SELECT num, CAST('/' + CAST(num AS varchar(10)) + '/' AS varchar(200)) AS p
    FROM Board.POSTS WHERE parent_num IS NULL
    UNION ALL
    SELECT c.num, CAST(T.p + CAST(c.num AS varchar(10)) + '/' AS varchar(200))
    FROM Board.POSTS c JOIN T ON c.parent_num = T.num
)
UPDATE P SET path = T.p FROM Board.POSTS P JOIN T ON T.num = P.num;
GO
CREATE NONCLUSTERED INDEX IX_POSTS_path ON Board.POSTS (path);
GO
SELECT num, parent_num, depth, path FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no;
결과
numparent_numdepthpath
6NULL0/6/
35261/6/352/
4563522/6/352/456/
조상이 값 안에 다 들어 있습니다. 가장 긴 경로가 13자, 평균 6바이트입니다.

하위 전체를 한 번에 가져오는 데 강합니다. 앞이 같은 문자열을 찾으면 되므로 인덱스 탐색이 됩니다.

SQL
-- (가) 인접 목록: 재귀로 내려갑니다.
WITH T AS (
    SELECT num FROM Board.POSTS WHERE num = 6
    UNION ALL
    SELECT p.num FROM Board.POSTS p JOIN T ON p.parent_num = T.num
)
SELECT COUNT(*) FROM T;

-- (나) 경로 열거: 앞이 같은 것을 찾습니다.
SELECT COUNT(*) FROM Board.POSTS WHERE path LIKE '/6/%';
결과 — 읽은 페이지
방식논리적 읽기
인접 목록 + 재귀 CTE27
경로 열거 + LIKE2
깊이가 2뿐인 자료입니다. 조직도처럼 깊어질수록 차이가 벌어집니다.

LIKE '/6/%'뒤에만 % 가 있어 인덱스를 사용합니다. '%/456/' 처럼 앞에 붙이면 그러지 못합니다. 조상을 찾는 것이 아니라 후손을 찾는 데 쓰는 구조입니다.

그런데 게시판 목록에는 맞지 않습니다

경로로 정렬해 봅니다.

SQL
SELECT TOP (8) num, path FROM Board.POSTS WHERE board_num = 2 ORDER BY path;

SELECT TOP (8) num, group_num, sort_no FROM Board.POSTS
WHERE board_num = 2 ORDER BY group_num DESC, sort_no;
결과 — 경로로 정렬
numpath
1/1/
101/101/
102/102/
381/102/381/
103/103/
결과 — 정렬 열로 정렬
numgroup_numsort_no
3463460
3453450
4543451
5073452
3443440
위쪽은 1 다음이 101 입니다. 문자열이라 그렇습니다.

문자열 정렬이므로 /1/ 다음에 /101/ 이 옵니다. 번호순도 아니고 게시판이 원하는 최신순도 아닙니다. 자리를 채워 /0000000001/ 처럼 만들면 번호순은 되지만, 최신 원글부터 내려면 번호를 뒤집어 담아야 합니다. 같은 화면을 내는 데 25장이 들었고 순서도 맞지 않았습니다.

경로 열거의 다른 대가는 옮길 때입니다. 어떤 글을 다른 부모 아래로 옮기면 그 아래 모든 후손의 경로를 다시 써야 합니다. 그리고 길이 제한이 있어 깊이가 깊어지면 넘칩니다.

방식 4

hierarchyid — SQL Server 가 주는 형식

경로 열거와 생각은 같은데 문자열이 아니라 전용 형식으로 담습니다. 같은 계층을 훨씬 적은 바이트에 넣고, 계층을 다루는 메서드가 딸려 옵니다.

SQL
ALTER TABLE Board.POSTS ADD hid hierarchyid NULL;
-- 형제 사이의 순번으로 경로를 만들어 채웁니다(문장은 줄였습니다).
GO
SELECT num, depth, path, hid.ToString() AS hid문자열,
       DATALENGTH(hid) AS hid바이트, DATALENGTH(path) AS path바이트
FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no;
결과
numdepthpathhid 문자열hid 바이트path 바이트
60/6//6/13
3521/6/352//6/1/27
4562/6/352/456//6/1/1/211
전체 합계
담는 방식507행 합계평균
hierarchyid1,4612
경로 열거(varchar)3,2146
hid 는 글 번호가 아니라 형제 사이의 순번을 담습니다. 그래서 짧습니다.

메서드가 붙어 있어 계층을 다루기 편합니다.

SQL
DECLARE @root hierarchyid = (SELECT hid FROM Board.POSTS WHERE num = 6);
SELECT COUNT(*) FROM Board.POSTS WHERE hid.IsDescendantOf(@root) = 1;

SELECT num, hid.GetLevel() AS 수준, hid.GetAncestor(1).ToString() AS 부모경로
FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no;
결과 — 하위 전체는 3장
num수준부모경로
61/
3522/6/
4563/6/1/
GetLevel 은 뿌리를 1 로 셉니다. depth 열의 0 과 한 칸 어긋나는 점에 주의하십시오.

다만 SQL Server 전용 형식입니다. 다른 데이터베이스로 옮길 때 그대로 가져갈 수 없고, 응용 프로그램에서 다루려면 그쪽 드라이버가 이 형식을 알아야 합니다. 값을 눈으로 읽기도 어렵습니다 — ToString() 을 붙이지 않으면 이진 값이 나옵니다. 깊고 넓은 계층을 데이터베이스 안에서 주로 다루는 경우에 값어치가 있습니다.

비교

무엇을 사고 무엇을 팔았습니까

재는 것인접 목록정렬 열경로 열거hierarchyid
담는 바이트 (행마다)41462
게시판 목록 20건2,362225
하위 전체 세기2723
답글 끼우기 (로그 레코드)220222

507행 · 201행짜리 덩어리 기준. 논리적 읽기와 로그 레코드입니다.

숫자로 드러나지 않는 것도 있습니다.

  • 옮기기 — 인접 목록은 부모 번호 하나만 고치면 끝입니다. 정렬 열은 두 덩어리의 차례를 다시 매겨야 하고, 경로 열거와 hierarchyid 는 후손 전체의 값을 다시 씁니다.
  • 이식성hierarchyid 만 SQL Server 전용입니다. 나머지 셋은 어느 데이터베이스에서나 같은 방식으로 만듭니다.
  • 깊이 제한 — 경로 열거는 열 길이가 곧 깊이 제한입니다. 나머지는 제한이 없습니다.

실습 데이터베이스가 정렬 열을 고른 까닭

게시판에서 가장 자주 열리는 화면이 목록이기 때문입니다. 그 화면을 2장에 내려고 나머지를 내주었습니다. 답글이 드물게 달리는 게시판에서는 남는 거래입니다.

그러면서 parent_num 도 함께 들고 있습니다. 정렬 열만으로는 누가 누구의 답글인지 알 수 없기 때문입니다. depth 는 몇 칸 들여쓸지만 알려 줄 뿐, 어느 글에 달린 답글인지는 말해 주지 않습니다. 그래서 넷을 다 두었습니다.

다른 자료라면 다른 답이 나옵니다. 조직도는 사람이 옮겨 다니고 "이 아래 전부" 를 자주 묻습니다 — 경로 열거나 hierarchyid 가 맞습니다. 댓글은 한 단계뿐이라 parent_num 하나로 충분합니다. 실습 데이터베이스가 COMMENTS 에 계층 열을 두지 않은 것이 그 까닭입니다.

연습

직접 해보기

1. 조상을 거슬러 올라갑니다 난이도 하

456번 글이 어느 글에 달린 답글인지, 그 글은 또 어디에 달렸는지 뿌리까지 거슬러 올라가 보세요. parent_num 만 사용합니다.

WITH T AS ( SELECT num, parent_num, depth, title FROM Board.POSTS WHERE num = 456 UNION ALL SELECT p.num, p.parent_num, p.depth, p.title FROM Board.POSTS p JOIN T ON T.parent_num = p.num -- 조인 방향이 반대입니다 ) SELECT num, parent_num, depth, LEFT(title, 30) AS 제목 FROM T ORDER BY depth; -- num parent_num depth 제목 -- 6 NULL 0 저장 프로시저 질문드립니다 -- 352 6 1 [답글] 저장 프로시저 질문드립니다 -- 456 352 2 [답글] [답글] 저장 프로시저 질문드립니다 -- 내려갈 때는 p.parent_num = T.num, 올라갈 때는 T.parent_num = p.num 입니다. -- 경로 열거였다면 재귀 없이 path 문자열을 끊어 읽으면 됩니다.
2. 답글을 제자리에 넣습니다 난이도 중

6번 글에 답글을 답니다. group_num · depth · sort_no 를 어떻게 정하고, 기존 행은 무엇을 해야 합니까. 확인한 뒤에는 되돌립니다.

새 답글은 부모 바로 다음 자리에 들어갑니다. 그 자리에 이미 있던 것들은 어떻게 됩니까.
BEGIN TRAN; DECLARE @parent int = 6, @grp int, @dep smallint, @sort int; -- 부모에게서 덩어리와 자리를 받아 옵니다. SELECT @grp = group_num, @dep = depth + 1, @sort = sort_no + 1 FROM Board.POSTS WHERE num = @parent; -- 그 자리부터 뒤를 한 칸씩 밉니다. 이것을 빠뜨리면 차례가 겹칩니다. UPDATE Board.POSTS SET sort_no = sort_no + 1 WHERE group_num = @grp AND sort_no >= @sort; INSERT INTO Board.POSTS (board_num, category_num, user_num, parent_num, group_num, depth, sort_no, title, hit_count, reg_date) SELECT board_num, category_num, 1, @parent, @grp, @dep, @sort, N'[답글] 새로 단 답글', 0, '2026-03-01' FROM Board.POSTS WHERE num = @parent; SELECT num, parent_num, depth, sort_no FROM Board.POSTS WHERE group_num = @grp ORDER BY sort_no; -- num parent_num depth sort_no -- 6 NULL 0 0 -- 508 6 1 1 ← 새 답글이 부모 바로 다음에 -- 352 6 1 2 ← 밀렸습니다 -- 456 352 2 3 ← 밀렸습니다 ROLLBACK; -- 밀기와 넣기가 한 트랜잭션이어야 합니다. 사이에서 끊기면 차례가 어긋납니다(4.6). -- 되돌려도 IDENTITY 번호 508 은 소비됩니다(3.2). -- 이 절차를 문장으로 흩어 두지 말고 프로시저 하나에 담는 것이 4부의 주제입니다.

이 단원에서 더한 열과 표를 지웁니다.
DROP INDEX IX_POSTS_path ON Board.POSTS; DROP INDEX IX_POSTS_hid ON Board.POSTS;
ALTER TABLE Board.POSTS DROP COLUMN path, hid;
DROP TABLE Board.T_H;
ALTER INDEX PK_POSTS ON Board.POSTS REBUILD;

요약
  • 계층을 표에 담는 방법은 넷입니다. 정답은 자료가 아니라 자주 여는 화면이 정합니다.
  • 인접 목록(parent_num)은 담기와 넣기가 가장 싸고 정렬된 목록이 가장 비쌉니다(20건에 2,362장).
  • 정렬 보조 열(group_num · depth · sort_no)은 순서를 미리 담아 목록을 2장에 냅니다. 실습 데이터베이스의 선택입니다.
  • 그 대가는 삽입입니다. 답글이 끼어들면 뒤를 전부 밀어야 합니다(201행 덩어리에서 로그 레코드 202 대 2).
  • 경로 열거하위 전체 조회에 강합니다(27장 대 2장). 다만 문자열 정렬이라 /1/ 다음에 /101/ 이 오고, 옮기면 후손을 다 다시 써야 합니다.
  • hierarchyid 는 같은 것을 3분의 1 크기로 담고 메서드를 제공합니다. SQL Server 전용이라는 것이 값입니다.
  • 실습 데이터베이스는 정렬 열과 parent_num함께 둡니다. 정렬 열은 순서만 알고, 누가 누구의 답글인지는 parent_num 이 압니다.
  • 댓글처럼 한 단계뿐인 계층에는 parent_num 하나로 충분합니다.