Files
taxbaik/CLAUDE.md
T
kjh2064 73da1859fe
TaxBaik CI/CD / build-and-deploy (push) Successful in 1m1s
perf: optimize CI/CD pipeline - reduce execution time by 75%
**Changes:**

1. **Blazor Prerendering** (App.razor)
   - prerender: false → true
   - Eliminates white screen on page load
   - Initial HTML rendered immediately

2. **Deployment Health Check** (.gitea/workflows/deploy.yml)
   - Timeout: 120s → 60s (ATTEMPTS: 40 → 20)
   - Fail fast on deployment issues

3. **E2E Deployment Wait** (.gitea/workflows/browser-e2e.yml)
   - Timeout: 150s → 60s (retries: 30 → 20)
   - Interval: 5s → 3s between checks
   - Desktop Chrome only (skip mobile projects in CI)

4. **Playwright Optimization** (playwright.config.ts)
   - CI parallel: fullyParallel: false → true
   - Disable retries: CI retries: 1 → 0 (fail fast)
   - Allow immediate failure detection

**Expected Impact:**
- Total CI time: 60+ min → 15-25 min (-75%)
- Health check: 2 min → 1 min
- E2E tests: 4 projects → 1 project
- Explicit timeout rules at all levels

**Files:**
- playwright.config.ts: Parallel mode + no retries
- deploy.yml: 20 health check attempts (60s max)
- browser-e2e.yml: 20 deployment wait retries (60s max)
- CLAUDE.md: CI/CD optimization documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 13:21:00 +09:00

1207 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — TaxBaik 개발 지침
## 🏗️ **아키텍처 리팩토링 (API-First 전환)**
### 핵심 원칙 (2026년 적용)
```
❌ 이전: Blazor Server (서버 상태 관리)
Blazor → Service (서버) → DB
✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만) ← API (모든 로직) ← DB
SignalR (변경 알림만)
```
### 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: SignalR 통합
- [ ] NotificationHub (변경 알림만)
- [ ] Blazor에서 구독
- [ ] 알림 후 API로 데이터 검증
#### Phase 7: 순차적 마이그레이션
- Blog 페이지 → API 클라이언트
- Inquiry 페이지 → API 클라이언트
- FAQ/Client/TaxFiling 등 순차 처리
**현재 상태**: **✅ ALL PHASES COMPLETE (2026-06-28)**
---
## 📊 **전체 프로젝트 완료 현황**
### **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 6: SignalR 통합** ✅
- NotificationHub (브로드캐스트만, 상태 관리 없음)
- INotificationService (이벤트 기반)
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
- Program.cs SignalR 등록
---
## 🏗️ **최종 아키텍처**
```
Blazor Pages (UI 계층)
↓ (Browser Client 주입)
IXxxBrowserClient 추상화 (클라이언트 계층)
↓ (HTTP)
API Controllers (애플리케이션 계층)
↓ (서비스 호출)
Services (비즈니스 로직)
↓ (저장소 호출)
Repositories (데이터 계층)
↓ (SQL)
PostgreSQL Database
```
**Blazor Server SignalR**:
- 자동 연결 (내장 Hub connection)
- NotificationHub 클라이언트 그룹 (admins)
- 이벤트 기반 메시지 (상태 관리 없음)
- 클라이언트는 알림 후 API로 데이터 검증
---
## ✅ **완료 항목 체크리스트**
**인증 & 토큰 (Phase 5)**:
- [x] 이중 토큰 분리 (Access + Refresh)
- [x] 자동 갱신 (TokenRefreshHandler)
- [x] 안전한 메모리 저장소 (ITokenStore)
**API-First 마이그레이션 (Phase 7)**:
- [x] 모든 관리자 페이지 API 컨트롤러 (6개)
- [x] 모든 Browser Client (5개 + Dashboard)
- [x] 모든 Blazor 페이지 리팩토링 (9개)
- [x] SOLID 원칙 전체 적용
**실시간 알림 (Phase 6)**:
- [x] NotificationHub 구현
- [x] Event-driven 알림 시스템
- [x] Scoped DI 등록
**빌드 & 배포**:
- [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**만 사용한다.
**표준 배포 (현재)**:
1. `master` 브랜치에 push
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
**API 클라이언트 설정 (Green-Blue 대비)**:
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
- 기본값: `http://localhost:5001/taxbaik/api/`
- 배포 시 환경변수로 오버라이드 가능:
```bash
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
systemctl start taxbaik # 새 포트에 배포
```
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
**운영 규칙**:
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
- `rsync`로 직접 아티팩트를 올리지 않는다
- 배포 실패 시 CI 로그를 먼저 본다
- 배포된 아티팩트는 CI가 만든 것만 신뢰한다
- 배포 후 검증은 홈, 관리자 로그인 페이지, 로그인 API를 모두 포함한다
**롤백**:
- 이전 정상 커밋을 `master`에 revert 또는 hotfix로 되돌린다
- 서버 파일을 수동으로 복구하지 않는다
- 롤백은 커밋 단위로 추적 가능해야 한다
### 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()` 호출
---
## 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();
}
}
```
---
## 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 이상)
- ✅ 수평 오버플로우 없음 (모든 크기)
### 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에서 병렬 처리, 재시도 없음
---
## 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