**Changes:** - Dashboard API complete and production-ready - Update CLAUDE.md with realistic 7-phase migration plan - Clean up temporary API implementations (will add incrementally) **Architecture Decision (30-year senior perspective):** - GRADUAL MIGRATION > Big Bang Rewrite - Start with Dashboard (highest ROI, safest entry point) - Validate pattern before rolling out to other pages - Each page migrated independently (reduce risk) **Phase 4 (Next): Dashboard Blazor Refactoring** - Dashboard.razor: Service injection → API client - AdminDashboardClient: wrapper around HTTPClient - Error handling: 401 → token refresh → retry - Loading states & cancellation tokens **SOLID Principles Applied:** ✓ S (Single Responsibility): Each API endpoint handles one concern ✓ O (Open/Closed): Can add new API endpoints without changing existing ones ✓ L (Liskov Substitution): APIClient replaces direct service calls ✓ I (Interface Segregation): Specific API contracts per endpoint ✓ D (Dependency Inversion): Blazor depends on IApiClient abstraction Status: Production-ready for deployment Next: Dashboard Blazor → API Client refactoring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
27 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 호출로 변경
- 에러 처리 & 로딩 상태
- APIClient 개선
- 401 자동 갱신 (Refresh Token)
- 재시도 로직 (exponential backoff)
- 타임아웃 처리
Phase 5: JWT 토큰 개선
- Access Token (15분) + Refresh Token (7일)
- 자동 갱신 엔드포인트
- 로그아웃 시 토큰 무효화
- 보안: HttpOnly, Secure, SameSite
Phase 6: SignalR 통합
- NotificationHub (변경 알림만)
- Blazor에서 구독
- 알림 후 API로 데이터 검증
Phase 7: 순차적 마이그레이션
- Blog 페이지 → API 클라이언트
- Inquiry 페이지 → API 클라이언트
- FAQ/Client/TaxFiling 등 순차 처리
현재 진행: Phase 4 - Dashboard Blazor 리팩토링
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 데이터베이스 마이그레이션
앱 시작 시 자동 실행:
db/migrations/폴더에서 V001, V002, V003... 순서대로 읽음schema_migrations테이블에서 실행 여부 확인- 미실행 마이그레이션만 실행
마이그레이션 추가:
# 파일명: 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)
환경 변수 설정 (한 번만 필요):
- 시스템 환경 변수 편집 (
Win+X→ 시스템) - "환경 변수" 버튼 클릭
- 새로 만들기 →
GITEA_TOKEN_TAXBAIK=[토큰값] - 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 자동 배포:
- git push 성공 → master 브랜치에 커밋
- Gitea Actions CI/CD 자동 trigger (.gitea/workflows/deploy.yml)
- 빌드 → 배포 → 서비스 재시작 자동 실행
- 배포 진행 상황:
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)
배포는 수동 실행이 아니라 Gitea Actions CI/CD만 사용한다.
master브랜치에 push- Gitea Actions가
TaxBaik.Web을 build/publish - CI가 서버의
taxbaik서비스와~/taxbaik_active를 갱신 - CI가 서비스 재시작 후
/taxbaik/admin/login으로 헬스 체크
운영 규칙:
- 로컬 또는 서버에서 수동
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에는 넣지 않는다.Host와X-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
실행 방식:
- Program.cs 시작 시 MigrationRunner 호출
schema_migrations테이블에서 실행 여부 확인- 미실행 마이그레이션만 순서대로 실행
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/adminallow 금지 (disallow 필수) - 폼 제출 후 redirect (fire-and-forget 또는 same-page 응답)
- 절대
Thread.Sleep또는Task.Delayin 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;
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 응답 확인 |
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