TypeScript와 React에 File-based App 기반 서버를 추가한 풀스택 환경 만들기

잘 조명되지 않은 사실로, 타입스크립트는 MSBuild CI/CD 파이프라인에 통합되는 기능도 제공합니다. 이 부분도 Visual Studio 중심의 에코시스템이다보니 파묻힌 느낌이 있는데, File-based App에서도 이 레거시를 그대로 가져올 수 있어 매우 매력적인 기능으로 프로모션할 수 있는 것 같습니다.

이 방식을 사용할 경우 장점은 node_modules 같은 빌드 아티팩트 폴더가 ASP .NET 소스 폴더와는 분리된 위치에 만들어지고 관리되며, 실제로 웹 애플리케이션이 사용할 asset만 따로 wwwroot 폴더에 최종적으로 만들어지도록관리할 수 있다는 점입니다. 그러면서도, 기존의 React/TypeScript 개발 워크플로우에 익숙한 프론트엔드 개발자가 닷넷의 존재를 신경쓰지 않아도 된다는 점이 (파일 하나로 끝나는 닷넷 템플릿이이니까요) 매우 좋습니다.

구성은 다음과 같습니다.

Program.cs

#!/usr/bin/env dotnet

#:sdk Microsoft.NET.Sdk.Web
#:property TargetFramework=NET_TFM

#:package Microsoft.TypeScript.MSBuild@5.9.2
#:property PublishAot=False

// Zero-config TypeScript + React fullstack development with single-file approach
// Run with `dotnet run --no-cache Program.cs`

var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddConsole();

var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/api/hello", () => new { Message = "Hello from ASP.NET Core!" });

app.Run();

tsconfig.json

{
  "compileOnSave": true,
  "compilerOptions": {
    "noImplicitAny": false,
    "noEmitOnError": true,
    "removeComments": false,
    "sourceMap": true,
    "target": "ES5",
    "outDir": "wwwroot/assets",
    "jsx": "react",
    "module": "umd",
    "lib": ["DOM", "ES6"],
    "moduleResolution": "node",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "allowJs": true,
    "checkJs": false
  },
  "include": [
    "scripts/**/*.ts",
    "scripts/**/*.tsx",
    "scripts/**/*.js",
    "scripts/**/*.jsx"
  ],
  "exclude": [
    "node_modules",
    "wwwroot"
  ]
}

package.json

{
  "name": "ts-react-fba",
  "version": "1.0.0",
  "description": "TypeScript React FBA Test Project",
  "main": "wwwroot/assets/app.js",
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch",
    "start": "dotnet run Program.cs"
  },
  "devDependencies": {
    "@types/react": "^18.0.0",
    "@types/react-dom": "^18.0.0",
    "typescript": "^5.0.0"
  },
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

scripts/app.tsx

// React를 전역 객체로 사용
declare var React: any;
declare var ReactDOM: any;

interface Person {
   firstName: string;
   lastName: string;
}

class Student {
   fullName: string;
   constructor(public firstName: string, public middleInitial: string, public lastName: string) {
      this.fullName = firstName + " " + middleInitial + " " + lastName;
   }
}

function greeter(person: Person) {
   return "Hello, " + person.firstName + " " + person.lastName;
}

const TSButton = () => {
   const user = new Student("Fred", "M.", "Smith");
   const [apiMessage, setApiMessage] = React.useState("");
   const [isLoading, setIsLoading] = React.useState(false);

   const handleClick = () => {
      alert(greeter(user));
   };

   const handleApiCall = async () => {
      setIsLoading(true);
      try {
         const response = await fetch('/api/hello');
         if (response.ok) {
            const data = await response.text();
            setApiMessage(data);
         } else {
            setApiMessage(`Error: ${response.status} ${response.statusText}`);
         }
      } catch (error) {
         setApiMessage(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
      } finally {
         setIsLoading(false);
      }
   };

   return React.createElement("div", null,
      React.createElement("h2", null, "TypeScript + React Example"),
      React.createElement("button", { onClick: handleClick },
         "Click me to greet " + user.fullName
      ),
      React.createElement("p", null, "Greeting: " + greeter(user)),
      React.createElement("hr", null),
      React.createElement("h3", null, "API Test"),
      React.createElement("button", { 
         onClick: handleApiCall, 
         disabled: isLoading 
      }, isLoading ? "Loading..." : "Call /api/hello API"),
      React.createElement("p", null, "API Response: ", apiMessage)
   );
};

// DOM에 렌더링
window.addEventListener('DOMContentLoaded', () => {
   const container = document.getElementById('ts-example');
   if (container) {
      ReactDOM.render(React.createElement(TSButton), container);
   }
});

wwwroot/index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>FBA React Demo</title>
    </head>
    <body>
        <div id="root">
            <div id="ts-example">
                <!-- React component will be rendered here -->
            </div>
        </div>
        <!-- React and ReactDOM from CDN -->
        <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
        <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
        <script src="js/app.js"></script>
    </body>
</html>

여기서 눈여겨 볼 것은 Program.cs 파일에서 타입스크립트 컴파일러 버전과 일치하는 nuget 패키지를 사용한다는 점, 그리고 타입스크립트 빌드 관련 설정은 tsconfig.json 파일과 package.json 파일에서 관리한다는 점입니다. 그리고 타입스크립트 빌드가 항상 명시적으로 호출될 수 있게, FBA 캐시를 무시하고 dotnet run --no-cache Program.cs 를 호출해준다는 점만 잘 고려해주시면 되겠습니다.

최종적으로는 아래와 같이 풀 스택 지향성 애플리케이션을 만들어 볼 수 있습니다.

이상의 내용을 FBA 템플릿 팩으로도 만들었으며, dotnet-fba-templates/content/ts-react-fba at main · rkttu/dotnet-fba-templates · GitHub 에서 살펴보실 수 있습니다. :smiley:

8 Likes