Azunt.PostManagement 모듈 구축 가이드
// Azunt.PostManagement: Building a Reusable Post CRUD Module with Blazor Server
Azunt.PostManagement 구축 가이드 목차
- 소개
- 프로젝트 준비
- 웹 프로젝트 생성 및 기본 실행
- Azunt.PostManagement 적용 준비
- NuGet 패키지 설치
- Open Iconic 적용
- 테이블 구조
- Posts 테이블 생성 및 초기 데이터 시드 처리
- 모델 클래스
- 공통 코드
- 리포지토리 인터페이스
- DbContext 클래스
- DbContext 팩터리 클래스
- 리포지토리 클래스
- DI 등록 관련 코드 모음 클래스
- 리포지토리 테스트 클래스
- 종속성 주입
- Posts 관련 MVC Controller with CRUD 뷰 페이지
- Posts 관련 Web API Controller with CRUD
- Posts 관련 Excel 다운로드 API 생성
- Azunt.Web 프로젝트 구성
- Blazor Server 컴포넌트
- 웹 브라우저 실행 및 단계별 테스트
폴더 및 파일 생성
Azunt.PostManagement.csproj
│
├─01_Models
│ Post.cs // 포스트(Post) 모델 클래스
│
├─02_Contracts
│ IPostRepository.cs // IRepositoryBase<Post, long> 상속 인터페이스
│ IPostBaseRepository.cs
│ IPostStorageService.cs
│
├─03_Repositories
│ ├─AdoNet
│ │ PostRepositoryAdoNet.cs // ADO.NET 방식 저장소 구현
│ │
│ ├─Dapper
│ │ PostRepositoryDapper.cs // Dapper 방식 저장소 구현
│ │
│ └─EfCore
│ PostAppDbContext.cs // EF Core DbContext
│ PostAppDbContextFactory.cs // DbContext Factory
│ PostRepository.cs // EF Core 방식 저장소 구현
│
├─04_Extensions
│ PostServicesRegistrationExtensions.cs // DI(Dependency Injection) 등록 확장 클래스
│
└─05_Enhancers
PostsTableBuilder.cs // 테이블 생성 및 기본 데이터 삽입 유틸리티
Manage.razor
│ Manage.razor.cs
│
├─Apis
│ PostDownloadController.cs
│
├─Components
│ DeleteDialog.razor
│ DeleteDialog.razor.cs
│ ModalForm.razor
│ ModalForm.razor.cs
│ SearchBox.razor
│ SearchBox.razor.cs
│ SortOrderArrow.razor
│
└─Controls
PostComboBox.razor
소개
Azunt.PostManagement 패키지는 C# 클래스 라이브러리를 사용하여 SQL Server 데이터베이스에 대해 CRUD 기능을 교과서처럼 구현한 코드 모음입니다.
Azunt GitHub에 Azunt.PostManagement 이름으로 별도 리포지토리가 있으며, 이곳에서 만든 코드는 Azunt 솔루션 등에서 NuGet 패키지 형태로 포함하여 사용할 수 있습니다.
- GitHub Repository: https://github.com/Azunt/Azunt.PostManagement
이 패키지와 유사한 성격을 가지는 또 다른 패키지로는 파일 업로드 및 다운로드 기능을 염두에 둔 완성형 게시판 소스인 Memos
패키지가 있습니다. 이 내용은 Hawaso 프로젝트의 Memos 모듈을 참고하시기 바랍니다.
GitHub 저장소 생성 및 로컬 클론, README.md 수정 후 푸시하기
// Create Azunt.PostManagement repository and add initial README
이 절차에서는 VisualAcademy/Azunt.PostManagement 저장소를 사용하여, GitHub에 저장소를 만들고, 로컬에 클론(clone)한 후, README.md 파일을 수정하고 원격 저장소로 다시 푸시(sync)하는 과정을 진행합니다.
1. GitHub 저장소 생성
- GitHub에 로그인합니다.
- 새 저장소 만들기(New Repository)로 이동합니다.
- 아래와 같이 입력합니다.
- Repository name:
Azunt.PostManagement
- Description: (선택) Azunt 프로젝트의 Post 관리 모듈
- Post management module for the Azunt project using Blazor and EF Core.
- Public/Private: 필요에 따라 선택
- Initialize this repository with:
README.md
체크 (선택) (처음부터 빈 저장소로 만들 경우 체크 해제)
- Repository name:
- Create repository 버튼 클릭
※ 주의: 이미 저장소가 생성되어 있다면 이 과정은 생략합니다.
2. 로컬에 클론(Clone)하기
터미널(CMD, PowerShell, Git Bash 등)이나 VS Code를 열고 다음 명령을 실행합니다.
git clone https://github.com/VisualAcademy/Azunt.PostManagement.git
명령어 실행 후, Azunt.PostManagement
폴더가 로컬에 생성됩니다.
cd Azunt.PostManagement
3. README.md 파일 수정하기
로컬에서 Azunt.PostManagement/README.md
파일을 편집기로 열어, 내용을 수정하거나 추가합니다.
예를 들어:
# Azunt.PostManagement
Azunt 프로젝트의 Post 관리 모듈입니다.
- Entity Framework Core 기반 CRUD
- Dapper, ADO.NET 대체 구현 포함
- Blazor Server 컴포넌트 예제 제공
수정이 끝나면 파일을 저장합니다.
4. 수정 내용을 커밋(Commit)하고 푸시(Push)하기
터미널에서 다음 명령을 실행합니다.
git add README.md
git commit -m "Update README.md"
git push origin main
※ 기본 브랜치가
main
이 아닌 경우,master
나 다른 브랜치명을 사용해야 합니다.
5. 완료 확인
GitHub 사이트에 접속해 VisualAcademy/Azunt.PostManagement 저장소의 README.md
가 정상적으로 수정되었는지 확인합니다.
프로젝트 준비
// Add project structure and setup guide for Azunt.PostManagement
Azunt.PostManagement를 활용하려면 다음과 같은 프로젝트 구성이 필요합니다.
- Azunt.Web
: ASP.NET Core MVC, Blazor Server, Razor Pages가 통합된 웹 프로젝트 - Azunt.SqlServer
: SQL Server 데이터베이스 스키마를 관리하는 데이터베이스 프로젝트 - Azunt.PostManagement
: .NET 8.0 이상을 기반으로 하는 클래스 라이브러리 프로젝트 (본 강의의 중심)
Azunt.PostManagement는 Entity Framework Core를 통한 데이터베이스 접근과 Blazor Server 컴포넌트를 통한 UI 구성을 별도로 모듈화하여, 다른 프로젝트에서도 손쉽게 재사용할 수 있도록 설계되었습니다.
웹 프로젝트 생성 및 기본 실행
Azunt.PostManagement를 적용하기 전에, 먼저 Azunt.Web 웹 프로젝트를 생성하고, 정상적으로 실행해 보는 과정을 진행합니다.
이를 통해 기본 환경 구성이 완료되었는지 확인하고, 이후 Post 모듈을 적용할 준비를 합니다.
1. Visual Studio에서 Azunt.Web 프로젝트 생성
- Visual Studio 2022 이상을 실행합니다.
- Create a new project를 클릭합니다.
- Blazor Web App 템플릿을 검색하여 선택합니다.
- 프로젝트 이름을 Azunt.Web로 지정합니다.
설정 요약:
- Framework: .NET 8.0 이상
- Authentication Type: Individual Accounts (In-app 저장)
- Blazor Type: Blazor Server
- 기타 옵션: 필요에 따라 HTTPS, Docker 지원 여부 설정
2. 기본 실행 및 확인
- 프로젝트를 생성한 뒤, 별다른 수정 없이 F5 또는 Ctrl+F5 를 눌러 실행합니다.
- 기본 제공되는 Blazor Server 템플릿 화면이 정상적으로 뜨는지 확인합니다.
- 로그인/회원가입 기능이 포함되어 있어야 합니다.
여기까지 완료되면 웹 기반 프로젝트 준비가 완료된 것입니다.
Azunt.PostManagement 적용 준비
Azunt.Web 기본 실행을 확인한 후, 이제 Azunt.PostManagement 모듈을 적용할 준비를 진행합니다.
Initial commit for Azunt.PostManagement with modular post CRUD and multi-database support.
1. 클래스 라이브러리 프로젝트 추가
- 솔루션에 새 프로젝트를 추가합니다.
- Class Library (.NET) 템플릿을 선택합니다.
- 프로젝트 이름을 Azunt.PostManagement로 지정합니다.
- .NET 8.0 이상을 대상 프레임워크로 설정합니다.
기본 폴더 생성
Azunt.PostManagement/
├── Azunt.PostManagement.csproj # 프로젝트 파일
├── 01_Models/ # 도메인 모델 및 엔터티 클래스
├── 02_Contracts/ # 인터페이스 및 서비스 계약 정의
├── 03_Repositories/ # 데이터 접근 구현체 (EF Core, Dapper 등)
├── 04_Extensions/ # DI 등록 코드 조각
└── 05_Enhancers/ # 보완 기능 (예: 테이블 생성)
2. 프로젝트 참조 추가
- Azunt.Web 프로젝트에서 Project Reference로 Azunt.PostManagement를 추가합니다.
- NuGet 패키지 설치
Install-Package Azunt -Version 1.0.4
Install-Package Dul -Version 1.3.4
Install-Package Microsoft.EntityFrameworkCore -Version 9.0.4
Install-Package Microsoft.EntityFrameworkCore.SqlServer -Version 9.0.4
Install-Package System.Configuration.ConfigurationManager -Version 9.0.4
Install-Package EPPlus -Version 7.5.3
※ EPPlus는 엑셀 다운로드 기능을 위해 추가합니다. 이 패키지는 무료 버전을 기준으로 합니다.
<ItemGroup>
<PackageReference Include="Azunt" Version="1.0.6" />
<PackageReference Include="Azunt.Components" Version="1.0.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Dul" Version="1.3.4" />
<PackageReference Include="DulPager" Version="1.0.2" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.4" />
</ItemGroup>
NuGet 패키지 설치
Azunt.PostManagement 프로젝트에는 다음과 같은 NuGet 패키지가 필요합니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azunt" Version="1.0.6" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Dul" Version="1.3.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.4" />
<PackageReference Include="EPPlus" Version="7.5.3" />
</ItemGroup>
</Project>
Open Iconic
Open Iconic 적용
PostManagement 모듈에서는 버튼이나 액션 링크 등에 간단한 아이콘을 사용하기 위해
Open Iconic 아이콘 세트를 적용하는 것을 권장합니다.
다음 방법 중 하나를 선택하여 Open Iconic을 프로젝트에 적용할 수 있습니다.
1. 로컬 적용 방법
Visual Studio 프로젝트의 /wwwroot/lib/open-iconic/
경로에
Open Iconic 파일(open-iconic-bootstrap.min.css
)을 추가합니다.
그리고 App.razor
, _Host.cshtml
, 또는 _Layout.cshtml
의 <head>
영역에 다음 링크를 추가합니다:
<link href="/lib/open-iconic/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" />
2. CDN 적용 방법
별도로 파일을 다운로드하지 않고,
CDN(Content Delivery Network) 경로를 사용해 Open Iconic을 바로 연결할 수도 있습니다.
<head>
영역에 다음 링크를 추가합니다:
<link href="https://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 적용 (로컬 또는 CDN 중 하나 선택) -->
<link href="/lib/open-iconic/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" />
<!-- 또는 -->
<!--
<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 공식 문서를 참고하면 됩니다.
azunt.js 파일 적용
\wwwroot\lib\azunt\azunt.js
// Azunt 네임스페이스를 전역(window)에 정의 (이미 존재하면 덮어쓰지 않음)
window.Azunt = window.Azunt || {};
// Azunt.TimeZone 도메인을 만들어 함수 등록
window.Azunt.TimeZone = {
// 브라우저의 로컬 시간대 오프셋을 분 단위로 반환
// 예: -540 (한국, UTC+9)
getLocalOffsetMinutes: function () {
return new Date().getTimezoneOffset();
}
};
위 파일을 App.razor 또는 _Host.cshtml 파일에서 참조합니다.
App.razor 또는 _Host.cshtml
<script src="https://cdn.jsdelivr.net/npm/azunt@1.1.0/src/azunt.min.js"></script>
<script src="/lib/azunt/azunt.js"></script>
테이블 구조
// Add initial SQL script to create Posts table for post module
이번 아티클에서 사용할 SQL 테이블 구조는 다음과 같습니다. 메인 프로젝트의 데이터베이스 프로젝트에 다음 테이블을 설정합니다.
경로:
C:\Azunt.PostManagement\src\
Azunt.PostManagement\
Azunt.SqlServer\
00_Posts.sql
C:\Azunt.PostManagement\src\Azunt.PostManagement\Azunt.SqlServer\dob\Tables\Posts.sql
Posts 테이블 생성
코드: Posts.sql
-- [0][0] 포스트: Posts
CREATE TABLE [dbo].[Posts]
(
[Id] BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, -- 고유 ID
[Active] BIT NOT NULL DEFAULT(1), -- 활성 상태
[IsDeleted] BIT NOT NULL DEFAULT(0), -- 소프트 삭제
[Created] DATETIMEOFFSET(7) NOT NULL DEFAULT SYSDATETIMEOFFSET(), -- 생성 일시
[CreatedBy] NVARCHAR(255) NULL, -- 생성자
[Name] NVARCHAR(255) NULL, -- 포스트 이름
[DisplayOrder] INT NOT NULL DEFAULT(0), -- 정렬 순서
[FileName] NVARCHAR(255) NULL, -- 저장된 파일명
[FileSize] INT NULL, -- 파일 크기 (bytes)
[DownCount] INT NULL, -- 다운로드 횟수
[ParentId] BIGINT NULL, -- 연관 부모 ID
[ParentKey] NVARCHAR(255) NULL, -- 연관 부모 키
-- 게시판 스레드 관련 필드
[Ref] INT NULL DEFAULT 0, -- 그룹 번호 (원글 기준)
[Step] INT NULL DEFAULT 0, -- 들여쓰기 단계
[RefOrder] INT NULL DEFAULT 0, -- 그룹 내 정렬 순서
[AnswerNum] INT NULL DEFAULT 0, -- 답변 개수
[ParentNum] INT NULL DEFAULT 0 -- 부모 글 번호 (원글과 연결)
);
Posts 테이블 생성 및 초기 데이터 시드 처리
Implements PostsTableBuilder to create and seed the Posts table dynamically.
Azunt.PostManagement\05_Enhancers\PostsTableBuilder.cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
namespace Azunt.PostManagement
{
public class PostsTableBuilder
{
private readonly string _masterConnectionString;
private readonly ILogger<PostsTableBuilder> _logger;
public PostsTableBuilder(string masterConnectionString, ILogger<PostsTableBuilder> logger)
{
_masterConnectionString = masterConnectionString;
_logger = logger;
}
public void BuildTenantDatabases()
{
var tenantConnectionStrings = GetTenantConnectionStrings();
foreach (var connStr in tenantConnectionStrings)
{
try
{
EnsurePostsTable(connStr);
_logger.LogInformation($"Posts table processed (tenant DB): {connStr}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"[{connStr}] Error processing tenant DB");
}
}
}
public void BuildMasterDatabase()
{
try
{
EnsurePostsTable(_masterConnectionString);
_logger.LogInformation("Posts table processed (master DB)");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing master DB");
}
}
private List<string> GetTenantConnectionStrings()
{
var result = new List<string>();
using (var connection = new SqlConnection(_masterConnectionString))
{
connection.Open();
var cmd = new SqlCommand("SELECT ConnectionString FROM dbo.Tenants", connection);
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var connectionString = reader["ConnectionString"]?.ToString();
if (!string.IsNullOrEmpty(connectionString))
{
result.Add(connectionString);
}
}
}
}
return result;
}
private void EnsurePostsTable(string connectionString)
{
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
// Check if 'Posts' table exists
var cmdCheck = new SqlCommand(@"
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'Posts'", connection);
int tableCount = (int)cmdCheck.ExecuteScalar();
if (tableCount == 0)
{
// Create 'Posts' table if it doesn't exist
var cmdCreate = new SqlCommand(@"
CREATE TABLE [dbo].[Posts] (
[Id] BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, -- 고유 ID
[Active] BIT NOT NULL DEFAULT(1), -- 활성 상태
[IsDeleted] BIT NOT NULL DEFAULT(0), -- 소프트 삭제
[Created] DATETIMEOFFSET(7) NOT NULL DEFAULT SYSDATETIMEOFFSET(),-- 생성 일시
[CreatedBy] NVARCHAR(255) NULL, -- 생성자
[Name] NVARCHAR(255) NULL, -- 포스트 이름 (255자로 제한)
[Category] NVARCHAR(255) NULL DEFAULT('Free'), -- 카테고리 추가 (초기값 Free)
[DisplayOrder] INT NOT NULL DEFAULT(0), -- 정렬 순서
[FileName] NVARCHAR(255) NULL, -- 실제 저장된 파일명
[FileSize] INT NULL, -- 파일 크기 (바이트)
[DownCount] INT NULL, -- 다운로드 횟수
[ParentId] BIGINT NULL, -- 외래키 ID
[ParentKey] NVARCHAR(255) NULL,
[Ref] INT NULL DEFAULT 0,
[Step] INT NULL DEFAULT 0,
[RefOrder] INT NULL DEFAULT 0,
[AnswerNum] INT NULL DEFAULT 0,
[ParentNum] INT NULL DEFAULT 0
)", connection);
cmdCreate.ExecuteNonQuery();
_logger.LogInformation("Posts table created.");
}
// Check and add missing columns (if any)
var expectedColumns = new Dictionary<string, string>
{
// All columns from the Alls table
["ParentId"] = "BIGINT NULL",
["ParentKey"] = "NVARCHAR(255) NULL",
["CreatedBy"] = "NVARCHAR(255) NULL",
["Created"] = "DATETIMEOFFSET NULL DEFAULT SYSDATETIMEOFFSET()",
["ModifiedBy"] = "NVARCHAR(255) NULL",
["Modified"] = "DATETIMEOFFSET NULL",
["Name"] = "NVARCHAR(255) NULL",
["PostDate"] = "DATETIME NULL DEFAULT GETDATE()",
["PostIp"] = "NVARCHAR(20) NULL",
["Title"] = "NVARCHAR(512) NULL",
["Content"] = "NTEXT NULL",
["Category"] = "NVARCHAR(255) DEFAULT('Free') NULL",
["Email"] = "NVARCHAR(255) NULL",
["Password"] = "NVARCHAR(255) NULL",
["ReadCount"] = "INT DEFAULT 0 NULL",
["Encoding"] = "NVARCHAR(20) DEFAULT('HTML') NULL",
["Homepage"] = "NVARCHAR(100) NULL",
["ModifyDate"] = "DATETIME NULL",
["ModifyIp"] = "NVARCHAR(15) NULL",
["CommentCount"] = "INT DEFAULT 0 NULL",
["IsPinned"] = "BIT DEFAULT 0 NULL",
["FileName"] = "NVARCHAR(255) NULL",
["FileSize"] = "INT DEFAULT 0 NULL",
["DownCount"] = "INT DEFAULT 0 NULL",
["Ref"] = "INT DEFAULT 0 NULL",
["Step"] = "INT DEFAULT 0 NULL",
["RefOrder"] = "INT DEFAULT 0 NULL",
["AnswerNum"] = "INT DEFAULT 0 NULL",
["ParentNum"] = "INT DEFAULT 0 NULL",
["Status"] = "NVARCHAR(255) NULL",
["TenantId"] = "BIGINT DEFAULT 0 NULL",
["TenantName"] = "NVARCHAR(255) NULL",
["AppId"] = "INT DEFAULT 0 NULL",
["AppName"] = "NVARCHAR(255) NULL",
["ModuleId"] = "INT DEFAULT 0 NULL",
["ModuleName"] = "NVARCHAR(255) NULL",
["IsLocked"] = "BIT DEFAULT 0 NULL",
["Vote"] = "INT DEFAULT 0 NULL",
["Weather"] = "TINYINT DEFAULT 0 NULL",
["ReplyEmail"] = "BIT DEFAULT 0 NULL",
["Published"] = "BIT DEFAULT 0 NULL",
["BoardType"] = "NVARCHAR(100) NULL",
["BoardName"] = "NVARCHAR(255) NULL",
["NickName"] = "NVARCHAR(255) NULL",
["IconName"] = "NVARCHAR(100) NULL",
["Price"] = "DECIMAL(18,2) DEFAULT 0.00 NULL",
["Community"] = "NVARCHAR(255) NULL",
["StartDate"] = "DATETIMEOFFSET(7) NULL",
["EndDate"] = "DATETIMEOFFSET(7) NULL",
["Video"] = "NVARCHAR(1024) NULL",
["SecurityLevel"] = "NVARCHAR(10) NULL",
["AvailableCustomerLevel"] = "NVARCHAR(10) NULL",
["Num"] = "INT DEFAULT 0 NULL",
["UID"] = "INT DEFAULT 0 NULL",
["UserId"] = "NVARCHAR(255) NULL",
["UserName"] = "NVARCHAR(255) NULL",
["DivisionId"] = "INT DEFAULT 0 NULL",
["CategoryId"] = "INT DEFAULT 0 NULL",
["BoardId"] = "INT DEFAULT 0 NULL",
["ApplicationId"] = "INT DEFAULT 0 NULL",
["IsDeleted"] = "BIT DEFAULT 0 NULL",
["DeletedBy"] = "NVARCHAR(255) NULL",
["Deleted"] = "DATETIMEOFFSET NULL",
["ApprovalStatus"] = "NVARCHAR(50) NULL",
["ApprovalBy"] = "NVARCHAR(255) NULL",
["ApprovalDate"] = "DATETIMEOFFSET NULL",
["UserAgent"] = "NVARCHAR(512) NULL",
["Referer"] = "NVARCHAR(512) NULL",
["SessionId"] = "NVARCHAR(255) NULL",
["DisplayOrder"] = "INT DEFAULT 0 NULL",
["ViewRoles"] = "NVARCHAR(255) NULL",
["Tags"] = "NVARCHAR(255) NULL",
["LikeCount"] = "INT DEFAULT 0 NULL",
["DislikeCount"] = "INT DEFAULT 0 NULL",
["Rating"] = "DECIMAL(3,2) DEFAULT 0.0 NULL",
["Culture"] = "NVARCHAR(10) NULL",
["IsSystem"] = "BIT DEFAULT 0 NULL",
["SearchKeywords"] = "NVARCHAR(1024) NULL",
["SortKey"] = "NVARCHAR(255) NULL",
["Version"] = "INT DEFAULT 1 NULL",
["HistoryGroupId"] = "UNIQUEIDENTIFIER NULL",
["IsNotified"] = "BIT DEFAULT 0 NULL",
["IsSubscribed"] = "BIT DEFAULT 0 NULL",
["ExternalId"] = "NVARCHAR(255) NULL",
["ExternalUrl"] = "NVARCHAR(1024) NULL",
["SourceType"] = "NVARCHAR(50) NULL",
["IsMobile"] = "BIT DEFAULT 0 NULL"
};
foreach (var (columnName, columnDefinition) in expectedColumns)
{
var cmdColCheck = new SqlCommand(@"
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'Posts' AND COLUMN_NAME = @ColumnName", connection);
cmdColCheck.Parameters.AddWithValue("@ColumnName", columnName);
int columnExists = (int)cmdColCheck.ExecuteScalar();
if (columnExists == 0)
{
var cmdAlter = new SqlCommand($@"
ALTER TABLE [dbo].[Posts]
ADD [{columnName}] {columnDefinition}", connection);
cmdAlter.ExecuteNonQuery();
_logger.LogInformation($"Column added to Posts: {columnName} ({columnDefinition})");
}
}
// Insert default rows if the table is empty
var cmdCountRows = new SqlCommand("SELECT COUNT(*) FROM [dbo].[Posts]", connection);
int rowCount = (int)cmdCountRows.ExecuteScalar();
if (rowCount == 0)
{
var cmdInsertDefaults = new SqlCommand(@"
INSERT INTO [dbo].[Posts] (Active, IsDeleted, Created, CreatedBy, Name, DisplayOrder)
VALUES
(1, 0, SYSDATETIMEOFFSET(), 'System', 'Initial Post 1', 1),
(1, 0, SYSDATETIMEOFFSET(), 'System', 'Initial Post 2', 2)", connection);
int inserted = cmdInsertDefaults.ExecuteNonQuery();
_logger.LogInformation($"Posts default data inserted: {inserted} rows.");
}
}
}
// Run method to call EnhanceMasterDatabase or EnhanceTenantDatabases
public static void Run(IServiceProvider services, bool forMaster, string? optionalConnectionString = null)
{
try
{
var logger = services.GetRequiredService<ILogger<PostsTableBuilder>>();
var config = services.GetRequiredService<IConfiguration>();
string connectionString;
if (!string.IsNullOrWhiteSpace(optionalConnectionString))
{
connectionString = optionalConnectionString;
}
else
{
var tempConnectionString = config.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(tempConnectionString))
{
throw new InvalidOperationException("DefaultConnection is not configured in appsettings.json.");
}
connectionString = tempConnectionString;
}
var builder = new PostsTableBuilder(connectionString, logger);
if (forMaster)
{
builder.BuildMasterDatabase();
}
else
{
builder.BuildTenantDatabases();
}
}
catch (Exception ex)
{
var fallbackLogger = services.GetService<ILogger<PostsTableBuilder>>();
fallbackLogger?.LogError(ex, "Error while processing Posts table.");
}
}
}
}
소개
멀티 테넌트 환경에서 마스터 및 각 테넌트 데이터베이스에 Posts
테이블을 생성하고, 필요한 경우 누락된 컬럼을 추가하며, 기본 데이터를 삽입합니다.
사용 방법
1. 프로젝트 참조 추가
PostsTableBuilder
클래스를 사용하려면, 해당 클래스가 포함된 클래스 라이브러리 프로젝트를 웹 프로젝트에 참조로 추가합니다.
2. PostsTableBuilder.Run()
호출
예를 들어, AssetSchemaInitializer
클래스에서 Posts 테이블 초기화를 호출하려면 다음 메서드를 추가합니다:
private static void InitializePostsTable(IServiceProvider services, ILogger logger, bool forMaster)
{
string target = forMaster ? "마스터 DB" : "테넌트 DB";
try
{
Azunt.PostManagement.PostsTableBuilder.Run(services, forMaster);
logger.LogInformation($"{target}의 Posts 테이블 초기화 완료");
}
catch (Exception ex)
{
logger.LogError(ex, $"{target}의 Posts 테이블 초기화 중 오류 발생");
}
}
그 다음 Initialize()
메서드 내부에 호출을 삽입합니다:
InitializePostsTable(services, logger, forMaster: true);
3. Program.cs
에서 직접 호출 예시
Blazor Web App의 Program.cs
에서 직접 초기화할 수도 있습니다:
using Azunt.PostManagement; // 네임스페이스 임포트
var builder = WebApplication.CreateBuilder(args);
// 필요한 서비스 등록 ...
var app = builder.Build();
// 마스터 DB 대상 초기화
PostsTableBuilder.Run(app.Services, forMaster: true);
// 테넌트 DB 대상 초기화
PostsTableBuilder.Run(app.Services, forMaster: false);
// 또는 명시적 연결 문자열 사용
// PostsTableBuilder.Run(app.Services, forMaster: true, optionalConnectionString: "Server=...;Database=...;...");
app.Run();
NuGet 패키지로 Azunt.PostManagement 시작 버전 게시하기
Azunt.PostManagement
프로젝트를 NuGet 패키지로 만들어 nuget.org 또는 사내 NuGet 서버에 게시하면, 다른 .NET 프로젝트에서 쉽게 참조하여 사용할 수 있습니다.
1. .csproj
설정 확인
Azunt.PostManagement.csproj
파일에 NuGet 메타데이터를 포함시킵니다:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 버전 정보 -->
<VersionPrefix>1.0.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<!-- NuGet 기본 정보 -->
<Title>Azunt.PostManagement</Title>
<Description>Azunt.PostManagement is a reusable .NET module for managing posts in multi-tenant systems. It supports SQL Server and is designed for extensibility with EF Core, Dapper, and ADO.NET.</Description>
<PackageTags>Azunt, post, content, multi-tenant, EFCore, Dapper, ADO.NET, Blazor, SQLServer</PackageTags>
<Authors>VisualAcademy</Authors>
<Company>Hawaso</Company>
<!-- NuGet 링크 및 저장소 -->
<PackageProjectUrl>https://github.com/VisualAcademy/Azunt.PostManagement</PackageProjectUrl>
<RepositoryUrl>https://github.com/VisualAcademy/Azunt.PostManagement</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<!-- 저작권 정보 -->
<Copyright>© 2025 Hawaso. All rights reserved.</Copyright>
</PropertyGroup>
<ItemGroup>
<Folder Include="01_Models\" />
<Folder Include="02_Contracts\" />
<Folder Include="03_Repositories\" />
<Folder Include="04_Extensions\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azunt" Version="1.0.6" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Dul" Version="1.3.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
<PackageReference Include="EPPlus" Version="7.5.3" />
</ItemGroup>
</Project>
2. 패키지 빌드
dotnet pack -c Release
출력 결과:
./bin/Release/Azunt.PostManagement.1.0.0.nupkg
3. NuGet에 게시
dotnet nuget push ./bin/Release/Azunt.PostManagement.1.0.0.nupkg --api-key [YourKey] --source https://api.nuget.org/v3/index.json
사설 피드인 경우
--source
경로를 바꿔주세요.
다른 프로젝트에서 PostsTableBuilder 사용하기
게시된 NuGet 패키지를 다른 Blazor Web App 또는 ASP.NET Core 프로젝트에 설치한 뒤, Posts
테이블을 동적으로 생성하고 초기화할 수 있습니다.
1. NuGet 패키지 설치
dotnet add package Azunt.PostManagement
또는 Visual Studio에서 NuGet 브라우저로 설치합니다.
2. 테이블 빌더 호출을 위한 구성
서비스 등록 및 호출 (예: Program.cs
또는 AssetSchemaInitializer.cs
)
using Azunt.PostManagement;
PostsTableBuilder.Run(app.Services, forMaster: true); // 마스터 DB 대상
PostsTableBuilder.Run(app.Services, forMaster: false); // 테넌트 DB 대상
선택적으로 연결 문자열을 직접 전달할 수도 있습니다:
PostsTableBuilder.Run(app.Services, forMaster: true, optionalConnectionString: "Server=...;Database=...;");
3. 실제 초기화 메서드에 통합 예시
AssetSchemaInitializer.cs
클래스 내에 다음처럼 메서드를 추가하고:
private static void InitializePostsTable(IServiceProvider services, ILogger logger, bool forMaster)
{
string target = forMaster ? "마스터 DB" : "테넌트 DB";
try
{
PostsTableBuilder.Run(services, forMaster);
logger.LogInformation($"{target}의 Posts 테이블 초기화 완료");
}
catch (Exception ex)
{
logger.LogError(ex, $"{target}의 Posts 테이블 초기화 중 오류 발생");
}
}
Initialize()
메서드에서 호출합니다:
InitializePostsTable(services, logger, forMaster: true);
모델 클래스
// Add Post entity class with schema mapping and validation
다음 코드는 포스트 정보를 나타내는 모델 클래스입니다. Post
, PostDto
등 원하는 형태의 모델명을 사용하세요.
Post.cs
위치:
Azunt.PostManagement\01_Models\Post.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Azunt.PostManagement
{
/// <summary>
/// Posts 테이블과 매핑되는 포스트(Post) 엔터티 클래스입니다.
/// </summary>
[Table("Posts")]
public class Post
{
/// <summary>
/// 포스트 고유 아이디 (자동 증가)
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
/// <summary>
/// 활성 상태 (기본값: true)
/// </summary>
public bool? Active { get; set; }
/// <summary>
/// 소프트 삭제 플래그 (기본값: false)
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// 생성 일시
/// </summary>
public DateTimeOffset Created { get; set; }
/// <summary>
/// 생성자 이름
/// </summary>
public string? CreatedBy { get; set; }
/// <summary>
/// 포스트 이름
/// </summary>
[Required(ErrorMessage = "Name is required.")]
[StringLength(255, ErrorMessage = "Name cannot exceed 255 characters.")]
public string? Name { get; set; }
/// <summary>
/// 정렬 순서
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// 실제 파일 이름
/// </summary>
[StringLength(255)]
public string? FileName { get; set; }
/// <summary>
/// 파일 크기 (바이트)
/// </summary>
public int? FileSize { get; set; }
/// <summary>
/// 다운로드 횟수
/// </summary>
public int? DownCount { get; set; }
/// <summary>
/// 숫자 형식의 외래키? - AppId 형태로 ParentId와 ParentKey 속성은 보조로 만들어 놓은 속성
/// </summary>
public long? ParentId { get; set; } = default; // long? 형식으로 변경 가능
/// <summary>
/// 숫자 형식의 외래키? - AppId 형태로 ParentId와 ParentKey 속성은 보조로 만들어 놓은 속성
/// </summary>
public string? ParentKey { get; set; } = string.Empty;
public string? Category { get; set; }
}
}
이 클래스는 Posts
테이블과 1:1로 매핑되며, EF Core, Dapper, ADO.NET 모두에서 공통으로 사용됩니다.
테이블 생성 시 참고할 필드와 데이터 형식을 그대로 반영하고 있으며,
Created
은 서버 시간으로 자동 설정되도록 처리하는 것이 일반적입니다.
필요하면 Description
, Location
, Capacity
등의 추가 필드를 나중에 확장 가능합니다.
공통 코드
ArticleSet<T, V>
클래스는 페이징 처리에 최적화된 제네릭 데이터 컨테이너로, Azunt NuGet 패키지에 포함되어 제공됩니다. 이 클래스는 특정 페이지의 항목 목록(Items
)과 전체 항목 수(TotalCount
)를 함께 표현할 수 있어, 페이징된 데이터를 효과적으로 처리하고 전달하는 데 유용합니다.
구성 요소 설명:
T
: 반환되는 항목의 형식 (예: 도메인 모델 클래스)V
: 전체 항목 수의 형식 (예:int
,long
등)
이 클래스는
Azunt.dll
의 일부이며, NuGet 패키지로 재사용 가능한 공통 모델로 설계되었습니다.
파일 경로:
C:\Azunt.dll\src\Azunt\Azunt\Models\Common\ArticleSet.cs
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// Represents a generic data container for paginated or grouped results.
/// </summary>
/// <typeparam name="T">The type of items in the result set.</typeparam>
/// <typeparam name="V">The type of the total count (e.g., int, long).</typeparam>
public class ArticleSet<T, V>
{
/// <summary>
/// Gets or sets the collection of items.
/// </summary>
public IEnumerable<T> Items { get; set; }
/// <summary>
/// Gets or sets the total count of items (e.g., for pagination).
/// </summary>
public V TotalCount { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="ArticleSet{T, V}"/> class
/// with an empty items list and default total count.
/// </summary>
public ArticleSet()
{
Items = Enumerable.Empty<T>();
TotalCount = default(V);
}
/// <summary>
/// Initializes a new instance of the <see cref="ArticleSet{T, V}"/> class
/// with the specified items and total count.
/// </summary>
/// <param name="items">The items to include in the result set.</param>
/// <param name="totalCount">The total count of items.</param>
public ArticleSet(IEnumerable<T> items, V totalCount)
{
Items = items ?? Enumerable.Empty<T>();
TotalCount = totalCount;
}
}
Azunt.Repositories.IRepositoryBase 사용
Azunt/Repositories/IRepositoryBase.cs
IRepositoryBase<T, TId>
는 Azunt 전역에서 기본 CRUD(Create, Read, Update, Delete) 작업을 표준화하기 위해 사용되는 최상위 공통 리포지토리 인터페이스입니다.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Azunt.Repositories
{
/// <summary>
/// Defines a generic repository interface for basic CRUD operations.
/// This is the base abstraction used across the Azunt ecosystem to unify data access logic.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TId">The identifier type (e.g., int, long, Guid, string).</typeparam>
public interface IRepositoryBase<T, TId> where T : class
{
/// <summary>
/// Adds a new entity to the data store.
/// </summary>
/// <param name="entity">The entity to add.</param>
/// <returns>The added entity with any generated values populated.</returns>
Task<T> AddAsync(T entity);
/// <summary>
/// Retrieves all entities of the specified type.
/// </summary>
/// <returns>A collection of all entities.</returns>
Task<IEnumerable<T>> GetAllAsync();
/// <summary>
/// Retrieves a single entity by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the entity.</param>
/// <returns>The entity if found; otherwise, null.</returns>
Task<T> GetByIdAsync(TId id);
/// <summary>
/// Updates an existing entity in the data store.
/// </summary>
/// <param name="entity">The entity with updated values.</param>
/// <returns>True if the update was successful; otherwise, false.</returns>
Task<bool> UpdateAsync(T entity);
/// <summary>
/// Deletes an entity based on its unique identifier.
/// </summary>
/// <param name="id">The identifier of the entity to delete.</param>
/// <returns>True if the deletion was successful; otherwise, false.</returns>
Task<bool> DeleteAsync(TId id);
}
}
리포지토리 인터페이스
Add IPostBaseRepository and IPostRepository interfaces for Post entity with basic and extended operations
파일 구조
Azunt.PostManagement\02_Contracts\
│
├─ IPostBaseRepository.cs // 기본 CRUD 인터페이스
└─ IPostRepository.cs // 확장 기능 포함 인터페이스
1. IPostBaseRepository.cs
이 인터페이스는 Azunt의 공통 CRUD 인터페이스인 IRepositoryBase<T, TId>
를 기반으로 Post
엔터티에 특화된 표준 인터페이스입니다.
using Azunt.Repositories;
namespace Azunt.PostManagement;
/// <summary>
/// 기본 CRUD 작업을 위한 Post 전용 저장소 인터페이스
/// </summary>
public interface IPostBaseRepository : IRepositoryBase<Post, long>
{
}
이 인터페이스는 테스트 및 일반 사용 시 기본 CRUD 기능만 필요한 경우 사용합니다.
2. IPostRepository.cs
이 인터페이스는 IPostBaseRepository
를 상속하며, 페이징, 검색, 고급 필터링 등 추가 기능을 포함합니다.
주로 Blazor Server 컴포넌트, Web API, 관리자 기능에서 사용됩니다.
using Azunt.Models.Common;
namespace Azunt.PostManagement;
/// <summary>
/// Post 전용 확장 저장소 인터페이스 - 페이징, 검색 기능 포함
/// </summary>
public interface IPostRepository : IPostBaseRepository
{
/// <summary>
/// 페이징 + 검색 기능 제공
/// </summary>
Task<ArticleSet<Post, int>> GetAllAsync<TParentIdentifier>(
int pageIndex, int pageSize, string searchField, string searchQuery, string sortOrder, TParentIdentifier parentIdentifier, string category = "");
/// <summary>
/// 필터 옵션 기반 조회 기능 제공
/// </summary>
Task<ArticleSet<Post, long>> GetAllAsync<TParentIdentifier>(
Dul.Articles.FilterOptions<TParentIdentifier> options);
Task<bool> MoveUpAsync(long id);
Task<bool> MoveDownAsync(long id);
}
사용 예
IPostBaseRepository
는 단순한 CRUD가 필요한 경우 사용IPostRepository
는 고급 검색, 페이징, 정렬 기능이 필요한 경우 사용
의존성 주입 시 예시
services.AddTransient<IPostBaseRepository, PostRepository>();
// 또는
services.AddTransient<IPostRepository, PostRepository>();
DbContext 클래스
Add EF Core DbContext and factory for Post entity with default configuration support
IPostStorageService.cs
namespace Azunt.PostManagement
{
public interface IPostStorageService
{
Task<string> UploadAsync(Stream postStream, string postName);
Task<Stream> DownloadAsync(string postName);
Task DeleteAsync(string postName);
}
}
Program.cs
builder.Services.AddScoped<IPostStorageService, AzureBlobStorageService>();
DbContext 클래스
PostAppDbContext
는 Azunt.PostManagement
모듈에서 사용하는 Entity Framework Core 기반의 데이터베이스 컨텍스트 클래스입니다. 이 클래스는 EF Core와 실제 SQL Server 간의 브릿지 역할을 하며, 모델 구성 및 테이블 설정을 담당합니다.
Azunt.PostManagement\03_Repositories\EfCore\PostAppDbContext.cs
using Microsoft.EntityFrameworkCore;
namespace Azunt.PostManagement
{
public class PostAppDbContext : DbContext
{
public PostAppDbContext(DbContextOptions<PostAppDbContext> options)
: base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.Property(m => m.Created)
.HasDefaultValueSql("GetDate()");
}
public DbSet<Post> Posts { get; set; } = null!;
}
}
DbContext 팩터리 클래스
PostAppDbContextFactory
는 다양한 방법으로 PostAppDbContext
인스턴스를 생성하는 팩터리 클래스입니다. 명시적인 연결 문자열이 전달되지 않은 경우 appsettings.json
에 정의된 "DefaultConnection"
값을 사용합니다. 이 구조를 통해 서비스 등록 시 유연하게 사용할 수 있습니다.
Azunt.PostManagement\03_Repositories\EfCore\PostAppDbContextFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Azunt.PostManagement;
public class PostAppDbContextFactory
{
private readonly IConfiguration? _configuration;
public PostAppDbContextFactory() { }
public PostAppDbContextFactory(IConfiguration configuration)
{
_configuration = configuration;
}
public PostAppDbContext CreateDbContext(string connectionString)
{
var options = new DbContextOptionsBuilder<PostAppDbContext>()
.UseSqlServer(connectionString)
.Options;
return new PostAppDbContext(options);
}
public PostAppDbContext CreateDbContext(DbContextOptions<PostAppDbContext> options)
{
ArgumentNullException.ThrowIfNull(options);
return new PostAppDbContext(options);
}
public PostAppDbContext 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 PostRepository with EF Core support for CRUD, paging, sorting, and multi-tenant context handling
PostRepository 클래스 개요
PostRepository
는 Azunt.PostManagement
모듈의 핵심 리포지토리 클래스입니다.
Entity Framework Core 기반이며, PostAppDbContextFactory
를 통해 DbContext
인스턴스를 생성합니다.
Blazor Server의 회로 문제 방지와 멀티테넌트 지원을 고려한 설계입니다.
파일 경로
Azunt.PostManagement\03_Repositories\EfCore\PostRepository.cs
using Azunt.PostManagement;
using Azunt.Repositories;
using Dul.Articles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Azunt.PostManagement;
/// <summary>
/// Post 테이블에 대한 Entity Framework Core 기반 리포지토리 구현체입니다.
/// Blazor Server 회로 유지 이슈를 피하고, 멀티테넌트 연결 문자열 지원을 위해 팩터리 사용.
/// </summary>
public class PostRepository : IPostRepository
{
private readonly PostAppDbContextFactory _factory;
private readonly ILogger<PostRepository> _logger;
private readonly string? _connectionString;
public PostRepository(
PostAppDbContextFactory factory,
ILoggerFactory loggerFactory)
{
_factory = factory;
_logger = loggerFactory.CreateLogger<PostRepository>();
}
public PostRepository(
PostAppDbContextFactory factory,
ILoggerFactory loggerFactory,
string connectionString)
{
_factory = factory;
_logger = loggerFactory.CreateLogger<PostRepository>();
_connectionString = connectionString;
}
private PostAppDbContext CreateContext() =>
string.IsNullOrWhiteSpace(_connectionString)
? _factory.CreateDbContext()
: _factory.CreateDbContext(_connectionString);
public async Task<Post> AddAsyncDefault(Post model)
{
await using var context = CreateContext();
model.Created = DateTime.UtcNow;
model.IsDeleted = false;
context.Posts.Add(model);
await context.SaveChangesAsync();
return model;
}
public async Task<Post> AddAsync(Post model)
{
await using var context = CreateContext();
model.Created = DateTime.UtcNow;
model.IsDeleted = false;
// 현재 가장 높은 DisplayOrder 값 조회
var maxDisplayOrder = await context.Posts
.Where(m => !m.IsDeleted)
.MaxAsync(m => (int?)m.DisplayOrder) ?? 0;
model.DisplayOrder = maxDisplayOrder + 1;
context.Posts.Add(model);
await context.SaveChangesAsync();
return model;
}
public async Task<IEnumerable<Post>> GetAllAsync()
{
await using var context = CreateContext();
return await context.Posts
.Where(m => !m.IsDeleted)
//.OrderByDescending(m => m.Id)
.OrderBy(m => m.DisplayOrder) // 정렬 순서 변경
.ToListAsync();
}
public async Task<Post> GetByIdAsync(long id)
{
await using var context = CreateContext();
return await context.Posts
.Where(m => m.Id == id && !m.IsDeleted)
.SingleOrDefaultAsync()
?? new Post();
}
public async Task<bool> UpdateAsync(Post model)
{
await using var context = CreateContext();
context.Attach(model);
context.Entry(model).State = EntityState.Modified;
return await context.SaveChangesAsync() > 0;
}
public async Task<bool> DeleteAsync(long id)
{
await using var context = CreateContext();
var entity = await context.Posts.FindAsync(id);
if (entity == null || entity.IsDeleted) return false;
entity.IsDeleted = true;
context.Posts.Update(entity);
return await context.SaveChangesAsync() > 0;
}
public async Task<Azunt.Models.Common.ArticleSet<Post, int>> GetAllAsync<TParentIdentifier>(
int pageIndex,
int pageSize,
string searchField,
string searchQuery,
string sortOrder,
TParentIdentifier parentIdentifier)
{
await using var context = CreateContext();
var query = context.Posts
.Where(m => !m.IsDeleted)
.AsQueryable();
if (!string.IsNullOrEmpty(searchQuery))
{
query = query.Where(m => m.Name != null && m.Name.Contains(searchQuery));
}
query = sortOrder switch
{
"Name" => query.OrderBy(m => m.Name),
"NameDesc" => query.OrderByDescending(m => m.Name),
"DisplayOrder" => query.OrderBy(m => m.DisplayOrder),
_ => query.OrderBy(m => m.DisplayOrder) // 기본 정렬도 DisplayOrder
};
var totalCount = await query.CountAsync();
var items = await query
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToListAsync();
return new Azunt.Models.Common.ArticleSet<Post, int>(items, totalCount);
}
public async Task<Azunt.Models.Common.ArticleSet<Post, long>> GetAllAsync<TParentIdentifier>(
FilterOptions<TParentIdentifier> options)
{
await using var context = CreateContext();
var query = context.Posts
.Where(m => !m.IsDeleted)
.AsQueryable();
if (!string.IsNullOrEmpty(options.SearchQuery))
{
query = query.Where(m => m.Name != null && m.Name.Contains(options.SearchQuery));
}
var totalCount = await query.CountAsync();
var items = await query
.OrderBy(m => m.DisplayOrder)
.Skip(options.PageIndex * options.PageSize)
.Take(options.PageSize)
.ToListAsync();
return new Azunt.Models.Common.ArticleSet<Post, long>(items, totalCount);
}
public async Task<bool> MoveUpAsync(long id)
{
await using var context = CreateContext();
var current = await context.Posts.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (current == null) return false;
var upper = await context.Posts
.Where(x => x.DisplayOrder < current.DisplayOrder && !x.IsDeleted)
.OrderByDescending(x => x.DisplayOrder)
.FirstOrDefaultAsync();
if (upper == null) return false;
// Swap
int temp = current.DisplayOrder;
current.DisplayOrder = upper.DisplayOrder;
upper.DisplayOrder = temp;
// 명시적 변경 추적
context.Posts.Update(current);
context.Posts.Update(upper);
await context.SaveChangesAsync();
return true;
}
public async Task<bool> MoveDownAsync(long id)
{
await using var context = CreateContext();
var current = await context.Posts.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (current == null) return false;
var lower = await context.Posts
.Where(x => x.DisplayOrder > current.DisplayOrder && !x.IsDeleted)
.OrderBy(x => x.DisplayOrder)
.FirstOrDefaultAsync();
if (lower == null) return false;
// Swap
int temp = current.DisplayOrder;
current.DisplayOrder = lower.DisplayOrder;
lower.DisplayOrder = temp;
// 명시적 변경 추적
context.Posts.Update(current);
context.Posts.Update(lower);
await context.SaveChangesAsync();
return true;
}
}
주요 특징
- EF Core 기반 CRUD 및 검색/페이징 기능 지원
- DbContext 직접 DI가 아닌 팩터리 방식 사용
- 멀티테넌트 환경을 위한 connectionString 생성자 주입 지원
생성자 오버로드 구조
public PostRepository(
PostAppDbContextFactory factory,
ILoggerFactory loggerFactory)
public PostRepository(
PostAppDbContextFactory factory,
ILoggerFactory loggerFactory,
string connectionString)
- 첫 번째 생성자는 기본 연결 문자열 사용 (
appsettings.json
의 "DefaultConnection") - 두 번째 생성자는 멀티테넌시 대응용 명시적 연결 문자열 사용
Blazor Server에서 사용하는 코드 예시
1. 기본 연결 문자열을 사용하는 DI 방식
Program.cs
또는 Startup.cs
에서:
builder.Services.AddScoped<IPostRepository, PostRepository>();
builder.Services.AddScoped<PostAppDbContextFactory>();
사용 예 (Blazor 컴포넌트 내부):
@inject IPostRepository PostRepository
@code {
private List<Post> posts = [];
protected override async Task OnInitializedAsync()
{
posts = await PostRepository.GetAllAsync();
}
}
2. 명시적 connectionString을 전달하는 수동 생성 방식 (멀티테넌트)
@inject PostAppDbContextFactory Factory
@inject ILoggerFactory LoggerFactory
@code {
private PostRepository? _repo;
protected override async Task OnInitializedAsync()
{
var conn = $"Server=...;Database=Tenant1Db;Trusted_Connection=True;";
_repo = new PostRepository(Factory, LoggerFactory, conn);
var result = await _repo.GetAllAsync();
}
}
PostRepositoryAdoNet.cs
Azunt.PostManagement\03_Repositories\AdoNet\PostRepositoryAdoNet.cs
Add ADO.NET-based PostRepository implementation with CRUD, paging, and reordering support
using Dul.Articles;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using System.Data;
namespace Azunt.PostManagement;
public class PostRepositoryAdoNet : IPostRepository
{
private readonly string _connectionString;
private readonly ILogger<PostRepositoryAdoNet> _logger;
public PostRepositoryAdoNet(string connectionString, ILoggerFactory loggerFactory)
{
_connectionString = connectionString;
_logger = loggerFactory.CreateLogger<PostRepositoryAdoNet>();
}
private SqlConnection GetConnection() => new(_connectionString);
public async Task<Post> AddAsync(Post model)
{
using var conn = GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO Posts (Active, Created, CreatedBy, Name, IsDeleted)
OUTPUT INSERTED.Id
VALUES (@Active, @Created, @CreatedBy, @Name, 0)";
cmd.Parameters.AddWithValue("@Active", model.Active ?? true);
cmd.Parameters.AddWithValue("@Created", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("@CreatedBy", model.CreatedBy ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@Name", model.Name ?? (object)DBNull.Value);
await conn.OpenAsync();
var result = await cmd.ExecuteScalarAsync();
if (result == null)
{
throw new InvalidOperationException("Failed to insert Post. No ID was returned.");
}
model.Id = (long)result;
return model;
}
public async Task<IEnumerable<Post>> GetAllAsync()
{
var result = new List<Post>();
using var conn = GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Id, Active, Created, CreatedBy, Name FROM Posts WHERE IsDeleted = 0 ORDER BY Id DESC";
await conn.OpenAsync();
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
result.Add(new Post
{
Id = reader.GetInt64(0),
Active = reader.IsDBNull(1) ? (bool?)null : reader.GetBoolean(1),
Created = reader.GetDateTimeOffset(2),
CreatedBy = reader.IsDBNull(3) ? null : reader.GetString(3),
Name = reader.IsDBNull(4) ? null : reader.GetString(4)
});
}
return result;
}
public async Task<Post> GetByIdAsync(long id)
{
using var conn = GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Id, Active, Created, CreatedBy, Name FROM Posts WHERE Id = @Id AND IsDeleted = 0";
cmd.Parameters.AddWithValue("@Id", id);
await conn.OpenAsync();
using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new Post
{
Id = reader.GetInt64(0),
Active = reader.IsDBNull(1) ? (bool?)null : reader.GetBoolean(1),
Created = reader.GetDateTimeOffset(2),
CreatedBy = reader.IsDBNull(3) ? null : reader.GetString(3),
Name = reader.IsDBNull(4) ? null : reader.GetString(4)
};
}
return new Post();
}
public async Task<bool> UpdateAsync(Post model)
{
using var conn = GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
UPDATE Posts SET
Active = @Active,
Name = @Name
WHERE Id = @Id AND IsDeleted = 0";
cmd.Parameters.AddWithValue("@Active", model.Active ?? true);
cmd.Parameters.AddWithValue("@Name", model.Name ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@Id", model.Id);
await conn.OpenAsync();
return await cmd.ExecuteNonQueryAsync() > 0;
}
public async Task<bool> DeleteAsync(long id)
{
using var conn = GetConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Posts SET IsDeleted = 1 WHERE Id = @Id AND IsDeleted = 0";
cmd.Parameters.AddWithValue("@Id", id);
await conn.OpenAsync();
return await cmd.ExecuteNonQueryAsync() > 0;
}
public async Task<Azunt.Models.Common.ArticleSet<Post, int>> GetAllAsync<TParentIdentifier>(
int pageIndex,
int pageSize,
string searchField,
string searchQuery,
string sortOrder,
TParentIdentifier parentIdentifier,
string category = "")
{
var all = await GetAllAsync();
var filtered = all.AsQueryable();
// 검색어 필터
if (!string.IsNullOrWhiteSpace(searchQuery))
{
filtered = filtered.Where(m => m.Name != null && m.Name.Contains(searchQuery));
}
// 카테고리 필터
if (!string.IsNullOrWhiteSpace(category))
{
filtered = filtered.Where(m => m.Category == category);
}
var resultItems = filtered
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToList();
return new Azunt.Models.Common.ArticleSet<Post, int>(resultItems, filtered.Count());
}
public async Task<Azunt.Models.Common.ArticleSet<Post, long>> GetAllAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options)
{
var all = await GetAllAsync();
var filtered = all
.Where(m => string.IsNullOrWhiteSpace(options.SearchQuery)
|| (m.Name != null && m.Name.Contains(options.SearchQuery)))
.ToList();
var paged = filtered
.Skip(options.PageIndex * options.PageSize)
.Take(options.PageSize)
.ToList();
return new Azunt.Models.Common.ArticleSet<Post, long>(paged, filtered.Count);
}
public async Task<bool> MoveUpAsync(long id)
{
using var conn = GetConnection();
await conn.OpenAsync();
using var cmd1 = conn.CreateCommand();
cmd1.CommandText = "SELECT Id, DisplayOrder FROM Posts WHERE Id = @Id AND IsDeleted = 0";
cmd1.Parameters.AddWithValue("@Id", id);
using var reader1 = await cmd1.ExecuteReaderAsync();
if (!await reader1.ReadAsync()) return false;
long currentId = reader1.GetInt64(0);
int currentOrder = reader1.GetInt32(1);
await reader1.CloseAsync();
using var cmd2 = conn.CreateCommand();
cmd2.CommandText = @"
SELECT TOP 1 Id, DisplayOrder
FROM Posts
WHERE DisplayOrder < @CurrentOrder AND IsDeleted = 0
ORDER BY DisplayOrder DESC";
cmd2.Parameters.AddWithValue("@CurrentOrder", currentOrder);
using var reader2 = await cmd2.ExecuteReaderAsync();
if (!await reader2.ReadAsync()) return false;
long upperId = reader2.GetInt64(0);
int upperOrder = reader2.GetInt32(1);
await reader2.CloseAsync();
using var tx = conn.BeginTransaction();
try
{
using var cmdUpdate1 = conn.CreateCommand();
cmdUpdate1.Transaction = tx;
cmdUpdate1.CommandText = "UPDATE Posts SET DisplayOrder = @NewOrder WHERE Id = @Id";
cmdUpdate1.Parameters.AddWithValue("@NewOrder", upperOrder);
cmdUpdate1.Parameters.AddWithValue("@Id", currentId);
await cmdUpdate1.ExecuteNonQueryAsync();
using var cmdUpdate2 = conn.CreateCommand();
cmdUpdate2.Transaction = tx;
cmdUpdate2.CommandText = "UPDATE Posts SET DisplayOrder = @NewOrder WHERE Id = @Id";
cmdUpdate2.Parameters.AddWithValue("@NewOrder", currentOrder);
cmdUpdate2.Parameters.AddWithValue("@Id", upperId);
await cmdUpdate2.ExecuteNonQueryAsync();
await tx.CommitAsync();
return true;
}
catch
{
await tx.RollbackAsync();
return false;
}
}
public async Task<bool> MoveDownAsync(long id)
{
using var conn = GetConnection();
await conn.OpenAsync();
using var cmd1 = conn.CreateCommand();
cmd1.CommandText = "SELECT Id, DisplayOrder FROM Posts WHERE Id = @Id AND IsDeleted = 0";
cmd1.Parameters.AddWithValue("@Id", id);
using var reader1 = await cmd1.ExecuteReaderAsync();
if (!await reader1.ReadAsync()) return false;
long currentId = reader1.GetInt64(0);
int currentOrder = reader1.GetInt32(1);
await reader1.CloseAsync();
using var cmd2 = conn.CreateCommand();
cmd2.CommandText = @"
SELECT TOP 1 Id, DisplayOrder
FROM Posts
WHERE DisplayOrder > @CurrentOrder AND IsDeleted = 0
ORDER BY DisplayOrder ASC";
cmd2.Parameters.AddWithValue("@CurrentOrder", currentOrder);
using var reader2 = await cmd2.ExecuteReaderAsync();
if (!await reader2.ReadAsync()) return false;
long lowerId = reader2.GetInt64(0);
int lowerOrder = reader2.GetInt32(1);
await reader2.CloseAsync();
using var tx = conn.BeginTransaction();
try
{
using var cmdUpdate1 = conn.CreateCommand();
cmdUpdate1.Transaction = tx;
cmdUpdate1.CommandText = "UPDATE Posts SET DisplayOrder = @NewOrder WHERE Id = @Id";
cmdUpdate1.Parameters.AddWithValue("@NewOrder", lowerOrder);
cmdUpdate1.Parameters.AddWithValue("@Id", currentId);
await cmdUpdate1.ExecuteNonQueryAsync();
using var cmdUpdate2 = conn.CreateCommand();
cmdUpdate2.Transaction = tx;
cmdUpdate2.CommandText = "UPDATE Posts SET DisplayOrder = @NewOrder WHERE Id = @Id";
cmdUpdate2.Parameters.AddWithValue("@NewOrder", currentOrder);
cmdUpdate2.Parameters.AddWithValue("@Id", lowerId);
await cmdUpdate2.ExecuteNonQueryAsync();
await tx.CommitAsync();
return true;
}
catch
{
await tx.RollbackAsync();
return false;
}
}
}
PostRepositoryDapper.cs
Add Dapper-based repository implementation for Post entity with full CRUD and reordering support
Azunt.PostManagement\03_Repositories\Dapper\PostRepositoryDapper.cs
using Dapper;
using Dul.Articles;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
namespace Azunt.PostManagement;
public class PostRepositoryDapper : IPostRepository
{
private readonly string _connectionString;
private readonly ILogger<PostRepositoryDapper> _logger;
public PostRepositoryDapper(string connectionString, ILoggerFactory loggerFactory)
{
_connectionString = connectionString;
_logger = loggerFactory.CreateLogger<PostRepositoryDapper>();
}
private SqlConnection GetConnection() => new(_connectionString);
public async Task<Post> AddAsync(Post model)
{
const string sql = @"
INSERT INTO Posts (Active, Created, CreatedBy, Name, IsDeleted)
OUTPUT INSERTED.Id
VALUES (@Active, @Created, @CreatedBy, @Name, 0)";
model.Created = DateTimeOffset.UtcNow;
using var conn = GetConnection();
model.Id = await conn.ExecuteScalarAsync<long>(sql, model);
return model;
}
public async Task<IEnumerable<Post>> GetAllAsync()
{
const string sql = @"
SELECT Id, Active, Created, CreatedBy, Name
FROM Posts
WHERE IsDeleted = 0
ORDER BY Id DESC";
using var conn = GetConnection();
return await conn.QueryAsync<Post>(sql);
}
public async Task<Post> GetByIdAsync(long id)
{
const string sql = @"
SELECT Id, Active, Created, CreatedBy, Name
FROM Posts
WHERE Id = @Id AND IsDeleted = 0";
using var conn = GetConnection();
return await conn.QuerySingleOrDefaultAsync<Post>(sql, new { Id = id }) ?? new Post();
}
public async Task<bool> UpdateAsync(Post model)
{
const string sql = @"
UPDATE Posts SET
Active = @Active,
Name = @Name
WHERE Id = @Id AND IsDeleted = 0";
using var conn = GetConnection();
var affected = await conn.ExecuteAsync(sql, model);
return affected > 0;
}
public async Task<bool> DeleteAsync(long id)
{
const string sql = @"
UPDATE Posts SET IsDeleted = 1
WHERE Id = @Id AND IsDeleted = 0";
using var conn = GetConnection();
var affected = await conn.ExecuteAsync(sql, new { Id = id });
return affected > 0;
}
public async Task<Azunt.Models.Common.ArticleSet<Post, int>> GetAllAsync<TParentIdentifier>(
int pageIndex,
int pageSize,
string searchField,
string searchQuery,
string sortOrder,
TParentIdentifier parentIdentifier,
string category = "")
{
var all = await GetAllAsync();
var filtered = all.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchQuery))
{
filtered = filtered.Where(m => m.Name != null && m.Name.Contains(searchQuery));
}
if (!string.IsNullOrWhiteSpace(category))
{
filtered = filtered.Where(m => m.Category == category);
}
var paged = filtered
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToList();
return new Azunt.Models.Common.ArticleSet<Post, int>(paged, filtered.Count());
}
public async Task<Azunt.Models.Common.ArticleSet<Post, long>> GetAllAsync<TParentIdentifier>(FilterOptions<TParentIdentifier> options)
{
var all = await GetAllAsync();
var filtered = all
.Where(m => string.IsNullOrWhiteSpace(options.SearchQuery)
|| (m.Name != null && m.Name.Contains(options.SearchQuery)))
.ToList();
var paged = filtered
.Skip(options.PageIndex * options.PageSize)
.Take(options.PageSize)
.ToList();
return new Azunt.Models.Common.ArticleSet<Post, long>(paged, filtered.Count);
}
public async Task<bool> MoveUpAsync(long id)
{
const string getCurrent = "SELECT Id, DisplayOrder FROM Posts WHERE Id = @Id AND IsDeleted = 0";
const string getUpper = @"
SELECT TOP 1 Id, DisplayOrder
FROM Posts
WHERE DisplayOrder < @DisplayOrder AND IsDeleted = 0
ORDER BY DisplayOrder DESC";
const string update = "UPDATE Posts SET DisplayOrder = @DisplayOrder WHERE Id = @Id";
using var conn = GetConnection();
await conn.OpenAsync();
var current = await conn.QuerySingleOrDefaultAsync<(long Id, int DisplayOrder)>(getCurrent, new { Id = id });
if (current.Id == 0) return false;
var upper = await conn.QuerySingleOrDefaultAsync<(long Id, int DisplayOrder)>(getUpper, new { DisplayOrder = current.DisplayOrder });
if (upper.Id == 0) return false;
using var tx = conn.BeginTransaction();
try
{
await conn.ExecuteAsync(update, new { DisplayOrder = upper.DisplayOrder, Id = current.Id }, tx);
await conn.ExecuteAsync(update, new { DisplayOrder = current.DisplayOrder, Id = upper.Id }, tx);
await tx.CommitAsync();
return true;
}
catch
{
await tx.RollbackAsync();
return false;
}
}
public async Task<bool> MoveDownAsync(long id)
{
const string getCurrent = "SELECT Id, DisplayOrder FROM Posts WHERE Id = @Id AND IsDeleted = 0";
const string getLower = @"
SELECT TOP 1 Id, DisplayOrder
FROM Posts
WHERE DisplayOrder > @DisplayOrder AND IsDeleted = 0
ORDER BY DisplayOrder ASC";
const string update = "UPDATE Posts SET DisplayOrder = @DisplayOrder WHERE Id = @Id";
using var conn = GetConnection();
await conn.OpenAsync();
var current = await conn.QuerySingleOrDefaultAsync<(long Id, int DisplayOrder)>(getCurrent, new { Id = id });
if (current.Id == 0) return false;
var lower = await conn.QuerySingleOrDefaultAsync<(long Id, int DisplayOrder)>(getLower, new { DisplayOrder = current.DisplayOrder });
if (lower.Id == 0) return false;
using var tx = conn.BeginTransaction();
try
{
await conn.ExecuteAsync(update, new { DisplayOrder = lower.DisplayOrder, Id = current.Id }, tx);
await conn.ExecuteAsync(update, new { DisplayOrder = current.DisplayOrder, Id = lower.Id }, tx);
await tx.CommitAsync();
return true;
}
catch
{
await tx.RollbackAsync();
return false;
}
}
}
DI 등록 관련 코드 모음 클래스
Add DI extension methods for PostApp with support for EF Core, Dapper, and ADO.NET
목적
Azunt.PostManagement
모듈을 사용하는 ASP.NET Core 또는 Blazor Server 프로젝트에서
의존성 주입(Dependency Injection)을 손쉽게 등록할 수 있도록 확장 메서드를 제공합니다.
이 확장 클래스는 EF Core
, Dapper
, ADO.NET
중 선택하여 저장소(Repository)를 등록할 수 있도록 하며,
멀티테넌트 및 다양한 아키텍처 환경에서도 유연하게 대응할 수 있습니다.
경로
Azunt.PostManagement\04_Extensions\PostServicesRegistrationExtensions.cs
전체 소스 코드
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Azunt.PostManagement;
/// <summary>
/// PostApp 의존성 주입 확장 메서드
/// </summary>
public static class PostServicesRegistrationExtensions
{
/// <summary>
/// 선택 가능한 저장소 모드 정의
/// </summary>
public enum RepositoryMode
{
EfCore,
Dapper,
AdoNet
}
/// <summary>
/// PostApp 모듈의 서비스를 등록합니다.
/// </summary>
/// <param name="services">서비스 컬렉션</param>
/// <param name="connectionString">기본 연결 문자열</param>
/// <param name="mode">레포지토리 모드 (EfCore, Dapper, AdoNet)</param>
/// <param name="dbContextLifetime">DbContext 수명 주기 (기본: Transient)</param>
public static void AddDependencyInjectionContainerForPostApp(
this IServiceCollection services,
string connectionString,
RepositoryMode mode = RepositoryMode.EfCore,
ServiceLifetime dbContextLifetime = ServiceLifetime.Transient)
{
switch (mode)
{
case RepositoryMode.EfCore:
// EF Core 방식 등록
services.AddDbContext<PostAppDbContext>(
options => options.UseSqlServer(connectionString),
dbContextLifetime);
services.AddTransient<IPostRepository, PostRepository>();
services.AddTransient<PostAppDbContextFactory>();
break;
case RepositoryMode.Dapper:
// Dapper 방식 등록
services.AddTransient<IPostRepository>(provider =>
new PostRepositoryDapper(
connectionString,
provider.GetRequiredService<ILoggerFactory>()));
break;
case RepositoryMode.AdoNet:
// ADO.NET 방식 등록
services.AddTransient<IPostRepository>(provider =>
new PostRepositoryAdoNet(
connectionString,
provider.GetRequiredService<ILoggerFactory>()));
break;
default:
throw new InvalidOperationException(
$"Invalid repository mode '{mode}'. Supported modes: EfCore, Dapper, AdoNet.");
}
}
}
사용 예
1. 기본 EF Core 방식으로 등록 (권장)
var defaultConnStr = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("DefaultConnection is missing in configuration.");
builder.Services.AddDependencyInjectionContainerForPostApp(defaultConnStr);
builder.Services.AddTransient<PostAppDbContextFactory>();
builder.Services.AddScoped<IPostStorageService, AzureBlobStorageService>();
2. Dapper 방식으로 등록
builder.Services.AddDependencyInjectionContainerForPostApp(
builder.Configuration.GetConnectionString("DefaultConnection"),
PostServicesRegistrationExtensions.RepositoryMode.Dapper);
builder.Services.AddTransient<PostAppDbContextFactory>();
3. ADO.NET 방식으로 등록
builder.Services.AddDependencyInjectionContainerForPostApp(
builder.Configuration.GetConnectionString("DefaultConnection"),
PostServicesRegistrationExtensions.RepositoryMode.AdoNet);
builder.Services.AddTransient<PostAppDbContextFactory>();
주의사항
- Dapper 또는 ADO.NET 방식 사용 시,
connectionString
은 필수입니다. - EF Core 방식에서는
PostAppDbContextFactory
와PostAppDbContext
모두 DI 컨테이너에 등록됩니다. - Blazor Server 환경에서는 DbContext 직접 주입을 지양하고 Factory 방식을 통해 사용해야 회로 문제가 발생하지 않습니다.
리포지토리 테스트 클래스
Memos 테스트 프로젝트를 참고하여, Posts 모듈에 대한 테스트 프로젝트를 생성하면 됩니다.
// 코드 생략...
종속성 주입
Statup.cs 또는 Program.cs에 종속성 주입(DI)
다음 코드를 사용하여 Posts 관련 클래스들(모델, 인터페이스, 리포지토리 클래스)을 해당 프로젝트에서 사용할 수 있습니다.
Startup.cs 파일에서의 사용 모양
using Azunt.PostManagement;
// 포스트 관리: 기본 CRUD 교과서 코드
services.AddDependencyInjectionContainerForPostApp(Configuration.GetConnectionString("DefaultConnection"));
services.AddTransient<PostAppDbContextFactory>();
services.AddScoped<IPostStorageService, AzureBlobStorageService>();
Program.cs 파일에서의 사용 모양
using Azunt.PostManagement;
// 포스트 관리: 기본 CRUD 교과서 코드
var defaultConnStr = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("DefaultConnection is missing in configuration.");
builder.Services.AddDependencyInjectionContainerForPostApp(defaultConnStr);
builder.Services.AddTransient<PostAppDbContextFactory>();
builder.Services.AddScoped<IPostStorageService, AzureBlobStorageService>();
Posts 관련 MVC Controller with CRUD 뷰 페이지
다음은 Posts 주제로 ASP.NET Core MVC 스캐폴딩 기능을 사용하여 CRUD를 구현하는 코드의 내용입니다.
바로 Blazor Server 컴포넌트 제작으로 넘어가려면 다음 절로 이동하세요.
MVC 스캐폴딩으로 CRUD를 구현하는 내용에 대한 강좌는 다음 링크를 참고하세요.
TODO: MVC 스캐폴딩
Posts 관련 Web API Controller with CRUD
하나의 모듈에 대한 CRUD를 Swagger UI를 사용하는 ASP.NET Core Web API를 스캐폴딩 기능을 사용하여 구현할 수 있습니다. 이 곳의 내용은 Blazor Server에서 사용하기 보다는 Blazor Wasm, React, Vue, Angular, jQuery 등에서 호출되어 사용되는 부분입니다. Blazor Server 관점에서 개발하려면 다음 절로 이동하세요.
Web API 스캐폴딩으로 CRUD를 구현하는 내용에 대한 강좌는 다음 링크를 참고하세요.
TODO: Web API 스캐폴딩
Azunt.Web 프로젝트 구성
NuGet 패키지 설치
Blazor Server 프로젝트인 Azunt.Web
에는 다음과 같은 NuGet 패키지를 설치합니다:
<PackageReference Include="Azunt" Version="1.0.5" />
<PackageReference Include="Azunt.Components" Version="1.0.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EPPlus" Version="6.0.5" />
<PackageReference Include="Dul" Version="1.3.4" />
<PackageReference Include="DulPager" Version="1.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />
EPPlus는 6.0.5 버전으로 테스트가 되었습니다. 이 버전을 기준으로 하겠습니다.
Program.cs에서 Web API 사용을 위한 설정
Enable Web API controller routing and implement Excel download endpoint for Posts module
builder.Services.AddControllers();
app.MapControllers();
var builder = WebApplication.CreateBuilder(args);
// [1] Web API 컨트롤러 사용을 위해 추가
builder.Services.AddControllers();
// [2] Razor Pages 및 Blazor Server 지원
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// [3] (선택) 인증 및 권한 설정
builder.Services.AddAuthorization();
// [4] (선택) Post 모듈 등록
builder.Services.AddDependencyInjectionContainerForPostApp(
builder.Configuration.GetConnectionString("DefaultConnection"));
builder.Services.AddTransient<PostAppDbContextFactory>();
var app = builder.Build();
// [5] 미들웨어 구성
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticPosts();
app.UseRouting();
// [6] 인증 및 권한 미들웨어 (선택 사항)
app.UseAuthentication();
app.UseAuthorization();
// [7] Web API 컨트롤러 매핑
app.MapControllers();
// [8] Blazor 및 Razor Pages 매핑
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
Posts 관련 Storage Service 생성
포스트 기능 구현하기
NuGet 추가 설치
dotnet add package Azure.Storage.Blobs
<PackageReference Include="Azure.Storage.Blobs" Version="12.18.0" />
Azunt.PostManagement\02_Contracts\IPostStorageService.cs
namespace Azunt.PostManagement
{
public interface IPostStorageService
{
Task<string> UploadAsync(Stream postStream, string postName);
Task<Stream> DownloadAsync(string postName);
Task DeleteAsync(string postName);
}
}
<PackageReference Include="Azure.Storage.Blobs" Version="12.24.0" />
LocalPostStorageService.cs
C:\Azunt.PostManagement\src\Azunt.PostManagement\Azunt.Web\Azunt.Web\Components\Pages\PostsPages\Services\LocalPostStorageService.cs
using Azunt.PostManagement;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading.Tasks;
namespace Azunt.Web.Components.Pages.PostsPages.Services
{
public class LocalPostStorageService : IPostStorageService
{
private readonly string _rootPath;
private readonly ILogger<LocalPostStorageService> _logger;
public LocalPostStorageService(IWebHostEnvironment env, ILogger<LocalPostStorageService> logger)
{
_logger = logger;
_rootPath = Path.Combine(env.WebRootPath, "posts", "Posts");
if (!Directory.Exists(_rootPath))
{
Directory.CreateDirectory(_rootPath);
}
}
public async Task<string> UploadAsync(Stream postStream, string postName)
{
// 파일명 중복 방지
string safeFileName = GetUniqueFileName(postName);
string fullPath = Path.Combine(_rootPath, safeFileName);
using (var post = File.Create(fullPath))
{
await postStream.CopyToAsync(post);
}
// 웹에서 접근 가능한 상대 경로 반환
return $"/posts/Posts/{safeFileName}";
}
private string GetUniqueFileName(string postName)
{
string baseName = Path.GetFileNameWithoutExtension(postName);
string extension = Path.GetExtension(postName);
string newFileName = postName;
int count = 1;
하여 중복 파일명 체크
while (File.Exists(Path.Combine(_rootPath, newFileName)))
{
newFileName = $"{baseName}({count}){extension}";
count++;
}
return newFileName;
}
public Task<Stream> DownloadAsync(string postName)
{
string fullPath = Path.Combine(_rootPath, postName);
if (!File.Exists(fullPath))
throw new PostNotFoundException($"Post not found: {postName}");
var stream = File.OpenRead(fullPath);
return Task.FromResult<Stream>(stream);
}
public Task DeleteAsync(string postName)
{
string fullPath = Path.Combine(_rootPath, postName);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
return Task.CompletedTask;
}
}
}
AzureBlobStorageService.cs
C:\Azunt.PostManagement\src\Azunt.PostManagement\Azunt.Web\Azunt.Web\Components\Pages\PostsPages\Services\AzureBlobStorageService.cs
using Azunt.PostManagement;
using Azure.Storage.Blobs;
using Microsoft.Extensions.Configuration;
using System.IO;
using System.Net; // URL 디코딩 및 인코딩을 위한 네임스페이스
using System.Threading.Tasks;
namespace Azunt.Web.Components.Pages.PostsPages.Services
{
public class AzureBlobStorageService : IPostStorageService
{
private readonly BlobContainerClient _containerClient;
public AzureBlobStorageService(IConfiguration config)
{
var connStr = config["AzureBlobStorage:Default:ConnectionString"];
var containerName = config["AzureBlobStorage:Default:ContainerName"];
_containerClient = new BlobContainerClient(connStr, containerName);
_containerClient.CreateIfNotExists();
}
// 파일 업로드 시 URL 인코딩된 파일명 처리
public async Task<string> UploadAsync(Stream postStream, string postName)
{
// URL 인코딩 처리
string encodedFileName = WebUtility.UrlEncode(postName);
// 파일명 중복 방지 처리
string safeFileName = await GetUniqueFileNameAsync(encodedFileName);
var blobClient = _containerClient.GetBlobClient(safeFileName);
// 파일 업로드
await blobClient.UploadAsync(postStream, overwrite: true);
return blobClient.Uri.ToString(); // 전체 URL 반환
}
// 파일명을 안전하게 고유하게 만듦 (중복 방지)
private async Task<string> GetUniqueFileNameAsync(string postName)
{
string baseName = Path.GetFileNameWithoutExtension(postName);
string extension = Path.GetExtension(postName);
string newFileName = postName;
int count = 1;
// Blob Storage에서 파일이 이미 존재하는지 체크
while (await _containerClient.GetBlobClient(newFileName).ExistsAsync())
{
newFileName = $"{baseName}({count}){extension}";
count++;
}
return newFileName;
}
// 파일 다운로드 시 URL 디코딩된 파일명 처리
public async Task<Stream> DownloadAsync(string postName)
{
// URL 디코딩 처리
string decodedFileName = WebUtility.UrlDecode(postName);
var blobClient = _containerClient.GetBlobClient(decodedFileName);
if (!await blobClient.ExistsAsync())
throw new PostNotFoundException($"Post not found: {postName}");
var response = await blobClient.DownloadAsync();
return response.Value.Content;
}
// 파일 삭제 시 URL 디코딩된 파일명 처리
public Task DeleteAsync(string postName)
{
// URL 디코딩 처리
string decodedFileName = WebUtility.UrlDecode(postName);
var blobClient = _containerClient.GetBlobClient(decodedFileName);
return blobClient.DeleteIfExistsAsync();
}
}
}
Posts 관련 Excel 다운로드 API 생성
Blazor Server의 포스트 관리(Posts) 기능에서는 등록된 포스트 데이터를 엑셀로 내려받을 수 있는 기능을 제공합니다.
이 기능은 Web API Controller
로 구현되며, 엑셀 생성은 EPPlus
라이브러리를 사용합니다.
컨트롤러 경로
Components\Pages\PostsPages\Apis\PostDownloadController.cs
주요 목적
- 인증된 관리자만 다운로드 가능 (
[Authorize(Roles = "Administrators")]
) - 엑셀 시트 내 조건부 서식(Conditional Formatting) 포함
- Blazor Server 프로젝트에서 회로 유지 이슈를 피하기 위해 팩터리 기반 리포지토리 사용
전체 소스 코드
using Microsoft.AspNetCore.Mvc;
using OfficeOpenXml;
using OfficeOpenXml.Style;
using System.Drawing;
using Azunt.PostManagement;
using System.Threading.Tasks;
using System.Linq;
using System;
using System.IO;
namespace Azunt.Apis.Posts
{
[Route("api/[controller]")]
[ApiController]
public class PostDownloadController : ControllerBase
{
private readonly IPostRepository _repository;
private readonly IPostStorageService _postStorage;
public PostDownloadController(IPostRepository repository, IPostStorageService postStorage)
{
_repository = repository;
_postStorage = postStorage;
}
/// <summary>
/// 포스트 리스트 엑셀 다운로드
/// GET /api/PostDownload/ExcelDown
/// </summary>
[HttpGet("ExcelDown")]
public async Task<IActionResult> ExcelDown()
{
var items = await _repository.GetAllAsync();
if (!items.Any())
{
return NotFound("No post records found.");
}
using var package = new ExcelPackage();
var sheet = package.Workbook.Worksheets.Add("Posts");
var range = sheet.Cells["B2"].LoadFromCollection(
items.Select(m => new
{
m.Id,
m.Name,
Created = m.Created.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
m.Active,
m.CreatedBy
}),
PrintHeaders: true
);
var header = sheet.Cells["B2:F2"];
sheet.DefaultColWidth = 22;
range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Left;
range.Style.Fill.PatternType = ExcelFillStyle.Solid;
range.Style.Fill.BackgroundColor.SetColor(Color.WhiteSmoke);
range.Style.Border.BorderAround(ExcelBorderStyle.Medium);
header.Style.Font.Bold = true;
header.Style.Font.Color.SetColor(Color.White);
header.Style.Fill.BackgroundColor.SetColor(Color.DarkBlue);
var activeCol = range.Offset(1, 3, items.Count(), 1);
var rule = activeCol.ConditionalFormatting.AddThreeColorScale();
rule.LowValue.Color = Color.Red;
rule.MiddleValue.Color = Color.White;
rule.HighValue.Color = Color.Green;
var content = package.GetAsByteArray();
return Post(content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"{DateTime.Now:yyyyMMddHHmmss}_Posts.xlsx");
}
/// <summary>
/// 파일 단일 다운로드
/// GET /api/PostDownload/{postName}
/// </summary>
[HttpGet("{postName}")]
public async Task<IActionResult> Download(string postName)
{
try
{
var stream = await _postStorage.DownloadAsync(postName);
return Post(stream, "application/octet-stream", postName);
}
catch (PostNotFoundException)
{
return NotFound($"Post not found: {postName}");
}
catch (Exception ex)
{
return StatusCode(500, $"Download error: {ex.Message}");
}
}
}
}
Blazor Web App (Blazor Server 또는 Blazor WebAssembly)에서 Web API를 사용하려면 Program.cs
파일에서 Web API 컨트롤러 라우팅을 명확하게 설정해야 합니다.
아래는 ASP.NET Core Blazor Web App (Blazor Server 기반) 프로젝트에서 Web API를 사용 가능하도록 설정하는 방법입니다.
Blazor Server에서의 등록 예
Program.cs 예시 (기본 EF Core + DI 등록 방식)
builder.Services.AddDependencyInjectionContainerForPostApp(
builder.Configuration.GetConnectionString("DefaultConnection"),
PostServicesRegistrationExtensions.RepositoryMode.EfCore);
builder.Services.AddTransient<PostAppDbContextFactory>();
builder.Services.AddScoped<IPostStorageService,
Azunt.Web.Components.Pages.PostsPages.Services.AzureBlobStorageService>();
// 포스트 관리: 기본 CRUD 교과서 코드
services.AddDependencyInjectionContainerForPostApp(Configuration.GetConnectionString("DefaultConnection"));
services.AddTransient<PostAppDbContextFactory>();
라우팅 예시
다운로드 API는 다음 경로로 호출됩니다:
GET /api/PostDownload/ExcelDown
인증 권한
[Authorize(Roles = "Administrators")]
가 적용되어 있어 관리자만 접근 가능합니다.- 인증되지 않은 사용자는 자동으로 로그인 페이지로 리디렉션되거나 401 Unauthorized 응답을 받습니다.
Azunt.PostManagement: 기본 컴포넌트 뼈대 생성 가이드
이 문서에서는 Azunt.Web
프로젝트의 Components/Pages/Posts
경로 아래에
Blazor Server에서 사용할 기본 테스트용 컴포넌트 뼈대를 생성하는 방법을 안내합니다.
각 컴포넌트는 단일 <h1>
요소만 렌더링하며, 이후 기능 구현을 위한 구조 준비 용도로 활용됩니다.
전체 폴더 및 파일 구조
Azunt.Web/
└─ Components/
└─ Pages/
└─ PostsPages/
├─ Manage.razor
├─ Manage.razor.cs
├─ Components/
│ ├─ DeleteDialog.razor
│ ├─ DeleteDialog.razor.cs
│ ├─ ModalForm.razor
│ └─ ModalForm.razor.cs
└─ Controls/
└─ PostComboBox.razor
Blazor Server 컴포넌트
Posts 관련하여 Blazor Server 컴포넌트를 구성할 폴더 구조는 다음과 같습니다. Posts 폴더에 Manage 이름으로 Razor Component를 생성하고, 관련된 서브 컴포넌트들은 Components 폴더에 둡니다.
Posts 보다 더 향상된 기능을 구현하는 Memos 모듈의 폴더 구조는 참고용으로 아래에 표시하였습니다. 내용은 비슷합니다. 제 강의의 Blazor Server 게시판 프로젝트 강의를 수강하셨다면, 각각의 컴포넌트 파일명을 만드는 연습을 여러 번 했기에 익숙한 폴더명과 파일명이 될 것입니다. 참고로 Manage 이름의 컴포넌트는 단일 페이지에서 CRUD를 구현하는 코드를 나타냅니다.
강의용 Azunt 프로젝트의 Pages 폴더의 일부 내용
.NET 8.0 이상 사용하는 환경이라면, /Pages/ 폴더 대신에 /Components/ 폴더에 관련된 컴포넌트를 모아 놓으면 됩니다.
Pages 또는 Components 폴더 (최신)
│ _Host.cshtml 또는 App.razor (최신)
│
├─PostsPages
│ │ Manage.razor
│ │ Manage.razor.cs
│ │
│ └─Components
│ DeleteDialog.razor
│ DeleteDialog.razor.cs
│ ModalForm.razor
│ ModalForm.razor.cs
│
├─Memos
│ │ Create.razor
│ │ Create.razor.cs
│ │ Delete.razor
│ │ Delete.razor.cs
│ │ Details.razor
│ │ Details.razor.cs
│ │ Edit.razor
│ │ Edit.razor.cs
│ │ Index.razor
│ │ Index.razor.cs
│ │ Manage.razor
│ │ Manage.razor.cs
│ │
│ └─Components
│ DeleteDialog.razor
│ DeleteDialog.razor.cs
│ EditorForm.razor
│ EditorForm.razor.cs
│ ModalForm.razor
│ ModalForm.razor.cs
│ ...
Components\Pages\PostsPages>tree /F
D:.
│ Manage.razor
│ Manage.razor.cs
│
├─Components
│ DeleteDialog.razor
│ DeleteDialog.razor.cs
│ ModalForm.razor
│ ModalForm.razor.cs
│
└─Controls
PostComboBox.razor
DeleteDialog.razor 컴포넌트
DeleteDialog 컴포넌트는 리스트(Manage) 페이지에서 특정 레코드를 삭제할 때 뜨는 팝업 다이얼로그입니다.
@namespace Azunt.Web.Components.Pages.Posts.Components
@if (IsShow)
{
<div class="modal fade show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">DELETE</h5>
<button type="button" class="btn-close" @onclick="Hide" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @onclick="OnClickCallback">Yes</button>
<button type="button" class="btn btn-secondary" @onclick="Hide">Cancel</button>
</div>
</div>
</div>
</div>
}
DeleteDialog.razor.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace Azunt.Web.Components.Pages.Posts.Components;
public partial class DeleteDialog
{
#region Parameters
/// <summary>
/// 부모에서 OnClickCallback 속성에 지정한 이벤트 처리기 실행
/// </summary>
[Parameter]
public EventCallback<MouseEventArgs> OnClickCallback { get; set; }
#endregion
#region Properties
/// <summary>
/// 모달 다이얼로그를 표시할건지 여부
/// </summary>
public bool IsShow { get; set; } = false;
#endregion
#region Public Methods
/// <summary>
/// 폼 보이기
/// </summary>
public void Show() => IsShow = true;
/// <summary>
/// 폼 닫기
/// </summary>
public void Hide() => IsShow = false;
#endregion
}
ModalForm.razor
ModalForm 컴포넌트는 리스트(Manage) 페이지에서 팝업을 통해서 특정 항목을 입력하거나, 이미 입력된 내용을 수정할 때 사용하는 모달 팝업 다이얼로그 컴포넌트입니다.
@namespace Azunt.Web.Components.Pages.Posts.Components
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using Azunt.PostManagement
@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 shadow-lg rounded">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">@EditorFormTitle</h5>
<button type="button" class="btn-close btn-close-white" @onclick="Hide" aria-label="Close"></button>
</div>
<div class="modal-body">
<EditForm Model="ModelEdit" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary class="text-danger" />
<input type="hidden" @bind-value="ModelEdit.Id" />
<div class="mb-3">
<label class="form-label fw-bold">Upload Post</label>
<InputPost OnChange="HandlePostChange" class="form-control" />
@if (!string.IsNullOrEmpty(ModelEdit.FileName))
{
<div class="form-text mt-1">📎 Selected: <strong>@ModelEdit.FileName</strong></div>
}
</div>
<div class="mb-3">
<label for="txtName" class="form-label fw-bold">Display Name</label>
<InputText id="txtName" class="form-control" placeholder="Enter name" @bind-Value="ModelEdit.Name" />
<ValidationMessage For="@(() => ModelEdit.Name)" />
</div>
<div class="d-flex justify-content-end pt-3">
<button type="submit" class="btn btn-success me-2">
<i class="bi bi-check-circle me-1"></i> Save
</button>
<button type="button" class="btn btn-secondary" @onclick="Hide">
<i class="bi bi-x-circle me-1"></i> Cancel
</button>
</div>
</EditForm>
</div>
</div>
</div>
</div>
}
ModalForm.razor.cs
using Azunt.PostManagement;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using System;
using System.IO;
using System.Threading.Tasks;
namespace Azunt.Web.Components.Pages.Posts.Components;
public partial class ModalForm : ComponentBase
{
private IBrowserPost selectedFile = default!;
#region Properties
/// <summary>
/// 모달 다이얼로그 표시 여부
/// </summary>
public bool IsShow { get; set; } = false;
#endregion
#region Public Methods
public void Show() => IsShow = true;
public void Hide()
{
IsShow = false;
StateHasChanged();
}
#endregion
#region Parameters
[Parameter] public string UserName { get; set; } = "";
[Parameter] public RenderFragment EditorFormTitle { get; set; } = null!;
[Parameter] public Post ModelSender { get; set; } = null!;
public Post ModelEdit { get; set; } = null!;
[Parameter] public Action CreateCallback { get; set; } = null!;
[Parameter] public EventCallback<bool> EditCallback { get; set; }
[Parameter] public int ParentId { get; set; } = 0;
[Parameter] public string ParentKey { get; set; } = "";
[Parameter] public string Category { get; set; } = "";
#endregion
#region Injectors
[Inject]
public IPostRepository RepositoryReference { get; set; } = null!;
[Inject]
private IPostStorageService PostStorage { get; set; } = null!;
#endregion
#region Lifecycle
protected override void OnParametersSet()
{
if (ModelSender != null)
{
ModelEdit = new Post
{
Id = ModelSender.Id,
Name = ModelSender.Name,
Active = ModelSender.Active,
Created = ModelSender.Created,
CreatedBy = ModelSender.CreatedBy,
FileName = ModelSender.FileName
};
}
else
{
ModelEdit = new Post();
}
}
#endregion
#region Event Handlers
protected async Task HandlePostChange(InputPostChangeEventArgs e)
{
selectedFile = e.Post;
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var postUrl = await PostStorage.UploadAsync(stream, selectedFile.Name);
// 파일명 저장
ModelEdit.FileName = Path.GetFileName(postUrl);
// 만약 Name이 비어 있으면 FileName을 자동 설정
if (string.IsNullOrWhiteSpace(ModelEdit.Name))
{
ModelEdit.Name = ModelEdit.FileName;
}
}
protected async Task HandleValidSubmit()
{
ModelSender.Active = true;
ModelSender.Name = ModelEdit.Name;
ModelSender.CreatedBy = UserName ?? "Anonymous";
// 기존 파일 삭제 조건: 수정 중이며 파일명이 바뀐 경우
bool isPostReplaced = ModelSender.Id > 0 &&
!string.IsNullOrWhiteSpace(ModelSender.FileName) &&
ModelSender.FileName != ModelEdit.FileName;
if (isPostReplaced)
{
// 기존 파일 삭제
await PostStorage.DeleteAsync(ModelSender.FileName!);
}
ModelSender.FileName = ModelEdit.FileName;
ModelSender.ParentKey = ParentKey;
ModelSender.ParentId = ParentId;
ModelSender.Category = Category;
if (ModelSender.Id == 0)
{
ModelSender.Created = DateTime.UtcNow;
await RepositoryReference.AddAsync(ModelSender);
CreateCallback?.Invoke();
}
else
{
await RepositoryReference.UpdateAsync(ModelSender);
await EditCallback.InvokeAsync(true);
}
Hide();
}
#endregion
}
SearchBox.razor
SearchBox 컴포넌트는 리스트 페이지에서 항목을 검색할 때 사용하는 검색 폼입니다. 이 검색 폼에는 디바운스 기능이라고 해서, 계속 입력되는 동안에는 검색을 진행하지 않고 입력이 완료된 후 300밀리초 후에 검색이 진행되는 기능이 들어 있습니다. 이 시간은 필요에 의해서 코드 비하인드에서 적절한 시간으로 수정해서 사용하면 됩니다.
이 내용은 Azunt.Components 이름의 NuGet 패키지의 다음 컴포넌트로 대체합니다.
<Azunt.Components.Search.SearchBox />
@namespace Azunt.Web.Components.Pages.Posts.Components
<div class="input-group mb-3">
<input class="form-control form-control-sm form-control-borderless"
type="search"
placeholder="Search topics or keywords"
aria-describedby="btnSearch"
@attributes="AdditionalAttributes"
@bind="SearchQuery"
@bind:event="oninput" />
<div class="input-group-append">
<button class="btn btn-sm btn-success" type="submit" @onclick="Search" id="btnSearch">Search</button>
</div>
</div>
SearchBox.razor.cs
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Timers;
namespace Azunt.Web.Components.Pages.Posts.Components;
public partial class SearchBox : ComponentBase, IDisposable
{
#region Fields
private string searchQuery = "";
private System.Timers.Timer? debounceTimer;
#endregion
#region Parameters
/// <summary>
/// 추가 HTML 속성 (placeholder 등) 처리
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object> AdditionalAttributes { get; set; } = new Dictionary<string, object>();
/// <summary>
/// 부모 컴포넌트로 검색어 전달
/// </summary>
[Parameter]
public EventCallback<string> SearchQueryChanged { get; set; }
/// <summary>
/// 디바운스 시간 (기본값: 300ms)
/// </summary>
[Parameter]
public int Debounce { get; set; } = 300;
#endregion
#region Properties
/// <summary>
/// 검색어 바인딩 속성 (입력 시 디바운스 적용)
/// </summary>
public string SearchQuery
{
get => searchQuery;
set
{
searchQuery = value;
debounceTimer?.Stop(); // 입력 중이면 기존 타이머 중지
debounceTimer?.Start(); // 새 타이머 시작 (입력 완료 후 실행)
}
}
#endregion
#region Lifecycle Methods
/// <summary>
/// 컴포넌트 초기화 시 디바운스 타이머 구성
/// </summary>
protected override void OnInitialized()
{
debounceTimer = new System.Timers.Timer
{
Interval = Debounce,
AutoReset = false // 한 번만 실행되도록 설정
};
debounceTimer.Elapsed += SearchHandler!;
}
#endregion
#region Event Handlers
/// <summary>
/// Search 버튼 직접 클릭 시 즉시 검색 실행
/// </summary>
protected void Search()
{
SearchQueryChanged.InvokeAsync(SearchQuery);
}
/// <summary>
/// 디바운스 타이머 종료 시 이벤트 발생
/// </summary>
protected async void SearchHandler(object source, ElapsedEventArgs e)
{
await InvokeAsync(() => SearchQueryChanged.InvokeAsync(SearchQuery));
}
#endregion
#region Public Methods
/// <summary>
/// 리소스 해제
/// </summary>
public void Dispose()
{
debounceTimer?.Dispose();
}
#endregion
}
SortOrderArrow.razor
SortOrderArrow 컴포넌트는 리스트 페이지에서 컬럼 정렬(Sorting) 기능을 구현할 때 현재 정렬 상태를 3가지로 표현하는 화살표 모양을 순수 텍스트로 표시해주기 위한 컴포넌트입니다.
언젠가는 QuickGrid 등으로 대체할 때까지는 정렬 기능이 이 컴포넌트를 사용할 예정입니다.
이 내용은 Azunt.Components 이름의 NuGet 패키지의 다음 컴포넌트로 대체합니다.
<Azunt.Components.Sorting.SortOrderArrow />
@namespace Azunt.Web.Components.Pages.Posts.Components
<span style="color: silver; vertical-align: text-bottom; margin-left: 7px; font-weight: bold; float: right;">@arrow</span>
@code {
/// <summary>
/// 현재 정렬을 적용 중인 컬럼명
/// </summary>
[Parameter]
public string SortColumn { get; set; } = "";
/// <summary>
/// 현재 정렬 조건 문자열 (예: "Name", "NameDesc")
/// </summary>
[Parameter]
public string SortOrder { get; set; } = "";
private string arrow = " ";
/// <summary>
/// 파라미터 변경 시 화살표 기호 계산
/// </summary>
protected override void OnParametersSet()
{
if (string.IsNullOrWhiteSpace(SortOrder))
{
arrow = "↕";
}
else if (SortOrder.Contains(SortColumn) && SortOrder.Contains("Desc"))
{
arrow = "↓";
}
else if (SortOrder.Contains(SortColumn))
{
arrow = "↑";
}
else
{
arrow = " ";
}
StateHasChanged();
}
}
PostComboBox.razor
@namespace Azunt.Web.Components.Pages.Posts.Controls
@using Azunt.PostManagement
@inject IPostRepository PostRepository
<div>
<!-- 드롭다운 리스트 -->
<select class="form-control mb-2" @onchange="OnSelectChanged">
<option value="">-- Select a Post --</option>
@foreach (var post in PostList)
{
<option value="@post" selected="@(post == SelectedPost)">
@post
</option>
}
</select>
<!-- 직접 입력용 텍스트박스: 필요없으면 제거 -->
<!-- 텍스트박스 입력 시에도 SelectedPostChanged 호출 -->
<input class="form-control" type="text" placeholder="Or type a new post..."
@bind="SelectedPost"
@oninput="OnInputChanged" />
</div>
@code {
[Parameter]
public string SelectedPost { get; set; } = "";
[Parameter]
public EventCallback<string> SelectedPostChanged { get; set; }
private List<string> PostList { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var posts = await PostRepository.GetAllAsync();
PostList = posts
.Select(d => d.Name ?? "")
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.ToList();
}
private async Task OnSelectChanged(ChangeEventArgs e)
{
var selected = e.Value?.ToString();
if (!string.IsNullOrWhiteSpace(selected))
{
SelectedPost = selected;
await SelectedPostChanged.InvokeAsync(SelectedPost);
}
}
private async Task OnInputChanged(ChangeEventArgs e)
{
SelectedPost = e.Value?.ToString() ?? "";
await SelectedPostChanged.InvokeAsync(SelectedPost);
}
}
Manage.razor
Manage 컴포넌트는 Blazor Server로 구현된 모듈의 핵심 페이지입니다. 이 컴포넌트에서 CRUD, 즉, 입력, 출력, 상세 보기, 수정, 삭제, 검색, 페이징, 정렬, 엑셀 다운로드 등의 전반적인 웹 애플리케이션의 기능을 모두 맛보기 형태로 살펴볼 수 있습니다. 사실, 현재 텍스트 아티클을 구성하는 목적도 현업에서 매번 비슷한 형태로 특정 기능을 구현할 때 이 문서의 내용 순서로 머릿속의 생각을 정리하면서 구현할 수 있는 가이드를 위해서 만들어 놓은 것입니다.
현재 Post는 하나의 항목(포스트 이름)만 입력 받지만, 현업에서는 훨씨 더 많은 내용들을 서로 다른 모양(텍스트박스, 체크박스, 드롭다운리스트 등)으로 입력 받지만 그 내용은 비슷하다보면 됩니다.
개수가 많고 적음의 차이이지 CRUD 관점에서는 기본 뼈대 코드는 같습니다.
그래서 현재 아티클의 목적은 CRUD에 대한 교과서적 코드를 완성하는데 목적이 있습니다.
Blazor Web App 템플릿이 아닌 경우는 다음 코드는 삭제하세요.
@rendermode InteractiveServer
Azunt.Web\Components\Pages\PostsPages\Manage.razor
@page "/Posts"
@page "/Posts/Manage"
@page "/Posts/Manage/{Category?}"
@namespace Azunt.Web.Pages.Posts
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Web
@rendermode InteractiveServer
<h3 class="mt-1 mb-1">
Posts
<span class="oi oi-plus text-primary align-baseline" @onclick="ShowEditorForm" style="cursor: pointer;"></span>
<button onclick="location.href = '/api/PostDownload/ExcelDown';" class="btn btn-sm btn-primary" style="float: right;">Excel Export</button>
</h3>
<div class="row">
<div class="col-md-12">
@if (models == null)
{
<p>Loading...</p>
}
else
{
<div class="table-responsive" style="min-height: 450px;">
<table class="table table-bordered table-hover">
<colgroup>
<col style="width: 40%;" /> @* Name (가장 넓게) *@
<col style="width: 20%;" /> @* Download *@
@if (!SimpleMode)
{
<col style="width: 15%;" /> @* Created *@
<col style="width: 10%;" /> @* Active *@
<col style="width: 5%;" /> @* (빈 열 또는 기타) *@
}
<col style="width: 15%;" /> @* Action (좁게 설정) *@
</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" />
</th>
@if (!SimpleMode)
{
<th class="text-center text-nowrap">Created</th>
<th class="text-center text-nowrap">Active</th>
<th class="text-center text-nowrap"></th>
}
<th class="text-center text-nowrap">Download</th>
<th class="text-center text-nowrap">Action</th>
</tr>
</thead>
<tbody>
@if (models.Count == 0)
{
<tr>
<td colspan="@(SimpleMode ? 2 : 5)" class="text-center">
No Data.
</td>
</tr>
}
else
{
@foreach (var m in models)
{
<tr>
<!-- Name Column -->
<td class="text-nowrap">@m.Name</td>
<!-- FileName Link Column -->
<td class="text-center text-nowrap">
@if (!string.IsNullOrEmpty(m.FileName))
{
<a href="@($"/api/PostDownload/{System.Net.WebUtility.UrlEncode(m.FileName)}")" target="_blank">
@* @System.Net.WebUtility.UrlDecode(m.FileName) <!-- URL 디코딩된 파일명 출력 --> *@
<span class="oi oi-data-transfer-download me-1 text-primary"></span>
</a>
}
else
{
<span>No Post</span> <!-- Or provide a placeholder text if the post name is null -->
}
</td>
@if (!SimpleMode)
{
<td class="text-center text-nowrap small">@Dul.DateTimeUtility.ShowTimeOrDate(m.Created.UtcDateTime.AddMinutes(-timeZoneOffsetMinutes))</td>
<td class="text-center">
<input type="checkbox" checked="@(m.Active ?? false)" disabled />
</td>
<td></td>
}
@if (!SimpleMode)
{
<td class="text-center">
<button class="btn btn-sm btn-primary" @onclick="@(() => EditBy(m))">Edit</button>
<button class="btn btn-sm btn-danger" @onclick="@(() => DeleteBy(m))">Del</button>
<button class="btn btn-sm btn-warning" @onclick="@(() => ToggleBy(m))">Change Active</button>
<button class="btn btn-sm btn-light" @onclick="@(() => MoveUp(m.Id))"><span class="oi oi-chevron-top"></span></button>
<button class="btn btn-sm btn-light" @onclick="@(() => MoveDown(m.Id))"><span class="oi oi-chevron-bottom"></span></button>
</td>
}
else
{
<td class="text-center">
<button class="btn btn-sm btn-primary" @onclick="@(() => EditBy(m))">Edit</button>
<button class="btn btn-sm btn-danger" @onclick="@(() => DeleteBy(m))">Del</button>
<button class="btn btn-sm btn-light" @onclick="@(() => MoveUp(m.Id))"><span class="oi oi-chevron-top"></span></button>
<button class="btn btn-sm btn-light" @onclick="@(() => MoveDown(m.Id))"><span class="oi oi-chevron-bottom"></span></button>
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
}
</div>
<div class="col-md-12">
<DulPager.DulPagerComponent Model="pager" PageIndexChanged="PageIndexChanged" />
</div>
<div class="col-md-12">
<Azunt.Web.Components.Pages.Posts.Components.SearchBox placeholder="Search Posts..." SearchQueryChanged="Search" />
</div>
</div>
<Azunt.Web.Components.Pages.Posts.Components.ModalForm @ref="EditorFormReference" ModelSender="model" CreateCallback="CreateOrEdit" EditCallback="CreateOrEdit" UserName="@UserName" ParentId="@ParentId" ParentKey="@ParentKey">
<EditorFormTitle>@EditorFormTitle</EditorFormTitle>
</Azunt.Web.Components.Pages.Posts.Components.ModalForm>
<Azunt.Web.Components.Pages.Posts.Components.DeleteDialog @ref="DeleteDialogReference" OnClickCallback="DeleteClick" />
@if (IsInlineDialogShow)
{
<div class="modal fade show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content shadow">
<div class="modal-header">
<h5 class="modal-title">Change Active State</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="ToggleClose"></button>
</div>
<div class="modal-body">
<p>Do you want to change the Active state of <strong>@model.Name</strong>?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @onclick="ToggleClick">Yes, Change</button>
<button type="button" class="btn btn-secondary" @onclick="ToggleClose">Cancel</button>
</div>
</div>
</div>
</div>
}
Azunt.Web\Components\Pages\PostsPages\Manage.razor.cs
using Azunt.PostManagement;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;
using Azunt.Web.Components.Pages.Posts.Components;
using Microsoft.Extensions.Configuration;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Threading.Tasks;
using Azunt.Web.Data;
using PostModel = Azunt.PostManagement.Post;
namespace Azunt.Web.Pages.Posts;
public partial class Manage : ComponentBase
{
public bool SimpleMode { get; set; } = true;
private int timeZoneOffsetMinutes;
#region Parameters
[Parameter] public int ParentId { get; set; } = 0;
[Parameter] public string ParentKey { get; set; } = "";
[Parameter] public string UserId { get; set; } = "";
[Parameter] public string UserName { get; set; } = "";
[Parameter] public string Category { get; set; } = "";
#endregion
#region Injectors
[Inject] public NavigationManager NavigationManagerInjector { get; set; } = null!;
[Inject] public IJSRuntime JSRuntimeInjector { get; set; } = null!;
[Inject] public IPostRepository RepositoryReference { get; set; } = null!;
[Inject] public IConfiguration Configuration { get; set; } = null!;
[Inject] public PostAppDbContextFactory DbContextFactory { get; set; } = null!;
[Inject] public UserManager<ApplicationUser> UserManagerRef { get; set; } = null!;
[Inject] public AuthenticationStateProvider AuthenticationStateProviderRef { get; set; } = null!;
[Inject] private IPostStorageService PostStorage { get; set; } = null!;
#endregion
#region Properties
public string EditorFormTitle { get; set; } = "CREATE";
public ModalForm EditorFormReference { get; set; } = null!;
public DeleteDialog DeleteDialogReference { get; set; } = null!;
protected List<PostModel> models = new();
protected PostModel model = new();
public bool IsInlineDialogShow { get; set; } = false;
private string searchQuery = "";
private string sortOrder = "";
protected DulPager.DulPagerBase pager = new()
{
PageNumber = 1,
PageIndex = 0,
PageSize = 10,
PagerButtonCount = 5
};
#endregion
#region Lifecycle
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
timeZoneOffsetMinutes = await JSRuntimeInjector.InvokeAsync<int>("Azunt.TimeZone.getLocalOffsetMinutes");
StateHasChanged(); // UI에 반영되도록
}
}
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrEmpty(UserId) || string.IsNullOrEmpty(UserName))
await GetUserIdAndUserName();
await DisplayData();
}
#endregion
#region Data Load
private async Task DisplayData()
{
var articleSet = ParentKey != ""
? await RepositoryReference.GetAllAsync<string>(pager.PageIndex, pager.PageSize, "", searchQuery, sortOrder, ParentKey, Category)
: await RepositoryReference.GetAllAsync<int>(pager.PageIndex, pager.PageSize, "", searchQuery, sortOrder, ParentId, Category);
pager.RecordCount = articleSet.TotalCount;
models = articleSet.Items.ToList();
StateHasChanged();
}
protected async void PageIndexChanged(int pageIndex)
{
pager.PageIndex = pageIndex;
pager.PageNumber = pageIndex + 1;
await DisplayData();
}
#endregion
#region CRUD Events
protected void ShowEditorForm()
{
EditorFormTitle = "CREATE";
model = new PostModel();
EditorFormReference.Show();
}
protected void EditBy(PostModel m)
{
EditorFormTitle = "EDIT";
model = m;
EditorFormReference.Show();
}
protected void DeleteBy(PostModel m)
{
model = m;
DeleteDialogReference.Show();
}
protected async void CreateOrEdit()
{
EditorFormReference.Hide();
await Task.Delay(50);
model = new PostModel();
await DisplayData();
}
protected async void DeleteClick()
{
if (!string.IsNullOrEmpty(model.FileName))
{
// 먼저 파일을 삭제
await PostStorage.DeleteAsync(model.FileName);
}
// 그 후, 데이터베이스에서 파일 레코드 삭제
await RepositoryReference.DeleteAsync(model.Id);
DeleteDialogReference.Hide();
model = new PostModel();
await DisplayData();
}
#endregion
#region Toggle Active
protected void ToggleBy(PostModel m)
{
model = m;
IsInlineDialogShow = true;
}
protected void ToggleClose()
{
IsInlineDialogShow = false;
model = new PostModel();
}
protected async void ToggleClick()
{
var connectionString = Configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrWhiteSpace(connectionString))
throw new InvalidOperationException("DefaultConnection is not configured.");
await using var context = DbContextFactory.CreateDbContext(connectionString);
model.Active = !model.Active;
context.Posts.Update(model);
await context.SaveChangesAsync();
IsInlineDialogShow = false;
model = new PostModel();
await DisplayData();
}
#endregion
#region Search & Sort
protected async void Search(string query)
{
pager.PageIndex = 0;
searchQuery = query;
await DisplayData();
}
protected async void SortByName()
{
sortOrder = sortOrder switch
{
"" => "Name",
"Name" => "NameDesc",
_ => ""
};
await DisplayData();
}
#endregion
#region User Info
private async Task GetUserIdAndUserName()
{
var authState = await AuthenticationStateProviderRef.GetAuthenticationStateAsync();
var user = authState.User;
if (user?.Identity?.IsAuthenticated == true)
{
var currentUser = await UserManagerRef.GetUserAsync(user);
UserId = currentUser?.Id ?? "";
UserName = user.Identity?.Name ?? "Anonymous";
}
else
{
UserId = "";
UserName = "Anonymous";
}
}
#endregion
private async Task MoveUp(long id)
{
await RepositoryReference.MoveUpAsync(id);
await DisplayData();
}
private async Task MoveDown(long id)
{
await RepositoryReference.MoveDownAsync(id);
await DisplayData();
}
}
웹 브라우저 실행 및 단계별 테스트
Ctrl+F5를 눌러 프로젝트를 실행하고 웹브라우저에서 /posts/
경로를 요청하면 Posts 폴더의 Manage 컴포넌트가 실행이 됩니다.
이 컴포넌트가 정상적으로 실행되면, 포스트를 입력, 출력, 수정, 삭제, 검색, 정렬, 등의 기능을 단계별로 테스트해 볼 수 있습니다.