Blazor 의존성 주입: 의존성 주입(DI) 컨테이너 서비스 등록과 컴포넌트 주입

의존성 주입

📚 의존성 주입

한눈에 보기

Blazor의 의존성 주입(DI) 컨테이너에 서비스를 등록하고 컴포넌트에 주입하는 방법을 학습합니다.

📌 학습 목표

이 글을 다 읽고 나면 다음을 할 수 있습니다:

  • 의존성 주입(Dependency Injection)의 개념과 필요성을 설명할 수 있습니다.
  • Blazor의 의존성 주입(DI) 컨테이너에 서비스를 등록할 수 있습니다.
  • Singleton, Scoped, Transient 세 가지 서비스 수명을 구분하고 적절히 선택할 수 있습니다.
  • @inject 지시어와 [Inject] 속성으로 컴포넌트에 서비스를 주입할 수 있습니다.
  • 인터페이스 기반 서비스 패턴으로 느슨한 결합을 구현할 수 있습니다.

📝 개념 설명

의존성 주입(Dependency Injection)이란?

의존성 주입은 객체가 직접 자신의 의존성(필요한 서비스나 객체)을 생성하는 대신, 외부에서 주입받는 디자인 패턴입니다. Blazor는 .NET Core의 기본 의존성 주입(DI) 컨테이너를 그대로 사용하며, 이를 통해 서비스를 컴포넌트 간에 쉽고 일관되게 공유할 수 있습니다.

 

의존성 주입(DI)를 사용하지 않으면 컴포넌트가 특정 구현체에 강하게 결합되어 테스트가 어렵고, 구현체를 교체하려면 컴포넌트 코드를 직접 수정해야 합니다.

// ❌ 의존성 주입(DI) 없이 직접 생성 — 강한 결합(Tight Coupling)
@code {
    // WeatherService가 변경되면 이 코드도 함께 수정해야 함
    private WeatherService _service = new WeatherService();
}

// ✅ 의존성 주입(DI) 사용 — 느슨한 결합(Loose Coupling)
@inject IWeatherService WeatherService
@code {
    // 인터페이스에만 의존 → 구현체를 자유롭게 교체 가능
}

 

 

🔄 의존성 주입(DI) 전체 흐름

 

① 서비스 정의
인터페이스 +
구현 클래스 작성
② 서비스 등록
Program.cs
builder.Services.AddXxx()
③ 앱 빌드
builder.Build()
컨테이너 확정
④ 서비스 주입
@inject
또는 [Inject]
⑤ 서비스 사용
컴포넌트 코드에서
메서드 호출

서비스 수명(Service Lifetime) 3종류

서비스를 등록할 때 가장 중요한 결정은 서비스 수명(Lifetime)입니다. 수명은 인스턴스가 언제 생성되고 언제 소멸되는지를 결정합니다.

 

 

⚖️ 서비스 수명(Lifetime) 비교

 

수명등록 메서드인스턴스 생성 시점소멸 시점권장 사용 예
SingletonAddSingleton()앱 시작 시 최초 1회앱 종료 시설정 값, 캐시, 전역 로그
ScopedAddScoped()Circuit / 요청마다Circuit / 요청 종료DB 컨텍스트, 사용자 세션
TransientAddTransient()주입 요청마다사용 직후경량 유틸리티, 이메일 발송

Singleton — 앱 전체에서 하나의 인스턴스

한 번 생성되면 앱이 종료될 때까지 유지됩니다. 상태(state)를 가지는 Singleton은 여러 사용자가 동시에 접근하므로 스레드 안전성에 주의해야 합니다.

builder.Services.AddSingleton<IAppSettings, AppSettings>();
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

Scoped — Blazor Server에서 Circuit마다 하나

Blazor Server에서는 각 SignalR Circuit(브라우저 탭과 서버 간 연결)마다 하나의 인스턴스가 생성되고, 같은 Circuit 내의 모든 컴포넌트가 동일한 인스턴스를 공유합니다.
Blazor WebAssembly에서는 단일 사용자 환경이므로 앱 수명과 동일하게 동작합니다(사실상 Singleton과 동일).

builder.Services.AddScoped<IUserStateService, UserStateService>();
// Entity Framework DbContext는 반드시 Scoped로 등록
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

Transient — 주입 요청마다 새 인스턴스

서비스가 주입될 때마다 항상 새로운 인스턴스를 생성합니다. 무상태(stateless)이고 가벼운 서비스에 적합합니다. 무거운 서비스에 Transient를 적용하면 메모리 할당이 급증하므로 주의하세요.

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<IReportGenerator, PdfReportGenerator>();

서비스 등록 방법 (Program.cs)

서비스 등록은 Program.cs에서 builder.Build() 호출 이전에 모두 작성해야 합니다. Build() 이후에는 서비스를 등록할 수 없습니다.

var builder = WebApplication.CreateBuilder(args);

// Blazor 기본 서비스 등록
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// 사용자 정의 서비스 등록
builder.Services.AddSingleton<IAppConfigService, AppConfigService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddTransient<IEmailService, EmailService>();

// 팩토리(Factory) 방식 — 복잡한 초기화가 필요한 경우
builder.Services.AddSingleton<ISpecialService>(sp => {
    var config = sp.GetRequiredService<IConfiguration>();
    return new SpecialService(config["ConnectionString"]);
});

var app = builder.Build(); // 이 이후로는 서비스 등록 불가
💡 Blazor WebAssembly의 경우:
WebAssembly 프로젝트는 WebAssemblyHostBuilder를 사용합니다.
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped<IWeatherService, WeatherService>();
await builder.Build().RunAsync();

컴포넌트에서 서비스 주입하기

등록된 서비스를 컴포넌트에서 사용하는 방법은 두 가지입니다.

방법 1: @inject 지시어 — 가장 일반적인 방법

Razor 파일 상단에 @inject 타입 변수명 형태로 선언합니다. 간결하고 직관적이어서 대부분의 경우 이 방법을 권장합니다.

@page "/weather"
@inject IWeatherService WeatherService
@inject NavigationManager NavManager
@inject ILogger<WeatherPage> Logger

<h3>날씨 정보</h3>

@if (forecasts == null)
{
    <p>로딩 중...</p>
}
else
{
    <ul>
    @foreach (var f in forecasts)
    {
        <li>@f.Date — @f.TemperatureC°C</li>
    }
    </ul>
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        Logger.LogInformation("날씨 데이터 로딩 시작");
        forecasts = await WeatherService.GetForecastAsync();
    }
}

방법 2: [Inject] 속성 — 코드 비하인드 방식

@code 블록 또는 .razor.cs 코드 비하인드 파일에서 속성에 [Inject] 어노테이션을 붙여 주입합니다.

// .razor.cs 파일 또는 @code 블록 내부
public partial class WeatherPage : ComponentBase
{
    [Inject]
    protected IWeatherService WeatherService { get; set; } = default!;

    [Inject]
    protected ILogger<WeatherPage> Logger { get; set; } = default!;

    protected override async Task OnInitializedAsync()
    {
        Logger.LogInformation("날씨 데이터 로딩 시작");
        forecasts = await WeatherService.GetForecastAsync();
    }
}
🔍 @inject vs [Inject] 비교:
  • @inject: .razor 파일 상단에 선언, 간결하고 직관적. 일반적인 경우 권장합니다.
  • [Inject]: @code 블록 내 또는 .razor.cs 파일에서 속성 기반으로 주입. 코드 비하인드 방식을 사용할 때 필수입니다.

인터페이스 기반 서비스 패턴

의존성 주입(DI)의 진정한 가치는 인터페이스를 통한 추상화에 있습니다. 구현체가 아닌 인터페이스에 의존하면, 구현체 교체나 테스트용 Mock 대체가 매우 쉬워집니다.

// 1. 인터페이스 정의 (Services/IWeatherService.cs)
public interface IWeatherService
{
    Task<IEnumerable<WeatherForecast>> GetForecastAsync();
}

// 2. 실제 구현체 (Services/WeatherService.cs)
public class WeatherService : IWeatherService
{
    private readonly HttpClient _httpClient;

    public WeatherService(HttpClient httpClient) // 생성자 주입
    {
        _httpClient = httpClient;
    }

    public async Task<IEnumerable<WeatherForecast>> GetForecastAsync()
    {
        return await _httpClient.GetFromJsonAsync<WeatherForecast[]>("api/weather")
               ?? Enumerable.Empty<WeatherForecast>();
    }
}

// 3. 테스트용 Mock 구현체 (Services/MockWeatherService.cs)
public class MockWeatherService : IWeatherService
{
    public Task<IEnumerable<WeatherForecast>> GetForecastAsync()
    {
        var data = new[]
        {
            new WeatherForecast { Date = DateTime.Today, TemperatureC = 25 },
            new WeatherForecast { Date = DateTime.Today.AddDays(1), TemperatureC = 22 }
        };
        return Task.FromResult<IEnumerable<WeatherForecast>>(data);
    }
}

// 4. 환경별 서비스 교체 (Program.cs)
if (builder.Environment.IsDevelopment())
    builder.Services.AddScoped<IWeatherService, MockWeatherService>();
else
    builder.Services.AddScoped<IWeatherService, WeatherService>();

서비스에서 다른 서비스를 주입받기

서비스 클래스 자체도 생성자를 통해 다른 서비스를 주입받을 수 있습니다. 의존성 주입(DI) 컨테이너가 의존성 그래프를 자동으로 분석하여 필요한 인스턴스를 모두 제공합니다.

// OrderService는 세 가지 서비스에 의존
public class OrderService : IOrderService
{
    private readonly IUserService _userService;
    private readonly IEmailService _emailService;
    private readonly ILogger<OrderService> _logger;

    // 의존성 주입(DI) 컨테이너가 세 서비스를 모두 자동으로 생성하여 전달
    public OrderService(
        IUserService userService,
        IEmailService emailService,
        ILogger<OrderService> logger)
    {
        _userService = userService;
        _emailService = emailService;
        _logger = logger;
    }

    public async Task PlaceOrderAsync(Order order)
    {
        var user = await _userService.GetCurrentUserAsync();
        _logger.LogInformation("주문 처리: {OrderId}", order.Id);
        await _emailService.SendConfirmationAsync(user.Email, order);
    }
}

// Program.cs — 등록 순서는 관계없음. 컨테이너가 의존성을 자동 해결
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddTransient<IEmailService, EmailService>();

💡 예제 & 실습 — 공유 카운터 서비스 전체 구현

의존성 주입(DI)의 전체 흐름을 실제 코드로 단계별로 실습합니다. 간단한 카운터 서비스를 만들어 여러 컴포넌트에서 공유하는 패턴을 구현합니다.

Step 1: 서비스 인터페이스 및 구현체 작성

// Services/ICounterService.cs
public interface ICounterService
{
    int Count { get; }
    void Increment();
    void Decrement();
    void Reset();
    event Action? OnCountChanged; // 상태 변경 알림용 이벤트
}

// Services/CounterService.cs
public class CounterService : ICounterService
{
    public int Count { get; private set; } = 0;
    public event Action? OnCountChanged;

    public void Increment()
    {
        Count++;
        OnCountChanged?.Invoke();
    }

    public void Decrement()
    {
        if (Count > 0) Count--;
        OnCountChanged?.Invoke();
    }

    public void Reset()
    {
        Count = 0;
        OnCountChanged?.Invoke();
    }
}

Step 2: Program.cs에 서비스 등록

// Program.cs
// Singleton: 모든 사용자(Circuit)가 동일한 카운터 값을 공유
builder.Services.AddSingleton<ICounterService, CounterService>();

// Scoped로 변경하면 브라우저 탭(Blazor Server Circuit)마다 별도 카운터
// builder.Services.AddScoped<ICounterService, CounterService>();

Step 3: 카운터 페이지 컴포넌트 구현

@* Pages/CounterPage.razor *@
@page "/counter"
@inject ICounterService CounterService
@implements IDisposable

<h3>공유 카운터</h3>
<p>현재 값: <strong>@CounterService.Count</strong></p>

<button class='btn btn-primary' @onclick='Increment'>+1 증가</button>
<button class='btn btn-secondary' @onclick='Decrement'>-1 감소</button>
<button class='btn btn-danger' @onclick='Reset'>초기화</button>

@code {
    protected override void OnInitialized()
    {
        // 서비스 상태 변경 시 UI 자동 갱신 구독
        CounterService.OnCountChanged += StateHasChanged;
    }

    private void Increment() => CounterService.Increment();
    private void Decrement() => CounterService.Decrement();
    private void Reset() => CounterService.Reset();

    // IDisposable: 컴포넌트 제거 시 이벤트 구독 해제(메모리 누수 방지)
    public void Dispose()
    {
        CounterService.OnCountChanged -= StateHasChanged;
    }
}

Step 4: 다른 컴포넌트에서 동일 서비스 재사용

@* Shared/CounterBadge.razor *@
@inject ICounterService CounterService
@implements IDisposable

<div class='counter-badge'>
    현재 카운터: <span>@CounterService.Count</span>
</div>

@code {
    protected override void OnInitialized()
    {
        CounterService.OnCountChanged += StateHasChanged;
    }

    public void Dispose()
    {
        CounterService.OnCountChanged -= StateHasChanged;
    }
}
🧪 실습 포인트 — 수명별 동작 차이 확인:
  1. Singleton: CounterPage에서 증가시키면 CounterBadge도 동일한 값을 즉시 표시합니다.
  2. Scoped: 같은 탭(Circuit) 내에서는 공유되지만, 새 탭을 열면 별도 인스턴스가 생성됩니다.
  3. Transient: 각 컴포넌트가 서로 다른 인스턴스를 받으므로 카운터 값을 공유하지 못합니다.

⚠️ 자주 틀리는 것 / 주의사항

1. Singleton 서비스에서 Scoped 서비스를 직접 주입받을 수 없음

Singleton은 Scoped보다 수명이 길기 때문에, 생성자에서 Scoped 서비스를 직접 주입받으면 런타임에 InvalidOperationException이 발생합니다. IServiceScopeFactory를 사용하여 필요할 때만 범위를 생성하는 방식으로 해결합니다.

// ❌ 런타임 오류: Singleton → Scoped 직접 주입 불가
public class MySingleton : IMySingleton
{
    public MySingleton(IMyScopedService scoped) { } // InvalidOperationException!
}

// ✅ 해결책: IServiceScopeFactory로 필요 시 스코프를 직접 생성
public class MySingleton : IMySingleton
{
    private readonly IServiceScopeFactory _scopeFactory;

    public MySingleton(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public void DoWork()
    {
        using var scope = _scopeFactory.CreateScope();
        var scoped = scope.ServiceProvider.GetRequiredService<IMyScopedService>();
        scoped.Execute(); // scope Dispose 시 scoped도 함께 해제됨
    }
}

2. Blazor WebAssembly에서 Scoped와 Singleton은 사실상 동일

⚠️ 주의: Blazor WebAssembly는 브라우저에서 단일 사용자로 실행됩니다. AddScoped()로 등록해도 앱 수명 동안 하나의 인스턴스만 유지되어 AddSingleton()과 동일하게 동작합니다. DB 컨텍스트처럼 진정한 Scoped 동작이 필요하다면 Blazor Server를 사용하세요.

3. builder.Build() 이후 서비스 등록 불가

// ❌ 잘못된 코드 — Build() 이후 등록 시 예외 발생
var app = builder.Build();
builder.Services.AddScoped<IMyService, MyService>(); // InvalidOperationException!

// ✅ 올바른 코드 — 반드시 Build() 이전에 모두 등록
builder.Services.AddScoped<IMyService, MyService>(); // ✅
var app = builder.Build();

4. [Inject] 속성은 protected 이상으로 선언

// ❌ private으로 선언하면 일부 환경에서 주입이 안 될 수 있음
[Inject]
private IMyService MyService { get; set; } = default!;

// ✅ protected 이상으로 선언 권장
[Inject]
protected IMyService MyService { get; set; } = default!;

5. 동일 인터페이스를 두 번 등록하면 마지막 등록이 우선됨

builder.Services.AddSingleton<IMyService, ServiceA>(); // 첫 번째
builder.Services.AddSingleton<IMyService, ServiceB>(); // 두 번째

// @inject IMyService로 주입하면 → ServiceB가 주입됨(마지막 등록 우선)

// 두 구현체 모두 필요하다면 IEnumerable로 주입
@inject IEnumerable<IMyService> AllServices

@code {
    void UseAll()
    {
        foreach (var svc in AllServices)
            svc.DoWork(); // ServiceA, ServiceB 모두 호출됨
    }
}

6. IDisposable 구현 누락 — 이벤트 구독 메모리 누수

⚠️ 주의: 서비스의 이벤트에 StateHasChanged를 구독했다면 반드시 컴포넌트에서 IDisposable을 구현하고 Dispose()에서 구독을 해제해야 합니다. 해제하지 않으면 컴포넌트가 제거된 후에도 이벤트 핸들러가 메모리에 남아 메모리 누수가 발생합니다.

🎯 마무리

의존성 주입은 Blazor 애플리케이션의 핵심 아키텍처 패턴입니다. 서비스를 올바르게 등록하고, 수명을 적절히 선택하며, 인터페이스 기반으로 설계하는 습관을 들이면 코드의 유연성·테스트 용이성·유지보수성이 크게 향상됩니다. 특히 Singleton/Scoped/Transient 수명의 차이를 정확히 이해하는 것이 예상치 못한 런타임 오류를 방지하는 핵심입니다.

✅ 핵심 정리
  • 의존성 주입(DI) 컨테이너: builder.Services에 등록 → builder.Build()로 확정. Build 이후 등록 불가.
  • Singleton: 앱 전체에서 하나의 인스턴스 공유. 설정·캐시·전역 상태에 적합.
  • Scoped: Blazor Server에서 SignalR Circuit마다 하나. DB 컨텍스트·사용자 세션에 적합.
  • Transient: 주입 요청마다 새 인스턴스 생성. 경량·무상태 서비스에 적합.
  • @inject: .razor 파일 상단에서 서비스를 주입하는 가장 간결한 방법.
  • [Inject]: @code 블록 또는 .razor.cs 코드 비하인드에서 속성 기반 주입 시 사용. protected 이상으로 선언.
  • 인터페이스 기반 등록: AddScoped<IService, Impl>() 패턴으로 구현체 교체와 테스트 Mock 활용.
  • Captive Dependency 주의: Singleton 서비스는 Scoped를 직접 주입받을 수 없음 → IServiceScopeFactory 사용.
  • 이벤트 구독 해제: 서비스 이벤트를 구독했다면 반드시 IDisposable을 구현하고 Dispose에서 해제.

댓글 남기기

Wordpress Social Share Plugin powered by Ultimatelysocial
Copy link
URL has been copied successfully!
THREADS
RSS
error: 저작권 콘텐츠보호를 부탁드립니다.