Add deployment infrastructure and development guide

- db/migrations/: V001 (schema) + V002 (seed data) SQL files
- deploy/: systemd service files (taxbaik.service, taxbaik-admin.service)
- deploy/: Nginx location block configuration
- .gitea/workflows/deploy.yml: CI/CD pipeline (build, test, deploy)
- CLAUDE.md: Comprehensive development guidelines (9 sections)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-26 14:59:51 +09:00
parent 6dff8e7777
commit 88409b8fea
7 changed files with 697 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
# 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<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 방지)
- 절대 문자열 연결 금지
- 대소문자 구분 안 함 (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<T>로 모든 예외 로깅
---
## 7. Dapper 패턴
### 7.1 단일 행 조회
```csharp
var post = await conn.QueryFirstOrDefaultAsync<BlogPost>(
"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<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
```csharp
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 트랜잭션
```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]
<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 ✅
- [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
<!-- _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 폼 제출
```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