Blazor 상태 관리 기초: CascadingValue와 AppState 패턴 완전 정복

상태 관리 기초
📚
한눈에 보기

상태 관리 기초

컴포넌트 상태와 CascadingValue를 사용해 여러 컴포넌트에서 데이터를 공유합니다.

Blazor 상태 관리 기초
컴포넌트 상태와 CascadingValue, AppState 패턴을 활용해 여러 컴포넌트에서 데이터를 공유하는 방법을 학습합니다.

📌 학습 목표

  • Blazor 컴포넌트의 상태(State) 개념을 이해하고 올바르게 정의할 수 있습니다.
  • @code 블록에서 상태를 선언하고, 상태 변경이 UI에 자동 반영되는 원리를 설명할 수 있습니다.
  • CascadingValueCascadingParameter를 조합해 컴포넌트 트리 전체에 데이터를 공유할 수 있습니다.
  • 전역 상태 관리를 위한 AppState 패턴을 직접 설계하고 구현할 수 있습니다.
  • StateHasChanged()가 필요한 상황을 구분하고 적절히 활용할 수 있습니다.

📝 개념 설명

1. 컴포넌트 상태(State)란?

Blazor에서 상태(State)는 컴포넌트가 현재 기억하고 있는 데이터입니다. 버튼 클릭 횟수, 사용자가 입력한 텍스트, 로그인 여부, 선택된 탭 등 UI의 현재 모습을 결정하는 모든 정보가 상태에 해당합니다.

 

상태는 @code 블록에서 C# 필드 또는 프로퍼티로 선언합니다. 값이 바뀌는 순간 Blazor는 해당 컴포넌트를 자동으로 다시 렌더링(Re-render)합니다.

@* Counter.razor — 가장 단순한 상태 예제 *@
<p>현재 카운트: @count</p>
<button @onclick="Increment">+1 증가</button>
<button @onclick="Reset">초기화</button>

@code {
    private int count = 0;          // 상태 선언

    private void Increment()
    {
        count++;                    // 상태 변경 → 자동 UI 갱신
    }

    private void Reset()
    {
        count = 0;
    }
}

Increment()Reset()이 호출되어 count 값이 변경되면 Blazor는 자동으로 <p>의 내용을 새 값으로 업데이트합니다. 이것이 Blazor 상태 관리의 가장 기본 형태입니다.

2. 부모-자식 간 상태 전달과 Prop Drilling 문제

부모가 자식에게 데이터를 전달할 때는 [Parameter]를 사용합니다. 그런데 컴포넌트 트리가 깊어지면 심각한 문제가 생깁니다.

@* App → Layout → Page → Section → DeepWidget 으로 전달하는 상황 *@

@* Layout.razor *@
<Page UserName="@userName" />
@code { private string userName = "홍길동"; }

@* Page.razor — UserName을 직접 쓰지 않지만 전달을 위해 선언 *@
<Section UserName="@UserName" />
@code { [Parameter] public string UserName { get; set; } = string.Empty; }

@* Section.razor — 마찬가지로 전달만 담당 *@
<DeepWidget UserName="@UserName" />
@code { [Parameter] public string UserName { get; set; } = string.Empty; }

@* DeepWidget.razor — 실제로 UserName이 필요한 컴포넌트 *@
<span>@UserName</span>
@code { [Parameter] public string UserName { get; set; } = string.Empty; }

Page와 Section은 UserName을 직접 사용하지 않으면서도 단순 전달을 위해 Parameter를 선언해야 합니다. 이를 Prop Drilling(프롭 드릴링)이라 하며, 코드를 불필요하게 복잡하게 만드는 대표적인 안티패턴입니다.

3. CascadingValue — 계단식 값 전달

CascadingValue는 Prop Drilling 문제를 해결하는 Blazor의 내장 메커니즘입니다. 상위 컴포넌트에서 <CascadingValue>로 값을 흘려보내면, 중간 컴포넌트를 모두 건너뛰고 깊은 곳의 자식 컴포넌트가 [CascadingParameter]로 직접 받을 수 있습니다.

흘려보내는 쪽 — CascadingValue 컴포넌트로 감싸기
@* MainLayout.razor *@
<CascadingValue Value="@theme">
    @Body
</CascadingValue>

@code {
    private string theme = "dark";
}
받는 쪽 — [CascadingParameter] 특성으로 수신
@* 깊은 곳의 자식 컴포넌트 DeepWidget.razor *@
<div class="widget @Theme">위젯 내용</div>

@code {
    [CascadingParameter]
    public string Theme { get; set; } = string.Empty;
    // Page, Section은 아무 코드도 없어도 됩니다
}

Blazor는 타입 매칭을 사용합니다. string 타입으로 흘려보낸 값은 [CascadingParameter]로 선언된 string 타입 프로퍼티에 자동으로 연결됩니다.

4. Name 속성으로 같은 타입 여러 개 구분하기

같은 타입의 CascadingValue가 여러 개 있을 때는 Name 속성을 지정해 구분합니다.

@* MainLayout.razor — 같은 string 타입, Name으로 구분 *@
<CascadingValue Name="Theme" Value="@theme">
    <CascadingValue Name="Language" Value="@language">
        @Body
    </CascadingValue>
</CascadingValue>

@code {
    private string theme = "dark";
    private string language = "ko";
}
@* 자식 컴포넌트 — 이름으로 구분해서 각각 받기 *@
@code {
    [CascadingParameter(Name = "Theme")]
    public string Theme { get; set; } = string.Empty;

    [CascadingParameter(Name = "Language")]
    public string Language { get; set; } = string.Empty;
}

5. IsFixed 옵션 — 성능 최적화

CascadingValue의 값이 앱 생명주기 동안 절대 바뀌지 않는다면 IsFixed="true"를 설정합니다. Blazor가 변경 감지를 건너뛰어 불필요한 재렌더링을 방지합니다.

@* 앱 설정값처럼 불변인 경우 *@
<CascadingValue Value="@appConfig" IsFixed="true">
    @Body
</CascadingValue>

@code {
    private readonly AppConfig appConfig = new AppConfig();
}
⚠️ IsFixed 주의사항
IsFixed="true"로 설정된 CascadingValue를 나중에 변경해도 자식 컴포넌트에 전파되지 않습니다. “절대 바뀌지 않는 값”에만 사용하세요.

6. AppState 패턴 — 전역 상태 관리

로그인 사용자 정보, 장바구니 수량, 알림 카운트처럼 앱 전체에서 공유되어야 하는 상태는 AppState 패턴으로 관리합니다. 이 패턴은 세 가지 핵심 요소로 구성됩니다:

  1. AppState 클래스: 상태 데이터, 변경 메서드, OnChange 알림 이벤트 포함
  2. 의존성 주입(DI) 등록: AddScoped<AppState>()로 사용자 세션당 독립 인스턴스 생성
  3. 컴포넌트 구독: @inject AppState로 주입받고 OnChange 이벤트를 구독

 

 

⚖️ Blazor 상태 공유 방법 비교

 

항목ParameterCascadingValueAppState
전달 방향부모 → 직계 자식조상 → 모든 자손어디서나 접근 가능
중간 컴포넌트모두 Parameter 선언 필요완전히 건너뜀불필요
주요 사용처1~2단계 단순 전달테마·언어·레이아웃 설정로그인 정보·장바구니
변경 감지부모 재렌더링 시 자동CascadingValue 변경 시OnChange 이벤트 수동 구독
의존성 주입(DI) 등록 필요불필요불필요필수 (AddScoped)

💡 예제 & 실습

실습: 쇼핑몰 앱의 전역 상태 관리 구현

로그인 사용자 이름과 장바구니 개수를 앱 전체에서 공유하는 예제를 5단계로 구현합니다.

Step 1 — AppState 클래스 작성
// Services/AppState.cs
public class AppState
{
    private string _userName = string.Empty;
    private int _cartCount = 0;

    // 읽기 전용 프로퍼티로 외부 직접 수정 차단
    public string UserName => _userName;
    public int CartCount => _cartCount;
    public bool IsLoggedIn => !string.IsNullOrEmpty(_userName);

    // 상태가 변경되면 구독 컴포넌트들에 알리는 이벤트
    public event Action? OnChange;

    public void Login(string userName)
    {
        _userName = userName;
        NotifyStateChanged();
    }

    public void Logout()
    {
        _userName = string.Empty;
        _cartCount = 0;
        NotifyStateChanged();
    }

    public void AddToCart()
    {
        _cartCount++;
        NotifyStateChanged();
    }

    public void RemoveFromCart()
    {
        if (_cartCount > 0) _cartCount--;
        NotifyStateChanged();
    }

    // 구독자 전체에게 변경 알림
    private void NotifyStateChanged() => OnChange?.Invoke();
}
Step 2 — Program.cs에 Scoped로 등록
// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Scoped: 사용자(Blazor 서킷)당 하나의 독립적인 인스턴스
builder.Services.AddScoped<AppState>();

var app = builder.Build();
// ... 나머지 설정
Step 3 — 내비게이션 메뉴에서 상태 표시
@* Components/NavMenu.razor *@
@inject AppState AppState
@implements IDisposable

<nav class="navbar">
    @if (AppState.IsLoggedIn)
    {
        <span>@AppState.UserName 님</span>
        <a href="/cart">
            🛒 장바구니 (@AppState.CartCount)
        </a>
        <button @onclick="HandleLogout">로그아웃</button>
    }
    else
    {
        <a href="/login">로그인</a>
    }
</nav>

@code {
    protected override void OnInitialized()
    {
        // AppState 변경 시 이 컴포넌트도 재렌더링되도록 구독
        AppState.OnChange += StateHasChanged;
    }

    private void HandleLogout()
    {
        AppState.Logout();
    }

    // 컴포넌트 소멸 시 반드시 구독 해제 (메모리 누수 방지)
    public void Dispose()
    {
        AppState.OnChange -= StateHasChanged;
    }
}
Step 4 — 상품 페이지에서 장바구니 추가
@* Pages/Products.razor *@
@page "/products"
@inject AppState AppState

<h2>상품 목록</h2>

@foreach (var product in products)
{
    <div class="product-card">
        <h3>@product.Name</h3>
        <p>가격: @product.Price.ToString("N0")원</p>
        <button @onclick="() => AddToCart(product)">
            장바구니 담기
        </button>
    </div>
}

@code {
    private record Product(string Name, decimal Price);

    private List<Product> products = new()
    {
        new("노트북", 1_200_000),
        new("무선 마우스", 35_000),
        new("기계식 키보드", 85_000),
    };

    private void AddToCart(Product product)
    {
        AppState.AddToCart();
        // OnChange 이벤트 발생 → NavMenu 자동 갱신
    }
}
Step 5 — 로그인 페이지
@* Pages/Login.razor *@
@page "/login"
@inject AppState AppState
@inject NavigationManager Nav

<h2>로그인</h2>
<input @bind="inputName" placeholder="사용자 이름을 입력하세요" />
<button @onclick="HandleLogin">로그인</button>

@code {
    private string inputName = string.Empty;

    private void HandleLogin()
    {
        if (!string.IsNullOrWhiteSpace(inputName))
        {
            AppState.Login(inputName);      // 전역 상태 업데이트
            Nav.NavigateTo("/");            // 홈으로 이동
            // NavMenu는 OnChange 이벤트를 받아 자동으로 갱신됨
        }
    }
}

이제 어느 페이지에서 로그인하거나 장바구니 버튼을 눌러도 NavMenu의 사용자 이름과 장바구니 수량이 즉시 자동으로 갱신됩니다. 컴포넌트 간에 직접적인 참조나 콜백이 전혀 없어도 됩니다.

 

 

🔄 AppState 패턴 동작 흐름

 

사용자 액션
(버튼 클릭 등)
AppState 메서드 호출
(Login / AddToCart 등)
내부 상태 변경
+ OnChange 이벤트 발생
구독 컴포넌트들
StateHasChanged() 실행
UI 자동 갱신

StateHasChanged()를 직접 호출해야 하는 경우

Blazor는 @onclick 같은 이벤트 핸들러가 종료된 후 자동으로 재렌더링합니다. 그러나 Blazor의 이벤트 루프 밖에서 상태가 변경되는 경우에는 StateHasChanged()를 직접 호출해야 합니다.

@* 타이머로 현재 시간을 표시하는 컴포넌트 *@
@implements IDisposable

<p>현재 시간: @currentTime</p>

@code {
    private string currentTime = string.Empty;
    private System.Threading.Timer? timer;

    protected override void OnInitialized()
    {
        timer = new System.Threading.Timer(_ =>
        {
            currentTime = DateTime.Now.ToString("HH:mm:ss");

            // 타이머 콜백은 Blazor 렌더링 스레드 밖에서 실행됨
            // → InvokeAsync로 안전하게 렌더링 스레드에 전달
            InvokeAsync(StateHasChanged);
        }, null, 0, 1000);
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
💡 InvokeAsync(StateHasChanged) vs StateHasChanged()
타이머 콜백, 외부 이벤트, JavaScript interop 콜백처럼 Blazor 이벤트 루프 밖에서 UI를 갱신해야 할 때는 StateHasChanged() 대신 반드시 InvokeAsync(StateHasChanged)를 사용하세요. UI 스레드에서 안전하게 실행됩니다.

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

1. AppState를 Singleton으로 등록하는 실수

// ❌ 잘못된 등록: 모든 사용자가 하나의 상태를 공유함
builder.Services.AddSingleton<AppState>();
// → A 사용자가 로그인하면 B 사용자 화면에도 A의 이름이 표시됨!

// ✅ 올바른 등록: 사용자(Blazor 서킷)당 독립적인 인스턴스
builder.Services.AddScoped<AppState>();

Blazor Server에서 AddSingleton을 사용하면 서버에 접속한 모든 사용자가 같은 AppState 인스턴스를 공유합니다. 개인정보가 다른 사용자에게 노출되는 심각한 보안 버그가 됩니다. 반드시 AddScoped를 사용하세요.

2. OnChange 이벤트 구독 해제 누락

// ❌ 구독은 했지만 해제하지 않음 → 메모리 누수
protected override void OnInitialized()
{
    AppState.OnChange += StateHasChanged;
}
// Dispose 메서드 없음 → 컴포넌트가 소멸된 후에도 이벤트 핸들러가 살아있음

// ✅ IDisposable 구현으로 반드시 해제
@implements IDisposable

@code {
    protected override void OnInitialized()
    {
        AppState.OnChange += StateHasChanged;
    }

    public void Dispose()
    {
        AppState.OnChange -= StateHasChanged;
    }
}

3. CascadingParameter 타입 불일치 — 에러 없이 기본값 반환

// 부모: string 타입으로 흘려보냄
<CascadingValue Value="@theme"> ... </CascadingValue>
@code { private string theme = "dark"; }

// ❌ 자식: int 타입으로 받으려 함
// → 타입 불일치, 예외 없이 기본값(0) 반환
[CascadingParameter] public int Theme { get; set; }

// ✅ 자식: 동일한 string 타입으로 받아야 함
[CascadingParameter] public string Theme { get; set; } = string.Empty;

타입이 일치하지 않아도 컴파일 에러나 런타임 예외가 발생하지 않습니다. 단순히 매칭이 실패해 기본값이 사용됩니다. 이 때문에 디버깅이 어렵습니다. CascadingValue와 CascadingParameter의 타입이 정확히 일치하는지 항상 확인하세요.

4. IsFixed와 가변 값의 조합

// ❌ 잘못된 사용: 바뀌는 값에 IsFixed="true" 설정
<CascadingValue Value="@dynamicTheme" IsFixed="true">
    @Body
</CascadingValue>
// → 나중에 dynamicTheme 값이 바뀌어도 자식 컴포넌트에 전달되지 않음

// ✅ 올바른 사용: 불변 값에만 IsFixed
<CascadingValue Value="@AppVersion" IsFixed="true">
    @Body
</CascadingValue>
@code { private const string AppVersion = "1.0.0"; }

5. 상태 변경 후 NotifyStateChanged() 호출 누락

public class AppState
{
    public int Count { get; private set; } = 0;
    public event Action? OnChange;

    // ❌ 상태를 바꾸고 알림을 보내지 않음
    public void Increment()
    {
        Count++;
        // OnChange?.Invoke() 누락 → 구독 컴포넌트들이 갱신되지 않음
    }

    // ✅ 상태 변경 후 반드시 알림
    public void IncrementCorrect()
    {
        Count++;
        OnChange?.Invoke();   // 또는 NotifyStateChanged() 헬퍼 메서드 호출
    }
}

🎯 마무리

Blazor의 상태 관리는 복잡도에 따라 단계적으로 선택합니다. 단일 컴포넌트 내부 상태는 @code 필드만으로 충분하고, 깊은 계층에 걸쳐 공유해야 할 때는 CascadingValue가 효과적입니다. 로그인 정보나 장바구니처럼 앱 전체 범위의 상태는 AppState 패턴으로 관리합니다.

 

AppState 패턴의 세 가지 핵심 — AddScoped 등록, OnChange 이벤트 구독, 컴포넌트 소멸 시 구독 해제 — 이 세 가지를 습관처럼 지키면 실전 Blazor 앱의 상태 관리 문제 대부분을 안전하게 해결할 수 있습니다.

✅ 핵심 정리
  • 컴포넌트 상태: @code 블록의 필드/프로퍼티로 선언, 값 변경 시 자동 재렌더링
  • Prop Drilling: 여러 계층을 거쳐 Parameter를 반복 전달하는 안티패턴
  • CascadingValue: 상위 컴포넌트에서 흘려보낸 값이 중간 계층을 건너뛰고 자손에게 전달
  • CascadingParameter: 타입(또는 Name) 매칭으로 CascadingValue 수신 — 타입이 반드시 일치해야 함
  • IsFixed=”true”: 불변 값에만 사용, 가변 값에 쓰면 변경이 전파되지 않음
  • AppState 패턴: 상태 클래스 + AddScoped 등록 + OnChange 이벤트로 전역 상태 관리
  • 구독 해제 필수: IDisposable.Dispose()에서 OnChange -= StateHasChanged 반드시 호출
  • 외부 스레드 갱신: 타이머·외부 콜백에서는 StateHasChanged() 대신 InvokeAsync(StateHasChanged) 사용

댓글 남기기

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