Azunt.ReasonManagement 모듈 구축 가이드

  • 52 minutes to read
// Azunt.ReasonManagement: Building a Reusable Reason CRUD Module with Blazor Server

Azunt.ReasonManagement 구축 가이드 목차


C:.
│  Azunt.ReasonManagement.csproj
│
├─01_Models
│      Reason.cs
│
├─02_Contracts
│      IReasonRepository.cs
│
├─03_Repositories
│  ├─AdoNet
│  │      ReasonRepositoryAdoNet.cs
│  │
│  ├─Dapper
│  │      ReasonRepositoryDapper.cs
│  │
│  └─EfCore
│          ReasonAppDbContext.cs
│          ReasonAppDbContextFactory.cs
│          ReasonRepository.cs
│
├─04_Extensions
│      ReasonServicesRegistrationExtensions.cs
│
├─05_Enhancers
│      ReasonsTableBuilder.cs

소개

Azunt.ReasonManagement 패키지는 C# 클래스 라이브러리를 사용하여 SQL Server 데이터베이스에 대해 CRUD 기능을 교과서처럼 구현한 코드 모음입니다.

이 패키지와 유사한 성격을 가지는 또 다른 패키지로는 파일 업로드 및 다운로드 기능을 염두에 둔 완성형 게시판 소스인 Memos 패키지가 있습니다. 이 내용은 Hawaso 프로젝트의 Memos 모듈을 참고하시기 바랍니다.


프로젝트 준비

Azunt.ReasonManagement를 활용하려면 다음과 같은 프로젝트 구성이 필요합니다.

  • Azunt.Web
    : ASP.NET Core MVC, Blazor Server, Razor Pages가 통합된 웹 프로젝트
  • Azunt.SqlServer
    : SQL Server 데이터베이스 스키마를 관리하는 데이터베이스 프로젝트
  • Azunt.ReasonManagement
    : .NET 8.0 이상을 기반으로 하는 클래스 라이브러리 프로젝트 (본 강의의 중심)

Azunt.ReasonManagement는 Entity Framework Core를 통한 데이터베이스 접근과 Blazor Server 컴포넌트를 통한 UI 구성을 별도로 모듈화하여, 다른 프로젝트에서도 손쉽게 재사용할 수 있도록 설계되었습니다.


웹 프로젝트 생성 및 기본 실행

Azunt.ReasonManagement를 적용하기 전에, 먼저 Azunt.Web 웹 프로젝트를 생성하고, 정상적으로 실행해 보는 과정을 진행합니다.

이를 통해 기본 환경 구성이 완료되었는지 확인하고, 이후 Reason 모듈을 적용할 준비를 합니다.

1. Visual Studio에서 Azunt.Web 프로젝트 생성

  1. Visual Studio 2022 이상을 실행합니다.
  2. **"Create a new project"**를 클릭합니다.
  3. "Blazor Web App" 템플릿을 검색하여 선택합니다.
  4. 프로젝트 이름을 Azunt.Web로 지정합니다.

설정 요약:

  • Framework: .NET 8.0 이상
  • Authentication Type: Individual Accounts (In-app 저장)
  • Blazor Type: Blazor Server
  • 기타 옵션: 필요에 따라 HTTPS, Docker 지원 여부 설정

2. 기본 실행 및 확인

  1. 프로젝트를 생성한 뒤, 별다른 수정 없이 F5 (또는 Ctrl+F5) 를 눌러 실행합니다.
  2. 기본 제공되는 Blazor Server 템플릿 화면이 정상적으로 뜨는지 확인합니다.
    • 로그인/회원가입 기능이 포함되어 있어야 합니다.

여기까지 완료되면 웹 기반 프로젝트 준비가 완료된 것입니다.


Azunt.ReasonManagement 적용 준비

Azunt.Web 기본 실행을 확인한 후, 이제 Azunt.ReasonManagement 모듈을 적용할 준비를 진행합니다.

1. 클래스 라이브러리 프로젝트 추가

  1. 솔루션에 새 프로젝트를 추가합니다.
  2. Class Library (.NET) 템플릿을 선택합니다.
  3. 프로젝트 이름을 Azunt.ReasonManagement로 지정합니다.
  4. .NET 8.0 이상을 대상 프레임워크로 설정합니다.

2. 프로젝트 참조 추가

  • Azunt.Web 프로젝트에서 Project Reference로 Azunt.ReasonManagement를 추가합니다.
  • NuGet 패키지 설치:
Install-Package Dul -Version 1.3.4
Install-Package Microsoft.EntityFrameworkCore -Version 9.0.4
Install-Package Microsoft.EntityFrameworkCore.SqlServer -Version 9.0.4
Install-Package System.Configuration.ConfigurationManager -Version 9.0.4
Install-Package EPPlus -Version 7.5.3

※ EPPlus는 엑셀 다운로드 기능을 위해 추가합니다. 이 패키지는 무료 버전을 기준으로 합니다.

3. SQL Server 테이블 준비

  • SQL Server Management Studio(SSMS) 또는 Database Project를 이용해 Reasons 테이블을 미리 생성해둡니다. 잠시 후에 진행합니다.

  • 테이블이 정상적으로 생성되었는지 확인합니다.

4. DI 및 서비스 등록 준비

  • Azunt.Web의 Program.cs 또는 Startup.cs 파일에 Reason 모듈을 등록할 준비를 합니다. 다음 코드는 이후에 적용할 것입니다. 뒤에서 진행할 예정입니다. 지금은 목록만 참고하세요.
using Azunt.ReasonManagement;

builder.Services.AddDependencyInjectionContainerForReasonApp(
    builder.Configuration.GetConnectionString("DefaultConnection"));

...

// 이용 사유 관리: 기본 CRUD 교과서 코드
builder.Services.AddDependencyInjectionContainerForReasonApp(connectionString,
    ReasonServicesRegistrationExtensions.RepositoryMode.EfCore);
builder.Services.AddTransient<ReasonAppDbContextFactory>();

이 준비 작업이 완료되면, 이제 본격적으로 모델, 리포지토리, Blazor 컴포넌트 개발을 진행할 수 있습니다.


NuGet 패키지 설치

Azunt.ReasonManagement 프로젝트에는 다음과 같은 NuGet 패키지가 필요합니다:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Dul" Version="1.3.4" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />
    <PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.4" />
    <PackageReference Include="EPPlus" Version="7.5.3" />
  </ItemGroup>

</Project>

Open Iconic 적용

ReasonManagement 모듈에서는 버튼이나 액션 링크 등에 간단한 아이콘을 사용하기 위해
Open Iconic 아이콘 세트를 적용하는 것을 권장합니다.

다음 방법 중 하나를 선택하여 Open Iconic을 프로젝트에 적용할 수 있습니다.


1. 로컬 적용 방법

Visual Studio 프로젝트의 /wwwroot/lib/open-iconic/ 경로에
Open Iconic 파일(open-iconic-bootstrap.min.css)을 추가합니다.

그리고 App.razor, _Host.cshtml, 또는 _Layout.cshtml<head> 영역에 다음 링크를 추가합니다:

<link href="/lib/open-iconic/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" />

2. CDN 적용 방법

별도로 파일을 다운로드하지 않고,
CDN(Content Delivery Network) 경로를 사용해 Open Iconic을 바로 연결할 수도 있습니다.

<head> 영역에 다음 링크를 추가합니다:

<link href="https://cdnjs.cloudflare.com/ajax/libs/open-iconic/1.1.1/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" integrity="sha512-JoVaaJJLNdaUn9P1UnIFVQGryuVKyXjH9MuNoMNFc13JUO2c+hJ1ytY1/6V2vNh+lX6YsJhBKt3vnDnN/SUXxw==" crossorigin="anonymous" referrerpolicy="no-referrer" />

이 방법은 별도의 다운로드 없이 빠르게 적용할 수 있으며, CDN 서버를 통해 최적화된 속도로 제공됩니다.


3. 적용 예시

Open Iconic을 적용한 전체 레이아웃 파일 예시는 다음과 같습니다:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    
    <link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
    <link rel="stylesheet" href="@Assets["app.css"]" />
    <link rel="stylesheet" href="@Assets["Azunt.Web.styles.css"]" />
    
    <!-- Open Iconic 적용 (로컬 또는 CDN 중 하나 선택) -->
    <link href="/lib/open-iconic/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" />
    <!-- 또는 -->
    <!--<link href="https://cdnjs.cloudflare.com/ajax/libs/open-iconic/1.1.1/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" integrity="..." crossorigin="anonymous" referrerpolicy="no-referrer" />-->

    <ImportMap />
    <link rel="icon" type="image/png" href="favicon.png" />
    <link href="_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css" rel="stylesheet" />
    <script src="_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.lib.module.js" type="module" async></script>
    <HeadOutlet />
</head>

<body>
    <Routes />
    <script src="_framework/blazor.web.js"></script>
</body>

</html>

주의:
로컬 적용과 CDN 적용 중 하나만 선택하여 연결합니다.
둘 다 동시에 연결하면 불필요한 리소스 로드가 발생할 수 있습니다.


4. 사용 예시

Open Iconic 아이콘은 다음과 같이 사용할 수 있습니다:

<button class="btn btn-primary">
    <span class="oi oi-plus"></span> Add New
</button>
  • oi oi-plus: 플러스 아이콘 표시
  • 다양한 아이콘 클래스는 Open Iconic 공식 문서를 참고하면 됩니다.

테이블 구조

이번 아티클에서 사용할 SQL 테이블 구조는 다음과 같습니다:

경로:

C:\Azunt.ReasonManagement\src\
    Azunt.ReasonManagement\
        Azunt.SqlServer\
            00_Reasons.sql

스크립트:

--[0][0] 이용 사유: Reasons 
CREATE TABLE [dbo].[Reasons]
(
    [Id]        BIGINT             IDENTITY (1, 1) NOT NULL PRIMARY KEY,    -- 이용 사유 고유 아이디, 자동 증가
    [Active]    BIT                DEFAULT ((1)) NOT NULL,                  -- 활성 상태 표시, 기본값 1 (활성)
    [CreatedAt] DATETIMEOFFSET (7) NOT NULL,                                -- 레코드 생성 시간
    [CreatedBy] NVARCHAR (255)     NULL,                                    -- 레코드 생성자 이름
    [Name]      NVARCHAR (MAX)     NULL                                     -- 이름
);

Reasons table creation and initial seeding

Azunt.ReasonManagement\05_Enhancers\ReasonsTableBuilder.cs

using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;

namespace Azunt.ReasonManagement;

public class ReasonsTableBuilder
{
    private readonly string _masterConnectionString;
    private readonly ILogger<ReasonsTableBuilder> _logger;

    public ReasonsTableBuilder(string masterConnectionString, ILogger<ReasonsTableBuilder> logger)
    {
        _masterConnectionString = masterConnectionString;
        _logger = logger;
    }

    public void BuildTenantDatabases()
    {
        var tenantConnectionStrings = GetTenantConnectionStrings();

        foreach (var connStr in tenantConnectionStrings)
        {
            try
            {
                EnsureReasonsTable(connStr);
                _logger.LogInformation($"Reasons table processed (tenant DB): {connStr}");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, $"[{connStr}] Error processing tenant DB");
            }
        }
    }

    public void BuildMasterDatabase()
    {
        try
        {
            EnsureReasonsTable(_masterConnectionString);
            _logger.LogInformation("Reasons table processed (master DB)");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing master DB");
        }
    }

    private List<string> GetTenantConnectionStrings()
    {
        var result = new List<string>();

        using (var connection = new SqlConnection(_masterConnectionString))
        {
            connection.Open();
            var cmd = new SqlCommand("SELECT ConnectionString FROM dbo.Tenants", connection);

            using (var reader = cmd.ExecuteReader())
            {
                while (reader.Read())
                {
                    var connectionString = reader["ConnectionString"]?.ToString();
                    if (!string.IsNullOrEmpty(connectionString))
                    {
                        result.Add(connectionString);
                    }
                }
            }
        }

        return result;
    }

    private void EnsureReasonsTable(string connectionString)
    {
        using (var connection = new SqlConnection(connectionString))
        {
            connection.Open();

            var cmdCheck = new SqlCommand(@"
                SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES 
                WHERE TABLE_NAME = 'Reasons'", connection);

            int tableCount = (int)cmdCheck.ExecuteScalar();

            if (tableCount == 0)
            {
                var cmdCreate = new SqlCommand(@"
                    CREATE TABLE [dbo].[Reasons] (
                        [Id] BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
                        [Active] BIT DEFAULT ((1)) NOT NULL,
                        [CreatedAt] DATETIMEOFFSET(7) NOT NULL,
                        [CreatedBy] NVARCHAR(255) NULL,
                        [Name] NVARCHAR(MAX) NULL
                    )", connection);

                cmdCreate.ExecuteNonQuery();

                _logger.LogInformation("Reasons table created.");
            }
            else
            {
                var expectedColumns = new Dictionary<string, string>
                {
                    ["Active"] = "BIT",
                    ["CreatedAt"] = "DATETIMEOFFSET(7)",
                    ["CreatedBy"] = "NVARCHAR(255)",
                    ["Name"] = "NVARCHAR(MAX)"
                };

                foreach (var kvp in expectedColumns)
                {
                    var columnName = kvp.Key;

                    var cmdColumnCheck = new SqlCommand(@"
                        SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS 
                        WHERE TABLE_NAME = 'Reasons' AND COLUMN_NAME = @ColumnName", connection);
                    cmdColumnCheck.Parameters.AddWithValue("@ColumnName", columnName);

                    int colExists = (int)cmdColumnCheck.ExecuteScalar();

                    if (colExists == 0)
                    {
                        var alterCmd = new SqlCommand(
                            $"ALTER TABLE [dbo].[Reasons] ADD [{columnName}] {kvp.Value} NULL", connection);
                        alterCmd.ExecuteNonQuery();

                        _logger.LogInformation($"Column added: {columnName} ({kvp.Value})");
                    }
                }
            }

            var cmdCountRows = new SqlCommand("SELECT COUNT(*) FROM [dbo].[Reasons]", connection);
            int rowCount = (int)cmdCountRows.ExecuteScalar();

            if (rowCount == 0)
            {
                var cmdInsertDefaults = new SqlCommand(@"
                    INSERT INTO [dbo].[Reasons] (Active, CreatedAt, CreatedBy, Name)
                    VALUES
                        (1, SYSDATETIMEOFFSET(), 'System', 'Initial Reason 1'),
                        (1, SYSDATETIMEOFFSET(), 'System', 'Initial Reason 2')", connection);

                int inserted = cmdInsertDefaults.ExecuteNonQuery();
                _logger.LogInformation($"Reasons 기본 데이터 {inserted}건 삽입 완료");
            }
        }
    }

    public static void Run(IServiceProvider services, bool forMaster)
    {
        try
        {
            var logger = services.GetRequiredService<ILogger<ReasonsTableBuilder>>();
            var config = services.GetRequiredService<IConfiguration>();
            var masterConnectionString = config.GetConnectionString("DefaultConnection");

            if (string.IsNullOrEmpty(masterConnectionString))
            {
                throw new InvalidOperationException("DefaultConnection is not configured in appsettings.json.");
            }

            var builder = new ReasonsTableBuilder(masterConnectionString, logger);

            if (forMaster)
            {
                builder.BuildMasterDatabase();
            }
            else
            {
                builder.BuildTenantDatabases();
            }
        }
        catch (Exception ex)
        {
            var fallbackLogger = services.GetService<ILogger<ReasonsTableBuilder>>();
            fallbackLogger?.LogError(ex, "Error while processing Reasons table.");
        }
    }
}

모델 클래스

다음 코드는 이용 사유 정보를 나타내는 모델 클래스입니다. Reason, ReasonDto 등 원하는 형태의 모델명을 사용하세요.

코드: 01_Reason.cs

Azunt.ReasonManagement\01_Models\Reason.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Azunt.ReasonManagement
{
    /// <summary>
    /// 테이블과 일대일로 매핑되는 모델 클래스: Reason, ReasonObject, ...
    /// </summary>
    [Table("Reasons")]
    public class Reason
    {
        /// <summary>
        /// 이용 사유 고유 아이디, 자동 증가
        /// </summary>
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public long Id { get; set; }

        /// <summary>
        /// 활성 상태 표시, 기본값 true (활성)
        /// </summary>
        public bool? Active { get; set; }

        /// <summary>
        /// 레코드 생성 시간
        /// </summary>
        public DateTimeOffset CreatedAt { get; set; }

        /// <summary>
        /// 레코드 생성자 이름
        /// </summary>
        public string? CreatedBy { get; set; }

        /// <summary>
        /// 이용 사유
        /// </summary>
        public string? Name { get; set; }
    }
}

공통 코드

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: 주어진 조건에 맞는 모델 인스턴스를 페이징 처리하여 반환합니다. 이 메서드들은 검색 필드, 검색 쿼리, 정렬 순서 및 부모 식별자를 파라미터로 받아 필터링된 결과를 제공합니다.

리포지토리 인터페이스

다음은 02_IReasonRepository.cs 파일의 내용입니다. 코드를 작성하기에 앞서 Dul 이름의 NuGet 패키지를 프로젝트에 참조 추가합니다.

Azunt.ReasonManagement\02_Contracts\IReasonRepository.cs
using Dul.Articles;

namespace Azunt.ReasonManagement;

public interface IReasonRepository
{
    Task<Reason> AddAsync(Reason model, string? connectionString = null);
    Task<List<Reason>> GetAllAsync(string? connectionString = null);
    Task<Reason> GetByIdAsync(long id, string? connectionString = null);
    Task<bool> UpdateAsync(Reason model, string? connectionString = null);
    Task<bool> DeleteAsync(long id, string? connectionString = null);
    Task<ArticleSet<Reason, int>> GetArticlesAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, string? connectionString = null);
    Task<ArticleSet<Reason, long>> GetByAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options, string? connectionString = null);
}

PagingResult.cs

다음 코드는 Dul 이름의 NuGet 패키지 안에 있는 PagingResult.cs 파일의 내용입니다.

using System.Collections.Generic;

namespace Dul.Domain.Common
{
    /// <summary>
    /// Paging 처리된 레코드셋과 전체 레코드 카운트를 반환하는 PagingResult 클래스
    /// </summary>
    /// <typeparam name="T">모델 클래스 형식</typeparam>
    public struct PagingResult<T>
    {
        /// <summary>
        /// 페이징 처리된 레코드셋
        /// </summary>
        public IEnumerable<T> Records { get; set; }

        /// <summary>
        /// 전체 레코드의 개수
        /// </summary>
        public int TotalRecords { get; set; }

        /// <summary>
        /// 페이징 처리된 레코드셋과 전체 레코드의 개수를 인자로 받아 초기화하는 생성자
        /// </summary>
        /// <param name="items">페이징 처리된 레코드셋</param>
        /// <param name="totalRecords">전체 레코드의 개수</param>
        public PagingResult(IEnumerable<T> items, int totalRecords)
        {
            Records = items;
            TotalRecords = totalRecords;
        }
    }
}

ICrudRepositoryBase.cs

다음 코드는 Dul 이름의 NuGet 패키지 안에 있는 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);
    }
}

DbContext 클래스

다음 코드 DbContext 클래스입니다.

Azunt.ReasonManagement\03_Repositories\EfCore\ReasonAppDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace Azunt.ReasonManagement
{
    /// <summary>
    /// ReasonApp에서 사용하는 데이터베이스 컨텍스트 클래스입니다.
    /// Entity Framework Core와 데이터베이스 간의 브리지 역할을 합니다.
    /// </summary>
    public class ReasonAppDbContext : DbContext
    {
        /// <summary>
        /// DbContextOptions을 인자로 받는 생성자입니다.
        /// 주로 Program.cs 또는 Startup.cs에서 서비스로 등록할 때 사용됩니다.
        /// </summary>
        public ReasonAppDbContext(DbContextOptions<ReasonAppDbContext> options)
            : base(options)
        {
            ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        /// <summary>
        /// 데이터베이스 모델을 설정하는 메서드입니다.
        /// </summary>
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Reasons 테이블의 CreatedAt 열은 기본값으로 현재 날짜/시간을 사용합니다.
            modelBuilder.Entity<Reason>()
                .Property(m => m.CreatedAt)
                .HasDefaultValueSql("GetDate()");
        }

        /// <summary>
        /// ReasonApp 관련 테이블을 정의합니다.
        /// </summary>
        public DbSet<Reason> Reasons { get; set; } = null!;
    }
}

ReasonAppDbContextFactory

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace Azunt.ReasonManagement;

/// <summary>
/// ReasonAppDbContext 인스턴스를 생성하는 Factory 클래스
/// </summary>
public class ReasonAppDbContextFactory
{
    private readonly IConfiguration? _configuration;

    /// <summary>
    /// 기본 생성자 (Configuration 없이 사용 가능)
    /// </summary>
    public ReasonAppDbContextFactory()
    {
    }

    /// <summary>
    /// IConfiguration을 주입받는 생성자
    /// </summary>
    public ReasonAppDbContextFactory(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    /// <summary>
    /// 연결 문자열을 사용하여 DbContext 인스턴스를 생성합니다.
    /// </summary>
    public ReasonAppDbContext CreateDbContext(string connectionString)
    {
        if (string.IsNullOrWhiteSpace(connectionString))
        {
            throw new ArgumentException("Connection string must not be null or empty.", nameof(connectionString));
        }

        var options = new DbContextOptionsBuilder<ReasonAppDbContext>()
            .UseSqlServer(connectionString)
            .Options;

        return new ReasonAppDbContext(options);
    }

    /// <summary>
    /// DbContextOptions를 사용하여 DbContext 인스턴스를 생성합니다.
    /// </summary>
    public ReasonAppDbContext CreateDbContext(DbContextOptions<ReasonAppDbContext> options)
    {
        ArgumentNullException.ThrowIfNull(options);

        return new ReasonAppDbContext(options);
    }

    /// <summary>
    /// appsettings.json의 "DefaultConnection"을 사용하여 DbContext 인스턴스를 생성합니다.
    /// </summary>
    public ReasonAppDbContext CreateDbContext()
    {
        if (_configuration == null)
        {
            throw new InvalidOperationException("Configuration is not provided.");
        }

        var defaultConnection = _configuration.GetConnectionString("DefaultConnection");

        if (string.IsNullOrWhiteSpace(defaultConnection))
        {
            throw new InvalidOperationException("DefaultConnection is not configured properly.");
        }

        return CreateDbContext(defaultConnection);
    }
}

리포지토리 클래스

다음은 ReasonRepository 클래스의 전체 내용입니다. 강의에서 단계별로 만들면서 사용한 메서드들이라서 단일 테이블에 대한 여러 가지 경우의 수를 넣다보니 코드가 많이 깁니다. 필요한 메서드들만 선택해서 가져다 사용해도 됩니다.

Azunt.ReasonManagement\03_Repositories\EfCore\ReasonRepository.cs
using Azunt.ReasonManagement;
using Dul.Articles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Azunt.ReasonManagement
{
    public class ReasonRepository : IReasonRepository
    {
        private readonly ReasonAppDbContextFactory _factory;
        private readonly ILogger<ReasonRepository> _logger;

        public ReasonRepository(
            ReasonAppDbContextFactory factory,
            ILoggerFactory loggerFactory)
        {
            _factory = factory;
            _logger = loggerFactory.CreateLogger<ReasonRepository>();
        }

        private ReasonAppDbContext CreateContext(string? connectionString)
        {
            return string.IsNullOrEmpty(connectionString)
                ? _factory.CreateDbContext()
                : _factory.CreateDbContext(connectionString);
        }

        public async Task<Reason> AddAsync(Reason model, string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);
            model.CreatedAt = DateTime.UtcNow;
            context.Reasons.Add(model);
            await context.SaveChangesAsync();
            return model;
        }

        public async Task<List<Reason>> GetAllAsync(string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);
            return await context.Reasons
                .OrderByDescending(m => m.Id)
                .ToListAsync();
        }

        public async Task<Reason> GetByIdAsync(long id, string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);
            return await context.Reasons
                       .SingleOrDefaultAsync(m => m.Id == id)
                   ?? new Reason();
        }

        public async Task<bool> UpdateAsync(Reason model, string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);
            context.Attach(model);
            context.Entry(model).State = EntityState.Modified;
            return await context.SaveChangesAsync() > 0;
        }

        public async Task<bool> DeleteAsync(long id, string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);
            var entity = await context.Reasons.FindAsync(id);
            if (entity == null) return false;
            context.Reasons.Remove(entity);
            return await context.SaveChangesAsync() > 0;
        }

        public async Task<ArticleSet<Reason, int>> GetArticlesAsync<TParentIdentifier>(
            int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);

            var query = context.Reasons.AsQueryable();

            if (!string.IsNullOrEmpty(searchQuery))
            {
                query = query.Where(m => m.Name!.Contains(searchQuery));
            }

            var totalCount = await query.CountAsync();
            var items = await query
                .OrderByDescending(m => m.Id)
                .Skip(pageIndex * pageSize)
                .Take(pageSize)
                .ToListAsync();

            return new ArticleSet<Reason, int>(items, totalCount);
        }

        public async Task<ArticleSet<Reason, long>> GetByAsync<TParentIdentifier>(
            FilterOptions<TParentIdentifier> options, string? connectionString = null)
        {
            await using var context = CreateContext(connectionString);

            var query = context.Reasons.AsQueryable();

            if (!string.IsNullOrEmpty(options.SearchQuery))
            {
                query = query.Where(m => m.Name!.Contains(options.SearchQuery));
            }

            var totalCount = await query.CountAsync();
            var items = await query
                .OrderByDescending(m => m.Id)
                .Skip(options.PageIndex * options.PageSize)
                .Take(options.PageSize)
                .ToListAsync();

            return new ArticleSet<Reason, long>(items, totalCount);
        }
    }
}

Azunt.ReasonManagement\03_Repositories\AdoNet\ReasonRepositoryAdoNet.cs

using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using System.Data;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Dul.Articles;

namespace Azunt.ReasonManagement;

public class ReasonRepositoryAdoNet : IReasonRepository
{
    private readonly string _defaultConnectionString;
    private readonly ILogger<ReasonRepositoryAdoNet> _logger;

    public ReasonRepositoryAdoNet(string defaultConnectionString, ILoggerFactory loggerFactory)
    {
        _defaultConnectionString = defaultConnectionString;
        _logger = loggerFactory.CreateLogger<ReasonRepositoryAdoNet>();
    }

    private SqlConnection GetConnection(string? connectionString)
    {
        return new SqlConnection(connectionString ?? _defaultConnectionString);
    }

    public async Task<Reason> AddAsync(Reason model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = @"INSERT INTO Reasons (Active, CreatedAt, CreatedBy, Name)
                            OUTPUT INSERTED.Id
                            VALUES (@Active, @CreatedAt, @CreatedBy, @Name)";

        cmd.Parameters.AddWithValue("@Active", model.Active ?? true);
        cmd.Parameters.AddWithValue("@CreatedAt", DateTimeOffset.UtcNow);
        cmd.Parameters.AddWithValue("@CreatedBy", model.CreatedBy ?? (object)DBNull.Value);
        cmd.Parameters.AddWithValue("@Name", model.Name ?? (object)DBNull.Value);

        await conn.OpenAsync();
        model.Id = (long)await cmd.ExecuteScalarAsync();
        return model;
    }

    public async Task<List<Reason>> GetAllAsync(string? connectionString = null)
    {
        var result = new List<Reason>();

        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Reasons ORDER BY Id DESC";

        await conn.OpenAsync();
        var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            result.Add(new Reason
            {
                Id = reader.GetInt64(0),
                Active = reader.IsDBNull(1) ? (bool?)null : reader.GetBoolean(1),
                CreatedAt = reader.GetDateTimeOffset(2),
                CreatedBy = reader.IsDBNull(3) ? null : reader.GetString(3),
                Name = reader.IsDBNull(4) ? null : reader.GetString(4)
            });
        }
        return result;
    }

    public async Task<Reason> GetByIdAsync(long id, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Reasons WHERE Id = @Id";
        cmd.Parameters.AddWithValue("@Id", id);

        await conn.OpenAsync();
        var reader = await cmd.ExecuteReaderAsync();
        if (await reader.ReadAsync())
        {
            return new Reason
            {
                Id = reader.GetInt64(0),
                Active = reader.IsDBNull(1) ? (bool?)null : reader.GetBoolean(1),
                CreatedAt = reader.GetDateTimeOffset(2),
                CreatedBy = reader.IsDBNull(3) ? null : reader.GetString(3),
                Name = reader.IsDBNull(4) ? null : reader.GetString(4)
            };
        }

        return new Reason(); // 빈 모델 반환
    }

    public async Task<bool> UpdateAsync(Reason model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = @"UPDATE Reasons SET
                                Active = @Active,
                                Name = @Name
                            WHERE Id = @Id";

        cmd.Parameters.AddWithValue("@Active", model.Active ?? true);
        cmd.Parameters.AddWithValue("@Name", model.Name ?? (object)DBNull.Value);
        cmd.Parameters.AddWithValue("@Id", model.Id);

        await conn.OpenAsync();
        return await cmd.ExecuteNonQueryAsync() > 0;
    }

    public async Task<bool> DeleteAsync(long id, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = "DELETE FROM Reasons WHERE Id = @Id";
        cmd.Parameters.AddWithValue("@Id", id);

        await conn.OpenAsync();
        return await cmd.ExecuteNonQueryAsync() > 0;
    }

    public async Task<ArticleSet<Reason, int>> GetArticlesAsync<TParentIdentifier>(
        int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, string? connectionString = null)
    {
        // 심플 버전
        var result = await GetAllAsync(connectionString);
        var filtered = string.IsNullOrWhiteSpace(searchQuery)
            ? result
            : result.Where(m => m.Name != null && m.Name.Contains(searchQuery)).ToList();

        var paged = filtered
            .Skip(pageIndex * pageSize)
            .Take(pageSize)
            .ToList();

        return new ArticleSet<Reason, int>(paged, filtered.Count);
    }

    public async Task<ArticleSet<Reason, long>> GetByAsync<TParentIdentifier>(
        FilterOptions<TParentIdentifier> options, string? connectionString = null)
    {
        var result = await GetAllAsync(connectionString);
        var filtered = result
            .Where(m => string.IsNullOrWhiteSpace(options.SearchQuery) ||
                        (m.Name != null && m.Name.Contains(options.SearchQuery)))
            .ToList();

        var paged = filtered
            .Skip(options.PageIndex * options.PageSize)
            .Take(options.PageSize)
            .ToList();

        return new ArticleSet<Reason, long>(paged, filtered.Count);
    }
}

Azunt.ReasonManagement\03_Repositories\Dapper\ReasonRepositoryDapper.cs

using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Dul.Articles;

namespace Azunt.ReasonManagement;

public class ReasonRepositoryDapper : IReasonRepository
{
    private readonly string _defaultConnectionString;
    private readonly ILogger<ReasonRepositoryDapper> _logger;

    public ReasonRepositoryDapper(string defaultConnectionString, ILoggerFactory loggerFactory)
    {
        _defaultConnectionString = defaultConnectionString;
        _logger = loggerFactory.CreateLogger<ReasonRepositoryDapper>();
    }

    private SqlConnection GetConnection(string? connectionString)
    {
        return new SqlConnection(connectionString ?? _defaultConnectionString);
    }

    public async Task<Reason> AddAsync(Reason model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = @"INSERT INTO Reasons (Active, CreatedAt, CreatedBy, Name)
                    OUTPUT INSERTED.Id
                    VALUES (@Active, @CreatedAt, @CreatedBy, @Name)";

        model.CreatedAt = DateTimeOffset.UtcNow;
        model.Id = await conn.ExecuteScalarAsync<long>(sql, model);
        return model;
    }

    public async Task<List<Reason>> GetAllAsync(string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Reasons ORDER BY Id DESC";
        var list = await conn.QueryAsync<Reason>(sql);
        return list.ToList();
    }

    public async Task<Reason> GetByIdAsync(long id, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Reasons WHERE Id = @Id";
        var model = await conn.QuerySingleOrDefaultAsync<Reason>(sql, new { Id = id });
        return model ?? new Reason();
    }

    public async Task<bool> UpdateAsync(Reason model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = @"UPDATE Reasons SET
                        Active = @Active,
                        Name = @Name
                    WHERE Id = @Id";

        var rows = await conn.ExecuteAsync(sql, model);
        return rows > 0;
    }

    public async Task<bool> DeleteAsync(long id, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = "DELETE FROM Reasons WHERE Id = @Id";
        var rows = await conn.ExecuteAsync(sql, new { Id = id });
        return rows > 0;
    }

    public async Task<ArticleSet<Reason, int>> GetArticlesAsync<TParentIdentifier>(
        int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, string? connectionString = null)
    {
        var all = await GetAllAsync(connectionString);
        var filtered = string.IsNullOrWhiteSpace(searchQuery)
            ? all
            : all.Where(m => m.Name != null && m.Name.Contains(searchQuery)).ToList();

        var paged = filtered
            .Skip(pageIndex * pageSize)
            .Take(pageSize)
            .ToList();

        return new ArticleSet<Reason, int>(paged, filtered.Count);
    }

    public async Task<ArticleSet<Reason, long>> GetByAsync<TParentIdentifier>(
        FilterOptions<TParentIdentifier> options, string? connectionString = null)
    {
        var all = await GetAllAsync(connectionString);
        var filtered = all
            .Where(m => string.IsNullOrWhiteSpace(options.SearchQuery) ||
                        (m.Name != null && m.Name.Contains(options.SearchQuery)))
            .ToList();

        var paged = filtered
            .Skip(options.PageIndex * options.PageSize)
            .Take(options.PageSize)
            .ToList();

        return new ArticleSet<Reason, long>(paged, filtered.Count);
    }
}

DI 등록 관련 코드 모음 클래스 생성

다음은 Startup.cs 또는 Program.cs 파일에서 DI에 등록할 때 사용하는 코드를 하나의 클래스로 모아 놓은 클래스입니다.

Azunt.ReasonManagement\04_Extensions\ReasonServicesRegistrationExtensions.cs

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

namespace Azunt.ReasonManagement;

/// <summary>
/// ReasonApp 의존성 주입 확장 메서드
/// </summary>
public static class ReasonServicesRegistrationExtensions
{
    // 선택 가능한 저장소 모드 정의
    public enum RepositoryMode
    {
        EfCore,
        Dapper,
        AdoNet
    }

    /// <summary>
    /// ReasonApp 모듈의 서비스를 등록합니다.
    /// </summary>
    /// <param name="services">서비스 컨테이너</param>
    /// <param name="connectionString">연결 문자열</param>
    /// <param name="mode">레포지토리 사용 모드 (기본: EF Core)</param>
    /// <param name="dbContextLifetime">DbContext 수명 주기 (기본: Transient)</param>
    public static void AddDependencyInjectionContainerForReasonApp(
        this IServiceCollection services,
        string connectionString,
        RepositoryMode mode = RepositoryMode.EfCore,
        ServiceLifetime dbContextLifetime = ServiceLifetime.Transient)
    {
        switch (mode)
        {
            case RepositoryMode.EfCore:
                // EF Core 방식 등록
                services.AddDbContext<ReasonAppDbContext>(
                    options => options.UseSqlServer(connectionString),
                    dbContextLifetime);

                services.AddTransient<IReasonRepository, ReasonRepository>();
                services.AddTransient<ReasonAppDbContextFactory>();
                break;

            case RepositoryMode.Dapper:
                // Dapper 방식 등록
                services.AddTransient<IReasonRepository>(provider =>
                    new ReasonRepositoryDapper(
                        connectionString,
                        provider.GetRequiredService<ILoggerFactory>()));
                break;

            case RepositoryMode.AdoNet:
                // ADO.NET 방식 등록
                services.AddTransient<IReasonRepository>(provider =>
                    new ReasonRepositoryAdoNet(
                        connectionString,
                        provider.GetRequiredService<ILoggerFactory>()));
                break;

            default:
                throw new InvalidOperationException(
                    $"Invalid repository mode '{mode}'. Supported modes: EfCore, Dapper, AdoNet.");
        }
    }
}

리포지토리 테스트 클래스

Memos 테스트 프로젝트를 참고하여, Reasons 모듈에 대한 테스트 프로젝트를 생성하면 됩니다.

// 코드 생략...

종속성 주입

Statup.cs 또는 Program.cs에 종속성 주입(DI)

다음 코드를 사용하여 Reasons 관련 클래스들(모델, 인터페이스, 리포지토리 클래스)을 해당 프로젝트에서 사용할 수 있습니다.

Startup.cs 파일에서의 사용 모양

using Azunt.ReasonManagement;

// 이용 사유 관리: 기본 CRUD 교과서 코드
services.AddDependencyInjectionContainerForReasonApp(Configuration.GetConnectionString("DefaultConnection"));
services.AddTransient<ReasonAppDbContextFactory>();

Program.cs 파일에서의 사용 모양

using Azunt.ReasonManagement;

// 이용 사유 관리: 기본 CRUD 교과서 코드
builder.Services.AddDependencyInjectionContainerForReasonApp(Configuration.GetConnectionString("DefaultConnection"));
builser.Services.AddTransient<ReasonAppDbContextFactory>();

Reasons 관련 MVC Controller with CRUD 뷰 페이지

다음은 Reasons 주제로 ASP.NET Core MVC 스캐폴딩 기능을 사용하여 CRUD를 구현하는 코드의 내용입니다.

바로 Blazor Server 컴포넌트 제작으로 넘어가려면 다음 절로 이동하세요.

MVC 스캐폴딩으로 CRUD를 구현하는 내용에 대한 강좌는 다음 링크를 참고하세요.

TODO: MVC 스캐폴딩

Reasons 관련 Web API Controller with CRUD

하나의 모듈에 대한 CRUD를 Swagger UI를 사용하는 ASP.NET Core Web API를 스캐폴딩 기능을 사용하여 구현할 수 있습니다. 이 곳의 내용은 Blazor Server에서 사용하기 보다는 Blazor Wasm, React, Vue, Angular, jQuery 등에서 호출되어 사용되는 부분입니다. Blazor Server 관점에서 개발하려면 다음 절로 이동하세요.

Web API 스캐폴딩으로 CRUD를 구현하는 내용에 대한 강좌는 다음 링크를 참고하세요.

TODO: Web API 스캐폴딩

Reasons 관련 Excel 다운로드 API 생성

아직 제작하지 않은 Blazor Server의 Manage 컴포넌트에서는 리스트로 출력되는 내용을 Excel 파일로 다운로드하는 기능을 제공하고 있습니다. 이 부분에서 사용되는 API는 ASP.NET Core Web API로 구현이 되는데요. 해당 코드는 다음과 같습니다.

ReasonDownloadController.cs

Azunt\Apis\Reasons\ReasonDownloadController.cs

using Microsoft.AspNetCore.Authorization;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using System.Drawing;
using Azunt.ReasonManagement;
using Microsoft.AspNetCore.Mvc;

namespace Azunt.Apis.Reasons;

[Authorize(Roles = "Administrators")]
public class ReasonDownloadController : Controller
{
    private readonly IReasonRepository _repository;

    public ReasonDownloadController(IReasonRepository repository) => _repository = repository;

    /// <summary>
    /// 엑셀 파일 강제 다운로드 기능(/ExcelDown)
    /// </summary>
    public async Task<IActionResult> ExcelDown()
    {
        var models = await _repository.GetAllAsync(); // 그냥 전체 가져오기 (List<Reason>)

        if (models != null && models.Count > 0)
        {
            using (var package = new ExcelPackage())
            {
                var worksheet = package.Workbook.Worksheets.Add("Reasons");

                var tableBody = worksheet.Cells["B2:B2"].LoadFromCollection(
                    from m in models
                    select new
                    {
                        m.Id,
                        m.Name,
                        CreatedAt = m.CreatedAt.ToLocalTime().ToString(), // LocalDateTime 대신 ToLocalTime()
                        m.Active,
                        m.CreatedBy
                    }, 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;
                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:yyyyMMddHHmmss}_Reasons.xlsx");
            }
        }

        return Redirect("/");
    }
}

Azunt.Web

<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="DocumentFormat.OpenXml" Version="2.7.2" />
<PackageReference Include="DulPager" Version="1.0.2" />
<PackageReference Include="EPPlus" Version="6.0.5" />

Blazor Server 컴포넌트

Reasons 관련하여 Blazor Server 컴포넌트를 구성할 폴더 구조는 다음과 같습니다. Reasons 폴더에 Manage 이름으로 Razor Component를 생성하고, 관련된 서브 컴포넌트들은 Components 폴더에 둡니다.

Reasons 보다 더 향상된 기능을 구현하는 Memos 모듈의 폴더 구조는 참고용으로 아래에 표시하였습니다. 내용은 비슷합니다. 제 강의의 Blazor Server 게시판 프로젝트 강의를 수강하셨다면, 각각의 컴포넌트 파일명을 만드는 연습을 여러 번 했기에 익숙한 폴더명과 파일명이 될 것입니다. 참고로 Manage 이름의 컴포넌트는 단일 페이지에서 CRUD를 구현하는 코드를 나타냅니다.

강의용 Azunt 프로젝트의 Pages 폴더의 일부 내용

.NET 8.0 이상 사용하는 환경이라면, /Pages/ 폴더 대신에 /Components/ 폴더에 관련된 컴포넌트를 모아 놓으면 됩니다.

Pages 또는 Components 폴더 (최신)
│  _Host.cshtml 또는 App.razor (최신)
│  
├─Reasons
│  │  Manage.razor
│  │  Manage.razor.cs
│  │  
│  └─Components
│          DeleteDialog.razor
│            DeleteDialog.razor.cs
│          ModalForm.razor
│            ModalForm.razor.cs
│          SearchBox.razor
│            SearchBox.razor.cs
│          SortOrderArrow.razor
│      
├─Memos
│  │  Create.razor
│  │    Create.razor.cs
│  │  Delete.razor
│  │    Delete.razor.cs
│  │  Details.razor
│  │    Details.razor.cs
│  │  Edit.razor
│  │    Edit.razor.cs
│  │  Index.razor
│  │    Index.razor.cs
│  │  Manage.razor
│  │    Manage.razor.cs
│  │  
│  └─Components
│          DeleteDialog.razor
│            DeleteDialog.razor.cs
│          EditorForm.razor
│            EditorForm.razor.cs
│          ModalForm.razor
│            ModalForm.razor.cs
│          SearchBox.razor
│            SearchBox.razor.cs
│          SortOrderArrow.razor
│ ...

Components\Pages\Reasons>tree /F
D:.
│  Manage.razor
│  Manage.razor.cs
│
├─Components
│      DeleteDialog.razor
│      DeleteDialog.razor.cs
│      ModalForm.razor
│      ModalForm.razor.cs
│      SearchBox.razor
│      SearchBox.razor.cs
│      SortOrderArrow.razor
│
└─Controls
        ReasonComboBox.razor

DeleteDialog.razor 컴포넌트

DeleteDialog 컴포넌트는 리스트(Manage) 페이지에서 특정 레코드를 삭제할 때 뜨는 팝업 다이얼로그입니다.

@namespace Azunt.Web.Pages.Reasons.Components

@if (IsShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
        <div class="modal-dialog modal-dialog-centered" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">DELETE</h5>
                    <button type="button" class="btn-close" @onclick="Hide" aria-label="Close"></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" @onclick="Hide">Cancel</button>
                </div>
            </div>
        </div>
    </div>
}

DeleteDialog.razor.cs

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

namespace Azunt.Web.Pages.Reasons.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
}

ModalForm.razor

ModalForm 컴포넌트는 리스트(Manage) 페이지에서 팝업을 통해서 특정 항목을 입력하거나, 이미 입력된 내용을 수정할 때 사용하는 모달 팝업 다이얼로그 컴포넌트입니다.

@namespace Azunt.Web.Pages.Reasons.Components

@if (IsShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
        <div class="modal-dialog modal-lg modal-dialog-scrollable modal-dialog-centered" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">@EditorFormTitle</h5>
                    <button type="button" class="btn-close" @onclick="Hide" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    @* ID를 화면에 표시하지 않고 hidden input만 남김 *@
                    <input type="hidden" @bind-value="@ModelEdit.Id" />

                    <div class="mb-3">
                        <label for="txtName" class="form-label">Name</label>
                        <input id="txtName" type="text" @bind="@ModelEdit.Name" class="form-control" placeholder="Enter Name" />
                    </div>

                    <div class="d-flex justify-content-end">
                        <button type="button" class="btn btn-primary me-2" @onclick="CreateOrEditClick">Submit</button>
                        <button type="button" class="btn btn-secondary" @onclick="Hide">Cancel</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
}

ModalForm.razor.cs

using Microsoft.AspNetCore.Components;
using System;
using Azunt.ReasonManagement;

namespace Azunt.Web.Pages.Reasons.Components;

public partial class ModalForm : ComponentBase
{
    #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;
        StateHasChanged(); // 추가!!
    }
    #endregion

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

    /// <summary>
    /// 폼의 제목 영역
    /// </summary>
    [Parameter]
    public RenderFragment EditorFormTitle { get; set; } = null!; // null이 아닌 RenderFragment로 초기화

    /// <summary>
    /// 넘어온 모델 개체 
    /// </summary>
    [Parameter]
    public Reason ModelSender { get; set; } = null!; // null이 아닌 Reason으로 초기화

    public Reason ModelEdit { get; set; } = null!; // null이 아닌 Reason으로 초기화

    #region Lifecycle Methods
    // 넘어온 Model 값을 수정 전용 ModelEdit에 담기 
    protected override void OnParametersSet()
    {
        if (ModelSender != null)
        {
            ModelEdit = new Reason
            {
                Id = ModelSender.Id,
                Name = ModelSender.Name,
                Active = ModelSender.Active,
                CreatedAt = ModelSender.CreatedAt,
                CreatedBy = ModelSender.CreatedBy
                // 필요한 필드 더 복사
            };
        }
        else
        {
            ModelEdit = new Reason();
        }
    }
    #endregion

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

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

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

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

    #endregion

    #region Event Handlers
    protected async void CreateOrEditClick()
    {
        // 변경 내용 저장
        ModelSender.Active = true;
        ModelSender.Name = ModelEdit.Name;
        ModelSender.CreatedBy = UserName ?? "Anonymous";

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

SearchBox.razor

SearchBox 컴포넌트는 리스트 페이지에서 항목을 검색할 때 사용하는 검색 폼입니다. 이 검색 폼에는 디바운스 기능이라고 해서, 계속 입력되는 동안에는 검색을 진행하지 않고 입력이 완료된 후 300밀리초 후에 검색이 진행되는 기능이 들어 있습니다. 이 시간은 필요에 의해서 코드 비하인드에서 적절한 시간으로 수정해서 사용하면 됩니다.

@namespace Azunt.Web.Pages.Reasons.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>

SearchBox.razor.cs

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

namespace Azunt.Web.Pages.Reasons.Components;

public partial class SearchBox : ComponentBase, IDisposable
{
    #region Fields
    private string searchQuery;
    private System.Timers.Timer debounceTimer;
    #endregion

    #region Parameters
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object> AdditionalAttributes { get; set; }

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

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

    #region Properties
    public string SearchQuery
    {
        get => searchQuery;
        set
        {
            searchQuery = value;
            debounceTimer.Stop(); // 텍스트박스에 값을 입력하는 동안 타이머 중지
            debounceTimer.Start(); // 타이머 실행(300밀리초 후에 딱 한 번 실행)
        }
    }
    #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
    protected void Search() => SearchQueryChanged.InvokeAsync(SearchQuery); // 부모의 메서드에 검색어 전달

    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 컴포넌트는 리스트 페이지에서 컬럼 정렬(Sorting) 기능을 구현할 때 현재 정렬 상태를 3가지로 표현하는 화살표 모양을 순수 텍스트로 표시해주기 위한 컴포넌트입니다.

언젠가는 QuickGrid 등으로 대체할 때까지는 정렬 기능이 이 컴포넌트를 사용할 예정입니다.

@namespace Azunt.Web.Pages.Reasons.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();
    }
}

Manage.razor

Manage 컴포넌트는 Blazor Server로 구현된 모듈의 핵심 페이지입니다. 이 컴포넌트에서 CRUD, 즉, 입력, 출력, 상세 보기, 수정, 삭제, 검색, 페이징, 정렬, 엑셀 다운로드 등의 전반적인 웹 애플리케이션의 기능을 모두 맛보기 형태로 살펴볼 수 있습니다. 사실, 현재 텍스트 아티클을 구성하는 목적도 현업에서 매번 비슷한 형태로 특정 기능을 구현할 때 이 문서의 내용 순서로 머릿속의 생각을 정리하면서 구현할 수 있는 가이드를 위해서 만들어 놓은 것입니다.

현재 Reason는 하나의 항목(이용 사유 이름)만 입력 받지만, 현업에서는 훨씨 더 많은 내용들을 서로 다른 모양(텍스트박스, 체크박스, 드롭다운리스트 등)으로 입력 받지만 그 내용은 비슷하다보면 됩니다.

개수가 많고 적음의 차이이지 CRUD 관점에서는 기본 뼈대 코드는 같습니다.

그래서 현재 아티클의 목적은 CRUD에 대한 교과서적 코드를 완성하는데 목적이 있습니다.

@page "/Reasons"
@page "/Reasons/Manage"

@namespace Azunt.Web.Pages.Reasons
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Web
@rendermode InteractiveServer

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

<h3 class="mt-1 mb-1">
    Reasons
    <span class="oi oi-plus text-primary align-baseline" @onclick="ShowEditorForm" style="cursor: pointer;"></span>
    <button onclick="location.href = '/ReasonDownload/ExcelDown';" class="btn btn-sm btn-primary" style="float: right;">Excel Export</button>
</h3>

<div class="row">
    <div class="col-md-12">
        @if (models == null)
        {
            <div>
                <p>Loading...</p>
            </div>
        }
        else
        {
            <div class="table-responsive">
                <table class="table table-bordered table-hover">
                    <colgroup>
                        @if (!SimpleMode)
                        {
                            <col style="width: 300px;" />
                            <col style="width: 200px;" />
                            <col style="width: 200px;" />
                            <col style="width: auto;" />
                        }
                        else
                        {
                            <col style="width: auto;" />
                        }
                        <col style="width: 300px;" />
                    </colgroup>
                    <thead class="thead-light">
                        <tr>
                            <th class="text-center text-nowrap" @onclick="@(() => SortByName())" style="cursor: pointer;">
                                Name <Azunt.Web.Pages.Reasons.Components.SortOrderArrow SortColumn="Name" SortOrder="@sortOrder"></Azunt.Web.Pages.Reasons.Components.SortOrderArrow>
                            </th>

                            @if (!SimpleMode)
                            {
                                <th class="text-center text-nowrap">Created</th>
                                <th class="text-center text-nowrap">Active</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="@(SimpleMode ? 2 : 5)" class="text-center">
                                    <p>No Data.</p>
                                </td>
                            </tr>
                        </tbody>
                    }
                    else
                    {
                        <tbody>
                            @foreach (var m in models)
                            {
                                <tr>
                                    <td class="text-center text-nowrap">
                                        <span class="btn-link">@m.Name</span>
                                    </td>

                                    @if (!SimpleMode)
                                    {
                                        <td class="text-center text-nowrap small">
                                            @(Dul.DateTimeUtility.ShowTimeOrDate(m.CreatedAt))
                                        </td>
                                        <td class="text-center">
                                            @if (m.Active != null && m.Active.Value)
                                            {
                                                <input type="checkbox" checked disabled />
                                            }
                                            else
                                            {
                                                <input type="checkbox" disabled />
                                            }
                                        </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))" />
                                        @if (!SimpleMode)
                                        {
                                            <input type="button" name="btnToggle" value="Change Active" class="btn btn-sm btn-warning" @onclick="(() => ToggleBy(m))" />
                                        }
                                    </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">
        <Azunt.Web.Pages.Reasons.Components.SearchBox placeholder="Search Reasons..." SearchQueryChanged="Search"></Azunt.Web.Pages.Reasons.Components.SearchBox>
    </div>
</div>

<Azunt.Web.Pages.Reasons.Components.ModalForm @ref="EditorFormReference" ModelSender="model" CreateCallback="CreateOrEdit" EditCallback="CreateOrEdit" UserName="@UserName">
    <EditorFormTitle>@EditorFormTitle</EditorFormTitle>
</Azunt.Web.Pages.Reasons.Components.ModalForm>

<Azunt.Web.Pages.Reasons.Components.DeleteDialog @ref="DeleteDialogReference" OnClickCallback="DeleteClick">
</Azunt.Web.Pages.Reasons.Components.DeleteDialog>

@if (IsInlineDialogShow)
{
    <div class="modal fade show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0, 0, 0, 0.5);">
        <div class="modal-dialog modal-dialog-centered" role="document">
            <div class="modal-content shadow">
                <div class="modal-header">
                    <h5 class="modal-title">Change Active State</h5>
                    <button type="button" class="btn-close" aria-label="Close" @onclick="ToggleClose"></button>
                </div>
                <div class="modal-body">
                    <p>Do you want to change the Active state of <strong>@model.Name</strong>?</p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary" @onclick="ToggleClick">Yes, Change</button>
                    <button type="button" class="btn btn-secondary" @onclick="ToggleClose">Cancel</button>
                </div>
            </div>
        </div>
    </div>
}

Azunt.Web\Components\Pages\Reasons\Manage.razor.cs

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azunt.ReasonManagement;

namespace Azunt.Web.Pages.Reasons;

public partial class Manage : ComponentBase
{
    public bool SimpleMode { get; set; } = false; 

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

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

    #region Injectors
    [Inject]
    public NavigationManager NavigationManagerInjector { get; set; } = null!;

    [Inject]
    public IJSRuntime JSRuntimeInjector { get; set; } = null!;

    [Inject]
    public IReasonRepository RepositoryReference { get; set; } = null!;

    [Inject]
    public IConfiguration Configuration { get; set; } = null!;

    [Inject]
    public ReasonAppDbContextFactory DbContextFactory { get; set; } = null!;
    #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; } = null!; // null이 아닌 ModalForm으로 초기화

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

    /// <summary>
    /// 현재 페이지에서 리스트로 사용되는 모델 리스트 
    /// </summary>
    protected List<Reason> models = new List<Reason>();

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

    /// <summary>
    /// 페이저 설정
    /// </summary>
    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()
    {
        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, "", 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, "", 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
    }

    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";
        this.model = new Reason(); // 모델 초기화
        EditorFormReference.Show();
    }

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

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

    /// <summary>
    /// 모델 초기화 및 모달 폼 닫기
    /// </summary>
    protected async void CreateOrEdit()
    {
        EditorFormReference.Hide(); // 모달 먼저 닫고

        await Task.Delay(50); // 아주 짧게 대기 (서버-클라이언트 싱크 맞추기)

        this.model = new Reason(); // 초기화

        await DisplayData(); // 데이터 다시 로드
    }

    /// <summary>
    /// 삭제 모달 폼에서 현재 선택한 항목 삭제
    /// </summary>
    protected async void DeleteClickOld()
    {
        await RepositoryReference.DeleteAsync(this.model.Id);
        DeleteDialogReference.Hide();
        this.model = new Reason(); // 선택했던 모델 초기화
        await DisplayData(); // 다시 로드
    }

    protected async void DeleteClick()
    {
        var connectionString = Configuration.GetConnectionString("DefaultConnection");

        await RepositoryReference.DeleteAsync(this.model.Id, connectionString);
        DeleteDialogReference.Hide();
        this.model = new Reason();
        await DisplayData();
    }

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

    protected void ToggleClose()
    {
        IsInlineDialogShow = false;
        this.model = new Reason();
    }

    /// <summary>
    /// 토글: Pinned
    /// </summary>
    protected async void ToggleClickOld()
    {
        model.Active = !model.Active;

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

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

        await DisplayData(); // 다시 로드
    }
    protected async void ToggleClick()
    {
        var connectionString = Configuration.GetConnectionString("DefaultConnection");

        if (string.IsNullOrWhiteSpace(connectionString))
        {
            // 예외를 직접 던지거나, 기본 동작을 선택
            throw new InvalidOperationException("DefaultConnection is not configured properly.");
        }

        await using var context = DbContextFactory.CreateDbContext(connectionString);

        model.Active = !model.Active;

        context.Reasons.Update(model);
        await context.SaveChangesAsync();

        IsInlineDialogShow = false;
        this.model = new Reason();

        await DisplayData();
    }

    /// <summary>
    /// ToggleBy(PinnedBy)
    /// </summary>
    protected void ToggleBy(Reason model)
    {
        this.model = model;
        IsInlineDialogShow = true;
    }
    #endregion

    #region Search
    private string searchQuery = "";

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

        this.searchQuery = query;

        await DisplayData();
    }
    #endregion

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

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

    #region Sorting
    private string sortOrder = "";

    protected async void SortByName()
    {
        if (!sortOrder.Contains("Name"))
        {
            sortOrder = ""; // 다른 열을 정렬하고 있었다면, 다시 초기화
        }

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

        await DisplayData();
    }
    #endregion

    #region Get UserId and UserName
    [Parameter]
    public string UserId { get; set; } = "";

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

    [Inject] public UserManager<Azunt.Web.Data.ApplicationUser> UserManagerRef { get; set; } = null!;

    [Inject] public AuthenticationStateProvider AuthenticationStateProviderRef { get; set; } = null!;

    private async Task GetUserIdAndUserName()
    {
        if (AuthenticationStateProviderRef == null || UserManagerRef == null)
        {
            UserId = "";
            UserName = "Anonymous";
            return;
        }

        var authState = await AuthenticationStateProviderRef.GetAuthenticationStateAsync();
        var user = authState.User;

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

웹 브라우저 실행 및 단계별 테스트

Ctrl+F5를 눌러 프로젝트를 실행하고 웹브라우저에서 /reasons/ 경로를 요청하면 Reasons 폴더의 Manage 컴포넌트가 실행이 됩니다.

이 컴포넌트가 정상적으로 실행되면, 이용 사유를 입력, 출력, 수정, 삭제, 검색, 정렬, 등의 기능을 단계별로 테스트해 볼 수 있습니다.

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