2039 lines
70 KiB
Markdown
2039 lines
70 KiB
Markdown
# CLAUDE.md — TaxBaik 개발 지침
|
||
|
||
## 🏗️ **아키텍처 리팩토링 (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)
|
||
|
||
**현재 상태**: **✅ Phase 1-7 COMPLETE (2026-06-28)**
|
||
- 모든 API 엔드포인트 구현됨
|
||
- 모든 Browser Client 구현됨
|
||
- 16개 Blazor 페이지 API-First 마이그레이션 완료
|
||
- MudDataGrid Douzone ERP 수준 UX 적용
|
||
- MudDialog 모달 패턴 (흰 화면 플래시 제거)
|
||
- ConfirmDialog 삭제 확인 컴포넌트
|
||
|
||
---
|
||
|
||
## 📊 **전체 프로젝트 완료 현황**
|
||
|
||
### **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)
|
||
- Douzone ERP 수준의 그리드 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 재조회로 제한한다.
|
||
|
||
---
|
||
|
||
## 🏗️ **최종 아키텍처**
|
||
|
||
```
|
||
Blazor Pages (UI 계층)
|
||
↓ (Browser Client 주입)
|
||
IXxxBrowserClient 추상화 (클라이언트 계층)
|
||
↓ (HTTP)
|
||
API Controllers (애플리케이션 계층)
|
||
↓ (서비스 호출)
|
||
Services (비즈니스 로직)
|
||
↓ (저장소 호출)
|
||
Repositories (데이터 계층)
|
||
↓ (SQL)
|
||
PostgreSQL Database
|
||
```
|
||
|
||
**Lite Blazor 데이터 갱신**:
|
||
- Blazor Server 자동 연결은 컴포넌트 상호작용용 기본 회선으로만 사용한다.
|
||
- 데이터 변경 알림용 별도 Hub, 그룹, broadcast, client subscription을 추가하지 않는다.
|
||
- 저장/삭제/완료 같은 사용자 액션 이후 필요한 목록만 API로 다시 조회한다.
|
||
- 공지사항, 문의, 고객, 신고 등 도메인 CRUD 기능은 그대로 유지한다.
|
||
|
||
---
|
||
|
||
## ✅ **완료 항목 체크리스트**
|
||
|
||
**인증 & 토큰 (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 계산, 팔로업 자동 추적
|
||
|
||
**빌드 & 배포**:
|
||
- [x] 0 오류, 모든 경고 기록됨
|
||
- [x] 모든 커밋 Gitea에 푸시됨
|
||
- [x] CI/CD 자동 배포 준비 완료
|
||
|
||
---
|
||
|
||
## 📝 **개발 원칙 준수**
|
||
|
||
✅ **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 앱):
|
||
|
||
```
|
||
TaxBaik.Domain 클래스 라이브러리 (엔티티, 인터페이스, enum)
|
||
TaxBaik.Infrastructure 클래스 라이브러리 (Dapper repository, DB 마이그레이션)
|
||
TaxBaik.Application 클래스 라이브러리 (서비스, DTO, 비즈니스 로직)
|
||
TaxBaik.Web ASP.NET Core 앱 (포트 5001)
|
||
├─ Pages/ Razor Pages (공개 홈페이지, 블로그, 문의폼)
|
||
├─ Components/
|
||
│ ├─ (Web pages)
|
||
│ └─ Admin/ Blazor Server (관리자 백오피스)
|
||
│ ├─ Pages/
|
||
│ ├─ Layout/
|
||
│ └─ App.razor
|
||
└─ Services/ 인증, 블로그, 문의 등
|
||
```
|
||
|
||
**경로:**
|
||
- 홈페이지: `/taxbaik` (Razor Pages)
|
||
- 관리자: `/taxbaik/admin` (Blazor Server)
|
||
- 로그인: `/taxbaik/admin/login`
|
||
|
||
**운영 원칙:**
|
||
- 단일 앱, 단일 서비스, 단일 배포 경로를 유지한다.
|
||
- 운영 변경은 코드 또는 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 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 TaxBaik.Web
|
||
dotnet run
|
||
# 접속:
|
||
# - 홈페이지: http://localhost:5001/taxbaik
|
||
# - 관리자: http://localhost:5001/taxbaik/admin/login
|
||
# - 로그인: admin / <TAXBAIK_ADMIN_TEST_PASSWORD>
|
||
```
|
||
|
||
**장점**:
|
||
- ✅ 한 개의 포트 (5001)
|
||
- ✅ 한 개의 터미널에서 실행
|
||
- ✅ 한 번의 DB 마이그레이션
|
||
- ✅ 모든 기능 유지 (JWT 인증, Blazor UI, Razor Pages SEO)
|
||
|
||
### 3.2 appsettings.json (로컬)
|
||
|
||
```json
|
||
{
|
||
"ConnectionStrings": {
|
||
"Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=XXXXXXXX"
|
||
}
|
||
}
|
||
```
|
||
|
||
**중요**: 로컬 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`
|
||
- 비밀번호: API로 설정 (reset-password 엔드포인트)
|
||
- 용도: 프로덕션 관리자
|
||
- 권한: 모든 관리 기능 액세스
|
||
|
||
**테스트 계정** (test_admin):
|
||
- 사용자명: `test_admin`
|
||
- 비밀번호: API로 설정 (reset-password 엔드포인트)
|
||
- 용도: E2E Playwright 자동 테스트
|
||
- 권한: admin과 동일
|
||
- 환경: 로컬/CI 테스트만
|
||
|
||
#### 비밀번호 관리 (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
|
||
# admin 계정 로그인
|
||
curl -X POST http://localhost:5001/taxbaik/api/auth/login \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"username":"admin","password":"Admin@123456"}'
|
||
|
||
# test_admin 계정 로그인
|
||
curl -X POST http://localhost:5001/taxbaik/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 방법** (권장: SSH 터널):
|
||
|
||
#### 방법 A: SSH 터널 + HTTP Push (권장)
|
||
|
||
**단계 1: 터미널 1 - SSH 터널 유지**
|
||
```bash
|
||
ssh -L 3000:127.0.0.1:3000 kjh2064@178.104.200.7
|
||
# 터널이 열린 상태 유지
|
||
```
|
||
|
||
**단계 2: 터미널 2 - Git Push**
|
||
```powershell
|
||
cd D:\JobRoomz\taxbaik
|
||
$token = $env:GITEA_TOKEN_TAXBAIK
|
||
git push "http://kjh2064:${token}@localhost:3000/kjh2064/taxbaik.git" master
|
||
```
|
||
|
||
**장점**:
|
||
- ✅ 로컬 네트워크 차단 회피 (SSH는 열림)
|
||
- ✅ 안전 (token은 로컬 루프백)
|
||
- ✅ 신뢰성 높음
|
||
|
||
**보안 규칙**:
|
||
- 토큰은 채팅/문서/스크린샷에 붙이지 않는다.
|
||
- push URL에 토큰이 남아 있으면 즉시 제거한다.
|
||
- 가능하면 SSH key 기반 인증을 우선 사용한다.
|
||
|
||
#### 방법 B: SSH로 직접 Push (SSH key 필요)
|
||
|
||
```bash
|
||
# SSH key가 이미 설정되어 있으면
|
||
git push ssh://git@178.104.200.7:2222/kjh2064/taxbaik.git master
|
||
```
|
||
|
||
#### 방법 C: HTTPS Direct (네트워크 차단이 없으면)
|
||
|
||
```powershell
|
||
$token = $env:GITEA_TOKEN_TAXBAIK
|
||
git push "https://kjh2064:${token}@178.104.200.7/kjh2064/taxbaik.git" master
|
||
```
|
||
|
||
**Gitea Actions 자동 배포**:
|
||
1. git push 성공 → master 브랜치에 커밋
|
||
2. Gitea Actions CI/CD 자동 trigger (.gitea/workflows/deploy.yml)
|
||
3. 빌드 → 배포 → 서비스 재시작 자동 실행
|
||
4. 배포 진행 상황: `http://localhost:3000/kjh2064/taxbaik/actions` (SSH 터널 사용 시)
|
||
|
||
---
|
||
|
||
## 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 /taxbaik)
|
||
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는 `/taxbaik` 트래픽을 기존과 같이 `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/taxbaik/healthz` 헬스 체크를 통과할 때까지 폴링(최대 60초)합니다.
|
||
- 헬스 체크 성공 시 `/home/kjh2064/taxbaik_port` 파일에 새 포트 번호를 기입하여 **트래픽을 즉시 무중단 전환**합니다.
|
||
- 기존 포트에서 동작하던 구버전 .NET 프로세스를 종료(`kill -15`)합니다.
|
||
- 만약 헬스 체크 실패 시 새 프로세스만 강제 종료하고 배포를 롤백하여 실서비스 다운타임을 방지합니다.
|
||
|
||
**운영 규칙**:
|
||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다.
|
||
- 배포 실패 시 Gitea Actions CI/CD 로그 및 `~/deployments/taxbaik_timestamp/web_*.log`를 먼저 확인한다.
|
||
- 배포 후 최종 검증은 프록시 포트를 경유하는 메인 홈페이지, 관리자 로그인 페이지, 로그인 API를 모두 포함한다.
|
||
|
||
**롤백**:
|
||
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌려 다시 배포를 수행하거나, 비상시 서버의 `taxbaik_port` 파일의 포트 번호를 수동 수정하여 이전 버전 포트로 즉시 원상복구한다.
|
||
|
||
### 3.4 서비스 파일 위치
|
||
```
|
||
/etc/systemd/system/taxbaik.service ← 통합 Web 앱 (공개 사이트 + 관리자)
|
||
```
|
||
|
||
### 5.5 배포 디렉토리 구조 (서버)
|
||
배포 디렉토리는 CI가 관리한다. 로컬에서 구조를 맞추거나 수동으로 갱신하지 않는다.
|
||
|
||
---
|
||
|
||
## 6. Nginx 라우팅
|
||
|
||
기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가:
|
||
|
||
```nginx
|
||
# /etc/nginx/sites-available/default (또는 현재 설정 파일)에 아래 블록 추가
|
||
|
||
location /taxbaik {
|
||
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;
|
||
}
|
||
```
|
||
|
||
**참고**: 단일 `/taxbaik` 블록이 공개 사이트와 관리자 Blazor 회로를 모두 처리합니다. 운영은 `5001` 통합 앱 기준이며, 설정 반영은 CI 배포로만 수행한다.
|
||
|
||
**Nginx 보안**:
|
||
- `Upgrade` 헤더는 Blazor WebSocket 경로에만 허용하고, 필요 없는 location에는 넣지 않는다.
|
||
- `Host`와 `X-Forwarded-Proto`는 유지해 원본 URL과 스킴을 보존한다.
|
||
- `/taxbaik/admin`는 robots.txt에서 차단한다.
|
||
|
||
---
|
||
|
||
## 6. 데이터베이스
|
||
|
||
### 4.1 연결 설정
|
||
|
||
**환경 변수** (systemd unit file에 설정):
|
||
```ini
|
||
Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=XXXXXXXX
|
||
```
|
||
|
||
**절대 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일 보관 정책으로 최근 한 달 데이터 손실 방지
|
||
- 자동 실행이므로 수동 개입 불필요
|
||
- 장애 발생 시 즉시 최근 백업으로 복구 가능
|
||
|
||
---
|
||
|
||
## 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
|
||
전체 앱은 `/taxbaik/` 경로에서 실행:
|
||
|
||
```csharp
|
||
// Program.cs
|
||
app.UsePathBase("/taxbaik");
|
||
```
|
||
|
||
`@page` 지시문의 경로는 이 기본값에 상대적. 예:
|
||
```razor
|
||
@page "/admin/login" ← 실제 URL: /taxbaik/admin/login
|
||
@page "/admin/blog" ← 실제 URL: /taxbaik/admin/blog
|
||
@page "/blog" ← 실제 URL: /taxbaik/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 (Dorsum ERP 수준)
|
||
|
||
**목표**: 패드/PC에 특화된 고밀도 데이터 표시 + ERP 수준의 상호작용성
|
||
|
||
#### 그리드 기본 원칙
|
||
- **데이터 밀도**: 줄 높이 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 피드백 미제공
|
||
|
||
---
|
||
|
||
## 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에서 `/taxbaik/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 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/taxbaik
|
||
|
||
# Nginx 라우팅 확인
|
||
curl http://127.0.0.1/taxbaik
|
||
curl http://127.0.0.1/taxbaik/admin/login
|
||
```
|
||
|
||
### E2E 테스트 & 반응형 검증
|
||
```bash
|
||
# 문의 폼 제출
|
||
curl -X POST http://178.104.200.7/taxbaik/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/taxbaik)
|
||
# - 또는 직접 포트 지정 (http://localhost:5001/taxbaik)
|
||
|
||
# 방법 1: Nginx 거쳐서 (권장 - active 버전 자동 테스트)
|
||
export E2E_BASE_URL="http://localhost/taxbaik"
|
||
export E2E_ADMIN_USERNAME="test_admin"
|
||
export E2E_ADMIN_PASSWORD="TestAdmin@123456"
|
||
|
||
# 방법 2: 직접 포트 지정 (5001 또는 5002)
|
||
# export E2E_BASE_URL="http://localhost:5001/taxbaik"
|
||
|
||
# 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` (API reset-password로 설정)
|
||
- 용도: E2E Playwright 자동 테스트 (실 admin 계정과 완전 분리)
|
||
- 권한: admin과 동일
|
||
- 비밀번호 변경: `/api/auth/reset-password` API 사용
|
||
|
||
**프로덕션 E2E 테스트**:
|
||
```bash
|
||
export E2E_BASE_URL="http://178.104.200.7/taxbaik"
|
||
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>("/taxbaik/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("/taxbaik/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/taxbaik/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` | 흰 화면 제거 |
|
||
| **배포 헬스 체크** | 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://178.104.200.7/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"
|
||
```
|
||
빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다.
|
||
|
||
**이번 장애 원인 기록**:
|
||
- `deploy.yml`의 Telegram 여러 줄 메시지 일부가 YAML 블록 들여쓰기 밖에 있어 Gitea workflow 파서가 실패했다.
|
||
- 이후 배포 실행은 되었지만, 운영 `Authentication:*:ClientId`가 빈 값인데 OAuth provider를 무조건 등록해 `ClientId` 예외로 500이 발생했다.
|
||
- 외부 OAuth provider는 ClientId/ClientSecret이 모두 있을 때만 등록한다.
|
||
|
||
---
|
||
|
||
## 12. 문제 해결
|
||
|
||
| 문제 | 해결 |
|
||
|------|------|
|
||
| 앱 시작 안 됨 | `journalctl -u taxbaik -n 50` 로그 확인 |
|
||
| DB 연결 실패 | 환경 변수 `ConnectionStrings__Default` 확인 (systemd unit file) |
|
||
| 404 /taxbaik | Nginx 설정 재로드: `sudo nginx -t && sudo systemctl reload nginx` |
|
||
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection "Upgrade"` 헤더가 모두 있는지 확인 |
|
||
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
||
| 로그인 실패 | `admin_users.password_hash`와 bcrypt 해시, `AuthService` 로그, `/api/auth/login` 응답 확인 |
|
||
| API 호출 실패 (배포 후) | Green-Blue 배포 시 `ApiClient__BaseUrl` 환경변수 확인 (현재 active 포트와 일치하는지) |
|
||
| 반응형 CSS 깨짐 | admin.css 로드 확인 (헬스 체크에 포함됨), 브라우저 DevTools에서 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 |
|
||
|
||
캘린더 정의 위치: `TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs`
|
||
|
||
시즌 추가/수정은 이 파일만 변경하면 된다. DB·마이그레이션 변경 없음.
|
||
|
||
### 13.3 공지사항 (Announcement)
|
||
|
||
어드민 `/taxbaik/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
|