feat: complete Oracle and MariaDB WebView data layer

This commit is contained in:
2026-07-10 05:33:19 +09:00
parent 5aa90e4aaa
commit 39c4504b87
44 changed files with 3956 additions and 46 deletions

2
.gitignore vendored
View File

@@ -24,6 +24,8 @@ BundleArtifacts/
# Local settings and secrets
Config/appsettings.local.json
Config/*.secrets.json
Config/database.local.json
**/database.local.json
*.local.ini
.env
.env.*

View File

@@ -1,21 +1,29 @@
{
"database": {
"oracle": {
"host": "",
"port": 1521,
"serviceName": "",
"userName": ""
"sid": "",
"serviceName": null,
"userName": "",
"password": "",
"connectTimeoutSeconds": 10,
"commandTimeoutSeconds": 30,
"pooling": true
},
"mariaDb": {
"host": "",
"port": 3306,
"database": "",
"userName": ""
}
"userName": "",
"password": "",
"tlsMode": "Preferred",
"connectTimeoutSeconds": 10,
"commandTimeoutSeconds": 30,
"pooling": true
},
"playout": {
"host": "127.0.0.1",
"port": 30001,
"sceneFolder": ""
"resilience": {
"operationTimeoutSeconds": 45,
"maximumRetryCount": 2,
"initialRetryDelayMilliseconds": 250
}
}

12
Directory.Build.props Normal file
View File

@@ -0,0 +1,12 @@
<Project>
<!--
The development MSIX does not publish a symbol package. This keeps package
creation deterministic on machines without the optional VC++ UWP
mspdbcmf.exe component. Normal Debug/Release builds still produce PDBs.
-->
<PropertyGroup Condition="'$(GenerateAppxPackageOnBuild)' == 'true'">
<DebugSymbols>false</DebugSymbols>
<DebugType>None</DebugType>
<AppxSymbolPackageEnabled>false</AppxSymbolPackageEnabled>
</PropertyGroup>
</Project>

View File

@@ -42,11 +42,15 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="Config\appsettings.example.json" />
<Content Include="ThirdPartyNotices\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<!-- The app project lives at the repository root. Do not compile child-project sources twice. -->
<ItemGroup>
<Compile Remove="src\**\*.cs" />
<Compile Remove="src\**\*.cs;tests\**\*.cs;tools\**\*.cs" />
</ItemGroup>
<ItemGroup>
@@ -67,6 +71,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
</ItemGroup>
<!--

View File

@@ -9,28 +9,112 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Core", "src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj", "{CCFB7CB0-F898-444F-A16B-E28554A2698A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Infrastructure", "src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj", "{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Core.Tests", "tests\MBN_STOCK_WEBVIEW.Core.Tests\MBN_STOCK_WEBVIEW.Core.Tests.csproj", "{040E8620-035C-4F62-B408-C05C5BDC90FE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbSmoke", "tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj", "{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Infrastructure.Tests", "tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj", "{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Debug|Any CPU = Debug|Any CPU
Debug|x86 = Debug|x86
Release|x64 = Release|x64
Release|Any CPU = Release|Any CPU
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.ActiveCfg = Debug|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Build.0 = Debug|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Deploy.0 = Debug|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x86.ActiveCfg = Debug|x86
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x86.Build.0 = Debug|x86
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.ActiveCfg = Release|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Build.0 = Release|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Deploy.0 = Release|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|Any CPU.Build.0 = Release|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x86.ActiveCfg = Release|x86
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x86.Build.0 = Release|x86
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.ActiveCfg = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.Build.0 = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x86.ActiveCfg = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x86.Build.0 = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.ActiveCfg = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.Build.0 = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|Any CPU.Build.0 = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x86.ActiveCfg = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x86.Build.0 = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x64.ActiveCfg = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x64.Build.0 = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x86.ActiveCfg = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x86.Build.0 = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x64.ActiveCfg = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x64.Build.0 = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|Any CPU.Build.0 = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x86.ActiveCfg = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x86.Build.0 = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x64.ActiveCfg = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x64.Build.0 = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x86.ActiveCfg = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x86.Build.0 = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x64.ActiveCfg = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x64.Build.0 = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|Any CPU.Build.0 = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x86.ActiveCfg = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x86.Build.0 = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x64.ActiveCfg = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x64.Build.0 = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x86.ActiveCfg = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x86.Build.0 = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x64.ActiveCfg = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x64.Build.0 = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|Any CPU.Build.0 = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x86.ActiveCfg = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x86.Build.0 = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x64.ActiveCfg = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x64.Build.0 = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x86.ActiveCfg = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x86.Build.0 = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x64.ActiveCfg = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x64.Build.0 = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|Any CPU.Build.0 = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x86.ActiveCfg = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CCFB7CB0-F898-444F-A16B-E28554A2698A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{040E8620-035C-4F62-B408-C05C5BDC90FE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07}

View File

@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Infrastructure;
using Microsoft.UI.Windowing;
using Microsoft.Web.WebView2.Core;
using MMoneyCoderSharp.Data;
@@ -15,20 +16,51 @@ public sealed partial class MainWindow : Window
{
private const string AppHost = "app.mbn.local";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly CancellationTokenSource _lifetimeCancellation = new();
private DatabaseRuntime? _databaseRuntime;
private IMarketDataService? _marketDataService;
private CancellationTokenSource? _marketDataCancellation;
private string? _databaseInitializationError;
private bool _webViewReady;
public MainWindow()
{
InitializeComponent();
InitializeDatabaseRuntime();
Root.Loaded += OnRootLoaded;
Closed += OnClosed;
}
private void InitializeDatabaseRuntime()
{
try
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
DataQueryExecutor.Configure(_databaseRuntime.Executor);
_marketDataService = new LegacyMarketDataService(_databaseRuntime.Executor);
}
catch (DatabaseOperationException exception)
{
_databaseInitializationError = exception.Message;
DataQueryExecutor.Reset();
}
catch
{
_databaseInitializationError = "데이터베이스 설정을 초기화하지 못했습니다. 로컬 설정 파일을 확인하세요.";
DataQueryExecutor.Reset();
}
}
private async void OnRootLoaded(object sender, RoutedEventArgs e)
{
Root.Loaded -= OnRootLoaded;
ResizeWindow();
await InitializeWebViewAsync();
if (_webViewReady)
{
_ = MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
}
}
private void ResizeWindow()
@@ -139,6 +171,13 @@ public sealed partial class MainWindow : Window
case "ready":
case "request-app-info":
PostAppInfo();
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
break;
case "request-database-status":
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
break;
case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object:
_ = HandleMarketDataRequestAsync(request.Payload);
break;
case "open-external" when request.Payload.ValueKind == JsonValueKind.Object:
if (request.Payload.TryGetProperty("url", out var value) &&
@@ -156,7 +195,231 @@ public sealed partial class MainWindow : Window
{
PostMessage("bridge-error", new { message = exception.Message });
}
catch
{
PostMessage("bridge-error", new { message = "요청 메시지를 처리하지 못했습니다." });
}
}
private async Task HandleMarketDataRequestAsync(JsonElement payload)
{
var requestId = GetString(payload, "requestId");
var viewName = GetString(payload, "view");
if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128 ||
!TryParseMarketDataView(viewName, out var view))
{
PostMessage("market-data-error", new
{
requestId = requestId ?? string.Empty,
view = viewName ?? string.Empty,
message = "지원하지 않는 시장 데이터 요청입니다."
});
return;
}
var maximumRows = 250;
if (payload.TryGetProperty("maximumRowsPerTable", out var rowLimit) &&
rowLimit.ValueKind == JsonValueKind.Number &&
rowLimit.TryGetInt32(out var requestedRows))
{
maximumRows = requestedRows;
}
if (maximumRows is < 1 or > 2_000)
{
PostMessage("market-data-error", new
{
requestId,
view = viewName,
message = "한 번에 조회할 수 있는 행 수는 1~2,000개입니다."
});
return;
}
if (_marketDataService is null)
{
PostMessage("market-data-error", new
{
requestId,
view = viewName,
message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."
});
return;
}
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token);
var previousCancellation = Interlocked.Exchange(ref _marketDataCancellation, requestCancellation);
previousCancellation?.Cancel();
previousCancellation?.Dispose();
try
{
var snapshot = await _marketDataService
.GetAsync(view, maximumRows, requestCancellation.Token);
PostMessage("market-data", new
{
requestId,
view = ToWireValue(snapshot.View),
snapshot.RetrievedAt,
tables = snapshot.Tables.Select(table => new
{
table.Name,
source = ToWireValue(table.Source),
table.Columns,
table.Rows,
table.TotalRowCount
})
});
}
catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested)
{
// A newer navigation request or app shutdown superseded this response.
}
catch (DatabaseOperationException exception)
{
PostMarketDataError(requestId, view, exception.Message);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch
{
PostMarketDataError(requestId, view, "시장 데이터를 조회하지 못했습니다. DB 연결 상태를 확인하세요.");
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
finally
{
if (ReferenceEquals(Interlocked.CompareExchange(
ref _marketDataCancellation,
null,
requestCancellation), requestCancellation))
{
requestCancellation.Dispose();
}
}
}
private void PostMarketDataError(string requestId, MarketDataView view, string message)
{
PostMessage("market-data-error", new
{
requestId,
view = ToWireValue(view),
message
});
}
private async Task PostDatabaseStatusAsync(CancellationToken cancellationToken)
{
if (!_webViewReady)
{
return;
}
if (_databaseRuntime is null)
{
var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
PostMessage("database-status", new
{
sources = new[]
{
CreateUnavailableStatus("oracle", message),
CreateUnavailableStatus("mariaDb", message)
}
});
return;
}
try
{
var statuses = await _databaseRuntime.HealthService
.CheckAllAsync(cancellationToken);
PostMessage("database-status", new
{
sources = statuses.Select(status => new
{
source = ToWireValue(status.Source),
configured = _databaseRuntime.Options.IsConfigured(status.Source),
healthy = status.State == DatabaseHealthState.Healthy,
state = status.State.ToString().ToLowerInvariant(),
message = status.UserMessage,
checkedAt = status.CheckedAt,
latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds)
})
});
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch
{
PostMessage("database-status", new
{
sources = new[]
{
CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.")
}
});
}
}
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await PostDatabaseStatusAsync(cancellationToken);
try
{
await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
}
}
private static object CreateUnavailableStatus(string source, string message) => new
{
source,
configured = false,
healthy = false,
state = "notconfigured",
message,
checkedAt = DateTimeOffset.Now,
latencyMs = 0L
};
private static string? GetString(JsonElement payload, string propertyName) =>
payload.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
private static bool TryParseMarketDataView(string? value, out MarketDataView view)
{
view = value?.ToLowerInvariant() switch
{
"kospi" => MarketDataView.Kospi,
"kosdaq" => MarketDataView.Kosdaq,
"index" => MarketDataView.Index,
"overseas" => MarketDataView.Overseas,
_ => default
};
return value?.ToLowerInvariant() is "kospi" or "kosdaq" or "index" or "overseas";
}
private static string ToWireValue(MarketDataView view) => view.ToString().ToLowerInvariant();
private static string ToWireValue(DataSourceKind source) => source switch
{
DataSourceKind.Oracle => "oracle",
DataSourceKind.MariaDb => "mariaDb",
_ => "unknown"
};
private void PostAppInfo()
{
@@ -171,7 +434,9 @@ public sealed partial class MainWindow : Window
architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(),
package = "MSIX",
migratedQueryFiles = 71,
databaseConfigured = DataQueryExecutor.IsConfigured
databaseConfigured = _databaseRuntime is not null &&
new[] { DataSourceKind.Oracle, DataSourceKind.MariaDb }
.Any(_databaseRuntime.Options.IsConfigured)
});
}
@@ -233,8 +498,17 @@ public sealed partial class MainWindow : Window
private void OnClosed(object sender, WindowEventArgs args)
{
if (!_webViewReady)
var wasWebViewReady = _webViewReady;
_webViewReady = false;
_lifetimeCancellation.Cancel();
var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
requestCancellation?.Cancel();
requestCancellation?.Dispose();
DataQueryExecutor.Reset();
if (!wasWebViewReady)
{
_lifetimeCancellation.Dispose();
return;
}
@@ -244,6 +518,7 @@ public sealed partial class MainWindow : Window
Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
Browser.CoreWebView2.ProcessFailed -= OnProcessFailed;
_lifetimeCancellation.Dispose();
}
private sealed record WebRequest(string Type, JsonElement Payload);

View File

@@ -46,6 +46,8 @@
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<Capability Name="privateNetworkClientServer" />
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -22,10 +22,11 @@
- 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
- `F2`, `F8`, `Esc` 단축키
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
- DB 구현을 UI 및 Core에서 분리하기 위한 `IDataQueryExecutor` 경계
- Oracle/MariaDB 실제 비동기 `IDataQueryExecutor`와 소스별 health/retry/timeout
- WebView 코스피·코스닥·NXT·5개 지수·해외 실데이터 조회와 장애 UI
- MSIX 패키지 매니페스트와 x64 게시 프로필
실제 Oracle/MariaDB 연결 Tornado/K3D 송출은 아직 어댑터를 연결하지 않았습니다. 화면의 송출 버튼은 현재 운영 흐름 검증용이며 실제 방송 신호를 보내지 않습니다. 상세 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D 송출은 아직 어댑터를 연결하지 않았으며, 화면의 송출 버튼은 운영 흐름 검증용입니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
## Visual Studio 2026에서 실행
@@ -60,7 +61,7 @@ dotnet build MBN_STOCK_WEBVIEW.csproj `
## 구성과 보안
원본 `RES/MmoneyCoder.ini`에는 실제로 보이는 DB 접속 정보가 있어 복사하지 않았습니다. [예제 설정](Config/appsettings.example.json)에는 비밀값이 없으며, 실제 로컬 설정은 Git에서 제외되는 `Config/appsettings.local.json`을 사용하도록 예정되어 있습니다.
원본 `RES/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. [예제 설정](Config/appsettings.example.json)에는 빈 값만 있으며 실제 설정은 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` 또는 `MBN_STOCK_*` 환경 변수를 사용합니다. 로컬 파일 생성, 환경 변수, 보안과 스모크 테스트 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
MSIX 설치 폴더는 읽기 전용입니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 이후 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장해야 합니다. Tornado 씬/영상처럼 큰 운영 자산은 별도의 설정 가능한 외부 폴더로 유지합니다.
@@ -71,7 +72,11 @@ MBN_STOCK_WEBVIEW.csproj WinUI 3 / WebView2 / 단일 프로젝트 MSIX
MainWindow.xaml(.cs) 네이티브 창, WebView 초기화, 보안 경계, 메시지 브리지
Web/ FarPoint를 대체하는 로컬 운영 UI
src/MBN_STOCK_WEBVIEW.Core/ 이관된 데이터 요청 및 공급자 비의존 Core
src/MBN_STOCK_WEBVIEW.Infrastructure/ Oracle/MariaDB 공급자·설정·복원력·health
tests/ Core/Infrastructure 단위 테스트
tools/MBN_STOCK_WEBVIEW.DbSmoke/ 비밀값을 출력하지 않는 실제 DB 스모크
Config/ 비밀값 없는 구성 템플릿
ThirdPartyNotices/ 공급자 재배포 라이선스 고지
docs/ 마이그레이션 기록과 다음 단계
```

View File

@@ -0,0 +1,21 @@
MySqlConnector 2.6.1
Copyright (c) 2016-2026 MySqlConnector contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,42 @@
Your use of this Program is governed by the Oracle Free Distribution, Hosting, and Use Terms and Conditions set forth below, unless you have received this Program (alone or as part of another Oracle product) under an Oracle license agreement (including but not limited to the Oracle Master Agreement), in which case your use of this Program is governed solely by such license agreement with Oracle.
Oracle Free Distribution, Hosting, and Use Terms and Conditions
Definitions
"Oracle" refers to Oracle America, Inc. "You" and "Your" refers to (a) a company or organization (each an "Entity") accessing the Programs, if use of the Programs will be on behalf of such Entity; or (b) an individual accessing the Programs, if use of the Programs will not be on behalf of an Entity. "Program(s)" refers to Oracle software provided by Oracle pursuant to the following terms and any updates, error corrections, and/or Program Documentation provided by Oracle. "Program Documentation" refers to Program user manuals and Program installation manuals, if any. If available, Program Documentation may be delivered with the Programs and/or may be accessed from www.oracle.com/documentation. "Separate Terms" refers to separate license terms that are specified in the Program Documentation, readmes or notice files and that apply to Separately Licensed Technology. "Separately Licensed Technology" refers to Oracle or third party technology that is licensed under Separate Terms and not under the terms of this license.
Separately Licensed Technology
Oracle may provide certain notices to You in Program Documentation, readmes or notice files in connection with Oracle or third party technology provided as or with the Programs. If specified in the Program Documentation, readmes or notice files, such technology will be licensed to You under Separate Terms. Your rights to use Separately Licensed Technology under Separate Terms are not restricted in any way by the terms herein. For clarity, notwithstanding the existence of a notice, third party technology that is not Separately Licensed Technology shall be deemed part of the Programs licensed to You under the terms of this license.
Source Code for Open Source Software
For software that You receive from Oracle in binary form that is licensed under an open source license that gives You the right to receive the source code for that binary, You can obtain a copy of the applicable source code from https://oss.oracle.com/sources/ or http://www.oracle.com/goto/opensourcecode. If the source code for such software was not provided to You with the binary, You can also receive a copy of the source code on physical media by submitting a written request pursuant to the instructions in the "Written Offer for Source Code" section of the latter website.
-------------------------------------------------------------------------------
The following license terms apply to those Programs that are not provided to You under Separate Terms.
License Rights and Restrictions
Oracle grants to You, as a recipient of this Program, a nonexclusive, nontransferable, limited license to, subject to the conditions stated herein, use the unmodified Programs, including, without limitation, for the purposes of:
• developing, testing, prototyping and demonstrating applications;
• running the unmodified Programs for training, personal use, your business operations, and the business operations of third parties;
• making the unmodified Programs available for use by third parties in your hosted environment and in cloud services;
• redistributing unmodified Programs and Programs Documentation under the terms of this License; and
• copying the unmodified Programs and Program Documentation to the extent reasonably necessary to exercise the license rights granted herein and for backup purposes.
For the purposes of this license, compiling, interpreting or configuring an otherwise unmodified Program as necessary to run the Program shall not be considered modification.
Your license is contingent on Your compliance with the following conditions:
- You include a copy of this license with any distribution by You of the Programs;
- You do not charge your customers, end users, distributees or other third parties any additional fees for the distribution or use of the Programs; however, for clarity, if you comply with the foregoing condition, distribution or use of the Program as part of your for-fee product or service that adds substantial additional value is permitted;
- You do not remove markings or notices of either Oracle's or a licensor's proprietary rights from the Programs or Program Documentation;
- You comply with all U.S. and applicable export control and economic sanctions laws and regulations that govern Your use of the Programs (including technical data); and
- You do not cause or permit reverse engineering, disassembly or decompilation of the Programs (except as allowed by law) by You nor allow an associated party to do so.
Any source code that may be included in the distribution with the Programs may not be modified, unless such source code is under Separate Terms permitting modification.
Ownership
Oracle or its licensors retain all ownership and intellectual property rights to the Programs.
Information Collection
The Programs' installation and/or auto-update processes, if any, may transmit a limited amount of data to Oracle or its service provider about those processes to help Oracle understand and optimize them. Oracle does not associate the data with personally identifiable information. Refer to Oracle's Privacy Policy at www.oracle.com/privacy.
Disclaimer of Warranties; Limitation of Liability
THE PROGRAMS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. ORACLE FURTHER DISCLAIMS ALL WARRANTIES, EXPRESS AND IMPLIED, INCLUDING WITHOUT LIMITATION, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW WILL ORACLE BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
Version 1.0
Last updated: 28 June 2022

View File

@@ -0,0 +1,11 @@
# Third-party notices
The MSIX output uses these pinned provider packages:
- `Oracle.ManagedDataAccess.Core` 23.26.200 — Oracle Free Distribution, Hosting, and Use Terms. The complete package license is included beside this file.
- `MySqlConnector` 2.6.1 — MIT license, included beside this file.
Keep these notices with any redistribution of the packaged application. See the package pages for the corresponding release and security notices:
- https://www.nuget.org/packages/Oracle.ManagedDataAccess.Core/23.26.200
- https://www.nuget.org/packages/MySqlConnector/2.6.1

View File

@@ -24,6 +24,8 @@
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
compare: "종목 비교", theme: "테마 관리", expert: "전문가 추천", halted: "거래 정지"
};
const liveDataViews = new Set(["kospi", "kosdaq", "index", "overseas"]);
const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };
const storageKey = "mbn-stock-webview.playlist.v1";
const state = {
@@ -34,15 +36,34 @@
selectedRows: new Set(),
currentIndex: -1,
dragIndex: -1,
playout: "IDLE"
playout: "IDLE",
database: { sources: [], loading: true, lastSignature: "" },
liveData: {
requestId: null,
view: null,
status: "idle",
tables: [],
retrievedAt: null,
error: "",
timeoutId: null
}
};
const elements = {
bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
databaseStateDot: document.querySelector("#databaseStateDot"),
databaseSummary: document.querySelector("#databaseSummary"),
databaseSummaryIcon: document.querySelector("#databaseSummaryIcon"),
databaseSummaryDetail: document.querySelector("#databaseSummaryDetail"),
databaseSummaryBadge: document.querySelector("#databaseSummaryBadge"),
runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
catalogList: document.querySelector("#catalogList"), playlistBody: document.querySelector("#playlistBody"),
catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"),
liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"),
liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"),
databaseSources: document.querySelector("#databaseSources"),
playlistBody: document.querySelector("#playlistBody"),
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
@@ -324,15 +345,377 @@
nativeBridge?.postMessage({ type, payload });
}
function normalizeSourceName(source) {
const value = String(source || "").toLocaleLowerCase("en-US");
if (value === "oracle") return "oracle";
if (value === "mariadb" || value === "maria_db" || value === "maria-db") return "mariaDb";
return String(source || "unknown");
}
function normalizedDatabaseSources(sources) {
const received = new Map((Array.isArray(sources) ? sources : []).map(source => [normalizeSourceName(source?.source), source]));
return ["oracle", "mariaDb"].map(name => {
const source = received.get(name);
return {
source: name,
configured: Boolean(source?.configured),
healthy: Boolean(source?.healthy),
message: typeof source?.message === "string" ? source.message : "",
checkedAt: source?.checkedAt || null,
latencyMs: source?.latencyMs !== null && source?.latencyMs !== undefined && Number.isFinite(Number(source.latencyMs))
? Number(source.latencyMs)
: null
};
});
}
function sourceHealthClass(source) {
if (state.database.loading && !state.database.sources.length) return "pending";
if (!source.configured) return "unconfigured";
return source.healthy ? "healthy" : "error";
}
function sourceHealthText(source) {
if (state.database.loading && !state.database.sources.length) return "확인 중";
if (!source.configured) return "미설정";
if (!source.healthy) return "연결 오류";
return source.latencyMs === null ? "정상" : `정상 · ${Math.round(source.latencyMs)}ms`;
}
function renderDatabaseSources() {
elements.databaseSources.replaceChildren();
const sources = state.database.sources.length
? state.database.sources
: normalizedDatabaseSources([]);
for (const source of sources) {
const chip = document.createElement("div");
const healthClass = sourceHealthClass(source);
chip.className = `database-source ${healthClass}`;
if (source.message) chip.title = source.message;
const dot = document.createElement("span");
dot.className = "source-dot";
const label = document.createElement("strong");
label.textContent = sourceLabels[source.source] || source.source;
const status = document.createElement("small");
status.textContent = sourceHealthText(source);
chip.append(dot, label, status);
elements.databaseSources.append(chip);
}
}
function renderDatabaseStatus() {
const sources = state.database.sources;
const configured = sources.filter(source => source.configured);
const healthy = configured.filter(source => source.healthy);
let stateClass = "pending";
let stateText = "확인 중";
let detail = "연결 상태 확인 중";
let badge = "CHECKING";
if (!state.database.loading || sources.length) {
if (!configured.length) {
stateClass = "unconfigured";
stateText = "미설정";
detail = "Oracle 미설정 · MariaDB 미설정";
badge = "SETUP";
} else if (healthy.length === sources.length && sources.length === 2) {
stateClass = "healthy";
stateText = "정상 (2/2)";
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "CONNECTED";
} else if (configured.some(source => !source.healthy)) {
stateClass = "error";
stateText = `오류 (${healthy.length}/${configured.length})`;
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "ERROR";
} else {
stateClass = "partial";
stateText = `일부 연결 (${healthy.length}/2)`;
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "PARTIAL";
}
}
elements.databaseState.textContent = stateText;
elements.databaseStateDot.className = `status-dot ${stateClass}`;
elements.databaseSummary.className = `database-summary ${stateClass}`;
elements.databaseSummaryIcon.className = `metric-icon database-icon ${stateClass}`;
elements.databaseSummaryDetail.textContent = detail;
elements.databaseSummaryBadge.textContent = badge;
renderDatabaseSources();
}
function requestDatabaseStatus() {
if (!nativeBridge) return;
state.database.loading = true;
renderDatabaseStatus();
postNative("request-database-status", { requestId: createId() });
}
function handleDatabaseStatus(payload) {
state.database.sources = normalizedDatabaseSources(payload?.sources);
state.database.loading = false;
renderDatabaseStatus();
const signature = state.database.sources.map(source => `${source.source}:${source.configured}:${source.healthy}`).join("|");
if (signature !== state.database.lastSignature) {
const message = state.database.sources
.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`)
.join(" · ");
addLog(`DB 상태 · ${message}`);
state.database.lastSignature = signature;
}
}
function clearLiveDataTimeout() {
if (state.liveData.timeoutId !== null) clearTimeout(state.liveData.timeoutId);
state.liveData.timeoutId = null;
}
function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("ko-KR", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
}).format(date);
}
function normalizeColumns(table) {
let columns = Array.isArray(table?.columns) ? table.columns : [];
const firstRow = Array.isArray(table?.rows) ? table.rows[0] : null;
if (!columns.length && firstRow && !Array.isArray(firstRow) && typeof firstRow === "object") {
columns = Object.keys(firstRow);
}
if (!columns.length && Array.isArray(firstRow)) {
columns = firstRow.map((_, index) => `${index + 1}`);
}
return columns.map((column, index) => {
if (typeof column === "string") return { key: column, label: column };
const key = column?.key ?? column?.name ?? column?.field ?? column?.dataPropertyName ?? `column-${index}`;
const label = column?.label ?? column?.displayName ?? column?.title ?? column?.name ?? key;
return { key: String(key), label: String(label) };
});
}
function cellValue(row, column, index) {
if (Array.isArray(row)) return row[index];
if (row && typeof row === "object") return row[column.key];
return index === 0 ? row : null;
}
function formatCellValue(value) {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "number") return value.toLocaleString("ko-KR");
if (typeof value === "boolean") return value ? "예" : "아니요";
if (typeof value === "object") {
try { return JSON.stringify(value); } catch { return String(value); }
}
return String(value);
}
function renderLiveTables() {
elements.liveDataTables.replaceChildren();
for (const table of state.liveData.tables) {
const section = document.createElement("section");
section.className = "live-table-card";
const header = document.createElement("header");
const heading = document.createElement("div");
const name = document.createElement("h4");
name.textContent = table?.name || "시장 데이터";
const meta = document.createElement("small");
const rows = Array.isArray(table?.rows) ? table.rows : [];
const total = Number.isFinite(Number(table?.totalRowCount)) ? Number(table.totalRowCount) : rows.length;
meta.textContent = `전체 ${total.toLocaleString("ko-KR")}건 · 표시 ${rows.length.toLocaleString("ko-KR")}`;
heading.append(name, meta);
const source = document.createElement("span");
source.className = "live-source-badge";
const sourceName = normalizeSourceName(table?.source);
source.textContent = sourceLabels[sourceName] || table?.source || "DB";
header.append(heading, source);
section.append(header);
if (!rows.length) {
const empty = document.createElement("p");
empty.className = "live-table-empty";
empty.textContent = "조회된 행이 없습니다.";
section.append(empty);
elements.liveDataTables.append(section);
continue;
}
const columns = normalizeColumns(table);
const scroll = document.createElement("div");
scroll.className = "live-table-scroll";
const grid = document.createElement("table");
grid.className = "live-table";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const column of columns) {
const th = document.createElement("th");
th.textContent = column.label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
columns.forEach((column, index) => {
const td = document.createElement("td");
td.textContent = formatCellValue(cellValue(row, column, index));
tr.append(td);
});
body.append(tr);
}
grid.append(head, body);
scroll.append(grid);
section.append(scroll);
elements.liveDataTables.append(section);
}
}
function renderLiveData() {
const visible = liveDataViews.has(state.activeMarket);
elements.liveDataPanel.hidden = !visible;
elements.catalogModeBadge.textContent = visible ? "DB LIVE" : "Web UI";
elements.catalogModeBadge.classList.toggle("live", visible);
if (!visible) return;
elements.liveDataTitle.textContent = `${marketTitles[state.activeMarket]} · DB 조회`;
elements.liveDataState.replaceChildren();
elements.liveDataState.className = `live-data-state ${state.liveData.status}`;
if (state.liveData.status === "loading") {
const spinner = document.createElement("span");
spinner.className = "live-spinner";
const copy = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = "데이터베이스에서 조회 중입니다.";
small.textContent = "연결 상태와 시장 데이터를 확인하고 있습니다.";
copy.append(strong, small);
elements.liveDataState.append(spinner, copy);
} else if (state.liveData.status === "error") {
const icon = document.createElement("span");
icon.className = "live-error-icon";
icon.textContent = "!";
const copy = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = "시장 데이터를 조회하지 못했습니다.";
small.textContent = state.liveData.error || "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
copy.append(strong, small);
elements.liveDataState.append(icon, copy);
} else if (state.liveData.status === "ready") {
const totalRows = state.liveData.tables.reduce((sum, table) => {
const count = Number(table?.totalRowCount);
return sum + (Number.isFinite(count) ? count : (Array.isArray(table?.rows) ? table.rows.length : 0));
}, 0);
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = state.liveData.tables.length
? `${state.liveData.tables.length}개 데이터셋 · 전체 ${totalRows.toLocaleString("ko-KR")}`
: "조회 결과가 없습니다.";
small.textContent = state.liveData.retrievedAt ? `조회 시각 ${formatDateTime(state.liveData.retrievedAt)}` : "조회 완료";
elements.liveDataState.append(strong, small);
}
renderDatabaseSources();
renderLiveTables();
}
function requestMarketData(view = state.activeMarket) {
clearLiveDataTimeout();
if (!liveDataViews.has(view)) {
state.liveData.requestId = null;
state.liveData.view = null;
state.liveData.status = "idle";
state.liveData.tables = [];
renderLiveData();
return;
}
const requestId = createId();
state.liveData.requestId = requestId;
state.liveData.view = view;
state.liveData.status = "loading";
state.liveData.tables = [];
state.liveData.retrievedAt = null;
state.liveData.error = "";
renderLiveData();
if (!nativeBridge) {
state.liveData.status = "error";
state.liveData.error = "브라우저 미리보기에서는 실제 DB 데이터를 조회할 수 없습니다.";
renderLiveData();
return;
}
postNative("request-market-data", { requestId, view, maximumRowsPerTable: 250 });
state.liveData.timeoutId = setTimeout(() => {
if (state.liveData.requestId !== requestId || state.liveData.status !== "loading") return;
state.liveData.status = "error";
state.liveData.error = "DB 응답 시간이 초과되었습니다. 연결 상태를 확인하고 다시 조회하세요.";
renderLiveData();
addLog(`${marketTitles[view]} DB 조회 시간 초과`);
}, 30000);
}
function handleMarketData(payload) {
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
clearLiveDataTimeout();
state.liveData.status = "ready";
state.liveData.tables = Array.isArray(payload.tables) ? payload.tables : [];
state.liveData.retrievedAt = payload.retrievedAt || new Date().toISOString();
state.liveData.error = "";
renderLiveData();
const shownRows = state.liveData.tables.reduce((sum, table) => sum + (Array.isArray(table?.rows) ? table.rows.length : 0), 0);
addLog(`${marketTitles[payload.view]} DB 조회 완료 · ${shownRows.toLocaleString("ko-KR")}행 표시`);
}
function handleMarketDataError(payload) {
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
clearLiveDataTimeout();
state.liveData.status = "error";
state.liveData.tables = [];
state.liveData.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
renderLiveData();
addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`);
}
function handleNativeMessage(event) {
const message = event.data;
if (!message || message.type !== "app-info") return;
const info = message.payload;
let message = event.data;
if (typeof message === "string") {
try { message = JSON.parse(message); } catch { return; }
}
if (!message || typeof message.type !== "string") return;
switch (message.type) {
case "app-info": {
const info = message.payload || {};
elements.bridgeState.textContent = "연결됨";
elements.databaseState.textContent = info.databaseConfigured ? "연결됨" : "미설정";
elements.runtimeLabel.textContent = `${info.framework} · ${info.architecture}`;
elements.packageLabel.textContent = `${info.package} · v${info.version}`;
addLog(`Native Bridge 연결 · Windows App SDK ${info.windowsAppSdk}`);
elements.runtimeLabel.textContent = `${info.framework || ".NET 8"} · ${info.architecture || "x64"}`;
elements.packageLabel.textContent = `${info.package || "MSIX"}${info.version ? ` · v${info.version}` : ""}`;
addLog(`Native Bridge 연결${info.windowsAppSdk ? ` · Windows App SDK ${info.windowsAppSdk}` : ""}`);
break;
}
case "database-status":
handleDatabaseStatus(message.payload);
break;
case "market-data":
handleMarketData(message.payload);
break;
case "market-data-error":
handleMarketDataError(message.payload);
break;
}
}
function bindEvents() {
@@ -343,6 +726,8 @@
state.activeMarket = button.dataset.market;
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
renderCatalog();
requestMarketData(state.activeMarket);
if (liveDataViews.has(state.activeMarket)) requestDatabaseStatus();
});
elements.catalogTabs.addEventListener("click", event => {
@@ -365,7 +750,15 @@
document.querySelector("#nextButton").addEventListener("click", next);
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
document.querySelector("#requestInfoButton").addEventListener("click", () => postNative("request-app-info"));
document.querySelector("#requestInfoButton").addEventListener("click", () => {
postNative("request-app-info");
requestDatabaseStatus();
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
});
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
requestDatabaseStatus();
requestMarketData(state.activeMarket);
});
document.addEventListener("keydown", event => {
if (event.ctrlKey && event.key.toLowerCase() === "k") {
@@ -381,6 +774,8 @@
bindEvents();
renderCatalog();
renderPlaylist();
renderDatabaseStatus();
renderLiveData();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");
@@ -388,8 +783,12 @@
if (nativeBridge) {
nativeBridge.addEventListener("message", handleNativeMessage);
postNative("ready");
postNative("request-app-info");
requestDatabaseStatus();
} else {
elements.bridgeState.textContent = "브라우저 미리보기";
state.database.loading = false;
renderDatabaseStatus();
addLog("Native Bridge 없이 브라우저 모드로 실행");
}
}

View File

@@ -33,7 +33,7 @@
<div class="sidebar-status">
<div class="status-row"><span class="status-dot ok"></span><span>Native Bridge</span><strong id="bridgeState">연결 중</strong></div>
<div class="status-row"><span class="status-dot warn"></span><span>Database</span><strong id="databaseState">미설정</strong></div>
<div class="status-row"><span id="databaseStateDot" class="status-dot pending"></span><span>Database</span><strong id="databaseState">확인 중</strong></div>
<div class="runtime" id="runtimeLabel">.NET 8 · WinUI 3 · x64</div>
</div>
</aside>
@@ -54,7 +54,7 @@
<section class="summary-strip" aria-label="마이그레이션 상태">
<article><span class="metric-icon mint"></span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
<article><span class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small>안전한 어댑터 대기</small></div><b>PENDING</b></article>
<article id="databaseSummary"><span id="databaseSummaryIcon" class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small id="databaseSummaryDetail">연결 상태 확인 중</small></div><b id="databaseSummaryBadge">CHECKING</b></article>
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
</section>
@@ -62,7 +62,7 @@
<section class="panel catalog-panel">
<div class="panel-header">
<div><p class="panel-kicker">CUT CATALOG</p><h2>그래픽 항목</h2></div>
<span class="badge">Web UI</span>
<span id="catalogModeBadge" class="badge">Web UI</span>
</div>
<label class="search-box">
<span></span>
@@ -75,6 +75,18 @@
<button type="button" data-category="chart">차트</button>
<button type="button" data-category="market">시장</button>
</div>
<section id="liveDataPanel" class="live-data-panel" aria-labelledby="liveDataTitle" hidden>
<div class="live-data-header">
<div>
<p class="panel-kicker">LIVE DATABASE</p>
<h3 id="liveDataTitle">실시간 시장 데이터</h3>
</div>
<button id="retryLiveDataButton" class="live-retry" type="button">다시 조회</button>
</div>
<div id="databaseSources" class="database-sources" aria-label="데이터베이스 연결 상태"></div>
<div id="liveDataState" class="live-data-state" role="status" aria-live="polite"></div>
<div id="liveDataTables" class="live-data-tables"></div>
</section>
<div id="catalogList" class="catalog-list" aria-live="polite"></div>
</section>

View File

@@ -85,6 +85,10 @@ button { color: inherit; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.ok { background: var(--mint); box-shadow: 0 0 0 3px rgba(50,213,164,.1); }
.status-dot.warn { background: var(--amber); box-shadow: 0 0 0 3px rgba(255,186,85,.1); }
.status-dot.pending, .status-dot.unconfigured { background: var(--muted-2); box-shadow: 0 0 0 3px rgba(94,114,139,.1); }
.status-dot.healthy { background: var(--mint); box-shadow: 0 0 0 3px rgba(50,213,164,.1); }
.status-dot.partial { background: var(--amber); box-shadow: 0 0 0 3px rgba(255,186,85,.1); }
.status-dot.error { background: var(--red); box-shadow: 0 0 0 3px rgba(255,102,128,.1); }
.runtime { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-soft); color: var(--muted-2); font: 9px/1.4 Consolas, monospace; }
.workspace { min-width: 0; min-height: 0; overflow: auto; padding: 0 24px 22px; background: radial-gradient(circle at 76% -10%, rgba(44,104,132,.13), transparent 35%), var(--bg); }
@@ -119,6 +123,16 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.metric-icon.blue { background: rgba(86,168,255,.12); color: var(--blue); }
.metric-icon.amber { background: rgba(255,186,85,.12); color: var(--amber); }
.metric-icon.violet { background: rgba(155,140,255,.12); color: var(--violet); }
.metric-icon.database-icon.pending, .metric-icon.database-icon.unconfigured { background: rgba(94,114,139,.12); color: var(--muted); }
.metric-icon.database-icon.healthy { background: var(--mint-soft); color: var(--mint); }
.metric-icon.database-icon.partial { background: rgba(255,186,85,.12); color: var(--amber); }
.metric-icon.database-icon.error { background: rgba(255,102,128,.1); color: var(--red); }
.database-summary.healthy { border-color: rgba(50,213,164,.22); }
.database-summary.partial { border-color: rgba(255,186,85,.2); }
.database-summary.error { border-color: rgba(255,102,128,.24); }
.database-summary.healthy > b { color: var(--mint); }
.database-summary.partial > b { color: var(--amber); }
.database-summary.error > b { color: var(--red); }
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
@@ -127,6 +141,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.panel-header h2 { margin: 0; font-size: 15px; font-weight: 650; }
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
.badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); }
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
.search-box > span { color: var(--muted); font-size: 17px; }
@@ -137,6 +152,52 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.catalog-tabs { display: flex; gap: 5px; padding: 0 13px 11px; border-bottom: 1px solid var(--border-soft); }
.catalog-tabs button { padding: 5px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }
.catalog-tabs button.active { border-color: var(--border); background: var(--surface-3); color: white; }
.live-data-panel { display: flex; min-height: 235px; max-height: 54%; flex: 0 1 365px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
.live-data-header { display: flex; min-height: 51px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 7px; border-bottom: 1px solid var(--border-soft); }
.live-data-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.live-data-header .panel-kicker { margin-bottom: 3px; }
.live-retry { min-height: 27px; padding: 0 9px; border: 1px solid #29415b; border-radius: 6px; background: var(--surface-2); color: #aebed0; font-size: 9px; cursor: pointer; }
.live-retry:hover { border-color: var(--mint); color: var(--mint); }
.database-sources { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 8px 10px 0; }
.database-source { display: grid; grid-template-columns: 7px auto minmax(0, 1fr); align-items: center; min-width: 0; gap: 5px; min-height: 28px; padding: 0 7px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(17,31,50,.72); }
.database-source .source-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--muted-2); }
.database-source strong { color: #aebed0; font-size: 8px; }
.database-source small { overflow: hidden; color: var(--muted-2); font-size: 8px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.database-source.healthy { border-color: rgba(50,213,164,.17); }
.database-source.healthy .source-dot { background: var(--mint); box-shadow: 0 0 0 2px rgba(50,213,164,.09); }
.database-source.healthy small { color: #79d9bd; }
.database-source.error { border-color: rgba(255,102,128,.2); }
.database-source.error .source-dot { background: var(--red); box-shadow: 0 0 0 2px rgba(255,102,128,.08); }
.database-source.error small { color: #dc8796; }
.database-source.pending .source-dot { animation: live-pulse 1.1s ease-in-out infinite; background: var(--blue); }
.database-source.unconfigured small { color: #657991; }
.live-data-state { display: flex; min-height: 42px; align-items: center; gap: 9px; margin: 7px 10px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(13,25,41,.76); color: #b7c6d7; }
.live-data-state:empty { display: none; }
.live-data-state strong, .live-data-state small { display: block; }
.live-data-state strong { margin-bottom: 2px; font-size: 9px; }
.live-data-state small { overflow: hidden; color: var(--muted-2); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.live-data-state.error { border-color: rgba(255,102,128,.22); background: rgba(255,102,128,.055); }
.live-data-state.error strong { color: #ff9bad; }
.live-data-state.ready { border-color: rgba(50,213,164,.18); background: rgba(50,213,164,.055); }
.live-data-state.ready strong { color: #8bdcc4; }
.live-spinner { width: 16px; height: 16px; flex: 0 0 auto; border: 2px solid rgba(86,168,255,.2); border-top-color: var(--blue); border-radius: 50%; animation: live-spin .8s linear infinite; }
.live-error-icon { display: grid; width: 17px; height: 17px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: rgba(255,102,128,.13); color: var(--red); font: 700 10px Consolas, monospace; }
.live-data-tables { min-height: 0; flex: 1; overflow-y: auto; padding: 7px 10px 10px; }
.live-table-card { margin-bottom: 7px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(13,25,41,.7); overflow: hidden; }
.live-table-card:last-child { margin-bottom: 0; }
.live-table-card > header { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px; border-bottom: 1px solid var(--border-soft); }
.live-table-card h4 { margin: 0 0 2px; color: #cbd8e7; font-size: 9px; }
.live-table-card header small { display: block; color: var(--muted-2); font-size: 7px; }
.live-source-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.2); border-radius: 5px; background: rgba(86,168,255,.08); color: #79b7f8; font: 7px Consolas, monospace; }
.live-table-scroll { max-height: 145px; overflow: auto; }
.live-table { width: max-content; min-width: 100%; table-layout: auto; }
.live-table th { height: 27px; min-width: 76px; padding: 0 7px; background: #0c1a29; color: #70849c; font-size: 7px; white-space: nowrap; }
.live-table td { max-width: 210px; height: 27px; padding: 0 7px; overflow: hidden; color: #adbdcf; font: 8px Consolas, "Malgun Gothic", sans-serif; text-overflow: ellipsis; white-space: nowrap; }
.live-table tbody tr { cursor: default; }
.live-table tbody tr:hover { background: rgba(86,168,255,.035); }
.live-table-empty { margin: 0; padding: 15px 8px; color: var(--muted-2); font-size: 8px; text-align: center; }
@keyframes live-spin { to { transform: rotate(360deg); } }
@keyframes live-pulse { 50% { opacity: .35; } }
.catalog-list { min-height: 0; flex: 1; overflow-y: auto; padding: 8px; }
.catalog-item { display: grid; grid-template-columns: 34px 1fr 27px; align-items: center; min-height: 50px; gap: 9px; padding: 6px 7px; border: 1px solid transparent; border-radius: 8px; }
.catalog-item:hover { border-color: var(--border); background: rgba(255,255,255,.025); }

77
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,77 @@
# Oracle/MariaDB 연결 운영 가이드
## 적용된 공급자
`.NET 8`용 실제 ADO.NET 계층은 다음 버전을 고정합니다.
- Oracle: `Oracle.ManagedDataAccess.Core` `23.26.200`
- MariaDB: `MySqlConnector` `2.6.1`
Oracle ODP.NET Core 23 계열은 TAP 기반 `OpenAsync`/명령 비동기를 지원하며, 이 구현은 Oracle pipelining을 켜지 않습니다. MariaDB Connector/NET 공식 가이드가 권장하는 MySqlConnector는 `OpenAsync`, reader 비동기와 취소를 사용합니다. Oracle 23.26.200은 Oracle Database 19c 이상을 전제로 하며, 현재 운영 스모크에서 서버 주 버전 21을 확인했습니다.
공식 참고:
- https://www.nuget.org/packages/Oracle.ManagedDataAccess.Core/23.26.200
- https://docs.oracle.com/en/database/oracle/oracle-database/26/odpnt/featAsyncPipelining.html
- https://mariadb.com/docs/connectors/mariadb-connector-net/mariadb-connector-net-guide
- https://www.nuget.org/packages/MySqlConnector/2.6.1
## 런타임 설정
MSIX 설치 폴더는 읽기 전용이므로 설정 파일을 패키지 디렉터리에 두지 않습니다. 기본 경로는 다음과 같습니다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json
```
저장소의 [appsettings.example.json](../Config/appsettings.example.json)은 비밀값이 없는 구조 예시입니다. 실제 파일은 [Initialize-DatabaseConfig.ps1](../scripts/Initialize-DatabaseConfig.ps1)로 만들 수 있습니다. 이 스크립트는 비밀번호를 프롬프트로 받고 현재 Windows 사용자만 파일을 읽도록 ACL을 설정합니다.
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\Initialize-DatabaseConfig.ps1 `
-OracleHost '<host>' -OraclePort 1521 -OracleSid '<sid>' -OracleUserName '<user>' `
-MariaDbHost '<host>' -MariaDbPort 3306 -MariaDbDatabase '<database>' -MariaDbUserName '<user>'
```
Oracle은 `sid` 또는 `serviceName` 중 정확히 하나를 설정합니다. 원본 MBN_STOCK_N은 SID descriptor를 사용했으므로 legacy 연결은 `sid`로 옮깁니다. 설정 파일은 평문 비밀번호를 포함할 수 있으므로 저장소·로그·첨부파일로 복사하지 않습니다. 운영 환경에서는 실행 계정의 파일 ACL을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다.
JSON보다 환경 변수가 우선합니다.
```text
MBN_STOCK_ORACLE_HOST / PORT / SID / SERVICE_NAME / USERNAME / PASSWORD
MBN_STOCK_MARIADB_HOST / PORT / DATABASE / USERNAME / PASSWORD / TLS_MODE
MBN_STOCK_DB_OPERATION_TIMEOUT_SECONDS
MBN_STOCK_DB_MAX_RETRY_COUNT
MBN_STOCK_DB_INITIAL_RETRY_DELAY_MS
```
MariaDB `tlsMode``Disabled`, `Preferred`, `Required`, `VerifyCertificate`, `VerifyIdentity`를 지원합니다. 인증서와 호스트 이름을 운영 환경에서 검증할 수 있으면 `VerifyIdentity`를 사용합니다. legacy LAN과의 호환을 위해 예시 기본값은 `Preferred`입니다.
## 실행 정책
각 조회는 연결을 새로 만들고 공급자 풀링을 사용합니다. `OpenAsync`, `ExecuteReaderAsync`, `ReadAsync`에 같은 취소 토큰을 전달하며, 공급자 command timeout과 전체 작업 timeout을 모두 적용합니다. 호출자가 취소한 작업은 재시도하지 않습니다. 재시도는 `SELECT`/`WITH` 읽기와 검토된 일시적 네트워크 오류에만 제한하고, 매 시도마다 기존 연결을 폐기한 뒤 새 연결을 엽니다. INSERT/UPDATE/DDL과 인증·스키마·SQL 오류는 재실행하지 않습니다.
DB 오류는 `DatabaseOperationException`의 안전한 한국어 메시지로 변환됩니다. provider 예외, raw connection string, 사용자명, 비밀번호, SQL은 WebView 메시지나 예외 `ToString()`에 포함하지 않습니다. health 상태는 Oracle과 MariaDB를 별도로 표시하므로 한쪽 장애가 앱 종료나 다른 쪽 결과 표시를 막지 않습니다.
## WebView 메시지
Web UI가 `request-database-status`를 보내면 native bridge가 양쪽 health 상태를 반환합니다. `kospi`, `kosdaq`, `index`, `overseas` 메뉴는 `request-market-data`를 보내고, native는 bounded DTO로 열 이름·행·전체 행 수를 반환합니다. `DataTable` 자체를 JSON으로 직렬화하지 않으며, 이전 `requestId` 응답은 Web UI가 버립니다. 오류는 `market-data-error`로 표시되고 기존 플레이리스트·PREPARE/TAKE IN/TAKE OUT UI는 계속 동작합니다.
## 검증 명령
단위 테스트:
```powershell
dotnet test MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64
```
실제 DB 스모크(설정 파일을 읽어 비밀값은 출력하지 않음):
```powershell
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release
```
스모크는 양쪽 `SELECT 1`, Oracle 서버 버전, 코스피·코스닥·NXT·5개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다.
## MSIX
패키지 앱은 `internetClient``privateNetworkClientServer` capability를 사용합니다. Debug/Release x64와 MSIX 생성은 저장소 루트 README의 명령을 사용합니다. `ThirdPartyNotices`의 Oracle 및 MySqlConnector 고지는 MSIX 콘텐츠에 포함됩니다. 실제 운영 배포는 조직 인증서로 서명해야 하며 `.pfx`/비밀키는 저장소에 넣지 않습니다.

View File

@@ -22,6 +22,9 @@
| WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 |
| 직접 DBManager 호출 | Core의 `IDataQueryExecutor` 경계 |
| `Data/Request` SQL 정의 | .NET 8 Core 프로젝트로 71개 이관 |
| 동기 Oracle/MySQL 연결 | 비동기 Oracle/MariaDB 공급자, 취소·timeout·선별 재시도 |
| DB 실패 시 `Application.Exit()` | 앱 유지 + WebView 소스별 health/오류/재조회 |
| 종목·지수 선택 데이터 | WebView의 KRX/NXT 종목 및 5개 지수 실데이터 표 |
| AnyCPU/x86 혼재 | 솔루션 및 게시 프로필 x64 단일화 |
| 상대 경로 Web 파일 | MSIX Content 및 안전한 가상 호스트 매핑 |
@@ -31,11 +34,12 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
### 데이터베이스
- `IDataQueryExecutor`를 구현하는 Oracle/MariaDB 어댑터
- .NET 8 호환 공급자 선정 및 연결 검증
- 기존 문자열 조합 SQL의 매개변수화
- 비동기 실행, 취소, 타임아웃, 재연결 정책
- Credential Locker/DPAPI 등 승인된 비밀 저장 방식
- 완료: `Oracle.ManagedDataAccess.Core 23.26.200`, `MySqlConnector 2.6.1` 고정
- 완료: 실제 비동기 `IDataQueryExecutor`, 취소, 명령/전체 timeout, 새 연결 재시도
- 완료: LocalAppData/환경 변수 설정과 공개 오류의 비밀값 비노출
- 완료: Oracle/MariaDB health, 기존 종목·지수 SQL의 WebView 조회, 단위/실DB 스모크
- 후속: 운영 보안 정책에 맞춘 Credential Locker/DPAPI 전환과 계정 자동 순환
- 후속: 사용자 입력이 들어가는 나머지 legacy SQL의 단계적 매개변수화
### Tornado/K3D

View File

@@ -0,0 +1,111 @@
[CmdletBinding(DefaultParameterSetName = 'Sid')]
param(
[Parameter(Mandatory)]
[string] $OracleHost,
[Parameter(Mandatory)]
[ValidateRange(1, 65535)]
[int] $OraclePort,
[Parameter(Mandatory, ParameterSetName = 'Sid')]
[string] $OracleSid,
[Parameter(Mandatory, ParameterSetName = 'ServiceName')]
[string] $OracleServiceName,
[Parameter(Mandatory)]
[string] $OracleUserName,
[Parameter(Mandatory)]
[string] $MariaDbHost,
[Parameter(Mandatory)]
[ValidateRange(1, 65535)]
[int] $MariaDbPort,
[Parameter(Mandatory)]
[string] $MariaDbDatabase,
[Parameter(Mandatory)]
[string] $MariaDbUserName,
[ValidateSet('Disabled', 'Preferred', 'Required', 'VerifyCertificate', 'VerifyIdentity')]
[string] $MariaDbTlsMode = 'Preferred',
[string] $OutputPath = (Join-Path $env:LOCALAPPDATA 'MBN_STOCK_WEBVIEW\Config\database.local.json')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function ConvertTo-PlainText {
param([Parameter(Mandatory)][Security.SecureString] $SecureValue)
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
$oraclePassword = Read-Host 'Oracle password' -AsSecureString
$mariaDbPassword = Read-Host 'MariaDB password' -AsSecureString
try {
$configuration = [ordered]@{
oracle = [ordered]@{
host = $OracleHost
port = $OraclePort
sid = if ($PSCmdlet.ParameterSetName -eq 'Sid') { $OracleSid } else { $null }
serviceName = if ($PSCmdlet.ParameterSetName -eq 'ServiceName') { $OracleServiceName } else { $null }
userName = $OracleUserName
password = ConvertTo-PlainText $oraclePassword
connectTimeoutSeconds = 10
commandTimeoutSeconds = 30
pooling = $true
}
mariaDb = [ordered]@{
host = $MariaDbHost
port = $MariaDbPort
database = $MariaDbDatabase
userName = $MariaDbUserName
password = ConvertTo-PlainText $mariaDbPassword
tlsMode = $MariaDbTlsMode
connectTimeoutSeconds = 10
commandTimeoutSeconds = 30
pooling = $true
}
resilience = [ordered]@{
operationTimeoutSeconds = 45
maximumRetryCount = 2
initialRetryDelayMilliseconds = 250
}
}
$fullPath = [IO.Path]::GetFullPath($OutputPath)
$directory = [IO.Path]::GetDirectoryName($fullPath)
[IO.Directory]::CreateDirectory($directory) | Out-Null
$json = $configuration | ConvertTo-Json -Depth 5
[IO.File]::WriteAllText($fullPath, $json, [Text.UTF8Encoding]::new($false))
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$acl = [Security.AccessControl.FileSecurity]::new()
$acl.SetAccessRuleProtection($true, $false)
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
$identity,
[Security.AccessControl.FileSystemRights]::FullControl,
[Security.AccessControl.AccessControlType]::Allow)
$acl.AddAccessRule($rule)
[IO.File]::SetAccessControl($fullPath, $acl)
Write-Host "Database configuration created at: $fullPath"
Write-Warning 'This user-only file contains plaintext passwords. Do not copy it into the repository or attach it to logs.'
}
finally {
$oraclePassword.Dispose()
$mariaDbPassword.Dispose()
$configuration = $null
$json = $null
}

View File

@@ -19,6 +19,12 @@ public enum DataSourceKind
public interface IDataQueryExecutor
{
DataTable Execute(DataSourceKind source, string tableName, string query);
Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default);
}
/// <summary>
@@ -28,9 +34,9 @@ public static class DataQueryExecutor
{
private static IDataQueryExecutor? _current;
public static bool IsConfigured => _current is not null;
public static bool IsConfigured => Volatile.Read(ref _current) is not null;
public static IDataQueryExecutor Current => _current
public static IDataQueryExecutor Current => Volatile.Read(ref _current)
?? throw new InvalidOperationException(
"A database query executor has not been configured. " +
"Configure the infrastructure adapter before requesting live data.");
@@ -38,8 +44,8 @@ public static class DataQueryExecutor
public static void Configure(IDataQueryExecutor executor)
{
ArgumentNullException.ThrowIfNull(executor);
_current = executor;
Interlocked.Exchange(ref _current, executor);
}
public static void Reset() => _current = null;
public static void Reset() => Interlocked.Exchange(ref _current, null);
}

View File

@@ -0,0 +1,165 @@
#nullable enable
using System.Data;
using System.Globalization;
namespace MMoneyCoderSharp.Data;
public enum MarketDataView
{
Kospi,
Kosdaq,
Index,
Overseas
}
public sealed record MarketDataTable(
string Name,
DataSourceKind Source,
IReadOnlyList<string> Columns,
IReadOnlyList<IReadOnlyList<string?>> Rows,
int TotalRowCount);
public sealed record MarketDataSnapshot(
MarketDataView View,
DateTimeOffset RetrievedAt,
IReadOnlyList<MarketDataTable> Tables);
public interface IMarketDataService
{
Task<MarketDataSnapshot> GetAsync(
MarketDataView view,
int maximumRowsPerTable = 250,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Loads the same stock and index lookup queries used by the legacy application.
/// Query text is intentionally kept unchanged during the migration.
/// </summary>
public sealed class LegacyMarketDataService(IDataQueryExecutor executor) : IMarketDataService
{
private readonly IDataQueryExecutor _executor = executor ?? throw new ArgumentNullException(nameof(executor));
public async Task<MarketDataSnapshot> GetAsync(
MarketDataView view,
int maximumRowsPerTable = 250,
CancellationToken cancellationToken = default)
{
if (maximumRowsPerTable is < 1 or > 2_000)
{
throw new ArgumentOutOfRangeException(
nameof(maximumRowsPerTable),
"The requested row limit must be between 1 and 2,000.");
}
var queries = LegacyMarketQueries.For(view);
var loads = queries.Select(query => LoadAsync(query, maximumRowsPerTable, cancellationToken));
var tables = await Task.WhenAll(loads).ConfigureAwait(false);
return new MarketDataSnapshot(view, DateTimeOffset.Now, tables);
}
private async Task<MarketDataTable> LoadAsync(
LegacyMarketQuery query,
int maximumRows,
CancellationToken cancellationToken)
{
var table = await _executor
.ExecuteAsync(query.Source, query.Name, query.Sql, cancellationToken)
.ConfigureAwait(false);
var columns = table.Columns.Cast<DataColumn>().Select(column => column.ColumnName).ToArray();
var rows = table.Rows.Cast<DataRow>()
.Take(maximumRows)
.Select(row => (IReadOnlyList<string?>)columns
.Select(column => FormatValue(row[column]))
.ToArray())
.ToArray();
return new MarketDataTable(query.Name, query.Source, columns, rows, table.Rows.Count);
}
private static string? FormatValue(object? value)
{
if (value is null or DBNull)
{
return null;
}
return value switch
{
DateTime dateTime => dateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString()
};
}
}
internal sealed record LegacyMarketQuery(string Name, DataSourceKind Source, string Sql);
internal static class LegacyMarketQueries
{
internal static IReadOnlyList<LegacyMarketQuery> For(MarketDataView view) => view switch
{
MarketDataView.Kospi =>
[
new("코스피", DataSourceKind.Oracle,
"SELECT F_STOCK_WANNAME, F_STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
new("NXT코스피", DataSourceKind.MariaDb,
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') F_STOCK_NAME, F_STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'")
],
MarketDataView.Kosdaq =>
[
new("코스닥", DataSourceKind.Oracle,
"SELECT F_STOCK_WANNAME, F_STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
new("NXT코스닥", DataSourceKind.MariaDb,
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') F_STOCK_NAME, F_STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
],
MarketDataView.Index =>
[
new("코스피", DataSourceKind.Oracle, IndexKospi),
new("코스닥", DataSourceKind.Oracle, IndexKosdaq),
new("코스피200", DataSourceKind.Oracle, IndexKospi200),
new("KRX100", DataSourceKind.Oracle, IndexKrx100),
new("선물", DataSourceKind.Oracle, IndexFuture)
],
MarketDataView.Overseas =>
[
new("해외", DataSourceKind.Oracle,
"SELECT F_KNAM, F_SYMB FROM T_WORLD_IX_EQ_MASTER")
],
_ => throw new ArgumentOutOfRangeException(nameof(view), view, "Unsupported market data view.")
};
private const string IndexKospi = @"SELECT '코스피' name, round(f_part_idx/100,2) part_idx, f_chg_type chg_type, round(f_part_chg/100,2) part_chg,
round((f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1)) * 100, 2) rate
FROM t_index
WHERE f_part_code = '001'";
private const string IndexKosdaq = @"SELECT '코스닥' name, round(f_part_idx/100,2) part_idx, f_chg_type chg_type, round(f_part_chg/100,2) part_chg,
round((f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1)) * 100, 2) rate
FROM t_kosdaq_index
WHERE f_part_code = '001'";
private const string IndexKospi200 = @"SELECT '코스피200' name,f_part_idx/100 part_idx, f_chg_type chg_type, f_part_chg/100 part_chg,
round(f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1) * 100, 2) rate
FROM T_200_INDEX
WHERE f_part_code = '029'";
private const string IndexKrx100 = @"SELECT 'KRX100' name, f_part_idx/100 part_idx, f_chg_type chg_type, f_part_chg/100 part_chg,
round(f_part_chg / decode(f_chg_type, '+', (f_part_idx / 100) - (f_part_chg / 100), '-', (f_part_idx / 100) + (f_part_chg / 100)), 2) rate
FROM t_krx100_index WHERE f_part_code = '043'";
private const string IndexFuture = @"SELECT '선물' name, part_idx part_idx, chg_type, ABS(part_chg) part_chg,F_NET_VOL,f_out_vol, F_NET_TURNOVER,
ABS(round(part_chg / decode(chg_type, '+', (part_idx / 100) - (part_chg / 100),
'-', (part_idx / 100) + (part_chg / 100), 1), 2)) rate
FROM(SELECT decode(a.f_curr_price,0, b.F_JUN_LAST_PRICE/100, a.f_curr_price/100) part_idx, a.F_NET_VOL, a.f_out_vol, a.F_NET_TURNOVER,
decode(a.f_curr_price, 0, 0, (a.f_curr_price - b.F_JUN_LAST_PRICE*100)/100) part_chg,
decode(a.f_curr_price, 0, ' ', decode(sign(a.f_curr_price -b.F_JUN_LAST_PRICE*100),1,'+',-1,'-',0,' ')) chg_type
FROM t_sunmul_online a, t_sunmul_batch b
WHERE a.f_stock_code = b.f_stock_code
AND a.f_stock_seq = 1
AND b.f_market_date = (select max(OPEN_DAY) from v_open_day)
AND b.F_MONTH_GUBUN = '1') ";
}

View File

@@ -0,0 +1,217 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public enum MariaDbTlsMode
{
Disabled,
Preferred,
Required,
VerifyCertificate,
VerifyIdentity
}
public sealed class DatabaseOptions
{
public OracleDatabaseOptions Oracle { get; set; } = new();
public MariaDbDatabaseOptions MariaDb { get; set; } = new();
public DatabaseResilienceOptions Resilience { get; set; } = new();
public bool IsConfigured(DataSourceKind source) => GetConfigurationErrors(source).Count == 0;
public IReadOnlyList<string> GetConfigurationErrors(DataSourceKind source) => source switch
{
DataSourceKind.Oracle => Oracle.GetConfigurationErrors(),
DataSourceKind.MariaDb => MariaDb.GetConfigurationErrors(),
_ => ["DataSource"]
};
internal void Normalize()
{
Oracle ??= new OracleDatabaseOptions();
MariaDb ??= new MariaDbDatabaseOptions();
Resilience ??= new DatabaseResilienceOptions();
}
internal void ValidateRuntimeSettings()
{
if (Resilience.OperationTimeoutSeconds is < 1 or > 600)
{
throw new DatabaseConfigurationException(
"OperationTimeoutSeconds는 1초에서 600초 사이여야 합니다.");
}
if (Resilience.MaximumRetryCount is < 0 or > 5)
{
throw new DatabaseConfigurationException(
"MaximumRetryCount는 0에서 5 사이여야 합니다.");
}
if (Resilience.InitialRetryDelayMilliseconds is < 0 or > 30_000)
{
throw new DatabaseConfigurationException(
"InitialRetryDelayMilliseconds는 0에서 30000 사이여야 합니다.");
}
Oracle.ValidateTimeouts();
MariaDb.ValidateTimeouts();
}
public override string ToString() =>
$"DatabaseOptions {{ Oracle = {ConfigurationState(Oracle.GetConfigurationErrors())}, " +
$"MariaDb = {ConfigurationState(MariaDb.GetConfigurationErrors())}, " +
$"Resilience = {Resilience} }}";
private static string ConfigurationState(IReadOnlyCollection<string> errors) =>
errors.Count == 0 ? "Configured" : "NotConfigured";
}
public sealed class OracleDatabaseOptions
{
public string? Host { get; set; }
public int Port { get; set; } = 1521;
public string? Sid { get; set; }
public string? ServiceName { get; set; }
public string? UserName { get; set; }
public string? Password { get; set; }
public int ConnectTimeoutSeconds { get; set; } = 10;
public int CommandTimeoutSeconds { get; set; } = 30;
public bool Pooling { get; set; } = true;
internal IReadOnlyList<string> GetConfigurationErrors()
{
var errors = new List<string>();
AddIfBlank(errors, Host, nameof(Host));
AddIfBlank(errors, UserName, nameof(UserName));
AddIfBlank(errors, Password, nameof(Password));
var hasSid = !string.IsNullOrWhiteSpace(Sid);
var hasServiceName = !string.IsNullOrWhiteSpace(ServiceName);
if (hasSid == hasServiceName)
{
errors.Add("SidOrServiceName");
}
if (Port is < 1 or > 65_535)
{
errors.Add(nameof(Port));
}
return errors;
}
internal void ValidateTimeouts()
{
ValidateTimeout(ConnectTimeoutSeconds, nameof(ConnectTimeoutSeconds));
ValidateTimeout(CommandTimeoutSeconds, nameof(CommandTimeoutSeconds));
}
public override string ToString() =>
$"OracleDatabaseOptions {{ State = {(GetConfigurationErrors().Count == 0 ? "Configured" : "NotConfigured")}, " +
$"Port = {Port}, ConnectTimeoutSeconds = {ConnectTimeoutSeconds}, " +
$"CommandTimeoutSeconds = {CommandTimeoutSeconds}, Pooling = {Pooling} }}";
private static void ValidateTimeout(int value, string settingName)
{
if (value is < 1 or > 600)
{
throw new DatabaseConfigurationException($"{settingName}는 1초에서 600초 사이여야 합니다.");
}
}
private static void AddIfBlank(ICollection<string> errors, string? value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
errors.Add(name);
}
}
}
public sealed class MariaDbDatabaseOptions
{
public string? Host { get; set; }
public int Port { get; set; } = 3306;
public string? Database { get; set; }
public string? UserName { get; set; }
public string? Password { get; set; }
public MariaDbTlsMode TlsMode { get; set; } = MariaDbTlsMode.Preferred;
public int ConnectTimeoutSeconds { get; set; } = 10;
public int CommandTimeoutSeconds { get; set; } = 30;
public bool Pooling { get; set; } = true;
internal IReadOnlyList<string> GetConfigurationErrors()
{
var errors = new List<string>();
AddIfBlank(errors, Host, nameof(Host));
AddIfBlank(errors, Database, nameof(Database));
AddIfBlank(errors, UserName, nameof(UserName));
AddIfBlank(errors, Password, nameof(Password));
if (Port is < 1 or > 65_535)
{
errors.Add(nameof(Port));
}
return errors;
}
internal void ValidateTimeouts()
{
ValidateTimeout(ConnectTimeoutSeconds, nameof(ConnectTimeoutSeconds));
ValidateTimeout(CommandTimeoutSeconds, nameof(CommandTimeoutSeconds));
}
public override string ToString() =>
$"MariaDbDatabaseOptions {{ State = {(GetConfigurationErrors().Count == 0 ? "Configured" : "NotConfigured")}, " +
$"Port = {Port}, TlsMode = {TlsMode}, ConnectTimeoutSeconds = {ConnectTimeoutSeconds}, " +
$"CommandTimeoutSeconds = {CommandTimeoutSeconds}, Pooling = {Pooling} }}";
private static void ValidateTimeout(int value, string settingName)
{
if (value is < 1 or > 600)
{
throw new DatabaseConfigurationException($"{settingName}는 1초에서 600초 사이여야 합니다.");
}
}
private static void AddIfBlank(ICollection<string> errors, string? value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
errors.Add(name);
}
}
}
public sealed class DatabaseResilienceOptions
{
public int OperationTimeoutSeconds { get; set; } = 45;
public int MaximumRetryCount { get; set; } = 2;
public int InitialRetryDelayMilliseconds { get; set; } = 250;
public override string ToString() =>
$"DatabaseResilienceOptions {{ OperationTimeoutSeconds = {OperationTimeoutSeconds}, " +
$"MaximumRetryCount = {MaximumRetryCount}, " +
$"InitialRetryDelayMilliseconds = {InitialRetryDelayMilliseconds} }}";
}

View File

@@ -0,0 +1,223 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public sealed class DatabaseOptionsLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly Func<string, string?> _readEnvironmentVariable;
public DatabaseOptionsLoader(Func<string, string?>? readEnvironmentVariable = null)
{
_readEnvironmentVariable = readEnvironmentVariable ?? Environment.GetEnvironmentVariable;
}
public static string DefaultConfigurationPath
{
get
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
throw new DatabaseConfigurationException(
"사용자 로컬 애플리케이션 데이터 폴더를 찾을 수 없습니다.");
}
return Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"Config",
"database.local.json");
}
}
public DatabaseOptions Load(string? jsonPath = null)
{
var path = jsonPath ?? DefaultConfigurationPath;
var options = LoadJson(path);
ApplyEnvironmentOverrides(options);
options.Normalize();
options.ValidateRuntimeSettings();
return options;
}
public async Task<DatabaseOptions> LoadAsync(
string? jsonPath = null,
CancellationToken cancellationToken = default)
{
var path = jsonPath ?? DefaultConfigurationPath;
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
ApplyEnvironmentOverrides(options);
options.Normalize();
options.ValidateRuntimeSettings();
return options;
}
private static DatabaseOptions LoadJson(string path)
{
if (!File.Exists(path))
{
return new DatabaseOptions();
}
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<DatabaseOptions>(json, SerializerOptions)
?? new DatabaseOptions();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
throw new DatabaseConfigurationException(
"로컬 데이터베이스 설정 파일을 읽을 수 없습니다.");
}
}
private static async Task<DatabaseOptions> LoadJsonAsync(string path, CancellationToken cancellationToken)
{
if (!File.Exists(path))
{
return new DatabaseOptions();
}
try
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4_096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return await JsonSerializer
.DeserializeAsync<DatabaseOptions>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false)
?? new DatabaseOptions();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
throw new DatabaseConfigurationException(
"로컬 데이터베이스 설정 파일을 읽을 수 없습니다.");
}
}
private void ApplyEnvironmentOverrides(DatabaseOptions options)
{
options.Normalize();
OverrideString("MBN_STOCK_ORACLE_HOST", value => options.Oracle.Host = value);
OverrideInt32("MBN_STOCK_ORACLE_PORT", value => options.Oracle.Port = value);
OverrideString("MBN_STOCK_ORACLE_SID", value => options.Oracle.Sid = value);
OverrideString("MBN_STOCK_ORACLE_SERVICE_NAME", value => options.Oracle.ServiceName = value);
OverrideString("MBN_STOCK_ORACLE_USERNAME", value => options.Oracle.UserName = value);
OverrideString("MBN_STOCK_ORACLE_PASSWORD", value => options.Oracle.Password = value);
OverrideInt32(
"MBN_STOCK_ORACLE_CONNECT_TIMEOUT_SECONDS",
value => options.Oracle.ConnectTimeoutSeconds = value);
OverrideInt32(
"MBN_STOCK_ORACLE_COMMAND_TIMEOUT_SECONDS",
value => options.Oracle.CommandTimeoutSeconds = value);
OverrideBoolean("MBN_STOCK_ORACLE_POOLING", value => options.Oracle.Pooling = value);
OverrideString("MBN_STOCK_MARIADB_HOST", value => options.MariaDb.Host = value);
OverrideInt32("MBN_STOCK_MARIADB_PORT", value => options.MariaDb.Port = value);
OverrideString("MBN_STOCK_MARIADB_DATABASE", value => options.MariaDb.Database = value);
OverrideString("MBN_STOCK_MARIADB_USERNAME", value => options.MariaDb.UserName = value);
OverrideString("MBN_STOCK_MARIADB_PASSWORD", value => options.MariaDb.Password = value);
OverrideEnum<MariaDbTlsMode>(
"MBN_STOCK_MARIADB_TLS_MODE",
value => options.MariaDb.TlsMode = value);
OverrideInt32(
"MBN_STOCK_MARIADB_CONNECT_TIMEOUT_SECONDS",
value => options.MariaDb.ConnectTimeoutSeconds = value);
OverrideInt32(
"MBN_STOCK_MARIADB_COMMAND_TIMEOUT_SECONDS",
value => options.MariaDb.CommandTimeoutSeconds = value);
OverrideBoolean("MBN_STOCK_MARIADB_POOLING", value => options.MariaDb.Pooling = value);
OverrideInt32(
"MBN_STOCK_DB_OPERATION_TIMEOUT_SECONDS",
value => options.Resilience.OperationTimeoutSeconds = value);
OverrideInt32(
"MBN_STOCK_DB_MAX_RETRY_COUNT",
value => options.Resilience.MaximumRetryCount = value);
OverrideInt32(
"MBN_STOCK_DB_INITIAL_RETRY_DELAY_MS",
value => options.Resilience.InitialRetryDelayMilliseconds = value);
}
private void OverrideString(string name, Action<string> setter)
{
var value = _readEnvironmentVariable(name);
if (value is not null)
{
setter(value.Trim());
}
}
private void OverrideInt32(string name, Action<int> setter)
{
var value = _readEnvironmentVariable(name);
if (value is null)
{
return;
}
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
throw InvalidEnvironmentSetting(name);
}
setter(parsed);
}
private void OverrideBoolean(string name, Action<bool> setter)
{
var value = _readEnvironmentVariable(name);
if (value is null)
{
return;
}
if (!bool.TryParse(value, out var parsed))
{
throw InvalidEnvironmentSetting(name);
}
setter(parsed);
}
private void OverrideEnum<T>(string name, Action<T> setter)
where T : struct, Enum
{
var value = _readEnvironmentVariable(name);
if (value is null)
{
return;
}
if (!Enum.TryParse<T>(value, ignoreCase: true, out var parsed)
|| !Enum.IsDefined(parsed))
{
throw InvalidEnvironmentSetting(name);
}
setter(parsed);
}
private static DatabaseConfigurationException InvalidEnvironmentSetting(string name) =>
new($"환경 설정 {name}의 값이 올바르지 않습니다.");
}

View File

@@ -0,0 +1,11 @@
using System.Data.Common;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public interface IDatabaseConnectionFactory
{
DbConnection Create(DataSourceKind source);
int GetCommandTimeoutSeconds(DataSourceKind source);
}

View File

@@ -0,0 +1,123 @@
using System.Data.Common;
using MMoneyCoderSharp.Data;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public sealed class ProviderDatabaseConnectionFactory : IDatabaseConnectionFactory
{
private readonly DatabaseOptions _options;
public ProviderDatabaseConnectionFactory(DatabaseOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_options.Normalize();
_options.ValidateRuntimeSettings();
}
public DbConnection Create(DataSourceKind source)
{
var errors = _options.GetConfigurationErrors(source);
if (errors.Count > 0)
{
throw new DatabaseNotConfiguredException(source, errors);
}
try
{
return source switch
{
DataSourceKind.Oracle => CreateOracleConnection(),
DataSourceKind.MariaDb => CreateMariaDbConnection(),
_ => throw new DatabaseConfigurationException("지원하지 않는 데이터 원본입니다.")
};
}
catch (DatabaseInfrastructureException)
{
throw;
}
catch
{
throw new DatabaseConfigurationException(
$"{source} 데이터베이스 연결 설정을 구성할 수 없습니다.");
}
}
public int GetCommandTimeoutSeconds(DataSourceKind source) => source switch
{
DataSourceKind.Oracle => _options.Oracle.CommandTimeoutSeconds,
DataSourceKind.MariaDb => _options.MariaDb.CommandTimeoutSeconds,
_ => throw new DatabaseConfigurationException("지원하지 않는 데이터 원본입니다.")
};
private OracleConnection CreateOracleConnection()
{
var options = _options.Oracle;
var host = RequireOracleDescriptorToken(options.Host, nameof(options.Host));
var identifierName = string.IsNullOrWhiteSpace(options.ServiceName) ? "SID" : "SERVICE_NAME";
var identifierValue = string.IsNullOrWhiteSpace(options.ServiceName)
? RequireOracleDescriptorToken(options.Sid, nameof(options.Sid))
: RequireOracleDescriptorToken(options.ServiceName, nameof(options.ServiceName));
var dataSource =
$"(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={host})(PORT={options.Port}))" +
$"(CONNECT_DATA=(SERVER=DEDICATED)({identifierName}={identifierValue})))";
var builder = new OracleConnectionStringBuilder
{
DataSource = dataSource,
UserID = options.UserName,
Password = options.Password,
Pooling = options.Pooling,
PersistSecurityInfo = false,
ConnectionTimeout = options.ConnectTimeoutSeconds
};
return new OracleConnection(builder.ConnectionString);
}
private MySqlConnection CreateMariaDbConnection()
{
var options = _options.MariaDb;
var builder = new MySqlConnectionStringBuilder
{
Server = options.Host,
Port = checked((uint)options.Port),
Database = options.Database,
UserID = options.UserName,
Password = options.Password,
Pooling = options.Pooling,
PersistSecurityInfo = false,
ConnectionTimeout = checked((uint)options.ConnectTimeoutSeconds),
DefaultCommandTimeout = checked((uint)options.CommandTimeoutSeconds),
CancellationTimeout = 2,
SslMode = options.TlsMode switch
{
MariaDbTlsMode.Disabled => MySqlSslMode.None,
MariaDbTlsMode.Preferred => MySqlSslMode.Preferred,
MariaDbTlsMode.Required => MySqlSslMode.Required,
MariaDbTlsMode.VerifyCertificate => MySqlSslMode.VerifyCA,
MariaDbTlsMode.VerifyIdentity => MySqlSslMode.VerifyFull,
_ => throw new DatabaseConfigurationException("MariaDB TLS 설정이 올바르지 않습니다.")
}
};
return new MySqlConnection(builder.ConnectionString);
}
private static string RequireOracleDescriptorToken(string? value, string settingName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new DatabaseConfigurationException($"Oracle {settingName} 설정이 필요합니다.");
}
if (value.IndexOfAny(['(', ')', '=', ';', '\'', '"', '\r', '\n']) >= 0)
{
throw new DatabaseConfigurationException($"Oracle {settingName} 설정 형식이 올바르지 않습니다.");
}
return value.Trim();
}
}

View File

@@ -0,0 +1,44 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public sealed class DatabaseRuntime
{
private DatabaseRuntime(
DatabaseOptions options,
IDatabaseConnectionFactory connectionFactory,
IDataQueryExecutor executor,
IDatabaseHealthService healthService)
{
Options = options;
ConnectionFactory = connectionFactory;
Executor = executor;
HealthService = healthService;
}
public DatabaseOptions Options { get; }
public IDatabaseConnectionFactory ConnectionFactory { get; }
public IDataQueryExecutor Executor { get; }
public IDatabaseHealthService HealthService { get; }
public static DatabaseRuntime CreateDefault(string? configurationPath = null)
{
var options = new DatabaseOptionsLoader().Load(configurationPath);
return Create(options);
}
public static DatabaseRuntime Create(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Normalize();
options.ValidateRuntimeSettings();
var connectionFactory = new ProviderDatabaseConnectionFactory(options);
var executor = new ResilientDataQueryExecutor(connectionFactory, options.Resilience);
var healthService = new DatabaseHealthService(executor, options);
return new DatabaseRuntime(options, connectionFactory, executor, healthService);
}
}

View File

@@ -0,0 +1,81 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public class DatabaseInfrastructureException : Exception
{
public DatabaseInfrastructureException(string message)
: base(message)
{
}
}
public sealed class DatabaseConfigurationException : DatabaseInfrastructureException
{
public DatabaseConfigurationException(string message)
: base(message)
{
}
}
public abstract class DatabaseOperationException : DatabaseInfrastructureException
{
protected DatabaseOperationException(
DataSourceKind source,
string message,
bool isTransient,
int? providerErrorCode = null)
: base(message)
{
DataSource = source;
IsTransient = isTransient;
ProviderErrorCode = providerErrorCode;
}
public DataSourceKind DataSource { get; }
public bool IsTransient { get; }
public int? ProviderErrorCode { get; }
}
public sealed class DatabaseNotConfiguredException : DatabaseOperationException
{
public DatabaseNotConfiguredException(DataSourceKind source, IReadOnlyList<string> invalidSettings)
: base(
source,
$"{source} 데이터베이스 연결 정보가 설정되지 않았습니다. " +
$"확인할 항목: {string.Join(", ", invalidSettings)}.",
isTransient: false)
{
InvalidSettings = invalidSettings.ToArray();
}
public IReadOnlyList<string> InvalidSettings { get; }
}
public sealed class DatabaseQueryException : DatabaseOperationException
{
public DatabaseQueryException(
DataSourceKind source,
bool isTransient,
int? providerErrorCode = null)
: base(
source,
"데이터베이스 요청을 처리할 수 없습니다. 연결 설정과 네트워크 상태를 확인하세요.",
isTransient,
providerErrorCode)
{
}
}
public sealed class DatabaseQueryTimeoutException : DatabaseOperationException
{
public DatabaseQueryTimeoutException(DataSourceKind source)
: base(
source,
"데이터베이스 응답 시간이 초과되었습니다. 잠시 후 다시 시도하세요.",
isTransient: true)
{
}
}

View File

@@ -0,0 +1,270 @@
using System.Data;
using System.Data.Common;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public sealed class ResilientDataQueryExecutor : IDataQueryExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly TimeSpan _operationTimeout;
private readonly int _maximumRetryCount;
private readonly TimeSpan _initialRetryDelay;
public ResilientDataQueryExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector = null)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
_errorDetector = errorDetector ?? new TransientDatabaseErrorDetector();
_operationTimeout = TimeSpan.FromSeconds(resilienceOptions.OperationTimeoutSeconds);
_maximumRetryCount = resilienceOptions.MaximumRetryCount;
_initialRetryDelay = TimeSpan.FromMilliseconds(resilienceOptions.InitialRetryDelayMilliseconds);
}
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
ExecuteAsync(source, tableName, query, CancellationToken.None)
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
public async Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
var retryableQuery = IsIdempotentReadQuery(query);
for (var attempt = 0; ; attempt++)
{
try
{
return await ExecuteAttemptAsync(source, tableName, query, operationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
throw new DatabaseQueryTimeoutException(source);
}
catch (DatabaseNotConfiguredException)
{
throw;
}
catch (DatabaseConfigurationException)
{
throw;
}
catch (Exception exception)
{
if (_errorDetector.IsTimeout(source, exception))
{
throw new DatabaseQueryTimeoutException(source);
}
var isTransient = _errorDetector.IsTransient(source, exception);
if (retryableQuery && isTransient && attempt < _maximumRetryCount)
{
await DelayBeforeRetryAsync(source, attempt, operationToken, cancellationToken)
.ConfigureAwait(false);
continue;
}
throw new DatabaseQueryException(
source,
isTransient,
_errorDetector.GetProviderErrorCode(source, exception));
}
}
}
private async Task<DataTable> ExecuteAttemptAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken)
{
await using var connection = _connectionFactory.Create(source);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = query;
command.CommandType = CommandType.Text;
command.CommandTimeout = _connectionFactory.GetCommandTimeoutSeconds(source);
await using var reader = await command
.ExecuteReaderAsync(CommandBehavior.SequentialAccess, cancellationToken)
.ConfigureAwait(false);
return await ReadDataTableAsync(reader, tableName, cancellationToken)
.ConfigureAwait(false);
}
private async Task DelayBeforeRetryAsync(
DataSourceKind source,
int completedAttempt,
CancellationToken operationToken,
CancellationToken callerToken)
{
if (_initialRetryDelay <= TimeSpan.Zero)
{
operationToken.ThrowIfCancellationRequested();
return;
}
var multiplier = 1L << Math.Min(completedAttempt, 10);
var delayMilliseconds = Math.Min(
_initialRetryDelay.TotalMilliseconds * multiplier,
30_000d);
try
{
await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds), operationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (callerToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
throw new DatabaseQueryTimeoutException(source);
}
}
private static async Task<DataTable> ReadDataTableAsync(
DbDataReader reader,
string tableName,
CancellationToken cancellationToken)
{
var table = new DataTable(string.IsNullOrWhiteSpace(tableName) ? "Result" : tableName)
{
Locale = System.Globalization.CultureInfo.InvariantCulture
};
var columnNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var ordinal = 0; ordinal < reader.FieldCount; ordinal++)
{
var baseName = string.IsNullOrWhiteSpace(reader.GetName(ordinal))
? $"Column{ordinal + 1}"
: reader.GetName(ordinal);
var uniqueName = baseName;
for (var suffix = 2; !columnNames.Add(uniqueName); suffix++)
{
uniqueName = $"{baseName}_{suffix}";
}
var fieldType = reader.GetFieldType(ordinal);
table.Columns.Add(uniqueName, fieldType);
}
var values = new object[reader.FieldCount];
table.BeginLoadData();
try
{
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
reader.GetValues(values);
table.LoadDataRow(values, true);
}
}
finally
{
table.EndLoadData();
}
return table;
}
internal static bool IsIdempotentReadQuery(string query)
{
var span = query.AsSpan().TrimStart();
while (span.StartsWith("--", StringComparison.Ordinal))
{
var lineEnd = span.IndexOfAny('\r', '\n');
if (lineEnd < 0)
{
return false;
}
span = span[(lineEnd + 1)..].TrimStart();
}
if (!StartsWithToken(span, "SELECT") && !StartsWithToken(span, "WITH"))
{
return false;
}
// This is deliberately conservative. False negatives only disable a retry;
// a false positive could repeat a state-changing statement.
ReadOnlySpan<string> stateChangingTokens =
[
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"CALL",
"EXEC",
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"GRANT",
"REVOKE"
];
foreach (var token in stateChangingTokens)
{
if (ContainsToken(span, token))
{
return false;
}
}
return true;
}
private static bool StartsWithToken(ReadOnlySpan<char> sql, ReadOnlySpan<char> token) =>
sql.StartsWith(token, StringComparison.OrdinalIgnoreCase)
&& (sql.Length == token.Length || !IsIdentifierCharacter(sql[token.Length]));
private static bool ContainsToken(ReadOnlySpan<char> sql, ReadOnlySpan<char> token)
{
for (var index = 0; index <= sql.Length - token.Length; index++)
{
if (!sql[index..].StartsWith(token, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var hasLeftBoundary = index == 0 || !IsIdentifierCharacter(sql[index - 1]);
var rightIndex = index + token.Length;
var hasRightBoundary = rightIndex == sql.Length || !IsIdentifierCharacter(sql[rightIndex]);
if (hasLeftBoundary && hasRightBoundary)
{
return true;
}
}
return false;
}
private static bool IsIdentifierCharacter(char value) =>
char.IsLetterOrDigit(value) || value is '_' or '$' or '#';
}

View File

@@ -0,0 +1,138 @@
using System.Data.Common;
using System.Net.Sockets;
using MMoneyCoderSharp.Data;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public interface ITransientDatabaseErrorDetector
{
bool IsTransient(DataSourceKind source, Exception exception);
bool IsTimeout(DataSourceKind source, Exception exception);
int? GetProviderErrorCode(DataSourceKind source, Exception exception);
}
public sealed class TransientDatabaseErrorDetector : ITransientDatabaseErrorDetector
{
private static readonly HashSet<int> OracleTransientErrors =
[
28,
1033,
1034,
1089,
1090,
3113,
3114,
12153,
12154,
12170,
12514,
12516,
12518,
12520,
12528,
12537,
12541,
12543,
12545,
12547,
12560,
12571
];
private static readonly HashSet<int> MariaDbTransientErrors =
[
1040,
1042,
1043,
1047,
1081,
1129,
1130,
1152,
1153,
1154,
1155,
1156,
1157,
1158,
1159,
1160,
1161,
1205,
1213,
1927,
2002,
2003,
2004,
2005,
2006,
2012,
2013,
2014,
2047,
2055
];
public bool IsTransient(DataSourceKind source, Exception exception)
{
if (exception is SocketException or IOException)
{
return true;
}
return source switch
{
DataSourceKind.Oracle when exception is OracleException oracleException =>
OracleTransientErrors.Contains(oracleException.Number),
DataSourceKind.MariaDb when exception is MySqlException mySqlException =>
mySqlException.IsTransient
|| MariaDbTransientErrors.Contains(mySqlException.Number),
_ when exception is DbException dbException => dbException.IsTransient,
_ => false
};
}
public bool IsTimeout(DataSourceKind source, Exception exception)
{
if (exception is TimeoutException)
{
return true;
}
if (source == DataSourceKind.Oracle
&& exception is OracleException oracleException
&& oracleException.Number == 1013)
{
return true;
}
if (source == DataSourceKind.MariaDb
&& exception is MySqlException mySqlException
&& mySqlException.ErrorCode == MySqlErrorCode.CommandTimeoutExpired)
{
return true;
}
for (var inner = exception.InnerException; inner is not null; inner = inner.InnerException)
{
if (inner is TimeoutException)
{
return true;
}
}
return false;
}
public int? GetProviderErrorCode(DataSourceKind source, Exception exception) => source switch
{
DataSourceKind.Oracle when exception is OracleException oracleException => oracleException.Number,
DataSourceKind.MariaDb when exception is MySqlException mySqlException => mySqlException.Number,
_ when exception is DbException dbException => dbException.ErrorCode,
_ => null
};
}

View File

@@ -0,0 +1,119 @@
using System.Diagnostics;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
public enum DatabaseHealthState
{
NotConfigured,
Healthy,
Unhealthy,
TimedOut
}
public sealed record DatabaseHealthStatus(
DataSourceKind Source,
DatabaseHealthState State,
DateTimeOffset CheckedAt,
TimeSpan Elapsed,
string UserMessage);
public interface IDatabaseHealthService
{
Task<DatabaseHealthStatus> CheckAsync(
DataSourceKind source,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<DatabaseHealthStatus>> CheckAllAsync(
CancellationToken cancellationToken = default);
}
public sealed class DatabaseHealthService : IDatabaseHealthService
{
private readonly IDataQueryExecutor _executor;
private readonly DatabaseOptions _options;
public DatabaseHealthService(IDataQueryExecutor executor, DatabaseOptions options)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async Task<DatabaseHealthStatus> CheckAsync(
DataSourceKind source,
CancellationToken cancellationToken = default)
{
var checkedAt = DateTimeOffset.Now;
if (!_options.IsConfigured(source))
{
return new DatabaseHealthStatus(
source,
DatabaseHealthState.NotConfigured,
checkedAt,
TimeSpan.Zero,
"연결 정보가 설정되지 않았습니다.");
}
var stopwatch = Stopwatch.StartNew();
try
{
var query = source switch
{
DataSourceKind.Oracle => "SELECT 1 FROM DUAL",
DataSourceKind.MariaDb => "SELECT 1",
_ => throw new ArgumentOutOfRangeException(nameof(source), source, null)
};
await _executor
.ExecuteAsync(source, $"{source}Health", query, cancellationToken)
.ConfigureAwait(false);
return new DatabaseHealthStatus(
source,
DatabaseHealthState.Healthy,
checkedAt,
stopwatch.Elapsed,
"연결 정상");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (DatabaseQueryTimeoutException exception)
{
return new DatabaseHealthStatus(
source,
DatabaseHealthState.TimedOut,
checkedAt,
stopwatch.Elapsed,
exception.Message);
}
catch (DatabaseInfrastructureException exception)
{
return new DatabaseHealthStatus(
source,
DatabaseHealthState.Unhealthy,
checkedAt,
stopwatch.Elapsed,
exception.Message);
}
catch
{
return new DatabaseHealthStatus(
source,
DatabaseHealthState.Unhealthy,
checkedAt,
stopwatch.Elapsed,
"데이터베이스 연결 상태를 확인할 수 없습니다.");
}
}
public async Task<IReadOnlyList<DatabaseHealthStatus>> CheckAllAsync(
CancellationToken cancellationToken = default)
{
var checks = Enum
.GetValues<DataSourceKind>()
.Select(source => CheckAsync(source, cancellationToken));
return await Task.WhenAll(checks).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>MBN_STOCK_WEBVIEW.Infrastructure</RootNamespace>
<AssemblyName>MBN_STOCK_WEBVIEW.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.200" />
<PackageReference Include="MySqlConnector" Version="2.6.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -0,0 +1,65 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class DataQueryExecutorTests : IDisposable
{
public DataQueryExecutorTests() => DataQueryExecutor.Reset();
public void Dispose() => DataQueryExecutor.Reset();
[Fact]
public void Configure_PublishesExecutorAndResetRemovesIt()
{
var executor = new StubExecutor();
DataQueryExecutor.Configure(executor);
Assert.True(DataQueryExecutor.IsConfigured);
Assert.Same(executor, DataQueryExecutor.Current);
DataQueryExecutor.Reset();
Assert.False(DataQueryExecutor.IsConfigured);
Assert.Throws<InvalidOperationException>(() => DataQueryExecutor.Current);
}
[Fact]
public void Configure_RejectsNullWithoutReplacingCurrentExecutor()
{
var executor = new StubExecutor();
DataQueryExecutor.Configure(executor);
Assert.Throws<ArgumentNullException>(() => DataQueryExecutor.Configure(null!));
Assert.Same(executor, DataQueryExecutor.Current);
}
[Fact]
public void ConfigureAndReset_AreSafeUnderConcurrentPublication()
{
var executors = Enumerable.Range(0, 64).Select(_ => new StubExecutor()).ToArray();
Parallel.ForEach(executors, executor =>
{
DataQueryExecutor.Configure(executor);
_ = DataQueryExecutor.IsConfigured;
DataQueryExecutor.Reset();
});
DataQueryExecutor.Configure(executors[^1]);
Assert.Same(executors[^1], DataQueryExecutor.Current);
}
private sealed class StubExecutor : IDataQueryExecutor
{
public DataTable Execute(DataSourceKind source, string tableName, string query) => new();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default) => Task.FromResult(new DataTable());
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,232 @@
using System.Collections.Concurrent;
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyMarketDataServiceTests
{
[Fact]
public async Task GetAsync_Kospi_ForwardsBothLegacyQueriesToTheirProviders()
{
var executor = new RecordingQueryExecutor(call => CreateSingleRowTable(call.Name));
var service = new LegacyMarketDataService(executor);
using var cancellation = new CancellationTokenSource();
var snapshot = await service.GetAsync(MarketDataView.Kospi, cancellationToken: cancellation.Token);
Assert.Equal(MarketDataView.Kospi, snapshot.View);
Assert.Collection(
snapshot.Tables,
table =>
{
Assert.Equal("코스피", table.Name);
Assert.Equal(DataSourceKind.Oracle, table.Source);
},
table =>
{
Assert.Equal("NXT코스피", table.Name);
Assert.Equal(DataSourceKind.MariaDb, table.Source);
});
var calls = executor.Calls.OrderBy(call => call.Name, StringComparer.Ordinal).ToArray();
Assert.Equal(2, calls.Length);
Assert.Contains(calls, call =>
call.Source == DataSourceKind.Oracle &&
call.Name == "코스피" &&
call.Query == "SELECT F_STOCK_WANNAME, F_STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'");
Assert.Contains(calls, call =>
call.Source == DataSourceKind.MariaDb &&
call.Name == "NXT코스피" &&
call.Query == "SELECT CONCAT(F_STOCK_NAME, '(NXT)') F_STOCK_NAME, F_STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'");
Assert.All(calls, call => Assert.Equal(cancellation.Token, call.CancellationToken));
Assert.Equal(0, executor.SynchronousCallCount);
}
[Fact]
public async Task GetAsync_Index_ForwardsFiveOracleQueriesAndPreservesLegacyKospiSql()
{
const string expectedKospiSql = @"SELECT '코스피' name, round(f_part_idx/100,2) part_idx, f_chg_type chg_type, round(f_part_chg/100,2) part_chg,
round((f_part_chg / decode(f_chg_type, '+', f_part_idx - f_part_chg, '-', f_part_idx + f_part_chg, 1)) * 100, 2) rate
FROM t_index
WHERE f_part_code = '001'";
var executor = new RecordingQueryExecutor(call => CreateSingleRowTable(call.Name));
var service = new LegacyMarketDataService(executor);
var snapshot = await service.GetAsync(MarketDataView.Index);
Assert.Equal(5, snapshot.Tables.Count);
Assert.Equal(
["코스피", "코스닥", "코스피200", "KRX100", "선물"],
snapshot.Tables.Select(table => table.Name));
var calls = executor.Calls.ToArray();
Assert.Equal(5, calls.Length);
Assert.All(calls, call => Assert.Equal(DataSourceKind.Oracle, call.Source));
Assert.Equal(
expectedKospiSql,
Assert.Single(calls, call => call.Name == "코스피").Query);
}
[Fact]
public async Task GetAsync_CapsReturnedRowsButReportsUncappedTotal()
{
var executor = new RecordingQueryExecutor(_ => CreateNumberedTable(5));
var service = new LegacyMarketDataService(executor);
var snapshot = await service.GetAsync(MarketDataView.Overseas, maximumRowsPerTable: 2);
var table = Assert.Single(snapshot.Tables);
Assert.Equal(["Number", "Label"], table.Columns);
Assert.Equal(2, table.Rows.Count);
Assert.Equal(["1", "row-1"], table.Rows[0]);
Assert.Equal(["2", "row-2"], table.Rows[1]);
Assert.Equal(5, table.TotalRowCount);
}
[Fact]
public async Task GetAsync_FormatsNullNumbersAndDatesUsingInvariantCulture()
{
var previousCulture = CultureInfo.CurrentCulture;
var previousUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR");
var executor = new RecordingQueryExecutor(_ => CreateFormattingTable());
var service = new LegacyMarketDataService(executor);
var snapshot = await service.GetAsync(MarketDataView.Overseas);
var row = Assert.Single(Assert.Single(snapshot.Tables).Rows);
Assert.Equal(
[null, "1234.56", "2026-07-10 14:15:16", "2026-07-10 14:15:16 +09:00", "plain"],
row);
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
CultureInfo.CurrentUICulture = previousUiCulture;
}
}
[Fact]
public async Task GetAsync_PropagatesCancellationToExecutorAndCaller()
{
var executor = new RecordingQueryExecutor((call, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(CreateSingleRowTable(call.Name));
});
var service = new LegacyMarketDataService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.GetAsync(MarketDataView.Kospi, cancellationToken: cancellation.Token));
Assert.NotEmpty(executor.Calls);
Assert.All(executor.Calls, call => Assert.Equal(cancellation.Token, call.CancellationToken));
}
[Theory]
[InlineData(-1)]
[InlineData(0)]
[InlineData(2001)]
[InlineData(int.MaxValue)]
public async Task GetAsync_RejectsInvalidRowLimitBeforeExecutingQueries(int maximumRows)
{
var executor = new RecordingQueryExecutor(_ => CreateSingleRowTable("unused"));
var service = new LegacyMarketDataService(executor);
var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => service.GetAsync(MarketDataView.Kospi, maximumRows));
Assert.Equal("maximumRowsPerTable", exception.ParamName);
Assert.Empty(executor.Calls);
}
private static DataTable CreateSingleRowTable(string value)
{
var table = new DataTable();
table.Columns.Add("Value", typeof(string));
table.Rows.Add(value);
return table;
}
private static DataTable CreateNumberedTable(int rowCount)
{
var table = new DataTable();
table.Columns.Add("Number", typeof(int));
table.Columns.Add("Label", typeof(string));
for (var index = 1; index <= rowCount; index++)
{
table.Rows.Add(index, $"row-{index}");
}
return table;
}
private static DataTable CreateFormattingTable()
{
var table = new DataTable();
table.Columns.Add("Null", typeof(object));
table.Columns.Add("Decimal", typeof(decimal));
table.Columns.Add("Date", typeof(DateTime));
table.Columns.Add("DateOffset", typeof(DateTimeOffset));
table.Columns.Add("Text", typeof(string));
table.Rows.Add(
DBNull.Value,
1234.56m,
new DateTime(2026, 7, 10, 14, 15, 16, DateTimeKind.Unspecified),
new DateTimeOffset(2026, 7, 10, 14, 15, 16, TimeSpan.FromHours(9)),
"plain");
return table;
}
private sealed record QueryCall(
DataSourceKind Source,
string Name,
string Query,
CancellationToken CancellationToken);
private sealed class RecordingQueryExecutor : IDataQueryExecutor
{
private readonly Func<QueryCall, CancellationToken, Task<DataTable>> _handler;
private int _synchronousCallCount;
public RecordingQueryExecutor(Func<QueryCall, DataTable> handler)
: this((call, _) => Task.FromResult(handler(call)))
{
}
public RecordingQueryExecutor(Func<QueryCall, CancellationToken, Task<DataTable>> handler)
{
_handler = handler;
}
public ConcurrentQueue<QueryCall> Calls { get; } = new();
public int SynchronousCallCount => Volatile.Read(ref _synchronousCallCount);
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
Interlocked.Increment(ref _synchronousCallCount);
throw new InvalidOperationException("The live market-data service must use the asynchronous executor API.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
var call = new QueryCall(source, tableName, query, cancellationToken);
Calls.Enqueue(call);
return _handler(call, cancellationToken);
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,279 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
internal sealed class ConnectionPlan
{
public Func<CancellationToken, Task> OpenAsync { get; init; } = _ => Task.CompletedTask;
public Func<CancellationToken, Task<DbDataReader>> ExecuteReaderAsync { get; init; } =
_ => Task.FromResult<DbDataReader>(new DataTable().CreateDataReader());
public bool Disposed { get; set; }
public int? ObservedCommandTimeoutSeconds { get; set; }
public static ConnectionPlan Success(DataTable data) => new()
{
ExecuteReaderAsync = _ => Task.FromResult<DbDataReader>(data.CreateDataReader())
};
}
internal sealed class FakeConnectionFactory : IDatabaseConnectionFactory
{
private readonly Queue<ConnectionPlan> _plans;
public FakeConnectionFactory(params ConnectionPlan[] plans)
{
_plans = new Queue<ConnectionPlan>(plans);
}
public int CreateCount { get; private set; }
public List<DbConnection> CreatedConnections { get; } = [];
public DbConnection Create(DataSourceKind source)
{
CreateCount++;
if (!_plans.TryDequeue(out var plan))
{
throw new InvalidOperationException("No fake connection plan remains.");
}
var connection = new FakeDbConnection(plan);
CreatedConnections.Add(connection);
return connection;
}
public int GetCommandTimeoutSeconds(DataSourceKind source) => 5;
}
internal sealed class StubErrorDetector(bool transient) : ITransientDatabaseErrorDetector
{
public bool IsTransient(DataSourceKind source, Exception exception) => transient;
public bool IsTimeout(DataSourceKind source, Exception exception) => exception is TimeoutException;
public int? GetProviderErrorCode(DataSourceKind source, Exception exception) => null;
}
internal sealed class TestDatabaseException(string message) : Exception(message);
internal sealed class FakeDbConnection(ConnectionPlan plan) : DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "FakeDatabase";
public override string DataSource => "FakeDataSource";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName)
{
}
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => OpenAsync(CancellationToken.None).GetAwaiter().GetResult();
public override async Task OpenAsync(CancellationToken cancellationToken)
{
await plan.OpenAsync(cancellationToken);
_state = ConnectionState.Open;
}
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeDbCommand(this, plan);
protected override void Dispose(bool disposing)
{
if (disposing)
{
plan.Disposed = true;
_state = ConnectionState.Closed;
}
base.Dispose(disposing);
}
}
internal sealed class FakeDbCommand(DbConnection connection, ConnectionPlan plan) : DbCommand
{
private readonly FakeDbParameterCollection _parameters = new();
[AllowNull]
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; } = connection;
protected override DbParameterCollection DbParameterCollection => _parameters;
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel()
{
}
public override int ExecuteNonQuery() => throw new NotSupportedException();
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare()
{
}
protected override DbParameter CreateDbParameter() => new FakeDbParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) =>
ExecuteDbDataReaderAsync(behavior, CancellationToken.None).GetAwaiter().GetResult();
protected override Task<DbDataReader> ExecuteDbDataReaderAsync(
CommandBehavior behavior,
CancellationToken cancellationToken)
{
plan.ObservedCommandTimeoutSeconds = CommandTimeout;
return plan.ExecuteReaderAsync(cancellationToken);
}
}
internal sealed class FakeDbParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
[AllowNull]
public override string ParameterName { get; set; } = string.Empty;
public override int Size { get; set; }
[AllowNull]
public override string SourceColumn { get; set; } = string.Empty;
public override bool SourceColumnNullMapping { get; set; }
public override object? Value { get; set; }
public override void ResetDbType()
{
}
}
internal sealed class FakeDbParameterCollection : DbParameterCollection
{
private readonly List<DbParameter> _parameters = [];
public override int Count => _parameters.Count;
public override object SyncRoot => ((ICollection)_parameters).SyncRoot;
public override int Add(object value)
{
_parameters.Add(RequireParameter(value));
return _parameters.Count - 1;
}
public override void AddRange(Array values)
{
foreach (var value in values)
{
Add(value ?? throw new ArgumentNullException(nameof(values)));
}
}
public override void Clear() => _parameters.Clear();
public override bool Contains(object value) =>
value is DbParameter parameter && _parameters.Contains(parameter);
public override bool Contains(string value) => IndexOf(value) >= 0;
public override void CopyTo(Array array, int index) =>
((ICollection)_parameters).CopyTo(array, index);
public override IEnumerator GetEnumerator() => _parameters.GetEnumerator();
public override int IndexOf(object value) =>
value is DbParameter parameter ? _parameters.IndexOf(parameter) : -1;
public override int IndexOf(string parameterName) =>
_parameters.FindIndex(
parameter => string.Equals(
parameter.ParameterName,
parameterName,
StringComparison.OrdinalIgnoreCase));
public override void Insert(int index, object value) =>
_parameters.Insert(index, RequireParameter(value));
public override void Remove(object value)
{
if (value is DbParameter parameter)
{
_parameters.Remove(parameter);
}
}
public override void RemoveAt(int index) => _parameters.RemoveAt(index);
public override void RemoveAt(string parameterName)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
RemoveAt(index);
}
}
protected override DbParameter GetParameter(int index) => _parameters[index];
protected override DbParameter GetParameter(string parameterName)
{
var index = IndexOf(parameterName);
return index >= 0
? _parameters[index]
: throw new IndexOutOfRangeException();
}
protected override void SetParameter(int index, DbParameter value) => _parameters[index] = value;
protected override void SetParameter(string parameterName, DbParameter value)
{
var index = IndexOf(parameterName);
if (index >= 0)
{
_parameters[index] = value;
}
else
{
_parameters.Add(value);
}
}
private static DbParameter RequireParameter(object value) =>
value as DbParameter
?? throw new ArgumentException("A DbParameter is required.", nameof(value));
}

View File

@@ -0,0 +1,105 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class DatabaseHealthServiceTests
{
[Fact]
public async Task CheckAsync_Unconfigured_ReturnsNotConfiguredWithoutQuery()
{
var executor = new StubDataQueryExecutor();
var service = new DatabaseHealthService(executor, new DatabaseOptions());
var status = await service.CheckAsync(DataSourceKind.Oracle);
Assert.Equal(DatabaseHealthState.NotConfigured, status.State);
Assert.Equal(DataSourceKind.Oracle, status.Source);
Assert.Equal(TimeSpan.Zero, status.Elapsed);
Assert.Equal(0, executor.CallCount);
}
[Theory]
[InlineData(DataSourceKind.Oracle, "SELECT 1 FROM DUAL")]
[InlineData(DataSourceKind.MariaDb, "SELECT 1")]
public async Task CheckAsync_ConfiguredAndQuerySucceeds_ReturnsHealthy(
DataSourceKind source,
string expectedQuery)
{
var executor = new StubDataQueryExecutor
{
ExecuteAsyncHandler = (_, _, _, _) => Task.FromResult(new DataTable())
};
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
var status = await service.CheckAsync(source);
Assert.Equal(DatabaseHealthState.Healthy, status.State);
Assert.Equal(source, status.Source);
Assert.Equal(1, executor.CallCount);
Assert.Equal(expectedQuery, executor.LastQuery);
}
[Fact]
public async Task CheckAsync_ConfiguredAndQueryFails_ReturnsUnhealthyWithoutThrowing()
{
const string host = "health-host-sensitive-sentinel";
var executor = new StubDataQueryExecutor
{
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
new DatabaseQueryException(DataSourceKind.MariaDb, isTransient: true))
};
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
var status = await service.CheckAsync(DataSourceKind.MariaDb);
Assert.Equal(DatabaseHealthState.Unhealthy, status.State);
Assert.Equal(DataSourceKind.MariaDb, status.Source);
Assert.DoesNotContain(host, status.UserMessage, StringComparison.Ordinal);
}
[Fact]
public async Task CheckAsync_ConfiguredAndQueryTimesOut_ReturnsTimedOut()
{
var executor = new StubDataQueryExecutor
{
ExecuteAsyncHandler = (_, _, _, _) => Task.FromException<DataTable>(
new DatabaseQueryTimeoutException(DataSourceKind.Oracle))
};
var service = new DatabaseHealthService(executor, TestOptions.CreateConfigured());
var status = await service.CheckAsync(DataSourceKind.Oracle);
Assert.Equal(DatabaseHealthState.TimedOut, status.State);
Assert.Equal(DataSourceKind.Oracle, status.Source);
}
}
internal sealed class StubDataQueryExecutor : IDataQueryExecutor
{
public Func<DataSourceKind, string, string, CancellationToken, Task<DataTable>>? ExecuteAsyncHandler
{
get;
init;
}
public int CallCount { get; private set; }
public string? LastQuery { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
ExecuteAsync(source, tableName, query).GetAwaiter().GetResult();
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
CallCount++;
LastQuery = query;
return ExecuteAsyncHandler?.Invoke(source, tableName, query, cancellationToken)
?? Task.FromResult(new DataTable());
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class DatabaseOptionsLoaderTests
{
[Fact]
public void Load_MissingFile_ReturnsSafeUnconfiguredDefaults()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.json");
var loader = new DatabaseOptionsLoader(_ => null);
var options = loader.Load(path);
Assert.False(options.IsConfigured(DataSourceKind.Oracle));
Assert.False(options.IsConfigured(DataSourceKind.MariaDb));
Assert.Equal(1521, options.Oracle.Port);
Assert.Equal(3306, options.MariaDb.Port);
Assert.Null(options.Oracle.Password);
Assert.Null(options.MariaDb.Password);
}
[Fact]
public async Task LoadAsync_EnvironmentValuesOverrideJsonValues()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "database.local.json");
try
{
var jsonOptions = new DatabaseOptions
{
Oracle = new OracleDatabaseOptions
{
Host = "json-oracle-host.invalid",
Port = 1521,
Sid = "JSONSID",
UserName = "json-oracle-user",
Password = "json-oracle-password"
},
MariaDb = new MariaDbDatabaseOptions
{
Host = "json-maria-host.invalid",
Port = 3306,
Database = "json_database",
UserName = "json-maria-user",
Password = "json-maria-password",
TlsMode = MariaDbTlsMode.Preferred
},
Resilience = new DatabaseResilienceOptions
{
OperationTimeoutSeconds = 45,
MaximumRetryCount = 2,
InitialRetryDelayMilliseconds = 250
}
};
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(jsonOptions));
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MBN_STOCK_ORACLE_HOST"] = "env-oracle-host.invalid",
["MBN_STOCK_ORACLE_PORT"] = "1522",
["MBN_STOCK_ORACLE_USERNAME"] = "env-oracle-user",
["MBN_STOCK_ORACLE_PASSWORD"] = "env-oracle-password",
["MBN_STOCK_MARIADB_HOST"] = "env-maria-host.invalid",
["MBN_STOCK_MARIADB_PORT"] = "3307",
["MBN_STOCK_MARIADB_DATABASE"] = "env_database",
["MBN_STOCK_MARIADB_USERNAME"] = "env-maria-user",
["MBN_STOCK_MARIADB_PASSWORD"] = "env-maria-password",
["MBN_STOCK_MARIADB_TLS_MODE"] = "Required",
["MBN_STOCK_DB_OPERATION_TIMEOUT_SECONDS"] = "12",
["MBN_STOCK_DB_MAX_RETRY_COUNT"] = "4",
["MBN_STOCK_DB_INITIAL_RETRY_DELAY_MS"] = "75"
};
var loader = new DatabaseOptionsLoader(
name => environment.TryGetValue(name, out var value) ? value : null);
var options = await loader.LoadAsync(path);
Assert.Equal("env-oracle-host.invalid", options.Oracle.Host);
Assert.Equal(1522, options.Oracle.Port);
Assert.Equal("JSONSID", options.Oracle.Sid);
Assert.Equal("env-oracle-user", options.Oracle.UserName);
Assert.Equal("env-oracle-password", options.Oracle.Password);
Assert.Equal("env-maria-host.invalid", options.MariaDb.Host);
Assert.Equal(3307, options.MariaDb.Port);
Assert.Equal("env_database", options.MariaDb.Database);
Assert.Equal("env-maria-user", options.MariaDb.UserName);
Assert.Equal("env-maria-password", options.MariaDb.Password);
Assert.Equal(MariaDbTlsMode.Required, options.MariaDb.TlsMode);
Assert.Equal(12, options.Resilience.OperationTimeoutSeconds);
Assert.Equal(4, options.Resilience.MaximumRetryCount);
Assert.Equal(75, options.Resilience.InitialRetryDelayMilliseconds);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ToString_DoesNotExposeHostUserOrPassword()
{
const string oracleHost = "oracle-host-sensitive-sentinel";
const string oracleUser = "oracle-user-sensitive-sentinel";
const string oraclePassword = "oracle-password-sensitive-sentinel";
const string mariaHost = "maria-host-sensitive-sentinel";
const string mariaUser = "maria-user-sensitive-sentinel";
const string mariaPassword = "maria-password-sensitive-sentinel";
var options = TestOptions.CreateConfigured();
options.Oracle.Host = oracleHost;
options.Oracle.UserName = oracleUser;
options.Oracle.Password = oraclePassword;
options.MariaDb.Host = mariaHost;
options.MariaDb.UserName = mariaUser;
options.MariaDb.Password = mariaPassword;
var representations = string.Join(
Environment.NewLine,
options.ToString(),
options.Oracle.ToString(),
options.MariaDb.ToString(),
options.Resilience.ToString());
Assert.DoesNotContain(oracleHost, representations, StringComparison.Ordinal);
Assert.DoesNotContain(oracleUser, representations, StringComparison.Ordinal);
Assert.DoesNotContain(oraclePassword, representations, StringComparison.Ordinal);
Assert.DoesNotContain(mariaHost, representations, StringComparison.Ordinal);
Assert.DoesNotContain(mariaUser, representations, StringComparison.Ordinal);
Assert.DoesNotContain(mariaPassword, representations, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
using MySqlConnector;
using Oracle.ManagedDataAccess.Client;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class ProviderDatabaseConnectionFactoryTests
{
[Fact]
public void CreateOracleConnection_UsesSidDescriptor()
{
var options = TestOptions.CreateConfigured();
options.Oracle.Sid = "TESTSID";
options.Oracle.ServiceName = null;
var factory = new ProviderDatabaseConnectionFactory(options);
using var connection = factory.Create(DataSourceKind.Oracle);
var parsed = new OracleConnectionStringBuilder(connection.ConnectionString);
Assert.True(parsed.DataSource.Contains("(SID=TESTSID)", StringComparison.Ordinal));
Assert.False(parsed.DataSource.Contains("SERVICE_NAME", StringComparison.Ordinal));
Assert.False(parsed.PersistSecurityInfo);
}
[Fact]
public void CreateOracleConnection_UsesServiceNameDescriptor()
{
var options = TestOptions.CreateConfigured();
options.Oracle.Sid = null;
options.Oracle.ServiceName = "TESTSERVICE";
var factory = new ProviderDatabaseConnectionFactory(options);
using var connection = factory.Create(DataSourceKind.Oracle);
var parsed = new OracleConnectionStringBuilder(connection.ConnectionString);
Assert.True(parsed.DataSource.Contains("(SERVICE_NAME=TESTSERVICE)", StringComparison.Ordinal));
Assert.False(parsed.DataSource.Contains("(SID=", StringComparison.Ordinal));
Assert.False(parsed.PersistSecurityInfo);
}
[Theory]
[InlineData(MariaDbTlsMode.Disabled, MySqlSslMode.None)]
[InlineData(MariaDbTlsMode.Preferred, MySqlSslMode.Preferred)]
[InlineData(MariaDbTlsMode.Required, MySqlSslMode.Required)]
[InlineData(MariaDbTlsMode.VerifyCertificate, MySqlSslMode.VerifyCA)]
[InlineData(MariaDbTlsMode.VerifyIdentity, MySqlSslMode.VerifyFull)]
public void CreateMariaDbConnection_UsesEndpointPortAndTls(
MariaDbTlsMode configuredTls,
MySqlSslMode expectedTls)
{
var options = TestOptions.CreateConfigured();
options.MariaDb.Host = "maria-builder-test.invalid";
options.MariaDb.Port = 43306;
options.MariaDb.TlsMode = configuredTls;
var factory = new ProviderDatabaseConnectionFactory(options);
using var connection = factory.Create(DataSourceKind.MariaDb);
var parsed = new MySqlConnectionStringBuilder(connection.ConnectionString);
Assert.Equal("maria-builder-test.invalid", parsed.Server);
Assert.Equal(43306u, parsed.Port);
Assert.Equal(expectedTls, parsed.SslMode);
Assert.False(parsed.PersistSecurityInfo);
}
[Fact]
public void PublicConfigurationException_DoesNotExposeRejectedHostUserOrPassword()
{
const string rejectedHost = "rejected(host)-sensitive-sentinel";
const string user = "oracle-user-sensitive-sentinel";
const string password = "oracle-password-sensitive-sentinel";
var options = TestOptions.CreateConfigured();
options.Oracle.Host = rejectedHost;
options.Oracle.UserName = user;
options.Oracle.Password = password;
var factory = new ProviderDatabaseConnectionFactory(options);
var exception = Assert.Throws<DatabaseConfigurationException>(
() => factory.Create(DataSourceKind.Oracle));
Assert.DoesNotContain(rejectedHost, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(user, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(password, exception.Message, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,216 @@
using System.Data;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class ResilientDataQueryExecutorTests
{
[Fact]
public async Task ExecuteAsync_Success_PreservesSchemaRowsAndDbNull()
{
var source = new DataTable();
source.Columns.Add("CODE", typeof(int));
source.Columns.Add("NAME", typeof(string));
source.Rows.Add(7, DBNull.Value);
source.Rows.Add(8, "sample");
var plan = ConnectionPlan.Success(source);
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory);
var result = await executor.ExecuteAsync(
DataSourceKind.Oracle,
"Stocks",
"SELECT CODE, NAME FROM SAMPLE");
Assert.Equal("Stocks", result.TableName);
Assert.Equal(2, result.Columns.Count);
Assert.Equal("CODE", result.Columns[0].ColumnName);
Assert.Equal(typeof(int), result.Columns[0].DataType);
Assert.Equal("NAME", result.Columns[1].ColumnName);
Assert.Equal(typeof(string), result.Columns[1].DataType);
Assert.Equal(2, result.Rows.Count);
Assert.Equal(7, result.Rows[0]["CODE"]);
Assert.Equal(DBNull.Value, result.Rows[0]["NAME"]);
Assert.Equal("sample", result.Rows[1]["NAME"]);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
Assert.Equal(5, plan.ObservedCommandTimeoutSeconds);
}
[Fact]
public async Task ExecuteAsync_CanceledBeforeOpen_DoesNotRetry()
{
var plan = new ConnectionPlan
{
OpenAsync = token =>
{
token.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => executor.ExecuteAsync(
DataSourceKind.Oracle,
"Canceled",
"SELECT 1 FROM DUAL",
cancellation.Token));
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
[Fact]
public async Task ExecuteAsync_CanceledDuringCommand_DoesNotRetry()
{
var commandStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var plan = new ConnectionPlan
{
ExecuteReaderAsync = async token =>
{
commandStarted.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, token);
throw new InvalidOperationException("Unreachable.");
}
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
using var cancellation = new CancellationTokenSource();
var operation = executor.ExecuteAsync(
DataSourceKind.MariaDb,
"Canceled",
"SELECT 1",
cancellation.Token);
await commandStarted.Task;
await cancellation.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
[Fact]
public async Task ExecuteAsync_OverallTimeout_ThrowsSafeTimeoutAndDoesNotRetryCancellation()
{
var plan = new ConnectionPlan
{
ExecuteReaderAsync = async token =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, token);
throw new InvalidOperationException("Unreachable.");
}
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2, timeoutSeconds: 1);
var exception = await Assert.ThrowsAsync<DatabaseQueryTimeoutException>(
() => executor.ExecuteAsync(
DataSourceKind.Oracle,
"Timeout",
"SELECT 1 FROM DUAL"));
Assert.Equal(DataSourceKind.Oracle, exception.DataSource);
Assert.True(exception.IsTransient);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
[Fact]
public async Task ExecuteAsync_TransientReadFailure_RetriesWithFreshConnectionAndSucceeds()
{
var first = new ConnectionPlan
{
OpenAsync = _ => Task.FromException(new TestDatabaseException("temporary"))
};
var data = new DataTable();
data.Columns.Add("VALUE", typeof(int));
data.Rows.Add(1);
var second = ConnectionPlan.Success(data);
var factory = new FakeConnectionFactory(first, second);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
var result = await executor.ExecuteAsync(
DataSourceKind.MariaDb,
"Retry",
"SELECT VALUE FROM SAMPLE");
Assert.Single(result.Rows.Cast<DataRow>());
Assert.Equal(2, factory.CreateCount);
Assert.Equal(2, factory.CreatedConnections.Count);
Assert.NotSame(factory.CreatedConnections[0], factory.CreatedConnections[1]);
Assert.True(first.Disposed);
Assert.True(second.Disposed);
}
[Fact]
public async Task ExecuteAsync_PermanentFailure_AttemptsOnceAndUsesSafePublicMessage()
{
const string host = "host-sensitive-sentinel";
const string user = "user-sensitive-sentinel";
const string password = "password-sensitive-sentinel";
var plan = new ConnectionPlan
{
OpenAsync = _ => Task.FromException(
new TestDatabaseException($"{host}|{user}|{password}"))
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: false, retryCount: 2);
var exception = await Assert.ThrowsAsync<DatabaseQueryException>(
() => executor.ExecuteAsync(
DataSourceKind.Oracle,
"Permanent",
"SELECT 1 FROM DUAL"));
Assert.False(exception.IsTransient);
Assert.Equal(1, factory.CreateCount);
Assert.Null(exception.InnerException);
Assert.DoesNotContain(host, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(user, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(password, exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ExecuteAsync_NonReadQuery_DoesNotRetryTransientFailure()
{
var plan = new ConnectionPlan
{
ExecuteReaderAsync = _ => Task.FromException<System.Data.Common.DbDataReader>(
new TestDatabaseException("temporary"))
};
var factory = new FakeConnectionFactory(plan);
var executor = CreateExecutor(factory, transient: true, retryCount: 2);
var exception = await Assert.ThrowsAsync<DatabaseQueryException>(
() => executor.ExecuteAsync(
DataSourceKind.MariaDb,
"Mutation",
"UPDATE SAMPLE SET VALUE = 1"));
Assert.True(exception.IsTransient);
Assert.Equal(1, factory.CreateCount);
Assert.True(plan.Disposed);
}
private static ResilientDataQueryExecutor CreateExecutor(
FakeConnectionFactory factory,
bool transient = false,
int retryCount = 0,
int timeoutSeconds = 5) =>
new(
factory,
new DatabaseResilienceOptions
{
OperationTimeoutSeconds = timeoutSeconds,
MaximumRetryCount = retryCount,
InitialRetryDelayMilliseconds = 0
},
new StubErrorDetector(transient));
}

View File

@@ -0,0 +1,39 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
internal static class TestOptions
{
public static DatabaseOptions CreateConfigured() => new()
{
Oracle = new OracleDatabaseOptions
{
Host = "oracle-unit-test.invalid",
Port = 1521,
Sid = "TESTSID",
UserName = "oracle-unit-user",
Password = "oracle-unit-password",
ConnectTimeoutSeconds = 5,
CommandTimeoutSeconds = 5,
Pooling = false
},
MariaDb = new MariaDbDatabaseOptions
{
Host = "maria-unit-test.invalid",
Port = 3306,
Database = "test_database",
UserName = "maria-unit-user",
Password = "maria-unit-password",
TlsMode = MariaDbTlsMode.Preferred,
ConnectTimeoutSeconds = 5,
CommandTimeoutSeconds = 5,
Pooling = false
},
Resilience = new DatabaseResilienceOptions
{
OperationTimeoutSeconds = 5,
MaximumRetryCount = 2,
InitialRetryDelayMilliseconds = 0
}
};
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,132 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
Console.OutputEncoding = Encoding.UTF8;
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(3));
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancellation.Cancel();
};
var configurationPath = GetConfigurationPath(args);
try
{
var runtime = DatabaseRuntime.CreateDefault(configurationPath);
var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token);
foreach (var status in statuses)
{
Console.WriteLine(
$"{status.Source}: {status.State} ({status.Elapsed.TotalMilliseconds:F0} ms) - {status.UserMessage}");
}
if (statuses.Any(status => status.State != DatabaseHealthState.Healthy))
{
Console.Error.WriteLine("Smoke test stopped because both database health probes must be healthy.");
return 2;
}
await VerifyOracleServerVersionAsync(runtime.Executor, cancellation.Token);
var service = new LegacyMarketDataService(runtime.Executor);
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
await VerifySnapshotAsync(service, MarketDataView.Kosdaq, cancellation.Token);
await VerifySnapshotAsync(service, MarketDataView.Index, cancellation.Token);
await VerifySnapshotAsync(service, MarketDataView.Overseas, cancellation.Token);
Console.WriteLine("REAL_DB_SMOKE: PASS");
return 0;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("REAL_DB_SMOKE: CANCELED_OR_TIMED_OUT");
return 3;
}
catch (DatabaseInfrastructureException exception)
{
Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}");
return 1;
}
catch
{
Console.Error.WriteLine("REAL_DB_SMOKE: FAIL - An unexpected error occurred. No provider details were printed.");
return 1;
}
static string? GetConfigurationPath(string[] arguments)
{
if (arguments.Length == 0)
{
return null;
}
if (arguments.Length == 2 && arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase))
{
return arguments[1];
}
throw new DatabaseConfigurationException("Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>]");
}
static async Task VerifyOracleServerVersionAsync(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
{
const string query =
"SELECT VERSION FROM PRODUCT_COMPONENT_VERSION " +
"WHERE PRODUCT LIKE 'Oracle Database%' FETCH FIRST 1 ROWS ONLY";
var table = await executor.ExecuteAsync(
DataSourceKind.Oracle,
"OracleVersion",
query,
cancellationToken);
if (table.Rows.Count == 0 || table.Columns.Count == 0)
{
throw new DatabaseConfigurationException("Oracle 서버 버전을 확인할 수 없습니다.");
}
var version = Convert.ToString(table.Rows[0][0], CultureInfo.InvariantCulture) ?? string.Empty;
var match = Regex.Match(version, @"(?<!\d)(?<major>\d{2})(?:\.\d+)", RegexOptions.CultureInvariant);
if (!match.Success ||
!int.TryParse(match.Groups["major"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var major))
{
throw new DatabaseConfigurationException("Oracle 서버 주 버전을 판별할 수 없습니다.");
}
Console.WriteLine($"Oracle server major version: {major}");
if (major < 19)
{
throw new DatabaseConfigurationException(
"Oracle.ManagedDataAccess.Core 23.26.200은 Oracle Database 19c 이상이 필요합니다.");
}
}
static async Task VerifySnapshotAsync(
IMarketDataService service,
MarketDataView view,
CancellationToken cancellationToken)
{
var snapshot = await service.GetAsync(view, maximumRowsPerTable: 5, cancellationToken);
if (snapshot.Tables.Count == 0)
{
throw new DatabaseConfigurationException($"{view} 조회 결과에 데이터셋이 없습니다.");
}
foreach (var table in snapshot.Tables)
{
if (table.Columns.Count == 0)
{
throw new DatabaseConfigurationException($"{view}/{table.Name} 결과 열이 없습니다.");
}
Console.WriteLine(
$"{view}/{table.Source}/{table.Name}: {table.TotalRowCount.ToString(CultureInfo.InvariantCulture)} rows");
}
}