최근에 EF Core 8 기반의 코드를 작성하다가, 구현 상의 고민에 빠졌지만 바람직하다고 여겨지는 답을 찾지 못해서 커뮤니티 회원 여러분들의 의견을 모아보고 싶어 질답 글에 글을 남겨봅니다.
IAsyncDisposable이 추가된 것 자체는 오래된 일입니다. 그리고 자원 해제에 관해서라면 기본적으로 IDisposable을 정확히 구현하는 것이 권장되는 것도 확실합니다.
그런데 Microsoft Learn이나 여러 자료를 찾아본바로는, IDisposable과 IAsyncDisposable을 모두 구현하면서도 코드 중복을 최소화하면서 메모리 누수를 발생시키지 않을 만한 좋은 패턴에 대한 이야기는 명확히 보이지 않는 것 같습니다.
public abstract class UnitOfWorkBase<TDbContext> : IAsyncDisposable, IDisposable
where TDbContext : DbContext
{
public UnitOfWorkBase(IDbContextFactory<TDbContext> contextFactory, IAuditLogger auditLogger)
{
_contextFactory = contextFactory;
_auditLogger = auditLogger;
_disposed = false;
}
private readonly IDbContextFactory<TDbContext> _contextFactory;
private readonly IAuditLogger _auditLogger;
private bool _disposed;
private TDbContext? _context;
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
_context?.Dispose();
_context = null;
}
// 필요한 경우 비 관리 리소스 할당 해제를 여기서 진행
_disposed = true;
}
/*
~UnitOfWorkBase()
{
// 비 관리 리소스 할당 해제만 다시 진행
Dispose(false);
}
*/
public async ValueTask DisposeAsync()
{
// ValueTask 타입의 async 메서드를 호출할 때는 ?. 연산자를 쓸 수 없음.
if (_context != null)
{
await _context.DisposeAsync().ConfigureAwait(false);
_context = null;
}
// 비동기식 Dispose가 이미 불린 경우는 건너뛰고, 동기식 Dispose가 필요한 경우 빠짐없이 호출
Dispose(true);
// DisposeAsync를 부른 것을 소멸자를 직접 부른 것으로 침.
GC.SuppressFinalize(this);
}
public void Dispose()
{
// 비동기식/동기식에 관계없이 Dispose를 모두 호출
Dispose(true);
// Dispose를 부른 것을 소멸자를 직접 부른 것으로 침.
GC.SuppressFinalize(this);
}
}
위와 같이 코드를 작성한 이유는, IAsyncDisposable의 DisposeAsync를 불렀든, IDisposable의 Dispose를 불렀든 어느 한쪽만 호출하는 것이 타당하고, 어디를 불렀든 간에 객체의 모든 리소스는 정리된 상태로 변경되야 한다는 논리에서 나온 코드입니다.
그런데 역시 검증된 구현 패턴이 아니다보니 많은 분들의 피드백을 받아보는 것이 좋겠다는 생각이 들어 포럼에 질문 글을 올려봅니다.
물어보신 IDispose 또는 IDisposeAsync 에 관한 내용과는 좀 동떨어진 대답일 수 있으나,
UnitOfWork 패턴을 구현하시고자 하는 것 같은데
Dispose 를 굳이 신경쓰며 사용할 필요 없이,
EF의 DbContext 의 상속받아 맡기는 편이 더 안전하게 접근할 수 있지 않을까 생각이 듭니다.
IAuditLogger를 주입받아 실행하시려는 로직은 EF의 SaveChangesInterceptor 를 이용하시면 좋을 것 같습니다.
저는 보통 아래와 같은 방식으로 작업합니다.
IAuditLogger.cs
public interface IAuditLogger
{
// 어떤식의 구현을 하셨는지 몰라 간단한 메서드만 정의했습니다.
void LogInformation(string message);
}
IUnitOfWork.cs : abstract 클래스가 아닌 interface로 기능만 정의합니다.
public interface IApplicationDbContext
{
DatabaseFacade Database { get; } // 필요에 따라 이것으로 접근하여 DB 관련(e.g. 트랜잭션 생성 등) 작업을 수행할 수 있습니다.
DbSet<User> Users { get; }
}
ApplicationDbContext.cs
DbContext
internal sealed class ApplicationDbContext : DbContext, IApplicationDbContext, IUnitOfWork
{
// 생성자
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
// DBSet
public DbSet<User> Users { get; set; }
// 필요에 따라 재정의
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return base.SaveChangesAsync(cancellationToken);
}
}
ApplicationDbContextAuditInterceptor.cs
internal sealed class ApplicationDbContextAuditInterceptor : SaveChangesInterceptor
{
private readonly IAuditLogger _auditLogger;
public ApplicationDbContextAuditInterceptor(IAuditLogger auditLogger)
{
_auditLogger = auditLogger;
}
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
DbContext? dbContext = eventData.Context;
if (eventData.Context is null)
{
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
DateTimeOffset now = DateTimeOffset.UtcNow;
// 변경점이 발생한 엔티티 중 AuditableEntity 를 상속받는 엔티티만 뽑아내어 처리합니다.
var entries = dbContext.ChangeTracker.Entries<AuditableEntity>();
foreach (var entry in entries)
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedDate = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedDate = now;
break;
}
}
// 간단한 로깅
int added = entries.Count(x => x.State == EntityState.Added);
int modified = entries.Count(x => x.State == EntityState.Added);
_auditLogger.LogInformation($"{added} will be added on SaveChangesAsync on {now}.");
_auditLogger.LogInformation($"{modified} will be modifed on SaveChangesAsync on {now}.");
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
ApplicationDbContextExtensions.cs
public static class ApplicationDbContextExtensions
{
public static IServiceCollection AddApplicationDbContext(this IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>((IServiceProvider serviceProvider, DbContextOptionsBuilder options) =>
{
var config = serviceProvider.GetRequiredService<IConfiguration>();
var connectionString = config.GetConnectionString("DbContextConnection");
options.UseSqlServer(connectionString);
options.AddInterceptors(
new ApplicationDbContextAuditInterceptor(serviceProvider.GetRequiredService<IAuditLogger>())
);
});
// 아래와 같이 등록하면 IApplicationDbContext, IUnitOfWork가
// 사실 상 같은 인스턴스를 반환하나 인터페이스를 통해 접근을 제한할 수 있습니다.
services.AddScoped<IApplicationDbContext>(sp => sp.GetRequiredService<ApplicationDbContext>());
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ApplicationDbContext>());
return services;
}
}
Program.cs
// ...
builder.Services.AddApplicationDbContext();
// ...
var app = builder.Build();