Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
# ===========================================
# Copy this file to .env and fill in your actual values

ENVIRONMENT=development
DEBUG_MODE=true
LOG_LEVEL=INFO
LOG_FORMAT=json
DATA_PATH=../data
Comment on lines +6 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ENVIRONMENT vs ASPNETCORE_ENVIRONMENT 값 충돌

두 변수가 서로 다른 값을 갖고 있어 실제 실행 환경이 오인될 수 있습니다. 하나로 통일하세요.

-ASPNETCORE_ENVIRONMENT=Production
+ASPNETCORE_ENVIRONMENT=Development

(또는 ENVIRONMENT 키를 제거/주석 처리하고 ASPNETCORE_ENVIRONMENT만 사용)

Also applies to: 50-50

🧰 Tools
🪛 dotenv-linter (3.3.0)

[warning] 7-7: [UnorderedKey] The DEBUG_MODE key should go before the ENVIRONMENT key

(UnorderedKey)


[warning] 9-9: [UnorderedKey] The LOG_FORMAT key should go before the LOG_LEVEL key

(UnorderedKey)


[warning] 10-10: [UnorderedKey] The DATA_PATH key should go before the DEBUG_MODE key

(UnorderedKey)

🤖 Prompt for AI Agents
In .env.example around lines 6 to 10 (and similarly at lines ~50), there is a
potential conflict between ENVIRONMENT and ASPNETCORE_ENVIRONMENT having
different values; pick one environment variable to be authoritative (preferably
ASPNETCORE_ENVIRONMENT for ASP.NET apps) and either remove or comment out the
duplicate ENVIRONMENT entry, or set both to the same value to avoid mismatched
runtime behavior; update the file so only the chosen key remains (or both match)
and add a brief comment explaining which variable the app reads.


# Database Configuration
DB_CONNECTION_STRING=Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

DB 연결 문자열은 따옴표로 감싸기

특수문자 포함 값은 인용이 안전합니다. dotenv-linter 경고도 해소됩니다.

-DB_CONNECTION_STRING=Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true
+DB_CONNECTION_STRING="Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
DB_CONNECTION_STRING=Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true
DB_CONNECTION_STRING="Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true"
🧰 Tools
🪛 dotenv-linter (3.3.0)

[warning] 13-13: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)

🤖 Prompt for AI Agents
.env.example around line 13: the DB_CONNECTION_STRING value contains special
characters and should be wrapped in quotes to be safe and satisfy dotenv-linter;
update the line to enclose the entire connection string in double quotes (e.g.
DB_CONNECTION_STRING="Server=...;Password=...;...") so parsers and linters treat
it as a single quoted value.

DB_PASSWORD=YOUR_DB_PASSWORD

# Redis Configuration
REDIS_CONNECTION_STRING=host.docker.internal:6380

# Distributed System Configuration
DISTRIBUTED_MODE=false
SERVER_ID=

# External Services
LLM_BASE_URL=http://host.docker.internal:7930
MEMORY_BASE_URL=http://host.docker.internal:7940
Expand All @@ -31,6 +41,11 @@ GOOGLE_OAUTH_REDIRECT_URI=http://localhost:7900/auth/oauth2/callback
GOOGLE_OAUTH_AUTO_CREATE_USER=true
GOOGLE_OAUTH_DEFAULT_ROLE=User

# WebSocket Configuration
WEBSOCKET_KEEPALIVE_MINUTES=0
WEBSOCKET_RECEIVE_BUFFER_SIZE=4096
WEBSOCKET_SEND_BUFFER_SIZE=4096

# Application Configuration
ASPNETCORE_ENVIRONMENT=Production

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ _ReSharper*/
# Docker
**/Dockerfile.*
docker-compose.override.yml
.dockerignore.local

# Keep template files but ignore runtime files
!docker-compose.prod.yml
Expand Down
57 changes: 55 additions & 2 deletions ProjectVG.Api/ApiMiddlewareExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public static class ApiMiddlewareExtensions
/// </summary>
public static IApplicationBuilder UseApiMiddleware(this IApplicationBuilder app, IWebHostEnvironment environment)
{
var configuration = app.ApplicationServices.GetRequiredService<IConfiguration>();
// 개발 환경 설정
if (environment.IsDevelopment()) {
app.UseSwagger();
Expand All @@ -23,8 +24,9 @@ public static IApplicationBuilder UseApiMiddleware(this IApplicationBuilder app,
// 전역 예외 처리
app.UseGlobalExceptionHandler();

// WebSocket 지원
app.UseWebSockets();
// WebSocket 지원 - 구성 가능한 옵션 사용
var webSocketOptions = GetWebSocketOptions(configuration);
app.UseWebSockets(webSocketOptions);

// WebSocket 미들웨어 등록
app.UseMiddleware<WebSocketMiddleware>();
Expand Down Expand Up @@ -63,5 +65,56 @@ public static IApplicationBuilder UseDevelopmentFeatures(this IApplicationBuilde

return app;
}

/// <summary>
/// WebSocket 옵션을 구성 파일과 환경 변수에서 가져옵니다
/// </summary>
private static WebSocketOptions GetWebSocketOptions(IConfiguration configuration)
{
var options = new WebSocketOptions();

// KeepAliveInterval 설정 (환경 변수 > appsettings.json 순서)
var keepAliveMinutes = Environment.GetEnvironmentVariable("WEBSOCKET_KEEPALIVE_MINUTES");
if (string.IsNullOrEmpty(keepAliveMinutes))
{
keepAliveMinutes = configuration.GetValue<string>("WebSocket:KeepAliveIntervalMinutes");
}

if (double.TryParse(keepAliveMinutes, out var minutes))
{
if (minutes <= 0)
{
options.KeepAliveInterval = TimeSpan.Zero; // KeepAlive 비활성화
}
else
{
options.KeepAliveInterval = TimeSpan.FromMinutes(minutes);
}
}
else
{
// 기본값: KeepAlive 비활성화 (연결 안정성을 위해)
options.KeepAliveInterval = TimeSpan.Zero;
}

// 수신 버퍼 크기 설정
var receiveBufferSize = Environment.GetEnvironmentVariable("WEBSOCKET_RECEIVE_BUFFER_SIZE") ??
configuration.GetValue<string>("WebSocket:ReceiveBufferSize");
if (int.TryParse(receiveBufferSize, out var recvSize) && recvSize > 0)
{
options.ReceiveBufferSize = recvSize;
}

// 송신 버퍼 크기 설정 (WebSocketOptions에는 없으므로 로깅만)
var sendBufferSize = Environment.GetEnvironmentVariable("WEBSOCKET_SEND_BUFFER_SIZE") ??
configuration.GetValue<string>("WebSocket:SendBufferSize");

// 콘솔 로깅으로 설정 확인
Console.WriteLine($"[WebSocket 설정] KeepAlive: {(options.KeepAliveInterval == TimeSpan.Zero ? "비활성화" : $"{options.KeepAliveInterval.TotalMinutes}분")}, " +
$"ReceiveBuffer: {options.ReceiveBufferSize} bytes" +
$"{(int.TryParse(sendBufferSize, out var sendSize) && sendSize > 0 ? $", SendBuffer: {sendSize} bytes (참고용)" : "")}");

return options;
}
}
}
117 changes: 98 additions & 19 deletions ProjectVG.Api/Middleware/WebSocketMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using ProjectVG.Application.Services.Session;
using ProjectVG.Application.Services.WebSocket;
using ProjectVG.Infrastructure.Auth;
using ProjectVG.Infrastructure.Realtime.WebSocketConnection;
using ProjectVG.Domain.Services.Server;
using System.Net.WebSockets;

namespace ProjectVG.Api.Middleware
Expand All @@ -10,22 +10,25 @@ public class WebSocketMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<WebSocketMiddleware> _logger;
private readonly IWebSocketManager _webSocketService;
private readonly IConnectionRegistry _connectionRegistry;
private readonly ISessionManager _sessionManager;
private readonly IWebSocketConnectionManager _connectionManager;
private readonly IJwtProvider _jwtProvider;
private readonly IServerRegistrationService? _serverRegistrationService;

public WebSocketMiddleware(
RequestDelegate next,
ILogger<WebSocketMiddleware> logger,
IWebSocketManager webSocketService,
IConnectionRegistry connectionRegistry,
IJwtProvider jwtProvider)
ISessionManager sessionManager,
IWebSocketConnectionManager connectionManager,
IJwtProvider jwtProvider,
IServerRegistrationService? serverRegistrationService = null)
{
_next = next;
_logger = logger;
_webSocketService = webSocketService;
_connectionRegistry = connectionRegistry;
_sessionManager = sessionManager;
_connectionManager = connectionManager;
_jwtProvider = jwtProvider;
_serverRegistrationService = serverRegistrationService;
}

public async Task InvokeAsync(HttpContext context)
Expand Down Expand Up @@ -88,19 +91,59 @@ private string ExtractToken(HttpContext context)
return string.Empty;
}

/// <summary>
/// 기존 연결 정리 후 새 연결 등록
/// <summary>
/// 새 아키텍처: 세션 관리와 WebSocket 연결 관리 분리
/// </summary>
private async Task RegisterConnection(Guid userId, WebSocket socket)
{
if (_connectionRegistry.TryGet(userId.ToString(), out var existing) && existing != null) {
_logger.LogInformation("기존 연결 정리: {UserId}", userId);
await _webSocketService.DisconnectAsync(userId.ToString());
}
var userIdString = userId.ToString();
_logger.LogInformation("[WebSocketMiddleware] 연결 등록 시작: UserId={UserId}", userId);

try
{
// 기존 로컬 연결이 있으면 정리
if (_connectionManager.HasLocalConnection(userIdString))
{
_logger.LogInformation("[WebSocketMiddleware] 기존 로컬 연결 발견 - 정리 중: UserId={UserId}", userId);
_connectionManager.UnregisterConnection(userIdString);
}

// 1. 세션 관리자에 세션 생성 (Redis 저장)
await _sessionManager.CreateSessionAsync(userId);
_logger.LogInformation("[WebSocketMiddleware] 세션 관리자에 세션 저장 완료: UserId={UserId}", userId);

// 2. WebSocket 연결 관리자에 로컬 연결 등록
var connection = new WebSocketClientConnection(userIdString, socket);
_connectionManager.RegisterConnection(userIdString, connection);
_logger.LogInformation("[WebSocketMiddleware] 로컬 WebSocket 연결 등록 완료: UserId={UserId}", userId);

// 3. 분산 시스템: 사용자-서버 매핑 저장 (Redis)
if (_serverRegistrationService != null)
{
try
{
var serverId = _serverRegistrationService.GetServerId();
await _serverRegistrationService.SetUserServerAsync(userIdString, serverId);
_logger.LogInformation("[WebSocketMiddleware] 사용자-서버 매핑 저장 완료: UserId={UserId}, ServerId={ServerId}", userId, serverId);
}
catch (Exception mapEx)
{
_logger.LogWarning(mapEx, "[WebSocketMiddleware] 사용자-서버 매핑 저장 실패: UserId={UserId}", userId);
// 매핑 저장 실패는 로그만 남기고 연결은 계속 진행
}
}

var connection = new WebSocketClientConnection(userId.ToString(), socket);
_connectionRegistry.Register(userId.ToString(), connection);
await _webSocketService.ConnectAsync(userId.ToString());
// [디버그] 등록 후 상태 확인
var isSessionActive = await _sessionManager.IsSessionActiveAsync(userId);
var hasLocalConnection = _connectionManager.HasLocalConnection(userIdString);
_logger.LogInformation("[WebSocketMiddleware] 연결 등록 완료: UserId={UserId}, SessionActive={SessionActive}, LocalConnection={LocalConnection}",
userId, isSessionActive, hasLocalConnection);
}
catch (Exception ex)
{
_logger.LogError(ex, "[WebSocketMiddleware] 연결 등록 실패: UserId={UserId}", userId);
throw;
}
}

/// <summary>
Expand Down Expand Up @@ -160,6 +203,17 @@ await socket.SendAsync(
WebSocketMessageType.Text,
true,
cancellationTokenSource.Token);

// 세션 하트비트 업데이트 (Redis TTL 갱신)
try {
if (Guid.TryParse(userId, out var userGuid))
{
await _sessionManager.UpdateSessionHeartbeatAsync(userGuid);
}
}
catch (Exception heartbeatEx) {
_logger.LogWarning(heartbeatEx, "세션 하트비트 업데이트 실패: {UserId}", userId);
}
}
}
}
Expand All @@ -177,14 +231,39 @@ await socket.SendAsync(
_logger.LogInformation("WebSocket 연결 해제: {UserId}", userId);

try {
await _webSocketService.DisconnectAsync(userId);
_connectionRegistry.Unregister(userId);
// 새 아키텍처: 세션과 로컬 연결 분리해서 정리
if (Guid.TryParse(userId, out var userGuid))
{
// 1. 세션 관리자에서 세션 삭제 (Redis에서 제거)
await _sessionManager.DeleteSessionAsync(userGuid);
_logger.LogDebug("세션 관리자에서 세션 삭제 완료: {UserId}", userId);
}

// 2. 분산 시스템: 사용자-서버 매핑 제거 (Redis)
if (_serverRegistrationService != null)
{
try
{
await _serverRegistrationService.RemoveUserServerAsync(userId);
_logger.LogDebug("사용자-서버 매핑 제거 완료: {UserId}", userId);
}
catch (Exception mapEx)
{
_logger.LogWarning(mapEx, "사용자-서버 매핑 제거 실패: {UserId}", userId);
}
}

// 3. 로컬 WebSocket 연결 해제
_connectionManager.UnregisterConnection(userId);
_logger.LogDebug("로컬 WebSocket 연결 해제 완료: {UserId}", userId);

Comment on lines +234 to 259
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

재연결 시 신규 WebSocket 세션까지 정리되는 치명적 버그

동일 사용자로 새로운 WebSocket이 붙으면, 기존 루프가 finally 블록을 실행하면서 _sessionManager.DeleteSessionAsync_connectionManager.UnregisterConnection을 다시 호출합니다. 현재 코드는 사용자 ID만 기준으로 공유 상태를 정리하기 때문에, 새로 등록된 연결까지 통째로 지워지면서 분산 브로커 매핑과 로컬 연결이 동시에 사라집니다.

아래처럼 현재 소켓이 여전히 매니저에 등록된 소켓과 동일한 경우에만 공유 상태를 정리하도록 가드가 필요합니다.

                 try {
-                    // 새 아키텍처: 세션과 로컬 연결 분리해서 정리
-                    if (Guid.TryParse(userId, out var userGuid))
-                    {
-                        // 1. 세션 관리자에서 세션 삭제 (Redis에서 제거)
-                        await _sessionManager.DeleteSessionAsync(userGuid);
-                        _logger.LogDebug("세션 관리자에서 세션 삭제 완료: {UserId}", userId);
-                    }
-
-                    // 2. 분산 시스템: 사용자-서버 매핑 제거 (Redis)
-                    if (_serverRegistrationService != null)
-                    {
-                        try
-                        {
-                            await _serverRegistrationService.RemoveUserServerAsync(userId);
-                            _logger.LogDebug("사용자-서버 매핑 제거 완료: {UserId}", userId);
-                        }
-                        catch (Exception mapEx)
-                        {
-                            _logger.LogWarning(mapEx, "사용자-서버 매핑 제거 실패: {UserId}", userId);
-                        }
-                    }
-
-                    // 3. 로컬 WebSocket 연결 해제
-                    _connectionManager.UnregisterConnection(userId);
-                    _logger.LogDebug("로컬 WebSocket 연결 해제 완료: {UserId}", userId);
+                    var currentConnection = _connectionManager.GetConnection(userId);
+                    var ownsRegistration = currentConnection == null;
+
+                    if (currentConnection is WebSocketClientConnection registeredConnection)
+                    {
+                        ownsRegistration = ReferenceEquals(registeredConnection.WebSocket, socket);
+                    }
+                    else if (currentConnection != null)
+                    {
+                        ownsRegistration = false;
+                    }
+
+                    if (!ownsRegistration)
+                    {
+                        _logger.LogDebug("다른 WebSocket 연결이 이미 등록되어 있어 공유 상태 정리를 건너뜁니다: {UserId}", userId);
+                    }
+                    else
+                    {
+                        if (Guid.TryParse(userId, out var userGuid))
+                        {
+                            // 1. 세션 관리자에서 세션 삭제 (Redis에서 제거)
+                            await _sessionManager.DeleteSessionAsync(userGuid);
+                            _logger.LogDebug("세션 관리자에서 세션 삭제 완료: {UserId}", userId);
+                        }
+
+                        // 2. 분산 시스템: 사용자-서버 매핑 제거 (Redis)
+                        if (_serverRegistrationService != null)
+                        {
+                            try
+                            {
+                                await _serverRegistrationService.RemoveUserServerAsync(userId);
+                                _logger.LogDebug("사용자-서버 매핑 제거 완료: {UserId}", userId);
+                            }
+                            catch (Exception mapEx)
+                            {
+                                _logger.LogWarning(mapEx, "사용자-서버 매핑 제거 실패: {UserId}", userId);
+                            }
+                        }
+
+                        // 3. 로컬 WebSocket 연결 해제
+                        _connectionManager.UnregisterConnection(userId);
+                        _logger.LogDebug("로컬 WebSocket 연결 해제 완료: {UserId}", userId);
+                    }
 
                     // 4. WebSocket 소켓 정리
                     if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) {
                         await socket.CloseAsync(
                             WebSocketCloseStatus.NormalClosure,
                             "Connection closed",
                             CancellationToken.None);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 새 아키텍처: 세션과 로컬 연결 분리해서 정리
if (Guid.TryParse(userId, out var userGuid))
{
// 1. 세션 관리자에서 세션 삭제 (Redis에서 제거)
await _sessionManager.DeleteSessionAsync(userGuid);
_logger.LogDebug("세션 관리자에서 세션 삭제 완료: {UserId}", userId);
}
// 2. 분산 시스템: 사용자-서버 매핑 제거 (Redis)
if (_serverRegistrationService != null)
{
try
{
await _serverRegistrationService.RemoveUserServerAsync(userId);
_logger.LogDebug("사용자-서버 매핑 제거 완료: {UserId}", userId);
}
catch (Exception mapEx)
{
_logger.LogWarning(mapEx, "사용자-서버 매핑 제거 실패: {UserId}", userId);
}
}
// 3. 로컬 WebSocket 연결 해제
_connectionManager.UnregisterConnection(userId);
_logger.LogDebug("로컬 WebSocket 연결 해제 완료: {UserId}", userId);
try
{
var currentConnection = _connectionManager.GetConnection(userId);
var ownsRegistration = currentConnection == null;
if (currentConnection is WebSocketClientConnection registeredConnection)
{
ownsRegistration = ReferenceEquals(registeredConnection.WebSocket, socket);
}
else if (currentConnection != null)
{
ownsRegistration = false;
}
if (!ownsRegistration)
{
_logger.LogDebug(
"다른 WebSocket 연결이 이미 등록되어 있어 공유 상태 정리를 건너뜁니다: {UserId}",
userId);
}
else
{
if (Guid.TryParse(userId, out var userGuid))
{
// 1. 세션 관리자에서 세션 삭제 (Redis에서 제거)
await _sessionManager.DeleteSessionAsync(userGuid);
_logger.LogDebug(
"세션 관리자에서 세션 삭제 완료: {UserId}",
userId);
}
// 2. 분산 시스템: 사용자-서버 매핑 제거 (Redis)
if (_serverRegistrationService != null)
{
try
{
await _serverRegistrationService.RemoveUserServerAsync(userId);
_logger.LogDebug(
"사용자-서버 매핑 제거 완료: {UserId}",
userId);
}
catch (Exception mapEx)
{
_logger.LogWarning(
mapEx,
"사용자-서버 매핑 제거 실패: {UserId}",
userId);
}
}
// 3. 로컬 WebSocket 연결 해제
_connectionManager.UnregisterConnection(userId);
_logger.LogDebug(
"로컬 WebSocket 연결 해제 완료: {UserId}",
userId);
}
// 4. WebSocket 소켓 정리
if (socket.State == WebSocketState.Open
|| socket.State == WebSocketState.CloseReceived)
{
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed",
CancellationToken.None);
}
}
🤖 Prompt for AI Agents
ProjectVG.Api/Middleware/WebSocketMiddleware.cs around lines 234 to 259: the
cleanup in finally indiscriminately deletes shared session and unregisters
connections by userId, which can remove a newly-registered WebSocket for the
same user; change the logic to first retrieve the currently-registered
connection for this user from _connectionManager and compare it to the
connection instance (or its connection Id) being closed, and only if they are
the same proceed to call _sessionManager.DeleteSessionAsync,
_serverRegistrationService.RemoveUserServerAsync and
_connectionManager.UnregisterConnection; keep null checks and exception handling
around the distributed removal, and ensure you do not delete session or
unregister if the registered connection differs or is already null.

// 4. WebSocket 소켓 정리
if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) {
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed",
CancellationToken.None);
_logger.LogDebug("WebSocket 소켓 정리 완료: {UserId}", userId);
}
}
catch (Exception ex) {
Expand Down
2 changes: 1 addition & 1 deletion ProjectVG.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
}

builder.Services.AddInfrastructureServices(builder.Configuration);
builder.Services.AddApplicationServices();
builder.Services.AddApplicationServices(builder.Configuration);
builder.Services.AddDevelopmentCors();

// 부하테스트 환경에서 성능 모니터링 서비스 추가
Expand Down
25 changes: 25 additions & 0 deletions ProjectVG.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,30 @@
"JWT": {
"Issuer": "ProjectVG",
"Audience": "ProjectVG"
},
"DistributedSystem": {
"Enabled": true,
"ServerId": "api-server-001",
"HeartbeatIntervalSeconds": 30,
"CleanupIntervalMinutes": 5,
"ServerTimeoutMinutes": 2
},
Comment on lines +14 to +20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

ServerId 고정값은 인스턴스 충돌 유발 — 인스턴스별 유일값 보장 필요

"api-server-001" 고정은 다중 인스턴스에서 충돌/세션 오염을 유발합니다. 권장:

  • ServerId를 환경변수로 주입(예: 컨테이너 인스턴스 ID/호스트명 기반).
  • 코드에서 미지정/공백일 때 머신명+GUID 등으로 자동 생성.

또한 간격/단위 정합성 확인:

  • HeartbeatIntervalSeconds=30, ServerTimeoutMinutes=2, CleanupIntervalMinutes=5 조합은 최대 5분까지 정리 지연이 발생합니다. 타임아웃(2분) ≤ 정리주기(5분)이면 지연이 큽니다. 정리주기를 더 짧게 권장합니다.

빠른 개선안(정리 주기 단축):

-    "CleanupIntervalMinutes": 5,
+    "CleanupIntervalMinutes": 1,

추가 권장(선택):

  • 단위 혼용(Seconds/Minutes)으로 혼동 여지 → 동일 단위로 통일하거나 옵션 명확화.
  • 환경/클러스터 분리를 위한 ChannelPrefix/Namespace 추가로 키/채널 충돌 방지.

중복 ServerId 사용 가능성 점검:


🏁 Script executed:

#!/bin/bash
rg -n -C2 '"ServerId"\s*:\s*"' --hidden

Length of output: 1942


ServerId 하드코딩 제거 — 인스턴스별 유일값 필요

ProjectVG.Api/appsettings.json에 "ServerId": "api-server-001"이 하드코딩되어 있어 다중 인스턴스에서 충돌/세션 오염 발생 가능. 문서에도 동일 예시가 존재함.

  • 조치: ServerId를 환경변수로 주입(컨테이너 인스턴스 ID/호스트명 또는 런타임 생성 GUID). 미지정/빈값일 경우 머신명+GUID로 자동 생성.
  • 타이밍 정합성: HeartbeatIntervalSeconds=30, ServerTimeoutMinutes=2, CleanupIntervalMinutes=5 조합은 정리 지연을 초래함 — Cleanup 주기 단축(예: 1분) 또는 단위 통일 권장.

빠른 개선안:

-    "CleanupIntervalMinutes": 5,
+    "CleanupIntervalMinutes": 1,

수정 위치: ProjectVG.Api/appsettings.json (약 lines 14–18); 문서 예시: docs/distributed-system/README.md, docs/architecture/chat-system-flow.md.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"DistributedSystem": {
"Enabled": true,
"ServerId": "api-server-001",
"HeartbeatIntervalSeconds": 30,
"CleanupIntervalMinutes": 5,
"ServerTimeoutMinutes": 2
},
"DistributedSystem": {
"Enabled": true,
"ServerId": "api-server-001",
"HeartbeatIntervalSeconds": 30,
"CleanupIntervalMinutes": 1,
"ServerTimeoutMinutes": 2
},
🤖 Prompt for AI Agents
In ProjectVG.Api/appsettings.json around lines 14 to 20, remove the hardcoded
"ServerId": "api-server-001" and instead read ServerId from an environment
variable (e.g., DISTRIBUTED_SERVER_ID); if the env var is missing or empty,
generate a fallback value at runtime using the machine hostname concatenated
with a GUID to guarantee per-instance uniqueness; update the configuration
loading logic to accept the env var and runtime fallback and persist no
hardcoded value in appsettings.json; also adjust CleanupIntervalMinutes to a
shorter/default value (e.g., 1) or unify timing units with
HeartbeatIntervalSeconds/ServerTimeoutMinutes to avoid delayed cleanup, and
update docs (docs/distributed-system/README.md and
docs/architecture/chat-system-flow.md) to show using the env var or
runtime-generated ServerId.

"LLM": {
"BaseUrl": "http://localhost:7930"
},
"MEMORY": {
"BaseUrl": "http://localhost:7940"
},
"TTS": {
"BaseUrl": "https://supertoneapi.com"
},
Comment on lines +21 to +29
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

외부 서비스 BaseUrl만으로는 운영 리스크 — 타임아웃/재시도 등 구성 노출 권장

LLM/MEMORY/TTS 섹션에 최소 다음을 함께 노출하면 운영 안정성이 향상됩니다:

  • 요청 타임아웃(초), 재시도/백오프, 회로차단(서킷브레이커) 스위치
  • 베이스 URL 트레일링 슬래시 규칙 통일(클라이언트에서 중복/누락 방지)
  • 인증 토큰/키는 비밀 주입으로 처리

예시(권장 키; 실제 반영은 코드 지원 필요):

  • TimeoutSeconds, RetryCount, RetryBackoffMillis, CircuitBreakerEnabled

BaseUrl 사용부에서 슬래시 정규화/타임아웃이 적용되는지 확인:


🏁 Script executed:

#!/bin/bash
rg -n -C3 -i '(LLM|MEMORY|TTS).*BaseUrl|HttpClient|Timeout|Circuit|Retry' --type cs --hidden

Length of output: 35983


운영 리스크: LLM/MEMORY/TTS 외부 클라이언트 — 타임아웃/재시도/회로차단 구성 노출 및 BaseUrl 슬래시 정규화 필요

  • 검증 결과: ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs에서 BaseAddress만 설정(client.BaseAddress = new Uri(...))하고 재시도/백오프/서킷브레이커/타임아웃 정책 미적용.
  • LLMClient는 생성자에서 _httpClient.Timeout = TimeSpan.FromSeconds(30)로 하드코딩됨 (ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs) — 타임아웃을 구성으로 이동 필요.
  • Memory/TTS 클라이언트에는 명시적 타임아웃·재시도·서킷 적용 없음 (ProjectVG.Infrastructure/Integrations/MemoryClient/VectorMemoryClient.cs, ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs).
  • 경로 결합 불일치: LLMClient.PostAsync("api/v1/chat") (선행 슬래시 없음) vs Memory/TTS는 "/api/…" (선행 슬래시 있음) — BaseUrl에 경로/트레일링 슬래시가 포함되면 URI 결합 오류 가능. BaseUrl 정규화(슬래시 규칙 통일) 또는 클라이언트 경로 결합 규칙 통일 필요.
  • 권장 조치: appsettings.json에 TimeoutSeconds/RetryCount/RetryBackoffMillis/CircuitBreakerEnabled 등 노출, InfrastructureServiceCollectionExtensions.cs의 AddHttpClient에 Polly 기반 재시도·백오프·서킷·타임아웃 정책 추가(또는 HttpClientFactory 설정으로 중앙관리), LLMClient 내부 하드코딩 제거 및 BaseUrl 정규화/검증 로직 추가. 인증 키는 시크릿 주입(환경변수/시크릿스토어) 유지.

"WebSocket": {
"KeepAliveIntervalMinutes": 10,
"ReceiveBufferSize": 4096,
"SendBufferSize": 4096
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost,1433;Database=ProjectVG;User Id=sa;Password=ProjectVG123!;TrustServerCertificate=true;MultipleActiveResultSets=true",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

심각: DB 자격증명(비밀번호) 커밋됨 — 즉시 제거 및 비밀 회수 필요

sa 계정 비밀번호가 평문으로 커밋되었습니다. 이는 치명적 보안사고로 이어질 수 있습니다. 기본 원칙: 비밀번호는 절대 저장소에 두지 말고 환경변수/비밀관리(User-Secrets, Vault 등)로 주입하세요.

권고 조치:

  • 비밀번호 교체(회수) 및 DB 사용자 최소권한 계정으로 전환.
  • 저장소에서 값 제거(Git 이력도 정리 필요).
  • 환경별 오버라이드(appsettings.{Environment}.json, 환경변수)로 주입.

즉시 적용 가능한 임시 수정(기본값에서 비밀번호 제거; 실제 값은 환경변수로 오버라이드):

-    "DefaultConnection": "Server=localhost,1433;Database=ProjectVG;User Id=sa;Password=ProjectVG123!;TrustServerCertificate=true;MultipleActiveResultSets=true",
+    "DefaultConnection": "Server=localhost,1433;Database=ProjectVG;TrustServerCertificate=true;MultipleActiveResultSets=true",

비밀 노출 여부 점검 스크립트:


🏁 Script executed:

#!/bin/bash
# 민감 문자열 및 연결문자열 유사 패턴 탐지
rg -n -C1 -i '(Password=|User Id=sa|ProjectVG123!)|DefaultConnection' --hidden

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for 'DefaultConnection' (fixed string)..."
rg -nF --hidden --no-ignore 'DefaultConnection' -S || true

echo
echo "Searching for 'Password=' (fixed string)..."
rg -nF --hidden --no-ignore 'Password=' -S || true

echo
echo "Searching for 'User Id=sa' (fixed string)..."
rg -nF --hidden --no-ignore 'User Id=sa' -S || true

echo
echo "Showing ProjectVG.Api/appsettings.json (if present):"
if [ -f ProjectVG.Api/appsettings.json ]; then
  nl -ba ProjectVG.Api/appsettings.json | sed -n '1,200p'
else
  echo "File not found: ProjectVG.Api/appsettings.json"
fi

Length of output: 3207


즉시 조치 필요: 평문 DB 비밀번호(sa:ProjectVG123!)가 커밋됨 — 즉시 제거·회수 및 이력 정리

다음 파일에서 평문 연결문자열 확인(수정 필요):

  • ProjectVG.Api/appsettings.json:31
  • ProjectVG.Api/appsettings.Development.json:11
  • ProjectVG.Infrastructure/Persistence/EfCore/Data/DesignTimeDbContextFactory.cs:38

권장 즉시 조치:

  • DB 비밀번호(해당 sa 계정) 즉시 교체 및 해당 자격증명 폐기 → 최소 권한 계정 사용.
  • 저장소에서 민감값 제거 후 Git 이력에서 완전 삭제(예: git filter-repo 또는 BFG). 단순 커밋 삭제만으로는 불충분.
  • 코드/설정에서 평문 제거 — 연결문자열은 환경변수 또는 시크릿스토어(User-Secrets/HashiCorp Vault/GitHub Secrets 등)로 주입.
  • DesignTimeDbContextFactory.cs 내 하드코딩된 연결문자열을 제거하고 구성(Configuration) 또는 환경변수로 대체.

임시 패치(앱설정에서 비밀번호 제거 — 실제 값은 환경변수로 주입):

-    "DefaultConnection": "Server=localhost,1433;Database=ProjectVG;User Id=sa;Password=ProjectVG123!;TrustServerCertificate=true;MultipleActiveResultSets=true",
+    "DefaultConnection": "Server=localhost,1433;Database=ProjectVG;TrustServerCertificate=true;MultipleActiveResultSets=true",

추가 확인: .env.example 및 문서에 예시 연결문자열 존재(민감값 포함 여부 점검). 모든 변경 후 CI/CD 및 배포 비밀도 갱신.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"DefaultConnection": "Server=localhost,1433;Database=ProjectVG;User Id=sa;Password=ProjectVG123!;TrustServerCertificate=true;MultipleActiveResultSets=true",
"DefaultConnection": "Server=localhost,1433;Database=ProjectVG;TrustServerCertificate=true;MultipleActiveResultSets=true",
🤖 Prompt for AI Agents
In ProjectVG.Api/appsettings.json around line 31 (and also review
ProjectVG.Api/appsettings.Development.json line 11 and
ProjectVG.Infrastructure/Persistence/EfCore/Data/DesignTimeDbContextFactory.cs
line 38), remove the hardcoded plaintext sa connection string and replace it
with a placeholder that reads the connection string (or just the DB password)
from an environment variable or secret store; update DesignTimeDbContextFactory
to obtain configuration from IConfiguration or
Environment.GetEnvironmentVariable rather than a hardcoded literal; rotate the
exposed sa credential immediately (create a new least-privilege DB user and
revoke/disable the compromised account) and ensure CI/CD secrets are updated;
then purge the secret from Git history using git filter-repo or BFG and verify
.env.example/docs contain no real secrets.

"Redis": "projectvg-redis:6379"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Redis 보안 설정 누락(인증/TLS) — 프로덕션 대비 강화 필요

현재 "projectvg-redis:6379"로 인증/암호화 없이 접속합니다. 운영환경에서는 최소한 다음을 보장하세요:

  • 인증 비밀번호 사용(password) 및 주입은 환경변수/비밀관리로.
  • TLS 사용(예: ssl=true 등 라이브러리 호환 옵션).
  • 타임아웃/재시도/풀 크기 옵션 튜닝.

개발/로컬은 유지하되, appsettings.Production.json 또는 환경변수로 보안 옵션을 강제하는 구성을 추가해 주세요.

비슷한 설정이 다른 파일에 하드코딩되지 않았는지 점검:


🏁 Script executed:

#!/bin/bash
rg -n -C2 -i 'redis.*(password|ssl|rediss|:6379)' --hidden

Length of output: 1835


Redis 보안(인증/TLS) 미설정 — 프로덕션 적용 전 즉시 수정 필요

문제: ProjectVG.Api/appsettings.json에 인증/암호화 없이 Redis가 하드코딩되어 있습니다:

"Redis": "projectvg-redis:6379"

조치(우선순위)

  • Redis 연결 문자열에서 비밀번호 사용(ACL) — 민감값은 환경변수 또는 시크릿 매니저로 주입.
  • TLS 활성화(예: rediss:// 또는 클라이언트 옵션 ssl=true 등) 및 클라이언트 라이브러리 설정 확인.
  • 프로덕션 전용 구성 파일(appsettings.Production.json) 또는 환경변수로 보안 옵션 강제.
  • 문서/배포 예시 업데이트(docs/deployment/ci-cd-setup.md의 REDIS_CONNECTION_STRING 예시 점검).
  • 코드 검증: ProjectVG.Infrastructure/Persistence/Session/RedisSessionStorage.cs가 안전하게 구성된 IConnectionMultiplexer를 사용하도록 확인(구성 주입 경로 점검).

ProjectVG.Api/appsettings.json, docs/deployment/ci-cd-setup.md, ProjectVG.Infrastructure/Persistence/Session/RedisSessionStorage.cs에서 우선 수정 필요.

🤖 Prompt for AI Agents
In ProjectVG.Api/appsettings.json around line 32, the Redis connection is
hardcoded without auth/TLS ("Redis": "projectvg-redis:6379"); update
configuration to require a secured connection string (use password/ACL and TLS
scheme like rediss:// or ssl=true) sourced from environment variables or a
secrets manager (move this value into appsettings.Production.json and/or read
from ASPNETCORE configuration for production), update
docs/deployment/ci-cd-setup.md to show REDIS_CONNECTION_STRING example with
credentials and TLS, and verify
ProjectVG.Infrastructure/Persistence/Session/RedisSessionStorage.cs uses the
injected connection string to configure IConnectionMultiplexer with password and
SSL options (fail fast if missing) so production always uses
authenticated+encrypted Redis.

}
}
Loading