MSSQL 3.6 · 3부. 설계

계산 열

다른 열에서 값을 이끌어 냅니다. PERSISTED 와 인덱스를 걸 때의 전제, 그리고 3.5 에서 미뤄 둔 문제의 해법입니다.

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

다른 열에서 값을 이끌어 냅니다

글이 몇 월에 올라왔는지, 첨부가 몇 KB 인지는 이미 표 안에 있는 값에서 나옵니다. reg_datesize_bytes 가 있으면 그만입니다. 그런데도 MONTH(reg_date) 를 문장마다 적는 것은 번거롭고, 적는 사람마다 다르게 적으면 결과가 어긋납니다.

계산 열은 그 식을 표에 적어 두는 것입니다. 열처럼 보이지만 값을 담고 있지는 않습니다.

SQL
ALTER TABLE Board.POSTS ADD reg_month AS MONTH(reg_date);

SELECT cc.name AS 열, cc.definition AS 정의, t.name AS 형식,
       cc.is_persisted AS 저장됨, cc.is_nullable AS NULL허용
FROM sys.computed_columns cc
    JOIN sys.types t ON t.user_type_id = cc.user_type_id
WHERE cc.object_id = OBJECT_ID('Board.POSTS');
결과
정의형식저장됨NULL허용
reg_month(datepart(month,[reg_date]))int01
형식을 적지 않았는데 int 로 정해졌습니다. 식이 무엇을 내는지 보고 엔진이 결정합니다.

MONTH 라고 적었는데 정의에는 datepart(month, …) 로 들어갔습니다. 같은 뜻이고, SQL Server 가 안쪽 표현으로 바꿔 담은 것입니다.

이 열이 표를 얼마나 무겁게 했는지 봅니다.

SQL
SELECT ps.page_count AS 페이지, ps.page_count * 8 AS KB, ps.record_count AS 행,
       CONVERT(decimal(5,1), ps.avg_record_size_in_bytes) AS 행바이트
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.POSTS'), 1, NULL, 'DETAILED') ps
WHERE ps.index_level = 0;
결과
잰 때페이지KB행바이트
계산 열을 더하기 전1296507177.8
계산 열을 더한 뒤1296507177.8
한 바이트도 늘지 않았습니다. 값을 담지 않고 읽을 때마다 계산하기 때문입니다.

읽을 때 계산합니다. 그래서 공간을 쓰지 않고, reg_date 를 고치면 다음에 읽을 때 자동으로 새 값이 나옵니다. 어긋날 여지가 없습니다.

대신 값을 직접 넣을 수는 없습니다.

SQL
INSERT INTO Board.POSTS (board_num, user_num, group_num, depth, sort_no,
                         title, hit_count, reg_date, reg_month)
VALUES (1, 1, 999999, 0, 1, N'임시 글', 0, '2026-01-01', 1);
오류
메시지 271 The column "reg_month" cannot be modified because it is either a computed column or is the result of a UNION operator.

SELECT * 에는 계산 열도 함께 나옵니다. 표에 계산 열을 더하면 SELECT * 로 받던 응용 프로그램의 결과 모양이 바뀝니다. 열을 적어서 가져오는 편이 안전한 까닭이 하나 더 있는 셈입니다.

범위

같은 행 안의 값만 봅니다

3.4 의 CHECK 와 같은 제약이 있습니다. 계산 열의 식은 그 행 안에서 끝나야 합니다. 다른 행이나 다른 표를 보려 하면 만들어지지 않습니다.

SQL
-- 다른 행의 평균을 보려 합니다.
ALTER TABLE Board.POSTS ADD is_hot AS
    (CASE WHEN hit_count > (SELECT AVG(hit_count) FROM Board.POSTS) THEN 1 ELSE 0 END);

-- 다른 표를 보려 합니다.
ALTER TABLE Board.POSTS ADD board_code AS
    (SELECT code FROM Board.BOARDS WHERE num = board_num);
오류 — 둘 다
메시지 1046 Subqueries are not allowed in this context. Only scalar expressions are allowed.

같은 행 안이면 됩니다. 게시판에서 자주 필요한 원글과 답글 구분이 그렇습니다.

SQL
ALTER TABLE Board.POSTS ADD is_reply AS
    (CASE WHEN parent_num IS NULL THEN 0 ELSE 1 END);

SELECT is_reply, COUNT(*) AS 글수 FROM Board.POSTS GROUP BY is_reply;
결과
is_reply글수
0350
1157
원글 350, 답글과 답답글을 합쳐 157 입니다.

parent_num IS NULL 을 문장마다 적는 대신 이름을 하나 두었습니다. 규칙을 표에 적어 두면 읽는 사람이 뜻을 다시 해석하지 않아도 됩니다.

인덱스

3.5 에서 미뤄 둔 것

3.5 끝에서 열을 함수로 감싸면 인덱스를 사용하지 못한다고 했습니다. reg_date 로 정렬된 인덱스는 MONTH(reg_date) 값으로 정렬되어 있지 않기 때문입니다. 실습 데이터에서 2월 글을 세어 봅니다.

SQL
SET STATISTICS IO ON;
SELECT COUNT(*) FROM Board.POSTS WHERE MONTH(reg_date) = 2;
SET STATISTICS IO OFF;
메시지
Table 'POSTS'. Scan count 1, logical reads 14, ...
2월 글은 156건입니다. 그것을 세려고 표를 처음부터 끝까지 읽었습니다.

계산 열에는 인덱스를 걸 수 있습니다. 식의 결과로 정렬된 구조를 만드는 것이므로, 함수를 씌운 조건도 탐색이 됩니다.

SQL
CREATE NONCLUSTERED INDEX IX_POSTS_month ON Board.POSTS (reg_month);

SET STATISTICS IO ON;
-- 계산 열 이름으로 적습니다.
SELECT COUNT(*) FROM Board.POSTS WHERE reg_month = 2;

-- 계산 열이 있는 줄 모르고 원래 식 그대로 적습니다.
SELECT COUNT(*) FROM Board.POSTS WHERE MONTH(reg_date) = 2;
SET STATISTICS IO OFF;
결과 — 읽은 페이지
적은 조건인덱스 전인덱스 후
reg_month = 22
MONTH(reg_date) = 2142
아래 줄이 이 단원의 핵심입니다. 문장을 하나도 고치지 않았는데 2 로 줄었습니다.

식을 그대로 적어도 엔진이 계산 열을 알아봅니다. 조건에 적힌 식과 계산 열의 정의가 같으면 인덱스를 사용합니다. 계획으로 확인합니다.

SQL
SET SHOWPLAN_TEXT ON;
GO
SELECT COUNT(*) FROM Board.POSTS WHERE MONTH(reg_date) = 2;
GO
SET SHOWPLAN_TEXT OFF;
결과
|--Compute Scalar(DEFINE:([Expr1002]=CONVERT_IMPLICIT(int,[Expr1004],0))) |--Stream Aggregate(DEFINE:([Expr1004]=Count(*))) |--Index Seek(OBJECT:([POSTS].[IX_POSTS_month]), SEEK:([POSTS].[reg_month]=(2)) ORDERED FORWARD)
문장에 없던 reg_month 가 계획에 나옵니다. 데이터베이스와 스키마 이름은 줄여 옮겼습니다.

이미 돌고 있는 응용 프로그램을 고치지 않고 조회를 빠르게 만드는 방법입니다. 코드를 배포하지 않고 데이터베이스에서만 처리할 수 있어, 느린 문장을 당장 손볼 수 없을 때 사용할 수 있는 수단이 됩니다.

다만 식이 정확히 같아야 합니다. DATEPART(month, reg_date) 는 같은 것으로 보지만, DATENAME(month, reg_date)CONVERT(char(2), reg_date, 110) 처럼 결과가 다른 식은 물론 알아보지 못합니다. 근본은 문장을 고치는 것이고, 이것은 그때까지의 방법입니다. 조건을 어떻게 고쳐 적는지는 5.4 에서 다룹니다.

PERSISTED

값을 실제로 저장합니다

인덱스를 걸려면 조건이 둘 있습니다. 같은 입력에 늘 같은 값을 내야 하고(결정적), 소수점 오차가 없어야 합니다(정확). 첨부 크기의 제곱근처럼 부동 소수점을 내는 식은 뒤쪽에 걸립니다.

SQL
ALTER TABLE Board.FILES ADD size_root AS SQRT(size_bytes);
CREATE NONCLUSTERED INDEX IX_FILES_root ON Board.FILES (size_root);
오류
메시지 2799 Cannot create index or statistics 'IX_FILES_root' on table 'Board.FILES' because the computed column 'size_root' is imprecise and not persisted. Consider removing column from index or statistics key or marking computed column persisted.
오류가 해법까지 적어 주고 있습니다.

PERSISTED 를 붙이면 계산해서 나온 값을 실제로 저장합니다. 저장된 값이므로 읽을 때마다 다시 계산하지 않고, 그래서 인덱스를 걸 수 있습니다.

SQL
ALTER TABLE Board.FILES DROP COLUMN size_root;
ALTER TABLE Board.FILES ADD size_root AS SQRT(size_bytes) PERSISTED;
CREATE NONCLUSTERED INDEX IX_FILES_root ON Board.FILES (size_root);   -- 만들어집니다

결정적이지 않은 식은 PERSISTED 로도 구제되지 않습니다.

SQL
-- 며칠 지났는지. 오늘이 언제냐에 따라 값이 달라집니다.
ALTER TABLE Board.FILES ADD age_days AS
    DATEDIFF(day, reg_date, GETDATE()) PERSISTED;
오류
메시지 4936 Computed column 'age_days' in table 'FILES' cannot be persisted because the column is non-deterministic.
PERSISTED 를 떼면 만들어집니다. 다만 읽을 때마다 값이 달라지므로 여기에 결과를 싣지 않았습니다.

저장하면 값을 저장하는 만큼 표가 무거워집니다. 제목에서 공백을 없앤 열을 붙여 재 봅니다.

SQL
ALTER TABLE Board.POSTS ADD title_norm AS
    UPPER(REPLACE(title, N' ', N'')) PERSISTED;

SELECT ps.page_count AS 페이지,
       CONVERT(decimal(5,1), ps.avg_record_size_in_bytes) AS 행바이트,
       CONVERT(decimal(5,1), ps.avg_fragmentation_in_percent) AS 단편화,
       CONVERT(decimal(5,1), ps.avg_page_space_used_in_percent) AS 사용률
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Board.POSTS'), 1, NULL, 'DETAILED') ps
WHERE ps.index_level = 0;

-- 붙인 뒤 다시 만듭니다.
ALTER INDEX PK_POSTS ON Board.POSTS REBUILD;
결과
잰 때페이지행바이트단편화사용률
붙이기 전12177.816.793.8
PERSISTED 를 붙인 직후23205.587.056.5
다시 만든 뒤14205.50.092.8
507행짜리 표입니다. 12장이 23장이 되었다가 14장으로 내려앉습니다.

행이 27바이트 늘었으니 페이지는 14장이 되는 것이 맞습니다. 23장은 그 과정에서 생긴 것입니다. 이미 차 있는 페이지마다 값을 밀어 넣어야 하니 3.2 에서 본 페이지 분할이 일어나고, 단편화가 87.0% 까지 올라갑니다. 사용률 56.5% 는 페이지 절반이 비었다는 뜻입니다.

운영 중인 표에 PERSISTED 계산 열을 더했다면 인덱스를 다시 만드십시오. 크기가 작을 때는 이 정도로 끝나지만, 큰 표에서는 붙이는 동안 표가 잠기고 로그가 크게 늡니다. 3.10 과 6.3 에서 다시 다룹니다.

함정

SET 옵션이 어긋나면 수정도 막힙니다

계산 열에 인덱스를 걸었다면 그 표를 다루는 연결의 SET 옵션이 정해진 값이어야 합니다. 식의 결과가 옵션에 따라 달라질 수 있어, 저장된 인덱스와 어긋나는 것을 막기 위함입니다.

SQL
SET QUOTED_IDENTIFIER OFF;
CREATE NONCLUSTERED INDEX IX_POSTS_reply ON Board.POSTS (is_reply);

-- 인덱스를 만드는 것만 막히는 것이 아닙니다.
UPDATE Board.POSTS SET hit_count = hit_count WHERE num = 1;
오류 — CREATE INDEX
메시지 1934 CREATE INDEX failed because the following SET options have incorrect settings: 'QUOTED_IDENTIFIER'.
오류 — UPDATE
메시지 1934 UPDATE failed because the following SET options have incorrect settings: 'QUOTED_IDENTIFIER'.
인덱스가 걸린 계산 열이 있는 표는 옵션이 어긋난 연결에서 고칠 수 없습니다.

도구가 무엇으로 붙느냐에 따라 갈립니다. SSMS 와 최신 드라이버는 알맞은 값으로 붙지만, sqlcmd-I 옵션을 주지 않으면 QUOTED_IDENTIFIER 가 꺼진 채 연결합니다. 배포 스크립트를 그렇게 돌리면 실행은 성공하는데 나중에 수정이 막히는 상태가 됩니다. 필요한 옵션은 여섯이고 (ANSI_NULLS · ANSI_PADDING · ANSI_WARNINGS · ARITHABORT · CONCAT_NULL_YIELDS_NULL · QUOTED_IDENTIFIER) 모두 켜져 있어야 하며, NUMERIC_ROUNDABORT 는 꺼져 있어야 합니다.

연습

직접 해보기

1. 첨부 크기를 KB 로 냅니다 난이도 하

Board.FILESsize_bytes 는 바이트 단위라 화면에 그대로 내기 어렵습니다. KB 로 내는 계산 열을 더해 보세요. 확인한 뒤에는 지웁니다.

ALTER TABLE Board.FILES ADD size_kb AS size_bytes / 1024; SELECT TOP 5 num, origin_name, size_bytes, size_kb FROM Board.FILES ORDER BY num; -- num origin_name size_bytes size_kb -- 1 첨부_5.pdf 7168 7 -- 2 첨부_10.xlsx 12288 12 -- 3 첨부_15.zip 17408 17 -- size_bytes 가 bigint 라 나눗셈도 정수로 끝납니다. 소수점을 남기려면 -- size_bytes / 1024.0 처럼 적어야 하고, 그러면 형식이 numeric 이 됩니다. -- PERSISTED 를 붙이지 않았으므로 공간은 늘지 않습니다. ALTER TABLE Board.FILES DROP COLUMN size_kb;
2. 무엇을 계산 열로 만들 수 있습니까 난이도 중

아래 넷을 인덱스까지 걸 수 있는 것 · 계산 열은 되지만 인덱스는 안 되는 것 · 계산 열 자체가 안 되는 것으로 나누어 보세요.

  • (가) 첨부 파일 이름의 글자 수
  • (나) 그 글이 속한 게시판의 이름
  • (다) 조회수가 전체 평균보다 높은지
  • (라) 등록일로부터 며칠 지났는지
두 가지만 따지면 됩니다. 그 행 안에서 끝나는가, 그리고 언제 계산해도 같은 값인가.
(가) 인덱스까지 됩니다 ALTER TABLE Board.FILES ADD name_len AS LEN(origin_name); CREATE NONCLUSTERED INDEX IX_FILES_len ON Board.FILES (name_len); -- 같은 행 안이고, LEN 은 결정적이며 정수라 정확합니다. (라) 계산 열은 되지만 인덱스는 안 됩니다 ALTER TABLE Board.FILES ADD age_days AS DATEDIFF(day, reg_date, GETDATE()); -- GETDATE 가 있어 오늘이 언제냐에 따라 값이 달라집니다(비결정적). -- PERSISTED 를 붙이면 4936, 인덱스를 걸려 하면 거부됩니다. (나) (다) 계산 열 자체가 안 됩니다 -- (나)는 Board.BOARDS 를, (다)는 다른 행을 봐야 합니다. -- 둘 다 서브쿼리가 필요하므로 1046 으로 거부됩니다. -- (나)는 JOIN 으로, (다)는 윈도 함수(2.7)로 조회할 때 냅니다. DROP INDEX IX_FILES_len ON Board.FILES; ALTER TABLE Board.FILES DROP COLUMN name_len, age_days;

이 단원에서 더한 열과 인덱스를 지웁니다.
DROP INDEX IX_POSTS_month ON Board.POSTS;
ALTER TABLE Board.POSTS DROP COLUMN reg_month, is_reply, title_norm;
DROP INDEX IX_FILES_root ON Board.FILES;
ALTER TABLE Board.FILES DROP COLUMN size_root;
ALTER INDEX PK_POSTS ON Board.POSTS REBUILD;

요약
  • 계산 열은 식을 표에 적어 두는 것입니다. 값을 담지 않고 읽을 때 계산하므로 공간을 사용하지 않습니다.
  • 값을 직접 넣을 수 없습니다(271). SELECT * 에는 함께 나옵니다.
  • 식은 같은 행 안에서 끝나야 합니다. 다른 행이나 다른 표를 보려 하면 1046 입니다.
  • 계산 열에 인덱스를 걸면 식을 그대로 적은 문장도 그 인덱스를 사용합니다. 3.5 에서 미뤄 둔 문제의 해법이고, 응용 프로그램을 고치지 않고 적용할 수 있습니다.
  • 인덱스를 걸려면 결정적이고 정확해야 합니다. 부정확한 식은 PERSISTED 로 구제되지만(2799), 비결정적인 식은 그것도 안 됩니다(4936).
  • PERSISTED 는 값을 저장하므로 표가 무거워집니다. 507행에서 12장이 14장이 되었고, 붙이는 동안 단편화가 87% 까지 올랐습니다 — 붙인 뒤 인덱스를 다시 만드십시오.
  • 계산 열에 인덱스가 걸린 표는 SET 옵션이 어긋난 연결에서 수정도 막힙니다(1934). sqlcmd 로 배포한다면 -I 를 빠뜨리지 마십시오.