Azunt.EmployeeManagement 모듈 구축 가이드

  • 72 minutes to read
// Azunt.EmployeeManagement: Building a Reusable Employee CRUD Module with Blazor Server

Employee Management

그림: Employee Management 모듈

Employee Management

Azunt.EmployeeManagement 구축 가이드 목차


C:.
│  Azunt.EmployeeManagement.csproj
│
├─01_Models
│      Employee.cs
│
├─02_Contracts
│      IEmployeeRepository.cs
│
├─03_Repositories
│  ├─AdoNet
│  │      EmployeeRepositoryAdoNet.cs
│  │
│  ├─Dapper
│  │      EmployeeRepositoryDapper.cs
│  │
│  └─EfCore
│          EmployeeDbContext.cs
│          EmployeeDbContextFactory.cs
│          EmployeeRepository.cs
│
├─04_Extensions
│      EmployeeServicesRegistrationExtensions.cs
│
├─05_Initializers
│      EmployeesTableBuilder.cs

소개

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

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


프로젝트 준비

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

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

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


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

// Initialize Azunt.Web Blazor Server project with default template and verify basic run

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

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

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

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

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

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

2. 프로젝트 참조 추가

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

위 버전들은 강의 시점의 가장 최신 버전을 설치하면 됩니다.

3. SQL Server 테이블 준비

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

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

4. DI 및 서비스 등록 준비

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

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

...

// 직원 관리: 기본 CRUD 교과서 코드
builder.Services.AddDependencyInjectionContainerForEmployeeApp(connectionString,
    EmployeeServicesRegistrationExtensions.RepositoryMode.EfCore);
builder.Services.AddTransient<EmployeeDbContextFactory>();

NuGet 패키지 설치 및 메타데이터 설정

Azunt.EmployeeManagement 프로젝트는 클래스 라이브러리로서, 기능 구현과 확장을 위해 여러 NuGet 패키지가 필요합니다.

필수 NuGet 패키지

다음은 프로젝트에서 기본적으로 참조해야 하는 NuGet 패키지 목록입니다.

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

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

  <ItemGroup>
    <PackageReference Include="Azunt" Version="1.1.7" />
    <PackageReference Include="Dul" Version="1.3.4" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.1" />
    <PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.1" />
  </ItemGroup>

</Project>

NuGet 패키지 배포 메타데이터

클래스 라이브러리를 NuGet 패키지로 배포하기 위해서는 아래와 같은 메타데이터가 필요합니다.

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

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

        <!-- Version info -->
        <VersionPrefix>1.0.0</VersionPrefix>
        <VersionSuffix></VersionSuffix>

        <!-- NuGet metadata -->
        <Title>Azunt.EmployeeManagement</Title>
        <Description>Azunt.EmployeeManagement is a reusable .NET module for managing employee information in multi-tenant systems. It provides essential DTOs and integration points for Blazor, EF Core, Dapper, and ADO.NET.</Description>
        <PackageTags>Azunt, employee, HR, multi-tenant, EFCore, Dapper, ADO.NET, Blazor, SQLServer</PackageTags>

        <Authors>VisualAcademy</Authors>
        <Company>Hawaso</Company>

        <!-- NuGet links & repository -->
        <PackageProjectUrl>https://github.com/VisualAcademy/Azunt.EmployeeManagement</PackageProjectUrl>
        <RepositoryUrl>https://github.com/VisualAcademy/Azunt.EmployeeManagement</RepositoryUrl>
        <RepositoryType>git</RepositoryType>

        <!-- Copyright -->
        <Copyright>© 2025 Hawaso. All rights reserved.</Copyright>

        <!-- README & License for NuGet -->
        <PackageReadmeFile>README.md</PackageReadmeFile>
        <PackageLicenseFile>LICENSE</PackageLicenseFile>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Azunt" Version="1.1.7" />
        <PackageReference Include="Dapper" Version="2.1.66" />
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.1" />
        <PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.1" />
    </ItemGroup>

    <!-- Include solution-root README and LICENSE in the NuGet package -->
    <ItemGroup>
        <None Include="..\..\..\README.md" Pack="true" PackagePath="" />
        <None Include="..\..\..\LICENSE"   Pack="true" PackagePath="" />
    </ItemGroup>

</Project>

웹 프로젝트에 Open Iconic 적용하기

Open Iconic 아이콘 세트 적용

ASP.NET Core / Blazor 기반 웹 프로젝트에서는 버튼, 링크, 액션 항목 등에 간단한 아이콘을 활용하기 위해 Open Iconic 아이콘 세트를 적용하는 것을 권장합니다.

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


1. 로컬 적용

Visual Studio 웹 프로젝트의 /wwwroot/lib/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://cdn.jsdelivr.net/npm/open-iconic@1.1.1/font/css/open-iconic-bootstrap.min.css" rel="stylesheet">

이 방법은 빠르게 적용할 수 있으며, 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 (choose local OR CDN) -->
    <link href="/lib/open-iconic/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" />
    <!-- OR -->
    <!--<link href="https://cdn.jsdelivr.net/npm/open-iconic@1.1.1/font/css/open-iconic-bootstrap.min.css" rel="stylesheet">-->

    <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 공식 문서를 참고

테이블 구조

-- Add Employees table script to SQL Server project

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

경로:

C:\Azunt.EmployeeManagement\src\
    Azunt.EmployeeManagement\
        Azunt.SqlServer\
            00_Employees.sql

스크립트 파일: Employees.sql

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

Employees table creation and initial seeding

Azunt.EmployeeManagement\05_Initializers\EmployeesTableBuilder.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.EmployeeManagement;

public class EmployeesTableBuilder
{
    private readonly string _masterConnectionString;
    private readonly ILogger<EmployeesTableBuilder> _logger;
    private readonly bool _enableSeeding;

    public EmployeesTableBuilder(string masterConnectionString, ILogger<EmployeesTableBuilder> logger, bool enableSeeding = true)
    {
        _masterConnectionString = masterConnectionString;
        _logger = logger;
        _enableSeeding = enableSeeding;
    }

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

        foreach (var connStr in tenantConnectionStrings)
        {
            try
            {
                EnsureEmployeesTable(connStr);
                _logger.LogInformation("Employees table processed for tenant database.");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing tenant database.");
            }
        }
    }

    public void BuildMasterDatabase()
    {
        try
        {
            EnsureEmployeesTable(_masterConnectionString);
            _logger.LogInformation("Employees table processed for master database.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error processing master database.");
        }
    }

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

        using var connection = new SqlConnection(_masterConnectionString);
        connection.Open();

        using 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 EnsureEmployeesTable(string connectionString)
    {
        using var connection = new SqlConnection(connectionString);
        connection.Open();

        // 1) 테이블 존재 여부 확인
        using (var cmdCheck = new SqlCommand(@"
            SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES 
            WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'Employees'", connection))
        {
            int tableCount = (int)cmdCheck.ExecuteScalar();

            if (tableCount == 0)
            {
                // 2) 신규 생성
                using var cmdCreate = new SqlCommand(@"
                    CREATE TABLE [dbo].[Employees] (
                        [Id]         BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
                        [Active]     BIT NULL CONSTRAINT DF_Employees_Active DEFAULT ((1)),
                        [CreatedAt]  DATETIMEOFFSET NULL CONSTRAINT DF_Employees_CreatedAt DEFAULT SYSDATETIMEOFFSET(),
                        [CreatedBy]  NVARCHAR(255) NULL,
                        [Name]       NVARCHAR(MAX) NULL,
                        [FirstName]  NVARCHAR(255) NULL,
                        [LastName]   NVARCHAR(255) NULL
                    );", connection);

                cmdCreate.ExecuteNonQuery();
                _logger.LogInformation("Employees table created.");
            }
            else
            {
                // 3) 누락된 컬럼만 추가
                var expectedColumns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
                {
                    ["Active"] = "BIT NULL CONSTRAINT DF_Employees_Active DEFAULT ((1))",
                    ["CreatedAt"] = "DATETIMEOFFSET NULL CONSTRAINT DF_Employees_CreatedAt DEFAULT SYSDATETIMEOFFSET()",
                    ["CreatedBy"] = "NVARCHAR(255) NULL",
                    ["Name"] = "NVARCHAR(MAX) NULL",
                    ["FirstName"] = "NVARCHAR(255) NULL",
                    ["LastName"] = "NVARCHAR(255) NULL"
                };

                foreach (var (columnName, typeClause) in expectedColumns)
                {
                    if (!ColumnExists(connection, "dbo", "Employees", columnName))
                    {
                        using var alterCmd = new SqlCommand(
                            $"ALTER TABLE [dbo].[Employees] ADD [{columnName}] {typeClause};", connection);
                        alterCmd.ExecuteNonQuery();
                        _logger.LogInformation("Column added: {Col} ({Type})", columnName, typeClause);
                    }
                }
            }
        }

        // 4) 초기 데이터 삽입 (토글 켜져 있을 때만)
        if (_enableSeeding)
        {
            using var cmdCountRows = new SqlCommand("SELECT COUNT(*) FROM [dbo].[Employees];", connection);
            int rowCount = (int)cmdCountRows.ExecuteScalar();

            if (rowCount == 0)
            {
                using var cmdInsertDefaults = new SqlCommand(@"
                    INSERT INTO [dbo].[Employees] (Active, CreatedAt, CreatedBy, Name, FirstName, LastName)
                    VALUES
                        (1, SYSDATETIMEOFFSET(), N'System', N'Initial Employee 1', N'Initial', N'Employee1'),
                        (1, SYSDATETIMEOFFSET(), N'System', N'Initial Employee 2', N'Initial', N'Employee2');", connection);

                int inserted = cmdInsertDefaults.ExecuteNonQuery();
                _logger.LogInformation("Employees seed inserted: {Count}", inserted);
            }
        }
    }

    private static bool ColumnExists(SqlConnection connection, string schema, string table, string column)
    {
        using var cmd = new SqlCommand(@"
            SELECT COUNT(*) 
            FROM INFORMATION_SCHEMA.COLUMNS 
            WHERE TABLE_SCHEMA = @Schema AND TABLE_NAME = @Table AND COLUMN_NAME = @Column;", connection);

        cmd.Parameters.AddWithValue("@Schema", schema);
        cmd.Parameters.AddWithValue("@Table", table);
        cmd.Parameters.AddWithValue("@Column", column);

        return (int)cmd.ExecuteScalar() > 0;
    }

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

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

TableBuilder 실행 테스트

코드: Program.cs

#region Employees 테이블 초기화/보강 및 시드
try
{
    // EmployeesTableBuilder.Run(IServiceProvider services, bool forMaster, bool enableSeeding)
    EmployeesTableBuilder.Run(app.Services, forMaster: true, enableSeeding: false);   // Master DB
    EmployeesTableBuilder.Run(app.Services, forMaster: false, enableSeeding: false);  // Tenant DBs

    Console.WriteLine("Employees table initialization completed.");
}
catch (Exception ex)
{
    Console.WriteLine($"Employees table initialization failed: {ex.Message}");
}
#endregion

app.Run();

좀 더 확장된 사용 방법은 다음과 같습니다.

코드: appsettings.json

"Database": {
    "InitializeOnStartup": true,
    "Initializers": [
        {
            "Name": "Employees",
            "ForMaster": true,
            "EnableSeeding": false
        },
        {
            "Name": "Resources",
            "ForMaster": true
        }
    ]
},

코드: Program.cs

#region Employees 테이블 초기화/보강 및 시드 (DbInitItem 클래스 없이)
try
{
    var cfg = app.Services.GetRequiredService<IConfiguration>();
    var employeesSection = cfg.GetSection("Database:Initializers")
                                .GetChildren()
                                .FirstOrDefault(x =>
                                    string.Equals(x["Name"], "Employees", StringComparison.OrdinalIgnoreCase));

    if (employeesSection != null)
    {
        bool forMaster = bool.TryParse(employeesSection["ForMaster"], out var fm) ? fm : false;
        bool enableSeeding = bool.TryParse(employeesSection["EnableSeeding"], out var es) ? es : false; // 기본값 false

        EmployeesTableBuilder.Run(app.Services, forMaster: forMaster, enableSeeding: enableSeeding);

        Console.WriteLine(
            $"Employees table initialization finished. Target={(forMaster ? "Master" : "Tenants")}, Seed={enableSeeding}"
        );
    }
    else
    {
        Console.WriteLine("Employees initializer not configured in Database:Initializers. Skipped.");
    }
}
catch (Exception ex)
{
    Console.WriteLine($"Employees table initialization failed: {ex.Message}");
}
#endregion

app.Run();

모델 클래스

// Add Employee model class mapped to Employees table

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

코드: Employee.cs

Azunt.EmployeeManagement\01_Models\Employee.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Azunt.EmployeeManagement
{
    /// <summary>
    /// Represents the Employee table mapped to the database.
    /// Each property corresponds to a column in the Employees table.
    /// </summary>
    [Table("Employees")]
    public class Employee
    {
        /// <summary>
        /// Employee unique identifier (auto-increment).
        /// </summary>
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public long Id { get; set; }

        /// <summary>
        /// Indicates whether the employee is active. Default is true.
        /// </summary>
        public bool? Active { get; set; }

        /// <summary>
        /// Record creation timestamp.
        /// </summary>
        public DateTimeOffset CreatedAt { get; set; }

        /// <summary>
        /// Name of the record creator.
        /// </summary>
        public string? CreatedBy { get; set; }

        /// <summary>
        /// Full name of the employee.
        /// </summary>
        public string? Name { get; set; }

        /// <summary>
        /// Employee's first name.
        /// </summary>
        public string? FirstName { get; set; }

        /// <summary>
        /// Employee's last name.
        /// </summary>
        public string? LastName { get; set; }
    }
}

ApplicationDbContext에 Employee 엔터티 등록하기

// Register Employee entity in ApplicationDbContext

1. 코드 예시

using Azunt.EmployeeManagement;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace Azunt.Web.Data
{
    /// <summary>
    /// 애플리케이션 전역에서 사용되는 DbContext 클래스
    /// IdentityDbContext를 상속하여 사용자 인증/인가 관련 테이블과 함께
    /// 커스텀 도메인 테이블(Employees 등)을 관리합니다.
    /// </summary>
    public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) 
        : IdentityDbContext<ApplicationUser>(options)
    {
        /// <summary>
        /// Employees 테이블에 매핑되는 엔터티 집합입니다.
        /// LINQ 쿼리 및 마이그레이션에서 사용됩니다.
        /// </summary>
        public DbSet<Employee> Employees { get; set; } = default!;
    }
}

2. 등록 목적

  • EF Core에 매핑 Employee 모델 클래스와 실제 데이터베이스 테이블(Employees)을 연결합니다.
  • LINQ 쿼리 사용 가능 context.Employees.Where(e => e.Active == true)와 같은 코드 작성 가능.
  • 마이그레이션 반영 새 엔터티를 DB 스키마에 반영하기 위해 마이그레이션을 수행할 수 있습니다.

3. 마이그레이션 적용 절차

  1. 마이그레이션 추가

    dotnet ef migrations add AddEmployeeEntity
    
  2. 데이터베이스 업데이트

    dotnet ef database update
    
  3. 결과

    • Employees 테이블이 DB에 생성되고
    • ApplicationDbContext.Employees를 통해 코드에서 접근 가능.

4. 활용 예시

// 직원 추가
using var context = new ApplicationDbContext(options);
context.Employees.Add(new Employee 
{ 
    FirstName = "Jane", 
    LastName = "Doe", 
    Active = true, 
    CreatedAt = DateTimeOffset.UtcNow, 
    CreatedBy = "System" 
});
await context.SaveChangesAsync();

// 직원 조회
var activeEmployees = await context.Employees
    .Where(e => e.Active == true)
    .ToListAsync();

리포지토리 인터페이스

// Add IEmployeeRepository interface with async CRUD and paging methods

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

Azunt.EmployeeManagement\02_Contracts\IEmployeeRepository.cs
using Azunt.Models.Common;

namespace Azunt.EmployeeManagement;

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

DbContext 클래스

// Add EmployeeDbContext with EF Core configuration and CreatedAt default value

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

Azunt.EmployeeManagement\03_Repositories\EfCore\EmployeeDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace Azunt.EmployeeManagement
{
    /// <summary>
    /// EmployeeApp에서 사용하는 EF Core DbContext.
    /// 애그리게이트 루트(Employee 등)에 대한 매핑과 공통 규칙을 구성합니다.
    /// </summary>
    public class EmployeeDbContext : DbContext
    {
        /// <summary>
        /// DbContextOptions를 받는 기본 생성자.
        /// 주로 Program.cs/Startup.cs 등록에서 사용합니다.
        /// </summary>
        public EmployeeDbContext(DbContextOptions<EmployeeDbContext> options)
            : base(options)
        {
            // 기본 조회는 변경 추적 없이 수행 (쓰기 시나리오에서는 AsTracking() 사용)
            ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        /// <summary>
        /// Employees 테이블에 매핑되는 엔터티 집합.
        /// </summary>
        public DbSet<Employee> Employees { get; set; } = null!;

        /// <summary>
        /// 모델 구성: 컬럼 타입/기본값 등 스키마 규칙 정의.
        /// </summary>
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.Entity<Employee>(entity =>
            {
                // CreatedAt: 테이블 스키마와 일치 (datetimeoffset + SYSDATETIMEOFFSET())
                entity.Property(e => e.CreatedAt)
                      .HasColumnType("datetimeoffset")
                      .HasDefaultValueSql("SYSDATETIMEOFFSET()");
            });
        }
    }
}

DbContextFactory 클래스

// Add EmployeeDbContextFactory for flexible DbContext instantiation with config support

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

Azunt.EmployeeManagement\03_Repositories\EfCore\EmployeeDbContextFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace Azunt.EmployeeManagement;

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

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

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

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

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

        return new EmployeeDbContext(options);
    }

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

        return new EmployeeDbContext(options);
    }

    /// <summary>
    /// appsettings.json의 "DefaultConnection"을 사용하여 DbContext 인스턴스를 생성합니다.
    /// </summary>
    public EmployeeDbContext 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);
    }
}

리포지토리 클래스

// Implement EmployeeRepository with EF Core support for CRUD, paging, sorting, and multi-tenant context

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

Azunt.EmployeeManagement\03_Repositories\EfCore\EmployeeRepository.cs
using Azunt.Models.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Azunt.EmployeeManagement;

/// <summary>
/// EF Core 기반의 Employee 리포지토리 구현체입니다.
/// CRUD, 검색, 페이징, 정렬을 지원하며 멀티 테넌트 연결 문자열을 처리할 수 있습니다.
/// </summary>
public class EmployeeRepository : IEmployeeRepository
{
    private readonly EmployeeDbContextFactory _factory;
    private readonly ILogger<EmployeeRepository> _logger;

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

    protected virtual EmployeeDbContext CreateContext(string? connectionString) =>
        string.IsNullOrEmpty(connectionString)
            ? _factory.CreateDbContext()
            : _factory.CreateDbContext(connectionString);

    /// <summary>
    /// 신규 직원 추가
    /// </summary>
    public async Task<Employee> AddAsync(Employee model, string? connectionString = null)
    {
        await using var context = CreateContext(connectionString);

        // DB 기본값 대신 코드 일관성을 원하면 아래 라인 유지
        model.CreatedAt = DateTimeOffset.UtcNow;

        context.Employees.Add(model);
        await context.SaveChangesAsync();
        return model;
    }

    /// <summary>
    /// 전체 목록 조회 (기본: Id DESC)
    /// </summary>
    public async Task<List<Employee>> GetAllAsync(string? connectionString = null)
    {
        await using var context = CreateContext(connectionString);
        return await context.Employees
            .OrderByDescending(m => m.Id)
            .ToListAsync();
    }

    /// <summary>
    /// Id로 단건 조회 (없으면 빈 개체 반환)
    /// </summary>
    public async Task<Employee> GetByIdAsync(long id, string? connectionString = null)
    {
        await using var context = CreateContext(connectionString);
        return await context.Employees.SingleOrDefaultAsync(m => m.Id == id)
               ?? new Employee();
    }

    /// <summary>
    /// 직원 수정 (필요 필드만 업데이트)
    /// </summary>
    public async Task<bool> UpdateAsync(Employee model, string? connectionString = null)
    {
        await using var context = CreateContext(connectionString);

        // 전역 NoTracking이어도 이 쿼리는 트래킹 모드로 강제
        var entity = await context.Employees
            .AsTracking()
            .FirstOrDefaultAsync(e => e.Id == model.Id);

        if (entity is null) return false;

        entity.Active = model.Active;
        entity.Name = model.Name;
        entity.FirstName = model.FirstName;
        entity.LastName = model.LastName;
        entity.CreatedBy = model.CreatedBy;

        return await context.SaveChangesAsync() > 0;
    }

    /// <summary>
    /// 직원 삭제
    /// </summary>
    public async Task<bool> DeleteAsync(long id, string? connectionString = null)
    {
        await using var context = CreateContext(connectionString);
        var entity = await context.Employees.FindAsync(id);
        if (entity == null) return false;

        context.Employees.Remove(entity);
        return await context.SaveChangesAsync() > 0;
    }

    /// <summary>
    /// 페이징 + 검색 + 정렬 지원 목록 조회
    /// </summary>
    public async Task<ArticleSet<Employee, int>> GetAllAsync<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.Employees.AsQueryable();

        // 검색: Name / FirstName / LastName 대상 LIKE
        if (!string.IsNullOrWhiteSpace(searchQuery))
        {
            var like = $"%{searchQuery}%";
            query = query.Where(m =>
                (m.Name != null && EF.Functions.Like(m.Name, like)) ||
                (m.FirstName != null && EF.Functions.Like(m.FirstName, like)) ||
                (m.LastName != null && EF.Functions.Like(m.LastName, like)));
        }

        // 정렬 적용
        query = ApplySorting(query, sortOrder);

        var totalCount = await query.CountAsync();

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

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

    /// <summary>
    /// sortOrder 문자열을 해석하여 안전한 정렬을 적용합니다.
    /// 지원: Id, IdDesc, Name, NameDesc, FirstName, FirstNameDesc, LastName, LastNameDesc, CreatedAt, CreatedAtDesc, Active, ActiveDesc
    /// 기본: IdDesc
    /// </summary>
    private static IQueryable<Employee> ApplySorting(IQueryable<Employee> query, string sortOrder)
    {
        var key = (sortOrder ?? string.Empty).Trim();

        return key switch
        {
            "Id" => query.OrderBy(e => e.Id),
            "IdDesc" => query.OrderByDescending(e => e.Id),

            "Name" => query.OrderBy(e => e.Name ?? string.Empty)
                                      .ThenByDescending(e => e.Id),
            "NameDesc" => query.OrderByDescending(e => e.Name ?? string.Empty)
                                      .ThenByDescending(e => e.Id),

            "FirstName" => query.OrderBy(e => e.FirstName ?? string.Empty)
                                      .ThenBy(e => e.LastName ?? string.Empty)
                                      .ThenByDescending(e => e.Id),
            "FirstNameDesc" => query.OrderByDescending(e => e.FirstName ?? string.Empty)
                                      .ThenByDescending(e => e.LastName ?? string.Empty)
                                      .ThenByDescending(e => e.Id),

            "LastName" => query.OrderBy(e => e.LastName ?? string.Empty)
                                      .ThenBy(e => e.FirstName ?? string.Empty)
                                      .ThenByDescending(e => e.Id),
            "LastNameDesc" => query.OrderByDescending(e => e.LastName ?? string.Empty)
                                      .ThenByDescending(e => e.FirstName ?? string.Empty)
                                      .ThenByDescending(e => e.Id),

            "CreatedAt" => query.OrderBy(e => e.CreatedAt)
                                      .ThenByDescending(e => e.Id),
            "CreatedAtDesc" => query.OrderByDescending(e => e.CreatedAt)
                                      .ThenByDescending(e => e.Id),

            "Active" => query.OrderBy(e => e.Active ?? false)
                                      .ThenByDescending(e => e.Id),
            "ActiveDesc" => query.OrderByDescending(e => e.Active ?? false)
                                      .ThenByDescending(e => e.Id),

            _ => query.OrderByDescending(e => e.Id) // 기본값
        };
    }
}

EmployeeRepositoryAdoNet.cs

// Add ADO.NET-based EmployeeRepository with CRUD, paging, and multi-tenant support

Azunt.EmployeeManagement\03_Repositories\AdoNet\EmployeeRepositoryAdoNet.cs

using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using System.Data;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Azunt.Models.Common;

namespace Azunt.EmployeeManagement;

public class EmployeeRepositoryAdoNet : IEmployeeRepository
{
    private readonly string _defaultConnectionString;
    private readonly ILogger<EmployeeRepositoryAdoNet> _logger;

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

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

    public async Task<Employee> AddAsync(Employee model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = @"INSERT INTO Employees (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();
        var result = await cmd.ExecuteScalarAsync();
        if (result == null || result == DBNull.Value)
        {
            throw new InvalidOperationException("Failed to insert Employee. No ID was returned.");
        }

        model.Id = Convert.ToInt64(result);
        return model;
    }

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

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

        await conn.OpenAsync();
        var reader = await cmd.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
            result.Add(new Employee
            {
                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<Employee> GetByIdAsync(long id, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Employees WHERE Id = @Id";
        cmd.Parameters.AddWithValue("@Id", id);

        await conn.OpenAsync();
        var reader = await cmd.ExecuteReaderAsync();
        if (await reader.ReadAsync())
        {
            return new Employee
            {
                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 Employee(); // 빈 모델 반환
    }

    public async Task<bool> UpdateAsync(Employee model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var cmd = conn.CreateCommand();
        cmd.CommandText = @"UPDATE Employees 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 Employees WHERE Id = @Id";
        cmd.Parameters.AddWithValue("@Id", id);

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

    public async Task<ArticleSet<Employee, int>> GetAllAsync<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<Employee, int>(paged, filtered.Count);
    }
}

EmployeeRepositoryDapper.cs

// Add Dapper-based EmployeeRepository with CRUD and simple paging support

Azunt.EmployeeManagement\03_Repositories\Dapper\EmployeeRepositoryDapper.cs

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

namespace Azunt.EmployeeManagement;

public class EmployeeRepositoryDapper : IEmployeeRepository
{
    private readonly string _defaultConnectionString;
    private readonly ILogger<EmployeeRepositoryDapper> _logger;

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

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

    public async Task<Employee> AddAsync(Employee model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = @"INSERT INTO Employees (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<Employee>> GetAllAsync(string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = "SELECT Id, Active, CreatedAt, CreatedBy, Name FROM Employees ORDER BY Id DESC";
        var list = await conn.QueryAsync<Employee>(sql);
        return list.ToList();
    }

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

    public async Task<bool> UpdateAsync(Employee model, string? connectionString = null)
    {
        var conn = GetConnection(connectionString);
        var sql = @"UPDATE Employees 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 Employees WHERE Id = @Id";
        var rows = await conn.ExecuteAsync(sql, new { Id = id });
        return rows > 0;
    }

    public async Task<ArticleSet<Employee, int>> GetAllAsync<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<Employee, int>(paged, filtered.Count);
    }
}

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

// Add DI extension class for registering EmployeeApp services with support for EF Core, Dapper, and ADO.NET

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

Azunt.EmployeeManagement\04_Extensions\EmployeeServicesRegistrationExtensions.cs

using Azunt.Models.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Azunt.EmployeeManagement;

/// <summary>
/// EmployeeApp 의존성 주입(Dependency Injection) 확장 메서드 모음
/// - EF Core / Dapper / ADO.NET 모드를 선택적으로 등록
/// - 멀티테넌트 시나리오에서 각 리포지토리 생성자에 연결 문자열을 주입
/// </summary>
public static class EmployeeServicesRegistrationExtensions
{
    /// <summary>
    /// EmployeeApp 모듈의 서비스를 등록합니다.
    /// </summary>
    /// <param name="services">서비스 컨테이너</param>
    /// <param name="connectionString">연결 문자열</param>
    /// <param name="mode">레포지토리 사용 모드 (기본: EF Core)</param>
    /// <param name="dbContextLifetime">DbContext 수명 주기 (기본: Scoped)</param>
    public static void AddDependencyInjectionContainerForEmployeeApp(
        this IServiceCollection services,
        string connectionString,
        RepositoryMode mode = RepositoryMode.EfCore,
        ServiceLifetime dbContextLifetime = ServiceLifetime.Scoped)
    {
        if (string.IsNullOrWhiteSpace(connectionString))
            throw new ArgumentException("Connection string must not be null or empty.", nameof(connectionString));

        switch (mode)
        {
            case RepositoryMode.EfCore:
                // EF Core 방식 등록
                services.AddDbContext<EmployeeDbContext>(
                    options => options.UseSqlServer(connectionString),
                    dbContextLifetime);

                // DbContextFactory는 EmployeeRepository에서 사용
                services.AddTransient<EmployeeDbContextFactory>();

                // Repository 등록
                services.AddTransient<IEmployeeRepository, EmployeeRepository>();
                break;

            case RepositoryMode.Dapper:
                // Dapper 방식 등록 (연결 문자열 + LoggerFactory 주입)
                services.AddTransient<IEmployeeRepository>(provider =>
                    new EmployeeRepositoryDapper(
                        connectionString,
                        provider.GetRequiredService<ILoggerFactory>()));
                break;

            case RepositoryMode.AdoNet:
                // ADO.NET 방식 등록 (연결 문자열 + LoggerFactory 주입)
                services.AddTransient<IEmployeeRepository>(provider =>
                    new EmployeeRepositoryAdoNet(
                        connectionString,
                        provider.GetRequiredService<ILoggerFactory>()));
                break;

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

    /// <summary>
    /// IConfiguration에서 "DefaultConnection"을 읽어 EmployeeApp 서비스를 등록합니다.
    /// </summary>
    /// <param name="services">서비스 컨테이너</param>
    /// <param name="configuration">구성(예: appsettings.json)</param>
    /// <param name="mode">레포지토리 사용 모드 (기본: EF Core)</param>
    /// <param name="dbContextLifetime">DbContext 수명 주기 (기본: Scoped)</param>
    public static void AddDependencyInjectionContainerForEmployeeApp(
        this IServiceCollection services,
        IConfiguration configuration,
        RepositoryMode mode = RepositoryMode.EfCore,
        ServiceLifetime dbContextLifetime = ServiceLifetime.Scoped)
    {
        var connectionString = configuration.GetConnectionString("DefaultConnection");
        if (string.IsNullOrWhiteSpace(connectionString))
            throw new InvalidOperationException("DefaultConnection is not configured properly.");

        services.AddDependencyInjectionContainerForEmployeeApp(
            connectionString,
            mode,
            dbContextLifetime);
    }
}

사용방법

  • Program.cs (기본 연결 문자열 사용)
builder.Services.AddDependencyInjectionContainerForEmployeeApp(
    builder.Configuration, 
    mode: RepositoryMode.EfCore, 
    dbContextLifetime: ServiceLifetime.Scoped);
  • Program.cs (명시적 연결 문자열 사용)
var conn = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDependencyInjectionContainerForEmployeeApp(
    conn!, 
    mode: RepositoryMode.Dapper);

리포지토리 테스트 클래스

// Add unit tests for EmployeeRepository with in-memory EF Core context

이 절에서는 Employees 모듈의 EF Core 리포지토리를 InMemory 데이터베이스로 검증하는 단위 테스트를 작성합니다. InMemory 공급자는 실제 SQL Server 없이도 빠르게 CRUD·페이징·정렬 로직을 검증할 수 있어, CI 파이프라인에서도 유용합니다.

사전 준비(테스트 프로젝트)

테스트 프로젝트에 다음 NuGet 패키지가 필요합니다.

  • Microsoft.EntityFrameworkCore.InMemory – InMemory DB 공급자
  • Microsoft.NET.Test.Sdk, MSTest.TestAdapter, MSTest.TestFramework – MSTest 러너
  • (선택) coverlet.collector – 코드 커버리지 수집

예시는 net9.0 기준이지만, 사용 중인 .NET 버전에 맞추어 패키지 버전을 조정하세요.

설계 포인트

1) EmployeeRepository의 컨텍스트 생성 오버라이드

EmployeeRepository는 내부적으로 CreateContext(string? connectionString)를 통해 DbContext를 생성합니다. 테스트에서는 이 메서드를 오버라이드해, 매번 동일한 InMemory 옵션으로 새 컨텍스트를 반환하도록 만들어 테스트 격리를 보장합니다.

리포지토리 원본에서 CreateContextprotected virtual이어야 하며, 테스트용 파생 클래스에서 protected override로 정확히 시그니처를 일치시켜야 합니다.

2) 테스트 격리(데이터 오염 방지)

각 테스트는 서로 영향을 주지 않아야 합니다. 본 예제는 CreateRepository(Guid.NewGuid().ToString())매 테스트마다 고유 DB 이름을 부여해 격리합니다.

3) 로깅 구성

테스트에서도 ILoggerFactory를 주입합니다. 이는 리포지토리 내부에서 예외 상황이나 경고 메시지를 확인할 때 도움이 됩니다. ServiceCollection().AddLogging()으로 간단히 구성합니다.

4) NoTracking 환경에서의 Update

실무 코드에서 DbContextQueryTrackingBehaviorNoTracking으로 두는 경우가 많습니다. 본 리포지토리는 업데이트 시 AsTracking()을 사용해 변경 감지를 강제합니다. 이 덕분에 InMemory 환경에서도 Update_Works 테스트가 안정적으로 통과합니다.


테스트 코드

파일 경로(예): Azunt.EmployeeManagement.Tests/EmployeeRepositoryTests.cs

using System;
using System.Linq; // Count(), First() 확장 메서드
using System.Threading.Tasks;
using Azunt.EmployeeManagement;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Azunt.EmployeeManagement.Tests;

[TestClass]
public class EmployeeRepositoryTests
{
    // InMemory DbContextOptions를 보관하고, 매 호출마다 새 컨텍스트를 반환
    private sealed class TestableEmployeeRepository : EmployeeRepository
    {
        private readonly DbContextOptions<EmployeeDbContext> _options;

        public TestableEmployeeRepository(
            DbContextOptions<EmployeeDbContext> options,
            ILoggerFactory loggerFactory)
            : base(new EmployeeDbContextFactory(), loggerFactory)
        {
            _options = options;
        }

        // 기반 시그니처와 정확히 일치(기반: protected virtual)
        protected override EmployeeDbContext CreateContext(string? connectionString)
            => new EmployeeDbContext(_options);
    }

    private static TestableEmployeeRepository CreateRepository(string dbName)
    {
        var options = new DbContextOptionsBuilder<EmployeeDbContext>()
            .UseInMemoryDatabase(dbName)
            .Options;

        var services = new ServiceCollection().AddLogging().BuildServiceProvider();
        var loggerFactory = services.GetRequiredService<ILoggerFactory>();

        return new TestableEmployeeRepository(options, loggerFactory);
    }

    [TestMethod]
    public async Task AddAndGetById_Works()
    {
        var repo = CreateRepository(Guid.NewGuid().ToString());

        var employee = new Employee
        {
            Name = "홍길동",
            FirstName = "길동",
            LastName = "홍",
            Active = true,
            CreatedBy = "test"
        };

        await repo.AddAsync(employee);
        var result = await repo.GetByIdAsync(employee.Id);

        Assert.AreEqual("홍길동", result.Name);
    }

    [TestMethod]
    public async Task Update_Works()
    {
        var repo = CreateRepository(Guid.NewGuid().ToString());
        var employee = new Employee { Name = "OldName" };
        await repo.AddAsync(employee);

        employee.Name = "NewName";
        var updated = await repo.UpdateAsync(employee);

        Assert.IsTrue(updated);

        var result = await repo.GetByIdAsync(employee.Id);
        Assert.AreEqual("NewName", result.Name);
    }

    [TestMethod]
    public async Task Delete_Works()
    {
        var repo = CreateRepository(Guid.NewGuid().ToString());
        var employee = new Employee { Name = "DeleteMe" };
        await repo.AddAsync(employee);

        var deleted = await repo.DeleteAsync(employee.Id);
        Assert.IsTrue(deleted);

        var result = await repo.GetByIdAsync(employee.Id);
        Assert.AreEqual(0, result.Id);
    }

    [TestMethod]
    public async Task PagingAndSorting_Works()
    {
        var repo = CreateRepository(Guid.NewGuid().ToString());
        await repo.AddAsync(new Employee { Name = "Alpha" });
        await repo.AddAsync(new Employee { Name = "Bravo" });
        await repo.AddAsync(new Employee { Name = "Charlie" });

        var result = await repo.GetAllAsync<int>(
            pageIndex: 0,
            pageSize: 2,
            searchField: "",
            searchQuery: "",
            sortOrder: "Name",
            parentIdentifier: 0);

        Assert.AreEqual(3, result.TotalCount);
        Assert.AreEqual(2, result.Items.Count());
        Assert.AreEqual("Alpha", result.Items.First().Name);
    }
}

테스트가 검증하는 시나리오

  1. Add & GetById

    • 새 직원을 추가하고 Id로 다시 조회하여 저장된 값이 일치하는지 확인합니다.
  2. Update

    • 기존 직원의 Name을 변경하고 다시 조회했을 때 변경 사항이 반영되었는지 확인합니다.
    • 내부 구현은 조회 시 AsTracking()으로 트래킹을 강제하여, 전역 NoTracking 설정에서도 정상 동작합니다.
  3. Delete

    • 직원 삭제 후 GetById에서 더 이상 유효한 키를 가지지 않도록(예: Id == 0인 빈 개체) 확인합니다.
  4. Paging & Sorting

    • 3건을 추가한 뒤, 이름 오름차순으로 정렬하여 첫 페이지(2건)를 가져오고

      • TotalCount == 3, Items.Count == 2, Items.First().Name == "Alpha"를 검증합니다.

실행 방법

  1. 솔루션에서 테스트 프로젝트를 빌드합니다.
  2. Visual Studio Test Explorer 또는 dotnet test테스트 실행합니다.
  3. 모든 테스트가 Passed로 표시되면 리포지토리의 기본 동작이 유효함을 확인한 것입니다.

종속성 주입

// Add DI extension for EmployeeApp with multi-repository mode support

Program.cs 또는 Startup.cs에서 종속성 주입(DI) 설정하기

Employee 모듈을 사용하려면, 프로젝트의 진입 지점(Program.cs 또는 Startup.cs)에서 의존성 주입(Dependency Injection)을 통해 관련 서비스(모델, 인터페이스, 리포지토리)를 등록해야 합니다. 이렇게 하면 Blazor Server, ASP.NET Core MVC 등 애플리케이션 어디에서든 IEmployeeRepository를 주입받아 사용할 수 있습니다.

Startup.cs에서 등록하는 방법 (ASP.NET Core 5 이하)

using Azunt.EmployeeManagement;

public void ConfigureServices(IServiceCollection services)
{
    // 직원 관리 모듈 등록: 기본 CRUD 코드
    services.AddDependencyInjectionContainerForEmployeeApp(
        Configuration.GetConnectionString("DefaultConnection"));

    // DbContextFactory는 리포지토리 내부에서 DbContext 생성을 위해 사용됨
    services.AddTransient<EmployeeDbContextFactory>();

    // 기타 서비스 등록 ...
}

Program.cs에서 등록하는 방법 (.NET 6 이상 Minimal Hosting)

using Azunt.EmployeeManagement;

var builder = WebApplication.CreateBuilder(args);

// 직원 관리 모듈 등록: 기본 CRUD 코드
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDependencyInjectionContainerForEmployeeApp(connectionString);

// DbContextFactory는 리포지토리 내부에서 DbContext 생성을 위해 사용됨
builder.Services.AddTransient<EmployeeDbContextFactory>();

var app = builder.Build();

// 앱 파이프라인 설정 ...
app.Run();

Employees 관련 MVC Controller with CRUD 뷰 페이지

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

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

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

TODO: MVC 스캐폴딩

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

Employees 관련 Excel 다운로드 API 생성

// Add Excel export API for Employees with admin-only access and conditional formatting

Blazor Server의 조직 관리(Employees) 기능에서는 등록된 Employee 데이터를 Excel 파일로 내려받을 수 있습니다. 이 기능은 Web API Controller로 구현되며, 엑셀 생성은 Open XML SDK 라이브러리를 사용합니다.


컨트롤러 경로

Components\Pages\Employees\Apis\EmployeeExportController.cs

주요 특징

  • 관리자 전용 다운로드 가능 ([Authorize(Roles = "Administrators")])
  • 엑셀 시트 내 조건부 서식(Conditional Formatting) 적용
  • Blazor Server 특성상 회로 유지 이슈를 피하기 위해 DI 기반 리포지토리 사용

전체 소스 코드

아래는 Employees 데이터를 Excel로 내보내는 전체 구현 예시입니다.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

using Azunt.EmployeeManagement;

// Open XML SDK
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;

namespace Azunt.Apis.Employees
{
    [Route("api/[controller]")]
    [ApiController]
    //[Authorize(Roles = "Administrators")]
    public class EmployeeExportController : ControllerBase
    {
        private readonly IEmployeeRepository _repository;

        public EmployeeExportController(IEmployeeRepository repository)
        {
            _repository = repository;
        }

        /// <summary>
        /// Employee 목록 엑셀 다운로드 (Open XML SDK)
        /// GET /api/EmployeeExport/Excel
        /// </summary>
        [HttpGet("Excel")]
        public async Task<IActionResult> ExportToExcel()
        {
            var models = await _repository.GetAllAsync();
            if (models == null || models.Count == 0)
                return NotFound("No employee records found.");

            using var stream = new MemoryStream();

            using (var doc = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook, true))
            {
                // Workbook
                var wbPart = doc.AddWorkbookPart();
                wbPart.Workbook = new Workbook();

                // Styles
                var styles = wbPart.AddNewPart<WorkbookStylesPart>();
                styles.Stylesheet = BuildStylesheet();
                styles.Stylesheet.Save();

                // Worksheet
                var wsPart = wbPart.AddNewPart<WorksheetPart>();
                var sheetData = new SheetData();

                // 컬럼 폭 (AutoFit 대체)
                var columns = new Columns(
                    new Column { Min = 2, Max = 2, Width = 12, CustomWidth = true },  // B: Id
                    new Column { Min = 3, Max = 3, Width = 28, CustomWidth = true },  // C: Name
                    new Column { Min = 4, Max = 4, Width = 22, CustomWidth = true },  // D: CreatedAt
                    new Column { Min = 5, Max = 5, Width = 12, CustomWidth = true },  // E: Active
                    new Column { Min = 6, Max = 6, Width = 24, CustomWidth = true }   // F: CreatedBy
                );

                var ws = new Worksheet();
                ws.Append(columns);
                ws.Append(sheetData);

                // 시작 위치(B2)
                const int startRow = 2;
                const int startCol = 2; // B

                // 헤더
                var headerRow = new Row { RowIndex = (uint)startRow };
                var headers = new[] { "Id", "Name", "CreatedAt", "Active", "CreatedBy" };
                for (int i = 0; i < headers.Length; i++)
                {
                    headerRow.Append(CreateTextCell(ToRef(startCol + i, startRow), headers[i], styleIndex: 2)); // 2: Header
                }
                sheetData.Append(headerRow);

                // 데이터
                var currentRow = startRow + 1;
                foreach (var m in models)
                {
                    var row = new Row { RowIndex = (uint)currentRow };

                    // B: Id (숫자)
                    row.Append(CreateNumberCell(ToRef(startCol + 0, currentRow), m.Id));

                    // C: Name (문자)
                    row.Append(CreateTextCell(ToRef(startCol + 1, currentRow), m.Name ?? string.Empty));

                    // D: CreatedAt (날짜/시간)
                    var local = m.CreatedAt.ToLocalTime();
                    row.Append(CreateDateTimeCell(ToRef(startCol + 2, currentRow), local));

                    // E: Active (FALSE 시 강조 스타일 적용)
                    var isActive = m.Active ?? false;
                    var activeStyle = isActive ? (uint)1 : (uint)4; // 1: 본문, 4: 본문+연한 빨강 배경
                    row.Append(CreateTextCell(ToRef(startCol + 3, currentRow), isActive ? "TRUE" : "FALSE", activeStyle));

                    // F: CreatedBy
                    row.Append(CreateTextCell(ToRef(startCol + 4, currentRow), m.CreatedBy ?? string.Empty));

                    sheetData.Append(row);
                    currentRow++;
                }

                wsPart.Worksheet = ws;
                wsPart.Worksheet.Save();

                // Sheets
                var sheets = new Sheets();
                sheets.Append(new Sheet
                {
                    Id = wbPart.GetIdOfPart(wsPart),
                    SheetId = 1U,
                    Name = "Employees"
                });
                wbPart.Workbook.Append(sheets);
                wbPart.Workbook.Save();
            }

            var bytes = stream.ToArray();
            var fileName = $"{DateTime.Now:yyyyMMddHHmmss}_Employees.xlsx";
            return File(
                bytes,
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                fileName
            );
        }

        // =========================
        // Stylesheet
        // 0: 기본
        // 1: 본문(얇은 테두리)
        // 2: 헤더(흰 글꼴 + 파란 배경 + 테두리)
        // 3: 날짜시간(yyyy-mm-dd hh:mm:ss + 얇은 테두리)
        // 4: 본문(얇은 테두리 + 연한 빨강 배경) → Active==FALSE 강조용
        // =========================
        private static Stylesheet BuildStylesheet()
        {
            // 사용자 정의 포맷: 164 = yyyy-mm-dd hh:mm:ss
            var nfs = new NumberingFormats { Count = 1U };
            nfs.Append(new NumberingFormat
            {
                NumberFormatId = 164U,
                FormatCode = StringValue.FromString("yyyy-mm-dd hh:mm:ss")
            });

            // Fonts
            var fonts = new Fonts { Count = 2U };
            fonts.Append(new Font(new FontSize { Val = 11 }, new Color { Theme = 1 }, new FontName { Val = "Calibri" })); // 0
            fonts.Append(new Font(new Bold(), new Color { Rgb = "FFFFFFFF" }, new FontSize { Val = 11 }, new FontName { Val = "Calibri" })); // 1

            // Fills
            var fills = new Fills { Count = 4U };
            fills.Append(new Fill(new PatternFill { PatternType = PatternValues.None }));    // 0
            fills.Append(new Fill(new PatternFill { PatternType = PatternValues.Gray125 })); // 1
            fills.Append(new Fill( // Header 파랑
                new PatternFill(
                    new ForegroundColor { Rgb = "FF1F4E79" },
                    new BackgroundColor { Indexed = 64U }
                )
                { PatternType = PatternValues.Solid })); // 2
            fills.Append(new Fill( // 연한 빨강(강조)
                new PatternFill(
                    new ForegroundColor { Rgb = "FFFFC7CE" },
                    new BackgroundColor { Indexed = 64U }
                )
                { PatternType = PatternValues.Solid })); // 3

            // Borders
            var borders = new Borders { Count = 2U };
            borders.Append(new Border()); // 0: none
            borders.Append(new Border(    // 1: thin
                new LeftBorder { Style = BorderStyleValues.Thin },
                new RightBorder { Style = BorderStyleValues.Thin },
                new TopBorder { Style = BorderStyleValues.Thin },
                new BottomBorder { Style = BorderStyleValues.Thin },
                new DiagonalBorder()
            ));

            // CellFormats
            var cfs = new CellFormats { Count = 5U };

            // 0: 기본
            cfs.Append(new CellFormat());

            // 1: 본문(테두리)
            cfs.Append(new CellFormat
            {
                BorderId = 1U,
                ApplyBorder = true
            });

            // 2: 헤더(흰 글꼴 + 파랑 배경 + 테두리)
            cfs.Append(new CellFormat
            {
                FontId = 1U,
                FillId = 2U,
                BorderId = 1U,
                ApplyFont = true,
                ApplyFill = true,
                ApplyBorder = true,
                Alignment = new Alignment { Horizontal = HorizontalAlignmentValues.Left, Vertical = VerticalAlignmentValues.Center }
            });

            // 3: 날짜시간(테두리 + 사용자 포맷 164)
            cfs.Append(new CellFormat
            {
                NumberFormatId = 164U,
                ApplyNumberFormat = true,
                BorderId = 1U,
                ApplyBorder = true
            });

            // 4: 본문(테두리 + 연한 빨강 배경)
            cfs.Append(new CellFormat
            {
                BorderId = 1U,
                FillId = 3U,
                ApplyBorder = true,
                ApplyFill = true
            });

            return new Stylesheet(nfs, fonts, fills, borders, cfs);
        }

        // =========================
        // Cell helpers
        // =========================
        private static Cell CreateTextCell(string cellRef, string text, uint styleIndex = 1) =>
            new Cell
            {
                CellReference = cellRef,
                DataType = CellValues.InlineString,
                StyleIndex = styleIndex,
                InlineString = new InlineString(
                    new DocumentFormat.OpenXml.Spreadsheet.Text(text ?? string.Empty)
                )
            };

        private static Cell CreateNumberCell(string cellRef, long number, uint styleIndex = 1) =>
            new Cell
            {
                CellReference = cellRef,
                StyleIndex = styleIndex,
                CellValue = new CellValue(number.ToString()),
                DataType = CellValues.Number
            };

        private static Cell CreateDateTimeCell(string cellRef, DateTimeOffset value, uint styleIndex = 3)
        {
            var oa = value.DateTime.ToOADate();
            return new Cell
            {
                CellReference = cellRef,
                StyleIndex = styleIndex,
                CellValue = new CellValue(oa.ToString(System.Globalization.CultureInfo.InvariantCulture)),
                DataType = CellValues.Number
            };
        }

        // =========================
        // Address helpers
        // =========================
        private static string ToRef(int colIndex, int rowIndex) => $"{ToColName(colIndex)}{rowIndex}";

        private static string ToColName(int index)
        {
            var dividend = index;
            var columnName = string.Empty;
            while (dividend > 0)
            {
                var modulo = (dividend - 1) % 26;
                columnName = (char)('A' + modulo) + columnName;
                dividend = (dividend - modulo) / 26;
            }
            return columnName;
        }
    }
}

Blazor Web App에서의 Web API 사용 설정

Blazor Web App(Blazor Server 또는 Blazor WebAssembly)에서 Web API를 사용하려면 Program.cs 파일에서 컨트롤러 서비스 등록라우팅 매핑을 해주어야 합니다.

Program.cs 예시

var builder = WebApplication.CreateBuilder(args);

// 1. MVC Controller 등록
builder.Services.AddControllers();

// 2. Employee 모듈 DI 등록 (EF Core 예시)
builder.Services.AddDependencyInjectionContainerForEmployeeApp(
    builder.Configuration.GetConnectionString("DefaultConnection"),
    EmployeeServicesRegistrationExtensions.RepositoryMode.EfCore);

builder.Services.AddTransient<EmployeeDbContextFactory>();

var app = builder.Build();

// 3. 컨트롤러 라우트 매핑
app.MapControllers(); // [ApiController] 라우트 매핑
app.MapDefaultControllerRoute(); // 기본 MVC 라우트 매핑 (선택적)

app.Run();

서비스 등록 예시 (추가)

// Employee 관리: 기본 CRUD 구성
services.AddDependencyInjectionContainerForEmployeeApp(
    Configuration.GetConnectionString("DefaultConnection"));

services.AddTransient<EmployeeDbContextFactory>();

API 호출 경로

Excel 다운로드는 다음 엔드포인트를 통해 호출할 수 있습니다:

GET /api/EmployeeExport/Excel

Azunt.Web

<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
<PackageReference Include="DulPager" Version="1.0.2" />

Blazor Server 컴포넌트

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

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

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

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

Pages 또는 Components 폴더 (최신)
│  _Host.cshtml 또는 App.razor (최신)
│  
├─Employees
│  │  EmployeeManager.razor
│  │    EmployeeManager.razor.cs
│  │  
│  └─Components
   │   ModalForm.razor
   │     ModalForm.razor.cs
   └─Controls
       EmployeeComboBox.razor

ModalForm.razor

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

@namespace Azunt.Web.Pages.Employees.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.EmployeeManagement;

namespace Azunt.Web.Pages.Employees.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 Employee ModelSender { get; set; } = null!; // null이 아닌 Employee으로 초기화

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

    #region Lifecycle Methods
    // 넘어온 Model 값을 수정 전용 ModelEdit에 담기 
    protected override void OnParametersSet()
    {
        if (ModelSender != null)
        {
            ModelEdit = new Employee
            {
                Id = ModelSender.Id,
                Name = ModelSender.Name,
                Active = ModelSender.Active,
                CreatedAt = ModelSender.CreatedAt,
                CreatedBy = ModelSender.CreatedBy
                // 필요한 필드 더 복사
            };
        }
        else
        {
            ModelEdit = new Employee();
        }
    }
    #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 IEmployeeRepository 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 Component

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

이 내용은 Azunt.Components 이름의 NuGet 패키지의 다음 컴포넌트로 대체합니다.

<Azunt.Components.Search.SearchBox />

SearchBox.razor

코드: Azunt.Components\Search\SearchBox.razor

@namespace Azunt.Components.Search

<div class="input-group mb-3">
    <input class="form-control form-control-sm"
           type="search"
           placeholder="Search..."
           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.Timers;

namespace Azunt.Components.Search;

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

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

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

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

    public string SearchQuery
    {
        get => searchQuery;
        set
        {
            searchQuery = value;
            debounceTimer?.Stop();
            debounceTimer?.Start();
        }
    }

    protected override void OnInitialized()
    {
        debounceTimer = new System.Timers.Timer { Interval = Debounce, AutoReset = false };
        debounceTimer.Elapsed += SearchHandler!;
    }

    protected void Search() => SearchQueryChanged.InvokeAsync(SearchQuery);

    protected async void SearchHandler(object? source, ElapsedEventArgs e)
    {
        await InvokeAsync(() => SearchQueryChanged.InvokeAsync(SearchQuery));
    }

    public void Dispose() => debounceTimer?.Dispose();
}

SortOrderArrow Component

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

언젠가는 QuickGrid 등으로 대체할 때까지는 정렬 기능의 화살표 텍스트 모양에 이 컴포넌트를 사용할 예정입니다.

이 내용은 Azunt.Components 이름의 NuGet 패키지의 다음 컴포넌트로 대체합니다.

<Azunt.Components.Sorting.SortOrderArrow />

SortOrderArrow.razor

코드: Azunt.Components\Sorting\SortOrderArrow.razor

@namespace Azunt.Components.Sorting

<span style="color: silver; 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 (string.IsNullOrWhiteSpace(SortOrder))
        {
            arrow = "↕";
        }
        else if (SortOrder.Contains(SortColumn) && SortOrder.Contains("Desc"))
        {
            arrow = "↓";
        }
        else if (SortOrder.Contains(SortColumn))
        {
            arrow = "↑";
        }
        else
        {
            arrow = " ";
        }
    }
}

EmployeeManager.razor

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

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

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

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

@page "/Employees"
@page "/Employees/Manage"

@namespace Azunt.Web.Components.Pages.Employees

@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Web
@rendermode InteractiveServer

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

<h3 class="mt-1 mb-1">
    Employees
    <span class="oi oi-plus text-primary align-baseline" @onclick="ShowEditorForm" style="cursor: pointer;"></span>
    <button onclick="location.href = '/api/EmployeeExport/Excel';" 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.Components.Sorting.SortOrderArrow SortColumn="Name" SortOrder="@sortOrder"></Azunt.Components.Sorting.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))" />&nbsp;
                                        @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.Components.Search.SearchBox placeholder="Search Employees..." SearchQueryChanged="Search"></Azunt.Components.Search.SearchBox>
    </div>
</div>

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

<Azunt.Components.Dialogs.DeleteDialog @ref="DeleteDialogReference" OnClickCallback="DeleteClick">
</Azunt.Components.Dialogs.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\Employees\EmployeeManager.razor.cs

using Azunt.Components.Dialogs;
using Azunt.EmployeeManagement;
using Azunt.Web.Pages.Employees.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;

namespace Azunt.Web.Components.Pages.Employees;

public partial class EmployeeManager : 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 IEmployeeRepository RepositoryReference { get; set; } = null!;

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

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

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

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

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

    /// <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.GetAllAsync<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.GetAllAsync<int>(pager.PageIndex, pager.PageSize, "", this.searchQuery, this.sortOrder, ParentId);
            pager.RecordCount = articleSet.TotalCount;
            models = articleSet.Items.ToList();
        }
        else
        {
            var articleSet = await RepositoryReference.GetAllAsync<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 Employee(); // 모델 초기화
        EditorFormReference.Show();
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        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.Employees.Update(model);
        await context.SaveChangesAsync();

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

        await DisplayData();
    }

    /// <summary>
    /// ToggleBy(PinnedBy)
    /// </summary>
    protected void ToggleBy(Employee 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, "/EmployeeDownload/ExcelDown");

        NavigationManagerInjector.NavigateTo($"/Employees"); // 다운로드 후 현재 페이지 다시 로드
    }
    #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
}

Employees용 콤보박스 컴포넌트: 기본 HTML & Fluent UI 버전

Employees 목록에서 기존 이름을 선택하거나, 새 이름을 직접 입력할 수 있는 콤보형 입력 UI를 제공합니다.

  • EmployeeComboBox: 기본 HTML <select> + <input> 조합
  • EmployeeComboBoxFluent: Microsoft Fluent UI 기반의 <FluentCombobox>

두 컴포넌트 모두 IEmployeeRepository를 통해 Employee 목록을 불러오며, @bind-SelectedEmployee로 바인딩해 사용합니다.


1) 준비 단계 (Fluent UI 사용 설정)

1-1. NuGet 패키지 설치

아래 패키지를 Web 프로젝트에 설치합니다.

dotnet add package Microsoft.FluentUI.AspNetCore.Components

1-2. Program.cs 등록

Fluent UI 컴포넌트를 서비스/미들웨어로 등록합니다. (.NET 8 기준)

var builder = WebApplication.CreateBuilder(args);

// (선택) API 컨트롤러 사용 중이면 함께 등록
builder.Services.AddControllers();

// Fluent UI
builder.Services.AddFluentUIComponents();

// Azunt Employee DI (예시)
builder.Services.AddDependencyInjectionContainerForEmployeeApp(
    builder.Configuration.GetConnectionString("DefaultConnection"),
    EmployeeServicesRegistrationExtensions.RepositoryMode.EfCore);
builder.Services.AddTransient<EmployeeDbContextFactory>();

var app = builder.Build();

// 라우팅
app.MapRazorComponents<App>();
app.MapControllers();                 // [ApiController] 라우팅
app.MapDefaultControllerRoute();      // 기본 MVC 라우트 (선택)

// Fluent UI
app.UseFluentUIComponents();

app.Run();

참고

  • AddFluentUIComponents() / UseFluentUIComponents()가 핵심입니다.
  • 컨트롤러가 있다면 AddControllers(), MapControllers(), MapDefaultControllerRoute()를 함께 사용하세요.

1-3. 네임스페이스 임포트(선택)

각 컴포넌트 파일에 @using Microsoft.FluentUI.AspNetCore.Components를 넣었으므로 필수는 아니지만, 전역 임포트를 원하면 /_Imports.razor에 아래를 추가할 수 있습니다.

@using Microsoft.FluentUI.AspNetCore.Components

2) 폴더 구조 권장안

Azunt.Web
└─ Components
   └─ Pages
      └─ Employees
         │  EmployeeManager.razor
         │  EmployeeManager.razor.cs
         └─ Controls
            │  EmployeeComboBox.razor
            │  EmployeeComboBoxFluent.razor
         └─ Components
            │  ModalForm.razor
            │  ModalForm.razor.cs

3) EmployeeComboBox.razor (기본 HTML 버전)

경로: Azunt.Web/Components/Pages/Employees/Controls/EmployeeComboBox.razor

@namespace Azunt.Web.Components.Pages.Employees.Controls
@using Azunt.EmployeeManagement
@inject IEmployeeRepository EmployeeRepository

<div class="d-flex align-items-center">
    <select class="form-control" style="width: 250px;" @onchange="OnSelectChanged">
        <option value="">-- Select an Employee --</option>
        @foreach (var name in EmployeeNames)
        {
            <option value="@name" selected="@(name == SelectedEmployee)">
                @name
            </option>
        }
    </select>

    <span class="mx-2 text-muted">or</span>

    <input class="form-control"
           type="text"
           placeholder="Or type a new employee name..."
           style="width: 250px;"
           @bind="SelectedEmployee"
           @oninput="OnInputChanged" />
</div>

@code {
    [Parameter] public string SelectedEmployee { get; set; } = "";
    [Parameter] public EventCallback<string> SelectedEmployeeChanged { get; set; }

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

    protected override async Task OnInitializedAsync()
    {
        var employees = await EmployeeRepository.GetAllAsync();
        EmployeeNames = employees
            .Select(e => e.Name ?? "")
            .Where(n => !string.IsNullOrWhiteSpace(n))
            .Distinct()
            .OrderBy(n => n)
            .ToList();
    }

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

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

4) EmployeeComboBoxFluent.razor (Fluent UI 버전)

경로: Azunt.Web/Components/Pages/Employees/Controls/EmployeeComboBoxFluent.razor

@namespace Azunt.Web.Components.Pages.Employees.Controls
@using Azunt.EmployeeManagement
@using Microsoft.FluentUI.AspNetCore.Components
@inject IEmployeeRepository EmployeeRepository

<FluentCombobox TItem="Option<string>"
                Label="Employee"
                Items="EmployeeOptions"
                OptionText="@(o => o.Text)"
                OptionValue="@(o => o.Value)"
                OptionSelected="@(o => o.Value == SelectedEmployee)"
                Value="SelectedEmployee"
                ValueChanged="OnEmployeeChanged"
                ValueExpression="() => SelectedEmployee"
                AllowFreeform="true"
                Autocomplete="ComboboxAutocomplete.List"
                Style="width: 250px;" />

@code {
    [Parameter] public string SelectedEmployee { get; set; } = "";
    [Parameter] public EventCallback<string> SelectedEmployeeChanged { get; set; }

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

    protected override async Task OnInitializedAsync()
    {
        var employees = await EmployeeRepository.GetAllAsync();
        var names = employees
            .Select(e => e.Name ?? "")
            .Where(n => !string.IsNullOrWhiteSpace(n))
            .Distinct()
            .OrderBy(n => n)
            .ToList();

        EmployeeOptions = names
            .Select(n => new Option<string>
            {
                Value = n,
                Text = n,
                Selected = n == SelectedEmployee
            })
            .ToList();

        // 넘어온 선택 값이 리스트에 없을 경우 수동 삽입
        if (!string.IsNullOrWhiteSpace(SelectedEmployee) &&
            !EmployeeOptions.Any(o => o.Value == SelectedEmployee))
        {
            EmployeeOptions.Insert(0, new Option<string>
            {
                Value = SelectedEmployee,
                Text = SelectedEmployee,
                Selected = true
            });
        }
    }

    private async Task OnEmployeeChanged(string? newValue)
    {
        SelectedEmployee = newValue ?? "";
        await SelectedEmployeeChanged.InvokeAsync(SelectedEmployee);
    }
}

5) 부모에서 사용 예시

5-1. 기본 바인딩

@* 이름 문자열을 바인딩 *@
<EmployeeComboBox @bind-SelectedEmployee="@Model.Name" />
<EmployeeComboBoxFluent @bind-SelectedEmployee="@Model.Name" />

5-2. 명시적 콜백

<EmployeeComboBox SelectedEmployee="@Model.Name"
                  SelectedEmployeeChanged="@((string name) => Model.Name = name)" />

<EmployeeComboBoxFluent SelectedEmployee="@Model.Name"
                        SelectedEmployeeChanged="@((string name) => Model.Name = name)" />

5-3. 모달 편집 폼에 끼워 넣기(예: ModalForm.razor)

<div class="mb-3">
    <label class="form-label">Name</label>
    <EmployeeComboBoxFluent @bind-SelectedEmployee="ModelEdit.Name" />
    @* 또는 기본 버전:
       <EmployeeComboBox @bind-SelectedEmployee="ModelEdit.Name" /> *@
</div>

EmployeeSummary 컴포넌트

Azunt.Web\Components\Pages\Employees\Controls\EmployeeSummary.razor

코드

@namespace Azunt.Web.Components.Pages.Employees.Controls
@using Azunt.EmployeeManagement
@inject IEmployeeRepository EmployeeRepository
@inject NavigationManager Nav

<div class="card shadow-sm h-100">
    <div class="card-header d-flex justify-content-between align-items-center">
        <h6 class="mb-0">@Title</h6>
        @if (ShowViewAllLink)
        {
            <a href="javascript:void(0);" class="small text-decoration-none" @onclick="GoEmployees">
                View all
            </a>
        }
    </div>

    <div class="card-body p-0">
        @if (isLoading)
        {
            <ul class="list-group list-group-flush">
                @for (int i = 0; i < 5; i++)
                {
                    <li class="list-group-item">
                        <div class="d-flex align-items-center">
                            <div class="rounded-circle placeholder-glow me-3" style="width:36px;height:36px;background-color:#e9ecef;"></div>
                            <div class="flex-fill">
                                <div class="placeholder-glow">
                                    <span class="placeholder col-6"></span>
                                </div>
                                <div class="placeholder-glow">
                                    <span class="placeholder col-4"></span>
                                </div>
                            </div>
                            <span class="badge bg-light text-secondary ms-2">&nbsp;</span>
                        </div>
                    </li>
                }
            </ul>
        }
        else if (items.Count == 0)
        {
            <div class="p-3 text-center text-muted">No recent employees.</div>
        }
        else
        {
            <ul class="list-group list-group-flush">
                @foreach (var e in items)
                {
                    <li class="list-group-item">
                        <div class="d-flex align-items-center">
                            <div class="rounded-circle d-flex align-items-center justify-content-center me-3"
                                 style="width:36px;height:36px;background-color:#f1f3f5;font-weight:600;">
                                @Initials(e.FirstName, e.LastName)
                            </div>

                            <div class="flex-fill">
                                <div class="fw-semibold">@FullNameText(e.FirstName, e.LastName)</div>
                                <div class="text-muted small">
                                    Created @Humanize(e.CreatedAt)
                                </div>
                            </div>

                            @if (e.Active)
                            {
                                <span class="badge bg-success-subtle text-success border border-success-subtle ms-2">Active</span>
                            }
                            else
                            {
                                <span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle ms-2">Inactive</span>
                            }
                        </div>
                    </li>
                }
            </ul>
        }
    </div>
</div>

<style>
    /* 부트스트랩 4 호환용 배지 색(있으면 사용, 없으면 fallback) */
    .bg-success-subtle {
        background-color: #e6f4ea !important;
    }

    .bg-secondary-subtle {
        background-color: #f0f1f2 !important;
    }

    .border-success-subtle {
        border-color: #cfe9d7 !important;
    }

    .border-secondary-subtle {
        border-color: #e2e3e5 !important;
    }
</style>

@code {
    // --- Parameters ---
    /// <summary>표제(카드 제목)</summary>
    [Parameter] public string Title { get; set; } = "Recent Employees";
    /// <summary>가져올 항목 수</summary>
    [Parameter] public int Count { get; set; } = 5;
    /// <summary>우측 상단 'View all' 링크 표시 여부</summary>
    [Parameter] public bool ShowViewAllLink { get; set; } = true;

    private List<EmployeeItem> items = new();
    private bool isLoading = true;

    protected override async Task OnInitializedAsync()
    {
        var all = await EmployeeRepository.GetAllAsync();
        // CreatedAt 내림차순 Top N
        items = all
            .Select(e => new EmployeeItem
            {
                Id = e.Id,                                      // long → long
                FirstName = e.FirstName ?? SplitFirst(e.Name),
                LastName = e.LastName ?? SplitLast(e.Name),
                CreatedAt = e.CreatedAt,
                Active = e.Active ?? false
            })
            .OrderByDescending(e => e.CreatedAt)
            .Take(Math.Max(1, Count))
            .ToList();

        isLoading = false;
    }

    private void GoEmployees() => Nav.NavigateTo("/Employees");

    // --- Helpers ---
    private static string SplitFirst(string? fullName)
    {
        if (string.IsNullOrWhiteSpace(fullName)) return "";
        var parts = fullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return parts.Length > 0 ? parts[0] : fullName.Trim();
    }

    private static string SplitLast(string? fullName)
    {
        if (string.IsNullOrWhiteSpace(fullName)) return "";
        var parts = fullName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : "";
    }

    private static string Initials(string first, string last)
    {
        char c1 = string.IsNullOrWhiteSpace(first) ? '?' : char.ToUpperInvariant(first[0]);
        char c2 = string.IsNullOrWhiteSpace(last) ? ' ' : char.ToUpperInvariant(last[0]);
        return (c1.ToString() + (c2 == ' ' ? "" : c2.ToString()));
    }

    private static string Humanize(DateTimeOffset utc)
    {
        var dt = utc.ToLocalTime().DateTime;
        var diff = DateTime.Now - dt;
        if (diff.TotalSeconds < 60) return "just now";
        if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} min ago";
        if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hr ago";
        if (diff.TotalDays < 7) return $"{(int)diff.TotalDays} day(s) ago";
        return dt.ToString("yyyy-MM-dd HH:mm");
    }

    private static string FullNameText(string first, string last)
    {
        if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last))
            return "(No name)";
        var full = $"{first} {last}".Trim();
        return string.IsNullOrWhiteSpace(full) ? "(No name)" : full;
    }

    private class EmployeeItem
    {
        public long Id { get; set; }                  // ★ long으로 변경
        public string FirstName { get; set; } = "";
        public string LastName { get; set; } = "";
        public DateTimeOffset CreatedAt { get; set; }
        public bool Active { get; set; }
    }
}

호출 테스트

<div class="row">
    <div class="col-md-6 mb-4">
        <EmployeeSummary Title="Recent Employees" Count="5" ShowViewAllLink="true" />
    </div>
    <div class="col-md-6 mb-4">
        <EmployeeSummary Title="Recent Employees" Count="5" ShowViewAllLink="true" />
    </div>
</div>

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

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

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

기존 .NET 프로젝트에 Azunt 모듈 적용하기

이 강좌는 NuGet 패캐지로 게시한 각각의 모듈을 새로운 프로젝트에 설치해서 사용하는 방법을 데모로 진행합니다. Resource 모듈을 기준으로 설명하겠습니다.

1. NuGet 패키지 설치

먼저 아래 NuGet 패키지를 설치합니다.

Azunt.NoteManagement.dll

이 패키지는 Note 테이블 관리, CRUD, 초기화 및 DI 구성을 포함합니다.


2. Notes 테이블 초기화 메서드 작성

초기화 로직을 다음과 같이 구성합니다.

\Infrastructures\00_Initializers\02_SecurityInitializer.cs

private static void InitializeNotesTable(IServiceProvider services, ILogger logger, bool forMaster)
{
    string target = forMaster ? "마스터 DB" : "테넌트 DB";
    try
    {
        Azunt.NoteManagement.NotesTableBuilder.Run(services, forMaster);
        logger.LogInformation($"{target}의 Notes 테이블 초기화 완료");
    }
    catch (Exception ex)
    {
        logger.LogError(ex, $"{target}의 Notes 테이블 초기화 중 오류 발생");
    }
}

3. Notes 테이블 초기화 호출 추가

DatabaseInitializer 또는 프로젝트의 초기화 루틴에 Notes 초기화를 포함합니다.

\Infrastructures\00_DatabaseInitializer.cs

try
{
    // 보안(Security) 관련 테이블 초기화
    SecurityInitializer.Initialize(services, forMaster: true);
    logger.LogInformation("보안 관련 테이블 초기화 완료");

    // Notes 테이블 초기화
    SecurityInitializer.InitializeNotesTable(services, forMaster: true);
}
catch (Exception ex)
{
    logger.LogError(ex, "보안 또는 Notes 테이블 초기화 중 오류 발생");
}

4. Program.cs에서 Note 모듈 DI 등록

#region NoteManagement 
// Note 모듈 등록 (EF Core 모드 사용)
builder.Services.AddDependencyInjectionContainerForNoteApp(
    connectionString,
    Azunt.Models.Enums.RepositoryMode.EfCore
);
builder.Services.AddTransient<NoteAppDbContextFactory>();
#endregion

5. 데이터베이스 초기화 트리거

보통 Program.csapp 실행 직전에 다음과 같이 초기화를 트리거합니다.

var app = builder.Build();

#region 데이터베이스 및 인증 스키마 초기화
var config = app.Services.GetRequiredService<IConfiguration>();
bool initializeDatabase = config.GetValue<bool>("Database:InitializeOnStartup");

if (initializeDatabase)
{
    DatabaseInitializer.Initialize(app.Services);
}
else
{
    Console.WriteLine("Database initialization is skipped (Database:InitializeOnStartup = false)");
}
#endregion

appsettings.json에서 "Database": { "InitializeOnStartup": true } 설정이 필요합니다.

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