EfCore์˜ AddRange์™€ BulkInsert

์ตœ๊ทผ์— ์‚ฌ์ด๋“œ ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด์„œ ๊ฒช์—ˆ๋˜ ๋ฌธ์ œ์‚ฌํ•ญ๊ณผ ํ•ด๊ฒฐ(?)๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ๊ณต์œ ๋“œ๋ฆฌ๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค.

PostgresSql์„ ์‚ฌ์šฉํ•˜์—ฌ EF Core๋ฅผ ์‚ฌ์šฉ์ค‘์— Transaction๊ณผ ๊ด€๋ จ๋œ ๋ฌธ์ œ์ž…๋‹ˆ๋‹ค.
ํด๋ฆฐ์•„ํ‚คํ…์ณ ๊ตฌ์กฐ๋ฅผ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

[Authorize]
public record CreateRecipeByBarCommand : IRequest<BaseResponse<long>>, ITransactional
{
    public string? RecipeName { get; init; }
    
    public string? Description { get; init; }
    
    public bool IsPublic { get; init; }
    
    public bool IsChangeableByOthers { get; init; }
    
    public ICollection<CreateRecipeAlcohol> Alcohols { get; init; } = new List<CreateRecipeAlcohol>();
    
    public ICollection<CreateRecipeIngredient> Ingredients { get; init; } = new List<CreateRecipeIngredient>();
}

public class CreateRecipeByBarCommandHandler : IRequestHandler<CreateRecipeByBarCommand, BaseResponse<long>>
{
    private readonly IApplicationDbContext _context;
    private readonly IMediator _sender;

    public CreateRecipeByBarCommandHandler(IApplicationDbContext context, IMediator sender)
    {
        _context = context;
        _sender = sender;
    }
    
    public async Task<BaseResponse<long>> Handle(CreateRecipeByBarCommand request, CancellationToken cancellationToken)
    {
        var recipe = new Recipe
        {
            Name = request.RecipeName,
            Description = request.Description,
            IsPublic = request.IsPublic,
            IsChangeableByOthers = request.IsChangeableByOthers
        };
        
        recipe.AddDomainEvent(new RecipeCreatedEvent(recipe));
        
        _context.Recipes.Add(recipe);
        
        await _context.SaveChangesAsync(cancellationToken);

        var commandRecipeAlcohols = new CreateRecipeAlcoholsCommand
        {
            RecipeId = recipe.Id,
            Alcohols = request.Alcohols
        };
        
        await _sender.Send(commandRecipeAlcohols, cancellationToken);

        var commandRecipeIngredients = new CreateRecipeIngredientsCommand
        {
            RecipeId = recipe.Id,
            Ingredients = request.Ingredients
        };
        
        await _sender.Send(commandRecipeIngredients, cancellationToken);
        
        return BaseResponse<long>.Success(recipe.Id);
    }
}

๋‹ค์Œ๊ณผ ๊ฐ™์ด Recipe๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋ฉ”์†Œ๋“œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ITransactional์ด๋ผ๋Š” ๋งˆ์ปค์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ƒ์†๋ฐ›์•„์„œ ํŠธ๋žœ์žญ์…˜์„ ํ™œ์„ฑํ™” ์‹œ์ผฐ์Šต๋‹ˆ๋‹ค.

public record CreateRecipeAlcoholsCommand : IRequest
{
    public ICollection<CreateRecipeAlcohol> Alcohols { get; init; } = new List<CreateRecipeAlcohol>();

    public long RecipeId { get; init; }
}

public record CreateRecipeAlcohol
{
    public long AlcoholId { get; init; }

    public decimal AlcoholCost { get; init; }

    // [9] ์•ก์ฒด ๋‹จ์œ„ ํƒ€์ž… ์ฝ”๋“œ
    public int SystemUnitTypeCode { get; init; }
}

public class CreateRecipeAlcoholsCommandHandler : IRequestHandler<CreateRecipeAlcoholsCommand>
{
    private readonly IApplicationDbContext _context;

    public CreateRecipeAlcoholsCommandHandler(IApplicationDbContext context)
    {
        _context = context;
    }

    public async Task Handle(CreateRecipeAlcoholsCommand request, CancellationToken cancellationToken)
    {
        {
            var entities = request.Alcohols.Select(alcohol =>
            {
                var entity = new RecipeAlcohol
                {
                    RecipeId = request.RecipeId,
                    AlcoholId = alcohol.AlcoholId,
                    AlcoholCost = alcohol.AlcoholCost.ToMilliLiterDecimal(alcohol.SystemUnitTypeCode),
                    SystemUnitTypeCode = alcohol.SystemUnitTypeCode
                };
                entity.AddDomainEvent(new RecipeAlcoholCreatedEvent(entity));
                //_context.RecipeAlcohols.Add(entity);
                return entity;
            }).ToList();
            
            _context.RecipeAlcohols.AddRange(entities);

            await _context.SaveChangesAsync(cancellationToken);
        }
    }
}

๊ทธ๋ฆฌ๊ณ  ์œ„์™€ ๊ฐ™์ด AlcoholRecipe ๋ฆฌ์ŠคํŠธ๋ฅผ ์ƒ์„ฑํ•ด์ฃผ๋Š” ๋ฉ”์„œ๋“œ๋„ ํ˜ธ์ถœํ–ˆ์Šต๋‹ˆ๋‹ค (IngredientRecipe์˜ ๋ชจ์Šต์€ ๋™์ผํ•˜์—ฌ, ์ƒ๋žตํ•ฉ๋‹ˆ๋‹ค.)

๋‹ค๋งŒ,

์•—โ€ฆ ์—ฌ๊ธฐ์„œ ๋ฉ˜๋ถ•์ด ์˜ค๋”๊ตฐ์š”.
์ผ๋‹จ ๊ตฌ๊ธ€์„ ์ข€ ์ฐพ์•„๋ณด๋‹ˆ AddRange๋ฅผ ์ €์žฅํ•  ๊ฒฝ์šฐ์—๋Š” ๋‹จ๊ฑด์˜ ์ฟผ๋ฆฌ๋ฅผ ์—ฌ๋Ÿฌ๋ฒˆ ์ „์†กํ•œ๋‹ค. ๋ผ๊ณ  ํ™•์ธ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค.

ํ , ๊ทธ๋ ‡๋‹ค๋ฉด ์—ฌ๊ธฐ์„œ ๋ฐฉ๋ฒ•์„ ๋ฐ”๊ฟ”์„œ
Add๋Š” ์–ด๋–จ๊นŒ ํ•˜๋Š” ์‹คํ—˜์ •์‹ ์œผ๋กœ ๋‹จ๊ฑด์œผ๋กœ ์—ฌ๋Ÿฌ๋ฒˆ ์ถ”๊ฐ€ํ•ด๋ณธ๊ฒฐ๊ณผ, ์–ด๋ž˜๋ž˜โ€ฆ ์ž˜๋˜๋„ค์—ฌโ€ฆ? ๋‹ค๋งŒ ๋” ์ฐพ์•„๋ณด๋‹ˆ Add๋Š” ๋ฒค์น˜๋งˆํฌ์—์„œ๋„ ํ™•์‹คํžˆ ์‹œ๊ฐ„์ฐจ์ด๊ฐ€ ๋งŽ์ด๋ฒŒ์–ด์ง€๋„ค์—ฌโ€ฆ

๊ทธ๋ž˜์„œ ์ œ๊ฐ€ ์„ ํƒํ•œ๋ฐฉ๋ฒ•์€

ef core์˜ extension nuget์„ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

    public async Task BulkInsertAuditableEntitiesAsync<T>(IList<T> entities, BulkConfig? bulkConfig = null,
        Action<decimal>? progress = null, Type? type = null, CancellationToken cancellationToken = default)
        where T : BaseAuditableEntity
    {
        entities.UpdateEntities(_userService.Id);

        await this.BulkInsertAsync(entities, bulkConfig, progress, type, cancellationToken);
    }
    
    public async Task BulkInsertEntitiesAsync<T>(IList<T> entities, BulkConfig? bulkConfig = null,
        Action<decimal>? progress = null, Type? type = null, CancellationToken cancellationToken = default)
        where T : class
    {
        await this.BulkInsertAsync(entities, bulkConfig, progress, type, cancellationToken);
    }

์œ„์™€ ๊ฐ™์ด ์ง„ํ–‰ํ•˜์˜€์œผ๋ฉฐ,

image

๋„ค ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๊ฒฐ๊ณผ์ ์œผ๋กœ ์•„๋ž˜์™€ ๊ฐ™์ด ์œ ์ถ”๊ฐ€ ๊ฐ€๋Šฅํ•œ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  1. AddRange ๋Š” SaveChangesAsync๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ˆœ๊ฐ„ transaction์„ ๊ฑด๋‹ค.
  2. Add๋กœ ๋‹จ๊ฑด์”ฉ ์ถ”๊ฐ€ํ•œ๊ฒฝ์šฐ์—๋Š” transaction์„ ๊ฑธ์ง€์•Š๊ณ , ๋‹จ๊ฑด์”ฉ ์ €์žฅํ•œ๋‹ค.

๋‹ค๋งŒ, ๋ช‡๋ช‡๋ถ€๋ถ„๋“ค์€ ์ข€ ๋” ๊ณ ๋ฏผ์ด ํ•„์š”ํ•œ ๊ฒƒ ๊ฐ™์ง€๋งŒ, EF Core์—์„œ์˜ ๋Œ€๋Ÿ‰ ์—…๋ฐ์ดํŠธ๊ฐ€ ํ•„์š”ํ•˜๊ฑฐ๋‚˜, ์ƒ๋‹จ์—์„œ Transaction์ด ๊ฑธ๋ ค์žˆ๋‹ค๋ฉด, BulkInsert Extension์„ ๊ณ ๋ คํ•ด๋ณผ๋งŒ ํ•œ๊ฒƒ๊ฐ™์Šต๋‹ˆ๋‹ค.

์ถ”๊ฐ€๋กœ, ์ œ๊ฐ€ ํ‹€๋ฆฐ๋ถ€๋ถ„์ด ์žˆ๊ฑฐ๋‚˜, ๋” ์ข‹์€ ๋ฐฉ๋ฒ•์ด ์žˆ๋‹ค๋ฉด ์–ธ์ œ๋“  ์ €์—๊ฒŒ ์•Œ๋ ค์ฃผ์„ธ์š”. ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.

4๊ฐœ์˜ ์ข‹์•„์š”

IApplicationDbContext ๊ฐ€ ์ธํ„ฐํŽ˜์ด์Šค์ธ๊ฐ€์š”?

๋„ต ๋งž์Šต๋‹ˆ๋‹ค.

DbContext.SaveChanges ๋Š” ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์ž…๋‹ˆ๋‹ค.

์˜ˆ์™ธ๋กœ ๋ณด์•„, CreateRecipeByBarCommandHandler ๊ฐ€ ์ƒ์„ฑํ•œ Transcation ๋‚ด๋ถ€์—์„œ, CreateRecipeAlcoholsCommandHandler ์™€ CreateRecipeIngredientsCommandHandler๊ฐ€ SaveChanges ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด์„œ ๋ฐœ์ƒ์‹œํ‚จ Transaction ์ด ๋ฌธ์ œ๊ฐ€ ๋˜๋Š” ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

MediatR ์€ RequestHandler ๋ฅผ ๋น„๋™๊ธฐ์ ์œผ๋กœ ํ˜ธ์ถœํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ๊ฐ Transaction ์€ Nested/Concurrent Transaction ์ด ๋ฉ๋‹ˆ๋‹ค.

๋‹ค๋งŒ, ๊ฐ™์€ transcation ์ธ๋ฐ, DbSet.Add()๋Š” ๋ฌธ์ œ๊ฐ€ ์—†๊ณ , DbSet.AddRange ๋งŒ ๋ฌธ์ œ๊ฐ€ ๋˜๋Š” ๊ฒƒ์€ ์ข€ ํฅ๋ฏธ๋กญ์Šต๋‹ˆ๋‹ค.

DbContext ๋ฌธ์ œ๋ฅผ ๋– ๋‚˜, CreateRecipeByBarCommandHandler ๊ฐ€ CreateRecipeByBarCommand ๋ฅผ Unit of Work ๋กœ ์ฒ˜๋ฆฌํ•˜๋„๋ก ๋ณ€๊ฒฝํ•  ํ•„์š”๋Š” ์žˆ์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

์ฆ‰, CreateRecipeAlcoholsCommandHandler ์™€ CreateRecipeIngredientsCommandHandler ๋กœ์ง์„ ๋ชจ๋‘ ํฌํ•จํ•˜๊ฒŒ ๋งŒ๋“œ๋Š” ๊ฒƒ์ด์ฃ .

์ด๋Š” CreateRecipeByBarCommand ๊ฐ€ ๋ณธ์งˆ์ ์œผ๋กœ ์ €์žฅ์†Œ์— ๋Œ€ํ•œ Transaction์„ ๋‚˜ํƒ€๋‚ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ๋ค์œผ๋กœ DbContext Concurrency ๋ฌธ์ œ๋„ ํ•ด๊ฒฐ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

3๊ฐœ์˜ ์ข‹์•„์š”

์ผ๋‹จ ITransactional ๋งˆ์ปค ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ƒ์† ๋ฐ›์„์‹œ

public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;

    public TransactionBehavior(
        IUnitOfWork unitOfWork, 
        ILogger<TransactionBehavior<TRequest, TResponse>> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request, 
        RequestHandlerDelegate<TResponse> next, 
        CancellationToken cancellationToken)
    {
        // ITransactional ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•œ ์š”์ฒญ์—๋งŒ ํŠธ๋žœ์žญ์…˜ ์ ์šฉ
        if (request is ITransactional)
        {
            var startTime = DateTime.UtcNow;
            
            _logger.LogInformation(
                "Transaction started for {RequestType} at {StartTime}", 
                typeof(TRequest).Name, 
                startTime
            );

            using var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken);
            try 
            {
                var response = await next();

                _unitOfWork.Commit();

                var endTime = DateTime.UtcNow;
                var duration = endTime - startTime;

                _logger.LogInformation(
                    "Transaction for {RequestType} completed in {Duration}", 
                    typeof(TRequest).Name, 
                    duration
                );

                return response;
            }
            catch (Exception ex)
            {
                _logger.LogError(
                    ex, 
                    "Transaction for {RequestType} failed after {Duration}", 
                    typeof(TRequest).Name, 
                    DateTime.UtcNow - startTime
                );

                _unitOfWork.Rollback();
                throw;
            }
        }

        // ํŠธ๋žœ์žญ์…˜์ด ํ•„์š” ์—†๋Š” ์š”์ฒญ์€ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ
        return await next();
    }
}
public interface IUnitOfWork
{
    Task<IDbTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default);
    
    void Commit();
    
    void Rollback();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly IApplicationDbContext _context;
    private IDbTransaction _transaction;

    public UnitOfWork(IApplicationDbContext context)
    {
        _context = context;
        _transaction = null!;
    }

    public async Task<IDbTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default)
    {
        var connection = _context.DatabaseConnection;

        if (connection.State != ConnectionState.Open)
        {
            await connection.OpenAsync(cancellationToken);
        }

        _transaction = await connection.BeginTransactionAsync(cancellationToken);
        return _transaction;
    }

    public void Commit()
    {
        _transaction.Commit();
    }

    public void Rollback()
    {
        _transaction.Rollback();
    }
}

ํ•ด๋‹น ํ˜•์‹์œผ๋กœ ํ•˜๋‚˜์˜ ๋‹จ์œ„๋ฅผ ์žก๊ณ  ์‹คํ–‰ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
๋ง์”€ํ•˜์‹  ๋ถ€๋ถ„์ด ์ด๋Ÿฐํ˜•์‹์ด ๋งž์œผ์‹ค๊นŒ์š”?

๊ทธ๋Ÿฐ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

UnitOfWork ์˜ Transcation ๋‚ด๋ถ€์—์„œ IMediator.Send ๊ฐ€ ๋˜ ๋‹ค๋ฅธ Task ๋ฅผ ์œ ๋ฐœํ•˜๊ณ , ๊ฑฐ๊ธฐ์—์„œ ๋‹ค์‹œ DbContext ๊ฐ€ ์‚ฌ์šฉ๋˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

DbContext ๋Š” ์ด๋ ‡๊ฒŒ ๋™์‹œ์— ์—ฌ๋Ÿฌ ์“ฐ๋ ˆ๋“œ์—์„œ ์‚ฌ์šฉ๋  ๋•Œ ๋งค์šฐ ์ทจ์•ฝํ•ฉ๋‹ˆ๋‹ค.

๊ฐœ๋ฐœ ๋‹จ๊ณ„์—์„œ ๋ฐœํ˜„๋˜์ง€ ์•Š๋”๋ผ๋„, ์•ˆ์‹ฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
์šด์šฉ ๋‹จ๊ณ„์—์„œ ๋ฐœํ˜„๋  ์ˆ˜๋„ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ , DbContext ๋ฅผ IRepository, IApplicationDbContext ๋“ฑ ์ถ”๊ฐ€์ ์ธ Abstraction์œผ๋กœ ๊ฐ์‹ธ์ง€ ๋ง๋ผ๊ณ  ํ•˜๋Š”๋ฐ๋Š” ์ด๋Ÿฐ Concurrency ์ด์Šˆ๋„ ํ•œ ๋ชซํ•ฉ๋‹ˆ๋‹ค.

ํŠนํžˆ, MeditaR๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ๋•Œ๋Š” ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ๋” ์ข‹๋‹ค๋Š” ๊ฒƒ์ด ๊ฐœ์ธ์ ์ธ ์ƒ๊ฐ์ž…๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค๋ฉด, ์•„๋ž˜๋Š” ์ปค๋งจ๋“œ, ์ปค๋งจ๋“œ Response์ž…๋‹ˆ๋‹ค.

Application.csproj
/Recipe/
Create.cs, CreateResponse.cs
Update.cs, UpdateResponse.cs
Query.cs, QueryResponse.cs
QueryById.cs, QueryByIdResponse.cs
โ€ฆ

์•„๋ž˜๋Š” ํ•ธ๋“ค๋Ÿฌ ๊ณต๊ธ‰์ž๋“ค์ž…๋‹ˆ๋‹ค.

DatabaseHandler.EFcore.csproj.
abstract AppDbContext.cs
/Configurations/
โ€ฆํ…Œ์ด๋ธ” ์„ค์ •๋“ค
/Handlers/Recipes/
RecipeCreateHandler(AppDbContext db) : IRequestHandler<Create, CreateResponse>,
โ€ฆ
services.AddHandlers();

DataBase.SqlServer.csproj โ†’ DatabaseHandler.EFcore.csproj.
SqlServerDbContext : AppDbContext.cs (Sql server ์šฉ ๋””ํ…Œ์ผ ์„ค์ •)
โ€ฆ
services.AddScoped<AppDbContext, SqlDbContext>();

DataBase.Postgresql.csproj โ†’ DatabaseHandler.EFcore.csproj.
PgsqlDbContext : AppDbContext.cs (Pgsql ์šฉ ๋””ํ…Œ์ผ ์„ค์ •.)
โ€ฆ
services.AddScoped<AppDbContext, PgsqlDbContext>();

ClientHandlers.csproj
/Recipes/
RecipeCreateHandler(HttpClient proxy) : IRequestHandler<Create, CreateResponse>,
โ€ฆ
services.AddHandlers()

์•„๋ž˜๋Š” ์ปค๋งจ๋“œ ์ „์†ก์ž๋“ค์ž…๋‹ˆ๋‹ค.

Api.csproj
builder.Services.AddEFcoreHandlers();
builder.Services.AddSqlServerServices();
// builder.Services.AddPgsqlServices();

Was.csproj
builder.Services.AddEFcoreHandlers();
builder.Services.AddSqlServerServices();
// builder.Services.AddPgsqlServices();

Client.BlazorWasm.csproj โ†’ ClientHanders.csproj
builder.Services.AddClientHandlers();

Client.WPF.csproj โ†’ ClientHandlers.csproj
builder.Services.AddClientHandlers();

WPFStandalone.csproj
builder.Services.AddEFcoreHandlers();
builder.Services.AddSqlServerServices();
// builder.Services.AddPgsqlServices();

1๊ฐœ์˜ ์ข‹์•„์š”

์•—, ์ด๋Ÿฐ๋ถ€๋ถ„์— ๋Œ€ํ•ด์„œ ๋†“์ณค๋„ค์š”. ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค

1๊ฐœ์˜ ์ข‹์•„์š”