TSRX: JSX의 뒤를 잇는 TypeScript UI 언어 확장

React 컴포넌트를 쓰다 보면 이런 코드를 한 번쯤 마주치게 되는데요.
function TodoList({ isLoading, error, items }) {
return (
<ul className="todo-list">
{isLoading ? (
<Spinner />
) : error ? (
<ErrorMessage error={error} />
) : items.length > 0 ? (
items.map((item, i) => (
<li key={item.id}>
{i + 1}. {item.text}
</li>
))
) : (
<li>할 일이 없습니다</li>
)}
</ul>
);
}
로딩, 에러, 목록, 빈 상태를 구분하는 평범한 요구사항인데 정작 코드는 괄호를 세 겹 열고 삼항 연산자를 두 번 중첩합니다. 자바스크립트에는 if도 있고 for도 있는데, JSX 안에서는 이 문법들을 쓸 수 없어서 전부 표현식으로 바꿔 끼워 넣어야 하기 때문입니다. 😅
TSRX는 바로 이 지점을 건드리는 프로젝트입니다. 이번 포스팅에서는 TSRX가 어떤 문법을 제공하고, 왜 이런 문법이 지금 나왔는지 살펴보겠습니다.
TSRX는 무엇인가요?
TSRX는 TypeScript Render Extensions의 약자로, 선언적 UI를 작성하기 위한 TypeScript 언어 확장(language extension)입니다. 스스로를 “JSX의 정신적 후속작”이라고 소개하는데, 이 표현이 성격을 꽤 정확하게 담고 있습니다. JSX처럼 TypeScript 안에 마크업을 직접 끼워 넣는 사고방식은 그대로 유지하면서, JSX가 표현식 슬롯으로 밀어 넣었던 것들을 문법 차원으로 끌어올렸습니다.
만든 사람은 Dominic Gannaway입니다. Inferno를 만들었고 React와 Svelte 코어 팀을 거쳤으며, 최근에는 Ripple이라는 프레임워크를 만들고 있는데요. TSRX는 원래 Ripple의 일부였던 문법을 떼어내 프레임워크에 종속되지 않도록 다듬은 결과물입니다.
그래서 TSRX는 프레임워크가 아닙니다. 새로운 런타임이나 렌더러를 들고 오지 않고, 파서와 컴파일러만 제공합니다. .tsrx 확장자로 작성한 파일 하나가 컴파일러 설정에 따라 React, Preact, Solid, Vue, Ripple, Octane 중 원하는 타깃(target)의 코드로 변환됩니다. 기존 .ts나 .tsx 파일에서 그냥 임포트해서 쓰면 되기 때문에, 프로젝트 전체를 갈아엎지 않고 컴포넌트 하나부터 시험해 볼 수 있습니다.
현재는 활발한 베타 개발 단계이고 MIT 라이선스로 공개되어 있습니다.
설치하고 설정하기
Vite 기반 React 프로젝트라면 두 개의 패키지만 추가하면 됩니다. 타깃 프레임워크에 맞는 컴파일러 패키지와 번들러 통합 패키지입니다.
bun add @tsrx/react
bun add -D @tsrx/vite-plugin-react
npm을 쓴다면 다음과 같이 설치합니다.
npm install @tsrx/react
npm install -D @tsrx/vite-plugin-react
Solid나 Vue를 쓴다면 @tsrx/solid, @tsrx/vue처럼 타깃 이름이 붙은 패키지를 대신 설치하고, rspack이나 turbopack, Bun을 쓴다면 번들러 이름이 붙은 통합 패키지를 고르면 됩니다.
그다음 Vite 플러그인을 설정 파일에 등록합니다.
import { defineConfig } from "vite";
import tsrxReact from "@tsrx/vite-plugin-react";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [tsrxReact(), react()],
});
TSRX 플러그인이 .tsrx 파일을 먼저 JSX로 바꾸고, 그 결과를 React 플러그인이 이어받는 구조라서 순서가 중요합니다.
에디터에서 타입 검사와 자동 완성을 받으려면 tsconfig.json에 TypeScript 플러그인도 등록합니다.
{
"compilerOptions": {
"jsx": "preserve",
"plugins": [{ "name": "@tsrx/typescript-plugin" }]
}
}
여기까지 하고 VS Code 마켓플레이스에서 “TSRX for VS Code” 확장을 설치하면 진단과 포매팅이 동작합니다. Zed와 Neovim, IntelliJ, Sublime Text용 지원도 함께 제공되고, Prettier와 ESLint 플러그인도 별도로 나와 있습니다. 문법을 새로 만드는 프로젝트치고는 주변 도구를 꽤 성실하게 챙긴 편입니다.
문장 컨테이너로 시작하기
TSRX 컴포넌트는 그냥 TypeScript 함수입니다. 다만 함수 몸통을 {가 아니라 @{로 열면, 그 안은 문장 컨테이너(statement container)라고 부르는 특별한 영역이 됩니다.
export function Greeting({ name }: { name?: string }) @{
const message = name ? `안녕하세요, ${name}님` : "안녕하세요, 낯선 분";
<div className="card">
<p>{message}</p>
</div>
}
return이 없다는 점이 눈에 띄실 텐데요. 문장 컨테이너의 규칙은 단순합니다. 준비 작업에 해당하는 문장이 먼저 오고, 마지막에 렌더링할 출력 노드가 딱 하나 와야 합니다. 형제 요소를 여러 개 그리고 싶다면 프래그먼트(fragment)로 감싸면 됩니다.
@{를 붙였을 뿐인데 왜 굳이 새 문법이 필요할까 싶지만, 이 표시가 있어야 컴파일러도 사람도 “여기부터는 템플릿을 만들어 내는 영역”이라는 걸 착각 없이 알 수 있습니다. 실제로 TSRX는 초기에 이 표시 없이 평범한 함수 몸통을 쓰다가, 어디까지가 일반 로직이고 어디부터가 템플릿인지 구분이 안 된다는 피드백을 받고 @ 접두사를 도입했습니다.
문장 컨테이너는 컴포넌트 최상단에만 쓸 수 있는 게 아니라 템플릿 중간에도 열 수 있습니다. 값이 쓰이는 자리 바로 옆에서 그 값을 계산할 수 있다는 뜻입니다.
function ProductCard({ product }: { product: Product }) @{
const name = product.name.trim();
const price = money(product.price);
<article>
<h2>{name}</h2>
<footer>@{
const hasDiscount = product.discount > 0;
<>
<strong>{price}</strong>
@if (hasDiscount) {
<span>세일</span>
}
</>
}</footer>
</article>
}
JSX였다면 hasDiscount를 컴포넌트 최상단으로 올리거나 즉시 실행 함수로 감쌌을 텐데, 여기서는 <footer> 안에서 선언하고 바로 씁니다. 각 컨테이너와 제어 흐름 블록은 자기만의 스코프를 가지기 때문에 이름 충돌 걱정도 없습니다.
조건과 반복을 문법으로
이제 앞에서 봤던 삼항 연산자 지옥이 어떻게 풀리는지 볼 차례입니다. 조건 분기는 @if, @else if, @else로 씁니다.
function StatusBadge({ status }: { status: "active" | "idle" | "offline" }) @{
@if (status === "active") {
<span className="badge active">온라인</span>
} @else if (status === "idle") {
<span className="badge idle">자리 비움</span>
} @else {
<span className="badge">오프라인</span>
}
}
반복은 @for인데, 단순한 for...of에 두 가지가 더 붙습니다. index로 순번 변수를 받고, key로 항목을 식별할 값을 지정합니다. 항목이 하나도 없을 때 그릴 내용은 @empty 블록에 씁니다.
function TodoList({ items }: { items: Todo[] }) @{
const visibleItems = items.filter((item) => !item.hidden);
<ul>
@for (const item of visibleItems; index i; key item.id) {
<li>{i + 1}. {item.text}</li>
} @empty {
<li>할 일이 없습니다</li>
}
</ul>
}
React에서 리스트를 그릴 때마다 key 속성을 빠뜨려 콘솔 경고를 보는 일이 잦은데, 여기서는 키가 반복문 머리에 자리를 잡고 있어서 빠뜨리기가 오히려 어렵습니다. 빈 목록 처리도 items.length > 0 ? ... : ...을 덧붙이는 대신 @empty라는 이름이 붙은 자리를 갖게 됩니다.
분기가 셋 이상이면 @switch를 씁니다. 자바스크립트의 switch와 달리 케이스 사이에 폴스루(fall-through)가 없고, 블록 안에서 break나 return을 쓰면 문법 오류로 잡힙니다.
function StatusMessage({ status }: { status: string }) @{
@switch (status) {
@case "loading": {
<p>불러오는 중...</p>
}
@case "success": {
<p className="success">완료했습니다</p>
}
@default: {
<p>알 수 없는 상태입니다</p>
}
}
}
세 문법 모두 표현식 자리에 들어가는 값이 아니라 템플릿의 정식 구성 요소이기 때문에 컴파일러가 구조를 그대로 이해합니다. 삼항 연산자 체인을 파싱해서 분기를 추측하는 것과, 처음부터 분기로 적힌 것을 읽는 것은 도구 입장에서 차이가 큽니다.
에러 경계와 비동기도 템플릿 안에서
React에서 에러 경계(error boundary)를 만들려면 클래스 컴포넌트를 쓰거나 라이브러리를 하나 설치해야 하고, 로딩 상태를 처리하려면 <Suspense>로 감싸야 합니다. TSRX는 이 둘을 @try 문법으로 통합했습니다.
export function RetryBoundary() @{
@try {
<ComponentThatMightFail />
} @catch (e, reset) {
<div>
<p>오류: {e.message}</p>
<button onClick={() => reset()}>다시 시도</button>
</div>
}
}
@catch 블록은 에러 객체와 함께 reset 함수를 받습니다. 사용자가 다시 시도 버튼을 눌렀을 때 경계를 초기화하는 흐름을 별도 상태 없이 이 자리에서 끝낼 수 있습니다.
비동기 자식을 다룰 때는 @pending 블록이 추가됩니다.
const UserProfile = lazy(() => import("./UserProfile.tsrx"));
export function App() @{
@try {
<UserProfile id={1} />
} @pending {
<p>불러오는 중...</p>
} @catch (e) {
<p>문제가 발생했습니다</p>
}
}
React 타깃으로 컴파일하면 이 코드는 Suspense와 에러 경계 조합으로 바뀝니다. 로딩과 실패, 성공이라는 세 갈래가 한 화면에 나란히 보이니 컴포넌트를 감싸고 또 감싸던 구조보다 읽기가 수월합니다.
스타일도 같은 파일에
TSRX 컴포넌트 안에는 <style> 블록을 직접 쓸 수 있습니다.
function Card() @{
<>
<div className="card">
<h2>스코프가 적용된 제목</h2>
<p>여기 스타일은 밖으로 새지 않습니다</p>
</div>
<style>
.card {
padding: 1.5rem;
border: 1px solid #ddd;
}
h2 { color: #333; }
</style>
</>
}
이렇게 컴포넌트 안에 스타일을 두는 방식을 스코프 스타일(scoped styles)이라고 부르는데요. 컴파일러가 선택자마다 고유한 해시 클래스를 붙여 주기 때문에 스타일이 다른 컴포넌트로 새어 나가지 않습니다. 런타임 라이브러리가 필요 없다는 점에서 styled-components 같은 CSS-in-JS와 다르고, 별도 .module.css 파일을 두지 않는다는 점에서 CSS Modules와도 다릅니다. 전역으로 내보내야 하는 선택자는 :global()로 감싸면 됩니다.
스타일 블록을 변수에 담으면 클래스 맵이 되어 자식 컴포넌트로 넘길 수도 있습니다.
const articleStyles = <style>
.card { padding: 1rem; }
.title { font-weight: 700; }
</style>;
export function ArticleCard({ title }: { title: string }) @{
<article className={articleStyles.card}>
<h2 className={articleStyles.title}>{title}</h2>
</article>
}
하나의 소스, 여러 타깃
앞의 예제는 모두 React 타깃 기준으로 className을 썼는데요. Ripple과 Preact, Solid, Vue로 컴파일할 때는 class를 씁니다. 호스트 프레임워크의 관례를 TSRX가 억지로 통일하지 않고 그대로 따르기 때문입니다.
프레임워크마다 다른 부분을 문법으로 흡수한 사례도 있습니다. 지연 구조 분해(lazy destructuring)라고 부르는 &{ ... } 문법인데, 프로퍼티 접근을 실제로 읽는 시점까지 미룹니다.
function UserCard(&{ name, age }: { name: string; age: number }) {
return <div>
<h2>{name}</h2>
<p>나이: {age}</p>
</div>;
}
Solid나 Vue에서는 props를 그냥 구조 분해하면 반응성이 끊어지기 때문에 평소에 props.name처럼 매번 객체를 거쳐 접근해야 하는데요. &{ }로 받으면 코드는 구조 분해처럼 읽히면서 반응성은 유지됩니다. React 타깃에서는 이 구분이 의미가 없으므로 평범한 구조 분해로 컴파일됩니다. 프레임워크별 어색한 관례를 문법 한 겹으로 덮어 버린 셈입니다.
값이 아니라 태그 자체를 런타임에 정하고 싶을 때는 동적 요소 문법을 씁니다.
export function Panel({ as = "section", item, expanded }: PanelProps) @{
const Body = expanded ? Details : Summary;
<{as} className="panel">
<{Body} item={item} />
</{as}>
}
JSX에서는 대문자로 시작하는 변수에 컴포넌트를 담아 넘기는 관례를 썼는데, 여기서는 <{...}>라는 전용 자리가 생겼습니다. 다만 아무 표현식이나 넣을 수는 없고 식별자나 멤버 접근, 정적 문자열 정도로 제한됩니다.
이렇게 문법 하나로 여러 프레임워크를 커버한다는 발상 자체가 TSRX의 가장 야심 찬 부분이자, 가장 검증이 덜 된 부분이기도 합니다.
왜 지금 이런 문법이 나왔을까요?
TSRX 홈페이지는 스스로를 “에이전트 시대의 선언적 UI를 위한 언어 확장”이라고 소개합니다. 조금 유행어처럼 들리지만, 근거로 드는 이야기는 구체적입니다.
언어 모델은 긴 문맥에서 정보를 고르게 보지 못하고, 관련 정보가 가까이 모여 있을 때 가장 잘 동작한다는 연구가 있습니다. 컴포넌트의 구조와 제어 흐름, 스타일이 파일 세 곳에 흩어져 있으면 사람도 왔다 갔다 해야 하지만, 코드를 읽고 고치는 주체가 에이전트일 때는 그 비용이 더 직접적으로 드러납니다. TSRX가 스타일을 같은 파일에 두고 조건과 반복을 템플릿 안에 두는 이유가 여기 있습니다. 소스가 결과 화면의 모양에 최대한 가깝게 읽히도록 만들자는 것입니다.
같은 맥락에서 문법이 최근에 한 차례 크게 바뀌었습니다. 초기 TSRX에는 component라는 전용 키워드가 있었고, 제어 흐름도 @ 없이 평범한 if와 for로 썼으며, 정적 텍스트는 따옴표로 감싸야 했습니다. 그러다 2026년 5월과 6월에 걸쳐 방향을 틀었습니다. 컴포넌트 로직으로 실행되는 자바스크립트와 템플릿을 만들어 내는 자바스크립트 모양의 문법을 사람들이 잘 구분하지 못한다는 피드백 때문이었습니다. 지금의 @ 접두사는 그 경계를 눈에 보이게 만든 결과입니다.
그래서 지금 검색해서 나오는 TSRX 소개 글 중에는 예전 문법으로 쓰인 것이 섞여 있습니다. component 키워드나 따옴표로 감싼 텍스트가 보인다면 옛 문법이니, 실제로 따라 해 볼 때는 공식 문서를 기준으로 삼는 편이 안전합니다.
도입 전에 알아둘 점
기대되는 프로젝트지만 오늘 당장 실무에 넣기에는 걸리는 점이 분명합니다.
우선 활발한 베타 단계라 명세와 API가 계속 바뀝니다. 위에서 본 것처럼 몇 달 사이에 컴포넌트 선언 방식이 통째로 달라졌으니, 지금 작성한 코드도 다시 손봐야 할 가능성이 있습니다.
그리고 툴체인 의존이 큽니다. 표준 TypeScript 컴파일러만으로는 .tsrx 파일을 이해하지 못하기 때문에 빌드와 타입 검사, 에디터가 모두 TSRX를 아는 상태여야 합니다. 팀원 중 한 명이라도 확장을 설치하지 않으면 그 사람 화면에서는 파일이 온통 빨간 줄로 보일 겁니다.
생태계 규모도 아직 작습니다. 예제와 참고 자료가 적고, 역설적이게도 언어 모델을 위해 설계한 문법인데 정작 학습 데이터가 부족해서 코드 생성 품질은 JSX만 못합니다. 시간이 해결해 줄 문제이긴 하지만 지금 시점의 현실이기도 합니다.
마지막으로 취향의 문제가 있습니다. 로직과 마크업, 스타일을 한 스코프에 모으는 방향을 두고 관심사 분리를 해친다고 보는 시각이 있습니다. Svelte나 Vue의 단일 파일 컴포넌트를 편하게 써 왔다면 자연스럽겠지만, JSX와 CSS 파일을 분리해 온 팀이라면 논의가 필요한 지점입니다.
마치며
이번 포스팅에서는 TSRX가 제공하는 문장 컨테이너와 @if, @for, @switch, @try 같은 제어 흐름 문법, 스코프 스타일, 그리고 여러 프레임워크로 컴파일되는 구조를 살펴봤습니다.
핵심은 JSX가 “표현식만 넣을 수 있는 자리”라는 제약 때문에 감수해 온 것들을 문법으로 되돌려 놓았다는 점입니다. 조건은 @if로, 반복은 @for로, 에러와 로딩은 @try로 적히니 코드가 하려는 일과 코드의 생김새가 훨씬 가까워집니다. 당장 프로덕션에 넣을 물건은 아니지만, JSX 다음을 고민하는 흐름이 어디로 향하는지 보여 주는 좋은 참고 자료입니다.
설치 없이 문법만 만져 보고 싶다면 TSRX 플레이그라운드에서 코드를 입력하고 React나 Solid로 컴파일된 결과를 바로 확인해 볼 수 있습니다. 더 자세한 문법은 TSRX 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0