올바른 IAsyncDisposable 구현과 IDisposable과의 코드 중복 최소화 사이의 선택

최근에 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를 불렀든 어느 한쪽만 호출하는 것이 타당하고, 어디를 불렀든 간에 객체의 모든 리소스는 정리된 상태로 변경되야 한다는 논리에서 나온 코드입니다.

그런데 역시 검증된 구현 패턴이 아니다보니 많은 분들의 피드백을 받아보는 것이 좋겠다는 생각이 들어 포럼에 질문 글을 올려봅니다.

이 주제에 관하여 경험있으신 분들의 의견을 모아보고 코드를 개선해보고 싶습니다. :smiley:

4개의 좋아요

비동기 DisposeAsync에서 동기 Dispose 호출하는게 패턴인거 같군요. 이해하기 어려운

1개의 좋아요

네. 제가 이해하기로도 그렇게 정리되는 것 같습니다. 그렇지만 Dispose 계통의 메서드는 메모리 누수와 관련이 있다보니, 아무래도 조심스럽게 되짚어 보게 되는 것 같습니다. ㅎㅎ

1개의 좋아요

물어보신 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();
    
1개의 좋아요

피드백 주셔서 감사합니다.

말씀하신 방법도 있겠네요! 다만 지금 작성하고 있는 코드는 EF에 플러그인되서 들어가는 형태보다는 조금 거리 두기를 하면서 작성하고 있다보니 저런 설계가 나왔는데, 좀 더 검토해보고 말씀하신 방법도 테스트해볼 필요가 있을 것 같습니다. :+1:

상위레이어에서 Persistence의 직접적인 EF 참조가 신경쓰이신다면

아래 Microsoft.EntityFrameworkCore.Relational nuget 만 추가하시어 DbSet<T>, DataFacade 를 추상화 하실 수 있으십니다.

1개의 좋아요

맞습니다. 추상 인터페이스를 쓰는 것도 가능하지요.

지금은 Audit Logging을 EF가 아닌 곳을 대상으로 써야 할 수도 있다고 폭넓게 보고 있어서 EF에 관해서는 아예 아무것도 연관짓지 않게 만든 점도 있습니다.

1개의 좋아요