Azunt.DepotManagement 모듈 구축 가이드

  • 62 minutes to read
// Azunt.DepotManagement: Building a Reusable Depot CRUD Module with Blazor Server

Azunt.DepotManagement 구축 가이드 목차


Azunt.DepotManagement.csproj
│
├─01_Models
│      Depot.cs                             // 창고(Depot) 모델 클래스
│
├─02_Contracts
│      IDepotRepository.cs                  // IRepositoryBase<Depot, long> 상속 인터페이스
│
├─03_Repositories
│  ├─AdoNet
│  │      DepotRepositoryAdoNet.cs          // ADO.NET 방식 저장소 구현
│  │
│  ├─Dapper
│  │      DepotRepositoryDapper.cs          // Dapper 방식 저장소 구현
│  │
│  └─EfCore
│          DepotAppDbContext.cs              // EF Core DbContext
│          DepotAppDbContextFactory.cs       // DbContext Factory
│          DepotRepository.cs                // EF Core 방식 저장소 구현
│
├─04_Extensions
│      DepotServicesRegistrationExtensions.cs  // DI(Dependency Injection) 등록 확장 클래스
│
└─05_Enhancers
       DepotsTableBuilder.cs                // 테이블 생성 및 기본 데이터 삽입 유틸리티
  Manage.razor
│  Manage.razor.cs
│
├─Apis
│      DepotDownloadController.cs
│
├─Components
│      DeleteDialog.razor
│      DeleteDialog.razor.cs
│      ModalForm.razor
│      ModalForm.razor.cs
│      SearchBox.razor
│      SearchBox.razor.cs
│      SortOrderArrow.razor
│
└─Controls
        DepotComboBox.razor

소개

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

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


GitHub 저장소 생성 및 로컬 클론, README.md 수정 후 푸시하기

이 절차에서는 VisualAcademy/Azunt.DepotManagement 저장소를 사용하여, GitHub에 저장소를 만들고, 로컬에 클론(clone)한 후, README.md 파일을 수정하고 원격 저장소로 다시 푸시(sync)하는 과정을 진행합니다.

1. GitHub 저장소 생성

  1. GitHub에 로그인합니다.
  2. 새 저장소 만들기(New Repository)로 이동합니다.
  3. 아래와 같이 입력합니다:
    • Repository name: Azunt.DepotManagement
    • Description: (선택) Azunt 프로젝트의 Depot 관리 모듈
    • Public/Private: 필요에 따라 선택
    • Initialize this repository with:
      • README.md 체크 (선택) (처음부터 빈 저장소로 만들 경우 체크 해제)
  4. Create repository 버튼 클릭

※ 주의: 이미 저장소가 생성되어 있다면 이 과정은 생략합니다.


2. 로컬에 클론(Clone)하기

터미널(CMD, PowerShell, Git Bash 등)이나 VS Code를 열고 다음 명령을 실행합니다.

git clone https://github.com/VisualAcademy/Azunt.DepotManagement.git

명령어 실행 후, Azunt.DepotManagement 폴더가 로컬에 생성됩니다.

cd Azunt.DepotManagement

3. README.md 파일 수정하기

로컬에서 Azunt.DepotManagement/README.md 파일을 편집기로 열어, 내용을 수정하거나 추가합니다.

예를 들어:

# Azunt.DepotManagement

Azunt 프로젝트의 Depot 관리 모듈입니다.

- Entity Framework Core 기반 CRUD
- Dapper, ADO.NET 대체 구현 포함
- Blazor Server 컴포넌트 예제 제공

수정이 끝나면 파일을 저장합니다.


4. 수정 내용을 커밋(Commit)하고 푸시(Push)하기

터미널에서 다음 명령을 실행합니다:

git add README.md
git commit -m "Update README.md"
git push origin main

※ 기본 브랜치가 main이 아닌 경우, master나 다른 브랜치명을 사용해야 합니다.


5. 완료 확인

GitHub 사이트에 접속해 VisualAcademy/Azunt.DepotManagement 저장소의 README.md가 정상적으로 수정되었는지 확인합니다.

프로젝트 준비

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

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

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


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

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

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

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.DepotManagement 적용 준비

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

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

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

기본 폴더 생성

│  Azunt.DepotManagement.csproj
│
├─01_Models
├─02_Contracts
├─03_Repositories
├─04_Extensions
├─05_Enhancers

2. 프로젝트 참조 추가

  • Azunt.Web 프로젝트에서 Project Reference로 Azunt.DepotManagement를 추가합니다.
  • NuGet 패키지 설치:
Install-Package Azunt -Version 1.0.2
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는 엑셀 다운로드 기능을 위해 추가합니다. 이 패키지는 무료 버전을 기준으로 합니다.

<ItemGroup>
    <PackageReference Include="Azunt" Version="1.0.2" />
    <PackageReference Include="Dapper" Version="2.1.66" />
    <PackageReference Include="Dul" Version="1.3.4" />
    <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
    <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" />
</ItemGroup>

3. SQL Server 테이블 준비

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

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

4. DI 및 서비스 등록 준비

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

builder.Services.AddDependencyInjectionContainerForDepotApp(
    builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.AddTransient<DepotAppDbContextFactory>();
...

// 창고 관리: 기본 CRUD 교과서 코드
builder.Services.AddDependencyInjectionContainerForDepotApp(connectionString,
    DepotServicesRegistrationExtensions.RepositoryMode.EfCore);
builder.Services.AddTransient<DepotAppDbContextFactory>();

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

테이블 자동 설치 코드 조각

var app = builder.Build();

// Depots 테이블 자동 생성 (마스터 DB만)
using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;
    DepotsTableBuilder.Run(services, forMaster: true); // or false for tenant DBs
}

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
    app.UseMigrationsEndPoint();
}

NuGet 패키지 설치

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

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

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

  <ItemGroup>
    <PackageReference Include="Azunt" Version="1.0.2" />
    <PackageReference Include="Dapper" Version="2.1.66" />
    <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 적용

DepotManagement 모듈에서는 버튼이나 액션 링크 등에 간단한 아이콘을 사용하기 위해
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.DepotManagement\src\
    Azunt.DepotManagement\
        Azunt.SqlServer\
            00_Depots.sql

스크립트:

--[0][0] 창고: Depots 
CREATE TABLE [dbo].[Depots]
(
    [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,                                    -- 이름
    [IsDeleted] BIT NOT NULL DEFAULT(0)                                     -- Soft Delete 플래그
);

Depots table creation and initial seeding

Azunt.DepotManagement\05_Enhancers\DepotsTableBuilder.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.DepotManagement;

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

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

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

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

    public void BuildMasterDatabase()
    {
        try
        {
            EnsureDepotsTable(_masterConnectionString);
            _logger.LogInformation("Depots 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 EnsureDepotsTable(string connectionString)
    {
        using (var connection = new SqlConnection(connectionString))
        {
            connection.Open();

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

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

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

                cmdCreate.ExecuteNonQuery();

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

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

                    var cmdColumnCheck = new SqlCommand(@"
                        SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS 
                        WHERE TABLE_NAME = 'Depots' 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].[Depots] ADD [{columnName}] {kvp.Value}", connection);
                        alterCmd.ExecuteNonQuery();

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

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

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

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

    public static void Run(IServiceProvider services, bool forMaster)
    {
        try
        {
            var logger = services.GetRequiredService<ILogger<DepotsTableBuilder>>();
            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 DepotsTableBuilder(masterConnectionString, logger);

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

모델 클래스

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

Depot.cs

위치:

Azunt.DepotManagement\01_Models\Depot.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Azunt.DepotManagement
{
    /// <summary>
    /// Depots 테이블과 매핑되는 창고(Depot) 엔터티 클래스입니다.
    /// </summary>
    [Table("Depots")]
    public class Depot
    {
        /// <summary>
        /// 창고 고유 아이디 (자동 증가)
        /// </summary>
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public long Id { get; set; }

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

        /// <summary>
        /// 소프트 삭제 플래그 (기본값: false)
        /// </summary>
        public bool IsDeleted { get; set; }

        /// <summary>
        /// 생성 일시
        /// </summary>
        public DateTimeOffset CreatedAt { get; set; }

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

        /// <summary>
        /// 창고 이름
        /// </summary>
        [Required(ErrorMessage = "Name is required.")]
        [StringLength(100, ErrorMessage = "Name cannot exceed 100 characters.")]
        public string? Name { get; set; }
    }
}

이 클래스는 Depots 테이블과 1:1로 매핑되며, EF Core, Dapper, ADO.NET 모두에서 공통으로 사용됩니다. 테이블 생성 시 참고할 필드와 데이터 형식을 그대로 반영하고 있으며, CreatedAt은 서버 시간으로 자동 설정되도록 처리하는 것이 일반적입니다.

필요하면 Description, Location, Capacity 등의 추가 필드를 나중에 확장 가능합니다.

공통 코드

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) 형식을 지정합니다. 이 인터페이스는 데이터를 추가, 조회, 수정, 삭제하는 기본적인 메서드를 정의하며, 필터링을 위한 GetAllAsyncGetAllAsync 메서드도 포함합니다. 이러한 메서드들은 페이징 및 검색 기능을 지원하기 위해 설계되었습니다.

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>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetAllAsync 메서드와 동일 구조 
        /// </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>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

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

Azunt.Repositories.IRepositoryBase 사용

Azunt/Repositories/IRepositoryBase.cs

IRepositoryBase<T, TId>는 Azunt 전역에서 기본 CRUD(Create, Read, Update, Delete) 작업을 표준화하기 위해 사용되는 최상위 공통 리포지토리 인터페이스입니다.

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

namespace Azunt.Repositories
{
    /// <summary>
    /// Defines a generic repository interface for basic CRUD operations.
    /// This is the base abstraction used across the Azunt ecosystem to unify data access logic.
    /// </summary>
    /// <typeparam name="T">The entity type.</typeparam>
    /// <typeparam name="TId">The identifier type (e.g., int, long, Guid, string).</typeparam>
    public interface IRepositoryBase<T, TId> where T : class
    {
        /// <summary>
        /// Adds a new entity to the data store.
        /// </summary>
        /// <param name="entity">The entity to add.</param>
        /// <returns>The added entity with any generated values populated.</returns>
        Task<T> AddAsync(T entity);

        /// <summary>
        /// Retrieves all entities of the specified type.
        /// </summary>
        /// <returns>A collection of all entities.</returns>
        Task<IEnumerable<T>> GetAllAsync();

        /// <summary>
        /// Retrieves a single entity by its unique identifier.
        /// </summary>
        /// <param name="id">The unique identifier of the entity.</param>
        /// <returns>The entity if found; otherwise, null.</returns>
        Task<T> GetByIdAsync(TId id);

        /// <summary>
        /// Updates an existing entity in the data store.
        /// </summary>
        /// <param name="entity">The entity with updated values.</param>
        /// <returns>True if the update was successful; otherwise, false.</returns>
        Task<bool> UpdateAsync(T entity);

        /// <summary>
        /// Deletes an entity based on its unique identifier.
        /// </summary>
        /// <param name="id">The identifier of the entity to delete.</param>
        /// <returns>True if the deletion was successful; otherwise, false.</returns>
        Task<bool> DeleteAsync(TId id);
    }
}

리포지토리 인터페이스

파일 구조

Azunt.DepotManagement\02_Contracts\
│
├─ IDepotBaseRepository.cs     // 기본 CRUD 인터페이스
└─ IDepotRepository.cs         // 확장 기능 포함 인터페이스

1. IDepotBaseRepository.cs

이 인터페이스는 Azunt의 공통 CRUD 인터페이스인 IRepositoryBase<T, TId>를 기반으로 Depot 엔터티에 특화된 표준 인터페이스입니다.

using Azunt.Repositories;

namespace Azunt.DepotManagement;

/// <summary>
/// 기본 CRUD 작업을 위한 Depot 전용 저장소 인터페이스
/// </summary>
public interface IDepotBaseRepository : IRepositoryBase<Depot, long>
{
}

이 인터페이스는 테스트 및 일반 사용 시 기본 CRUD 기능만 필요한 경우 사용합니다.


2. IDepotRepository.cs

이 인터페이스는 IDepotBaseRepository를 상속하며, 페이징, 검색, 고급 필터링 등 추가 기능을 포함합니다. 주로 Blazor Server 컴포넌트, Web API, 관리자 기능에서 사용됩니다.

using Dul.Articles;

namespace Azunt.DepotManagement;

/// <summary>
/// Depot 전용 확장 저장소 인터페이스 - 페이징, 검색 기능 포함
/// </summary>
public interface IDepotRepository : IDepotBaseRepository
{
    /// <summary>
    /// 페이징 + 검색 기능 제공
    /// </summary>
    Task<ArticleSet<Depot, int>> GetAllAsync<TParentIdentifier>(
        int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

    /// <summary>
    /// 필터 옵션 기반 조회 기능 제공
    /// </summary>
    Task<ArticleSet<Depot, long>> GetAllAsync<TParentIdentifier>(
        FilterOptions<TParentIdentifier> options);
}

사용 예

  • IDepotBaseRepository는 단순한 CRUD가 필요한 경우 사용
  • IDepotRepository는 고급 검색, 페이징, 정렬 기능이 필요한 경우 사용

의존성 주입 시 예시

services.AddTransient<IDepotBaseRepository, DepotRepository>();
// 또는
services.AddTransient<IDepotRepository, DepotRepository>();

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>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

        /// <summary>
        /// 필터링 관련 메서드 이름 추가: GetAllAsync 메서드와 동일 구조 
        /// </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>> GetAllAsync<TParentIdentifier>(int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier);

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

DbContext 클래스

DbContext 클래스

DepotAppDbContextAzunt.DepotManagement 모듈에서 사용하는 Entity Framework Core 기반의 데이터베이스 컨텍스트 클래스입니다. 이 클래스는 EF Core와 실제 SQL Server 간의 브릿지 역할을 하며, 모델 구성 및 테이블 설정을 담당합니다.

Azunt.DepotManagement\03_Repositories\EfCore\DepotAppDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace Azunt.DepotManagement
{
    public class DepotAppDbContext : DbContext
    {
        public DepotAppDbContext(DbContextOptions<DepotAppDbContext> options)
            : base(options)
        {
            ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Depot>()
                .Property(m => m.CreatedAt)
                .HasDefaultValueSql("GetDate()");
        }

        public DbSet<Depot> Depots { get; set; } = null!;
    }
}

DbContext 팩터리 클래스

DepotAppDbContextFactory는 다양한 방법으로 DepotAppDbContext 인스턴스를 생성하는 팩터리 클래스입니다. 명시적인 연결 문자열이 전달되지 않은 경우 appsettings.json에 정의된 "DefaultConnection" 값을 사용합니다. 이 구조를 통해 서비스 등록 시 유연하게 사용할 수 있습니다.

Azunt.DepotManagement\03_Repositories\EfCore\DepotAppDbContextFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace Azunt.DepotManagement;

public class DepotAppDbContextFactory
{
    private readonly IConfiguration? _configuration;

    public DepotAppDbContextFactory() { }

    public DepotAppDbContextFactory(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public DepotAppDbContext CreateDbContext(string connectionString)
    {
        var options = new DbContextOptionsBuilder<DepotAppDbContext>()
            .UseSqlServer(connectionString)
            .Options;

        return new DepotAppDbContext(options);
    }

    public DepotAppDbContext CreateDbContext(DbContextOptions<DepotAppDbContext> options)
    {
        ArgumentNullException.ThrowIfNull(options);
        return new DepotAppDbContext(options);
    }

    public DepotAppDbContext 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);
    }
}

리포지토리 클래스

DepotRepository 클래스 개요

DepotRepositoryAzunt.DepotManagement 모듈의 핵심 리포지토리 클래스입니다. Entity Framework Core 기반이며, DepotAppDbContextFactory를 통해 DbContext 인스턴스를 생성합니다. Blazor Server의 회로 문제 방지멀티테넌트 지원을 고려한 설계입니다.


파일 경로

Azunt.DepotManagement\03_Repositories\EfCore\DepotRepository.cs
using Azunt.DepotManagement;
using Azunt.Repositories;
using Dul.Articles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Azunt.DepotManagement;

/// <summary>
/// Depot 테이블에 대한 Entity Framework Core 기반 리포지토리 구현체입니다.
/// Blazor Server 회로 유지 이슈를 피하고, 멀티테넌트 연결 문자열 지원을 위해 팩터리 사용.
/// </summary>
public class DepotRepository : IDepotRepository
{
    private readonly DepotAppDbContextFactory _factory;
    private readonly ILogger<DepotRepository> _logger;
    private readonly string? _connectionString;

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

    public DepotRepository(
        DepotAppDbContextFactory factory,
        ILoggerFactory loggerFactory,
        string connectionString)
    {
        _factory = factory;
        _logger = loggerFactory.CreateLogger<DepotRepository>();
        _connectionString = connectionString;
    }

    private DepotAppDbContext CreateContext() =>
        string.IsNullOrWhiteSpace(_connectionString)
            ? _factory.CreateDbContext()
            : _factory.CreateDbContext(_connectionString);

    public async Task<Depot> AddAsync(Depot model)
    {
        await using var context = CreateContext();
        model.CreatedAt = DateTime.UtcNow;
        model.IsDeleted = false;
        context.Depots.Add(model);
        await context.SaveChangesAsync();
        return model;
    }

    public async Task<IEnumerable<Depot>> GetAllAsync()
    {
        await using var context = CreateContext();
        return await context.Depots
            .Where(m => !m.IsDeleted)
            .OrderByDescending(m => m.Id)
            .ToListAsync();
    }

    public async Task<Depot> GetByIdAsync(long id)
    {
        await using var context = CreateContext();
        return await context.Depots
            .Where(m => m.Id == id && !m.IsDeleted)
            .SingleOrDefaultAsync()
            ?? new Depot();
    }

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

    public async Task<bool> DeleteAsync(long id)
    {
        await using var context = CreateContext();
        var entity = await context.Depots.FindAsync(id);
        if (entity == null || entity.IsDeleted) return false;

        entity.IsDeleted = true;
        context.Depots.Update(entity);
        return await context.SaveChangesAsync() > 0;
    }

    public async Task<ArticleSet<Depot, int>> GetAllAsync<TParentIdentifier>(
        int pageIndex,
        int pageSize,
        string searchField,
        string searchQuery,
        string sortOrder,
        TParentIdentifier parentIdentifier)
    {
        await using var context = CreateContext();
        var query = context.Depots
            .Where(m => !m.IsDeleted)
            .AsQueryable();

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

        query = sortOrder switch
        {
            "Name" => query.OrderBy(m => m.Name),
            "NameDesc" => query.OrderByDescending(m => m.Name),
            _ => query.OrderByDescending(m => m.Id)
        };

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

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

    public async Task<ArticleSet<Depot, long>> GetAllAsync<TParentIdentifier>(
        FilterOptions<TParentIdentifier> options)
    {
        await using var context = CreateContext();
        var query = context.Depots
            .Where(m => !m.IsDeleted)
            .AsQueryable();

        if (!string.IsNullOrEmpty(options.SearchQuery))
        {
            query = query.Where(m => m.Name != null && 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<Depot, long>(items, totalCount);
    }
}

주요 특징

  • EF Core 기반 CRUD 및 검색/페이징 기능 지원
  • DbContext 직접 DI가 아닌 팩터리 방식 사용 → Blazor Server의 회로 문제 방지
  • 멀티테넌트 환경을 위한 connectionString 생성자 주입 지원

생성자 오버로드 구조

public DepotRepository(
    DepotAppDbContextFactory factory,
    ILoggerFactory loggerFactory)

public DepotRepository(
    DepotAppDbContextFactory factory,
    ILoggerFactory loggerFactory,
    string connectionString)
  • 첫 번째 생성자는 기본 연결 문자열 사용 (appsettings.json의 "DefaultConnection")
  • 두 번째 생성자는 멀티테넌시 대응용 명시적 연결 문자열 사용

Blazor Server에서 사용하는 코드 예시

1. 기본 연결 문자열을 사용하는 DI 방식

Program.cs 또는 Startup.cs에서:

builder.Services.AddScoped<IDepotRepository, DepotRepository>();
builder.Services.AddScoped<DepotAppDbContextFactory>();

사용 예 (Blazor 컴포넌트 내부):

@inject IDepotRepository DepotRepository

@code {
    private List<Depot> depots = [];

    protected override async Task OnInitializedAsync()
    {
        depots = await DepotRepository.GetAllAsync();
    }
}

2. 명시적 connectionString을 전달하는 수동 생성 방식 (멀티테넌트)

@inject DepotAppDbContextFactory Factory
@inject ILoggerFactory LoggerFactory

@code {
    private DepotRepository? _repo;

    protected override async Task OnInitializedAsync()
    {
        var conn = $"Server=...;Database=Tenant1Db;Trusted_Connection=True;";
        _repo = new DepotRepository(Factory, LoggerFactory, conn);

        var result = await _repo.GetAllAsync();
    }
}

Azunt.DepotManagement\03_Repositories\AdoNet\DepotRepositoryAdoNet.cs

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

namespace Azunt.DepotManagement;

public class DepotRepositoryAdoNet : IDepotRepository
{
    private readonly string _connectionString;
    private readonly ILogger<DepotRepositoryAdoNet> _logger;

    public DepotRepositoryAdoNet(string connectionString, ILoggerFactory loggerFactory)
    {
        _connectionString = connectionString;
        _logger = loggerFactory.CreateLogger<DepotRepositoryAdoNet>();
    }

    private SqlConnection GetConnection() => new(_connectionString);

    public async Task<Depot> AddAsync(Depot model)
    {
        using var conn = GetConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            INSERT INTO Depots (Active, CreatedAt, CreatedBy, Name, IsDeleted)
            OUTPUT INSERTED.Id
            VALUES (@Active, @CreatedAt, @CreatedBy, @Name, 0)";
        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();
        var result = await cmd.ExecuteScalarAsync();
        if (result == null)
        {
            throw new InvalidOperationException("Failed to insert Depot. No ID was returned.");
        }
        model.Id = (long)result;
        return model;
    }

    public async Task<IEnumerable<Depot>> GetAllAsync()
    {
        var result = new List<Depot>();
        using var conn = GetConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Depots WHERE IsDeleted = 0 ORDER BY Id DESC";

        await conn.OpenAsync();
        using var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            result.Add(new Depot
            {
                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<Depot> GetByIdAsync(long id)
    {
        using var conn = GetConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Depots WHERE Id = @Id AND IsDeleted = 0";
        cmd.Parameters.AddWithValue("@Id", id);

        await conn.OpenAsync();
        using var reader = await cmd.ExecuteReaderAsync();
        if (await reader.ReadAsync())
        {
            return new Depot
            {
                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 Depot();
    }

    public async Task<bool> UpdateAsync(Depot model)
    {
        using var conn = GetConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = @"
            UPDATE Depots SET
                Active = @Active,
                Name = @Name
            WHERE Id = @Id AND IsDeleted = 0";
        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)
    {
        using var conn = GetConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "UPDATE Depots SET IsDeleted = 1 WHERE Id = @Id AND IsDeleted = 0";
        cmd.Parameters.AddWithValue("@Id", id);

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

    public async Task<ArticleSet<Depot, int>> GetAllAsync<TParentIdentifier>(
        int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier)
    {
        var all = await GetAllAsync();
        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<Depot, int>(paged, filtered.Count());
    }

    public async Task<ArticleSet<Depot, long>> GetAllAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options)
    {
        var all = await GetAllAsync();
        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<Depot, long>(paged, filtered.Count);
    }
}

Azunt.DepotManagement\03_Repositories\Dapper\DepotRepositoryDapper.cs

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

namespace Azunt.DepotManagement;

public class DepotRepositoryDapper : IDepotRepository
{
    private readonly string _connectionString;
    private readonly ILogger<DepotRepositoryDapper> _logger;

    public DepotRepositoryDapper(string connectionString, ILoggerFactory loggerFactory)
    {
        _connectionString = connectionString;
        _logger = loggerFactory.CreateLogger<DepotRepositoryDapper>();
    }

    private SqlConnection GetConnection() => new(_connectionString);

    public async Task<Depot> AddAsync(Depot model)
    {
        const string sql = @"
            INSERT INTO Depots (Active, CreatedAt, CreatedBy, Name, IsDeleted)
            OUTPUT INSERTED.Id
            VALUES (@Active, @CreatedAt, @CreatedBy, @Name, 0)";

        model.CreatedAt = DateTimeOffset.UtcNow;

        using var conn = GetConnection();
        model.Id = await conn.ExecuteScalarAsync<long>(sql, model);
        return model;
    }

    public async Task<IEnumerable<Depot>> GetAllAsync()
    {
        const string sql = @"
            SELECT Id, Active, CreatedAt, CreatedBy, Name 
            FROM Depots 
            WHERE IsDeleted = 0 
            ORDER BY Id DESC";

        using var conn = GetConnection();
        return await conn.QueryAsync<Depot>(sql);
    }

    public async Task<Depot> GetByIdAsync(long id)
    {
        const string sql = @"
            SELECT Id, Active, CreatedAt, CreatedBy, Name 
            FROM Depots 
            WHERE Id = @Id AND IsDeleted = 0";

        using var conn = GetConnection();
        return await conn.QuerySingleOrDefaultAsync<Depot>(sql, new { Id = id }) ?? new Depot();
    }

    public async Task<bool> UpdateAsync(Depot model)
    {
        const string sql = @"
            UPDATE Depots SET
                Active = @Active,
                Name = @Name
            WHERE Id = @Id AND IsDeleted = 0";

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

    public async Task<bool> DeleteAsync(long id)
    {
        const string sql = @"
            UPDATE Depots SET IsDeleted = 1 
            WHERE Id = @Id AND IsDeleted = 0";

        using var conn = GetConnection();
        var affected = await conn.ExecuteAsync(sql, new { Id = id });
        return affected > 0;
    }

    public async Task<ArticleSet<Depot, int>> GetAllAsync<TParentIdentifier>(
        int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier)
    {
        var all = await GetAllAsync();
        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<Depot, int>(paged, filtered.Count());
    }

    public async Task<ArticleSet<Depot, long>> GetAllAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options)
    {
        var all = await GetAllAsync();
        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<Depot, long>(paged, filtered.Count);
    }
}

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

목적

Azunt.DepotManagement 모듈을 사용하는 ASP.NET Core 또는 Blazor Server 프로젝트에서 의존성 주입(Dependency Injection)을 손쉽게 등록할 수 있도록 확장 메서드를 제공합니다.

이 확장 클래스는 EF Core, Dapper, ADO.NET 중 선택하여 저장소(Repository)를 등록할 수 있도록 하며, 멀티테넌트 및 다양한 아키텍처 환경에서도 유연하게 대응할 수 있습니다.


경로

Azunt.DepotManagement\04_Extensions\DepotServicesRegistrationExtensions.cs

전체 소스 코드

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

namespace Azunt.DepotManagement;

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

    /// <summary>
    /// DepotApp 모듈의 서비스를 등록합니다.
    /// </summary>
    /// <param name="services">서비스 컬렉션</param>
    /// <param name="connectionString">기본 연결 문자열</param>
    /// <param name="mode">레포지토리 모드 (EfCore, Dapper, AdoNet)</param>
    /// <param name="dbContextLifetime">DbContext 수명 주기 (기본: Transient)</param>
    public static void AddDependencyInjectionContainerForDepotApp(
        this IServiceCollection services,
        string connectionString,
        RepositoryMode mode = RepositoryMode.EfCore,
        ServiceLifetime dbContextLifetime = ServiceLifetime.Transient)
    {
        switch (mode)
        {
            case RepositoryMode.EfCore:
                // EF Core 방식 등록
                services.AddDbContext<DepotAppDbContext>(
                    options => options.UseSqlServer(connectionString),
                    dbContextLifetime);

                services.AddTransient<IDepotRepository, DepotRepository>();
                services.AddTransient<DepotAppDbContextFactory>();
                break;

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

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

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

사용 예

1. 기본 EF Core 방식으로 등록 (권장)

builder.Services.AddDependencyInjectionContainerForDepotApp(
    builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.AddTransient<DepotAppDbContextFactory>();    

2. Dapper 방식으로 등록

builder.Services.AddDependencyInjectionContainerForDepotApp(
    builder.Configuration.GetConnectionString("DefaultConnection"),
    DepotServicesRegistrationExtensions.RepositoryMode.Dapper);
builder.Services.AddTransient<DepotAppDbContextFactory>();    

3. ADO.NET 방식으로 등록

builder.Services.AddDependencyInjectionContainerForDepotApp(
    builder.Configuration.GetConnectionString("DefaultConnection"),
    DepotServicesRegistrationExtensions.RepositoryMode.AdoNet);
builder.Services.AddTransient<DepotAppDbContextFactory>();    

주의사항

  • Dapper 또는 ADO.NET 방식 사용 시, connectionString은 필수입니다.
  • EF Core 방식에서는 DepotAppDbContextFactoryDepotAppDbContext 모두 DI 컨테이너에 등록됩니다.
  • Blazor Server 환경에서는 DbContext 직접 주입을 지양하고 Factory 방식을 통해 사용해야 회로 문제가 발생하지 않습니다.

리포지토리 테스트 클래스

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

// 코드 생략...

종속성 주입

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

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

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

using Azunt.DepotManagement;

// 창고 관리: 기본 CRUD 교과서 코드
services.AddDependencyInjectionContainerForDepotApp(Configuration.GetConnectionString("DefaultConnection"));
services.AddTransient<DepotAppDbContextFactory>();

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

using Azunt.DepotManagement;

// 창고 관리: 기본 CRUD 교과서 코드
builder.Services.AddDependencyInjectionContainerForDepotApp(Configuration.GetConnectionString("DefaultConnection"));
builser.Services.AddTransient<DepotAppDbContextFactory>();

Depots 관련 MVC Controller with CRUD 뷰 페이지

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

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

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

TODO: MVC 스캐폴딩

Depots 관련 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 스캐폴딩

Azunt.Web 프로젝트 구성

NuGet 패키지 설치

Blazor Server 프로젝트인 Azunt.Web에는 다음과 같은 NuGet 패키지를 설치합니다:

<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EPPlus" Version="6.0.5" />
<PackageReference Include="Dul" Version="1.3.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />

EPPlus는 6.0.5 버전으로 테스트가 되었습니다. 이 버전을 기준으로 하겠습니다.


Depots 관련 Excel 다운로드 API 생성

Blazor Server의 창고 관리(Depots) 기능에서는 등록된 창고 데이터를 엑셀로 내려받을 수 있는 기능을 제공합니다. 이 기능은 Web API Controller로 구현되며, 엑셀 생성은 EPPlus 라이브러리를 사용합니다.


컨트롤러 경로

Azunt.Web\Apis\Depots\DepotDownloadController.cs

주요 목적

  • 인증된 관리자만 다운로드 가능 ([Authorize(Roles = "Administrators")])
  • 엑셀 시트 내 조건부 서식(Conditional Formatting) 포함
  • Blazor Server 프로젝트에서 회로 유지 이슈를 피하기 위해 팩터리 기반 리포지토리 사용

전체 소스 코드

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

namespace Azunt.Apis.Depots;

[Authorize(Roles = "Administrators")]
[Route("api/[controller]")]
[ApiController]
public class DepotDownloadController : ControllerBase
{
    private readonly IDepotRepository _repository;

    public DepotDownloadController(IDepotRepository repository)
    {
        _repository = repository;
    }

    /// <summary>
    /// 창고 리스트 엑셀 다운로드
    /// GET /api/DepotDownload/ExcelDown
    /// </summary>
    [HttpGet("ExcelDown")]
    public async Task<IActionResult> ExcelDown()
    {
        var items = await _repository.GetAllAsync();

        if (!items.Any())
        {
            return NotFound("No depot records found.");
        }

        using var package = new ExcelPackage();
        var sheet = package.Workbook.Worksheets.Add("Depots");

        // 데이터 바인딩
        var range = sheet.Cells["B2"].LoadFromCollection(
            items.Select(m => new
            {
                m.Id,
                m.Name,
                CreatedAt = m.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
                m.Active,
                m.CreatedBy
            }),
            PrintHeaders: true
        );

        // 스타일 설정
        var header = sheet.Cells["B2:F2"];
        sheet.DefaultColWidth = 22;
        range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
        range.Style.Fill.PatternType = ExcelFillStyle.Solid;
        range.Style.Fill.BackgroundColor.SetColor(Color.WhiteSmoke);
        range.Style.Border.BorderAround(ExcelBorderStyle.Medium);

        header.Style.Font.Bold = true;
        header.Style.Font.Color.SetColor(Color.White);
        header.Style.Fill.BackgroundColor.SetColor(Color.DarkBlue);

        // 조건부 서식 (예: Active 컬럼 색상 강조)
        var activeCol = range.Offset(1, 3, items.Count(), 1); // Active = 4th column (0-indexed)
        var rule = activeCol.ConditionalFormatting.AddThreeColorScale();
        rule.LowValue.Color = Color.Red;
        rule.MiddleValue.Color = Color.White;
        rule.HighValue.Color = Color.Green;

        // 파일 다운로드 반환
        var content = package.GetAsByteArray();
        return File(content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"{DateTime.Now:yyyyMMddHHmmss}_Depots.xlsx");
    }
}

Blazor Web App (Blazor Server 또는 Blazor WebAssembly)에서 Web API를 사용하려면 Program.cs 파일에서 Web API 컨트롤러 라우팅을 명확하게 설정해야 합니다.

아래는 ASP.NET Core Blazor Web App (Blazor Server 기반) 프로젝트에서 Web API를 사용 가능하도록 설정하는 방법입니다.


Program.cs에서 Web API 사용을 위한 설정

var builder = WebApplication.CreateBuilder(args);

// [1] Web API 컨트롤러 사용을 위해 추가
builder.Services.AddControllers();

// [2] Razor Pages 및 Blazor Server 지원
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

// [3] (선택) 인증 및 권한 설정
builder.Services.AddAuthorization();

// [4] (선택) Depot 모듈 등록
builder.Services.AddDependencyInjectionContainerForDepotApp(
    builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.AddTransient<DepotAppDbContextFactory>();    

var app = builder.Build();

// [5] 미들웨어 구성
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

// [6] 인증 및 권한 미들웨어 (선택 사항)
app.UseAuthentication();
app.UseAuthorization();

// [7] Web API 컨트롤러 매핑
app.MapControllers(); 

// [8] Blazor 및 Razor Pages 매핑
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

app.Run();

Blazor Server에서의 등록 예

Program.cs 예시 (기본 EF Core + DI 등록 방식)

builder.Services.AddDependencyInjectionContainerForDepotApp(
    builder.Configuration.GetConnectionString("DefaultConnection"),
    DepotServicesRegistrationExtensions.RepositoryMode.EfCore);
builder.Services.AddTransient<DepotAppDbContextFactory>();    

라우팅 예시

다운로드 API는 다음 경로로 호출됩니다:

GET /api/DepotDownload/ExcelDown

인증 권한

  • [Authorize(Roles = "Administrators")]가 적용되어 있어 관리자만 접근 가능합니다.
  • 인증되지 않은 사용자는 자동으로 로그인 페이지로 리디렉션되거나 401 Unauthorized 응답을 받습니다.

Azunt.DepotManagement: 기본 컴포넌트 뼈대 생성 가이드

이 문서에서는 Azunt.Web 프로젝트의 Components/Pages/Depots 경로 아래에 Blazor Server에서 사용할 기본 테스트용 컴포넌트 뼈대를 생성하는 방법을 안내합니다. 각 컴포넌트는 단일 <h1> 요소만 렌더링하며, 이후 기능 구현을 위한 구조 준비 용도로 활용됩니다.


전체 폴더 및 파일 구조

Azunt.Web/
└─ Components/
   └─ Pages/
      └─ Depots/
         ├─ Manage.razor
         ├─ Manage.razor.cs
         ├─ Components/
         │   ├─ DeleteDialog.razor
         │   ├─ DeleteDialog.razor.cs
         │   ├─ ModalForm.razor
         │   ├─ ModalForm.razor.cs
         │   ├─ SearchBox.razor
         │   ├─ SearchBox.razor.cs
         │   ├─ SortOrderArrow.razor
         │   └─ SortOrderArrow.razor.cs
         └─ Controls/
             └─ DepotComboBox.razor

1. Manage 컴포넌트

Manage.razor

@page "/components/pages/depots/manage"
<h1>Manage</h1>

Manage.razor.cs

using Microsoft.AspNetCore.Components;

namespace Azunt.Web.Components.Pages.Depots;

public partial class Manage : ComponentBase
{
}

2. Components 폴더

DeleteDialog.razor

@page "/components/pages/depots/components/deletedialog"
<h1>DeleteDialog</h1>

DeleteDialog.razor.cs

using Microsoft.AspNetCore.Components;

namespace Azunt.Web.Components.Pages.Depots.Components;

public partial class DeleteDialog : ComponentBase
{
}

ModalForm.razor

@page "/components/pages/depots/components/modalform"
<h1>ModalForm</h1>

ModalForm.razor.cs

using Microsoft.AspNetCore.Components;

namespace Azunt.Web.Components.Pages.Depots.Components;

public partial class ModalForm : ComponentBase
{
}

SearchBox.razor

@page "/components/pages/depots/components/searchbox"
<h1>SearchBox</h1>

SearchBox.razor.cs

using Microsoft.AspNetCore.Components;

namespace Azunt.Web.Components.Pages.Depots.Components;

public partial class SearchBox : ComponentBase
{
}

SortOrderArrow.razor

@page "/components/pages/depots/components/sortorderarrow"
<h1>SortOrderArrow</h1>

SortOrderArrow.razor.cs

using Microsoft.AspNetCore.Components;

namespace Azunt.Web.Components.Pages.Depots.Components;

public partial class SortOrderArrow : ComponentBase
{
}

3. Controls 폴더

DepotComboBox.razor

@page "/components/pages/depots/controls/depotcombobox"
<h1>DepotComboBox</h1>

4. 라우팅 테스트용 경로 목록

아래 URL을 웹 브라우저에서 요청하면 각각의 컴포넌트가 <h1> 한 줄로 출력되는지 확인할 수 있습니다.

  • /components/pages/depots/manage
  • /components/pages/depots/components/deletedialog
  • /components/pages/depots/components/modalform
  • /components/pages/depots/components/searchbox
  • /components/pages/depots/components/sortorderarrow
  • /components/pages/depots/controls/depotcombobox

Blazor Server 컴포넌트

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

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

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

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

Pages 또는 Components 폴더 (최신)
│  _Host.cshtml 또는 App.razor (최신)
│  
├─Depots
│  │  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\Depots>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
        DepotComboBox.razor

DeleteDialog.razor 컴포넌트

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

@namespace Azunt.Web.Components.Pages.Depots.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.Components.Pages.Depots.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.Components.Pages.Depots.Components
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using Azunt.DepotManagement

@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">
                    <EditForm Model="ModelEdit" OnValidSubmit="HandleValidSubmit">
                        <DataAnnotationsValidator />
                        <ValidationSummary />

                        <input type="hidden" @bind-value="ModelEdit.Id" />

                        <div class="mb-3">
                            <label for="txtName" class="form-label">Name</label>
                            <InputText id="txtName" class="form-control" placeholder="Enter Name" @bind-Value="ModelEdit.Name" />
                            <ValidationMessage For="@(() => ModelEdit.Name)" />
                        </div>

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

ModalForm.razor.cs

using Microsoft.AspNetCore.Components;
using Azunt.DepotManagement;

namespace Azunt.Web.Components.Pages.Depots.Components;

public partial class ModalForm : ComponentBase
{
    #region Properties

    /// <summary>
    /// 모달 다이얼로그 표시 여부
    /// </summary>
    public bool IsShow { get; set; } = false;

    #endregion

    #region Public Methods

    public void Show() => IsShow = true;

    public void Hide()
    {
        IsShow = false;
        StateHasChanged();
    }

    #endregion

    #region Parameters

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

    [Parameter]
    public RenderFragment EditorFormTitle { get; set; } = null!;

    [Parameter]
    public Depot ModelSender { get; set; } = null!;

    public Depot ModelEdit { get; set; } = null!;

    [Parameter]
    public Action CreateCallback { get; set; } = null!;

    [Parameter]
    public EventCallback<bool> EditCallback { get; set; }

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

    #endregion

    #region Lifecycle

    protected override void OnParametersSet()
    {
        if (ModelSender != null)
        {
            ModelEdit = new Depot
            {
                Id = ModelSender.Id,
                Name = ModelSender.Name,
                Active = ModelSender.Active,
                CreatedAt = ModelSender.CreatedAt,
                CreatedBy = ModelSender.CreatedBy
            };
        }
        else
        {
            ModelEdit = new Depot();
        }
    }

    #endregion

    #region Injectors

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

    #endregion

    #region Event Handlers

    protected async Task HandleValidSubmit()
    {
        ModelSender.Active = true;
        ModelSender.Name = ModelEdit.Name;
        ModelSender.CreatedBy = UserName ?? "Anonymous";

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

        Hide();
    }

    #endregion
}

SearchBox.razor

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

@namespace Azunt.Web.Components.Pages.Depots.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.Components.Pages.Depots.Components;

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

    #region Parameters

    /// <summary>
    /// 추가 HTML 속성 (placeholder 등) 처리
    /// </summary>
    [Parameter(CaptureUnmatchedValues = true)]
    public IDictionary<string, object> AdditionalAttributes { get; set; } = new Dictionary<string, object>();

    /// <summary>
    /// 부모 컴포넌트로 검색어 전달
    /// </summary>
    [Parameter]
    public EventCallback<string> SearchQueryChanged { get; set; }

    /// <summary>
    /// 디바운스 시간 (기본값: 300ms)
    /// </summary>
    [Parameter]
    public int Debounce { get; set; } = 300;

    #endregion

    #region Properties

    /// <summary>
    /// 검색어 바인딩 속성 (입력 시 디바운스 적용)
    /// </summary>
    public string SearchQuery
    {
        get => searchQuery;
        set
        {
            searchQuery = value;
            debounceTimer?.Stop();    // 입력 중이면 기존 타이머 중지
            debounceTimer?.Start();   // 새 타이머 시작 (입력 완료 후 실행)
        }
    }

    #endregion

    #region Lifecycle Methods

    /// <summary>
    /// 컴포넌트 초기화 시 디바운스 타이머 구성
    /// </summary>
    protected override void OnInitialized()
    {
        debounceTimer = new System.Timers.Timer
        {
            Interval = Debounce,
            AutoReset = false // 한 번만 실행되도록 설정
        };
        debounceTimer.Elapsed += SearchHandler!;
    }

    #endregion

    #region Event Handlers

    /// <summary>
    /// Search 버튼 직접 클릭 시 즉시 검색 실행
    /// </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

    /// <summary>
    /// 리소스 해제
    /// </summary>
    public void Dispose()
    {
        debounceTimer?.Dispose();
    }

    #endregion
}

SortOrderArrow.razor

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

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

@namespace Azunt.Web.Components.Pages.Depots.Components

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

@code {
    /// <summary>
    /// 현재 정렬을 적용 중인 컬럼명
    /// </summary>
    [Parameter]
    public string SortColumn { get; set; } = "";

    /// <summary>
    /// 현재 정렬 조건 문자열 (예: "Name", "NameDesc")
    /// </summary>
    [Parameter]
    public string SortOrder { get; set; } = "";

    private string arrow = " ";

    /// <summary>
    /// 파라미터 변경 시 화살표 기호 계산
    /// </summary>
    protected override void OnParametersSet()
    {
        if (string.IsNullOrWhiteSpace(SortOrder))
        {
            arrow = "↕";
        }
        else if (SortOrder.Contains(SortColumn) && SortOrder.Contains("Desc"))
        {
            arrow = "↓";
        }
        else if (SortOrder.Contains(SortColumn))
        {
            arrow = "↑";
        }
        else
        {
            arrow = " ";
        }

        StateHasChanged();
    }
}

DepotComboBox.razor

@namespace Azunt.Web.Components.Pages.Depots.Controls
@using Azunt.DepotManagement
@inject IDepotRepository DepotRepository

<div>
    <!-- 드롭다운 리스트 -->
    <select class="form-control mb-2" @onchange="OnSelectChanged">
        <option value="">-- Select a Asset Location --</option>
        @foreach (var depot in DepotList)
        {
            <option value="@depot" selected="@(depot == SelectedDepot)">
                @depot
            </option>
        }
    </select>

    <!-- 직접 입력용 텍스트박스: 필요없으면 제거 -->
    <!-- 텍스트박스 입력 시에도 SelectedDepotChanged 호출 -->
    <input class="form-control" type="text" placeholder="Or type a new depot..."
           @bind="SelectedDepot"
           @oninput="OnInputChanged" />
</div>

@code {
    [Parameter]
    public string SelectedDepot { get; set; } = "";

    [Parameter]
    public EventCallback<string> SelectedDepotChanged { get; set; }

    private List<string> DepotList { get; set; } = new();

    protected override async Task OnInitializedAsync()
    {
        var depots = await DepotRepository.GetAllAsync();
        DepotList = depots
            .Select(d => d.Name ?? "")
            .Where(n => !string.IsNullOrWhiteSpace(n))
            .Distinct()
            .ToList();
    }

    private async Task OnSelectChanged(ChangeEventArgs e)
    {
        var selected = e.Value?.ToString();
        if (!string.IsNullOrWhiteSpace(selected))
        {
            SelectedDepot = selected;
            await SelectedDepotChanged.InvokeAsync(SelectedDepot);
        }
    }

    private async Task OnInputChanged(ChangeEventArgs e)
    {
        SelectedDepot = e.Value?.ToString() ?? "";
        await SelectedDepotChanged.InvokeAsync(SelectedDepot);
    }
}

Manage.razor

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

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

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

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

Azunt.Web\Components\Pages\Depots\Manage.razor

@page "/Depots"
@page "/Depots/Manage"

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

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

<h3 class="mt-1 mb-1">
    Depots
    <span class="oi oi-plus text-primary align-baseline" @onclick="ShowEditorForm" style="cursor: pointer;">+</span>
    <button onclick="location.href = '/api/DepotDownload/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)
        {
            <p>Loading...</p>
        }
        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 />
                        }
                        else
                        {
                            <col />
                        }
                        <col style="width: 300px;" />
                    </colgroup>
                    <thead class="thead-light">
                        <tr>
                            <th class="text-center text-nowrap" @onclick="SortByName" style="cursor: pointer;">
                                Name <Azunt.Web.Components.Pages.Depots.Components.SortOrderArrow SortColumn="Name" SortOrder="@sortOrder" />
                            </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"></th>
                            }

                            <th class="text-center text-nowrap">Admin</th>
                        </tr>
                    </thead>
                    <tbody>
                        @if (models.Count == 0)
                        {
                            <tr>
                                <td colspan="@(SimpleMode ? 2 : 5)" class="text-center">
                                    No Data.
                                </td>
                            </tr>
                        }
                        else
                        {
                            @foreach (var m in models)
                            {
                                <tr>
                                    <td class="text-center text-nowrap">@m.Name</td>

                                    @if (!SimpleMode)
                                    {
                                        <td class="text-center text-nowrap small">@Dul.DateTimeUtility.ShowTimeOrDate(m.CreatedAt)</td>
                                        <td class="text-center">
                                            <input type="checkbox" checked="@(m.Active ?? false)" disabled />
                                        </td>
                                        <td></td>
                                    }

                                    @if (!SimpleMode)
                                    {
                                        <td class="text-center">
                                            <button class="btn btn-sm btn-primary" @onclick="@(() => EditBy(m))">Edit</button>
                                            <button class="btn btn-sm btn-danger" @onclick="@(() => DeleteBy(m))">Del</button>
                                            <button class="btn btn-sm btn-warning" @onclick="@(() => ToggleBy(m))">Change Active</button>
                                        </td>
                                    }
                                    else
                                    {
                                        <td class="text-center">
                                            <button class="btn btn-sm btn-primary" @onclick="@(() => EditBy(m))">Edit</button>
                                            <button class="btn btn-sm btn-danger" @onclick="@(() => DeleteBy(m))">Del</button>
                                        </td>
                                    }
                                </tr>
                            }
                        }
                    </tbody>
                </table>
            </div>
        }
    </div>

    <div class="col-md-12">
        <DulPager.DulPagerComponent Model="pager" PageIndexChanged="PageIndexChanged" />
    </div>

    <div class="col-md-12">
        <Azunt.Web.Components.Pages.Depots.Components.SearchBox placeholder="Search Depots..." SearchQueryChanged="Search" />
    </div>
</div>

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

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

@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\Depots\Manage.razor.cs

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;
using Azunt.DepotManagement;
using Azunt.Web.Components.Pages.Depots.Components;

namespace Azunt.Web.Pages.Depots;

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; } = "";
    [Parameter] public string UserId { get; set; } = "";
    [Parameter] public string UserName { get; set; } = "";
    #endregion

    #region Injectors
    [Inject] public NavigationManager NavigationManagerInjector { get; set; } = null!;
    [Inject] public IJSRuntime JSRuntimeInjector { get; set; } = null!;
    [Inject] public IDepotRepository RepositoryReference { get; set; } = null!;
    [Inject] public IConfiguration Configuration { get; set; } = null!;
    [Inject] public DepotAppDbContextFactory DbContextFactory { get; set; } = null!;
    [Inject] public UserManager<Azunt.Web.Data.ApplicationUser> UserManagerRef { get; set; } = null!;
    [Inject] public AuthenticationStateProvider AuthenticationStateProviderRef { get; set; } = null!;
    #endregion

    #region Properties
    public string EditorFormTitle { get; set; } = "CREATE";
    public ModalForm EditorFormReference { get; set; } = null!;
    public DeleteDialog DeleteDialogReference { get; set; } = null!;
    protected List<Depot> models = new();
    protected Depot model = new();
    public bool IsInlineDialogShow { get; set; } = false;
    private string searchQuery = "";
    private string sortOrder = "";
    protected DulPager.DulPagerBase pager = new()
    {
        PageNumber = 1,
        PageIndex = 0,
        PageSize = 10,
        PagerButtonCount = 5
    };
    #endregion

    #region Lifecycle
    protected override async Task OnInitializedAsync()
    {
        if (string.IsNullOrEmpty(UserId) || string.IsNullOrEmpty(UserName))
            await GetUserIdAndUserName();

        await DisplayData();
    }
    #endregion

    #region Data Load
    private async Task DisplayData()
    {
        var articleSet = ParentKey != ""
            ? await RepositoryReference.GetAllAsync<string>(pager.PageIndex, pager.PageSize, "", searchQuery, sortOrder, ParentKey)
            : await RepositoryReference.GetAllAsync<int>(pager.PageIndex, pager.PageSize, "", searchQuery, sortOrder, ParentId);

        pager.RecordCount = articleSet.TotalCount;
        models = articleSet.Items.ToList();
        StateHasChanged();
    }

    protected async void PageIndexChanged(int pageIndex)
    {
        pager.PageIndex = pageIndex;
        pager.PageNumber = pageIndex + 1;
        await DisplayData();
    }
    #endregion

    #region CRUD Events
    protected void ShowEditorForm()
    {
        EditorFormTitle = "CREATE";
        model = new Depot();
        EditorFormReference.Show();
    }

    protected void EditBy(Depot m)
    {
        EditorFormTitle = "EDIT";
        model = m;
        EditorFormReference.Show();
    }

    protected void DeleteBy(Depot m)
    {
        model = m;
        DeleteDialogReference.Show();
    }

    protected async void CreateOrEdit()
    {
        EditorFormReference.Hide();
        await Task.Delay(50);
        model = new Depot();
        await DisplayData();
    }

    protected async void DeleteClick()
    {
        var connectionString = Configuration.GetConnectionString("DefaultConnection");
        await RepositoryReference.DeleteAsync(model.Id);
        DeleteDialogReference.Hide();
        model = new Depot();
        await DisplayData();
    }
    #endregion

    #region Toggle Active
    protected void ToggleBy(Depot m)
    {
        model = m;
        IsInlineDialogShow = true;
    }

    protected void ToggleClose()
    {
        IsInlineDialogShow = false;
        model = new Depot();
    }

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

        if (string.IsNullOrWhiteSpace(connectionString))
            throw new InvalidOperationException("DefaultConnection is not configured.");

        await using var context = DbContextFactory.CreateDbContext(connectionString);
        model.Active = !model.Active;
        context.Depots.Update(model);
        await context.SaveChangesAsync();

        IsInlineDialogShow = false;
        model = new Depot();
        await DisplayData();
    }
    #endregion

    #region Search & Sort
    protected async void Search(string query)
    {
        pager.PageIndex = 0;
        searchQuery = query;
        await DisplayData();
    }

    protected async void SortByName()
    {
        sortOrder = sortOrder switch
        {
            "" => "Name",
            "Name" => "NameDesc",
            _ => ""
        };

        await DisplayData();
    }
    #endregion

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

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

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

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

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

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