내가 알고 있던 서버:
SSR, RSC를 접하고 생긴 의문:
서버는 말 그대로 ‘클라이언트의 요청에 응답하는 프로그램’ 을 통칭한다. 따라서 어떤 요청에 응답하는지에 따라 HTML/CSS/JS 등 정적 파일을 보내주는 프론트엔드 서버와, DB 데이터를 보내주고 연산하는 백엔드 서버로 나눌 수 있다.
백엔드 서버(API)서버는 DB 조회 및 다양한 로직을 처리하는데만 집중하도록 해야 한다. 웹 서버(프론트 서버)를 통해 단순히 정적 컨텐츠를 응답해주는 역할을 맡기면서 서버의 부하를 방지할 수 있다.
결론적으로, 우리가 create-react-app 에서 dev
명령어를 사용하거나, create-next-app에서 next dev
명령어를 사용해 프로그램을 실행시키면 작성된 소스코드를 바탕으로 개발 서버가 실행되어 각 페이지에 필요한 정적 리소스를 브라우저에 보내주는 것이다.
node_modules
의 next
패키지를 확인해 보자.// package.json
"bin": {
"next": "./dist/bin/next"
},
bin
필드를 확인하면 CLI 명령어 입력 시 어떤 파일이 실행되는지 확인할 수 있다.// ./dist/bin/next
const _commands = require("../lib/commands");
const foundCommand = Boolean(_commands.commands[args._[0]]);
const command = foundCommand ? args._[0] : defaultCommand;
...
async function main() {
const currentArgsSpec = _commandargs.commandArgs[command]();
const validatedArgs = (0, _getvalidatedargs.getValidatedArgs)(currentArgsSpec, forwardedArgs);
...
await _commands.commands[command]().then((exec)=>exec(validatedArgs)).then(()=>{
if (command === "build" || command === "experimental-compile") {
// ensure process exits after build completes so open handles/connections
// don't cause process to hang
process.exit(0);
}
});
}
main();
// ./dist/lib/commands
const commands = {
build: ()=>Promise.resolve(require("../cli/next-build").nextBuild),
start: ()=>Promise.resolve(require("../cli/next-start").nextStart),
export: ()=>Promise.resolve(require("../cli/next-export").nextExport),
dev: ()=>Promise.resolve(require("../cli/next-dev").nextDev),
lint: ()=>Promise.resolve(require("../cli/next-lint").nextLint),
telemetry: ()=>Promise.resolve(require("../cli/next-telemetry").nextTelemetry),
info: ()=>Promise.resolve(require("../cli/next-info").nextInfo),
"experimental-compile": ()=>Promise.resolve(require("../cli/next-build").nextBuild),
"experimental-generate": ()=>Promise.resolve(require("../cli/next-build").nextBuild)
};
dev
명령어 실행 시에는 프로세스가 계속 실행 중인 상태가 된다. 즉, 서버를 켜고 있다는 사실을 알 수 있다.const nextDev = async (args)=>{
...
const port = (0, _utils.getPort)(args);
const distDir = _path.default.join(dir, config.distDir ?? ".next");
...
const startServerPath = require.resolve("../server/lib/start-server");
async function startServer(options) {
return new Promise((resolve)=>{
var _options_selfSignedCertificate;
let resolved = false;
child = (0, _child_process.fork)(startServerPath, {...});
child.on("message", (msg)=>{...});
child.on("exit", async (code, signal)=>{...});
}
const runDevServer = async (reboot)=>{
try {
if (!!args["--experimental-https"]) {
...
} else {
await startServer(devServerOptions);
}
await preflight(reboot);
} catch (err) {
console.error(err);
process.exit(1);
}
};
await (0, _trace.trace)("start-dev-server").traceAsyncFn(async (_)=>{
await runDevServer(false);
});
};
start-server
모듈을 불러와서, 해당 프로세스를 fork 해서 실행시키는 것을 확인할 수 있다.// server/lib/start-server
const _http = /*#__PURE__*/ _interop_require_default(require("http"));
const _https = /*#__PURE__*/ _interop_require_default(require("https"));
...
async function startServer({...}) {
...
const server = selfSignedCertificate ? _https.default.createServer({
key: _fs.default.readFileSync(selfSignedCertificate.key),
cert: _fs.default.readFileSync(selfSignedCertificate.cert)
}, requestListener) : _http.default.createServer(requestListener);
...
await new Promise((resolve)=>{
server.on("listening", async () => {
...
resolve();
});
server.listen(port, hostname);
});
}
start-server
모듈은 찐 서버 로직이라 자세히 읽지 않았지만, http 또는 https 서버를 실행시키는 역할을 한다. (next 에는 이외에도 render-server, router-server 등의 파일이 더 있다.)결국 SSR 또는 RSC 사용 시 작업을 도맡는 ‘서버’ 란 next에 의해 구현되어 있는 웹 서버다. 백엔드 개발자들이 DB 데이터 읽기/쓰기를 위해 구현하는 웹 어플리케이션 서버와 혼동하면 안 된다.
기존에 리액트 소스 코드를 JS 스크립트로 번들에 포함시켜 클라이언트에서 렌더링하던 방식과는 다르게, SSR의 경우 소스코드를 바탕으로 HTML을 생성하는 작업을 이러한 서버에서 모두 마친 뒤 브라우저에 HTML을 전송한다는 것이고, RSC의 경우에는 HTML 이전에 컴포넌트 렌더링 자체를 서버에서 작업한다는 것이다.
리액트에서 컴포넌트란 리액트 element를 리턴하는 함수 또는 클래스다. 이 컴포넌트들로 이루어진 React tree를 해석해서 HTML로 만드는 것이 렌더링이다. 기존에는 이 렌더링을 위한 JS 번들에 컴포넌트를 해석하는 로직이 포함되어 있어 컴포넌트를 브라우저에서 그리기 시작했다면, RSC는 이미 서버에서 한 차례 해석되어 직렬화된 JSON 형태로 전달된다.
RSC를 사용하면, 브라우저에서 실행될 JS 번들의 사이즈를 크게 줄일 수 있다. 컴포넌트 소스 코드나, 컴포넌트 내부에서 사용된 외부 라이브러리 코드 등이 포함될 필요가 없다. 어차피 서버에서 전부 실행되고, 클라이언트는 직렬화된 JSON만 받기 때문이다.
또한 기존 Next.js의 SSR에서는 서버 쪽 로직을 작성하기 위해 반드시 getServerSideProps, getStaticProps 등의 함수를 사용해야 했고, 이는 page-level 에서만 export될 수 있었다. 따라서 서버 쪽 로직에서 데이터를 fetch해왔다면 컴포넌트에 넘겨주기 위해서는 props drilling이 불가피했다. 반면 RSC는 컴포넌트 코드 자체가 서버에서 해석(렌더링)되므로 별도의 함수가 필요없고, 컴포넌트 내부에서 직접 서버 리소스를 활용할 수 있게 되었다.