Files
taxbaik/CLAUDE.md
T
kjh2064 700cdaed4f
TaxBaik CI/CD / build-and-deploy (push) Successful in 47s
test: fix E2E base URL for green-blue deployment and use test account
Green-Blue 배포에서 E2E 테스트가 항상 새 버전을 테스트하도록 개선:

Changes:
- E2E_BASE_URL default: http://localhost/taxbaik (Nginx 라우팅 → active 포트)
- 이전: http://localhost:5001/taxbaik (하드코드, 구 버전 테스트 위험)
- CI/E2E 워크플로우: test_admin 계정으로 변경 (실 admin 분리)
- Playwright config 주석 명확화 (Green-Blue 배포 지원)
- 로컬 테스트: Nginx 거쳐서 또는 명시적 포트 설정

Architecture:
┌─────────────────────────┐
│  E2E Test Runner        │
│  (test_admin account)   │
└────────────┬────────────┘
             │
    E2E_BASE_URL (env var)
             │
    ┌────────┴────────┐
    │                 │
 http://localhost/   http://localhost:5001/
  taxbaik (Nginx)    taxbaik (direct)
    │                 │
 ┌──▼──┐             │
 │Nginx├─────────────┘
 └──┬──┘
    │ (active port: 5001 or 5002)
    │
 ┌──▼──────────────┐
 │Active TaxBaik   │
 │(5001 or 5002)   │
 └─────────────────┘

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

34 KiB

CLAUDE.md — TaxBaik 개발 지침

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

핵심 원칙 (2026년 적용)

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

✅ 현재: API-First (클라이언트-서버 분리)
Blazor (UI만) ← API (모든 로직) ← DB
          SignalR (변경 알림만)

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 호출로 변경
    • 에러 처리 & 로딩 상태
  • 구조: 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: SignalR 통합

  • NotificationHub (변경 알림만)
  • Blazor에서 구독
  • 알림 후 API로 데이터 검증

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

  • Blog 페이지 → API 클라이언트
  • Inquiry 페이지 → API 클라이언트
  • FAQ/Client/TaxFiling 등 순차 처리

현재 상태: ALL PHASES COMPLETE (2026-06-28)


📊 전체 프로젝트 완료 현황

Phase 5: JWT 토큰 개선

  • Access Token (15분) + Refresh Token (7일) 분리
  • TokenRefreshHandler (401 자동 갱신)
  • ITokenStore (메모리 기반 Blazor Server 안전)
  • CustomAuthenticationStateProvider (토큰 쌍 관리)
  • Login.razor (새 토큰 패턴 구현)

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

Phase 7-1: Blog

  • API: 완성 (CRUD, 페이징)
  • Blazor: 이미 API 클라이언트 사용 중

Phase 7-2: Inquiry

  • API: 완성 (상태 변경, 메모, 고객 변환)
  • Blazor: InquiryTable + InquiryDetail 완전 마이그레이션

Phase 7-3: 모든 관리자 페이지

  • 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
  • 5개 Browser Client (IXxxBrowserClient)
  • 9개 Blazor 페이지 마이그레이션
페이지 API Client Blazor
Clients ClientController IClientBrowserClient List + Edit
TaxFilings TaxFilingController ITaxFilingBrowserClient List + Table
Faqs FaqController IFaqBrowserClient List + Edit
Announcements AnnouncementController IAnnouncementBrowserClient List + Edit
Inquiries InquiryController IInquiryBrowserClient List + Detail
Dashboard AdminDashboardController IAdminDashboardClient Refactored

Phase 6: SignalR 통합

  • NotificationHub (브로드캐스트만, 상태 관리 없음)
  • INotificationService (이벤트 기반)
  • 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
  • Program.cs SignalR 등록

🏗️ 최종 아키텍처

Blazor Pages (UI 계층)
    ↓ (Browser Client 주입)
IXxxBrowserClient 추상화 (클라이언트 계층)
    ↓ (HTTP)
API Controllers (애플리케이션 계층)
    ↓ (서비스 호출)
Services (비즈니스 로직)
    ↓ (저장소 호출)
Repositories (데이터 계층)
    ↓ (SQL)
PostgreSQL Database

Blazor Server SignalR:

  • 자동 연결 (내장 Hub connection)
  • NotificationHub 클라이언트 그룹 (admins)
  • 이벤트 기반 메시지 (상태 관리 없음)
  • 클라이언트는 알림 후 API로 데이터 검증

완료 항목 체크리스트

인증 & 토큰 (Phase 5):

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

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

  • 모든 관리자 페이지 API 컨트롤러 (6개)
  • 모든 Browser Client (5개 + Dashboard)
  • 모든 Blazor 페이지 리팩토링 (9개)
  • SOLID 원칙 전체 적용

실시간 알림 (Phase 6):

  • NotificationHub 구현
  • Event-driven 알림 시스템
  • Scoped DI 등록

빌드 & 배포:

  • 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.4 블로그 & 문의 테스트 데이터

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

  • 테스트 관리자: admin / <TAXBAIK_ADMIN_TEST_PASSWORD>
  • 테스트 블로그 포스트 5개
  • 테스트 카테고리 5개

운영 보안 주의:

  • 시드 계정은 운영 초기화용이다. 배포 후에는 반드시 별도 강한 비밀번호로 교체한다.
  • 테스트 계정이 운영에 남아 있으면, 배포 후 즉시 비밀번호 재설정 또는 계정 비활성화를 수행한다.

수동 추가:

-- 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. 미실행 마이그레이션만 순서대로 실행

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() 호출

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();
    }
}

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="test123456"

# 방법 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"

테스트 계정 정보:

  • 사용자명: test_admin
  • 비밀번호: test123456 (개발/테스트 환경만, 프로덕션에서는 강력한 비밀번호로 변경)
  • 용도: E2E 자동 테스트 (실 admin 계정과 완전 분리)
  • 권한: admin과 동일 (마이그레이션 V003에서 자동 생성)

테스트 항목:

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

12. 문제 해결

문제 해결
앱 시작 안 됨 journalctl -u taxbaik -n 50 로그 확인
DB 연결 실패 환경 변수 ConnectionStrings__Default 확인 (systemd unit file)
404 /taxbaik Nginx 설정 재로드: sudo nginx -t && sudo systemctl reload nginx
Blazor WebSocket 안 됨 /taxbaik location에 proxy_http_version 1.1, Upgrade, Connection "Upgrade" 헤더가 모두 있는지 확인
배포 후 503 서비스 시작 대기 (startup 시간 ~5초), systemctl status taxbaik 확인
로그인 실패 admin_users.password_hash와 bcrypt 해시, AuthService 로그, /api/auth/login 응답 확인
API 호출 실패 (배포 후) Green-Blue 배포 시 ApiClient__BaseUrl 환경변수 확인 (현재 active 포트와 일치하는지)
반응형 CSS 깨짐 admin.css 로드 확인 (헬스 체크에 포함됨), 브라우저 DevTools에서 viewport 설정 확인


13. 시즌별 마케팅 (Seasonal Marketing)

13.1 핵심 방향

세무사 사무실은 1년 중 특정 시기에 특정 고객이 집중된다. 홈페이지는 이 시기마다 자동으로 전환되어야 한다.

목표: 방문자가 접속한 날짜에 맞는 세무 이벤트를 즉시 인지하고 상담 신청으로 전환

전환 방식:

  • Hero 섹션 헤드라인과 CTA가 시즌에 맞게 변경됨
  • 마감 D-7일 이내에는 긴박감 메시지 추가 표시
  • 시즌 관련 서비스 카드가 맨 앞으로 이동
  • 최종 CTA도 시즌 문구로 전환
  • 관리자가 별도 공지사항을 등록하면 모든 페이지 최상단에 배너로 노출

13.2 연간 세무 캘린더

기간 이벤트 Key 타깃 서비스
1/1 ~ 1/25 부가가치세 2기 확정신고 vat-2nd business-tax
1/15 ~ 2/28 연말정산 year-end-settlement business-tax
3/1 ~ 3/31 법인세 신고 corporate-tax business-tax
5/1 ~ 5/31 종합소득세 신고 (연중 최대 피크) income-tax business-tax
7/1 ~ 7/25 부가가치세 1기 확정신고 vat-1st business-tax
11/15 ~ 11/30 종합부동산세 납부 comprehensive-real-estate-tax real-estate-tax
12/1 ~ 12/31 연말 증여·절세 플래닝 year-end-gift family-asset

캘린더 정의 위치: TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs

시즌 추가/수정은 이 파일만 변경하면 된다. DB·마이그레이션 변경 없음.

13.3 공지사항 (Announcement)

어드민 /taxbaik/admin/announcements에서 관리.

  • 유형: 일반(info, 파란색) / 배너(banner, 주황색) / 긴급(urgent, 빨간색)
  • 게시 기간: 시작일종료일 설정 가능. 비우면 즉시무기한
  • 노출 위치: 홈페이지 최상단 (공지 배너 스트립)
  • 우선순위: sort_order 내림차순

공지사항은 시즌 Hero와 독립적으로 동작한다. 동시 표시 가능.

13.4 시즌 우선순위 / 광고 규칙 준수

  • 허용: "지금 신고 준비하세요", "마감 전 사전 검토", "D-N일 남았습니다"
  • 금지: "100% 절세 보장", "최저가 신고", "무료"

마지막 체크리스트:

  • 솔루션 빌드 성공 (dotnet build)
  • 모든 프로젝트 참조 정확
  • DB 마이그레이션 SQL 파일 생성
  • systemd 서비스 파일 서버에 설치
  • Nginx location 블록 설정
  • Gitea Secrets (DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY_B64) 추가
  • 초기 커밋 및 git push