# 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 / ``` **장점**: - ✅ 한 개의 포트 (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 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()` 호출 ### 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 ``` #### 색상 & 상태 표시 - **정상** (Normal): 회색 배경 - **주의** (Warning): 주황색 배경 (TaxRiskLevel: "warning") - **긴급** (Danger): 빨간색 배경 (TaxRiskLevel: "danger", 미납 송장) - **완료** (Success): 녹색 배경 (완료된 신고, 결제됨) ```razor @item.TaxRiskLevel ``` #### 페이지 구조 (예: 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: 페이지 헤더 (`
`)** ```razor @page "/admin/새페이지" @attribute [Authorize] @inject INewPageClient NewPageClient @inject NavigationManager Nav 페이지 제목
카테고리 페이지 제목 한 줄 설명
새 항목 추가
``` **Step 2: 콘텐츠 영역** ```razor @if (items == null) { } else if (items.Count == 0) { 데이터가 없습니다. } else { } ``` **Step 3: 모달 다이얼로그 (Create/Edit)** ```razor @(isEditMode ? "항목 수정" : "새 항목 추가") 취소 저장 ``` **Step 4: @code 섹션 구조** ```csharp @code { private List? items; private List relatedItems = []; private Dictionary 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("", 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 주입 - [ ] 추가 - [ ]
(캡션, 제목, 부제, 추가 버튼) - [ ] 로딩 상태 (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 @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(); } } ``` ### 10.5 폼 UI/UX - Enter 키 포커스 이동 **목표**: 관리 페이지 폼에서 Enter 키를 누르면 다음 필드로 자동 포커스 #### 구현 패턴 ```razor ``` ```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 VerifyBusinessRegistrationAsync(string registrationNumber); Task GetTaxFilingStatusAsync(string registrationNumber, int year); Task GetTaxObligationsAsync(string registrationNumber); Task 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://taxbaik.com/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://taxbaik.com/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("/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) { 새 버전 배포 새 버전이 배포되고 있습니다. 진행 중인 작업을 계속할 수 있습니다. 업데이트: @countdown초 후 새로고침 (또는 수동으로 새로고침) 지금 새로고침 나중에 } @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("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(string key, T data) { await js.InvokeVoidAsync("sessionStorage.setItem", key, System.Text.Json.JsonSerializer.Serialize(data)); } public async Task RestoreFormAsync(string key) { var json = await js.InvokeAsync("sessionStorage.getItem", key); return json == null ? default : System.Text.Json.JsonSerializer.Deserialize(json); } } ``` #### 5. 배포 상태 확인 엔드포인트 ```csharp // Controllers/DeploymentController.cs [ApiController] [Route("api/admin/[controller]")] public class DeploymentController : ControllerBase { [HttpPost("deployment-start")] [Authorize(Roles = "Admin")] public async Task NotifyDeploymentStart( [FromServices] IHubContext 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://gitea.taxbaik.com/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10" $runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion ``` `deploy.yml@refs/heads/master`, `event=push`, 최신 `head_sha`가 있어야 배포가 실제로 시작된 것이다. 3. **workflow 파싱 검증** ```powershell curl.exe -sS -w "`nHTTP_STATUS:%{http_code}`n" ` -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" ` -H "Content-Type: application/json" ` -X POST "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/workflows/deploy.yml/dispatches?return_run_details=true" ` --data '{"ref":"refs/heads/master","inputs":{}}' ``` `failed to unmarshal workflow content`가 나오면 `.gitea/workflows/deploy.yml` YAML 문법 문제다. 여러 줄 문자열은 반드시 `run: |` 블록 들여쓰기 안에 둔다. 4. **job 실패 로그 확인** ```powershell curl.exe -sS -H "Authorization: token $env:GITEA_TOKEN_TAXBAIK" ` "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/jobs/{job_id}/logs" ``` 빌드/테스트/배포/헬스체크 중 어느 단계인지 먼저 분리한다. **이번 장애 원인 기록**: - `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