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

실전 — 게시판 프로시저 세트

목록·상세·저장·답글·삭제를 한 벌로 만듭니다.

예상 학습 시간 28분 난이도 실전
들어가며

배운 것을 한 벌로 모읍니다

여기서 새로 배우는 문법은 없습니다. 지금까지 나눠서 본 것들을 실제로 쓸 수 있는 한 벌로 묶습니다.

프로시저하는 일쓰는 것
P_POST_LIST목록·검색·다음 쪽계층 정렬(3.9) · 키셋 페이징(5.6)
P_POST_SELECT상세와 댓글결과 집합 둘(4.3)
P_POST_SAVE새 글·답글·고치기트랜잭션(4.6) · 오류(4.5) · 잠금(5.5)
P_POST_DELETE지우기권한 확인 · 자리 메우기

표는 3부에서 만든 것을 그대로 씁니다. Board.POSTS 에 계층 열 (parent_num · group_num · depth · sort_no)이 있고, IX_POSTS_list 가 목록 정렬을 받칩니다.

목록

한 프로시저로 목록·검색·다음 쪽

SQL
CREATE OR ALTER PROCEDURE Board.P_POST_LIST
    @board_num    int,
    @category_num int = NULL,          -- 말머리로 거를 때
    @keyword      nvarchar(100) = NULL, -- 제목 검색
    @group_num    int = NULL,          -- 앞 쪽 마지막 행의 키(5.6)
    @sort_no      int = NULL,
    @size         int = 20
AS
BEGIN
    SET NOCOUNT ON;

    SELECT TOP (@size + 1)                  -- 한 건 더 — 다음 쪽 여부(5.6)
        p.num, p.board_num, p.category_num, p.user_num,
        p.group_num, p.depth, p.sort_no,
        p.title, p.hit_count, p.reg_date, p.mod_date,
        u.nickname,
        (SELECT COUNT(*) FROM Board.COMMENTS c
         WHERE c.post_num = p.num) AS comment_count
    FROM Board.POSTS p
        JOIN Member.USERS u ON u.num = p.user_num
    WHERE p.board_num = @board_num
      AND (@category_num IS NULL OR p.category_num = @category_num)
      AND (@keyword IS NULL OR p.title LIKE N'%' + @keyword + N'%')
      AND (@group_num IS NULL                    -- 첫 쪽
           OR (p.group_num <= @group_num           -- 범위를 따로(5.4 · 5.6)
               AND (p.group_num < @group_num OR p.sort_no > @sort_no)))
    ORDER BY p.group_num DESC, p.sort_no
    OPTION (RECOMPILE);                     -- 매개 변수가 여럿이라(5.3)
END
결과 — 논리적 읽기
부르는 법POSTSUSERSCOMMENTS
첫 쪽154242
다음 쪽154242
제목 검색15
말머리로 거르기154242
어느 쪽을 봐도 POSTS 15장입니다. 키셋이라 깊이와 상관이 없습니다(5.6).

여기서 정한 것들

정한 것까닭
TOP (@size + 1)총 건수를 세지 않고 다음 쪽 여부만 봅니다(5.6)
범위 조건을 따로 적음그래야 SEEK 에 들어갑니다(5.4)
OPTION (RECOMPILE)매개 변수 조합마다 최적 계획이 다릅니다(5.3)
본문(content)을 내지 않음목록에 필요 없습니다. 커버링에서 빠집니다(5.2)
댓글 수는 상관 서브쿼리조인하면 글이 중복됩니다(2.4)

댓글 수 42장이 눈에 걸립니다. 21행마다 한 번씩 세기 때문입니다. 목록이 자주 열린다면 Board.POSTS 에 댓글 수 열을 두고 트리거로 맞추는 편이 낫습니다(3.6 · 4.7). 지금은 507행이라 문제가 없지만, 그 판단은 재 보고 하십시오.

검색을 LIKE N'%…%' 로 둔 것도 임시입니다. 5.4 에서 본 대로 앞에 % 가 붙으면 인덱스를 사용하지 못합니다. 글이 수십만 건이 되면 전문 검색으로 바꿔야 합니다(5.8).

상세

결과 집합을 둘 냅니다

SQL
CREATE OR ALTER PROCEDURE Board.P_POST_SELECT
    @num int,
    @hit bit = 1            -- 미리보기에서는 0 으로 부릅니다
AS
BEGIN
    SET NOCOUNT ON;

    IF @hit = 1
        UPDATE Board.POSTS SET hit_count = hit_count + 1 WHERE num = @num;

    -- 첫째 — 글
    SELECT p.num, p.board_num, p.category_num, p.user_num,
           p.parent_num, p.group_num, p.depth, p.sort_no,
           p.title, p.content, p.hit_count, p.reg_date, p.mod_date,
           u.nickname
    FROM Board.POSTS p
        JOIN Member.USERS u ON u.num = p.user_num
    WHERE p.num = @num;

    -- 둘째 — 댓글
    SELECT c.num, c.user_num, c.content, c.reg_date, u.nickname
    FROM Board.COMMENTS c
        JOIN Member.USERS u ON u.num = c.user_num
    WHERE c.post_num = @num
    ORDER BY c.num;
END
결과 — 논리적 읽기
읽기
POSTS2
USERS2 + 2
COMMENTS11
한 번의 왕복으로 글과 댓글을 함께 가져옵니다. 응용 프로그램에서는 NextResult 로 넘깁니다.

결과 집합을 둘 내면 왕복이 하나로 줄어듭니다. 응용 프로그램에서는 이렇게 받습니다.

C# (6.5)
await using var r = await cmd.ExecuteReaderAsync();

if (await r.ReadAsync())
    post = ReadPost(r);              // 첫째 — 글

if (await r.NextResultAsync())    // 둘째로 넘어갑니다
    while (await r.ReadAsync())
        comments.Add(ReadComment(r));

조회수를 여기서 올리는 것이 걸립니다. 5.5 에서 본 대로 인기 글일수록 그 한 줄에 사람이 몰립니다. @hit 매개 변수를 둔 것은 미리보기나 관리자 화면에서 올리지 않기 위해서이고, 사람이 몰리는 곳이라면 모았다가 반영하는 쪽으로 바꿔야 합니다(5.7 의 연습 1).

저장

새 글·답글·고치기를 하나로

셋을 따로 만들 수도 있지만, 번호가 있으면 고치기, 없으면 새 글, 부모가 있으면 답글로 갈라 하나에 담았습니다.

SQL
CREATE OR ALTER PROCEDURE Board.P_POST_SAVE
    @num          int = NULL,          -- 있으면 고치기
    @board_num    int,
    @category_num int = NULL,
    @user_num     int,
    @parent_num   int = NULL,          -- 있으면 답글
    @title        nvarchar(200),
    @content      nvarchar(max) = NULL,
    @result       int = 0 OUTPUT       -- 글 번호를 돌려줍니다
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;                       -- 4.5

    IF LEN(LTRIM(RTRIM(@title))) = 0
        THROW 50010, N'제목을 입력하십시오.', 1;

    DECLARE @outer bit = CASE WHEN @@TRANCOUNT > 0 THEN 1 ELSE 0 END;  -- 4.6

    BEGIN TRY
        IF @outer = 0 BEGIN TRAN;

        /* 고치기 */
        IF @num IS NOT NULL
        BEGIN
            UPDATE Board.POSTS
            SET category_num = @category_num,
                title        = @title,
                content      = @content,
                mod_date     = SYSDATETIME()
            WHERE num = @num AND user_num = @user_num;   -- 권한을 조건에

            IF @@ROWCOUNT = 0
                THROW 50011, N'글이 없거나 고칠 권한이 없습니다.', 1;

            SET @result = @num;
        END
        /* 새 글 · 답글 */
        ELSE
        BEGIN
            DECLARE @group int, @depth smallint, @sort int;

            IF @parent_num IS NULL
            BEGIN
                SET @depth = 0;  SET @sort = 0;
            END
            ELSE
            BEGIN
                -- UPDLOCK 으로 잡아 두 사람이 같은 자리를 노리지 않게 합니다(5.5).
                SELECT @group = group_num, @depth = depth + 1, @sort = sort_no + 1
                FROM Board.POSTS WITH (UPDLOCK) WHERE num = @parent_num;

                IF @group IS NULL
                    THROW 50012, N'답글을 달 원글이 없습니다.', 1;

                -- 뒤에 있는 것들을 한 칸씩 밉니다(3.9).
                UPDATE Board.POSTS SET sort_no = sort_no + 1
                WHERE group_num = @group AND sort_no >= @sort;
            END

            INSERT INTO Board.POSTS
                (board_num, category_num, user_num, parent_num,
                 group_num, depth, sort_no, title, content, hit_count, reg_date)
            VALUES (@board_num, @category_num, @user_num, @parent_num,
                 ISNULL(@group, 0), @depth, @sort, @title, @content, 0, SYSDATETIME());

            SET @result = SCOPE_IDENTITY();               -- 1.8

            -- 원글은 제 번호가 덩어리 번호가 됩니다.
            IF @parent_num IS NULL
                UPDATE Board.POSTS SET group_num = @result WHERE num = @result;
        END

        IF @outer = 0 COMMIT;
    END TRY
    BEGIN CATCH
        IF @outer = 0 AND XACT_STATE() <> 0 ROLLBACK;
        THROW;
    END CATCH
END
원글 하나에 답글 셋을 달면
numdepthsort_notitle
51400실전 시험 원글
51611나중 답글
51512첫 답글
51723답답글
나중에 단 답글이 앞에 옵니다. 같은 원글의 답글끼리는 최신이 위입니다.

여기서 정한 것들

정한 것까닭
권한을 WHERE 에 넣음먼저 조회해 확인하면 그 사이에 바뀔 수 있습니다
@@ROWCOUNT = 0 으로 판정글이 없는 것과 권한이 없는 것을 구분하지 않습니다
UPDLOCK두 사람이 같은 원글에 동시에 답글을 달 때(5.5)
@outer 로 감쌈부르는 쪽 트랜잭션을 건드리지 않습니다(4.6)
오류 번호를 정해 둠응용 프로그램이 갈라 처리합니다(6.5)

글이 없는 것과 권한이 없는 것을 나누지 않은 것은 일부러입니다. "그 글은 있는데 당신 것이 아닙니다" 라고 알려 주면 번호를 바꿔 가며 어떤 글이 있는지 알아낼 수 있습니다. 둘을 한 문구로 두면 그것을 막습니다.

삭제

지우고 자리를 메웁니다

SQL
CREATE OR ALTER PROCEDURE Board.P_POST_DELETE
    @num      int,
    @user_num int,
    @result   int = 0 OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    DECLARE @outer bit = CASE WHEN @@TRANCOUNT > 0 THEN 1 ELSE 0 END;

    BEGIN TRY
        IF @outer = 0 BEGIN TRAN;

        DECLARE @owner int, @group int, @sort int;
        SELECT @owner = user_num, @group = group_num, @sort = sort_no
        FROM Board.POSTS WITH (UPDLOCK) WHERE num = @num;

        IF @owner IS NULL
            THROW 50013, N'글이 없습니다.', 1;
        IF @owner <> @user_num
            THROW 50014, N'지울 권한이 없습니다.', 1;
        IF EXISTS (SELECT 1 FROM Board.POSTS WHERE parent_num = @num)
            THROW 50015, N'답글이 달려 있어 지울 수 없습니다.', 1;

        -- 딸린 것부터 지웁니다. 외래 키 순서입니다(3.3).
        DELETE FROM Board.COMMENTS WHERE post_num = @num;
        DELETE FROM Board.FILES    WHERE post_num = @num;
        DELETE FROM Board.POSTS    WHERE num = @num;
        SET @result = @@ROWCOUNT;

        -- 뒤에 있던 것들을 한 칸씩 당깁니다.
        UPDATE Board.POSTS SET sort_no = sort_no - 1
        WHERE group_num = @group AND sort_no > @sort;

        IF @outer = 0 COMMIT;
    END TRY
    BEGIN CATCH
        IF @outer = 0 AND XACT_STATE() <> 0 ROLLBACK;
        THROW;
    END CATCH
END
막히는 경우들
답글이 달린 원글을 지우려 하면 50015 - 답글이 달려 있어 지울 수 없습니다. 남의 글을 지우려 하면 50014 - 지울 권한이 없습니다. 남의 글을 고치려 하면 50011 - 글이 없거나 고칠 권한이 없습니다.
제 답글(516)을 지운 뒤
numdepthsort_notitle
51400실전 시험 원글
51511첫 답글
51722답답글
sort_no 가 다시 촘촘해졌습니다. 빈자리를 두면 페이징이 어긋납니다(5.6).

정말 지울지, 표시만 할지 먼저 정하십시오. 3.10 에서 본 soft delete 라면 DELETE 대신 usable 열을 내리고, 목록 조건에 그것을 더합니다. 답글이 달린 글은 지울 수 없는 문제도 함께 사라집니다 — 표시만 바꾸면 되니까요.

함정

바깥 트랜잭션에서 부를 때

이 프로시저들을 바깥 트랜잭션 안에서 여러 번 부르면 뜻밖의 오류를 만납니다.

SQL
BEGIN TRAN;

BEGIN TRY
    EXEC Board.P_POST_DELETE @num = @p1, @user_num = 1;   -- 50015 로 실패
END TRY
BEGIN CATCH
    PRINT ERROR_MESSAGE();                                -- 잡았습니다
END CATCH

EXEC Board.P_POST_DELETE @num = @p3, @user_num = 3;       -- 이번엔 될 줄 알았는데
결과
메시지 3930, 수준 16, 상태 1 The current transaction cannot be committed and cannot support operations that write to the log file. Roll back the transaction.
오류를 잡았는데도 그다음 문장이 실패합니다.

SET XACT_ABORT ON 인 프로시저에서 오류가 나면 바깥 트랜잭션이 "커밋할 수 없는" 상태가 됩니다. 오류를 CATCH 로 잡아도 그 상태는 남습니다. 롤백하는 수밖에 없습니다.

SQL
-- 바깥에서 잡았다면 상태를 확인하고 롤백해야 합니다(4.6).
BEGIN CATCH
    PRINT ERROR_MESSAGE();
    IF XACT_STATE() = -1 ROLLBACK;      -- -1 이면 되돌리는 수밖에 없습니다
END CATCH

그래서 프로시저를 부르는 쪽에서 트랜잭션을 여는 일을 줄이십시오. 프로시저 하나가 하나의 일을 온전히 마치게 두면(4.6) 이런 상태를 다룰 일이 없습니다. 여럿을 묶어야 한다면 그 묶음을 담는 프로시저를 따로 만드는 편이 낫습니다.

연습

직접 해보기

1. 댓글 프로시저를 더합니다 난이도 하

댓글을 달고 지우는 프로시저를 이 한 벌에 맞춰 만들어 보세요. 같은 규약을 지키십시오 — 오류 번호, 권한 확인, @outer.

CREATE OR ALTER PROCEDURE Board.P_COMMENT_SAVE @post_num int, @user_num int, @content nvarchar(1000), @result int = 0 OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; IF LEN(LTRIM(RTRIM(@content))) = 0 THROW 50020, N'댓글을 입력하십시오.', 1; IF NOT EXISTS (SELECT 1 FROM Board.POSTS WHERE num = @post_num) THROW 50021, N'글이 없습니다.', 1; INSERT INTO Board.COMMENTS (post_num, user_num, content, reg_date) VALUES (@post_num, @user_num, @content, SYSDATETIME()); SET @result = SCOPE_IDENTITY(); END GO CREATE OR ALTER PROCEDURE Board.P_COMMENT_DELETE @num int, @user_num int, @result int = 0 OUTPUT AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DELETE FROM Board.COMMENTS WHERE num = @num AND user_num = @user_num; -- 권한을 조건에 SET @result = @@ROWCOUNT; IF @result = 0 THROW 50022, N'댓글이 없거나 지울 권한이 없습니다.', 1; END GO -- 규약을 맞춘 곳 -- 오류 번호를 50020 대로 묶었습니다. 글은 5001x, 댓글은 5002x 입니다. -- 권한을 WHERE 에 넣고 @@ROWCOUNT 로 판정했습니다. -- 있고 없고와 권한을 한 문구로 뭉쳤습니다. -- 트랜잭션을 감싸지 않은 까닭 -- 문장이 하나뿐이라 그 자체로 원자적입니다(4.6). -- 묶을 것이 없으면 감쌀 까닭도 없습니다. -- 글쓴이도 남의 댓글을 지울 수 있게 하려면 -- WHERE num = @num -- AND (user_num = @user_num -- OR EXISTS (SELECT 1 FROM Board.POSTS p -- WHERE p.num = post_num AND p.user_num = @user_num))
2. 목록이 느려지기 시작합니다 난이도 중

글이 50만 건이 되자 목록이 느려졌습니다. 이 프로시저에서 무엇을 어떤 순서로 고치겠습니까. 5부에서 잰 값들을 근거로 적으십시오.

이 프로시저 안에 5부에서 "이러면 느립니다" 라고 한 것이 둘 들어 있습니다.
-- 0. 먼저 잽니다. 짐작으로 고치지 않습니다(5부). SET STATISTICS IO ON; EXEC Board.P_POST_LIST @board_num = 2; EXEC Board.P_POST_LIST @board_num = 2, @keyword = N'인덱스'; -- 1. 댓글 수 세기 — 행마다 셉니다. -- 21행에 42장이었습니다. 50만 건이 되어도 쪽마다 21행이라 그대로지만, -- COMMENTS 가 커지면 한 번 세는 값이 올라갑니다. -- → POSTS 에 comment_count 열을 두고 트리거로 맞춥니다(3.6 · 4.7). ALTER TABLE Board.POSTS ADD comment_count int NOT NULL CONSTRAINT DF_POSTS_cc DEFAULT 0; -- 6.3 의 넓히기·채우기·좁히기 순서로 배포합니다. -- 2. 제목 검색 — LIKE N'%…%' 는 어떻게 해도 다 읽습니다(5.4). -- 50만 행에서 2,734장이었습니다. -- → 전문 검색으로 바꿉니다(5.8). 3장이 됩니다. WHERE (@keyword IS NULL OR CONTAINS(p.title, @term)) -- 다만 CONTAINS 는 OR 로 묶기 어렵습니다. -- 검색이 있을 때와 없을 때를 프로시저로 나누는 편이 낫습니다. -- 3. 인덱스를 확인합니다. -- IX_POSTS_list (board_num, group_num DESC, sort_no) 는 -- 목록 정렬을 그대로 받습니다(5.2). -- 말머리로 거르는 조회가 잦다면 category_num 을 넣은 인덱스를 더합니다. -- 다만 쓰기 비용을 함께 따지십시오(5.2 · 5.7). -- 4. OPTION (RECOMPILE) 을 다시 봅니다. -- 매개 변수 조합이 많아 붙였지만, 1,000번에 30밀리초가 -- 355밀리초가 되는 값이 있습니다(5.3). -- 목록이 초당 수백 번 불린다면 그 값이 커집니다. -- → 검색이 있을 때만 RECOMPILE 하도록 나눕니다. -- 순서 -- 댓글 수(가장 잦은 비용) → 검색(가장 느린 문장) → 인덱스 → RECOMPILE -- 하나 고칠 때마다 다시 재고, 나아진 것을 확인하고 다음으로 갑니다.

이 단원에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_POST_LIST, Board.P_POST_SELECT,
Board.P_POST_SAVE, Board.P_POST_DELETE;

요약
  • 목록 하나로 첫 쪽·다음 쪽·검색·말머리 거르기를 처리합니다. 키셋이라 어느 쪽을 봐도 POSTS 15장입니다(5.6).
  • 총 건수를 세지 않고 TOP (@size + 1) 로 다음 쪽 여부만 봅니다.
  • 키셋 조건은 범위를 따로 적어야 SEEK 에 들어갑니다(5.4).
  • 상세는 결과 집합을 둘 내 왕복을 하나로 줄입니다. 응용 프로그램은 NextResult 로 넘깁니다(6.5).
  • 저장은 번호가 있으면 고치기, 부모가 있으면 답글로 갈라 하나에 담았습니다.
  • 권한을 WHERE 에 넣고 @@ROWCOUNT 로 판정합니다. 먼저 조회해 확인하면 그 사이에 바뀔 수 있습니다.
  • 글이 없는 것과 권한이 없는 것을 한 문구로 뭉칩니다. 나누면 번호를 바꿔 가며 글의 존재를 알아낼 수 있습니다.
  • 답글 자리를 만들 때 UPDLOCK 으로 잡습니다. 두 사람이 같은 자리를 노리는 것을 막습니다(5.5).
  • 지운 뒤에는 sort_no 를 당겨 촘촘하게 만듭니다. 빈자리를 두면 페이징이 어긋납니다.
  • XACT_ABORT ON 인 프로시저가 오류를 내면 바깥 트랜잭션이 커밋 불가 상태(3930)가 됩니다. XACT_STATE() = -1 이면 롤백하는 수밖에 없습니다(4.6).