# CLAUDE.md — TaxBaik 개발 지침 ## 1. 프로젝트 개요 **클라이언트**: 백원숙 세무사 (세무사·부동산중개사·보험설계사 자격) **목적**: 온라인 전문성 표현 + 블로그 SEO 유입 + 전국 고객 확보 **핵심 포지셔닝**: "사업자 세금 + 부동산 + 가족자산 = 맞춤형 세무 파트너" **기술 스택**: ASP.NET Core 8 / Dapper / PostgreSQL 18 / Nginx / Gitea CI --- ## 2. 아키텍처 ### 2.1 프로젝트 구조 ``` TaxBaik.Domain 클래스 라이브러리 (엔티티, 인터페이스, enum — 외부 의존성 없음) TaxBaik.Infrastructure 클래스 라이브러리 (Dapper repository, DB 마이그레이션) TaxBaik.Application 클래스 라이브러리 (서비스, DTO, 비즈니스 로직) TaxBaik.Web ASP.NET Core 앱 (Razor Pages SSR, port 5001) TaxBaik.Admin ASP.NET Core 앱 (Blazor Server, port 5002) ``` ### 2.2 계층 책임 - **Domain**: 비즈니스 규칙, 엔티티 정의 - **Infrastructure**: DB 접근, Dapper 구현체, 마이그레이션 실행 - **Application**: 서비스, DTO 매핑, 비즈니스 워크플로우 - **Web**: 공개 사이트 (SEO 최적화, Razor Pages SSR) - **Admin**: 관리자 백오피스 (실시간 UI, Blazor Server) ### 2.3 기술 결정 이유 **왜 Razor Pages (Web)인가?** - Razor Pages는 서버에서 HTML을 렌더링 → Google, Naver가 즉시 크롤 가능 - Blazor Server는 초기 응답이 shell HTML → SEO 불리 (블로그는 구글 검색이 핵심) **왜 Blazor Server (Admin)인가?** - 관리자는 SEO 불필요 → WebSocket으로 실시간 UI 업데이트 가능 - 기존 QuantEngine 패턴과 일치 **왜 Dapper인가?** - QuantEngine에서 이미 사용 중 (팀 지식) - 복잡한 조인, 페이징, 성능 제어 용이 - EF Core 대비 SQL 완전 제어 가능 **왜 두 개의 ASP.NET Core 앱인가?** - Web: 정적 콘텐츠 + Razor Pages - Admin: 동적 Blazor + WebSocket - 미들웨어 파이프라인 다름 → 독립 배포로 한쪽 문제가 다른 쪽에 영향 안 함 --- ## 3. 서버 & 배포 ### 3.1 SSH 접속 ```bash ssh kjh2064@178.104.200.7 ``` ### 3.2 포트 배치 ``` 80 : Nginx reverse proxy (공개) 3000 : Gitea Web (localhost만, proxy via /를 통해) 2222 : Gitea SSH (공개) 5000 : QuantEngine Blazor (localhost, proxy via /quant/) 5001 : TaxBaik.Web (localhost, proxy via /taxbaik) 5002 : TaxBaik.Admin (localhost, proxy via /taxbaik/admin) 5432 : PostgreSQL (localhost 바인드) ``` ### 3.3 배포 절차 1. 로컬에서 `dotnet publish -c Release` 2. CI/CD (Gitea Actions)가 자동으로 rsync로 서버에 업로드 3. 심링크 스왑: `ln -sfn ~/deployments/taxbaik_TIMESTAMP ~/taxbaik_active` 4. 서비스 재시작: `sudo systemctl restart taxbaik` 5. 롤백: 이전 TIMESTAMP로 심링크 재설정 ### 3.4 서비스 파일 위치 ``` /etc/systemd/system/taxbaik.service ← Web /etc/systemd/system/taxbaik-admin.service ← Admin ``` ### 3.5 배포 디렉토리 구조 (서버) ``` /home/kjh2064/ ├── taxbaik_active → ./deployments/taxbaik_20260626_150000/ ├── taxbaik_admin_active → ./deployments/taxbaik_admin_20260626_150000/ └── deployments/ ├── taxbaik_20260626_150000/ (Web publish 출력) ├── taxbaik_20260626_140000/ (이전 버전) ├── taxbaik_admin_20260626_150000/ (Admin publish 출력) └── ... ``` --- ## 4. Nginx 라우팅 기존 Gitea (`/`)와 QuantEngine (`/quant/`)을 유지하면서 TaxBaik 추가: ```nginx # /etc/nginx/sites-available/default (또는 현재 설정 파일)에 아래 블록 추가 location /taxbaik { proxy_pass http://127.0.0.1:5001; proxy_http_version 1.1; proxy_set_header Connection keep-alive; 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; } location /taxbaik/admin { proxy_pass http://127.0.0.1:5002; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "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; } ``` **더 구체적인 경로가 우선**: `/taxbaik` 블록이 `/` Gitea 블록보다 먼저 매칭됨. --- ## 5. 데이터베이스 ### 5.1 연결 설정 **환경 변수** (systemd unit file에 설정): ```ini Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=XXXXXXXX ``` **절대 appsettings.Production.json에 비밀값을 하드코딩하지 말 것.** ### 5.2 Dapper 사용 패턴 **DbConnectionFactory.cs**: ```csharp public sealed class DbConnectionFactory : IDbConnectionFactory { private readonly string _cs; public DbConnectionFactory(IConfiguration cfg) => _cs = cfg.GetConnectionString("Default") ?? throw new InvalidOperationException("Missing 'Default' connection string."); public IDbConnection CreateConnection() => new NpgsqlConnection(_cs); } ``` **Repository 메서드**: ```csharp public async Task GetBySlugAsync(string slug, CancellationToken ct) { using var conn = Conn(); // 항상 using return await conn.QueryFirstOrDefaultAsync( "SELECT * FROM blog_posts WHERE slug = @Slug AND is_published = TRUE", new { Slug = slug }); } ``` **규칙**: - 항상 `using var conn = Conn();` 사용 (자동 닫기) - 항상 `@ParameterName` 파라미터 사용 (SQL injection 방지) - 절대 문자열 연결 금지 - 대소문자 구분 안 함 (Dapper가 매핑) ### 5.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 파일 구조 ``` 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 wwwroot/css/site.css Admin/ Components/Pages/Blog/BlogList.razor Services/AdminAuthStateProvider.cs ``` ### 6.3 모든 UI는 한국어 - 버튼 레이블, 폼 레이블, 에러 메시지 → 한국어만 - 코드 주석, 예외 메시지 → 영어 가능 ### 6.4 오류 처리 - 서비스는 **타입화된 예외** 던지기 (ValidationException, ThrottleException) - PageModel/Component에서 catch → ModelState 또는 Toast - 절대 stack trace를 HTML에 노출 금지 - ILogger로 모든 예외 로깅 --- ## 7. Dapper 패턴 ### 7.1 단일 행 조회 ```csharp var post = await conn.QueryFirstOrDefaultAsync( "SELECT * FROM blog_posts WHERE id = @Id", new { Id = id }); ``` ### 7.2 여러 행 + 페이징 ```csharp var (rows, total) = await GetPublishedPagedAsync(page: 1, pageSize: 12); // 구현: public async Task<(IEnumerable, int)> GetPublishedPagedAsync(int page, int pageSize) { using var conn = Conn(); using var reader = await conn.QueryMultipleAsync( @"SELECT bp.* FROM blog_posts bp WHERE is_published = TRUE ORDER BY published_at DESC LIMIT @PageSize OFFSET @Offset; SELECT COUNT(*) FROM blog_posts WHERE is_published = TRUE;", new { PageSize = pageSize, Offset = (page - 1) * pageSize }); var rows = (await reader.ReadAsync()).ToList(); var total = await reader.ReadFirstAsync(); return (rows, total); } ``` ### 7.3 삽입 + 반환된 ID ```csharp var newId = await conn.QueryFirstAsync( @"INSERT INTO blog_posts (title, content, slug, is_published, created_at) VALUES (@Title, @Content, @Slug, FALSE, NOW()) RETURNING id", new { Title = title, Content = content, Slug = slug }); ``` ### 7.4 트랜잭션 ```csharp using var conn = Conn(); using var tx = conn.BeginTransaction(); try { // 여러 명령 await conn.ExecuteAsync("UPDATE ...", null, tx); await conn.ExecuteAsync("INSERT ...", null, tx); tx.Commit(); } catch { tx.Rollback(); throw; } ``` --- ## 8. Blazor Admin 패턴 ### 8.1 PathBase Admin은 `/taxbaik/admin/` 경로에서 실행: ```csharp // Program.cs app.UsePathBase("/taxbaik/admin"); ``` `@page` 지시문의 경로는 이 기본값에 상대적. 예: ```razor @page "/login" ← 실제 URL: /taxbaik/admin/login @page "/blog" ← 실제 URL: /taxbaik/admin/blog ``` ### 8.2 Cookie 인증 ```csharp // Program.cs services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(opts => opts.LoginPath = "/login"); app.UseAuthentication(); app.UseAuthorization(); ``` ### 8.3 모든 페이지에 [Authorize] 추가 ```razor @* _Imports.razor *@ @attribute [Authorize] ``` Login 페이지만 [AllowAnonymous]: ```razor @page "/login" @attribute [AllowAnonymous] ``` ### 8.4 컴포넌트 구조 ```razor @page "/blog" @inject IBlogService BlogService @attribute [Authorize] 블로그 관리 @if (posts != null) { @foreach (var post in posts) { } } @code { private List? posts; protected override async Task OnInitializedAsync() { posts = await BlogService.GetAllAsync(); } private async Task HandleDelete(int id) { await BlogService.DeleteAsync(id); posts = await BlogService.GetAllAsync(); StateHasChanged(); } } ``` ### 8.5 상태 관리 - 전역 상태 불필요 (세션 → DB에서 읽음) - 페이지 로드 시 `OnInitializedAsync`에서 데이터 가져오기 - 업데이트는 `StateHasChanged()` 호출 --- ## 9. Do's & Don'ts ### DO ✅ - [x] 모든 UI 문자열은 한국어 - [x] 항상 `@ParameterName` 파라미터 사용 (Dapper) - [x] Domain 엔티티를 비즈니스 경계로 사용 - [x] Repository 인터페이스를 의존성 주입 (Service) - [x] Razor Page: 비즈니스 로직은 PageModel 또는 Service에 - [x] Blazor: 비즈니스 로직은 Service에, Component는 뷰만 - [x] 블로그 포스트 작성 시 SEO 필드 필수 입력 (seo_title, seo_description) - [x] 광고 규칙 준수 (2026년 6월 광고 규칙): - 허용: "사전 검토", "리스크 점검", "상황별 절세 방향 안내" - 금지: "보장", "최저가", "무료", "100% 해결", "세무조사 안 받게" - [x] 카테고리 목록 캐시 (IMemoryCache, 10분 유효) - [x] 비밀값은 환경 변수에서 읽기 - [x] `[ValidateAntiForgeryToken]` POST 메서드에 추가 ### 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 --- ## 10. Razor Pages 패턴 ### 10.1 SEO 메타 태그 ```csharp // Index.cshtml.cs public void OnGet() { ViewData["Title"] = "백원숙 세무회계 | 사업자·부동산·가족자산 세금 상담"; ViewData["Description"] = "세무사 백원숙이 제공하는 사업자 기장, 부동산 양도세, 증여세 상담..."; ViewData["OgImage"] = "/images/hero.jpg"; } ``` ```html @ViewData["Title"] ``` ### 10.2 폼 제출 ```csharp // Contact.cshtml.cs [ValidateAntiForgeryToken] public async Task OnPostAsync() { if (!ModelState.IsValid) return Page(); try { await InquiryService.SubmitAsync(Input.Name, Input.Phone, Input.ServiceType, Input.Message); TempData["Success"] = "문의가 접수되었습니다. 빠른 시간 내에 연락드리겠습니다."; return RedirectToPage(); } catch (ValidationException ex) { ModelState.AddModelError("", ex.Message); return Page(); } } ``` --- ## 11. 배포 검증 ### 빌드 ```bash dotnet build TaxBaik.sln ``` ### 서버 상태 확인 (SSH) ```bash ssh kjh2064@178.104.200.7 # DB 확인 psql -U kjh2064 -d taxbaikdb -c "\dt" # 서비스 상태 systemctl status taxbaik taxbaik-admin # 엔드포인트 확인 curl http://127.0.0.1:5001/health curl http://127.0.0.1:5002/health # Nginx 라우팅 확인 curl http://127.0.0.1/taxbaik curl http://127.0.0.1/taxbaik/admin ``` ### E2E 테스트 ```bash # 문의 폼 제출 curl -X POST http://178.104.200.7/taxbaik/contact \ -d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트" # 관리자 DB에서 확인 ssh kjh2064@178.104.200.7 psql -U kjh2064 -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/admin` 경로에 Upgrade 헤더 필요 (Nginx 설정 확인) | | 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 | --- **마지막 체크리스트:** - [ ] 솔루션 빌드 성공 (`dotnet build`) - [ ] 모든 프로젝트 참조 정확 - [ ] DB 마이그레이션 SQL 파일 생성 - [ ] systemd 서비스 파일 서버에 설치 - [ ] Nginx location 블록 설정 - [ ] Gitea Secrets (DEPLOY_USER, DEPLOY_HOST, DEPLOY_SSH_KEY) 추가 - [ ] 초기 커밋 및 git push