블레이저 웹 앱을 배포하는 방법은 크게 두 가지로 나뉩니다.
-
Asp.Net Core 앱 배포
이 시나리오에서는 Blazor Web App 으로 작성됩니다.
잘 알려진 Static SSR, Interactive Server, Interactive WebAssembly, Interactive Auto 등의 렌더링 모드와 Streaming Rendering, Pre-Rendering 등의 부차적인 서비스가 개입됩니다. -
정적 파일 배포
이 시나리오는 Blazor WebAssembly Standalone 앱으로 작성됩니다.
렌더링 모드와 Streaming Rendering, Pre-Rendering 등이 개입되지 않습니다.
대부분의 경우, 별도의 API 서버가 필요합니다. API 서버는 Asp.Net Core 앱도 되고 다른 프레임워크로 작성된 것도 상관이 없습니다.
보통 앱의 빌드 결과물을 CDN에 올려 두는 것으로 배포는 끝나고, PWA ready 를 지원해서, 브라우저를 통해 사용자 컴퓨터에 자동 설치가 가능합니다. 또한 블레이저 하이브리드 앱과 호환성이 좋습니다.
저는 아직도 이 둘 중 어떤 시나리오를 선택해야 하는 지 확실한 답을 얻지 못한 상태인데, 그 썰을 풀어 보겠습니다.
운영 호스트
1번 시나리오
단일 호스트 배포가 가능합니다. 당연히 호스트에 설치되는 앱은 Asp.Net Core 앱이며, 블레이저는 이 앱에 포함된 서비스 중에 하나가 되는데, 이 서비스가 블레이저 호스팅 모델 중 하나입니다.
2번 시나리오
거의 대부분의 경우, WASM 앱을 위한 호스트와 API 서버를 위한 호스트 2개가 필요합니다.
물론, API 서버가 정적 서빙을 통해 WASM 앱을 제공하도록 하면, 단일 호스트 배포도 가능합니다.
그러나, 0 ~ 00 Mb에 달하는 WASM 앱 파일의 용량은 대역폭 부담이 큽니다.
그래서, 앱은 저렴하고 빠른 CDN에 호스팅하고, 데이터 제공을 위한 API 서버를 별도로 배포하는 형태가 현실적 대안입니다.
1번 시나리오는 보안, 코드량 관련해서 비교적 간단하지만 반응성에 문제가 있고, 2번은 반응성은 좋지만, 별도의 API서버를 작성해야 하고, CORS, 보안 문제 등 자잘하게 신경써야 할 것들이 많습니다.
반응성 - Interactivity
이 글에서 반응성이란 요소(Html element)의 이벤트에 의해 DOM을 변경하는 것을 가리킵니다.
예를 들면, 다크 모드 테마 변경 같은 것들로, 전통적으로 javascript 가 담당하는 부분입니다.
블레이저는 javascript 를 C# 으로 대체할 수 있다고는 하나, 어느 하나라도 깔끔하게 대체하지는 않습니다.
1번 시나리오
Static SSR
Enhance 라우팅과 폼 핸들링이 있어, 폼을 통해 반응성을 제한적으로나마 구현할 수 있습니다.
하지만, css 값 하나 바꾸는 것도 Http request/response 가 결부된다는 단점이 있습니다.
물론, 전통적인 레이저 뷰(.cshtml) 보다야 페이로드가 매우 적기는 하지만 말이죠.
이 렌더링 모드에서는 반응성은 javascript 에 맡기는 것이 가장 효율적이라 생각합니다.
다만, 서버에 대한 fetch 는 폼을 통해 레이저 요소(c# 코드)가 처리하는 게 전체 솔루션 구현 측면에서 유리합니다. javascript 가 fetch를 하면 서버에 별도의 API 코드가 들어가야 하기 때문입니다.
javascript 는 폼이 제출되기 전에, UI만 변경하는 수준까지만 적용하는 것이죠.
단점이라면, javascript 를 모르면 배우거나, 별도의 인력이 필요하다는 점입니다.
혹시라도 javascript 를 알면 블레이저가 아니라 프론트는 javascript, 서버는 API로 하는게 좋지않냐는 생각을 하시는 분도 있을 것입니다.
그런데, (블레이저 Static SSR + javascript 반응성 담당)과 (javascript 프론트+ API) 는 코드 관리포인트 측면에서 차이가 많이 납니다. (블레이저를 논하는 자리라 full javascript 는 제외하겠습니다.)
예를 들어, 블레이저에서 Form 을 위한 데이터 모델은 C# 코드 하나만 있으면 되고, Razor 문법을 통해 UI에서 바로 사용할 수 있으며, 컴파일러 등 개발도구의 도움을 받을 수 있습니다.
그러나, javascript 가 프론트엔드를 담당하면, API의 C# 데이터 모델에 매칭되는 데이터 구조를 별도로 정의해야 하는데, 문제는 언어가 다르기에, 이 둘 사이의 연관성은 수동으로 처리해야 한다는 점입니다. API 엔드 포인트가 많아 질 수록 수동으로 관리해야 하는 포인트도 늘어납니다.
Interactive Server / Interactive WebAssembly
이 모드에서는 반응성은 C# 코드가 담당할 수 있지만, 레이저 요소(.razor)의 life-cycle 메서드가 두 번씩 호출되는 단점이 있습니다.
예를 들어, OnInitialied(Async) 에서 DB에 접근하는 코드가 있다면 서버에서 프리 렌더링할 때 한번 실행되고, 클라이언트 단에서 렌더링할 때 한번 더 실행됩니다.
블레이저 8.0 에서는 이러한 단점을 커버하기 위해 PersistentComponentState 서비스가 서비스 컨테이너에 포함되어 있습니다.
이 서비스는 서버에서 프리 렌더링할 때 생성된 데이터를 클라이언트에서 실행되는 코드로 보낼 때 사용합니다.
이 서비스를 이용한다고 해서, 레이저 요소의 라이프 사이클 이벤트가 두 번씩 호출되는 것이 막아지는 것은 아닙니다. 그러나, DB 접근 코드가 클라이언트 단에서 한 번 더 실행되는 것은 막을 수 있습니다.
예를 들어, 프로젝트 템플릿으로 생성된 경우, InteractiveWebAssembly 로 설정된 페이지들(.Client 프로젝트에 포함된 페이지 요소들)은 이 서비스에 기반한 AuthenticationStateProvider 를 사용합니다.
즉, 서버에서 프리 렌더링 시에 인증을 실행하고, 그 결과물만 PersistentComponentState 서비스를 통해 클라이언트 코드에게 보내는 것이죠.
클라이언트에서 실행되는 AuthenticationStateProvider 는 PersistentComponentState 에 인증 결과물이 있으면 인증이 된 것으로, 없으면 안된 것으로 처리를 합니다.
템플릿에 포함된, 클라이언트 단에서 사용하는 인증 결과물은 아래와 같습니다.
// Add properties to this class and update the server and client AuthenticationStateProviders
// to expose more information about the authenticated user to the client.
public class UserInfo
{
public required string UserId { get; set; }
public required string Email { get; set; }
}
보시다 시피, 인증 결과물에는 인증 키가 포함되지 않기 때문에, 보안 문제에 보다 자유롭습니다.
프로젝트 템플릿에 포함된 클라이언트 용 AuthenticationStateProvider :
// 이 객체는 클라이언트 측에서 사용되는 AuthenticationStateProvider 로, 사용자의 인증 상태를 결정합니다.
// 결정 방식은 서버가 페이지를 프리 렌더링할 때 저장한 (UserInfo) 데이터가 있는 지 확인하는 것입니다.
// 결정된 인증 상태 (UserInfo)는 WebAssembly application 의 생애 동안 변경되지 않습니다.
// 그래서, 사용자가 로그인/로그아웃할 때는 전체 페이지 리로드가 필요합니다.
// (서버에서 로그인/로그아웃 결과물을 다시 저장하도록)
//
// ...
internal class PersistentAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly Task<AuthenticationState> defaultUnauthenticatedTask =
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
private readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask;
// PersistentComponentState 를 주입 받음.
public PersistentAuthenticationStateProvider(PersistentComponentState state)
{
if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) || userInfo is null)
{
return;
}
Claim[] claims = [
new Claim(ClaimTypes.NameIdentifier, userInfo.UserId),
new Claim(ClaimTypes.Name, userInfo.Email),
new Claim(ClaimTypes.Email, userInfo.Email) ];
authenticationStateTask = Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims,
authenticationType: nameof(PersistentAuthenticationStateProvider)))));
}
public override Task<AuthenticationState> GetAuthenticationStateAsync() => authenticationStateTask;
}
참고로, Pre-rendering 과 PersistentComponentState 서비스는 Asp.Net Core 서버 앱에 블레이저가 호스팅 되었을 때만 동작한다는 점을 강조하고 싶습니다.
즉, 1 번 시나리오만 해당되고, 2번 시나리오는 상관이 없습니다.
각 렌더링 모드 별 단점으로는,
Interactive Server 모드는
반응성을 위해 웹소켓을 유지해야 하는 단점이 있습니다.
이는 Static SSR에서 인핸스 폼을 통해 반응성을 구현할 때 외부 통신이 필요하다는 점과 본질적으로 같습니다. 차이점이라면, 전자는 Http 컨텍스트가 생성되었다 사라지는 반면, 웹소켓은 계속 유지해야 한다는 점입니다. 아무래도 후자가 서버 부담이 더 클 수 밖에 없습니다.
물론 페이로드에서 차이가 많이 납니다. 인핸스 폼의 경우, 000 ~ 0000 bytes 정도의 페이로드가 소요되지만, 웹소켓인 경우, 대략 1/10 정도만 소요됩니다.
참고로 아래는 DOM 업데이트를 위한 통신 페이로드 용량 크기를 내림차순으로 정렬한 것입니다.
- Razor View (.cshtml) (MVC, 웹 페이지에서 사용되는)
- Razor Component (.razor) : Static SSR (Enhance Routing )
- Razor Component (.razor) : Static SSR (Enhance Routing + Enhance Form)
- Razor Component (.razor) : InteractiveServer
- Razor Component (.razor) : InteractiveWebassembly == 0
- Razor Component (.razor) : Webassembly Standalone == 0
Interactive WebAssembly 모드의 경우,
별도의 외부 통신이 필요 없어 가장 빠른 반응성을 제공합니다.
그러나, WASM은 서버앱과 별도로 존재하는 앱 콘텍스트이기 때문에 의존 객체를 쌍으로 준비해야 불편이 있습니다.
예를 들어, 서버에서 프리 렌더링 시에 어떤 서비스를 통해 데이터를 받는 경우, 이 서비스는 WASM 에서는 동작하지 않는 것이 많습니다. 따라서, WASM을 위한 서비스 구현 코드를 별도로 작성해서 WebAssembly 의 program.cs 에서 의존성 등록을 한번 더 해줘야 합니다.
참고로, WASM 내부에서 시도한 DB 커넥션은 브라우저가 허용하지 않기 때문에 EF core, Dapper, ADO 등에 기반하는 서비스 객체를 WebAssembly 프로젝트에서 사용할 수 없습니다.
즉, 반응성이란 목적은 훌륭하지만, 그만큼 손가락이 고생을 합니다.
2번 시나리오 (WebAssembly Standalone)
이 형태에서도 반응성은 C# 코드가 담당합니다.
프리렌더링 그런 거 없기 때문에 간편하고 빠릅니다.
그러나, 이 장점에 비해 로딩 타임이 너무 길고, SEO에 매우 불리합니다.
서두에 언급했듯 API를 별도로 작성해야 하고, 보안과 관련하여 자잘하게 신경써야 하는 부분도 무시할 수 없습니다.
마치며
지금까지 살펴봤듯 블레이저는 다양한 옵션을 제공하지만, 어떤 선택을 하느냐에 따라 손익(Trade-off)이 극명하게 다릅니다.
이러한 손익은 단순히 반응성 차원이 아니라, 앱의 아키텍쳐와 배포 방식과 밀접하게 연관되어 있어, 어느 하나를 선택하는 순간 코드 작성 방식도 완전히 달라지기에, 결정하는 것이 쉽지는 않습니다.
물론 배포 방식과 소프트웨어 아키텍쳐 결정 문제는 블레이저만의 문제는 아닙니다.
어떤 프레임워크를 써도 항상 고민해야 하는 부분입니다.
블레이저를 선택하실 때, 저처럼 이것 저것 기웃거리다가 선택 장애에 빠지지 마시라고 끄적여 보았습니다. 마지막으로, 저만의 가이드라인으로 아래 두 가지를 제시하고 싶습니다.
-
간편성이 우선시 되는 경우 (사이드, 시험 프로젝트 등)
Static SSR -
멀티 플랫폼 빌드가 고려되는 경우 (웹, 데스크탑, 모바일)
WebAssembly Standalone + API