실습 — 게시판 스키마 설계
요구사항에서 테이블까지, 3부에서 배운 것을 한 번에 적용합니다. 태그·좋아요·지우기 셋을 설계하며 무엇을 내줄지 고릅니다.
요구사항 셋을 표로 옮깁니다
운영 중인 게시판에 기능을 더한다고 합시다. 기획서에 적힌 것은 이 셋입니다.
- 태그 — 글에 태그를 여러 개 붙입니다. 태그를 눌러 그 태그가 붙은 글을 봅니다.
- 좋아요 — 회원이 글에 좋아요를 누릅니다. 한 사람이 같은 글에 두 번 누를 수는 없습니다.
- 지우기 — 글을 지워도 바로 사라지지 않습니다. 언제 지웠는지 남고, 관리자는 볼 수 있습니다.
3부에서 배운 것을 순서대로 적용합니다. 정규화로 표를 나누고, 키를 정하고, 제약으로 규칙을 적고, 인덱스를 걸고, 뷰로 감쌉니다. 하나씩 해나가되 왜 그렇게 정했는지를 매번 적습니다.
태그 — 한 칸에 여러 개를 넣지 않습니다
가장 빨리 끝내는 방법은 열 하나를 더하고 쉼표로 이어 넣는 것입니다. 실제로 어떻게 되는지 봅니다.
CREATE TABLE Board.T_TAGCSV (post_num int PRIMARY KEY, tags nvarchar(200) NULL); INSERT INTO Board.T_TAGCSV VALUES (1, N'SQL,조인,입문'), (2, N'SQL,트랜잭션'), (3, N'입문'), (4, N'조인,성능'), (5, N'SQL서버,백업'), (6, N'성능'); GO -- (가) SQL 태그가 붙은 글을 찾습니다. SELECT post_num, tags FROM Board.T_TAGCSV WHERE tags LIKE N'%SQL%'; -- (나) 태그별 글 수를 셉니다. SELECT tags, COUNT(*) AS 글수 FROM Board.T_TAGCSV GROUP BY tags; -- (다) 태그 이름을 SQL 에서 T-SQL 로 고칩니다. SELECT post_num, REPLACE(tags, N'SQL', N'T-SQL') AS 고친결과 FROM Board.T_TAGCSV;
| post_num | tags |
|---|---|
| 1 | SQL,조인,입문 |
| 2 | SQL,트랜잭션 |
| 5 | SQL서버,백업 |
| tags | 글수 |
|---|---|
| SQL,조인,입문 | 1 |
| SQL,트랜잭션 | 1 |
| SQL서버,백업 | 1 |
| 조인,성능 | 1 |
| post_num | 고친결과 |
|---|---|
| 1 | T-SQL,조인,입문 |
| 5 | T-SQL서버,백업 |
한 칸에 값을 여러 개 넣으면 그 값을 개별로 다룰 수 없습니다. 3.1 의 1정규형이 말하는 것이 이것입니다. 게다가 태그 이름이 글마다 되풀이되어 있어 하나를 고치려면 모든 글을 훑어야 합니다.
나눈 설계
태그는 태그대로 한 표에 두고, 글과 태그를 잇는 표를 따로 둡니다. 글 하나에 태그가 여럿, 태그 하나에 글이 여럿이므로 다대다이고, 다대다는 연결 표로 풉니다.
CREATE TABLE Board.TAGS ( num int IDENTITY(1,1) NOT NULL, name nvarchar(30) NOT NULL, CONSTRAINT PK_TAGS PRIMARY KEY CLUSTERED (num), CONSTRAINT UX_TAGS_name UNIQUE (name) -- 같은 이름이 둘 있으면 안 됩니다 ); GO CREATE TABLE Board.POST_TAGS ( post_num int NOT NULL, tag_num int NOT NULL, -- 두 열이 함께 기본 키입니다. 대리 키를 따로 두지 않습니다. CONSTRAINT PK_POST_TAGS PRIMARY KEY CLUSTERED (post_num, tag_num), CONSTRAINT FK_POST_TAGS_POSTS FOREIGN KEY (post_num) REFERENCES Board.POSTS (num) ON DELETE CASCADE, CONSTRAINT FK_POST_TAGS_TAGS FOREIGN KEY (tag_num) REFERENCES Board.TAGS (num) -- 태그는 연쇄 삭제하지 않습니다 ); GO -- 태그로 글을 찾는 반대 방향입니다. 두 열뿐이라 그대로 커버링이 됩니다. CREATE NONCLUSTERED INDEX IX_POST_TAGS_tag ON Board.POST_TAGS (tag_num, post_num);
여기서 정한 것 넷
- 연결 표에 대리 키를 두지 않았습니다.
(post_num, tag_num)자체가 겹치면 안 되는 값이므로, 복합 기본 키가 곧 중복 방지입니다. 번호 열을 따로 두면UNIQUE제약을 또 걸어야 합니다(3.2 · 3.4). - 클러스터형 키 순서를
(post_num, tag_num)으로 두었습니다. 글 하나의 태그를 모아 읽는 것이 더 잦기 때문입니다. 3.5 의 선행 열 규칙대로, 이 순서라야WHERE post_num = ?가 탐색이 됩니다. - 반대 방향 인덱스를 하나 더 두었습니다.
(tag_num, post_num)입니다. 태그를 눌렀을 때 필요하고, 열이 둘뿐이라 표에 갈 일이 없습니다. - 글에는
CASCADE, 태그에는 걸지 않았습니다. 글이 사라지면 그 글의 태그 연결도 뜻이 없지만, 태그를 지웠다고 글의 연결이 조용히 사라지면 곤란합니다(3.3).
실습 데이터의 300건에 태그를 붙이고 다시 물어봅니다.
-- (가) SQL 태그가 붙은 글 SELECT COUNT(*) AS 글수 FROM Board.POST_TAGS pt JOIN Board.TAGS t ON t.num = pt.tag_num WHERE t.name = N'SQL'; -- (나) 태그별 글 수 SELECT t.name AS 태그, COUNT(*) AS 글수 FROM Board.POST_TAGS pt JOIN Board.TAGS t ON t.num = pt.tag_num GROUP BY t.name ORDER BY 글수 DESC, 태그; -- (다) 이름 고치기 UPDATE Board.TAGS SET name = N'T-SQL' WHERE name = N'SQL';
| 물음 | 쉼표 한 열 | 나눈 설계 |
|---|---|---|
| SQL 태그가 붙은 글 | SQL서버까지 딸려 옴 | 128건 정확히 |
| 태그별 글 수 | 조합별로 세어짐 | 태그마다 정확히 |
| 이름 고치기 | 5행을 고치며 SQL서버까지 망가짐 | 1행 |
| 태그 | 글수 |
|---|---|
| SQL | 128 |
| 입문 | 128 |
| 조인 | 128 |
| SQL서버 | 43 |
| 백업 | 43 |
좋아요 — 키가 곧 규칙입니다
"한 사람이 같은 글에 두 번 누를 수 없다" 는 요구는 표를 어떻게 잡느냐로 이미 지켜집니다. 태그 연결 표와 같은 모양입니다.
CREATE TABLE Board.POST_LIKES ( post_num int NOT NULL, user_num int NOT NULL, reg_date datetime2(0) NOT NULL CONSTRAINT DF_POST_LIKES_reg_date DEFAULT SYSDATETIME(), CONSTRAINT PK_POST_LIKES PRIMARY KEY CLUSTERED (post_num, user_num), CONSTRAINT FK_POST_LIKES_POSTS FOREIGN KEY (post_num) REFERENCES Board.POSTS (num) ON DELETE CASCADE, CONSTRAINT FK_POST_LIKES_USERS FOREIGN KEY (user_num) REFERENCES Member.USERS (num) ); GO -- 내가 누른 글 목록을 내는 방향입니다. CREATE NONCLUSTERED INDEX IX_POST_LIKES_user ON Board.POST_LIKES (user_num, post_num); GO -- 같은 사람이 같은 글에 또 누르면 INSERT INTO Board.POST_LIKES (post_num, user_num) VALUES (1, 2);
번호 열을 따로 두었다면 이 규칙을 잃습니다.
num int IDENTITY 를 기본 키로 삼으면 같은 사람이
같은 글에 몇 번이든 누를 수 있고, 막으려면
UNIQUE (post_num, user_num) 을 따로 걸어야 합니다.
그러면 인덱스가 둘이 되고 쓰기가 그만큼 비싸집니다(3.5).
좋아요 수를 미리 담아 둘까요
목록 화면에 좋아요 수를 보이려면 매번 세야 합니다. 미리 담아 두고 싶어집니다. 3부에서 배운 도구로 두 가지를 시도해 봅니다.
-- (가) 계산 열로 두려 하면 ALTER TABLE Board.POSTS ADD like_count AS (SELECT COUNT(*) FROM Board.POST_LIKES WHERE post_num = num); GO -- (나) 인덱싱된 뷰로 두면 CREATE VIEW Board.V_POST_LIKE_COUNT WITH SCHEMABINDING AS SELECT post_num, COUNT_BIG(*) AS like_count FROM Board.POST_LIKES GROUP BY post_num; GO CREATE UNIQUE CLUSTERED INDEX CX_V_POST_LIKE_COUNT ON Board.V_POST_LIKE_COUNT (post_num);
| 방법 | 논리적 읽기 |
|---|---|
| 그냥 세기 | 2 |
| 인덱싱된 뷰(NOEXPAND) | 2 |
계산 열은 3.6 에서 본 대로 다른 표를 볼 수 없어 애초에
안 됩니다. 인덱싱된 뷰는 만들어지지만
세는 비용이 이미 2장이라 줄일 것이 없습니다.
PK_POST_LIKES (post_num, user_num) 가 있어
WHERE post_num = ? 가 탐색 한 번에 끝나기
때문입니다.
그러므로 담아 두지 않습니다. 배운 도구가 있다고 해서 쓸 자리가 되는 것은 아닙니다. 3.1 에서 정규화를 어디서 멈출지 물었던 것과 같은 판단입니다 — 재 보고 이득이 없으면 넣지 않습니다. 좋아요가 글마다 수만 건이 되어 세는 것이 비싸지면, 그때 다시 재고 넣으면 됩니다.
지우기 — 지울 수 없다는 데서 시작합니다
"지워도 바로 사라지지 않는다" 를 만들기 전에, 진짜로 지우면 어떻게 되는지부터 봅니다. 답글이 둘 딸린 6번 글입니다.
DELETE FROM Board.POSTS WHERE num = 6;
답글이 달린 글은 애초에 지울 수 없습니다. 지우려면 답글부터 지워야 하고, 그러면 남의 글이 사라집니다. 답글만 남기면 부모 없는 답글이 됩니다. 지우지 않고 표시만 남기는 설계는 취향이 아니라 이 구조가 요구하는 것입니다.
열 하나를 더합니다
ALTER TABLE Board.POSTS ADD del_date datetime2(0) NULL;
usable bit 이 아니라
del_date datetime2 로 둔 까닭이 셋입니다.
- 언제 지웠는지가 함께 남습니다. 보관 기간을 두고 정리하려면 그 날짜가 필요합니다(6.6).
- NULL 이 곧 살아 있음입니다. 1.9 에서 본 대로 NULL 은 "값이 없다" 는 뜻이고, 지운 적이 없다는 것이 정확히 그 뜻입니다.
- 필터 인덱스가 자연스럽습니다.
WHERE del_date IS NULL로 살아 있는 글만 인덱싱합니다(3.4).
목록 인덱스를 필터 인덱스로
목록 화면은 이제 살아 있는 글만 냅니다. 그렇다면 인덱스도 그것만 담으면 됩니다.
CREATE NONCLUSTERED INDEX IX_POSTS_live ON Board.POSTS (board_num, group_num DESC, sort_no) INCLUDE (title, user_num, reg_date, hit_count, depth) WHERE del_date IS NULL; GO SELECT TOP (20) num, title FROM Board.POSTS WHERE board_num = 2 AND del_date IS NULL ORDER BY group_num DESC, sort_no;
살아 있는 글만 담은 인덱스를 만들었는데 쓰이지 않습니다. 까닭을 보려면 힌트로 강제해 봅니다.
SELECT TOP (20) num, title FROM Board.POSTS WITH (INDEX(IX_POSTS_live)) WHERE board_num = 2 AND del_date IS NULL ORDER BY group_num DESC, sort_no;
인덱스가 이미 살아 있는 글만 담고 있는데도 표로 되돌아갑니다.
del_date 를 인덱스가 들고 있지 않아
IS NULL 을 확인할 데가 없기 때문입니다. 그 키 조회
때문에 42장이 들고, 그래서 옵티마이저가 16장짜리 스캔을 고른 것입니다. 필터에
사용한 열을 포함 열에 넣으면 사라집니다.
DROP INDEX IX_POSTS_live ON Board.POSTS; GO CREATE NONCLUSTERED INDEX IX_POSTS_live ON Board.POSTS (board_num, group_num DESC, sort_no) INCLUDE (title, user_num, reg_date, hit_count, depth, del_date) -- 필터 열을 더합니다 WHERE del_date IS NULL;
| 인덱스 | 논리적 읽기 | 계획 |
|---|---|---|
| 필터 열이 없을 때 | 16 | Sort + Clustered Index Scan |
| 필터 열이 없을 때(힌트로 강제) | 42 | Index Seek + LOOKUP |
| 필터 열을 넣었을 때 | 2 | Top + Index Seek |
| 인덱스 | 페이지 | 담은 행 |
|---|---|---|
| IX_POSTS_list (전부) | 14 | 507 |
| IX_POSTS_live (살아 있는 것만) | 5 | 406 |
필터 인덱스를 만들 때는 필터에 사용한 열을 포함 열에 함께 넣으십시오. 3.4 와 3.5 가 만나는 자리이고, 넣지 않아도 오류가 나지 않아 알아채기 어렵습니다.
표시만 남기면 계층이 지켜집니다
UPDATE Board.POSTS SET del_date = '2026-03-01' WHERE num = 6; SELECT num, parent_num, depth, sort_no, del_date, LEFT(title, 20) AS 제목 FROM Board.POSTS WHERE group_num = 6 ORDER BY sort_no;
| num | parent_num | depth | sort_no | del_date | 제목 |
|---|---|---|---|---|---|
| 6 | NULL | 0 | 0 | 2026-03-01 | 저장 프로시저 질문드립니다 |
| 352 | 6 | 1 | 1 | NULL | [답글] 저장 프로시저… |
| 456 | 352 | 2 | 2 | NULL | [답글] [답글] 저장… |
화면에서는 지운 원글을 "삭제된 글입니다" 로 그리고 답글은 그대로 보입니다. 진짜로 지웠다면 답글까지 사라지거나 547 에 막혔을 것입니다.
살아 있는 글만 보는 뷰
모든 화면에서 del_date IS NULL 을 적는 것은
빠뜨리기 쉽습니다. 한곳에 적어 두고 이름을 붙입니다.
CREATE VIEW Board.V_LIVE_POSTS AS SELECT num, board_num, category_num, user_num, parent_num, group_num, depth, sort_no, title, content, hit_count, reg_date, mod_date FROM Board.POSTS WHERE del_date IS NULL;
| 보는 것 | 행수 |
|---|---|
| Board.POSTS | 507 |
| Board.V_LIVE_POSTS | 405 |
del_date 를 뷰에서 뺀 것은 3.7 의 열 감추기와 같은
뜻입니다. 이 뷰를 보는 쪽은 지운 글이 있다는 사실 자체를 알 필요가
없습니다. SELECT * 를 적지 않은 까닭도
3.7 에 있습니다.
세 요구가 모두 되는지 확인합니다
-- 태그별로 살아 있는 글이 몇 건인지 SELECT TOP (5) t.name AS 태그, COUNT(*) AS 글수 FROM Board.POST_TAGS pt JOIN Board.TAGS t ON t.num = pt.tag_num JOIN Board.V_LIVE_POSTS p ON p.num = pt.post_num GROUP BY t.name ORDER BY 글수 DESC, 태그; -- 좋아요가 많은 글 SELECT TOP (5) p.num, LEFT(p.title, 26) AS 제목, COUNT(l.user_num) AS 좋아요 FROM Board.V_LIVE_POSTS p JOIN Board.POST_LIKES l ON l.post_num = p.num GROUP BY p.num, p.title ORDER BY 좋아요 DESC, p.num; -- 글을 지운 것으로 표시해도 태그와 좋아요는 남는지 UPDATE Board.POSTS SET del_date = SYSDATETIME() WHERE num = 100; SELECT COUNT(*) AS 태그 FROM Board.POST_TAGS WHERE post_num = 100; SELECT COUNT(*) AS 좋아요 FROM Board.POST_LIKES WHERE post_num = 100;
| 태그 | 글수 |
|---|---|
| 입문 | 103 |
| 조인 | 102 |
| SQL | 101 |
| SQL서버 | 35 |
| 백업 | 34 |
| num | 제목 | 좋아요 |
|---|---|---|
| 4 | 백업 질문드립니다 | 5 |
| 9 | 뷰 질문드립니다 | 5 |
| 14 | 윈도 함수 질문드립니다 | 5 |
| 보는 것 | 표시 전 | 표시 후 |
|---|---|---|
| 100번 글의 태그 | 2 | 2 |
| 100번 글의 좋아요 | 1 | 1 |
앞의 태그별 글 수가 128 · 128 · 128 이었는데 지금은 103 · 102 · 101 입니다. 지운 것으로 표시한 글 102건이 빠졌기 때문입니다. 뷰를 조인에 넣은 것만으로 모든 집계가 살아 있는 글 기준이 되었습니다.
진짜로 지우는 일은 남습니다. 표시만 남기면 자료가 계속 쌓이므로, 일정 기간이 지난 것을 골라 실제로 지우는 작업이 따로 필요합니다. 그때는 답글부터 지워야 하고 트랜잭션으로 묶어야 합니다 — 6.6 과 5.7 의 주제입니다.
직접 해보기
살아 있는 글 가운데 태그가 하나도 붙지 않은 것이 몇 건인지 세어 보세요. 두 가지 방법으로 적고 읽은 페이지를 비교해 보십시오.
요구는 이렇습니다. 회원이 글을 신고합니다. 같은 글을 두 번 신고할 수는 없습니다. 사유를 적되 너무 짧으면 받지 않습니다. 관리자가 처리했는지 남기고, 아직 처리하지 않은 것을 빠르게 찾아야 합니다. 표를 설계해 보세요.
이 단원에서 만든 것을 지웁니다.
DROP VIEW Board.V_LIVE_POSTS;
DROP TABLE Board.POST_LIKES, Board.POST_TAGS, Board.TAGS;
DROP INDEX IX_POSTS_live ON Board.POSTS;
ALTER TABLE Board.POSTS DROP COLUMN del_date;
ALTER INDEX PK_POSTS ON Board.POSTS REBUILD;
설계는 무엇을 비싸게 할지 정하는 일입니다
이 단원에서 내린 판단을 다시 늘어놓으면, 하나같이 무엇을 얻고 무엇을 내줄지 고른 것이었습니다.
| 정한 것 | 얻은 것 | 내준 것 |
|---|---|---|
| 태그를 표로 나눔 | 정확한 검색과 집계 | 조인 하나 |
| 연결 표에 복합 키 | 중복 방지가 공짜 | 키가 길어짐 |
| 반대 방향 인덱스 | 태그로 글 찾기 2장 | 쓰기 한 번 더 |
| 좋아요 수를 담지 않음 | 틀릴 일이 없음 | 매번 세기(2장) |
| 지우지 않고 표시 | 계층과 딸린 자료 보존 | 자료가 쌓임 |
| 필터 인덱스 | 인덱스가 작아짐(14 → 5) | 필터 열을 넣어야 함 |
공짜로 얻은 것은 하나도 없습니다. 3부의 아홉 과가 알려 준 것은 "이렇게 하십시오" 가 아니라 무엇을 재고 무엇과 견줄지였습니다. 정규화도 인덱스도 뷰도 마찬가지입니다.
여기까지가 무엇을 담을지입니다. 4부에서는 그 위에서 무엇을 할지를 적습니다. 이 단원에서 손으로 이어 붙인 절차 — 답글의 차례를 밀고 넣기, 신고를 받고 처리 표시하기 — 를 프로시저 하나에 담고, 중간에 끊겨도 어긋나지 않게 만드는 일입니다.
- 한 칸에 값을 여러 개 넣지 마십시오. 검색이 부정확해지고(SQL 이 SQL서버를 잡음), 집계가 되지 않으며, 이름을 고칠 수 없습니다.
- 다대다는 연결 표로 풉니다. 연결 표에는 대리 키를 두지 말고 복합 기본 키를 쓰십시오 — 그것이 곧 중복 방지입니다(2627).
- 연결 표는 양쪽 방향 인덱스가 필요합니다. 열이 둘뿐이라 반대 방향 인덱스도 그대로 커버링이 됩니다.
CASCADE는 한쪽에만 겁니다. 글이 사라지면 연결도 뜻이 없지만, 태그를 지웠다고 연결이 사라지면 안 됩니다.- 미리 담아 두기 전에 재 보십시오. 좋아요 수는 이미 2장에 나오므로 인덱싱된 뷰를 만들 이유가 없었습니다.
- 답글이 달린 글은 애초에 지울 수 없습니다(547). 표시만 남기는 설계는 취향이 아니라 구조가 요구하는 것입니다.
- 상태는
bit보다 시각으로 담으십시오. NULL 이 곧 "아직" 이고, 언제였는지가 함께 남습니다. - 필터 인덱스에는 필터에 사용한 열을 포함 열로 넣으십시오. 넣지 않으면 키 조회가 붙어 옵티마이저가 그 인덱스를 버립니다(16장). 넣으면 2장입니다.
- 조건을 뷰에 한 번만 적어 모든 화면이 같은 기준을 보게 합니다.