큰 꿈은 파편이 크다!!⚡️

ASP.NET으로 서버&리액트 프로젝트 서빙하기 본문

기타 CS

ASP.NET으로 서버&리액트 프로젝트 서빙하기

wood.forest 2024. 2. 4. 06:54

 

ASP.NET으로 서버&리액트 프로젝트 서빙하기.. 전에는?

지금까지 회사에서 CSR을 구현할 때 리액트 웹은 웹대로, ASP.NET 서버는 서버대로 따로 동작시키며 서비스를 제공했었다. 만약 같은 서버가 클라이언트+서버의 역할을 모두 할 때는 ASP.NET의 Razor page라는 문법을 사용해서 SSR 방식으로 웹페이지를 구현했고 나는 ASP.NET을 극혐하게 되었다(..)

그러던 중 같은 서버에서 포트를 다르게 해서 웹 클라이언트, 서버를 각각 돌리되 웹 부분은 리액트로 구현하자는 이야기가 나왔다. 아무래도 리액트 구현 방식에 익숙해져있기도 하고 유저 인터랙션에 강하다보니 리액트의 생산성을 Razor(따위)가 따라올 수 없다는 느낌에서였다.

결과적으로 웹 쪽은 vscode로 작업한 뒤 빌드하면, 빌드된 결과물인 클라이언트와 서버를 돌리는 건 asp.net쪽에서 할 수 있다. 배포할 때에는 vscode로 빌드 후 ASP.NET에서 Publish하면 된다.

여담으로, 사실 이런 시도가 처음은 아니다. 그때는 서버 코드의 어떤 컨트롤러에서 빌드된 채로 존재하는 파일을 html 형식으로 리턴하도록 구현하는 방식이었고, 웹 작업을 한 뒤에는 해당 폴더로 빌드 결과물을 복붙해서 동작을 확인했어야 했다.. 그래서 핫 리로드나 디버깅/테스트가 어려운 단점이 있어서 이거 못할짓이라고 생각했었는데, 여기 나름 최근에 나온 블로그 포스트를 참고하다보니 생각보다 간단했다.

 

 

 

How To

블로그에서 잘 설명해주고 있기 때문에 나는 정리하는 느낌으로만 작성해보려 한다.

 

1. Visual Studio에서 ASP.NET 웹 프로젝트(ReactAspNetCoreTemplate)를 만들고, 프로젝트 내부에 리액트 프로젝트(ClientApp)를 만든다.

 

2. Visual Studio에서는 ASP.NET+React 프로젝트도 제공해주는데, 그 프로젝트를 생성했을 때 나타나는 파일들이 필요하다고 블로그는 설명한다. (개인적으로 파일을 삭제해서 뭐가 안되는지 확인한다거나.. 해보지는 않았다.) 각 파일 내용은 아래와 같다.

 

aspnetcore-https.js

// This script sets up HTTPS for the application using the ASP.NET Core HTTPS certificate
const fs = require('fs');
const spawn = require('child_process').spawn;
const path = require('path');

const baseFolder =
  process.env.APPDATA !== undefined && process.env.APPDATA !== ''
    ? `${process.env.APPDATA}/ASP.NET/https`
    : `${process.env.HOME}/.aspnet/https`;

const certificateArg = process.argv.map(arg => arg.match(/--name=(?<value>.+)/i)).filter(Boolean)[0];
const certificateName = certificateArg ? certificateArg.groups.value : process.env.npm_package_name;

if (!certificateName) {
  console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<<app>> explicitly.')
  process.exit(-1);
}

const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);

if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
  spawn('dotnet', [
    'dev-certs',
    'https',
    '--export-path',
    certFilePath,
    '--format',
    'Pem',
    '--no-password',
  ], { stdio: 'inherit', })
  .on('exit', (code) => process.exit(code));
}

 

aspnetcore-react.js

// This script configures the .env.development.local file with additional environment variables to configure HTTPS using the ASP.NET Core
// development certificate in the webpack development proxy.

const fs = require('fs');
const path = require('path');

const baseFolder =
  process.env.APPDATA !== undefined && process.env.APPDATA !== ''
    ? `${process.env.APPDATA}/ASP.NET/https`
    : `${process.env.HOME}/.aspnet/https`;

const certificateArg = process.argv.map(arg => arg.match(/--name=(?<value>.+)/i)).filter(Boolean)[0];
const certificateName = certificateArg ? certificateArg.groups.value : process.env.npm_package_name;

if (!certificateName) {
  console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<<app>> explicitly.')
  process.exit(-1);
}

const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);

if (!fs.existsSync('.env.development.local')) {
  fs.writeFileSync(
    '.env.development.local',
`SSL_CRT_FILE=${certFilePath}
SSL_KEY_FILE=${keyFilePath}`
  );
} else {
  let lines = fs.readFileSync('.env.development.local')
    .toString()
    .split('\n');

  let hasCert, hasCertKey = false;
  for (const line of lines) {
    if (/SSL_CRT_FILE=.*/i.test(line)) {
      hasCert = true;
    }
    if (/SSL_KEY_FILE=.*/i.test(line)) {
      hasCertKey = true;
    }
  }
  if (!hasCert) {
    fs.appendFileSync(
      '.env.development.local',
      `\nSSL_CRT_FILE=${certFilePath}`
    );
  }
  if (!hasCertKey) {
    fs.appendFileSync(
      '.env.development.local',
      `\nSSL_KEY_FILE=${keyFilePath}`
    );
  }
}

 

.env

BROWSER=none

 

.env.development (포트번호, public url은 예시이다)

PORT=3333
HTTPS=true
PUBLIC_URL=/spaApp

 

 

3. package.json에 prestart 설정을 추가한다.

"scripts": {
  "prestart": "node aspnetcore-https && node aspnetcore-react",
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"
},

 

 

4. Visual Studio에서 Microsoft.AspNetCore.SpaServices.Extensions 누겟을 설치한다.

 

5. Program.cs에서 SpaStaticFiles를 추가한다. clientapp build폴더 내의 파일들이 웹에 나타나게 될 것이며, 서버 소유(?)의 파일이므로 캐싱되지 않는다.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// ↓ Add the following lines: ↓
builder.Services.AddSpaStaticFiles(configuration => {
    configuration.RootPath = "clientapp/build";
});
// ↑ these lines ↑

var app = builder.Build();

 

6. Startup.cs에서 우리가 리액트 프로젝트를 보여줄 경로를 추가한다. 또한 코드에서는, dev모드일 경우 클라이언트를 특정 url로 띄우도록 설정하고 있다. production모드일 때는 clientapp폴더 내의 코드들을 사용함을 명시한다.

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

// ↓ Add the following lines: ↓
var spaPath = "/spaApp";
if (app.Environment.IsDevelopment())
{
    app.MapWhen(y => y.Request.Path.StartsWithSegments(spaPath), client =>
    {
        client.UseSpa(spa =>
        {
            spa.UseProxyToSpaDevelopmentServer("<https://localhost:3333>");
        });
    });
}
else
{
    app.Map(new PathString(spaPath), client =>
    {
        client.UseSpaStaticFiles();
        client.UseSpa(spa => {
            spa.Options.SourcePath = "clientapp";

            // adds no-store header to index page to prevent deployment issues (prevent linking to old .js files)
            // .js and other static resources are still cached by the browser
            spa.Options.DefaultPageStaticFileOptions = new StaticFileOptions
            {
                OnPrepareResponse = ctx =>
                {
                    ResponseHeaders headers = ctx.Context.Response.GetTypedHeaders();
                    headers.CacheControl = new CacheControlHeaderValue
                    {
                        NoCache = true,
                        NoStore = true,
                        MustRevalidate = true
                    };
                }
            };
        });
    });
}
// ↑ these lines ↑

app.Run();

 

7. .csproj 파일에 아래 내용을 추가한다. 그러면 ASP.NET 프로젝트를 Publish했을 때 우리가 작업하던 리액트 프로젝트를 같이 빌드하도록 한다.

<PropertyGroup>
	<SpaRoot>clientapp\\</SpaRoot>
</PropertyGroup>

<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
	<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
	<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
	<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

	<!-- Include the newly-built files in the publish output -->
	<ItemGroup>
		<DistFiles Include="$(SpaRoot)build\\**" />
		<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
			<RelativePath>%(DistFiles.Identity)</RelativePath> <!-- Changed! -->
			<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
			<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
		</ResolvedFileToPublish>
	</ItemGroup>
</Target>

 

 

 

트러블슛: 배포 후 접속 안되는 문제

위 내용대로 하면 "http://www.example.com/spaApp"으로 접속하면 거기서부터 리액트 페이지다. 그런데 접속하면 www.example.com/static/css/main.c7f02381.css 파일을 찾을 수 없다는 오류가 나타난다. 이러한 경우, index.html에서 해당 파일 경로를 <link href=".**/spaApp**/static/css/main.c7f02381.css" rel="stylesheet"> 으로 변경하면 정상동작한다.

하지만 이걸 빌드할때마다 해야하는건 문제니까 빌드 설정을 바꿔야 하겠지만, 나는 "www.example.com/spaApp"이 아닌, " www.example.com/"부터 리액트 페이지로 시작할것이기 때문에 설정을 바꾸지는 않았다.

루트 경로부터 리액트 프로젝트를 시작하게 하려면? 아래 내용에 이어진다.

 

 

응용편: 리액트 페이지 라우팅 변경하기

“www.example.com”, 즉 루트부터 리액트 페이지를 뜨게 할려고 spaPath를 바꿨다. 경로의 마지막에 "/"가 오면 안되어서 “/”가 아닌 그냥 “”으로 작성한다.

var spaPath = "";

.env.development 에서도 동일하게 변경해줘야 한다.

PUBLIC_URL=/

 

여기에서 문제가 있다. 나는 같은 url에 /api를 붙인 경로로 api호출을 하고 있는데, "/경로부터 리액트 페이지다", 라고 선언해버리니 api호출을 할 수가 없게 되어 404오류가 생겼다.

그래서 /api로 시작하는 api path가 아닌 경우에만 리액트로 연결되도록 변경했다.

var spaPath = "";
var apiPath = "/api";

app.MapWhen(y => (!y.Request.Path.StartsWithSegments(apiPath)), client =>
{
    //...
});

 

결과는?

이렇게 설정하면 우리는 어떤 경험을 하게 될까?

개발 시 vscode로 리액트 프로젝트를 작업/디버깅하고, Visual studio로 띄운 로컬 서버와 api통신을 제대로 하는지 실시간으로 확인할 수 있다.

해당 프로젝트를 배포하면, 사용자는 www.example.com으로 접속했을 때 리액트로 만들어진 CSR 웹사이트를 누빌 수 있다. 해당 리액트 프로젝트에서의 api 호출 경로는 www.example.com/api이다.

 

Reference

 

🐍 사족

너무 오랜만에 기술 글을 작성하기도 하고 이 글의 초안이 2023년 7월에 작성되었던 만큼, 기억 속에서 잘 끄집어내서 쓸 수 있을까..? 걱정되기도 하고 글쓰기 너무 귀찮기도 했다.

그런데 결국 끝낸 것을 보니 글또와 예치금의 힘은 위대하고 자극적이다.

글 쓰기 시작하고 30~40분 정도는 몸을 뒤틀면서 썼는데 결말에 가까워지니 날개돋친듯 쓰고 있는 나를 발견했다. 앞으로는 고통스러워도 30분은 참으면서 해보자..

반응형