diff --git a/SPMS.API/appsettings.json b/SPMS.API/appsettings.json index d5a32b5..185232c 100644 --- a/SPMS.API/appsettings.json +++ b/SPMS.API/appsettings.json @@ -22,6 +22,11 @@ "PrefetchCount": 10, "MessageTtl": 86400000 }, + "Redis": { + "ConnectionString": "", + "InstanceName": "spms_dev_", + "DuplicateTtlHours": 24 + }, "CredentialEncryption": { "Key": "" }, diff --git a/SPMS.Application/Interfaces/IDuplicateChecker.cs b/SPMS.Application/Interfaces/IDuplicateChecker.cs new file mode 100644 index 0000000..b4ede06 --- /dev/null +++ b/SPMS.Application/Interfaces/IDuplicateChecker.cs @@ -0,0 +1,6 @@ +namespace SPMS.Application.Interfaces; + +public interface IDuplicateChecker +{ + Task IsDuplicateAsync(string requestId, CancellationToken cancellationToken = default); +} diff --git a/SPMS.Application/Settings/RedisSettings.cs b/SPMS.Application/Settings/RedisSettings.cs new file mode 100644 index 0000000..4fa2f42 --- /dev/null +++ b/SPMS.Application/Settings/RedisSettings.cs @@ -0,0 +1,10 @@ +namespace SPMS.Application.Settings; + +public class RedisSettings +{ + public const string SectionName = "Redis"; + + public string ConnectionString { get; set; } = "localhost:6379"; + public string InstanceName { get; set; } = "spms_dev_"; + public int DuplicateTtlHours { get; set; } = 24; +} diff --git a/SPMS.Infrastructure/Caching/DuplicateChecker.cs b/SPMS.Infrastructure/Caching/DuplicateChecker.cs new file mode 100644 index 0000000..a256413 --- /dev/null +++ b/SPMS.Infrastructure/Caching/DuplicateChecker.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SPMS.Application.Interfaces; +using SPMS.Application.Settings; +using StackExchange.Redis; + +namespace SPMS.Infrastructure.Caching; + +public class DuplicateChecker : IDuplicateChecker +{ + private readonly RedisConnection _redis; + private readonly RedisSettings _settings; + private readonly ILogger _logger; + + public DuplicateChecker( + RedisConnection redis, + IOptions settings, + ILogger logger) + { + _redis = redis; + _settings = settings.Value; + _logger = logger; + } + + public async Task IsDuplicateAsync(string requestId, CancellationToken cancellationToken = default) + { + var key = $"{_settings.InstanceName}duplicate:{requestId}"; + var ttl = TimeSpan.FromHours(_settings.DuplicateTtlHours); + + try + { + var db = await _redis.GetDatabaseAsync(); + var wasSet = await db.StringSetAsync(key, "1", ttl, When.NotExists); + + if (!wasSet) + { + _logger.LogWarning("중복 메시지 감지: requestId={RequestId}", requestId); + return true; + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Redis 중복 체크 실패: requestId={RequestId} — 중복 아닌 것으로 처리", requestId); + return false; + } + } +} diff --git a/SPMS.Infrastructure/Caching/RedisConnection.cs b/SPMS.Infrastructure/Caching/RedisConnection.cs new file mode 100644 index 0000000..2ac0f23 --- /dev/null +++ b/SPMS.Infrastructure/Caching/RedisConnection.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SPMS.Application.Settings; +using StackExchange.Redis; + +namespace SPMS.Infrastructure.Caching; + +public class RedisConnection : IAsyncDisposable +{ + private readonly RedisSettings _settings; + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private ConnectionMultiplexer? _connection; + + public RedisConnection(IOptions settings, ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + public async Task GetDatabaseAsync() + { + if (_connection is { IsConnected: true }) + return _connection.GetDatabase(); + + await _semaphore.WaitAsync(); + try + { + if (_connection is { IsConnected: true }) + return _connection.GetDatabase(); + + _connection?.Dispose(); + + _logger.LogInformation("Redis 연결 시도: {ConnectionString}", + MaskConnectionString(_settings.ConnectionString)); + + _connection = await ConnectionMultiplexer.ConnectAsync(_settings.ConnectionString); + + _logger.LogInformation("Redis 연결 성공"); + return _connection.GetDatabase(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Redis 연결 실패"); + throw; + } + finally + { + _semaphore.Release(); + } + } + + private static string MaskConnectionString(string connectionString) + { + var parts = connectionString.Split(','); + return parts.Length > 0 ? parts[0] + ",..." : connectionString; + } + + public async ValueTask DisposeAsync() + { + if (_connection != null) + { + await _connection.CloseAsync(); + _connection.Dispose(); + } + + _semaphore.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index f455816..0eee960 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using SPMS.Application.Interfaces; using SPMS.Application.Settings; using SPMS.Domain.Interfaces; using SPMS.Infrastructure.Auth; +using SPMS.Infrastructure.Caching; using SPMS.Infrastructure.Messaging; using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Push; @@ -46,6 +47,11 @@ public static class DependencyInjection // File Storage services.AddSingleton(); + // Redis + services.Configure(configuration.GetSection(RedisSettings.SectionName)); + services.AddSingleton(); + services.AddSingleton(); + // RabbitMQ services.Configure(configuration.GetSection(RabbitMQSettings.SectionName)); services.AddSingleton(); diff --git a/SPMS.Infrastructure/SPMS.Infrastructure.csproj b/SPMS.Infrastructure/SPMS.Infrastructure.csproj index 7a25b55..a8404cf 100644 --- a/SPMS.Infrastructure/SPMS.Infrastructure.csproj +++ b/SPMS.Infrastructure/SPMS.Infrastructure.csproj @@ -21,6 +21,7 @@ +