Scrabbit은 스크랩북처럼 여러 웹사이트에서 취사선택한 정보만 모아 정적인 웹 페이지로 배포해주는 Next.js 기반 웹 애플리케이션입니다.
| 사용예시) 여러 페이지를 이동하며 컨텐츠 중 원하는 내용만 선택하여 페이지 구성 |
|---|
![]() |
이 프로젝트의 시작은 메모앱인 Notion의 페이지 공유 기능인 "게시하기"에서 영감을 받아, 현재 Notion에서 지원되는 페이지 전체 공유하는 방식에 다양화를 더해 전체 뿐 아니라 페이지의 원하는 부분만 선택해서 배포할 수 있는 기능을 구현한 개인 프로젝트였습니다.
개발 공부를 시작하며 알게된 Notion은 무엇보다 간편함이 매력이었고, 자료를 정리하기도, 공유하기도 꽤 유용했습니다만, 사용하며 정보 전달 방법이 너무 단일적이라는 생각이 들었습니다.
Notion은 이미 “게시하기” 기능으로 충분히 정보를 전달하기에 유용하지만, 너무 간편한 탓에 익숙하지 않은 사용자는 보내고 싶지 않은 정보까지 보내게 될 때도 있었고(하위 페이지, 상위 페이지를 의도치 않게 포함), 보내고 싶은 부분만 보내는 것 또한 코드블럭, 코드조각 등의 마크다운 형식의 문법 사용으로 인해 메신저 등 다른 방법으로 공유하기에는 동일한 형식으로 전달되지않는 찝찝함 또한 있었습니다.
위 부분의 사용성을 개선하고자 초기 프로젝트를 기획하게 되었고, 개발 과정 중 Notion API를 사용하지 않아도 웹 페이지의 일부를 추출하는 기능을 구현하게 되어 프로젝트 기능을 Notion에만 국한시킬 필요가 없다고 판단하였습니다.
그 결과 스크랩북처럼 모든 사이트에서 원하는 정보만 취사선택하여 페이지를 생성할 수 있는 Scrabbit이 탄생하게 되었습니다.
이전 프로젝트의 핵심 기능은 웹 페이지의 모든 요소를 동일하게 렌더링하고, 원하는 정보만 취사선택하여 SSG로 생성하는 것이었습니다.
여러 방법 중 Puppeteer라이브러리를 사용하여 스냅샷으로 페이지를 구현하는 것이 가장 동일하게 구현할 수 있는 방법이었고, 일부 구현이 불완전한 요소에 대해서는 요소마다 필요한 속성을 추가해줌으로써 웹 페이지의 모든 요소를 동일하게 재현할 수 있었습니다.
처음에는 Notion을 타겟으로 프로젝트를 제작하였기 때문에, Notion에 국한된 기능을 정의하고 구현하였습니다만, 사실상 Puppeteer는 Notion 뿐만 아니라 다른 웹 페이지 또한 스냅샷 생성이 가능하였고, 후처리가 필요한 속성은 추가해주는 기능도 있었기 때문에 다른 웹 페이지에 접근을 굳이 제한할 필요가 없다고 판단했습니다.
또한 다른 웹 페이지의 컨텐츠도 배포 구성에 추가가 가능하다면, 사용자의 입장에서 Notion에 기록한 내용에 여러 공식 문서, 블로그나 커뮤니티에서 얻는 정보글 또한 포함이 가능해지기 때문에 사용성과 확장성 측면에서 개선점이 많다고 생각되어서 많은 챌린지가 예상되었지만 확장을 시도해보았습니다.
그 결과, 이전에 Notion의 컨텐츠만 가져올 수 있었던 프로젝트에서, Notion, 공식문서(MDN, React, Next.js 등), 블로그(Velog, tistory, Medium, 기타 개인 블로그)에도 접근이 가능해져 더욱 사용 범위가 넓은 프로젝트로 개선되었습니다.
풀스택 프레임워크인 Next.js의 App router 구조는 CSR과 SSR, SSG와 같은 렌더링 방식을 다양하게 활용하는 프로젝트에 그야말로 '최적' 이었습니다.
App router가 '최적'이라고 판단되었던 대표적인 2가지 이유는 아래와 같습니다.
- React Server Components(RSC)의 지원
Page router 방식에서는 지원되지 않는 RSC를 활용하여, 서버에서 데이터를 처리하고 클라이언트로 JSX 형태의 데이터를 전달하여 자바스크립트 번들 크기를 줄이고 성능을 향상시킬 수 있었습니다. - 컴포넌트 단위 렌더링 선택 방식
페이지 라우터는 페이지 단위로 하나의 렌더링 방식을 선택해 사용하지만, 앱 라우터는 컴포넌트 단위로 서버와 클라이언트에서 렌더링 방식을 유연하게 조합할 수 있었습니다.
따라서 각 렌더링 방식의 용도와 목적에 부합되도록 초기 페이지 로딩은 SSR을 통해 최적화하고, 동적 상호작용은 CSR로 처리하며, 정적 컨텐츠는 SSG를 통해 생성해 상황에 맞게 최적화된 렌더링 방식을 구현했습니다.
초기 렌더링의 경우 CSR 방식으로 렌더링된다면 아래의 과정으로 화면에 표시가 됩니다.
| ① 최소한의 HTML파일과 자바스크립트 번들을 받아옴 ② 클라이언트에서 자바스크립트를 실행 ③ 자바스크립트를 실행한 후 서버에서 데이터를 받아 화면을 렌더링 |
|---|
하지만 초기 렌더링이 SSR 방식으로 된다면 이렇게 작동됩니다.
| ① 서버에서 HTML을 사전에 렌더링 ② 렌더링된 HTML을 클라이언트에 전달하여 즉시 화면에 표시 |
|---|
정리하자면, CSR 방식은 클라이언트에서 모든 렌더링을 처리하므로, 네트워크 속도나 외부 환경에 따라 초기 렌더링이 지연되거나 불완전할 수 있습니다.
이를 방지하기 위해, 서버에서 HTML을 사전에 렌더링하고 클라이언트로 전달하는 SSR 방식으로 구현하였습니다.
이로써 CSR의 단점인 초기 렌더링 지연을 개선하고, 클라이언트 자바스크립트 실행에 의존하지 않아도 되어 추가로 외부 요인에 영향을 덜 받아 안정적으로 화면을 표시할 수 있었습니다.
내부 컨텐츠의 진행 상태에 따른 애니메이션과 요소 배치 형태, 내용 선택 상태는 CSR을 활용한 SPA로 구성하여 상호 작용이 자연스럽게 이루어지는 UI/UX를 고려했습니다.
SSG의 특징은 빌드 시점에 한번만 렌더링하기에 매 요청마다 렌더링 하지 않아도 되고, 이를 통해 생성된 HTML파일을 CDN(Content Delivery Network) 서버에 저장할 수 있기 때문에 매 요청마다 서버에서 렌더링이 발생하게 되는 SSR에 비해 서버 부하를 줄일 수 있습니다. 이는 곧 서버 비용 절감으로 이어집니다.
또한, CDN을 통해 빠르게 정적 페이지를 제공받을 수 있어 SSR보다 로딩 속도가 빠르고, 서버 문제가 발생해도 안정적으로 페이지를 전달받을 수 있습니다.
또한 이 프로젝트는 App router 구조를 사용했기에, 더욱 간단하게 SSG를 구현할 수 있었습니다.
![]() |
|---|
Next.js 공식문서의 위 한 줄이 의미하는 바는 아래와 같습니다.
- 사전 생성된 페이지 제공: 빌드 시점에 페이지를 생성해 요청 시 서버 부하를 줄입니다.
- 빠른 응답 및 확장성: 미리 생성된 컨텐츠를 CDN에 저장하고 요청시 사용자에게 렌더링 없이 제공하여 빠른 응답을 제공합니다.
이 장점을 그대로 활용하여 구현하였고, Next.js를 제작한 Vercel의 SSG 생성 및 배포 서비스를 통해 사용자가 선택한 배포 구성에 따라 SSG를 생성하고 배포하도록 했습니다.
![]() |
|---|
| 입력된 웹 페이지의 스냅샷을 생성하여 렌더링하고, 부분적으로 선택하여 추출할 수 있습니다. |
웹 페이지의 모든 컨텐츠를 렌더링하여 조작하기 위해 서버 액션과 헤드리스 브라우저를 사용하여 웹 페이지의 스냅샷을 생성하고, 특정 HTML 요소를 부분적으로 추출하는 기능을 구현했습니다. 이 기능은 사용자가 지정한 페이지의 컨텐츠를 정확히 렌더링하고 필요한 부분만 선택적으로 처리할 수 있도록 설계되었습니다.
Puppeteer와 같은 헤드리스 브라우저는 많은 메모리와 CPU 리소스를 소모하기에 클라이언트 사이드에서 실행되면 이는 성능 저하로 이어지고, 곧 사용자 경험 저하로 이어지기 쉽습니다.
반면 서버 액션은 브라우저 환경에서 실행되지 않고 서버에서 코드를 실행합니다. 즉, 클라이언트 대신 서버에서 실행함으로써 사용자의 디바이스에 부담을 주지 않고 원활한 처리를 보장할 수 있습니다.
서버에서 작업을 수행하면, 사용자는 단순히 요청 결과만 받아보기 때문에 네트워크 리소스도 효율적으로 사용해서 퍼포먼스 저하를 방지할 수 있기에 서버 액션으로 스냅샷을 생성하도록 구성했습니다.
서버 액션은 SSR 방식으로 페이지의 완전한 HTML 구조를 생성합니다. 이를 통해 서버에서 처리된 HTML 스냅샷을 클라이언트에 바로 제공함으로써 로딩 속도를 개선했습니다.
동일한 방식으로 스냅샷을 생성해도 간혹 스타일이 누락되어 재현이 되지 않는 경우가 있었고, 정확한 이유가 궁금해서 문제 상황을 먼저 짚어보니 크게 2가지 경우가 있었습니다.
동일한 스냅샷 생성 로직으로 렌더링하는데 왜 CSS속성이 부여되지 않는 경우도 있는지 확인해보니, 원인은 웹 페이지의 CSS 적용방식에 따른 차이 때문이었습니다.
웹 페이지에 스타일을 적용시키는 방법은 3가지가 있습니다.
| ① inline 방식 : 요소마다 CSS 속성을 추가한 경우 ② internal 방식 : <style>태그로 스타일 시트를 추가하여 적용한 경우 ③ external 방식 : <link>태그로 외부의 별도의 파일을 참조하여 적용한 경우 |
|---|
여러 웹 페이지를 확인한 결과, 웹 페이지의 스타일은 주로 외부 CSS 리소스에 의존하는 경우가 많았습니다. 헤드리스 브라우저를 통해 스냅샷을 생성할 때, 이 외부 리소스가 제대로 로드되지 않거나 동적 스타일이 적용되지 않으면 원본 페이지의 레이아웃과 스타일이 일부 재현되지 않는 문제가 발생하는 것이었습니다.
따라서 이를 해결하기 위해서는 internal, external 방식으로 적용되어있는 웹 페이지의 스타일을 inline 방식으로 변환해줌으로써 일관적으로 렌더링 되도록 스냅샷 생성 후 후처리 작업을 추가했습니다.
| [스타일 시트의 스타일 속성이 적용되지 않은 경우] | [스타일 속성을 인라인 요소로 삽입한 경우] |
|---|---|
![]() |
![]() |
이 과정에서는 Window API의 getComputedStyle메서드를 활용해 각 HTML 요소의 렌더링된 최종 CSS 속성을 추출하고, 이를 인라인 스타일로 삽입하는 방식을 적용하여 스냅샷을 생성 시 발생 가능한 누락 문제를 해결할 수 있었습니다.
이 경우는 컴포넌트의 영역을 벗어나 특정 위치에 고정되는 특징이 있었고, 가진 속성의 공통점을 찾아보니 absolute 또는 fixed와 같은 position 속성이 적용되어 있었습니다.
이런 고정 요소는 웹 페이지에서 상위 컨텍스트의 위치에 따라 결정이 되다보니, 특정 레이아웃 환경에서 고정된 위치가 잘못 렌더링되거나, 필요 없는 컨텐츠(광고 배너, 팝업 등)가 포함되어 불필요한 시각적 불편을 야기했습니다.
따라서 해당 요소는 스냅샷이 렌더링되는 과정에서 후처리 작업으로 제거하여 애플리케이션을 사용에 불편하지 않도록 작업했습니다.
여러 페이지에서 내용을 선택한 상태를 유지하고, 이를 기반으로 생성할 페이지를 구성할 수 있는 기능을 구현하였습니다.
구현을 위해 사용자가 한 페이지에서 선택한 내용의 정보를 상태로 저장하고, 다른 페이지로 이동하더라도 해당 상태를 유지하도록 상태 관리를 설계했습니다.
- 각 컨텐츠는는 고유 식별자인
data-block-id를 통해 관리됩니다. - 선택 상태는 클라이언트 측의 상태 관리 로직을 통해 유지되며, 페이지 생성 요청 시 서버로 전송합니다.
이를 통해, 사용자는 페이지 단위가 아닌 내용 단위로 컨텐츠를 선택하고 관리할 수 있고, 여러 페이지에서 원하는 정보만 취사선택할 수 있도록 구현하여 사용성을 극대화하였습니다.
페이지에서 원하는 컨텐츠만 선택하여 배포 내용을 구성할 수 있도록 구현했지만, 사용자가 선택한 컨텐츠를 페이지 생성 전에 어떻게 미리 확인할 수 있을지 고민이 되는 지점이 있었습니다.
물론 배구성에 추가할 컨텐츠를 선택하면 하이라이트 표시로 인식이 가능하지만, 사용자의 입장에서 구성을 한눈에 볼 수 있어야 만족감이 더 좋을거라 생각했습니다.
특히 이 고민을 하면서 중점적으로 생각한 부분은, 사용자가 선택한 컨텐츠들이 생성될 페이지와 동일한 형태로 정확하게 미리보기 화면에 나타나게 하여 사용성을 높이는 것이었습니다.
미리보기 화면은 컨텐츠를 선택하는 즉시 갱신되어 확인할 수 있는게 가장 사용자 만족감이 높을거라 생각했고, 컨텐츠 선택과 동시에 반영시키기 위해 부모 컴포넌트에서 useState로 상태를 관리하고 전달하면 되겠다고 생각했습니다.
배포할 요소를 선택하면 미리보기 컴포넌트가 같이 업데이트 되도록 선택 상태를 useState에 배열 구조로 저장하고, 그 상태를 부모 컴포넌트로 끌어올려 미리보기 컴포넌트에 공유되도록 설계하였습니다.
이를 통해 사용자는 선택한 요소가 실제로 어떻게 구성될지를 직관적으로 확인할 수 있게 하고, 추가로 빈번한 상태 업데이트로 인한 성능 저하를 줄이기 위해 useRef를 사용하여 불필요한 리렌더링을 방지했습니다.
사용자가 mypage.scrabbit.site 와 같이 고유의 서브도메인을 설정하면, 해당 URL로 다른 사람에게 공유할 수 있도록 공유 방식을 제공하였습니다.
서브도메인 부여는 Vercel API를 통해 자동화되어 있으며, 사용자들은 복잡한 설정 없이 단순한 입력만으로 빠르게 웹사이트를 생성할 수 있게 하였습니다.
현재는 로그인 등의 개인정보 관리 시스템은 구축되어 있지 않으나, 추후 필요 시 로그인을 통해 자신이 배포한 페이지 리스트를 제공할 수 있게 하여 간편하게 관리할 수 있는 시스템도 고려중입니다.
Vercel은 LetsEncrypt라는 서비스를 제공하여 페이지 생성 시 자동으로 SSL 인증서 등록을 해줍니다.
SSL 인증서를 등록을 대신 작업해주는건 감사한 일이었으나, 즉시 발급이 되는 경우도 있었고 간혹 수 십초 정도 발급이 지연되는 경우도 있었기 때문에 일관성있는 결과를 제공할 필요를 느꼈습니다.
![]() |
|---|
| 생성이 완료되어도 페이지에 접근을 못하고 SSL 인증서 등록을 기다려야 할 때도 있었습니다. |
페이지가 생성되는 즉시 사용자에게 제공하면 간혹 보안 위협이 있는 불완전한 웹 페이지를 제공할 수도 있으니, SSL 인증서 등록이 완료된 후 제공하는 방법이 있는지 확인해보았고 아쉽게도 Vercel 서비스 중 SSL 인증서 등록을 확인해주는건 없었기 때문에, 직접 이를 확인하는 로직을 구현해야 했습니다.
polling은 일정한 주기로 서버와 응답을 주고 받아 특정 조건을 만족할 때 송수신 등의 자료 처리를 하는 방식입니다.
보통 클라이언트와 서버 사이에 사용하는 경우가 일반적이지만, 이미 클라이언트에서 API 라우트에 요청을 보내 페이지를 생성하는 작업을 하고 있었기 때문에 굳이 클라이언트로 돌아와 polling을 적용하는 것보다 서버에서 페이지 배포 후 SSL 등록이 완료됨을 확인하고 한번에 클라이언트로 응답을 보내는 것이 더 효율적이라고 판단했습니다.
export async function waitForSSLCertification(subdomain) {
const maxRetries = 20;
const interval = 3000;
let retries = 0;
while (retries < maxRetries) {
const response = await fetch(`https://${subdomain}.scrabbit.site`).catch(
() => null,
);
if (response && response.ok) {
return;
}
retries++;
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error("SSL 인증이 완료되지 않았습니다. 나중에 다시 시도해주세요.");
}그래서 서버에서 delay나 timeout등의 경우에 사용하는 retry패턴을 응용하여 polling 로직을 구현하고, SSL등록이 완료되어 생성성된 페이지에 안전하게 접근 가능하게 되었을 때 클라이언트로 응답을 보내 사용자에게 링크를 제공하도록 구현하였습니다.
이번 프로젝트를 통해 Next.js의 App Router 방식으로 프로젝트 시작부터 끝까지 구현을 하며 단순히 React Server Component를 지원하는 프레임워크를 다뤄본 것 이상으로 SSR, SSG 방식의 렌더링을 하는 방식에 여러 구조적 이점에 대해 깊은 이해를 얻게 된 과정이었다고 생각합니다.
Next.js를 사용해 SSG를 생성하고 배포하는 프로젝트를 구현해가는 과정에서, 조사하며 발견한 대부분의 레퍼런스는 Page Router 방식을 사용하고 있었습니다.
Page Router 방식으로 구현된 프로젝트의 핵심 기술은 getStaticProps 메서드를 활용하여 구현하는게 일반적이지만, App Router 방식에서는 getServerSideProps나 getStaticProps 같은 메서드를 사용할 수 없었습니다. 이 때문에 데이터를 직접 fetch로 가져오고, html태그를 모아 정적 사이트를 생성하는 과정을 설계해야 했습니다.
레퍼런스가 없는 상황에서도 위에서 서술한 App router의 여러 장점들을 공식 문서를 통해 차근차근 공부해가며 구현하는 재미가 있었고, 이 과정에서 특히 React Server Component, React Client Component의 특징과 서로가 어떤 차이점이 있는지 이해하게 되면서 렌더링 동작방식에 대해 실제 적용을 통해 더 와닿게 알게 되어 Next.js에 대해서도, React에 대해서도 조금 더 동작방식에 대해 익숙해진 계기가 되었습니다.












