Blazor 서비스 레이어 설계 — 비즈니스 로직 분리 실전 가이드

서비스 레이어 설계
📚 오늘의 주제

서비스 레이어 설계

비즈니스 로직을 서비스 클래스로 분리하여 컴포넌트를 간결하게 유지하는 구조를 설계합니다.

📌 학습 목표

이 글을 완독하면 다음을 수행할 수 있습니다.

  1. 서비스 레이어가 왜 필요한지 그 설계 철학을 설명할 수 있습니다.
  2. 인터페이스와 구현 클래스를 분리하여 서비스를 정의할 수 있습니다.
  3. Program.cs에 서비스를 올바른 생명 주기로 등록할 수 있습니다.
  4. @inject를 사용해 컴포넌트에서 서비스를 주입받아 사용할 수 있습니다.
  5. 단일 책임 원칙을 적용해 컴포넌트와 비즈니스 로직을 완전히 분리할 수 있습니다.

📝 개념 설명

1. 서비스 레이어란 무엇인가?

Blazor 컴포넌트(.razor 파일)는 본래 UI 렌더링과 사용자 이벤트 처리를 담당합니다. 그런데 처음 개발을 시작하면 데이터 조회, 계산, 유효성 검사, 외부 API 호출 같은 비즈니스 로직을 컴포넌트 안에 모두 작성하게 됩니다.

 

이렇게 되면 컴포넌트 하나가 수백 줄이 넘어가고, 같은 로직을 다른 컴포넌트에서도 복붙해야 하는 상황이 생깁니다. 서비스 레이어(Service Layer)는 이 비즈니스 로직을 별도 클래스로 추출해 관리하는 아키텍처 계층입니다.

💡 서비스 레이어의 핵심 원칙
컴포넌트는 무엇을 보여줄지만 결정하고, 서비스는 어떻게 데이터를 처리할지를 담당합니다. 이것이 소프트웨어 설계의 단일 책임 원칙(Single Responsibility Principle)입니다.

2. 서비스 레이어 없이 개발했을 때의 문제점

아래는 서비스 레이어 없이 컴포넌트에 모든 로직을 직접 작성한 예입니다.

@page "/products"

@code {
    private List<Product> products = new();
    private decimal totalRevenue;

    protected override async Task OnInitializedAsync()
    {
        // HTTP 호출, 필터링, 계산이 모두 컴포넌트 안에
        var response = await Http.GetFromJsonAsync<List<Product>>("/api/products");
        products = response?.Where(p => p.Stock > 0).ToList() ?? new();
        totalRevenue = products.Sum(p => p.Price * p.SoldCount);
    }
}

이 컴포넌트는 HTTP 통신, 필터링 규칙, 매출 계산을 모두 직접 처리합니다. 다른 페이지에서도 같은 상품 목록이 필요하다면? 코드를 복붙해야 합니다.

 

 

비즈니스 규칙이 바뀐다면? 모든 컴포넌트를 찾아 수정해야 합니다.

3. 인터페이스 기반 서비스 설계

서비스 레이어를 설계할 때 인터페이스(Interface)를 먼저 정의하는 것이 표준 관행입니다. 인터페이스는 "이 서비스가 무엇을 할 수 있는지"의 계약(Contract)을 명세하고, 실제 구현은 별도 클래스에서 담당합니다.

// 인터페이스 — Services/IProductService.cs
public interface IProductService
{
    Task<List<Product>> GetAvailableProductsAsync();
    Task<decimal> CalculateTotalRevenueAsync();
    Task<bool> UpdateStockAsync(int productId, int quantity);
}

// 구현 클래스 — Services/ProductService.cs
public class ProductService : IProductService
{
    private readonly HttpClient _http;

    public ProductService(HttpClient http)
    {
        _http = http;
    }

    public async Task<List<Product>> GetAvailableProductsAsync()
    {
        var all = await _http.GetFromJsonAsync<List<Product>>("/api/products");
        return all?.Where(p => p.Stock > 0).ToList() ?? new();
    }

    public async Task<decimal> CalculateTotalRevenueAsync()
    {
        var products = await GetAvailableProductsAsync();
        return products.Sum(p => p.Price * p.SoldCount);
    }

    public async Task<bool> UpdateStockAsync(int productId, int quantity)
    {
        var result = await _http.PutAsJsonAsync($"/api/products/{productId}/stock", quantity);
        return result.IsSuccessStatusCode;
    }
}
📌 서비스 레이어 핵심 포인트
📋
인터페이스 (계약)
IProductService — 서비스가 제공할 기능 목록을 선언. 컴포넌트는 인터페이스만 알면 됩니다.
⚙️
구현 클래스
ProductService — 실제 HTTP 호출, 계산, 비즈니스 규칙이 여기 집결됩니다.
🔌
의존성 주입(의존성 주입(DI))
Program.cs에 등록하면 Blazor가 자동으로 컴포넌트에 서비스를 공급합니다.
🧩
컴포넌트 슬림화
@inject로 서비스를 받아 메서드를 호출만 합니다. UI 로직만 남습니다.

4. 서비스 생명 주기(Lifetime) 등록

Program.cs에 서비스를 등록할 때 세 가지 생명 주기 중 하나를 선택해야 합니다. 잘못 선택하면 데이터가 다른 사용자 사이에서 공유되거나, 매번 불필요하게 객체가 생성되는 문제가 생깁니다.

// Program.cs
builder.Services.AddScoped<IProductService, ProductService>();   // 권장 (대부분의 경우)
builder.Services.AddTransient<IEmailService, EmailService>();    // 매 주입마다 새 인스턴스
builder.Services.AddSingleton<IConfigService, ConfigService>();  // 앱 전체 단일 인스턴스
⚖️ 서비스 생명 주기 비교
생명 주기인스턴스 생성 시점공유 범위주요 사용처
Scoped사용자 연결(Circuit)당 1회해당 사용자 세션DB 컨텍스트, API 서비스
Transient주입될 때마다공유 안 함상태 없는 유틸리티
Singleton앱 시작 시 1회모든 사용자 공유설정값, 캐시
⚡ Blazor Server에서의 주의점
Blazor Server는 WebSocket 기반 연결(Circuit)을 사용합니다. Scoped 서비스는 이 Circuit 단위로 생성되므로, 사용자 A의 서비스 인스턴스와 사용자 B의 인스턴스가 분리됩니다. 반면 Singleton은 모든 사용자가 같은 인스턴스를 공유하므로 상태를 가지는 Singleton은 매우 위험합니다.

5. 컴포넌트에서 서비스 사용하기

서비스를 등록한 뒤에는 컴포넌트에서 @inject 지시어로 주입받습니다.

@page "/products"
@inject IProductService ProductService

<h2>상품 목록</h2>

@if (products == null)
{
    <p>불러오는 중...</p>
}
else
{
    <ul>
        @foreach (var p in products)
        {
            <li>@p.Name — @p.Price.ToString("C")</li>
        }
    </ul>
    <p>총 매출: @totalRevenue.ToString("C")</p>
}

@code {
    private List<Product>? products;
    private decimal totalRevenue;

    protected override async Task OnInitializedAsync()
    {
        products = await ProductService.GetAvailableProductsAsync();
        totalRevenue = await ProductService.CalculateTotalRevenueAsync();
    }
}

컴포넌트의 @code 블록이 매우 간결해졌습니다. HTTP 호출 방법도, 필터링 규칙도, 계산 공식도 컴포넌트가 알 필요가 없습니다. 컴포넌트는 오직 "무엇을 보여줄지"만 결정합니다.


💡 예제 & 실습 — 전체 서비스 레이어 구성

실습 시나리오: 할 일(Todo) 관리 서비스 만들기

간단한 할 일 목록 앱을 통해 서비스 레이어 전체 구성을 연습합니다.

Step 1 — 모델 정의 (Models/TodoItem.cs)

public class TodoItem
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.Now;
}

Step 2 — 인터페이스 정의 (Services/ITodoService.cs)

public interface ITodoService
{
    List<TodoItem> GetAll();
    List<TodoItem> GetPending();
    void Add(string title);
    void Complete(int id);
    void Delete(int id);
    int GetCompletionRate();
}

Step 3 — 구현 클래스 (Services/TodoService.cs)

public class TodoService : ITodoService
{
    private readonly List<TodoItem> _items = new();
    private int _nextId = 1;

    public List<TodoItem> GetAll() => _items.ToList();

    public List<TodoItem> GetPending()
        => _items.Where(t => !t.IsCompleted).ToList();

    public void Add(string title)
    {
        if (string.IsNullOrWhiteSpace(title)) return;
        _items.Add(new TodoItem { Id = _nextId++, Title = title });
    }

    public void Complete(int id)
    {
        var item = _items.FirstOrDefault(t => t.Id == id);
        if (item != null) item.IsCompleted = true;
    }

    public void Delete(int id)
        => _items.RemoveAll(t => t.Id == id);

    public int GetCompletionRate()
    {
        if (_items.Count == 0) return 0;
        return (int)(_items.Count(t => t.IsCompleted) * 100.0 / _items.Count);
    }
}

Step 4 — Program.cs 등록

// Program.cs
builder.Services.AddScoped<ITodoService, TodoService>();

메모리 내 상태를 가지는 서비스이므로 Scoped를 선택합니다. 사용자마다 독립된 할 일 목록을 가집니다.

Step 5 — 컴포넌트에서 사용 (Pages/Todo.razor)

@page "/todo"
@inject ITodoService TodoService

<h2>할 일 목록 (완료율: @TodoService.GetCompletionRate()%)</h2>

<input @bind="newTitle" placeholder="새 할 일 입력" />
<button @onclick="AddItem">추가</button>

<ul>
@foreach (var item in TodoService.GetAll())
{
    <li style="text-decoration: @(item.IsCompleted ? "line-through" : "none")">
        @item.Title
        @if (!item.IsCompleted)
        {
            <button @onclick="() => Complete(item.Id)">완료</button>
        }
        <button @onclick="() => Delete(item.Id)">삭제</button>
    </li>
}
</ul>

@code {
    private string newTitle = string.Empty;

    private void AddItem()
    {
        TodoService.Add(newTitle);
        newTitle = string.Empty;
    }

    private void Complete(int id) => TodoService.Complete(id);
    private void Delete(int id) => TodoService.Delete(id);
}

@code 블록에는 UI 이벤트를 서비스로 전달하는 얇은 래퍼만 남아 있습니다. 비즈니스 규칙(빈 문자열 방지, 완료율 계산)은 모두 TodoService가 책임집니다.

Step 6 — 여러 컴포넌트에서 같은 서비스 재사용

// Pages/Dashboard.razor
@page "/dashboard"
@inject ITodoService TodoService

<p>미완료 항목: @TodoService.GetPending().Count개</p>
<p>전체 완료율: @TodoService.GetCompletionRate()%</p>

같은 ITodoServiceDashboard 컴포넌트에서도 주입해 재사용합니다. 비즈니스 로직을 복붙할 필요가 없습니다.


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

① 인터페이스 없이 구현 클래스만 등록하는 경우

// 잘못된 예
builder.Services.AddScoped<ProductService>();

// 올바른 예
builder.Services.AddScoped<IProductService, ProductService>();

인터페이스 없이 구체 클래스만 등록하면 나중에 구현체를 교체(테스트용 Mock 등)하기 어려워집니다. 반드시 인터페이스 → 구현 클래스 형태로 등록하는 습관을 들이세요.

② Singleton 서비스에 상태를 저장하는 경우

// 위험: 모든 사용자가 _items 공유
builder.Services.AddSingleton<ITodoService, TodoService>();

TodoService가 내부 리스트(_items)를 가지고 있을 때 Singleton으로 등록하면 사용자 A가 추가한 항목이 사용자 B에게도 보입니다. 상태를 가지는 서비스는 반드시 Scoped로 등록하세요.

③ 서비스에서 다른 Scoped 서비스를 Singleton에 주입하는 캡처 문제

⚠️ Captive Dependency(종속성 포획) 문제
Singleton 서비스의 생성자에 Scoped 서비스를 주입하면 런타임 예외가 발생합니다. Singleton은 앱 시작 시 한 번 생성되므로, 사용자 세션 범위의 Scoped 서비스를 유지할 수 없기 때문입니다. 생명 주기의 방향은 항상 긴 것 → 짧은 것 순서여야 합니다. (Singleton > Scoped > Transient)

④ 서비스 내에서 UI 상태를 건드리는 경우

서비스는 순수하게 데이터 처리와 비즈니스 로직만 담당해야 합니다. NavigationManagerIJSRuntime 같은 UI 관련 서비스를 비즈니스 서비스에 주입하면 서비스가 UI에 결합되어 재사용성이 떨어집니다. 이런 경우 컴포넌트에서 서비스 결과를 받아 UI 작업은 컴포넌트가 직접 처리하게 하세요.

⑤ @inject를 @code 블록 안에 쓰는 경우

// 잘못된 위치 (컴파일 에러)
@code {
    @inject ITodoService TodoService
}

// 올바른 위치 (파일 상단, @page 아래)
@inject ITodoService TodoService
@code {
    // ...
}

🎯 마무리

설계 흐름 정리

🔄 서비스 레이어 구성 흐름
모델 정의
Models/
인터페이스 선언
IXxxService
구현 클래스 작성
XxxService
의존성 주입(DI) 등록
Program.cs
컴포넌트 주입
@inject

실전 활용 팁

  • 서비스 클래스는 Services/ 폴더에 모아두면 프로젝트 구조가 명확해집니다.
  • 서비스 하나가 너무 많은 메서드를 갖는다면(10개 이상) 책임을 분리해 서비스를 나누는 것을 검토하세요.
  • 비동기(async/await)로 설계하는 것이 기본입니다. 나중에 동기 → 비동기 전환은 매우 어렵습니다.
  • 외부 API, DB 등에 의존하는 서비스는 생성자에 HttpClientDbContext를 주입받아 사용하고, 직접 new로 생성하지 마세요.
✅ 핵심 정리
  • 서비스 레이어는 비즈니스 로직을 컴포넌트 밖으로 분리해 단일 책임 원칙을 실현합니다.
  • 항상 인터페이스 + 구현 클래스 쌍으로 설계해 교체 가능성과 테스트 용이성을 확보합니다.
  • Scoped는 사용자 세션 단위, Transient는 호출마다 새 인스턴스, Singleton은 앱 전체 공유 — 상태 유무에 따라 정확히 선택해야 합니다.
  • builder.Services.AddScoped<IXxx, Xxx>()로 등록하고 @inject IXxx 변수명으로 컴포넌트에서 사용합니다.
  • Singleton에 Scoped 서비스를 주입하면 Captive Dependency 오류가 발생하므로 생명 주기 방향을 반드시 확인하세요.

댓글 남기기

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