C#으로 서버에서 HTML을 만드는 문법이 Razor다. 2010년에 나와 15년째 쓰이는데, 그 사이에 사는 곳이 여러 번 바뀌었다.

이 글은 계보를 뼈대로 삼는다. Razor는 무엇을 풀려던 문법이고, 그 문법이 지금 어디에 살고 있는가. 그래야 “요즘은 뭘 쓰나”가 끝에 붙는 부록이 아니라 이야기의 척추가 된다.

마지막에는 성능을 본다. 인용하려던 벤치마크가 죽어 있어서 직접 쟀다.


배경 — 무엇을 대체하려 했나

Razor는 빈 자리에 나온 게 아니라 먼저 있던 방식이 불편해서 나왔다.

그전엔 Web Forms였다

2002년의 ASP.NET Web Forms는 데스크톱 앱처럼 웹을 만들자는 발상이었다. 버튼을 놓고 클릭 이벤트 핸들러를 쓰면 되고, HTML은 프레임워크가 알아서 뱉는다.

여기서 세 가지가 걸렸다.

  • HTML을 내가 통제하지 못한다. 서버 컨트롤이 만들어 내는 마크업은 내가 쓴 것이 아니다. id 속성이 자동 생성된 접두사로 바뀌어 CSS·JS를 붙이기 나빴다.
  • ViewState가 무겁다. 페이지 상태를 숨은 필드에 직렬화해 왕복시키므로 응답이 커진다.
  • 포스트백 모델이 웹의 모델과 다르다. 모든 상호작용이 자기 페이지로 POST하는 형태라 URL 하나에 여러 동작이 겹친다.

그리고 뷰를 직접 쓰려면 <% %> 구문이었는데, 여는 기호와 닫는 기호가 매번 필요해 마크업 사이사이에 코드를 넣을수록 눈이 아팠다.

ASP.NET MVC가 뷰 엔진을 필요로 했다

2009년 ASP.NET MVC가 나오면서 상황이 바뀐다. MVC는 HTML을 개발자가 직접 쓴다는 전제라 뷰 템플릿이 훨씬 자주 코드와 마크업을 오간다. 기존 방식으로는 그 왕복이 너무 시끄러웠다.

Razor는 그 왕복을 싸게 만들려고 설계된 문법이다. 2010년에 나왔다. 목표가 분명했다.

  • 전환 문자를 하나로 줄인다@ 하나. 닫는 기호가 없다.
  • 경계를 파서가 추론한다 — 코드가 어디서 끝나고 마크업이 어디서 시작하는지 사람이 매번 표시하지 않는다.
  • 기본값을 안전하게 둔다@로 출력한 값은 HTML 인코딩된다.

같은 반복문을 두 방식으로 쓰면 차이가 바로 보인다.

@* Razor *@
<ul>
@foreach (var item in Model.Items)
{
    <li>@item.Name</li>
}
</ul>

옛 방식에서는 foreach를 여는 줄과 닫는 중괄호 줄에 각각 전환 기호 쌍이 필요했다. 닫는 기호가 사라지고 줄당 잡음이 줄었다. 이게 Razor가 판 것의 전부라고 해도 크게 틀리지 않다. 새로운 실행 모델이 아니라 같은 일을 덜 시끄럽게 쓰는 문법이다.


Razor란 무엇인가

템플릿 문법이다. 서버에서 실행할 C#을 마크업 안에 섞어 쓰고, 결과 HTML을 만들어 브라우저에 보낸다. 파일 확장자는 .cshtml(컴포넌트면 .razor)이다.

규칙이 사실상 셋뿐이다.

@* 1. 식 — 값을 그 자리에 출력한다. HTML 인코딩된다 *@
<p>안녕하세요, @userName 님</p>
 
@* 2. 블록 — 중괄호 안은 C#이다 *@
@{
    var now = DateTime.Now;
    var greeting = now.Hour < 12 ? "아침" : "오후";
}
 
@* 3. 제어 구문 — @ 뒤에 바로 온다 *@
@if (items.Count == 0)
{
    <p>비어 있습니다.</p>
}

인코딩이 기본값인 게 중요하다. @userName이 스크립트 태그를 담고 있어도 태그로 해석되지 않는다. 그대로 마크업으로 내보내려면 명시적으로 그렇게 말해야 한다(@Html.Raw(...)). 안전한 쪽이 기본이고 위험한 쪽이 수고스럽다.


어떻게 구성하는가

$ dotnet new razor -n Demo -o Demo
$ dotnet build
빌드했습니다.
    경고 0개
    오류 0개

만들어지는 구조에서 눈여겨볼 것만 추리면 이렇다.

Demo/
├── Program.cs                     앱 시작점. 서비스 등록과 라우팅
├── Pages/
│   ├── _ViewImports.cshtml        모든 페이지에 공통으로 넣을 using·태그 헬퍼
│   ├── _ViewStart.cshtml          모든 페이지의 기본 레이아웃 지정
│   ├── Shared/_Layout.cshtml      공통 껍데기(head, nav, footer)
│   ├── Index.cshtml               페이지 — 마크업
│   └── Index.cshtml.cs            페이지 모델 — 그 페이지의 코드
└── wwwroot/                       정적 파일

.cshtml.cshtml.cs가 짝을 이루는 것이 지금 Razor Pages의 핵심 모양이다. 마크업과 그 페이지의 로직이 파일로 갈리되 이름으로 묶인다.

_ViewStart·_ViewImports관례로 동작하는 파일이다. 어디서도 명시적으로 부르지 않는데 폴더 안 모든 페이지에 적용된다. 처음 보면 “이 using은 어디서 왔지”로 헤매기 쉽다.


실제 코드

두 수를 받아 더하는 페이지다. 폼 POST와 모델 바인딩을 한 번에 보여 준다.

Pages/Add.cshtml

@page
@model AddModel
 
<h1>두 수 더하기</h1>
 
<form method="post">
    <input asp-for="A" />
    <input asp-for="B" />
    <button type="submit">더하기</button>
</form>
 
@if (Model.Total is not null)
{
    <p id="total">@Model.A + @Model.B = @Model.Total</p>
}

Pages/Add.cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
 
public class AddModel : PageModel
{
    [BindProperty] public int A { get; set; }
    [BindProperty] public int B { get; set; }
    public int? Total { get; private set; }
 
    public void OnGet() { }
 
    public void OnPost() => Total = A + B;
}

실제로 띄워 확인한 결과다.

$ curl -s -c cookies -D - http://localhost:5021/Add | head -1
HTTP/1.1 200 OK
    <h1>두 수 더하기</h1>        ← 렌더됨
    id="total" 블록: 0개          ← GET이라 없는 게 맞다
 
$ curl -s -b cookies -X POST http://localhost:5021/Add \
    --data-urlencode "A=17" --data-urlencode "B=25" \
    --data-urlencode "__RequestVerificationToken=..."
<p id="total">17 + 25 = 42</p>

여기서 짚을 것 셋.

  • OnGet/OnPost가 HTTP 메서드에 그대로 대응한다. 라우팅 설정이 없다. 파일 위치가 URL이고 메서드 이름이 동사다.
  • [BindProperty]가 폼 필드를 속성에 채워 준다. 문자열을 꺼내 직접 변환하는 코드가 없다. int로 선언했으니 변환도 검증도 프레임워크가 한다.
  • 위조 방지 토큰이 기본으로 걸려 있다. <form method="post">만 썼는데 숨은 토큰 필드가 자동 생성되고 POST에서 검증된다. 그래서 위 curl도 토큰과 쿠키를 같이 보내야 통과한다. 처음에 쿠키를 안 보내 실패했다 — 토큰만으로는 안 되고 쌍이 맞아야 한다.

어디로 가고 있나 — 시대별 코드

같은 “두 수 더하기”를 시대별로 써 보면 무엇이 옮겨 갔는지 보인다.

(1) ASP.NET Web Pages — 2010~2014

지금도 “Razor 구문”을 검색하면 상위에 뜨는 마이크로소프트 문서가 이 방식을 설명한다(2014년 기준이다). 파일 하나에 로직과 마크업이 다 있고, 요청이 POST인지 직접 묻고, 폼 값을 이름으로 꺼내 손수 변환한다.

@{
    var total = 0;
    if (IsPost) {
        total = Request["a"].AsInt() + Request["b"].AsInt();
    }
}
<form method="post">
    <input name="a" /><input name="b" />
    <button type="submit">더하기</button>
</form>
<p>@total</p>

이 코드는 .NET 10에서 안 돈다. IsPost, Request[...], .AsInt()는 ASP.NET Web Pages 런타임의 것이고 그 스택은 .NET Framework에서 끝났다. 비교용으로만 싣는다 — 이 글에서 유일하게 실행해 보지 못한 코드다.

(2) MVC 뷰

컨트롤러와 뷰가 갈린다. 뷰는 모델을 받아 그리기만 하고, 분기는 컨트롤러가 한다.

public class CalcController : Controller
{
    [HttpGet]  public IActionResult Add() => View();
    [HttpPost] public IActionResult Add(int a, int b) => View(model: a + b);
}

(3) Razor Pages — 지금의 기본값

앞서 본 코드다. MVC의 분리는 유지하되 컨트롤러 대신 페이지에 붙은 모델을 쓴다. 페이지 하나짜리 기능에 컨트롤러를 만들 필요가 없어진다.

(4) Blazor 컴포넌트 — 지금 가는 방향

포스트백이 사라진다. 폼을 보내고 페이지를 다시 그리는 대신, 이벤트 핸들러가 상태를 바꾸고 바뀐 부분만 갱신된다.

@page "/add"
@rendermode InteractiveServer
 
<h1>두 수 더하기</h1>
 
<input type="number" @bind="a" />
<input type="number" @bind="b" />
<button @onclick="Calculate">더하기</button>
 
@if (total is not null)
{
    <p id="total">@a + @b = @total</p>
}
 
@code {
    private int a;
    private int b;
    private int? total;
 
    private void Calculate() => total = a + b;
}

<form>이 없다. @onclick이 서버의 메서드를 직접 부르고(InteractiveServer면 웹소켓 회로로), @bind가 입력과 필드를 양방향으로 묶는다. 문법은 여전히 Razor인데 실행 모델이 다르다.

브라우저로 실제로 눌러 확인했다. 두 입력에 1725를 넣고 버튼을 누르면 페이지 새로고침 없이 17 + 25 = 42가 아래에 나타난다. 주소창은 그대로다 — POST도, 이동도 없다. Razor Pages판이 폼을 보내고 페이지를 다시 받는 것과 대비된다.

함정 하나(실측). 파일명이 Add.razor면 생성되는 클래스가 Add다. 메서드도 Add로 두면 error CS0542: 멤버 이름은 바깥쪽 형식과 같을 수 없습니다가 난다. 그래서 위 코드의 메서드는 Calculate다. 컴포넌트 클래스 이름은 파일명에서 온다.

정리

로직 위치상호작용상태
Web Pages페이지 파일 안폼 POST요청마다 없음
MVC 뷰컨트롤러폼 POST요청마다 없음
Razor Pages페이지 모델폼 POST요청마다 없음
Blazor컴포넌트 @code이벤트 핸들러컴포넌트가 들고 있다

옮겨 간 것은 문법이 아니라 실행 모델이다. @는 15년째 그대로고, 바뀐 것은 “그 코드가 언제 어디서 도는가”다.


성능 — 인용할 벤치마크가 죽었다

“ASP.NET Core는 다른 서버에 비해 얼마나 빠른가”를 물으면 거의 항상 TechEmpower Framework Benchmarks가 답으로 나온다. 그런데 지금 그 답에는 단서가 붙는다.

확인한 사실

  • 마지막 라운드는 Round 23이고 2025년 2월이다. 330개가 넘는 구현이 참여했다.
  • 2026년 3월 24일 프로젝트가 중단됐다. GitHub 저장소가 아카이브되어 읽기 전용이다.
  • 결과 서버가 지금 응답하지 않는다. 라운드 23 결과 URL에 붙어 보면 연결이 거부된다 (ECONNREFUSED). 2026-08-30 확인.

이 벤치마크는 더 이상 갱신되지 않고, 원본 수치를 확인하기도 어려운 상태다.

그래서 어떻게 쓰나

순위표를 그대로 옮기지 않는다. 두 가지 이유다.

  1. 측정이 .NET 9 시대에 멈춰 있다. 이 글의 실측은 .NET 10에서 했다. 1년 반 전 수치로 지금 성능을 말하면 어긋난다.
  2. 1차 자료를 확인할 수 없다. 여러 2차 기사에 구체적인 초당 요청 수가 돌아다니지만 원본 결과를 못 여는 상태에서 그 숫자를 옮겨 적는 것은 근거 없이 쓰는 것과 같다. 이 글에는 그 숫자를 싣지 않는다.

말할 수 있는 것은 방향까지다 — ASP.NET Core는 라운드가 거듭되는 동안 최상위권에 꾸준히 자리했고, “느린 프레임워크”라는 평판은 Web Forms 시절의 것이지 지금 것이 아니다.

그래서 직접 쟀다

다른 프레임워크와 겨루는 대신 이 글이 실제로 답해야 할 질문을 쟀다. “서버에서 HTML을 렌더링하는 비용이 얼마나 되는가.”

같은 앱 안에 네 경로를 두고 같은 데이터(20행) 를 내보내게 했다.

경로무엇req/sp50p99
/api/rows최소 API, JSON27,2000.87ms2.03ms
/RowsRazor Pages, 표 렌더링26,0601.11ms2.61ms
/Calc/RowsMVC 뷰, 같은 표25,7401.18ms2.81ms
/Calc/AddMVC 뷰 + 폼(위조 방지 토큰)20,9301.44ms3.16ms

여기서 나온 것 셋.

  • Razor 렌더링은 생각보다 싸다. 20행짜리 표를 서버에서 그려 HTML로 내보내는 것이 같은 데이터를 JSON으로 던지는 최소 API보다 약 4% 느릴 뿐이다. “서버 렌더링이라 느리다”는 직관이 이 규모에서는 성립하지 않는다.
  • MVC 뷰와 Razor Pages는 사실상 같다. 차이가 1% 남짓으로 측정 변동 폭 안에 있다. 둘 중 하나를 성능 때문에 고를 이유는 없다.
  • 폼이 있는 뷰가 눈에 띄게 느리다. 같은 MVC 파이프라인인데 약 18% 낮다. 그런데 /Calc/Add/Calc/Rows보다 마크업을 적게 그린다 — 표 20행 대신 입력 둘이다. 분량이 원인이 아니라는 뜻이고, 남는 후보는 요청마다 만드는 위조 방지 토큰이다. → 아래에서 따로 쟀다.

위조 방지 토큰의 비용

소거법으로 좁힌 것을 확정하려고 마크업이 같고 토큰만 끈 뷰를 하나 더 만들었다. asp-antiforgery="false" 한 속성 차이다.

조작이 먹었는지부터 확인했다.

/Calc/Add          200  본문 345바이트  __RequestVerificationToken 1개
/Calc/AddNoToken   200  본문 150바이트  __RequestVerificationToken 0개

앞서 먼저 도는 경로가 유리한 편향을 발견했으므로, 순서를 번갈아 네 번 쟀다.

회차먼저 돈 쪽토큰 있음토큰 없음비율
1토큰 있음27,09433,3840.81
2토큰 없음24,20733,3710.73
3토큰 있음20,97825,8500.81
4토큰 없음21,06225,6340.82

토큰 없는 쪽이 항상 빠르다. 2·4회차는 토큰 없는 쪽이 먼저 돌아 오히려 불리한 조건이었는데도 방향이 안 바뀌었다. 순서 편향이 원인이 아니라는 뜻이다.

결론: 위조 방지 토큰이 처리량을 약 19% 깎는다(비율 0.81). 요청마다 토큰을 만들고 응답에 실어 보내는 비용이다 — 본문도 345 대 150바이트로 두 배 넘게 커진다.

비율로 읽어야 하는 이유가 이 표에 있다. 절대 수치는 회차마다 흔들린다 (토큰 없는 쪽이 33,384 → 25,634). 그런데 비율은 0.81 근처로 안정적이다. 같은 조건에서 나란히 잰 두 값의 비만 쓰고, 절대값은 다른 환경에 옮기지 않는다.

끄라는 뜻이 아니다. CSRF 방어를 19%와 바꾸는 것은 대개 나쁜 거래다. 다만 폼이 없어도 되는 페이지에 폼을 두지 않는 것은 공짜로 얻는 이득이고, 읽기 전용 목록 페이지가 느릴 때 의심할 곳이 하나 늘었다는 뜻이다.

측정 방법과 한계

  • 직접 만든 부하 발생기(C#, HttpClient 32 동시)로 워밍업 2~3초 후 10초 측정. Release 빌드, Production 환경. .NET 10.0.400, 논리 코어 32, 루프백.
  • 부하 발생기와 서버가 같은 머신에서 돈다. CPU를 나눠 쓰므로 절대 수치는 실제 배치 환경의 값이 아니다. 비교에만 쓴다.
  • 첫 측정이 부풀려진다. 같은 경로를 반복해 재면 첫 회차만 30% 가까이 높고 이후 안정된다 (/api/rows 35,019 → 27,228 → 27,131). 지속 부하로 클럭이 내려가는 것으로 보인다. 위 표는 안정된 뒤의 값이다.
  • 순서 편향이 남아 있다. 배치에서 먼저 도는 경로가 유리하다. 표의 차이 중 1~2%p는 이 편향일 수 있어, 4%·1% 같은 작은 차이를 단정하지 않는다.
  • 20행은 작은 페이지다. 렌더링 분량이 커지면 비율이 달라진다. 그건 재지 않았다.

순위를 봤더라도 조심할 것 넷

벤치마크가 살아 있었더라도 이 글의 질문(“Razor 서버가 빠른가”)에 곧바로 답이 되지는 않는다.

  • 무엇을 재는 항목인지 봐야 한다. plaintext·JSON 항목은 프레임워크 오버헤드를 재는 것이지 애플리케이션 성능이 아니다. DB를 끼는 항목이 그나마 현실에 가깝다.
  • 상위권 수치는 대개 Razor를 거치지 않는다. 최소 API와 직렬화 경로로 낸 숫자이지 서버에서 HTML을 렌더링하는 경로가 아니다. “ASP.NET Core가 빠르다”와 “Razor 페이지 렌더링이 빠르다”는 다른 문장이다. → 다만 그 둘의 거리가 크다는 뜻은 아니다. 위 실측에서 렌더링 비용은 4% 남짓이었다. 경로가 다르다는 것과 비용이 크다는 것은 별개다.
  • Blazor Server는 처리량으로 비교할 대상이 아니다. 상호작용마다 웹소켓 왕복이 있어 체감 성능이 네트워크 지연에 묶인다. 같은 축에 놓고 줄 세우면 오해가 된다.
  • 벤치마크는 튜닝된 구현끼리의 경기다. 참가 코드는 그 테스트를 위해 최적화돼 있어 기본 템플릿으로 만든 앱의 성능과 다르다.

후속 벤치마크로 HttpArena가 언급된다. 다만 이건 2차 기사에서 본 것이고 직접 확인하지 않았다. 이 글에 근거로 쓰려면 먼저 확인해야 한다.


참고