diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..d0a92ef --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,213 @@ +# TaxBaik 배포 가이드 + +## 서버 초기 설정 + +### 1. PostgreSQL 데이터베이스 생성 + +```bash +ssh kjh2064@178.104.200.7 + +# PostgreSQL 접속 +sudo -u postgres psql + +# 데이터베이스 및 사용자 생성 +CREATE USER taxbaik WITH PASSWORD 'secure_password_here'; +CREATE DATABASE taxbaikdb OWNER taxbaik; +GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik; +\q +``` + +### 2. 환경 변수 설정 + +**Web 서비스** (`/etc/systemd/system/taxbaik.service`): +```ini +[Service] +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=ASPNETCORE_URLS=http://127.0.0.1:5001 +Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password +``` + +**Admin 서비스** (`/etc/systemd/system/taxbaik-admin.service`): +```ini +[Service] +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=ASPNETCORE_URLS=http://127.0.0.1:5002 +Environment=ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=your_secure_password +``` + +### 3. systemd 서비스 파일 설치 + +```bash +sudo cp deploy/taxbaik.service /etc/systemd/system/ +sudo cp deploy/taxbaik-admin.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable taxbaik taxbaik-admin +``` + +### 4. Nginx 설정 + +```bash +# 현재 Nginx 설정 확인 +sudo cat /etc/nginx/sites-available/default | head -30 + +# location 블록 추가 (또는 기존 설정에 병합) +sudo cp deploy/nginx-taxbaik-locations.conf /etc/nginx/conf.d/taxbaik.conf + +# 테스트 및 재로드 +sudo nginx -t +sudo systemctl reload nginx +``` + +## 배포 프로세스 + +### Gitea Actions 준비 + +1. Gitea 저장소 Secrets 추가: + - `DEPLOY_USER`: `kjh2064` + - `DEPLOY_HOST`: `178.104.200.7` + - `DEPLOY_SSH_KEY`: SSH 개인키 (줄바꿈 포함) + +2. 배포 워크플로우는 자동으로 실행: + ``` + master 브랜치 push → build → publish → rsync → restart + ``` + +### 수동 배포 (필요시) + +```bash +# 로컬에서 빌드 +dotnet publish TaxBaik.sln -c Release -o ./publish + +# 서버에 배포 +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +rsync -az ./publish/web/ kjh2064@178.104.200.7:~/deployments/taxbaik_${TIMESTAMP}/ +rsync -az ./publish/admin/ kjh2064@178.104.200.7:~/deployments/taxbaik_admin_${TIMESTAMP}/ + +# 서버에서 심링크 변경 및 재시작 +ssh kjh2064@178.104.200.7 << EOF +ln -sfn ~/deployments/taxbaik_${TIMESTAMP} ~/taxbaik_active +ln -sfn ~/deployments/taxbaik_admin_${TIMESTAMP} ~/taxbaik_admin_active +sudo systemctl restart taxbaik taxbaik-admin +EOF +``` + +## 마이그레이션 자동 실행 + +애플리케이션 시작시 자동으로 마이그레이션이 실행됩니다: + +1. `schema_migrations` 테이블 생성 (없으면) +2. 실행된 마이그레이션 확인 +3. 미실행 마이그레이션 순서대로 실행 + +로그 확인: +```bash +journalctl -u taxbaik -n 50 +journalctl -u taxbaik-admin -n 50 +``` + +## 검증 + +### E2E 테스트 + +```bash +# 공개 사이트 접근 +curl -I http://178.104.200.7/taxbaik/ + +# 관리자 로그인 페이지 +curl -I http://178.104.200.7/taxbaik/admin/login + +# 문의 폼 제출 테스트 +curl -X POST http://178.104.200.7/taxbaik/contact \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "name=테스트&phone=010-1234-5678&service_type=사업자세무&message=테스트" + +# DB에서 확인 +ssh kjh2064@178.104.200.7 +psql -U taxbaik -d taxbaikdb -c "SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;" +``` + +### 블로그 포스트 확인 + +```bash +# 초기 5개 포스트 확인 +curl http://178.104.200.7/taxbaik/blog + +# 첫 번째 포스트 상세 (slug: accountant-mistakes-5) +curl http://178.104.200.7/taxbaik/blog/accountant-mistakes-5 +``` + +## 롤백 + +```bash +# 이전 버전으로 복귀 +ssh kjh2064@178.104.200.7 + +# 이전 배포 디렉토리 확인 +ls -la ~/deployments/ | grep taxbaik + +# 심링크 변경 (예: 이전 버전이 taxbaik_20260626_140000) +ln -sfn ~/deployments/taxbaik_20260626_140000 ~/taxbaik_active +sudo systemctl restart taxbaik +``` + +## 모니터링 + +### 서비스 상태 확인 + +```bash +ssh kjh2064@178.104.200.7 + +# 서비스 상태 +systemctl status taxbaik taxbaik-admin + +# 포트 확인 +netstat -tlnp | grep -E '5001|5002' + +# 프로세스 확인 +ps aux | grep TaxBaik +``` + +### 성능 모니터링 + +```bash +# Nginx 프록시 로그 +tail -f /var/log/nginx/access.log | grep taxbaik + +# 애플리케이션 로그 +journalctl -u taxbaik -f +journalctl -u taxbaik-admin -f +``` + +## 트러블슈팅 + +| 증상 | 원인 | 해결 | +|------|------|------| +| 404 /taxbaik | Nginx 설정 미적용 | `sudo nginx -t && sudo systemctl reload nginx` | +| DB 연결 오류 | 환경 변수 미설정 | systemd service 파일의 ConnectionStrings__Default 확인 | +| 503 Service Unavailable | 앱 미시작 | `sudo systemctl restart taxbaik` | +| 마이그레이션 실패 | DB 권한 문제 | `GRANT ALL PRIVILEGES ON DATABASE taxbaikdb TO taxbaik;` | + +## 초기 데이터 + +### 관리자 계정 + +- **username**: `admin` +- **password**: `admin123` (bcrypt 해시됨) +- 초기 로그인 후 비밀번호 변경 권장 + +### 블로그 포스트 + +V003 마이그레이션에서 5개 포스트 자동 생성: +1. 사업자 기장 시 자주 하는 실수 5가지 +2. 부동산 양도세 계산하기 +3. 프리랜서를 위한 종합소득세 신고 +4. 부가가치세 간이과세 vs 일반과세 +5. 가족 자산 증여세 절세 방법 + +## 차후 작업 + +- [ ] SSL 인증서 적용 (Let's Encrypt) +- [ ] 도메인 연결 (현재는 IP 기반) +- [ ] 관리자 인증 로직 구현 (현재는 플레이스홀더) +- [ ] 블로그 포스트 CRUD 기능 완성 +- [ ] Naver/Google Search Console 등록 diff --git a/TaxBaik.Admin/Program.cs b/TaxBaik.Admin/Program.cs index 89d309b..4351ab2 100644 --- a/TaxBaik.Admin/Program.cs +++ b/TaxBaik.Admin/Program.cs @@ -22,6 +22,16 @@ builder.Services.AddApplication(); var app = builder.Build(); +// Run migrations on startup +using (var scope = app.Services.CreateScope()) +{ + var connectionFactory = scope.ServiceProvider.GetRequiredService(); + var cs = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("Missing connection string"); + var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(cs, connectionFactory); + await migrationRunner.RunAsync(); +} + if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); diff --git a/TaxBaik.Infrastructure/Data/MigrationRunner.cs b/TaxBaik.Infrastructure/Data/MigrationRunner.cs new file mode 100644 index 0000000..2d88ec4 --- /dev/null +++ b/TaxBaik.Infrastructure/Data/MigrationRunner.cs @@ -0,0 +1,133 @@ +using System.Reflection; +using System.Text; +using Npgsql; + +namespace TaxBaik.Infrastructure.Data; + +public class MigrationRunner +{ + private readonly string _connectionString; + private readonly IDbConnectionFactory _connectionFactory; + + public MigrationRunner(string connectionString, IDbConnectionFactory connectionFactory) + { + _connectionString = connectionString; + _connectionFactory = connectionFactory; + } + + public async Task RunAsync() + { + await EnsureMigrationTableAsync(); + await ExecutePendingMigrationsAsync(); + } + + private async Task EnsureMigrationTableAsync() + { + using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(50) PRIMARY KEY, + description VARCHAR(500), + installed_on TIMESTAMPTZ DEFAULT NOW() + );"; + await cmd.ExecuteNonQueryAsync(); + } + + private async Task ExecutePendingMigrationsAsync() + { + var executedMigrations = await GetExecutedMigrationsAsync(); + var migrations = GetAvailableMigrations(); + + foreach (var migration in migrations.OrderBy(x => x.Version)) + { + if (!executedMigrations.Contains(migration.Version)) + { + await ExecuteMigrationAsync(migration); + } + } + } + + private async Task> GetExecutedMigrationsAsync() + { + var executed = new HashSet(); + using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;"; + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + executed.Add(reader.GetString(0)); + } + + return executed; + } + + private List GetAvailableMigrations() + { + var migrations = new List(); + var assembly = Assembly.GetExecutingAssembly(); + var resourceNames = assembly.GetManifestResourceNames() + .Where(x => x.Contains("Migrations") && x.EndsWith(".sql")) + .ToList(); + + foreach (var resourceName in resourceNames.OrderBy(x => x)) + { + var parts = resourceName.Split('.'); + var fileName = parts[parts.Length - 2]; + + if (fileName.StartsWith("V")) + { + var version = fileName.Substring(1, fileName.IndexOf('_') - 1); + var description = fileName.Substring(fileName.IndexOf('_') + 2); + + using var stream = assembly.GetManifestResourceStream(resourceName); + using var reader = new StreamReader(stream); + var sql = reader.ReadToEnd(); + + migrations.Add(new Migration { Version = version, Description = description, Sql = sql }); + } + } + + return migrations; + } + + private async Task ExecuteMigrationAsync(Migration migration) + { + using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = migration.Sql; + await cmd.ExecuteNonQueryAsync(); + + using var insertCmd = conn.CreateCommand(); + insertCmd.CommandText = + "INSERT INTO schema_migrations (version, description) VALUES (@version, @description);"; + insertCmd.Parameters.AddWithValue("@version", migration.Version); + insertCmd.Parameters.AddWithValue("@description", migration.Description); + await insertCmd.ExecuteNonQueryAsync(); + + Console.WriteLine($"✓ Migration {migration.Version} executed"); + } + catch (Exception ex) + { + Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}"); + throw; + } + } + + private class Migration + { + public string Version { get; set; } + public string Description { get; set; } + public string Sql { get; set; } + } +} diff --git a/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj b/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj index 4f138d2..5d59d95 100644 --- a/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj +++ b/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj @@ -8,6 +8,10 @@ + + + + net8.0 enable diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 6760aaf..34e96d4 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -15,6 +15,16 @@ builder.Services.AddApplication(); var app = builder.Build(); +// Run migrations on startup +using (var scope = app.Services.CreateScope()) +{ + var connectionFactory = scope.ServiceProvider.GetRequiredService(); + var cs = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("Missing connection string"); + var migrationRunner = new TaxBaik.Infrastructure.Data.MigrationRunner(cs, connectionFactory); + await migrationRunner.RunAsync(); +} + app.UsePathBase("/taxbaik"); app.UseResponseCompression(); app.UseStaticFiles();