19. ASP.NET 웹 폼 게시판 프로젝트
ASP.NET 4.X 웹 폼 기술의 전반적인 기능을 정리해보는 차원에서 하나의 완성된 웹 응용 프로그램인 게시판을 만들어보겠습니다. 게시판은 웹 프로그래밍에 필요한 모든 요소를 두루 갖추고 있기에 따라하기로 각각의 기능을 완성해보겠습니다. 제가 강의에서는 DotNetNote, MemoEngine, Answers, Supports 등등의 프로젝트 이름으로 작성하는데요. 직접 원하는 이름으로 작성해도 됩니다.
19.1. ASP.NET 웹 폼 게시판 만들기 프로젝트 소개
이 강의에서는 ASP.NET 웹 폼 기반으로 데이터를 입력(Write), 출력(List), 상세(View), 수정(Modify), 삭제(Delete), 검색(Search), 답변(Reply), 댓글(Comment) 등의 기능이 있는 게시판을 만들어 볼 것입니다. 초급 개발자에게는 손수 게시판을 만들어 보는 게 웹 프로그래밍의 핵심이라고 불릴 만큼 상당히 중요합니다.
19.1.1 Answers 게시판 프로젝트
가장 최신 버전의 ASP.NET 웹 폼 게시판 프로젝트 소스는 Answers 이름의 솔루션으로 다음 경로에서 다운로드 받을 수 있습니다.
- https://github.com/VisualAcademy/Answers 위 경로에서 제공하는 게시판 소스를 다운로드 후 Azure 웹앱에 올려 실행하는 내용에 대한 동영상 강의는 다음 경로에서 제공받을 수 있습니다.
- ASP.NET 게시판을 Azure 웹앱과 SQL 데이터베이스를 기반으로 운영하기
- https://youtu.be/3FEjxL8Jl_I Answers 게시판 프로젝트의 실행 모양은 다음 그림과 같습니다.
19.1.2 준비 사항
ASP.NET Web Forms 기반의 완성형 게시판 프로젝트를 진행하려면 다음과 같은 환경이 준비되어 있으면 됩니다.
- Visual Studio의 가장 최신 버전
- 참고 동영상: https://youtu.be/n4tKX9nEIJ4
19.2. 게시판의 주요 기능
만들게 될 게시판의 주요 페이지는 다음과 같습니다.
- 입력(BoardWrite.aspx): 데이터를 입력하는 페이지
- 출력(BoardList.aspx): 데이터를 출력하는 페이지
- 상세(BoardView.aspx): 단일 데이터에 대해서 상세 보기 페이지
- 수정(BoardModify.aspx): 데이터를 수정하는 페이지
- 삭제(BoardDelete.aspx): 데이터를 삭제하는 페이지
- 검색(BoardSearchFormSingleControl.ascx): 데이터를 검색하는 페이지(리스트에 포함)
- 답변(BoardReply.aspx): 글에 대한 답변을 입력하는 페이지
- 댓글(BoardCommentControl.ascx): 댓글을 입력 및 출력하는 페이지(상세 보기에 포함)
- 축소(ThumbNail.aspx): 사진을 축소하는 기능
그림: 프로젝트, 폴더 및 각각의 페이지 미리 만들고 시작하기
19.3. 완성형 게시판(DotNetNote, Answers, Supports) 만들기 프로젝트 개요
따라하기를 통해서 기존에 배웠던 ASP.NET 웹 폼 분야의 내용 전체를 정리하는 차원에서 완성형 게시판을 제작해볼 것입니다. 완성형 게시판이란 기본형에 자료실+답변형 및 댓글 추가 등의 기능이 혼합된 형태로 일반적인 웹 프로젝트에 가장 많이 사용되는 게시판입니다. 게시판을 주제로 미니 프로젝트를 진행하는 것이므로 각각의 소스를 자세히 설명하지 않고 실행 모양, 태그 보기(aspx), 코드 보기(aspx.cs) 순으로 프로젝트 소스를 나열하는 구성으로 실습을 진행합니다. 참고: 시간상으로 DotNetNoet 대신에 Answers가 더 최신 소스입니다. 즉, /MemoEngine/DotNetNote/ 대신에 MemoEngine 프로젝트의 Answers 게시판 프로젝트 소스를 적용하기 바랍니다. 그 소스가 가장 최신입니다.
19.4. 완성형 게시판의 주요 기능
완성형 게시판의 주요 기능은 다음과 같습니다. 참고로, 완성형 게시판은 기본형, 자료실, 답변형, 댓글 기능이 모두 포함된 게시판을 말합니다.
- 글 쓰기, 글 목록, 글 보기, 글 수정, 글 삭제, 글 검색 기능을 제공합니다.
- 글에 대한 답변 기능을 제공합니다.
- 파일 업로드 및 다운로드 기능을 제공합니다. 이때 다운로드는 강제 다운로드 기능을 적용합니다.
- 이미지를 업로드했을 때 상세 보기 페이지에서 이미지를 실행시켜 주는 기능을 제공합니다.
19.5. 완성형 게시판 미리 보기
이 장에서 제작할 완성형 게시판을 미리 살펴보면 다음 그림과 같습니다. 자, 이제부터 게시판 프로젝트를 진행해보겠습니다. 그림 19 1 완성된 게시판 리스트
추가 학습: 그림: MainSummaryWithThumbNail.ascx
19.6. [실습] 완성형 게시판 만들기 프로젝트
19.6.1 소개
앞서 소개한 완성형 게시판을 만들어가는 과정을 처음부터 끝까지 따라하기로 살펴보겠습니다. 지금까지 작성해보았던 실습과 달리 꽤 긴 시간 동안 프로젝트를 진행해볼 것입니다.
참고: 이번 실습과 관련해서 "ASP.NET 4.6 게시판 프로젝트 미리 살펴보기"라는 제목의 동영상 강좌로도 마련하였으니 참고하기 바랍니다.
19.6.2 따라하기 1: 웹 프로젝트 생성 및 리스트 페이지 생성 후 테스트
(1) C:\ASP.NET 폴더에 MemoEngine이라는 이름으로 ASP.NET 4.6 웹 프로젝트를 생성합니다. <확인> 버튼을 클릭합니다.
이름 위치 ASP.NET 템플릿 선택 폴더 및 핵심 참조 추가 MemoEngine C:\ASP.NET ASP.NET 4.6 템플릿-웹 폼 Web Forms, MVC, Web API
그림 19 2 ASP.NET 웹 폼 기반으로 새 프로젝트 생성
(2) 템플릿 선택 창에서 다음 그림과 같이 <웹 폼>을 선택한 후 <Web Forms, MVC, Web API>에 대한 핵심 참조를 추가합니다.
그림 19 3 웹 폼 선택
오른쪽 영역에 있는 <인증 변경> 버튼을 클릭하여 다음과 같이 <인증 변경> 창이 뜨면 <인증 안 함>을 선택합니다. 그림 19 4 인증 안 함 선택
<확인> 버튼을 클릭하여 프로젝트 생성을 완료합니다.
(3) 생성된 MemoEngine 웹 프로젝트에 하위 폴더로 DotNetNote라는 폴더를 생성합니다. 게시판 관련된 모든 소스는 웹 프로젝트 루트에서 관리해도 되지만, 서브 폴더인 DotNetNote 폴더에서 관리하도록 하겠습니다. 그러면 나중에 새로운 프로젝트를 만들더라도 DotNetNote 폴더만 복사해서 쉽게 게시판 소스를 적용할 수 있습니다. DotNetNote 폴더에 게시판의 핵심 리스트 페이지인 BoardList.aspx 웹 폼을 추가합니다. 이때 <마스터 페이지가 있는 웹 폼>으로 선택합니다. 그림 19 5 마스터 페이지가 있는 웹 폼 추가
<마스터 페이지 선택> 창이 뜨면 프로젝트 루트에 기본 생성된 Site.Master를 선택하고 <확인> 버튼을 클릭합니다. 각각의 소스는 따로 보여주겠다. 그림 19 6 마스터 페이지 선택
(4) BoardList.aspx 페이지를 열고 다음과 같이 코드를 한 줄 작성합니다.
~/DotNetNote/BoardList.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardList.aspx.cs"
Inherits="MemoEngine.DotNetNote.BoardList" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h1>게시판 리스트</h1>
</asp:Content>
(5) 프로젝트 루트에 있는 Site.Master 페이지에 다음 코드와 같이 게시판 링크 메뉴를 등록합니다.
~/Site.Master 페이지 코드 일부
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a runat="server" href="~/">홈</a></li>
<li><a runat="server"
href="~/DotNetNote/BoardList.aspx">게시판</a></li>
<li><a runat="server" href="~/About">정보</a></li>
<li><a runat="server" href="~/Contact">연락처</a></li>
</ul>
</div>
(6) ~/DotNetNote/BoardList.aspx 파일을 시작 페이지로 설정 후 [Ctrl]+[F5]를 클릭하여 실행하면 다음과 같이 실행됩니다. 그림 19 7 게시판 리스트 페이지 생성
19.6.3 따라하기 2: 데이터베이스 설정 및 테이블과 저장 프로시저 생성 후 게시
(1) 이 책의 모든 데이터베이스 관련 쿼리문을 따로 모아서 관리하고 있는 DotNetNote.Database 프로젝트를 열고 게시판 프로젝트를 위한 테이블과 저장 프로시저를 생성해보겠습니다. 참고로 SQL Server Management Studio를 열고 새로운 데이터베이스를 생성한 뒤 이곳에 테이블과 저장 프로시저를 직접 만들고 생성해도 무관합니다. 나의 데이터베이스 환경은 다음과 같습니다.
- 데이터베이스 서버: 로컬 DB – (localdb)\MSSQLLocalDB
- 데이터베이스 이름: DotNetNote
- 사용자 인증: Windows 인증(또는 SQL Server 혼합 인증)
(2) SQL Server 데이터베이스 프로젝트인 DotNetNote.Database 프로젝트를 실행합니다. 그림을 참조하여 게시판 관련 테이블과 저장 프로시저를 기본값으로 미리 만들어 놓자. Tables 폴더에 DotNetNote 폴더를 생성합니다. 이 폴더에 <추가 > 테이블> 메뉴를 사용하여 아래 이름으로 테이블을 두 개 생성합니다.
- Notes.sql
- NoteComments.sql Stored Procedures 폴더에도 DotNetNote 폴더를 생성합니다. 이 폴더에 <추가 > 저장 프로시저> 메뉴를 아래 이름으로 저장 프로시저를 아홉 개 생성합니다.
- DNN_DeleteNote.sql
- DNN_GetCountNotes.sql
- DNN_ListNotes.sql
- DNN_ModifyNote.sql
- DNN_ReplyNote.sql
- DNN_SearchNoteCount.sql
- DNN_SearchNotes.sql
- DNN_ViewNote.sql
- DNN_WriteNote.sql
각 파일의 내용은 이어지는 내용에서 따로 작성할 것입니다. 그림 19 8 데이터베이스 프로젝트에 게시판 관련 SQL 파일 생성
(3) 이제부터 각 SQL 파일들을 열고 게시판 프로젝트를 위한 테이블과 저장 프로시저를 입력합니다. 첫 번째로 완성형 게시판(DotNetNote)용 테이블 구문을 다음과 같이 작성합니다. Category 필드는 분류를 추가하기 위해서 확장용으로 입력해 놓은 것입니다.
DotNetNote.Database 프로젝트: /dbo/Tables/DotNetNote/Notes.sql
--[1] 게시판(DotNetNote)용 테이블 설계
Create Table dbo.Notes
(
Id Int Identity(1, 1) Not Null Primary Key, -- 번호
Name NVarChar(25) Not Null, -- 이름
Email NVarChar(100) Null, -- 이메일
Title NVarChar(150) Not Null, -- 제목
PostDate DateTime Default GetDate() Not Null, -- 작성일
PostIp NVarChar(15) Null, -- 작성IP
Content NText Not Null, -- 내용
Password NVarChar(255) Null, -- 비밀번호
ReadCount Int Default 0, -- 조회수
Encoding NVarChar(10) Not Null, -- 인코딩(HTML/Text)
Homepage NVarChar(100) Null, -- 홈페이지
ModifyDate DateTime Null, -- 수정일
ModifyIp NVarChar(15) Null, -- 수정IP
FileName NVarChar(255) Null, -- 파일명
FileSize Int Default 0, -- 파일크기
DownCount Int Default 0, -- 다운수
Ref Int Not Null, -- 참조(부모글)
Step Int Default 0, -- 답변깊이(레벨)
RefOrder Int Default 0, -- 답변순서
AnswerNum Int Default 0, -- 답변수
ParentNum Int Default 0, -- 부모글번호
CommentCount Int Default 0, -- 댓글수
Category NVarChar(10) Default('Free') Null -- 카테고리(확장...)
)
Go
모두 타이핑하였으면 저장합니다. 다른 파일까지 모두 입력한 뒤 데이터베이스 프로젝트에서 <게시> 메뉴를 통해 테이블과 저장 프로시저를 한 번에 실행하겠습니다.
(4) 게시판 각각의 글에 대한 댓글(코멘트)을 저장하는 테이블인 NoteComments 테이블을 다음 구문과 같이 생성합니다. BoardName 필드는 추후 멀티형 게시판으로 업그레이드를 할 경우를 대비해서 남겨놓은 필드인데, 이 책에서는 사용하지 않습니다.
/dbo/Tables/DotNetNote/NoteComments.sql
--[2] 댓글 테이블 생성
Create Table dbo.NoteComments
(
Id Int Identity(1, 1)
Not Null Primary Key, -- 일련번호
BoardName NVarChar(50) Null, -- 게시판이름(확장): Notice
BoardId Int Not Null, -- 해당 게시판의 게시물 번호
Name NVarChar(25) Not Null, -- 작성자
Opinion NVarChar(4000) Not Null, -- 댓글 내용
PostDate SmallDateTime Default(GetDate()), -- 작성일
Password NVarChar(20) Not Null -- 댓글 삭제용 암호
)
Go
(5) 게시판의 글 작성 페이지인 BoardWrite.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다. 저장 프로시저 이름은 WriteNote이지만, 파일명은 DNN_WriteNote.sql로 작성되었습니다.
/dbo/Stored Procedures/DotNetNote/DNN_WriteNote.sql
--[1] 게시판(DotNetNote)에 글을 작성 : WriteNote
Create Proc dbo.WriteNote
@Name NVarChar(25),
@Email NVarChar(100),
@Title NVarChar(150),
@PostIp NVarChar(15),
@Content NText,
@Password NVarChar(20),
@Encoding NVarChar(10),
@Homepage NVarChar(100),
@FileName NVarChar(255),
@FileSize Int
As
Declare @MaxRef Int
Select @MaxRef = Max(Ref) From Notes
If @MaxRef is Null
Set @MaxRef = 1 -- 테이블 생성 후 처음만 비교
Else
Set @MaxRef = @MaxRef + 1
Insert Notes
(
Name, Email, Title, PostIp, Content, Password, Encoding,
Homepage, Ref, FileName, FileSize
)
Values
(
@Name, @Email, @Title, @PostIp, @Content, @Password, @Encoding,
@Homepage, @MaxRef, @FileName, @FileSize
)
Go
(6) 게시판의 글 리스트 페이지인 BoardList.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다. 페이징 처리를 위한 쿼리가 적용되었습니다.
/dbo/Stored Procedures/DotNetNote/DNN_ListNotes.sql
--[2] 게시판(DotNetNote)에서 데이터 출력 : ListNotes
Create Procedure dbo.ListNotes
@Page Int
As
With DotNetNoteOrderedLists
As
(
Select
[Id], [Name], [Email], [Title], [PostDate], [ReadCount],
[Ref], [Step], [RefOrder], [AnswerNum], [ParentNum],
[CommentCount], [FileName], [FileSize], [DownCount],
ROW_NUMBER() Over (Order By Ref Desc, RefOrder Asc)
As 'RowNumber'
From Notes
)
Select * From DotNetNoteOrderedLists
Where RowNumber Between @Page * 10 + 1 And (@Page + 1) * 10
Go
추가 학습: ListNotes 저장 프로시저는 @Page 이름의 매개변수를 사용하여 해당 페이지의 10개 데이터만을 가져옵니다. 만약, 20개 또는 30개씩 가져오고자 한다면, 추가적으로 PageSize와 같은 매개변수를 지정하여야 합니다.
(7) 게시판의 글 상세보기 페이지인 BoardView.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다. 글의 내용을 보는 순간 조회수를 1 증가시켜 주는 코드가 포함되었습니다.
/dbo/Stored Procedures/DotNetNote/ DNN_ViewNote.sql
--[3] 해당 글을 세부적으로 읽어오는 저장 프로시저 : ViewNote
Create Procedure dbo.ViewNote
@Id Int
As
-- 조회수 카운트 1증가
Update Notes Set ReadCount = ReadCount + 1 Where Id = @Id
-- 모든 항목 조회
Select * From Notes Where Id = @Id
Go
(8) 게시판의 글 답변 페이지인 BoardReply.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다. 참고로 답변 기능에 대한 쿼리는 복잡한 로직이 적용되어 있다(이 책에서 제시한 쿼리 구문 말고도 더 많은 방법이 있으므로 답변형 게시판에 대한 로직을 많이 검색해보기 바란다).
/dbo/Stored Procedures/DotNetNote/DNN_ReplyNote.sql
--[4] 게시판(DotNetNote)에 글을 답변 : ReplyNote
Create Proc dbo.ReplyNote
@Name NVarChar(25),
@Email NVarChar(100),
@Title NVarChar(150),
@PostIp NVarChar(15),
@Content NText,
@Password NVarChar(20),
@Encoding NVarChar(10),
@Homepage NVarChar(100),
@ParentNum Int, -- 부모글의 고유번호(Id)
@FileName NVarChar(255),
@FileSize Int
As
--[0] 변수 선언
Declare @MaxRefOrder Int
Declare @MaxRefAnswerNum Int
Declare @ParentRef Int
Declare @ParentStep Int
Declare @ParentRefOrder Int
--[1] 부모글의 답변수(AnswerNum)를 1증가
Update Notes Set AnswerNum = AnswerNum + 1 Where Id = @ParentNum
--[2] 같은 글에 대해서 답변을 두 번 이상하면 먼저 답변한 게 위에 나타나게 합니다.
Select @MaxRefOrder = RefOrder, @MaxRefAnswerNum = AnswerNum From Notes
Where
ParentNum = @ParentNum And
RefOrder =
(Select Max(RefOrder) From Notes Where ParentNum = @ParentNum)
If @MaxRefOrder Is Null
Begin
Select @MaxRefOrder = RefOrder From Notes Where Id = @ParentNum
Set @MaxRefAnswerNum = 0
End
--[3] 중간에 답변달 때(비집고 들어갈 자리 마련)
Select
@ParentRef = Ref, @ParentStep = Step
From Notes Where Id = @ParentNum
Update Notes
Set
RefOrder = RefOrder + 1
Where
Ref = @ParentRef And RefOrder > (@MaxRefOrder + @MaxRefAnswerNum)
--[4] 최종저장
Insert Notes
(
Name, Email, Title, PostIp, Content, Password, Encoding,
Homepage, Ref, Step, RefOrder, ParentNum, FileName, FileSize
)
Values
(
@Name, @Email, @Title, @PostIp, @Content, @Password, @Encoding,
@Homepage, @ParentRef, @ParentStep + 1,
@MaxRefOrder + @MaxRefAnswerNum + 1, @ParentNum, @FileName, @FileSize
)
Go
(9) 게시판의 레코드의 개수를 받아오는 저장 프로시저 구문을 다음과 같이 작성합니다.
/dbo/Stored Procedures/DotNetNote/DNN_GetCountNotes.sql
--[5] DotNetNote 테이블에 있는 레코드의 개수를 구하는 저장 프로시저
Create Proc dbo.GetCountNotes
As
Select Count(*) From Notes
Go
(10) 게시물의 검색 결과에 대한 레코드 수를 반환시켜 주는 저장 프로시저를 다음과 같이 작성합니다.
/dbo/Stored Procedures/DotNetNote/DNN_SearchNoteCount.sql
--[6] 검색 결과의 레코드 수 반환
Create Proc dbo.SearchNoteCount
@SearchField NVarChar(25),
@SearchQuery NVarChar(25)
As
Set @SearchQuery = '%' + @SearchQuery + '%'
Select Count(*)
From Notes
Where
(
Case @SearchField
When 'Name' Then [Name]
When 'Title' Then Title
When 'Content' Then Content
Else @SearchQuery
End
)
Like
@SearchQuery
Go
(11) 게시판의 글 삭제 페이지인 BoardDelete.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다. 뒤에서 답변 페이지 구현 시 자세히 다루겠지만, 저장 프로시저 구문이 상당히 길다. 어떤 부모 글에 답변 글이 있을 때 해당 부모 글을 지우면 글을 삭제하지 않고 지워진 글로 수정하고, 답변 글이 모두 삭제되었을 때에야 같이 삭제되는 기능을 구현하고 있기 때문입니다.
/dbo/Stored Procedures/DotNetNote/DNN_DeleteNote.sql
--[7] 해당 글을 지우는 저장 프로시저: 답변 글이 있으면 업데이트하고 없으면 지운다.
Create Proc dbo.DeleteNote
@Id Int,
@Password NVarChar(30) -- 암호 매개변수 추가
As
Declare @cnt Int
Select @cnt = Count(*) From Notes
Where Id = @Id And Password = @Password
If @cnt = 0
Begin
Return 0 -- 번호와 암호가 맞는 게 없으면 0을 반환
End
Declare @AnswerNum Int
Declare @RefOrder Int
Declare @Ref Int
Declare @ParentNum Int
Select
@AnswerNum = AnswerNum, @RefOrder = RefOrder,
@Ref = Ref, @ParentNum = ParentNum
From Notes
Where Id = @Id
If @AnswerNum = 0
Begin
If @RefOrder > 0
Begin
UPDATE Notes SET RefOrder = RefOrder - 1
WHERE Ref = @Ref AND RefOrder > @RefOrder
UPDATE Notes SET AnswerNum = AnswerNum - 1 WHERE Id = @ParentNum
End
Delete Notes Where Id = @Id
Delete Notes
WHERE
Id = @ParentNum AND ModifyIp = N'((DELETED))' AND AnswerNum = 0
End
Else
Begin
Update Notes
Set
Name = N'(Unknown)', Email = '', Password = '',
Title = N'(삭제된 글입니다.)',
Content = N'(삭제된 글입니다. '
+ N'현재 답변이 포함되어 있기 때문에 내용만 삭제되었습니다.)',
ModifyIp = N'((DELETED))', FileName = '',
FileSize = 0, CommentCount = 0
Where Id = @Id
End
Go
(12) 게시판의 글 수정 페이지인 BoardModify.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다.
/dbo/Stored Procedures/DotNetNote/DNN_ModifyNote.sql
--[8] 해당 글을 수정하는 저장 프로시저 : ModifyNote
Create Proc dbo.ModifyNote
@Name NVarChar(25),
@Email NVarChar(100),
@Title NVarChar(150),
@ModifyIp NVarChar(15),
@Content NText,
@Password NVarChar(30),
@Encoding NVarChar(10),
@Homepage NVarChar(100),
@FileName NVarChar(255),
@FileSize Int,
@Id Int
As
Declare @cnt Int
Select @cnt = Count(*) From Notes
Where Id = @Id And Password = @Password
If @cnt > 0 -- 번호와 암호가 맞는 게 있다면...
Begin
Update Notes
Set
Name = @Name, Email = @Email, Title = @Title,
ModifyIp = @ModifyIp, ModifyDate = GetDate(),
Content = @Content, Encoding = @Encoding,
Homepage = @Homepage, FileName = @FileName, FileSize = @FileSize
Where Id = @Id
Select '1'
End
Else
Select '0'
Go
(13) 게시판의 글 검색의 결과 페이지인 BoardList.aspx에서 사용할 저장 프로시저 구문을 다음과 같이 작성합니다.
/dbo/Stored Procedures/DotNetNote/SearchNotes.sql
--[9] 게시판(DotNetNote)에서 데이터 검색 리스트 : SearchNotes
Create Procedure dbo.SearchNotes
@Page Int,
@SearchField NVarChar(25),
@SearchQuery NVarChar(25)
As
With DotNetNoteOrderedLists
As
(
Select
[Id], [Name], [Email], [Title], [PostDate],
[ReadCount], [Ref], [Step], [RefOrder], [AnswerNum],
[ParentNum], [CommentCount], [FileName], [FileSize],
[DownCount],
ROW_NUMBER() Over (Order By Ref Desc, RefOrder Asc)
As 'RowNumber'
From Notes
Where (
Case @SearchField
When 'Name' Then [Name]
When 'Title' Then Title
When 'Content' Then Content
Else
@SearchQuery
End
) Like '%' + @SearchQuery + '%'
)
Select
[Id], [Name], [Email], [Title], [PostDate],
[ReadCount], [Ref], [Step], [RefOrder],
[AnswerNum], [ParentNum], [CommentCount],
[FileName], [FileSize], [DownCount],
[RowNumber]
From DotNetNoteOrderedLists
Where RowNumber Between @Page * 10 + 1 And (@Page + 1) * 10
Order By Id Desc
Go
이상으로 테이블 및 저장 프로시저로 구성된 완성형 게시판 관련 스크립트를 모두 만들었습니다. SQL Server 데이터베이스 프로젝트에서 <게시> 메뉴를 통해 원하는 데이터베이스로 게시합니다. 앞에서 작성한 쿼리문은 내가 ASP.NET 초기 버전 때부터 사용하던 구문입니다. SQL Server 2014, 2016 전용 등 최신 쿼리문으로 구문을 변경해서 사용해도 됩니다.
(14) SQL Server 데이터베이스 프로젝트에서 로컬 DB로 데이터베이스를 게시하는 모습입니다. 그림 19 9 LocalDB에 데이터베이스 게시
(15) Visual Studio의 SQL Server 개체 탐색기를 통해서 로컬DB의 DotNetNote 데이터베이스를 열어보면 게시판과 관련된 테이블 두 개과 저장 프로시저 아홉 개가 추가되어 있습니다. 그림 19 10 생성된 테이블과 저장 프로시저
19.6.4 따라하기 3: 솔루션에 클래스 라이브러리 및 데이터베이스 프로젝트 포함하기
(1) C:\ASP.NET\MemoEngine 프로젝트를 실행합니다.
(2) 솔루션 ‘MemoEngine’에 마우스 오른쪽 버튼을 클릭하여 <추가 > 기존 프로젝트> 메뉴를 선택하고 DotNetNote.Database 프로젝트를 가져옵니다. MemoEngine 웹 프로젝트와 DotNetNote.Database 프로젝트를 함께 모아서 관리할 수 있습니다.
(3) 게시판 프로젝트에서 사용할 주요 함수는 앞에서 Development Utility Library라는 주제로 만들어 놓은 Dul 클래스 라이브러리 프로젝트를 그대로 사용합니다. MemoEngine 솔루션에 마우스 오른쪽 버튼을 클릭하여 <추가 > 기존 프로젝트>
를 선택하고 Dul 클래스 라이브러리 프로젝트를 솔루션에 포함시킵니다. 이렇게 해서 세 프로젝트, 즉 웹 프로젝트, DB 프로젝트, 라이브러리 프로젝트를 구성한 후 게시판의 주요 페이지와 기능을 구현합니다. 다음 그림은 MemoEngine 웹 프로젝트의 솔루션에 DotNetNote.Database 프로젝트와 Dul 프로젝트가 추가된 상태입니다.
그림 19 11 솔루션에 세 프로젝트가 추가된 상태
(4) MemoEngine 웹 프로젝트에서 Dul 클래스 라이브러리 프로젝트를 사용해봅니다. 다음 그림과 같이 <참조 > 참조 추가>
메뉴를 선택합니다.
그림 19 12 참조 추가
(5) <참조 관라지> 창이 뜨면 프로젝트 탭의 솔루션 영역에서 Dul 클래스 라이브러리 프로젝트를 체크하고 <확인> 버튼을 클릭하여 Dul 프로젝트를 MemoEngine 프로젝트에 포함시킵니다. 그림 19 13 참조 관리자
(6) 웹 프로젝트 루트에 있는 Web.config 파일에 데이터베이스 연결 문자열을 설정합니다. Web.config 파일을 열고 다음과 같이 두 섹션을 작성합니다.
그림 19 14 <ConnectionStrings>
섹션
<!-- 데이터베이스 연결 문자열 지정 -->
<connectionStrings>
<add
name="ConnectionString"
connectionString=
"server=(localdb)\MSSQLLocalDB;database=DotNetNote;Integrated Security=True;"
providerName="System.Data.SqlClient"/>
</connectionStrings>
(7) MemoEngine 프로젝트의 Web.config 파일과 images 폴더를 제외한 모든 소스는 DotNetNote 서브 폴더에 구성할 것입니다. 그림을 참조하여 DotNetNote 폴더의 주요 폴더 및 웹 폼을 기본값으로 미리 만들어 놓자.
- DotNetNote 폴더에 Controls, Documents, images, Models, MyFiles 폴더를 생성합니다.
- DotNetNote 폴더에 BoardList.aspx 페이지처럼 <마스터 페이지가 있는 웹 폼>을 생성합니다. 마스터 페이지는 프로젝트 루트에 있는 Site.Master 마스터 페이지를 선택합니다. – BoardCommentDelete.aspx – BoardDelete.aspx – BoardModify.aspx – BoardReply.aspx – BoardView.aspx – BoardWrite.aspx
- DotNetNote 폴더에 마스터 페이지를 사용하지 않는 <웹 폼>을 다음과 같이 추가합니다. – BoardDown.aspx – Default.aspx – ImageDown.aspx – ImageText.aspx – ThumbNail.aspx
그림 19 15 전체 프로젝트 구조
추가 학습: Answers 게시판 파일 구조
(8) DotNetNote 웹 프로젝트의 루트에 게시판 소스를 두지 않고, DotNetNote 폴더를 두고 작업하는 이유는 다른 웹 프로젝트에 DotNetNote 폴더만 복사해가면 손쉽게 게시판 소스를 옮길 수 있기 때문입니다. 이번 실습에서 마스터 페이지는 웹 프로젝트의 루트에 기본으로 생성된 Site.Master 페이지를 사용합니다. 그래서 부트스트랩(Bootstrap)을 사용하여 레이아웃 및 폼의 모양 등 기본 스타일을 구성합니다. MemoEngine 웹 프로젝트의 루트에 있는 Default.aspx 페이지를 시작 페이지로 설정 후 [Ctrl]+[F5]를 눌러 실행하면 그림과 같이 기본 스타일의 웹 페이지가 출력됩니다. 그림 19 16 게시판 프로젝트 메인 페이지
19.6.5 따라하기 4: 웹 프로젝트에 모델과 리포지토리 클래스 생성
(1) 게시판의 글쓰기 폼 형태는 입력, 수정, 답변, 세 가지다. 이를 구분하기 위한 열거형을 웹 프로젝트 DotNetNote 폴더의 Models 폴더에 마우스 오른쪽 버튼 클릭 후 <추가 > 새 항목 추가 > 클래스>를 선택 후 BoardWriteFormType.cs라는 이름으로 다음과 같이 구성합니다. 기본 제공 소스를 모두 지운 후 다음 코드를 입력하는 것을 기준으로 삼겠다. Models 폴더에 생성되는 모든 열거형 및 클래스 파일은 최상위 네임스페이스인 MemoEngine은 제거한 상태로 구성합니다.
~/DotNetNote/Models/BoardWriteFormType.cs
namespace DotNetNote.Models
{
/// <summary>
/// 게시판의 글쓰기 폼 구성 분류(Write, Modify, Reply)
/// </summary>
public enum BoardWriteFormType
{
/// <summary>
/// 글 쓰기 페이지
/// </summary>
Write,
/// <summary>
/// 글 수정 페이지
/// </summary>
Modify,
/// <summary>
/// 글 답변 페이지
/// </summary>
Reply
}
}
(2) Models 폴더에 ContentEncodingType.cs 파일로 다음과 같이 작성합니다. 게시판에 글을 쓸 때 태그 처리를 하는 세 가지 항목을 관리하는 열거형입니다.
//~/DotNetNote/Models/ContentEncodingType.cs
namespace DotNetNote.Models
{
/// <summary>
/// 게시판의 글 내용(Content)의 인코딩 처리 방식
/// </summary>
public enum ContentEncodingType
{
/// <summary>
/// 입력한 소스 그대로 표시(태그 실행하지 않음)
/// </summary>
Text,
/// <summary>
/// HTML로 실행
/// </summary>
Html,
/// <summary>
/// HTML로 실행 + 엔터키(\r\n) 적용됨
/// </summary>
Mixed
}
}
(3) 웹 프로젝트의 Models 폴더에 Notes 테이블과 일대일로 매핑되는 Note 모델 클래스를 생성합니다. 모델과 뷰 모델 클래스 형태로 개발하면 웹 폼이 아닌 MVC에서도 그대로 사용할 있습니다. 따라서 게시판 프로젝트에서는 SqlDataReader와 DataSet 같은 웹 폼 친화적인 클래스를 사용하지 않고, List<T>
형태의 제네릭 클래스를 사용합니다.
~/DotNetNote/Models/Note.cs
using System;
using System.ComponentModel.DataAnnotations;
namespace DotNetNote.Models
{
/// <summary>
/// Note 클래스: Notes 테이블과 일대일 매핑되는 ViewModel 클래스
/// </summary>
public class Note
{
[Display(Name = "번호")]
public int Id { get; set; }
[Display(Name = "작성자")]
[Required(ErrorMessage = "* 이름을 작성해 주세요.")]
public string Name { get; set; }
[EmailAddress(ErrorMessage = "이메일을 정확인 입력하세요.")]
public string Email { get; set; }
[Display(Name = "제목")]
[Required(ErrorMessage = "* 제목을 작성해 주세요.")]
public string Title { get; set; }
[Display(Name = "작성일")]
public DateTime PostDate { get; set; }
public string PostIp { get; set; }
[Display(Name = "내용")]
[Required(ErrorMessage = "* 내용을 작성해 주세요.")]
public string Content { get; set; }
[Display(Name = "비밀번호")]
[Required(ErrorMessage = "* 비밀번호를 작성해 주세요.")]
public string Password { get; set; }
[Display(Name = "조회수")]
public int ReadCount { get; set; }
[Display(Name = "인코딩")]
public string Encoding { get; set; } = "Text";
public string Homepage { get; set; }
public DateTime ModifyDate { get; set; }
public string ModifyIp { get; set; }
[Display(Name = "파일")]
public string FileName { get; set; }
public int FileSize { get; set; }
public int DownCount { get; set; }
public int Ref { get; set; }
public int Step { get; set; }
public int RefOrder { get; set; }
public int AnswerNum { get; set; }
public int ParentNum { get; set; }
public int CommentCount { get; set; }
public string Category { get; set; } = "Free"; // 자유게시판(Free) 기본
}
}
System.ComponentModel.DataAnnotations 네임스페이스에 들어있는 [Required] 특성을 사용하면 속성에 대한 제약조건을 줄 수 있습니다.
(4) DB 입출력 기능을 사용할 때 기본 제공 ADO.NET 대신에 Micro ORM인 Dapper를 사용합니다. MemoEngine 프로젝트의 <참조>에 마우스 오른쪽 버튼을 클릭 후 <NuGet 패키지 관리자>를 띄운 후 <찾아보기> 탭에서 Dapper를 검색 후 Dapper를 내려받습니다.
그림 19 17 NuGet 패키지 관리자로 Dapper 설치
다음은 MemoEngine 프로젝트에 Dapper가 추가된 모습입니다. 그림 19 18 설치 완료된 Dapper
(5) 나는 SQL Server와 같은 데이터 저장소와 연관된 모든 코드는 Repository로 끝나는 클래스에 모아 놓는다. Models 폴더에 NoteRepository.cs 파일을 생성하고 이곳에 데이터 입출력 관련된 모든 코드를 작성합니다. 순수 ADO.NET, Entity Framework, Dapper 등 원하는 방식으로 데이터베이스 프로그래밍을 진행하면 되는데 이번 게시판 프로젝트는 Micro ORM인 Dapper를 사용합니다. 앞서 웹 프로젝트에 NuGet을 사용하여 Dapper.dll 파일이 추가되었다면 다음 코드를 입력합니다.
~/DotNetNote/Models/NoteRepository.cs
using Dapper;
using System.Collections.Generic;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
namespace DotNetNote.Models
{
public class NoteRepository
{
private SqlConnection con;
public NoteRepository()
{
con = new SqlConnection(ConfigurationManager.ConnectionStrings[
"ConnectionString"].ConnectionString);
}
/// <summary>
/// 데이터 저장, 수정, 답변 공통 메서드
/// </summary>
public int SaveOrUpdate(Note n, BoardWriteFormType formType)
{
int r = 0;
// 파라미터 추가
var p = new DynamicParameters();
//[a] 공통
p.Add("@Name", value: n.Name, dbType: DbType.String);
p.Add("@Email", value: n.Email, dbType: DbType.String);
p.Add("@Title", value: n.Title, dbType: DbType.String);
p.Add("@Content", value: n.Content, dbType: DbType.String);
p.Add("@Password", value: n.Password, dbType: DbType.String);
p.Add("@Encoding", value: n.Encoding, dbType: DbType.String);
p.Add("@Homepage", value: n.Homepage, dbType: DbType.String);
p.Add("@FileName", value: n.FileName, dbType: DbType.String);
p.Add("@FileSize", value: n.FileSize, dbType: DbType.Int32);
switch (formType)
{
case BoardWriteFormType.Write:
// [b] 글쓰기 전용
p.Add("@PostIp", value: n.PostIp, dbType: DbType.String);
r = con.Execute("WriteNote", p
, commandType: CommandType.StoredProcedure);
break;
case BoardWriteFormType.Modify:
// [b] 수정하기 전용
p.Add("@ModifyIp",
value: n.ModifyIp, dbType: DbType.String);
p.Add("@Id", value: n.Id, dbType: DbType.Int32);
r = con.Execute("ModifyNote", p,
commandType: CommandType.StoredProcedure);
break;
case BoardWriteFormType.Reply:
// [b] 답변쓰기 전용
p.Add("@PostIp", value: n.PostIp, dbType: DbType.String);
p.Add("@ParentNum",
value: n.ParentNum, dbType: DbType.Int32);
r = con.Execute("ReplyNote", p,
commandType: CommandType.StoredProcedure);
break;
}
return r;
}
/// <summary>
/// 게시판 글쓰기
/// </summary>
public void Add(Note vm)
{
try
{
SaveOrUpdate(vm, BoardWriteFormType.Write);
}
catch (System.Exception ex)
{
throw new System.Exception(ex.Message); // 로깅 처리 권장 영역
}
}
/// <summary>
/// 수정하기
/// </summary>
public int UpdateNote(Note vm)
{
int r = 0;
try
{
r = SaveOrUpdate(vm, BoardWriteFormType.Modify);
}
catch (System.Exception ex)
{
throw new System.Exception(ex.Message);
}
return r;
}
/// <summary>
/// 답변 글쓰기
/// </summary>
public void ReplyNote(Note vm)
{
try
{
SaveOrUpdate(vm, BoardWriteFormType.Reply);
}
catch (System.Exception ex)
{
throw new System.Exception(ex.Message);
}
}
/// <summary>
/// 게시판 리스트: GetAll, FindAll
/// </summary>
/// <param name="page">페이지 번호</param>
public List<Note> GetAll(int page)
{
try
{
var parameters = new DynamicParameters(new { Page = page });
return con.Query<Note>("ListNotes", parameters,
commandType: CommandType.StoredProcedure).ToList();
}
catch (System.Exception ex)
{
throw new System.Exception(ex.Message);
}
}
/// <summary>
/// 검색 카운트
/// </summary>
public int GetCountBySearch(string searchField, string searchQuery)
{
try
{
return con.Query<int>("SearchNoteCount", new
{
SearchField = searchField,
SearchQuery = searchQuery
},
commandType: CommandType.StoredProcedure)
.SingleOrDefault();
}
catch (System.Exception ex)
{
throw new System.Exception(ex.Message);
}
}
/// <summary>
/// Notes 테이블의 모든 레코드 수
/// </summary>
public int GetCountAll()
{
try
{
return con.Query<int>(
"Select Count(*) From Notes").SingleOrDefault();
}
catch (System.Exception)
{
return -1;
}
}
/// <summary>
/// Id에 해당하는 파일명 반환
/// </summary>
public string GetFileNameById(int id)
{
return
con.Query<string>("Select FileName From Notes Where Id = @Id",
new { Id = id }).SingleOrDefault();
}
/// <summary>
/// 검색 결과 리스트
/// </summary>
public List<Note> GetSeachAll(
int page, string searchField, string searchQuery)
{
var parameters = new DynamicParameters(new
{
Page = page,
SearchField = searchField,
SearchQuery = searchQuery
});
return con.Query<Note>("SearchNotes", parameters,
commandType: CommandType.StoredProcedure).ToList();
}
/// <summary>
/// 다운 카운트 1 증가
/// </summary>
public void UpdateDownCount(string fileName)
{
con.Execute("Update Notes Set DownCount = DownCount + 1 "
+ " Where FileName = @FileName", new { FileName = fileName });
}
public void UpdateDownCountById(int id)
{
var p = new DynamicParameters(new { Id = id });
con.Execute("Update Notes Set DownCount = DownCount + 1 "
+ " Where Id = @Id", p, commandType: CommandType.Text);
}
/// <summary>
/// 상세 보기
/// </summary>
public Note GetNoteById(int id)
{
var parameters = new DynamicParameters(new { Id = id });
return con.Query<Note>("ViewNote", parameters,
commandType: CommandType.StoredProcedure).SingleOrDefault();
}
/// <summary>
/// 삭제
/// </summary>
public int DeleteNote(int id, string password)
{
return con.Execute("DeleteNote",
new { Id = id, Password = password },
commandType: CommandType.StoredProcedure);
}
/// <summary>
/// 최근 올라온 사진 리스트 4개 출력
/// </summary>
public List<Note> GetNewPhotos()
{
string sql =
"SELECT TOP 4 Id, Title, FileName, FileSize FROM Notes "
+ " Where FileName Like '%.png' Or FileName Like '%.jpg' Or "
+ " FileName Like '%.jpeg' Or FileName Like '%.gif' "
+ " Order By Id Desc";
return con.Query<Note>(sql).ToList();
}
/// <summary>
/// 최근 글 리스트
/// </summary>
public List<Note> GetNoteSummaryByCategory(string category)
{
string sql = "SELECT TOP 3 Id, Title, Name, PostDate, FileName, "
+ " FileSize, ReadCount, CommentCount, Step "
+ " FROM Notes "
+ " Where Category = @Category Order By Id Desc";
return con.Query<Note>(sql, new { Category = category }).ToList();
}
/// <summary>
/// 최근 글 리스트 전체(최근 글 5개 리스트)
/// </summary>
public List<Note> GetRecentPosts()
{
string sql = "SELECT TOP 3 Id, Title, Name, PostDate FROM Notes "
+ " Order By Id Desc";
return con.Query<Note>(sql).ToList();
}
/// <summary>
/// 최근 글 리스트 n개
/// </summary>
public List<Note> GetRecentPosts(int numberOfNotes)
{
string sql =
$"SELECT TOP {numberOfNotes} Id, Title, Name, PostDate "
+ " FROM Notes Order By Id Desc";
return con.Query<Note>(sql).ToList();
}
}
}
(6) 이번에는 NoteComments 테이블과 일대일인 NoteComment 클래스를 생성합니다.
~/DotNetNote/Models/NoteComment.cs
using System;
using System.ComponentModel.DataAnnotations;
namespace DotNetNote.Models
{
/// <summary>
/// 댓글 뷰 모델
/// NoteComment 클래스: NoteComments 테이블과 일대일 매핑되는 ViewModel 클래스
/// </summary>
public class NoteComment
{
public int Id { get; set; }
public string BoardName { get; set; }
public int BoardId { get; set; }
[Required(ErrorMessage = "이름을 입력하세요.")]
public string Name { get; set; }
[Required(ErrorMessage = "의견을 입력하세요.")]
public string Opinion { get; set; }
public DateTime PostDate { get; set; }
[Required(ErrorMessage = "암호를 입력하세요.")]
public string Password { get; set; }
}
}
(7) 마찬가지로 NoteComment 모델 클래스를 바탕으로 NoteComments 테이블에 데이터 입출력을 구현하는 리포지터리 클래스를 NoteCommentRepository 이름으로 구현합니다. 이번에는 학습을 위해 저장 프로시저가 아닌 인라인 SQL 구문으로 표현해보았습니다.
~/DotNetNote/Models/NoteCommentRepository.cs
using Dapper;
using System.Collections.Generic;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
namespace DotNetNote.Models
{
public class NoteCommentRepository
{
private SqlConnection con;
public NoteCommentRepository()
{
con = new SqlConnection(ConfigurationManager.ConnectionStrings[
"ConnectionString"].ConnectionString);
}
/// <summary>
/// 특정 게시물에 댓글 추가
/// </summary>
public void AddNoteComment(NoteComment model)
{
// 파라미터 추가
var parameters = new DynamicParameters();
parameters.Add(
"@BoardId", value: model.BoardId, dbType: DbType.Int32);
parameters.Add(
"@Name", value: model.Name, dbType: DbType.String);
parameters.Add(
"@Opinion", value: model.Opinion, dbType: DbType.String);
parameters.Add(
"@Password", value: model.Password, dbType: DbType.String);
string sql = @"
Insert Into NoteComments (BoardId, Name, Opinion, Password)
Values(@BoardId, @Name, @Opinion, @Password);
Update Notes Set CommentCount = CommentCount + 1
Where Id = @BoardId
";
con.Execute(sql, parameters, commandType: CommandType.Text);
}
/// <summary>
/// 특정 게시물에 해당하는 댓글 리스트
/// </summary>
public List<NoteComment> GetNoteComments(int boardId)
{
return con.Query<NoteComment>(
"Select * From NoteComments Where BoardId = @BoardId"
, new { BoardId = boardId }
, commandType: CommandType.Text).ToList();
}
/// <summary>
/// 특정 게시물의 특정 Id에 해당하는 댓글 카운트
/// </summary>
public int GetCountBy(int boardId, int id, string password)
{
return con.Query<int>(@"Select Count(*) From NoteComments
Where BoardId = @BoardId And Id = @Id And Password = @Password"
, new { BoardId = boardId, Id = id, Password = password }
, commandType: CommandType.Text).SingleOrDefault();
}
/// <summary>
/// 댓글 삭제
/// </summary>
public int DeleteNoteComment(int boardId, int id, string password)
{
return con.Execute(@"Delete NoteComments
Where BoardId = @BoardId And Id = @Id And Password = @Password;
Update Notes Set CommentCount = CommentCount - 1
Where Id = @BoardId"
, new { BoardId = boardId, Id = id, Password = password }
, commandType: CommandType.Text);
}
/// <summary>
/// 최근 댓글 리스트 전체
/// </summary>
public List<NoteComment> GetRecentComments()
{
string sql = @"SELECT TOP 3 Id, BoardId, Opinion, PostDate
FROM NoteComments Order By Id Desc";
return con.Query<NoteComment>(sql).ToList();
}
}
}
19.6.6 따라하기 5: 게시판의 주요 독립적인 모듈 페이지 구현
(1) 이번에는 웹 페이지를 다른 페이지에 도움을 주는 독립적인 페이지부터 시작해서 하나씩 생성해 나갑니다. DotNetNote 폴더의 Default.aspx, BoardDown.aspx, ImageText.aspx, ThumbNail.aspx 페이지를 실행합니다.
(2) Default.aspx 페이지는 UI를 따로 구성하지 않고 코드 숨김 파일에 다음과 같이 코드를 한 줄 추가합니다. 이렇게 하면 /DotNetNote/ 경로 요청 시 자동으로 Default.aspx 페이지가 실행되고 바로 BoardList.aspx 페이지로 이동시켜 줍니다.
~/DotNetNote/Default.aspx.cs
using System;
namespace MemoEngine.DotNetNote
{
public partial class Default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
Response.Redirect("BoardList.aspx");
}
}
}
(3) 만들고 있는 게시판은 파일 업로드 및 다운로드 기능이 포함된 게시판입니다. 게시판의 파일 다운로드를 도와주는 헬퍼 페이지인 BoardDown.aspx 페이지를 구현해보겠습니다. 이 페이지를 구현하면 다음과 같이 파일 첨부란에 마우스 커서를 올렸을 때 BoardDown.aspx 페이지를 통해서 파일 강제 다운로드를 진행합니다. 그림 19 19 BoardDown 페이지를 통한 파일 강제 다운로드
(4) BoardDown.aspx 페이지는 기본으로 두고 BoardDown.aspx.cs 파일을 열고 다음과 같이 타이핑을 합니다. 이 코드 블록 안에 있는 내용은 특정 경로에 있는 파일을 강제로 다운로드 시켜주는 공식 같은 코드입니다. BoardDown.aspx 페이지는 넘어 온 번호에 해당하는 파일을 지정된 폴더로부터 강제 다운로드시키고 다운로드 카운트를 1 증가시켜 주는 페이지입니다.
~/DotNetNote/BoardDown.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardDown : System.Web.UI.Page
{
private string fileName = "";
private string dir = "";
private NoteRepository _repository;
public BoardDown()
{
_repository = new NoteRepository();
}
protected void Page_Load(object sender, EventArgs e)
{
// 넘어 온 번호에 해당하는 파일명 가져오기(보안 때문에... 파일명 숨김)
fileName =
_repository.GetFileNameById(Convert.ToInt32(Request["Id"]));
// 다운로드 폴더 지정 : 실제 사용 시 반드시 변경
dir = Server.MapPath("./MyFiles/");
if (fileName == null) // 특정 번호에 해당하는 첨부파일이 없다면,
{
Response.Clear();
Response.End();
}
else
{
// 다운로드 카운트 증가 메서드 호출
_repository.UpdateDownCount(fileName);
//[!] 강제 다운로드 창 띄우기 주요 로직
Response.Clear();
Response.ContentType = "application/octet-stream";
Response.AddHeader("Content-Disposition", "attachment;filename="
+ Server.UrlPathEncode(
(fileName.Length > 50) ?
fileName.Substring(fileName.Length - 50, 50) :
fileName));
Response.WriteFile(dir + fileName);
Response.End();
}
}
}
}
(5) 이번에도 UI가 필요 없는 페이지 중에서 이미지 파일만 다운로드시켜 주는 ImageDown.aspx 페이지를 구성해 보겠습니다. ImageDown.aspx 페이지는 그대로 두고, 코드 숨김 페이지인 ImageDown.aspx.cs 파일을 열고 다음과 같이 타이핑합니다. 소스를 분석해보면 알겠지만, 이 또한 간단하게 이미지 파일만 다운로드시켜 줍니다.
~/DotNetNote/ImageDown.aspx.cs
using System;
using System.IO;
namespace MemoEngine.DotNetNote
{
/// <summary>
/// ImageDown : 완성형(DotNetNote) 게시판의 이미지 전용 다운 페이지
/// 이미지 경로를 보여주지 않고 다운로드합니다.
/// </summary>
public partial class ImageDown : System.Web.UI.Page
{
protected void Page_Load(object sender, System.EventArgs e)
{
// 넘어 온 파일명 체크
if (String.IsNullOrEmpty(Request.QueryString["FileName"]))
{
Response.End();
}
string fileName = Request.Params["FileName"].ToString();
string ext = Path.GetExtension(fileName); // 확장자만 추출
string contentType = "";
if (ext == ".gif" || ext == ".jpg"
|| ext == ".jpeg" || ext == ".png")
{
switch (ext)
{
case ".gif":
contentType = "image/gif"; break;
case ".jpg":
contentType = "image/jpeg"; break;
case ".jpeg":
contentType = "image/jpeg"; break;
case ".png":
contentType = "image/png"; break;
}
}
else
{
Response.Write(
"<script language='javascript'>"
+ "alert('이미지 파일이 아닙니다.')</script>");
Response.End();
}
string file = Server.MapPath("./MyFiles/") + fileName;
// 강제 다운로드 로직 적용
Response.Clear();
Response.ContentType = contentType;
Response.WriteFile(file);
Response.End();
}
}
}
(6) 제작할 게시판은 데이터 입력과 출력에 대해 연습하기 위해 회원 로그인이 없는 익명 게시판 형태입니다. 보안상 자동 입력 프로그램에 의해서 광고 등이 입력되는 것을 방지하기 위해서 ImageText.aspx 페이지를 사용해서 동적으로 문자 네 개를 랜덤하게 생성해서 같은 값을 입력할 때만 데이터를 저장하도록 할 것입니다. 이에 대한 소스는 UI 없이 코드 숨김 파일에서만 다음과 같이 코드를 구현하면 됩니다. 랜덤하게 문자 네 개를 영문 대문자, 정수, 영문 소문자, 정수 순서대로 출력하게 만들었습니다. 그림 19 20 보안 코드 입력 예시
~/DotNetNote/ImageText.aspx.cs
using System;
using System.Drawing;
namespace MemoEngine.DotNetNote
{
public partial class ImageText : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
// [1] 비트맵 이미지 생성
Bitmap objBitmap = new Bitmap(80, 20);
Graphics objGraphics = Graphics.FromImage(objBitmap);
objGraphics.Clear(Color.White);
objGraphics.SmoothingMode =
System.Drawing.Drawing2D.SmoothingMode.HighSpeed;
objGraphics.TextRenderingHint =
System.Drawing.Text.TextRenderingHint.AntiAlias;
// [2] 랜덤하게 문자 네 개 생성 : 영문 대문자, 정수, 영문 소문자, 정수
Random random = new Random();
char c1 = (char)random.Next(65, 90);
char c2 = (char)random.Next(48, 57);
char c3 = (char)random.Next(97, 122);
char c4 = (char)random.Next(48, 57);
// [3] 입력 페이지에서 비교를 위해서 세션 변수에 담기
Session["ImageText"] = $"{c1}{c2}{c3}{c4}";
// [4] 사각형 비트맵 이미지에 문자 네 개 기록
objGraphics.DrawString(c1.ToString(),
new Font("Verdana", 12, FontStyle.Bold),
Brushes.DarkBlue, new PointF(5, 1));
objGraphics.DrawString(c2.ToString(),
new Font("Arial", 11, FontStyle.Italic),
Brushes.DarkBlue, new PointF(25, 1));
objGraphics.DrawString(c3.ToString(),
new Font("Verdana", 11, FontStyle.Regular),
Brushes.DarkBlue, new PointF(45, 1));
objGraphics.DrawString(c4.ToString(),
new Font("Arial", 12, FontStyle.Underline),
Brushes.DarkBlue, new PointF(65, 1));
//[5] 비트맵 이미지 출력
Response.ContentType = "image/gif";
objBitmap.Save(
Response.OutputStream, System.Drawing.Imaging.ImageFormat.Gif);
//[6] 메모리 정리
objBitmap.Dispose();
objGraphics.Dispose();
}
}
}
(7) UI가 필요 없는 마지막 페이지인 축소판(썸네일;ThumbNail) 이미지를 생성시키는 ThumbNail.aspx 페이지를 생성합니다. 역시 ThumbNail.aspx 페이지는 그대로 둔 채 코드 숨김 페이지인 ThumbNail.aspx.cs 파일을 열고 다음과 같이 타이핑합니다. 이 안에 있는 코드는 이 책의 범위를 벗어나는 영역이 포함되어 있기에 이 또한 먼저 사용하다가 MSDN을 참고하여 주요 명령어를 익히도록 합니다. 또한, System.Drawing 네임스페이스는 반드시 추가하기 바랍니다.
~/DotNetNote/ThumbNail.aspx.cs
using System;
using System.Drawing;
namespace MemoEngine.DotNetNote
{
/// <summary>
/// ThumbNail : 축소판 이미지 생성기
/// </summary>
public partial class ThumbNail : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
// 변수 초기화
int boxWidth = 100;
int boxHeight = 100;
double scale = 0;
// 파일 이름 설정
string fileName = String.Empty;
string selectedFile = String.Empty;
if (Request["FileName"] != null)
{
selectedFile = Request.QueryString["FileName"];
fileName = Server.MapPath("./MyFiles/" + selectedFile);
}
else
{
selectedFile = "/images/dnn/img.jpg";//기본 이미지로 초기화
fileName = Server.MapPath("/images/dnn/img.jpg");
}
int tmpW = 0;
int tmpH = 0;
if (Request.QueryString["Width"] != null
&& Request.QueryString["Height"] != null)
{
tmpW = Convert.ToInt32(Request.QueryString["Width"]);
tmpH = Convert.ToInt32(Request.QueryString["Height"]);
}
if (tmpW > 0 && tmpH > 0)
{
boxWidth = tmpW;
boxHeight = tmpH;
}
// 새 이미지 생성
Bitmap b = new Bitmap(fileName);
// 크기 비율을 계산합니다.
if (b.Height < b.Width)
{
scale = ((double)boxHeight) / b.Width;
}
else
{
scale = ((double)boxWidth) / b.Height;
}
// 새 너비와 높이를 설정합니다.
int newWidth = (int)(scale * b.Width);
int newHeight = (int)(scale * b.Height);
// 출력 비트맵을 생성, 출력합니다.
Bitmap bOut = new Bitmap(b, newWidth, newHeight);
bOut.Save(Response.OutputStream, b.RawFormat);
// 마무리
b.Dispose();
bOut.Dispose();
}
}
}
<참고> 축소판 이미지 출력
축소판 이미지 생성기는 다음 리스트처럼 웹 브라우저로 요청하면 Width와 Height 값을 전달하지 않았을 때는 기본 100 픽셀 영역에 출력하고, Width와 Height 값을 전달하면 해당 사이즈에 맞게 축소 또는 확대해서 출력해줍니다. 참고로 ThumbNail.aspx 페이지는 IE가 아닌 다른 웹 브라우저에서는 기본으로 이미지가 표시되지 않고 태그의 src 속성을 사용해야만 보인다.
- /DotNetNote/ThumbNail?FileName=RedPlusCaricature.jpg
- /DotNetNote/ThumbNail?FileName=RedPlusCaricature.jpg&Width=50&Height=50
- /DotNetNote/ThumbNail?FileName=RedPlusCaricature.jpg&Width=150&Height=150
세 가지 형태로 축소판 이미지 보여주기
</참고>
19.6.7 따라하기 6: 입력 폼 및 글쓰기 웹 페이지 구현
(1) 이번에는 게시판의 주요 페이지를 구현해보겠습니다. 입력, 수정, 답변 페이지에서 공통적으로 사용할 데이터 입력 폼을 구현합니다. DotNetNote 폴더의 Controls 폴더에 BoardEditorFormControl.ascx 이름으로 <웹 폼 사용자 정의 컨트롤>을 생성합니다. 이는 다음 그림과 같은 모양으로 웹 폼에 포함되어 실행됩니다. 앞으로 만들 BoardWrite.aspx 페이지를 실행하면 다음 그림과 같이 입력 폼이 생성됩니다. 하단에는 <보안코드>를 입력해주는 기능도 포함되어 있습니다. 이러한 형태의 입력 폼은 수정 폼과 답변 폼에서 그대로 사용하기에 공통 페이지인 BoardEditorFormControl.ascx 파일을 먼저 작성한 후 각각의 웹 폼에 포함되는 형태로 구현하면 뒤에서 수정과 답변 페이지 작성할 때 재사용할 수 있습니다.
그림 19 21 BoardEditorFormControl의 실행 모양
(2) BoardEditorFormControl.ascx 파일을 열고 다음과 같이 코드를 작성합니다. 이 웹 폼 사용자 정의 컨트롤은 BoardWrite, BoardModify, BoardReply, 세 페이지에서 공통적으로 사용합니다.
~/DotNetNote/Controls/BoardEditorFormControl.ascx
<%@ Control Language="C#" AutoEventWireup="true"
CodeBehind="BoardEditorFormControl.ascx.cs"
Inherits="MemoEngine.DotNetNote.Controls.BoardEditorFormControl" %>
<style>
.BoardWriteFormTableLeftStyle {
width: 100px; text-align:right;
}
</style>
<h2 style="text-align:center;">게시판</h2>
<asp:Label ID="lblTitleDescription" runat="server" ForeColor="#ff0000">
</asp:Label>
<hr />
<table style="width:600px; border-collapse: collapse;
padding: 5px; margin-left:auto; margin-right:auto;">
<% if (!String.IsNullOrEmpty(Request.QueryString["Id"]) &&
FormType == DotNetNote.Models.BoardWriteFormType.Modify) { %>
<tr>
<td class="BoardWriteFormTableLeftStyle">
<span style="color: #ff0000;">*</span>번 호
</td>
<td style="width:500px;">
<%= Request.QueryString["Id"] %>
</td>
</tr>
<% } %>
<tr>
<td class="BoardWriteFormTableLeftStyle">
<span style="color: #ff0000;">*</span>이 름
</td>
<td style="width:500px;">
<asp:TextBox ID="txtName" runat="server" MaxLength="10"
Width="150px" CssClass="form-control"></asp:TextBox>
<asp:RequiredFieldValidator ID="valName" runat="server"
ErrorMessage="* 이름을 작성해 주세요."
ControlToValidate="txtName" Display="None"
SetFocusOnError="True"></asp:RequiredFieldValidator>
</td>
</tr>
<tr>
<td style="text-align:right;">E-mail
</td>
<td>
<asp:TextBox ID="txtEmail" runat="server"
MaxLength="80" Width="200px" CssClass="form-control"
style="display:inline-block;"></asp:TextBox>
<span style="color:#aaaaaa;font-style:italic">(Optional)</span>
<asp:RegularExpressionValidator ID="valEmail" runat="server"
ErrorMessage="* 메일 형식이 올바르지 않습니다."
ControlToValidate="txtEmail" Display="None"
ValidationExpression=
"\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*"
SetFocusOnError="True"></asp:RegularExpressionValidator>
</td>
</tr>
<tr>
<td style="text-align:right;">Homepage</td>
<td>
<asp:TextBox ID="txtHomepage" runat="server"
CssClass="form-control" style="display:inline-block;"
MaxLength="80" Width="300px"></asp:TextBox>
<span style="color:#aaaaaa;font-style:italic;">(Optional)</span>
<asp:RegularExpressionValidator ID="valHomepage" runat="server"
ErrorMessage="* 홈페이지를 정확히 작성해주세요."
ControlToValidate="txtHomepage" Display="None"
ValidationExpression=
"http://([\w-]+\.)+[\w-]+(/[\w- ./?%&=]*)?"
SetFocusOnError="True"></asp:RegularExpressionValidator>
</td>
</tr>
<tr>
<td style="text-align:right;">
<span style="color: #ff0000;">*</span>제 목
</td>
<td>
<asp:TextBox ID="txtTitle" runat="server" CssClass="form-control"
Width="480px"></asp:TextBox>
<asp:RequiredFieldValidator ID="valTitle" runat="server"
ErrorMessage="* 제목을 기입해 주세요."
ControlToValidate="txtTitle" Display="None"
SetFocusOnError="True"></asp:RequiredFieldValidator>
</td>
</tr>
<tr>
<td style="text-align:right;">
<span style="color: #ff0000;">*</span>내 용
</td>
<td>
<asp:TextBox ID="txtContent" TextMode="MultiLine" runat="server"
Height="150px" Width="480px" CssClass="form-control"
style="display:inline-block;"></asp:TextBox>
<asp:RequiredFieldValidator ID="valContent" runat="server"
ErrorMessage="* 내용을 기입해 주세요."
ControlToValidate="txtContent" Display="None"
SetFocusOnError="True"></asp:RequiredFieldValidator>
</td>
</tr>
<tr>
<td style="text-align:right;">파일첨부</td>
<td>
<asp:CheckBox ID="chkUpload" runat="server" CssClass="check-inline"
Text="이 체크박스를 선택하면 업로드 화면이 나타납니다."
AutoPostBack="True"
OnCheckedChanged="chkUpload_CheckedChanged"></asp:CheckBox>
<span style="color:#aaaaaa;font-style:italic">(Optional)</span>
<br />
<asp:Panel ID="pnlFile" runat="server" Width="480px"
Visible="false" Height="25px">
<input id="txtFileName" style="width: 290px;
height: 19px" type="file" name="txtFileName" runat="server">
<asp:label ID="lblFileNamePrevious" text="" runat="server"
Visible="false" />
</asp:Panel>
</td>
</tr>
<tr>
<td style="text-align:right;">
<span style="color: #ff0000;">*</span>인코딩
</td>
<td>
<asp:RadioButtonList ID="rdoEncoding" runat="server"
RepeatDirection="Horizontal" RepeatLayout="Flow">
<asp:ListItem Value="Text" Selected="True">Text</asp:ListItem>
<asp:ListItem Value="HTML">HTML</asp:ListItem>
<asp:ListItem Value="Mixed">Mixed</asp:ListItem>
</asp:RadioButtonList>
</td>
</tr>
<tr>
<td style="text-align:right;">
<span style="color: #ff0000;">*</span>비밀번호
</td>
<td>
<asp:TextBox ID="txtPassword" runat="server" CssClass="form-control"
style="display:inline-block;" MaxLength="20" Width="150px"
TextMode="Password" EnableViewState="False"></asp:TextBox>
<span style=" color: #aaaaaa;">(수정/삭제 시 필요)</span>
<asp:RequiredFieldValidator ID="valPassword" runat="server"
ErrorMessage="* 비밀번호를 기입해 주세요."
ControlToValidate="txtPassword" Display="None"
SetFocusOnError="True"></asp:RequiredFieldValidator>
</td>
</tr>
<%
if (!Page.User.Identity.IsAuthenticated)
{
%>
<tr>
<td style="text-align:right;">
<span style="color: #ff0000;">*</span>보안코드
</td>
<td>
<asp:TextBox ID="txtImageText" runat="server"
CssClass="form-control"
style="display:inline-block;"
EnableViewState="False" MaxLength="20"
Width="150px"></asp:TextBox>
<span style=" color: #aaaaaa;">
(아래에 제시되는 보안코드를 입력하십시오.)</span>
<br />
<asp:Image ID="imgSecurityImageText" runat="server"
ImageUrl="~/DotNetNote/ImageText.aspx" />
<asp:Label ID="lblError" runat="server" ForeColor="Red"></asp:Label>
</td>
</tr>
<%
}
%>
<tr>
<td colspan="2" style="text-align:center;">
<asp:Button ID="btnWrite" runat="server" Text="저장"
CssClass="btn btn-primary" OnClick="btnWrite_Click"></asp:Button>
<a href="BoardList.aspx" class="btn btn-default">리스트</a>
<br />
<asp:ValidationSummary ID="valSummary" runat="server"
ShowSummary="False"
ShowMessageBox="True"
DisplayMode="List"></asp:ValidationSummary>
<br />
</td>
</tr>
</table>
(3) BoardEditorFormControl.ascx.cs 파일을 열고 다음과 같이 코드를 작성합니다.
~/DotNetNote/Controls/BoardEditorFormControl.ascx.cs
using DotNetNote.Models;
using Dul;
using System;
using System.IO;
namespace MemoEngine.DotNetNote.Controls
{
public partial class BoardEditorFormControl : System.Web.UI.UserControl
{
/// <summary>
/// 공통 속성
/// </summary>
public BoardWriteFormType FormType { get; set; }
private string _Id;// 앞(리스트)에서 넘어 온 번호 저장
private string _BaseDir = String.Empty;// 파일 업로드 폴더
private string _FileName = String.Empty;// 파일명 저장 필드
private int _FileSize = 0;// 파일 크기 저장 필드
protected void Page_Load(object sender, EventArgs e)
{
_Id = Request.QueryString["Id"];
if (!Page.IsPostBack) // 처음 로드할 때만 바인딩
{
switch (FormType)
{
case BoardWriteFormType.Write:
lblTitleDescription.Text =
"글 쓰기 - 다음 필드들을 채워주세요.";
break;
case BoardWriteFormType.Modify:
lblTitleDescription.Text =
"글 수정 - 아래 항목을 수정하세요.";
DisplayDataForModify();
break;
case BoardWriteFormType.Reply:
lblTitleDescription.Text =
"글 답변 - 다음 필드들을 채워주세요.";
DisplayDataForReply();
break;
}
}
}
private void DisplayDataForModify()
{
// 넘어 온 Id 값에 해당하는 레코드를 하나 읽어서 Note 클래스에 바인딩
var note = (new NoteRepository()).GetNoteById(Convert.ToInt32(_Id));
txtName.Text = note.Name;
txtEmail.Text = note.Email;
txtHomepage.Text = note.Homepage;
txtTitle.Text = note.Title;
txtContent.Text = note.Content;
// 인코딩 방식에 따른 데이터 출력
string strEncoding = note.Encoding;
if (strEncoding == "Text") // Text : 소스 그대로 표현
{
rdoEncoding.SelectedIndex = 0;
}
else if (strEncoding == "Mixed") // Mixed : 엔터 처리만
{
rdoEncoding.SelectedIndex = 2;
}
else // HTML : HTML 형식으로 출력
{
rdoEncoding.SelectedIndex = 1;
}
// 첨부된 파일명 및 파일 크기 기록
if (note.FileName.Length > 1)
{
ViewState["FileName"] = note.FileName;
ViewState["FileSize"] = note.FileSize;
pnlFile.Height = 50;
lblFileNamePrevious.Visible = true;
lblFileNamePrevious.Text =
$"기존에 업로드된 파일명: {note.FileName}";
}
else
{
ViewState["FileName"] = "";
ViewState["FileSize"] = 0;
}
}
private void DisplayDataForReply()
{
// 넘어 온 Id 값에 해당하는 레코드를 하나 읽어서 Note 클래스에 바인딩
var note = (new NoteRepository()).GetNoteById(Convert.ToInt32(_Id));
txtTitle.Text = $"Re : {note.Title}";
txtContent.Text =
$"\n\nOn {note.PostDate}, '{note.Name}' wrote:\n----------\n>"
+ $"{note.Content.Replace("\n", "\n>")}\n---------";
}
protected void btnWrite_Click(object sender, EventArgs e)
{
// 보안 문자를 정확히 입력했거나, 로그인이 된 상태라면...
if (IsImageTextCorrect())
{
UploadProcess(); // 파일 업로드 관련 코드 분리
Note note = new Note();
note.Id = Convert.ToInt32(_Id);
note.Name = txtName.Text;
note.Email = HtmlUtility.Encode(txtEmail.Text);
note.Homepage = txtHomepage.Text;
note.Title = HtmlUtility.Encode(txtTitle.Text);
note.Content = txtContent.Text;
note.FileName = _FileName;
note.FileSize = _FileSize;
note.Password = txtPassword.Text;
note.PostIp = Request.UserHostAddress;
note.Encoding = rdoEncoding.SelectedValue;
NoteRepository repository = new NoteRepository();
switch (FormType)
{
case BoardWriteFormType.Write:
repository.Add(note);
Response.Redirect("BoardList.aspx");
break;
case BoardWriteFormType.Modify:
note.ModifyIp = Request.UserHostAddress;
note.FileName = ViewState["FileName"].ToString();
note.FileSize = Convert.ToInt32(ViewState["FileSize"]);
int r = repository.UpdateNote(note);
if (r > 0) // 업데이트 완료
{
Response.Redirect($"BoardView.aspx?Id={_Id}");
}
else
{
lblError.Text =
"업데이트가 되지 않았습니다. 암호를 확인하세요.";
}
break;
case BoardWriteFormType.Reply:
note.ParentNum = Convert.ToInt32(_Id);
repository.ReplyNote(note);
Response.Redirect("BoardList.aspx");
break;
default:
repository.Add(note);
Response.Redirect("BoardList.aspx");
break;
}
}
else
{
lblError.Text = "보안코드가 틀립니다. 다시 입력하세요.";
}
}
private void UploadProcess()
{
// 파일 업로드 처리 시작
_BaseDir = Server.MapPath("./MyFiles");
_FileName = String.Empty;
_FileSize = 0;
if (txtFileName.PostedFile != null)
{
if (txtFileName.PostedFile.FileName.Trim().Length > 0
&& txtFileName.PostedFile.ContentLength > 0)
{
if (FormType == BoardWriteFormType.Modify)
{
ViewState["FileName"] =
FileUtility.GetFileNameWithNumbering(
_BaseDir, Path.GetFileName(
txtFileName.PostedFile.FileName));
ViewState["FileSize"] =
txtFileName.PostedFile.ContentLength;
//업로드 처리 : SaveAs()
txtFileName.PostedFile.SaveAs(
Path.Combine(_BaseDir,
ViewState["FileName"].ToString()));
}
else // BoardWrite, BoardReply
{
_FileName =
FileUtility.GetFileNameWithNumbering(
_BaseDir,
Path.GetFileName(
txtFileName.PostedFile.FileName));
_FileSize = txtFileName.PostedFile.ContentLength;
// 업로드 처리 : SaveAs()
txtFileName.PostedFile.SaveAs(
Path.Combine(_BaseDir, _FileName));
}
}
}// 파일 업로드 처리 끝
}
/// <summary>
/// 로그인하였거나, 이미지 텍스트를 정상적으로 입력하면 true 값 반환
/// </summary>
private bool IsImageTextCorrect()
{
if (Page.User.Identity.IsAuthenticated)
{
return true;
}
else
{
if (Session["ImageText"] != null)
{
return (txtImageText.Text == Session["ImageText"].ToString());
}
}
return false; // 보안 코드를 통과하지 못함
}
// 파일 첨부 레이어 보이기/감추기
protected void chkUpload_CheckedChanged(object sender, EventArgs e)
{
pnlFile.Visible = !pnlFile.Visible;
}
}
}
(4) 자, 그러면, BoardWrite.aspx 페이지를 열고 다음과 같이 웹 폼 사용자 정의 컨트롤을 등록합니다. 태그 보기 부분의 소스는 앞서 웹 브라우저로 실행한 페이지와 구성이 같습니다. 모든 태그를 책에 옮겨 놓았으나 Visual Studio에서 자동으로 생성해주는 부분을 제외하고 필요한 부분만 타이핑해도 상관없습니다.
~/DotNetNote/BoardWrite.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardWrite.aspx.cs"
ValidateRequest="false"
Inherits="MemoEngine.DotNetNote.BoardWrite" %>
<%@ Register Src="~/DotNetNote/Controls/BoardEditorFormControl.ascx"
TagPrefix="uc1" TagName="BoardEditorFormControl" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<uc1:BoardEditorFormControl runat="server" id="ctlBoardEditorFormControl" />
</asp:Content>
코드에서 한 가지 주의합니다. HTML 태그를 폼을 통해서 전송받으려면 반드시 aspx 페이지 상단에 있는 Page 지시문에서 ValidateRequest=”false” 코드가 설정되어 있어야 합니다. 보안상 ASP.NET은 폼 태그를 통해서 HTML 소스를 직접 입력할 수 없습니다.
(5) BoardWrite.aspx.cs 파일을 열고 다음과 같이 코드를 작성합니다. 이미 공통 폼을 웹 폼 사용자 정의 컨트롤로 뽑아냈으므로 코드가 거의 없이 BoardWriteFormType만 Write로 전달해주면 끝입니다. 이렇게 하면 게시판의 글쓰기 페이지는 완성됩니다.
~/DotNetNote/BoardWrite.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardWrite : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
ctlBoardEditorFormControl.FormType = BoardWriteFormType.Write;
}
}
}
<참고> 파일 업로드 폴더의 윈도 보안 기능 웹 프로젝트의 DotNetNote 폴더의 MyFiles 폴더는 반드시 NTFS 파일 시스템의 “Everyone” 또는 “NETWORK SERVICE” 보안 권한에 쓰기 권한을 부여해야 합니다. 그렇지 않으면 파일을 업로드할 때 에러가 발생할 수 있습니다.
</참고>
(6) ~/DotNetNote/BoardWrite.aspx 페이지를 웹 브라우저로 실행한 후 값을 입력해 봅니다. 코드가 잘 작성되었다면 데이터가 저장되고 아직 완성하지 않은 리스트 페이지로 이동합니다. 그림 19 22 데이터 저장 테스트
저장에 문제가 없다면 다음 그림과 같이 게시판 리스트 페이지로 이동됩니다. 그림 19 23 게시판 리스트 페이지로 이동
(7) 아직 데이터 출력 페이지를 구현하지 않았기 때문에 데이터가 저장되었는지 확인하기 위해서는 SQL Server 개체 탐색기를 통해야 합니다. DotNetNote 데이터베이스의 Notes 테이블에 마우스 오른쪽 버튼을 클릭한 후 <데이터 보기> 메뉴를 통해 데이터를 살펴볼 수 있습니다. 그림 19 24 저장된 데이터 확인
19.6.8 따라하기 7: 리스트 페이지 구현
(1) 게시판 리스트 페이지에서 사용될 검색 폼의 기능을 먼저 구현하도록 합니다. 검색 폼이 리스트 페이지에서 사용되므로 먼저 작업합니다. 반대로 작업하면 리스트 페이지 작성 시 검색 폼의 코드가 들어오는데 없는 파일을 요청하므로 에러가 발생합니다. Controls 폴더에 BoardSearchFormSingleControl.ascx 이름으로 웹 폼 사용자 정의 컨트롤을 생성합니다. 반드시 Controls 폴더에 구성할 필요는 없지만, 많은 양의 ascx 파일을 생성할 때는 Controls 또는 UserControls 같은 폴더를 두고 파일을 모아놓는 방법도 하나의 방법이기에 적용해보았습니다. 리스트 페이지에서 실행될 검색 폼의 모양은 다음 그림과 같습니다. 그림 19 25 검색 폼의 모양
(2) BoardSearchFormSingleControl.ascx 파일을 열고 다음과 같이 코드를 작성합니다.
~/DotNetNote/Controls/BoardSearchFormSingleControl.aspx
<%@ Control Language="C#" AutoEventWireup="true"
CodeBehind="BoardSearchFormSingleControl.ascx.cs"
Inherits="MemoEngine.DotNetNote.Controls.BoardSearchFormSingleControl" %>
<div style="text-align:center;">
<asp:DropDownList ID="SearchField" runat="server"
CssClass="form-control" Width="80px" Style="display: inline-block;">
<asp:ListItem Value="Name">이름</asp:ListItem>
<asp:ListItem Value="Title">제목</asp:ListItem>
<asp:ListItem Value="Content">내용</asp:ListItem>
</asp:DropDownList>
<asp:TextBox ID="SearchQuery" runat="server" Width="200px"
CssClass="form-control" Style="display: inline-block;">
</asp:TextBox>
<asp:RequiredFieldValidator ID="valSearchQuery" runat="server"
ControlToValidate="SearchQuery" Display="None"
ErrorMessage="검색할 단어를 입력하세요."></asp:RequiredFieldValidator>
<asp:ValidationSummary ID="valSummary" runat="server"
ShowSummary="False" ShowMessageBox="True"></asp:ValidationSummary>
<asp:Button ID="btnSearch" runat="server" Text="검 색"
CssClass="form-control" Width="100px"
Style="display: inline-block;" OnClick="btnSearch_Click"></asp:Button>
</div>
<br />
<% if (!string.IsNullOrEmpty(Request.QueryString["SearchField"])
&& !String.IsNullOrEmpty(Request.QueryString["SearchQuery"])) { %>
<div style="text-align:center;">
<a href="/DotNetNote/BoardList.aspx" class="btn btn-success">검색 완료</a>
</div>
<% } %>
<코드>
(3) BoardSearchFormSingleControl.ascx 파일에서 [F7]을 누르면 코드 숨김 파일로 이동할 수 있습니다. 다음과 같이 검색 문자열을 넘겨서 리스트 페이지로 다시 요청하는 코드를 구현합니다.
```csharp
~/DotNetNote/Controls/BoardSearchFormSingleControl.ascx.cs
using System;
namespace MemoEngine.DotNetNote.Controls
{
public partial class BoardSearchFormSingleControl : System.Web.UI.UserControl
{
protected void Page_Load(object sender, EventArgs e)
{
}
protected void btnSearch_Click(object sender, EventArgs e)
{
string strQuery =
String.Format(
"/DotNetNote/BoardList.aspx?SearchField={0}&SearchQuery={1}",
SearchField.SelectedItem.Value, SearchQuery.Text);
Response.Redirect(strQuery);
}
}
}
<코드>
(4) 페이저(Pager)는 리스트 페이지에서 페이지를 이동하는 링크 모음을 의미합니다. DotNetNote 폴더의 Controls 폴더에 AdvancedPagingSingleWithBootstrap.ascx 이름으로 웹 폼 사용자 정의 컨트롤을 생성합니다. 게시판 리스트 페이지에서 사용할 페이징 처리 로직을 따로 웹 폼 사용자 정의 컨트롤을 구현하는 방법을 살펴보겠습니다.
그림처럼 페이저가 구현될 때 <4>번 링크에 마우스를 올리면 웹 브라우저에서는 아래 링크처럼 게시판 리스트 페이지에 Page 매개변수를 바탕으로 만든 쿼리스트링이 전송됩니다. ASP.NET 웹 폼 기본 템플릿으로 프로젝트를 만들어서 Friendly URL 기능이 적용되어 BoardList.aspx의 aspx 확장자는 제거된 상태로 호출이 되도 무관합니다.
그림 19 26 페이징 리스트 링크
검색 후 결과 페이지에서 페이저의 모습은 다음과 같이 SearchField와 SearchQuery, 두 가지 매개변수를 Page와 함께 전송되는 기능도 포함되어 있습니다.
그림 19 27 검색 결과 페이징 리스트
(5) 페이징 처리를 위한 UI 쪽 코드는 단순히 리터럴 컨트롤 하나만을 사용합니다.
```csharp
~/DotNetNote/Controls/AdvancedPagingSingleWithBootstrap.ascx
<%@ Control Language="C#" AutoEventWireup="true"
CodeBehind="AdvancedPagingSingleWithBootstrap.ascx.cs"
Inherits="MemoEngine.DotNetNote.Controls.AdvancedPagingSingleWithBootstrap" %>
<asp:Literal ID="ctlAdvancedPaingWithBootstrap" runat="server"></asp:Literal>
<코드>
(6) 페이징 컨트롤의 코드 숨김 파일로 이동해서 다음과 같이 코드를 입력합니다.
```csharp
~/DotNetNote/Controls/AdvancedPagingSingleWithBootstrap.ascx.cs
using System;
using System.ComponentModel;
namespace MemoEngine.DotNetNote.Controls
{
public partial class AdvancedPagingSingleWithBootstrap :
System.Web.UI.UserControl
{
// 공통 속성: 검색 모드: 검색 모드이면 true, 그렇지 않으면 false.
public bool SearchMode { get; set; } = false; // 일반 모드, 검색 모드
public string SearchField { get; set; } // 검색 필드: Name, Title, ...
public string SearchQuery { get; set; } // 검색 내용
/// <summary>
/// 몇 번째 페이지를 보여줄 건지 : 웹 폼에서 속성으로 전달됨
/// </summary>
[Category("페이징처리")] // Category 특성은 모두 생략 가능(속성에 표시됨)
public int PageIndex { get; set; }
/// <summary>
/// 총 몇 개의 페이지가 만들어지는지 : 총 레코드 수 / 10(한 페이지에서 보여줄)
/// </summary>
[Category("페이징처리")]
public int PageCount { get; set; }
/// <summary>
/// 페이지 사이즈 : 한 페이지에 몇 개의 레코드를 보여줄 건지 결정
/// </summary>
[Category("페이징처리")]
[Description("한 페이지에 몇 개의 레코드를 보여줄 건지 결정")]
public int PageSize { get; set; } = 10; // 페이지 사이즈는 기본값이 10
/// <summary>
/// 레코드 카운트 : 현재 테이블에 몇 개의 레코드가 있는지 지정
/// </summary>
private int _RecordCount;
[Category("페이징처리")]
[Description("현재 테이블에 몇 개의 레코드가 있는지 지정")]
public int RecordCount
{
get { return _RecordCount; }
set
{
_RecordCount = value;
// 총 페이지 수 계산
PageCount = ((_RecordCount - 1) / PageSize) + 1; // 계산식
}
}
// 페이지 로드할 때 페이저 구현하기
protected void Page_Load(object sender, EventArgs e)
{
// 검색 모드 결정: 검색 모드이면 SearchMode 속성이 true
SearchMode =
(!string.IsNullOrEmpty(Request.QueryString["SearchField"]) &&
!string.IsNullOrEmpty(Request.QueryString["SearchQuery"]));
if (SearchMode)
{
SearchField = Request.QueryString["SearchField"];
SearchQuery = Request.QueryString["SearchQuery"];
}
++PageIndex; // 코드: 0, 1, 2 인덱스로 사용, UI: 1, 2, 3 페이지로 사용
int i = 0;
// <!--이전 10개, 다음 10개 페이징 처리 시작-->
string strPage = "<ul class='pagination pagination-sm'>";
if (PageIndex > 10) // 이전 10개 링크가 있다면, ...
{
// 검색 모드이면 추가적으로 SearchField와 SearchQuery를 전송함
if (SearchMode)
{
strPage += "<li><a href=\""
+ Request.ServerVariables["SCRIPT_NAME"]
//+ "?BoardName=" + Request["BoardName"] // 멀티 게시판
+ "?Page="
+ Convert.ToString(((PageIndex - 1) / (int)10) * 10)
+ "&SearchField=" + SearchField
+ "&SearchQuery=" + SearchQuery + "\">◀</a></li>";
}
else
{
strPage += "<li><a href=\""
+ Request.ServerVariables["SCRIPT_NAME"]
//+ "?BoardName=" + Request["BoardName"]
+ "?Page="
+ Convert.ToString(((PageIndex - 1) / (int)10) * 10)
+ "\">◀</a></li>";
}
}
else
{
strPage += "<li class=\"disabled\"><a>◁</a></li>";
}
// 가운데, 숫자 형식의 페이저 표시
for (
i = (((PageIndex - 1) / (int)10) * 10 + 1);
i <= ((((PageIndex - 1) / (int)10) + 1) * 10);
i++)
{
if (i > PageCount)
{
break; // 있는 페이지까지만 페이저 링크 출력
}
// 현재 보고 있는 페이지면, 활성화(active)
if (i == PageIndex)
{
strPage += " <li class='active'><a href='#'>"
+ i.ToString() + "</a></li>";
}
else
{
if (SearchMode)
{
strPage += "<li><a href=\""
+ Request.ServerVariables["SCRIPT_NAME"]
//+ "?BoardName=" + Request["BoardName"]
+ "?Page=" + i.ToString()
+ "&SearchField=" + SearchField
+ "&SearchQuery=" + SearchQuery + "\">"
+ i.ToString() + "</a></li>";
}
else
{
strPage += "<li><a href=\""
+ Request.ServerVariables["SCRIPT_NAME"]
//+ "?BoardName=" + Request["BoardName"]
+ "?Page=" + i.ToString() + "\">"
+ i.ToString() + "</a></li>";
}
}
}
// 다음 10개 링크
if (i < PageCount) // 다음 10개 링크가 있다면, ...
{
if (SearchMode)
{
strPage += "<li><a href=\""
+ Request.ServerVariables["SCRIPT_NAME"]
//+ "?BoardName=" + Request["BoardName"]
+ "?Page="
+ Convert.ToString(((PageIndex - 1) / (int)10) * 10 + 11)
+ "&SearchField=" + SearchField
+ "&SearchQuery=" + SearchQuery + "\">▶</a></li>";
}
else
{
strPage += "<li><a href=\""
+ Request.ServerVariables["SCRIPT_NAME"]
//+ "?BoardName=" + Request["BoardName"]
+ "?Page="
+ Convert.ToString(((PageIndex - 1) / (int)10) * 10 + 11)
+ "\">▶</a></li>";
}
}
else
{
strPage += "<li class=\"disabled\"><a>▷</a></li>";
}
// <!--이전 10개, 다음 10개 페이징 처리 종료-->
strPage += "</ul>";
ctlAdvancedPaingWithBootstrap.Text = strPage;
}
}
}
<코드>
<참고> Category 특성
페이징을 위한 코드 숨김 파일의 속성에 사용된 Category 특성은 웹 폼 사용자 정의 컨트롤이 특정 웹 폼에 등록된 후 [F4]를 눌러 나타나는 속성창에서 해당 속성이 노출되도록 설정해주는 기능입니다.
</참고>
<참고> 선언적으로 웹 폼 사용자 정의 컨트롤의 속성 설정하기
웹 폼 사용자 정의 컨트롤의 속성은 아래 코드와 같이 특정 웹 폼에 등록된 후 직접 RecordCount와 같은 속성에 값을 지정할 수 있습니다.
<코드>
<uc1:AdvancedPagingSingleWithBootstrap runat="server"
RecordCount="100" PageSize="10" PageIndex="1"
id="AdvancedPagingSingleWithBootstrap" />
위 코드와 같이 특정 속성을 설정하면 그림과 같이 출력됩니다.
</참고>
(7) 이제 게시판의 시작 페이지인 글 목록 페이지를 작성해보겠습니다. BoardList.aspx 페이지는 완성 후 다음 그림과 같이 실행됩니다. 그림 19 28 게시판 리스트 페이지
(8) 글 목록 페이지 소스 부분인 BoardList.aspx 페이지를 열고 다음과 같이 작성합니다. 참고로 주요 로직은 Dul 클래스 라이브러리 프로젝트에 구현이 되어 있는 상태라서 단순히 문자열만 출력하는 게 아니라 Eval(“필드명”) 형태를 주요 라이브러리 함수로 묶어서 UI를 꾸미는 형태로 출력됩니다. 참고로 Dul 라이브러리는 걷어내고 Eval(“필드명”) 형태로만 출력해도 실행에는 지장이 없습니다.
~/DotNetNote/BoardList.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardList.aspx.cs"
Inherits="MemoEngine.DotNetNote.BoardList" %>
<%@ Register
Src="~/DotNetNote/Controls/BoardSearchFormSingleControl.ascx"
TagPrefix="uc1" TagName="BoardSearchFormSingleControl" %>
<%@ Register Src="~/DotNetNote/Controls/AdvancedPagingSingleWithBootstrap.ascx"
TagPrefix="uc1" TagName="AdvancedPagingSingleWithBootstrap" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h2 style="text-align: center;">게시판</h2>
<span style="color: #ff0000">글 목록 - 완성형 게시판입니다.</span>
<hr />
<table style="width: 700px; margin-left: auto; margin-right: auto;">
<tr>
<td>
<style>
table th {
text-align: center;
}
</style>
<div style=
"font-style: italic; text-align: right; font-size: 8pt;">
Total Record:
<asp:Literal ID="lblTotalRecord" runat="server"></asp:Literal>
</div>
<asp:GridView ID="ctlBoardList"
runat="server" AutoGenerateColumns="False" DataKeyNames="Id"
CssClass="table table-bordered table-hover table-condensed
table-striped table-responsive">
<Columns>
<asp:TemplateField HeaderText="번호"
HeaderStyle-Width="50px"
ItemStyle-HorizontalAlign="Center">
<ItemTemplate>
<%--<%# Eval("Id") %>--%>
<%# RecordCount -
((Container.DataItemIndex)) -
(PageIndex * 10) %>
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="제 목"
ItemStyle-HorizontalAlign="Left"
HeaderStyle-Width="350px">
<ItemTemplate>
<%# Dul.BoardLibrary.FuncStep(Eval("Step")) %>
<asp:HyperLink ID="lnkTitle" runat="server"
NavigateUrl=
'<%# "BoardView.aspx?Id=" + Eval("Id") %>'>
<%# Dul.StringLibrary.CutStringUnicode(
Eval("Title").ToString(), 30) %>
</asp:HyperLink>
<%# Dul.BoardLibrary.ShowCommentCount(
Eval("CommentCount")) %>
<%# Dul.BoardLibrary.FuncNew(Eval("PostDate"))%>
</ItemTemplate>
</asp:TemplateField>
<asp:TemplateField HeaderText="파일"
HeaderStyle-Width="70px"
ItemStyle-HorizontalAlign="Center">
<ItemTemplate>
<%# Dul.BoardLibrary.FuncFileDownSingle(
Convert.ToInt32(Eval("Id")),
Eval("FileName").ToString(),
Eval("FileSize").ToString()) %>
</ItemTemplate>
</asp:TemplateField>
<asp:BoundField DataField="Name" HeaderText="작성자"
HeaderStyle-Width="60px"
ItemStyle-HorizontalAlign="Center"></asp:BoundField>
<asp:TemplateField HeaderText="작성일"
ItemStyle-Width="90px"
ItemStyle-HorizontalAlign="Center">
<ItemTemplate>
<%# Dul.BoardLibrary.FuncShowTime(
Eval("PostDate")) %>
</ItemTemplate>
</asp:TemplateField>
<asp:BoundField DataField="ReadCount" HeaderText="조회수"
ItemStyle-HorizontalAlign="Right"
HeaderStyle-Width="60px"></asp:BoundField>
</Columns>
</asp:GridView>
</td>
</tr>
<tr>
<td style="text-align: center;">
<uc1:AdvancedPagingSingleWithBootstrap runat="server"
ID="AdvancedPagingSingleWithBootstrap" />
</td>
</tr>
<tr>
<td style="text-align: right;">
<a href="BoardWrite.aspx" class="btn btn-primary">글쓰기</a>
</td>
</tr>
</table>
<uc1:BoardSearchFormSingleControl runat="server"
ID="BoardSearchFormSingleControl" />
</asp:Content>
(9) 리스트 페이지의 코드 숨김 파일은 다음과 같이 작성합니다.
~/DotNetNote/BoardList.aspx.cs
using DotNetNote.Models;
using System;
using System.Web.UI;
namespace MemoEngine.DotNetNote
{
public partial class BoardList : System.Web.UI.Page
{
// 공통 속성: 검색 모드: 검색 모드이면 true, 그렇지 않으면 false.
// [참고] 이러한 공통 속성들은 Base 클래스에 모아 넣고 상속해줘도 좋음
public bool SearchMode { get; set; } = false;
public string SearchField { get; set; }
public string SearchQuery { get; set; }
public int PageIndex = 0; // 현재 보여줄 페이지 번호
public int RecordCount = 0; // 총 레코드 개수(글번호 순서 정렬용)
private NoteRepository _repository;
public BoardList()
{
_repository = new NoteRepository();
}
protected void Page_Load(object sender, EventArgs e)
{
// 검색 모드 결정
SearchMode =
(!string.IsNullOrEmpty(Request.QueryString["SearchField"]) &&
!string.IsNullOrEmpty(Request.QueryString["SearchQuery"]));
if (SearchMode)
{
SearchField = Request.QueryString["SearchField"];
SearchQuery = Request.QueryString["SearchQuery"];
}
// 쿼리스트링에 따른 페이지 보여주기
if (Request["Page"] != null)
{
// Page는 보여지는 쪽은 1, 2, 3, ... 코드단에서는 0, 1, 2, ...
PageIndex = Convert.ToInt32(Request["Page"]) - 1;
}
else
{
PageIndex = 0; // 1페이지
}
// 쿠키를 사용한 리스트 페이지 번호 유지 적용:
// 100번째 페이지의 글 보고, 다시 리스트 왔을 때 100번째 페이지 표시
if (Request.Cookies["DotNetNote"] != null)
{
if (!String.IsNullOrEmpty(
Request.Cookies["DotNetNote"]["PageNum"]))
{
PageIndex = Convert.ToInt32(
Request.Cookies["DotNetNote"]["PageNum"]);
}
else
{
PageIndex = 0;
}
}
// 레코드 카운트 출력
if (SearchMode == false)
{
// Notes 테이블의 전체 레코드
RecordCount =
_repository.GetCountAll();
}
else
{
// Notes 테이블 중 SearchField+SearchQuery에 해당하는 레코드 수
RecordCount =
_repository.GetCountBySearch(SearchField, SearchQuery);
}
lblTotalRecord.Text = RecordCount.ToString();
// 페이징 처리
AdvancedPagingSingleWithBootstrap.PageIndex = PageIndex;
AdvancedPagingSingleWithBootstrap.RecordCount = RecordCount;
if (!Page.IsPostBack)
{
DisplayData();
}
}
private void DisplayData()
{
if (SearchMode == false) // 기본 리스트
{
ctlBoardList.DataSource = _repository.GetAll(PageIndex);
}
else // 검색 결과 리스트
{
ctlBoardList.DataSource = _repository.GetSeachAll(
PageIndex, SearchField, SearchQuery);
}
ctlBoardList.DataBind();
}
}
}
(10) BoardList.aspx 페이지를 실행 후 <글쓰기> 버튼을 눌러 글쓰기 페이지로 이동 후 서너 개의 데이터를 입력해봅니다. 다음은 데이터 입력 후 리스트 페이지에서 출력된 모습입니다. 최근 글이라서 모두 new 이미지가 표시되고, 파일이 첨부가 되면 파일 항목에 아이콘이 표시됩니다. 참고로 이미지와 아이콘 파일은 이 책의 소스를 내려받은 후 MemoEngine 프로젝트의 images 폴더에서 찾을 수 있습니다. 그림 19 29 게시판 리스트 페이지 실행
19.6.9 따라하기 8: 상세 보기 페이지 구현
(1) DotNetNote 폴더의 Controls 폴더에 게시판 상세 보기 페이지인 BoardView.aspx 페이지에서 사용할 댓글 입출력 폼인 BoardCommentControl.aspcx 파일을 생성합니다. 이 웹 폼 사용자 정의 컨트롤이 상세 보기 페이지에서 실행될 모양은 다음 그림과 같습니다. 댓글 입력과 출력 리스트, 댓글 삭제 페이지로 이동하는 링크를 제공하고 댓글 수정 기능은 따로 구현하지 않았다. 그림 19 30 댓글 입력 및 출력 폼
(2) BoardCommentControl.ascx 파일에 다음과 같이 댓글 입출력 폼을 작성합니다.
~/DotNetNote/Controls/BoardCommentControl.ascx
<%@ Control Language="C#" AutoEventWireup="true"
CodeBehind="BoardCommentControl.ascx.cs"
Inherits="MemoEngine.DotNetNote.Controls.BoardCommentControl" %>
<%--<h3>댓글 리스트</h3>--%>
<asp:Repeater ID="ctlCommentList" runat="server">
<HeaderTemplate>
<table style=
"padding: 10px; margin-left: 20px; margin-right: 20px; width: 95%;">
</HeaderTemplate>
<ItemTemplate>
<tr style="border-bottom: 1px dotted silver;">
<td style="width: 80px;">
<%# Eval("Name") %>
</td>
<td style="width: 350px;">
<%# Dul.HtmlUtility.Encode(Eval("Opinion").ToString()) %>
</td>
<td style="width: 180px;">
<%# Eval("PostDate") %>
</td>
<td style="width: 10px; text-align: center;">
<a href=
'BoardCommentDelete.aspx?BoardId=<%= Request["Id"]
%>&Id=<%# Eval("Id") %>'
title="댓글 삭제">
<img src="/images/dnn/icon_delete_red.gif" border="0">
</a>
</td>
</tr>
</ItemTemplate>
<FooterTemplate>
</table>
</FooterTemplate>
</asp:Repeater>
<%--<h3>댓글 입력</h3>--%>
<table style="width: 500px; margin-left: auto;">
<tr>
<td style="width: 64px; text-align: right;">이 름 :
</td>
<td style="width: 128px;">
<asp:TextBox ID="txtName" runat="server" Width="128px"
CssClass="form-control"
Style="display: inline-block;"></asp:TextBox>
</td>
<td style="width: 64px; text-align: right;">암 호 :
</td>
<td style="width: 128px;">
<asp:TextBox ID="txtPassword" runat="server"
TextMode="Password" Width="128px"
CssClass="form-control" Style="display: inline-block;">
</asp:TextBox>
</td>
<td style="width: 128px; text-align: right;">
<asp:Button ID="btnWriteComment" runat="server"
Text="의견남기기" Width="96px"
CssClass="form-control btn btn-primary"
Style="display: inline-block;"
OnClick="btnWriteComment_Click" />
</td>
</tr>
<tr>
<td style="width: 64px; text-align: right;">의 견 :
</td>
<td colspan="4" style="width: 448px;">
<asp:TextBox ID="txtOpinion" runat="server"
TextMode="MultiLine" Rows="3" Columns="70"
Width="448px" CssClass="form-control"
Style="display: inline-block;"></asp:TextBox>
</td>
</tr>
</table>
<hr />
(3) 댓글 입출력 폼의 처리를 담당하는 BoardCommentControl.ascx.cs 파일은 다음과 같이 코드를 작성합니다.
~/DotNetNote/Controls/BoardCommentControl.ascx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote.Controls
{
public partial class BoardCommentControl : System.Web.UI.UserControl
{
// 리포지터리 개체 생성
private NoteCommentRepository _repository;
public BoardCommentControl()
{
_repository = new NoteCommentRepository();
}
protected void Page_Load(object sender, EventArgs e)
{
if (!Page.IsPostBack)
{
// 데이터 출력(현재 게시글의 번호(Id)에 해당하는 댓글 리스트)
ctlCommentList.DataSource =
_repository.GetNoteComments(Convert.ToInt32(Request["Id"]));
ctlCommentList.DataBind();
}
}
protected void btnWriteComment_Click(object sender, EventArgs e)
{
NoteComment comment = new NoteComment();
comment.BoardId = Convert.ToInt32(Request["Id"]); // 부모글
comment.Name = txtName.Text; // 이름
comment.Password = txtPassword.Text; // 암호
comment.Opinion = txtOpinion.Text; // 댓글
// 데이터 입력
_repository.AddNoteComment(comment);
Response.Redirect(
$"{Request.ServerVariables["SCRIPT_NAME"]}?Id={Request["Id"]}");
}
}
}
(4) 상세 보기 작성 전에 댓글 삭제 기능 페이지인 BoardCommentDelete.aspx 페이지를 작성합니다. 댓글 삭제 페이지는 다음과 같은 스타일로 보여집니다. 그림 19 31 댓글 삭제 폼
(5) BoardCommentDelete.aspx 페이지를 열고 댓글 삭제 폼의 UI 부분을 다음과 같이 작성합니다.
~/DotNetNote/BoardCommentDelete.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardCommentDelete.aspx.cs"
Inherits="MemoEngine.DotNetNote.BoardCommentDelete" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h2 style="text-align: center;">게시판</h2>
<span style="color: #ff0000">
댓글 삭제 - 정확한 암호를 입력하시면 댓글을 삭제하실 수 있습니다.</span>
<hr />
<table style=
"width: 500px; margin-left: auto; margin-right: auto;">
<tr>
<td colspan="2">
<i class="glyphicon glyphicon-lock"></i>
<span style="font-size: 12pt;">댓글 삭제</span>
</td>
</tr>
<tr>
<td> </td>
<td>
<span>해당 댓글을 삭제하시려면 올바른 암호를 입력하십시오.</span>
<br />
암호(<u>P</u>):
<asp:TextBox ID="txtPassword" runat="server" TextMode="Password"
MaxLength="40" Width="250px" AccessKey="P" TabIndex="2"
CssClass="form-control" Style="display: inline-block;">
</asp:TextBox>
</td>
</tr>
<tr>
<td colspan="2" style="text-align: center;">
<asp:Button ID="btnCommentDelete" runat="server" Text="확인"
CssClass="btn btn-danger" OnClick="btnCommentDelete_Click" />
<asp:RequiredFieldValidator ID="valPassword" runat="server"
ErrorMessage="암호를 입력하세요."
ControlToValidate="txtPassword"
Display="None"></asp:RequiredFieldValidator>
<asp:ValidationSummary ID="valSummary" runat="server"
ShowMessageBox="true" ShowSummary="false" />
<input type="button" value="뒤로"
onclick="history.go(-1);" class="btn btn-default"><br />
<asp:Label ID="lblError" runat="server" ForeColor="Red">
</asp:Label>
</td>
</tr>
</table>
</asp:Content>
(6) BoardCommentDelete.aspx.cs 파일을 열고, 댓글 삭제에 대한 로직은 다음과 같이 작성합니다.
~/DotNetNote/BoardCommentDelete.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardCommentDelete : System.Web.UI.Page
{
public int BoardId { get; set; } // 게시판 글 번호
public int Id { get; set; } // 댓글 번호
protected void Page_Load(object sender, EventArgs e)
{
if (Request["BoardId"] != null && Request.QueryString["Id"] != null)
{
BoardId = Convert.ToInt32(Request["BoardId"]);
Id = Convert.ToInt32(Request["Id"]);
}
else
{
Response.End();
}
}
protected void btnCommentDelete_Click(object sender, EventArgs e)
{
var repo = new NoteCommentRepository();
if (repo.GetCountBy(BoardId, Id, txtPassword.Text) > 0)
{
repo.DeleteNoteComment(BoardId, Id, txtPassword.Text);
Response.Redirect($"BoardView.aspx?Id={BoardId}");
}
else
{
lblError.Text = "암호가 틀립니다. 다시 입력해주세요.";
}
}
}
}
(7) 이번에는 글 상세 보기 페이지인 BoardView.aspx 페이지를 작성해보겠습니다. 상세 페이지는 단독으로 실행하지 않고 반드시 리스트 페이지에서 Id 이름으로 쿼리 스트링이 넘어와야 하므로 리스트 페이지를 시작 페이지로 구성합니다. 글 상세 보기 페이지의 실행 결과는 다음과 같습니다. 첨부된 파일이 이미지면 바로 미리보기로 보여주는 기능을 포함하여 하단에 댓글 입출력 폼이 위치합니다. 그림 19 32 게시판 상세 보기 페이지
(8) 상세 보기 페이지인 BoardView.aspx 페이지의 UI 부분을 다음과 같이 작성합니다.
~/DotNetNote/BoardView.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardView.aspx.cs"
Inherits="MemoEngine.DotNetNote.BoardView" %>
<%@ Register Src="~/DotNetNote/Controls/BoardCommentControl.ascx"
TagPrefix="uc1" TagName="BoardCommentControl" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h2 style="text-align: center;">게시판</h2>
<span style="color: #ff0000">
글 보기 - 현재 글에 대해서 수정 및 삭제를 할 수 있습니다. </span>
<hr />
<table style="width: 700px; margin-left: auto; margin-right: auto;">
<tbody>
<tr style="color: white; background-color: #46698c;">
<td style="width: 80px; text-align: right; height: 35px;">
<b style="font-size: 18px">제 목</b> :
</td>
<td colspan="3">
<asp:Label ID="lblTitle" Font-Bold="True" Font-Size="18px"
Width="100%" runat="server"></asp:Label>
</td>
</tr>
<tr style="background-color: #efefef;">
<td class="text-right">번 호 :
</td>
<td>
<asp:Label ID="lblNum" Width="84" runat="server">
</asp:Label>
</td>
<td class="text-right">E-mail :
</td>
<td>
<asp:Label ID="lblEmail" Width="100%" runat="server">
</asp:Label>
</td>
</tr>
<tr style="background-color: #efefef;">
<td class="text-right">이 름 :
</td>
<td>
<asp:Label ID="lblName" Width="100%" runat="server">
</asp:Label>
</td>
<td class="text-right">Homepage :
</td>
<td>
<asp:Label ID="lblHomepage" Width="100%" runat="server">
</asp:Label>
</td>
</tr>
<tr style="background-color: #efefef;">
<td class="text-right">작성일 :
</td>
<td>
<asp:Label ID="lblPostDate" Width="100%" runat="server">
</asp:Label></td>
<td class="text-right">IP 주소 :
</td>
<td>
<asp:Label ID="lblPostIP" Width="100%" runat="server">
</asp:Label>
</td>
</tr>
<tr style="background-color: #efefef;">
<td class="text-right">조회수 :
</td>
<td>
<asp:Label ID="lblReadCount" Width="100%" runat="server">
</asp:Label>
</td>
<td class="text-right">파일 :
</td>
<td>
<asp:Label ID="lblFile" Width="100%" runat="server">
</asp:Label>
</td>
</tr>
<tr>
<td colspan="4" style="padding: 10px;">
<asp:Literal ID="ltrImage" runat="server"></asp:Literal>
<asp:Label ID="lblContent" runat="server"
Width="100%" Height="115px"></asp:Label>
</td>
</tr>
<tr>
<td colspan="4">
<hr />
</td>
</tr>
<tr>
<td colspan="4">
<uc1:BoardCommentControl runat="server"
ID="BoardCommentControl" />
</td>
</tr>
</tbody>
</table>
<div style="text-align: center;">
<asp:HyperLink ID="lnkDelete" runat="server"
CssClass="btn btn-default">삭제</asp:HyperLink>
<asp:HyperLink ID="lnkModify" runat="server"
CssClass="btn btn-default">수정</asp:HyperLink>
<asp:HyperLink ID="lnkReply" runat="server"
CssClass="btn btn-default">답변</asp:HyperLink>
<asp:HyperLink ID="lnkList" runat="server"
NavigateUrl="BoardList.aspx"
CssClass="btn btn-default">리스트</asp:HyperLink>
</div>
<asp:Label ID="lblError" runat="server"
ForeColor="Red" EnableViewState="False"></asp:Label>
<br />
</asp:Content>
(9) BoardView.aspx.cs 파일을 열고 다음과 같이 코드를 작성합니다.
~/DotNetNote/BoardView.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardView : System.Web.UI.Page
{
private string _Id; // 앞(리스트)에서 넘어 온 번호 저장
protected void Page_Load(object sender, EventArgs e)
{
lnkDelete.NavigateUrl = "BoardDelete.aspx?Id=" + Request["Id"];
lnkModify.NavigateUrl = "BoardModify.aspx?Id=" + Request["Id"];
lnkReply.NavigateUrl = "BoardReply.aspx?Id=" + Request["Id"];
_Id = Request.QueryString["Id"];
if (_Id == null)
{
Response.Redirect("./BoardList.aspx");
}
if (!Page.IsPostBack)
{
// 넘어 온 번호에 해당하는 글만 읽어서 각 레이블에 출력
DisplayData();
}
}
private void DisplayData()
{
// 넘어 온 Id 값에 해당하는 레코드를 하나 읽어서 Note 클래스에 바인딩
var note = (new NoteRepository()).GetNoteById(Convert.ToInt32(_Id));
lblNum.Text = _Id; // 번호
lblName.Text = note.Name; // 이름
lblEmail.Text =
String.Format("<a href=\"mailto:{0}\">{0}</a>", note.Email);
lblTitle.Text = note.Title;
string content = note.Content;
// 인코딩 방식에 따른 데이터 출력
string strEncoding = note.Encoding;
if (strEncoding == "Text") // Text : 소스 그대로 표현
{
lblContent.Text =
Dul.HtmlUtility.EncodeWithTabAndSpace(content);
}
else if (strEncoding == "Mixed") // Mixed : 엔터 처리만
{
lblContent.Text = content.Replace("\r\n", "<br />");
}
else // HTML : HTML 형식으로 출력
{
lblContent.Text = content; // 변환없음
}
lblReadCount.Text = note.ReadCount.ToString();
lblHomepage.Text = String.Format(
"<a href=\"{0}\" target=\"_blank\">{0}</a>", note.Homepage);
lblPostDate.Text = note.PostDate.ToString();
lblPostIP.Text = note.PostIp;
if (note.FileName.Length > 1)
{
lblFile.Text = String.Format(
"<a href='./BoardDown.aspx?Id={0}'>"
+ "{1}{2} / 전송수: {3}</a>",
note.Id,
"<img src=\"/images/ext/ext_zip.gif\" border=\"0\">",
note.FileName, note.DownCount);
if (Dul.BoardLibrary.IsPhoto(note.FileName))
{
ltrImage.Text = "<img src=\'ImageDown.aspx?FileName="
+ $"{Server.UrlEncode(note.FileName)}\'>";
}
}
else
{
lblFile.Text = "(업로드된 파일이 없습니다.)";
}
}
}
}
19.6.10 따라하기 9: 수정, 삭제, 답변 페이지 구현
(1) 이번에는 게시판의 글 수정 페이지를 구성해봅니다. 수정 페이지에서는 이미 앞서서 작성한 웹 폼 사용자 정의 컨트롤을 재사용하기에 코드가 간결합니다.
~/DotNetNote/BoardModify.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardModify.aspx.cs"
ValidateRequest="false"
Inherits="MemoEngine.DotNetNote.BoardModify" %>
<%@ Register Src="~/DotNetNote/Controls/BoardEditorFormControl.ascx"
TagPrefix="uc1" TagName="BoardEditorFormControl" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<uc1:BoardEditorFormControl runat="server" ID="ctlBoardEditorFormControl" />
</asp:Content>
(2) BoardModify.aspx.cs 파일의 코드는 다음과 같이 작성합니다.
~/DotNetNote/BoardModify.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardModify : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
ctlBoardEditorFormControl.FormType = BoardWriteFormType.Modify;
}
}
}
(3) 이번에는 게시판의 글 삭제 페이지를 구성해봅니다. 삭제 페이지는 넘어 온 Id에 해당하는 글의 비밀번호가 맞으면 삭제합니다. 글 삭제 페이지는 실행했을 때 다음과 같이 실행됩니다. 그림 19 33 게시판 삭제 페이지
(4) BoardDelete.aspx 페이지를 다음과 같이 작성합니다.
~/DotNetNote/BoardDelete.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardDelete.aspx.cs"
Inherits="MemoEngine.DotNetNote.BoardDelete" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<script>
function ConfirmDelete() {
var varFlag = false;
if (window.confirm("현재 글을 삭제하시겠습니까?")) {
varFlag = true;
}
else {
varFlag = false;
}
return varFlag;
}
</script>
<h2 style="text-align: center;">게시판</h2>
<span style="color: #ff0000">
글 삭제 - 글을 삭제하려면 글 작성 시에 기록하였던 비밀번호가 필요합니다.
</span>
<hr />
<div style="text-align: center;">
<asp:Label ID="lblId" runat="server" ForeColor="Red"></asp:Label>
번 글을 지우시겠습니까?
<br />
비밀번호 :
<asp:TextBox ID="txtPassword" runat="server" Width="120px"
CssClass="form-control" Style="display: inline-block;"
TextMode="Password"></asp:TextBox>
<asp:Button ID="btnDelete" runat="server" Width="100px"
CssClass="btn btn-danger" Style="display: inline-block;"
Text="지우기" OnClick="btnDelete_Click"></asp:Button>
<asp:HyperLink ID="lnkCancel" runat="server"
CssClass="btn btn-default">취소</asp:HyperLink>
<br />
<asp:Label ID="lblMessage" runat="server" ForeColor="Red" />
<br />
</div>
</asp:Content>
(5) BoardDelete.aspx.cs 파일을 열고 다음과 같이 코드를 작성합니다.
~/DotNetNote/BoardDelete.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardDelete : System.Web.UI.Page
{
private string _Id;
protected void Page_Load(object sender, EventArgs e)
{
_Id = Request.QueryString["Id"];
lnkCancel.NavigateUrl = "BoardView.aspx?Id=" + _Id;
lblId.Text = _Id;
// 버튼의 OnClientClick 속성 지정 방식과 동일
btnDelete.Attributes["onclick"] = "return ConfirmDelete();";
if (String.IsNullOrEmpty(_Id))
{
Response.Redirect("BoardList.aspx");
}
}
protected void btnDelete_Click(object sender, EventArgs e)
{
// 현재 글(Id)의 비밀번호가 맞으면 삭제
if ((new NoteRepository()).DeleteNote(
Convert.ToInt32(_Id), txtPassword.Text) > 0)
{
Response.Redirect("BoardList.aspx");
}
else
{
lblMessage.Text = "삭제되지 않았습니다. 비밀번호를 확인하세요.";
}
}
}
}
(6) 마지막으로 게시판의 글 답변 페이지를 구성해봅니다. 답변하기 페이지의 실행 결과는 다음과 같습니다. 그림 19 34 게시판 답변 쓰기 페이지
(7) BoardReply.aspx 페이지를 열고 다음과 같이 작성합니다. 이 역시 글쓰기 페이지와 같이 간결한 코드를 유지할 수 있습니다.
~/DotNetNote/BoardReply.aspx
<%@ Page Title="" Language="C#" MasterPageFile="~/Site.Master"
AutoEventWireup="true" CodeBehind="BoardReply.aspx.cs"
ValidateRequest="false"
Inherits="MemoEngine.DotNetNote.BoardReply" %>
<%@ Register Src="~/DotNetNote/Controls/BoardEditorFormControl.ascx"
TagPrefix="uc1" TagName="BoardEditorFormControl" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<uc1:BoardEditorFormControl runat="server" ID="ctlBoardEditorFormControl" />
</asp:Content>
(8) BoardReply.aspx.cs 파일을 열고 다음 코드를 작성합니다.
~/DotNetNote/BoardReply.aspx.cs
using DotNetNote.Models;
using System;
namespace MemoEngine.DotNetNote
{
public partial class BoardReply : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
ctlBoardEditorFormControl.FormType = BoardWriteFormType.Reply;
}
}
}
19.6.11 따라하기 10: 전체 게시판 테스트
(1) BoardWrite.aspx 페이지를 실행 후 데이터를 입력해봅니다. 그림 19 35 글쓰기 페이지 실행
(2) BoardList.aspx 페이지를 실행 후 데이터가 조회되는 것을 확인합니다.
그림 19 36 리스트 페이지 실행
(3) BoardList.aspx 페이지에서 링크 클릭하여 BoardView.aspx 페이지를 실행합니다. 데이터가 상세보기로 보여집니다. 그림 19 37 상세 보기 페이지 실행
(4) BoardView.aspx 페이지에서 댓글을 입력하고 출력시켜 봅니다.
그림 19 38 댓글 입출력 확인
(5) 상세 보기 페이지에서 <수정> 버튼을 클릭하여 데이터 수정 페이지로 이동합니다. 데이터를 변경해봅니다. 그림 19 39 수정 페이지 실행
(6) 내용 수정 후 다시 상세 보기 페이지로 왔을 때 데이터가 수정되었는지 확인합니다. 그림 19 40 수정된 데이터 확인
(7) 상세 보기 페이지에서 <답변> 버튼을 클릭하여 답변을 달아봅니다. 그림 19 41 답변 달기
(8) 특정 답변에 추가로 답변을 또 달 수 있습니다. 그림 19 42 답변에 답변 달기
(9) 다음은 글쓰기, 답변, 댓글 달기 등을 진행한 모습입니다. 그림 19 43 데이터 입출력 후의 리스트 페이지
(10) 부모 글이 있고 자식 글이 있는 중간 답변 글을 삭제 버튼을 클릭하여 지워봅니다. 그림 19 44 삭제 버튼 클릭
(11) 삭제 페이지에서 암호가 맞으면 삭제가 진행됩니다. 그림 19 45 삭제 페이지
(12) 일반 글은 삭제되면 바로 리스트에서 사라진다. 그러나 답변이 있는 글을 삭제하면 다음 그림과 같이 삭제되지 않고 “(삭제된 글입니다.)”로 업데이트됩니다. 그림 19 46 중간 글 삭제 시 나타나는 메시지
(13) 검색 폼에 데이터를 입력 후 검색 버튼을 클릭하면 조건에 맞는 데이터가 검색됩니다. 그림 19 47 검색 결과 리스트
마무리 이상으로 게시판 프로젝트를 마무리합니다. ASP.NET 웹 폼을 전체적으로 정리하는 차원에서 게시판을 만들어 보았습니다. 게시판 작성시 입력, 출력, 상세보기, 수정, 삭제, 검색, 답변, 댓글 등의 로직은 웹 사이트 제작에 필요한 거의 모든 패턴을 담고 있습니다. 이 장의 내용을 잘 이해한 후 좀 더 응용해서 소스 하나로 게시판 여러 개를 생성해내는 멀티형 게시판으로 확장해나가기 바랍니다.
19.7. ASP.NET 웹 폼 학습을 위한 추천 사이트
이번 강의까지 ASP.NET 웹 폼의 전반적인 내용을 다루었지만, 웹 폼이 할 수 있는 영역의 일부분만 소개하는 정도다. 책에서 다 소개하지 못한 정보는 여러 웹 사이트를 통해서 볼 수 있습니다. 덕분에 ASP.NET의 모든 것을 다루지 않고 최대한 ASP.NET 4.6과 앞으로 배울 MVC 및 ASP.NET Core에서 공통적으로 사용되는 코드 위주로 지금껏 진행해올 수 있었습니다. 다음 사이트를 참고하기 바랍니다.
- ASP.NET 공식 사이트: http://www.asp.net/
- 닷넷코리아: http://www.dotnetkorea.com/
- 태오닷넷: http://www.taeyo.net/
- ASP.NET Korea User Group: https://www.facebook.com/groups/AspxKorea/
19.8 데브렉 강의: 고객 지원 게시판(Supports)
권장 선수 학습: Q&A 게시판(Answers)
https://github.com/VisualAcademy/Answers
완성된 고객 지원 게시판
https://github.com/VisualAcademy/Supports