[번역] TSRX | 선언적 UI를 위한 TypeScript 언어 확장

June 8, 2026



TSRX에 대한 재고

TSRX는 다시 JSX의 방식으로 돌아가고 있으며, 특화된 구문이 일반적인 JavaScript인 척 위장하는 대신 그 자체로 특별해 보이도록 개선하고 있습니다.
Ripple은 우리가 여전히 믿고 있는 아이디어에서 시작되었습니다. 바로 UI 템플릿이 마크업과 이를 사용하는 JavaScript의 힘을 가깝게 유지하도록 하는 것입니다. TSRX가 그 작업에서 파생되어 성장함에 따라, 우리는 특히 팀, 도구 및 AI 지원 개발 전반에 걸쳐 확장되어야 하는 코드베이스에서 이 아이디어에 더 명확한 경계가 필요한 지점이 어디인지 배웠습니다.
이전 포스트인 Simplifying TSRX after feedback에서는 첫 번째 큰 수정을 다루었습니다. 컴포넌트는 일반 함수가 되었고, 반환(return)은 일반적인 반환이 되었으며, TSRX 값은 저장, 전달 및 타입 지정이 더 쉬워졌습니다. 이 변화는 TSRX를 TypeScript와 JSX에 더 가깝게 만들었습니다. 이번 포스트는 그다음 수정 사항에 관한 것입니다. TSRX의 특화된 부분들이 실수로 네이티브 JavaScript처럼 보이지 않도록 확실히 하는 것입니다.
피드백은 일관되었습니다. 사람들은 일반적인 컴포넌트 로직으로 실행되는 JavaScript와 "이 UI를 생성하라"는 의미의 JavaScript 형태를 가진 구문의 차이를 항상 구별할 수 없었습니다. 초기 제어 흐름(control flow)은 네이티브 if, switch, try, for와 너무 비슷해 보였습니다. 이로 인해 사람이 코드를 훑어보기가 더 어려워졌고, LLM이 안전하게 설명하거나 편집하기도 힘들었습니다. 커스텀 템플릿 시맨틱이 무엇을 생성하는지에 대한 명확한 표시 없이 실제 JavaScript인 것처럼 위장하고 있었기 때문입니다.
또한 정적 텍스트를 "" 또는 {''}를 통해 강제로 입력하게 하는 것이 번거롭고 직관적이지 않다는 의견도 분명히 들었습니다. JSX 텍스트는 JSX의 가장 유용한 편의 기능 중 하나이며, TSRX는 이미 잘 작동하는 것을 사람들이 다시 배우게 하는 대신 JSX와의 하위 호환성을 유지해야 합니다. 이는 구성(composition)에도 적용됩니다. 팀들은 단일 엘리먼트, 여러 엘리먼트가 포함된 프래그먼트(fragment), 또는 JSX 제어 흐름 값을 반환하기를 원하며, 각 케이스가 별개의 언어 기능처럼 느껴지지 않기를 바랍니다.
이것이 이번 업데이트의 핵심 정신입니다. TSRX는 템플릿을 표현력 있게 만드는 부분은 유지하되, 템플릿 구문이 어디서 시작되고 JavaScript가 어디서 시작되는지, 그리고 값이 시스템을 통해 어떻게 흐르는지에 대해 더 솔직해졌습니다. 결정적으로, TSRX는 원래 시작되었던 문(statement) 기반이 아니라 JSX와 같은 식(expression) 기반이 되었습니다.

다시 JSX로

이 변화 이전에는 TSRX 정적 텍스트가 템플릿 문으로 파싱되지 않도록 별도의 처리를 해야 했습니다. 즉, 동적인 내용이 전혀 없는 간단한 JSX 형태의 마크업이라도 텍스트 주위에 추가 따옴표를 붙여야 하는 경우가 많았습니다.
const Greeting = (): JSX.Element => <div>"Hello there!"</div>;

const EmptyState = ({ search }: EmptyStateProps): JSX.Element => <section>
<h2>"No results for "{search}</h2>
<p>"Try a different search term."</p>
</section>;
작동은 했지만, 일상적인 마크업이 JSX 그 자체가 아니라 JSX의 방언처럼 느껴지게 만들었습니다. 이번 업데이트에서 가장 중요한 부분은 가장 평범한 것이기도 합니다. JSX를 안다면 이제 템플릿 영역이 익숙하게 느껴질 것입니다. 정적 텍스트는 JSX 텍스트입니다. 동적 값은 표현식 중괄호를 사용합니다. 여러 자식 요소는 엘리먼트나 프래그먼트 안에 위치합니다.
const Greeting = (): JSX.Element => <div>Hello there!</div>;

const EmptyState = ({ search }: EmptyStateProps): JSX.Element => <section>
<h2>No results for {search}</h2>
<p>Try a different search term.</p>
</section>;
이로써 작지만 지속적이었던 마찰의 원인이 제거되었습니다. 이제 <div>Hello there!</div>는 JSX 사용자가 기대하는 의미를 갖습니다. {} 안에는 JSX나 React에서와 동일하게 JavaScript 표현식 구문을 작성하면 됩니다. 포매터를 호출하거나, 속성을 읽거나, 값을 선택하거나, 계산된 prop을 전달할 수 있습니다.

주석은 주석으로 유지됩니다

TSRX는 여러 줄 블록 주석을 포함하여 JSX 자식 요소 사이의 JavaScript 주석을 허용합니다. 이는 까다로운 마크업 부분 근처에 의도를 기록해 두는 데 유용하며, 텍스트로 렌더링되지 않습니다.
const Toolbar = (): JSX.Element => <nav>
// 키보드 사용자를 위해 주요 액션을 먼저 배치합니다.
<button>Save</button>
<button>Publish</button>

/*
* 보조 액션은 나중에 그룹화할 수 있습니다.
*/
<a href="/settings">Settings</a>
</nav>;

JSX 문 컨테이너 (Statement containers)

JSX에는 이미 표현식 컨테이너인 {value}가 있습니다. TSRX는 문 버전인 @{...}를 추가합니다. 이것은 여전히 표현식이므로 컴포넌트 본문, 할당된 값, 반환 값 또는 다른 엘리먼트 내부의 자식 등 JSX가 나타날 수 있는 모든 곳에 나타날 수 있습니다. 유일한 차이점은 이제 JavaScript 문을 포함할 수 있고, 마지막에 배치되었을 때 JSX 템플릿을 산출(yield)할 수 있다는 점입니다.
const App = (): JSX.Element => @{
const message = formatMessage(user);

<p>{message}</p>
};
JSX 문 컨테이너는 단지 표현식일 뿐이므로, 변수에 할당하고 다른 JSX처럼 사용할 수 있습니다.
const summary: JSX.Element = @{
const count = items.length;
const label = count === 1 ? 'item' : 'items';

<p>{count} {label}</p>
};

return <aside>{summary}</aside>;
문 컨테이너는 정확히 하나의 출력 노드로 끝납니다. 이는 JSX 엘리먼트, JSX 프래그먼트, 또는 @if와 같은 JSX 제어 흐름일 수 있습니다. 여러 엘리먼트가 필요한 경우 프래그먼트가 필요하며, 일반 JSX 텍스트도 마찬가지입니다. 해당 출력이 나타나면 컨테이너는 종료됩니다. 그 뒤에는 어떤 JavaScript 로직도 올 수 없습니다.
@는 중요한 경계입니다. 일반적인 {} 함수 본문에서 설정 문들을 작성한 뒤 바로 JSX 엘리먼트를 작성하면, 컴파일러는 본문이 @{...}가 되도록 누락된 @를 추가하라고 알려줄 것입니다.
const App = (): JSX.Element => @{
const title = getTitle();

// 텍스트와 여러 자식은 하나의 프래그먼트로 감쌉니다.
<>
Plain text goes in a fragment.
<h2>{title}</h2>
</>
};

컴포넌트 본문으로서의 문 컨테이너

대부분의 TSRX 컴포넌트는 JSX를 직접 반환할 수 있습니다. 렌더링 전에 본문에 로컬 설정이 필요한 경우, 빈 프래그먼트로 감싸는 대신 문 컨테이너를 직접 반환하세요. 프래그먼트는 여전히 실제 다중 자식 출력을 위해 존재하며, 단지 형식적인 절차(ceremony)가 아니게 된 것입니다.
type ProductCardProps = {
product: Product;
};
const ProductCard = ({ product }: ProductCardProps): JSX.Element => @{
const price = formatCurrency(product.price);
<article>
<h2>{product.name}</h2>
<p>{price}</p>
</article>
};
이름이 있는 함수(named function)도 동일한 본문 형태를 사용할 수 있습니다. 최종 템플릿 출력 전에는 일반적인 JavaScript 가드 반환(guard return)이 여전히 작동합니다.
export function UserBadge({ user }: UserBadgeProps): JSX.Element @{
if (!user) {
return <span class="muted">Signed out</span>;
}

const initials = user.name.slice(0, 2).toUpperCase();

<button title={user.name}>{initials}</button>
}
@{...}가 컴포넌트 함수의 본문일 때 최상위 수준의 조기 반환(early return)이 허용됩니다. 이는 일반적인 컴포넌트 가드 반환처럼 동작합니다. 필요할 때 일찍 종료하고, 그렇지 않으면 하나의 최종 템플릿 출력으로 문 컨테이너를 마칩니다.
export function AccountPanel({ user }: AccountPanelProps): JSX.Element @{
if (!user) {
return <a href="/login">Sign in</a>;
}

const displayName = user.name.trim();

<section>
<h2>{displayName}</h2>
<p>{user.plan}</p>
</section>
}

React 및 Preact Hook은 명시적으로 유지됩니다

TSRX는 조건부 React 또는 Preact Hook을 생성된 자식 컴포넌트로 옮겨서 안전하게 만들려고 시도하지 않습니다. Hook의 순서는 런타임 동작이며, 특히 Effect의 경우 더욱 그렇습니다. 따라서 Hook 상태를 소유하는 분기는 추론 가능한 이름을 가진 명시적인 컴포넌트여야 합니다.
const DetailsPanel = ({ open }: DetailsPanelProps): JSX.Element => @{
const [selected, setSelected] = useState('summary');

@if (open) {
<DetailsEditor />
} @else {
<button onClick={() => setSelected('details')}>Showing {selected}</button>
}
};

function DetailsEditor(): JSX.Element {
const [draft, setDraft] = useState('');

return <Editor value={draft} onInput={setDraft} />;
}

JSX 제어 흐름

제어 흐름은 이제 명시적인 JSX 구문입니다. 제어 흐름 자체가 템플릿 출력을 생성해야 할 때 @if, @for, @switch, @try를 사용하세요. 각 분기나 블록은 @{...}와 동일한 구조적 경계를 따릅니다. 즉, 선택적인 TypeScript 설정이 먼저 오고, 그 다음 단일 렌더링 템플릿 출력이 옵니다.
여기서 @는 실질적인 역할을 합니다. 그냥 for는 JavaScript 제어 흐름처럼 보이기 때문에, 사람과 AI 에이전트 모두 자연스럽게 break, continue, fallthrough 또는 분기 로컬 반환과 같은 JavaScript 규칙을 떠올리게 됩니다. 템플릿 제어 흐름은 다릅니다. 무엇을 렌더링할지 선택하는 JSX입니다. 접두사(prefix)는 누군가가 문맥에서 이를 추론하기 전에 그 경계를 가시화해 줍니다.
const ActivityPanel = ({ result }: ActivityPanelProps): JSX.Element => @{
@try {
const activity = result.value;
const latest = activity[0];

@if (latest) {
<ActivityCard activity={latest} />
} @else {
<p>No activity yet</p>
}
} @pending {
<p>Loading activity...</p>
} @catch (error) {
<p>{getErrorMessage(error)}</p>
}
};
다음은 이전의 모습입니다. 일반 루프로 작성된 동일한 리스트인데, 아이템을 건너뛰기 위해 JavaScript 스타일의 continue를 사용하는 것이 아주 자연스러워 보입니다.
const ProductList = ({ products }: ProductListProps): JSX.Element => @{
<ul>
for (const product of products) {
if (!product.available) {
continue;
}

<li>{product.name}</li>
}
</ul>
};
접두사가 붙은 루프는 이러한 모호함을 제거하고 동일한 형태를 사용합니다. 각 반복의 상단에서 설정을 수행할 수 있으며, 루프 본문은 하나의 출력 노드를 생성합니다. 일부 아이템을 렌더링하지 않아야 한다면 루프 전에 이터러블(iterable)을 필터링하세요. 리스트가 비어 있다면 템플릿 제어 흐름에 continuereturn을 몰래 넣는 대신 루프의 @empty 분기를 사용하세요.
const ProductList = ({ products }: ProductListProps): JSX.Element => @{
const visibleProducts = products.filter((product) => product.available);

<ul>
@for (const product of visibleProducts; key product.id) {
const price = formatCurrency(product.price);

<li>
<span>{product.name}</span>
<strong>{price}</strong>
</li>
} @empty {
<li>No products available</li>
}
</ul>
};
분기에 하나 이상의 렌더링된 자식이 필요한 경우, 단일 출력 노드는 단순히 프래그먼트가 됩니다. 이를 통해 분기가 비좁게 느껴지지 않으면서도 형태를 예측 가능하게 유지할 수 있습니다.
1 const Profile = ({ user }: ProfileProps): JSX.Element => @{
2 @if (user) {
3 const displayName = user.name.trim();
4
5 <>
6 <h2>{displayName}</h2>
7 <p>{user.bio}</p>
8 </>
9 } @else {
10 <a href="/login">Sign in</a>
11 }
12 };
JSX 제어 흐름은 명시적인 템플릿 구문이므로 변수에 직접 할당할 수도 있습니다. 더 이상 switch 분기를 저장하기 위해 헬퍼 함수로 감쌀 필요가 없습니다.
const StatusBadge = ({ status }: StatusBadgeProps): JSX.Element => {
const badge: JSX.Element = @switch (status) {
@case 'active': {
<strong>Active</strong>
}
@case 'idle': {
<span>Idle</span>
}
@default: {
<span>Offline</span>
}
};

return <header>{badge}</header>;
};
이 지점에서 정밀한 타입이 더욱 유용해집니다. 저장된 엘리먼트는 JSX.Element로 주석을 달 수 있고, 컴포넌트 화살표 함수는 JSX.Element를 반환한다고 선언할 수 있습니다. 타입은 특별한 컴포넌트 선언 형식에 의존하는 대신, 당신이 생성하고 있는 값 그 자체를 설명합니다.

결론

그 결과 훑어보기 쉽고, 타입을 지정하기 쉬우며, 도구가 이해하기 쉬워졌습니다. 템플릿은 여전히 렉시컬 스코프(lexical scope)를 가집니다. 로직은 여전히 함께 배치될 수 있습니다. JSX 제어 흐름은 여전히 UI가 생성되는 순서대로 읽힙니다. 하지만 특별한 부분은 이제 @로 특별하다고 표시되며, 일반적인 JavaScript는 일반적인 JavaScript로 남을 수 있게 되었습니다.
TSRX가 이러한 형태를 찾는 동안 기다려 주셔서 감사합니다. 이제 베타 단계로 넘어갈 준비가 되었으며, 주요 버그 수정을 제외하고는 더 이상의 핵심 구문 변경은 예상하지 않습니다. 이제 초점은 이 구문을 중심으로 컴파일러 강화, 런타임 동작, 에디터 도구 및 문서화로 옮겨갑니다.
0
2

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글