본문 바로가기
카테고리 없음

claude

by keisoft 2026. 9. 21.

세션(circuit)이 사라져서 생기는 현상일 가능성이 큽니다. MudBlazor를 쓰시니 Interactive Server 모드일 텐데, 이 모드에서는 조회한 데이터가 **서버 메모리의 circuit**에 들어 있습니다. 연결이 끊긴 뒤 재연결하려 할 때 그 circuit이 이미 없으면 Blazor가 페이지를 새로 로드합니다. 그래서 조회 결과가 날아갑니다.

## circuit이 사라지는 흔한 원인 (서버 3대 + L4 + IIS 기준)

1. **L4 세션 유지(스티키) 미설정**: 재연결 요청이 다른 서버로 가면 그 서버에는 circuit이 없어서 재연결이 거부되고 새로고침됩니다.
2. **L4 idle timeout**: 오래 유지되는 WebSocket 연결을 L4가 끊어버리는 경우입니다.
3. **브라우저 백그라운드 탭 제한이나 PC 절전**: 연결이 끊긴 채로 `DisconnectedCircuitRetentionPeriod`(기본 **3분**)가 지나면 서버가 circuit을 폐기합니다.
4. **IIS 앱풀**: 유휴 시간 제한(기본 20분)이나 주기적 재활용으로 워커 프로세스가 내려가면 모든 circuit이 사라집니다.
5. **Data Protection 키가 서버마다 다름**: 다른 서버로 요청이 넘어가면 복호화에 실패합니다.

먼저 원인을 확인해 보세요. 브라우저 콘솔(F12)의 재연결 로그를 보고, 서버에서 `Microsoft.AspNetCore.Components.Server.Circuits` 로그 레벨을 Debug로 올려 circuit 폐기(evict) 시점을 확인하면 어느 경우인지 알 수 있습니다.

## 1단계: 인프라 설정

- **L4**: Source IP 기반 persistence를 켜고, persistence 시간과 idle timeout을 충분히 길게 잡습니다(예: 수 시간).
- **IIS 앱풀**: 유휴 시간 제한은 `0`, 시작 모드는 `AlwaysRunning`으로 두고, 재활용은 고정 시간(새벽)으로만 설정합니다. WebSocket 프로토콜 기능이 설치되어 있는지도 확인하세요. 없으면 롱폴링으로 동작해서 더 불안정해집니다.
- **Data Protection 키 공유** (3대 공통):
```csharp
builder.Services.AddDataProtection()
    .SetApplicationName("MyApp")
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\공유경로\keys"));
```

## 2단계: circuit 보존 시간 늘리기

```csharp
builder.Services.AddServerSideBlazor().AddCircuitOptions(o =>
{
    o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(30);
    o.DisconnectedCircuitMaxRetained = 500;
});
```
보존 시간을 늘린 만큼 서버 메모리를 더 쓰는 점은 감안하셔야 합니다.

## 3단계: 새로고침돼도 상태가 남게 만들기 (근본 대책)

인프라를 다 맞춰도 앱풀 재활용이나 배포, 서버 장애 때는 circuit이 사라집니다. 그래서 "새로고침돼도 복원되는 구조"가 가장 확실합니다.

**방법 A: 조회 조건을 URL 쿼리스트링에 두기** (가장 단순하고, 서버 3대 환경에서도 안전)
```csharp
[SupplyParameterFromQuery] public string? Code { get; set; }
[SupplyParameterFromQuery] public DateTime? From { get; set; }

async Task Search()
{
    Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
        { ["code"] = Code, ["from"] = From?.ToString("yyyy-MM-dd") }), replace: true);
    await LoadAsync();
}

protected override async Task OnParametersSetAsync()
{
    if (!string.IsNullOrEmpty(Code)) await LoadAsync();
}
```
새로고침되면 같은 조건으로 자동 재조회됩니다. 기준정보처럼 다시 조회해도 되는 데이터에 잘 맞습니다.

**방법 B: .NET 10의 circuit 상태 영속화**
.NET 10이라면 컴포넌트의 public 속성에 `[PersistentState]` 특성을 붙여 circuit 상태 영속화를 켤 수 있습니다. 기본 메모리 저장소의 보존 기간은 2시간이고, 최대 1,000개 circuit까지 보관합니다. 다만 메모리 저장소는 서버별로 따로 있으므로, 서버가 3대라면 Redis 같은 분산 저장소가 필요합니다. HybridCache가 DI에 등록되어 있으면 자동으로 사용되고, Redis 등 분산 저장소를 HybridCache를 통해 지원합니다. [learn.microsoft.com +2](https://learn.microsoft.com/th-th/ASPNET/Core/blazor/state-management/server?view=aspnetcore-3.1)
```csharp
[PersistentState] public List<Item>? Items { get; set; }

protected override async Task OnInitializedAsync()
    => Items ??= await LoadItemsAsync();
```
참고로 보존 기간이 지난 뒤 재개하면 화면이 멈춘 것처럼 동작한다는 버그 리포트가 있었으니, 적용한다면 최신 패치 버전인지 확인하세요. [GitHub](https://github.com/dotnet/aspnetcore/issues/64607)

## 추천 순서
1. 로그로 원인 확인
2. L4 스티키, IIS 앱풀, Data Protection 설정 점검
3. 주요 조회 화면에 방법 A 적용

Redis 인프라가 있고 .NET 10이라면 방법 B도 검토할 만합니다.

Sources:
- [ASP.NET Core Blazor server-side state management (.NET 10)](https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management/server?view=aspnetcore-10.0)
- [dotnet/aspnetcore #60494 – Persisting circuit state](https://github.com/dotnet/aspnetcore/issues/60494)
- [dotnet/aspnetcore #64607 – circuit resume bug](https://github.com/dotnet/aspnetcore/issues/64607)

.NET 10이면 **circuit 상태 영속화(`[PersistentState]`)**를 쓰는 게 이 문제에 가장 직접적인 해법입니다. 연결이 오래 끊겨 circuit이 폐기되더라도, 새 circuit이 만들어질 때 저장해 둔 상태로 복원됩니다.

## 1. 조회 화면에 적용

```razor
@code {
    [PersistentState] public SearchCond? Cond { get; set; }
    [PersistentState] public List<ItemDto>? Items { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Cond ??= new();
        // Items는 복원되면 그대로 쓰고, 처음 진입일 때만 비어 있음
    }
}
```

사용 조건:
- 대상은 **public 속성**이어야 하고, **JSON 직렬화가 가능한 타입**이어야 합니다. 순환 참조가 있으면 안 되므로 Dapper DTO 정도가 적당합니다.
- 루프로 컴포넌트를 렌더링할 때는 `@key`를 지정해야 합니다.
- 필요한 상태만 저장해야 합니다. 조회 결과가 크면(수천 건 이상) 조회조건만 저장하고, 복원될 때 재조회하는 방식이 낫습니다.
- 사용자가 직접 새로고침(F5)하면 저장된 상태도 사라지고, 복원이 항상 보장되지는 않습니다. 실패하면 기존처럼 새로 시작합니다.

## 2. 서버 3대라면 분산 저장소 필요

`AddInteractiveServerComponents()`를 호출하면 이 기능은 기본으로 켜집니다. 다만 기본 저장소는 MemoryCache이고, 서버 인스턴스 하나 안에서만 동작합니다(최대 1,000개 circuit, 2시간 보관). 재연결이 다른 서버로 가면 복원되지 않습니다.

HybridCache를 등록하면 그것을 분산 저장소로 사용합니다. 이때 보관 기간은 `PersistedCircuitDistributedRetentionPeriod`로 따로 정해지며 기본값은 8시간입니다.

```csharp
// L2 저장소: Redis 또는 SQL Server 중 선택
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "...");
// builder.Services.AddDistributedSqlServerCache(o => { o.ConnectionString = "..."; o.SchemaName = "dbo"; o.TableName = "BlazorCircuitCache"; });

builder.Services.AddHybridCache();   // 등록된 IDistributedCache를 L2로 사용

builder.Services.Configure<CircuitOptions>(o =>
{
    o.PersistedCircuitDistributedRetentionPeriod = TimeSpan.FromHours(10); // 업무시간 커버
});

builder.Services.AddDataProtection()          // 3대 키 공유 (필수)
    .SetApplicationName("MyApp")
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\공유경로\keys"));
```

Redis 인프라가 없다면 이미 쓰고 계신 SQL Server를 분산 캐시로 쓰는 것도 방법입니다. 캐시 테이블은 `dotnet sql-cache create`로 만들 수 있습니다. L4 스티키 설정은 이와 별개로 계속 유지하는 것을 권장합니다.

## 3. 앱풀 재활용 / 배포 대비 (선택)

문서에는 서버 종료 직전에 `Circuit.RequestCircuitPauseAsync()`로 연결된 클라이언트들을 정상적으로 일시정지시키는 예제가 있습니다. 이렇게 하면 배포나 재활용 때도 상태가 보존됩니다. 탭이 숨겨진 뒤 일정 시간이 지나면 자동으로 일시정지하는 `Microsoft.AspNetCore.Components.Server.AutoPause` 패키지도 소개되어 있습니다.

다만 제가 확인한 문서 페이지가 .NET 11 버전 내용을 함께 담고 있는 것으로 보입니다. 이 두 기능이 .NET 10에서 사용 가능한지는 확인하지 못했으니, NuGet과 API가 있는지 먼저 확인해 보세요. 수동으로 `Blazor.pauseCircuit()`/`resumeCircuit()`을 호출하는 방식은 .NET 10 기능입니다.

## 주의

.NET 10 초기 버전에는 보관 기간이 지난 뒤 재개하면 화면이 멈춘 것처럼 동작하는 버그 리포트(#64607)가 있었습니다. 최신 패치를 적용한 뒤 테스트하세요. 테스트할 때는 `PersistedCircuitInMemoryRetentionPeriod`를 10초 정도로 줄이고 브라우저 콘솔에서 `Blazor.pauseCircuit()`을 호출하면 오래 기다리지 않고 재현할 수 있습니다.

Sources:
- [ASP.NET Core Blazor server-side state management (.NET 10)](https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management/server?view=aspnetcore-10.0)
- [dotnet/aspnetcore #64607](https://github.com/dotnet/aspnetcore/issues/64607)



네, 인프라와 별개로 **코드 쪽에서 새로고침이나 circuit 종료를 일으키는 원인**이 꽤 있습니다. 아래 순서대로 확인해 보세요.

## 1. 먼저 로그부터: CircuitHandler 추가

어떤 경우인지 가려내는 데 가장 효과적입니다.

```csharp
public class CircuitLogHandler(ILogger<CircuitLogHandler> log) : CircuitHandler
{
    public override Task OnCircuitOpenedAsync(Circuit c, CancellationToken ct)
    { log.LogInformation("Circuit OPEN {Id} {Machine}", c.Id, Environment.MachineName); return Task.CompletedTask; }
    public override Task OnConnectionDownAsync(Circuit c, CancellationToken ct)
    { log.LogWarning("Conn DOWN {Id}", c.Id); return Task.CompletedTask; }
    public override Task OnConnectionUpAsync(Circuit c, CancellationToken ct)
    { log.LogInformation("Conn UP {Id}", c.Id); return Task.CompletedTask; }
    public override Task OnCircuitClosedAsync(Circuit c, CancellationToken ct)
    { log.LogWarning("Circuit CLOSED {Id}", c.Id); return Task.CompletedTask; }
}
// Program.cs
builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped<CircuitHandler, CircuitLogHandler>());
```

로그에 `MachineName`을 같이 남기면, 재연결 요청이 다른 서버로 갔는지 바로 보입니다. `appsettings.json`에서 `"Microsoft.AspNetCore.Components.Server.Circuits": "Debug"`도 켜 두세요.

## 2. 강제 새로고침 코드 (`forceLoad`, `location.reload`)

프로젝트 전체에서 다음을 검색해 보세요.
- `forceLoad: true` 또는 `NavigateTo(..., true)`
- `location.reload`, `location.href =`
- 세션 타임아웃이나 자동 로그아웃용 **Timer**, `PeriodicTimer`, JS `setTimeout`

업무 시스템에서 "N분 미사용 시 로그아웃" 로직이 있으면 증상이 정확히 이렇게 나타납니다.

## 3. 인증 만료와 재검증

- **`RevalidatingServerAuthenticationStateProvider`**: Identity 템플릿 기본값은 30분마다 재검증합니다. 재검증에 실패하면 로그아웃되고 로그인 페이지로 이동합니다.
- **쿠키 인증**: `ExpireTimeSpan`, `SlidingExpiration`을 확인하세요. 인터랙티브 상태에서는 HTTP 요청이 없어서 쿠키가 갱신되지 않습니다. 그래서 재연결이나 새로고침 시점에 쿠키가 이미 만료되어 있을 수 있습니다.
- **`AuthorizeRouteView`**의 NotAuthorized 처리에서 `forceLoad`로 이동하고 있지 않은지도 보세요.

## 4. 처리되지 않은 예외로 circuit 종료

- `Timer` 콜백, `async void` 이벤트, `InvokeAsync` 밖에서 `StateHasChanged`를 호출하는 경우에 예외가 나면 circuit이 통째로 죽습니다. 화면 하단에 "An unhandled error has occurred. Reload"가 뜨고, 사용자가 새로고침하게 됩니다.
- `ErrorBoundary`를 적용했는지, 백그라운드 작업에 try/catch가 있는지 확인하세요.

## 5. 메모리 증가로 앱풀 재활용

- 컴포넌트 필드에 대량의 조회 결과를 들고 있는 경우, 사용자 수 × 데이터 크기만큼 메모리를 씁니다.
- 이벤트 구독 해제 누락, `IDisposable`/`IAsyncDisposable` 미구현, Singleton 서비스에 컴포넌트 참조를 보관하는 경우는 메모리 누수로 이어집니다.
- IIS 앱풀에 **개인 메모리 제한(Private Memory Limit)**이 걸려 있으면 재활용되면서 모든 사용자의 circuit이 사라집니다. 이벤트 뷰어(WAS 이벤트)에서 재활용 사유를 확인해 보세요.

## 6. SignalR / Circuit 옵션

```csharp
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents(o =>
    {
        o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(30); // 기본 3분
        o.DetailedErrors = builder.Environment.IsDevelopment();
    })
    .AddHubOptions(h =>
    {
        h.ClientTimeoutInterval = TimeSpan.FromSeconds(60); // 기본 30초
        h.KeepAliveInterval    = TimeSpan.FromSeconds(15);
        h.MaximumReceiveMessageSize = 64 * 1024;            // 기본 32KB
    });
```

- 클라이언트에서 큰 데이터를 보내면(JS interop 반환값, 큰 입력값, 파일 등) `MaximumReceiveMessageSize`를 넘어 연결이 끊깁니다. 서버 로그에서 해당 오류를 확인하세요.
- `ClientTimeoutInterval`을 늘렸다면, 클라이언트 쪽 `serverTimeout`은 그보다 짧게 맞춰야 합니다(아래 7번 참고).

## 7. App.razor의 클라이언트 설정과 재연결 UI

- .NET 8/9 템플릿으로 시작해서 10으로 올렸다면, .NET 10 템플릿의 `ReconnectModal` 컴포넌트가 없을 수 있습니다. circuit 재개(resume) 흐름이 제대로 동작하는지 확인하세요.
- `Blazor.start()`를 커스텀으로 쓰고 있다면 다음을 점검합니다.
```html
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
  Blazor.start({
    circuit: {
      reconnectionOptions: { maxRetries: 30, retryIntervalMilliseconds: 3000 },
      configureSignalR: b => b.withServerTimeout(60000).withKeepAliveInterval(15000)
    }
  });
</script>
```
- 커스텀 reconnect 핸들러가 실패하면 곧바로 `location.reload()`를 부르고 있지 않은지도 확인하세요.

## 8. 기타

- **렌더 모드**: 조회 페이지가 의도대로 `InteractiveServer`인지 확인하세요. prerender가 켜져 있으면 `OnInitializedAsync`가 두 번 실행되니, `[PersistentState]`로 중복 조회를 막는 패턴을 쓰세요.
- **IIS 재시작 트리거**: 앱이 실행 중에 자기 폴더의 `web.config`를 수정하면 사이트가 재시작됩니다. 배포 스크립트의 `app_offline.htm`도 마찬가지입니다.

가장 의심되는 곳은 **2번(자동 로그아웃/타이머)**과 **3번(인증 재검증)**입니다. 1번 로그를 붙여 두면 "Conn DOWN → CLOSED" 패턴인지(연결/보존 문제), 연결이 살아 있는데 이동한 것인지(코드 문제)로 바로 구분됩니다.