Files
taxbaik/CLAUDE.md
T

21 KiB

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 앱 (포트 5001)
  ├─ Pages/             Razor Pages (공개 홈페이지, 블로그, 문의폼)
  ├─ Components/
  │  ├─ (Web pages)
  │  └─ Admin/          Blazor Server (관리자 백오피스)
  │     ├─ Pages/
  │     ├─ Layout/
  │     └─ App.razor
  └─ Services/          인증, 블로그, 문의 등

경로:

  • 홈페이지: /taxbaik (Razor Pages)
  • 관리자: /taxbaik/admin (Blazor)
  • 로그인: /taxbaik/admin/login

2.2 계층 책임

  • Domain: 비즈니스 규칙, 엔티티 정의
  • Infrastructure: DB 접근, Dapper 구현체, 마이그레이션 실행
  • Application: 서비스, DTO 매핑, 비즈니스 워크플로우
  • Web (Pages/): 공개 홈페이지 (SEO 최적화, Razor Pages SSR)
  • Web (Components/Admin): 관리자 백오피스 (실시간 UI, Blazor Server)
  • Web (Services/): 인증(JWT), 블로그, 문의 관리 등

2.3 기술 결정 이유

왜 Razor Pages (공개 사이트)인가?

  • 서버에서 HTML을 렌더링 → Google, Naver가 즉시 크롤 가능
  • Blazor는 초기 응답이 shell HTML → SEO 불리 (블로그는 검색 유입이 핵심)

왜 Blazor Server (관리자)인가?

  • 관리자는 SEO 불필요 → WebSocket으로 실시간 UI 업데이트 가능
  • 복잡한 관리 UI를 쉽게 구현

왜 단일 앱 (통합 Web)인가?

  • 소규모 프로젝트 → 분리의 이점 < 개발 복잡도
  • 개발: 터미널 1개, 포트 1개 (5001)
  • 배포: 앱 1개, DB 마이그레이션 1회
  • 유지보수: 모든 비즈니스 로직 한 곳 (Application)
  • 장점: 기존 분리 구조의 모든 기능 + 간단한 개발 경험

왜 Dapper인가?

  • 팀 기존 지식 (QuantEngine에서 사용)
  • 복잡한 조인, 페이징, 성능 제어 용이
  • EF Core 대비 SQL 완전 제어 가능

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)
cd TaxBaik.Web
dotnet run
# 접속:
#   - 홈페이지: http://localhost:5001/taxbaik
#   - 관리자: http://localhost:5001/taxbaik/admin/login
#   - 로그인: admin / admin123

장점:

  • 한 개의 포트 (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.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 / admin123
  • 테스트 블로그 포스트 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은 로컬 루프백)
  • 신뢰성 높음

방법 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 배포 절차 (Shadow Copy를 통한 Hot Deploy)

핵심 전략: .NET Core shadow copy로 배포 중 무중단 실행

  1. 로컬 빌드 (단일 앱 통합):

    dotnet clean TaxBaik.sln
    dotnet publish TaxBaik.Web -c Release -o ./publish
    
  2. CI/CD 배포 (Gitea Actions):

    • 새 버전을 ~/deployments/taxbaik_TIMESTAMP/ 에 업로드
    • 기존 프로세스는 계속 실행 (원본 DLL은 영향 없음)
  3. Shadow Copy 메커니즘:

    • .NET Core 런타임이 어셈블리를 메모리에 로드
    • ~/deployments/ 아래의 새 DLL들을 준비
    • 심링크만 변경 (ln -sfn ~/deployments/taxbaik_TIMESTAMP ~/taxbaik_active)
  4. Graceful Restart:

    • 기존 요청 완료 대기 (max 30초)
    • sudo systemctl restart taxbaik 실행
    • 새 프로세스가 새 DLL 로드
  5. 롤백 (1초 이내):

    ln -sfn ~/deployments/taxbaik_PREVIOUS_TIMESTAMP ~/taxbaik_active
    sudo systemctl restart taxbaik
    
  6. 오래된 배포 정리 (매 배포마다):

    # 최근 5개 배포만 유지
    ls -dt ~/deployments/taxbaik_* | tail -n +6 | xargs -r rm -rf
    

systemd 서비스 graceful shutdown 설정:

[Service]
TimeoutStopSec=35          # 기존 요청 완료 대기 (30초) + 여유
KillMode=mixed             # SIGTERM → 30초 대기 → SIGKILL

3.4 서비스 파일 위치

/etc/systemd/system/taxbaik.service  ← 통합 Web 앱 (공개 사이트 + 관리자)

5.5 배포 디렉토리 구조 (서버)

/home/kjh2064/
├── taxbaik_active → ./deployments/taxbaik_20260626_150000/
└── deployments/
    ├── taxbaik_20260626_150000/          (통합 Web publish 출력)
    ├── taxbaik_20260626_140000/          (이전 버전)
    └── ...

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_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 WebSocket)를 모두 처리합니다.


6. 데이터베이스

4.1 연결 설정

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

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

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

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 방지)
  • 절대 문자열 연결 금지
  • 대소문자 구분 안 함 (Dapper가 매핑)

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가 자동으로 복원:

// 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 메서드에 추가

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 메타 태그

// 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 kjh2064 -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 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