diff --git a/CLAUDE.md b/CLAUDE.md
index 918b019..2ca21ae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,8 +8,8 @@
Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리)
-Blazor (UI만) ← API (모든 로직) ← DB
- SignalR (변경 알림만)
+Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
+ Blazor 데이터 변경 자동 push/broadcast 금지
```
### SOLID 기반 순차 마이그레이션 전략
@@ -61,10 +61,10 @@ _refreshTokenExpirationMinutes = 10080;
**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴
-#### Phase 6: SignalR 통합
-- [ ] NotificationHub (변경 알림만)
-- [ ] Blazor에서 구독
-- [ ] 알림 후 API로 데이터 검증
+#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
+- [x] NotificationHub 제거
+- [x] 데이터 변경용 INotificationService 제거
+- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거
#### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트
@@ -136,11 +136,11 @@ _refreshTokenExpirationMinutes = 10080;
- Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동)
-### **Phase 6: SignalR 통합** ✅
-- NotificationHub (브로드캐스트만, 상태 관리 없음)
-- INotificationService (이벤트 기반)
-- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
-- Program.cs SignalR 등록
+### **Phase 6: Lite Blazor 운영 원칙** ✅
+- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
+- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
+- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
+- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.
---
@@ -160,11 +160,11 @@ Repositories (데이터 계층)
PostgreSQL Database
```
-**Blazor Server SignalR**:
-- 자동 연결 (내장 Hub connection)
-- NotificationHub 클라이언트 그룹 (admins)
-- 이벤트 기반 메시지 (상태 관리 없음)
-- 클라이언트는 알림 후 API로 데이터 검증
+**Lite Blazor 데이터 갱신**:
+- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
+- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
+- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
+- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
---
@@ -182,10 +182,10 @@ PostgreSQL Database
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)
-**실시간 알림 (Phase 6)**:
-- [x] NotificationHub 구현
-- [x] Event-driven 알림 시스템
-- [x] Scoped DI 등록
+**Lite Blazor / 데이터 갱신 (Phase 6)**:
+- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
+- [x] NotificationHub 제거
+- [x] 데이터 변경용 INotificationService 제거
**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지
diff --git a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor
index 3442860..bb5cb62 100644
--- a/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/ConsultingActivities.razor
@@ -100,7 +100,7 @@
@foreach (var client in clients)
{
- @client.CompanyName
+ @GetClientDisplayName(client)
}
@@ -155,9 +155,9 @@
try
{
activities = await ActivityClient.GetAllAsync();
- var (clientItems, _) = await ClientClient.GetPagedAsync();
+ var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
- clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
+ clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -273,6 +273,12 @@
activityForm = new();
}
+ private static string GetClientDisplayName(Client client)
+ => !string.IsNullOrWhiteSpace(client.CompanyName)
+ ? client.CompanyName
+ : !string.IsNullOrWhiteSpace(client.Name)
+ ? client.Name
+ : $"Client #{client.Id}";
private class ConsultingActivityForm
{
public int ClientId { get; set; }
diff --git a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor
index a31ec56..9d480be 100644
--- a/TaxBaik.Web/Components/Admin/Pages/Contracts.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/Contracts.razor
@@ -110,7 +110,7 @@
@foreach (var client in clients)
{
- @(string.IsNullOrEmpty(client.CompanyName) ? client.Name : client.CompanyName)
+ @GetClientDisplayName(client)
}
@@ -165,9 +165,9 @@
try
{
contracts = await ContractClient.GetAllAsync();
- var (clientItems, _) = await ClientClient.GetPagedAsync();
+ var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
- clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
+ clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
mrr = await ContractClient.GetMonthlyRecurringRevenueAsync();
}
catch (Exception ex)
@@ -253,6 +253,12 @@
contractForm = new();
}
+ private static string GetClientDisplayName(Client client)
+ => !string.IsNullOrWhiteSpace(client.CompanyName)
+ ? client.CompanyName
+ : !string.IsNullOrWhiteSpace(client.Name)
+ ? client.Name
+ : $"Client #{client.Id}";
private class ContractForm
{
public int? ClientId { get; set; }
diff --git a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor
index cb54fbf..dfa1c3f 100644
--- a/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/RevenueTrackings.razor
@@ -96,7 +96,7 @@
@foreach (var client in clients)
{
- @client.CompanyName
+ @GetClientDisplayName(client)
}
@@ -150,9 +150,9 @@
try
{
revenues = await RevenueClient.GetAllAsync();
- var (clientItems, _) = await ClientClient.GetPagedAsync();
+ var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
- clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
+ clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -252,6 +252,12 @@
revenueForm = new();
}
+ private static string GetClientDisplayName(Client client)
+ => !string.IsNullOrWhiteSpace(client.CompanyName)
+ ? client.CompanyName
+ : !string.IsNullOrWhiteSpace(client.Name)
+ ? client.Name
+ : $"Client #{client.Id}";
private class RevenueForm
{
public int ClientId { get; set; }
diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor
index 51e45ca..4ef829e 100644
--- a/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilingSchedules.razor
@@ -127,7 +127,7 @@
RequiredError="고객을 선택하세요.">
@foreach (var client in clients)
{
- @(string.IsNullOrEmpty(client.CompanyName) ? client.Name : client.CompanyName)
+ @GetClientDisplayName(client)
}
@@ -182,9 +182,9 @@
try
{
schedules = await TaxFilingClient.GetAllAsync();
- var (clientItems, _) = await ClientClient.GetPagedAsync();
+ var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
- clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
+ clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -286,9 +286,15 @@
scheduleForm = new();
}
+ private static string GetClientDisplayName(Client client)
+ => !string.IsNullOrWhiteSpace(client.CompanyName)
+ ? client.CompanyName
+ : !string.IsNullOrWhiteSpace(client.Name)
+ ? client.Name
+ : $"Client #{client.Id}";
private class TaxFilingScheduleForm
{
- public int ClientId { get; set; }
+ public int? ClientId { get; set; }
public string FilingType { get; set; } = "";
public DateTime? DueDate { get; set; }
public int FilingYear { get; set; } = DateTime.Now.Year;
diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor
index 1993d02..3c58f0e 100644
--- a/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/TaxFilings/TaxFilingList.razor
@@ -101,7 +101,7 @@
{
try
{
- var (items, _) = await ClientClient.GetPagedAsync(1, 20, search: value);
+ var (items, _) = await ClientClient.GetPagedAsync(1, 100, search: value);
return items;
}
catch
@@ -110,6 +110,12 @@
}
}
+ private static string GetClientDisplayName(Client client)
+ => !string.IsNullOrWhiteSpace(client.CompanyName)
+ ? client.CompanyName
+ : !string.IsNullOrWhiteSpace(client.Name)
+ ? client.Name
+ : $"Client #{client.Id}";
private async Task AddFiling()
{
try
diff --git a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor
index 4098001..ef88d0d 100644
--- a/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/TaxProfiles.razor
@@ -87,7 +87,7 @@ else
@foreach (var client in clients)
{
- @(string.IsNullOrEmpty(client.CompanyName) ? client.Name : client.CompanyName)
+ @GetClientDisplayName(client)
}
@@ -150,9 +150,9 @@ else
try
{
profiles = await TaxProfileClient.GetAllAsync();
- var (clientItems, _) = await ClientClient.GetPagedAsync();
+ var (clientItems, _) = await ClientClient.GetPagedAsync(pageSize: 1000);
clients = clientItems.ToList();
- clientMap = clients.ToDictionary(c => c.Id, c => c.CompanyName ?? "");
+ clientMap = clients.ToDictionary(c => c.Id, GetClientDisplayName);
}
catch (Exception ex)
{
@@ -195,7 +195,7 @@ else
await form.Validate();
if (!form.IsValid)
{
- Snackbar.Add("필수 항목을 입력해주세요.", Severity.Warning);
+ Snackbar.Add("고객을 선택하세요.", Severity.Warning);
return;
}
}
@@ -210,8 +210,13 @@ else
}
else
{
+ if (!profileForm.ClientId.HasValue)
+ {
+ Snackbar.Add("고객을 선택하세요.", Severity.Warning);
+ return;
+ }
var newId = await TaxProfileClient.CreateAsync(
- profileForm.ClientId,
+ profileForm.ClientId.Value,
profileForm.BusinessType);
if (newId > 0)
{
@@ -274,9 +279,15 @@ else
_ => Color.Default
};
+ private static string GetClientDisplayName(Client client)
+ => !string.IsNullOrWhiteSpace(client.CompanyName)
+ ? client.CompanyName
+ : !string.IsNullOrWhiteSpace(client.Name)
+ ? client.Name
+ : $"Client #{client.Id}";
private class TaxProfileForm
{
- public int ClientId { get; set; }
+ public int? ClientId { get; set; }
public string BusinessType { get; set; } = "";
public string TaxRiskLevel { get; set; } = "normal";
public DateTime? NextFilingDueDate { get; set; }
diff --git a/TaxBaik.Web/Hubs/NotificationHub.cs b/TaxBaik.Web/Hubs/NotificationHub.cs
deleted file mode 100644
index 0b029a6..0000000
--- a/TaxBaik.Web/Hubs/NotificationHub.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.SignalR;
-
-namespace TaxBaik.Web.Hubs;
-
-///
-/// Real-time notification hub for admin dashboard
-/// SOLID: Single Responsibility - Only broadcasts change notifications
-/// No state management - stateless broadcast pattern
-///
-[Authorize]
-public class NotificationHub : Hub
-{
- private const string AdminGroup = "admins";
-
- public override async Task OnConnectedAsync()
- {
- await Groups.AddToGroupAsync(Context.ConnectionId, AdminGroup);
- await base.OnConnectedAsync();
- }
-
- ///
- /// Broadcast inquiry status changed to all connected admins
- /// Clients should re-fetch from API to verify
- ///
- public async Task NotifyInquiryStatusChanged(int inquiryId, string newStatus)
- {
- await Clients.Group(AdminGroup).SendAsync("InquiryStatusChanged", new
- {
- InquiryId = inquiryId,
- Status = newStatus,
- ChangedAt = DateTime.UtcNow
- });
- }
-
- ///
- /// Broadcast inquiry submitted (new inquiry created)
- ///
- public async Task NotifyInquiryCreated(int inquiryId, string name)
- {
- await Clients.Group(AdminGroup).SendAsync("InquiryCreated", new
- {
- InquiryId = inquiryId,
- Name = name,
- CreatedAt = DateTime.UtcNow
- });
- }
-
- ///
- /// Broadcast client created
- ///
- public async Task NotifyClientCreated(int clientId, string name)
- {
- await Clients.Group(AdminGroup).SendAsync("ClientCreated", new
- {
- ClientId = clientId,
- Name = name,
- CreatedAt = DateTime.UtcNow
- });
- }
-
- ///
- /// Broadcast announcement published
- ///
- public async Task NotifyAnnouncementPublished(int announcementId, string title)
- {
- await Clients.Group(AdminGroup).SendAsync("AnnouncementPublished", new
- {
- AnnouncementId = announcementId,
- Title = title,
- PublishedAt = DateTime.UtcNow
- });
- }
-
- ///
- /// Broadcast tax filing completed
- ///
- public async Task NotifyFilingCompleted(int filingId, string filingType)
- {
- await Clients.Group(AdminGroup).SendAsync("FilingCompleted", new
- {
- FilingId = filingId,
- FilingType = filingType,
- CompletedAt = DateTime.UtcNow
- });
- }
-}
diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs
index 253eb5a..89308fe 100644
--- a/TaxBaik.Web/Program.cs
+++ b/TaxBaik.Web/Program.cs
@@ -52,9 +52,6 @@ builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
-// SignalR (Notifications only, no state management)
-builder.Services.AddSignalR();
-
// Razor Pages + Blazor Server 통합
builder.Services.AddRazorPages();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
@@ -197,9 +194,6 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationCore();
-// Notifications (SignalR)
-builder.Services.AddScoped();
-
// Telegram Notification
builder.Services.AddHttpClient();
@@ -350,8 +344,6 @@ app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
-// SignalR Hub
-app.MapHub("/taxbaik/notifications");
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
app.MapRazorComponents()
diff --git a/TaxBaik.Web/Services/NotificationService.cs b/TaxBaik.Web/Services/NotificationService.cs
deleted file mode 100644
index 90ebf75..0000000
--- a/TaxBaik.Web/Services/NotificationService.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-namespace TaxBaik.Web.Services;
-
-///
-/// Notification service for real-time admin updates
-/// SOLID: Single Responsibility - Event notification only
-/// Uses Blazor Server's built-in SignalR for real-time communication
-///
-public interface INotificationService
-{
- event Func? OnInquiryStatusChanged;
- event Func? OnInquiryCreated;
- event Func? OnClientCreated;
- event Func? OnAnnouncementPublished;
- event Func? OnFilingCompleted;
-
- Task TriggerInquiryStatusChanged(int inquiryId, string status);
- Task TriggerInquiryCreated(int inquiryId, string name);
- Task TriggerClientCreated(int clientId, string name);
- Task TriggerAnnouncementPublished(int announcementId, string title);
- Task TriggerFilingCompleted(int filingId, string filingType);
-}
-
-public class NotificationService : INotificationService
-{
- private readonly ILogger _logger;
-
- public NotificationService(ILogger logger)
- {
- _logger = logger;
- }
-
- public event Func? OnInquiryStatusChanged;
- public event Func? OnInquiryCreated;
- public event Func? OnClientCreated;
- public event Func? OnAnnouncementPublished;
- public event Func? OnFilingCompleted;
-
- public async Task TriggerInquiryStatusChanged(int inquiryId, string status)
- {
- _logger.LogInformation($"Inquiry {inquiryId} status changed to {status}");
- if (OnInquiryStatusChanged != null)
- await OnInquiryStatusChanged(inquiryId, status);
- }
-
- public async Task TriggerInquiryCreated(int inquiryId, string name)
- {
- _logger.LogInformation($"New inquiry {inquiryId} from {name}");
- if (OnInquiryCreated != null)
- await OnInquiryCreated(inquiryId, name);
- }
-
- public async Task TriggerClientCreated(int clientId, string name)
- {
- _logger.LogInformation($"New client {clientId}: {name}");
- if (OnClientCreated != null)
- await OnClientCreated(clientId, name);
- }
-
- public async Task TriggerAnnouncementPublished(int announcementId, string title)
- {
- _logger.LogInformation($"Announcement {announcementId} published: {title}");
- if (OnAnnouncementPublished != null)
- await OnAnnouncementPublished(announcementId, title);
- }
-
- public async Task TriggerFilingCompleted(int filingId, string filingType)
- {
- _logger.LogInformation($"Filing {filingId} ({filingType}) completed");
- if (OnFilingCompleted != null)
- await OnFilingCompleted(filingId, filingType);
- }
-}