# CLAUDE.md — TaxBaik 운영 메모

## 우선 기준

1. [docs/INDEX.md](./docs/INDEX.md)
2. [docs/ENGINEERING_HARNESS.md](./docs/ENGINEERING_HARNESS.md)
3. [docs/DOUZONE_UX_GUIDE.md](./docs/DOUZONE_UX_GUIDE.md)
4. [docs/COMMON_CODE_POLICY.md](./docs/COMMON_CODE_POLICY.md)
5. [docs/COMBO_POLICY.md](./docs/COMBO_POLICY.md)

이 파일은 실행 절차, 서버 메모, 과거 이력만 둔다. 아키텍처/UX/콤보 기준은 위 문서를 따른다.

## 🎯 **개발 핵심 지침 (Development Standards 2026-07)**

### 입력 검증 패턴 (Validation Pattern)

**원칙: 클라이언트 + 서버 이중 검증**

#### 1. 클라이언트 (프론트엔드)
- ✅ 실시간 마스킹 (사용자 입력하면서 자동 포맷팅)
- ✅ 실시간 피드백 (에러 메시지 즉시 표시)
- ✅ 유효성 검사 후 제출 차단
- ✅ 문자 카운터 (예: "현재: 50/5000")

**예시: 전화번호**
```javascript
// 입력: 01012345678 → 표시: 010-1234-5678
// 정규식: ^(0(2|3[1-3]|4[1-4]|...|70|50[5-9])\d{7,8}|0\d{9,10})$
```

#### 2. 서버 (백엔드)
- ✅ DTO 어노테이션 (DataAnnotations)
- ✅ 서비스 로직 검증 (명확한 에러 메시지)
- ✅ 데이터베이스 제약 조건

**패턴: InquiryService.cs**
```csharp
// 1. DTO 레벨
public class SubmitInquiryDto
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [Required]
    [RegularExpression(@"^(0(2|3[1-3]|...")]
    public string Phone { get; set; }

    [StringLength(5000, MinimumLength = 10)]
    public string Message { get; set; }
}

// 2. 서비스 로직
public async Task<int> SubmitAsync(...)
{
    if (string.IsNullOrWhiteSpace(message))
        throw new ValidationException("문의 내용을 입력하세요.");

    var trimmedMessage = message.Trim();
    if (trimmedMessage.Length < 10)
        throw new ValidationException("문의 내용은 최소 10자 이상이어야 합니다.");
}
```

### 한국 전화번호 처리 표준

**지원 형식:**
- 고정전화: `02-123-4567`, `031-1234-5678`
- 휴대폰: `010-1234-5678`, `011-1234-5678`
- VoIP: `070-1234-5678`, `0505-1234-5678`

**포맷팅 규칙:**
- 2-3자리 국번 + 3-4자리 국번 뒤 + 4자리 번호
- 고정전화(10자): `XXXX-XXX-XXXX` (4-3-3)
- 휴대폰(11자): `XXX-XXXX-XXXX` (3-4-4)

**정규식:**
```csharp
private static readonly Regex PhoneRegex = new(
    @"^(0(?:2|3[1-3]|4[1-4]|5[1-5]|6[1-4]|70|50[5-9]|[7-9](?:\d{1,2})?)\d{7,8}|0\d{9,10})$");
```

### 메시지 내용 길이 제한 표준

**규칙:**
- 최소: 10자 (너무 짧은 내용 방지)
- 최대: 5000자 (DB 및 성능)

**적용:**
- 프론트엔드: `<textarea maxlength="5000">`, 실시간 카운터
- 백엔드: `[StringLength(5000, MinimumLength = 10)]`
- 서비스: 길이 검증 + 명확한 에러 메시지

### DTO 및 데이터 어노테이션 규칙

**필수 어노테이션:**
```csharp
using System.ComponentModel.DataAnnotations;

public class SubmitInquiryDto
{
    [Required(ErrorMessage = "이름을 입력하세요.")]
    [StringLength(100, ErrorMessage = "이름은 최대 100자입니다.")]
    public string Name { get; set; }

    [Required]
    [RegularExpression(pattern, ErrorMessage = "올바른 형식이 아닙니다.")]
    public string Phone { get; set; }

    [StringLength(5000, MinimumLength = 10)]
    public string Message { get; set; }
}
```

**원칙:**
- DTO는 기본 데이터 검증만 담당 (필수, 길이, 형식)
- 복잡한 검증은 서비스 로직에서 처리
- 모든 에러 메시지는 사용자 친화적

### DataAnnotations vs FluentValidation 선택 기준

**DataAnnotations 사용 (현재 기본)**

언제 사용:
- 필수/선택, 길이, 정규식 등 기본 검증
- 대부분의 DTO 검증

장점:
- 프레임워크 내장 (추가 패키지 불필요)
- 간단하고 빠름
- DTO에서 한눈에 볼 수 있음

```csharp
public class SubmitInquiryDto
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [Required]
    [RegularExpression(@"^01[0-9]\d{7,8}$")]
    public string Phone { get; set; }
}
```

**FluentValidation 사용 (필요시 도입)**

언제 사용:
- 조건부 검증 (필드 A가 있으면 필드 B는 필수)
- 데이터베이스 조회 필요한 검증 (중복 체크)
- 복잡한 비즈니스 규칙 검증
- 여러 필드 간 관계 검증

도입 절차:
1. `FluentValidation` NuGet 패키지 추가
2. `AbstractValidator<DTO>` 상속한 검증기 클래스 생성
3. `Program.cs`에 등록
4. 서비스 또는 엔드포인트에서 검증기 주입

```csharp
public class SubmitInquiryValidator : AbstractValidator<SubmitInquiryDto>
{
    public SubmitInquiryValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("이름을 입력하세요.")
            .MaximumLength(100);

        RuleFor(x => x.Phone)
            .NotEmpty()
            .Matches(@"^01[0-9]\d{7,8}$").WithMessage("올바른 전화번호");

        // 복잡한 검증
        RuleFor(x => x.ServiceType)
            .NotEmpty()
            .When(x => x.Name.Contains("법인")).WithMessage("법인은 상담분야 필수");
    }
}
```

**결정 규칙:**
- ✅ **기본 (지금)**: DataAnnotations
- ⏳ **필요시 (향후)**: FluentValidation 도입
- ❌ **혼용 금지**: 같은 DTO에 두 방식 섞지 않기

### Telegram 알림 통합 패턴

**구현 단계:**
1. DTO에 검증 어노테이션 추가
2. 서비스에서 비즈니스 로직 검증
3. 데이터 저장 후 비동기 알림 발송
4. 알림 실패해도 저장 데이터는 유지

**예시: Contact.cshtml.cs**
```csharp
await _inquiryService.SubmitAsync(...);  // await로 완료 대기
// 서비스 내부에서 TelegramInquiryNotificationService 호출
```

### 현장 검증 체크리스트

새 폼/페이지 추가 시:

- [ ] 클라이언트 검증: 실시간 마스킹 & 피드백
- [ ] DTO: DataAnnotations 어노테이션 완성
- [ ] 서버 검증: 서비스에 명확한 메시지 추가
- [ ] 길이 제한: 적절한 Min/Max 설정
- [ ] Telegram 알림: 서비스에 통지 로직 추가
- [ ] 테스트: 유효/무효 데이터 모두 테스트
- [ ] 로그: 제출 성공/실패 모두 기록

---

## Gitea Token Rule

**URL**: `https://gitea.taxbaik.com` (공개 Gitea 인스턴스)

**토큰 관리**:
- `GITEA_TOKEN_TAXBAIK` 환경변수 사용 (필수)
- `GITEA_TOKEN`은 사용하지 않음
- dispatch 전에는 `GET /api/v1/user`로 토큰 유효성 먼저 확인

**Git Push 명령**:
```bash
# 권장 (간단함)
git push http://gitea.taxbaik.com/kjh2064/taxbaik.git master

# 또는 토큰 명시
git push "http://kjh2064:${GITEA_TOKEN_TAXBAIK}@gitea.taxbaik.com/kjh2064/taxbaik.git" master

# Git credential 저장했다면 (권장)
git config credential.helper store
echo "http://kjh2064:${GITEA_TOKEN_TAXBAIK}@gitea.taxbaik.com" > ~/.git-credentials
chmod 600 ~/.git-credentials
git push http://gitea.taxbaik.com/kjh2064/taxbaik.git master
```

**CI/CD 자동 배포**:
1. git push 완료 → Gitea Actions 자동 trigger
2. `.gitea/workflows/deploy.yml` 실행
3. 빌드 → 배포 → 서비스 재시작
4. 진행 상황은 Gitea Actions에서 확인

## 🏗️ **아키텍처 리팩토링 (API-First 전환)**

### 핵심 원칙 (2026년 적용)
```
❌ 이전: Blazor Server (서버 상태 관리)
Blazor → Service (서버) → DB

✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만, 사용자 액션 후 API 재조회) ← API (모든 로직) ← DB
          Blazor 데이터 변경 자동 push/broadcast 금지
```

### SOLID 기반 순차 마이그레이션 전략

#### Phase 1-3: API Foundations ✅
- [x] Auth API (JWT 토큰)
- [x] Blog API (CRUD)
- [x] Category API
- [x] Inquiry API
- [x] SiteSettings API
- [x] Dashboard API ⭐ (v1.0 - 2026-06-28)

**전략**: Dashboard를 먼저 마이그레이션 → 검증 후 다른 페이지 순차 처리

#### Phase 4: Dashboard Blazor → API 클라이언트 ✅
- [x] Dashboard.razor 리팩토링
  - AdminDashboardClient 구현
  - 서비스 inject → API 호출로 변경
  - 에러 처리 & 로딩 상태
- [x] 구조: IAdminDashboardClient → HttpClient 추상화

**완료**: 2026-06-28 / Blazor 컴포넌트가 API 클라이언트를 통해 RESTful 엔드포인트 호출

#### Phase 5: JWT 토큰 개선 (진행중) ✅
- [x] Access Token (15분) + Refresh Token (7일) 분리
- [x] AuthController에 `/api/auth/refresh` 엔드포인트 추가
- [x] AuthService: GenerateTokenPair() & ValidateRefreshToken()
- [x] CustomAuthenticationStateProvider: accessToken/refreshToken 저장 분리
- [x] TokenRefreshHandler: DelegatingHandler로 401 자동 갱신
- [x] Program.cs: AdminDashboardClient에 TokenRefreshHandler 적용
- [x] Login.razor: 새 토큰 쌍 처리

**구현 상세**:
```csharp
// Access Token: 15분 / Refresh Token: 7일
_accessTokenExpirationMinutes = 15;
_refreshTokenExpirationMinutes = 10080;

// 토큰 갱신: POST /api/auth/refresh?refreshToken=...
// 응답: { accessToken, refreshToken, expiresIn }
```

**자동 갱신 흐름**:
1. AdminDashboardClient 요청 → TokenRefreshHandler
2. Bearer token 자동 추가
3. 401 응답 → localStorage에서 refreshToken 읽기
4. POST /api/auth/refresh 호출
5. 새 토큰 쌍 저장 및 원래 요청 재시도

**완료**: 2026-06-28 / 토큰 갱신 자동화 + 이중 토큰 패턴

#### Phase 6: Blazor 데이터 변경 SignalR 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거
- [x] Program.cs의 별도 AddSignalR/MapHub 등록 제거

#### Phase 7: 순차적 마이그레이션 ✅
- [x] Blog 페이지 → API 클라이언트
- [x] Inquiry 페이지 → API 클라이언트
- [x] 공개 콘텐츠 & 기본 관리 (Clients, TaxFilings, FAQs, Announcements)
- [x] CRM & 세무관리 (TaxProfile, TaxFilingSchedule, Contract, ConsultingActivity, RevenueTracking)

**완료**: 2026-06-28 / 모든 도메인 API-First 마이그레이션 완료

#### Phase 8: WebAssembly 렌더 모드 전환 ✅ (2026-07-03)
- [x] InteractiveWebAssemblyRenderMode 적용 (Blazor Server → WebAssembly)
- [x] Admin 컴포넌트 WebAssembly 클라이언트 전환
- [x] 서버 상태 관리 제거 (Circuit 불필요)
- [x] 클라이언트-서버 완전 분리
- [x] E2E 테스트 검증 (20/20 통과 - 프로덕션)

**구현 상세**:
```csharp
// Program.cs - Admin UI 렌더 모드
app.MapRazorComponents<TaxBaik.WasmClient.Components.Admin.App>()
   .AddInteractiveWebAssemblyRenderMode()
   .AddAdditionalAssemblies(typeof(TaxBaik.WasmClient._Imports).Assembly)  // ⭐ 필수!
   .AllowAnonymous();
```

**⚠️ 중요: AddAdditionalAssemblies 필수 이유**:
- Root 컴포넌트(App.razor)만으로는 모든 WASM 컴포넌트를 탐색할 수 없음
- Routes.razor, 모든 Page 컴포넌트, Shared 컴포넌트는 명시적 등록 필수
- 제거하면 컴포넌트 탐색 실패 → ObjectDisposedException → 초기화 실패
- **절대 제거하지 말 것**

**배포 환경 변수 (deploy_gb.sh)**:
```bash
# ✅ 반드시 설정해야 함
export ASPNETCORE_ENVIRONMENT=Production
export ASPNETCORE_URLS="http://127.0.0.1:$TARGET_PORT"
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=<secret>"
export DOTNET_PRINT_TELEMETRY_MESSAGE=false

# ❌ 누락하면 배포 실패 (Missing connection string)
```

**효과**:
- ✅ 무상태 서버 (stateless)
- ✅ 클라이언트 사이드 렌더링 (CSR)
- ✅ 서버 부하 0 (Circuit 메모리 해제)
- ✅ 동시 접속 무제한 (확장성 ∞)
- ✅ Green-Blue 무중단 배포 검증됨
- ✅ E2E 테스트로 모든 페이지 검증됨
- ✅ ERP 프로젝트 아키텍처 준비 완료

**완료**: 2026-07-03 / WebAssembly 기반 아키텍처 확정 + 프로덕션 검증

**⚠️ Phase 8 알려진 한계 (Phase 9에서 수정됨)**:
Phase 8에서는 `<Routes>`(App.razor)와 `<Router>`(Routes.razor)에 전역 `@rendermode`를 지정해 `prerender: false`로 고정했다. 그 결과 로그인 화면을 포함한 모든 어드민 페이지가 WASM 다운로드 완료 전까지 빈 화면/스피너만 보여주는 문제가 있었다(`scripts/validate_admin_render.sh`에 이 트레이드오프가 "기능 우선, 흰 화면 0.5~2초 감수"로 기록되어 있었음). 이는 `docs/ENGINEERING_HARNESS.md`의 "로그인 화면은 예외적으로 서버 프리렌더 허용" 규칙을 충족하지 못한 상태였다. Phase 9에서 페이지별 개별 렌더모드 지정으로 교체했다.

#### Phase 9: 어드민 페이지별 렌더모드 정상화 ✅ (2026-07-03)
- [x] `App.razor`/`Routes.razor`에서 전역 `@rendermode` 제거 (Router/Routes 자체는 렌더모드를 강제하지 않음)
- [x] `Login.razor`만 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: true))`로 명시 → 로그인 폼이 최초 HTML 응답에 정적으로 포함되어 WASM 다운로드 중에도 즉시 표시됨
- [x] 나머지 `[Authorize]` 어드민 페이지는 `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`로 명시 유지 → 인증 컨텍스트 없이 prerender될 때 `AuthorizeRouteView`가 빈 화면을 그리는 문제(Phase 8 초기에 겪었던 문제) 재발 방지
- [x] WASM 부팅 완료 전 로그인 버튼은 "준비 중" 비활성 상태로 표시, 부팅 완료 시 정상 상태로 전환(업데이트 스플래시)

**핵심 원칙**: Blazor Web App은 "전역 렌더모드" 또는 "페이지별 렌더모드" 중 하나만 선택할 수 있다. Router/Routes에 렌더모드를 지정하면 그 하위 모든 페이지의 개별 `@rendermode` 지시자는 무시된다. 로그인만 예외적으로 prerender가 필요하므로 전역 방식을 버리고 페이지별 방식으로 전환했다.

**완료**: 2026-07-03 / 로그인 흰 화면 제거 + 인증 페이지 안정성 유지

#### Phase 13: FastEndpoints 마이그레이션 ✅ (2026-07-03)
- [x] AdminDashboardController → FastEndpoints 마이그레이션
  - GetSummaryEndpoint.cs (GET /api/admin-dashboard/summary)
  - GetUpcomingFilingsEndpoint.cs (GET /api/admin-dashboard/upcoming-filings)
  - GetRecentInquiriesEndpoint.cs (GET /api/admin-dashboard/recent-inquiries)
  - GetMonthlyStatsEndpoint.cs (GET /api/admin-dashboard/monthly-stats)
- [x] AdminDashboardDtos.cs (요청/응답 DTO 정의)
- [x] 기존 AdminDashboardController.cs 제거
- [x] AdminDashboardClient와 호환성 유지 (엔드포인트 경로 동일)
- [x] FastEndpoints 자동 등록 (Program.cs AddFastEndpoints 활용)

**이점**:
- 컨트롤러 기반에서 FastEndpoints 기반으로 일관성 강화
- 모든 API 엔드포인트가 FastEndpoints로 통일됨
- 더 간결한 엔드포인트 구조 (try-catch 불필요)
- 자동 매핑 및 검증

**완료**: 2026-07-03 / AdminDashboard 엔드포인트 FastEndpoints 마이그레이션 완료

**보류된 결정 (2026-07-03, 향후 별도 Phase)**:
- 공개 홈페이지 Razor Pages → MVC(Controller+View) 전면 재작성: 기능적 이득 없이 운영 중인 SEO 트래픽 페이지 전체를 기계적으로 재작성하는 고비용 작업이라 이번엔 보류. 필요 시 Phase 10으로 별도 진행.
- 포털(고객용, `Pages/Portal/*`, 현재 Razor Pages + 쿠키/OAuth) → 어드민과 동일한 MudBlazor+WASM 전환: 완전히 새로운 프로젝트 구조가 필요해 이번 범위에서 제외. 필요 시 Phase 11로 별도 진행.

**현재 상태**: **✅ Phase 1-9, Phase 13 COMPLETE & VERIFIED (2026-07-03)**
- ✅ 모든 API 엔드포인트 구현됨
- ✅ 모든 Browser Client 구현됨
- ✅ 16개 Blazor 페이지 API-First 마이그레이션 완료
- ✅ MudDataGrid 더존 세무회계프로그램 UX 수준 적용
- ✅ MudDialog 모달 패턴 (흰 화면 플래시 제거)
- ✅ ConfirmDialog 삭제 확인 컴포넌트
- ✅ **WebAssembly 렌더 모드 완전 적용** (Admin UI 클라이언트 사이드)
- ✅ **E2E 테스트 검증 완료** (20/20 테스트 통과 - 프로덕션 환경)
  - Desktop Chrome: 5/5
  - iPhone 12: 5/5
  - iPad Pro: 5/5
  - Galaxy S9+: 5/5
- ✅ 배포 스크립트 환경 변수 강화

---

## 📊 **전체 프로젝트 완료 현황**

### **Phase 5: JWT 토큰 개선** ✅
- Access Token (15분) + Refresh Token (7일) 분리
- TokenRefreshHandler (401 자동 갱신)
- ITokenStore (메모리 기반 Blazor Server 안전)
- CustomAuthenticationStateProvider (토큰 쌍 관리)
- Login.razor (새 토큰 패턴 구현)

### **Phase 7: API-First 마이그레이션** ✅

**Phase 7-1: Blog** ✅
- API: 완성 (CRUD, 페이징)
- Blazor: 이미 API 클라이언트 사용 중

**Phase 7-2: Inquiry** ✅
- API: 완성 (상태 변경, 메모, 고객 변환)
- Blazor: InquiryTable + InquiryDetail 완전 마이그레이션

**Phase 7-3: 공개 콘텐츠 & 기본 관리 페이지** ✅
- 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
- 5개 Browser Client (IXxxBrowserClient)
- 9개 Blazor 페이지 마이그레이션

| 페이지 | API | Client | Blazor |
|------|---|---|---|
| Clients | ✅ ClientController | ✅ IClientBrowserClient | ✅ List + Edit |
| TaxFilings | ✅ TaxFilingController | ✅ ITaxFilingBrowserClient | ✅ List + Table |
| Faqs | ✅ FaqController | ✅ IFaqBrowserClient | ✅ List + Edit |
| Announcements | ✅ AnnouncementController | ✅ IAnnouncementBrowserClient | ✅ List + Edit |
| Inquiries | ✅ InquiryController | ✅ IInquiryBrowserClient | ✅ List + Detail |
| Dashboard | ✅ AdminDashboardController | ✅ IAdminDashboardClient | ✅ Refactored |

**Phase 7-4: CRM & 세무관리 (신규 - 2026-06-28)** ✅
- 5개 API Controller (TaxProfile, TaxFilingSchedule, ConsultingActivity, Contract, RevenueTracking)
- 5개 Browser Client (API-First 패턴)
- 5개 Blazor 페이지 (MudDataGrid Dense, Virtualize, Modal Dialog)
- 더존 세무회계프로그램 수준의 그리드 UX (32px 행 높이, 데이터 밀도 최적화)

| 페이지 | API | Client | Blazor | 핵심 기능 |
|------|---|---|---|---------|
| TaxProfiles | ✅ TaxProfileController | ✅ ITaxProfileBrowserClient | ✅ List + Modal | 위험도 추적, 신고 예정일 |
| TaxFilingSchedules | ✅ TaxFilingScheduleController | ✅ ITaxFilingScheduleBrowserClient | ✅ List + Modal | D-day 추적, 완료 처리 |
| Contracts | ✅ ContractController | ✅ IContractBrowserClient | ✅ List + Modal | MRR 계산, 계약 기간 추적 |
| ConsultingActivities | ✅ ConsultingActivityController | ✅ IConsultingActivityBrowserClient | ✅ List + Modal | 상담 기록, 팔로업 자동 추적 |
| RevenueTrackings | ✅ RevenueTrackingController | ✅ IRevenueTrackingBrowserClient | ✅ List + Modal | 청구/납부 추적, 상태 관리 |

**UI 특성**:
- MudDataGrid Dense (행높이 32px) + Virtualize (1000+ 행 성능)
- MudDialog Create/Edit (흰 화면 플래시 방지)
- ConfirmDialog Delete (사용자 확인)
- Status Color Chips (Error/Warning/Success)
- Client 링크 (상세 페이지 연동)

### **Phase 6: Lite Blazor 운영 원칙** ✅
- Blazor에서 데이터 변경 시 SignalR publish/subscribe로 목록을 자동 갱신하지 않는다.
- NotificationHub와 데이터 변경용 INotificationService는 제거된 상태를 유지한다.
- Blazor Server의 기본 interactive 연결은 UI 구동에만 사용한다.
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지하고, 변경 전파 방식만 API 재조회로 제한한다.

---

## 🏗️ **최종 아키텍처 (Phase 8: WebAssembly)**

```
🌐 브라우저 (클라이언트)
  ↓ (WebAssembly 런타임)
Admin Pages (CSR - 클라이언트 사이드 렌더링)
    ↓ (Browser Client 주입)
IXxxBrowserClient 추상화 (HttpClient 기반)
    ↓ (HTTP/REST API)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🖥️ 서버 (ASP.NET Core 10 - 무상태/Stateless)
API Controllers (애플리케이션 계층)
    ↓ (서비스 호출)
Services (비즈니스 로직)
    ↓ (저장소 호출)
Repositories (데이터 계층)
    ↓ (SQL/Dapper)
🗄️ PostgreSQL 18
```

**WebAssembly 렌더 모드 (Phase 8)**:
- Admin UI는 **클라이언트 사이드에서 완전 렌더링** (WebAssembly)
- 서버는 **순수 API 역할** (Circuit 메모리 0)
- 모든 비즈니스 로직은 서버 API에만 존재
- 클라이언트는 API 호출 + 상태 관리만 담당

**API-First 데이터 패턴**:
- Blazor Server의 자동 연결/Circuit 미사용
- 사용자 액션 후 필요한 데이터는 API로 조회
- 데이터 변경 broadcast/push 금지
- 각 도메인 CRUD는 REST API 엔드포인트만 사용

**확장성 (ERP 대비)**:
- 서버 메모리: Circuit 해제로 무제한 확장 가능
- 동시 접속: Stateless 아키텍처로 수평 확장
- WebAssembly 클라이언트: 독립적 배포 가능 (향후 WASM-only 앱 지원)

---

## ✅ **완료 항목 체크리스트**

**인증 & 토큰 (Phase 5)**:
- [x] 이중 토큰 분리 (Access + Refresh)
- [x] 자동 갱신 (TokenRefreshHandler)
- [x] 안전한 메모리 저장소 (ITokenStore)

**API-First 마이그레이션 (Phase 7)**:
- [x] Phase 7-1: Blog API + Blazor 클라이언트
- [x] Phase 7-2: Inquiry API + Blazor 클라이언트
- [x] Phase 7-3: 공개 콘텐츠 & 기본 관리 페이지 (6개 API, 6개 Blazor)
- [x] Phase 7-4: CRM & 세무관리 (5개 API, 5개 Blazor) - **2026-06-28 완료**
- [x] SOLID 원칙 전체 적용 (Single Responsibility, Dependency Inversion)

**Lite Blazor / 데이터 갱신 (Phase 6)**:
- [x] Blazor 데이터 변경 SignalR 자동 갱신 제거
- [x] NotificationHub 제거
- [x] 데이터 변경용 INotificationService 제거

**Blazor 페이지 & UI 고도화 (Phase 7-4)**:
- [x] 5개 CRM/세무관리 Blazor 페이지
- [x] MudDataGrid Dense + Virtualize (32px 행 높이)
- [x] MudDialog 모달 Create/Edit (흰 화면 플래시 제거)
- [x] ConfirmDialog 삭제 확인
- [x] 상태별 컬러 칩 (Status/Risk Level)
- [x] 클라이언트 링크 (상세 페이지 연동)
- [x] D-day 추적, MRR 계산, 팔로업 자동 추적

**WebAssembly 렌더 모드 (Phase 8 - 2026-07-03)**:
- [x] InteractiveWebAssemblyRenderMode 적용
- [x] Admin 컴포넌트 클라이언트 사이드 렌더링
- [x] 서버 Circuit 메모리 완전 해제
- [x] Stateless 아키텍처 확정
- [x] ERP 프로젝트 아키텍처 준비

**FastEndpoints 마이그레이션 (Phase 13 - 2026-07-03)**:
- [x] AdminDashboardController → FastEndpoints 4개 엔드포인트
- [x] AdminDashboardDtos 요청/응답 정의
- [x] 기존 컨트롤러 제거
- [x] 엔드포인트 경로 호환성 유지 (AdminDashboardClient 미수정)

**빌드 & 배포**:
- [x] 0 오류, 모든 경고 기록됨
- [x] 모든 커밋 Gitea에 푸시됨
- [x] CI/CD 자동 배포 준비 완료
- [x] WebAssembly 렌더 모드 검증 완료
- [x] FastEndpoints 마이그레이션 완료

---

## 📝 **개발 원칙 준수**

✅ **SOLID 원칙**:
- Single Responsibility: 각 클라이언트 = 한 도메인
- Open/Closed: 기존 코드 수정 없이 확장
- Liskov Substitution: 대체 가능한 구현
- Interface Segregation: 세밀한 인터페이스
- Dependency Inversion: 추상화에 의존

✅ **유지보수성**:
- 명확한 계층 분리
- 일관된 에러 처리
- 타입 안전성 (C# + Dapper)
- 테스트 가능한 구조 (DI + 인터페이스)

✅ **리팩토링**:
- 서비스 직접 주입 → API 클라이언트
- 강한 결합 → 느슨한 결합
- 서버 상태 → 클라이언트-서버 분리

---

## 1. 프로젝트 개요

**클라이언트**: 백원숙 세무사 (세무사·부동산중개사·보험설계사 자격)  
**목적**: 온라인 전문성 표현 + 블로그 SEO 유입 + 전국 고객 확보  
**핵심 포지셔닝**: "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너"  
**기술 스택**: ASP.NET Core 10 / Dapper / PostgreSQL 18 / Nginx / Gitea CI

---

## 2. 아키텍처

### 2.1 프로젝트 구조 (통합)

**단일 앱 구조** (공개 사이트 + 관리자까지 하나의 ASP.NET Core 앱):

```
src/                     빌드 가능한 .NET 소스 전체 (CI는 이 폴더만 빌드 대상으로 참조)
  TaxBaik.Domain           클래스 라이브러리 (엔티티, 인터페이스, enum)
  TaxBaik.Infrastructure   클래스 라이브러리 (Dapper repository, DB 마이그레이션)
  TaxBaik.Application      클래스 라이브러리 (서비스, DTO, 비즈니스 로직)
  TaxBaik.Web              ASP.NET Core 앱 (포트 5001 - 서버는 순수 API)
    ├─ Pages/             Razor Pages (공개 홈페이지, 블로그, 문의폼)
    ├─ Components/
    │  ├─ (Web pages)
    │  └─ App.razor        Blazor Root (WebAssembly 렌더링)
    └─ Services/          인증, 블로그, 문의 등 (API만 제공)

  TaxBaik.Web.Client      (NEW) Blazor WebAssembly WASM 클라이언트
  ├─ _Imports.razor     네임스페이스 임포트
  └─ Components/
     └─ Admin/          관리자 페이지 (클라이언트 사이드)
        ├─ Pages/       (모든 페이지)
        ├─ Layout/      (레이아웃)
        ├─ Shared/      (공유 컴포넌트)
        ├─ App.razor    Root 컴포넌트
        └─ Routes.razor 라우팅 정의
```

**경로:**
- 홈페이지: `/`
- 관리자: `/admin` (Blazor WebAssembly - CSR)
- 로그인: `/admin/login`

**렌더링 방식**:
- 공개 사이트: SSR (Razor Pages) - SEO 최적화
- 관리자 페이지: CSR (Blazor WebAssembly) - 클라이언트 사이드

**운영 원칙:**
- 단일 앱, 단일 서비스, 단일 배포 경로를 유지한다.
- 운영 변경은 코드 또는 CI에서만 반영한다.
- 서버에 임시 수동 수정이나 파일 드리프트가 생기지 않도록 한다.
- 공개 사이트와 관리자 UI는 같은 앱에서 처리하되, 보안 경계는 인증과 권한으로 분리한다.

### 2.2 계층 책임
- **Domain**: 비즈니스 규칙, 엔티티 정의
- **Infrastructure**: DB 접근, Dapper 구현체, 마이그레이션 실행
- **Application**: 서비스, DTO 매핑, 비즈니스 워크플로우
- **Web (Pages/)**: 공개 홈페이지 (SEO 최적화, Razor Pages SSR)
- **Web (Components/Admin)**: 관리자 백오피스 (Blazor Server, 사용자 액션 기반 갱신)
- **Web (Services/)**: 인증(JWT), 블로그, 문의 관리 등

### 2.3 기술 결정 이유

**왜 Razor Pages (공개 사이트)인가?**
- 서버에서 HTML을 렌더링 → Google, Naver가 즉시 크롤 가능
- Blazor는 초기 응답이 shell HTML → SEO 불리 (블로그는 검색 유입이 핵심)

**왜 Blazor Server (관리자)인가?**
- 관리자는 SEO 불필요 → 복잡한 관리 UI를 .NET 컴포넌트로 구현 가능
- 데이터 변경 시 전체 사용자에게 push/broadcast하는 기능은 기본값으로 두지 않는다.
- 관리자 화면은 일반 웹페이지처럼 조회/저장/상태 변경 요청 시점에만 데이터를 갱신한다.

**왜 단일 앱 (통합 Web)인가?**
- 공개 사이트와 관리자 화면을 같은 호스트와 PathBase에서 운영하면 라우팅과 인증 구성이 단순함
- **개발**: 터미널 1개, 포트 1개 (5001)
- **배포**: 앱 1개, DB 마이그레이션 1회
- **유지보수**: 모든 비즈니스 로직 한 곳 (Application)
- **장점**: 블로그 SEO와 관리자 기능을 하나의 실행 단위로 운영

**왜 Dapper인가?**
- 팀 기존 지식 (QuantEngine에서 사용)
- 복잡한 조인, 페이징, 성능 제어 용이
- EF Core 대비 SQL 완전 제어 가능

**왜 이 운영 모델인가?**
- 운영 복잡도를 낮춰 장애 포인트를 줄인다.
- 배포를 CI로 고정하면 서버 간 상태 드리프트를 줄인다.
- 민감 정보는 코드/문서/로그에 남기지 않고 환경 변수와 서버 비밀 저장소에만 둔다.

---

## 3. 로컬 개발 환경 설정

### 3.1 SSH 터널링으로 서버 DB 접속

**목적**: 로컬에서 개발/테스트 시 서버의 PostgreSQL에 접속

#### 단계 1: SSH 터널 구성 (PowerShell / Bash)

```bash
# 터널 열기 (백그라운드 유지)
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7

# 터널이 열린 상태에서 다른 터미널에서 개발
```

또는 **영구 설정** (`~/.ssh/config`):
```
Host taxbaik-tunnel
    HostName 178.104.200.7
    User kjh2064
    LocalForward 5432 127.0.0.1:5432
    IdentityFile ~/.ssh/id_ed25519
```

그 후:
```bash
ssh taxbaik-tunnel  # 터널 유지
```

#### 단계 2: 연결 확인

```bash
# 로컬에서 PostgreSQL 연결 테스트
psql -h localhost -U taxbaik -d taxbaikdb -c "\dt"

# 또는 .NET 앱 실행 (자동으로 마이그레이션 실행)
dotnet run -p src/TaxBaik.Web
```

#### 단계 3: 개발 워크플로우 (단일 앱 통합)

```bash
# 터미널 1: SSH 터널 유지
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7

# 터미널 2: 통합 Web 앱 (Razor Pages + Blazor Server Admin)
cd src/TaxBaik.Web
dotnet run
# 접속:
#   - 홈페이지: http://localhost:5001/
#   - 관리자: http://localhost:5001/admin/login
#   - 로그인: admin / Admin@123456
```

**장점**:
- ✅ 한 개의 포트 (5001)
- ✅ 한 개의 터미널에서 실행
- ✅ 한 번의 DB 마이그레이션
- ✅ 모든 기능 유지 (JWT 인증, Blazor UI, Razor Pages SEO)

### 3.2 appsettings.json (로컬)

```json
{
  "ConnectionStrings": {
    "Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=<secret>"
  }
}
```

**중요**: 로컬 appsettings.json은 버전 관리에서 제외 또는 .local suffix 사용

**보안 규칙**:
- `appsettings.Production.json`에는 비밀값을 두지 않는다.
- JWT Secret, DB 비밀번호, 외부 API 키는 환경 변수 또는 서버 전용 비밀 경로에서만 읽는다.
- 값이 비어 있으면 조용히 넘어가지 말고 시작 시 즉시 실패시킨다.

```bash
# 로컬 오버라이드
appsettings.Development.json  # gitignore에 추가
```

### 3.3 데이터베이스 마이그레이션

앱 시작 시 자동 실행:
1. `db/migrations/` 폴더에서 V001, V002, V003... 순서대로 읽음
2. `schema_migrations` 테이블에서 실행 여부 확인
3. 미실행 마이그레이션만 실행

**마이그레이션 추가**:
```bash
# 파일명: db/migrations/V004__새기능설명.sql
# 예시
CREATE TABLE IF NOT EXISTS new_table (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL
);
```

### 3.5 관리자 계정 관리 (API 기반)

#### 계정 정보 (마이그레이션 V013)

**계정 기준**
- 운영 관리자: `admin / Admin@123456`
- E2E 자동화: `test_admin / TestAdmin@123456`
- 둘 다 관리자 권한이며, 운영 변경은 `admin`, 반복 검증은 `test_admin`을 사용한다.

#### 비밀번호 관리 (API 기반)

**Reset-password API**:
```bash
POST /api/auth/reset-password
Content-Type: application/json

{
  "username": "admin",
  "newPassword": "YourNewPassword@123456",
  "resetToken": "dev-reset-token-12345"
}

응답:
{ "message": "비밀번호가 재설정되었습니다." }
```

**요구사항**:
- 비밀번호: 12자 이상
- Reset Token: `appsettings.json`의 `Admin:PasswordResetToken` 값 사용
- 마이그레이션이 아닌 API로만 계정 관리

#### 보안 규칙

- 비밀번호는 마이그레이션이나 하드코드로 저장하지 않음
- 모든 계정 변경은 API로만 수행 (reset-password 엔드포인트)
- 로그인 실패는 AuthService에서 로깅됨 (비밀번호는 로그에 남기지 않음)
- Reset Token은 환경 변수로만 관리 (코드에 하드코드 금지)
- 프로덕션 배포 후 기본 비밀번호 변경 필수

### 3.6 블로그 & 문의 테스트 데이터

마이그레이션 V003에서 자동 생성:
- 테스트 블로그 포스트 5개
- 테스트 카테고리 5개
- 테스트 FAQ 3개

**테스트 데이터 생성 경로**:
```
마이그레이션 실행 → V001-V011 스키마 생성 → V012 test_admin 계정 → V013 admin 계정
```

**테스트 계정 검증**:
```bash
# 운영 관리자 로그인
curl -X POST http://localhost:5001/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"Admin@123456"}'

# E2E 자동화 계정 로그인
curl -X POST http://localhost:5001/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"test_admin","password":"TestAdmin@123456"}'
```

수동 추가:
```sql
-- Admin 추가
INSERT INTO admin_users (username, password_hash, created_at)
VALUES ('newadmin', '$2a$11$...bcrypt_hash...', NOW());

-- 블로그 포스트 추가
INSERT INTO blog_posts (title, content, slug, category_id, is_published, created_at)
VALUES ('제목', '내용', 'slug-text', 1, true, NOW());
```

### 3.5 Git Push with Gitea Token (Windows)

**환경 변수 설정** (한 번만 필요):
1. 시스템 환경 변수 편집 (`Win+X` → 시스템)
2. "환경 변수" 버튼 클릭
3. 새로 만들기 → `GITEA_TOKEN_TAXBAIK` = `[토큰값]`
4. PowerShell 재시작 필수

**Git Push 방법** (권장: 공개 URL 직접)

#### 방법 A: Gitea 공개 URL (권장 ⭐)

**설정 (한 번만)**:
```bash
# Git credential 저장 (암호 매번 입력 불필요)
git config credential.helper store
mkdir -p ~/.git
echo "http://kjh2064:${GITEA_TOKEN_TAXBAIK}@gitea.taxbaik.com" > ~/.git-credentials
chmod 600 ~/.git-credentials
```

**푸시**:
```bash
git push http://gitea.taxbaik.com/kjh2064/taxbaik.git master
```

또는

```powershell
$token = $env:GITEA_TOKEN_TAXBAIK
git push "http://kjh2064:${token}@gitea.taxbaik.com/kjh2064/taxbaik.git" master
```

**장점**:
- ✅ 공개 URL로 어디서나 접속 가능
- ✅ SSH 터널 불필요
- ✅ 가장 간단하고 빠름
- ✅ CI/CD 자동 트리거됨

#### 방법 B: SSH 터널 + 로컬호스트 (대체)

**단계 1: 터미널 1 - SSH 터널 유지**
```bash
ssh -L 3000:127.0.0.1:3000 kjh2064@178.104.200.7
```

**단계 2: 터미널 2 - Git Push**
```bash
git push "http://kjh2064:${GITEA_TOKEN_TAXBAIK}@localhost:3000/kjh2064/taxbaik.git" master
```

**장점**:
- ✅ 로컬 네트워크만 사용
- ✅ 보안 (SSH 터널)

#### 방법 C: SSH로 직접 Push (SSH key 필요)

```bash
git push ssh://git@gitea.taxbaik.com:2222/kjh2064/taxbaik.git master
```

**보안 규칙**:
- 토큰은 채팅/문서/스크린샷에 붙이지 않는다.
- push URL에 토큰이 남아 있으면 즉시 제거한다.
- ~/.git-credentials 파일은 chmod 600으로 보호한다.

**Gitea Actions 자동 배포**:
1. git push 성공 → master 브랜치에 커밋
2. Gitea Actions CI/CD 자동 trigger (.gitea/workflows/deploy.yml)
3. 빌드 → 배포 → 서비스 재시작 자동 실행
4. 배포 진행 상황은 Gitea Actions에서 확인

---

## 6. 서버 & 배포

### 4.1 SSH 접속
```bash
ssh kjh2064@178.104.200.7
```

### 3.2 포트 배치
```
80    : Nginx reverse proxy (공개)
3000  : Gitea Web (localhost만, proxy via /를 통해)
2222  : Gitea SSH (공개)
5000  : QuantEngine Blazor (localhost, proxy via /quant/)
5001  : TaxBaik.Web (공개 사이트 + 관리자 통합, localhost, proxy via /)
5432  : PostgreSQL (localhost 바인드)
```

### 3.3 배포 절차 (CI only) & Green-Blue 지원

배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.

**무중단 Green-Blue 배포 아키텍처 (2026-06-30 적용 완료)**:
1. **프록시 레이어**: 포트 `5001`에서 영구 가동되는 초경량 .NET TCP 프록시([TaxBaik.Proxy])가 수신 대기합니다. Nginx는 루트 트래픽을 `5001`로 중계합니다.
2. **동적 포트 스위칭**: 프록시는 요청이 들어올 때마다 `/home/kjh2064/taxbaik_port` 파일을 읽어 active 포트(5003 또는 5004)를 판단하고 트래픽을 포워딩합니다.
3. **배포 흐름 (`deploy_gb.sh`)**:
   - Gitea Actions가 코드를 build/publish 후 압축하여 서버에 업로드합니다.
   - 서버의 배포 스크립트([deploy_gb.sh])가 실행되어 현재 미사용 중인 예비 포트(Target Port: 5003 또는 5004)를 파악합니다.
   - 예비 포트에서 새 .NET 웹 앱을 실행하고 `http://127.0.0.1:$target_port/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다.
   - 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다.
   - 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
   - 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.

**배포 환경 변수 (deploy_gb.sh에서 반드시 설정)**:
```bash
export ASPNETCORE_ENVIRONMENT=Production
export ASPNETCORE_URLS="http://127.0.0.1:$TARGET_PORT"
export ConnectionStrings__Default="Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=<secret>"
export DOTNET_PRINT_TELEMETRY_MESSAGE=false
```

⚠️ **필수 주의사항**:
- `ConnectionStrings__Default` 누락 시 배포 실패 (Missing connection string)
- 환경 변수는 dotnet 프로세스 시작 전에 export되어야 함
- deploy_gb.sh의 "Starting New App on Port" 섹션에서 설정 필수

**운영 규칙**:
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
  - `Missing connection string` → deploy_gb.sh 환경 변수 확인
  - `core dumped` + `Health check failed` → Program.cs 초기화 에러 확인
- 배포 후 최종 검증:
  - ✅ E2E 테스트 (20/20 통과 기준)
  - ✅ 프록시 포트 경유 (www.taxbaik.com)
  - ✅ 메인 홈페이지 HTTP 200
  - ✅ 관리자 로그인 페이지 로드
  - ✅ 로그인 API 응답

**롤백**:
- 배포 실패 시 자동 롤백 (이전 포트로 즉시 복구)
- 수동 롤백: 이전 정상 커밋을 `master`에 revert 후 다시 배포
- 긴급 복구: 서버의 `taxbaik_port` 파일 수동 수정

### 3.4 서비스 파일 위치
```
/etc/systemd/system/taxbaik.service  ← 통합 Web 앱 (공개 사이트 + 관리자)
```

### 5.5 배포 디렉토리 구조 (서버)
배포 디렉토리는 CI가 관리한다. 로컬에서 구조를 맞추거나 수동으로 갱신하지 않는다.

---

## 6. Nginx 라우팅

기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가:

```nginx
# 실제 로드되는 파일: /etc/nginx/sites-available/taxbaik-domains.conf
# (sites-enabled/taxbaik-domains.conf 심볼릭 링크로 활성화됨)

location / {
    proxy_pass         http://127.0.0.1:5001;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade $http_upgrade;
    proxy_set_header   Connection "Upgrade";
    proxy_cache_bypass  $http_upgrade;
    proxy_set_header   Host $host;
    proxy_set_header   X-Real-IP $remote_addr;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_read_timeout 120s;
}
```

**참고**: 단일 루트 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다.

**⚠️ 중요: 실제 로드되는 Nginx 설정 파일 확인 필수 (2026-07-03 장애로 확인)**:
- `nginx.conf`는 `include /etc/nginx/sites-enabled/*;`만 로드한다. `/etc/nginx/sites-available/`에 파일이 있어도 `sites-enabled/`에 심볼릭 링크되어 있지 않으면 **절대 반영되지 않는다**.
- 이 서버는 `sites-available/default`가 아니라 `sites-available/taxbaik-domains.conf` (→ `sites-enabled/taxbaik-domains.conf`)가 실제로 로드되는 파일이다. `default`를 아무리 수정해도 효과가 없다.
- 실제 로드 파일을 찾는 법: `ls -la /etc/nginx/sites-enabled/` 로 심볼릭 링크 대상을 먼저 확인한 뒤 그 파일을 수정한다.
- **불변식**: `taxbaik-domains.conf`의 `location /`는 항상 `127.0.0.1:5001` (TaxBaik.Proxy)만 가리켜야 한다. `5003`/`5004`(Green-Blue 앱 포트)를 직접 하드코딩하면 포트 전환 시 죽은 포트를 가리키게 되어 502/404가 발생한다. 이 설정은 배포마다 바뀔 필요가 없다 — 프록시가 `~/taxbaik_port` 파일을 읽어 자동으로 활성 포트에 연결한다.
- **trailing slash 주의**: `proxy_pass http://127.0.0.1:5001;` (슬래시 없음)은 원본 요청 경로를 그대로 전달한다. `proxy_pass http://127.0.0.1:5001/;` (슬래시 있음)은 URI를 재작성하는데, `location` 접두사와 슬래시 개수가 안 맞으면 백엔드로 이중 슬래시(`//`)가 전달되어 404가 발생한다. 접두사 매칭 `location`에서는 `proxy_pass`에 trailing slash를 붙이지 않는다.
- **디버깅 팁**: `curl http://127.0.0.1/`처럼 IP로 직접 테스트하면 `Host: 127.0.0.1` 헤더가 `server_name taxbaik.com www.taxbaik.com`과 매칭되지 않아 엉뚱한(또는 기본) server block으로 라우팅될 수 있다. 실제 도메인 기준 server block을 로컬에서 테스트하려면 Host 헤더/SNI를 강제로 지정한다:
  ```bash
  # HTTP
  curl -I -H "Host: www.taxbaik.com" http://127.0.0.1/
  # HTTPS (SNI 포함)
  curl -sk -I --resolve www.taxbaik.com:443:127.0.0.1 https://www.taxbaik.com/
  ```
- CI 배포(`deploy.yml`)는 매 배포마다 `sites-enabled/`의 실제 파일을 찾아 위 불변식을 검증하고, 위반 시 배포를 실패 처리한다. 또한 내부 `127.0.0.1:5001` 체크와 별개로 실제 공개 도메인(`https://www.taxbaik.com/`)을 외부에서 호출해 Nginx/Cloudflare 경로 전체를 검증한다 — 내부 체크만으로는 Nginx 설정 오류를 잡지 못하기 때문이다.

**Nginx 보안**:
- `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다.
- `Host`와 `X-Forwarded-Proto`는 유지해 원본 URL과 스킴을 보존한다.
- `/admin`는 robots.txt에서 차단한다.

---

## 6. 데이터베이스

### 4.1 연결 설정

**환경 변수** (systemd unit file에 설정):
```ini
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=<secret>
```

**절대 appsettings.Production.json에 비밀값을 하드코딩하지 말 것.**

**운영 보안 규칙**:
- DB 계정은 애플리케이션 전용 최소 권한으로 둔다.
- 관리자 비밀번호는 bcrypt로 해시하고, 평문 저장/전송을 금지한다.
- `PasswordHash`는 null이 되면 안 되며, null이면 인증 실패로 즉시 처리한다.
- 로그인 실패 로그는 사용자 이름만 남기고 비밀번호/해시를 절대 남기지 않는다.

### 3.2 Dapper 사용 패턴

**DbConnectionFactory.cs**:
```csharp
public sealed class DbConnectionFactory : IDbConnectionFactory
{
    private readonly string _cs;
    public DbConnectionFactory(IConfiguration cfg) =>
        _cs = cfg.GetConnectionString("Default")
              ?? throw new InvalidOperationException("Missing 'Default' connection string.");
    
    public IDbConnection CreateConnection() => new NpgsqlConnection(_cs);
}
```

**Repository 메서드**:
```csharp
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct)
{
    using var conn = Conn();  // 항상 using
    return await conn.QueryFirstOrDefaultAsync<BlogPost>(
        "SELECT * FROM blog_posts WHERE slug = @Slug AND is_published = TRUE",
        new { Slug = slug });
}
```

**규칙**:
- 항상 `using var conn = Conn();` 사용 (자동 닫기)
- 항상 `@ParameterName` 파라미터 사용 (SQL injection 방지)
- 절대 문자열 연결 금지
- PostgreSQL `snake_case` 컬럼은 Dapper underscore 매핑을 전제로 함
- 조회 쿼리는 필요한 컬럼만 명시한다. `SELECT *`는 스키마 변경 시 매핑 사고를 만든다.

### 3.3 마이그레이션

마이그레이션 파일: `db/migrations/V{number}__{description}.sql`

**실행 방식**:
1. Program.cs 시작 시 MigrationRunner 호출
2. `schema_migrations` 테이블에서 실행 여부 확인
3. 미실행 마이그레이션만 순서대로 실행

### 3.4 데이터베이스 백업 (프로덕션)

**자동 백업 정책** (2026-06-28 도입):

#### 백업 위치
```
서버: 178.104.200.7
경로: /home/kjh2064/backups/
```

#### 스케줄
```
시간: 매일 02:00 AM KST (자동 Cron 실행)
파일명: taxbaikdb_YYYYMMDD_HHMMSS.sql
형식: PostgreSQL pg_dump (완전 SQL 덤프)
```

#### 보관 정책
```
보관 기간: 최근 30일
자동 정리: 30일 이상 된 파일 자동 삭제
로깅: /home/kjh2064/backups/backup.log에 모든 백업 시도 기록
```

#### 복구 절차
```bash
# 1. 백업 파일 확인
ssh kjh2064@178.104.200.7 ls -lh /home/kjh2064/backups/

# 2. 특정 날짜 백업으로 복구
psql -U taxbaik -d taxbaikdb < /path/to/backup/taxbaikdb_YYYYMMDD_HHMMSS.sql

# 3. 복구 후 검증
SELECT COUNT(*) FROM inquiries;  # 데이터 존재 확인
```

#### 백업 스크립트
```bash
# 파일: /home/kjh2064/backup_taxbaik_db.sh
# 수동 실행:
ssh kjh2064@178.104.200.7 /home/kjh2064/backup_taxbaik_db.sh

# Cron 등록:
0 2 * * * /home/kjh2064/backup_taxbaik_db.sh
```

#### 모니터링
```bash
# 백업 로그 확인
ssh kjh2064@178.104.200.7 tail -20 /home/kjh2064/backups/backup.log

# Cron 상태 확인
ssh kjh2064@178.104.200.7 crontab -l | grep backup
```

**중요**: 
- 백업은 전체 데이터베이스를 포함합니다 (스키마 + 데이터)
- 30일 보관 정책으로 최근 한 달 데이터 손실 방지
- 자동 실행이므로 수동 개입 불필요
- 장애 발생 시 즉시 최근 백업으로 복구 가능

---

## 5-1. 블로그 & FAQ 콘텐츠 작성 규칙

**핵심**: 고객 임파워먼트 (당신도 할 수 있습니다!)
- ✅ 주변에서 흔히 보는 실제 사례 (이름, 나이, 직업 구체화)
- ✅ 절세 효과 수치화 ("세금을 X만 원 절약했습니다")
- ✅ 중학교 2학년도 이해 가능한 수준
- ✅ 단계별 설명 + 표로 시각화
- ✅ 결론: "정확하게 하면 이런 이점이 있습니다" (임파워먼트)

**피해야 할 톤**: "복잡하니까 맡기세요" (세무사 의존성 강화)  
**세무사 언급**: "더 복잡하면 전문가와 상담하세요" (선택지)

**자세한 템플릿 및 체크리스트**: `BLOG_TEMPLATE.md` 참고

---

## 6. 코드 규칙

### 6.1 C# 네이밍
- 클래스, 메서드, 프로퍼티: **PascalCase**
- 비공개 필드: **_camelCase**
- 로컬 변수, 파라미터: **camelCase**
- 상수: **PascalCase** (SCREAMING_SNAKE_CASE 사용 금지)
- 비동기 메서드: **Async** 접미사 (GetBySlugAsync)
- 비공개 메서드: **Async 접미사 생략 가능**

### 6.2 파일 구조 (통합 Web 앱)
```
Domain/
  Entities/BlogPost.cs
  Interfaces/IBlogPostRepository.cs
  Enums/InquiryStatus.cs

Infrastructure/
  Data/DbConnectionFactory.cs
  Repositories/BlogPostRepository.cs
  DependencyInjection.cs

Application/
  Services/BlogService.cs
  DTOs/BlogPostListDto.cs

Web/
  Pages/Blog/Index.cshtml
  Pages/Blog/Index.cshtml.cs  ← PageModel (공개 사이트)
  Components/
    Admin/
      Pages/Blog/BlogList.razor  ← Blazor 관리자 페이지
      Layout/MainLayout.razor
      App.razor
  Services/
    AuthService.cs             ← JWT 인증
    CustomAuthenticationStateProvider.cs
    LocalStorageService.cs
  wwwroot/css/site.css
```

### 6.3 모든 UI는 한국어
- 버튼 레이블, 폼 레이블, 에러 메시지 → 한국어만
- 코드 주석, 예외 메시지 → 영어 가능

### 6.4 오류 처리
- 서비스는 **타입화된 예외** 던지기 (ValidationException, ThrottleException)
- PageModel/Component에서 catch → ModelState 또는 Toast
- 절대 stack trace를 HTML에 노출 금지
- ILogger<T>로 모든 예외 로깅

---

## 7. Dapper 패턴

### 7.1 단일 행 조회
```csharp
var post = await conn.QueryFirstOrDefaultAsync<BlogPost>(
    "SELECT * FROM blog_posts WHERE id = @Id",
    new { Id = id });
```

### 7.2 여러 행 + 페이징
```csharp
var (rows, total) = await GetPublishedPagedAsync(page: 1, pageSize: 12);

// 구현:
public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync(int page, int pageSize)
{
    using var conn = Conn();
    using var reader = await conn.QueryMultipleAsync(
        @"SELECT bp.* FROM blog_posts bp WHERE is_published = TRUE
          ORDER BY published_at DESC LIMIT @PageSize OFFSET @Offset;
          SELECT COUNT(*) FROM blog_posts WHERE is_published = TRUE;",
        new { PageSize = pageSize, Offset = (page - 1) * pageSize });
    
    var rows = (await reader.ReadAsync<BlogPost>()).ToList();
    var total = await reader.ReadFirstAsync<int>();
    return (rows, total);
}
```

### 7.3 삽입 + 반환된 ID
```csharp
var newId = await conn.QueryFirstAsync<int>(
    @"INSERT INTO blog_posts (title, content, slug, is_published, created_at)
      VALUES (@Title, @Content, @Slug, FALSE, NOW())
      RETURNING id",
    new { Title = title, Content = content, Slug = slug });
```

### 7.4 트랜잭션
```csharp
using var conn = Conn();
using var tx = conn.BeginTransaction();
try
{
    // 여러 명령
    await conn.ExecuteAsync("UPDATE ...", null, tx);
    await conn.ExecuteAsync("INSERT ...", null, tx);
    tx.Commit();
}
catch
{
    tx.Rollback();
    throw;
}
```

---

## 8. Blazor Admin 패턴 (통합 Web 앱)

### 8.1 PathBase
전체 앱은 `/` 경로에서 실행:

```csharp
// Program.cs
app.UsePathBase("/");
```

`@page` 지시문의 경로는 이 기본값에 상대적. 예:
```razor
@page "/admin/login"   ← 실제 URL: /admin/login
@page "/admin/blog"    ← 실제 URL: /admin/blog
@page "/blog"          ← 실제 URL: /blog (Razor Pages)
```

### 8.2 JWT 인증 (LocalStorage + Bearer Token)
```csharp
// Program.cs
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(
    sp => sp.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();
```

토큰은 localStorage에 저장되며, `CustomAuthenticationStateProvider`가 자동으로 복원:

**보안 규칙**:
- JWT 만료 시간을 짧고 명확하게 유지한다.
- localStorage 토큰은 XSS가 없다는 전제 없이 다뤄야 한다.
- 관리자 기능은 `[Authorize]`로 감싸고, 클라이언트 렌더링만으로 권한을 믿지 않는다.

```csharp
// CustomAuthenticationStateProvider.cs
public async Task LoginAsync(string token)
{
    await _localStorage.SetItemAsStringAsync("authToken", token);
    StateHasChanged();  // Blazor 상태 갱신
}

public async Task LogoutAsync()
{
    await _localStorage.RemoveItemAsync("authToken");
    StateHasChanged();
}
```

### 8.3 모든 Admin 페이지에 [Authorize] 추가
```razor
@* Components/Admin/_Imports.razor *@
@attribute [Authorize]
```

Admin 로그인 페이지만 [AllowAnonymous]:
```razor
@page "/admin/login"
@attribute [AllowAnonymous]
```

### 8.4 컴포넌트 구조
```razor
@page "/blog"
@inject IBlogService BlogService
@attribute [Authorize]

<PageTitle>블로그 관리</PageTitle>

@if (posts != null)
{
    @foreach (var post in posts)
    {
        <BlogCard Post="post" OnDelete="HandleDelete" />
    }
}

@code {
    private List<BlogPostDto>? posts;

    protected override async Task OnInitializedAsync()
    {
        posts = await BlogService.GetAllAsync();
    }

    private async Task HandleDelete(int id)
    {
        await BlogService.DeleteAsync(id);
        posts = await BlogService.GetAllAsync();
        StateHasChanged();
    }
}
```

### 8.5 상태 관리
- 전역 상태 불필요 (세션 → DB에서 읽음)
- 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기
- 업데이트는 `StateHasChanged()` 호출

### 8.6 어드민 그리드 UX (더존 세무회계프로그램 수준)

**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + 더존식 상호작용성

#### 그리드 기본 원칙
- **데이터 밀도**: 줄 높이 32px, 최대 주요 정보 5-7개 컬럼 (시각적 혼잡 제거)
- **반응형**: PC(1920px) 6컬럼 → 태블릿(960px) 4컬럼 → 모바일(480px) 2컬럼
- **패드 특화**: 터치 친화적 (최소 24px 셀 높이, 36px 버튼)
- **PC 최적화**: 마우스 호버 선택행, 키보드 네비게이션 (Arrow/Enter/Esc)

#### 고급 인터랙션
- **인라인 편집**: 셀 더블클릭 → 편집 모드 (취소: Esc, 저장: Enter)
- **다중 선택**: Ctrl/Cmd + Click, Shift + Click로 범위 선택
- **컨텍스트 메뉴**: 우클릭 → 행 삭제, 복사, 내보내기
- **정렬/필터**: 컬럼 헤더 클릭 정렬, 필터 아이콘 필터링
- **페이징**: 하단 "1/10" 표시 + 이전/다음 버튼 (기본 20행/페이지)
- **검색**: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)

#### MudBlazor 적용 패턴
```razor
<MudDataGrid T="YourItem" 
    Dense="true"
    Hover="true"
    Striped="true"
    RowsPerPage="20"
    Virtualize="true"
    @ref="dataGrid"
    Items="items"
    Sortable="true"
    Filterable="true"
    ShowMenuIcon="true">
    
    <Columns>
        <PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
        <PropertyColumn Property="x => x.Name" Title="이름" Filterable="true" />
        <PropertyColumn Property="x => x.Amount" Title="금액" Sortable="true" 
            Format="C" />
        <TemplateColumn Title="작업">
            <CellTemplate>
                <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Small" 
                    OnClick="@(() => Edit(context.Item))" />
                <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Small" 
                    OnClick="@(() => Delete(context.Item))" />
            </CellTemplate>
        </TemplateColumn>
    </Columns>
</MudDataGrid>
```

#### 색상 & 상태 표시
- **정상** (Normal): 회색 배경
- **주의** (Warning): 주황색 배경 (TaxRiskLevel: "warning")
- **긴급** (Danger): 빨간색 배경 (TaxRiskLevel: "danger", 미납 송장)
- **완료** (Success): 녹색 배경 (완료된 신고, 결제됨)

```razor
<MudChip Color="@(item.TaxRiskLevel == "danger" ? Color.Error : 
                   item.TaxRiskLevel == "warning" ? Color.Warning : Color.Default)">
    @item.TaxRiskLevel
</MudChip>
```

#### 페이지 구조 (예: TaxProfile 관리)
```
┌─────────────────────────────────────────────┐
│ 세무프로필 관리                    [+새로 추가] │
├─────────────────────────────────────────────┤
│ 🔍 검색...                                   │
├──────┬────────┬────────┬────────┬────────┬──┤
│ 고객 │ 상태   │ 리스크 │ 다음신고│ 담당자 │작│
├──────┼────────┼────────┼────────┼────────┼──┤
│ (선택)고객A  │ 활성   │ 🔴높음 │5/30   │ A │✎│
│      │       │        │        │       │✕│
│ (선택)고객B  │ 활성   │ 🟡보통 │6/15   │ B │✎│
│      │       │        │        │       │✕│
├──────┴────────┴────────┴────────┴────────┴──┤
│ ◀ 1 2 3 4 ▶  |  20행/페이지  | 전체: 150개 │
└─────────────────────────────────────────────┘
```

#### CSS 클래스 표준
```css
/* admin-grid.css */
.admin-grid {
    font-size: 13px;
    line-height: 1.4;
}

.admin-grid--dense {
    --mud-table-row-height: 32px;
}

.admin-grid__header {
    background-color: #f5f5f5;
    font-weight: 600;
    padding: 8px;
}

.admin-grid__cell {
    padding: 8px;
    vertical-align: middle;
}

.admin-grid__cell--danger {
    background-color: #ffebee;
}

.admin-grid__cell--warning {
    background-color: #fff3e0;
}

.admin-grid__cell--success {
    background-color: #e8f5e9;
}

.admin-grid__action-button {
    padding: 4px 8px;
    min-width: 36px;
    min-height: 36px;
}
```

#### 성능 최적화
- **가상화**: `Virtualize="true"` (10,000행 이상 대응)
- **지연 로드**: IntersectionObserver로 스크롤 시 다음 페이지 로드
- **메모이제이션**: `OnParametersSet` vs `OnInitializedAsync` 구분
- **API 캐싱**: 변경이 없으면 `IMemoryCache` 사용 (5분 TTL)

### 8.7 Blazor 페이지 추가 표준 가이드 ✅ (2026-06-28 갱신)

**목표**: 모든 관리자 페이지가 일관된 구조와 UX를 유지하도록 강제

#### 필수 구조 (기존 Dashboard 패턴 준수)

**Step 1: 페이지 헤더 (`<section class="admin-page-hero">`)**
```razor
@page "/admin/새페이지"
@attribute [Authorize]
@inject INewPageClient NewPageClient
@inject NavigationManager Nav

<PageTitle>페이지 제목</PageTitle>

<!-- 반드시 포함할 요소 -->
<section class="admin-page-hero">
    <div>
        <MudText Typo="Typo.caption" Class="admin-eyebrow">카테고리</MudText>
        <MudText Typo="Typo.h4" Class="admin-page-title">페이지 제목</MudText>
        <MudText Typo="Typo.body2" Class="admin-page-subtitle">한 줄 설명</MudText>
    </div>
    <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="OpenCreateDialog">
        새 항목 추가
    </MudButton>
</section>
```

**Step 2: 콘텐츠 영역**
```razor
<!-- 로딩 상태 -->
@if (items == null)
{
    <MudProgressCircular Indeterminate="true" Class="mt-4" />
}
<!-- 빈 상태 -->
else if (items.Count == 0)
{
    <MudAlert Severity="Severity.Info" Class="mt-4">데이터가 없습니다.</MudAlert>
}
<!-- 데이터 그리드 -->
else
{
    <MudDataGrid T="YourEntity"
        Items="@items"
        Dense="true"
        Hover="true"
        Striped="true"
        Virtualize="true"
        RowsPerPage="30"
        Class="admin-grid mt-4">
        <Columns>
            <!-- 필수: 컬럼 정의 -->
        </Columns>
    </MudDataGrid>
}
```

**Step 3: 모달 다이얼로그 (Create/Edit)**
```razor
<MudDialog @bind-IsVisible="isDialogOpen" Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
    <TitleContent>
        <MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText>
    </TitleContent>
    <DialogContent>
        <MudForm @ref="form">
            <!-- 폼 필드 -->
        </MudForm>
    </DialogContent>
    <DialogActions>
        <MudButton OnClick="CloseDialog">취소</MudButton>
        <MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton>
    </DialogActions>
</MudDialog>
```

**Step 4: @code 섹션 구조**
```csharp
@code {
    private List<YourEntity>? items;
    private List<RelatedEntity> relatedItems = [];
    private Dictionary<int, string> itemMap = new();
    
    private MudForm? form;
    private bool isDialogOpen;
    private bool isEditMode;
    private YourEntity? editingItem;
    private YourItemForm itemForm = new();

    protected override async Task OnInitializedAsync()
    {
        await LoadData();
    }

    private async Task LoadData()
    {
        try
        {
            items = await YourItemClient.GetAllAsync();
            // 필요시 관련 데이터 로드
        }
        catch (Exception ex)
        {
            Snackbar.Add($"데이터 로드 실패: {ex.Message}", Severity.Error);
        }
    }

    private void OpenCreateDialog()
    {
        isEditMode = false;
        editingItem = null;
        itemForm = new();
        isDialogOpen = true;
    }

    private async Task OpenEditDialog(YourEntity item)
    {
        isEditMode = true;
        editingItem = item;
        itemForm = new YourItemForm { /* 초기화 */ };
        isDialogOpen = true;
    }

    private async Task SaveItem()
    {
        try
        {
            if (isEditMode)
            {
                await YourItemClient.UpdateAsync(editingItem!.Id, /* params */);
                Snackbar.Add("항목이 업데이트되었습니다.", Severity.Success);
            }
            else
            {
                var newId = await YourItemClient.CreateAsync(/* params */);
                if (newId > 0)
                {
                    Snackbar.Add("항목이 추가되었습니다.", Severity.Success);
                }
            }
            CloseDialog();
            await LoadData();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
        }
    }

    private async Task DeleteItem(int id)
    {
        var parameters = new DialogParameters();
        parameters.Add("Title", "삭제 확인");
        parameters.Add("Message", "이 항목을 삭제하시겠습니까?");

        var dialog = await DialogService.ShowAsync<ConfirmDialog>("", parameters);
        var result = await dialog.Result;

        if (result?.Canceled ?? true)
            return;

        try
        {
            await YourItemClient.DeleteAsync(id);
            Snackbar.Add("항목이 삭제되었습니다.", Severity.Success);
            await LoadData();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
        }
    }

    private void CloseDialog()
    {
        isDialogOpen = false;
        isEditMode = false;
        editingItem = null;
        itemForm = new();
    }

    private class YourItemForm
    {
        // DTO 필드
    }
}
```

#### 체크리스트 (모든 페이지)

- [ ] @page 지시문 확인
- [ ] @attribute [Authorize] 추가
- [ ] @inject로 필요한 Client 주입
- [ ] <PageTitle> 추가
- [ ] <section class="admin-page-hero"> (캡션, 제목, 부제, 추가 버튼)
- [ ] 로딩 상태 (MudProgressCircular)
- [ ] 빈 상태 (MudAlert)
- [ ] MudDataGrid (Dense=true, Virtualize=true, RowsPerPage=30, admin-grid 클래스)
- [ ] MudDialog (Create/Edit 모달)
- [ ] ConfirmDialog (Delete 확인)
- [ ] @code 섹션: OnInitializedAsync → LoadData() 패턴
- [ ] 모든 에러 처리 (try-catch, Snackbar 메시지)
- [ ] CloseDialog() 메서드로 모달 상태 초기화

#### 위반 사항

❌ **이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:**
- 페이지 헤더 (admin-page-hero) 누락
- 인라인 스타일로 레이아웃 구성
- MudDialog 없이 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
- @code 섹션 구조 다름
- 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공

---

## 8.8 MudBlazor 컴포넌트 사용 지침 (2026-07 확정)

### **핵심 원칙**

**모든 관리자 페이지는 MudBlazor 컴포넌트를 사용하여 일관된 UX를 제공한다.**
- ✅ 프리셋 컴포넌트 (MudTextField, MudSelect, MudButton, MudIcon 등)
- ✅ 레이아웃 (MudGrid/MudItem, MudPaper, MudContainer)
- ✅ 데이터 표시 (MudDataGrid, MudTable, MudChip)
- ✅ 피드백 (MudAlert, MudSnackbar, MudDialog)
- ✅ 색상 체계 (Color.Primary, Color.Error, Color.Success, Color.Warning)

### **주요 컴포넌트 사용법**

#### **1. 입력 필드 (MudTextField)**
```razor
<!-- 기본 텍스트 -->
<MudTextField
    @bind-Value="username"
    Label="사용자명"
    Variant="Variant.Outlined"
    FullWidth="true"
    Class="mb-4"
    Clearable="true" />

<!-- 비밀번호 -->
<MudTextField
    @bind-Value="password"
    Label="비밀번호"
    InputType="InputType.Password"
    Variant="Variant.Outlined"
    FullWidth="true" />

<!-- 여러 줄 -->
<MudTextField
    @bind-Value="message"
    Label="메시지"
    Variant="Variant.Outlined"
    Lines="5"
    FullWidth="true" />
```

**규칙**:
- `Variant="Variant.Outlined"` 사용 (기본)
- `FullWidth="true"` (반응형 필수)
- `Class="mb-4"` (여백 통일)
- `Clearable="true"` (선택적)

#### **2. 선택 필드 (MudSelect)**
```razor
<!-- 기본 선택 -->
<MudSelect T="int?" 
    Label="고객" 
    @bind-Value="profileForm.ClientId" 
    Variant="Variant.Outlined" 
    FullWidth="true" 
    Class="mb-3" 
    Placeholder="고객을 선택하세요">
    @foreach (var client in clients)
    {
        <MudSelectItem Value="@client.Id">@GetClientName(client)</MudSelectItem>
    }
</MudSelect>

<!-- 비활성화 (편집 모드) -->
<MudSelect T="int?"
    @bind-Value="selectedValue"
    Disabled="@isEditMode"
    ... />
```

**규칙**:
- 제네릭 타입 명시 (예: `T="int?"`, `T="string"`, `T="bool"`)
- `Placeholder` 사용 (사용자 가이드)
- 긴 목록은 가상화 고려

#### **3. 체크박스 (MudCheckBox)**
```razor
<MudCheckBox T="bool"
    @bind-Checked="rememberMe"
    Label="아이디 기억하기"
    Color="Color.Primary"
    Class="ml-n3" />
```

**규칙**:
- `T="bool"` 필수 (제네릭 타입 지정)
- `Color` 속성 권장 (Color.Primary)

#### **4. 버튼 (MudButton)**
```razor
<!-- 주요 액션 (Primary) -->
<MudButton
    Variant="Variant.Filled"
    Color="Color.Primary"
    OnClick="SaveItem"
    FullWidth="true"
    Size="Size.Large">
    저장
</MudButton>

<!-- 보조 액션 (Outlined) -->
<MudButton
    Variant="Variant.Outlined"
    Color="Color.Secondary"
    OnClick="Cancel">
    취소
</MudButton>

<!-- 위험 액션 (Error) -->
<MudButton
    Variant="Variant.Filled"
    Color="Color.Error"
    OnClick="Delete">
    삭제
</MudButton>

<!-- 아이콘 포함 -->
<MudButton
    Variant="Variant.Filled"
    Color="Color.Primary"
    StartIcon="@Icons.Material.Filled.Add"
    OnClick="Create">
    새 항목 추가
</MudButton>
```

**규칙**:
- 주요: `Variant.Filled` + `Color.Primary`
- 보조: `Variant.Outlined` + `Color.Secondary`
- 위험: `Color.Error` (삭제, 중지)
- 성공: `Color.Success` (완료, 활성화)

#### **5. 아이콘 (MudIcon)**
```razor
<!-- Material Design Icons -->
<MudIcon Icon="@Icons.Material.Filled.BugReport" Size="Size.Large" Color="Color.Error" Class="mb-3" />
<MudIcon Icon="@Icons.Material.Filled.Dashboard" Size="Size.Large" Color="Color.Info" />
<MudIcon Icon="@Icons.Material.Filled.CloudUpload" Size="Size.Large" Color="Color.Success" />

<!-- 주요 아이콘 목록 -->
Icons.Material.Filled.Add              // 추가
Icons.Material.Filled.Edit             // 편집
Icons.Material.Filled.Delete           // 삭제
Icons.Material.Filled.Save             // 저장
Icons.Material.Filled.Close            // 닫기
Icons.Material.Filled.Search           // 검색
Icons.Material.Filled.Menu             // 메뉴
Icons.Material.Filled.Settings         // 설정
Icons.Material.Filled.Info             // 정보
Icons.Material.Filled.Warning          // 경고
Icons.Material.Filled.Error            // 오류
Icons.Material.Filled.Check            // 확인
Icons.Material.Filled.KeyboardArrowDown// 드롭다운
```

**규칙**:
- `Size.Large` (32px), `Size.Medium` (24px), `Size.Small` (20px)
- 색상: `Color.Error`(빨강), `Color.Warning`(주황), `Color.Success`(초록), `Color.Info`(파랑)

#### **6. 링크 (MudLink)**
```razor
<MudLink Href="/admin/page" Color="Color.Primary" Class="font-weight-bold">
    페이지로 이동 →
</MudLink>
```

**규칙**:
- 기본 색상: `Color.Primary`
- 화살표 (→, ↓, ✓) 사용으로 시각적 피드백

#### **7. 텍스트 (MudText)**
```razor
<!-- 제목 -->
<MudText Typo="Typo.h4" Class="mb-6 text-center">관리자 로그인</MudText>

<!-- 부제목 -->
<MudText Typo="Typo.h6" Class="font-weight-bold mb-2">세무 프로필</MudText>

<!-- 본문 -->
<MudText Typo="Typo.body2" Class="mb-4 flex-grow-1">
    설명 텍스트
</MudText>

<!-- 주석 -->
<MudText Typo="Typo.caption" Class="text-center">
    <em>작은 글씨</em>
</MudText>
```

**규칙**:
- `Typo`: h1~h6 (제목), body1/body2 (본문), caption (주석)
- `Class="font-weight-bold"` (강조)

#### **8. 데이터 그리드 (MudDataGrid)**
```razor
<MudDataGrid T="YourEntity"
    Items="@items"
    Dense="true"
    Hover="true"
    Striped="true"
    Virtualize="true"
    RowsPerPage="30"
    Class="admin-grid">
    <Columns>
        <PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
        <PropertyColumn Property="x => x.Name" Title="이름" Filterable="true" />
        <TemplateColumn Title="작업">
            <CellTemplate>
                <MudIconButton Icon="@Icons.Material.Filled.Edit" 
                    OnClick="@(() => Edit(context.Item))" />
                <MudIconButton Icon="@Icons.Material.Filled.Delete" 
                    OnClick="@(() => Delete(context.Item))" />
            </CellTemplate>
        </TemplateColumn>
    </Columns>
</MudDataGrid>
```

**규칙**:
- `Dense="true"` (행 높이 32px)
- `Virtualize="true"` (1000+ 행 성능)
- `RowsPerPage="30"` (기본 페이지 크기)
- `Sortable/Filterable` 필수 컬럼

#### **9. 논문 (MudPaper)**
```razor
<MudPaper Class="pa-4 dashboard-card" 
    Elevation="0" 
    Style="border: 1px solid var(--border-color); min-height: 200px; display: flex; flex-direction: column;">
    <!-- 내용 -->
</MudPaper>
```

**규칙**:
- `Elevation="0"` (기본, 그림자 제거)
- `pa-4` (패딩 4)
- `border` + `--border-color` (CSS 변수)

#### **10. 그리드 레이아웃 (MudGrid/MudItem)**
```razor
<MudGrid Spacing="3" Class="mt-4">
    <!-- 반응형: XS(모바일), SM(태블릿), MD(데스크톱), LG(와이드) -->
    <MudItem XS="12" SM="6" MD="4">
        <!-- 콘텐츠 -->
    </MudItem>
    <MudItem XS="12" SM="6" MD="4">
        <!-- 콘텐츠 -->
    </MudItem>
    <MudItem XS="12">
        <!-- 전체 너비 -->
    </MudItem>
</MudGrid>
```

**규칙**:
- 12분할 시스템 (부트스트랩)
- 반응형 필수: XS(480px), SM(600px), MD(960px), LG(1264px)

#### **11. 알림 (MudAlert)**
```razor
<!-- 정보 -->
<MudAlert Severity="Severity.Info" Class="mb-4">데이터가 없습니다.</MudAlert>

<!-- 경고 -->
<MudAlert Severity="Severity.Warning" Class="mb-4">확인이 필요합니다.</MudAlert>

<!-- 오류 -->
<MudAlert Severity="Severity.Error" Class="mb-4">오류가 발생했습니다.</MudAlert>

<!-- 성공 -->
<MudAlert Severity="Severity.Success" Class="mb-4">저장되었습니다.</MudAlert>
```

**규칙**:
- `Info` (파랑), `Warning` (주황), `Error` (빨강), `Success` (초록)

#### **12. 다이얼로그 (MudDialog)**
```razor
<MudDialog @bind-IsVisible="isDialogOpen" 
    Options="new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true }">
    <TitleContent>
        <MudText Typo="Typo.h6">항목 수정</MudText>
    </TitleContent>
    <DialogContent>
        <MudForm @ref="form">
            <!-- 폼 필드 -->
        </MudForm>
    </DialogContent>
    <DialogActions>
        <MudButton OnClick="CloseDialog">취소</MudButton>
        <MudButton Color="Color.Primary" OnClick="Save">저장</MudButton>
    </DialogActions>
</MudDialog>
```

**규칙**:
- `MaxWidth.Small` (기본 모달 크기)
- `FullWidth="true"` (모바일 대응)
- DialogActions에 버튼 배치

### **색상 체계**

| 상황 | 색상 | 사용처 |
|------|------|--------|
| 주요 액션 | `Color.Primary` | 저장, 생성, 추가 |
| 보조 액션 | `Color.Secondary` | 취소, 뒤로가기 |
| 위험 액션 | `Color.Error` | 삭제, 중지 |
| 성공 상태 | `Color.Success` | 완료, 활성화 |
| 경고 상태 | `Color.Warning` | 주의, 보류 |
| 정보 상태 | `Color.Info` | 안내, 운영 |

### **CSS 클래스 표준**

```csharp
// 여백 (Margin)
mb-3, mb-4, mb-6      // margin-bottom
mt-4, mt-6            // margin-top
ml-n3                 // margin-left (음수)

// 패딩 (Padding)
pa-4                  // padding all
px-4, py-4            // padding x/y

// 플렉스
d-flex                // display: flex
align-center          // align-items: center
justify-space-between // justify-content: space-between
flex-grow-1           // flex-grow: 1

// 글꼴
font-weight-bold      // font-weight: 600
text-center           // text-align: center
text-uppercase        // text-transform: uppercase
```

### **페이지 표준 구조**

```razor
@page "/admin/새페이지"
@attribute [Authorize]
@inject IXxxClient XxxClient

<PageTitle>페이지 제목</PageTitle>

<!-- 헤더 -->
<AdminPageHeader Title="제목" Eyebrow="분류" Subtitle="설명">
    <MudButton ...>추가</MudButton>
</AdminPageHeader>

<!-- 로딩 상태 -->
@if (items == null)
{
    <MudProgressCircular Indeterminate="true" />
}
<!-- 빈 상태 -->
else if (items.Count == 0)
{
    <MudAlert Severity="Severity.Info">데이터 없음</MudAlert>
}
<!-- 콘텐츠 -->
else
{
    <MudDataGrid T="Item" Items="@items" Dense="true" Virtualize="true">
        <!-- 컬럼 -->
    </MudDataGrid>
}

<!-- 모달 -->
<MudDialog @bind-IsVisible="isDialogOpen">
    <!-- 폼 -->
</MudDialog>

@code {
    // 로직
}
```

---

## 9. Do's & Don'ts

### DO ✅
- [x] 모든 UI 문자열은 한국어
- [x] 항상 `@ParameterName` 파라미터 사용 (Dapper)
- [x] Domain 엔티티를 비즈니스 경계로 사용
- [x] Repository 인터페이스를 의존성 주입 (Service)
- [x] Razor Page: 비즈니스 로직은 PageModel 또는 Service에
- [x] Blazor: 비즈니스 로직은 Service에, Component는 뷰만
- [x] 블로그 포스트 작성 시 SEO 필드 필수 입력 (seo_title, seo_description)
- [x] 광고 규칙 준수 (2026년 6월 광고 규칙):
  - 허용: "사전 검토", "리스크 점검", "상황별 절세 방향 안내"
  - 금지: "보장", "최저가", "무료", "100% 해결", "세무조사 안 받게"
- [x] 카테고리 목록 캐시 (IMemoryCache, 10분 유효)
- [x] 비밀값은 환경 변수에서 읽기
- [x] `[ValidateAntiForgeryToken]` POST 메서드에 추가
- [x] 운영 배포는 CI-only
- [x] 관리자 로그인은 서버에서 직접 bypass하지 않기
- [x] DB/인증 문제는 로그와 쿼리로 먼저 확인

### DON'T ❌
- [ ] 비밀값을 appsettings.Production.json에 하드코딩
- [ ] EF Core 사용 금지 (Dapper 일관성)
- [ ] 동기 메서드 (async/await 필수)
- [ ] AutoMapper 사용 금지 (수동 매핑)
- [ ] Repository 인터페이스 없이 DB 직접 쿼리
- [ ] Razor Component의 @code에 비즈니스 로직
- [ ] robots.txt에서 `/admin` allow 금지 (disallow 필수)
- [ ] 폼 제출 후 redirect (fire-and-forget 또는 same-page 응답)
- [ ] 절대 `Thread.Sleep` 또는 `Task.Delay` in request handler
- [ ] 운영 서버에서 수동 publish/rsync/파일 교체
- [ ] 비밀번호/토큰을 로그에 출력
- [ ] `SELECT *`로 인증/권한 테이블 조회

---

## 10. Razor Pages 패턴

### 10.1 SEO 메타 태그
```csharp
// Index.cshtml.cs
public void OnGet()
{
    ViewData["Title"] = "백원숙 세무회계 | 사업자·부동산·가족자산 세금 상담";
    ViewData["Description"] = "세무사 백원숙이 제공하는 사업자 기장, 부동산 양도세, 증여세 상담...";
    ViewData["OgImage"] = "/images/hero.jpg";
}
```

```html
<!-- _Layout.cshtml -->
<title>@ViewData["Title"]</title>
<meta name="description" content="@ViewData["Description"]" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["Description"]" />
<meta property="og:image" content="@ViewData["OgImage"]" />
```

### 10.2 폼 제출
```csharp
// Contact.cshtml.cs
[ValidateAntiForgeryToken]
public async Task OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();
    
    try
    {
        await InquiryService.SubmitAsync(Input.Name, Input.Phone, Input.ServiceType, Input.Message);
        TempData["Success"] = "문의가 접수되었습니다. 빠른 시간 내에 연락드리겠습니다.";
        return RedirectToPage();
    }
    catch (ValidationException ex)
    {
        ModelState.AddModelError("", ex.Message);
        return Page();
    }
}
```

### 10.5 폼 UI/UX - Enter 키 포커스 이동

**목표**: 관리 페이지 폼에서 Enter 키를 누르면 다음 필드로 자동 포커스

#### 구현 패턴
```razor
<MudTextField @bind-Value="@request.FieldA" Label="필드 A"
    OnKeyDown="@((KeyboardEventArgs e) => HandleEnter(e, "fieldB"))"
    @ref="fieldA" Variant="Variant.Outlined" />

<MudTextField @ref="fieldB" Label="필드 B"
    OnKeyDown="@((KeyboardEventArgs e) => HandleEnter(e, "fieldC"))" />
```

```csharp
@code {
    private MudTextField? fieldB;
    private MudTextField? fieldC;

    private async Task HandleEnter(KeyboardEventArgs e, string nextFieldId)
    {
        if (e.Code == "Enter" || e.Key == "Enter")
        {
            e.PreventDefault();
            await FocusNextField(nextFieldId);
        }
    }

    private async Task FocusNextField(string fieldId)
    {
        // 다음 필드로 포커스 이동
        if (fieldId == "fieldB") 
            await fieldB?.FocusAsync()!;
        else if (fieldId == "fieldC")
            await fieldC?.FocusAsync()!;
    }
}
```

**규칙**:
- 모든 관리 페이지 폼에 Enter 키 지원 필수
- Tab 키와 동일하게 작동하되, 명시적 입력 의도 반영
- 마지막 필드에서 Enter = 폼 제출 (자동 검증)

---

### 10.6 더존(Douzone) 통합 가이드

**목표**: TaxBaik은 더존 세무회계의 상위 CRM/고객 관리 전략 시스템

#### 역할 정의
| 시스템 | 담당 | 기능 | 통합 지점 |
|--------|------|------|---------|
| **더존(Douzone)** | 세무 처리 | 신고, 장부관리, 결산 | 데이터 동기화 |
| **TaxBaik** | 고객 관리 | CRM, 계약, 수익 추적 | 고객 메타 정보 |

#### 중복 제거 원칙
- ❌ 세무 장부 데이터는 더존에만 관리 (중복 금지)
- ❌ 신고 자동화는 더존 API 활용 (TaxBaik은 상태만 추적)
- ✅ 고객사 정보 (회사명, 담당자, 연락처) = TaxBaik 관리
- ✅ 고객 계약 이력, CRM 활동 = TaxBaik 관리
- ✅ 수익 추적, 인보이스 관리 = TaxBaik 관리

#### 더존과의 차별화 기능
```
더존(Douzone)의 강점         TaxBaik의 고유 기능
┌─────────────────────┐  ┌──────────────────────┐
│ 신고 장부 자동화     │  │ 고객 수명주기 관리   │
│ 세금 계산기          │  │ 계약/수익 추적       │
│ 결산 보고서          │  │ 상담 활동 기록       │
│ 세율/세법 업데이트   │  │ 다중 회사 관리       │
│ 전자세금계산서       │  │ 마케팅 자동화        │
└─────────────────────┘  │ 모바일 앱            │
                         │ SEO 블로그           │
                         └──────────────────────┘
```

#### API 동기화 (향후)
```
더존(Douzone) API (엔터프라이즈)
    ↓
[고객별 신고 상태 조회]
    ↓
TaxBaik [상태 추적] → [CRM 분석]
    ↓
[수익 인식 자동화]
```

#### 데이터 주인 원칙
```
고객사 정보
├─ 더존 소유: 사업자등록번호, 기업명, 업종, 세무신고 이력
├─ TaxBaik 소유: 컨택트 정보, 계약 내용, 상담 기록, 계약 상태
└─ 동기화 필요: 회사 마스터ID

신고 일정
├─ 더존 소유: 신고 유형, 세법 기한, 신고 마감일
├─ TaxBaik 소유: 담당자 배정, 상담 노트, 처리 상태
└─ 참고만: TaxBaik은 더존의 신고 기한을 읽기만 함 (역동기화 금지)
```

#### 10.7 국세청(NTS) API 연동 전략

**목표**: 고객 편의성 향상 + 세무 업무 자동화 + 데이터 정확성 보증

#### 국세청 API가 필요한 이유

| 기능 | 현재 (수동) | 국세청 API (자동) | 고객 효과 |
|------|-----------|------------|---------|
| **사업자등록번호 검증** | 고객 입력 후 수동 확인 | 실시간 진위 확인 | 등록 즉시 검증 ✅ |
| **신고 현황 조회** | 더존에서 확인 후 TaxBaik에 입력 | 국세청에서 직접 조회 | 신고 상태 자동 동기화 ✅ |
| **납세 의무 확인** | 고객 자가 확인 | API로 자동 확인 | 맞춤형 상담 내용 생성 ✅ |
| **세무조사 이력** | 고객 진술만 가능 | 공식 기록 조회 | 정확한 위험도 평가 ✅ |

#### 국세청 API 연동 기능 (우선순위)

**Level 1: 사업자등록번호 검증 (즉시 도입 가능)**
```
TaxBaik에 고객 사업자등록번호 입력 → 국세청 API 호출 → 진위 확인
- API: 사업자등록번호 진위확인 조회 (National Tax Service OpenAPI)
- 응답: 성명/사업장주소/업태 반환
- 효과: 부정확한 정보 사전 차단
- 비용: 월 5,000호 무료, 초과 시 호출당 1원
```

**Level 2: 신고 현황 조회 (더존 연동 후)**
```
더존에서 신고 정보 → 국세청 API 검증 → TaxBaik 자동 갱신
- API: 종합소득세 신고현황 조회 / 부가가치세 신고현황 조회
- 연동 대상: 종소세, 부가세, 법인세
- 효과: 신고일정 자동 생성, 미신고 고객 즉시 알림
- 스케줄: 월 1회 배치 실행 (신고 기간 후)
```

**Level 3: 납세 의무 확인 (고급)**
```
고객 사업자등록번호 → 국세청 조회 → 의무 사항 리스트
- 자료제출 의무 (세무대리인)
- 장부작성 의무 (복식부기 필수)
- 부가가치세 업종별 특별공제 대상 여부
- 효과: 맞춤형 상담 가이드 자동 생성
```

**Level 4: 세무조사 이력 (전략)**
```
고객 사업자등록번호 → 국세청 조회 → 과거 3년 조사 이력
- 효과: 고위험 고객 조기 발굴, 예방 상담 강화
- 범위: 실명, 규모, 적발 사항 (부가세/소득세 구분)
```

#### 국세청 API 도입 로드맵

| Phase | 기능 | 일정 | 영향 |
|-------|------|------|------|
| **1** | 사업자등록번호 검증 | 즉시 | 고객 데이터 품질 ↑ |
| **2** | 더존 신고 현황 동기화 | Q3 | 자동 일정 생성, 미신고 알림 |
| **3** | 납세 의무 자동 가이드 | Q4 | 상담 콘텐츠 자동화 |
| **4** | 세무조사 위험도 평가 | 2027 | 예방 상담 강화 |

#### 필요한 준비물

**1. 국세청 오픈 API 신청**
- https://www.nts.go.kr (공식 신청)
- 또는 더존 엔터프라이즈 통해 간접 연동

**2. TaxBaik 구현**
```csharp
// NtsApiClient.cs
public interface INtsApiClient
{
    Task<BusinessRegistrationInfo> VerifyBusinessRegistrationAsync(string registrationNumber);
    Task<TaxFilingStatus> GetTaxFilingStatusAsync(string registrationNumber, int year);
    Task<TaxObligations> GetTaxObligationsAsync(string registrationNumber);
    Task<AuditHistory> GetAuditHistoryAsync(string registrationNumber);
}

// 사용처: ClientService / TaxProfileService에 주입
```

**3. 에러 처리**
- API 호출 실패 → 로컬 검증으로 폴백
- 네트워크 타임아웃 → 재시도 3회 + 캐시 사용
- 국세청 점검 중 → 오프라인 모드 지원

#### 고객 편의성 향상 예시

**Before (수동 프로세스)**:
1. 고객: 사업자등록번호 입력
2. 세무사: 수동으로 국세청 사이트 접속
3. 세무사: 신고 현황 수동 입력
4. TaxBaik: 불일치 가능성 ❌

**After (자동화)**:
1. 고객: 사업자등록번호 입력
2. TaxBaik: 즉시 국세청 검증 ✅
3. TaxBaik: 신고 일정 자동 생성 ✅
4. TaxBaik: 미신고 알림 자동 발송 ✅
5. 세무사: 데이터만 확인 (시간 절약 70%)

---

### 더존 통합 전략
**현재 (수동 연동)**:
- 더존에서 신고 일정 확인 → TaxBaik에 수동 입력
- 안정적이나 수작업 많음

**향후 (자동 동기화)**:
1. **더존 엔터프라이즈 API** 접근 (B2B 라이선스 필요)
2. **Webhook** 수신: 신고 완료, 결산 마감 이벤트
3. **일 1회 배치 폴링**: 신고 상태 자동 갱신
4. **수익 인식 자동화**: 더존 계약금액 → TaxBaik 인보이스 생성

**구현 팁**:
- 더존 API 사용 가능 시: webhook로 신고 완료 알림 수신
- 불가능하면: 주기적 배치로 더존 상태 폴링 (일 1회)
- TaxBaik에서 생성한 데이터는 절대 더존에 역동기화 금지
- 더존 기존 고객도 TaxBaik CRM에 등록 (중복 허용, 통합 관리)

---

## 11. 배포 검증

### 빌드
```bash
dotnet build src/TaxBaik.sln
```

### 서버 상태 확인 (SSH)
```bash
ssh kjh2064@178.104.200.7

# DB 확인
psql -U taxbaik -d taxbaikdb -c "\dt"

# 서비스 상태 (통합 Web 앱만)
systemctl status taxbaik

# 엔드포인트 확인
curl http://127.0.0.1:5001/

# Nginx 라우팅 확인
curl http://127.0.0.1/
curl http://127.0.0.1/admin/login
```

### E2E 테스트 & 반응형 검증
```bash
# 문의 폼 제출
curl -X POST http://taxbaik.com/contact \
  -d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트"

# 관리자 DB에서 확인
ssh kjh2064@178.104.200.7
psql -U taxbaik -d taxbaikdb
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
```

**반응형 디자인 E2E 테스트** (test_admin 테스트 계정 사용):
```bash
# Green-Blue 배포 지원:
# - Nginx를 통한 포트 무관 라우팅 (http://localhost)
# - 또는 직접 포트 지정 (http://localhost:5001)

# 방법 1: Nginx 거쳐서 (권장 - active 버전 자동 테스트)
export E2E_BASE_URL="http://localhost"
export E2E_ADMIN_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456"

# 방법 2: 직접 포트 지정 (5001 또는 5002)
# export E2E_BASE_URL="http://localhost:5001"

# Playwright로 반응형 테스트 실행 (8개 디바이스 크기)
npx playwright test admin-responsive.spec.ts

# 단일 프로젝트만 (빠른 검증)
npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
```

**테스트 계정 정보** (마이그레이션 V012-V013):
- 사용자명: `test_admin`
- 비밀번호: `TestAdmin@123456`
- 용도: E2E Playwright 자동 테스트 (실 admin 계정과 완전 분리)
- 권한: admin과 동일
- 비밀번호 변경: `/api/auth/reset-password` API 사용

**프로덕션 E2E 테스트**:
```bash
export E2E_BASE_URL="https://taxbaik.com"
export E2E_ADMIN_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456"

npx playwright test  # CI에서 배포 후 자동 실행
```

**테스트 항목**:
- ✅ Desktop (1920px, 1440px, 1024px): 메트릭 4개 컬럼
- ✅ Tablet L/M (960px, 768px): 메트릭 3/2 컬럼
- ✅ Tablet S (600px): 메트릭 1 컬럼, 드로어 축소
- ✅ Mobile (480px, 375px): 메트릭 1 컬럼, 모바일 네비게이션
- ✅ 텍스트 가독성 (최소 폰트 11px)
- ✅ 버튼 접근성 (최소 20x20px)
- ✅ 폼 필드 너비 (200px 이상)
- ✅ 수평 오버플로우 없음 (모든 크기)

### 배포 중 사용자 경험 보호

**문제**: 배포 중 사용자가 관리 페이지에서 작업 중이면 강제 새로고침이 발생하여 미저장 데이터 손실

**해결 방안**:

#### 1. 배포 알림 전략 (강제 새로고침 금지)
```csharp
// Program.cs - SignalR 배포 알림
app.MapHub<NotificationHub>("/hub/notifications");

// NotificationHub.cs
public async Task NotifyDeploymentStart()
{
    // ❌ 강제 새로고침하지 않음
    // ✅ 대신 사용자에게 알림만 보냄
    await Clients.Group("admins").SendAsync("DeploymentNotification", new
    {
        Type = "DeploymentStart",
        Message = "새 버전이 배포되고 있습니다. 진행 중인 작업을 계속할 수 있습니다.",
        TimeoutSeconds = 60  // 사용자가 60초 후 수동으로 새로고침 가능
    });
}
```

#### 2. 프론트엔드: 배포 알림 모달 (자동 새로고침 금지)
```razor
@* Components/Admin/Shared/DeploymentNotification.razor *@
@if (showNotification)
{
    <MudDialog @bind-Visible="showNotification">
        <TitleContent>
            <MudText Typo="Typo.h6">새 버전 배포</MudText>
        </TitleContent>
        <DialogContent>
            <MudText>새 버전이 배포되고 있습니다. 진행 중인 작업을 계속할 수 있습니다.</MudText>
            <MudText Typo="Typo.caption" Class="mt-4">
                업데이트: <strong>@countdown</strong>초 후 새로고침 (또는 수동으로 새로고침)
            </MudText>
            <MudLinearProgressIndeterminate />
        </DialogContent>
        <DialogActions>
            <MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton>
            <MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton>
        </DialogActions>
    </MudDialog>
}

@code {
    private bool showNotification = false;
    private int countdown = 60;
    private HubConnection? hubConnection;

    protected override async Task OnInitializedAsync()
    {
        hubConnection = new HubConnectionBuilder()
            .WithUrl("/hub/notifications", options =>
                options.AccessTokenProvider = async () => 
                    await LocalStorage.GetItemAsStringAsync("authToken") ?? "")
            .WithAutomaticReconnect()
            .Build();

        hubConnection.On<dynamic>("DeploymentNotification", async (notification) =>
        {
            showNotification = true;
            // 사용자가 "나중에" 누르지 않으면 60초 후 자동 새로고침
            await Task.Delay(TimeSpan.FromSeconds(60));
            if (showNotification)
                RefreshNow();
        });

        await hubConnection.StartAsync();
    }

    private void RefreshNow() => NavigationManager.NavigateTo(NavigationManager.Uri, true);

    private void DismissNotification()
    {
        showNotification = false;
        countdown = 0;
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (hubConnection is not null)
            await hubConnection.DisposeAsync();
    }
}
```

#### 3. CI/CD 배포 알림 (server-sent events 대신 SignalR)
```yaml
# .gitea/workflows/deploy.yml
- name: Notify deployment start
  run: |
    curl -X POST "http://127.0.0.1:5001/api/admin/deployment-start" \
      -H "Authorization: Bearer ${{ env.INTERNAL_API_TOKEN }}" \
      -H "Content-Type: application/json" \
      -d '{"message":"New version deploying..."}'
```

#### 4. 사용자 상태 보호 (데이터 손실 방지)
- ✅ 폼 데이터를 `sessionStorage`에 자동 저장 (변경 감지 시)
- ✅ 페이지 이탈 시 경고 (unsaved changes)
- ✅ 강제 새로고침 후 복구 옵션 제공

```csharp
// 폼 자동 저장 (선택적)
public class AutoSaveService
{
    private readonly IJSRuntime js;
    
    public async Task SaveFormAsync<T>(string key, T data)
    {
        await js.InvokeVoidAsync("sessionStorage.setItem", key, 
            System.Text.Json.JsonSerializer.Serialize(data));
    }
    
    public async Task<T?> RestoreFormAsync<T>(string key)
    {
        var json = await js.InvokeAsync<string>("sessionStorage.getItem", key);
        return json == null ? default : 
            System.Text.Json.JsonSerializer.Deserialize<T>(json);
    }
}
```

#### 5. 배포 상태 확인 엔드포인트
```csharp
// Controllers/DeploymentController.cs
[ApiController]
[Route("api/admin/[controller]")]
public class DeploymentController : ControllerBase
{
    [HttpPost("deployment-start")]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> NotifyDeploymentStart(
        [FromServices] IHubContext<NotificationHub> hubContext)
    {
        await hubContext.Clients.Group("admins").SendAsync(
            "DeploymentNotification", new
            {
                Type = "DeploymentStart",
                Timestamp = DateTime.UtcNow
            });
        
        return Ok(new { message = "배포 알림 전송됨" });
    }
    
    [HttpGet("status")]
    public IActionResult GetDeploymentStatus() =>
        Ok(new { Status = "Running", Version = "2026-06-28" });
}
```

**핵심 원칙**:
- 배포 중 강제 새로고침 절대 금지 ❌
- 사용자에게 알림만 보내고 수동 새로고침 제공 ✅
- 폼 데이터는 세션 저장소에 자동 보존 ✅
- 강제 새로고침 후 복구 옵션 제공 ✅

### Telegram 배포 알림 설정 (System Chat)

**배포 완료 메시지는 System Chat ID로만 전송**:

```bash
# .gitea/workflows/deploy.yml
- name: Notify deployment success
  if: success()
  run: |
    DEPLOYMENT_TIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
    curl -s -X POST https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage \
      -d chat_id=-5585148480 \
      -d text="✅ 배포 완료%0A%0A환경: Production%0A상태: 정상 운영 중%0A%0A${DEPLOYMENT_TIME}" \
      -d parse_mode=HTML
```

**메시지 라우팅 정책**:
| 알림 유형 | Chat ID | 목적 |
|---------|---------|------|
| 배포 완료 | -5585148480 (System) | CI/CD 파이프라인 모니터링 |
| 배포 실패 | -5585148480 (System) | 긴급 대응 |
| 문의 접수 | -5434691215 (Inquiry) | 고객 상담 |
| 로그인 알림 | 보내지 않음 | 스팸 방지 |

**구현**:
```csharp
// CI/CD 배포 단계에서
if (deploymentSucceeded)
{
    await telegramService.SendSystemNotificationAsync(
        $"✅ 배포 완료\n\n환경: Production\n상태: 정상 운영 중\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
}
else
{
    await telegramService.SendSystemNotificationAsync(
        $"❌ 배포 실패\n\n환경: Production\n오류: {errorMessage}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
}
```

---

### CI/CD 파이프라인 최적화 (2026-06-28)

**목표**: 전체 배포 시간을 최소화하고 명확한 Timeout 설정

**최적화 항목**:

| 항목 | 이전 | 현재 | 개선 |
|------|------|------|------|
| **Blazor 프리렌더링** | 전역 `prerender: false` (로그인 포함 전체 흰 화면) | 페이지별 지정 (로그인만 `prerender: true`, 나머지 `false`) | 로그인 흰 화면 제거, 인증 페이지는 그대로 안정 |
| **배포 헬스 체크** | 40 × 3초 = 120초 | 20 × 3초 = 60초 | -50% |
| **E2E 배포 대기** | 30 × 5초 = 150초 | 20 × 3초 = 60초 | -60% |
| **Playwright 병렬** | `fullyParallel: false` | CI에서 `true` | 테스트 병렬화 |
| **테스트 재시도** | CI에서 1회 재시도 | 재시도 없음 | 실패 즉시 감지 |
| **E2E 프로젝트** | 4개 (Desktop/Mobile/iPad/Galaxy) | 1개 (Desktop Chrome) | -75% 테스트 |

**예상 실행 시간** (정상 배포 시):
- Build: ~3-5분
- Test: ~1-2분  
- Publish: ~1분
- Deploy + Health Check: ~3-5분 (기존 2분 → 개선)
- E2E Tests: ~5-10분 (Desktop Chrome만, 병렬 처리)
- **전체**: ~15-25분 (기존 60분+ → -75% 단축)

**Timeout 규칙**:
- 배포 헬스 체크: 60초 (실패 시 즉시 롤백)
- E2E 배포 대기: 60초 (실패 시 테스트 스킵)
- Playwright 테스트: 30초/테스트 (느린 테스트는 즉시 실패)
- Expect 조건: 10초 (느린 상호작용은 즉시 실패)

**설정 파일**:
- `.gitea/workflows/deploy.yml`: 배포 헬스 체크 60초
- `.gitea/workflows/browser-e2e.yml`: E2E 대기 60초, Desktop Chrome만 실행
- `playwright.config.ts`: CI에서 병렬 처리, 재시도 없음

---

### CI Deploy 트러블슈팅 하네스 (2026-06-28)

커밋 후 배포가 동작하지 않는다고 판단하기 전에 아래 순서로 확인한다. 추측으로 runner, secret, 커밋 제목을 원인으로 단정하지 않는다.

1. **푸시 결과 확인**
   ```powershell
   git push origin master 2>&1 | Select-String "master|To|Processed|remote"
   ```
   `master -> master`가 보이면 Git push는 성공이다. 이 단계는 CI 실행 성공을 의미하지 않는다.

2. **Actions run 생성 확인**
   ```powershell
   $headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
   $runs = Invoke-RestMethod -Headers $headers -Uri "http://gitea.taxbaik.com/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
   $runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
   ```
   `deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다.

3. **workflow 파싱 검증**
   ```powershell
   curl.exe -sS -w "`nHTTP_STATUS:%{http_code}`n" `
     -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
     -H "Content-Type: application/json" `
     -X POST "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/workflows/deploy.yml/dispatches?return_run_details=true" `
     --data '{"ref":"refs/heads/master","inputs":{}}'
   ```
   `failed to unmarshal workflow content`가 나오면 `.gitea/workflows/deploy.yml` YAML 문법 문제다. 여러 줄 문자열은 반드시 `run: |` 블록 들여쓰기 안에 둔다.

4. **job 실패 로그 확인**
   ```powershell
   curl.exe -sS -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" `
     "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/jobs/{job_id}/logs"
   ```
   빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.

5. **"CI는 성공인데 실제 사이트는 502/404" 의심 시 — 반드시 Nginx 레이어부터 확인**
   내부 헬스체크(`http://127.0.0.1:5001/...`)는 Nginx를 거치지 않으므로 Nginx 설정 오류를 잡지 못한다. CI 성공과 실제 접속 가능 여부는 별개다.
   ```bash
   # 1) 실제 로드되는 파일 확인 (sites-available에 있어도 sites-enabled에 링크 안 되면 무효)
   ls -la /etc/nginx/sites-enabled/
   # 2) 그 파일에서 location / 이 5001을 가리키는지 확인 (5003/5004 하드코딩 금지)
   grep -A 2 'location /' /etc/nginx/sites-available/taxbaik-domains.conf
   # 3) 실제 도메인 기준 server block으로 로컬 검증 (Host/SNI 강제)
   curl -sk -I --resolve www.taxbaik.com:443:127.0.0.1 https://www.taxbaik.com/
   ```

**이번 장애 원인 기록 (2026-06-28)**:
- YAML 들여쓰기 오류와 OAuth provider 무조건 등록으로 배포/런타임 장애가 났다.
- OAuth provider는 `ClientId`/`ClientSecret`이 모두 있을 때만 등록한다.

**이번 장애 원인 기록 (2026-07-03)**:
- CI 성공 후에도 502/404가 났고, 원인은 Nginx 파일 선택/죽은 포트 하드코딩/`proxy_pass` 오설정이었다.
- 대책: `sites-enabled/` 기준 검증, 포트 하드코딩 금지, 실도메인 외부 검증을 배포 절차에 포함했다.

---

## 12. 문제 해결

| 문제 | 해결 |
|------|------|
| 앱 시작 안 됨 | `journalctl -u taxbaik -n 50` |
| DB 연결 실패 | `ConnectionStrings__Default` |
| 404 / | `sudo nginx -t && sudo systemctl reload nginx` |
| Blazor WebSocket 안 됨 | `location /` 헤더 확인 |
| 배포 후 503 | `systemctl status taxbaik` |
| 로그인 실패 | `admin_users`/`AuthService`/`/api/auth/login` |
| API 호출 실패 (배포 후) | `ApiClient__BaseUrl`와 active 포트 일치 여부 |
| 반응형 CSS 깨짐 | `admin.css`와 viewport |

---

---

## 13. 시즌별 마케팅 (Seasonal Marketing)

### 13.1 핵심 방향

세무사 사무실은 **1년 중 특정 시기에 특정 고객이 집중**된다. 홈페이지는 이 시기마다 자동으로 전환되어야 한다.

**목표**: 방문자가 접속한 날짜에 맞는 세무 이벤트를 즉시 인지하고 상담 신청으로 전환

**전환 방식**:
- Hero 섹션 헤드라인과 CTA가 시즌에 맞게 변경됨
- 마감 D-7일 이내에는 긴박감 메시지 추가 표시
- 시즌 관련 서비스 카드가 맨 앞으로 이동
- 최종 CTA도 시즌 문구로 전환
- 관리자가 별도 공지사항을 등록하면 모든 페이지 최상단에 배너로 노출

### 13.2 연간 세무 캘린더

| 기간 | 이벤트 | Key | 타깃 서비스 |
|------|--------|-----|-------------|
| 1/1 ~ 1/25 | 부가가치세 2기 확정신고 | `vat-2nd` | business-tax |
| 1/15 ~ 2/28 | 연말정산 | `year-end-settlement` | business-tax |
| 3/1 ~ 3/31 | 법인세 신고 | `corporate-tax` | business-tax |
| 5/1 ~ 5/31 | **종합소득세 신고** (연중 최대 피크) | `income-tax` | business-tax |
| 7/1 ~ 7/25 | 부가가치세 1기 확정신고 | `vat-1st` | business-tax |
| 11/15 ~ 11/30 | 종합부동산세 납부 | `comprehensive-real-estate-tax` | real-estate-tax |
| 12/1 ~ 12/31 | 연말 증여·절세 플래닝 | `year-end-gift` | family-asset |

캘린더 정의 위치: `src/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs`

시즌 추가/수정은 이 파일만 변경하면 된다. DB·마이그레이션 변경 없음.

### 13.3 공지사항 (Announcement)

어드민 `/admin/announcements`에서 관리.

- **유형**: 일반(info, 파란색) / 배너(banner, 주황색) / 긴급(urgent, 빨간색)
- **게시 기간**: 시작일~종료일 설정 가능. 비우면 즉시~무기한
- **노출 위치**: 홈페이지 최상단 (공지 배너 스트립)
- **우선순위**: sort_order 내림차순

공지사항은 시즌 Hero와 독립적으로 동작한다. 동시 표시 가능.

### 13.4 시즌 우선순위 / 광고 규칙 준수

- 허용: "지금 신고 준비하세요", "마감 전 사전 검토", "D-N일 남았습니다"
- 금지: "100% 절세 보장", "최저가 신고", "무료"

**마지막 체크리스트:**
- [ ] 솔루션 빌드 성공 (`dotnet build`)
- [ ] 모든 프로젝트 참조 정확
- [ ] DB 마이그레이션 SQL 파일 생성
- [ ] systemd 서비스 파일 서버에 설치
- [ ] Nginx location 블록 설정
- [ ] Gitea Secrets (DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY_B64) 추가
- [ ] 초기 커밋 및 git push
