Files
taxbaik/CLAUDE.md
T

72 KiB
Raw Blame History

CLAUDE.md — TaxBaik 개발 지침

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

핵심 원칙 (2026년 적용)

❌ 이전: Blazor Server (서버 상태 관리)
Blazor → Service (서버) → DB

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

UI 기준 원칙 (2026-06-29 추가)

  • 기본 디자인 템플릿은 https://v5.fluentui-blazor.net/ 기준으로 한다.
  • 신규 또는 리팩토링 UI는 Fluent UI Blazor v5 패턴을 우선 적용한다.
  • MudBlazor는 레거시 폐기 대상이다. 새 UI나 리팩토링 UI에서는 사용하지 않는다.
  • 기존 MudBlazor 잔여 코드는 Fluent v5 또는 순수 HTML/CSS로 점진 전환한다.
  • 기본 로딩 상태는 Skeleton이다. MudProgressCircular / MudProgressLinear는 예외적으로만 사용한다.
  • MudDataGrid, MudDialog, MudTabs는 폐기 대상이다. 새 작업에서는 사용하지 말고 Fluent v5 또는 순수 HTML/CSS 패턴으로 대체한다.
  • 목록, 카드, 대시보드, 상세 페이지의 초기 데이터 상태는 스켈톤으로 먼저 렌더링하고, 데이터 수신 후 실제 UI로 교체한다.
  • 로딩 중 블로킹 스피너보다 스켈톤을 우선한다.
  • 관리자와 공개 사이트는 가능한 한 같은 design-tokens.css / ui-primitives.css 기반으로 구성한다.
  • Blazor 진입점은 중복 매핑하지 말고, 동일 호스트 내에서 라우트 충돌이 없도록 단일 엔트리 기준으로 구성한다.
  • @page 중복이나 동일 경로의 Razor Pages + Blazor 중복 선언은 배포 전에 반드시 제거한다.

레거시 정책

  • MudBlazor, MudDataGrid, MudDialog, MudTabs는 신규 도입 금지다.
  • 남아 있는 레거시 UI는 우선순위에 따라 Fluent v5 또는 순수 HTML/CSS로 교체한다.

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

Phase 1-3: API Foundations

  • Auth API (JWT 토큰)
  • Blog API (CRUD)
  • Category API
  • Inquiry API
  • SiteSettings API
  • Dashboard API (v1.0 - 2026-06-28)

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

Phase 4: Dashboard Blazor → API 클라이언트

  • Dashboard.razor 리팩토링
    • AdminDashboardClient 구현
    • 서비스 inject → API 호출로 변경
    • 에러 처리 & 로딩 상태
    • 기본 로딩은 Skeleton 적용
  • 구조: IAdminDashboardClient → HttpClient 추상화

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

Phase 5: JWT 토큰 개선 (진행중)

  • Access Token (15분) + Refresh Token (7일) 분리
  • AuthController에 /api/auth/refresh 엔드포인트 추가
  • AuthService: GenerateTokenPair() & ValidateRefreshToken()
  • CustomAuthenticationStateProvider: accessToken/refreshToken 저장 분리
  • TokenRefreshHandler: DelegatingHandler로 401 자동 갱신
  • Program.cs: AdminDashboardClient에 TokenRefreshHandler 적용
  • Login.razor: 새 토큰 쌍 처리

구현 상세:

// 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 갱신 제거

  • NotificationHub 제거
  • 데이터 변경용 INotificationService 제거
  • Program.cs의 별도 AddSignalR/MapHub 등록 제거

Phase 7: 순차적 마이그레이션

  • Blog 페이지 → API 클라이언트
  • Inquiry 페이지 → API 클라이언트
  • 공개 콘텐츠 & 기본 관리 (Clients, TaxFilings, FAQs, Announcements)
  • CRM & 세무관리 (TaxProfile, TaxFilingSchedule, Contract, ConsultingActivity, RevenueTracking)

현재 상태: Phase 1-7 COMPLETE (2026-06-28)

  • 모든 API 엔드포인트 구현됨
  • 모든 Browser Client 구현됨
  • 16개 Blazor 페이지 API-First 마이그레이션 완료
  • 과거 기록: 관리 화면에서 그리드/모달 UX를 빠르게 안정화한 단계
  • 모달 패턴 (흰 화면 플래시 제거)
  • ConfirmDialog 삭제 확인 컴포넌트

2026-06-29 운영 기준 업데이트

  • 관리자 백오피스는 Fluent UI v5 우선 구조로 재정리한다.
  • 기본 로딩은 스피너가 아니라 Skeleton이다.
  • design-tokens.cssui-primitives.css는 사이트/관리자 공통의 기본 계층이다.
  • 라우팅 충돌은 가장 먼저 확인할 항목이며, 동일 경로가 두 번 등록되는 구조를 만들지 않는다.
  • 커밋은 기능/호스팅/UI/CSS처럼 주제별로 분리한다.
  • 레거시 제거 우선순위는 MudBlazor 계열 UI가 1순위다.

📊 전체 프로젝트 완료 현황

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 페이지 (그리드 Dense, Virtualize, 모달 패턴)
  • 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 특성:

  • Dense 그리드 + Virtualize (1000+ 행 성능)
  • 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):

  • 이중 토큰 분리 (Access + Refresh)
  • 자동 갱신 (TokenRefreshHandler)
  • 안전한 메모리 저장소 (ITokenStore)

API-First 마이그레이션 (Phase 7):

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

Lite Blazor / 데이터 갱신 (Phase 6):

  • Blazor 데이터 변경 SignalR 자동 갱신 제거
  • NotificationHub 제거
  • 데이터 변경용 INotificationService 제거

Blazor 페이지 & UI 고도화 (Phase 7-4):

  • 5개 CRM/세무관리 Blazor 페이지
  • Dense 그리드 + Virtualize (32px 행 높이)
  • 모달 Create/Edit (흰 화면 플래시 제거)
  • ConfirmDialog 삭제 확인
  • 상태별 컬러 칩 (Status/Risk Level)
  • 클라이언트 링크 (상세 페이지 연동)
  • D-day 추적, MRR 계산, 팔로업 자동 추적

빌드 & 배포:

  • 0 오류, 모든 경고 기록됨
  • 모든 커밋 Gitea에 푸시됨
  • 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)

# 터널 열기 (백그라운드 유지)
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

그 후:

ssh taxbaik-tunnel  # 터널 유지

단계 2: 연결 확인

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

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

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

# 터미널 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 (로컬)

{
  "ConnectionStrings": {
    "Default": "Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=XXXXXXXX"
  }
}

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

보안 규칙:

  • appsettings.Production.json에는 비밀값을 두지 않는다.
  • JWT Secret, DB 비밀번호, 외부 API 키는 환경 변수 또는 서버 전용 비밀 경로에서만 읽는다.
  • 값이 비어 있으면 조용히 넘어가지 말고 시작 시 즉시 실패시킨다.
# 로컬 오버라이드
appsettings.Development.json  # gitignore에 추가

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

앱 시작 시 자동 실행:

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

마이그레이션 추가:

# 파일명: 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:

POST /api/auth/reset-password
Content-Type: application/json

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

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

요구사항:

  • 비밀번호: 12자 이상
  • Reset Token: appsettings.jsonAdmin:PasswordResetToken 값 사용
  • 마이그레이션이 아닌 API로만 계정 관리

보안 규칙

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

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

마이그레이션 V003에서 자동 생성:

  • 테스트 블로그 포스트 5개
  • 테스트 카테고리 5개
  • 테스트 FAQ 3개

테스트 데이터 생성 경로:

마이그레이션 실행 → V001-V011 스키마 생성 → V012 test_admin 계정 → V013 admin 계정

테스트 계정 검증:

# 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"}'

수동 추가:

-- 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 터널 유지

ssh -L 3000:127.0.0.1:3000 kjh2064@178.104.200.7
# 터널이 열린 상태 유지

단계 2: 터미널 2 - Git Push

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 필요)

# SSH key가 이미 설정되어 있으면
git push ssh://git@178.104.200.7:2222/kjh2064/taxbaik.git master

방법 C: HTTPS Direct (네트워크 차단이 없으면)

$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 접속

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/
  • 배포 시 환경변수로 오버라이드 가능:
    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 추가:

# /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에는 넣지 않는다.
  • HostX-Forwarded-Proto는 유지해 원본 URL과 스킴을 보존한다.
  • /taxbaik/admin는 robots.txt에서 차단한다.

6. 데이터베이스

4.1 연결 설정

환경 변수 (systemd unit file에 설정):

Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=XXXXXXXX

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

운영 보안 규칙:

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

3.2 Dapper 사용 패턴

DbConnectionFactory.cs:

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 메서드:

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에 모든 백업 시도 기록

복구 절차

# 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;  # 데이터 존재 확인

백업 스크립트

# 파일: /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

모니터링

# 백업 로그 확인
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 단일 행 조회

var post = await conn.QueryFirstOrDefaultAsync<BlogPost>(
    "SELECT * FROM blog_posts WHERE id = @Id",
    new { Id = id });

7.2 여러 행 + 페이징

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

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 트랜잭션

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/ 경로에서 실행:

// Program.cs
app.UsePathBase("/taxbaik");

@page 지시문의 경로는 이 기본값에 상대적. 예:

@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)

// 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]로 감싸고, 클라이언트 렌더링만으로 권한을 믿지 않는다.
// 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] 추가

@* Components/Admin/_Imports.razor *@
@attribute [Authorize]

Admin 로그인 페이지만 [AllowAnonymous]:

@page "/admin/login"
@attribute [AllowAnonymous]

8.4 컴포넌트 구조

@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() 호출
  • 초기 렌더는 Skeleton 우선
  • 로딩이 필요한 목록/카드/대시보드는 items == null 또는 summary == null 패턴으로 스켈톤 렌더링

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행/페이지)
  • 검색: 우상단 검색 박스 (실시간 필터링, 하이라이트 처리)

UI 적용 패턴

```razor
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
<YourGridComponent T="YourItem"
    Dense="true"
    Hover="true"
    Striped="true"
    RowsPerPage="20"
    Virtualize="true"
    @ref="dataGrid"
    Items="items"
    Sortable="true"
    Filterable="true"
    ShowMenuIcon="true">
    
    <Columns>
        <PropertyColumn Property="x => x.Id" Title="ID" Sortable="true" />
        <PropertyColumn Property="x => x.Name" Title="이름" Filterable="true" />
        <PropertyColumn Property="x => x.Amount" Title="금액" Sortable="true" 
            Format="C" />
        <TemplateColumn Title="작업">
            <CellTemplate>
                <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Small" 
                    OnClick="@(() => Edit(context.Item))" />
                <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Small" 
                    OnClick="@(() => Delete(context.Item))" />
            </CellTemplate>
        </TemplateColumn>
    </Columns>
</YourGridComponent>

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

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

페이지 구조 (예: TaxProfile 관리)

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

CSS 클래스 표준

/* admin-grid.css */
.admin-grid {
    font-size: 13px;
    line-height: 1.4;
}

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

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

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

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

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

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

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

성능 최적화

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

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

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

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

Step 1: 페이지 헤더 (<section class="admin-page-hero">)

@page "/admin/새페이지"
@attribute [Authorize]
@inject INewPageClient NewPageClient
@inject NavigationManager Nav

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

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

Step 2: 콘텐츠 영역

<!-- 로딩 상태 -->
@if (items == null)
{
    <Skeleton Count="6" CssClass="taxbaik-skeleton-grid" />
}
<!-- 빈 상태 -->
else if (items.Count == 0)
{
    <MudAlert Severity="Severity.Info" Class="mt-4">데이터가 없습니다.</MudAlert>
}
<!-- 데이터 그리드 -->
else
{
```razor
<!-- 과거 예시: 현재는 Fluent v5 표나 HTML table로 대체 -->
    <YourGridComponent T="YourEntity"
        Items="@items"
        Dense="true"
        Hover="true"
        Striped="true"
        Virtualize="true"
        RowsPerPage="30"
        Class="admin-grid mt-4">
        <Columns>
            <!-- 필수: 컬럼 정의 -->
        </Columns>
    </YourGridComponent>

}


**Step 3: 모달 다이얼로그 (Create/Edit)**
```razor
```razor
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 별도 라우트로 대체 -->
<YourDialogComponent @bind-IsVisible="isDialogOpen">
    <TitleContent>
        <MudText Typo="Typo.h6">@(isEditMode ? "항목 수정" : "새 항목 추가")</MudText>
    </TitleContent>
    <DialogContent>
        <MudForm @ref="form">
            <!-- 폼 필드 -->
        </MudForm>
    </DialogContent>
    <DialogActions>
        <MudButton OnClick="CloseDialog">취소</MudButton>
        <MudButton Color="Color.Primary" OnClick="SaveItem">저장</MudButton>
    </DialogActions>
</YourDialogComponent>

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

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

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

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

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

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

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

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

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

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

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

    private class YourItemForm
    {
        // DTO 필드
    }
}

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

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

위반 사항

이 패턴을 따르지 않는 페이지는 실시간 코드 리뷰 대상:

  • 페이지 헤더 (admin-page-hero) 누락
  • 인라인 스타일로 레이아웃 구성
  • 별도 라우트로 Create/Edit 처리 (흰 화면 플래시)
  • @code 섹션 구조 다름
  • 모달에서 직접 onSubmit 대신 Snackbar 피드백 미제공

9. Do's & Don'ts

DO

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

// Index.cshtml.cs
public void OnGet()
{
    ViewData["Title"] = "백원숙 세무회계 | 사업자·부동산·가족자산 세금 상담";
    ViewData["Description"] = "세무사 백원숙이 제공하는 사업자 기장, 부동산 양도세, 증여세 상담...";
    ViewData["OgImage"] = "/images/hero.jpg";
}
<!-- _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 폼 제출

// 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 키를 누르면 다음 필드로 자동 포커스

구현 패턴

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

<MudTextField @ref="fieldB" Label="필드 B"
    OnKeyDown="@((KeyboardEventArgs e) => HandleEnter(e, "fieldC"))" />
@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 신청

2. TaxBaik 구현

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

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

3. 에러 처리

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

고객 편의성 향상 예시

Before (수동 프로세스):

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

After (자동화):

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

더존 통합 전략

현재 (수동 연동):

  • 더존에서 신고 일정 확인 → TaxBaik에 수동 입력
  • 안정적이나 수작업 많음

향후 (자동 동기화):

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

구현 팁:

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

11. 배포 검증

빌드

dotnet build TaxBaik.sln

서버 상태 확인 (SSH)

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 테스트 & 반응형 검증

# 문의 폼 제출
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 테스트 계정 사용):

# 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 테스트:

export E2E_BASE_URL="http://178.104.200.7/taxbaik"
export E2E_ADMIN_USERNAME="test_admin"
export E2E_ADMIN_PASSWORD="TestAdmin@123456"

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

테스트 항목:

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

배포 중 사용자 경험 보호

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

해결 방안:

1. 배포 알림 전략 (강제 새로고침 금지)

// Program.cs - SignalR 배포 알림
app.MapHub<NotificationHub>("/taxbaik/hub/notifications");

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

2. 프론트엔드: 배포 알림 모달 (자동 새로고침 금지)

@* Components/Admin/Shared/DeploymentNotification.razor *@
@if (showNotification)
{
```razor
<!-- 과거 예시: 현재는 Fluent v5 Dialog 또는 HTML/CSS 패턴으로 대체 -->
    <YourDialogComponent @bind-Visible="showNotification">
        <TitleContent>
            <MudText Typo="Typo.h6">새 버전 배포</MudText>
        </TitleContent>
        <DialogContent>
            <MudText>새 버전이 배포되고 있습니다. 진행 중인 작업을 계속할 수 있습니다.</MudText>
            <MudText Typo="Typo.caption" Class="mt-4">
                업데이트: <strong>@countdown</strong>초 후 새로고침 (또는 수동으로 새로고침)
            </MudText>
            <MudLinearProgressIndeterminate />
        </DialogContent>
        <DialogActions>
            <MudButton Color="Color.Primary" OnClick="RefreshNow">지금 새로고침</MudButton>
            <MudButton Color="Color.Default" OnClick="DismissNotification">나중에</MudButton>
        </DialogActions>
    </YourDialogComponent>

}

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

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

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

    await hubConnection.StartAsync();
}

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

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

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

}


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

4. 사용자 상태 보호 (데이터 손실 방지)

  • 폼 데이터를 sessionStorage에 자동 저장 (변경 감지 시)
  • 페이지 이탈 시 경고 (unsaved changes)
  • 강제 새로고침 후 복구 옵션 제공
// 폼 자동 저장 (선택적)
public class AutoSaveService
{
    private readonly IJSRuntime js;
    
    public async Task SaveFormAsync<T>(string key, T data)
    {
        await js.InvokeVoidAsync("sessionStorage.setItem", key, 
            System.Text.Json.JsonSerializer.Serialize(data));
    }
    
    public async Task<T?> RestoreFormAsync<T>(string key)
    {
        var json = await js.InvokeAsync<string>("sessionStorage.getItem", key);
        return json == null ? default : 
            System.Text.Json.JsonSerializer.Deserialize<T>(json);
    }
}

5. 배포 상태 확인 엔드포인트

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

핵심 원칙:

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

Telegram 배포 알림 설정 (System Chat)

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

# .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) 고객 상담
로그인 알림 보내지 않음 스팸 방지

구현:

// 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. 푸시 결과 확인

    git push origin master 2>&1 | Select-String "master|To|Processed|remote"
    

    master -> master가 보이면 Git push는 성공이다. 이 단계는 CI 실행 성공을 의미하지 않는다.

  2. Actions run 생성 확인

    $headers = @{ Authorization = "token $env:GITEA_TOKEN_TAXBAIK" }
    $runs = Invoke-RestMethod -Headers $headers -Uri "http://178.104.200.7/api/v1/repos/kjh2064/taxbaik/actions/runs?limit=10"
    $runs.workflow_runs | Select-Object id,path,event,head_sha,display_title,status,conclusion
    

    deploy.yml@refs/heads/master, event=push, 최신 head_sha가 있어야 배포가 실제로 시작된 것이다.

  3. workflow 파싱 검증

    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 실패 로그 확인

    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