using Application.Abstractions.Authentication; using Application.Abstractions.Cache; using Application.Abstractions.Chat; using Application.Abstractions.Crypto; using Application.Abstractions.Data; using Application.Abstractions.Hub; using Application.Abstractions.Identity; using Application.Abstractions.Forum; using Application.Abstractions.Messaging.Email; using Application.Abstractions.Notification; using Application.Abstractions.Payment; using Application.Abstractions.YouTube; using Infrastructure.Authentication; using Infrastructure.Crypto; using Infrastructure.Payment; using Infrastructure.Forum; using Infrastructure.Cache; using Infrastructure.Chat; using Infrastructure.Messaging.Email; using Infrastructure.Persistence; using Infrastructure.Persistence.Identity; using Infrastructure.StockData; using Infrastructure.Storage; using Infrastructure.YouTube; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SharedKernel; using SharedKernel.Storage; using StackExchange.Redis; using System.Text; namespace Infrastructure; public static class DependencyInjection { // SQL Server private static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) { var dbConn = configuration.GetConnectionString("DefaultConnection"); if (string.IsNullOrWhiteSpace(dbConn)) { throw new InvalidOperationException("Connection string 'DefaultConnection' is not configured."); } // Pooling: 매 request 마다 새 DbContext 생성 비용 제거. ChangeTracker 등 내부 상태는 풀 반환 시 자동 reset // ConfigureWarnings: EF 9+의 PendingModelChangesWarning을 무시 (snapshot 일관성 문제로 false-positive 발생, 마이그레이션은 별도 관리) void ConfigureAppDb(DbContextOptionsBuilder options) => options .UseSqlServer(dbConn) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); services.AddDbContextPool(ConfigureAppDb, poolSize: 128); services.AddDbContextPool(options => options .UseSqlServer(dbConn) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)), poolSize: 64); services.AddScoped(sp => sp.GetRequiredService()); // Phase 1.5: out-of-band 커밋(대사 로그·동시성 재시도)용 풀링 팩토리. // 내부 등록이 전부 TryAdd 라 위 AddDbContextPool 과 같은 옵션·풀을 공유하며 중복 등록 없이 공존한다. services.AddPooledDbContextFactory(ConfigureAppDb, poolSize: 128); services.AddSingleton(); return services; } // Redis Server public static IServiceCollection AddRedis(this IServiceCollection services, IConfiguration configuration) { var settings = configuration.Get()!; var redis = ConnectionMultiplexer.Connect(settings.Redis.DefaultConnection); services.AddSingleton(redis); services.AddDataProtection() .SetApplicationName(settings.App.Name) .PersistKeysToStackExchangeRedis(redis, settings.Redis.DataProtectionKey) .ProtectKeysWithDpapi(protectToLocalMachine: true) // key를 암호화하여 저장 (로컬 머신에서만 복호화 가능, 서버간 공유 불가) .SetDefaultKeyLifetime(settings.Redis.DefaultKeyLifetime); // 기본 90일 주기 // Distributed Cache 설정 services.AddStackExchangeRedisCache(options => { options.Configuration = settings.Redis.DefaultConnection; options.InstanceName = settings.Redis.CachePrefix; }); return services; } private static IServiceCollection AddHealthChecks(this IServiceCollection services, IConfiguration configuration) { var settings = configuration.Get()!; services.AddHealthChecks().AddSqlServer(settings.ConnectionStrings.DefaultConnection).AddRedis(settings.Redis.DefaultConnection); return services; } // Admin/Api 둘 다 필요한 공용 서비스 (singleton/transient/scoped, HttpClient) private static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration) { // 파일 저장 위치 옵션 (deploy 영향 받지 않는 외부 경로 권장) services.Configure(configuration.GetSection(StorageOptions.SectionName)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // M1 no-op — D3 RewardService 구현으로 교체 (d0 §2.2) services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddHttpClient(); // 소셜 로그인 provider (개미투자 D5 — Naver/Kakao authorization code). 핸들러가 IEnumerable 에서 이름으로 해석 services.AddHttpClient(); services.AddHttpClient(); services.AddTransient(sp => sp.GetRequiredService()); services.AddTransient(sp => sp.GetRequiredService()); // YouTube & Google OAuth (HttpClients + singletons — Admin에서도 "API 키 테스트" 등에 쓰므로 공용) services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddHttpClient("YouTubeApi", client => { client.Timeout = TimeSpan.FromSeconds(15); client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); }) .AddHttpMessageHandler(); services.AddHttpClient("PubSubHub", client => { client.Timeout = TimeSpan.FromSeconds(15); }); services.AddHttpClient("YouTubeFeed", client => { client.Timeout = TimeSpan.FromSeconds(10); client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/atom+xml")); }); services.AddHttpClient(); services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(60); // 토스 승인 API 권장 타임아웃 — 짧으면 통신 불명(대사 대상)만 늘어난다 }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); // Notification services.AddScoped(); // Feed broadcaster (SignalR) services.AddScoped(); // Channel status broadcaster (라이브 시작/종료/시청자 수 → AppHub 브로드캐스트) services.AddSingleton(); return services; } // Web.Api 전용 백그라운드 서비스 (Admin에서 중복 실행되면 quota 2배/폴링 2번 문제) // appsettings.json BackgroundJobs 섹션으로 개별 토글 (기본 모두 true). 진단용 A/B 테스트에 사용 private static IServiceCollection AddBackgroundServices(this IServiceCollection services, IConfiguration configuration) { // Features:Channel OFF → YouTube/채널 백그라운드 서비스 전체 미등록 (코드 보존, 기능 노출 0) var features = configuration.GetSection("Features").Get() ?? new AppSettings.FeaturesSection(); if (!features.Channel) { return services; } var bg = configuration.GetSection("BackgroundJobs").Get() ?? new AppSettings.BackgroundJobsSection(); if (bg.LiveChat) { services.AddHostedService(sp => sp.GetRequiredService()); } if (bg.PubSubRenewal) { services.AddHostedService(); } if (bg.FeedPolling) { services.AddHostedService(); } if (bg.LiveViewerPoller) { services.AddHostedService(); } if (bg.ChannelCacheRefresh) { services.AddHostedService(); } if (bg.YouTubeDailyAggregator) { services.AddHostedService(); } if (bg.YouTubeStaleDataPurge) { services.AddHostedService(); } return services; } // 결제 자동 대사 배치 (Toss NeedsReconciliation 재확인) — Features:Channel 게이트 밖 별도 등록, Web.Api 전용. // BackgroundJobs:PaymentReconcile 플래그로 토글 (기본 true — 대상 없으면 PG 호출 없이 skip) private static IServiceCollection AddPaymentBackgroundServices(this IServiceCollection services, IConfiguration configuration) { var bg = configuration.GetSection("BackgroundJobs").Get() ?? new AppSettings.BackgroundJobsSection(); if (bg.PaymentReconcile) { services.AddHostedService(); } return services; } // 주식 데이터 수집 배치 (개미투자 D1) — Features:Channel 게이트 밖 별도 등록. // StockData 섹션 플래그로 개별 토글 (기본 모두 false — API 키 발급/운영 결정 후 활성화) private static IServiceCollection AddStockDataServices(this IServiceCollection services, IConfiguration configuration) { services.AddHttpClient(DataGoKrHttp.ClientName, client => { client.Timeout = TimeSpan.FromSeconds(30); client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); }); var stockData = configuration.GetSection("StockData").Get() ?? new AppSettings.StockDataSection(); if (stockData.MasterSync) { services.AddHostedService(); } if (stockData.DailyPriceSync) { services.AddHostedService(); } return services; } /** * ======================================================================================================================================================================================================== * ======================================================================================================================================================================================================== */ // Admin 전용 — 백그라운드 서비스는 포함하지 않음 (Web.Api에서만 돌아야 함) // 메일은 즉시 SMTP 발송 (DirectMailService) public static IServiceCollection AddAdminInfrastructure(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); return services.AddDatabase(configuration).AddRedis(configuration).AddCommonServices(configuration).AddHealthChecks(configuration); } /** * ======================================================================================================================================================================================================== * ======================================================================================================================================================================================================== */ // API 전용 — 공용 서비스 + 백그라운드 서비스 모두 포함 // 메일은 EmailLog 큐에 적재 (QueuedMailService) — MailWorker가 별도로 폴링하여 SMTP 송신 public static IServiceCollection AddApiInfrastructure(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); return services.AddDatabase(configuration).AddRedis(configuration).AddApiAuthentication(configuration).AddCommonServices(configuration).AddBackgroundServices(configuration).AddPaymentBackgroundServices(configuration).AddStockDataServices(configuration).AddHealthChecks(configuration); } private static IServiceCollection AddApiAuthentication(this IServiceCollection services, IConfiguration configuration) { var settings = configuration.Get()!; // 인증 정책- JWT Bearer services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.RequireHttpsMetadata = false; options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = settings.JWT.Issuer, ValidAudience = settings.JWT.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.JWT.SecretKey)), ClockSkew = TimeSpan.Zero }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; var path = context.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) { context.Token = accessToken; } return Task.CompletedTask; } }; }); services.AddAuthorization(); // Identity Core (Admin Handler의 UserManager/RoleManager 의존 해소용) services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); return services; } }