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

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>