# 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 / ``` **장점**: - ✅ 한 개의 포트 (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 GetBySlugAsync(string slug, CancellationToken ct) { using var conn = Conn(); // 항상 using return await conn.QueryFirstOrDefaultAsync( "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로 모든 예외 로깅 --- ## 7. Dapper 패턴 ### 7.1 단일 행 조회 ```csharp var post = await conn.QueryFirstOrDefaultAsync( "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, 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()).ToList(); var total = await reader.ReadFirstAsync(); return (rows, total); } ``` ### 7.3 삽입 + 반환된 ID ```csharp var newId = await conn.QueryFirstAsync( @"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(); builder.Services.AddScoped(); builder.Services.AddScoped( sp => sp.GetRequiredService()); 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] 블로그 관리 @if (posts != null) { @foreach (var post in posts) { } } @code { private List? 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 @ViewData["Title"] ``` ### 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 이상) - ✅ 수평 오버플로우 없음 (모든 크기) --- ## 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