실전 — 통계 집계
일·월 집계 테이블을 두고 배치로 채웁니다.
같은 것을 되풀이해서 셉니다
관리자 화면이 "이번 달 게시판별 글 수" 를 보여 준다고 합시다. 화면을 열 때마다 원본을 다시 셉니다.
SELECT board_num, COUNT(*) AS 글수, SUM(CAST(hit_count AS bigint)) AS 조회합 FROM Board.POSTS_BIG WHERE reg_date >= '2026-06-01' AND reg_date < '2026-07-01' GROUP BY board_num;
| 어디서 세는가 | 논리적 읽기 | CPU |
|---|---|---|
| 원본에서 그때그때 | 4,535 | 16 밀리초 |
| 미리 세어 둔 표에서 | 2 | 0 밀리초 |
지난달 숫자는 다시 바뀌지 않습니다. 한 번 세어 두면 될 것을 볼 때마다 세고 있습니다.
| 집계 표를 두면 | 까닭 |
|---|---|
| 조회가 빨라집니다 | 4,535장이 2장입니다 |
| 원본을 지울 수 있습니다 | 요약이 남아 있으므로(6.6) |
| 과거 숫자가 고정됩니다 | 글을 지워도 그날 통계는 그대로입니다 |
| 원본을 건드리지 않습니다 | 집계 조회가 운영 표를 훑지 않습니다 |
"과거 숫자가 고정된다" 를 먼저 정하십시오. 글을 지우면 그날 글 수도 줄어야 합니까, 아니면 그날 올라온 것은 그대로 세야 합니까. 둘은 다른 숫자이고, 나중에 바꾸기 어렵습니다.
집계 표를 만듭니다
CREATE TABLE Board.STAT_DAILY ( stat_date date NOT NULL, board_num int NOT NULL, post_cnt int NOT NULL CONSTRAINT DF_STAT_DAILY_post DEFAULT 0, comment_cnt int NOT NULL CONSTRAINT DF_STAT_DAILY_cmt DEFAULT 0, user_cnt int NOT NULL CONSTRAINT DF_STAT_DAILY_user DEFAULT 0, hit_sum int NOT NULL CONSTRAINT DF_STAT_DAILY_hit DEFAULT 0, upd_date datetime2(0) NOT NULL CONSTRAINT DF_STAT_DAILY_upd DEFAULT SYSDATETIME(), CONSTRAINT PK_STAT_DAILY PRIMARY KEY CLUSTERED (stat_date, board_num) );
| 정한 것 | 까닭 |
|---|---|
| 기본 키가 (날짜, 게시판) | 묶는 기준이 그대로 키입니다. 중복도 막습니다(3.3) |
| 날짜가 키의 앞 | 기간으로 찾는 조회가 대부분입니다(5.2) |
| 기본값 0 | 셀 것이 없는 날도 0 으로 채웁니다. NULL 을 다루지 않아도 됩니다(1.9) |
| upd_date | 언제 채웠는지 남깁니다. 배치가 멈춘 것을 알아채는 근거입니다 |
| 인덱스를 더 두지 않음 | 행이 적어 필요 없습니다. 필요해지면 그때 잽니다(5.2) |
어느 단위로 셀지가 설계의 전부입니다. 일·게시판으로 두면 "이번 달 게시판별" 도 "오늘 전체" 도 여기서 다시 모을 수 있습니다. 더 잘게 두면 다시 모을 수 있고, 굵게 두면 못 나눕니다 — 망설여지면 잘게 두십시오.
몇 번을 돌려도 같아야 합니다
집계 배치는 반드시 다시 도는 날이 옵니다. 서버가 멈췄거나, 앞 단계가 실패했거나, 자료를 고친 뒤 다시 세야 할 때입니다. 두 번 돌아 숫자가 두 배가 되면 아무도 눈치채지 못합니다.
CREATE OR ALTER PROCEDURE Board.P_STAT_DAILY_BUILD @from date, @to date = NULL, -- 하루만이면 비웁니다 @rows int = 0 OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET @to = ISNULL(@to, @from); IF @to < @from THROW 50030, N'끝 날짜가 시작 날짜보다 빠릅니다.', 1; DECLARE @outer bit = CASE WHEN @@TRANCOUNT > 0 THEN 1 ELSE 0 END; BEGIN TRY IF @outer = 0 BEGIN TRAN; -- 그 기간을 먼저 지웁니다. 이 한 줄이 멱등을 만듭니다(6.3). DELETE FROM Board.STAT_DAILY WHERE stat_date BETWEEN @from AND @to; ;WITH P AS ( -- 글 쪽 (2.5) SELECT CAST(reg_date AS date) AS d, board_num, COUNT(*) AS post_cnt, COUNT(DISTINCT user_num) AS user_cnt, SUM(hit_count) AS hit_sum FROM Board.POSTS WHERE reg_date >= @from AND reg_date < DATEADD(day, 1, @to) -- 범위로(5.4) GROUP BY CAST(reg_date AS date), board_num ), C AS ( -- 댓글 쪽 SELECT CAST(c.reg_date AS date) AS d, p.board_num, COUNT(*) AS comment_cnt FROM Board.COMMENTS c JOIN Board.POSTS p ON p.num = c.post_num WHERE c.reg_date >= @from AND c.reg_date < DATEADD(day, 1, @to) GROUP BY CAST(c.reg_date AS date), p.board_num ) INSERT INTO Board.STAT_DAILY (stat_date, board_num, post_cnt, comment_cnt, user_cnt, hit_sum) SELECT ISNULL(P.d, C.d), ISNULL(P.board_num, C.board_num), ISNULL(P.post_cnt, 0), ISNULL(C.comment_cnt, 0), ISNULL(P.user_cnt, 0), ISNULL(P.hit_sum, 0) FROM P FULL OUTER JOIN C -- 한쪽만 있는 날도 담습니다(2.1) ON C.d = P.d AND C.board_num = P.board_num; SET @rows = @@ROWCOUNT; IF @outer = 0 COMMIT; END TRY BEGIN CATCH IF @outer = 0 AND XACT_STATE() <> 0 ROLLBACK; THROW; END CATCH END
| 집계 | 원본 | |
|---|---|---|
| 글 | 507 | 507 |
| 댓글 | 1,013 | 1,013 |
| 행수 | 글 합계 |
|---|---|
| 124 | 507 |
여기서 정한 것들
| 정한 것 | 까닭 |
|---|---|
| 지우고 넣기 | 가장 단순한 멱등입니다. MERGE 보다 읽기 쉽습니다 |
| 기간을 매개 변수로 | 하루치도, 지난 몇 달도 같은 프로시저로 다시 셉니다 |
| FULL OUTER JOIN | 댓글만 있고 글은 없는 날도 담아야 합니다 |
| 날짜를 범위로 | CAST(reg_date AS date) = @from 은 인덱스를 못 씁니다(5.4) |
| 채운 행수를 OUTPUT 으로 | 부르는 쪽이 성공을 확인할 수 있습니다 |
지우고 넣는 것이 부담스러워지면(하루치가 수백만 행이라면)
MERGE 로 바꿉니다. 다만 먼저 재
보십시오 — 집계 표는 대개 원본보다 훨씬 작아 지우고 넣어도
문제가 없습니다. 위에서는 45일치가 124행이었습니다.
일 집계에서 월 집계를 만듭니다
월 통계를 원본에서 다시 세지 마십시오. 일 집계에서 다시 모으면 됩니다.
CREATE TABLE Board.STAT_MONTHLY ( stat_month char(7) NOT NULL, board_num int NOT NULL, post_cnt int NOT NULL, comment_cnt int NOT NULL, user_cnt int NOT NULL, hit_sum int NOT NULL, CONSTRAINT PK_STAT_MONTHLY PRIMARY KEY CLUSTERED (stat_month, board_num) ); GO INSERT INTO Board.STAT_MONTHLY (stat_month, board_num, post_cnt, comment_cnt, user_cnt, hit_sum) SELECT CONVERT(char(7), stat_date, 23), board_num, SUM(post_cnt), SUM(comment_cnt), MAX(user_cnt), SUM(hit_sum) FROM Board.STAT_DAILY GROUP BY CONVERT(char(7), stat_date, 23), board_num;
| stat_month | board_num | post_cnt | comment_cnt | hit_sum |
|---|---|---|---|---|
| 2026-01 | 1 | 24 | 0 | 6,000 |
| 2026-01 | 2 | 220 | 410 | 39,130 |
| 2026-01 | 3 | 107 | 287 | 19,740 |
| 2026-02 | 2 | 95 | 184 | 20,332 |
다시 모을 수 있는 것과 없는 것을 가리십시오.
SUM 과 COUNT 는 더하면
되지만, COUNT(DISTINCT …) 는
더할 수 없습니다. 위에서 user_cnt 에
MAX 를 쓴 것은 정확하지 않다는
뜻입니다. 월별 순 사용자가 정확해야 한다면
원본에서 따로 세야 합니다.
| 값 | 다시 모을 수 | 어떻게 |
|---|---|---|
| 건수 · 합계 | 있습니다 | SUM |
| 최댓값 · 최솟값 | 있습니다 | MAX · MIN |
| 평균 | 조건부 | 합계와 건수를 함께 담아 두면 됩니다 |
| 순 사용자(DISTINCT) | 없습니다 | 그 단위로 따로 셉니다 |
| 중앙값 · 백분위 | 없습니다 | 원본이 필요합니다 |
평균을 담을 때는 평균이 아니라 합계와 건수를 담으십시오. 평균끼리 평균을 내면 틀립니다. 합계와 건수가 있으면 어느 단위로든 다시 낼 수 있습니다.
언제 어떻게 돌립니까
-- 어제치를 셉니다. 오늘 것은 아직 쌓이는 중입니다(6.6). DECLARE @yesterday date = CAST(DATEADD(day, -1, SYSDATETIME()) AS date); DECLARE @rows int; EXEC Board.P_STAT_DAILY_BUILD @from = @yesterday, @rows = @rows OUTPUT; -- 며칠 전 것까지 함께 다시 세면 늦게 들어온 자료를 잡습니다. EXEC Board.P_STAT_DAILY_BUILD @from = DATEADD(day, -3, @yesterday), @to = @yesterday, @rows = @rows OUTPUT;
| 지킬 것 | 까닭 |
|---|---|
| 어제치까지만 | 오늘 것은 아직 늘어납니다 |
| 며칠 겹쳐서 다시 | 늦게 들어온 자료를 잡습니다. 멱등이라 겹쳐도 됩니다 |
| 한산한 시간에 | 원본을 훑으므로 잠금과 부하가 있습니다(5.5) |
| 실패를 알아채게 | 조용히 멈추면 몇 달 뒤에 압니다 |
| 지우기보다 먼저 | 요약이 끝난 것만 지웁니다(6.6) |
-- 최근 7일 가운데 집계가 빠진 날이 있는지 봅니다. WITH D AS ( SELECT CAST(DATEADD(day, -n, SYSDATETIME()) AS date) AS d FROM (VALUES (1),(2),(3),(4),(5),(6),(7)) v(n) ) SELECT D.d AS 빠진날 FROM D WHERE NOT EXISTS (SELECT 1 FROM Board.STAT_DAILY s WHERE s.stat_date = D.d) ORDER BY D.d; -- 언제 채웠는지도 봅니다. SELECT MAX(stat_date) AS 마지막집계일, MAX(upd_date) AS 마지막실행시각, DATEDIFF(hour, MAX(upd_date), SYSDATETIME()) AS 몇시간전 FROM Board.STAT_DAILY;
SQL Server 에이전트로 돌립니다. Express 에디션에는
에이전트가 없으므로 Windows 작업 스케줄러에서
sqlcmd 를 부르거나 응용 프로그램 쪽
작업으로 두어야 합니다. 어느 쪽이든 실패를 알리는 길을 함께
만드십시오.
직접 해보기
"이 회원이 이번 달에 글 몇 개, 댓글 몇 개를 썼는가" 를 보여 주려 합니다. 집계 표와 채우는 프로시저를 만들어 보세요.
집계 표의 지난달 글 수가 원본보다 적습니다. 어떤 순서로 원인을 찾고 어떻게 고치겠습니까.
이 단원에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_STAT_DAILY_BUILD, Board.P_STAT_USER_BUILD;
DROP TABLE Board.STAT_DAILY, Board.STAT_MONTHLY, Board.STAT_USER_DAILY;
여기까지 왔습니다
1.1 에서 표와 행과 열로 시작해 55과를 지나왔습니다. 마지막 두 단원에서 만든 것은 새 문법이 하나도 없는데 앞의 모든 부를 불러왔습니다.
| 부 | 배운 것 | 실전에서 쓴 자리 |
|---|---|---|
| 1부 기초 | SELECT · INSERT · NULL | 모든 문장의 바탕 |
| 2부 조회 | 조인 · 집계 · CTE · 윈도 | 집계 프로시저(6.8) |
| 3부 설계 | 키 · 인덱스 · 계층 · 스키마 | 게시판 표와 답글 정렬(6.7) |
| 4부 T-SQL | 프로시저 · 오류 · 트랜잭션 | 저장·삭제 프로시저(6.7) |
| 5부 성능 | 계획 · 인덱스 · 잠금 · 페이징 | 목록 프로시저의 모든 결정(6.7) |
| 6부 운영 | 권한 · 백업 · 배포 · 보관 | 만든 것을 실제로 돌리는 일 |
트랙을 관통한 태도가 하나 있습니다 — 재고 나서 고칩니다.
5부의 여덟 단원이 그것이었고, 6부의 배포와 집계도 마찬가지였습니다.
짐작으로 인덱스를 더하거나 시간 초과를 늘리는 대신
SET STATISTICS IO ON 을 켜고 계획을 여는
것이 이 트랙이 남기려 한 습관입니다.
여기서 만든 게시판은 실제로 돌릴 수 있는 한 벌입니다. 실습 데이터베이스를 그대로 두고 프로시저를 늘려 가며 만들어 보십시오. 막히는 자리가 나오면 그 단원으로 돌아가면 됩니다.
- 같은 것을 되풀이해 세지 마십시오. 50만 행에서 4,535장이 2장이 됩니다.
- 집계 표의 기본 키는 묶는 기준 그대로 둡니다. 중복도 막고 조회도 받칩니다.
- 어느 단위로 셀지가 설계의 전부입니다. 잘게 두면 다시 모을 수 있고 굵게 두면 못 나눕니다.
- 집계 배치는 반드시 다시 도는 날이 옵니다. 그 기간을 먼저 지우고 넣으면 몇 번을 돌려도 같습니다 — 두 번 돌려 124행·507건 그대로였습니다.
- 날짜 조건은 범위로 적습니다.
CAST(reg_date AS date) = @from은 인덱스를 사용하지 못합니다(5.4). - 월 집계는 원본이 아니라 일 집계에서 다시 모읍니다.
- 다시 모을 수 있는 값과 없는 값을 가리십시오.
SUM·COUNT·MAX는 되지만COUNT(DISTINCT …)와 중앙값은 안 됩니다. - 평균이 아니라 합계와 건수를 담으십시오. 평균끼리 평균을 내면 틀립니다.
- 어제치까지만 세고, 며칠 겹쳐 다시 세십시오. 멱등이므로 겹쳐도 되고, 늦게 들어온 자료를 잡습니다.
- 맞는지 확인하는 문장을 함께 만드십시오. 원본을 지우고 나면 확인조차 못 합니다(6.6).