diff --git a/.gitignore b/.gitignore index 7c830f8..ec143ac 100644 --- a/.gitignore +++ b/.gitignore @@ -159,20 +159,6 @@ secrets.json *.temp ~$* -# Media files -*.wav -*.mp3 -*.mp4 -*.avi -*.wmv -*.flv -*.mkv -*.webm -*.m4a -*.m4v -*.m4b -*.m4r -*.m4p # Data files *.csv diff --git a/ProjectVG.Api/ApiServiceCollectionExtensions.cs b/ProjectVG.Api/ApiServiceCollectionExtensions.cs index f0cb974..95acfd2 100644 --- a/ProjectVG.Api/ApiServiceCollectionExtensions.cs +++ b/ProjectVG.Api/ApiServiceCollectionExtensions.cs @@ -75,5 +75,14 @@ public static IServiceCollection AddDevelopmentCors(this IServiceCollection serv return services; } + + /// + /// 부하테스트 전용 성능 모니터링 서비스 + /// + public static IServiceCollection AddLoadTestPerformanceServices(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } } } diff --git a/ProjectVG.Api/Configuration/LoadTestConfiguration.cs b/ProjectVG.Api/Configuration/LoadTestConfiguration.cs new file mode 100644 index 0000000..a284194 --- /dev/null +++ b/ProjectVG.Api/Configuration/LoadTestConfiguration.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Server.Kestrel.Core; + +namespace ProjectVG.Api.Configuration; + +/// +/// 부하테스트 환경을 위한 성능 최적화 설정을 제공합니다. +/// +public static class LoadTestConfiguration +{ + /// + /// 부하테스트 환경에서 Kestrel 서버의 성능을 최적화합니다. + /// + /// Kestrel 서버 옵션 + public static void ConfigureKestrelForLoadTest(KestrelServerOptions options) + { + // 연결 한도 설정 (부하테스트용 고성능) + options.Limits.MaxConcurrentConnections = 1000; + options.Limits.MaxConcurrentUpgradedConnections = 1000; + + // 요청 크기 및 타임아웃 최적화 + options.Limits.MaxRequestBodySize = 10_000_000; // 10MB + options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); + options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); + options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(10); + + // HTTP/2 최적화 + options.Limits.Http2.MaxStreamsPerConnection = 100; + options.Limits.Http2.HeaderTableSize = 4096; + options.Limits.Http2.MaxFrameSize = 16384; + + // 성능 로깅 + Console.WriteLine($"LoadTest Kestrel Configuration Applied:"); + Console.WriteLine($"- MaxConcurrentConnections: {options.Limits.MaxConcurrentConnections}"); + Console.WriteLine($"- MaxRequestBodySize: {options.Limits.MaxRequestBodySize} bytes"); + } + + /// + /// 부하테스트 환경에서 ThreadPool을 최적화합니다. + /// + public static void ConfigureThreadPoolForLoadTest() + { + // ThreadPool 최소 스레드 수 설정 (부하테스트용) + ThreadPool.SetMinThreads(100, 100); + + // ThreadPool 상태 로깅 + ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads); + ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads); + + Console.WriteLine($"ThreadPool Configuration:"); + Console.WriteLine($"- Min Worker Threads: {minWorkerThreads}"); + Console.WriteLine($"- Min Completion Port Threads: {minCompletionPortThreads}"); + Console.WriteLine($"- Max Worker Threads: {maxWorkerThreads}"); + Console.WriteLine($"- Max Completion Port Threads: {maxCompletionPortThreads}"); + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Controllers/MonitoringController.cs b/ProjectVG.Api/Controllers/MonitoringController.cs new file mode 100644 index 0000000..90b4eeb --- /dev/null +++ b/ProjectVG.Api/Controllers/MonitoringController.cs @@ -0,0 +1,348 @@ +using Microsoft.AspNetCore.Mvc; +using ProjectVG.Api.Services; +using System.Diagnostics; +using System.Runtime; + +namespace ProjectVG.Api.Controllers; + +[ApiController] +[Route("api/v1/monitoring")] +public class MonitoringController : ControllerBase +{ + private readonly PerformanceCounterService? _performanceService; + private readonly ILogger _logger; + private readonly IWebHostEnvironment _environment; + + public MonitoringController( + ILogger logger, + IWebHostEnvironment environment, + PerformanceCounterService? performanceService = null) + { + _logger = logger; + _environment = environment; + _performanceService = performanceService; + } + + /// + /// 실시간 성능 지표 조회 (LoadTest 환경 전용) + /// + [HttpGet("metrics")] + public ActionResult GetMetrics() + { + if (!_environment.IsEnvironment("LoadTest")) + { + return BadRequest(new { error = "Performance monitoring is only available in LoadTest environment" }); + } + + if (_performanceService == null) + { + return ServiceUnavailable(new { error = "PerformanceCounterService not available" }); + } + + try + { + var metrics = _performanceService.GetCurrentMetrics(); + return Ok(new + { + timestamp = metrics.Timestamp, + environment = _environment.EnvironmentName, + process = new + { + id = metrics.ProcessId, + workingSetMemoryMB = metrics.WorkingSetMemoryMB, + privateMemoryMB = metrics.PrivateMemoryMB, + virtualMemoryMB = metrics.VirtualMemoryMB, + cpuUsagePercent = metrics.CpuUsagePercent + }, + threadPool = new + { + workerThreads = metrics.ThreadPoolWorkerThreads, + completionPortThreads = metrics.ThreadPoolCompletionPortThreads, + pendingWorkItems = metrics.ThreadPoolPendingWorkItems, + availableThreads = metrics.AvailableThreads + }, + gc = new + { + gen0Collections = metrics.GCGen0Collections, + gen1Collections = metrics.GCGen1Collections, + gen2Collections = metrics.GCGen2Collections, + totalAllocatedBytesMB = metrics.TotalAllocatedBytes / 1024 / 1024, + totalMemoryMB = metrics.TotalMemoryMB + }, + system = new + { + cpuCount = metrics.SystemCpuCount, + machineName = metrics.MachineName + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get performance metrics"); + return StatusCode(500, new { error = "Failed to retrieve metrics", details = ex.Message }); + } + } + + /// + /// 빠른 성능 지표 조회 (캐시된 데이터) + /// + [HttpGet("metrics/quick")] + public ActionResult GetQuickMetrics() + { + if (!_environment.IsEnvironment("LoadTest")) + { + return BadRequest(new { error = "Performance monitoring is only available in LoadTest environment" }); + } + + if (_performanceService == null) + { + return ServiceUnavailable(new { error = "PerformanceCounterService not available" }); + } + + try + { + var quickMetrics = _performanceService.GetQuickMetrics(); + return Ok(quickMetrics); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get quick metrics"); + return StatusCode(500, new { error = "Failed to retrieve quick metrics" }); + } + } + + /// + /// 상세 헬스체크 (성능 지표 포함) + /// + [HttpGet("health-detailed")] + public ActionResult GetDetailedHealth() + { + if (!_environment.IsEnvironment("LoadTest")) + { + return BadRequest(new { error = "Detailed health monitoring is only available in LoadTest environment" }); + } + + var currentProcess = Process.GetCurrentProcess(); + + try + { + // GC 상태 확인 + var gcPressure = GC.GetTotalMemory(false) > 100 * 1024 * 1024; // 100MB 이상 + + // ThreadPool 상태 확인 + ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads); + var threadPoolPressure = workerThreads < 10 || completionPortThreads < 10; + + // 메모리 상태 확인 + var memoryPressure = currentProcess.WorkingSet64 > 500 * 1024 * 1024; // 500MB 이상 + + var status = "healthy"; + var warnings = new List(); + + if (gcPressure) + { + status = "degraded"; + warnings.Add("High GC memory pressure detected"); + } + + if (threadPoolPressure) + { + status = "degraded"; + warnings.Add("Low ThreadPool thread availability"); + } + + if (memoryPressure) + { + status = "degraded"; + warnings.Add("High memory usage detected"); + } + + return Ok(new + { + status, + timestamp = DateTime.UtcNow, + environment = _environment.EnvironmentName, + uptime = DateTime.UtcNow - Process.GetCurrentProcess().StartTime, + warnings = warnings, + details = new + { + processId = currentProcess.Id, + workingSetMB = currentProcess.WorkingSet64 / 1024 / 1024, + gcTotalMemoryMB = GC.GetTotalMemory(false) / 1024 / 1024, + availableWorkerThreads = workerThreads, + availableCompletionPortThreads = completionPortThreads, + threadPoolPendingWork = ThreadPool.PendingWorkItemCount + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get detailed health status"); + return StatusCode(500, new + { + status = "unhealthy", + error = "Health check failed", + details = ex.Message + }); + } + finally + { + currentProcess?.Dispose(); + } + } + + /// + /// ASP.NET Core 카운터 정보 조회 + /// + [HttpGet("counters")] + public ActionResult GetCounters() + { + if (!_environment.IsEnvironment("LoadTest")) + { + return BadRequest(new { error = "Counter monitoring is only available in LoadTest environment" }); + } + + if (_performanceService == null) + { + return ServiceUnavailable(new { error = "PerformanceCounterService not available" }); + } + + try + { + var dotnetCountersCommand = _performanceService.GetDotNetCountersCommand(); + + return Ok(new + { + timestamp = DateTime.UtcNow, + dotnetCountersCommand, + availableCounters = new[] + { + "Microsoft.AspNetCore.Hosting", + "Microsoft.AspNetCore.Http.Connections", + "System.Runtime", + "Microsoft.AspNetCore.Server.Kestrel" + }, + usage = new + { + command = "dotnet tool install --global dotnet-counters", + monitor = dotnetCountersCommand, + export = $"dotnet-counters collect --process-id {Process.GetCurrentProcess().Id} --output loadtest-metrics.json --format json" + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get counters info"); + return StatusCode(500, new { error = "Failed to get counters information" }); + } + } + + /// + /// GC 및 메모리 상세 정보 + /// + [HttpGet("gc")] + public ActionResult GetGCInfo() + { + if (!_environment.IsEnvironment("LoadTest")) + { + return BadRequest(new { error = "GC monitoring is only available in LoadTest environment" }); + } + + try + { + // GC 정보 수집 전 강제 GC 실행 (선택적) + var forceGC = Request.Query.ContainsKey("force"); + if (forceGC) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + var gcInfo = new + { + timestamp = DateTime.UtcNow, + forced = forceGC, + memory = new + { + totalMemoryMB = GC.GetTotalMemory(false) / 1024 / 1024, + totalAllocatedBytesMB = GC.GetTotalAllocatedBytes(false) / 1024 / 1024, + maxGeneration = GC.MaxGeneration + }, + collections = new + { + gen0 = GC.CollectionCount(0), + gen1 = GC.CollectionCount(1), + gen2 = GC.CollectionCount(2) + }, + settings = new + { + isServerGC = GCSettings.IsServerGC, + latencyMode = GCSettings.LatencyMode.ToString() + } + }; + + return Ok(gcInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get GC information"); + return StatusCode(500, new { error = "Failed to get GC information" }); + } + } + + /// + /// ThreadPool 상세 정보 + /// + [HttpGet("threadpool")] + public ActionResult GetThreadPoolInfo() + { + if (!_environment.IsEnvironment("LoadTest")) + { + return BadRequest(new { error = "ThreadPool monitoring is only available in LoadTest environment" }); + } + + try + { + ThreadPool.GetAvailableThreads(out int availableWorkerThreads, out int availableCompletionPortThreads); + ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads); + ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads); + + return Ok(new + { + timestamp = DateTime.UtcNow, + workerThreads = new + { + available = availableWorkerThreads, + inUse = maxWorkerThreads - availableWorkerThreads, + max = maxWorkerThreads, + min = minWorkerThreads + }, + completionPortThreads = new + { + available = availableCompletionPortThreads, + inUse = maxCompletionPortThreads - availableCompletionPortThreads, + max = maxCompletionPortThreads, + min = minCompletionPortThreads + }, + pendingWorkItems = ThreadPool.PendingWorkItemCount, + totalThreads = availableWorkerThreads + availableCompletionPortThreads, + utilization = new + { + workerThreadsPercent = Math.Round(((double)(maxWorkerThreads - availableWorkerThreads) / maxWorkerThreads) * 100, 2), + completionPortThreadsPercent = Math.Round(((double)(maxCompletionPortThreads - availableCompletionPortThreads) / maxCompletionPortThreads) * 100, 2) + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get ThreadPool information"); + return StatusCode(500, new { error = "Failed to get ThreadPool information" }); + } + } + + private ActionResult ServiceUnavailable(object value) + { + return StatusCode(503, value); + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Program.cs b/ProjectVG.Api/Program.cs index 2ba92a5..caf9c53 100644 --- a/ProjectVG.Api/Program.cs +++ b/ProjectVG.Api/Program.cs @@ -12,8 +12,20 @@ var port = builder.Configuration.GetValue("Port", 7900); builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(port); + + // 부하테스트 환경에서 성능 최적화 + if (builder.Environment.IsEnvironment("LoadTest")) + { + LoadTestConfiguration.ConfigureKestrelForLoadTest(options); + } }); +// ThreadPool 최적화 (부하테스트 환경) +if (builder.Environment.IsEnvironment("LoadTest")) +{ + LoadTestConfiguration.ConfigureThreadPoolForLoadTest(); +} + // 모듈별 서비스 등록 builder.Services.AddApiServices(); builder.Services.AddApiAuthentication(); @@ -29,6 +41,13 @@ builder.Services.AddApplicationServices(); builder.Services.AddDevelopmentCors(); +// 부하테스트 환경에서 성능 모니터링 서비스 추가 +if (builder.Environment.IsEnvironment("LoadTest")) +{ + builder.Services.AddLoadTestPerformanceServices(); + Console.WriteLine("LoadTest Performance Monitoring Services registered"); +} + var app = builder.Build(); // 데이터베이스 마이그레이션 자동 적용 diff --git a/ProjectVG.Api/Services/PerformanceCounterService.cs b/ProjectVG.Api/Services/PerformanceCounterService.cs new file mode 100644 index 0000000..0498d94 --- /dev/null +++ b/ProjectVG.Api/Services/PerformanceCounterService.cs @@ -0,0 +1,172 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Tracing; + +namespace ProjectVG.Api.Services; + +public class PerformanceCounterService : IDisposable +{ + private readonly ILogger _logger; + private readonly Timer _metricsTimer; + private readonly ConcurrentDictionary _lastMetrics; + private readonly Process _currentProcess; + private bool _disposed = false; + + public PerformanceCounterService(ILogger logger) + { + _logger = logger; + _lastMetrics = new ConcurrentDictionary(); + _currentProcess = Process.GetCurrentProcess(); + + // 5초마다 메트릭 수집 + _metricsTimer = new Timer(CollectMetrics, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); + + _logger.LogInformation("PerformanceCounterService initialized for LoadTest environment"); + } + + public PerformanceMetrics GetCurrentMetrics() + { + var metrics = new PerformanceMetrics + { + Timestamp = DateTime.UtcNow, + + // Process 메트릭 + ProcessId = _currentProcess.Id, + WorkingSetMemoryMB = _currentProcess.WorkingSet64 / 1024 / 1024, + PrivateMemoryMB = _currentProcess.PrivateMemorySize64 / 1024 / 1024, + VirtualMemoryMB = _currentProcess.VirtualMemorySize64 / 1024 / 1024, + CpuUsagePercent = GetCpuUsage(), + + // ThreadPool 메트릭 + ThreadPoolWorkerThreads = GetWorkerThreadCount(), + ThreadPoolCompletionPortThreads = GetCompletionPortThreadCount(), + ThreadPoolPendingWorkItems = ThreadPool.PendingWorkItemCount, + + // GC 메트릭 + GCGen0Collections = GC.CollectionCount(0), + GCGen1Collections = GC.CollectionCount(1), + GCGen2Collections = GC.CollectionCount(2), + TotalAllocatedBytes = GC.GetTotalAllocatedBytes(false), + TotalMemoryMB = GC.GetTotalMemory(false) / 1024 / 1024, + + // 시스템 메트릭 + AvailableThreads = GetAvailableThreads(), + SystemCpuCount = Environment.ProcessorCount, + MachineName = Environment.MachineName + }; + + return metrics; + } + + private void CollectMetrics(object? state) + { + try + { + var metrics = GetCurrentMetrics(); + + // 중요 메트릭만 로깅 (5초마다) + if (metrics.ThreadPoolPendingWorkItems > 0 || metrics.CpuUsagePercent > 50) + { + _logger.LogWarning("High load detected - CPU: {CpuUsage}%, Pending Work: {PendingWork}, Memory: {Memory}MB", + metrics.CpuUsagePercent, + metrics.ThreadPoolPendingWorkItems, + metrics.WorkingSetMemoryMB); + } + + // 최신 메트릭 캐시 + _lastMetrics.AddOrUpdate("LastUpdate", metrics.Timestamp, (k, v) => metrics.Timestamp); + _lastMetrics.AddOrUpdate("WorkingMemory", metrics.WorkingSetMemoryMB, (k, v) => metrics.WorkingSetMemoryMB); + _lastMetrics.AddOrUpdate("CpuUsage", metrics.CpuUsagePercent, (k, v) => metrics.CpuUsagePercent); + _lastMetrics.AddOrUpdate("ThreadPoolPending", metrics.ThreadPoolPendingWorkItems, (k, v) => metrics.ThreadPoolPendingWorkItems); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error collecting performance metrics"); + } + } + + private double GetCpuUsage() + { + try + { + return _currentProcess.TotalProcessorTime.TotalMilliseconds; + } + catch + { + return 0; + } + } + + private int GetWorkerThreadCount() + { + ThreadPool.GetAvailableThreads(out int workerThreads, out _); + ThreadPool.GetMaxThreads(out int maxWorkerThreads, out _); + return maxWorkerThreads - workerThreads; + } + + private int GetCompletionPortThreadCount() + { + ThreadPool.GetAvailableThreads(out _, out int completionPortThreads); + ThreadPool.GetMaxThreads(out _, out int maxCompletionPortThreads); + return maxCompletionPortThreads - completionPortThreads; + } + + private int GetAvailableThreads() + { + ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads); + return workerThreads + completionPortThreads; + } + + public Dictionary GetQuickMetrics() + { + return new Dictionary(_lastMetrics); + } + + public string GetDotNetCountersCommand() + { + return $"dotnet-counters monitor --process-id {_currentProcess.Id} " + + "Microsoft.AspNetCore.Hosting " + + "System.Runtime " + + "Microsoft.AspNetCore.Http.Connections"; + } + + public void Dispose() + { + if (_disposed) return; + + _metricsTimer?.Dispose(); + _currentProcess?.Dispose(); + _disposed = true; + + _logger.LogInformation("PerformanceCounterService disposed"); + } +} + +public class PerformanceMetrics +{ + public DateTime Timestamp { get; set; } + + // Process Metrics + public int ProcessId { get; set; } + public long WorkingSetMemoryMB { get; set; } + public long PrivateMemoryMB { get; set; } + public long VirtualMemoryMB { get; set; } + public double CpuUsagePercent { get; set; } + + // ThreadPool Metrics + public int ThreadPoolWorkerThreads { get; set; } + public int ThreadPoolCompletionPortThreads { get; set; } + public long ThreadPoolPendingWorkItems { get; set; } + + // GC Metrics + public int GCGen0Collections { get; set; } + public int GCGen1Collections { get; set; } + public int GCGen2Collections { get; set; } + public long TotalAllocatedBytes { get; set; } + public long TotalMemoryMB { get; set; } + + // System Metrics + public int AvailableThreads { get; set; } + public int SystemCpuCount { get; set; } + public string MachineName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/ProjectVG.Api/appsettings.Development.json b/ProjectVG.Api/appsettings.Development.json index 8db0525..0d632c7 100644 --- a/ProjectVG.Api/appsettings.Development.json +++ b/ProjectVG.Api/appsettings.Development.json @@ -11,10 +11,10 @@ "DefaultConnection": "Server=localhost,1433;Database=ProjectVG;User Id=sa;Password=ProjectVG123!;TrustServerCertificate=true;MultipleActiveResultSets=true" }, "LLM": { - "BaseUrl": "http://localhost:5601" + "BaseUrl": "http://localhost:7808" }, "MEMORY": { - "BaseUrl": "http://localhost:5602" + "BaseUrl": "http://localhost:7812" }, "TTSApiKey": "your-tts-api-key-here", "JWT_SECRET_KEY": "your-super-secret-jwt-key-here-minimum-32-characters", diff --git a/ProjectVG.Api/appsettings.loadtest.json b/ProjectVG.Api/appsettings.loadtest.json new file mode 100644 index 0000000..627cb26 --- /dev/null +++ b/ProjectVG.Api/appsettings.loadtest.json @@ -0,0 +1,57 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning", + "ProjectVG": "Warning" + } + }, + "AllowedHosts": "*", + "Port": 7900, + "JWT": { + "Issuer": "ProjectVG", + "Audience": "ProjectVG" + }, + "LoadTestOptimizations": { + "EnableDetailedLogging": false, + "EnableRequestResponseLogging": false, + "EnablePerformanceCounters": true, + "DisableSwagger": true, + "ConnectionPooling": { + "MaxPoolSize": 100, + "ConnectionTimeout": 30 + } + }, + "KestrelPerformance": { + "MaxConcurrentConnections": 1000, + "MaxConcurrentUpgradedConnections": 1000, + "MaxRequestBodySize": 10000000, + "RequestHeadersTimeoutSeconds": 10, + "KeepAliveTimeoutMinutes": 2, + "Http2MaxStreamsPerConnection": 100 + }, + "ThreadPoolSettings": { + "MinWorkerThreads": 100, + "MinCompletionPortThreads": 100, + "EnableOptimization": true + }, + "ExternalServices": { + "LLM": { + "BaseUrl": "http://localhost:7808", + "Timeout": 30000, + "RetryCount": 1 + }, + "Memory": { + "BaseUrl": "http://localhost:7812", + "Timeout": 5000, + "RetryCount": 2 + }, + "TTS": { + "BaseUrl": "http://localhost:7816", + "Timeout": 15000, + "RetryCount": 1 + } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/wwwroot/.gitkeep b/ProjectVG.Api/wwwroot/.gitkeep deleted file mode 100644 index 2486bae..0000000 --- a/ProjectVG.Api/wwwroot/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# This file keeps the wwwroot directory in Git -# The wwwroot directory is required by ASP.NET Core but we don't use static files diff --git a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 0166c36..98c9fcd 100644 --- a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -65,8 +65,10 @@ private static void AddDatabaseServices(IServiceCollection services, IConfigurat /// private static void AddExternalApiClients(IServiceCollection services, IConfiguration configuration) { - var llmBaseUrl = configuration.GetValue("LLM:BaseUrl") ?? Environment.GetEnvironmentVariable("LLM_BASE_URL") ?? "http://localhost:5601"; - var memoryBaseUrl = configuration.GetValue("MEMORY:BaseUrl") ?? Environment.GetEnvironmentVariable("MEMORY_BASE_URL") ?? "http://localhost:5602"; + var llmBaseUrl = configuration.GetValue("LLM:BaseUrl") ?? Environment.GetEnvironmentVariable("LLM_BASE_URL") + ?? throw new InvalidOperationException("LLM_BASE_URL environment variable or LLM:BaseUrl configuration is required"); + var memoryBaseUrl = configuration.GetValue("MEMORY:BaseUrl") ?? Environment.GetEnvironmentVariable("MEMORY_BASE_URL") + ?? throw new InvalidOperationException("MEMORY_BASE_URL environment variable or MEMORY:BaseUrl configuration is required"); services.AddHttpClient(client => { client.BaseAddress = new Uri(llmBaseUrl); @@ -76,8 +78,11 @@ private static void AddExternalApiClients(IServiceCollection services, IConfigur client.BaseAddress = new Uri(memoryBaseUrl); }); + var ttsBaseUrl = configuration.GetValue("TTS:BaseUrl") ?? Environment.GetEnvironmentVariable("TTS_BASE_URL") + ?? throw new InvalidOperationException("TTS_BASE_URL environment variable or TTS:BaseUrl configuration is required"); + services.AddHttpClient((sp, client) => { - client.BaseAddress = new Uri("https://supertoneapi.com"); + client.BaseAddress = new Uri(ttsBaseUrl); var apiKey = configuration.GetValue("TTSApiKey") ?? Environment.GetEnvironmentVariable("TTS_API_KEY"); diff --git a/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs b/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs index 65660db..6489218 100644 --- a/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs +++ b/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs @@ -22,7 +22,6 @@ public LLMClient(HttpClient httpClient, ILogger logger, IConfiguratio WriteIndented = false }; - _httpClient.BaseAddress = new Uri(configuration["LLM:BaseUrl"] ?? ""); _httpClient.Timeout = TimeSpan.FromSeconds(30); _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); } diff --git a/env.example b/env.example index 3e55d91..bb6cf69 100644 --- a/env.example +++ b/env.example @@ -2,8 +2,9 @@ # 이 파일을 .env로 복사하여 사용하세요 # 외부 서비스 연결 -LLM_BASE_URL=http://localhost:5601 -MEMORY_BASE_URL=http://localhost:5602 +LLM_BASE_URL=http://localhost:7808 +MEMORY_BASE_URL=http://localhost:7812 +TTS_BASE_URL=http://localhost:7816 TTS_API_KEY=your-tts-api-key-here # 데이터베이스 연결 diff --git a/env.loadtest b/env.loadtest new file mode 100644 index 0000000..bf933cf --- /dev/null +++ b/env.loadtest @@ -0,0 +1,34 @@ +# ProjectVG API 부하 테스트 환경변수 +# 부하 테스트용 설정으로 로그 레벨을 WARN으로 올리고 더미 서버를 사용 + +# 외부 서비스 연결 (더미 서버 사용) +LLM_BASE_URL=http://localhost:7808 +MEMORY_BASE_URL=http://localhost:7812 +TTS_BASE_URL=http://localhost:7816 +TTS_API_KEY=dummy-tts-api-key-for-loadtest + +# 데이터베이스 연결 (부하테스트 전용 - 운영 DB와 완전 분리) +DB_CONNECTION_STRING=Server=localhost,1434;Database=ProjectVG_LoadTest;User Id=sa;Password=LoadTest123!;TrustServerCertificate=true;MultipleActiveResultSets=true + +# Redis 연결 (부하테스트 전용 - 운영 Redis와 완전 분리) +REDIS_CONNECTION_STRING=localhost:6381 + +# JWT 설정 +JWT_SECRET_KEY=your-super-secret-jwt-key-here-minimum-32-characters +JWT_ACCESS_TOKEN_LIFETIME_MINUTES=15 +JWT_REFRESH_TOKEN_LIFETIME_DAYS=30 + +# OAuth2 설정 (부하 테스트에서는 비활성화) +OAUTH2_ENABLED=false + +# Google OAuth2 설정 (부하 테스트용 더미 설정) +GOOGLE_OAUTH_ENABLED=false +GOOGLE_OAUTH_CLIENT_ID=dummy-client-id +GOOGLE_OAUTH_CLIENT_SECRET=dummy-client-secret +GOOGLE_OAUTH_REDIRECT_URI=http://localhost:7804/auth/oauth2/callback +GOOGLE_OAUTH_AUTO_CREATE_USER=true +GOOGLE_OAUTH_DEFAULT_ROLE=User + +# 부하 테스트 전용 설정 +ASPNETCORE_ENVIRONMENT=LoadTest +LOGGING_LEVEL=Warning \ No newline at end of file diff --git a/scripts/dev-setup.ps1 b/scripts/dev-setup.ps1 index a99d988..aaf741d 100644 --- a/scripts/dev-setup.ps1 +++ b/scripts/dev-setup.ps1 @@ -11,8 +11,45 @@ if ($LASTEXITCODE -ne 0) { } # 2. DB 초기화 대기 -Write-Host "2. Waiting for DB initialization (30 seconds)..." -ForegroundColor Yellow -Start-Sleep -Seconds 30 +Write-Host "2. Waiting for DB and Redis to be ready..." -ForegroundColor Yellow + +function Wait-ForUrl { + param([string]$url, [int]$timeoutSec=120) + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $timeoutSec) { + try { + $resp = Invoke-WebRequest -Uri $url -TimeoutSec 3 -UseBasicParsing + if ($resp.StatusCode -eq 200) { return $true } + } catch { Start-Sleep -Milliseconds 500 } + } + return $false +} + +# Redis 연결 확인 (API를 통해) +Write-Host "Checking Redis connectivity..." -ForegroundColor Cyan +$redisReady = $false +$maxWait = 60 +$elapsed = 0 +while (-not $redisReady -and $elapsed -lt $maxWait) { + try { + # Redis가 준비되었는지 간접적으로 확인 (포트 확인) + $redisConnection = Test-NetConnection -ComputerName localhost -Port 6380 -WarningAction SilentlyContinue + if ($redisConnection.TcpTestSucceeded) { + Write-Host "Redis is ready!" -ForegroundColor Green + $redisReady = $true + } else { + Start-Sleep -Seconds 2 + $elapsed += 2 + } + } catch { + Start-Sleep -Seconds 2 + $elapsed += 2 + } +} + +if (-not $redisReady) { + Write-Host "Warning: Redis readiness check failed, continuing anyway..." -ForegroundColor Yellow +} # 3. API 빌드 및 시작 Write-Host "3. Building and starting API..." -ForegroundColor Yellow diff --git a/scripts/docker-monitor.ps1 b/scripts/docker-monitor.ps1 new file mode 100644 index 0000000..c115b7c --- /dev/null +++ b/scripts/docker-monitor.ps1 @@ -0,0 +1,207 @@ +# Docker Container Performance Monitoring Script +# 컨테이너에서 실행 중인 API의 성능 모니터링 + +param( + [string]$ContainerName = "projectvg-loadtest-projectvg-loadtest-api-1", + [string]$MonitoringType = "counters", # counters, trace, dump, gcdump + [int]$Duration = 60, # 모니터링 지속 시간 (초) + [string]$OutputDir = ".\loadtest-results" +) + +# 색상 출력 함수 +function Write-ColorOutput { + param([string]$Message, [string]$Color = "White") + + switch ($Color) { + "Red" { Write-Host $Message -ForegroundColor Red } + "Green" { Write-Host $Message -ForegroundColor Green } + "Yellow" { Write-Host $Message -ForegroundColor Yellow } + "Cyan" { Write-Host $Message -ForegroundColor Cyan } + default { Write-Host $Message } + } +} + +Write-ColorOutput "=== Docker Container Performance Monitor ===" "Cyan" +Write-ColorOutput "Container: $ContainerName" "White" +Write-ColorOutput "Monitoring Type: $MonitoringType" "White" +Write-ColorOutput "Duration: $Duration seconds" "White" +Write-ColorOutput "Output Directory: $OutputDir" "White" + +# 출력 디렉토리 생성 +if (!(Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + Write-ColorOutput "Created output directory: $OutputDir" "Green" +} + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + +# 컨테이너 상태 확인 +Write-ColorOutput "`nChecking container status..." "Yellow" +try { + $containerStatus = docker ps -f name=$ContainerName --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" + if ($containerStatus -like "*$ContainerName*") { + Write-ColorOutput "Container is running" "Green" + Write-Host $containerStatus + } else { + Write-ColorOutput "Container not found or not running" "Red" + Write-ColorOutput "Available containers:" "Yellow" + docker ps --format "table {{.Names}}\t{{.Status}}" + exit 1 + } +} catch { + Write-ColorOutput "Failed to check container status: $($_.Exception.Message)" "Red" + exit 1 +} + +# API 프로세스 ID 가져오기 +Write-ColorOutput "`nGetting API process information..." "Yellow" +try { + $processInfo = docker exec $ContainerName ps aux | Select-String "dotnet.*ProjectVG.Api.dll" + if ($processInfo) { + $processParts = $processInfo -split '\s+' + $processId = $processParts[1] + Write-ColorOutput "Found API process ID: $processId" "Green" + } else { + Write-ColorOutput "Could not find API process" "Red" + Write-ColorOutput "Available processes in container:" "Yellow" + docker exec $ContainerName ps aux + exit 1 + } +} catch { + Write-ColorOutput "Failed to get process information: $($_.Exception.Message)" "Red" + exit 1 +} + +# 모니터링 실행 +Write-ColorOutput "`nStarting $MonitoringType monitoring..." "Yellow" + +switch ($MonitoringType.ToLower()) { + "counters" { + $outputFile = Join-Path $OutputDir "docker-counters-$timestamp.txt" + Write-ColorOutput "Output file: $outputFile" "White" + + # dotnet-counters를 컨테이너에서 실행하고 출력을 로컬에 저장 + $countersCommand = "/root/.dotnet/tools/dotnet-counters monitor $processId --refresh-interval 1 --format table --counters Microsoft.AspNetCore.Hosting,System.Runtime,Microsoft.AspNetCore.Http.Connections" + + Write-ColorOutput "Running: $countersCommand" "Gray" + Write-ColorOutput "Press Ctrl+C to stop monitoring" "Yellow" + + try { + # PowerShell에서 직접 docker exec 실행 + Write-ColorOutput "Starting monitoring for $Duration seconds..." "Yellow" + + $job = Start-Job -ScriptBlock { + param($containerName, $command) + docker exec $containerName bash -c $command + } -ArgumentList $ContainerName, $countersCommand + + # 지정된 시간 후 작업 종료 + Start-Sleep -Seconds $Duration + Stop-Job $job + $result = Receive-Job $job + Remove-Job $job + + # 결과를 파일에 저장 + $result | Out-File -FilePath $outputFile -Encoding UTF8 + Write-ColorOutput "Monitoring completed. Output saved to: $outputFile" "Green" + + } catch { + Write-ColorOutput "Monitoring failed: $($_.Exception.Message)" "Red" + } + } + + "trace" { + $outputFile = Join-Path $OutputDir "docker-trace-$timestamp.nettrace" + Write-ColorOutput "Output file: $outputFile" "White" + + # dotnet-trace로 트레이스 수집 + $traceCommand = "dotnet-trace collect --process-id $processId --duration 00:00:$($Duration.ToString('D2')) --format NetTrace --output /app/logs/trace-$timestamp.nettrace" + + Write-ColorOutput "Running: $traceCommand" "Gray" + + try { + docker exec $ContainerName bash -c $traceCommand + # 컨테이너에서 로컬로 파일 복사 + docker cp "${ContainerName}:/app/logs/trace-$timestamp.nettrace" $outputFile + Write-ColorOutput "Trace collection completed: $outputFile" "Green" + } catch { + Write-ColorOutput "Trace collection failed: $($_.Exception.Message)" "Red" + } + } + + "dump" { + $outputFile = Join-Path $OutputDir "docker-dump-$timestamp.dmp" + Write-ColorOutput "Output file: $outputFile" "White" + + # dotnet-dump로 메모리 덤프 생성 + $dumpCommand = "dotnet-dump collect --process-id $processId --output /app/logs/dump-$timestamp.dmp" + + Write-ColorOutput "Running: $dumpCommand" "Gray" + + try { + docker exec $ContainerName bash -c $dumpCommand + # 컨테이너에서 로컬로 파일 복사 + docker cp "${ContainerName}:/app/logs/dump-$timestamp.dmp" $outputFile + Write-ColorOutput "Memory dump completed: $outputFile" "Green" + } catch { + Write-ColorOutput "Memory dump failed: $($_.Exception.Message)" "Red" + } + } + + "gcdump" { + $outputFile = Join-Path $OutputDir "docker-gcdump-$timestamp.gcdump" + Write-ColorOutput "Output file: $outputFile" "White" + + # dotnet-gcdump로 GC 덤프 생성 + $gcdumpCommand = "dotnet-gcdump collect --process-id $processId --output /app/logs/gcdump-$timestamp.gcdump" + + Write-ColorOutput "Running: $gcdumpCommand" "Gray" + + try { + docker exec $ContainerName bash -c $gcdumpCommand + # 컨테이너에서 로컬로 파일 복사 + docker cp "${ContainerName}:/app/logs/gcdump-$timestamp.gcdump" $outputFile + Write-ColorOutput "GC dump completed: $outputFile" "Green" + } catch { + Write-ColorOutput "GC dump failed: $($_.Exception.Message)" "Red" + } + } + + default { + Write-ColorOutput "Unknown monitoring type: $MonitoringType" "Red" + Write-ColorOutput "Available types: counters, trace, dump, gcdump" "Yellow" + exit 1 + } +} + +# 컨테이너 리소스 사용량 표시 +Write-ColorOutput "`nContainer resource usage:" "White" +try { + $containerStats = docker stats $ContainerName --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}" + Write-Host $containerStats +} catch { + Write-ColorOutput "Could not get container stats" "Yellow" +} + +# API 성능 지표도 수집 (가능한 경우) +Write-ColorOutput "`nTrying to get API performance metrics..." "White" +try { + $apiMetrics = Invoke-RestMethod -Uri "http://localhost:7804/api/v1/monitoring/metrics" -TimeoutSec 5 + + Write-ColorOutput "API Performance Summary:" "Green" + Write-ColorOutput " Memory Usage: $($apiMetrics.process.workingSetMemoryMB) MB" "White" + Write-ColorOutput " Available Threads: $($apiMetrics.threadPool.availableThreads)" "White" + Write-ColorOutput " Pending Work: $($apiMetrics.threadPool.pendingWorkItems)" "White" + Write-ColorOutput " GC Collections: Gen0=$($apiMetrics.gc.gen0Collections) Gen1=$($apiMetrics.gc.gen1Collections) Gen2=$($apiMetrics.gc.gen2Collections)" "White" +} catch { + Write-ColorOutput "Could not retrieve API metrics (API might not be in LoadTest mode)" "Yellow" +} + +Write-ColorOutput "`nDocker performance monitoring completed!" "Green" + +# 사용법 출력 +Write-ColorOutput "`nUsage Examples:" "Cyan" +Write-ColorOutput " Monitor counters: .\scripts\docker-monitor.ps1 -MonitoringType counters -Duration 60" "Gray" +Write-ColorOutput " Collect trace: .\scripts\docker-monitor.ps1 -MonitoringType trace -Duration 30" "Gray" +Write-ColorOutput " Memory dump: .\scripts\docker-monitor.ps1 -MonitoringType dump" "Gray" +Write-ColorOutput " GC dump: .\scripts\docker-monitor.ps1 -MonitoringType gcdump" "Gray" \ No newline at end of file diff --git a/scripts/loadtest-with-monitoring.ps1 b/scripts/loadtest-with-monitoring.ps1 new file mode 100644 index 0000000..06859e5 --- /dev/null +++ b/scripts/loadtest-with-monitoring.ps1 @@ -0,0 +1,213 @@ +# ProjectVG Load Test with Performance Monitoring +# 부하테스트와 성능 모니터링을 동시에 실행 + +param( + [string]$LoadTestScript = ".\test-clients\ai-chat-client\script.js", + [int]$Clients = 10, + [int]$Duration = 300, # 5분 기본값 + [string]$ApiUrl = "http://localhost:7900" +) + +$ErrorActionPreference = "Stop" + +# 색상 출력 함수 +function Write-ColorOutput { + param([string]$Message, [string]$Color = "White") + switch ($Color) { + "Red" { Write-Host $Message -ForegroundColor Red } + "Green" { Write-Host $Message -ForegroundColor Green } + "Yellow" { Write-Host $Message -ForegroundColor Yellow } + "Cyan" { Write-Host $Message -ForegroundColor Cyan } + "Magenta" { Write-Host $Message -ForegroundColor Magenta } + default { Write-Host $Message } + } +} + +# 타임스탬프 생성 +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultsDir = ".\loadtest-results\run-$timestamp" + +Write-ColorOutput "=== ProjectVG Load Test with Performance Monitoring ===" "Cyan" +Write-ColorOutput "Load Test Script: $LoadTestScript" "White" +Write-ColorOutput "Clients: $Clients" "White" +Write-ColorOutput "Duration: $Duration seconds" "White" +Write-ColorOutput "API URL: $ApiUrl" "White" +Write-ColorOutput "Results Directory: $resultsDir" "White" + +# 결과 디렉토리 생성 +if (!(Test-Path $resultsDir)) { + New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null + Write-ColorOutput "Created results directory" "Green" +} + +# 환경 검증 +Write-ColorOutput "`nValidating environment..." "Yellow" + +# API 상태 확인 +try { + $healthCheck = Invoke-RestMethod -Uri "$ApiUrl/health" -TimeoutSec 5 + Write-ColorOutput "✓ API is running" "Green" +} catch { + Write-ColorOutput "✗ API is not accessible: $($_.Exception.Message)" "Red" + exit 1 +} + +# LoadTest 환경 확인 +try { + $metricsCheck = Invoke-RestMethod -Uri "$ApiUrl/api/v1/monitoring/metrics" -TimeoutSec 5 + Write-ColorOutput "✓ Performance monitoring available" "Green" +} catch { + Write-ColorOutput "✗ Performance monitoring not available. Make sure ASPNETCORE_ENVIRONMENT=LoadTest" "Red" + exit 1 +} + +# Node.js 및 부하테스트 스크립트 확인 +if (!(Test-Path $LoadTestScript)) { + Write-ColorOutput "✗ Load test script not found: $LoadTestScript" "Red" + exit 1 +} + +try { + node --version | Out-Null + Write-ColorOutput "✓ Node.js is available" "Green" +} catch { + Write-ColorOutput "✗ Node.js is not installed or not in PATH" "Red" + exit 1 +} + +# 테스트 시작 시간 +$testStartTime = Get-Date +Write-ColorOutput "`nStarting load test at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" "Cyan" + +# 성능 모니터링 시작 +Write-ColorOutput "Starting performance monitoring..." "Yellow" +$performanceLogFile = Join-Path $resultsDir "performance-monitor.csv" +$performanceMonitorProcess = Start-Process -FilePath "powershell.exe" ` + -ArgumentList "-File", ".\scripts\monitor-performance.ps1", "-ApiUrl", $ApiUrl, "-IntervalSeconds", "5", "-OutputDir", $resultsDir, "-Continuous" ` + -WindowStyle "Minimized" -PassThru + +Write-ColorOutput "Performance monitoring started (PID: $($performanceMonitorProcess.Id))" "Green" + +# dotnet-counters 시작 (가능한 경우) +$dotnetCountersProcess = $null +try { + $apiProcesses = Get-Process -Name "ProjectVG.Api" -ErrorAction SilentlyContinue + if ($apiProcesses.Count -eq 0) { + $apiProcesses = Get-Process | Where-Object { $_.ProcessName -like "*ProjectVG*" } + } + + if ($apiProcesses.Count -gt 0) { + $processId = $apiProcesses[0].Id + $countersLogFile = Join-Path $resultsDir "dotnet-counters.json" + + Write-ColorOutput "Starting dotnet-counters for process $processId..." "Yellow" + $dotnetCountersProcess = Start-Process -FilePath "dotnet-counters" ` + -ArgumentList "collect", "--process-id", $processId, "--output", $countersLogFile, "--format", "json", "--counters", "Microsoft.AspNetCore.Hosting,System.Runtime" ` + -WindowStyle "Hidden" -PassThru + + Write-ColorOutput "dotnet-counters started (PID: $($dotnetCountersProcess.Id))" "Green" + } +} catch { + Write-ColorOutput "Could not start dotnet-counters: $($_.Exception.Message)" "Yellow" +} + +# 부하테스트 실행 +$loadTestLogFile = Join-Path $resultsDir "loadtest-output.log" +Write-ColorOutput "`nStarting load test..." "Yellow" +Write-ColorOutput "Load test output will be saved to: $loadTestLogFile" "White" + +try { + # Node.js 부하테스트 실행 + $loadTestProcess = Start-Process -FilePath "node" ` + -ArgumentList $LoadTestScript, "--clients", $Clients, "--duration", $Duration, "--url", $ApiUrl ` + -RedirectStandardOutput $loadTestLogFile ` + -RedirectStandardError $loadTestLogFile ` + -NoNewWindow -PassThru + + Write-ColorOutput "Load test started (PID: $($loadTestProcess.Id))" "Green" + Write-ColorOutput "Test will run for $Duration seconds..." "White" + + # 진행률 표시 + $progressInterval = [math]::Max(1, [math]::Floor($Duration / 20)) + for ($i = 0; $i -lt $Duration; $i += $progressInterval) { + $remaining = $Duration - $i + $progress = [math]::Round(($i / $Duration) * 100, 1) + + Write-Host "`r[Load Test] Progress: $progress% | Remaining: $remaining seconds | Elapsed: $i seconds" -NoNewline + + Start-Sleep -Seconds $progressInterval + + # 프로세스가 종료되었는지 확인 + if ($loadTestProcess.HasExited) { + Write-Host "" + Write-ColorOutput "Load test process completed early" "Yellow" + break + } + } + + Write-Host "" + + # 부하테스트 완료 대기 + if (!$loadTestProcess.HasExited) { + Write-ColorOutput "Waiting for load test to complete..." "Yellow" + $loadTestProcess.WaitForExit(30000) # 30초 추가 대기 + } + + $testEndTime = Get-Date + $actualDuration = $testEndTime - $testStartTime + + Write-ColorOutput "`nLoad test completed in $($actualDuration.ToString('hh\:mm\:ss'))" "Green" + +} catch { + Write-ColorOutput "Load test execution failed: $($_.Exception.Message)" "Red" +} finally { + # 모니터링 프로세스 정리 + Write-ColorOutput "`nStopping monitoring processes..." "Yellow" + + if ($performanceMonitorProcess -and !$performanceMonitorProcess.HasExited) { + try { + $performanceMonitorProcess.Kill() + Write-ColorOutput "Performance monitor stopped" "Green" + } catch { + Write-ColorOutput "Failed to stop performance monitor" "Yellow" + } + } + + if ($dotnetCountersProcess -and !$dotnetCountersProcess.HasExited) { + try { + $dotnetCountersProcess.Kill() + Write-ColorOutput "dotnet-counters stopped" "Green" + } catch { + Write-ColorOutput "Failed to stop dotnet-counters" "Yellow" + } + } +} + +# 결과 요약 생성 +Write-ColorOutput "`n=== Load Test Results Summary ===" "Cyan" +Write-ColorOutput "Test Duration: $($actualDuration.ToString('hh\:mm\:ss'))" "White" +Write-ColorOutput "Results Directory: $resultsDir" "White" + +# 파일 목록 +Write-ColorOutput "`nGenerated Files:" "White" +Get-ChildItem -Path $resultsDir | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1024, 1) + Write-ColorOutput " $($_.Name) ($sizeKB KB)" "Gray" +} + +# 간단한 성능 요약 (마지막 성능 지표) +try { + Write-ColorOutput "`nFinal Performance Metrics:" "White" + $finalMetrics = Invoke-RestMethod -Uri "$ApiUrl/api/v1/monitoring/metrics" -TimeoutSec 5 + + Write-ColorOutput " Memory Usage: $($finalMetrics.process.workingSetMemoryMB) MB" "White" + Write-ColorOutput " GC Collections: Gen0=$($finalMetrics.gc.gen0Collections) Gen1=$($finalMetrics.gc.gen1Collections) Gen2=$($finalMetrics.gc.gen2Collections)" "White" + Write-ColorOutput " Available Threads: $($finalMetrics.threadPool.availableThreads)" "White" + Write-ColorOutput " Pending Work: $($finalMetrics.threadPool.pendingWorkItems)" "White" + +} catch { + Write-ColorOutput "Could not retrieve final metrics" "Yellow" +} + +Write-ColorOutput "`nLoad test with monitoring completed successfully!" "Green" +Write-ColorOutput "Check the results directory for detailed logs and metrics." "White" \ No newline at end of file diff --git a/scripts/monitor-performance.ps1 b/scripts/monitor-performance.ps1 new file mode 100644 index 0000000..86e6573 --- /dev/null +++ b/scripts/monitor-performance.ps1 @@ -0,0 +1,234 @@ +# ProjectVG API Performance Monitoring Script +# 부하테스트 중 실시간 성능 모니터링 및 로깅 + +param( + [string]$ApiUrl = "http://localhost:7804", + [int]$IntervalSeconds = 5, + [string]$OutputDir = ".\loadtest-results", + [switch]$EnableDotnetCounters, + [switch]$Continuous +) + +# 색상 출력 함수 +function Write-ColorOutput { + param([string]$Message, [string]$Color = "White") + + switch ($Color) { + "Red" { Write-Host $Message -ForegroundColor Red } + "Green" { Write-Host $Message -ForegroundColor Green } + "Yellow" { Write-Host $Message -ForegroundColor Yellow } + "Cyan" { Write-Host $Message -ForegroundColor Cyan } + "Magenta" { Write-Host $Message -ForegroundColor Magenta } + default { Write-Host $Message } + } +} + +# 출력 디렉토리 생성 +if (!(Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + Write-ColorOutput "Created output directory: $OutputDir" "Green" +} + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$logFile = Join-Path $OutputDir "performance-monitor-$timestamp.csv" +$processLogFile = Join-Path $OutputDir "process-monitor-$timestamp.log" + +Write-ColorOutput "=== ProjectVG API Performance Monitor ===" "Cyan" +Write-ColorOutput "API URL: $ApiUrl" "White" +Write-ColorOutput "Monitoring Interval: $IntervalSeconds seconds" "White" +Write-ColorOutput "Log File: $logFile" "White" +Write-ColorOutput "Process Log: $processLogFile" "White" + +# CSV 헤더 작성 +$csvHeader = "Timestamp,WorkingMemoryMB,PrivateMemoryMB,CpuUsage%,ThreadPoolWorkers,ThreadPoolCP,PendingWork,GCGen0,GCGen1,GCGen2,TotalMemoryMB,AvailableThreads,Status" +$csvHeader | Out-File -FilePath $logFile -Encoding UTF8 + +# API 연결 테스트 +try { + Write-ColorOutput "Testing API connection..." "Yellow" + $healthCheck = Invoke-RestMethod -Uri "$ApiUrl/health" -TimeoutSec 10 + Write-ColorOutput "API Health Check: OK" "Green" +} catch { + Write-ColorOutput "API Health Check Failed: $($_.Exception.Message)" "Red" + Write-ColorOutput "Make sure the API is running in LoadTest environment" "Yellow" + exit 1 +} + +# dotnet-counters 프로세스 시작 (선택적) +$dotnetCountersProcess = $null +if ($EnableDotnetCounters) { + try { + Write-ColorOutput "Starting dotnet-counters..." "Yellow" + + # API 프로세스 ID 찾기 + $apiProcesses = Get-Process -Name "ProjectVG.Api" -ErrorAction SilentlyContinue + if ($apiProcesses.Count -eq 0) { + $apiProcesses = Get-Process | Where-Object { $_.ProcessName -like "*ProjectVG*" -or $_.MainWindowTitle -like "*ProjectVG*" } + } + + if ($apiProcesses.Count -gt 0) { + $processId = $apiProcesses[0].Id + Write-ColorOutput "Found API Process ID: $processId" "Green" + + $countersLogFile = Join-Path $OutputDir "dotnet-counters-$timestamp.txt" + $dotnetCountersArgs = @( + "monitor", + "--process-id", $processId, + "--refresh-interval", $IntervalSeconds, + "--format", "table", + "Microsoft.AspNetCore.Hosting", + "System.Runtime", + "Microsoft.AspNetCore.Http.Connections" + ) + + $dotnetCountersProcess = Start-Process -FilePath "dotnet-counters" -ArgumentList $dotnetCountersArgs -RedirectStandardOutput $countersLogFile -NoNewWindow -PassThru + Write-ColorOutput "dotnet-counters started, output: $countersLogFile" "Green" + } else { + Write-ColorOutput "Could not find API process for dotnet-counters" "Yellow" + } + } catch { + Write-ColorOutput "Failed to start dotnet-counters: $($_.Exception.Message)" "Red" + } +} + +# 성능 임계값 설정 +$thresholds = @{ + MemoryMB = 500 + CpuPercent = 80 + PendingWork = 100 + AvailableThreads = 10 +} + +Write-ColorOutput "=== Starting Performance Monitoring ===" "Cyan" +Write-ColorOutput "Press Ctrl+C to stop monitoring" "Yellow" + +$monitoringStartTime = Get-Date +$alertCount = 0 + +try { + do { + $currentTime = Get-Date + + try { + # API 성능 지표 수집 + $metricsResponse = Invoke-RestMethod -Uri "$ApiUrl/api/v1/monitoring/metrics" -TimeoutSec 5 + + # 데이터 추출 + $workingMemory = $metricsResponse.process.workingSetMemoryMB + $privateMemory = $metricsResponse.process.privateMemoryMB + $cpuUsage = [math]::Round($metricsResponse.process.cpuUsagePercent, 2) + $workerThreads = $metricsResponse.threadPool.workerThreads + $completionPortThreads = $metricsResponse.threadPool.completionPortThreads + $pendingWork = $metricsResponse.threadPool.pendingWorkItems + $gcGen0 = $metricsResponse.gc.gen0Collections + $gcGen1 = $metricsResponse.gc.gen1Collections + $gcGen2 = $metricsResponse.gc.gen2Collections + $totalMemory = $metricsResponse.gc.totalMemoryMB + $availableThreads = $metricsResponse.threadPool.availableThreads + + # 상태 결정 + $status = "OK" + $alerts = @() + + if ($workingMemory -gt $thresholds.MemoryMB) { + $status = "HIGH_MEMORY" + $alerts += "High Memory Usage: $workingMemory MB" + } + + if ($cpuUsage -gt $thresholds.CpuPercent) { + $status = "HIGH_CPU" + $alerts += "High CPU Usage: $cpuUsage%" + } + + if ($pendingWork -gt $thresholds.PendingWork) { + $status = "HIGH_QUEUE" + $alerts += "High Pending Work: $pendingWork" + } + + if ($availableThreads -lt $thresholds.AvailableThreads) { + $status = "LOW_THREADS" + $alerts += "Low Available Threads: $availableThreads" + } + + # CSV 로그 기록 + $csvLine = "$($currentTime.ToString('yyyy-MM-dd HH:mm:ss')),$workingMemory,$privateMemory,$cpuUsage,$workerThreads,$completionPortThreads,$pendingWork,$gcGen0,$gcGen1,$gcGen2,$totalMemory,$availableThreads,$status" + $csvLine | Out-File -FilePath $logFile -Append -Encoding UTF8 + + # 콘솔 출력 + $elapsed = $currentTime - $monitoringStartTime + Write-Host "`r[$(Get-Date -Format 'HH:mm:ss')] " -NoNewline + + $statusColor = switch ($status) { + "OK" { "Green" } + default { "Red" } + } + + Write-Host "[$status] " -ForegroundColor $statusColor -NoNewline + Write-Host "Memory: $workingMemory MB | " -NoNewline + Write-Host "CPU: $cpuUsage% | " -NoNewline + Write-Host "Queue: $pendingWork | " -NoNewline + Write-Host "Threads: $availableThreads | " -NoNewline + Write-Host "GC: G0=$gcGen0 G1=$gcGen1 G2=$gcGen2 | " -NoNewline + Write-Host "Elapsed: $($elapsed.ToString('hh\:mm\:ss'))" -NoNewline + + # 알림 처리 + if ($alerts.Count -gt 0) { + $alertCount++ + Write-Host "" + foreach ($alert in $alerts) { + Write-ColorOutput " ALERT: $alert" "Red" + } + + # 프로세스 로그에 알림 기록 + $alertLog = "[$($currentTime.ToString('yyyy-MM-dd HH:mm:ss'))] ALERTS: $($alerts -join ', ')" + $alertLog | Out-File -FilePath $processLogFile -Append -Encoding UTF8 + } + + } catch { + Write-Host "`r[$(Get-Date -Format 'HH:mm:ss')] " -NoNewline + Write-ColorOutput "[ERROR] Failed to get metrics: $($_.Exception.Message)" "Red" + + # 에러 로그 기록 + $errorLog = "[$($currentTime.ToString('yyyy-MM-dd HH:mm:ss'))] ERROR: $($_.Exception.Message)" + $errorLog | Out-File -FilePath $processLogFile -Append -Encoding UTF8 + } + + if (!$Continuous) { + Write-Host "" + } + + Start-Sleep -Seconds $IntervalSeconds + + } while ($Continuous) + +} finally { + # dotnet-counters 정리 + if ($dotnetCountersProcess -and !$dotnetCountersProcess.HasExited) { + Write-ColorOutput "`nStopping dotnet-counters..." "Yellow" + try { + $dotnetCountersProcess.Kill() + $dotnetCountersProcess.WaitForExit(5000) + } catch { + Write-ColorOutput "Failed to stop dotnet-counters gracefully" "Yellow" + } + } + + $endTime = Get-Date + $totalDuration = $endTime - $monitoringStartTime + + Write-ColorOutput "`n=== Monitoring Summary ===" "Cyan" + Write-ColorOutput "Total Duration: $($totalDuration.ToString('hh\:mm\:ss'))" "White" + Write-ColorOutput "Total Alerts: $alertCount" "White" + Write-ColorOutput "Log Files:" "White" + Write-ColorOutput " Performance: $logFile" "White" + Write-ColorOutput " Process: $processLogFile" "White" + + if ($EnableDotnetCounters) { + $countersLogFile = Join-Path $OutputDir "dotnet-counters-$timestamp.txt" + if (Test-Path $countersLogFile) { + Write-ColorOutput " dotnet-counters: $countersLogFile" "White" + } + } +} + +Write-ColorOutput "Performance monitoring completed." "Green" \ No newline at end of file diff --git a/scripts/quick-monitor.ps1 b/scripts/quick-monitor.ps1 new file mode 100644 index 0000000..7fa20ef --- /dev/null +++ b/scripts/quick-monitor.ps1 @@ -0,0 +1,117 @@ +# Quick Performance Monitor for ProjectVG API +# Load test real-time monitoring + +param( + [string]$ApiUrl = "http://localhost:7900", + [int]$RefreshSeconds = 2 +) + +function Write-PerformanceBar { + param([int]$Value, [int]$Max, [string]$Label, [string]$Unit = "") + + $percentage = if ($Max -gt 0) { [math]::Min(100, ($Value / $Max) * 100) } else { 0 } + $barLength = 20 + $filledLength = [math]::Floor(($percentage / 100) * $barLength) + + # Use ASCII characters instead of Unicode + $bar = "#" * $filledLength + "-" * ($barLength - $filledLength) + + $color = if ($percentage -gt 80) { "Red" } elseif ($percentage -gt 60) { "Yellow" } else { "Green" } + + Write-Host "$Label : " -NoNewline + Write-Host $bar -ForegroundColor $color -NoNewline + Write-Host " $Value$Unit/$Max$Unit ($([math]::Round($percentage, 1))%)" -ForegroundColor $color +} + +Write-Host "=== ProjectVG API Quick Monitor ===" -ForegroundColor Cyan +Write-Host "API: $ApiUrl" -ForegroundColor White +Write-Host "Press Ctrl+C to exit" -ForegroundColor Yellow +Write-Host "" + +$startTime = Get-Date +$previousMetrics = $null + +try { + while ($true) { + Clear-Host + $currentTime = Get-Date + $elapsed = $currentTime - $startTime + + Write-Host "=== ProjectVG API Performance Monitor ===" -ForegroundColor Cyan + Write-Host "Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Elapsed: $($elapsed.ToString('hh\:mm\:ss'))" -ForegroundColor White + Write-Host "===============================================================================" -ForegroundColor Gray + + try { + # API metrics collection + $metrics = Invoke-RestMethod -Uri "$ApiUrl/api/v1/monitoring/metrics" -TimeoutSec 3 + + # Memory usage + Write-Host "`nMemory Usage:" -ForegroundColor White + Write-PerformanceBar $metrics.process.workingSetMemoryMB 1000 "Working Set" "MB" + Write-PerformanceBar $metrics.gc.totalMemoryMB 500 "GC Memory " "MB" + + # ThreadPool status + Write-Host "`nThreadPool Status:" -ForegroundColor White + Write-PerformanceBar $metrics.threadPool.workerThreads 100 "Worker Threads" + Write-PerformanceBar $metrics.threadPool.pendingWorkItems 50 "Pending Work " + + # GC information + Write-Host "`nGarbage Collection:" -ForegroundColor White + + if ($previousMetrics) { + $gen0Delta = $metrics.gc.gen0Collections - $previousMetrics.gc.gen0Collections + $gen1Delta = $metrics.gc.gen1Collections - $previousMetrics.gc.gen1Collections + $gen2Delta = $metrics.gc.gen2Collections - $previousMetrics.gc.gen2Collections + + Write-Host "Gen 0: $($metrics.gc.gen0Collections) (+$gen0Delta)" -ForegroundColor $(if ($gen0Delta -gt 5) { "Red" } elseif ($gen0Delta -gt 2) { "Yellow" } else { "Green" }) + Write-Host "Gen 1: $($metrics.gc.gen1Collections) (+$gen1Delta)" -ForegroundColor $(if ($gen1Delta -gt 2) { "Red" } elseif ($gen1Delta -gt 0) { "Yellow" } else { "Green" }) + Write-Host "Gen 2: $($metrics.gc.gen2Collections) (+$gen2Delta)" -ForegroundColor $(if ($gen2Delta -gt 0) { "Red" } else { "Green" }) + } else { + Write-Host "Gen 0: $($metrics.gc.gen0Collections)" -ForegroundColor Green + Write-Host "Gen 1: $($metrics.gc.gen1Collections)" -ForegroundColor Green + Write-Host "Gen 2: $($metrics.gc.gen2Collections)" -ForegroundColor Green + } + + # System information + Write-Host "`nSystem Info:" -ForegroundColor White + Write-Host "Process ID : $($metrics.process.id)" -ForegroundColor Gray + Write-Host "Available Threads: $($metrics.threadPool.availableThreads)" -ForegroundColor $(if ($metrics.threadPool.availableThreads -lt 20) { "Red" } elseif ($metrics.threadPool.availableThreads -lt 50) { "Yellow" } else { "Green" }) + Write-Host "CPU Count : $($metrics.system.cpuCount)" -ForegroundColor Gray + + # Warning indicators + $warnings = @() + if ($metrics.process.workingSetMemoryMB -gt 500) { $warnings += "High memory usage" } + if ($metrics.threadPool.pendingWorkItems -gt 20) { $warnings += "High work queue" } + if ($metrics.threadPool.availableThreads -lt 20) { $warnings += "Low thread availability" } + + if ($warnings.Count -gt 0) { + Write-Host "`nWarnings:" -ForegroundColor Red + foreach ($warning in $warnings) { + Write-Host "! $warning" -ForegroundColor Red + } + } else { + Write-Host "`nStatus: All systems normal [OK]" -ForegroundColor Green + } + + $previousMetrics = $metrics + + } catch { + Write-Host "`nError: Failed to connect to API" -ForegroundColor Red + Write-Host "Details: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "`nMake sure:" -ForegroundColor Yellow + Write-Host "1. API is running on $ApiUrl" -ForegroundColor Yellow + Write-Host "2. Environment is set to 'LoadTest'" -ForegroundColor Yellow + } + + Write-Host "`n===============================================================================" -ForegroundColor Gray + Write-Host "Refreshing in $RefreshSeconds seconds... (Ctrl+C to exit)" -ForegroundColor Gray + + Start-Sleep -Seconds $RefreshSeconds + } +} catch [System.Management.Automation.PipelineStoppedException] { + Write-Host "`nMonitoring stopped by user." -ForegroundColor Yellow +} catch { + Write-Host "`nMonitoring stopped due to error: $($_.Exception.Message)" -ForegroundColor Red +} + +Write-Host "Quick monitor session ended." -ForegroundColor Green \ No newline at end of file diff --git a/scripts/start-loadtest.ps1 b/scripts/start-loadtest.ps1 new file mode 100644 index 0000000..16a23ec --- /dev/null +++ b/scripts/start-loadtest.ps1 @@ -0,0 +1,99 @@ +# ProjectVG Load Test Environment Start Script + +Write-Host "Starting ProjectVG load test environment..." -ForegroundColor Green + +# Move to project root from current script directory +$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectRoot = Split-Path -Parent $scriptPath +Set-Location $projectRoot + +# Check required files exist +if (-not (Test-Path "env.loadtest")) { + Write-Host "env.loadtest file not found!" -ForegroundColor Red + exit 1 +} + +if (-not (Test-Path "docker-compose.loadtest.yml")) { + Write-Host "docker-compose.loadtest.yml file not found!" -ForegroundColor Red + exit 1 +} + +# Clean up existing containers +Write-Host "Cleaning up existing load test containers..." -ForegroundColor Yellow +docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml down --remove-orphans 2>$null + +# Remove existing images (prevent cache conflicts) +docker rmi projectvg-loadtest-api:latest -f 2>$null +docker rmi projectvg-dummy-llm:latest -f 2>$null +docker rmi projectvg-dummy-memory:latest -f 2>$null +docker rmi projectvg-dummy-tts:latest -f 2>$null + +# Build and start load test environment with performance monitoring +Write-Host "Building load test environment with performance monitoring..." -ForegroundColor Yellow +docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml build --no-cache 2>$null +docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml up -d + +# Wait for services to start +Write-Host "Waiting for services to start..." -ForegroundColor Yellow + +function Wait-ForUrl { + param([string]$url, [int]$timeoutSec=120) + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $timeoutSec) { + try { + $resp = Invoke-WebRequest -Uri $url -TimeoutSec 3 -UseBasicParsing + if ($resp.StatusCode -eq 200) { return $true } + } catch { Start-Sleep -Milliseconds 500 } + } + return $false +} + +# Check service status with retry +Write-Host "Checking service status with retry..." -ForegroundColor Yellow + +$services = @( + @{Name="LLM Server"; Url="http://localhost:7808/health"}, + @{Name="Memory Server"; Url="http://localhost:7812/health"}, + @{Name="TTS Server"; Url="http://localhost:7816/health"}, + @{Name="Main API"; Url="http://localhost:7804/api/v1/health"} +) + +$allHealthy = $true +foreach ($service in $services) { + Write-Host "Waiting for $($service.Name)..." -ForegroundColor Cyan + $isHealthy = Wait-ForUrl -url $service.Url -timeoutSec 120 + + if ($isHealthy) { + Write-Host "$($service.Name): OK" -ForegroundColor Green + } else { + Write-Host "$($service.Name): TIMEOUT (120s)" -ForegroundColor Red + $allHealthy = $false + } +} + +if ($allHealthy) { + Write-Host "`nLoad test environment started successfully!" -ForegroundColor Green + Write-Host "Service URLs:" -ForegroundColor Cyan + Write-Host " - Main API: http://localhost:7804" -ForegroundColor White + Write-Host " - LLM Server: http://localhost:7808" -ForegroundColor White + Write-Host " - Memory Server: http://localhost:7812" -ForegroundColor White + Write-Host " - TTS Server: http://localhost:7816" -ForegroundColor White + + Write-Host "`nPerformance Monitoring:" -ForegroundColor Cyan + Write-Host " - Performance API: http://localhost:7804/api/v1/monitoring/metrics" -ForegroundColor White + Write-Host " - Detailed Health: http://localhost:7804/api/v1/monitoring/health-detailed" -ForegroundColor White + Write-Host " - GC Info: http://localhost:7804/api/v1/monitoring/gc" -ForegroundColor White + Write-Host " - ThreadPool Info: http://localhost:7804/api/v1/monitoring/threadpool" -ForegroundColor White + + Write-Host "`nMonitoring Scripts:" -ForegroundColor Cyan + Write-Host " - Quick Monitor: .\scripts\quick-monitor.ps1" -ForegroundColor White + Write-Host " - Detailed Monitor: .\scripts\monitor-performance.ps1" -ForegroundColor White + Write-Host " - Docker Monitor: .\scripts\docker-monitor.ps1" -ForegroundColor White + Write-Host " - Load Test + Monitor: .\scripts\loadtest-with-monitoring.ps1" -ForegroundColor White + + Write-Host "`nYou can now start load testing!" -ForegroundColor Green + Write-Host "To stop: scripts\stop-loadtest.ps1" -ForegroundColor Yellow +} else { + Write-Host "`nSome services failed to start" -ForegroundColor Red + Write-Host "Check logs: docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml logs" -ForegroundColor White +} \ No newline at end of file diff --git a/scripts/stop-loadtest.ps1 b/scripts/stop-loadtest.ps1 new file mode 100644 index 0000000..e070352 --- /dev/null +++ b/scripts/stop-loadtest.ps1 @@ -0,0 +1,53 @@ +# ProjectVG Load Test Environment Shutdown Script + +Write-Host "Stopping ProjectVG Load Test Environment..." -ForegroundColor Yellow + +# Navigate to project root from current script directory +$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectRoot = Split-Path -Parent $scriptPath +Set-Location $projectRoot + +Write-Host "Project Root: $projectRoot" -ForegroundColor Yellow + +# Stop load test containers +Write-Host "Stopping load test containers..." -ForegroundColor Yellow +docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml down --remove-orphans + +# Confirm image cleanup +$cleanupImages = Read-Host "Do you want to delete load test images as well? (y/N)" +if ($cleanupImages -eq "y" -or $cleanupImages -eq "Y") { + Write-Host "Removing load test images..." -ForegroundColor Yellow + + # Remove load test related images + $imagesToRemove = @( + "projectvg-loadtest-api:latest", + "projectvg-dummy-llm:latest", + "projectvg-dummy-memory:latest", + "projectvg-dummy-tts:latest" + ) + + foreach ($image in $imagesToRemove) { + try { + docker rmi $image -f + Write-Host "✅ $image image removed successfully" -ForegroundColor Green + } catch { + Write-Host "⚠️ $image image removal failed (not found or in use)" -ForegroundColor Yellow + } + } + + # Clean up unused images + Write-Host "Cleaning up unused Docker images..." -ForegroundColor Yellow + docker image prune -f +} + +# Clean up network +Write-Host "Cleaning up load test network..." -ForegroundColor Yellow +try { + docker network rm projectvg-loadtest-network + Write-Host "✅ Load test network removed successfully" -ForegroundColor Green +} catch { + Write-Host "⚠️ Load test network removal failed (not found or in use)" -ForegroundColor Yellow +} + +Write-Host "`n✅ Load test environment stopped successfully!" -ForegroundColor Green +Write-Host "To restart, run scripts\start-loadtest.ps1" -ForegroundColor Cyan \ No newline at end of file diff --git a/test-clients/ai-chat-client/script.js b/test-clients/ai-chat-client/script.js index ae5c923..ed0b2eb 100644 --- a/test-clients/ai-chat-client/script.js +++ b/test-clients/ai-chat-client/script.js @@ -1,8 +1,8 @@ // 환경에 따른 포트 설정 // Development 환경(7901)에서 실행되면 7901 사용, 아니면 기본값 7900 사용 const currentPort = window.location.port; -const isDevelopment = currentPort === '7901'; -const serverPort = isDevelopment ? '7901' : '7900'; +const isDevelopment = currentPort === '7900'; +const serverPort = isDevelopment ? '7900' : '7900'; const currentHost = window.location.hostname || 'localhost'; const ENDPOINT = `${currentHost}:${serverPort}`; const WS_URL = `ws://${ENDPOINT}/ws`; diff --git a/test-loadtest/LoadTest-Report-Phase1.md b/test-loadtest/LoadTest-Report-Phase1.md new file mode 100644 index 0000000..f9a03b5 --- /dev/null +++ b/test-loadtest/LoadTest-Report-Phase1.md @@ -0,0 +1,157 @@ +# ProjectVG Chat API Load Test Report - Phase 1 + +## 테스트 개요 + +**테스트 일시**: 2025-09-10 +**테스트 대상**: ProjectVG Chat API (LoadTest 환경) +**테스트 도구**: Node.js Chat Load Test Script +**테스트 환경**: Docker LoadTest Environment + +## 테스트 설정 + +| 항목 | 값 | +|------|-----| +| 동시 클라이언트 수 | 30명 | +| 테스트 지속 시간 | 90초 | +| 램프업 시간 | 10초 | +| 채팅 간격 | 3초 | +| 대상 캐릭터 수 | 3개 (공개 캐릭터) | +| 메시지 패턴 | 10가지 다양한 메시지 | + +## 테스트 아키텍처 + +### 실제 워크플로우 +1. **Phase 1**: Guest Login (JWT 토큰 발급) +2. **Phase 2**: WebSocket 연결 설정 +3. **Phase 3**: Chat API 요청 (`POST /api/v1/chat`) +4. **Phase 4**: WebSocket을 통한 실시간 응답 수신 +5. **Phase 5**: 연결 정리 및 종료 + +### 외부 서비스 +- **LLM 서비스**: Dummy LLM Server (1-2초 딜레이) +- **Memory 서비스**: Dummy Memory Server (100ms 딜레이) +- **TTS 서비스**: Dummy TTS Server (비활성화) + +## 테스트 결과 + +### 전체 성능 지표 + +| 지표 | 값 | +|------|-----| +| 총 테스트 시간 | 90초 | +| 총 Chat 요청 수 | 570개 | +| 평균 Chat RPS | 6.33 | +| 전체 RPS | 7.0 | +| 평균 응답 시간 | 1,430ms | +| 최소 응답 시간 | 1,015ms | +| 최대 응답 시간 | 2,024ms | + +### 인증 및 연결 성능 + +| 항목 | 성공 | 실패 | 성공률 | +|------|------|------|--------| +| Guest Login | 30 | 0 | 100% | +| WebSocket 연결 | 30 | 0 | 100% | +| Chat API 요청 | 570 | 0 | 100% | +| WebSocket 응답 | 557 | 13 | 97.7% | + +### 시간대별 성능 + +| 시간(초) | Chat 요청 누적 | Chat RPS | 평균 응답시간(ms) | +|----------|----------------|----------|-------------------| +| 10 | 37 | 4 | 852 | +| 20 | 105 | 5 | 1,169 | +| 30 | 167 | 6 | 1,274 | +| 40 | 238 | 6 | 1,350 | +| 50 | 302 | 6 | 1,382 | +| 60 | 371 | 6 | 1,397 | +| 70 | 435 | 6 | 1,420 | +| 80 | 501 | 6 | 1,426 | +| 90 | 570 | 6 | 1,430 | + +## 시스템 성능 모니터링 + +### 메모리 사용량 +- **Working Set**: 146MB (안정적 유지) +- **GC Memory**: 14MB (안정적 유지) +- **가비지 컬렉션**: 0회 (메모리 압박 없음) + +### ThreadPool 상태 +- **가용 스레드**: 33,766개 (충분) +- **대기 작업**: 0-5개 (정상 범위) +- **작업 스레드**: 1-2개 (정상 범위) + +### CPU 사용률 +- **최대 CPU 사용률**: 17,920% +- **지속적 고사용**: 전체 테스트 기간 동안 높은 사용률 유지 +- **알람 발생**: High CPU Usage 지속적 경고 + +## 성능 비교 분석 + +### 기존 모니터링 API vs Chat API + +| 지표 | 모니터링 API | Chat API | 차이 | +|------|-------------|----------|------| +| 처리량 (RPS) | 350+ | 6 | 58배 차이 | +| 평균 응답시간 | 30ms | 1,430ms | 48배 차이 | +| CPU 사용률 | 3,000% | 17,920% | 6배 차이 | +| 복잡도 | 단순 상태 조회 | 실제 채팅 워크플로우 | - | + +## 병목점 분석 + +### 주요 CPU 병목점 + +1. **Task.Run() 패턴**: 570개 동시 백그라운드 태스크 생성 +2. **DI Scope 생성**: 각 태스크마다 새로운 의존성 주입 스코프 +3. **JWT 검증**: 매 요청마다 암호화 연산 수행 +4. **데이터베이스 쿼리**: 이중 정렬 연산 및 동시 접근 +5. **성능 모니터링**: 5초마다 시스템 메트릭 수집 + +### 리소스 경합 +- **ThreadPool 포화**: 570+ 동시 태스크로 인한 스레드 고갈 +- **컨텍스트 스위칭**: 과도한 동시성으로 인한 CPU 오버헤드 +- **서비스 인스턴스화**: 14,250+ 객체 생성으로 인한 GC 압박 + +## 결론 + +### 성공 요소 +- 모든 인증 및 연결 100% 성공 +- 안정적인 메모리 사용량 유지 +- 실제 채팅 워크플로우 완전 구현 +- WebSocket 실시간 통신 97.7% 성공 + +### 성능 제한 요소 +- 극도로 높은 CPU 사용률 (17,920%) +- 비효율적인 동시성 패턴 +- JWT 검증 오버헤드 +- 데이터베이스 쿼리 최적화 필요 + +### 권장사항 + +#### 즉시 수정 필요 +1. Task.Run() 패턴을 적절한 async 패턴으로 변경 +2. 동시성 제한 메커니즘 구현 (SemaphoreSlim) +3. JWT 토큰 검증 결과 캐싱 +4. 데이터베이스 쿼리 최적화 (이중 정렬 제거) + +#### 성능 개선 +1. 백그라운드 서비스 패턴 도입 +2. 데이터베이스 연결 풀 최적화 +3. 요청 큐잉 및 스로틀링 구현 +4. 성능 모니터링 주기 조정 + +## 부록 + +### 테스트 파일 위치 +- **Load Test Script**: `test-loadtest/chat-loadtest.js` +- **성능 모니터링**: `scripts/monitor-performance.ps1` +- **Quick 모니터링**: `scripts/quick-monitor.ps1` +- **Docker 설정**: `docker-compose.loadtest.yml` + +### 로그 파일 +- **성능 로그**: `loadtest-results/performance-monitor-20250910-225853.csv` +- **프로세스 로그**: `loadtest-results/process-monitor-20250910-225853.log` + +--- +**보고서 작성일**: 2025-09-10 +**작성 도구**: ProjectVG Chat API Load Testing Framework \ No newline at end of file diff --git a/test-loadtest/README.md b/test-loadtest/README.md new file mode 100644 index 0000000..583278c --- /dev/null +++ b/test-loadtest/README.md @@ -0,0 +1,160 @@ +# ProjectVG Load Test Environment + +부하 테스트를 위한 더미 서버들과 환경 설정을 포함하는 디렉토리입니다. + +## 구성 요소 + +### 완전 분리된 부하테스트 환경 +- **loadtest-sqlserver** (포트 1434): 부하테스트 전용 SQL Server (운영 DB와 완전 분리) +- **loadtest-redis** (포트 6381): 부하테스트 전용 Redis (운영 Redis와 완전 분리) + +### 더미 서버들 +- **dummy-llm-server** (포트 7808): LLM 서비스 시뮬레이션 (1-2초 응답 딜레이) +- **dummy-memory-server** (포트 7812): Memory/VectorDB 서비스 시뮬레이션 (100ms 응답 딜레이) +- **dummy-tts-server** (포트 7816): TTS 서비스 시뮬레이션 (2-3초 응답 딜레이) + +### 부하 테스트 실행 방법 + +1. **환경 준비** + ```bash + # 프로젝트 루트에서 실행 + cd "C:\Users\imdls\Documents\Project\MainAPI Server" + ``` + +2. **부하 테스트 환경 시작 (운영 환경과 완전 분리)** + ```bash + # 부하 테스트용 Docker Compose 실행 - 모든 서비스가 독립적으로 실행됨 + docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml up --build + ``` + + **⚠️ 주의: 이 환경은 운영 환경과 완전히 분리됩니다** + - 별도의 데이터베이스 (포트 1434) + - 별도의 Redis (포트 6381) + - 더미 외부 서비스들 + - 운영 데이터에 전혀 영향을 주지 않음 + +3. **개별 더미 서버 실행 (개발/디버깅용)** + ```bash + # LLM 서버 + cd test-loadtest/dummy-llm-server + npm install && npm start + + # Memory 서버 + cd test-loadtest/dummy-memory-server + npm install && npm start + + # TTS 서버 + cd test-loadtest/dummy-tts-server + npm install && npm start + ``` + +4. **상태 확인** + ```bash + # 부하테스트 전용 인프라 서버 확인 + curl http://localhost:1434 # SQL Server (별도 도구 필요) + redis-cli -p 6381 ping # Redis: PONG 응답 확인 + + # 더미 서버 헬스체크 + curl http://localhost:7808/health # LLM + curl http://localhost:7812/health # Memory + curl http://localhost:7816/health # TTS + + # 메인 API 상태 확인 + curl http://localhost:7804/api/v1/health + ``` + +## 더미 서버 API 엔드포인트 + +### LLM 서버 (포트 7808) +- `POST /api/completion` - 채팅 완성 (1-2초 딜레이) +- `POST /api/completion/stream` - 스트리밍 응답 +- `GET /api/models` - 모델 목록 +- `GET /health` - 헬스체크 + +### Memory 서버 (포트 7812) +- `POST /api/memory/store` - 메모리 저장 (100ms 딜레이) +- `POST /api/memory/search` - 메모리 검색 +- `GET /api/memory/:documentId` - 특정 메모리 조회 +- `DELETE /api/memory/:documentId` - 메모리 삭제 +- `GET /api/memory/user/:userId` - 사용자 메모리 목록 +- `GET /api/memory/character/:characterId` - 캐릭터 메모리 목록 +- `GET /api/memory/stats` - 메모리 통계 +- `DELETE /api/memory/clear` - 모든 메모리 삭제 +- `GET /health` - 헬스체크 + +### TTS 서버 (포트 7816) +- `POST /api/tts/synthesize` - 텍스트 음성 변환 (2-3초 딜레이) +- `POST /api/tts/synthesize/:voiceId` - 특정 음성으로 변환 +- `GET /api/tts/voices` - 사용 가능한 음성 목록 +- `POST /api/tts/voices/:voiceId/preview` - 음성 미리보기 +- `POST /api/tts/batch` - 일괄 변환 +- `GET /api/tts/stats` - TTS 통계 +- `GET /health` - 헬스체크 + +## 부하 테스트 설정 + +### 완전 분리된 환경 (env.loadtest) +- **데이터베이스**: 별도 컨테이너 (포트 1434) - `ProjectVG_LoadTest` DB +- **Redis**: 별도 컨테이너 (포트 6381) - 독립적인 데이터 저장소 +- **로그 레벨**: WARN으로 설정하여 성능 최적화 +- **외부 서비스**: 더미 서버 포트로 설정 +- **OAuth2**: 부하 테스트에서는 비활성화 + +### 운영 환경 보호 +- **별도 포트**: 모든 서비스가 운영 환경과 다른 포트 사용 +- **별도 데이터베이스**: `ProjectVG_LoadTest` (운영: `ProjectVG`) +- **별도 비밀번호**: `LoadTest123!` (운영과 다른 패스워드) +- **독립적 볼륨**: 데이터 저장소 완전 분리 + +### API 최적화 설정 (appsettings.loadtest.json) +- 상세 로깅 비활성화 +- Swagger 비활성화 +- 연결 풀 최적화 +- 성능 카운터 활성화 + +## 성능 특성 + +### 응답 시간 +- **LLM**: 1-2초 (실제 LLM 처리 시간 시뮬레이션) +- **Memory**: 100ms (빠른 벡터 검색 시뮬레이션) +- **TTS**: 2-3초 (음성 합성 처리 시간 시뮬레이션) + +### 더미 데이터 +- **LLM**: 가짜 채팅 응답 생성 +- **Memory**: 10개 초기 더미 메모리, 가짜 벡터 임베딩 +- **TTS**: WAV 형식 더미 오디오 데이터 (사인파 440Hz) + +## 정리 + +```bash +# 부하 테스트 환경 종료 +docker-compose -p projectvg-loadtest --env-file env.loadtest -f docker-compose.loadtest.yml down + +# 이미지 정리 (선택사항) +docker rmi projectvg-loadtest-api:latest +docker rmi projectvg-dummy-llm:latest +docker rmi projectvg-dummy-memory:latest +docker rmi projectvg-dummy-tts:latest +``` + +## 주의사항 및 안전성 + +### 운영 환경 보호 ✅ +1. **완전히 분리된 데이터베이스**: 부하 테스트가 운영 데이터에 절대 영향을 미치지 않음 +2. **독립적인 Redis**: 운영 세션 데이터와 완전 분리 +3. **별도 포트 사용**: 모든 서비스가 운영 환경과 다른 포트 사용 +4. **더미 외부 서비스**: 실제 LLM, Memory, TTS 서비스에 부하를 주지 않음 + +### 데이터 관리 +1. **테스트 데이터 초기화**: 컨테이너 재시작시 깨끗한 상태로 시작 +2. **볼륨 지속성**: SQL Server와 Redis 데이터는 볼륨에 저장되어 유지 +3. **더미 서버 인메모리**: 더미 서버들은 인메모리 저장소 사용 + +### 성능 최적화 +1. **로그 레벨 WARN**: 부하 테스트시 성능 향상을 위해 로깅 최소화 +2. **Swagger 비활성화**: 프로덕션과 같은 환경 구성 +3. **연결 풀 최적화**: 높은 동시 연결 처리를 위한 설정 + +### 사용 제한 +1. **부하 테스트 전용**: 프로덕션 환경에서는 절대 사용하지 않음 +2. **로컬 개발 전용**: 개발 서버나 운영 서버에 배포하지 않음 \ No newline at end of file diff --git a/test-loadtest/chat-loadtest.js b/test-loadtest/chat-loadtest.js new file mode 100644 index 0000000..8c1de30 --- /dev/null +++ b/test-loadtest/chat-loadtest.js @@ -0,0 +1,554 @@ +#!/usr/bin/env node + +// Comprehensive Chat API Load Test Script for ProjectVG API +// Tests complete chat workflow: Guest Login -> JWT Auth -> Chat API -> WebSocket Response + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); +const WebSocket = require('ws'); + +// 설정 +const CONFIG = { + apiUrl: process.env.API_URL || 'http://localhost:7804', + wsUrl: process.env.WS_URL || 'ws://localhost:7804', + clients: parseInt(process.env.CLIENTS || '20'), + duration: parseInt(process.env.DURATION || '60'), + rampUp: parseInt(process.env.RAMP_UP || '10'), + chatInterval: parseInt(process.env.CHAT_INTERVAL || '5000'), // 채팅 간격 (ms) + + // 테스트할 공개 캐릭터들 (API에서 조회된 실제 ID 사용) + characters: [ + '22222222-2222-2222-2222-222222222222', // 소피아 (메이드) + '11111111-1111-1111-1111-111111111111', // 미유 (딸같은 존재) + '33333333-3333-3333-3333-333333333333' // 하루 (절친) + ], + + // 다양한 채팅 메시지 패턴 + chatMessages: [ + "안녕하세요! 오늘 날씨가 좋네요.", + "오늘 하루 어떻게 지내셨나요?", + "요즘 무엇을 하고 계시나요?", + "좋아하는 음식이 무엇인가요?", + "취미가 있다면 무엇인가요?", + "최근에 본 영화나 책이 있나요?", + "스트레스 받을 때 어떻게 해소하세요?", + "주말 계획이 있으신가요?", + "가장 기억에 남는 여행지는 어디인가요?", + "새해 목표나 계획이 있으신가요?" + ] +}; + +// WebSocket이 설치되어 있지 않으면 에러 메시지 표시 +if (!WebSocket) { + console.error('WebSocket package is required. Please install it:'); + console.error('npm install ws'); + process.exit(1); +} + +class ChatLoadTestClient { + constructor(clientId) { + this.clientId = clientId; + this.guestId = `loadtest-guest-${clientId}-${Date.now()}`; + this.accessToken = null; + this.refreshToken = null; + this.webSocket = null; + this.selectedCharacterId = null; + + // 통계 + this.stats = { + guestLogins: 0, + guestLoginErrors: 0, + chatRequests: 0, + chatSuccesses: 0, + chatErrors: 0, + wsConnections: 0, + wsConnectionErrors: 0, + wsMessages: 0, + wsErrors: 0, + totalResponseTime: 0, + minResponseTime: Infinity, + maxResponseTime: 0 + }; + + this.running = false; + this.authenticated = false; + this.wsConnected = false; + } + + // HTTP 요청 헬퍼 + async makeHttpRequest(method, path, data = null, headers = {}) { + return new Promise((resolve) => { + const startTime = Date.now(); + const url = new URL(path, CONFIG.apiUrl); + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: method, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `ChatLoadTest-Client-${this.clientId}`, + ...headers + } + }; + + const client = url.protocol === 'https:' ? https : http; + const req = client.request(options, (res) => { + let body = ''; + + res.on('data', chunk => { + body += chunk; + }); + + res.on('end', () => { + const responseTime = Date.now() - startTime; + this.updateResponseTimeStats(responseTime); + + try { + const jsonBody = body ? JSON.parse(body) : null; + resolve({ + success: res.statusCode >= 200 && res.statusCode < 300, + statusCode: res.statusCode, + data: jsonBody, + responseTime, + error: null + }); + } catch (parseError) { + resolve({ + success: false, + statusCode: res.statusCode, + data: null, + responseTime, + error: `JSON Parse Error: ${parseError.message}` + }); + } + }); + }); + + req.on('error', (error) => { + const responseTime = Date.now() - startTime; + resolve({ + success: false, + statusCode: 0, + data: null, + responseTime, + error: error.message + }); + }); + + req.on('timeout', () => { + req.destroy(); + const responseTime = Date.now() - startTime; + resolve({ + success: false, + statusCode: 0, + data: null, + responseTime, + error: 'Request Timeout' + }); + }); + + if (data) { + req.write(JSON.stringify(data)); + } + req.end(); + }); + } + + // 응답 시간 통계 업데이트 + updateResponseTimeStats(responseTime) { + this.stats.totalResponseTime += responseTime; + this.stats.minResponseTime = Math.min(this.stats.minResponseTime, responseTime); + this.stats.maxResponseTime = Math.max(this.stats.maxResponseTime, responseTime); + } + + // Phase 1: Guest Login으로 JWT 토큰 획득 + async authenticateAsGuest() { + try { + const response = await this.makeHttpRequest('POST', '/api/v1/auth/guest-login', this.guestId); + + if (response.success && response.data && response.data.tokens) { + this.accessToken = response.data.tokens.accessToken; + this.refreshToken = response.data.tokens.refreshToken; + this.authenticated = true; + this.stats.guestLogins++; + + console.log(`[Client ${this.clientId}] Guest login successful (${response.responseTime}ms)`); + return true; + } else { + this.stats.guestLoginErrors++; + console.error(`[Client ${this.clientId}] Guest login failed:`, response.error || `HTTP ${response.statusCode}`); + return false; + } + } catch (error) { + this.stats.guestLoginErrors++; + console.error(`[Client ${this.clientId}] Guest login exception:`, error.message); + return false; + } + } + + // Phase 2: WebSocket 연결 설정 + async connectWebSocket() { + return new Promise((resolve) => { + try { + if (!this.accessToken) { + console.error(`[Client ${this.clientId}] No access token for WebSocket connection`); + this.stats.wsConnectionErrors++; + resolve(false); + return; + } + + const wsUrl = `${CONFIG.wsUrl}/ws?token=${this.accessToken}`; + this.webSocket = new WebSocket(wsUrl); + + const connectionTimeout = setTimeout(() => { + if (this.webSocket.readyState === WebSocket.CONNECTING) { + this.webSocket.terminate(); + this.stats.wsConnectionErrors++; + console.error(`[Client ${this.clientId}] WebSocket connection timeout`); + resolve(false); + } + }, 10000); + + this.webSocket.on('open', () => { + clearTimeout(connectionTimeout); + this.wsConnected = true; + this.stats.wsConnections++; + console.log(`[Client ${this.clientId}] WebSocket connected`); + resolve(true); + }); + + this.webSocket.on('message', (data) => { + this.stats.wsMessages++; + try { + const message = JSON.parse(data.toString()); + // 채팅 응답 수신 처리 + if (message.type === 'chat_response') { + this.stats.chatSuccesses++; + } + } catch (parseError) { + // JSON이 아닌 메시지는 무시 + } + }); + + this.webSocket.on('error', (error) => { + clearTimeout(connectionTimeout); + this.stats.wsErrors++; + console.error(`[Client ${this.clientId}] WebSocket error:`, error.message); + resolve(false); + }); + + this.webSocket.on('close', () => { + this.wsConnected = false; + console.log(`[Client ${this.clientId}] WebSocket closed`); + }); + + } catch (error) { + this.stats.wsConnectionErrors++; + console.error(`[Client ${this.clientId}] WebSocket connection exception:`, error.message); + resolve(false); + } + }); + } + + // Phase 3: Chat API 요청 발송 + async sendChatMessage() { + try { + if (!this.authenticated || !this.accessToken) { + console.error(`[Client ${this.clientId}] Not authenticated for chat`); + return false; + } + + // 랜덤 캐릭터와 메시지 선택 + if (!this.selectedCharacterId) { + this.selectedCharacterId = CONFIG.characters[Math.floor(Math.random() * CONFIG.characters.length)]; + } + + const message = CONFIG.chatMessages[Math.floor(Math.random() * CONFIG.chatMessages.length)]; + + const chatRequest = { + message: message, + character_id: this.selectedCharacterId, + use_tts: false, // 부하 테스트에서는 TTS 비활성화 + request_at: new Date().toISOString() + }; + + const headers = { + 'Authorization': `Bearer ${this.accessToken}` + }; + + const response = await this.makeHttpRequest('POST', '/api/v1/chat', chatRequest, headers); + + if (response.success) { + this.stats.chatRequests++; + console.log(`[Client ${this.clientId}] Chat sent successfully (${response.responseTime}ms)`); + return true; + } else { + this.stats.chatErrors++; + console.error(`[Client ${this.clientId}] Chat failed:`, response.error || `HTTP ${response.statusCode}`); + return false; + } + } catch (error) { + this.stats.chatErrors++; + console.error(`[Client ${this.clientId}] Chat exception:`, error.message); + return false; + } + } + + // 메인 클라이언트 실행 루프 + async start() { + this.running = true; + console.log(`[Client ${this.clientId}] Starting chat load test...`); + + // Phase 1: Guest Login + const authenticated = await this.authenticateAsGuest(); + if (!authenticated) { + console.error(`[Client ${this.clientId}] Failed to authenticate, stopping`); + this.running = false; + return; + } + + // Phase 2: WebSocket Connection + const wsConnected = await this.connectWebSocket(); + if (!wsConnected) { + console.error(`[Client ${this.clientId}] Failed to connect WebSocket, continuing without real-time responses`); + } + + // Phase 3 & 4: Chat Loop + while (this.running) { + await this.sendChatMessage(); + + // 채팅 간격 대기 (랜덤 지터 추가) + const delay = CONFIG.chatInterval + (Math.random() * 2000 - 1000); // ±1초 지터 + await new Promise(resolve => setTimeout(resolve, Math.max(1000, delay))); + } + + // Phase 5: Cleanup + this.cleanup(); + console.log(`[Client ${this.clientId}] Stopped`); + } + + stop() { + this.running = false; + } + + cleanup() { + if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) { + this.webSocket.close(); + } + } + + getStats() { + const totalRequests = this.stats.guestLogins + this.stats.chatRequests; + const avgResponseTime = totalRequests > 0 ? + Math.round(this.stats.totalResponseTime / totalRequests) : 0; + + return { + ...this.stats, + totalRequests, + avgResponseTime, + authSuccessRate: this.stats.guestLogins > 0 ? + Math.round((this.stats.guestLogins / (this.stats.guestLogins + this.stats.guestLoginErrors)) * 100) : 0, + chatSuccessRate: this.stats.chatRequests > 0 ? + Math.round((this.stats.chatSuccesses / this.stats.chatRequests) * 100) : 0, + wsSuccessRate: this.stats.wsConnections > 0 ? + Math.round((this.stats.wsConnections / (this.stats.wsConnections + this.stats.wsConnectionErrors)) * 100) : 0 + }; + } +} + +class ChatLoadTestManager { + constructor() { + this.clients = []; + this.startTime = null; + this.endTime = null; + this.statsInterval = null; + } + + async run() { + console.log('=== ProjectVG Chat API Load Test ==='); + console.log(`API URL: ${CONFIG.apiUrl}`); + console.log(`WebSocket URL: ${CONFIG.wsUrl}`); + console.log(`Clients: ${CONFIG.clients}`); + console.log(`Duration: ${CONFIG.duration} seconds`); + console.log(`Ramp-up: ${CONFIG.rampUp} seconds`); + console.log(`Chat Interval: ${CONFIG.chatInterval}ms`); + console.log(`Characters: ${CONFIG.characters.length} available`); + console.log(''); + + // API 연결 테스트 + console.log('Testing API connection...'); + try { + const testClient = new ChatLoadTestClient(0); + const testResult = await testClient.makeHttpRequest('GET', '/health'); + if (testResult.success) { + console.log(`✓ API is accessible (${testResult.responseTime}ms)`); + } else { + console.log(`✗ API connection failed: ${testResult.error}`); + process.exit(1); + } + } catch (error) { + console.log(`✗ API connection failed: ${error.message}`); + process.exit(1); + } + + console.log(''); + console.log('Starting chat load test...'); + + this.startTime = Date.now(); + + // 클라이언트 생성 및 점진적 시작 + for (let i = 0; i < CONFIG.clients; i++) { + const client = new ChatLoadTestClient(i + 1); + this.clients.push(client); + + // 점진적 시작 (Ramp-up) + setTimeout(() => { + client.start(); + }, (i / CONFIG.clients) * CONFIG.rampUp * 1000); + } + + // 통계 출력 + this.statsInterval = setInterval(() => { + this.printStats(); + }, 10000); // 10초마다 통계 출력 + + // 테스트 종료 + setTimeout(() => { + this.stop(); + }, CONFIG.duration * 1000); + + // 종료 신호 처리 + process.on('SIGINT', () => { + console.log('\nReceived SIGINT, stopping chat load test...'); + this.stop(); + }); + } + + stop() { + console.log('\nStopping chat load test...'); + + this.clients.forEach(client => client.stop()); + + if (this.statsInterval) { + clearInterval(this.statsInterval); + } + + setTimeout(() => { + this.endTime = Date.now(); + this.printFinalStats(); + process.exit(0); + }, 5000); // 5초 대기 후 최종 통계 출력 + } + + printStats() { + const totalStats = this.aggregateStats(); + const runtime = Math.round((Date.now() - this.startTime) / 1000); + const chatRPS = Math.round(totalStats.chatRequests / runtime); + const totalRPS = Math.round(totalStats.totalRequests / runtime); + + console.log(`[${runtime}s] Auth: ${totalStats.guestLogins}/${totalStats.guestLoginErrors} | Chat: ${totalStats.chatRequests}/${totalStats.chatErrors} | WS: ${totalStats.wsConnections}/${totalStats.wsMessages} | RPS: ${chatRPS}(chat)/${totalRPS}(total) | Avg: ${totalStats.avgResponseTime}ms`); + } + + printFinalStats() { + console.log('\n=== Chat Load Test Results ==='); + const totalStats = this.aggregateStats(); + const duration = Math.round((this.endTime - this.startTime) / 1000); + const chatRPS = Math.round(totalStats.chatRequests / duration); + const totalRPS = Math.round(totalStats.totalRequests / duration); + + console.log(`Duration: ${duration} seconds`); + console.log(`\n--- Authentication Stats ---`); + console.log(`Guest Logins: ${totalStats.guestLogins} (${totalStats.authSuccessRate}% success)`); + console.log(`Login Errors: ${totalStats.guestLoginErrors}`); + + console.log(`\n--- Chat API Stats ---`); + console.log(`Chat Requests: ${totalStats.chatRequests}`); + console.log(`Chat Successes: ${totalStats.chatSuccesses} (${totalStats.chatSuccessRate}% success)`); + console.log(`Chat Errors: ${totalStats.chatErrors}`); + console.log(`Chat RPS: ${chatRPS}`); + + console.log(`\n--- WebSocket Stats ---`); + console.log(`WS Connections: ${totalStats.wsConnections} (${totalStats.wsSuccessRate}% success)`); + console.log(`WS Connection Errors: ${totalStats.wsConnectionErrors}`); + console.log(`WS Messages Received: ${totalStats.wsMessages}`); + console.log(`WS Errors: ${totalStats.wsErrors}`); + + console.log(`\n--- Overall Performance ---`); + console.log(`Total Requests: ${totalStats.totalRequests}`); + console.log(`Total RPS: ${totalRPS}`); + console.log(`Response Time: Min=${totalStats.minResponseTime}ms, Avg=${totalStats.avgResponseTime}ms, Max=${totalStats.maxResponseTime}ms`); + + console.log('\n=== Per-Client Stats ==='); + this.clients.slice(0, 5).forEach((client, index) => { // 처음 5개 클라이언트만 표시 + const stats = client.getStats(); + console.log(`Client ${index + 1}: Auth=${stats.guestLogins}, Chat=${stats.chatRequests}, WS=${stats.wsConnections}, Avg=${stats.avgResponseTime}ms`); + }); + if (this.clients.length > 5) { + console.log(`... and ${this.clients.length - 5} more clients`); + } + } + + aggregateStats() { + const aggregated = this.clients.reduce((total, client) => { + const stats = client.getStats(); + return { + guestLogins: total.guestLogins + stats.guestLogins, + guestLoginErrors: total.guestLoginErrors + stats.guestLoginErrors, + chatRequests: total.chatRequests + stats.chatRequests, + chatSuccesses: total.chatSuccesses + stats.chatSuccesses, + chatErrors: total.chatErrors + stats.chatErrors, + wsConnections: total.wsConnections + stats.wsConnections, + wsConnectionErrors: total.wsConnectionErrors + stats.wsConnectionErrors, + wsMessages: total.wsMessages + stats.wsMessages, + wsErrors: total.wsErrors + stats.wsErrors, + totalRequests: total.totalRequests + stats.totalRequests, + totalResponseTime: total.totalResponseTime + stats.totalResponseTime, + minResponseTime: Math.min(total.minResponseTime, stats.minResponseTime === Infinity ? 0 : stats.minResponseTime), + maxResponseTime: Math.max(total.maxResponseTime, stats.maxResponseTime) + }; + }, { + guestLogins: 0, + guestLoginErrors: 0, + chatRequests: 0, + chatSuccesses: 0, + chatErrors: 0, + wsConnections: 0, + wsConnectionErrors: 0, + wsMessages: 0, + wsErrors: 0, + totalRequests: 0, + totalResponseTime: 0, + minResponseTime: Infinity, + maxResponseTime: 0 + }); + + // 계산된 통계 추가 + aggregated.avgResponseTime = aggregated.totalRequests > 0 ? + Math.round(aggregated.totalResponseTime / aggregated.totalRequests) : 0; + + aggregated.authSuccessRate = (aggregated.guestLogins + aggregated.guestLoginErrors) > 0 ? + Math.round((aggregated.guestLogins / (aggregated.guestLogins + aggregated.guestLoginErrors)) * 100) : 0; + + aggregated.chatSuccessRate = aggregated.chatRequests > 0 ? + Math.round((aggregated.chatSuccesses / aggregated.chatRequests) * 100) : 0; + + aggregated.wsSuccessRate = (aggregated.wsConnections + aggregated.wsConnectionErrors) > 0 ? + Math.round((aggregated.wsConnections / (aggregated.wsConnections + aggregated.wsConnectionErrors)) * 100) : 0; + + return aggregated; + } +} + +// 실행 +if (require.main === module) { + const manager = new ChatLoadTestManager(); + manager.run().catch(error => { + console.error('Chat load test failed:', error); + process.exit(1); + }); +} + +module.exports = ChatLoadTestManager; \ No newline at end of file diff --git a/test-loadtest/dummy-llm-server/Dockerfile b/test-loadtest/dummy-llm-server/Dockerfile new file mode 100644 index 0000000..3b8b8bc --- /dev/null +++ b/test-loadtest/dummy-llm-server/Dockerfile @@ -0,0 +1,22 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --only=production + +# Copy source code +COPY . . + +# Expose port +EXPOSE 7808 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:7808/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" + +# Start server +CMD ["npm", "start"] \ No newline at end of file diff --git a/test-loadtest/dummy-llm-server/package.json b/test-loadtest/dummy-llm-server/package.json new file mode 100644 index 0000000..db4d823 --- /dev/null +++ b/test-loadtest/dummy-llm-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "dummy-llm-server", + "version": "1.0.0", + "description": "Dummy LLM server for load testing", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=16" + } +} \ No newline at end of file diff --git a/test-loadtest/dummy-llm-server/server.js b/test-loadtest/dummy-llm-server/server.js new file mode 100644 index 0000000..8d59190 --- /dev/null +++ b/test-loadtest/dummy-llm-server/server.js @@ -0,0 +1,109 @@ +const express = require('express'); +const cors = require('cors'); + +const app = express(); +const PORT = process.env.PORT || 7808; +const DELAY_MIN = parseInt(process.env.RESPONSE_DELAY_MIN || '1000'); +const DELAY_MAX = parseInt(process.env.RESPONSE_DELAY_MAX || '2000'); + +app.use(cors()); +app.use(express.json()); + +// Helper function to simulate processing delay +const simulateDelay = () => { + const delay = Math.floor(Math.random() * (DELAY_MAX - DELAY_MIN + 1)) + DELAY_MIN; + return new Promise(resolve => setTimeout(resolve, delay)); +}; + +// ProjectVG API format endpoint +app.post('/api/v1/chat', async (req, res) => { + console.log(`[${new Date().toISOString()}] ProjectVG chat request received`); + + const startTime = Date.now(); + await simulateDelay(); + + const { + system_prompt, + user_prompt, + conversation_history, + model, + max_tokens, + temperature, + instructions + } = req.body; + + // Generate ProjectVG-compatible response + const responseId = `llm_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const requestId = `req_${Date.now()}`; + // Generate structured response in ProjectVG format + const emotions = ['neutral', 'happy', 'sad', 'angry', 'surprised', 'confused', 'shy', 'excited']; + const actions = ['tilting_head', 'smiling', 'sighing', 'blushing', 'looking_away', 'clapping', 'jumping', 'waving']; + + const emotion = emotions[Math.floor(Math.random() * emotions.length)]; + const action = actions[Math.floor(Math.random() * actions.length)]; + + const userMsg = user_prompt || 'no message'; + let responseText; + + if (userMsg.toLowerCase().includes('hello') || userMsg.toLowerCase().includes('안녕')) { + responseText = `[emotion:${emotion}](action:${action})""안녕하세요! 저는 더미 AI입니다.""[emotion:happy]""${userMsg}라고 말씀해주셔서 감사해요!""`; + } else if (userMsg.toLowerCase().includes('how are you') || userMsg.toLowerCase().includes('어떻게')) { + responseText = `[emotion:${emotion}](action:${action})""저는 부하 테스트 중이라 매우 바쁘답니다!""[emotion:excited]""하지만 테스트는 잘 진행되고 있어요!""`; + } else { + responseText = `[emotion:${emotion}](action:${action})""${userMsg}에 대한 더미 응답입니다.""[emotion:neutral]""부하 테스트용 가짜 AI 응답이에요!""`; + } + + console.log(`[${new Date().toISOString()}] Generated structured response: ${responseText.substring(0, 100)}...`); + + const dummyResponse = { + id: responseId, + request_id: requestId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: "completed", + model: model || "dummy-model", + output_text: responseText, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cached_tokens: 0, + reasoning_tokens: 0, + text_format_type: "text", + cost: 0, + response_time: (Date.now() - startTime) / 1000, + success: true, + error: null, + use_user_api_key: false + }; + + console.log(`[${new Date().toISOString()}] ProjectVG chat response sent: ${responseText.substring(0, 50)}...`); + res.json(dummyResponse); +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'dummy-llm-server', + port: PORT, + timestamp: new Date().toISOString() + }); +}); + +// Catch all endpoint for any missed routes +app.all('*', (req, res) => { + console.log(`[${new Date().toISOString()}] Unhandled request: ${req.method} ${req.path}`); + res.status(404).json({ + error: 'Not Found', + message: 'This is a dummy LLM server for load testing', + service: 'dummy-llm-server' + }); +}); + +app.listen(PORT, () => { + console.log(`Dummy LLM Server running on port ${PORT}`); + console.log(`Response delay: ${DELAY_MIN}ms - ${DELAY_MAX}ms`); + console.log(`Health check: http://localhost:${PORT}/health`); +}); + +module.exports = app; \ No newline at end of file diff --git a/test-loadtest/dummy-memory-server/Dockerfile b/test-loadtest/dummy-memory-server/Dockerfile new file mode 100644 index 0000000..d291712 --- /dev/null +++ b/test-loadtest/dummy-memory-server/Dockerfile @@ -0,0 +1,22 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --only=production + +# Copy source code +COPY . . + +# Expose port +EXPOSE 7812 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:7812/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" + +# Start server +CMD ["npm", "start"] \ No newline at end of file diff --git a/test-loadtest/dummy-memory-server/package.json b/test-loadtest/dummy-memory-server/package.json new file mode 100644 index 0000000..76f5bd7 --- /dev/null +++ b/test-loadtest/dummy-memory-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "dummy-memory-server", + "version": "1.0.0", + "description": "Dummy Memory server for load testing", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=16" + } +} \ No newline at end of file diff --git a/test-loadtest/dummy-memory-server/server.js b/test-loadtest/dummy-memory-server/server.js new file mode 100644 index 0000000..85b183d --- /dev/null +++ b/test-loadtest/dummy-memory-server/server.js @@ -0,0 +1,282 @@ +const express = require('express'); +const cors = require('cors'); + +const app = express(); +const PORT = process.env.PORT || 7812; +const DELAY = parseInt(process.env.RESPONSE_DELAY || '100'); + +app.use(cors()); +app.use(express.json()); + +// In-memory storage for dummy data +const episodicMemories = new Map(); +const semanticMemories = new Map(); +let documentCounter = 1; + +// Helper function to simulate processing delay +const simulateDelay = () => { + return new Promise(resolve => setTimeout(resolve, DELAY)); +}; + +// VectorMemoryClient API endpoints + +// Insert episodic memory (InsertEpisodicAsync) - Required by ProjectVG +app.post('/api/memory/episodic', async (req, res) => { + console.log(`[${new Date().toISOString()}] Episodic memory insert request received`); + + await simulateDelay(); + + const { text, user_id, speaker, emotion, context, importance_score } = req.body; + + const memoryId = `epi_${documentCounter++}`; + const memory = { + id: memoryId, + text: text || '더미 에피소딕 메모리', + user_id: user_id || 'dummy-user', + speaker: speaker || 'user', + emotion, + context, + importance_score: importance_score || Math.random(), + memory_type: 'episodic', + collection_name: 'episodic_collection', + timestamp: new Date().toISOString() + }; + + episodicMemories.set(memoryId, memory); + + console.log(`[${new Date().toISOString()}] Episodic memory stored: ${memoryId}`); + res.json(memory); +}); + +// Insert semantic memory (InsertSemanticAsync) - Required by ProjectVG +app.post('/api/memory/semantic', async (req, res) => { + console.log(`[${new Date().toISOString()}] Semantic memory insert request received`); + + await simulateDelay(); + + const { text, user_id, fact_type, confidence_score, importance_score, last_updated } = req.body; + + const memoryId = `sem_${documentCounter++}`; + const memory = { + id: memoryId, + text: text || '더미 시맨틱 메모리', + user_id: user_id || 'dummy-user', + fact_type: fact_type || 'general', + confidence_score: confidence_score || Math.random(), + importance_score: importance_score || Math.random(), + last_updated: last_updated || new Date().toISOString(), + memory_type: 'semantic', + collection_name: 'semantic_collection', + timestamp: new Date().toISOString() + }; + + semanticMemories.set(memoryId, memory); + + console.log(`[${new Date().toISOString()}] Semantic memory stored: ${memoryId}`); + res.json(memory); +}); + +// Auto-insert memory (InsertAutoAsync) +app.post('/api/memory', async (req, res) => { + console.log(`[${new Date().toISOString()}] Auto memory insert request received`); + + await simulateDelay(); + + const { text, user_id, speaker, emotion, context, importance_score } = req.body; + + // Simulate auto-classification (50% episodic, 50% semantic) + const isEpisodic = Math.random() > 0.5; + const memoryType = isEpisodic ? 'episodic' : 'semantic'; + const store = isEpisodic ? episodicMemories : semanticMemories; + + const memoryId = `mem_${documentCounter++}`; + const memory = { + id: memoryId, + text: text || `더미 ${memoryType} 메모리`, + user_id: user_id || 'dummy-user', + speaker: speaker || 'user', + emotion, + context, + importance_score: importance_score || Math.random(), + memory_type: memoryType, + collection_name: `${memoryType}_collection`, + timestamp: new Date().toISOString(), + classification_confidence: Math.random() * 0.3 + 0.7, + classification_explanation: `Auto-classified as ${memoryType} based on content analysis` + }; + + store.set(memoryId, memory); + + console.log(`[${new Date().toISOString()}] Auto memory stored as ${memoryType}: ${memoryId}`); + res.json(memory); +}); + + + + + +// Multi-search across both memory types +app.get('/api/memory/search/multi', async (req, res) => { + console.log(`[${new Date().toISOString()}] Multi-search request received`); + + await simulateDelay(); + + const { query, limit = 10 } = req.query; + const userId = req.headers['x-user-id']; + const halfLimit = Math.ceil(limit / 2); + + const episodicResults = Array.from(episodicMemories.values()) + .filter(mem => !userId || mem.user_id === userId) + .slice(0, halfLimit) + .map(mem => ({ + text: mem.text, + score: Math.random() * 0.4 + 0.6 + })); + + const semanticResults = Array.from(semanticMemories.values()) + .filter(mem => !userId || mem.user_id === userId) + .slice(0, halfLimit) + .map(mem => ({ + text: mem.text, + score: Math.random() * 0.4 + 0.6 + })); + + const response = { + episodic_results: episodicResults, + semantic_results: semanticResults, + total_results: episodicResults.length + semanticResults.length + }; + + console.log(`[${new Date().toISOString()}] Multi-search completed: ${response.total_results} results`); + res.json(response); +}); + + + +// Legacy endpoints (keeping for backward compatibility) + +// Store document/memory +app.post('/api/memory/store', async (req, res) => { + console.log(`[${new Date().toISOString()}] Memory store request received`); + + await simulateDelay(); + + const { content, metadata, userId, characterId } = req.body; + + const documentId = `doc_${documentCounter++}`; + const document = { + id: documentId, + content: content || `더미 메모리 콘텐츠 ${documentId}`, + metadata: metadata || { type: 'conversation', importance: 'medium' }, + userId: userId || 'dummy-user', + characterId: characterId || 'dummy-character', + timestamp: new Date().toISOString(), + vectorEmbedding: Array.from({ length: 384 }, () => Math.random()) // 더미 임베딩 벡터 + }; + + episodicMemories.set(documentId, document); + + console.log(`[${new Date().toISOString()}] Memory stored with ID: ${documentId}`); + res.json({ + success: true, + documentId: documentId, + message: '메모리가 성공적으로 저장되었습니다.' + }); +}); + +// Search/retrieve memories +app.post('/api/memory/search', async (req, res) => { + console.log(`[${new Date().toISOString()}] Memory search request received`); + + await simulateDelay(); + + const { query, userId, characterId, limit = 5, threshold = 0.7 } = req.body; + + // Simulate relevant memory retrieval + const allDocuments = Array.from([...episodicMemories.values(), ...semanticMemories.values()]); + const userDocuments = allDocuments.filter(doc => + !userId || doc.userId === userId + ).filter(doc => + !characterId || doc.characterId === characterId + ); + + // Generate dummy search results + const searchResults = userDocuments.slice(0, limit).map((doc, index) => ({ + ...doc, + relevanceScore: Math.max(0.5, 1 - (index * 0.1)), // 감소하는 관련성 점수 + snippet: doc.content.substring(0, 150) + '...' + })); + + console.log(`[${new Date().toISOString()}] Memory search completed, ${searchResults.length} results`); + res.json({ + success: true, + results: searchResults, + query: query, + totalFound: searchResults.length + }); +}); + + + + + + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'dummy-memory-server', + port: PORT, + totalMemories: episodicMemories.size + semanticMemories.size, + responseDelay: `${DELAY}ms`, + timestamp: new Date().toISOString() + }); +}); + + +// Catch all endpoint for any missed routes +app.all('*', (req, res) => { + console.log(`[${new Date().toISOString()}] Unhandled request: ${req.method} ${req.path}`); + res.status(404).json({ + error: 'Not Found', + message: 'This is a dummy Memory server for load testing', + service: 'dummy-memory-server' + }); +}); + +app.listen(PORT, () => { + console.log(`Dummy Memory Server running on port ${PORT}`); + console.log(`Response delay: ${DELAY}ms`); + console.log(`Health check: http://localhost:${PORT}/health`); + + // Initialize with some dummy data + for (let i = 1; i <= 10; i++) { + const isEpisodic = i % 2 === 0; + const store = isEpisodic ? episodicMemories : semanticMemories; + const memType = isEpisodic ? 'episodic' : 'semantic'; + + const memId = `${memType.substring(0,3)}_${i}`; + store.set(memId, { + id: memId, + text: `이것은 더미 ${memType} 메모리 콘텐츠 ${i}입니다. 부하 테스트용 가짜 데이터입니다.`, + user_id: `user_${Math.ceil(i / 3)}`, + memory_type: memType, + collection_name: `${memType}_collection`, + timestamp: new Date(Date.now() - i * 86400000).toISOString(), + importance_score: Math.random(), + ...(isEpisodic ? { + speaker: 'user', + emotion: { valence: Math.random(), arousal: Math.random() }, + context: `Context ${i}` + } : { + fact_type: 'general', + confidence_score: Math.random() + }) + }); + } + documentCounter = 11; + + console.log(`Initialized with ${episodicMemories.size + semanticMemories.size} dummy memories`); +}); + +module.exports = app; \ No newline at end of file diff --git a/test-loadtest/dummy-tts-server/Dockerfile b/test-loadtest/dummy-tts-server/Dockerfile new file mode 100644 index 0000000..5792184 --- /dev/null +++ b/test-loadtest/dummy-tts-server/Dockerfile @@ -0,0 +1,22 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --only=production + +# Copy source code +COPY . . + +# Expose port +EXPOSE 7816 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:7816/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" + +# Start server +CMD ["npm", "start"] \ No newline at end of file diff --git a/test-loadtest/dummy-tts-server/package.json b/test-loadtest/dummy-tts-server/package.json new file mode 100644 index 0000000..726e211 --- /dev/null +++ b/test-loadtest/dummy-tts-server/package.json @@ -0,0 +1,21 @@ +{ + "name": "dummy-tts-server", + "version": "1.0.0", + "description": "Dummy TTS server for load testing", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": { + "nodemon": "^3.0.1" + }, + "engines": { + "node": ">=16" + } +} \ No newline at end of file diff --git a/test-loadtest/dummy-tts-server/public/Take1-1__voice.wav b/test-loadtest/dummy-tts-server/public/Take1-1__voice.wav new file mode 100644 index 0000000..71902f6 Binary files /dev/null and b/test-loadtest/dummy-tts-server/public/Take1-1__voice.wav differ diff --git a/test-loadtest/dummy-tts-server/public/Take1-2_voice.wav b/test-loadtest/dummy-tts-server/public/Take1-2_voice.wav new file mode 100644 index 0000000..4a01ef0 Binary files /dev/null and b/test-loadtest/dummy-tts-server/public/Take1-2_voice.wav differ diff --git a/test-loadtest/dummy-tts-server/server.js b/test-loadtest/dummy-tts-server/server.js new file mode 100644 index 0000000..ae38e7a --- /dev/null +++ b/test-loadtest/dummy-tts-server/server.js @@ -0,0 +1,132 @@ +const express = require('express'); +const cors = require('cors'); +const multer = require('multer'); +const fs = require('fs'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 7816; +const DELAY_MIN = parseInt(process.env.RESPONSE_DELAY_MIN || '2000'); +const DELAY_MAX = parseInt(process.env.RESPONSE_DELAY_MAX || '3000'); + +// Configure multer for file uploads +const upload = multer({ storage: multer.memoryStorage() }); + +app.use(cors()); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Helper function to simulate processing delay +const simulateDelay = () => { + const delay = Math.floor(Math.random() * (DELAY_MAX - DELAY_MIN + 1)) + DELAY_MIN; + return new Promise(resolve => setTimeout(resolve, delay)); +}; + +// Generate dummy audio data (fake WAV format) +const generateDummyAudio = (duration = 5) => { + // WAV header for a mono 16-bit 22050Hz file + const sampleRate = 22050; + const samples = sampleRate * duration; + const dataSize = samples * 2; // 16-bit = 2 bytes per sample + const fileSize = 44 + dataSize; + + const buffer = Buffer.alloc(fileSize); + + // WAV header + buffer.write('RIFF', 0); + buffer.writeUInt32LE(fileSize - 8, 4); + buffer.write('WAVE', 8); + buffer.write('fmt ', 12); + buffer.writeUInt32LE(16, 16); // PCM format chunk size + buffer.writeUInt16LE(1, 20); // PCM format + buffer.writeUInt16LE(1, 22); // Mono + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * 2, 28); // Byte rate + buffer.writeUInt16LE(2, 32); // Block align + buffer.writeUInt16LE(16, 34); // Bits per sample + buffer.write('data', 36); + buffer.writeUInt32LE(dataSize, 40); + + // Generate random audio data (pure random noise) + for (let i = 0; i < samples; i++) { + // Pure random noise - completely random audio data + const sample = (Math.random() - 0.5) * 32767; + + buffer.writeInt16LE(sample, 44 + i * 2); + } + + return buffer; +}; + + +// ProjectVG TextToSpeechClient compatible endpoint +app.post('/v1/text-to-speech/:voiceId', async (req, res) => { + console.log(`[${new Date().toISOString()}] ProjectVG TTS request with voice: ${req.params.voiceId}`); + + await simulateDelay(); + + const { text, speed = 1.0, pitch = 1.0 } = req.body; + const voiceId = req.params.voiceId; + + if (!text || text.trim().length === 0) { + return res.status(400).json({ + error: 'Text is required', + message: '변환할 텍스트가 필요합니다.' + }); + } + + // Calculate actual audio duration for generating audio data + const wordsPerMinute = 150 * speed; + const wordCount = text.trim().split(/\s+/).length; + const actualDuration = Math.max(1, Math.ceil((wordCount / wordsPerMinute) * 60)); + + // Generate normal dummy audio with actual duration + const audioBuffer = generateDummyAudio(actualDuration); + + // For load testing: report audio length as 0 (but send real audio data) + const reportedDuration = 0; + + console.log(`[${new Date().toISOString()}] ProjectVG TTS synthesis completed with voice ${voiceId}`); + + // Return audio buffer with essential headers + res.set({ + 'Content-Type': 'audio/wav', + 'Content-Length': audioBuffer.length, + 'X-Audio-Length': reportedDuration.toString(), + 'X-Text-Length': text.length.toString(), + 'X-Voice-Id': voiceId + }); + + res.send(audioBuffer); +}); + + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'dummy-tts-server', + port: PORT, + responseDelay: `${DELAY_MIN}ms - ${DELAY_MAX}ms`, + timestamp: new Date().toISOString() + }); +}); + + +// Catch all endpoint for any missed routes +app.all('*', (req, res) => { + console.log(`[${new Date().toISOString()}] Unhandled request: ${req.method} ${req.path}`); + res.status(404).json({ + error: 'Not Found', + message: 'This is a dummy TTS server for load testing', + service: 'dummy-tts-server' + }); +}); + +app.listen(PORT, () => { + console.log(`Dummy TTS Server running on port ${PORT}`); + console.log(`Response delay: ${DELAY_MIN}ms - ${DELAY_MAX}ms`); + console.log(`Health check: http://localhost:${PORT}/health`); +}); + +module.exports = app; \ No newline at end of file diff --git a/test-loadtest/monitoring-dashboard.html b/test-loadtest/monitoring-dashboard.html new file mode 100644 index 0000000..e3edf63 --- /dev/null +++ b/test-loadtest/monitoring-dashboard.html @@ -0,0 +1,566 @@ + + + + + + ProjectVG LoadTest Performance Monitor + + + +
+

ProjectVG LoadTest Performance Monitor

+
Connecting...
+
Last Update: Never
+
+ +
+ + + + + +
+ +
+ +
+

🖥️ Process Metrics

+
+ Process ID: + - +
+
+ Working Memory: +
+
+
+
+ - MB +
+
+
+ Private Memory: + - MB +
+
+ Virtual Memory: + - MB +
+
+ + +
+

🧵 ThreadPool Status

+
+ Worker Threads: + - +
+
+ Completion Threads: + - +
+
+ Available Threads: + - +
+
+ Pending Work: +
+
+
+
+ - +
+
+
+ + +
+

🗑️ Garbage Collection

+
+ Gen 0 Collections: + - +
+
+ Gen 1 Collections: + - +
+
+ Gen 2 Collections: + - +
+
+ Total Memory: +
+
+
+
+ - MB +
+
+
+ Allocated Memory: + - MB +
+
+ + +
+

🖥️ System Information

+
+ CPU Count: + - +
+
+ Machine Name: + - +
+
+ Environment: + LoadTest +
+
+ + +
+

⚠️ Alerts & Log

+
+
+
Performance monitoring dashboard ready
+
+
+ + +
+

⚡ Quick Actions

+
+ API Health: + +
+
+ Detailed Health: + +
+
+ ThreadPool Info: + +
+
+
+ + + + \ No newline at end of file diff --git a/test-loadtest/package-lock.json b/test-loadtest/package-lock.json new file mode 100644 index 0000000..5ee26cf --- /dev/null +++ b/test-loadtest/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "test-loadtest", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "ws": "^8.18.3" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/test-loadtest/package.json b/test-loadtest/package.json new file mode 100644 index 0000000..31fef03 --- /dev/null +++ b/test-loadtest/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "ws": "^8.18.3" + } +} diff --git a/test-loadtest/simple-loadtest.js b/test-loadtest/simple-loadtest.js new file mode 100644 index 0000000..d152859 --- /dev/null +++ b/test-loadtest/simple-loadtest.js @@ -0,0 +1,276 @@ +#!/usr/bin/env node + +// Simple LoadTest Script for ProjectVG API +// HTTP 부하테스트를 통한 성능 모니터링 + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +// 설정 +const CONFIG = { + apiUrl: process.env.API_URL || 'http://localhost:7804', + clients: parseInt(process.env.CLIENTS || '10'), + duration: parseInt(process.env.DURATION || '30'), + rampUp: parseInt(process.env.RAMP_UP || '5'), + endpoints: [ + '/health', + '/api/v1/monitoring/metrics', + '/api/v1/monitoring/health-detailed', + '/api/v1/monitoring/threadpool', + '/api/v1/monitoring/gc' + ] +}; + +class LoadTestClient { + constructor(clientId) { + this.clientId = clientId; + this.stats = { + requests: 0, + success: 0, + errors: 0, + totalResponseTime: 0, + minResponseTime: Infinity, + maxResponseTime: 0 + }; + this.running = false; + } + + async makeRequest(endpoint) { + return new Promise((resolve) => { + const startTime = Date.now(); + const url = new URL(endpoint, CONFIG.apiUrl); + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: 'GET', + timeout: 5000, + headers: { + 'User-Agent': `LoadTest-Client-${this.clientId}` + } + }; + + const client = url.protocol === 'https:' ? https : http; + const req = client.request(options, (res) => { + const responseTime = Date.now() - startTime; + + // 응답 데이터 소비 (메모리 누수 방지) + res.on('data', () => {}); + res.on('end', () => { + this.stats.requests++; + this.stats.totalResponseTime += responseTime; + this.stats.minResponseTime = Math.min(this.stats.minResponseTime, responseTime); + this.stats.maxResponseTime = Math.max(this.stats.maxResponseTime, responseTime); + + if (res.statusCode >= 200 && res.statusCode < 300) { + this.stats.success++; + } else { + this.stats.errors++; + } + + resolve({ success: true, responseTime, statusCode: res.statusCode }); + }); + }); + + req.on('error', (error) => { + const responseTime = Date.now() - startTime; + this.stats.requests++; + this.stats.errors++; + this.stats.totalResponseTime += responseTime; + resolve({ success: false, responseTime, error: error.message }); + }); + + req.on('timeout', () => { + req.destroy(); + const responseTime = Date.now() - startTime; + this.stats.requests++; + this.stats.errors++; + this.stats.totalResponseTime += responseTime; + resolve({ success: false, responseTime, error: 'Timeout' }); + }); + + req.end(); + }); + } + + async start() { + this.running = true; + console.log(`[Client ${this.clientId}] Starting load test...`); + + while (this.running) { + // 랜덤하게 엔드포인트 선택 + const endpoint = CONFIG.endpoints[Math.floor(Math.random() * CONFIG.endpoints.length)]; + + try { + await this.makeRequest(endpoint); + + // 요청 간격 (50-200ms) + const delay = 50 + Math.random() * 150; + await new Promise(resolve => setTimeout(resolve, delay)); + } catch (error) { + console.error(`[Client ${this.clientId}] Error:`, error.message); + } + } + + console.log(`[Client ${this.clientId}] Stopped`); + } + + stop() { + this.running = false; + } + + getStats() { + return { + ...this.stats, + avgResponseTime: this.stats.requests > 0 ? + Math.round(this.stats.totalResponseTime / this.stats.requests) : 0, + successRate: this.stats.requests > 0 ? + Math.round((this.stats.success / this.stats.requests) * 100) : 0 + }; + } +} + +class LoadTestManager { + constructor() { + this.clients = []; + this.startTime = null; + this.endTime = null; + this.statsInterval = null; + } + + async run() { + console.log('=== ProjectVG API Load Test ==='); + console.log(`API URL: ${CONFIG.apiUrl}`); + console.log(`Clients: ${CONFIG.clients}`); + console.log(`Duration: ${CONFIG.duration} seconds`); + console.log(`Ramp-up: ${CONFIG.rampUp} seconds`); + console.log(`Endpoints: ${CONFIG.endpoints.join(', ')}`); + console.log(''); + + // API 연결 테스트 + console.log('Testing API connection...'); + try { + const testResult = await new LoadTestClient(0).makeRequest('/health'); + if (testResult.success) { + console.log(`✓ API is accessible (${testResult.responseTime}ms)`); + } else { + console.log(`✗ API connection failed: ${testResult.error}`); + process.exit(1); + } + } catch (error) { + console.log(`✗ API connection failed: ${error.message}`); + process.exit(1); + } + + console.log(''); + console.log('Starting load test...'); + + this.startTime = Date.now(); + + // 클라이언트 생성 및 점진적 시작 + for (let i = 0; i < CONFIG.clients; i++) { + const client = new LoadTestClient(i + 1); + this.clients.push(client); + + // 점진적 시작 (Ramp-up) + setTimeout(() => { + client.start(); + }, (i / CONFIG.clients) * CONFIG.rampUp * 1000); + } + + // 통계 출력 + this.statsInterval = setInterval(() => { + this.printStats(); + }, 5000); + + // 테스트 종료 + setTimeout(() => { + this.stop(); + }, CONFIG.duration * 1000); + + // 종료 신호 처리 + process.on('SIGINT', () => { + console.log('\nReceived SIGINT, stopping load test...'); + this.stop(); + }); + } + + stop() { + console.log('\nStopping load test...'); + + this.clients.forEach(client => client.stop()); + + if (this.statsInterval) { + clearInterval(this.statsInterval); + } + + setTimeout(() => { + this.endTime = Date.now(); + this.printFinalStats(); + process.exit(0); + }, 2000); + } + + printStats() { + const totalStats = this.aggregateStats(); + const runtime = Math.round((Date.now() - this.startTime) / 1000); + const rps = Math.round(totalStats.requests / runtime); + + console.log(`[${runtime}s] Requests: ${totalStats.requests} | Success: ${totalStats.success} | Errors: ${totalStats.errors} | RPS: ${rps} | Avg: ${totalStats.avgResponseTime}ms`); + } + + printFinalStats() { + console.log('\n=== Load Test Results ==='); + const totalStats = this.aggregateStats(); + const duration = Math.round((this.endTime - this.startTime) / 1000); + const rps = Math.round(totalStats.requests / duration); + + console.log(`Duration: ${duration} seconds`); + console.log(`Total Requests: ${totalStats.requests}`); + console.log(`Successful: ${totalStats.success} (${totalStats.successRate}%)`); + console.log(`Errors: ${totalStats.errors}`); + console.log(`Requests/sec: ${rps}`); + console.log(`Response Time: Min=${totalStats.minResponseTime}ms, Avg=${totalStats.avgResponseTime}ms, Max=${totalStats.maxResponseTime}ms`); + + console.log('\n=== Per-Client Stats ==='); + this.clients.forEach((client, index) => { + const stats = client.getStats(); + console.log(`Client ${index + 1}: ${stats.requests} requests, ${stats.successRate}% success, ${stats.avgResponseTime}ms avg`); + }); + } + + aggregateStats() { + return this.clients.reduce((total, client) => { + const stats = client.getStats(); + return { + requests: total.requests + stats.requests, + success: total.success + stats.success, + errors: total.errors + stats.errors, + totalResponseTime: total.totalResponseTime + stats.totalResponseTime, + minResponseTime: Math.min(total.minResponseTime, stats.minResponseTime === Infinity ? 0 : stats.minResponseTime), + maxResponseTime: Math.max(total.maxResponseTime, stats.maxResponseTime), + avgResponseTime: 0, // 계산 후 설정 + successRate: 0 // 계산 후 설정 + }; + }, { + requests: 0, + success: 0, + errors: 0, + totalResponseTime: 0, + minResponseTime: Infinity, + maxResponseTime: 0 + }); + } +} + +// 실행 +if (require.main === module) { + const manager = new LoadTestManager(); + manager.run().catch(error => { + console.error('Load test failed:', error); + process.exit(1); + }); +} + +module.exports = LoadTestManager; \ No newline at end of file