MSSQL LAB
MSSQL 6.8 · 6부. 운영과 실전

실전 — 통계 집계

일·월 집계 테이블을 두고 배치로 채웁니다.

예상 학습 시간 24분 난이도 실전
문제

같은 것을 되풀이해서 셉니다

관리자 화면이 "이번 달 게시판별 글 수" 를 보여 준다고 합시다. 화면을 열 때마다 원본을 다시 셉니다.

SQL
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;
결과 — 50만 행에서
어디서 세는가논리적 읽기CPU
원본에서 그때그때4,53516 밀리초
미리 세어 둔 표에서20 밀리초
2,267배입니다. 그리고 이 값은 화면을 열 때마다 듭니다.

지난달 숫자는 다시 바뀌지 않습니다. 한 번 세어 두면 될 것을 볼 때마다 세고 있습니다.

집계 표를 두면까닭
조회가 빨라집니다4,535장이 2장입니다
원본을 지울 수 있습니다요약이 남아 있으므로(6.6)
과거 숫자가 고정됩니다글을 지워도 그날 통계는 그대로입니다
원본을 건드리지 않습니다집계 조회가 운영 표를 훑지 않습니다

"과거 숫자가 고정된다" 를 먼저 정하십시오. 글을 지우면 그날 글 수도 줄어야 합니까, 아니면 그날 올라온 것은 그대로 세야 합니까. 둘은 다른 숫자이고, 나중에 바꾸기 어렵습니다.

설계

집계 표를 만듭니다

SQL
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)

어느 단위로 셀지가 설계의 전부입니다. 일·게시판으로 두면 "이번 달 게시판별" 도 "오늘 전체" 도 여기서 다시 모을 수 있습니다. 더 잘게 두면 다시 모을 수 있고, 굵게 두면 못 나눕니다 — 망설여지면 잘게 두십시오.

채우기

몇 번을 돌려도 같아야 합니다

집계 배치는 반드시 다시 도는 날이 옵니다. 서버가 멈췄거나, 앞 단계가 실패했거나, 자료를 고친 뒤 다시 세야 할 때입니다. 두 번 돌아 숫자가 두 배가 되면 아무도 눈치채지 못합니다.

SQL
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
결과 — 45일치를 한 번에
채운 행: 124 (18 ms)
맞는지 확인합니다
집계원본
507507
댓글1,0131,013
두 번 돌려도
행수글 합계
124507
그 기간을 먼저 지우므로 몇 번을 돌려도 같습니다.

여기서 정한 것들

정한 것까닭
지우고 넣기가장 단순한 멱등입니다. MERGE 보다 읽기 쉽습니다
기간을 매개 변수로하루치도, 지난 몇 달도 같은 프로시저로 다시 셉니다
FULL OUTER JOIN댓글만 있고 글은 없는 날도 담아야 합니다
날짜를 범위로CAST(reg_date AS date) = @from 은 인덱스를 못 씁니다(5.4)
채운 행수를 OUTPUT 으로부르는 쪽이 성공을 확인할 수 있습니다

지우고 넣는 것이 부담스러워지면(하루치가 수백만 행이라면) MERGE 로 바꿉니다. 다만 먼저 재 보십시오 — 집계 표는 대개 원본보다 훨씬 작아 지우고 넣어도 문제가 없습니다. 위에서는 45일치가 124행이었습니다.

쌓기

일 집계에서 월 집계를 만듭니다

월 통계를 원본에서 다시 세지 마십시오. 일 집계에서 다시 모으면 됩니다.

SQL
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_monthboard_numpost_cntcomment_cnthit_sum
2026-0112406,000
2026-01222041039,130
2026-01310728719,740
2026-0229518420,332
STAT_DAILY 를 2장 읽어 만들었습니다. 원본 507행을 다시 훑지 않았습니다.

다시 모을 수 있는 것과 없는 것을 가리십시오. SUMCOUNT 는 더하면 되지만, COUNT(DISTINCT …) 는 더할 수 없습니다. 위에서 user_cntMAX 를 쓴 것은 정확하지 않다는 뜻입니다. 월별 순 사용자가 정확해야 한다면 원본에서 따로 세야 합니다.

다시 모을 수어떻게
건수 · 합계있습니다SUM
최댓값 · 최솟값있습니다MAX · MIN
평균조건부합계와 건수를 함께 담아 두면 됩니다
순 사용자(DISTINCT)없습니다그 단위로 따로 셉니다
중앙값 · 백분위없습니다원본이 필요합니다

평균을 담을 때는 평균이 아니라 합계와 건수를 담으십시오. 평균끼리 평균을 내면 틀립니다. 합계와 건수가 있으면 어느 단위로든 다시 낼 수 있습니다.

운영

언제 어떻게 돌립니까

SQL — 매일 새벽에 부를 것
-- 어제치를 셉니다. 오늘 것은 아직 쌓이는 중입니다(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)
SQL — 배치가 멈춘 것을 찾습니다
-- 최근 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;
이렇게 두면
빠진 날이 나오면 그 날짜로 다시 부르면 됩니다. 멱등이므로 이미 있는 날을 함께 불러도 괜찮습니다. 몇시간전이 30 을 넘으면 배치가 멈춘 것입니다. 그 한 줄을 매일 확인하는 작업으로 만들어 두십시오(6.2 의 연습 1).
글이 하나도 없는 날은 원래 행이 없습니다. 그것과 배치 실패를 구분하려면 upd_date 를 함께 보십시오.

SQL Server 에이전트로 돌립니다. Express 에디션에는 에이전트가 없으므로 Windows 작업 스케줄러에서 sqlcmd 를 부르거나 응용 프로그램 쪽 작업으로 두어야 합니다. 어느 쪽이든 실패를 알리는 길을 함께 만드십시오.

연습

직접 해보기

1. 회원별 활동 집계를 더합니다 난이도 하

"이 회원이 이번 달에 글 몇 개, 댓글 몇 개를 썼는가" 를 보여 주려 합니다. 집계 표와 채우는 프로시저를 만들어 보세요.

CREATE TABLE Board.STAT_USER_DAILY ( stat_date date NOT NULL, user_num int NOT NULL, post_cnt int NOT NULL CONSTRAINT DF_SUD_post DEFAULT 0, comment_cnt int NOT NULL CONSTRAINT DF_SUD_cmt DEFAULT 0, upd_date datetime2(0) NOT NULL CONSTRAINT DF_SUD_upd DEFAULT SYSDATETIME(), CONSTRAINT PK_STAT_USER_DAILY PRIMARY KEY CLUSTERED (stat_date, user_num) ); GO -- 회원 화면에서 "내 활동" 을 보여 준다면 회원으로 먼저 찾습니다. CREATE INDEX IX_STAT_USER_DAILY_user ON Board.STAT_USER_DAILY (user_num, stat_date DESC); GO CREATE OR ALTER PROCEDURE Board.P_STAT_USER_BUILD @from date, @to date = NULL, @rows int = 0 OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SET @to = ISNULL(@to, @from); DECLARE @outer bit = CASE WHEN @@TRANCOUNT > 0 THEN 1 ELSE 0 END; BEGIN TRY IF @outer = 0 BEGIN TRAN; DELETE FROM Board.STAT_USER_DAILY WHERE stat_date BETWEEN @from AND @to; ;WITH P AS ( SELECT CAST(reg_date AS date) AS d, user_num, COUNT(*) AS c FROM Board.POSTS WHERE reg_date >= @from AND reg_date < DATEADD(day, 1, @to) GROUP BY CAST(reg_date AS date), user_num ), C AS ( SELECT CAST(reg_date AS date) AS d, user_num, COUNT(*) AS c FROM Board.COMMENTS WHERE reg_date >= @from AND reg_date < DATEADD(day, 1, @to) GROUP BY CAST(reg_date AS date), user_num ) INSERT INTO Board.STAT_USER_DAILY (stat_date, user_num, post_cnt, comment_cnt) SELECT ISNULL(P.d, C.d), ISNULL(P.user_num, C.user_num), ISNULL(P.c, 0), ISNULL(C.c, 0) FROM P FULL OUTER JOIN C ON C.d = P.d AND C.user_num = P.user_num; SET @rows = @@ROWCOUNT; IF @outer = 0 COMMIT; END TRY BEGIN CATCH IF @outer = 0 AND XACT_STATE() <> 0 ROLLBACK; THROW; END CATCH END GO -- 이번 달 내 활동 SELECT SUM(post_cnt) AS 글, SUM(comment_cnt) AS 댓글 FROM Board.STAT_USER_DAILY WHERE user_num = 7 AND stat_date >= DATEFROMPARTS(YEAR(SYSDATETIME()), MONTH(SYSDATETIME()), 1); -- STAT_DAILY 와 다른 점 -- 묶는 기준이 게시판이 아니라 회원입니다. -- 인덱스를 하나 더 두었습니다 — 회원으로 찾는 조회가 주력이기 때문입니다. -- 키는 (날짜, 회원) 이고 인덱스는 (회원, 날짜 DESC) 입니다(5.2). -- 회원이 늘면 행도 늘어납니다. -- 회원 10만 명이 매일 활동하면 하루 10만 행입니다. -- 그럴 때는 "활동한 회원만" 담는 지금 방식이 맞습니다. -- 활동이 없는 회원은 행 자체가 없습니다.
2. 집계 숫자가 원본과 다릅니다 난이도 중

집계 표의 지난달 글 수가 원본보다 적습니다. 어떤 순서로 원인을 찾고 어떻게 고치겠습니까.

"적다" 는 것이 실마리입니다. 많은 것이 아니라 적습니다. 무엇이 빠졌겠습니까.
-- 1. 어느 날이 어긋나는지 좁힙니다. SELECT d.stat_date, d.post_cnt AS 집계, (SELECT COUNT(*) FROM Board.POSTS p WHERE p.board_num = d.board_num AND p.reg_date >= d.stat_date AND p.reg_date < DATEADD(day, 1, d.stat_date)) AS 원본, d.board_num, d.upd_date FROM Board.STAT_DAILY d WHERE d.stat_date >= '2026-01-01' AND d.stat_date < '2026-02-01' AND d.post_cnt <> (SELECT COUNT(*) FROM Board.POSTS p WHERE p.board_num = d.board_num AND p.reg_date >= d.stat_date AND p.reg_date < DATEADD(day, 1, d.stat_date)) ORDER BY d.stat_date; -- 2. 아예 빠진 날도 봅니다. 위 문장은 있는 행만 견줍니다. SELECT DISTINCT CAST(p.reg_date AS date) AS 원본에만있는날 FROM Board.POSTS p WHERE p.reg_date >= '2026-01-01' AND p.reg_date < '2026-02-01' AND NOT EXISTS (SELECT 1 FROM Board.STAT_DAILY s WHERE s.stat_date = CAST(p.reg_date AS date)); -- 흔한 원인 넷 -- (가) 배치가 그날 돌지 않았습니다. -- upd_date 를 보면 압니다. 그 날짜로 다시 부르면 끝입니다. -- (나) 배치가 도는 도중에 들어온 글이 빠졌습니다. -- "어제치를 오늘 새벽에" 라면 생기지 않습니다. -- 오늘치를 오늘 세면 그 뒤에 들어온 것이 빠집니다. -- (다) 늦게 들어온 자료입니다. -- reg_date 를 나중에 고치거나 옛 날짜로 넣는 경로가 있으면 -- 이미 센 날의 숫자가 바뀝니다. -- → 며칠 겹쳐 다시 세는 것이 대책입니다. -- (라) 조건이 어긋났습니다. -- BETWEEN '2026-01-01' AND '2026-01-31' 로 적었다면 -- 31일 00시 이후가 빠집니다(5.4). -- 3. 고칩니다. 멱등이므로 다시 부르면 됩니다. DECLARE @r int; EXEC Board.P_STAT_DAILY_BUILD @from = '2026-01-01', @to = '2026-01-31', @rows = @r OUTPUT; -- 4. 되풀이되지 않게 합니다. -- 위 확인 문장을 매일 도는 작업으로 만들어 -- 어긋나는 날이 나오면 그 날짜로 자동으로 다시 세게 합니다. -- 그리고 며칠 겹쳐 세는 것을 기본으로 둡니다. -- 배울 것 -- 집계는 "맞는지 확인하는 문장" 을 함께 만들어야 합니다. -- 원본을 지우고 나면 그 확인조차 못 하게 되므로(6.6), -- 지우기 전에 반드시 맞춰 보십시오.

이 단원에서 만든 것을 지웁니다.
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).