본문으로 건너뛰기

Cursor에서 Remix 프레임워크 개발: 완전한 가이드

Remix development in Cursor

Remix는 React Router 기반으로 구축된 풀스택 웹 프레임워크로, 점진적 향상, 웹 표준 및 서버 측 렌더링을 강조합니다. Remix v2는 플랫 라우트, 개선된 에러 처리 및 단순화된 데이터 로딩 모델을 도입했습니다. Cursor는 VS Code 호환 기반과 통합 AI 어시스턴트를 통해 Remix 개발에 이상적인 편집기입니다 -- .tsx 라우트 파일을 이해하고, 로더 반환 타입에 대한 IntelliSense를 제공하며, 자연어 프롬프트에서 전체 라우트 모듈을 생성할 수 있습니다.

이 가이드는 Cursor에서의 완전한 Remix 개발 워크플로우를 다룹니다: 프로젝트 설정, 라우팅 규칙, 로더와 액션, 중첩 라우트, 에러 바운더리 및 AI 지원 라우트 생성.

Cursor에서 Remix 개발 설정​

사전 요구 사항​

Remix v2는 Node.js 18 이상이 필요합니다 (Node 20 LTS 권장). 설치를 확인하세요:

node --version
npm --version
팁

Cursor의 통합 터미널(Ctrl+`)은 Explorer 패널과 동기화되므로, 프로젝트를 스캐폴드하고 코드에서 이동하지 않고 즉시 생성된 파일을 편집할 수 있습니다.

Remix 확장 설치​

Cursor는 VS Code 확장 호환성을 상속합니다. Remix 개발을 위해 확장 보기(Ctrl+Shift+X)에서 다음 확장을 설치하세요:

  1. ES7 React/Redux/GraphQL/React-Native snippets -- React 컴포넌트 스캐폴딩에 유용.
  2. Tailwind CSS IntelliSense -- Tailwind를 스타일링에 사용하는 경우.
  3. Prisma -- Remix 앱이 데이터에 Prisma를 사용하는 경우.

Cursor의 내장 TypeScript 지원은 MetaFunction, LoaderFunction, ActionFunction 타입을 포함하여 Remix 타입 추론을 즉시 처리합니다.

경고

더 이상 사용되지 않는 "Remix" 구문 강조 확장을 설치하지 마세요. Remix 파일은 Cursor가 이미 기본적으로 지원하는 표준 .tsx/.ts 파일입니다. 더 이상 사용되지 않는 확장은 Cursor의 TypeScript 언어 서버와 충돌할 수 있습니다.

Remix 프로젝트 스캐폴딩​

공식 Remix CLI를 사용하여 새 프로젝트를 생성합니다:

npx create-remix@latest my-remix-app --template remix-run/default --no-install --no-git

cd my-remix-app
npm install

Cursor에서 프로젝트를 엽니다:

cursor .

기본 프로젝트 구조:

my-remix-app/
├── app/
│ ├── components/
│ ├── routes/
│ │ └── _index.tsx
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ ├── tailwind.css
│ └── entry.client.tsx
├── public/
├── vite.config.ts
├── tsconfig.json
└── package.json
팁

Cursor에게 완전한 프로젝트 스캐폴딩을 요청하세요: "TypeScript, Tailwind CSS, Prisma로 Remix v2 프로젝트를 생성해줘. vite 설정과 내비게이션 바가 있는 루트 레이아웃을 설정해줘." Cursor가 모든 구성 파일과 초기 라우트 구조를 생성합니다.

라우팅 규칙​

Remix v2는 플랫 파일 기반 라우팅을 사용합니다. app/routes/ 디렉토리의 라우트 파일은 URL 경로에 직접 매핑됩니다.

기본 라우트​

파일URL 경로
app/routes/_index.tsx/
app/routes/about.tsx/about
app/routes/blog.tsx/blog
app/routes/blog.$slug.tsx/blog/:slug

플랫 라우트로 중첩 라우트​

중첩 레이아웃은 점 표기법을 사용합니다. app/routes/blog._index.tsx라는 파일은 app/routes/blog.tsx 레이아웃 내에 렌더링됩니다:

app/routes/
├── _index.tsx → /
├── about.tsx → /about
├── blog.tsx → /blog (레이아웃)
├── blog._index.tsx → /blog (인덱스, blog 레이아웃에서 렌더링)
├── blog.$slug.tsx → /blog/:slug (blog 레이아웃에서 렌더링)
├── blog.new.tsx → /blog/new
└── blog.edit.$id.tsx → /blog/edit/:id

경로 없는 레이아웃 라우트​

URL 세그먼트를 추가하지 않는 레이아웃 라우트에는 언더스코어 접두사를 사용합니다:

app/routes/
├── _auth.tsx → 레이아웃 래퍼 (URL 세그먼트 없음)
├── _auth.login.tsx → /login (_auth 레이아웃에서 렌더링)
├── _auth.register.tsx → /register (_auth 레이아웃에서 렌더링)
└── _auth.forgot-password.tsx → /forgot-password

괄호로 라우트 그룹화​

URL에 영향을 주지 않고 라우트를 그룹화하려면 괄호를 사용합니다:

app/routes/
├── (marketing)/
│ ├── _index.tsx → /
│ ├── pricing.tsx → /pricing
│ └── features.tsx → /features
├── (app)/
│ ├── dashboard.tsx → /dashboard
│ └── settings.tsx → /settings
팁

Cursor에게 요청하세요: "공개 마케팅 페이지(홈, 가격, 소개), 인증된 앱 섹션(대시보드, 설정, 프로필) 및 개별 게시물 페이지가 있는 블로그를 포함하는 Remix 앱의 플랫 라우트 파일 구조를 생성해줘." Cursor가 올바른 명명 규칙으로 모든 필요한 파일을 생성합니다.

로더와 액션​

로더는 페이지가 렌더링되기 전에 서버에서 데이터를 가져옵니다. 액션은 폼 제출과 변경 사항을 처리합니다. 둘 다 서버에서 실행되어 데이터베이스와 API에 직접 액세스할 수 있습니다.

로더 정의​

// app/routes/blog.$slug.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getPost } from '~/models/post.server';

export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) {
return [{ title: 'Post not found' }];
}
return [
{ title: data.post.title },
{ name: 'description', content: data.post.excerpt },
];
};

export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);

if (!post) {
throw new Response('Not Found', { status: 404 });
}

return json({ post });
}

export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();

return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
경고

useLoaderData를 항상 typeof loader로 타입을 지정하세요. 이렇게 하면 컴포넌트에서 데이터 모양에 대한 완전한 IntelliSense를 제공합니다. 제네릭 없이는 Cursor가 post.title, post.content 등에 대한 정확한 자동 완성을 제공할 수 없습니다.

액션 정의​

액션은 non-GET 요청(POST, PUT, DELETE, PATCH)을 처리합니다. HTML 폼과 자연스럽게 짝을 이룹니다:

// app/routes/blog.$slug.tsx (계속)
import { Form, redirect, useActionData } from '@remix-run/react';
import type { ActionFunctionArgs } from '@remix-run/node';
import { createComment } from '~/models/comment.server';

interface ActionData {
errors?: {
name?: string;
body?: string;
};
}

export async function action({ params, request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get('name');
const body = formData.get('body');

const errors: ActionData['errors'] = {};

if (!name || typeof name !== 'string' || name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters';
}

if (!body || typeof body !== 'string' || body.trim().length < 5) {
errors.body = 'Comment must be at least 5 characters';
}

if (Object.keys(errors).length > 0) {
return json<ActionData>({ errors }, { status: 400 });
}

await createComment({
postId: params.slug!,
authorName: name as string,
body: body as string,
});

return redirect(`/blog/${params.slug}`);
}

export default function BlogPost() {
const actionData = useActionData<typeof action>();

return (
<article>
{/* 게시물 내용 */}
<section>
<h2>Leave a comment</h2>
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input type="text" name="name" id="name" />
{actionData?.errors?.name && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
<div>
<label htmlFor="body">Comment</label>
<textarea name="body" id="body" rows={4} />
{actionData?.errors?.body && (
<p className="error">{actionData.errors.body}</p>
)}
</div>
<button type="submit">Submit</button>
</Form>
</section>
</article>
);
}
팁

Cursor에게 요청하세요: "Prisma 데이터베이스에서 제품 목록을 가져오는 로더와 쿼리 및 카테고리 필터가 있는 검색 폼을 처리하는 액션이 있는 Remix 라우트를 만들어줘." Cursor가 로더, 액션 및 적절한 검증이 있는 폼 UI를 생성합니다.

라우트당 여러 액션​

intent 필드를 사용하여 단일 라우트에서 여러 액션을 처리합니다:

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get('intent');

switch (intent) {
case 'create':
return handleCreate(formData);
case 'update':
return handleUpdate(formData);
case 'delete':
return handleDelete(formData);
default:
return json({ error: 'Unknown intent' }, { status: 400 });
}
}
<Form method="post">
<input type="hidden" name="intent" value="create" />
<button type="submit">Create</button>
</Form>

<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={item.id} />
<button type="submit">Delete</button>
</Form>

중첩 라우트​

중첩 라우트를 사용하면 부모 레이아웃과 자식 콘텐츠에서 페이지를 구성할 수 있습니다. 부모 라우트는 자식 라우트가 나타나는 <Outlet />을 렌더링합니다.

Outlet으로 레이아웃 생성​

// app/routes/blog.tsx
import { Outlet, NavLink, useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { getCategories } from '~/models/category.server';

export async function loader({}: LoaderFunctionArgs) {
const categories = await getCategories();
return json({ categories });
}

export default function BlogLayout() {
const { categories } = useLoaderData<typeof loader>();

return (
<div className="blog-layout">
<nav className="sidebar">
<h2>Categories</h2>
<ul>
{categories.map((cat) => (
<li key={cat.id}>
<NavLink to={`/blog/category/${cat.slug}`}>
{cat.name}
</NavLink>
</li>
))}
</ul>
</nav>
<main className="content">
<Outlet />
</main>
</div>
);
}
경고

중첩 라우트가 빈 페이지를 렌더링하는 경우, 부모 레이아웃에 <Outlet /> 컴포넌트가 포함되어 있는지 확인하세요. 없으면 자식 라우트 콘텐츠가 렌더링될 곳이 없습니다.

중첩 데이터 로딩​

중첩 계층의 각 라우트는 로더를 병렬로 실행합니다. 이는 부모와 자식 로더가 동시에 실행되어 체감 성능을 향상시킵니다:

// app/routes/blog.$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
return json({ post });
}

// app/routes/blog.tsx (부모)
export async function loader() {
const categories = await getCategories();
return json({ categories });
}

useNavigation으로 낙관적 UI​

제출 중 낙관적 UI를 표시하기 위해 탐색 상태를 추적합니다:

import { useNavigation, Form } from '@remix-run/react';

export default function CreatePost() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';

return (
<Form method="post">
<input name="title" placeholder="Title" disabled={isSubmitting} />
<textarea name="content" placeholder="Content" disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</Form>
);
}
팁

Cursor에게 요청하세요: "내 Remix 폼에 낙관적 UI를 추가해서 제출 버튼이 로딩 상태를 표시하고 제출 중 폼 필드가 비활성화되게 해줘." Cursor가 useNavigation을 연결하고 비활성화 상태를 올바르게 처리합니다.

에러 바운더리​

Remix v2는 라우트별 에러 바운더리를 도입했습니다. 라우트에서 에러가 발생하면 해당 라우트의 에러 바운더리만 렌더링되고 나머지 페이지는 그대로 유지됩니다.

라우트 수준 에러 바운더리​

// app/routes/blog.$slug.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';

export function ErrorBoundary() {
const error = useRouteError();

if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>Post not found</h1>
<p>The blog post you are looking for does not exist.</p>
<a href="/blog">Back to blog</a>
</div>
);
}
return (
<div>
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
</div>
);
}

if (error instanceof Error) {
return (
<div>
<h1>Unexpected Error</h1>
<p>{error.message}</p>
</div>
);
}

return <h1>Unknown error occurred</h1>;
}
경고

에러 바운더리는 명명된 내보내기(export function ErrorBoundary)로 내보내야 합니다. 기본 내보내기는 라우트 컴포넌트 전용입니다. 기본으로 내보내면 Remix가 에러 바운더리로 인식하지 않습니다.

루트 에러 바운더리​

루트 레이아웃(app/root.tsx)도 버블링되는 에러를 잡기 위해 ErrorBoundary를 내보내야 합니다:

// app/root.tsx
import { Links, Meta, Outlet, Scripts, useRouteError } from '@remix-run/react';

export function ErrorBoundary() {
const error = useRouteError();

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Application Error</title>
<Links />
</head>
<body>
<main>
<h1>Something went wrong</h1>
<p>Please try refreshing the page.</p>
</main>
<Scripts />
</body>
</html>
);
}

로더에서 에러 처리​

export async function loader({ params }: LoaderFunctionArgs) {
const user = await getUser(params.id);

if (!user) {
throw new Response('User not found', {
status: 404,
statusText: 'Not Found',
});
}

const posts = await getUserPosts(user.id);

if (posts.length === 0) {
throw new Response('No posts found', {
status: 404,
});
}

return json({ user, posts });
}
팁

Cursor에게 요청하세요: "내 Remix 라우트에 포괄적인 에러 처리를 추가해줘. 리소스를 찾을 수 없을 때 404 응답, 데이터베이스 에러에 대한 500, 각 경우에 적절한 UI가 있는 라우트 수준 에러 바운더리를 포함해줘." Cursor가 로더 에러 로직과 에러 바운더리 컴포넌트를 생성합니다.

Cursor에서 AI 지원 라우트 생성​

Cursor의 AI 어시스턴트는 단일 프롬프트에서 로더, 액션, 컴포넌트, 메타 함수 및 에러 바운더리를 포함한 전체 라우트 모듈을 생성할 수 있습니다.

프롬프트에서 전체 라우트 생성​

Cursor Chat(Ctrl+L)을 열고 라우트를 설명합니다:

Create a Remix v2 route at app/routes/tasks.$id.tsx that:
1. Loads a single task from Prisma by ID in the loader
2. Has an action to update the task (title, description, status)
3. Shows a form pre-filled with the task data
4. Validates inputs: title (min 3 chars), description (min 10 chars)
5. Includes a meta function with the task title
6. Has an error boundary for 404 (task not found)
7. Redirects to /tasks after successful update

Cursor가 로더, 액션, 폼 컴포넌트, 메타 함수 및 에러 바운더리가 포함된 완전한 라우트 파일을 생성합니다. 생성된 코드는 Remix v2 API(LoaderFunctionArgs, ActionFunctionArgs)를 사용하고, 인라인 에러 메시지가 있는 적절한 폼 검증, 작업의 현재 값으로 미리 선택된 상태 드롭다운, 성공 시 /tasks로의 리디렉션을 포함합니다.

팁

Cursor에게 라우트 생성을 요청할 때 항상 Remix 버전("v2")과 플랫 라우트 파일 경로를 지정하세요. 이렇게 하면 Cursor가 올바른 API 표면(예: 더 이상 사용되지 않는 params가 있는 LoaderFunction 대신 LoaderFunctionArgs)을 사용합니다.

AI 지원 리소스 라우트​

리소스 라우트는 UI를 렌더링하지 않고 데이터를 반환합니다 -- API 엔드포인트에 완벽합니다:

Create a Remix resource route at app/routes/api.health.tsx that returns
JSON with status "ok", server uptime, and current timestamp. Support GET only.
// app/routes/api.health.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';

export async function loader({}: LoaderFunctionArgs) {
return json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
}

테스트 및 배포​

Vercel에 배포​

Vercel 어댑터를 설치합니다:

npm install @remix-run/vercel

vite.config.ts를 구성합니다:

import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
});
경고

배포 전에 npm run build && npm start로 로컬에서 프로덕션 빌드를 테스트하세요. 개발 모드에서 작동하는 서버 측 코드는 프로덕션에서 다르게 동작할 수 있습니다 -- 특히 process.env 변수나 배포 플랫폼이 지원하지 않을 수 있는 Node.js 내장 모듈에 의존하는 코드.

결론​

Remix v2와 Cursor의 조합은 강력한 풀스택 개발 환경을 만듭니다. 웹 표준, 점진적 향상 및 중첩 라우팅에 대한 프레임워크의 강조는 파일 시스템에 깔끔하게 매핑되며, Cursor의 AI 어시스턴트는 자연어 설명에서 로더, 액션, 에러 바운더리 및 폼을 포함한 전체 라우트를 스캐폴드할 수 있습니다. 플랫 라우트 규칙으로 시작하고, 타입이 지정된 로더와 검증된 액션으로 데이터 계층을 구축하고, <Outlet />으로 중첩 레이아웃을 추가하고, Cursor가 반복적인 보일러플레이트를 생성하도록 하여 애플리케이션의 비즈니스 로직에 집중하세요.