SubtlefloSubtlefloSubtlefloSubtleflo

나만의 SSR 프레임웍 만들기

December 10, 2024 (10m ago)3,129 views

  • 목차
    • 개요
    • renderToString 만들기
    • hydration 만들기
      • 첫 번째: 가상 돔 노드와 리얼 돔 노드 매칭하기
      • 두 번째: 가상 돔의 이벤트 바인딩 정보를 리얼 돔에 연결하기
    • Express 라우터에서 만든 리얼DOM에 Hydration 하기
    • 파일명으로 라우팅 규칙 결정하기
    • 간단한 라우팅 구현
    • SSR을 위한 초기 데이터 만들기
    • 맺음말

개요

취미로 만들고 있는 UI 라이브러리가 있습니다. 가상돔의 동작 원리를 직접 구현하며 공부해 보면 재미있겠다는 생각에서 시작했으며, 만들면서 배운 내용을 글로 공유하려는 것이 처음 계획이었습니다.

최종 목표는, 예를 들어 Next.js와 같이 SSR 프레임워크처럼 동작하는 것을 만들어 보는 것이었는데, 마침내 어제 처음 설정했던 목표를 달성했습니다. 프레임워크라 부르기엔 부족하지만, 개인이 취미로 만들어 개인적으로 사용하기엔 충분히 만족스러운 결과물이라고 생각합니다.

첫 목표를 달성하고, 중간 정리 겸 이 글을 작성하게 되었습니다.

참고로, 프로젝트를 진행하며 작성했던 관련 글들도 아래에 함께 소개합니다.

이 글은 아마도 이 시리즈의 마지막 글이 될 것 같습니다. 저의 작은 도전과 실험을 돌아보는 글이라 다른 분들께 얼마나 도움이 될지는 모르겠습니다만, 이 내용이 누군가에게 조금이라도 영감을 주거나 도움이 되길 바랍니다.

renderToString 만들기

renderToString의 목표는 ‘가상 돔 객체’를 ‘HTML 문자열’로 변환하는 것입니다.

가상돔 객체

{
    type: 'element',
    tag: 'div',
    children: [
        { type: 'text' text: '테스트' },
        { type: 'text' text: '1' },
    ]
}

html문자열

'<div>테스트1</div>';

트리 형태의 가상 돔 객체를 순회하면서 텍스트 문자열로 변환하면 됩니다.

이미 나만의 커스텀 React 제작기에서 가상 돔을 실제 돔으로 변환하는 작업을 구현 해봤기 때문에, renderToString도 비교적 간단히 완성할 수 있었습니다.

먼저, 기존에 구현된 가상 돔을 실제 돔으로 출력하는 API인 render의 코드를 복사해 새로운 파일에 붙여넣었습니다. 그런 다음, document.createElement처럼 브라우저 관련 API를 호출하는 부분을 텍스트 변환 코드로 바꾸는 작업을 진행했습니다.

기존의 vDomToDom 함수는 vDomToString으로, 그리고 돔의 자식 노드를 처리하던 vDomChildrenToDom 함수는 vDomChildrenToString으로 이름을 변경했습니다.

돔 트리가 트리 구조를 가지기 때문에, 자식 노드들을 문자열로 변환하는 vDomChildrenToString 함수에는 필연적으로 재귀 호출이 포함됩니다.

결국 renderToString은 아래 3개의 함수로 완성되었습니다. 코드링크:

  1. vDomToString: 가상 돔을 문자열로 변환하는 핵심 함수
  2. vDomChildrenToString: 자식 노드의 가상 돔을 재귀적으로 탐색하며 vDomToString을 적용하는 함수
  3. makeProp: 돔의 속성(attribute)을 처리하는 함수

구현 중 예상치 못한 문제가 하나 있었는데, 바로 셀프 클로징 태그 처리였습니다. 예를 들어, <img /><input /> 같은 태그는 자식 노드를 가질 수 없습니다. 이 부분을 브라우저 API로 판별할 방법이 있는지 찾아봤지만, 마땅한 API는 없었습니다. 대신, Preact 같은 라이브러리에서는 미리 정의된 셀프 클로징 태그 목록을 사용해 처리하고 있었습니다. 저도 이 방식을 참고하여 코드를 작성했습니다.

아래는 vDomToString 함수 코드입니다.

function vDomToString(vDom: VDom) {
    let element = "";
    const { type, tag, text, props, children = [] } = vDom;
    const isVirtualType = checkVirtualType(type);

    if (isVirtualType) {
        element = vDomChildrenToDom(children, element);
    } else if (type === "element" && tag) {
        const innerHTML = props?.innerHTML;

        if (innerHTML) {
            element = `<${tag}${makeProp(props)}>${innerHTML}</${tag}>`;
        } else if (isAllowSelfClose(tag) && !children.length) {
            element = `<${tag}${makeProp(props)} />`;
        } else {
            element = `<${tag}${makeProp(props)}>`;
            element = vDomChildrenToDom(children, element);
            element = `${element}</${tag}>`;
        }
    } else if (type === "text" && checkExisty(text)) {
        element = String(text);
        element = vDomChildrenToDom(children, element);
    } else {
        throw new Error(
            "An attempt was made to render an abnormal virtual DOM object."
        );
    }

    return element;
}

여기서 isAllowSelfClose(tag) 판별식이 참인 경우, 해당 태그는 스스로 닫히는 방식으로 처리됩니다.

코드가 간단히 끝난 것 같아 혹시 놓친 부분이 있을까 싶어 React와 Preact의 코드를 살펴보았습니다. 훨씬 많은 예외처리와 기능이 있고 더 복잡하네요. 가장큰 차이는, React에서는 최신 API인 renderToPipeableStream을 통해 스트리밍 SSR을 더욱 강력하게 지원하고 있었습니다. Preact에서도 이름은 다르지만 유사한 API를 구현한 코드가 확인되었습니다.

renderToPipeableStreamSuspense와의 통합을 통해 HTML 문자열을 스트리밍 방식으로 클라이언트에 전달할 수 있도록 설계되었습니다. 이를 통해 페이지 로딩 속도가 빨라지고, 사용자 경험이 개선됩니다.

다만, 제가 만든 Lithent에는 Suspense와 같은 비동기 렌더링 기능이 없기 때문에, 이 기능은 목표했던 구현 범위를 초과한다고 판단하여 보류하기로 했습니다.

hydration 만들기

renderToString을 완성하고 나니, 빨리 서버와 연결해 실제 HTTP 요청을 통해 렌더링을 테스트해 보고 싶어졌습니다. 하지만 hydration 없이 진행하면 반쪽짜리 구현이 될 것 같아, 조금 더 참고 먼저 hydration을 구현하기로 결정했습니다.

hydration이란 서버에서 생성된 HTML을 “재활용”하면서, 클라이언트에서 해당 실제 돔(리얼 돔)과 가상 돔의 상태 및 이벤트 연결을 복원하는 작업입니다. 다시 말해, 정적인 페이지에 가상 돔의 상태 변화와 이벤트가 동작하도록 생명을 불어넣는 과정이죠.

저는 hydration이 다음 두 가지 역할을 한다고 생각하며 접근했습니다.

  1. 리얼 돔과 가상 돔 매칭
    • 리얼 돔의 트리를 순회하며, 가상 돔과 트리 구조에서 위치가 일치하는 노드들을 연결합니다.
  2. 이벤트 바인딩 복원
    • 가상 돔에 정의된 이벤트 바인딩 정보를 리얼 돔에 다시 연결합니다. 이 두 가지 작업은 순차적으로 이루어져야 합니다. 먼저, 리얼 돔 노드와 가상 돔 노드가 정확히 매칭되어야만, 그 다음으로 이벤트 리스너를 제대로 등록할 수 있기 때문입니다.

첫 번째: 리얼 돔과 가상 돔 매칭

일단 첫 번째 작업이 완료되어야 두 번째 작업이 가능합니다. 리얼 돔 노드와 가상 돔 노드가 잘 매칭이 되어야 이벤트 리스너를 등록할 수 있으니까요.

가상 돔은 각각의 가상 돔 노드에 해당하는 리얼 돔 참조를 속성으로 들고 있습니다. Preact의 경우는 노드의 ._dom 속성으로 참조 가능하고, 제가 만든 vDom의 경우에는 .el 속성을 가지고 있죠. 첫 번째 처리는 비어있는 가상 돔 노드에 실제 리얼 돔 참조를 연결해주는 일입니다.

이것도 처음 생각한 개념상으로만 본다면 그리 어려운 작업은 아닙니다. 그냥 리얼 돔 트리를 순회하면서 같은 위치에 가상 돔 노드를 매칭시켜 주는 작업이기 때문이죠.

간단하게 핵심 내요을 코드로 표현하면 아래처럼 표현할 수 있습니다. 실제로 제가 hydration 구현을 처음 시작할 때 시작한 코드가 아래 코드입니다.

function hydration(realDom, virtualDom) {
  Array.from(realDom.childNodes).forEach((realChildNode, index) => {
    const virtualChildNode = virtualDom[index];

    virtualChildNode.el = realChildNode;
    // ...
  });
}

위 기본 코드에서 제가 구현한 vDom 가상 돔의 다섯 가지 타입, null 타입 노드, Fragment 타입 노드, 실제 div 태그와 같은 엘리먼트 노드, 텍스트 타입 노드, 그리고 반복문 처리에 사용하는 loop 타입 노드의 특성을 고려하여 코드를 추가하면 완성됩니다.

하지만, 항상 예상치 못한 어려운 지점이 있기 마련이죠. hydration에서는 특히 텍스트 노드 처리가 까다로운 부분이었습니다.

텍스트 노드 처리하기

예를 들어 <div>텍스트1</div>라는 리얼 돔이 있다고 가정해 보겠습니다.

실제 가상 돔에서는 텍스트를 하나의 덩어리로 처리해 [{ type: ‘text’, text: ‘텍스트1’ }]처럼 표현될 수도 있습니다. 그러나, 문자열의 일부(예: ‘1’)가 특정 상태값에 묶여 있다면, [{ type: ‘text’, text: ‘텍스트’ }, { type: ‘text’, text: ‘1’ }]처럼 두 개의 덩어리로 나뉘어 있을 수도 있습니다.

확실한 점은 리얼 돔에서 연속된 텍스트는 하나의 텍스트 노드로 취급된다는 것입니다. 예를 들어 <div>텍스<br>트1</div>라는 리얼 돔에서는 독립된 텍스트 노드는 텍스1 두 개입니다.

이런 패턴을 기반으로, 이 문제를 가장 간단하게 해결할 방법을 고민했습니다.

가장 간단한 해결 방법

리얼 돔을 순회하면서 텍스트 노드를 만나면, 가상 돔의 텍스트 노드 정보를 바탕으로 document.createTextNode를 사용해 새로 만들어 리얼 돔 노드를 교체해 주는 것입니다.

이 방식은 가상 돔 정보를 기반으로 리얼 돔을 직접 다시 생성하기 때문에, 노드 매칭을 정확하게 보장할 수 있습니다.

추가로 필요한 작업은 가상 돔의 연속된 텍스트 노드를 하나로 합쳐서 리얼 돔과 교체하는 것입니다. 이를 위해 Fragment를 사용하여 연속되는 텍스트 노드를 처리하고, 리얼 돔에서 올바르게 대체하도록 합니다.

아래는 이를 구현한 코드입니다.

if (vDomItem.type === 'text' && nodeType === 3) {
  const { tFragment, nIndex } = processConsecutiveTextNodes(vDomList);
  index = nIndex;
  realDomItem.parentElement.replaceChild(tFragment, realDomItem);
}

위 코드에서 processConsecutiveTextNodes 함수는 연속된 텍스트 노드들을 탐색해서 엘리먼트를 만들어 tFragment로 리턴해줍니다. 그리고 리얼돔의 텍스트와 교체하죠.

이런식으로 처리하면 일일이 텍스트 비교를 하지 않고도 쉽게 매칭 가능하지만 실제로 텍스트 노드 교체 비용이 일어나는게 단점입니다.

nIndex 값이 역할은 연속된 텍스트 노드 처리때문에 리얼돔트리와 가상돔 트리의 돔 트리의 인덱스 정보를 맞춰주기 위함입니다.

두 번째: 이벤트 바인딩 복원

첫 번째 작업이 성공적으로 완료된 덕분에, 두 번째 작업은 이미 90% 정도 완성된 상태였습니다. 실제로, render 메서드에서 리얼 돔을 생성하는 부분을 제외하면 나머지가 바로 이벤트 바인딩 처리와 관련된 코드였기 때문입니다.

render 메서드의 세 번째 인자에 isHydration 플래그(render(<Component />, document.getElementById(‘root’), isHydration))를 추가하고, isHydartion 플래그가 있을 경우 document.createElement 로 실제돔을 만드는 부분을 모두 예외 처리하는 방식으로 구현을 완성했습니다.

완성된 hydration 코드는 (여기)에서 확인 가능합니다.

express 라우터에서 renderToString 사용하기

드디어 Express 서버에 제가 만든 renderToString과 hydration 함수를 연결할 수 있게 되었습니다.

아래 코드는 Express 라우터에 renderToString을 붙인 예시 코드입니다.

import { h } from 'lithent';
import { renderToString } from 'lithent/ssr';


app.get(`/${expressPath.replace(/_/g, ':')}`, async (req, res, next) => {
  const { default: Layout } = await vite.ssrLoadModule(`@/layout`);
  const { default: Page, preload } = await vite.ssrLoadModule(
    `@/pages/${pageKey}`
  );

  let pageString = renderToString(
    h(Layout, { page: Page })
  );
  pageString = await vite.transformIndexHtml(req.originalUrl, pageString);

  res.status(200).set({ 'Content-Type': 'text/html' }).end(`<!doctype html>${pageString}`);
}

PageLayout은 가상돔 컴포넌트를 구현한 모듈입니다. Node.js 코드에서 JSX를 사용하려면 추가적인 트랜스컴파일러를 사용하는 불필요한 복잡함이 있기 때문에, 저는 h 함수를 직접 사용하여 구현했습니다.

위의 예시에서 모듈을 불러올 때 vite.ssrLoadModule api를 사용하여 Vite를 통해 불러옵니다. 이 API를 사용하면 여러 가지 이점이 있는데, 가장 큰 이유는 서버 환경에서 모듈을 동적으로 가져올 수 있기 때문입니다. 페이지 모듈이 Express 라우터에 의해 동적으로 불러와져야 하기 때문에 이 방법을 선택했습니다.

또한, vite.transformIndexHtml은 생성된 HTML을 Vite의 플러그인 체인과 통합하는 역할을 합니다. 개발 모드의 HMR(Hot Module Replacement)을 위한 코드가 문서에 자동으로 삽입되고, TailwindCSS와 함께 사용할 때 Vite 플러그인을 통해 동적으로 CSS 스타일이 추가됩니다.

제 설명에서 눈치채셨겠지만, 위 코드는 개발 모드에서 실행되는 예시 코드입니다.

프러덕션 모드에서는 아래와 같은 코드가 실행됩니다. (이해하기 쉽게 설명하기 위한 간략화된 코드입니다.)

import { h } from 'lithent';
import { renderToString } from 'lithent/ssr';

app.get(`/${expressPath.replace(/_/g, ':')}`, async (req, res, next) => {
  const modulePath = path.resolve(__dirname, `dist/pages/${pageKey}-Cp61x3Tn.js`));
  const layoutPath = path.resolve(__dirname, 'dist/layout.ts-CKNQwccs.js'));

  const module = await import(modulePath);
  const layoutModule = await import(layoutPath);

  const Page = module.default;
  const preload = module.preload;

  const Layout = layoutModule.default;

  let pageString = renderToString(
    h(Layout, { page: Page })
  );

  const cssResourcePath = '/dist/style-DM0Cv7eB.css';
  return pageString.replace(
    '</head>',
    `<link rel="stylesheet" href="/${cssResourcePath}"></head>`
  );

  res.status(200).set({ 'Content-Type': 'text/html' }).end(`<!doctype html>${pageString}`);
}

개발 모드에서는 vite.ssrLoadModule을 사용하여 모듈을 동적으로 불러왔지만, 프로덕션 모드에서는 이미 빌드된 파일을 불러옵니다. CSS도 빌드된 압축된 버전을 사용하여 head 영역에 삽입합니다.

Express 라우터에서 만든 리얼DOM에 Hydration 하기

하지만 앞서 말했듯이, hydration이 이루어지지 않으면 그저 반쪽짜리 구현에 불과합니다.

그래서 hydration 코드를 포함한 load.ts를 구현하여, 아래와 같이 Express 서버가 반환하는 HTML 문자열에 스크립트 실행 부분을 추가했습니다.

//...
pageString = appHtmlOrig.replace(
  '</body>',
  `<script type="module">
    import load from '/src/base/load';
    load('${pageKey}');
   </script></body>`
);
//...

res
  .status(200)
  .set({ 'Content-Type': 'text/html' })
  .end(`<!doctype html>${pageString}`);

HTML 문자열의 <body> 태그 바로 아래에 아래와 같이 추가하면, 클라이언트에서 load 함수가 바로 실행됩니다.

그리고 아래 코드는 load 함수의 구현입니다. 이 함수는 hydration을 수행하는 역할을 합니다.

import { hydration } from 'lithent/ssr';

const pageModules = import.meta.glob('../pages/*.tsx');

export default async function load(key) {
  const res = await pageModules[`../pages/${key}`]();

  // const Page = h(res!.default as TagFunction, props) as VDom;
  const LayoutVDom = h(Layout, { page: res.default });
  hydration(LayoutVDom, document.documentElement);
}

위의 import.meta.glob(‘../pages/*.tsx’)는 동적으로 import해야 하는 컴포넌트들을 미리 준비할 수 있도록 트랜스파일러에게 알려주는 역할을 합니다. 이 방식은 Vite에서 제공하는 기능으로, 특정 패턴에 맞는 파일들을 동적으로 불러오게 해줍니다.

파일명으로 라우팅 규칙 결정하기

라우팅은 /src/pages/ 디렉토리 아래의 파일 이름에 따라 결정됩니다.

  • src/pages/index.tsx는 루트 URL인 http://localhost:3000에 매핑됩니다.
  • src/pages/one.tsxhttp://localhost:3000/one에 매핑됩니다.

동적 세그먼트는 파일 이름에 언더바(_)를 사용하여 정의됩니다.

  • src/pages/index._type.tsxhttp://localhost:3000/:type과 같은 동적 경로에 매핑됩니다.
  • src/pages/one._type._name.tsxhttp://localhost:3000/one/:type/:name에 매핑됩니다.

구현은 /src/pages/ 폴더 내의 모든 파일을 읽어 filePaths 변수에 담은 뒤, 이를 루프를 돌면서 express 라우터에 매칭시킵니다. express의 라우팅 규칙에서는 동적 세그먼트를 세미콜론(:)을 사용해 정의하므로, 파일명에 사용된 밑줄(_)을 세미콜론(:)으로 치환해줍니다.

filePaths.forEach(path => {
  app.get(`/${path.replace(/_/g, ':')}`, async (req, res, next) => {
    // ...
    res
      .status(200)
      .set({ 'Content-Type': 'text/html' })
      .end(`<!doctype html>${pageString}`);
  });
});

간단한 라우팅 구현

제가 만든 가상돔 라이브러리는 전용 라우터를 구현하지 않았기 때문에, 이 글에 주제를 완성하기 위해서는 간단한 라우팅 구현이 필요했습니다.

사용자가 페이지에 처음 진입할 때는 SSR(서버 사이드 렌더링) 방식으로 페이지가 렌더링되지만, 이후 navigate(‘/path’) api를 사용해 페이지를 이동할 때는 CSR(클라이언트 사이드 렌더링) 방식으로 동작합니다.

navigate로 페이지 정보가 변경되면 pushState가 실행되고, 변경된 페이지 경로가 반응형 데이터에 업데이트됩니다. 그 후, loadPage 함수가 트리거되어 가상돔으로부터 페이지를 다시 렌더링합니다.

아래는 간략화한 loadPage 함수입니다.

async function loadPage(dynamicPath: string) {
  const orgPage = `../pages${dynamicPath === '/' ? '/index' : dynamicPath}.tsx`;
  const { key, params } = findPageModlueKey( Object.keys(pageModules), orgPage);
  const vDom = routeRef.rVDom;

  if (key && pageModules[key]) {
    routeRef.loading = true;
    const res = await pageModules[key]();
    consrt Page = res.default;

    vDom.compProps.page = Page;
    vDom.compProps.query = query;
    vDom.compProps.params = params;
    routeRef.renew();
    routeRef.loading = false;
  }
}

현재 보여지고 있는 페이지 정보는 반응형 상태에 저장되어 있으며, routeRef는 해당 상태를 변경하고 참조할 수 있는 객체입니다. routeRef.rVDom은 현재 페이지를 렌더링한 가상돔의 루트 객체입니다.

이 루트 객체에 변경된 페이지 정보를 수동으로 업데이트한 후, routeRef.renew()를 호출하여 루트 컴포넌트를 업데이트하면 페이지가 변경됩니다.

const res = await pageModules[key]()로 페이지 경로에 해당하는 페이지 컴포넌트를 동적으로 불러오기 때문에 로딩이 발생합니다. 따라서 routeRef.loading 값을 사용해 로딩 상태를 처리합니다.

routeRef는 UI와 반응형으로 동작하는 상태 관리 객체로, 값이 변경되면 UI에 동적으로 반영됩니다.

SSR을 위한 초기 데이터 만들기

SSR을 위한 초기 데이터를 가져오는 과정은 Remixjs의 loader API의 사용자 인터페이스를 흉내낸 방식으로 구현했습니다. 각 페이지 라우팅에 해당하는 컴포넌트에서 preload라는 함수를 정의하고 이를 export하면, renderToString을 실행하기 전에 Node 서버에서 먼저 preload 함수를 호출하여 데이터를 준비합니다.

아래 Express 라우팅 코드에서는 preload로 준비된 값을 globalThis.pagedata에 할당하여, 이후 SSR을 위한 데이터로 사용할 수 있습니다.

import { h } from 'lithent';
import { renderToString } from 'lithent/ssr';

app.get(`/${expressPath.replace(/_/g, ':')}`, async (req, res, next) => {
  // ... 코드생략 ...
  const { default: Page, preload } = await this.vite.ssrLoadModule(
    `@/pages/${key}`
  );
  const  preloadData = await preload(this.props);

  globalThis.pagedata = preloadData;
  // ... 코드생략 ...
  res.status(200).set({ 'Content-Type': 'text/html' }).end(`<!doctype html>${pageString}`);
}

그리고 아래와 같이 페이지 컴포넌트에서 globalThis에서 preload 데이터를 참조할 수 있는 getPreloadData 함수를 통해 데이터를 가져와 렌더링에 사용할 수 있도록 했습니다.

export const preload = async () => {
  const data = await fetchTypeList();

  return { layout: { title: 'EXPRESS-LITHENT' }, data };
};

const Index = mount(() => {
  const preload = getPreloadData<{ data: { name: string; url: string }[] }>();

  return () => (
    <div class="container px-8 mx-auto xl:px-5 max-w-screen-lg py-5 lg:py-8">
      <div class="mt-10 grid gap-10 grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:gap-10 xl:grid-cols-3 ">
        {/* 내용 */}
      </div>
    </div>
  );
});

export default Index;

globalThis를 사용하는 방식이 마음에 들지 않지만, 이 부분은 실제로 사용하면서 점차 개선해 나갈 계획입니다. 이 프로젝트의 주요 목적은 빠르게 프로토타이핑을 해보는 것이기 때문입니다.

렌더링 후 페이지 이동이 CSR로 동작할 때도 동일하게 preload 함수를 import하여 사용할 수 있기 때문에, preload 함수는 서버와 클라이언트 모두에서 사용할 수 있도록 구현해야 합니다.

이 구현을 진행하면서, React 18부터 도입된 서버 컴포넌트와 일반 컴포넌트의 차이점을 좀 더 명확하게 이해하게 되었습니다. 서버 컴포넌트는 서버에서만 불러들인 후 실행되므로 클라이언트에서는 어떻게 동작할지 신경 쓸 필요가 없습니다. 또한, hydration을 위해 클라이언트에서 컴포넌트를 다시 import하여 처리할 필요가 없기 때문에, 자연스럽게 클라이언트에 필요한 자바스크립트 리소스의 양도 줄어듭니다.

맺으며

대략 여기까지가 핵심 구현 과정입니다.

물론 그 외에도 디테일한 부분들이 있지만, 너무 길어지면 글의 집중도가 떨어질 수 있을 것 같아 이 정도에서 마무리하려 합니다.

더 궁금하시면 아래의 프로젝트 제너레이터를 통해 직접 설치해보시고, 코드를 확인해보시기를 권장합니다. 이 글에서 핵심적인 부분을 다룬 만큼, 코드는 누구나 쉽게 이해하실 수 있을 것 같습니다.

npx create-lithent-ssr@latest

설치를 위한 간략한 설명은 README에서 확인하실 수 있습니다.

글을 쓰면서 이 글이 저만의 회고가 아닌 다른 사람들에게 유의미한 도움이 될까 하는 의문이 들어 포기할까도 했지만, 누군가에게 도움이 될 것이라는 믿음을 포기하지 않고 끝까지 써봤습니다. 이 글이 누군가에게 조금이라도 도움이 되기를 바랍니다.