diff --git a/TaxBaik.Web/Components/Admin/App.razor b/TaxBaik.Web/Components/Admin/App.razor
index 7a909ad..b8c78ab 100644
--- a/TaxBaik.Web/Components/Admin/App.razor
+++ b/TaxBaik.Web/Components/Admin/App.razor
@@ -37,7 +37,7 @@
-
+
diff --git a/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor b/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor
index e5b2bd0..4f3f76d 100644
--- a/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor
+++ b/TaxBaik.Web/Components/Admin/Layout/BlankLayout.razor
@@ -1,5 +1,3 @@
@inherits LayoutComponentBase
-
-
@Body
diff --git a/TaxBaik.Web/Components/Admin/Pages/Login.razor b/TaxBaik.Web/Components/Admin/Pages/Login.razor
index b6443bf..c04af83 100644
--- a/TaxBaik.Web/Components/Admin/Pages/Login.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/Login.razor
@@ -1,6 +1,6 @@
@page "/admin/login"
@layout TaxBaik.Web.Components.Admin.Layout.BlankLayout
@attribute [AllowAnonymous]
-@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
+@rendermode @(new InteractiveServerRenderMode(prerender: true))
로그인
diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor b/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor
index e4f3e75..2340adb 100644
--- a/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor
+++ b/TaxBaik.Web/Components/Admin/Shared/AdminLoginForm.razor
@@ -56,8 +56,15 @@
{
if (firstRender)
{
- await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
- await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm");
+ try
+ {
+ await Js.InvokeVoidAsync("taxbaikAdminSession.syncRouteClass");
+ await Js.InvokeVoidAsync("taxbaikAdminSession.bindLoginForm");
+ }
+ catch
+ {
+ // Login UI must remain visible even if JS binding fails.
+ }
}
}
}
diff --git a/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor b/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor
index 0a423c4..cd0c9b2 100644
--- a/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor
+++ b/TaxBaik.Web/Components/Admin/Shared/AdminTelemetryContext.razor
@@ -15,16 +15,23 @@
{
if (firstRender)
{
- var route = GetRoute();
- var context = ResolveContext(route);
- await Js.InvokeVoidAsync("taxbaikAdminSession.setContext",
- string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen,
- string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature,
- string.IsNullOrWhiteSpace(Action) ? context.Action : Action,
- string.IsNullOrWhiteSpace(Step) ? context.Step : Step,
- string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity,
- string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId,
- string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey);
+ try
+ {
+ var route = GetRoute();
+ var context = ResolveContext(route);
+ await Js.InvokeVoidAsync("taxbaikAdminSession.setContext",
+ string.IsNullOrWhiteSpace(Screen) ? context.Screen : Screen,
+ string.IsNullOrWhiteSpace(Feature) ? context.Feature : Feature,
+ string.IsNullOrWhiteSpace(Action) ? context.Action : Action,
+ string.IsNullOrWhiteSpace(Step) ? context.Step : Step,
+ string.IsNullOrWhiteSpace(Entity) ? context.Entity : Entity,
+ string.IsNullOrWhiteSpace(EntityId) ? context.EntityId : EntityId,
+ string.IsNullOrWhiteSpace(DataKey) ? context.DataKey : DataKey);
+ }
+ catch
+ {
+ // telemetry must never block rendering
+ }
}
}
diff --git a/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs b/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs
index 82f0ff7..98484ce 100644
--- a/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs
+++ b/TaxBaik.Web/Components/Admin/Shared/BusinessDayCalculator.cs
@@ -2,44 +2,55 @@ namespace TaxBaik.Web.Components.Admin.Shared;
public static class BusinessDayCalculator
{
- private sealed record HolidayWindow(DateOnly Start, DateOnly End)
+ private static readonly HashSet HolidayDates = new()
{
- public IEnumerable Dates()
- {
- for (var date = Start; date <= End; date = date.AddDays(1))
- {
- yield return date;
- }
- }
- }
+ // 2026
+ new DateOnly(2026, 1, 1),
+ new DateOnly(2026, 2, 16),
+ new DateOnly(2026, 2, 17),
+ new DateOnly(2026, 2, 18),
+ new DateOnly(2026, 3, 1),
+ new DateOnly(2026, 3, 2),
+ new DateOnly(2026, 5, 5),
+ new DateOnly(2026, 5, 25),
+ new DateOnly(2026, 6, 6),
+ new DateOnly(2026, 8, 15),
+ new DateOnly(2026, 8, 16),
+ new DateOnly(2026, 8, 17),
+ new DateOnly(2026, 9, 24),
+ new DateOnly(2026, 9, 25),
+ new DateOnly(2026, 9, 26),
+ new DateOnly(2026, 10, 3),
+ new DateOnly(2026, 10, 4),
+ new DateOnly(2026, 10, 5),
+ new DateOnly(2026, 10, 9),
+ new DateOnly(2026, 12, 25),
- private static readonly HolidayWindow[] HolidayWindows =
- {
- new(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 1)),
- new(new DateOnly(2026, 2, 16), new DateOnly(2026, 2, 18)),
- new(new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 2)),
- new(new DateOnly(2026, 5, 5), new DateOnly(2026, 5, 5)),
- new(new DateOnly(2026, 6, 6), new DateOnly(2026, 6, 6)),
- new(new DateOnly(2026, 8, 15), new DateOnly(2026, 8, 17)),
- new(new DateOnly(2026, 9, 24), new DateOnly(2026, 9, 26)),
- new(new DateOnly(2026, 10, 3), new DateOnly(2026, 10, 5)),
- new(new DateOnly(2026, 10, 9), new DateOnly(2026, 10, 9)),
- new(new DateOnly(2026, 12, 25), new DateOnly(2026, 12, 25)),
- new(new DateOnly(2027, 1, 1), new DateOnly(2027, 1, 1)),
- new(new DateOnly(2027, 2, 6), new DateOnly(2027, 2, 9)),
- new(new DateOnly(2027, 3, 1), new DateOnly(2027, 3, 2)),
- new(new DateOnly(2027, 5, 5), new DateOnly(2027, 5, 5)),
- new(new DateOnly(2027, 5, 13), new DateOnly(2027, 5, 13)),
- new(new DateOnly(2027, 6, 6), new DateOnly(2027, 6, 6)),
- new(new DateOnly(2027, 8, 15), new DateOnly(2027, 8, 16)),
- new(new DateOnly(2027, 9, 14), new DateOnly(2027, 9, 16)),
- new(new DateOnly(2027, 10, 3), new DateOnly(2027, 10, 4)),
- new(new DateOnly(2027, 10, 9), new DateOnly(2027, 10, 11)),
- new(new DateOnly(2027, 12, 25), new DateOnly(2027, 12, 26))
+ // 2027
+ new DateOnly(2027, 1, 1),
+ new DateOnly(2027, 2, 6),
+ new DateOnly(2027, 2, 7),
+ new DateOnly(2027, 2, 8),
+ new DateOnly(2027, 2, 9),
+ new DateOnly(2027, 3, 1),
+ new DateOnly(2027, 3, 2),
+ new DateOnly(2027, 5, 5),
+ new DateOnly(2027, 5, 13),
+ new DateOnly(2027, 6, 6),
+ new DateOnly(2027, 8, 15),
+ new DateOnly(2027, 8, 16),
+ new DateOnly(2027, 9, 14),
+ new DateOnly(2027, 9, 15),
+ new DateOnly(2027, 9, 16),
+ new DateOnly(2027, 10, 3),
+ new DateOnly(2027, 10, 4),
+ new DateOnly(2027, 10, 9),
+ new DateOnly(2027, 10, 10),
+ new DateOnly(2027, 10, 11),
+ new DateOnly(2027, 12, 25),
+ new DateOnly(2027, 12, 26)
};
- private static readonly HashSet HolidayDates = BuildHolidayDates();
-
public static DateOnly GetEffectiveDueDate(DateOnly dueDate)
{
var effectiveDate = dueDate;
@@ -61,19 +72,4 @@ public static class BusinessDayCalculator
public static bool IsBusinessDay(DateOnly date)
=> date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday
&& !HolidayDates.Contains(date);
-
- private static HashSet BuildHolidayDates()
- {
- var holidays = new HashSet();
-
- foreach (var window in HolidayWindows)
- {
- foreach (var date in window.Dates())
- {
- holidays.Add(date);
- }
- }
-
- return holidays;
- }
}
diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs
index 90c172f..e5cdad0 100644
--- a/TaxBaik.Web/Program.cs
+++ b/TaxBaik.Web/Program.cs
@@ -368,6 +368,7 @@ catch (Exception ex)
app.UsePathBase("/taxbaik");
app.UseResponseCompression();
+app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
@@ -385,6 +386,7 @@ if (!app.Environment.IsDevelopment())
app.MapControllers();
app.MapHealthChecks("/healthz");
app.MapRazorPages();
+app.MapStaticAssets();
// AllowAnonymous: JWT 미들웨어가 Blazor 셸 요청을 401로 차단하지 않도록 한다.
// 인증은 Blazor AuthorizeRouteView → RedirectToLogin 에서 처리한다.
diff --git a/docs/ENGINEERING_HARNESS.md b/docs/ENGINEERING_HARNESS.md
index 959a62b..ec4c5cb 100644
--- a/docs/ENGINEERING_HARNESS.md
+++ b/docs/ENGINEERING_HARNESS.md
@@ -13,7 +13,7 @@
| Auth | JWT 인증, 관리자 API는 `[Authorize]` | 익명으로 관리자 데이터 접근 가능 |
| Deploy | Gitea Actions CI/CD만 배포 경로 | 수동 SSH/복사로 운영 반영 |
| Evidence | 빌드, 테스트, E2E, API smoke 로그 | "확인함", "될 것" 같은 진술 |
-| Admin Render | `InteractiveWebAssemblyRenderMode(prerender: false)` | 어드민에 `InteractiveServerRenderMode` 또는 `prerender: true` 존재 |
+| Admin Render | 어드민 기본 셸은 `InteractiveWebAssemblyRenderMode(prerender: true)`로 초기 마크업을 확보하고, 로그인은 예외적으로 서버 프리렌더 허용 | 어드민 셸이 순수 클라이언트 렌더만으로 첫 화면을 비우거나, 로그인 폼이 HTML에 없다 |
| KST Timestamp | CI/배포/백업 폴더명과 추적 일시는 `TZ=Asia/Seoul` | `date`가 기본 UTC 또는 서버 로캘에 종속 |
## Architecture Guardrails
@@ -24,7 +24,10 @@
- Web은 Controller, 공개 Razor Pages SSR, Blazor host, 인증/서빙 설정을 가진다.
- Web.Client/Admin UI는 클라이언트 사이드 Blazor WebAssembly로 본다. 서버 DI 서비스에 의존하지 않고 API client만 호출한다.
- 관리자 호스트가 prerender를 사용하더라도 데이터 접근 원칙은 WASM + API-first다. prerender는 초기 마크업용이며 비즈니스 로직의 근거가 아니다.
-- 어드민 렌더 모드는 `InteractiveWebAssemblyRenderMode(prerender: false)`를 기본값으로 둔다. `InteractiveServerRenderMode`와 `prerender: true`는 어드민에서 허용하지 않는다.
+- 어드민 기본 렌더는 WASM이다. 다만 초기 흰 화면 방지 목적의 셸 프리렌더와 로그인 화면의 서버 프리렌더는 허용한다. 비즈니스 로직은 여전히 API-first다.
+- 로그인 화면은 예외적으로 “먼저 보여야 하는 화면”이다. JS 바인딩/텔레메트리/하이드레이션이 실패해도 로그인 폼 자체는 화면에 남아 있어야 하며, 실패 시 흰 화면이나 빈 본문을 허용하지 않는다.
+- 로그인 화면은 공통 추적보다 가시성을 우선한다. 추적은 보조이며, 로그인 폼 렌더를 가로막는 코드는 금지한다.
+- 로그인 화면의 JS는 `try/catch`로 감싸고, 실패해도 사용자 입력과 화면 표시를 막지 않아야 한다.
- JavaScript는 최소화한다. 브라우저 API, 인증 토큰 저장, 서드파티 편집기처럼 Blazor/MudBlazor만으로 해결하기 부적절한 경우에만 JS module로 격리한다.
- 상속은 프레임워크 요구 또는 명확한 다형성 모델에만 사용한다. 폼/테이블/CRUD 재사용은 기본적으로 컴포넌트 합성과 작은 service/client로 처리한다.
- 과유불급을 지킨다. 실제 재사용이 2곳 미만이면 새 추상화를 만들지 말고 기존 컴포넌트를 직접 조합한다.
@@ -62,6 +65,8 @@
- 상태 전이는 허용 목록을 둔다. 임의 문자열 저장을 금지한다.
- 삭제는 운영 데이터 손실 위험이 있으면 soft delete 또는 archive를 우선 검토한다.
- 콤보 값은 [COMMON_CODE_POLICY.md](./COMMON_CODE_POLICY.md)를 1차 기준으로 삼는다.
+- 로그인 화면은 배포 전 브라우저 실증이 필수다. `dotnet build`만으로 로그인 화면 정상 표시를 완료로 선언하지 않는다.
+- 로그인 화면 실증 기준은 최소 1회 실제 브라우저 응답, 로그인 폼 렌더, 입력 포커스 가능 여부 확인이다.
- 클라이언트 로그와 장애 진단 로그는 운영 데이터가 아니라 관측 데이터로 본다. 저장 실패는 사용자 흐름을 막지 않으며, 수집 실패 자체를 재시도 루프로 증폭하지 않는다.
- 동일 오류의 텔레그램 재알림은 일정 기간 1회로 제한하고, 재전송 목적의 루프는 금지한다.
- 데이터가 오류 재현에 필요하면 `entity`, `entityId`, `dataKey` 같은 최소 식별자만 남기고, 원문 데이터 전체를 로그에 싣지 않는다.