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

chapgpt

by keisoft 2026. 9. 21.

8s 동안 처리함

지금 증상이라면 가장 먼저 L4의 Sticky Session(세션 고정)과 IIS App Pool 설정을 보시는 게 맞습니다.

Blazor의 Interactive Server 방식은 사용자가 조회한 화면 상태, 컴포넌트 필드, Scoped 서비스 상태 등을 서버의 Circuit 메모리에 들고 있습니다. 따라서 처음 연결된 서버가 WEB01인데 잠시 후 재연결할 때 L4가 WEB02로 보내버리면 기존 Circuit이 없어서 새 Circuit이 만들어지고, 결과적으로 처음 화면처럼 다시 로딩되면서 조회 데이터가 사라질 수 있습니다. Microsoft도 서버 팜에서 Blazor/SignalR을 사용할 경우 일반적으로 **session affinity(sticky session)**가 필요하다고 명시합니다.

현재 구조에서 우선 확인할 것

사용자 브라우저
      │
      ▼
    L4
  ┌───┼───┐
  ▼   ▼   ▼
WEB1 WEB2 WEB3
IIS  IIS  IIS
  │
Blazor Interactive Server

이 구조라면 L4가 최소한 아래처럼 동작해야 합니다.

사용자 A ──────────────► WEB1
사용자 A 재접속 ───────► WEB1
사용자 A SignalR ──────► WEB1

사용자 B ──────────────► WEB2

즉 한 사용자의 _blazor SignalR 연결과 재연결을 같은 서버로 보내야 합니다.

L4 설정에서 이름은 장비마다 다르지만 보통 다음 항목입니다.

Persistence
Session Persistence
Sticky Session
Session Affinity
Source IP Affinity
Cookie Persistence

가능하면 Cookie 기반 Persistence를 우선 검토하시고, 장비 특성상 어렵다면 Source IP 기반이라도 적용해야 합니다.


---

그리고 IIS도 반드시 확인해보세요. IIS Application Pool의 기본 Idle Time-out은 일반적으로 20분입니다. ASP.NET Core in-process 앱은 이 값 때문에 일정 시간 유휴 후 프로세스가 종료될 수 있습니다. Microsoft 문서도 서버 앱을 계속 유지하려면 Idle Time-out = 0을 설정하는 방법을 안내합니다.

IIS에서:

IIS Manager
└─ Application Pools
      └─ 해당 App Pool
           └─ Advanced Settings

다음처럼 설정하는 것을 권합니다.

Start Mode                    : AlwaysRunning
Idle Time-out (minutes)       : 0

그리고 사이트에서:

사이트
└─ Advanced Settings
      Preload Enabled : True

또 App Pool의 **Regular Time Interval(주기적 Recycling)**도 확인하세요. 예를 들어 1740분 주기 recycle이 걸려 있으면 아무리 Sticky Session이어도 해당 서버 프로세스가 재시작되는 순간 Circuit은 사라집니다.

운영 환경에서는 다음 항목들도 확인하는 게 좋습니다.

Application Pool

Start Mode
    AlwaysRunning

Idle Time-out
    0

Regular Time Interval
    필요 없으면 0 또는 운영 정책에 맞춤

Private Memory Limit
    불필요한 강제 recycle이 없는지 확인

Specific Times
    특정 시간 recycle 설정 확인

그런데 이것만으로는 100% 해결되지 않습니다

Blazor Server의 Circuit은 기본적으로 서버 메모리에 있습니다.

예를 들어:

private List<OrderDto> orders;

protected override async Task OnInitializedAsync()
{
    orders = await OrderService.GetOrders();
}

이 orders는 WEB1 프로세스에만 있습니다.

WEB1이 죽거나 recycle되면:

WEB1
Circuit
├─ 검색조건
├─ Grid 데이터
├─ 선택된 Row
├─ 현재 페이지
└─ 기타 component state

     ↓ IIS recycle

전부 소멸

Blazor는 일시적인 네트워크 단절 때 기존 Circuit으로 재연결을 시도하지만, 서버가 기존 Circuit을 더 이상 가지고 있지 않으면 새 Circuit이 생성됩니다. Microsoft 문서에서도 이를 명확히 설명합니다.

그리고 기존 DisconnectedCircuitRetentionPeriod 기본값은 3분입니다.

따라서 단순히 이것만:

builder.Services.AddServerSideBlazor()
    .AddCircuitOptions(options =>
    {
        options.DisconnectedCircuitRetentionPeriod =
            TimeSpan.FromHours(1);
    });

늘리는 것은 근본 해결은 아닙니다.

왜냐하면 WEB1 자체가 죽으면 1시간으로 해놔도 데이터가 같이 없어지기 때문입니다.


---

제가 권하는 구성

현재처럼 IIS 3대 + L4라면 저는 3단계로 처리하겠습니다.

1단계: 반드시 할 것

L4
└─ Sticky Session 적용

IIS
├─ Idle Timeout = 0
├─ Start Mode = AlwaysRunning
├─ Preload Enabled = True
└─ 불필요한 App Pool Recycling 제거

이것만 해도 현재 발생하는 현상이 상당수 줄어들 가능성이 높습니다.

2단계: 중요한 조회 상태는 서버 Circuit에만 두지 않기

예를 들어 사용자가 다음 조건으로 조회했다고 하면:

조회기간 : 2026-09-01 ~ 2026-09-19
부서     : 개발팀
상태     : 진행중
Page     : 3
Sort     : CreatedDate desc

이 정도 정보는 sessionStorage, localStorage, URL QueryString 또는 Redis 등에 저장하세요.

예:

/orders
    ?from=2026-09-01
    &to=2026-09-19
    &dept=DEV
    &status=RUNNING
    &page=3

그러면 Circuit이 날아가더라도 페이지가 다시 생성되면서:

URL 상태 복원
       ↓
조회조건 복원
       ↓
DB 재조회
       ↓
사용자가 보고 있던 화면 복원

할 수 있습니다.

굳이 Grid 조회 결과 전체를 저장할 필요는 없습니다.

X 조회 데이터 50MB 저장
O 조회 조건만 저장

하고 다시 DB 조회하는 게 훨씬 좋습니다.


---

3대 서버 환경이라면 Redis도 고려할 만합니다

특히 지금처럼 WEB1/WEB2/WEB3 형태이면:

┌──── WEB1
Browser → L4 ├──── WEB2
             └──── WEB3
                  │
                  ▼
                Redis

형태로 세션성 상태를 공유할 수 있습니다.

최신 Blazor에는 Circuit state persistence를 위한 분산 저장 방식도 있으며 HybridCache + Redis 구성이 지원됩니다. Microsoft 문서에도 다중 인스턴스 환경에서 Redis 기반 HybridCache를 사용할 수 있다고 나와 있습니다.

예를 들어 최신 구성에서는 개념적으로:

builder.Services
    .AddHybridCache()
    .AddRedis(configuration.GetConnectionString("Redis"));

builder.Services
    .AddRazorComponents()
    .AddInteractiveServerComponents();

처럼 구성할 수 있습니다.

다만 이것은 현재 사용 중인 .NET 버전에 따라 적용 방법이 달라집니다.


---

그리고 한 가지 중요한 부분

사용자께서 말씀하신 게 "Blazor SSR"이라도 실제 페이지가 다음 중 무엇인지 확인해야 합니다.

@rendermode InteractiveServer

또는

.AddInteractiveServerComponents()

를 사용한다면 지금 설명한 Circuit/SignalR 문제가 정확히 관련됩니다.

반대로 완전히 Static SSR만 사용하고 있다면:

SignalR
Circuit
Sticky Session

문제는 아닙니다.

그 경우에는 페이지가 새로고침되는 원인을 별도로 찾아야 합니다.

지금까지 말씀하신 개발 환경상 Blazor SSR + InteractiveServer를 섞어서 사용하는 구조일 가능성이 높아 보입니다.


---

저는 현재 서버를 이렇게 점검하겠습니다

우선순위는 다음입니다.

순서 확인 항목 중요도

1 L4 Sticky Session 설정 ★★★★★
2 IIS Idle Timeout = 0 ★★★★★
3 App Pool 자동 Recycling 확인 ★★★★★
4 WebSocket 정상 사용 여부 ★★★★☆
5 Blazor Circuit disconnect 로그 ★★★★☆
6 조회조건을 sessionStorage/Redis 등에 보관 ★★★★★
7 Redis/HybridCache 기반 상태 복구 ★★★☆☆


특히 **“약 20분 정도 방치하면 잘 발생한다”**면 IIS Idle Timeout을 강하게 의심할 수 있고, 발생 시간이 들쭉날쭉하거나 특정 사용자만 간헐적으로 발생한다면 L4 Sticky Session 또는 네트워크/SignalR 재연결 쪽을 먼저 의심할 만합니다. IIS의 기본 Idle Timeout이 20분이라는 점도 이 진단에 도움이 됩니다.

원하시면 다음 답변에서 **현재 사용 중인 .NET 8/9 Blazor SSR 기준으로 Program.cs, IIS 설정, L4 설정, CircuitHandler 로그까지 포함해서 “어디서 끊기는지 정확하게 잡는 코드”**를 바로 구성해드릴 수 있습니다.


.NET 10이면 이야기가 조금 달라집니다. .NET 10 Blazor Interactive Server에는 Circuit 상태 영속화 기능이 정식으로 들어가 있어서, 지금 같은 “오랫동안 화면을 놔뒀다가 돌아왔더니 조회 상태가 사라지는 문제”를 이전 버전보다 훨씬 안정적으로 처리할 수 있습니다.

다만 현재처럼 IIS 3대 + L4 구조라면 핵심은 두 가지입니다. 첫째는 L4 Sticky Session, 둘째는 .NET 10 Circuit State Persistence를 Redis 같은 분산 저장소와 연결하는 것입니다. SignalR은 서버 팜 환경에서 기본적으로 동일 연결의 요청을 같은 서버 프로세스로 보내야 하므로 sticky session이 필요합니다. Redis backplane을 쓰더라도 일반적인 구성에서는 sticky session 요구가 사라지지 않습니다.

.NET 10에서는 AddInteractiveServerComponents()를 사용하면 Circuit state persistence가 기본 활성화되며, 단일 서버에서는 기본적으로 메모리 캐시를 사용합니다. 기본 보관은 최대 1,000개 Circuit, 2시간입니다. 그런데 서버가 3대이므로 각 서버 메모리에 저장하면 WEB1의 상태를 WEB2가 볼 수 없습니다. 그래서 이 환경에서는 Redis 기반 HybridCache가 훨씬 적합합니다.

예를 들면 Program.cs를 이런 방향으로 구성할 수 있습니다.

builder.Services
    .AddHybridCache()
    .AddRedis(builder.Configuration.GetConnectionString("Redis"));

builder.Services
    .AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.Configure<CircuitOptions>(options =>
{
    options.PersistedCircuitDistributedRetentionPeriod =
        TimeSpan.FromHours(8);
});

그리고 실제로 복원해야 할 상태는 컴포넌트에서 명시적으로 지정해야 합니다.

@page "/orders"
@rendermode InteractiveServer

@code {
    [PersistentState]
    public SearchCondition? Condition { get; set; }

    [PersistentState]
    public List<OrderDto>? Orders { get; set; }

    [PersistentState]
    public int CurrentPage { get; set; }
}

.NET 10에서는 [PersistentState]가 붙은 상태를 Circuit pause/resume 과정에서 저장할 수 있습니다. DB에서 가져온 목록, 사용자가 입력 중인 값, 현재 페이지 같은 것을 보존할 수 있습니다. 다만 상태는 JSON 직렬화가 가능해야 하며, EF Entity를 그대로 들고 있는 것보다는 DTO를 쓰는 게 안전합니다.

여기서 저는 Orders 전체를 저장하는 것보다는 보통 아래처럼 하겠습니다.

[PersistentState]
public SearchCondition? Condition { get; set; }

[PersistentState]
public int CurrentPage { get; set; }

[PersistentState]
public string? SortColumn { get; set; }

[PersistentState]
public bool SortDescending { get; set; }

그리고 Circuit이 복원되면 조회조건으로 다시 DB를 조회합니다.

즉:

Circuit 끊김
   ↓
Redis에 상태 저장

{
  "From": "2026-09-01",
  "To": "2026-09-19",
  "Department": "DEV",
  "Page": 3,
  "Sort": "CreateDate desc"
}

   ↓

다시 접속
   ↓
Circuit 상태 복원
   ↓
조건으로 DB 재조회
   ↓
사용자가 보던 화면 복원

이 구조가 조회 결과 수천~수만 건을 Redis에 통째로 저장하는 것보다 훨씬 좋습니다.

그리고 .NET 10에는 중요한 변화가 하나 더 있습니다. 브라우저 탭이 백그라운드로 가거나 모바일에서 앱 전환 등으로 오랫동안 비활성 상태가 되더라도 Circuit을 pause → persist → resume할 수 있습니다. Microsoft 문서에서도 정확히 다음 상황을 대상으로 설명합니다.

Browser tab throttling

모바일 앱 전환

네트워크 단절

장시간 inactive circuit

Enhanced navigation


따라서 지금 말씀하신

> 조회해 놓고 다른 작업하다가 한참 후 다시 보면 초기화됨



이라는 상황에 정확히 대응하는 기능입니다.

다만 한 가지 매우 중요합니다. 브라우저에서 F5 같은 full refresh가 일어나면 이 Circuit persistence만으로는 기존 상태가 유지되지 않습니다. Microsoft도 Circuit persistence는 full-page refresh가 발생하면 복원되지 않는다고 명시하고 있습니다.

그래서 실제로 브라우저가 "자동 새로고침"되는 건지 아니면

SignalR 끊김
→ reconnect
→ 기존 Circuit 없음
→ 새 Circuit 생성

때문에 사용자 입장에서 새로고침처럼 보이는 것인지 먼저 구분해야 합니다.

현재 환경에서는 저는 다음 구조를 권합니다.

Redis
                           ▲
                           │
Browser
   │
   │ SignalR
   ▼
  L4
Sticky Session
   │
┌─┴───────────────┐
│                 │
▼                 ▼
WEB1              WEB2              WEB3
IIS               IIS               IIS
.NET10             .NET10             .NET10
Blazor             Blazor             Blazor
Interactive        Interactive        Interactive
Server             Server             Server
   │                 │                 │
   └─────────────────┴─────────────────┘
                       │
                 HybridCache

이렇게 하면 WEB1이 살아있는 평상시에는 L4가 계속 WEB1으로 보내고, Circuit이 잠시 끊겨도 .NET 10 persistence를 이용해서 복원할 수 있습니다. WEB1의 프로세스가 recycle되는 경우에도 Redis에 저장된 persist 가능한 상태를 활용할 수 있는 구조가 됩니다.

IIS 쪽도 여전히 다음 설정은 권합니다.

Application Pool

Start Mode
    AlwaysRunning

Idle Time-out
    0

Regular Time Interval
    0
    또는 운영 정책에 맞춰 명확한 시간 지정

Site

Preload Enabled
    True

하지만 .NET 10이라고 해서 IIS AppPool을 절대로 recycle하지 말아야 한다는 의미는 아닙니다. 상태 복원 구조를 제대로 만들어 놓으면 배포/recycle에도 훨씬 강해집니다.

따라서 지금 환경이 .NET 10이라는 것을 기준으로 하면 제가 권하는 우선순위는 이렇습니다.

우선 적용

1 L4 Sticky Session 확인
2 IIS Idle Timeout / Recycling 확인
3 Redis 설치
4 HybridCache + Redis 적용
5 중요 조회조건에 [PersistentState] 적용
6 조회 데이터 전체 대신 조회조건을 보존
7 Circuit 연결/해제 로그 추가


특히 조회 결과를 유지해야 하는 화면이 많다면 .NET 10의 [PersistentState] + HybridCache + Redis를 적극 활용하는 게 현재 구조에 가장 잘 맞습니다.

그리고 제가 지금 상황이라면 다음 단계로는 CircuitHandler 로그를 먼저 넣어서 실제 원인이 L4 서버 변경, IIS recycle, Circuit timeout, SignalR disconnect 중 무엇인지 잡습니다. 원하시면 바로 다음 답변에서 .NET 10 + IIS 3대 + L4 + Redis 기준으로 Program.cs 전체 설정 + Redis 설정 + PersistentState 예제 + CircuitHandler 로그 코드까지 실제 적용 가능한 형태로 묶어서 보여드리겠습니다.


네. 서버 소스에서도 반드시 확인할 부분이 있습니다. 특히 지금 증상은 인프라 문제와 소스 문제가 같이 섞일 수 있습니다.

먼저 가장 중요한 건 Program.cs입니다. .NET 10 Interactive Server를 쓴다면 최소한 아래 구성이 있어야 합니다.

builder.Services
    .AddRazorComponents()
    .AddInteractiveServerComponents();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

그리고 실제 화면이 정말 Interactive Server인지 확인해야 합니다.

@rendermode InteractiveServer

또는 App.razor에서 전역으로:

<Routes @rendermode="InteractiveServer" />
<HeadOutlet @rendermode="InteractiveServer" />

Interactive Server라면 사용자 화면 상태는 Circuit에 들어갑니다. 컴포넌트 필드, Scoped 서비스 값, 현재 렌더링 상태 등이 Circuit에 묶이기 때문에 Circuit이 새로 생성되면 초기화됩니다.

예를 들어 이런 코드가 있다면:

private List<MyData> data = [];

protected override async Task OnInitializedAsync()
{
    data = await service.GetDataAsync();
}

Circuit이 새로 만들어질 때 OnInitializedAsync()가 다시 실행되고 data도 새로 만들어집니다.

그래서 사용자가 "화면이 새로고침됐다"고 느끼는 순간 OnInitializedAsync()가 다시 호출되는지 로그를 먼저 찍어보는 게 좋습니다.

protected override async Task OnInitializedAsync()
{
    logger.LogInformation(
        "Page Initialized. Time={Time}",
        DateTime.Now);

    data = await service.GetDataAsync();
}

여기에 서버 이름까지 찍으면 훨씬 좋습니다.

logger.LogInformation(
    "Page Initialized. Server={Server}, Time={Time}",
    Environment.MachineName,
    DateTime.Now);

로그가 이렇게 나온다면:

21:00 WEB01 Page Initialized
21:47 WEB02 Page Initialized

L4 때문에 다른 서버로 넘어갔을 가능성이 상당히 높습니다.

반대로:

21:00 WEB01
21:47 WEB01

인데 초기화됐다면 WEB01의 Circuit이 사라진 것입니다.


---

또 하나 꼭 봐야 하는 곳이 CircuitOptions입니다.

.NET 10에도 기존 disconnected circuit 보관 설정이 있습니다.

builder.Services.Configure<CircuitOptions>(options =>
{
    options.DisconnectedCircuitRetentionPeriod =
        TimeSpan.FromMinutes(30);

    options.DisconnectedCircuitMaxRetained = 1000;
});

이 값은 일시적으로 연결이 끊겼을 때 기존 Circuit 자체를 서버 메모리에 얼마나 오래 유지할지에 관련됩니다.

하지만 .NET 10에서는 여기에 별도로 Circuit persistence도 있습니다. AddInteractiveServerComponents()를 사용하는 경우 persistence 자체는 기본 활성화됩니다. 기본 메모리 persistence는 최대 1,000개 Circuit, 기본 보관 2시간입니다.

필요하면:

builder.Services.Configure<CircuitOptions>(options =>
{
    options.PersistedCircuitInMemoryMaxRetained = 5000;

    options.PersistedCircuitInMemoryRetentionPeriod =
        TimeSpan.FromHours(8);
});

같이 조정할 수 있습니다.

다만 서버가 3대라면 이건 각 서버 메모리이므로 이것만으로는 충분하지 않습니다.


---

소스에서 제가 가장 먼저 넣을 것은 Circuit 로그입니다

CircuitHandler 하나 만들어 두시면 원인을 잡기 상당히 편합니다.

using Microsoft.AspNetCore.Components.Server.Circuits;

public class CircuitLoggingHandler : CircuitHandler
{
    private readonly ILogger<CircuitLoggingHandler> _logger;

    public CircuitLoggingHandler(
        ILogger<CircuitLoggingHandler> logger)
    {
        _logger = logger;
    }

    public override Task OnCircuitOpenedAsync(
        Circuit circuit,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Circuit OPEN: {CircuitId}, Server={Server}",
            circuit.Id,
            Environment.MachineName);

        return Task.CompletedTask;
    }

    public override Task OnCircuitClosedAsync(
        Circuit circuit,
        CancellationToken cancellationToken)
    {
        _logger.LogWarning(
            "Circuit CLOSED: {CircuitId}, Server={Server}",
            circuit.Id,
            Environment.MachineName);

        return Task.CompletedTask;
    }

    public override Task OnConnectionDownAsync(
        Circuit circuit,
        CancellationToken cancellationToken)
    {
        _logger.LogWarning(
            "Connection DOWN: {CircuitId}, Server={Server}",
            circuit.Id,
            Environment.MachineName);

        return Task.CompletedTask;
    }

    public override Task OnConnectionUpAsync(
        Circuit circuit,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Connection UP: {CircuitId}, Server={Server}",
            circuit.Id,
            Environment.MachineName);

        return Task.CompletedTask;
    }
}

등록:

builder.Services.AddScoped<CircuitHandler, CircuitLoggingHandler>();

이것만 넣어도 상당 부분 드러납니다.

정상적인 일시 연결 끊김이라면 대략:

WEB01
Circuit OPEN      ABC
Connection DOWN   ABC
Connection UP     ABC

Circuit ID가 그대로입니다.

문제 상황이:

WEB01
Circuit OPEN      ABC
Connection DOWN   ABC

WEB02
Circuit OPEN      XYZ

라면 거의 확실하게 기존 Circuit 복구 실패 + 새로운 Circuit 생성입니다.

L4 또는 기존 서버 접근 실패를 강하게 의심하면 됩니다.

반대로:

WEB01
Circuit OPEN      ABC
Circuit CLOSED    ABC

...
WEB01
Circuit OPEN      XYZ

라면 WEB01 자체에서 Circuit을 종료한 것입니다.


---

Scoped 서비스도 꼭 확인하세요

이 부분이 실제 현장에서 꽤 많이 걸립니다.

예를 들어:

builder.Services.AddScoped<SearchState>();

그리고:

public class SearchState
{
    public SearchCondition? Condition { get; set; }

    public List<MyData>? Result { get; set; }
}

컴포넌트에서:

@inject SearchState SearchState

이렇게 쓰고 있다면 Interactive Server에서 Scoped는 보통 Circuit 단위입니다.

따라서 Circuit이 새로 만들어지면:

기존 Circuit

SearchState
├─ 검색조건
└─ 조회결과

Circuit 사라짐
      ↓

새 Circuit

SearchState
├─ null
└─ null

됩니다.

그래서 코드 전체에서 이런 것을 찾아보세요.

AddScoped<...>

특히 이름이 다음과 비슷한 클래스:

State
Session
Context
Cache
Store
UserState
SearchState
PageState
GridState

이런 Scoped 서비스 안에 조회 조건이나 결과를 보관하고 있다면 현재 증상과 직접적인 연관이 있을 수 있습니다.


---

OnInitializedAsync()에서 무조건 초기화하는 코드도 확인해야 합니다

예:

protected override async Task OnInitializedAsync()
{
    SearchCondition = new();
    Data = [];

    await LoadDefaultData();
}

이 코드는 새 Circuit이 생성되는 순간 사용자가 이전에 조회했던 조건을 전부 날려버립니다.

조금 더 안전하게 하려면:

protected override async Task OnInitializedAsync()
{
    if (SearchCondition == null)
    {
        SearchCondition = new SearchCondition();
    }

    if (Data == null)
    {
        await LoadData();
    }
}

처럼 기존 상태가 있으면 활용하는 구조가 필요합니다.

그리고 .NET 10에서는 중요 상태를 [PersistentState]로 지정할 수도 있습니다. Circuit persistence는 full-page refresh가 아닌 Circuit pause/resume 시나리오에서 상태를 복원하도록 설계되어 있습니다.

@code {

    [PersistentState]
    public SearchCondition? SearchCondition { get; set; }

    [PersistentState]
    public int PageIndex { get; set; }

    [PersistentState]
    public string? SortColumn { get; set; }
}

개인적으로는 조회 결과 전체보다는:

조회 조건
페이지 번호
정렬
필터
선택 항목 ID
탭 위치

정도만 저장하는 것을 권합니다.


---

JavaScript에서 새로고침시키는 코드도 검색해보세요

의외로 이것도 확인해야 합니다.

전체 소스에서 다음 문자열을 검색해보세요.

location.reload
window.location
NavigationManager.NavigateTo
forceLoad:
forceLoad = true
NavigateTo(
Reload

특히:

Navigation.NavigateTo(
    Navigation.Uri,
    forceLoad: true);

이런 코드가 있으면 완전한 페이지 reload가 발생합니다.

.NET 10 Circuit persistence도 full-page refresh가 일어나면 기존 Circuit을 그대로 복원하는 용도가 아닙니다.

JavaScript도:

window.location.reload();

같은 코드가 timeout/reconnect 처리에 들어가 있지는 않은지 확인해야 합니다.


---

지금 프로젝트에서 제가 확인할 순서

소스 기준으로는 이 순서대로 보면 됩니다.

1. Program.cs

AddInteractiveServerComponents()

AddInteractiveServerRenderMode()

CircuitOptions

Scoped 상태 서비스



2. App.razor

Routes @rendermode="InteractiveServer"



3. 문제되는 .razor

OnInitializedAsync

OnParametersSetAsync

조회조건 초기화 코드

[PersistentState] 여부



4. State 서비스

AddScoped

조회 결과/조건 저장 여부



5. JavaScript

location.reload()



6. NavigationManager

forceLoad:true



7. CircuitHandler 로그 추가



제가 보기에는 지금 당장 Redis부터 붙이는 것보다 이 CircuitLoggingHandler를 먼저 운영 서버 3대에 넣는 게 좋습니다.

그러면 다음번에 현상이 한 번 발생했을 때 로그 몇 줄만으로

L4가 WEB01 → WEB02로 변경했는지
WEB01 Circuit만 죽었는지
AppPool이 재시작됐는지
진짜 브라우저가 reload됐는지

를 상당히 정확하게 나눌 수 있습니다.

특히 로그에 **CircuitId + Environment.MachineName + 사용자 식별값 + 현재 URL**까지 같이 남기면 원인 추적이 훨씬 쉬워집니다.