Blazor Server 완성형 게시판 만들기

  • 147 minutes to read

완성된 프로젝트 실행하기 데모

계층형 게시판 테이블 구조 설명

Blazor Server 프로젝트 생성

이미 앞선 강의에서 Hawaso 이름의 솔루션과 프로젝트를 생성했으면, 그 프로젝트를 그대로 사용해도 됩니다.

Blazor Server를 이용하여 인증을 포함하는 프로젝트를 생성하는 과정은 다음 단계로 이루어집니다. Visual Studio 2022 또는 그 이상의 버전에서 이 작업을 수행할 수 있습니다. 이 문서는 .NET 8.0을 대상으로 합니다.

1. Visual Studio 실행 및 새 프로젝트 생성

  1. Visual Studio를 열고 **"Create a new project"**를 선택합니다.
  2. "Blazor Web App" 프로젝트 템플릿을 검색하고 선택한 다음, **"Next"**를 클릭합니다. Blazor 옵션은 Blazor Server로 설정합니다.
  3. 프로젝트 이름을 Hawaso로 지정하고, 원하는 위치를 선택한 후 **"Next"**를 클릭합니다.

2. 프로젝트 설정 구성

  1. .NET 8.0을 대상 프레임워크로 선택합니다.
  2. "Authentication Type" 옵션을 클릭하여 인증 유형을 선택합니다.
  3. **"Individual User Accounts"**를 선택하여 사용자 계정을 프로젝트에 포함시킵니다. 여기에서 **"Store user accounts in-app"**을 선택할 수 있습니다.
  4. 설정을 마쳤다면, **"Create"**를 클릭하여 프로젝트를 생성합니다.

3. 프로젝트 구조 및 파일 검토

프로젝트가 생성되면 다음과 같은 중요한 파일 및 폴더를 검토할 수 있습니다:

  • Data 폴더: 데이터베이스 컨텍스트 및 마이그레이션 파일이 포함됩니다.
  • Pages 폴더: Blazor 서버 애플리케이션의 Razor 페이지가 포함됩니다.
  • wwwroot 폴더: 정적 파일과 자산이 저장됩니다.
  • appsettings.json: 애플리케이션 설정을 구성하는 파일입니다.
  • Startup.cs: 애플리케이션의 시작 로직 및 서비스 구성을 포함합니다.

4. 데이터베이스 마이그레이션 및 업데이트

인증을 사용하려면 데이터베이스가 필요합니다. 이를 위한 마이그레이션을 생성하고 데이터베이스를 업데이트해야 합니다.

  1. 패키지 관리자 콘솔을 엽니다 (View > Terminal or View > Other Windows > Package Manager Console).
  2. 다음 명령어를 실행하여 마이그레이션을 추가합니다:
    Add-Migration InitialCreate
    
  3. 데이터베이스를 업데이트하려면 다음 명령어를 실행합니다:
    Update-Database
    

5. 인증 확인 및 테스트

  1. F5 키를 눌러 애플리케이션을 디버깅 모드에서 실행합니다.
  2. 애플리케이션에서 Register 또는 Login 페이지로 이동하여 사용자 인증 기능을 테스트합니다.

6. 추가 구성 및 개발

  • Program.cs(Startup.cs) 파일에서 인증, 세션, 쿠키 설정 등을 추가로 구성할 수 있습니다.
  • Components(Pages) 폴더에 새로운 Razor 페이지를 추가하여 애플리케이션을 확장합니다.
  • wwwroot 폴더에 스타일시트(CSS)와 JavaScript 파일을 추가하여 프런트엔드를 사용자 정의합니다.

7. 배포

개발이 완료되면, 애플리케이션을 배포할 준비가 됩니다. Publish 기능을 이용하여 애플리케이션을 웹 서버나 클라우드에 배포할 수 있습니다.

NuGet 패키지 추가

프로젝트에 다음 패키지들의 최신 버전을 설치하세요.

  <ItemGroup>
    <PackageReference Include="Dul" Version="1.3.3" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
    <PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.1" />
  </ItemGroup>

Hawaso.SqlServer SQL Server 데이터베이스 프로젝트 생성

이제 Blazor Server 프로젝트 Hawaso를 성공적으로 생성했습니다. 다음 단계는 Hawaso.SqlServer라는 이름의 SQL Server 데이터베이스 프로젝트를 Hawaso 솔루션에 추가하는 것입니다. 이 프로젝트는 데이터베이스 스키마, 테이블, 뷰, 저장 프로시저 등을 관리하는 데 도움이 됩니다. Visual Studio 2022 또는 그 이상의 버전을 사용하여 이 작업을 수행할 수 있습니다.

1. 솔루션에 새 프로젝트 추가

  1. Visual Studio에서 Hawaso 솔루션을 열어둔 상태에서 Solution Explorer에서 솔루션 이름을 마우스 오른쪽 버튼으로 클릭하고 **Add > New Project...**를 선택합니다.
  2. **"SQL Server Database Project"**를 검색하고 선택한 후, "Next" 버튼을 클릭합니다. 이 옵션이 보이지 않으면 Visual Studio Installer를 통해 SQL Server Data Tools(SSDT) 구성 요소를 설치해야 할 수 있습니다.
  3. 프로젝트 이름을 Hawaso.SqlServer로 지정하고, 위치를 확인한 다음, **"Next"**를 클릭합니다.
  4. 프로젝트 설정이 필요하다면 설정을 조정하고, 그렇지 않다면 **"Create"**를 클릭하여 프로젝트를 생성합니다.

2. 데이터베이스 개체 추가

  1. Hawaso.SqlServer 프로젝트가 생성되면, Solution Explorer에서 프로젝트를 확장하여 "Tables", "Views", "Stored Procedures" 등의 폴더를 볼 수 있습니다.
  2. 테이블을 추가하려면, "Tables" 폴더에서 마우스 오른쪽 버튼을 클릭하고 **Add > Table...**을 선택합니다. 나타나는 대화상자에서 테이블의 이름과 스키마를 정의할 수 있습니다.
  3. 마찬가지로, **"Views"**나 "Stored Procedures" 폴더에서 뷰나 저장 프로시저를 추가할 수 있습니다.

3. SQL 스크립트 작성 및 관리

  1. 각 데이터베이스 개체(테이블, 뷰, 저장 프로시저)에 대해 SQL 정의 스크립트를 작성합니다. 이 스크립트는 프로젝트 내 해당 파일을 더블 클릭하여 열 수 있습니다.
  2. SQL 스크립트를 작성할 때, Visual Studio의 인텔리센스 기능이 SQL 구문과 데이터베이스 스키마 요소를 자동으로 완성하는 데 도움을 줍니다.

4. 데이터베이스 스키마 빌드 및 배포

  1. Solution Explorer에서 Hawaso.SqlServer 프로젝트를 마우스 오른쪽 버튼으로 클릭하고 Build를 선택하여 데이터베이스 스키마를 컴파일합니다.
  2. 빌드가 성공적으로 완료되면, Publish를 선택하여 데이터베이스 스키마를 SQL Server 인스턴스에 배포할 수 있습니다. Publish Database 대화상자에서 대상 데이터베이스 설정을 구성합니다.
  3. "Publish" 버튼을 클릭하여 스키마를 데이터베이스에 배포합니다.

5. 솔루션 탐색 및 확장

  • 이제 Hawaso 솔루션에는 Blazor Server 프로젝트와 SQL Server 데이터베이스 프로젝트가 포함되어 있습니다.
  • 필요에 따라 추가 데이터베이스 개체를 생성하고, 애플리케이션의 데이터 관리 및 접근 방식을 개선하기 위해 이 프로젝트를 활용할 수 있습니다.

이 단계를 통해 완성형 게시판을 만드는 데 필요한 데이터베이스 구조를 관리하고, Blazor Server 애플리케이션과 통합할 수 있는 기반을 마련했습니다.

Memos 이름의 완성형 게시판용 테이블 생성

완성형 게시판용 Memos 테이블을 SQL Server 프로젝트에 추가하고 생성하는 과정은 다음과 같습니다. 이 과정은 Visual Studio에서 Hawaso.SqlServer 프로젝트를 사용하여 진행됩니다.

1. SQL 파일 추가

  1. Solution Explorer에서 Hawaso.SqlServer 프로젝트를 찾습니다.
  2. 프로젝트 내에서 "Tables" 폴더를 마우스 오른쪽 버튼으로 클릭하고 Add > New Folder를 선택하여 Memos라는 이름의 새 폴더를 생성합니다. (SQL Server Database 프로젝트에서는 폴더 구조를 사용자 정의하여 조직화할 수 있습니다.)
  3. Memos 폴더를 마우스 오른쪽 버튼으로 클릭하고 **Add > New Item...**을 선택합니다.
  4. "SQL Server" 섹션에서 "SQL Script" 파일 유형을 선택하고, 파일 이름을 **"00_Memos.sql"**로 지정한 후 Add 버튼을 클릭합니다.

2. SQL 스크립트 작성

  1. 생성된 00_Memos.sql 파일을 열고, 주어진 SQL 스크립트를 복사하여 붙여넣습니다.
  2. SQL 스크립트는 CREATE TABLE 명령문을 포함하며, Memos 테이블의 구조를 정의합니다. 이 스크립트는 테이블, 컬럼, 데이터 타입, 기본값, 제약조건 등을 명시합니다.

Memos 이름의 테이블의 내용음 다음 코드와 같습니다.

코드: Hawaso\Hawaso.SqlServer\Memos\00_Memos.sql

--[0] Table: Memos(완성형 게시판) 테이블 설계(멀티 게시판은 Acts로 이동됨) 
--[!] 게시판 테이블 설계: Articles, Posts, Entries, Notes, Memos, (Basic+Upload+Reply) => DotNetNote/DotNetMemo
CREATE TABLE [dbo].[Memos]
(
	[Id]            BIGINT NOT NULL PRIMARY KEY Identity(1, 1), -- [1][일련번호], Serial Number
	[ParentId]      BigInt Null,								    -- ParentId, AppId, SiteId, ...
	[ParentKey]     NVarChar(255) Null,						    -- ParentKey == 부모의 GUID

    -- Auditable
	[CreatedBy]     NVarChar(255) Null,						    -- 등록자(Creator)
	[Created]       DATETIMEOFFSET Default(GetDate()) Null,  	-- [5][생성일](PostDate), DatePublished, CreatedAt
	[ModifiedBy]    NVarChar(255) Null,					        -- 수정자(LastModifiedBy)
	[Modified]      DATETIMEOFFSET Null,						-- 수정일(LastModified)

    --[0] 5W1H: 누가, 언제, 어디서, 무엇을, 어떻게, 왜
    [Name]          NVarChar(255) Not Null,                      -- [2][이름](작성자)
    PostDate        DateTime Default GetDate() Not Null,        -- 작성일 
    PostIp          NVarChar(15) Null,                          -- 작성IP
    [Title]         NVarChar(150) Not Null,                     -- [3][제목]
    [Content]       NText Not Null,                             -- [4][내용]__NVarChar(Max) => NText__
    Category        NVarChar(20) Default('Free') Null,          -- 카테고리(확장...) => '공지', '자유', '자료', '사진', ...

	--[1] 기본형 게시판 관련 주요 컬럼
    Email           NVarChar(100) Null,                         -- 이메일 
    Password        NVarChar(255) Null,                         -- 비밀번호
    ReadCount       Int Default 0,                              -- 조회수
    Encoding        NVarChar(20) Not Null,                      -- 인코딩(HTML/Text/Mixed)
    Homepage        NVarChar(100) Null,                         -- 홈페이지
    ModifyDate      DateTime Null,                              -- 수정일 
    ModifyIp        NVarChar(15) Null,                          -- 수정IP
    CommentCount    Int Default 0,                              -- 댓글수
	IsPinned        Bit Default 0 Null,                         -- 공지글로 올리기, IsActive 

	--[2] 자료실 게시판 관련 주요 컬럼
    FileName        NVarChar(255) Null,                         -- 파일명
    FileSize        Int Default 0,                              -- 파일크기
    DownCount       Int Default 0,                              -- 다운수 

	--[3] 답변형 게시판 관련 주요 컬럼
    Ref             Int Not Null,                               -- 참조(부모글)
    Step            Int Not Null Default 0,                     -- 답변깊이(레벨)
    RefOrder        Int Not Null Default 0,                     -- 답변순서
    AnswerNum       Int Not Null Default 0,                     -- 답변수
    ParentNum       Int Not Null Default 0,                     -- 부모글번호

    --[!] 추가 항목 들어오는 곳...
    Num             Int Null,                                   -- 번호(확장...)
    UserId          Int Null,                                   -- (확장...) 사용자 테이블 Id
    CategoryId      Int Null Default 0,                         -- (확장...) 카테고리 테이블 Id
    BoardId         Int Null Default 0,                         -- (확장...) 게시판(Boards) 테이블 Id
    ApplicationId    Int Null Default 0                         -- (확장용) 응용 프로그램 Id
)
Go

3. SQL 스크립트 검토 및 수정

  • 스크립트를 붙여넣은 후, 프로젝트의 요구 사항에 맞게 필요한 수정을 진행합니다. 예를 들어, 기존 데이터베이스 스키마와의 호환성, 추가적인 인덱스 설정, 특정 필드에 대한 제약조건 추가 등을 검토할 수 있습니다.
  • SQL 스크립트 작성 시, Visual Studio의 IntelliSense 기능이 SQL 구문 작성을 돕습니다.

4. 테이블 빌드 및 배포

  1. Solution Explorer에서 Hawaso.SqlServer 프로젝트를 마우스 오른쪽 버튼으로 클릭하고 Build를 선택하여 프로젝트를 빌드합니다. 이 과정은 SQL 스크립트의 문법 오류를 검사합니다.
  2. 빌드가 성공적으로 완료되면, 데이터베이스 프로젝트를 배포할 준비가 됩니다. Publish를 선택하여 Memos 테이블을 포함하는 데이터베이스 스키마를 SQL Server 인스턴스에 배포합니다.
  3. Publish Database 대화상자에서 대상 데이터베이스의 설정을 구성하고 "Publish" 버튼을 클릭하여 스키마를 데이터베이스에 배포합니다.

5. 테이블 확인 및 데이터베이스 업데이트

  • 배포가 완료된 후, SQL Server Management Studio(SSMS) 또는 다른 데이터베이스 관리 툴을 사용하여 Memos 테이블이 성공적으로 생성되었는지 확인합니다.
  • 필요한 경우, 추가 데이터 삽입, 수정, 삭제 등의 작업을 수행하여 테이블을 업데이트할 수 있습니다.

이 절차를 통해 Hawaso.SqlServer 프로젝트 내에 완성형 게시판용 Memos 테이블을 성공적으로 추가하고 생성할 수 있습니다.

클래스 라이브러리 프로젝트 및 NuGet 패키지

모델 클래스

Hawaso.Models\Memos\01_Memo.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Hawaso.Models
{
    /// <summary>
    /// [!] 기본 클래스: 공통 속성들을 모두 모아 놓은 만능 모델 클래스
    /// MemoBase, ArticleBase, PostBase, EntryBase, ArchiveBase, ActBase, ...
    /// Scaffold-DbContext: https://learn.microsoft.com/ko-kr/ef/core/cli/powershell#scaffold-dbcontext
    /// </summary>
    public class MemoBase
    {
        #region Key Section
        /// <summary>
        /// [1] 일련 번호(Serial Number)
        /// </summary>
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        [Display(Name = "번호")]
        public long Id { get; set; } // Id 속성(컬럼)을 int로 사용해야하나? long으로 사용해야하나? 이것이 문제로다. 

        /// <summary>
        /// 숫자 형식의 외래키? - AppId 형태로 ParentId와 ParentKey 속성은 보조로 만들어 놓은 속성
        /// </summary>
        public long? ParentId { get; set; } = default;  // long? 형식으로 변경 가능    

        /// <summary>
        /// 숫자 형식의 외래키? - AppId 형태로 ParentId와 ParentKey 속성은 보조로 만들어 놓은 속성
        /// </summary>
        public string ParentKey { get; set; } = string.Empty; 
        #endregion

        #region Auditable
        /// <summary>
        /// 등록자: CreatedBy, Creator, Email, ...
        /// </summary>
        public string CreatedBy { get; set; }

        /// <summary>
        /// [5] 등록일(생성일): Created
        /// DateTime? 또는 DateTimeOffset? 
        /// </summary>        
        public DateTimeOffset? Created { get; set; }

        /// <summary>
        /// 수정자: LastModifiedBy, ModifiedBy
        /// </summary>
        public string ModifiedBy { get; set; }

        /// <summary>
        /// 수정일: LastModified, Modified
        /// </summary>
        public DateTimeOffset? Modified { get; set; }
        #endregion

        #region [0] 5W1H: 누가, 언제, 어디서, 무엇을, 어떻게, 왜
        /// <summary>
        /// [2] 이름(작성자)
        /// </summary>
        [Required(ErrorMessage = "이름을 입력하세요.")]
        [MaxLength(255)]
        [Display(Name = "작성자")]
        [Column(TypeName = "NVarChar(255)")]
        public string Name { get; set; } = string.Empty; 

        /// <summary>
        /// 작성일
        /// </summary>
        [Display(Name = "작성일")]
        public DateTime? PostDate { get; set; }

        /// <summary>
        /// 작성지 IP 주소
        /// </summary>
        [Display(Name = "작성IP")]
        [Column(TypeName = "NVarChar(255)")]
        public string PostIp { get; set; }

        /// <summary>
        /// [3] 제목
        /// </summary>
        [MaxLength(255)]
        [Required(ErrorMessage = "제목을 입력하세요.")]
        [Display(Name = "제목")]
        [Column(TypeName = "NVarChar(255)")]
        public string Title { get; set; } = string.Empty; 

        /// <summary>
        /// [4] 내용
        /// </summary>
        [Display(Name = "내용")]
        public string Content { get; set; }

        /// <summary>
        /// 카테고리: Notice, Free, Data, Photo, ...
        /// </summary>
        [Display(Name = "카테고리")]
        public string Category { get; set; }
        #endregion

        #region [1] 기본형 게시판 관련 주요 컬럼
        /// <summary>
        /// 작성자 이메일
        /// </summary>
        //[EmailAddress(ErrorMessage = "* 이메일을 정확히 입력하세요.")]
        public string Email { get; set; }

        /// <summary>
        /// 비밀번호
        /// </summary>
        [Display(Name = "비밀번호")]
        [Required(ErrorMessage = "* 비밀번호를 작성해 주세요.")]
        public string Password { get; set; }

        /// <summary>
        /// 조회수
        /// </summary>
        [Display(Name = "조회수")]
        public int? ReadCount { get; set; }

        /// <summary>
        /// 인코딩: Text(Plain-Text), HTML(Text/HTML), Mixed(Mixed-Text)
        /// </summary>
        [Display(Name = "인코딩")]
        public string Encoding { get; set; } = "Plain-Text"; // "Text"

        /// <summary>
        /// 홈페이지 
        /// </summary>
        [Display(Name = "홈페이지")]
        public string Homepage { get; set; } // URL

        /// <summary>
        /// 수정일
        /// </summary>
        [Display(Name = "수정일")]
        public DateTime? ModifyDate { get; set; }

        /// <summary>
        /// 수정 IP 주소
        /// </summary>
        [Display(Name = "수정IP")]
        public string ModifyIp { get; set; }

        /// <summary>
        /// 댓글수 
        /// </summary>
        [Display(Name = "댓글수")]
        public int? CommentCount { get; set; }

        /// <summary>
        /// 상단 고정: 공지글로 올리기, IsActive
        /// </summary>
        public bool IsPinned { get; set; } = false;
        #endregion

        #region [2] 자료실 게시판 관련 주요 컬럼
        /// <summary>
        /// 파일이름
        /// </summary>
        [Display(Name = "파일이름")]
        public string FileName { get; set; }

        /// <summary>
        /// 파일크기
        /// </summary>
        [Display(Name = "파일크기")]
        public int? FileSize { get; set; }

        /// <summary>
        /// 다운수 
        /// </summary>
        [Display(Name = "다운수")]
        public int? DownCount { get; set; }
        #endregion

        #region 답변형 게시판 관련 주요 속성
        /// <summary>
        /// 참조(부모글, 참조 번호)
        /// 그룹ID: 같은 그룹이면 모두 동일한 값 
        /// https://youtu.be/1B4AjvD7s1g
        /// </summary>
        public int Ref { get; set; } = 0;

        /// <summary>
        /// 답변깊이(레벨, 들여쓰기)
        /// </summary>
        public int Step { get; set; } = 0;

        /// <summary>
        /// 답변(참조) 순서
        /// </summary>
        public int RefOrder { get; set; } = 0;

        /// <summary>
        /// 답변수
        /// </summary>
        [Display(Name = "답변수")]
        public int AnswerNum { get; set; } = 0;

        /// <summary>
        /// 부모글 번호
        /// </summary>
        [Display(Name = "부모번호")]
        public int ParentNum { get; set; } = 0; 
        #endregion
    }

    /// <summary>
    /// [1] Model Class: Memo 모델(도메인, 엔터티) 클래스 == Memos 테이블과 일대일로 매핑
    /// Memo, MemoModel, MemoViewModel, MemoDto, MemoEntity, MemoObject, MemoTable 
    /// MemoDomain, ...
    /// </summary>
    [Table("Memos")]
    public class Memo : MemoBase
    {
        // PM> Install-Package System.ComponentModel.Annotations
        // Empty
    }
}

모델 클래스 상세 설명

모델 클래스 MemoMemoBaseMemos 테이블에 대응되는 엔터티 클래스입니다. 이 클래스들은 데이터베이스 테이블의 스키마를 .NET 코드 상에서 표현합니다. MemoBase 클래스는 Memos 테이블의 모든 컬럼을 프로퍼티로 가지며, 이는 데이터 조작 시 필요한 모든 데이터 정보를 담고 있습니다. Memo 클래스는 MemoBase 클래스를 상속받아, 테이블과 1:1 매핑되며, 추가적인 비즈니스 로직이 필요한 경우 이 클래스를 확장하여 사용합니다.

각 속성에 대한 상세 설명

  • Id: 데이터베이스에서 자동으로 생성되는 고유 식별자입니다. 각 메모(게시물)는 고유한 ID 값을 가집니다.
  • ParentId & ParentKey: 계층형 데이터 구조에서 상위 항목을 참조하는 데 사용됩니다. 특정 게시판 내부의 계층적 댓글이나 게시물 구조를 구현할 때 활용됩니다.
  • CreatedBy, Created, ModifiedBy, Modified: 데이터의 생성자, 생성 날짜, 마지막 수정자, 마지막 수정 날짜를 관리합니다. 이는 데이터의 감사 추적(auditing)에 필요한 정보입니다.
  • Name, Title, Content: 게시물을 작성한 사용자의 이름, 게시물의 제목, 그리고 내용을 저장합니다. 이들은 게시판에서 가장 핵심적인 정보입니다.
  • Category, Email, Password, ReadCount: 게시물의 카테고리, 작성자의 이메일, 게시물에 설정된 비밀번호, 게시물의 조회수 등을 저장합니다. 이들은 게시판 기능의 확장성과 사용자 인터랙션을 위해 필요한 속성들입니다.
  • Encoding, Homepage, ModifyDate, ModifyIp: 게시물의 내용 인코딩 방식, 작성자의 홈페이지 주소, 게시물의 마지막 수정 날짜와 IP 주소 등을 저장합니다. 이 정보들은 게시판의 사용성과 보안을 높이기 위한 데이터입니다.
  • FileName, FileSize, DownCount: 첨부된 파일의 이름, 크기, 다운로드 횟수를 관리합니다. 자료실 게시판과 같은 파일 공유 기능을 구현할 때 사용됩니다.
  • Ref, Step, RefOrder, AnswerNum, ParentNum: 답변형 게시판에서 사용되는 속성들로, 게시물 간의 관계와 계층 구조를 관리합니다. 이를 통해 복잡한 대화 형태의 토론이나 Q&A 섹션을 구현할 수 있습니다.

리포지토리 인터페이스

ArticleSet<T, V> 구조체는 페이징 처리를 위해 설계되었습니다. 이 구조체는 특정 페이지에 해당하는 아티클 리스트(Items)와 전체 컬렉션의 아티클 개수(TotalCount)를 포함합니다. 이를 통해 애플리케이션은 페이징된 데이터를 사용자에게 효과적으로 제공할 수 있습니다. T는 모델 클래스 형식을, V는 아티클 수를 나타내는 데이터 형식(예: int, long)을 지정합니다.

Dul\07_Articles\ArticleSet.cs

using System.Collections.Generic;

namespace Dul.Articles
{
    /// <summary>
    /// 페이징된 아티클과 아티클 개수
    /// </summary>
    /// <typeparam name="T">모델 클래스</typeparam>
    /// <typeparam name="V">개수 형식(int, long)</typeparam>
    public struct ArticleSet<T, V>
    {
        /// <summary>
        /// 아티클 리스트: 현재 페이지에 해당하는 아티클 리스트 
        /// </summary>
        public IEnumerable<T> Items { get; set; }

        /// <summary>
        /// 아티클 수: 현재 앱의 지정된 컬렉션의 레코드 수
        /// </summary>
        public V TotalCount { get; set; }

        /// <summary>
        /// 구조체 인스턴스 초기화
        /// </summary>
        /// <param name="items">페이지 아티클 리스트</param>
        /// <param name="totalCount">총 아티클 수</param>
        public ArticleSet(IEnumerable<T> items, V totalCount)
        {
            Items = items;
            TotalCount = totalCount;
        }
    }
}

ICrudRepositoryBase<T, TIdentifier> 인터페이스는 CRUD(Create, Read, Update, Delete) 작업을 추상화합니다. 제네릭 타입 T는 모델 클래스 형식을, TIdentifier는 식별자(예: 엔터티의 ID) 형식을 지정합니다. 이 인터페이스는 데이터를 추가, 조회, 수정, 삭제하는 기본적인 메서드를 정의하며, 필터링을 위한 GetArticlesAsyncGetAllAsync 메서드도 포함합니다. 이러한 메서드들은 페이징 및 검색 기능을 지원하기 위해 설계되었습니다.

ICrudRepositoryBase<T, V, TIdentifier> 인터페이스는 유사한 기능을 제공하지만, V 타입 파라미터를 추가하여 다양한 식별자 형식을 지원합니다. 이를 통해 다양한 데이터 형식의 ID를 가진 엔터티에 대한 CRUD 작업을 효율적으로 처리할 수 있습니다.

Dul\07_Articles\ICrudRepositoryBase.cs

using System.Collections.Generic;
using System.Threading.Tasks;

namespace Dul.Articles
{
    /// <summary>
    /// CRUD 제네릭 인터페이스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    /// <typeparam name="TIdentifier">부모 식별자 형식</typeparam>
    public interface ICrudRepositoryBase<T, TIdentifier> where T : class
    {
        /// <summary>
        /// 입력
        /// </summary>
        Task<T> AddAsync(T model);

        /// <summary>
        /// 출력
        /// </summary>
        Task<List<T>> GetAllAsync();

        /// <summary>
        /// 상세
        /// </summary>
        Task<T> GetByIdAsync(TIdentifier id);

        /// <summary>
        /// 수정
        /// </summary>
        Task<bool> UpdateAsync(T model);

        /// <summary>
        /// 삭제
        /// </summary>
        Task<bool> DeleteAsync(TIdentifier id);

        /// <summary>
        /// 필터링
        /// </summary>
        Task<ArticleSet<T, int>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetArticlesAsync 메서드와 동일 구조 
        /// </summary>
        Task<ArticleSet<T, int>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);
    }

    /// <summary>
    /// CRUD 제네릭 인터페이스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    /// <typeparam name="V">Id 형식: int or long</typeparam>
    /// <typeparam name="TIdentifier">식별자 형식</typeparam>
    public interface ICrudRepositoryBase<T, V, TIdentifier> where T : class
    {
        /// <summary>
        /// 입력
        /// </summary>
        Task<T> AddAsync(T model);

        /// <summary>
        /// 출력
        /// </summary>
        Task<List<T>> GetAllAsync();

        /// <summary>
        /// 상세
        /// </summary>
        Task<T> GetByIdAsync(TIdentifier id);

        /// <summary>
        /// 수정
        /// </summary>
        Task<bool> UpdateAsync(T model);

        /// <summary>
        /// 삭제
        /// </summary>
        Task<bool> DeleteAsync(TIdentifier id);

        /// <summary>
        /// 필터링
        /// </summary>
        Task<ArticleSet<T, V>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetArticlesAsync 메서드와 동일 구조 
        /// </summary>
        Task<ArticleSet<T, V>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);
    }
}
  • AddAsync: 모델 인스턴스를 추가합니다.
  • GetAllAsync: 모든 모델 인스턴스를 리스트로 반환합니다.
  • GetByIdAsync: 주어진 식별자에 해당하는 모델 인스턴스를 반환합니다.
  • UpdateAsync: 모델 인스턴스를 수정합니다.
  • DeleteAsync: 주어진 식별자에 해당하는 모델 인스턴스를 삭제합니다.
  • GetArticlesAsyncGetAllAsync: 주어진 조건에 맞는 모델 인스턴스를 페이징 처리하여 반환합니다. 이 메서드들은 검색 필드, 검색 쿼리, 정렬 순서 및 부모 식별자를 파라미터로 받아 필터링된 결과를 제공합니다.

코드: Hawaso.Models\Memos\02_IMemoRepository.cs

using Dul.Articles;
using Dul.Domain.Common;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Hawaso.Models
{
    /// <summary>
    /// [!] Generic Repository Interface => ICrudRepositoryBase.cs 
    /// </summary>
    //public interface IMemoCrudRepository<T> : IRepositoryBase<Memo, long, long>
    public interface IMemoCrudRepository<T> : ICrudRepositoryBase<Memo, long>
    {
        // PM> Install-Package Dul

        Task<bool> EditAsync(T model); // 수정

        Task<T> AddAsync(
            T model,
            int parentRef,
            int parentStep,
            int parentRefOrder); // 답변(기본: ReplyApp)

        Task<T> AddAsync(
            T model,
            int parentId); // 답변(고급: MemoApp)

        // 페이징
        Task<PagingResult<T>> GetAllAsync(
            int pageIndex,
            int pageSize);

        // 부모 Id
        Task<PagingResult<T>> GetAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            int parentId);

        // 부모 Key
        Task<PagingResult<T>> GetAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string parentKey);

        // 검색
        Task<PagingResult<T>> SearchAllAsync(
            int pageIndex,
            int pageSize,
            string searchQuery);

        // 검색 + 부모 Id
        Task<PagingResult<T>> SearchAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            int parentId);

        // 검색 + 부모 Key
        Task<PagingResult<T>> SearchAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            string parentKey);
    }

    /// <summary>
    /// [2] Repository Interface, Provider Interface
    /// </summary>
    public interface IMemoRepository : IMemoCrudRepository<Memo>
    {
        // PM> Install-Package Dul
        Task<ArticleSet<Memo, long>> GetByAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options);

        Task<Tuple<int, int>> GetStatus(int parentId);

        Task<bool> DeleteAllByParentId(int parentId);

        Task<SortedList<int, double>> GetMonthlyCreateCountAsync();

        // 강의 이외에 추가적인 API가 필요하다면 이곳에 기록(예를 들어, 시작일부터 종료일까지의 데이터 검색)
        Task<ArticleSet<Memo, long>> GetArticlesWithDateAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, DateTime from, DateTime to);
    }
}

DbContext 클래스

MemoAppDbContext 클래스는 Entity Framework Core를 사용하여 데이터베이스와의 상호작용을 관리하는 클래스입니다. DbContext는 모델(엔터티) 클래스와 데이터베이스 테이블 간의 매핑을 설정하고, 데이터베이스 작업을 위한 API를 제공합니다.

  • OnConfiguring: 이 메소드는 DbContext가 필요로 하는 데이터베이스 연결을 구성합니다. .NET Core.NET 5 이상에서는 주로 Startup.cs에서 DbContext 옵션을 구성하지만, .NET Framework 또는 기존 애플리케이션에서는 이 메소드를 사용하여 연결 문자열을 설정할 수 있습니다.
  • OnModelCreating: 모델 생성 시 데이터베이스 스키마에 대한 추가 구성을 제공합니다. 예를 들어, Memo 모델의 CreatedPostDate 프로퍼티에 기본값으로 현재 날짜/시간을 설정합니다.
  • DbSet<Memo>: Memos 테이블에 대응하는 Memo 엔터티에 대한 접근을 제공합니다. 이를 통해 메모 데이터를 쿼리하고, 새 메모를 추가하며, 메모를 업데이트하거나 삭제할 수 있습니다.

코드: Hawaso.Models\Memos\03_MemoAppDbContext.cs

using Microsoft.EntityFrameworkCore;
using System.Configuration;

namespace Hawaso.Models
{
    /// <summary>
    /// [3] DbContext Class
    /// </summary>
    public class MemoAppDbContext : DbContext
    {
        #region NuGet Packages
        // PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer -Version 3.1.24
        // PM> Install-Package Microsoft.Data.SqlClient
        // PM> Install-Package System.Configuration.ConfigurationManager
        // --OR--
        //// PM> Install-Package Microsoft.EntityFrameworkCore
        //// PM> Install-Package Microsoft.EntityFrameworkCore.Tools
        //// PM> Install-Package Microsoft.EntityFrameworkCore.InMemory 
        //// --OR--
        //// PM> Install-Package Microsoft.AspNetCore.All // 2.1 버전까지만 사용 가능 
        #endregion

        public MemoAppDbContext() : base() 
        {
            // Empty
            // ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        public MemoAppDbContext(DbContextOptions<MemoAppDbContext> options)
            : base(options)
        {
            // 공식과 같은 코드, 교과서다운 코드
            // ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        /// <summary>
        /// 참고 코드: 닷넷 프레임워크 또는 Windows Forms/WPF 기반에서 호출되는 코드 영역
        /// __App.config 또는 Web.config의 연결 문자열 사용
        /// __직접 데이터베이스 연결문자열 설정 가능
        /// __.NET Core 또는 .NET 5 이상에서는 사용하지 않음
        /// </summary>
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                string connectionString = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString;
                optionsBuilder.UseSqlServer(connectionString);
            }
        }

        /// <summary>
        /// 모델(테이블)이 생성될 때 처음 실행 
        /// </summary>
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Memos 테이블의 Created, PostDate 열은 자동으로 GetDate() 제약 조건을 부여하기 
            modelBuilder.Entity<Memo>().Property(m => m.Created).HasDefaultValueSql("GetDate()");
            modelBuilder.Entity<Memo>().Property(m => m.PostDate).HasDefaultValueSql("GetDate()");
        }

        //[!] MemoApp 솔루션 관련 모든 테이블에 대한 참조 
        public DbSet<Memo> Memos { get; set; } // = null!;
    }
}

리포지토리 클래스

리포지토리 클래스 소개

MemoRepository 클래스는 IMemoRepository 인터페이스를 구현하여 Memo 엔터티에 대한 CRUD 연산을 수행합니다. 이 클래스는 Entity Framework Core를 사용하여 데이터베이스 작업을 처리하며, MemoAppDbContext를 통해 데이터베이스 컨텍스트와 상호작용합니다. 추가로, 로깅 기능을 위해 ILogger를 사용합니다.

생성자

  • MemoRepository(MemoAppDbContext context, ILoggerFactory loggerFactory): 데이터베이스 컨텍스트와 로거 팩토리를 받아 인스턴스를 초기화합니다. 로거는 MemoRepository 클래스의 작업을 로깅하는 데 사용됩니다.

CRUD 메서드

  • AddAsync(Memo model): 비동기적으로 새 Memo 인스턴스를 데이터베이스에 추가합니다. 답변 기능을 지원하기 위해 참조 글 번호, 들여쓰기, 참조 순서 등을 설정합니다.
  • GetAllAsync(): 비동기적으로 모든 Memo 인스턴스를 리스트로 반환합니다.
  • GetByIdAsync(long id): 주어진 ID를 가진 Memo 인스턴스를 비동기적으로 검색합니다. 조회수를 자동으로 증가시킵니다.
  • EditAsync(Memo model), UpdateAsync(Memo model): 비동기적으로 기존 Memo 인스턴스를 수정합니다. EditAsync는 EF Core의 상태 관리를 사용하여 수정을 반영하고, UpdateAsync는 수동으로 프로퍼티 값을 설정하여 업데이트합니다.
  • DeleteAsync(long id): 비동기적으로 주어진 ID를 가진 Memo 인스턴스를 삭제합니다.

페이징 및 검색

  • GetAllAsync(int pageIndex, int pageSize): 페이징 처리된 Memo 인스턴스 리스트를 비동기적으로 반환합니다.
  • GetAllByParentIdAsync(int pageIndex, int pageSize, int parentId), GetAllByParentKeyAsync(int pageIndex, int pageSize, string parentKey): 부모 ID 또는 부모 Key에 따라 필터링된 Memo 인스턴스 리스트를 페이징 처리하여 비동기적으로 반환합니다.
  • SearchAllAsync(int pageIndex, int pageSize, string searchQuery), SearchAllByParentIdAsync(int pageIndex, int pageSize, string searchQuery, int parentId), SearchAllByParentKeyAsync(int pageIndex, int pageSize, string searchQuery, string parentKey): 검색 쿼리를 사용하여 필터링된 Memo 인스턴스 리스트를 페이징 처리하여 비동기적으로 반환합니다.

추가 기능

  • GetStatus(int parentId): 특정 부모 ID를 가진 Memo 인스턴스의 상태(고정된 글의 수, 전체 글의 수)를 비동기적으로 반환합니다.
  • DeleteAllByParentId(int parentId): 주어진 부모 ID를 가진 모든 Memo 인스턴스를 비동기적으로 삭제합니다.
  • GetMonthlyCreateCountAsync(): 월별로 생성된 Memo 인스턴스의 수를 계산하여 반환합니다.

리소스 관리

  • Dispose(), Dispose(bool disposing): 클래스 인스턴스가 가지고 있는 리소스를 해제합니다. IDisposable 인터페이스를 구현하여 리소스 관리를 개선합니다.

이 클래스는 Memo 관련 데이터의 관리 및 조작을 위한 핵심 기능을 제공하며, 데이터 액세스 레이어의 중요한 부분을 담당합니다.

코드: Hawaso.Models\Memos\04_MemoRepository.cs

using Dul.Articles;
using Dul.Domain.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Hawaso.Models
{
    /// <summary>
    /// [4] Repository Class: ADO.NET or Dapper(Micro ORM) or Entity Framework Core(Full ORM)
    /// ~Repository, ~Provider, ~Data
    /// </summary>
    public class MemoRepository : IMemoRepository, IDisposable
    {
        private readonly MemoAppDbContext _context;
        private readonly ILogger _logger;

        public MemoRepository(MemoAppDbContext context, ILoggerFactory loggerFactory)
        {
            this._context = context;
            this._logger = loggerFactory.CreateLogger(nameof(MemoRepository));
        }

        #region [4][1] 입력: AddAsync
        //[4][1] 입력: AddAsync
        public async Task<Memo> AddAsync(Memo model)
        {
            #region 답변 기능 추가
            // 현재테이블의 Ref의 Max값 가져오기
            int maxRef = 1;
            int? max = await _context.Memos.DefaultIfEmpty().MaxAsync(m => m == null ? 0 : m.Ref);
            if (max.HasValue)
            {
                maxRef = (int)max + 1;
            }

            model.Ref = maxRef; // 참조 글(부모 글, 그룹 번호)
            model.Step = 0; // 들여쓰기(처음 글을 0으로 초기화)
            model.RefOrder = 0; // 참조(그룹) 순서
            #endregion

            model.Created = DateTimeOffset.Now; // 현재 서버의 시간으로 저장

            try
            {
                _context.Memos.Add(model);
                await _context.SaveChangesAsync();
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(AddAsync)}): {e.Message}");
            }

            return model;
        }
        #endregion

        #region [4][2] 출력: GetAllAsync
        //[4][2] 출력: GetAllAsync
        public async Task<List<Memo>> GetAllAsync()
        {
            // 학습 목적으로... 인-메모리 데이터베이스에선 사용 금지 
            //return await _context.Memos.FromSqlRaw<Memo>("Select * From dbo.Memos Order By Id Desc") 
            return await _context.Memos.OrderByDescending(m => m.Id)
                //.Include(m => m.MemosComments)
                .ToListAsync();
        }
        #endregion

        #region [4][3] 상세: GetByIdAsync
        //[4][3] 상세: GetByIdAsync
        public async Task<Memo> GetByIdAsync(long id)
        {
            var model = await _context.Memos
                //.Include(m => m.MemosComments)
                .SingleOrDefaultAsync(m => m.Id == id);

            // ReadCount++
            if (model != null)
            {
                if (model.ReadCount != null)
                {
                    model.ReadCount++;
                }
                else
                {
                    model.ReadCount = 1; 
                }
                _context.Memos.Attach(model);
                _context.Entry(model).State = EntityState.Modified;
                _context.SaveChanges();
            }

            return model;
        }
        #endregion

        #region [4][4] 수정: UpdateAsync
        //[4][4] 수정: UpdateAsync, EdityAsync, SetAsync
        public async Task<bool> EditAsync(Memo model)
        {
            try
            {
                model.ModifyDate = DateTime.Now;

                _context.Memos.Attach(model);
                _context.Entry(model).State = EntityState.Modified;
                return (await _context.SaveChangesAsync() > 0);
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(EditAsync)}): {e.Message}");
            }

            return false;
        }
        public async Task<bool> UpdateAsync(Memo model)
        {
            try
            {
                // 강의에서는 AutoMapper NuGet 사용 X

                var old = _context.Memos.Find(model.Id);

                old.Name = model.Name;
                old.Email = model.Email;
                old.Homepage = model.Homepage;
                old.Title = model.Title;
                old.Content = model.Content;
                old.IsPinned = model.IsPinned;
                old.Encoding = model.Encoding;

                // TODO: 더 넣을 항목 처리: 이 부분은 어떻게 처리하는게 가장 좋은지 고민
                // - Repository에서는 전체 업데이트
                // - Service에서는 부분 업데이트

                // 이 부분은 예외 처리 추가(MVC 게시판과 병합을 위한...)
                if (model.FileName != null)
                {
                    old.FileName = model.FileName;
                    old.FileSize = model.FileSize;
                    old.DownCount = model.DownCount;
                }

                old.Modified = DateTimeOffset.Now; // 현재 서버의 시간으로 저장

                _context.Update(old);

                return (await _context.SaveChangesAsync() > 0);
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(UpdateAsync)}): {e.Message}");
            }

            return false;
        }
        #endregion

        #region [4][5] 삭제: DeleteAsync
        //[4][5] 삭제
        public async Task<bool> DeleteAsync(long id)
        {
            //var model = await _context.Memos.SingleOrDefaultAsync(m => m.Id == id);
            try
            {
                var model = await _context.Memos.FindAsync(id);
                //_context.Memos.Remove(model);
                _context.Remove(model);
                return await _context.SaveChangesAsync() > 0;
            }
            catch (Exception ಠ_ಠ) // ಠ_ಠ => Disapproval Look
            {
                string message = $"ERROR({nameof(DeleteAsync)}): {ಠ_ಠ.Message}";
                _logger?.LogError(message);
            }

            return false;
        }
        #endregion

        #region [4][6] 페이징: GetAllAsync()
        //[4][6] 페이징: GetAllAsync()
        public async Task<PagingResult<Memo>> GetAllAsync(int pageIndex, int pageSize)
        {
            var totalRecords = await _context.Memos.CountAsync();
            var models = await _context.Memos
                .OrderByDescending(m => m.Id)
                //.Include(m => m.MemosComments)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<Memo>(models, totalRecords);
        }
        #endregion

        #region [4][7] 부모: GetAllByParentIdAsync() 
        //[4][7] 부모
        public async Task<PagingResult<Memo>> GetAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            int parentId)
        {
            var totalRecords = await _context.Memos
                .Where(m => m.ParentId == parentId)
                .CountAsync();
            var models = await _context.Memos
                .Where(m => m.ParentId == parentId)
                .OrderByDescending(m => m.Id)
                //.Include(m => m.MemosComments)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<Memo>(models, totalRecords);
        }
        #endregion

        #region [4][8] 상태: GetStatus()
        //[4][8] 상태
        public async Task<Tuple<int, int>> GetStatus(int parentId)
        {
            var totalRecords = await _context.Memos
                .Where(m => m.ParentId == parentId)
                .CountAsync();
            var pinnedRecords = await _context.Memos
                .Where(m => m.ParentId == parentId && m.IsPinned == true)
                .CountAsync();

            return new Tuple<int, int>(pinnedRecords, totalRecords); // (2, 10)
        }
        #endregion

        #region [4][9] 부모 삭제: DeleteAllByParentId()
        //[4][9] 부모 삭제
        public async Task<bool> DeleteAllByParentId(int parentId)
        {
            try
            {
                var models = await _context.Memos
                    .Where(m => m.ParentId == parentId)
                    .ToListAsync();

                foreach (var model in models)
                {
                    _context.Memos.Remove(model);
                }

                return await _context.SaveChangesAsync() > 0;

            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(DeleteAllByParentId)}): {e.Message}");
            }

            return false;
        }
        #endregion

        #region [4][10] 검색: SearchAllAsync()
        //[4][10] 검색
        public async Task<PagingResult<Memo>> SearchAllAsync(
            int pageIndex,
            int pageSize,
            string searchQuery)
        {
            var totalRecords = await _context.Memos
                .Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery))
                .CountAsync();
            var models = await _context.Memos
                .Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery))
                .OrderByDescending(m => m.Id)
                //.Include(m => m.MemosComments)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<Memo>(models, totalRecords);
        }
        #endregion

        #region [4][11] 부모 검색: SearchAllByParentIdAsync()
        //[4][11] 부모 검색
        public async Task<PagingResult<Memo>> SearchAllByParentIdAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            int parentId)
        {
            var totalRecords = await _context.Memos
                .Where(m => m.ParentId == parentId)
                .Where(m => EF.Functions.Like(m.Name, $"%{searchQuery}%") || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery))
                .CountAsync();
            var models = await _context.Memos
                .Where(m => m.ParentId == parentId)
                .Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery))
                .OrderByDescending(m => m.Id)
                //.Include(m => m.MemosComments)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<Memo>(models, totalRecords);
        }
        #endregion

        #region [4][12] 통계: GetMonthlyCreateCountAsync()
        //[4][12] 통계
        public async Task<SortedList<int, double>> GetMonthlyCreateCountAsync()
        {
            SortedList<int, double> createCounts = new SortedList<int, double>();

            // 1월부터 12월까지 0.0으로 초기화
            for (int i = 1; i <= 12; i++)
            {
                createCounts[i] = 0.0;
            }

            for (int i = 0; i < 12; i++)
            {
                // 현재 달부터 12개월 전까지 반복
                var current = DateTime.Now.AddMonths(-i);
                var cnt = _context.Memos.AsEnumerable()
                    .Where(
                        m => m.Created != null
                        &&
                        Convert.ToDateTime(m.Created).Month == current.Month
                        &&
                        Convert.ToDateTime(m.Created).Year == current.Year
                    )
                    .ToList().Count();
                createCounts[current.Month] = cnt;
            }

            return await Task.FromResult(createCounts);
        }
        #endregion

        #region [4][13] 부모 페이징: GetAllByParentKeyAsync()
        //[4][13] 부모 페이징
        public async Task<PagingResult<Memo>> GetAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string parentKey)
        {
            var totalRecords = await _context.Memos
                .Where(m => m.ParentKey == parentKey)
                .CountAsync();
            var models = await _context.Memos
                .Where(m => m.ParentKey == parentKey)
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<Memo>(models, totalRecords);
        }
        #endregion

        #region [4][14] 부모 검색: SearchAllByParentKeyAsync()
        //[4][14] 부모 검색
        public async Task<PagingResult<Memo>> SearchAllByParentKeyAsync(
            int pageIndex,
            int pageSize,
            string searchQuery,
            string parentKey)
        {
            var totalRecords = await _context.Memos
                .Where(m => m.ParentKey == parentKey)
                .Where(m => EF.Functions.Like(m.Name, $"%{searchQuery}%") || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery))
                .CountAsync();
            var models = await _context.Memos
                .Where(m => m.ParentKey == parentKey)
                .Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery))
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new PagingResult<Memo>(models, totalRecords);
        }
        #endregion

        #region [4][15] 리스트(페이징, 검색, 정렬): GetAllAsync(), GetArticlesAsync()
        //[4][15] 리스트(페이징, 검색, 정렬)
        public async Task<ArticleSet<Memo, int>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier)
        {
            //var items = from m in _context.Memos select m; // 쿼리 구문(Query Syntax)
            //var items = _context.Memos.Select(m => m); // 메서드 구문(Method Syntax)
            var items =
                _context.Memos
                    //.Include(me => me.Comments)
                    .AsQueryable(); // IQueryable<T>: Expressoin Tree 생성(Deferred Execution)

            #region ParentBy: 특정 부모 키 값(int, string)에 해당하는 리스트인지 확인
            // ParentBy 
            if (parentIdentifier is int parentId && parentId != 0)
            {
                items = items.Where(m => m.ParentId == parentId);
            }
            else if (parentIdentifier is string parentKey && !string.IsNullOrEmpty(parentKey))
            {
                items = items.Where(m => m.ParentKey == parentKey);
            }
            #endregion

            #region Search Mode: SearchField와 SearchQuery에 해당하는 데이터 검색
            // Search Mode
            if (!string.IsNullOrEmpty(searchQuery))
            {
                if (searchField == "Name")
                {
                    // Name
                    items = items
                        .Where(m => m.Name.Contains(searchQuery));
                }
                else if (searchField == "Title")
                {
                    // Title
                    items = items
                        .Where(m => m.Title.Contains(searchQuery));
                }
                else
                {
                    // All: 기타 더 검색이 필요한 컬럼이 있다면 추가 가능
                    items = items
                        .Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery));
                }
            }
            #endregion

            // 총 레코드 수 계산
            var totalCount = await items.CountAsync();

            #region Sorting: 어떤 열에 대해 정렬(None, Asc, Desc)할 것인지 원하는 문자열로 지정
            // Sorting
            switch (sortOrder)
            {
                case "Name":
                    items = items
                        .OrderBy(m => m.Name).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "NameDesc":
                    items = items
                        .OrderByDescending(m => m.Name).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "Title":
                    items = items
                        .OrderBy(m => m.Title).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "TitleDesc":
                    items = items
                        .OrderByDescending(m => m.Title).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "Create":
                    items = items
                        .OrderBy(m => m.Created).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "CreateDesc":
                    items = items
                        .OrderByDescending(m => m.Created).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                default:
                    items = items
                        .OrderByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
            }
            #endregion

            // Paging
            items = items.Skip(pageIndex * pageSize).Take(pageSize);

            // AsNoTracking() 사용으로 성능 향상: https://learn.microsoft.com/ko-kr/ef/core/querying/tracking#no-tracking-queries
            return new ArticleSet<Memo, int>(await items.AsNoTracking().ToListAsync(), totalCount);
        }

        //[4][15] 리스트(페이징, 검색, 정렬)
        public async Task<ArticleSet<Memo, int>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier)
        {
            return await GetAllAsync(pageIndex, pageSize, searchField, searchQuery, sortOrder, parentIdentifier);
        }
        #endregion

        #region [4][16] 답변: ReplyApp.AddAsync()
        //[4][16] 답변: ReplyApp.AddAsync()
        public async Task<Memo> AddAsync(Memo model, int parentRef, int parentStep, int parentRefOrder)
        {
            #region 답변 관련 기능 추가된 영역
            // 비집고 들어갈 자리: 부모글 순서보다 큰 글이 있다면(기존 답변 글이 있다면) 해당 글의 순서를 모두 1씩 증가 
            var replys = await _context.Memos
                .Where(m => m.Ref == parentRef && m.RefOrder > parentRefOrder)
                .ToListAsync();
            foreach (var item in replys)
            {
                item.RefOrder++;
                try
                {
                    _context.Memos.Attach(item);
                    _context.Entry(item).State = EntityState.Modified;
                    await _context.SaveChangesAsync();
                }
                catch (Exception e)
                {
                    string message = $"ERROR({nameof(AddAsync)}): {e.Message}";
                    _logger?.LogError(message);
                }
            }

            model.Ref = parentRef; // 답변 글의 Ref(그룹)은 부모 글의 Ref를 그대로 저장 
            model.Step = parentStep + 1; // 어떤 글의 답변 글이기에 들여쓰기 1 증가 
            model.RefOrder = parentRefOrder + 1; // 부모글의 바로 다음번 순서로 보여지도록 설정 
            #endregion

            model.Created = DateTimeOffset.Now; // DateTime.UtcNow;

            // 이 부분은 예외 처리 추가(MVC 게시판과 병합을 위한...)
            if (model.FileName == null)
            {
                model.FileName = "";
                model.FileSize = 0;
                model.DownCount = 0;
            }

            try
            {
                _context.Memos.Add(model);
                await _context.SaveChangesAsync();
            }
            catch (Exception e)
            {
                string message = $"ERROR({nameof(AddAsync)}): {e.Message}";
                _logger?.LogError(message);
            }

            return model;
        } 
        #endregion

        #region [4][17] 답변: 메모앱.AddAsync()
        //[4][17] 답변: 메모앱.AddAsync()
        public async Task<Memo> AddAsync(Memo model, int parentId)
        {
            #region 답변 관련 기능 추가된 영역
            //[0] 변수 선언
            var maxRefOrder = 0;
            var maxAnswerNum = 0;
            var parentRef = 0;
            var parentStep = 0;
            var parentRefOrder = 0;

            //[1] 부모글(답변의 대상)의 답변수(AnswerNum)를 1증가
            var parent = await GetByIdAsync(parentId);
            if (parent != null)
            {
                parentRef = parent?.Ref ?? 0;
                parentStep = parent?.Step ?? 0;
                parentRefOrder = parent?.RefOrder ?? 0;

                parent.AnswerNum = parent.AnswerNum + 1;

                await EditAsync(parent);
            }

            //[2] 동일 레벨의 답변이라면, 답변 순서대로 RefOrder를 설정. 같은 글에 대해서 답변을 두 번 이상하면 먼저 답변한 게 위에 나타나게 한다.
            var tempMaxRefOrder = await _context.Memos.Where(m => m.ParentNum == parentId).DefaultIfEmpty().MaxAsync(m => m == null ? 0 : m.RefOrder);
            var sameGroup = await _context.Memos.Where(m => m.ParentNum == parentId && m.RefOrder == tempMaxRefOrder).FirstOrDefaultAsync();
            if (sameGroup != null)
            {
                maxRefOrder = sameGroup.RefOrder;
                maxAnswerNum = sameGroup.AnswerNum;
            }
            else
            {
                var tmpParent = await _context.Memos.Where(m => m.Id == parentId).SingleOrDefaultAsync();
                if (tmpParent != null)
                {
                    maxRefOrder = tmpParent.RefOrder;
                }
            }

            //[3] 중간에 답변달 때(비집고 들어갈 자리 마련): 부모글 순서보다 큰 글이 있다면(기존 답변 글이 있다면) 해당 글의 순서를 모두 1씩 증가 
            var replys = await _context.Memos.Where(m => m.Ref == parentRef && m.RefOrder > (maxRefOrder + maxAnswerNum)).ToListAsync();
            foreach (var item in replys)
            {
                //item.RefOrder = item.RefOrder + 1;
                item.RefOrder++;
                try
                {
                    //_context.Memos.Attach(item);
                    _context.Entry(item).State = EntityState.Modified;
                    await _context.SaveChangesAsync();
                }
                catch (Exception e)
                {
                    _logger?.LogError($"ERROR({nameof(AddAsync)}): {e.Message}");
                }
            }

            //[4] 최종 저장
            model.Ref = parentRef; // 답변 글의 Ref(그룹)은 부모 글의 Ref를 그대로 저장 
            model.Step = parentStep + 1; // 어떤 글의 답변 글이기에 들여쓰기 1 증가 
            model.RefOrder = (maxRefOrder + maxAnswerNum + 1); // 부모글의 바로 다음번 순서로 보여지도록 설정 

            model.ParentNum = parentId;
            model.AnswerNum = 0;
            #endregion

            model.Created = DateTime.UtcNow;

            try
            {
                _context.Memos.Add(model);
                await _context.SaveChangesAsync();
            }
            catch (Exception e)
            {
                _logger?.LogError($"ERROR({nameof(AddAsync)}): {e.Message}");
            }

            return model;
        } 
        #endregion

        #region [4][18] 검색: GetByAsync()
        //[4][18] 검색: GetByAsync()
        public async Task<ArticleSet<Memo, long>> GetByAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options)
        {
            //var items = from m in _context.Memos select m; // 쿼리 구문(Query Syntax)
            //var items = _context.Memos.Select(m => m); // 메서드 구문(Method Syntax)
            var items = _context.Memos.AsQueryable(); // IQueryable<T>: Expressoin Tree 생성(Deferred Execution)

            #region ParentBy: 특정 부모 키 값(int, string)에 해당하는 리스트인지 확인
            if (options.ChildMode)
            {
                // ParentBy 
                if (options.ParentIdentifier is int parentId && parentId != 0)
                {
                    //items = items.Where(m => m.ParentId == parentId);
                }
                else if (options.ParentIdentifier is string parentKey && !string.IsNullOrEmpty(parentKey))
                {
                    //items = items.Where(m => m.ParentKey == parentKey);
                }
            }
            #endregion

            #region Search Mode: SearchField와 SearchQuery에 해당하는 데이터 검색
            if (options.SearchMode)
            {
                // Search Mode
                if (!string.IsNullOrEmpty(options.SearchQuery))
                {
                    var searchQuery = options.SearchQuery; // 검색어

                    if (options.SearchField == "Name")
                    {
                        // Name
                        items = items.Where(m => m.Name.Contains(searchQuery));
                    }
                    else if (options.SearchField == "Title")
                    {
                        // Title
                        items = items.Where(m => m.Title.Contains(searchQuery));
                    }
                    else if (options.SearchField == "Content")
                    {
                        // Title
                        items = items.Where(m => m.Content.Contains(searchQuery));
                    }
                    else
                    {
                        // All: 기타 더 검색이 필요한 컬럼이 있다면 추가 가능
                        items = items.Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery) || m.Content.Contains(searchQuery));
                    }
                }
            }
            #endregion

            // 총 레코드 수 계산
            var totalCount = await items.CountAsync();

            #region Sorting: 어떤 열에 대해 정렬(None, Asc, Desc)할 것인지 원하는 문자열로 지정
            if (options.SortMode)
            {
                // Sorting
                foreach (var sf in options.SortFields)
                {
                    switch ($"{sf.Key}{sf.Value}")
                    {
                        case "NameAsc":
                            //items = items.OrderBy(m => m.Name);
                            items = items.OrderBy(m => m.Name).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                            break;
                        case "NameDesc":
                            //items = items.OrderByDescending(m => m.Name);
                            items = items.OrderByDescending(m => m.Name).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                            break;
                        case "TitleAsc":
                            //items = items.OrderBy(m => m.Title);
                            items = items.OrderBy(m => m.Title).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                            break;
                        case "TitleDesc":
                            //items = items.OrderByDescending(m => m.Title);
                            items = items.OrderByDescending(m => m.Title).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                            break;
                        default:
                            //items = items.OrderByDescending(m => m.Id);
                            items = items.OrderByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                            break;
                    }
                }
            }
            #endregion

            // Paging
            items = items.Skip(options.PageIndex * options.PageSize).Take(options.PageSize);

            // AsNoTracking() 사용으로 성능 향상: https://learn.microsoft.com/ko-kr/ef/core/querying/tracking#no-tracking-queries
            return new ArticleSet<Memo, long>(await items.AsNoTracking().ToListAsync(), totalCount);
        }
        #endregion

        #region [4][19] 날짜 범위 필터링
        //[4][19] 날짜 범위 필터링
        public async Task<ArticleSet<Memo, int>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, DateTime from, DateTime to)
        {
            return await GetArticlesWithDateAsync(pageIndex, pageSize, searchField, searchQuery, sortOrder, parentIdentifier, from, to);
        }

        public async Task<ArticleSet<Memo, int>> GetArticlesWithDateAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, DateTime from, DateTime to)
        {
            var items =
                _context.Memos
                    .AsQueryable();

            #region ParentBy: 특정 부모 키 값(int, string)에 해당하는 리스트인지 확인
            // ParentBy 
            if (parentIdentifier is int parentId && parentId != 0)
            {
                items = items.Where(m => m.ParentId == parentId);
            }
            else if (parentIdentifier is string parentKey && !string.IsNullOrEmpty(parentKey))
            {
                items = items.Where(m => m.ParentKey == parentKey);
            }
            #endregion

            if (from != null && to != null)
            {
                items = items.Where(it => (it.PostDate == null) || it.PostDate >= from && it.PostDate <= to);
            }

            #region Search Mode: SearchField와 SearchQuery에 해당하는 데이터 검색
            // Search Mode
            if (!string.IsNullOrEmpty(searchQuery))
            {
                if (searchField == "Name")
                {
                    // Name
                    items = items
                        .Where(m => m.Name.Contains(searchQuery));
                }
                else if (searchField == "Title")
                {
                    // Title
                    items = items
                        .Where(m => m.Title.Contains(searchQuery));
                }
                else
                {
                    // All: 기타 더 검색이 필요한 컬럼이 있다면 추가 가능
                    items = items
                        .Where(m => m.Name.Contains(searchQuery) || m.Title.Contains(searchQuery) || m.FileName.Contains(searchQuery));
                }
            }
            #endregion

            // 총 레코드 수 계산
            var totalCount = await items.CountAsync();

            #region Sorting: 어떤 열에 대해 정렬(None, Asc, Desc)할 것인지 원하는 문자열로 지정
            // Sorting
            switch (sortOrder)
            {
                case "Name":
                    //items = items.OrderBy(m => m.Name);
                    items = items
                        .OrderBy(m => m.Name).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "NameDesc":
                    //items = items.OrderByDescending(m => m.Name);
                    items = items
                        .OrderByDescending(m => m.Name).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "Title":
                    //items = items.OrderBy(m => m.Title);
                    items = items
                        .OrderBy(m => m.Title).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "TitleDesc":
                    //items = items.OrderByDescending(m => m.Title);
                    items = items
                        .OrderByDescending(m => m.Title).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "Created":
                    items = items
                        .OrderBy(m => m.Created).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                case "CreatedDesc":
                    items = items
                        .OrderByDescending(m => m.Created).ThenByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
                default:
                    items = items
                        .OrderByDescending(m => m.Ref).ThenBy(m => m.RefOrder);
                    break;
            }
            #endregion

            // Paging
            items = items.Skip(pageIndex * pageSize).Take(pageSize);

            return new ArticleSet<Memo, int>(await items.AsNoTracking().ToListAsync(), totalCount);
        }
        #endregion

        #region [4][20] Dispose
        //[4][20] Dispose
        // https://learn.microsoft.com/ko-kr/dotnet/api/system.gc.suppressfinalize?view=net-8.0
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                if (_context != null)
                {
                    _context.Dispose(); //_context = null;
                }
            }
        }
        #endregion
    }
}

스토리지 관리 클래스

스토리지 관리 클래스 소개

IMemoFileStorageManager 인터페이스는 파일(특히 BLOB(Binary Large OBject))을 업로드, 다운로드 및 삭제하는 메서드를 정의하여, Memo 관련 파일 관리 기능을 추상화합니다. 이 인터페이스를 통해 다양한 스토리지 시스템(예: 로컬 파일 시스템, 클라우드 스토리지)에 대한 파일 관리 작업을 일관된 방법으로 수행할 수 있습니다.

메서드 설명

  • UploadAsync(byte[] bytes, string fileName, string folderPath = "Memos", bool overwrite = false): 바이트 배열 형태의 파일을 스토리지에 업로드합니다. 파일명, 폴더 경로, 덮어쓰기 허용 여부를 지정할 수 있습니다. 새로운 파일명을 반환합니다.
  • UploadAsync(Stream stream, string fileName, string folderPath = "Memos", bool overwrite = false): 스트림을 사용하여 파일을 스토리지에 업로드합니다. 이 메서드 역시 파일명, 폴더 경로, 덮어쓰기 여부를 매개변수로 받습니다.
  • DownloadAsync(string fileName, string folderPath = "Memos"): 지정된 파일명과 폴더 경로를 사용하여 스토리지로부터 파일을 다운로드합니다. 다운로드된 파일의 바이트 배열을 반환합니다.
  • DeleteAsync(string fileName, string folderPath = "Memos"): 스토리지에서 특정 파일을 삭제합니다. 성공 여부를 bool 값으로 반환합니다.

이 인터페이스는 Memo 엔터티와 연관된 파일을 관리하는데 필수적인 작업을 정의합니다. 예를 들어, 사용자가 Memo에 첨부한 이미지나 문서 파일을 처리하는 기능을 구현할 때 이 인터페이스를 구현한 클래스를 사용할 수 있습니다. 구현 클래스를 통해 개발자는 파일 처리 로직을 캡슐화하고, 애플리케이션의 다른 부분과 독립적으로 관리할 수 있게 됩니다.

Hawaso.Models\Memos\05_IMemoFileStorageManager.cs

using System.IO;
using System.Threading.Tasks;

namespace Hawaso.Models
{
    /// <summary>
    /// 파일(BLOB) 업로드 및 다운로드에 대한 메서드 시그니처 정리
    /// </summary>
    public interface IMemoFileStorageManager
    {
        /// <summary>
        /// File(Blob) Upload with byte[]
        /// </summary>
        /// <returns>New FileName</returns>
        Task<string> UploadAsync(byte[] bytes, string fileName, string folderPath = "Memos", bool overwrite = false);

        /// <summary>
        /// File(Blob) Upload with Stream
        /// </summary>
        Task<string> UploadAsync(Stream stream, string fileName, string folderPath = "Memos", bool overwrite = false);

        /// <summary>
        /// File(Blob) Download
        /// </summary>
        /// <returns>File(Blob)</returns>
        Task<byte[]> DownloadAsync(string fileName, string folderPath = "Memos");

        /// <summary>
        /// File(Blob) Delete
        /// </summary>
        /// <returns>true or false</returns>
        Task<bool> DeleteAsync(string fileName, string folderPath = "Memos");
    }
}

파일 업로드 처리 코드

파일 업로드 처리 코드 소개

MemoFileStorageManager 클래스는 IMemoFileStorageManager 인터페이스를 구현하여 Memo 관련 파일을 로컬 파일 시스템에 업로드, 다운로드, 삭제하는 기능을 제공합니다. 이 클래스는 ASP.NET Core의 IWebHostEnvironment를 사용하여 웹 서버의 파일 시스템에 접근합니다.

생성자

  • MemoFileStorageManager(IWebHostEnvironment environment): 웹 호스팅 환경 정보를 이용하여 인스턴스를 초기화합니다. 파일들이 저장될 기본 폴더 경로를 설정합니다.

메서드 구현

  • UploadAsync(byte[] bytes, string fileName, string folderPath = moduleName, bool overwrite = false): 바이트 배열로부터 파일을 생성하고, 지정된 폴더 경로에 파일을 저장합니다. overwrite 파라미터가 true일 경우, 동일한 파일명이 존재하면 덮어쓰기를 수행합니다.
  • UploadAsync(Stream stream, string fileName, string folderPath = moduleName, bool overwrite = false): 스트림을 사용하여 파일을 업로드합니다. 중복된 파일명을 처리하기 위해 파일명에 번호를 추가하여 고유하게 만듭니다.
  • DownloadAsync(string fileName, string folderPath = moduleName): 지정된 폴더 경로에서 파일명에 해당하는 파일을 바이트 배열로 읽어 반환합니다.
  • DeleteAsync(string fileName, string folderPath = moduleName): 지정된 폴더 경로에서 파일명에 해당하는 파일을 삭제합니다.

MemoBlobStorageManager 클래스

MemoBlobStorageManager 클래스는 IMemoFileStorageManager 인터페이스를 구현하는 또 다른 예제로, 클라우드 스토리지(예: Azure Blob Storage, AWS S3)를 사용하여 파일을 관리할 때의 구현을 대략적으로 보여줍니다. 현재 모든 메서드에서 NotImplementedException 예외를 발생시키며, 클라우드 기반 파일 관리 로직을 추가할 필요가 있습니다.

이 클래스들은 Memo 엔터티와 연관된 파일을 효율적으로 관리하기 위한 기반을 제공합니다. 로컬 파일 시스템 또는 클라우드 스토리지를 사용하여 파일을 안전하게 업로드, 저장, 삭제할 수 있는 기능을 애플리케이션에 통합할 수 있습니다.

코드: Hawaso\Managers\Memos\MemoFileStorageManager.cs

using Azure.Storage.Blobs;
using Microsoft.AspNetCore.Hosting;
using System.IO;
using System.Threading.Tasks;
using Hawaso.Models;
using Microsoft.Extensions.Configuration;

namespace Hawaso.Models;

#region MemoFileStorageManager
public class MemoFileStorageManager : IMemoFileStorageManager
{
    private const string moduleName = "Memos";
    private readonly IWebHostEnvironment _environment;
    private readonly string _containerName;
    private readonly string _folderPath;

    public MemoFileStorageManager(IWebHostEnvironment environment)
    {
        this._environment = environment;
        _containerName = "files";
        _folderPath = Path.Combine(_environment.WebRootPath, _containerName);
    }

    public async Task<bool> DeleteAsync(string fileName, string folderPath = moduleName)
    {
        if (File.Exists(Path.Combine(_folderPath, folderPath, fileName)))
        {
            File.Delete(Path.Combine(_folderPath, folderPath, fileName));
        }
        return await Task.FromResult(true);
    }

    public async Task<byte[]> DownloadAsync(string fileName, string folderPath = moduleName)
    {
        if (File.Exists(Path.Combine(_folderPath, folderPath, fileName)))
        {
            byte[] fileBytes = await File.ReadAllBytesAsync(Path.Combine(_folderPath, folderPath, fileName));
            return fileBytes;
        }
        return null;
    }

    public async Task<string> UploadAsync(byte[] bytes, string fileName, string folderPath = moduleName, bool overwrite = false)
    {
        await File.WriteAllBytesAsync(Path.Combine(_folderPath, folderPath, fileName), bytes);

        return fileName;
    }

    public async Task<string> UploadAsync(Stream stream, string fileName, string folderPath = moduleName, bool overwrite = false)
    {
        // 파일명 중복 처리
        fileName = Dul.FileUtility.GetFileNameWithNumbering(Path.Combine(_folderPath, folderPath), fileName);

        using (var fileStream = new FileStream(Path.Combine(_folderPath, folderPath, fileName), FileMode.Create))
        {
            await stream.CopyToAsync(fileStream);
        }

        return fileName;
    }
} 
#endregion

#region MemoBlobStorageManager
public class MemoBlobStorageManager : IMemoFileStorageManager
{
    private readonly BlobServiceClient _blobServiceClient;
    private readonly BlobContainerClient _containerClient;
    private const string ContainerName = "files"; // Azure Blob Storage 컨테이너 이름
    private const string DefaultFolderPath = "Memos"; // 기본 폴더 경로를 클래스 레벨 상수로 정의

    public MemoBlobStorageManager(IConfiguration configuration)
    {
        var connectionString = configuration.GetConnectionString("BlobConnection");
        _blobServiceClient = new BlobServiceClient(connectionString);
        _containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName);
        _containerClient.CreateIfNotExists(); // 컨테이너가 없으면 생성
    }

    public async Task<bool> DeleteAsync(string fileName, string folderPath = DefaultFolderPath)
    {
        var blobClient = _containerClient.GetBlobClient(Path.Combine(folderPath, fileName));
        return await blobClient.DeleteIfExistsAsync();
    }

    public async Task<byte[]> DownloadAsync(string fileName, string folderPath = DefaultFolderPath)
    {
        var blobClient = _containerClient.GetBlobClient(Path.Combine(folderPath, fileName));
        if (await blobClient.ExistsAsync())
        {
            var downloadInfo = await blobClient.DownloadAsync();
            using (var ms = new MemoryStream())
            {
                await downloadInfo.Value.Content.CopyToAsync(ms);
                return ms.ToArray();
            }
        }
        return null;
    }

    public async Task<string> UploadAsync(byte[] bytes, string fileName, string folderPath = DefaultFolderPath, bool overwrite = false)
    {
        var blobClient = _containerClient.GetBlobClient(Path.Combine(folderPath, fileName));
        using (var ms = new MemoryStream(bytes))
        {
            await blobClient.UploadAsync(ms, overwrite);
        }
        return blobClient.Uri.ToString(); // 업로드된 파일의 URI 반환
    }

    public async Task<string> UploadAsync(Stream stream, string fileName, string folderPath = DefaultFolderPath, bool overwrite = false)
    {
        var blobClient = _containerClient.GetBlobClient(Path.Combine(folderPath, fileName));
        await blobClient.UploadAsync(stream, overwrite);
        return blobClient.Uri.ToString(); // 업로드된 파일의 URI 반환
    }
}
#endregion

서비스 레이어 작성하기

이번에는 서비스 레이어를 작성하겠습니다.

1. 서비스 인터페이스 정의하기

먼저, 메모 데이터를 관리하기 위한 서비스 인터페이스를 정의합니다. IMemoRepository와 동일하게 진행해도 됩니다.

// 파일: C:\dev\Hawaso\src\Hawaso\Services\Memos\IMemoService.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Hawaso.Models;

namespace Hawaso.Services.Memos
{
    public interface IMemoService
    {
        Task<IEnumerable<Memo>> GetAllMemosAsync();
        Task<Memo> GetMemoByIdAsync(long id);
        Task CreateMemoAsync(Memo memo);
        Task UpdateMemoAsync(Memo memo);
        Task DeleteMemoAsync(long id);
    }
}

2. 서비스 클래스 구현하기

서비스 클래스는 서비스 인터페이스를 구현하고, 데이터 접근 작업을 위해 저장소를 사용합니다.

// 파일: C:\dev\Hawaso\src\Hawaso\Services\Memos\MemoService.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Hawaso.Models;

namespace Hawaso.Services.Memos
{
    public class MemoService : IMemoService
    {
        private readonly IMemoRepository _memoRepository;

        public MemoService(IMemoRepository memoRepository)
        {
            _memoRepository = memoRepository;
        }

        public Task<IEnumerable<Memo>> GetAllMemosAsync() => _memoRepository.GetAllAsync();

        public Task<Memo> GetMemoByIdAsync(long id) => _memoRepository.GetByIdAsync(id);

        public Task CreateMemoAsync(Memo memo) => _memoRepository.AddAsync(memo);

        public Task UpdateMemoAsync(Memo memo) => _memoRepository.UpdateAsync(memo);

        public Task DeleteMemoAsync(long id) => _memoRepository.DeleteAsync(id);
    }
}

종속성 코드 모음

MemoAppStartupExtensions 클래스는 MemoApp 관련 의존성 주입 코드를 모아둔 확장 메서드 모음입니다. 이 클래스를 사용하여 Startup.cs 파일 내에서 MemoApp 관련 서비스의 종속성을 쉽게 관리하고 구성할 수 있습니다. 확장 메서드 AddDependencyInjectionContainerForMemoApp을 통해 MemoApp에 필요한 종속성을 IServiceCollection에 등록합니다.

코드: Hawaso\Extensions\Memos\08_MemoAppStartupExtensions.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Hawaso.Extensions.Memos;

/// <summary>
/// 메모앱(MemoApp) 관련 의존성(종속성) 주입 관련 코드만 따로 모아서 관리 
/// </summary>
public static class MemoAppStartupExtensions
{
    public static void AddDependencyInjectionContainerForMemoApp(this IServiceCollection services, string connectionString)
    {
        // MemoAppDbContext.cs Inject: New DbContext Add
        services.AddDbContext<MemoAppDbContext>(options => options.UseSqlServer(connectionString), ServiceLifetime.Transient);

        // IMemoRepository.cs Inject: DI Container에 서비스(리포지토리) 등록 
        services.AddTransient<IMemoRepository, MemoRepository>();

        // IMemoService.cs Inject: DI Container에 서비스 등록
        services.AddTransient<IMemoService, MemoService>();

        // 파일 업로드 및 다운로드 서비스(매니저) 등록
        services.AddTransient<IMemoFileStorageManager, MemoFileStorageManager>(); // Local Upload
    }
}
  • services.AddDbContext<MemoAppDbContext>(options => options.UseSqlServer(connectionString), ServiceLifetime.Transient): MemoAppDbContext를 서비스 컨테이너에 등록합니다. 이 때, SQL Server를 사용하도록 구성하고, 컨텍스트의 수명을 Transient로 설정하여 요청마다 새 인스턴스가 생성되도록 합니다.
  • services.AddTransient<IMemoRepository, MemoRepository>(): IMemoRepository 인터페이스와 MemoRepository 클래스 사이의 매핑을 등록합니다. 이를 통해 IMemoRepository 요청 시 MemoRepository의 인스턴스가 제공됩니다. 수명 주기는 Transient로 설정되어 있으므로, 요청마다 새로운 MemoRepository 인스턴스가 생성됩니다.
  • services.AddTransient<IMemoService, MemoService>(): IMemoService 인터페이스와 MemoService 클래스 사이의 매핑을 등록합니다.
  • services.AddTransient<IMemoFileStorageManager, MemoFileStorageManager>(): 파일 업로드 및 관리를 담당하는 IMemoFileStorageManager 인터페이스와 MemoFileStorageManager 클래스 사이의 매핑을 등록합니다. 이 역시 Transient 수명 주기를 가지며, MemoFileStorageManager를 통해 파일 관리 기능이 제공됩니다.

DI 컨테이너 등록

// MemoApp 관련 의존성 주입
services.AddDependencyInjectionContainerForMemoApp(Configuration.GetConnectionString("DefaultConnection"));

Startup.cs 파일의 ConfigureServices 메서드 내에서 services.AddDependencyInjectionContainerForMemoApp(Configuration.GetConnectionString("DefaultConnection")) 코드를 사용하여 MemoApp 관련 의존성을 등록합니다. 이 코드는 AddDependencyInjectionContainerForMemoApp 확장 메서드를 호출하여 MemoAppDbContext, MemoRepository, MemoFileStorageManager를 서비스 컨테이너에 등록하고, 애플리케이션의 설정 파일(appsettings.json)에서 "DefaultConnection" 연결 문자열을 사용하여 MemoAppDbContext의 데이터베이스 연결을 구성합니다.

파일 다운로드 Web API

코드: Hawaso\Apis\Memos\09_MemoDownloadController.cs

using Microsoft.AspNetCore.Authorization;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using System.Drawing;

namespace Hawaso.Controllers;

[Authorize]
public class MemoDownloadController(IMemoRepository repository, IMemoFileStorageManager fileStorageManager) : Controller
{
    private readonly string moduleName = "Memos";

    /// <summary>
    /// 게시판 파일 강제 다운로드 기능(/BoardDown/:Id)
    /// </summary>
    public async Task<IActionResult> FileDown(int id)
    {
        var model = await repository.GetByIdAsync(id);

        if (model == null)
        {
            return NotFound(); // 존재하지 않는 리소스에 대해 404 오류 반환
        }
        else
        {
            if (!string.IsNullOrEmpty(model.FileName))
            {
                byte[] fileBytes = await fileStorageManager.DownloadAsync(model.FileName, moduleName);
                if (fileBytes != null)
                {
                    model.DownCount += 1;
                    await repository.EditAsync(model);

                    return File(fileBytes, "application/octet-stream", model.FileName);
                }
                else
                {
                    // 파일이 Memos 폴더에 없을 경우, placeholder.png 파일을 대신 반환
                    var placeholderPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot/images", "file-not-found.png");
                    byte[] placeholderBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
                    return File(placeholderBytes, "image/png", "file-not-found.png");
                }
            }

            // 파일명이 비어있는 경우, 또는 다른 이유로 파일을 처리하지 못한 경우
            // 사용자에게 적절한 메시지와 함께 오류 페이지나 기본 페이지로 리디렉션할 수 있습니다.
            // 여기서는 간단히 NotFound를 반환합니다.
            return NotFound();
        }
    }

    /// <summary>
    /// 엑셀 파일 강제 다운로드 기능(/ExcelDown)
    /// </summary>
    public async Task<IActionResult> ExcelDown()
    {
        var results = await repository.GetAllAsync(0, 100); // 총 몇개를 포함할건지, 나중에 이 부분 업데이트할 것...

        var models = results.Records.ToList();

        if (models != null)
        {
            using (var package = new ExcelPackage())
            {
                var worksheet = package.Workbook.Worksheets.Add(moduleName);

                var tableBody = worksheet.Cells["A1:A1"].LoadFromCollection((from m in models select new { m.Id, Created = m.Created?.LocalDateTime.ToString(), m.Name, m.Title, m.DownCount, m.FileName }), true);

                var uploadCol = tableBody.Offset(1, 1, models.Count, 1);

                // 그라데이션 효과 부여 
                var rule = uploadCol.ConditionalFormatting.AddThreeColorScale();
                rule.LowValue.Color = Color.SkyBlue;
                rule.MiddleValue.Color = Color.White;
                rule.HighValue.Color = Color.Red;

                var header = worksheet.Cells["B2:F2"];
                worksheet.DefaultColWidth = 25;
                worksheet.Cells[3, 2, models.Count + 2, 2].Style.Numberformat.Format = "yyyy MMM d DDD";
                tableBody.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
                tableBody.Style.Fill.PatternType = ExcelFillStyle.Solid;
                tableBody.Style.Fill.BackgroundColor.SetColor(Color.WhiteSmoke);
                tableBody.Style.Border.BorderAround(ExcelBorderStyle.Medium);
                header.Style.Font.Bold = true;
                header.Style.Font.Color.SetColor(Color.White);
                header.Style.Fill.BackgroundColor.SetColor(Color.DarkBlue);

                return File(package.GetAsByteArray(), "application/octet-stream", $"{DateTime.Now.ToString("yyyyMMddhhmmss")}_{moduleName}.xlsx");
            }
        }
        return Redirect("/");
    }
}

테스트용 Web API

코드: Hawaso\Apis\Memos\10_MemoApiController.cs

using Microsoft.AspNetCore.Authorization;

namespace MemoApp.Apis.Controllers;

[Authorize(Roles = "Administrators")]
//[ApiVersion("1.0")] //[Route("api/v{v:apiVersion}/Memos")]
[ApiController] // @RestController 
[Route("api/[controller]")] // [Route("api/Memos")] // @RequestMapping
[Produces("application/json")]
public class MemosController : ControllerBase
{
    private readonly IMemoRepository _repository;
    private readonly ILogger _logger;

    public MemosController(IMemoRepository repository, ILoggerFactory loggerFactory)
    {
        _repository = repository ?? throw new ArgumentNullException(nameof(MemosController));
        _logger = loggerFactory.CreateLogger(nameof(MemosController));
    }

    #region 시험
    [HttpGet("[action]")] // api/Memos/Test
    public IEnumerable<Memo> Test() => Enumerable.Empty<Memo>();
    #endregion

    #region 입력: Add
    // 입력
    // POST api/Memos
    [HttpPost] // @PostMapping
    public async Task<IActionResult> AddAsync([FromBody] Memo dto)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest();
        }

        // <>
        var temp = new Memo();
        temp.Name = dto.Name;
        temp.Title = dto.Title;
        temp.Content = dto.Content;
        temp.Created = DateTime.UtcNow;
        // --TODO-- 
        // </>

        try
        {
            var model = await _repository.AddAsync(temp);
            if (model == null)
            {
                return BadRequest();
            }

            //[!] 다음 항목 중 원하는 방식 사용
            if (DateTime.Now.Second % 60 == 0)
            {
                return Ok(model); // 200 OK
            }
            else if (DateTime.Now.Second % 3 == 0)
            {
                return CreatedAtRoute("GetMemoById", new { id = model.Id }, model); // Status: 201 Created
            }
            else if (DateTime.Now.Second % 2 == 0)
            {
                var uri = Url.Link("GetMemoById", new { id = model.Id });
                return Created(uri, model); // 201 Created
            }
            else
            {
                // GetById 액션 이름 사용해서 입력된 데이터 반환 
                return CreatedAtAction(nameof(GetMemoById), new { id = model.Id }, model);
            }
        }
        catch (Exception e)
        {
            _logger.LogError(e.Message);
            return BadRequest();
        }
    }
    #endregion

    #region 출력: GetAll
    // 출력
    // GET api/Memos
    [HttpGet] // [HttpGet("[action]")] // @GetMapping
    public async ValueTask<ActionResult<IOrderedEnumerable<Memo>>> GetAll()
    {
        try
        {
            var models = await _repository.GetAllAsync();

            if (models == null)
                return NotFound(); // 학습용 코드 

            if (!models.Any())
            {
                return new NoContentResult(); // 참고용 코드
            }

            return new JsonResult(models); //return Ok(models); // 200 OK
        }
        catch (Exception e)
        {
            _logger.LogError(e.Message);
            return BadRequest();
        }
    }
    #endregion

    #region 상세: GetById
    // 상세
    // GET api/Memos/123
    [HttpGet("{id:int}", Name = nameof(GetMemoById))] // Name 속성으로 RouteName 설정
    public async Task<IActionResult> GetMemoById([FromRoute] int id)
    {
        try
        {
            #region 상세보기(GetById) 공식 코드 조각 
            var model = await _repository.GetByIdAsync(id);
            if (model == null)
            {
                //return new NoContentResult(); // 204 No Content
                return NotFound(); // 404 Not Found
            }

            return Ok(model);
            #endregion
        }
        catch (Exception e)
        {
            _logger.LogError(e.Message);
            return BadRequest();
        }
    }
    #endregion

    #region 수정: Update
    // 수정
    // PUT api/Memos/123
    [HttpPut("{id}")] // @PutMapping
    public async Task<IActionResult> UpdateAsync([FromRoute] int? id, [FromBody] Memo dto)
    {
        if (id is null)
        {
            return NotFound();
        }

        if (dto == null)
        {
            return BadRequest();
        }

        if (!ModelState.IsValid)
        {
            return BadRequest();
        }

        // <>
        var origin = await _repository.GetByIdAsync(id ?? default);
        if (origin != null)
        {
            origin.Name = dto.Name;
            origin.Title = dto.Title;
            origin.Content = dto.Content;
            // --TODO--
        }
        // </>

        try
        {
            origin.Id = id ?? default;
            var status = await _repository.UpdateAsync(origin);
            if (!status)
            {
                return BadRequest();
            }

            // 204 No Content
            return NoContent(); // 이미 전송된 정보에 모든 값 가지고 있기에...
        }
        catch (Exception e)
        {
            _logger.LogError(e.Message);
            return BadRequest();
        }
    }
    #endregion

    #region 삭제: Delete
    // 삭제
    // DELETE api/Memos/1
    [HttpDelete("{id:int}")] // @DeleteMapping 
    public async Task<IActionResult> DeleteAsync(int id)
    {
        try
        {
            var status = await _repository.DeleteAsync(id);
            if (!status)
            {
                return BadRequest();
            }

            return NoContent(); // 204 NoContent
        }
        catch (Exception e)
        {
            _logger.LogError(e.Message);
            return BadRequest("삭제할 수 없습니다.");
        }
    }
    #endregion

    #region 페이징: GetAll
    // 페이징
    // GET api/Memos/Page/1/10
    [HttpGet("Page/{pageNumber:int}/{pageSize:int}")]
    public async Task<IActionResult> GetAll(int pageNumber = 1, int pageSize = 10)
    {
        try
        {
            // 페이지 번호는 1, 2, 3 사용, 리포지토리에서는 0, 1, 2 사용
            int pageIndex = (pageNumber > 0) ? pageNumber - 1 : 0;

            var resultSet = await _repository.GetAllAsync(pageIndex, pageSize);
            if (resultSet.Records == null)
            {
                return NotFound($"아무런 데이터가 없습니다.");
            }

            // 응답 헤더에 총 레코드 수를 담아서 출력
            //Response.Headers.Add("X-TotalRecordCount", resultSet.TotalRecords.ToString());
            //Response.Headers.Add("Access-Control-Expose-Headers", "X-TotalRecordCount");
            Response.Headers["X-TotalRecordCount"] = resultSet.TotalRecords.ToString();
            Response.Headers["Access-Control-Expose-Headers"] = "X-TotalRecordCount";

            //return Ok(resultSet.Records);
            var ʘ‿ʘ = resultSet.Records; // 재미를 위해서 
            return Ok(ʘ‿ʘ); // Look of Approval
        }
        catch (Exception ಠ_ಠ) // Look of Disapproval
        {
            _logger?.LogError($"ERROR({nameof(GetAll)}): {ಠ_ಠ.Message}");
            return BadRequest();
        }
    }
    #endregion
}

공통 컴포넌트

DeleteDialog.razor

이 코드는 Blazor 프레임워크를 사용한 웹 애플리케이션 개발에 있어서 공통적으로 사용될 수 있는 DeleteDialog 컴포넌트의 구현을 보여줍니다.

  • DeleteDialog.razor: 이 Razor 파일은 사용자에게 데이터 삭제 확인을 요청하는 모달 대화 상자를 정의합니다. 모달은 Bootstrap 4 스타일을 사용하며, 'DELETE'라는 제목과 'Are you sure you want to delete?'라는 메시지를 포함합니다. 사용자가 'Yes'나 'Cancel' 버튼을 클릭하면 각각 OnClickCallback 이벤트나 Hide 메소드가 실행됩니다.

  • DeleteDialog.razor.cs: 이 C# 파일은 DeleteDialog 컴포넌트의 뒷단 로직을 담당합니다. OnClickCallback은 부모 컴포넌트에서 정의된 이벤트 핸들러로, 사용자가 'Yes'를 클릭할 때 실행됩니다. IsShow 프로퍼티는 모달의 표시 여부를 결정하고, ShowHide 메소드는 모달을 각각 표시하고 숨기는 기능을 수행합니다.

이 컴포넌트는 데이터 삭제와 같은 중요한 작업을 수행하기 전에 사용자에게 추가적인 확인을 요구하는 데 사용될 수 있으며, Blazor 애플리케이션의 사용자 경험을 향상시키는데 기여합니다.

코드: Hawaso\Pages\Memos\Components\DeleteDialog.razor

@namespace VisualAcademy.Pages.Memos.Components

@if (IsShow)
{
    @* 부트스트랩 4 기준으로 작성된 모달입니다.  *@
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">DELETE</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="Hide">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <p>Are you sure you want to delete?</p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @onclick="OnClickCallback">Yes</button>
                    <button type="button" class="btn btn-secondary" data-dismiss="modal" @onclick="@Hide">Cancel</button>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}

코드: Hawaso\Pages\Memos\Components\DeleteDialog.razor.cs

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;

namespace VisualAcademy.Pages.Memos.Components;

public partial class DeleteDialog
{
    #region Parameters
    /// <summary>
    /// 부모에서 OnClickCallback 속성에 지정한 이벤트 처리기 실행
    /// </summary>
    [Parameter]
    public EventCallback<MouseEventArgs> OnClickCallback { get; set; }
    #endregion

    #region Properties
    /// <summary>
    /// 모달 다이얼로그를 표시할건지 여부 
    /// </summary>
    public bool IsShow { get; set; } = false; 
    #endregion

    #region Public Methods
    /// <summary>
    /// 폼 보이기 
    /// </summary>
    public void Show() => IsShow = true;

    /// <summary>
    /// 폼 닫기
    /// </summary>
    public void Hide() => IsShow = false;
    #endregion
}

EditorForm.razor

EditorForm 컴포넌트는 Memo 엔터티의 생성 및 수정을 위한 모달 다이얼로그 형식의 폼을 제공합니다. 이 컴포넌트는 Blazor 서버 사이드 애플리케이션에서 사용되며, 사용자가 입력한 데이터를 처리하고 파일을 업로드하는 기능을 포함합니다.

주요 기능

  • 모달 다이얼로그: 사용자 입력을 위한 모달 폼을 제공합니다. IsShow 속성을 통해 폼의 표시 여부를 제어합니다.
  • 데이터 바인딩: EditForm 태그를 사용하여 ModelEdit 객체와 폼 컨트롤을 양방향으로 데이터 바인딩합니다.
  • 유효성 검사: DataAnnotationsValidatorValidationSummary 컴포넌트를 통해 입력 데이터의 유효성을 검사합니다.
  • 파일 업로드: BlazorInputFile 라이브러리를 사용하여 파일을 선택하고 업로드합니다. 선택된 파일은 selectedFiles 배열에 저장됩니다.
  • 생성 및 수정 처리: 사용자가 제출 버튼을 클릭하면, CreateOrEditClick 메서드가 호출되어 입력된 데이터와 선택된 파일을 처리합니다.

메서드 및 이벤트 핸들러

  • ShowHide: 폼을 표시하거나 숨기는 메서드입니다.
  • OnParametersSet: 컴포넌트가 받은 파라미터를 초기화하는 라이프사이클 메서드입니다. 이를 통해 Model에서 ModelEdit으로 데이터를 복사합니다.
  • CreateOrEditClick: 폼 제출 시 호출되는 메서드로, Memo 엔터티의 생성 또는 수정 로직을 처리합니다. 파일이 선택되었다면 해당 파일을 IMemoFileStorageManager를 통해 업로드하고, 관련 정보를 Model에 저장합니다.
  • HandleSelection: 파일 선택 컨트롤에서 파일이 선택될 때 호출되는 메서드로, 선택된 파일 정보를 selectedFiles 배열에 저장합니다.

주입된 서비스

  • IMemoRepository: 데이터베이스와의 상호작용을 위한 리포지토리 인터페이스입니다.
  • IMemoFileStorageManager: 파일 업로드 및 관리를 위한 서비스 인터페이스입니다.

이 컴포넌트는 Memo 엔터티의 데이터 관리뿐만 아니라 파일 업로드 처리까지 포괄하는 풍부한 사용자 인터페이스를 제공합니다. 사용자가 입력한 데이터의 유효성을 검사하고, 필요한 경우 파일을 안전하게 업로드하여 Memo 엔터티와 연결된 파일 관리를 지원합니다.

코드: Hawaso\Pages\Memos\Components\EditorForm.razor

@namespace VisualAcademy.Pages.Memos.Components

@if (IsShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">@EditorFormTitle</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="Hide">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <EditForm Model="ModelEdit" OnValidSubmit="CreateOrEditClick">
                        <DataAnnotationsValidator></DataAnnotationsValidator>
                        <ValidationSummary></ValidationSummary>
                        @if (ModelEdit.Id != 0)
                        {
                            <div class="form-group">
                                <label for="id">Id: </label> @ModelEdit.Id
                                <input type="hidden" @bind-value="@ModelEdit.Id" />
                            </div>
                        }
                        <div class="form-group">
                            <label for="txtName">Name</label>
                            <InputText id="txtName" class="form-control" placeholder="Enter Name" @bind-Value="@ModelEdit.Name"></InputText>
                            <ValidationMessage For="@(() => ModelEdit.Name)" class="form-text text-muted"></ValidationMessage>
                        </div>
                        <div class="form-group">
                            <label for="txtTitle">Title</label>
                            <InputText id="txtTitle" class="form-control" placeholder="Enter Title" @bind-Value="@ModelEdit.Title"></InputText>
                            <ValidationMessage For="@(() => ModelEdit.Title)" class="form-text text-muted"></ValidationMessage>
                        </div>
                        <div class="form-group">
                            <label for="txtContent">Content</label>
                            <textarea id="txtContext" class="form-control" placeholder="Enter Content" rows="5" @bind="ModelEdit.Content" @bind:event="oninput"></textarea>
                            <div class="text-right" style="font-size: 8px; font-style: italic;">
                                Count: @(ModelEdit?.Content?.Length ?? 0)
                            </div>
                        </div>
                        <div class="form-group">
                            <label for="txtPassword">Password</label>
                            <InputText id="txtPassword" type="password" class="form-control" placeholder="Enter Password" @bind-Value="@ModelEdit.Password"></InputText>
                            <ValidationMessage For="@(() => ModelEdit.Password)" class="form-text text-muted"></ValidationMessage>
                        </div>
                        <div class="form-group">
                            <label for="lstCategory">Parent</label>
                            <InputSelect @bind-Value="@parentId" class="form-control" id="lstCategory">
                                <option value="">--Select Parent--</option>
                                @foreach (var p in parentIds)
                                {
                                    <option value="@p">@p</option>
                                }
                            </InputSelect>
                            <ValidationMessage For="@(() => parentId)" class="form-text text-muted"></ValidationMessage>
                        </div>
                        <div class="form-group">
                            <label for="txtTitle">File</label>
                            <BlazorInputFile.InputFile OnChange="HandleSelection"></BlazorInputFile.InputFile>
                        </div>
                        <div class="form-group">
                            <button type="submit" class="btn btn-primary">Submit</button>
                            <button type="button" class="btn btn-secondary" @onclick="Hide">Cancel</button>
                        </div>
                    </EditForm>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}

코드: Hawaso\Pages\Memos\Components\EditorForm.razor.cs

using BlazorInputFile;
using Microsoft.AspNetCore.Components;

namespace VisualAcademy.Pages.Memos.Components;

public partial class EditorForm
{
    #region Fields
    private string parentId = "";

    protected int[] parentIds = { 1, 2, 3 };

    /// <summary>
    /// 첨부 파일 리스트 보관
    /// </summary>
    private IFileListEntry[] selectedFiles;
    #endregion

    #region Properties
    /// <summary>
    /// 모달 다이얼로그를 표시할건지 여부 
    /// </summary>
    public bool IsShow { get; set; } = false;
    #endregion

    #region Public Methods
    /// <summary>
    /// 폼 보이기 
    /// </summary>
    public void Show() => IsShow = true; // 현재 인라인 모달 폼 보이기

    /// <summary>
    /// 폼 닫기
    /// </summary>
    public void Hide() => IsShow = false; // 현재 인라인 모달 폼 숨기기
    #endregion

    #region Parameters
    /// <summary>
    /// 폼의 제목 영역
    /// </summary>
    [Parameter]
    public RenderFragment EditorFormTitle { get; set; }

    /// <summary>
    /// 넘어온 모델 개체 
    /// </summary>
    [Parameter]
    public Memo Model { get; set; }

    /// <summary>
    /// 전체 넘어온 개체 중에서 폼에서 변경되는 내용만 따로 관리: ModelEdit => MemoEdit, MemoViewModel, ...
    /// </summary>
    public Memo ModelEdit { get; set; }

    #region Lifecycle Methods
    // 넘어온 Model 값을 수정 전용 ModelEdit에 담기 
    protected override void OnParametersSet()
    {
        ModelEdit = new Memo();
        ModelEdit.Id = Model.Id;
        ModelEdit.Name = Model.Name;
        ModelEdit.Title = Model.Title;
        ModelEdit.Content = Model.Content;
        ModelEdit.Password = Model.Password;

        // ParentId가 넘어온 값이 있으면... 즉, 0이 아니면 ParentId 드롭다운 리스트 기본값 선택
        parentId = Model.ParentId.ToString();
        if (parentId == "0")
        {
            parentId = "";
        }
    }
    #endregion

    /// <summary>
    /// 부모 컴포넌트에게 생성(Create)이 완료되었다고 보고하는 목적으로 부모 컴포넌트에게 알림
    /// 학습 목적으로 Action 대리자 사용
    /// </summary>
    [Parameter]
    public Action CreateCallback { get; set; }

    /// <summary>
    /// 부모 컴포넌트에게 수정(Edit)이 완료되었다고 보고하는 목적으로 부모 컴포넌트에게 알림
    /// 학습 목적으로 EventCallback 구조체 사용
    /// </summary>
    [Parameter]
    public EventCallback<bool> EditCallback { get; set; }

    [Parameter]
    public string ParentKey { get; set; } = "";
    #endregion

    #region Injectors
    /// <summary>
    /// 리포지토리 클래스에 대한 참조 
    /// </summary>
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public IMemoFileStorageManager FileStorageManagerReference { get; set; }
    #endregion

    #region Event Handlers

    protected async void CreateOrEditClick()
    {
        // 변경 내용 저장
        Model.Name = ModelEdit.Name;
        Model.Title = ModelEdit.Title;
        Model.Content = ModelEdit.Content;
        Model.Password = ModelEdit.Password;

        #region 파일 업로드 관련 추가 코드 영역
        if (selectedFiles != null && selectedFiles.Length > 0)
        {
            // 파일 업로드
            var file = selectedFiles.FirstOrDefault();
            var fileName = "";
            int fileSize = 0;
            if (file != null)
            {
                //file.Name = $"{DateTime.Now.ToString("yyyyMMddhhmmss")}{file.Name}";
                fileName = file.Name;
                fileSize = Convert.ToInt32(file.Size);

                //[A] byte[] 형태
                //var ms = new MemoryStream();
                //await file.Data.CopyToAsync(ms);
                //await FileStorageManager.ReplyAsync(ms.ToArray(), file.Name, "", true);
                //[B] Stream 형태
                //string folderPath = Path.Combine(WebHostEnvironment.WebRootPath, "files");
                await FileStorageManagerReference.UploadAsync(file.Data, file.Name, "Memos", true);

                Model.FileName = fileName;
                Model.FileSize = fileSize;
            }
        }
        #endregion

        if (!int.TryParse(parentId, out int newParentId))
        {
            newParentId = 0;
        }
        Model.ParentId = newParentId;
        Model.ParentKey = Model.ParentKey;

        if (Model.Id == 0)
        {
            // Create
            await RepositoryReference.AddAsync(Model);
            CreateCallback?.Invoke();
        }
        else
        {
            // Edit
            await RepositoryReference.EditAsync(Model);
            await EditCallback.InvokeAsync(true);
        }
        //IsShow = false; // this.Hide()
    }

    protected void HandleSelection(IFileListEntry[] files) => this.selectedFiles = files;
    #endregion
}

ModalForm.razor

ModalForm.razor 컴포넌트는 Memos 애플리케이션 내에서 메모 작성 및 수정을 위한 모달 폼을 제공합니다. 이 폼은 사용자가 메모 정보를 입력하고 파일을 업로드할 수 있도록 다양한 입력 필드와 컨트롤을 포함합니다.

주요 기능 및 컴포넌트

  • 모달 다이얼로그: Bootstrap 모달을 사용하여 폼을 모달 다이얼로그로 표시합니다. IsShow 속성으로 모달의 표시 여부를 제어합니다.
  • 데이터 바인딩: Blazor의 @bind 지시어를 사용하여 ModelEdit 객체의 속성과 입력 필드를 양방향으로 바인딩합니다.
  • 유효성 검사: 입력 필드의 유효성 검사를 위해 HTML5 유효성 검사 속성을 사용합니다.
  • 파일 업로드: BlazorInputFile 라이브러리를 사용하여 사용자가 파일을 선택하고 업로드할 수 있게 합니다. 선택된 파일은 selectedFiles 배열에 저장됩니다.
  • 인코딩 선택: 사용자가 메모의 인코딩 타입을 선택할 수 있도록 드롭다운 리스트를 제공합니다.

메서드 및 이벤트 핸들러

  • ShowHide: 모달 폼을 표시하거나 숨기는 메서드입니다.
  • CreateOrEditClick: 사용자가 "Submit" 버튼을 클릭할 때 호출되는 메서드로, 입력된 메모 정보를 저장하고 선택된 파일을 업로드합니다. IMemoRepositoryIMemoFileStorageManager를 사용하여 데이터베이스 및 파일 시스템에 데이터를 저장합니다.
  • HandleSelection: 파일 선택 컨트롤에서 파일이 선택될 때 호출되는 메서드로, 선택된 파일 정보를 selectedFiles 배열에 저장합니다.

Parameters 및 Injectors

  • [Parameter] 속성: 부모 컴포넌트로부터 전달받는 파라미터를 정의합니다. 예를 들어, ModelSender는 현재 편집 중인 Memo 객체를 나타냅니다.
  • [Inject] 속성: 종속성 주입을 통해 필요한 서비스 인스턴스(예: IMemoRepository, IMemoFileStorageManager)를 컴포넌트에 주입합니다.

ModalForm.razor의 작동 방식

  1. 폼 표시: 사용자가 메모를 생성하거나 수정하는 버튼을 클릭하면, Show 메서드가 호출되어 모달 폼이 표시됩니다.
  2. 데이터 입력 및 수정: 사용자는 메모의 제목, 내용, 인코딩 타입, 비밀번호를 입력하고, 필요한 경우 파일을 선택합니다.
  3. 데이터 저장: "Submit" 버튼을 클릭하면, CreateOrEditClick 메서드가 호출되어 입력된 데이터와 선택된 파일이 처리됩니다. 새 메모는 생성되고, 기존 메모는 수정됩니다.
  4. 폼 숨김: 작업이 완료되면, Hide 메서드가 호출되어 모달 폼이 숨겨집니다.

이 컴포넌트는 Memos 애플리케이션에서 중요한 역할을 하며, 사용자에게 효율적인 데이터 입력 및 수정 인터페이스를 제공합니다.

코드: Hawaso\Pages\Memos\Components\ModalForm.razor

@namespace VisualAcademy.Pages.Memos.Components

@if (IsShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">@EditorFormTitle</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="Hide">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    @if (ModelEdit.Id != 0)
                    {
                        <div class="form-group">
                            <label for="id">Id: </label> @ModelEdit.Id
                            <input type="hidden" @bind-value="@ModelEdit.Id" />
                        </div>
                    }
                    <div class="form-group">
                        <label for="txtName">Name</label>
                        <input type="text" @bind="@ModelEdit.Name" class="form-control" placeholder="Enter Name" />
                    </div>
                    <div class="form-group">
                        <label for="txtTitle">Title</label>
                        <input type="text" @bind="@ModelEdit.Title" class="form-control" placeholder="Enter Title" />
                    </div>
                    <div class="form-group">
                        <label for="txtContent">Content</label>
                        <textarea id="txtContext" class="form-control" placeholder="Enter Content" rows="5" @bind="ModelEdit.Content" @bind:event="oninput"></textarea>
                        <div class="text-right" style="font-size: 8px; font-style: italic;">
                            Count: @(ModelEdit?.Content?.Length ?? 0)
                        </div>
                    </div>
                    <div class="form-group">
                        <label for="encoding">Encoding</label>
                        <select class="form-control" @bind="ModelEdit.Encoding">
                            @foreach (var encoding in Encodings)
                            {
                                @if (ModelEdit.Encoding == encoding)
                                {
                                    <option value="@encoding" selected>@encoding</option>
                                }
                                else
                                {
                                    <option value="@encoding">@encoding</option>
                                }
                            }
                        </select>
                    </div>
                    <div class="form-group">
                        <label for="txtPassword">Password</label>
                        <input type="password" id="txtPassword" class="form-control" placeholder="Enter Password" @bind="@ModelEdit.Password" />
                    </div>
                    <div class="form-group">
                        <label for="txtTitle">File</label>
                        <BlazorInputFile.InputFile OnChange="HandleSelection"></BlazorInputFile.InputFile>
                    </div>
                    <div class="form-group">
                        <button type="button" class="btn btn-primary" @onclick="CreateOrEditClick">Submit</button>
                        <button type="button" class="btn btn-secondary" @onclick="Hide">Cancel</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}

코드: Hawaso\Pages\Memos\Components\ModalForm.razor.cs

using BlazorInputFile;
using Microsoft.AspNetCore.Components;

namespace VisualAcademy.Pages.Memos.Components;

public partial class ModalForm
{
    #region Fields
    private string parentId = "";

    /// <summary>
    /// 첨부 파일 리스트 보관
    /// </summary>
    private IFileListEntry[] selectedFiles;
    #endregion

    #region Properties
    /// <summary>
    /// (글쓰기/글수정)모달 다이얼로그를 표시할건지 여부 
    /// </summary>
    public bool IsShow { get; set; } = false;
    #endregion

    #region Public Methods
    /// <summary>
    /// 폼 보이기 
    /// </summary>
    public void Show() => IsShow = true; // 현재 인라인 모달 폼 보이기

    /// <summary>
    /// 폼 닫기
    /// </summary>
    public void Hide() => IsShow = false; // 현재 인라인 모달 폼 숨기기
    #endregion

    #region Parameters
    /// <summary>
    /// 폼의 제목 영역
    /// </summary>
    [Parameter]
    public RenderFragment EditorFormTitle { get; set; }

    /// <summary>
    /// 넘어온 모델 개체 
    /// </summary>
    [Parameter]
    public Memo ModelSender { get; set; }

    /// <summary>
    /// 전체 넘어온 개체 중에서 폼에서 변경되는 내용만 따로 관리: ModelEdit => MemoEdit, MemoViewModel, ...
    /// </summary>
    public Memo ModelEdit { get; set; }

    public string[] Encodings { get; set; } = { "Plain-Text", "Text/HTML", "Mixed-Text" };

    #region Lifecycle Methods
    // 넘어온 Model 값을 수정 전용 ModelEdit에 담기 
    protected override void OnParametersSet()
    {
        ModelEdit = new Memo();
        ModelEdit.Id = ModelSender.Id;
        ModelEdit.Name = ModelSender.Name;
        ModelEdit.Title = ModelSender.Title;
        ModelEdit.Content = ModelSender.Content;
        ModelEdit.Password = ModelSender.Password;

        if (ModelEdit.Encoding != null)
        {
            ModelEdit.Encoding = ModelSender.Encoding;
        }
        else
        {
            ModelEdit.Encoding = "Plain-Text"; // Plain-Text, Text/HTML, Mixed-Text
        }

        // 더 많은 정보는 여기에서...

        // ParentId가 넘어온 값이 있으면... 즉, 0이 아니면 ParentId 드롭다운 리스트 기본값 선택
        parentId = ModelSender.ParentId.ToString();
        if (parentId == "0")
        {
            parentId = "";
        }
    }
    #endregion

    /// <summary>
    /// 부모 컴포넌트에게 생성(Create)이 완료되었다고 보고하는 목적으로 부모 컴포넌트에게 알림
    /// 학습 목적으로 Action 대리자 사용
    /// </summary>
    [Parameter]
    public Action CreateCallback { get; set; }

    /// <summary>
    /// 부모 컴포넌트에게 수정(Edit)이 완료되었다고 보고하는 목적으로 부모 컴포넌트에게 알림
    /// 학습 목적으로 EventCallback 구조체 사용
    /// </summary>
    [Parameter]
    public EventCallback<bool> EditCallback { get; set; }

    [Parameter]
    public string ParentKey { get; set; } = "";
    #endregion

    #region Injectors
    /// <summary>
    /// 리포지토리 클래스에 대한 참조 
    /// </summary>
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public IMemoFileStorageManager FileStorageManagerReference { get; set; }
    #endregion

    #region Event Handlers
    protected async void CreateOrEditClick()
    {
        // 변경 내용 저장
        ModelSender.Name = ModelEdit.Name;
        ModelSender.Title = ModelEdit.Title;
        ModelSender.Content = ModelEdit.Content;
        ModelSender.Password = ModelEdit.Password;
        ModelSender.Encoding = ModelEdit.Encoding;

        #region 파일 업로드 관련 추가 코드 영역
        if (selectedFiles != null && selectedFiles.Length > 0)
        {
            // 파일 업로드
            var file = selectedFiles.FirstOrDefault();
            if (file != null)
            {
                string fileName = file.Name;

                //file.Name = $"{DateTime.Now.ToString("yyyyMMddhhmmss")}{file.Name}";
                // 파일명이 30자를 넘으면 앞의 30자까지만 사용
                if (fileName.Length > 30)
                {
                    fileName = fileName.Substring(0, 30);
                }

                int fileSize = Convert.ToInt32(file.Size);

                //[A] byte[] 형태
                //var ms = new MemoryStream();
                //await file.Data.CopyToAsync(ms);
                //await FileStorageManager.ReplyAsync(ms.ToArray(), file.Name, "", true);
                //[B] Stream 형태
                //string folderPath = Path.Combine(WebHostEnvironment.WebRootPath, "files");
                await FileStorageManagerReference.UploadAsync(file.Data, fileName, "Memos", true);

                ModelSender.FileName = fileName;
                ModelSender.FileSize = fileSize;
            }
        }
        #endregion

        if (!int.TryParse(parentId, out int newParentId))
        {
            newParentId = 0;
        }
        ModelSender.ParentId = newParentId;
        ModelSender.ParentKey = ModelSender.ParentKey;

        if (ModelSender.Id == 0)
        {
            // Create
            await RepositoryReference.AddAsync(ModelSender);
            CreateCallback?.Invoke();
        }
        else
        {
            // Edit
            await RepositoryReference.UpdateAsync(ModelSender);
            await EditCallback.InvokeAsync(true);
        }
    }

    protected void HandleSelection(IFileListEntry[] files) => this.selectedFiles = files;
    #endregion
}

참고: 파일 이름 길이 제한

파일을 업로드할 때 운영체제에서 지원하는 파일 이름의 길이를 초과하면 오류가 발생할 수 있습니다. 대부분의 시스템에서 파일 이름은 최대 255자까지 허용되며, 이를 초과할 경우 업로드 과정에서 문제가 발생할 수 있습니다. 이러한 문제를 방지하기 위해, 우리 애플리케이션에서는 파일 이름을 자동으로 30자로 제한하고 있습니다. 파일 업로드 시 이 점을 유의해 주세요.

SearchBox.razor

SearchBox.razor 컴포넌트는 사용자로부터 검색 쿼리를 입력받아, 입력된 검색어를 바탕으로 검색을 수행할 수 있는 인터페이스를 제공합니다. 이 컴포넌트는 VisualAcademy.Pages.Memos.Components 네임스페이스 아래에 구현되어 있으며, 검색어 입력 및 검색 버튼 클릭 이벤트를 처리합니다.

주요 기능 및 컴포넌트

  • 입력 필드: 사용자가 검색어를 입력할 수 있는 텍스트 입력 필드를 제공합니다. @bind를 사용하여 양방향 데이터 바인딩을 수행하며, oninput 이벤트를 통해 사용자 입력을 즉시 반영합니다.
  • 검색 버튼: 사용자가 검색을 수행할 수 있는 버튼입니다. @onclick을 통해 클릭 이벤트를 처리합니다.

코드 동작 방식

  • 디바운싱: 사용자가 입력을 멈춘 후 일정 시간(기본값은 300밀리초)이 지나면 검색이 수행되도록 디바운싱 기능을 구현합니다. System.Timers.Timer를 사용하여 이 기능을 구현하며, 타이머가 만료될 때 SearchHandler 메서드가 호출됩니다.
  • 이벤트 처리: 검색 버튼 클릭 시 Search 메서드가 호출되고, 디바운싱 타이머가 만료될 때 SearchHandler 메서드가 호출됩니다. 두 메서드 모두 SearchQueryChanged 이벤트 콜백을 통해 부모 컴포넌트에 검색어를 전달합니다.

Parameters 및 Properties

  • AdditionalAttributes: 부모 컴포넌트에서 전달된 추가 HTML 속성을 받아서 사용합니다. 이를 통해 컴포넌트의 유연성을 높입니다.
  • SearchQueryChanged: 검색어가 변경되었을 때 부모 컴포넌트에 알리기 위한 이벤트 콜백입니다.
  • Debounce: 디바운싱 간격을 설정합니다. 기본값은 300밀리초입니다.
  • SearchQuery: 사용자가 입력한 검색어를 저장합니다. set 접근자에서 디바운싱 타이머를 재설정합니다.

활용 방법

SearchBox 컴포넌트는 메모 리스트를 검색하는 기능에 사용됩니다. 사용자가 검색어를 입력하고, 일정 시간 동안 추가 입력이 없거나 검색 버튼을 클릭하면, 입력된 검색어를 바탕으로 메모 리스트를 필터링하는 로직이 부모 컴포넌트에서 실행됩니다. 이 컴포넌트는 검색 기능을 구현할 때 사용자 경험을 향상시키기 위해 디바운싱 기법을 사용합니다.

코드: Hawaso\Pages\Memos\Components\SearchBox.razor

@namespace VisualAcademy.Pages.Memos.Components

<div class="input-group mb-3">
    <input class="form-control form-control-sm form-control-borderless" type="search" placeholder="Search topics or keywords"  aria-describedby="btnSearch"
           @attributes="AdditionalAttributes" @bind="SearchQuery" @bind:event="oninput">
    <div class="input-group-append">
        <button class="btn btn-sm btn-success" type="submit" @onclick="Search" id="btnSearch">Search</button>
    </div>
</div>

코드: Hawaso\Pages\Memos\Components\SearchBox.razor.cs

using Microsoft.AspNetCore.Components;
using System.Timers;

namespace VisualAcademy.Pages.Memos.Components;

public partial class SearchBox : IDisposable
{
    #region Fields
    private string searchQuery; // 검색어 담을 그릇 
    private System.Timers.Timer debounceTimer; // 디바운스 타임: 응답 대기 시간(300밀리초)
    #endregion

    #region Parameters
    #region 부모에서 전달된 기타 특성들을 모두 받아서 사용
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object> AdditionalAttributes { get; set; }
    #endregion

    // 자식 컴포넌트에서 발생한 정보를 부모 컴포넌트에게 전달
    [Parameter]
    public EventCallback<string> SearchQueryChanged { get; set; }

    [Parameter]
    public int Debounce { get; set; } = 300;
    #endregion

    #region Properties
    /// <summary>
    /// 부모(외부) 컴포넌트에 공개할 속성 이름
    /// </summary>
    public string SearchQuery
    {
        get => searchQuery;
        set
        {
            searchQuery = value;

            #region 타이머 가동
            debounceTimer.Stop(); // 텍스트박스에 값을 입력하는 동안 타이머 중지
            debounceTimer.Start(); // 타이머 실행(300밀리초 후에 딱 한 번 실행) 
            #endregion
        }
    }
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override void OnInitialized()
    {
        debounceTimer = new System.Timers.Timer();
        debounceTimer.Interval = Debounce;
        debounceTimer.AutoReset = false; // 딱 한번 실행 
        debounceTimer.Elapsed += SearchHandler;
    }
    #endregion

    #region Event Handlers
    /// <summary>
    /// 검색 버튼 클릭했을 때 실행
    /// </summary>
    protected void Search()
    {
        SearchQueryChanged.InvokeAsync(SearchQuery); // 부모의 메서드에 검색어 전달
    }

    /// <summary>
    /// 타이머에 의해서 실행
    /// </summary>
    protected async void SearchHandler(object source, ElapsedEventArgs e)
    {
        await InvokeAsync(() => SearchQueryChanged.InvokeAsync(SearchQuery)); // 부모의 메서드에 검색어 전달
    }
    #endregion

    #region Public Methods
    public void Dispose()
    {
        debounceTimer.Dispose(); // 타이머 개체 소멸 
    }
    #endregion
}

SortOrderArrow.razor

SortOrderArrow.razor 컴포넌트는 테이블 헤더에서 사용자가 선택한 정렬 순서(오름차순 또는 내림차순)를 시각적으로 표시하는 역할을 합니다. 이 컴포넌트는 사용자가 데이터를 정렬할 때 직관적으로 현재 정렬 상태를 이해할 수 있도록 도와줍니다.

주요 기능 및 컴포넌트

  • 정렬 상태 표시: 현재 선택된 정렬 컬럼과 정렬 순서(오름차순/내림차순)에 따라 화살표 아이콘을 표시합니다.
    • 은 정렬이 적용되지 않았음을 나타냅니다.
    • 은 해당 컬럼이 내림차순으로 정렬되었음을 나타냅니다.
    • 은 해당 컬럼이 오름차순으로 정렬되었음을 나타냅니다.

Parameters

  • SortColumn: 현재 정렬이 적용된 컬럼의 이름입니다.
  • SortOrder: 현재 정렬 순서를 나타내는 문자열입니다. 이 값에는 정렬 컬럼 이름과 정렬 방향(Desc를 포함할 수 있음)이 포함됩니다.

코드 동작 방식

  • OnParametersSet 메서드는 컴포넌트가 받은 파라미터가 설정될 때 호출됩니다. 이 메서드 내에서 SortOrderSortColumn 값에 따라 적절한 화살표 아이콘(arrow 변수 값)을 결정합니다.
  • SortOrder의 값에 따라 arrow 변수에 할당되는 값이 달라지며, 이는 컴포넌트가 표시하는 화살표 아이콘을 결정합니다.

활용 방법

SortOrderArrow 컴포넌트는 데이터 테이블의 각 헤더에 배치될 수 있습니다. 사용자가 특정 컬럼의 헤더를 클릭하여 정렬 순서를 변경할 때마다, 이 컴포넌트는 현재 정렬 상태를 나타내는 화살표 아이콘을 업데이트하여 표시합니다. 이를 통해 사용자는 현재 데이터가 어떤 컬럼에 따라 어떤 순서로 정렬되어 있는지 쉽게 인지할 수 있습니다.

SortOrderArrow 컴포넌트는 사용자 인터페이스의 사용성을 향상시키는 데 도움을 줄 수 있는 간단하면서도 효과적인 방법입니다. 사용자가 데이터를 탐색하고 이해하는 데 필요한 시각적 단서를 제공함으로써, 애플리케이션의 전반적인 사용자 경험을 개선할 수 있습니다.

코드: Hawaso\Pages\Memos\Components\SortOrderArrow.razor

@namespace VisualAcademy.Pages.Memos.Components

<span style="color: silver; vertical-align:text-bottom; margin-left: 7px; font-weight: bold; float: right;">@arrow</span>

@code {
    [Parameter]
    public string SortColumn { get; set; }

    [Parameter]
    public string SortOrder { get; set; }

    private string arrow = " ";

    protected override void OnParametersSet()
    {
        if (SortOrder == "")
        {
            arrow = "↕";
        }
        else if (SortOrder.Contains(SortColumn) && SortOrder.Contains("Desc"))
        {
            arrow = "↓";
        }
        else if (SortOrder.Contains(SortColumn))
        {
            arrow = "↑";
        }

        StateHasChanged();
    }
}

입력 컴포넌트

Create.razor 컴포넌트는 Memos 애플리케이션에서 사용자가 새로운 메모를 작성하거나 기존 메모에 대한 답변을 작성할 수 있는 페이지를 제공합니다. 이 페이지는 사용자로부터 메모에 필요한 정보(예: 이름, 이메일, 제목, 내용, 비밀번호)를 입력받고, 선택적으로 파일을 첨부할 수 있는 기능을 포함합니다.

주요 기능 및 컴포넌트

  • 양방향 데이터 바인딩: @bind-Value 지시어를 사용하여 입력 컨트롤과 Model 객체의 속성 간 양방향 데이터 바인딩을 구현합니다.
  • 유효성 검사: DataAnnotationsValidatorValidationSummary 컴포넌트를 사용하여 입력 데이터의 유효성을 검증합니다.
  • 파일 업로드: BlazorInputFile.InputFile 컴포넌트를 사용하여 사용자로부터 파일을 선택하고, HandleSelection 메서드를 통해 선택된 파일을 처리합니다.

코드 동작 방식

  • 폼 제출 처리: FormSubmit 메서드는 사용자가 "Submit" 버튼을 클릭할 때 호출되며, 입력된 데이터를 처리하고 선택된 파일을 업로드합니다.
  • 답변 글 작성: 페이지 URL에 Id 파라미터가 포함되어 있는 경우, 해당 Id의 메모에 대한 답변 글을 작성하는 모드로 동작합니다. OnInitializedAsync 메서드에서는 부모 메모의 데이터를 불러와 답변 글의 기본값을 설정합니다.

Parameters 및 Injectors

  • [Parameter] 속성: URL로부터 전달받은 Id 값을 처리합니다. 이 값은 답변 글을 작성할 때 부모 메모의 Id를 나타냅니다.
  • [Inject] 속성: IMemoRepositoryIMemoFileStorageManager 인터페이스를 구현한 서비스를 컴포넌트에 주입하여, 데이터베이스 작업 및 파일 관리 작업을 수행합니다.
  • NavigationManager: 페이지 이동을 위해 주입되며, 작업 완료 후 메모 목록 페이지로 리디렉션합니다.

활용 방법

Create.razor 페이지는 메모 작성 및 답변 글 작성 기능을 제공하여 사용자가 쉽게 새로운 내용을 추가하거나 기존 내용에 대해 응답할 수 있도록 합니다. 입력 폼은 사용자로부터 필요한 모든 정보를 수집하며, 첨부된 파일 처리 기능을 통해 더 풍부한 내용의 메모 작성을 가능하게 합니다. 유효성 검사를 통해 입력 데이터의 정확성을 보장하며, 사용자 경험을 개선합니다.

코드: Hawaso\Pages\Memos\Create.razor

@page "/Memos/Create"
@page "/Memos/Create/{Id:int}"

@namespace VisualAcademy.Pages.Memos

@*@attribute [Authorize(Roles = "Administrators")]*@

<PageTitle>게시판 글쓰기 | Hawaso</PageTitle>

<h3>Create</h3>

<div class="row">
    <div class="col-md-12">
        @if (Model is not null)
        {
            <EditForm Model="Model" OnValidSubmit="FormSubmit">
                <DataAnnotationsValidator></DataAnnotationsValidator>
                <ValidationSummary></ValidationSummary>

                <div class="form-group">
                    <label for="txtName">Name</label>
                    <InputText id="txtName" class="form-control" placeholder="Enter Name" @bind-Value="@Model.Name"></InputText>
                    <ValidationMessage For="@(() => Model.Name)" class="form-text text-muted"></ValidationMessage>
                </div>

                <div class="form-group">
                    <label for="txtTitle">Email</label>
                    <InputText id="txtEmail" class="form-control" placeholder="Enter Email" @bind-Value="@Model.Email"></InputText>
                    <ValidationMessage For="() => Model.Email" class="form-text text-muted"></ValidationMessage>
                </div>

                <div class="form-group">
                    <label for="txtTitle">Title</label>
                    <InputText id="txtTitle" class="form-control" placeholder="Enter Title" @bind-Value="@Model.Title"></InputText>
                    <ValidationMessage For="@(() => Model.Title)" class="form-text text-muted"></ValidationMessage>
                </div>

                <div class="form-group">
                    <label for="txtContent">Content</label>
                    <InputTextArea id="txtContext" class="form-control" placeholder="Enter Content" rows="5" @bind-Value="@Model.Content"></InputTextArea>
                    <ValidationMessage For="() => Model.Content" class="form-text text-muted"></ValidationMessage>
                </div>

                <div class="form-group">
                    <label for="txtPassword">Password</label>
                    <InputText id="txtPassword" type="password" class="form-control" placeholder="Enter Password" @bind-Value="@Model.Password"></InputText>
                    <ValidationMessage For="@(() => Model.Password)" class="form-text text-muted"></ValidationMessage>
                </div>

                <div class="form-group">
                    <label for="lstCategory">Parent</label>
                    <InputSelect @bind-Value="@ParentId" class="form-control" id="lstCategory">
                        <option value="">--Select Parent--</option>
                        @foreach (var p in parentIds)
                        {
                            <option value="@p">@p</option>
                        }
                    </InputSelect>
                    <ValidationMessage For="@(() => ParentId)" class="form-text text-muted"></ValidationMessage>
                </div>

                <div class="form-group">
                    <label for="txtTitle">File</label>
                    <BlazorInputFile.InputFile OnChange="HandleSelection"></BlazorInputFile.InputFile>
                </div>

                <div class="form-group">
                    <button type="submit" class="btn btn-primary">Submit</button>
                    <a href="/Memos" class="btn btn-secondary">List</a>
                </div>
            </EditForm>
        }
    </div>
</div>

코드: Hawaso\Pages\Memos\Create.razor.cs

using BlazorInputFile;
using Microsoft.AspNetCore.Components;

namespace VisualAcademy.Pages.Memos;

public partial class Create
{
    #region Fields
    /// <summary>
    /// 첨부 파일 리스트 보관
    /// </summary>
    private IFileListEntry[] selectedFiles;

    /// <summary>
    /// 부모(카테고리) 리스트가 저장될 임시 변수
    /// </summary>
    protected int[] parentIds = { 1, 2, 3 };
    #endregion

    #region Parameters
    [Parameter]
    public int Id { get; set; } = 0;
    #endregion

    #region Injectors
    // Reference 접미사 사용해 봄
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    // Injector 접미사 사용해 봄 
    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; }

    [Inject]
    public IMemoFileStorageManager FileStorageManagerInjector { get; set; }
    #endregion

    #region Properties
    private Memo Model { get; set; } = new Memo();

    public string Name { get; set; }

    public string Title { get; set; }

    public string Content { get; set; } = "";

    public string ParentId { get; set; } = "";

    // 부모 글의 답변형 게시판 계층 정보를 임시 보관
    public int ParentRef { get; set; } = 0;
    public int ParentStep { get; set; } = 0;
    public int ParentRefOrder { get; set; } = 0;
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync()
    {
        // 답변 글쓰기 페이지라면, 기존 데이터 읽어오기 
        if (Id != 0)
        {
            // 기존 글의 데이터를 읽어오기 
            var parent = await RepositoryReference.GetByIdAsync(Id);

            //Model.Id = 0; // 답변 페이지는 새로운 글로 초기화 
            Model.Name = "";
            Model.Title = $"Re: {parent.Title}";
            Model.Content = $"\r\n====\r\n{parent.Content}";

            ParentRef = (int)parent.Ref;
            ParentStep = (int)parent.Step;
            ParentRefOrder = (int)parent.RefOrder;
        }
    }
    #endregion

    #region Event Handlers

    /// <summary>
    /// 저장하기 버튼 클릭 이벤트 처리기
    /// </summary>
    protected async void FormSubmit()
    {
        int.TryParse(ParentId, out int parentId); // 드롭다운 선택 값을 정수형으로 변환
        Model.ParentId = parentId; // 선택한 ParentId 값 가져오기 

        #region 파일 업로드 관련 추가 코드 영역
        if (selectedFiles != null && selectedFiles.Length > 0)
        {
            // 파일 업로드
            var file = selectedFiles.FirstOrDefault();
            if (file != null)
            {
                string fileName = file.Name;
                int fileSize = Convert.ToInt32(file.Size);

                fileName = await FileStorageManagerInjector.UploadAsync(file.Data, file.Name, "Memos", true);

                Model.FileName = fileName;
                Model.FileSize = fileSize;
            }
        }
        #endregion

        var m = new Memo();

        m.Name = Model.Name;
        m.Title = Model.Title;
        m.Content = Model.Content;
        m.Password = Model.Password;
        m.Email = Model.Email;
        m.FileName = Model.FileName;
        m.FileSize = Model.FileSize;

        m.PostDate = DateTime.Now;
        m.ParentNum = 0;
        m.AnswerNum = 0;
        m.CommentCount = 0;
        m.Created = DateTime.UtcNow;
        m.CreatedBy = "";
        m.Category = "Free";
        m.AnswerNum = 0;
        m.CommentCount = 0;
        m.Encoding = "Text";
        m.Email = "";
        m.IsPinned = false;
        m.Modified = DateTime.Now;
        m.ModifyIp = "";
        m.PostIp = "127.0.0.1";
        m.Step = 0;
        m.RefOrder = 0;


        if (Id != 0)
        {
            // Memo: 답변 글이라면,
            await RepositoryReference.AddAsync(m, Id);
        }
        else
        {
            // Create: 일반 작성 글이라면,
            await RepositoryReference.AddAsync(m);
        }

        // Manage 컴포넌트로 이동 
        NavigationManagerInjector.NavigateTo("/Memos");
    }

    /// <summary>
    /// 파일 첨부 이벤트 처리기 
    /// </summary>
    protected void HandleSelection(IFileListEntry[] files) => this.selectedFiles = files;
    #endregion
}

출력 컴포넌트

Manage.razor 컴포넌트는 Memos 애플리케이션에서 사용자가 생성한 메모들을 관리하고, 메모의 리스트를 보여주며, 각 메모에 대한 상세보기, 편집, 삭제 등의 작업을 할 수 있는 관리 페이지를 제공합니다. 이 페이지는 관리자 및 사용자가 메모 관련 작업을 효율적으로 수행할 수 있도록 다양한 기능을 포함합니다.

주요 기능 및 컴포넌트

  • 리스트 표시: 메모의 리스트를 테이블 형태로 보여주고, 각 메모에 대한 기본적인 정보와 작업을 수행할 수 있는 버튼을 제공합니다.
  • 페이징 처리: DulPager.DulPagerComponent를 사용하여 데이터의 페이징 처리를 지원합니다.
  • 검색 기능: SearchBox 컴포넌트를 통해 메모를 검색할 수 있습니다.
  • 정렬 기능: 사용자가 리스트의 제목, 이름, 생성일 등의 컬럼을 클릭하여 데이터를 정렬할 수 있습니다.
  • 모달 폼: ModalForm 컴포넌트를 사용하여 메모를 생성하거나 수정할 때 모달 다이얼로그 형태로 입력 폼을 제공합니다.
  • 삭제 확인: DeleteDialog 컴포넌트를 사용하여 메모 삭제 시 사용자에게 확인을 요청합니다.

코드 동작 방식

  • 데이터 표시: OnInitializedAsync 메서드에서 초기 데이터 로딩을 수행하고, DisplayData 메서드를 통해 페이지 번호, 검색 쿼리, 정렬 순서에 따라 데이터를 조회하여 화면에 표시합니다.
  • 이벤트 처리: 각 버튼(생성, 수정, 삭제, 토글, 엑셀 다운로드 등)의 클릭 이벤트에 대한 처리 로직을 구현합니다.

Injectors 및 Parameters

  • [Inject] 속성: IMemoRepository, IMemoFileStorageManager, NavigationManager, IJSRuntime 등의 서비스를 주입하여 데이터 관리 및 페이지 네비게이션, 자바스크립트 인터로핑 작업을 수행합니다.
  • [Parameter] 속성: 외부로부터 ParentIdParentKey 값을 받아 특정 카테고리 또는 부모 메모에 속한 메모들만을 필터링하여 표시할 수 있도록 합니다.

활용 방법

Manage.razor 페이지는 관리자 및 사용자가 시스템에 저장된 메모들을 효과적으로 관리할 수 있도록 지원합니다. 이 페이지는 메모의 생성, 조회, 수정, 삭제 등의 기능을 제공하며, 사용자는 이를 통해 메모 정보를 쉽게 관리할 수 있습니다. 또한, 검색 및 정렬 기능을 통해 원하는 메모를 빠르게 찾고, 페이징 처리를 통해 대량의 데이터를 효율적으로 탐색할 수 있습니다.

코드: Hawaso\Pages\Memos\Manage.razor

@page "/Memos"
@page "/Memos/Manage"

@namespace VisualAcademy.Pages.Memos

@attribute [Authorize(Roles = "Administrators")]

<PageTitle>게시판 리스트 | Hawaso</PageTitle>

<h3>Memo Manage <a href="/Memos/Create"><span class="oi oi-plus align-baseline"></span></a>&nbsp; 
    <span class="oi oi-plus text-primary align-baseline" @onclick="ShowEditorForm" style="cursor: pointer;"></span></h3>

<div class="row mb-1">
    <div class="col-md-12">
        <AuthorizeView>
            <Authorized>
            </Authorized>
            <NotAuthorized>
                <a href="/Memos/Create" class="btn btn-sm btn-primary">Create</a>
                <a href="/Memos/Index" class="btn btn-sm btn-primary">List</a>
                <input type="button" name="btnCreate" value="Create with Modal" class="btn btn-sm btn-primary" @onclick="ShowEditorForm" />
            </NotAuthorized>
        </AuthorizeView>
        <AuthorizeView Roles="Administrators, Managers">
            <a href="/Memos/Create" class="btn btn-sm btn-primary">Create</a>
            <input type="button" name="btnCreate" value="Create with Modal" class="btn btn-sm btn-primary" @onclick="ShowEditorForm" />
            <a href="/Memos/Index" class="btn btn-sm btn-primary">List</a>
            <input type="button" class="btn btn-sm btn-primary" value="Excel Export With Web API" @onclick="DownloadExcelWithWebApi" />
            <input type="button" class="btn btn-sm btn-primary" value="Excel Export" @onclick="DownloadExcel" />
            <button onclick="location.href = '/MemoDownload/ExcelDown';" class="btn btn-sm btn-primary">Excel Export</button>
        </AuthorizeView>
    </div>
</div>

<div class="row">
    <div class="col-md-12">
        @if (Models == null) // null 예외 처리 
        {
            <div>
                <p>
                    @*<text>Loading...</text>*@
                    <MatProgressBar Indeterminate="true"></MatProgressBar>
                </p>
            </div>
        }
        else
        {
            <div class="table-responsive">
                <table class="table table-bordered table-hover">
                    <colgroup>
                        <col style="width: 50px;" />
                        <col style="width: 300px;" />
                        <col style="width: 100px;" />
                        <col style="width: 100px;" />
                        <col style="width: 100px;" />
                        <col style="width: 250px;" />
                        <col style="width: auto;" />
                        <col style="width: 250px;" />
                    </colgroup>
                    <thead class="thead-light">
                        <tr>
                            <th class="text-center">ID</th>
                            <th class="text-center text-nowrap" @onclick="@(() => SortByTitle())" style="cursor: pointer;">Title <VisualAcademy.Pages.Memos.Components.SortOrderArrow SortColumn="Title" SortOrder="@sortOrder"></VisualAcademy.Pages.Memos.Components.SortOrderArrow></th>
                            <th class="text-center text-nowrap" @onclick="@(() => SortByName())">Name <VisualAcademy.Pages.Memos.Components.SortOrderArrow SortColumn="Name" SortOrder="@sortOrder"></VisualAcademy.Pages.Memos.Components.SortOrderArrow></th>
                            <th class="text-center text-nowrap" @onclick="@(() => SortByCreate())">Create <VisualAcademy.Pages.Memos.Components.SortOrderArrow SortColumn="Create" SortOrder="@sortOrder"></VisualAcademy.Pages.Memos.Components.SortOrderArrow></th>
                            <th class="text-center text-nowrap">Read</th>
                            <th class="text-center text-nowrap">Actions</th>
                            <th class="text-center text-nowrap">&nbsp;</th>
                            <th class="text-center text-nowrap">Admin</th>
                        </tr>
                    </thead>
                    @if (Models.Count == 0)
                    {
                        <tbody>
                            <tr>
                                <td colspan="8" class="text-center">
                                    <p>No Data.</p>
                                </td>
                            </tr>
                        </tbody>
                    }
                    <tbody>
                        @foreach (var m in Models)
                        {
                            <tr>
                                <td class="text-center" style="font-size: xx-small;">
                                    @if (m.Step == 0)
                                    {
                                        @m.Id
                                    }
                                    else
                                    {
                                        <text>&nbsp;</text>
                                    }
                                </td>
                                <td style="cursor: pointer;" @onclick="@(_ => NameClick(m.Id))">
                                    <span class="btn-link text-nowrap">@m.Title</span>
                                </td>
                                <td>
                                    @if (m.Name != null)
                                    {
                                        @(Dul.StringLibrary.CutStringUnicode(m.Name, 17))
                                    }
                                    else
                                    {
                                        <text>(Unknown)</text>
                                    }
                                </td>
                                <td class="text-center text-nowrap small">
                                    @(Dul.DateTimeUtility.ShowTimeOrDate(m.Created))
                                </td>
                                <td class="text-center small">
                                    @m.ReadCount
                                </td>
                                <td class="small text-center">
                                    <a href="/Memos/Details/@m.Id" class="btn btn-sm btn-light">Details</a>
                                    <a href="/Memos/Edit/@m.Id" class="btn btn-sm btn-light">Edit</a>
                                    <a href="/Memos/Delete/@m.Id" class="btn btn-sm btn-light">Del</a>
                                </td>
                                <td>&nbsp;</td>
                                <td class="text-center">
                                    <input type="button" name="btnEdit" value="Edit" class="btn btn-sm btn-primary" @onclick="(() => EditBy(m))" />
                                    <input type="button" name="btnDelete" value="Del" class="btn btn-sm btn-danger" @onclick="(() => DeleteBy(m))" />
                                    <input type="button" name="btnToggle" value="Toggle" class="btn btn-sm btn-primary" @onclick="(() => ToggleBy(m))" />
                                    @if (m.FileSize > 0)
                                    {
                                        <input type="button" name="btnDownload" value="Dn" class="btn btn-sm btn-primary" @onclick="(() => DownloadBy(m))" />
                                        <a href="/MemoDownload/FileDown/@m.Id" class="btn btn-sm btn-primary" download="@m.FileName">Dn</a>
                                        <button onclick="location.href = '/MemoDownload/FileDown/@m.Id';" class="btn btn-sm btn-primary">Dn</button>
                                    }
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        }
    </div>
    <div class="col-md-12">
        <DulPager.DulPagerComponent Model="pager" PageIndexChanged="PageIndexChanged"></DulPager.DulPagerComponent>
    </div>
    <div class="col-md-12">
        <VisualAcademy.Pages.Memos.Components.SearchBox placeholder="Search Memos..." SearchQueryChanged="Search"></VisualAcademy.Pages.Memos.Components.SearchBox>
    </div>
</div>

@*입력 또는 수정 폼 모달 다이얼로그*@
<VisualAcademy.Pages.Memos.Components.ModalForm @ref="EditorFormReference" ModelSender="Model" CreateCallback="CreateOrEdit" EditCallback="CreateOrEdit">
    <EditorFormTitle>@EditorFormTitle</EditorFormTitle>
</VisualAcademy.Pages.Memos.Components.ModalForm>

@*삭제 폼 모달 다이얼로그*@
<VisualAcademy.Pages.Memos.Components.DeleteDialog @ref="DeleteDialogReference" OnClickCallback="DeleteClick">
</VisualAcademy.Pages.Memos.Components.DeleteDialog>

@if (IsInlineDialogShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">Toggle</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close" @onclick="ToggleClose">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <p>Do you want to toggle post #@(Model.Id)?</p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @onclick="ToggleClick">Toggle</button>
                    <button type="button" class="btn btn-secondary" @onclick="ToggleClose">Close</button>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop show"></div>
}

Manage.razor.cs

C:\dev\Hawaso\src\Hawaso\Pages\Memos\Manage.razor.cs

using BlazorUtils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using System.Drawing;

namespace VisualAcademy.Pages.Memos;

public partial class Manage
{
    #region Parameters
    // 정수 형식의 Id 값을 받는 공통 속성 이름
    [Parameter]
    public int ParentId { get; set; } = 0;

    // 문자열 형식의 Id 값을 받는 공통 속성 이름 
    [Parameter]
    public string ParentKey { get; set; } = "";
    #endregion

    #region Injectors
    // NavigationManager를 참조해서 사용하기에 Injector 접미사를 붙임 또는 _(언더스코어) 접두사 붙임 
    // public NavigationManager NavigationManager { get; set; } 형태가 기본 사용 방식임
    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; }

    [Inject]
    public IJSRuntime JSRuntimeInjector { get; set; }

    // Repository 참조는 Reference 접미사를 붙여봄 
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public IMemoFileStorageManager FileStorageManagerReference { get; set; }
    #endregion

    #region Properties
    /// <summary>
    /// 글쓰기 또는 수정하기 폼의 제목에 전달할 문자열(태그 포함 가능)
    /// </summary>
    public string EditorFormTitle { get; set; } = "CREATE";
    #endregion

    /// <summary>
    /// EditorForm에 대한 참조: 모달로 글쓰기 또는 수정하기
    /// </summary>
    //public Components.EditorForm EditorFormReference { get; set; }
    public Components.ModalForm EditorFormReference { get; set; }

    /// <summary>
    /// DeleteDialog에 대한 참조: 모달로 항목 삭제하기 
    /// </summary>
    public Components.DeleteDialog DeleteDialogReference { get; set; }

    /// <summary>
    /// 현재 페이지에서 리스트로 사용되는 모델 리스트 
    /// </summary>
    //protected List<Memo> models = new List<Memo>();
    //protected List<Memo> Models = new();
    public List<Memo> Models { get; set; } = new();

    /// <summary>
    /// 현재 페이지에서 선택된 단일 데이터를 나타내는 모델 클래스 
    /// </summary>
    //protected Memo model = new Memo();
    public Memo Model { get; set; } = new();

    /// <summary>
    /// 페이저 설정
    /// </summary>
    protected DulPager.DulPagerBase pager = new()
    {
        PageNumber = 1,
        PageIndex = 0,
        PageSize = 10,
        PagerButtonCount = 5
    };

    #region Search
    private string searchQuery = "";

    protected async void Search(string query)
    {
        pager.PageIndex = 0; // Pager 컴포넌트 인덱스 초기화 후 검색

        this.searchQuery = query;

        await DisplayData();
    }
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync()
    {
        // Razor Page 또는 MVC에서 인증 정보를 Blazor Server로 전송하여 Blazor에서는 따로 인증된 사용자 정보를 읽어오지 않도록 설정
        if (UserId == "" && UserName == "")
        {
            await GetUserIdAndUserName();
        }

        await DisplayData();
    }
    #endregion

    private async Task DisplayData()
    {
        // ParentKey와 ParentId를 사용하는 목적은 특정 부모의 Details 페이지에서 리스트로 표현하기 위함
        if (ParentKey != "")
        {
            var articleSet = await RepositoryReference.GetArticlesAsync<string>(pager.PageIndex, pager.PageSize, searchField: "", this.searchQuery, this.sortOrder, ParentKey);
            pager.RecordCount = articleSet.TotalCount;
            Models = articleSet.Items.ToList();
        }
        else if (ParentId != 0)
        {
            var articleSet = await RepositoryReference.GetArticlesAsync<int>(pager.PageIndex, pager.PageSize, searchField: "", this.searchQuery, this.sortOrder, ParentId);
            pager.RecordCount = articleSet.TotalCount;
            Models = articleSet.Items.ToList();
        }
        else
        {
            var articleSet = await RepositoryReference.GetArticlesAsync<int>(pager.PageIndex, pager.PageSize, searchField: "", this.searchQuery, this.sortOrder, parentIdentifier: 0);
            pager.RecordCount = articleSet.TotalCount;
            Models = articleSet.Items.ToList();
        }

        StateHasChanged(); // Refresh
    }

    /// <summary>
    /// 상세보기로 이동하는 링크
    /// </summary>
    protected void NameClick(long id) => NavigationManagerInjector.NavigateTo($"/Memos/Details/{id}");

    /// <summary>
    /// Pager 링크 버튼 클릭에 따른 리스트 내용 업데이트
    /// </summary>
    /// <param name="pageIndex">페이지 인덱스</param>
    protected async void PageIndexChanged(int pageIndex)
    {
        pager.PageIndex = pageIndex;
        pager.PageNumber = pageIndex + 1; // 하위 호환성때문에 이 코드 유지 

        await DisplayData();

        StateHasChanged();
    }

    #region Event Handlers
    /// <summary>
    /// 글쓰기 모달 폼 띄우기 
    /// </summary>
    protected void ShowEditorForm()
    {
        EditorFormTitle = "CREATE";
        Model = new Memo(); // 모델 초기화
        this.Model.ParentId = Model.ParentId;
        this.Model.ParentKey = Model.ParentKey;

        Model.Name = UserName; // 로그인 사용자 이름을 기본으로 제공

        EditorFormReference.Show();
    }

    /// <summary>
    /// 관리자 전용: 모달 폼으로 선택 항목 수정
    /// </summary>
    protected void EditBy(Memo model)
    {
        EditorFormTitle = "EDIT";
        this.Model = new Memo(); // 모델 초기화
        this.Model = model;
        //this.model.ParentId = ParentId;
        this.Model.ParentId = model.ParentId;
        //this.model.ParentKey = ParentKey; 
        this.Model.ParentKey = model.ParentKey;
        EditorFormReference.Show();
    }

    /// <summary>
    /// 관리자 전용: 모달 폼으로 선택 항목 삭제
    /// </summary>
    protected void DeleteBy(Memo model)
    {
        this.Model = model;
        DeleteDialogReference.Show();
    }
    #endregion

    protected async void DownloadBy(Memo model)
    {
        if (!string.IsNullOrEmpty(model.FileName))
        {
            byte[] fileBytes = await FileStorageManagerReference.DownloadAsync(model.FileName, "Memos");
            if (fileBytes != null)
            {
                model.DownCount = model.DownCount + 1;
                await RepositoryReference.EditAsync(model);

                await FileUtil.SaveAs(JSRuntimeInjector, model.FileName, fileBytes);
            }
        }
    }

    /// <summary>
    /// 모델 초기화 및 모달 폼 닫기
    /// </summary>
    protected async void CreateOrEdit()
    {
        EditorFormReference.Hide();
        this.Model = new Memo();
        await DisplayData();
    }

    /// <summary>
    /// 삭제 모달 폼에서 현재 선택한 항목 삭제
    /// </summary>
    protected async void DeleteClick()
    {
        if (!string.IsNullOrEmpty(Model?.FileName))
        {
            // 첨부 파일 삭제 
            await FileStorageManagerReference.DeleteAsync(Model.FileName, "Memos");
        }

        await RepositoryReference.DeleteAsync(this.Model.Id);
        DeleteDialogReference.Hide();
        this.Model = new Memo(); // 선택했던 모델 초기화
        await DisplayData(); // 다시 로드
    }

    #region Toggle with Inline Dialog
    /// <summary>
    /// 인라인 폼을 띄울건지 여부 
    /// </summary>
    public bool IsInlineDialogShow { get; set; } = false;

    protected void ToggleClose()
    {
        IsInlineDialogShow = false;
        this.Model = new Memo();
    }

    /// <summary>
    /// 토글: Pinned
    /// </summary>
    protected async void ToggleClick()
    {
        this.Model.IsPinned = (this.Model?.IsPinned == true) ? false : true;

        // 변경된 내용 업데이트
        await RepositoryReference.UpdateAsync(this.Model);

        IsInlineDialogShow = false; // 표시 속성 초기화
        this.Model = new Memo(); // 선택한 모델 초기화 

        await DisplayData(); // 다시 로드
    }

    /// <summary>
    /// ToggleBy(PinnedBy)
    /// </summary>
    protected void ToggleBy(Memo model)
    {
        Model = model;
        IsInlineDialogShow = true;
    }
    #endregion

    #region Excel
    protected void DownloadExcelWithWebApi()
    {
        FileUtil.SaveAsExcel(JSRuntimeInjector, "/MemoDownload/ExcelDown");

        NavigationManagerInjector.NavigateTo($"/Memos"); // 다운로드 후 현재 페이지 다시 로드
    }

    protected void DownloadExcel()
    {
        using (var package = new ExcelPackage())
        {
            var worksheet = package.Workbook.Worksheets.Add("Memos");

            var tableBody = worksheet.Cells["A1:A1"].LoadFromCollection(
                (from m in Models select new { m.Created, m.Name, m.Title, m.DownCount, m.FileName })
                , true);

            var uploadCol = tableBody.Offset(1, 1, Models.Count, 1);

            // 그라데이션 효과 부여
            var rule = uploadCol.ConditionalFormatting.AddThreeColorScale();
            rule.LowValue.Color = Color.SkyBlue;
            rule.MiddleValue.Color = Color.White;
            rule.HighValue.Color = Color.Red;

            var header = worksheet.Cells["A1:E1"];
            worksheet.DefaultColWidth = 25;
            worksheet.Cells[2, 1, Models.Count + 1, 1].Style.Numberformat.Format = "yyyy MMM d DDD";
            tableBody.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
            tableBody.Style.Fill.PatternType = ExcelFillStyle.Solid;
            tableBody.Style.Fill.BackgroundColor.SetColor(Color.WhiteSmoke);
            tableBody.Style.Border.BorderAround(ExcelBorderStyle.Medium);
            header.Style.Font.Bold = true;
            header.Style.Font.Color.SetColor(Color.White);
            header.Style.Fill.BackgroundColor.SetColor(Color.DarkBlue);

            FileUtil.SaveAs(JSRuntimeInjector, $"{DateTime.Now.ToString("yyyyMMddhhmmss")}_Memos.xlsx", package.GetAsByteArray());
        }
    }
    #endregion

    #region Sorting
    // 이 코드 부분은 Grid 컴포넌트에서 컬럼 정렬을 수행하기 위한 로직을 담고 있습니다.
    // .NET 7의 QuickGrid에서 사용하는 방식처럼 더 향상된 방식이 있지만,
    // 이 코드는 처음 Sorting 기능을 적용할 때 사용한 기본적인 구분 방식을 사용하고 있습니다.
    // sortOrder 변수는 현재 어떤 컬럼이 어떤 방식(오름차순, 내림차순)으로 정렬되어 있는지를 나타냅니다.
    private string sortOrder = "";

    // 아래의 각 메서드는 특정 컬럼에 대해 정렬을 수행합니다.
    // 각 메서드는 현재의 sortOrder 값을 검사하고, 이에 따라 적절하게 정렬 순서를 변경합니다.
    // 메서드가 호출될 때마다, sortOrder 값이 업데이트되며,
    // 이 값은 비동기적으로 데이터를 화면에 표시하는 DisplayData 메서드에 의해 사용됩니다.

    // 이 메서드는 "Create" 컬럼에 대한 정렬을 수행합니다.
    protected async void SortByCreate()
    {
        // sortOrder 값이 "Create"를 포함하고 있지 않으면, sortOrder를 초기화합니다.
        if (!sortOrder.Contains("Create"))
        {
            sortOrder = "";
        }

        // sortOrder 값이 비어있으면, sortOrder를 "Create"로 설정하여 오름차순 정렬하도록 합니다.
        if (sortOrder == "")
        {
            sortOrder = "Create";
        }
        // sortOrder 값이 "Create"이면, sortOrder를 "CreateDesc"로 설정하여 내림차순 정렬하도록 합니다.
        else if (sortOrder == "Create")
        {
            sortOrder = "CreateDesc";
        }
        // 그 외의 경우는 sortOrder를 초기화합니다.
        else
        {
            sortOrder = "";
        }

        // 데이터를 새로 고침하여 변경된 정렬 순서를 반영합니다.
        await DisplayData();
    }

    // 이 메서드는 "Name" 컬럼에 대한 정렬을 수행합니다.
    protected async void SortByName()
    {
        if (!sortOrder.Contains("Name"))
        {
            sortOrder = "";
        }

        if (sortOrder == "")
        {
            sortOrder = "Name";
        }
        else if (sortOrder == "Name")
        {
            sortOrder = "NameDesc";
        }
        else
        {
            sortOrder = "";
        }

        await DisplayData();
    }

    // 이 메서드는 "Title" 컬럼에 대한 정렬을 수행합니다.
    protected async void SortByTitle()
    {
        if (!sortOrder.Contains("Title"))
        {
            sortOrder = "";
        }

        if (sortOrder == "")
        {
            sortOrder = "Title";
        }
        else if (sortOrder == "Title")
        {
            sortOrder = "TitleDesc";
        }
        else
        {
            sortOrder = "";
        }

        await DisplayData();
    }
    #endregion

    #region Get UserId and UserName: Blazor에서 현재 로그인 사용자 이름 획득하기
    [Parameter]
    public string UserId { get; set; } = "";

    [Parameter]
    public string UserName { get; set; } = "";

    [Inject] public UserManager<ApplicationUser> UserManagerRef { get; set; }

    [Inject] public AuthenticationStateProvider AuthenticationStateProviderRef { get; set; }

    private async Task GetUserIdAndUserName()
    {
        var authState = await AuthenticationStateProviderRef.GetAuthenticationStateAsync();
        var user = authState.User;

        if (user.Identity.IsAuthenticated)
        {
            var currentUser = await UserManagerRef.GetUserAsync(user);
            UserId = currentUser.Id;
            UserName = user.Identity.Name;
        }
        else
        {
            UserId = "";
            UserName = "Anonymous";
        }
    }
    #endregion
}

단순 출력 컴포넌트

Index.razor 페이지는 Memos 애플리케이션에서 사용자가 생성한 메모의 리스트를 보여주며, 각 메모에 대한 상세보기, 편집, 삭제 등의 작업을 할 수 있는 인터페이스를 제공합니다. 이 페이지는 데이터의 효율적인 조회, 정렬 및 검색 기능을 통해 사용자에게 편리한 데이터 관리 경험을 제공합니다.

주요 기능 및 구성 요소

  • 리스트 표시: 데이터베이스에서 조회한 메모 데이터를 테이블 형태로 리스트업합니다.
  • 페이징 처리: DulPager.DulPagerComponent를 활용하여 데이터 리스트의 페이징 처리를 지원합니다.
  • 검색 기능: 사용자가 입력한 검색 쿼리를 바탕으로 메모를 검색할 수 있는 기능을 제공합니다.
  • 정렬 기능: 리스트의 헤더를 클릭하여 특정 컬럼 기준으로 데이터 정렬 기능을 수행합니다.
  • 데이터 관리 액션: 각 메모에 대해 상세보기, 편집, 삭제 등의 액션을 수행할 수 있는 링크 또는 버튼을 제공합니다.

구현 방식

  • 데이터 로딩 및 표시: OnInitializedAsync 생명주기 메서드에서 DisplayData 메서드를 호출하여 초기 데이터 로딩을 수행하고, 로드된 데이터를 화면에 표시합니다.
  • 검색 및 정렬: 사용자의 입력(검색어, 정렬 요청)에 따라 DisplayData 메서드를 재호출하여 데이터를 새롭게 조회하고 화면에 반영합니다.

Injectors 및 Parameters

  • [Inject] 속성: IMemoRepository 서비스를 주입받아 데이터 조회, NavigationManager를 통해 페이지 이동을 관리합니다.
  • protected List<Memo> models: 화면에 표시될 메모 데이터 리스트를 관리합니다.
  • protected DulPager.DulPagerBase pager: 페이징 처리를 위한 설정 정보를 관리합니다.

활용 방법

Index.razor 페이지는 사용자가 메모 데이터를 쉽게 조회하고 관리할 수 있게 해주는 사용자 친화적인 인터페이스를 제공합니다. 검색 및 정렬 기능을 통해 사용자는 원하는 데이터를 빠르게 찾아볼 수 있으며, 각 메모에 대한 상세보기, 편집, 삭제 등의 작업을 손쉽게 수행할 수 있습니다. 이 페이지는 데이터 관리 및 탐색에 있어 효과적인 사용자 경험을 제공하는 중요한 컴포넌트입니다.

코드: Hawaso\Pages\Memos\Index.razor

@page "/Memos/Index"

@namespace VisualAcademy.Pages.Memos

@*@attribute [Authorize(Roles = "Administrators")]*@

<PageTitle>게시판 리스트 | Hawaso</PageTitle>

<h3>Memo List <a href="/Memos/Create"><span class="oi oi-plus"></span></a></h3>

<div class="row">
    <div class="col-md-12">
        <a href="/Memos/Create" class="btn btn-primary">Create</a>
        <AuthorizeView>
            <Authorized>
            </Authorized>
            <NotAuthorized>
                <a href="/Memos/Manage" class="btn btn-primary">Manage</a>
                <a href="/Memos/Report" class="btn btn-primary">Report</a>
            </NotAuthorized>
        </AuthorizeView>
        <AuthorizeView Roles="Administrators, Managers">
            <a href="/Memos/Manage" class="btn btn-primary">Manage</a>
        </AuthorizeView>
    </div>
</div>

<div class="row">
    <div class="col-md-12">
        @if (models == null)
        {
            <div>
                <p>
                    <MatProgressBar Indeterminate="true"></MatProgressBar>
                </p>
            </div>
        }
        else
        {
            <div class="table-responsive">
                <table class="table table-bordered table-hover">
                    <thead class="thead-light">
                        <tr>
                            <th>ID</th>
                            <th @onclick="@(() => SortByName())" style="cursor: pointer;">Name <VisualAcademy.Pages.Memos.Components.SortOrderArrow SortColumn="Name" SortOrder="@sortOrder"></VisualAcademy.Pages.Memos.Components.SortOrderArrow></th>
                            <th @onclick="@(() => SortByTitle())" style="cursor: pointer;">Title <VisualAcademy.Pages.Memos.Components.SortOrderArrow SortColumn="Title" SortOrder="@sortOrder"></VisualAcademy.Pages.Memos.Components.SortOrderArrow></th>
                            <th>Created</th>
                            <th>Actions</th>
                        </tr>
                    </thead>
                    @if (models.Count == 0)
                    {
                        <tbody>
                            <tr>
                                <td colspan="5" class="text-center">
                                    <p>데이터가 없습니다.</p>
                                </td>
                            </tr>
                        </tbody>
                    }
                    else
                    {
                        <tbody>
                            @foreach (var m in models)
                            {
                                <tr>
                                    <td>@m.Id</td>
                                    <td @onclick="@(() => NameClick(m.Id))">
                                        <a href="/Memos/Details/@m.Id">
                                            @m.Name
                                        </a>
                                    </td>
                                    <td>@m.Title</td>
                                    <td>
                                        @if (@m.Created != null)
                                        { 
                                            @(Dul.DateTimeUtility.ShowTimeOrDate(m.Created))                                        
                                        }
                                    </td>
                                    <td>
                                        <a href="/Memos/Details/@m.Id" class="btn btn-light">Details</a>
                                        <a href="/Memos/Edit/@m.Id" class="btn btn-light">Edit</a>
                                        <a href="/Memos/Delete/@m.Id" class="btn btn-light">Delete</a>
                                    </td>
                                </tr>
                            }
                        </tbody>
                    }
                </table>
            </div>
        }
    </div>
    <div class="col-md-12">
        <DulPager.DulPagerComponent Model="pager" PageIndexChanged="PageIndexChanged"></DulPager.DulPagerComponent>
    </div>
    <div class="col-md-12">
        <VisualAcademy.Pages.Memos.Components.SearchBox placeholder="Search Memos..." SearchQueryChanged="Search"></VisualAcademy.Pages.Memos.Components.SearchBox>
    </div>
</div>

코드: Hawaso\Pages\Memos\Index.razor.cs

using Microsoft.AspNetCore.Components;

namespace VisualAcademy.Pages.Memos;

public partial class Index
{
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; }

    protected List<Memo> models;

    protected DulPager.DulPagerBase pager = new DulPager.DulPagerBase()
    {
        PageNumber = 1,
        PageIndex = 0,
        PageSize = 10,
        PagerButtonCount = 5
    };

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync() => await DisplayData();
    #endregion

    private async Task DisplayData()
    {
        var articleSet = await RepositoryReference.GetArticlesAsync<int>(pager.PageIndex, pager.PageSize, "", this.searchQuery, this.sortOrder, 0);
        pager.RecordCount = articleSet.TotalCount;
        models = articleSet.Items.ToList();

        StateHasChanged();
    }

    protected void NameClick(long id) => NavigationManagerInjector.NavigateTo($"/Memos/Details/{id}");

    protected async void PageIndexChanged(int pageIndex)
    {
        pager.PageIndex = pageIndex;
        pager.PageNumber = pageIndex + 1;

        await DisplayData();

        StateHasChanged();
    }

    #region Search
    private string searchQuery = "";

    protected async void Search(string query)
    {
        pager.PageIndex = 0;

        this.searchQuery = query;

        await DisplayData();

        StateHasChanged();
    }
    #endregion

    #region Sorting
    private string sortOrder = "";

    protected async void SortByName()
    {
        if (sortOrder == "")
        {
            sortOrder = "Name";
        }
        else if (sortOrder == "Name")
        {
            sortOrder = "NameDesc";
        }
        else
        {
            sortOrder = "";
        }

        await DisplayData();
    }

    protected async void SortByTitle()
    {
        if (sortOrder == "")
        {
            sortOrder = "Title";
        }
        else if (sortOrder == "Title")
        {
            sortOrder = "TitleDesc";
        }
        else
        {
            sortOrder = "";
        }

        await DisplayData();
    }
    #endregion
}

상세 보기 컴포넌트

Details.razor 컴포넌트는 Memos 애플리케이션에서 메모의 상세 정보를 보여주는 페이지입니다. 사용자는 이 페이지를 통해 메모의 세부 내용을 확인할 수 있으며, 해당 메모에 대한 답변, 편집, 삭제 등의 추가적인 작업을 수행할 수 있는 링크를 제공받습니다.

주요 기능 및 구성 요소

  • 메모 상세 정보 표시: 선택한 메모의 ID, 이름, 제목, 내용 등의 상세 정보를 사용자에게 보여줍니다.
  • 액션 링크 제공: 메모에 대한 답변, 편집, 삭제 작업을 위한 링크를 제공하여 사용자가 쉽게 해당 작업을 수행할 수 있도록 합니다.
  • 리스트 페이지로의 링크: 사용자가 메모 리스트 페이지로 쉽게 돌아갈 수 있도록 'List' 링크를 제공합니다.

구현 방식

  • 데이터 조회: OnInitializedAsync 생명주기 메서드에서는 IMemoRepository를 통해 현재 페이지 URL에 지정된 ID 값을 기반으로 메모 데이터를 조회합니다.
  • 데이터 바인딩: 조회된 메모 데이터는 Model 프로퍼티에 바인딩되어 페이지에 표시됩니다.
  • 내용 인코딩: 메모의 내용은 Dul.HtmlUtility.EncodeWithTabAndSpace 메서드를 통해 HTML로 안전하게 인코딩되며, @((MarkupString)Content)를 사용하여 렌더링됩니다.

Injectors 및 Parameters

  • [Inject] 속성: IMemoRepository 서비스를 주입받아 데이터베이스에서 메모 데이터를 조회합니다.
  • [Parameter] 속성: URL로부터 전달받은 메모의 ID 값을 페이지 컴포넌트에 전달합니다.

활용 방법

Details.razor 페이지는 메모의 상세 정보를 확인할 수 있는 중요한 인터페이스를 제공합니다. 사용자는 이 페이지를 통해 메모의 세부 내용을 자세히 볼 수 있으며, 필요한 경우 메모를 편집하거나 삭제할 수 있는 옵션에 쉽게 접근할 수 있습니다. 이 컴포넌트는 데이터의 상세 조회 및 관련 작업의 수행에 있어 사용자에게 효율적인 경험을 제공합니다.

코드: Hawaso\Pages\Memos\Details.razor

@page "/Memos/Details/{Id:int}"

@namespace VisualAcademy.Pages.Memos

@*@attribute [Authorize(Roles = "Administrators")]*@

<PageTitle>게시판 상세보기 | Hawaso</PageTitle>

<h3>Memo Details</h3>

<div class="row">
    <div class="col-md-12">
        <div class="form-group row">
            <label for="id" class="col-sm-2 col-form-label">ID: </label>
            <div class="col-sm-10">
                <input type="text" class="form-control-plaintext" id="id" value="@Model.Id" />
            </div>
        </div>
        <div class="form-group row">
            <label for="lblName" class="col-sm-2 col-form-label">Name: </label>
            <div class="col-sm-10">
                <input type="text" class="form-control-plaintext" id="lblName" value="@Model.Name" />
            </div>
        </div>
        <div class="form-group row">
            <label for="lblTitle" class="col-sm-2 col-form-label">Title: </label>
            <div class="col-sm-10">
                <input type="text" class="form-control-plaintext" id="lblTitle" value="@Model.Title" />
            </div>
        </div>
        <div class="form-group row">
            <div class="col-sm-12">
                @((MarkupString)Content)
            </div>
        </div>
        <div class="form-group">
            <a href="/Memos/Create/@Model.Id" class="btn btn-primary">Reply</a>
            <a href="/Memos/Edit/@Model.Id" class="btn btn-primary">Edit</a>
            <a href="/Memos/Delete/@Model.Id" class="btn btn-danger">Delete</a>
            <a href="/Memos" class="btn btn-secondary">List</a>
        </div>
    </div>
</div>

Details.razor.cs

코드: Hawaso\Pages\Memos\Details.razor.cs

using Microsoft.AspNetCore.Components;

namespace VisualAcademy.Pages.Memos;

public partial class Details
{
    #region Parameters
    [Parameter]
    public int Id { get; set; }
    #endregion

    #region Injectors
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }
    #endregion

    #region Properties
    // MVC에서 Controller에서 View로 Model 개체로 데이터 전송하는 것처럼, 코드 비하인드에서 컴포넌트로 모델 값 전송
    public Memo Model { get; set; } = new Memo();

    public string Content { get; set; } = "";
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync()
    {
        Model = await RepositoryReference.GetByIdAsync(Id);
        Content = Dul.HtmlUtility.EncodeWithTabAndSpace(Model.Content);
    }
    #endregion
}

수정 컴포넌트

수정 컴포넌트 소개

Edit.razor 컴포넌트는 Memos 애플리케이션에서 기존에 저장된 메모 정보를 수정할 수 있는 페이지를 제공합니다. 이 페이지를 통해 사용자는 메모의 내용을 업데이트하고, 필요한 경우 파일을 첨부하거나 기존 파일을 변경할 수 있습니다.

주요 기능 및 구성 요소

  • 메모 정보 수정: 사용자는 메모의 이름, 제목, 내용 등을 수정할 수 있습니다.
  • 부모(카테고리) 선택: 메모와 관련된 부모(카테고리)를 선택할 수 있는 드롭다운 리스트를 제공합니다.
  • 파일 첨부 기능: 새로운 파일을 업로드하거나 기존 파일을 변경할 수 있습니다.
  • 폼 제출: 수정된 정보를 데이터베이스에 저장하고, 메모 리스트 페이지로 리디렉션합니다.

구현 방식

  • 데이터 로드 및 바인딩: OnInitializedAsync 생명주기 메서드에서 IMemoRepository를 사용하여 URL에서 전달된 ID에 해당하는 메모 정보를 로드하고 Model에 바인딩합니다.
  • 파일 처리: BlazorInputFile 라이브러리를 활용하여 파일 업로드 이벤트를 처리하고, 선택된 파일 정보를 selectedFiles 배열에 저장합니다.
  • 데이터 저장 및 리디렉션: FormSubmit 이벤트 핸들러에서 수정된 메모 정보와 파일 데이터를 처리하고, 성공적으로 저장 후 메모 리스트 페이지로 리디렉션합니다.

Injectors 및 Parameters

  • [Inject] 속성: 데이터 저장 및 조회를 위한 IMemoRepository 서비스, 페이지 리디렉션을 위한 NavigationManager, 파일 관리를 위한 IMemoFileStorageManager 서비스를 주입받습니다.
  • [Parameter] 속성: URL로부터 전달받은 메모의 ID 값을 페이지 컴포넌트에 전달합니다.

활용 방법

Edit.razor 페이지는 기존 메모의 수정 작업을 위한 중요한 인터페이스를 제공합니다. 사용자는 이 페이지를 통해 메모의 세부 사항을 업데이트할 수 있으며, 필요에 따라 첨부 파일을 관리할 수 있습니다. 파일 업로드 및 삭제 로직을 포함하여, 사용자가 원활하게 데이터를 관리할 수 있도록 지원합니다.

소스 코드

코드: Hawaso\Pages\Memos\Edit.razor

@page "/Memos/Edit/{Id:int}"

@namespace VisualAcademy.Pages.Memos

@*@attribute [Authorize(Roles = "Administrators")]*@

<PageTitle>게시판 글 수정 | Hawaso</PageTitle>

<h3>Edit</h3>

<div class="row">
    <div class="col-md-8 offset-md-2">
        <EditForm Model="Model" OnValidSubmit="FormSubmit">
            <DataAnnotationsValidator></DataAnnotationsValidator>
            <ValidationSummary></ValidationSummary>
            <div class="form-group">
                <label for="id">Id: </label> @Model.Id
            </div>
            <div class="form-group">
                <label for="txtName">Name</label>
                <InputText id="txtName" class="form-control" placeholder="Enter Name" @bind-Value="@Model.Name"></InputText>
                <ValidationMessage For="@(() => Model.Name)" class="form-text text-muted"></ValidationMessage>
            </div>
            <div class="form-group">
                <label for="txtTitle">Title</label>
                <InputText id="txtTitle" class="form-control" placeholder="Enter Title" @bind-Value="@Model.Title"></InputText>
                <ValidationMessage For="@(() => Model.Title)" class="form-text text-muted"></ValidationMessage>
            </div>
            <div class="form-group">
                <label for="txtContent">Content</label>
                <InputTextArea id="txtContext" class="form-control" placeholder="Enter Content" rows="5" @bind-Value="@Model.Content"></InputTextArea>
                <ValidationMessage For="@(() => Model.Content)" class="form-text text-muted"></ValidationMessage>
            </div>
            <div class="form-group">
                <label for="lstCategory">Parent</label>
                <InputSelect @bind-Value="@ParentId" class="form-control" id="lstCategory">
                    <option value="">--Select Parent--</option>
                    @foreach (var p in parentIds)
                    {
                        <option value="@p">@p</option>
                    }
                </InputSelect>
                <ValidationMessage For="@(() => ParentId)" class="form-text text-muted"></ValidationMessage>
            </div>
            <div class="form-group">
                <label for="txtTitle">File</label>
                <BlazorInputFile.InputFile OnChange="HandleSelection">
                </BlazorInputFile.InputFile>
            </div>
            <div class="form-group">
                <button type="submit" class="btn btn-primary">Submit</button>
                <a href="/Memos" class="btn btn-secondary">List</a>
            </div>
        </EditForm>
    </div>
</div>

코드: Hawaso\Pages\Memos\Edit.razor.cs

using BlazorInputFile;
using Microsoft.AspNetCore.Components;

namespace VisualAcademy.Pages.Memos;

public partial class Edit
{
    #region Fields
    /// <summary>
    /// 첨부 파일 리스트 보관
    /// </summary>
    private IFileListEntry[] selectedFiles;

    /// <summary>
    /// 부모(카테고리) 리스트가 저장될 임시 변수
    /// </summary>
    protected int[] parentIds = { 1, 2, 3 };
    #endregion

    #region Parameters
    [Parameter]
    public int Id { get; set; }
    #endregion

    #region Injectors
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; }

    [Inject]
    public IMemoFileStorageManager FileStorageManagerInjector { get; set; }
    #endregion

    #region Properties
    //protected Memo Model = new Memo();
    public Memo Model { get; set; } = new Memo();

    public string ParentId { get; set; } = "";

    //protected string Content = "";
    public string Content { get; set; } = "";
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync()
    {
        Model = await RepositoryReference.GetByIdAsync(Id);
        Content = Dul.HtmlUtility.EncodeWithTabAndSpace(Model.Content);
        ParentId = Model.ParentId.ToString();
    }
    #endregion

    #region Event Handlers
    /// <summary>
    /// 수정 버튼 이벤트 처리기
    /// </summary>
    protected async void FormSubmit()
    {
        int.TryParse(ParentId, out int parentId);
        Model.ParentId = parentId;

        #region 파일 업로드 관련 추가 코드 영역
        if (selectedFiles != null && selectedFiles.Length > 0)
        {
            // 파일 업로드
            var file = selectedFiles.FirstOrDefault();
            int fileSize = 0;
            if (file != null)
            {
                string fileName = file.Name;
                fileSize = Convert.ToInt32(file.Size);

                // 첨부 파일 삭제 
                await FileStorageManagerInjector.DeleteAsync(Model.FileName, "Memos");

                // 다시 업로드
                fileName = await FileStorageManagerInjector.UploadAsync(file.Data, file.Name, "", true);

                Model.FileName = fileName;
                Model.FileSize = fileSize;
            }
        }
        #endregion

        await RepositoryReference.EditAsync(Model);
        NavigationManagerInjector.NavigateTo("/Memos");
    }

    /// <summary>
    /// 파일 선택 이벤트 처리기
    /// </summary>
    protected void HandleSelection(IFileListEntry[] files) => this.selectedFiles = files;
    #endregion
}

삭제 컴포넌트

삭제 컴포넌트 소개

Delete.razor 컴포넌트는 Memos 애플리케이션에서 사용자가 선택한 메모를 삭제할 수 있는 기능을 제공합니다. 이 페이지를 통해 사용자는 특정 메모의 상세 정보를 확인하고, 삭제를 확정지을 수 있습니다.

주요 기능 및 구성 요소

  • 메모 상세 정보 확인: 사용자가 삭제하고자 하는 메모의 ID, 이름, 제목 등의 상세 정보를 확인할 수 있습니다.
  • 삭제 확인: 사용자가 'Delete' 버튼을 클릭하면, 삭제를 다시 한 번 확인하는 알림이 표시됩니다.
  • 파일 첨부 삭제: 메모에 첨부된 파일이 있을 경우, 메모 삭제와 동시에 해당 파일도 삭제됩니다.

구현 방식

  • 데이터 로드 및 바인딩: OnInitializedAsync 생명주기 메서드에서 IMemoRepository를 사용하여 URL에서 전달된 ID에 해당하는 메모 정보를 로드하고 Model에 바인딩합니다.
  • 삭제 로직: DeleteClick 메서드에서는 JavaScript의 confirm 함수를 사용하여 사용자에게 삭제를 확인받습니다. 사용자가 삭제를 확정하면, IMemoRepository를 통해 메모를 삭제하고, IMemoFileStorageManager를 사용하여 관련 파일을 삭제합니다.
  • 페이지 리디렉션: 메모 삭제 후, 사용자는 메모 리스트 페이지로 자동으로 리디렉션됩니다.

Injectors 및 Parameters

  • [Inject] 속성: IJSRuntime, NavigationManager, IMemoRepository, IMemoFileStorageManager 서비스를 컴포넌트에 주입하여 사용합니다.
  • [Parameter] 속성: URL로부터 전달받은 메모의 ID 값을 페이지 컴포넌트에 전달합니다.

활용 방법

Delete.razor 페이지는 메모 삭제 작업을 위한 중요한 인터페이스를 제공합니다. 사용자는 이 페이지를 통해 메모의 상세 정보를 확인하고, 삭제 작업을 수행할 수 있습니다. 삭제 작업은 사용자의 최종 확인을 거쳐 진행되며, 관련된 파일도 함께 처리되어 데이터의 일관성을 유지합니다.

소스 코드

코드: Hawaso\Pages\Memos\Delete.razor

@page "/Memos/Delete/{Id:int}"

@namespace VisualAcademy.Pages.Memos

@*@attribute [Authorize(Roles = "Administrators")]*@

<PageTitle>게시판 글 삭제 | Hawaso</PageTitle>

<h3>Delete</h3>

<div class="row">
    <div class="col-md-12">
        <div class="form-group row">
            <label for="id" class="col-sm-2 col-form-label">Id: </label>
            <div class="col-sm-10">
                <input type="text" class="form-control-plaintext" id="id" value="@Model.Id" />
            </div>
        </div>
        <div class="form-group row">
            <label for="lblName" class="col-sm-2 col-form-label">Name: </label>
            <div class="col-sm-10">
                <input type="text" class="form-control-plaintext" id="lblName" value="@Model.Name" />
            </div>
        </div>
        <div class="form-group row">
            <label for="lblTitle" class="col-sm-2 col-form-label">Title: </label>
            <div class="col-sm-10">
                <input type="text" class="form-control-plaintext" id="lblTitle" value="@Model.Title" />
            </div>
        </div>
        <div class="form-group row">
            <div class="col-sm-12">
                @((MarkupString)Content)
            </div>
        </div>
        <div class="form-group">
            <a class="btn btn-danger" @onclick="DeleteClick">Delete</a>
            <a href="/Memos/Details/@Model.Id" class="btn btn-primary">Cancel</a>
            <a href="/Memos" class="btn btn-secondary">List</a>
        </div>
    </div>
</div>

코드: Hawaso\Pages\Memos\Delete.razor.cs

using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;

namespace VisualAcademy.Pages.Memos;

public partial class Delete
{
    #region Fields
    private const string MemoListPage = "/Memos";
    #endregion

    #region Parameters
    [Parameter]
    public int Id { get; set; }
    #endregion

    #region Injectors
    [Inject]
    public IJSRuntime JSRuntimeInjector { get; set; }

    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; }

    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public IMemoFileStorageManager FileStorageManagerReference { get; set; }
    #endregion

    #region Properties
    public Memo Model { get; set; } = new Memo();

    public string Content { get; set; } = "";
    #endregion

    #region Lifecycle Methods
    /// <summary>
    /// 페이지 초기화 이벤트 처리기
    /// </summary>
    protected override async Task OnInitializedAsync()
    {
        Model = await RepositoryReference.GetByIdAsync(Id);
        Content = Dul.HtmlUtility.EncodeWithTabAndSpace(Model.Content);
    }
    #endregion

    #region Event Handlers

    /// <summary>
    /// 삭제 버튼 클릭 이벤트 처리기
    /// </summary>
    protected async void DeleteClick()
    {
        bool isDelete = await JSRuntimeInjector.InvokeAsync<bool>("confirm", $"Are you sure you want to delete it?");

        if (isDelete)
        {
            if (!string.IsNullOrEmpty(Model?.FileName))
            {
                // 첨부 파일 삭제 
                await FileStorageManagerReference.DeleteAsync(Model.FileName, "");
            }

            await RepositoryReference.DeleteAsync(Id); // 삭제
            NavigationManagerInjector.NavigateTo(MemoListPage); // 리스트 페이지로 이동
        }
        else
        {
            await JSRuntimeInjector.InvokeAsync<object>("alert", "Canceled.");
        }
    }
    #endregion
}

기타

Blazor Server 6.0 인증 포함된 기본 템플릿 프로젝트에 Memos 이름의 완성형 게시판 소스 설치

엑셀 파일 업로드 및 미리보기 기능 구현 가이드

프로젝트: Memos 게시판

개요: 본 가이드에서는 Blazor 서버 사이드 애플리케이션에 엑셀 파일 업로드 및 미리보기 기능을 구현하는 방법을 설명합니다. 이 기능을 통해 사용자는 엑셀 파일을 업로드하고, 파일 내의 데이터를 웹 페이지에서 미리 볼 수 있습니다. 데이터는 Memos 게시판에 표시되며, 사용자가 제공한 엑셀 파일 형식에 따라 이름, 이메일, 제목, 내용, 작성자 정보가 포함됩니다.

필수 구성 파일

  1. 엑셀 템플릿 파일 위치: C:\dev\Hawaso\src\Hawaso\wwwroot\templates\Memos\MemosImport.xlsx
    • 내용 예시:
      Name    Email    Title    Content    CreatedBy
      홍길동  h@h.com  안녕      안녕하세요.  a@a.com
      백두산  b@b.com  방가      반갑습니다.  b@b.com
      

구현 파일

  • 파일 경로: C:\dev\Hawaso\src\Hawaso\Pages\Memos\Import.razor
@page "/Memos/Import"
@using Microsoft.AspNetCore.Components.Forms
@using System.IO
@using DocumentFormat.OpenXml.Packaging
@using DocumentFormat.OpenXml.Spreadsheet

<PageTitle>Excel 데이터 가져오기</PageTitle>

<h3>Excel 파일 업로드</h3>

<a href="/templates/Memos/MemosImport.xlsx" class="btn btn-secondary mb-3">Template Download</a><br />

<Microsoft.AspNetCore.Components.Forms.InputFile OnChange="HandleExcelUpload" accept=".xlsx" />

@if (memos != null && memos.Count > 0)
{
    <h4>데이터 미리보기</h4>
    <table class="table">
        <thead>
            <tr>
                <th>이름</th>
                <th>이메일</th>
                <th>제목</th>
                <th>내용</th>
                <th>작성자</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var memo in memos)
            {
                <tr>
                    <td>@memo.Name</td>
                    <td>@memo.Email</td>
                    <td>@memo.Title</td>
                    <td>@memo.Content</td>
                    <td>@memo.CreatedBy</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private List<Memo> memos = new List<Memo>();

    private async Task HandleExcelUpload(InputFileChangeEventArgs e)
    {
        var file = e.File;
        if (file != null)
        {
            MemoryStream memoryStream = new MemoryStream();
            await file.OpenReadStream(maxAllowedSize: 10485760).CopyToAsync(memoryStream);
            memoryStream.Position = 0; // 스트림의 위치를 처음으로 되돌림

            using var package = SpreadsheetDocument.Open(memoryStream, false);
            var workbookPart = package.WorkbookPart;
            var sheet = workbookPart.Workbook.Descendants<Sheet>().FirstOrDefault();
            var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id);
            var sheetData = worksheetPart.Worksheet.Elements<SheetData>().First();

            List<Memo> loadedMemos = new List<Memo>();

            // 엑셀 파일에서 첫 번째 행은 헤더로 가정, 데이터는 두 번째 행부터 시작
            foreach (Row row in sheetData.Descendants<Row>().Skip(1))
            {
                // 각 행에서 셀 데이터 읽기
                var cells = row.Descendants<Cell>().ToList();
                if (cells.Count >= 5) // 최소한 5개의 셀 데이터가 필요
                {
                    var name = ReadCellValue(workbookPart, cells[0]);
                    var email = ReadCellValue(workbookPart, cells[1]);
                    var title = ReadCellValue(workbookPart, cells[2]);
                    var content = ReadCellValue(workbookPart, cells[3]);
                    var createdBy = ReadCellValue(workbookPart, cells[4]);

                    loadedMemos.Add(new Memo
                        {
                            Name = name,
                            Email = email,
                            Title = title,
                            Content = content,
                            CreatedBy = createdBy,
                            Created = DateTime.UtcNow // 생성 날짜를 현재 시간으로 설정
                        });
                }
            }

            if (loadedMemos.Any())
            {
                memos = loadedMemos; // 로컬 상태에 업로드된 데이터 저장
                StateHasChanged(); // UI 갱신
            }
        }
    }

    // 셀 값 읽기 함수
    private string ReadCellValue(WorkbookPart workbookPart, Cell cell)
    {
        if (cell.DataType != null && cell.DataType.Value == CellValues.SharedString)
        {
            return workbookPart.SharedStringTablePart.SharedStringTable
                .ElementAt(int.Parse(cell.InnerText)).InnerText;
        }
        else
        {
            return cell.InnerText;
        }
    }
}

사용 방법

  1. 템플릿 다운로드: 사용자는 제공된 링크를 통해 엑셀 템플릿을 다운로드할 수 있습니다.
  2. 파일 업로드: 사용자는 .xlsx 파일을 선택하여 업로드합니다.
  3. 데이터 미리보기: 업로드된 파일의 데이터가 테이블 형태로 화면에 표시됩니다. 각 행은 하나의 Memo 객체에 해당하며, 이름, 이메일, 제목, 내용, 작성자 정보를 포함합니다.

이 가이드는 사용자가 Blazor 애플리케이션에서 엑셀 데이터를 쉽게 업로드하고 미리 볼 수 있도록 도와줍니다. 데이터는 서버에 저장되기 전에 미리 볼 수 있으며, 사용자는 데이터의 정확성을 확인할 수 있습니다.

최종 소스

C:\dev\Hawaso\src\Hawaso\Pages\Memos\Import.razor

@page "/Memos/Import"
@using Microsoft.AspNetCore.Components.Forms
@using System.IO
@using DocumentFormat.OpenXml.Packaging
@using DocumentFormat.OpenXml.Spreadsheet

<PageTitle>Excel 데이터 가져오기</PageTitle>

<h3>Excel 파일 업로드</h3>

<a href="/templates/Memos/MemosImport.xlsx" class="btn btn-secondary mb-3">Template Download</a><br />

<Microsoft.AspNetCore.Components.Forms.InputFile OnChange="HandleExcelUpload" accept=".xlsx" />

@if (memos != null && memos.Count > 0)
{
    <h4>데이터 미리보기</h4>
    <table class="table">
        <thead>
            <tr>
                <th>이름</th>
                <th>이메일</th>
                <th>제목</th>
                <th>내용</th>
                <th>작성자</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var memo in memos)
            {
                <tr>
                    <td>@memo.Name</td>
                    <td>@memo.Email</td>
                    <td>@memo.Title</td>
                    <td>@memo.Content</td>
                    <td>@memo.CreatedBy</td>
                </tr>
            }
        </tbody>
    </table>
}

<button class="btn btn-primary" @onclick="SaveMemos">Save All</button>

@code {
    [Inject]
    public IMemoRepository RepositoryReference { get; set; }

    [Inject]
    public NavigationManager NavigationManager { get; set; }

    private List<Memo> memos = new List<Memo>();

    private async Task HandleExcelUpload(InputFileChangeEventArgs e)
    {
        var file = e.File;
        if (file != null)
        {
            MemoryStream memoryStream = new MemoryStream();
            await file.OpenReadStream(maxAllowedSize: 10485760).CopyToAsync(memoryStream);
            memoryStream.Position = 0; // 스트림의 위치를 처음으로 되돌림

            using var package = SpreadsheetDocument.Open(memoryStream, false);
            var workbookPart = package.WorkbookPart;
            var sheet = workbookPart.Workbook.Descendants<Sheet>().FirstOrDefault();
            var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id);
            var sheetData = worksheetPart.Worksheet.Elements<SheetData>().First();

            List<Memo> loadedMemos = new List<Memo>();

            // 엑셀 파일에서 첫 번째 행은 헤더로 가정, 데이터는 두 번째 행부터 시작
            foreach (Row row in sheetData.Descendants<Row>().Skip(1))
            {
                // 각 행에서 셀 데이터 읽기
                var cells = row.Descendants<Cell>().ToList();
                if (cells.Count >= 5) // 최소한 5개의 셀 데이터가 필요
                {
                    var name = ReadCellValue(workbookPart, cells[0]);
                    var email = ReadCellValue(workbookPart, cells[1]);
                    var title = ReadCellValue(workbookPart, cells[2]);
                    var content = ReadCellValue(workbookPart, cells[3]);
                    var createdBy = ReadCellValue(workbookPart, cells[4]);

                    loadedMemos.Add(new Memo
                        {
                            Name = name,
                            Email = email,
                            Title = title,
                            Content = content,
                            CreatedBy = createdBy,
                            Created = DateTime.UtcNow // 생성 날짜를 현재 시간으로 설정
                        });
                }
            }

            if (loadedMemos.Any())
            {
                memos = loadedMemos; // 로컬 상태에 업로드된 데이터 저장
                StateHasChanged(); // UI 갱신
            }
        }
    }

    // 셀 값 읽기 함수
    private string ReadCellValue(WorkbookPart workbookPart, Cell cell)
    {
        if (cell.DataType != null && cell.DataType.Value == CellValues.SharedString)
        {
            return workbookPart.SharedStringTablePart.SharedStringTable
                .ElementAt(int.Parse(cell.InnerText)).InnerText;
        }
        else
        {
            return cell.InnerText;
        }
    }

    private async Task SaveMemos()
    {
        foreach (var memo in memos)
        {
            memo.Password = "";
            memo.FileName = "";
            memo.FileSize = 0;
            memo.PostDate = DateTime.Now;
            memo.ParentNum = 0;
            memo.AnswerNum = 0;
            memo.CommentCount = 0;
            memo.Encoding = "Text";
            memo.Step = 0;
            memo.RefOrder = 0;
            memo.PostIp = "127.0.0.1";
            memo.Password = "";
            memo.ReadCount = 0;
            memo.DownCount = 0;

            await RepositoryReference.AddAsync(memo);
        }
        NavigationManager.NavigateTo("/Memos");
    }
}

Excel 파일 업로드, 미리보기 및 저장 절차

  1. 리스트 페이지에서 Excel Upload 링크 버튼을 클릭합니다.

    1-excel-upload.png

  2. Template Download 버튼을 클릭하여 업로드할 Excel 파일의 기본 구조를 확인합니다.

    2-template-download.png

  3. 아래 이미지는 Excel 파일의 샘플 데이터 구조를 보여줍니다.

    3-excel-format.png

  4. 파일 선택 버튼을 클릭하고, Excel 파일을 첨부합니다. 파일이 첨부되면 내용이 미리보기에 로드됩니다.

    4-excel-upload-and-preview.png

  5. Save All 버튼을 클릭하면, 미리보기된 내용이 저장되어 리스트 페이지에 추가됩니다.

    5-uploaded-data.png

VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com