feat: Redis 중복 발송 방지 구현 (#108)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/109
This commit is contained in:
김선규 2026-02-10 07:09:17 +00:00
commit 81c3eee1f3
7 changed files with 147 additions and 0 deletions

View File

@ -22,6 +22,11 @@
"PrefetchCount": 10, "PrefetchCount": 10,
"MessageTtl": 86400000 "MessageTtl": 86400000
}, },
"Redis": {
"ConnectionString": "",
"InstanceName": "spms_dev_",
"DuplicateTtlHours": 24
},
"CredentialEncryption": { "CredentialEncryption": {
"Key": "" "Key": ""
}, },

View File

@ -0,0 +1,6 @@
namespace SPMS.Application.Interfaces;
public interface IDuplicateChecker
{
Task<bool> IsDuplicateAsync(string requestId, CancellationToken cancellationToken = default);
}

View File

@ -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;
}

View File

@ -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<DuplicateChecker> _logger;
public DuplicateChecker(
RedisConnection redis,
IOptions<RedisSettings> settings,
ILogger<DuplicateChecker> logger)
{
_redis = redis;
_settings = settings.Value;
_logger = logger;
}
public async Task<bool> 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;
}
}
}

View File

@ -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<RedisConnection> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private ConnectionMultiplexer? _connection;
public RedisConnection(IOptions<RedisSettings> settings, ILogger<RedisConnection> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task<IDatabase> 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);
}
}

View File

@ -5,6 +5,7 @@ using SPMS.Application.Interfaces;
using SPMS.Application.Settings; using SPMS.Application.Settings;
using SPMS.Domain.Interfaces; using SPMS.Domain.Interfaces;
using SPMS.Infrastructure.Auth; using SPMS.Infrastructure.Auth;
using SPMS.Infrastructure.Caching;
using SPMS.Infrastructure.Messaging; using SPMS.Infrastructure.Messaging;
using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Persistence;
using SPMS.Infrastructure.Push; using SPMS.Infrastructure.Push;
@ -46,6 +47,11 @@ public static class DependencyInjection
// File Storage // File Storage
services.AddSingleton<IFileStorageService, LocalFileStorageService>(); services.AddSingleton<IFileStorageService, LocalFileStorageService>();
// Redis
services.Configure<RedisSettings>(configuration.GetSection(RedisSettings.SectionName));
services.AddSingleton<RedisConnection>();
services.AddSingleton<IDuplicateChecker, DuplicateChecker>();
// RabbitMQ // RabbitMQ
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName)); services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));
services.AddSingleton<RabbitMQConnection>(); services.AddSingleton<RabbitMQConnection>();

View File

@ -21,6 +21,7 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
<PackageReference Include="StackExchange.Redis" Version="2.10.14" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup> </ItemGroup>