MSSQL 3.7 · 3부. 설계

언제 사용하고 언제 사용하지 말아야 하는지, 인덱싱된 뷰는 무엇인지 봅니다. 뷰가 자료를 담지 않는다는 사실에서 나머지가 따라옵니다.

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

저장해 두는 SELECT 문

목록 화면 하나를 내려면 표 넷을 이어야 합니다. 글에 게시판 이름, 말머리, 작성자 닉네임을 붙이는 조인입니다. 같은 조인을 화면마다 다시 적으면 어딘가에서 LEFT JOINJOIN 으로 적는 사람이 나옵니다.

뷰는 그 문장에 이름을 붙여 두는 것입니다.

SQL
CREATE VIEW Board.V_POST_LIST AS
SELECT p.num, b.name AS board_name, c.name AS category_name, u.nickname,
       p.title, p.hit_count, p.reg_date, p.depth
FROM Board.POSTS p
    JOIN Board.BOARDS b ON b.num = p.board_num
    LEFT JOIN Board.CATEGORIES c ON c.num = p.category_num
    JOIN Member.USERS u ON u.num = p.user_num;
GO

-- 이제 표처럼 조회합니다.
SELECT TOP (3) num, board_name, category_name, nickname, title
FROM Board.V_POST_LIST ORDER BY num;
결과
numboard_namecategory_namenicknametitle
1자유게시판후기김철수조인 질문드립니다
2자유게시판정보이영희트랜잭션 질문드립니다
3자유게시판질문박민수페이징 질문드립니다
말머리를 사용하지 않는 공지사항의 글은 category_name 이 NULL 로 나옵니다.

뷰는 자료를 담지 않습니다. 저장되는 것은 문장뿐이고, 조회할 때마다 안쪽 문장이 실행됩니다. 그래서 원본이 바뀌면 뷰도 즉시 바뀝니다.

비용

가져오지 않는 조인은 실행되지 않습니다

표 넷을 잇는 뷰이므로 무엇을 가져오든 넷을 다 읽을 것 같습니다. 그렇지 않습니다. 읽은 페이지를 세어 봅니다.

SQL
SET STATISTICS IO ON;
-- 글 자체의 열만 가져옵니다.
SELECT num, title FROM Board.V_POST_LIST WHERE num = 250;

-- 게시판 이름과 작성자까지 가져옵니다.
SELECT num, title, board_name, nickname FROM Board.V_POST_LIST WHERE num = 250;
SET STATISTICS IO OFF;
결과 — 읽은 표와 페이지
가져오는 열읽은 표논리적 읽기
num, titlePOSTS2
+ board_name, nicknamePOSTS · BOARDS · USERS2 + 2 + 2
두 경우 모두 CATEGORIES 는 읽지 않았습니다. 조인 넷을 적어 두었는데도 그렇습니다.

계획을 보면 분명합니다.

SQL
SET SHOWPLAN_TEXT ON;
GO
SELECT num, title FROM Board.V_POST_LIST WHERE num = 250;
GO
SET SHOWPLAN_TEXT OFF;
결과
|--Clustered Index Seek(OBJECT:([POSTS].[PK_POSTS] AS [p]), SEEK:([p].[num]=(250)) ORDERED FORWARD)
조인이 하나도 남지 않았습니다. 표 하나를 탐색하는 계획입니다.

이것을 조인 제거라고 합니다. 엔진은 두 가지를 알고 있어 조인을 지웁니다. LEFT JOIN 은 결과 행 수를 늘리지 않고 그 표의 열을 가져오지 않으므로 지워도 됩니다. JOIN 쪽은 3.3 에서 건 외래 키가 가리키는 상대가 반드시 있음을 보증하므로 역시 지워도 결과가 같습니다.

외래 키를 신뢰할 수 없는 상태로 두면 이 최적화가 사라집니다. 3.3 에서 WITH NOCHECK 로 붙인 제약이 is_not_trusted = 1 이 된다고 했습니다. 그 상태에서는 엔진이 보증을 믿지 못해 조인을 그대로 실행합니다. 제약을 걸어 두는 것이 조회 속도로도 돌아오는 자리입니다.

그러므로 뷰를 사용한다고 해서 느려지지 않습니다. 뷰는 조회할 때 안쪽 문장으로 펼쳐지고, 그다음부터는 직접 적은 문장과 똑같이 최적화됩니다.

수정

뷰를 통해 고칠 수 있습니다

뷰는 조회 전용이 아닙니다. 어느 행의 어느 열을 고치라는 것인지 뚜렷하면 엔진이 원본에 그대로 적용합니다. 공지사항만 보는 뷰로 확인합니다.

SQL
CREATE VIEW Board.V_NOTICE AS
SELECT num, board_num, title, hit_count FROM Board.POSTS WHERE board_num = 1;
GO

UPDATE Board.V_NOTICE SET hit_count = hit_count + 1 WHERE num = 250;
SELECT num, hit_count FROM Board.POSTS WHERE num = 250;
결과
numhit_count
250251
뷰를 고쳤는데 원본이 바뀌었습니다. 뷰는 자료를 담지 않으므로 달리 갈 곳이 없습니다.

조건 밖으로 밀어낼 수 있습니다

이 뷰는 board_num = 1 인 글만 봅니다. 그런데 그 조건 자체를 고치면 어떻게 될까요.

SQL
UPDATE Board.V_NOTICE SET board_num = 2 WHERE num = 250;

SELECT COUNT(*) AS 뷰에남았나 FROM Board.V_NOTICE WHERE num = 250;

-- 되돌립니다. 앞의 조회수도 함께 되돌립니다.
UPDATE Board.POSTS SET board_num = 1, hit_count = hit_count - 1 WHERE num = 250;
결과
뷰에남았나
0
막히지 않고 성공합니다. 그리고 그 행은 뷰에서 사라집니다.

방금 고친 행을 다시 조회하면 없습니다. 화면에서 저장 단추를 눌렀는데 목록에서 사라지는 상황이 이렇게 만들어집니다. WITH CHECK OPTION 이 이것을 막습니다.

SQL
ALTER VIEW Board.V_NOTICE AS
SELECT num, board_num, title, hit_count FROM Board.POSTS WHERE board_num = 1
WITH CHECK OPTION;
GO

UPDATE Board.V_NOTICE SET board_num = 2 WHERE num = 250;
오류
메시지 550 The attempted insert or update failed because the target view either specifies WITH CHECK OPTION or spans a view that specifies WITH CHECK OPTION and one or more rows resulting from the operation did not qualify under the CHECK OPTION constraint. The statement has been terminated.

조인 뷰는 한 표씩만

표 넷을 잇는 V_POST_LIST 도 고칠 수 있습니다. 다만 한 번에 한 표의 열만입니다.

SQL
-- POSTS 의 열만 고칩니다. 됩니다.
UPDATE Board.V_POST_LIST SET title = N'바꿔봅니다' WHERE num = 250;

-- POSTS 의 title 과 USERS 의 nickname 을 함께 고칩니다.
UPDATE Board.V_POST_LIST SET title = N'x', nickname = N'y' WHERE num = 250;

-- 지우는 것도 마찬가지입니다. 어느 표에서 지울지 정할 수 없습니다.
DELETE FROM Board.V_POST_LIST WHERE num = 250;

-- 첫 문장이 성공했으므로 제목을 되돌립니다.
UPDATE Board.POSTS SET title = N'제약 조건 이렇게 쓰는 게 맞나요' WHERE num = 250;
오류 — 뒤의 둘
메시지 4405 View or function 'Board.V_POST_LIST' is not updatable because the modification affects multiple base tables.
첫 문장은 조용히 성공합니다. 뷰로 고칠 때 무엇이 바뀌는지 알기 어려운 까닭입니다.

뷰를 통한 수정은 권하지 않습니다. 되는 경우와 안 되는 경우의 경계가 뚜렷하지 않고, 되는 쪽은 어느 표가 바뀌는지 문장만 보고 알 수 없습니다. 수정은 4부에서 다룰 저장 프로시저로 하고, 뷰는 조회에 사용하십시오.

함정

뷰에 SELECT * 를 적지 마십시오

뷰를 만들 때 SELECT * 를 적으면 그 시점의 열 목록이 뷰에 새겨집니다. 이후 원본에 열을 더해도 뷰는 모릅니다.

SQL
CREATE VIEW Board.V_STAR AS SELECT * FROM Board.BOARDS;
GO

-- 원본에 열을 하나 더합니다.
ALTER TABLE Board.BOARDS ADD memo nvarchar(100) NULL;

SELECT name ASFROM sys.columns
WHERE object_id = OBJECT_ID('Board.V_STAR') ORDER BY column_id;
결과 — 열을 더한 뒤에도
num
code
name
use_category
use_reply
reg_date
memo 가 없습니다. SELECT * 라고 적었는데 여섯 개에 멈춰 있습니다.

오류가 나지 않고 조용히 옛 목록을 냅니다. 되살리려면 뷰를 다시 해석하게 해야 합니다.

SQL
EXEC sp_refreshview 'Board.V_STAR';   -- 이제 memo 가 나옵니다

더 나쁜 경우가 있습니다. 열을 지우고 다른 열을 더하면 이름과 값이 어긋난 채로 나옵니다.

SQL
CREATE TABLE Board.T_V (a int, b nvarchar(20), c int);
INSERT INTO Board.T_V VALUES (1, N'비', 100), (2, N'비2', 200);
GO
CREATE VIEW Board.V_T AS SELECT * FROM Board.T_V;
GO
-- 가운데 열을 지우고 뒤에 다른 열을 더합니다.
ALTER TABLE Board.T_V DROP COLUMN b;
GO
ALTER TABLE Board.T_V ADD d nvarchar(20) NULL;
GO
UPDATE Board.T_V SET d = N'디' WHERE a = 1;
GO
SELECT * FROM Board.T_V;
SELECT * FROM Board.V_T;
결과 — 원본
acd
1100
2200NULL
결과 — 뷰
abc
1100
2200NULL
b 라는 이름 아래 c 의 값이, c 라는 이름 아래 d 의 값이 나옵니다.

오류가 아니라 잘못된 값입니다. 뷰는 만들 때의 자리 번호를 들고 있고 원본은 자리가 밀렸는데, 아무도 그것을 확인해 주지 않습니다. cint 로 선언된 열인데 글자가 나오는 것도 보십시오.

뷰에는 필요한 열을 적어 두십시오. 열을 적어 두면 원본이 바뀌어도 뷰가 내는 것은 그대로이고, 없어진 열을 적고 있으면 조회할 때 오류로 알려 줍니다.

SCHEMABINDING

원본을 함부로 못 고치게 묶습니다

WITH SCHEMABINDING 을 붙이면 뷰가 참조하는 열을 고치거나 지울 수 없게 됩니다. 뷰가 깨지는 것을 나중이 아니라 그 자리에서 막습니다.

SQL
CREATE VIEW Board.V_SB WITH SCHEMABINDING AS
SELECT num, title, hit_count FROM Board.POSTS;
GO
-- 뷰가 보고 있는 열의 형식을 바꿔 봅니다.
ALTER TABLE Board.POSTS ALTER COLUMN hit_count bigint NOT NULL;

-- 지우는 것도 막힙니다.
ALTER TABLE Board.POSTS DROP COLUMN hit_count;
오류
메시지 5074 The object 'DF_POSTS_hit_count' is dependent on column 'hit_count'. 메시지 5074 The object 'V_SB' is dependent on column 'hit_count'. 메시지 5074 The index 'IX_POSTS_list' is dependent on column 'hit_count'. 메시지 4922 ALTER TABLE ALTER COLUMN hit_count failed because one or more objects access this column.
3.6 에서 계산 열이 막던 것과 같은 5074 입니다. 무엇이 붙잡고 있는지 다 알려 줍니다.

두 가지 얼굴이 있습니다. 지켜 주는 것이면서 발목을 잡는 것이기도 합니다. 열 하나를 고치려면 뷰를 먼저 지우고, 고치고, 다시 만들어야 합니다. 그래서 모든 뷰에 붙이지는 않고, 깨지면 곤란한 뷰인덱싱된 뷰에 붙입니다. 뒤쪽은 선택이 아니라 필수입니다.

SCHEMABINDING 뷰에서는 SELECT * 를 적을 수 없고, 참조하는 표를 Board.POSTS 처럼 두 부분 이름으로 적어야 합니다. 앞 절의 함정을 문법으로 막아 둔 셈입니다.

인덱싱된 뷰

자료를 실제로 담게 만듭니다

여기까지 뷰는 문장일 뿐이었습니다. 클러스터형 인덱스를 걸면 뷰가 낸 결과를 실제로 저장합니다. 게시판별 집계처럼 자주 보면서 매번 세기에는 아까운 것에 사용합니다.

먼저 집계를 그냥 내 봅니다.

SQL
SET STATISTICS IO ON;
SELECT board_num, COUNT_BIG(*) AS post_count, SUM(hit_count) AS hit_sum
FROM Board.POSTS GROUP BY board_num;
SET STATISTICS IO OFF;
결과
board_numpost_counthit_sum
1359,100
231559,462
315729,959
메시지
Table 'POSTS'. Scan count 1, logical reads 16, ...
세 줄을 내려고 507행을 전부 읽었습니다. 글이 늘면 읽는 양도 같이 늡니다.

이것을 뷰로 만들고 인덱스를 걸어 봅니다. 조건이 까다롭습니다.

SQL
-- SCHEMABINDING 을 빠뜨리면
CREATE VIEW Board.V_BOARD_STAT_NS AS
SELECT board_num, COUNT_BIG(*) AS post_count, SUM(hit_count) AS hit_sum
FROM Board.POSTS GROUP BY board_num;
GO
CREATE UNIQUE CLUSTERED INDEX CX_NS ON Board.V_BOARD_STAT_NS (board_num);
GO

-- COUNT_BIG(*) 을 빠뜨리면
CREATE VIEW Board.V_BOARD_STAT_NC WITH SCHEMABINDING AS
SELECT board_num, SUM(hit_count) AS hit_sum
FROM Board.POSTS GROUP BY board_num;
GO
CREATE UNIQUE CLUSTERED INDEX CX_NC ON Board.V_BOARD_STAT_NC (board_num);
오류 — SCHEMABINDING 없음
메시지 1939 Cannot create index on view 'V_BOARD_STAT_NS' because the view is not schema bound.
오류 — COUNT_BIG 없음
메시지 10138 Cannot create index on view 'MssqlLab.Board.V_BOARD_STAT_NC' because its select list does not include a proper use of COUNT_BIG. Consider adding COUNT_BIG(*) to select list.

COUNT_BIG(*) 를 요구하는 것은 원본이 바뀔 때 저장된 집계를 고쳐 넣어야 하기 때문입니다. 글이 하나 늘면 그 게시판의 줄에서 개수를 1 늘리면 되고, 마지막 글이 지워지면 줄 자체를 없애야 합니다. 그 판단에 개수가 필요합니다.

조건을 맞춰 다시 만듭니다.

SQL
CREATE VIEW Board.V_BOARD_STAT WITH SCHEMABINDING AS
SELECT board_num, COUNT_BIG(*) AS post_count, SUM(hit_count) AS hit_sum
FROM Board.POSTS GROUP BY board_num;
GO
CREATE UNIQUE CLUSTERED INDEX CX_V_BOARD_STAT ON Board.V_BOARD_STAT (board_num);

-- 뷰가 자리를 차지하고 있는지 봅니다.
SELECT i.name AS 인덱스, ps.page_count AS 페이지, ps.record_count ASFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.V_BOARD_STAT'), NULL, NULL, 'DETAILED') ps
    JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id;
결과
인덱스페이지
CX_V_BOARD_STAT13
뷰인데 페이지를 가지고 있습니다. 집계 결과 세 줄이 실제로 저장되어 있습니다.

그런데 그냥 조회하면 사용되지 않습니다

SQL
SET STATISTICS IO ON;
SELECT board_num, post_count, hit_sum FROM Board.V_BOARD_STAT;
SELECT board_num, post_count, hit_sum FROM Board.V_BOARD_STAT WITH (NOEXPAND);
SET STATISTICS IO OFF;
결과 — 읽은 표와 페이지
적은 문장읽은 것논리적 읽기
그냥 조회POSTS16
WITH (NOEXPAND)V_BOARD_STAT2
위쪽은 뷰를 펼쳐 원본을 다시 셌습니다. 저장해 둔 것을 사용하지 않았습니다.

인덱싱된 뷰를 자동으로 알아보는 것은 Enterprise 계열뿐입니다. Standard 나 Express 에서는 WITH (NOEXPAND) 를 적어야 저장된 값을 읽습니다. 위 결과는 LocalDB(Express)에서 잰 것이라 차이가 그대로 드러났습니다.

그래서 인덱싱된 뷰는 만들 때 어디에서 돌 것인지 함께 정해야 합니다. 개발 장비가 Developer 에디션이면 힌트 없이 잘 돌다가, 운영이 Standard 면 아무 이득 없이 유지 비용만 물게 됩니다. 힌트를 적으면 두 곳에서 같게 동작합니다.

원본을 고칠 때 함께 갱신됩니다

저장해 두었으니 원본이 바뀌면 맞춰 두어야 합니다. 그 일이 수정 문장 안에서 함께 일어납니다. 3.5 에서 인덱스를 재던 방식으로 로그를 세어 봅니다.

SQL
BEGIN TRAN;
UPDATE Board.POSTS SET hit_count = hit_count + 1 WHERE num BETWEEN 1 AND 100;
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;
결과 — 100행을 고칠 때
상태로그 레코드로그 바이트
인덱싱된 뷰 있음20739,732
인덱싱된 뷰 없음20139,000
집계가 세 줄뿐이라 차이가 작습니다. 줄이 많거나 자주 바뀌면 이 비용이 커집니다.

읽기는 16 이 2 가 되고 쓰기는 201 이 207 이 되었습니다. 조회가 잦고 수정이 드문 집계에 어울리는 까닭입니다. 반대로 글이 초당 여러 건 올라오는 표라면 집계 줄 하나를 두고 모두가 부딪히게 됩니다.

판단

언제 사용하고 언제 사용하지 않습니까

사용하는 자리

  • 같은 조인이 여러 곳에서 반복될 때. 한곳에 적어 두면 조인 조건을 잘못 적는 자리가 하나로 줄어듭니다.
  • 열을 가려서 내보낼 때. Member.USERS 에서 전자 메일을 뺀 뷰를 만들고 그 뷰에만 권한을 주면, 표에는 권한을 주지 않고도 회원 목록을 보여 줄 수 있습니다. 권한은 6.1 에서 다룹니다.
  • 표 구조를 바꾸면서 옛 이름을 지킬 때. 표를 둘로 나눈 뒤 옛 이름의 뷰를 두면, 그 이름을 사용하던 코드가 계속 돕니다.

사용하지 않는 자리

  • 뷰 위에 뷰를 얹는 것. 뷰가 뷰를 부르고 그것이 또 뷰를 부르면, 조회 하나에 표 열 몇 개가 딸려 옵니다. 정작 필요한 것은 두 개인데도 그렇습니다. 이때는 조인 제거도 잘 듣지 않습니다.
  • 수정 통로로 사용하는 것. 앞에서 본 대로 되는 경우와 안 되는 경우의 경계가 흐립니다.
  • 느린 문장을 감추는 것. 뷰는 이름을 줄 뿐 문장을 빠르게 하지 않습니다. 느린 조인을 뷰에 넣으면 느린 뷰가 됩니다.

뷰의 정의는 sys.sql_modules 에서 볼 수 있습니다. SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID('Board.V_POST_LIST') 입니다. 남이 만든 뷰를 조회하기 전에 무엇을 이어 놓았는지 먼저 보십시오.

연습

직접 해보기

1. 전자 메일을 감춘 회원 뷰 난이도 하

Member.USERS 에서 전자 메일을 빼고 나머지를 내는 뷰를 만들어 보세요. 뒤에 열이 늘어도 전자 메일이 새어 나가지 않아야 합니다.

CREATE VIEW Member.V_USER_PUBLIC AS SELECT num, user_id, nickname, reg_date FROM Member.USERS; SELECT COUNT(*) AS 회원수 FROM Member.V_USER_PUBLIC; -- 20 -- SELECT * 로 만들고 email 만 빼는 방법은 없습니다. 열을 적는 수밖에 없고, -- 그것이 오히려 안전합니다. 나중에 phone 같은 열이 늘어도 이 뷰는 넷만 냅니다. -- SELECT * 로 만들었다면 sp_refreshview 를 부르는 순간 새 열이 딸려 나옵니다. DROP VIEW Member.V_USER_PUBLIC;
2. 목록 뷰에 인덱스를 걸 수 있습니까 난이도 중

V_POST_LIST 를 인덱싱된 뷰로 만들면 목록 화면이 빨라질 것 같습니다. WITH SCHEMABINDING 을 붙이고 인덱스를 걸어 보세요. 되지 않는다면 무엇을 고쳐야 합니까.

뷰가 잇는 표 넷 가운데 하나는 다른 방식으로 이어져 있습니다.
-- 걸리지 않습니다. 말머리를 LEFT JOIN 으로 잇고 있기 때문입니다. CREATE VIEW Board.V_LJ WITH SCHEMABINDING AS SELECT p.num, p.title, c.name AS category_name FROM Board.POSTS p LEFT JOIN Board.CATEGORIES c ON c.num = p.category_num; GO CREATE UNIQUE CLUSTERED INDEX CX_LJ ON Board.V_LJ (num); -- 메시지 10113 -- Cannot create index on view "MssqlLab.Board.V_LJ" because it uses a -- LEFT, RIGHT, or FULL OUTER join, and no OUTER joins are allowed in -- indexed views. Consider using an INNER join instead. -- 안쪽 조인만 두면 걸립니다. CREATE VIEW Board.V_IJ WITH SCHEMABINDING AS SELECT p.num, p.title, b.name AS board_name FROM Board.POSTS p JOIN Board.BOARDS b ON b.num = p.board_num; GO CREATE UNIQUE CLUSTERED INDEX CX_IJ ON Board.V_IJ (num); -- 507행 4페이지가 저장됩니다. 글을 한 벌 더 갖는 것과 같습니다. -- 그런데 여기서 멈추고 따져 보십시오. 말머리를 안쪽 조인으로 바꾸면 -- 말머리가 없는 공지사항 35건이 목록에서 통째로 사라집니다(1.9). -- 그리고 3.5 에서 본 대로 IX_POSTS_list 하나로 목록은 이미 2장에 나옵니다. -- 인덱싱된 뷰는 여기에 맞는 도구가 아닙니다. DROP VIEW Board.V_IJ; DROP VIEW Board.V_LJ;

이 단원에서 만든 것을 지웁니다.
DROP VIEW Board.V_POST_LIST, Board.V_NOTICE, Board.V_SB, Board.V_BOARD_STAT, Board.V_STAR, Board.V_T;
ALTER TABLE Board.BOARDS DROP COLUMN memo;
DROP TABLE Board.T_V;

요약
  • 뷰는 저장해 둔 SELECT 문입니다. 자료를 담지 않고 조회할 때 펼쳐집니다.
  • 펼쳐진 뒤에는 직접 적은 문장과 똑같이 최적화됩니다. 가져오지 않는 열의 조인은 아예 실행되지 않습니다(표 넷을 잇는 뷰에서 2장). 외래 키가 신뢰 상태여야 되는 최적화입니다.
  • 뷰로 고칠 수 있지만 한 번에 한 표의 열만입니다(4405). 조건 밖으로 밀어내는 수정은 막히지 않으므로 WITH CHECK OPTION 을 붙입니다(550).
  • 뷰에 SELECT * 를 적지 마십시오. 열 목록이 새겨져 원본과 어긋나고, 오류가 아니라 이름과 값이 뒤바뀐 결과가 나옵니다.
  • WITH SCHEMABINDING 은 원본 열을 잠급니다(5074). 인덱싱된 뷰에는 필수입니다.
  • 인덱싱된 뷰는 결과를 실제로 저장합니다. SCHEMABINDING(1939) · COUNT_BIG(*)(10138) · 안쪽 조인만(10113) 이라는 조건이 붙습니다.
  • Enterprise 계열이 아니면 WITH (NOEXPAND) 를 적어야 저장된 값을 읽습니다(16장 대 2장).
  • 인덱싱된 뷰는 조회가 잦고 수정이 드문 집계에 사용합니다. 원본을 고칠 때마다 함께 갱신되기 때문입니다.