현재 저는 Web 3D 가상공간 스타트업 에르사츠에서 프론트엔드 및 웹 3D 엔지니어로 근무하고 있습니다.
지난 6월부터 8월까지 2개의 서비스에 대한 메인과 어드민, 총 4개 도메인의 웹 프론트엔트를 제작하였습니다. 모두 리액트 기반으로 제작되었으며, 그 중 Web 3D Architrip에 대해 SEO 작업을 진행하였습니다.
Web 3D Architrip은 누구나 인정할 법한 명작 건축물을 Web 3D로 구현해 접근성을 높이고, VR을 통해 실감나는 체험을 제공하는 서비스입니다.
유명한 건축물들에 대한 Web 3D 컨텐츠를 제공하는만큼, 해당 건축물을 검색하는 사람들에게 최대한 많이 노출되어야 했습니다.
그렇기에 SEO의 중요도를 높게 책정하여 작업하였고, 이번 글을 통해 그 내용을 공유하려고 합니다.
결과적으로 아래의 작업들을 통해 Google Lighthouse 기준 SEO 항목 100점을 달성할 수 있었습니다.
먼저 프로젝트의 간단한 기술 스택부터 소개하겠습니다.
이번 프로젝트를 시작하며 프레임워크를 선택할 때 만든 표입니다.
특징/기술 | Vanilla | React | Next |
---|---|---|---|
서버 비용 | 낮음 | 낮음 ~ 중간 | 높음 |
DX | 나쁨 | 좋음 | 아주 좋음 |
성능 | 좋음(그러나 UX는 나쁨) | 조금 나쁨 | 평균적으로 좋음(설정에 따라 변화) |
SEO | 좋음 | 나쁨 | 아주 좋음 |
사용자 수 | 적음 | 많음 | 보통 |
러닝 커브 | 낮음 | 보통 | 높음 |
주로 고려한 사항은 트래픽 대비 비용, 고도화 및 유지보수 작업 시 인력 확충 용이성, 성능, SEO였습니다.
초기 비용이 크고 DX가 나빠 인원 확충이 어려울, Web components 등을 이용하는 Vanilla JS는 가장 먼저 배제되었습니다.
초기부터 서버가 필요한 NextJS를 이용하기보다는 우선 React로 빠르게 개발한 뒤, 고도화 과정에서 NextJS 등 다른 프레임워크로 마이그레이션 하는 것이 낫다 판단했습니다.
따라서 리액트의 가장 큰 단점인 SEO 문제를 해결해야 했습니다.
구글로 대표되는 검색 엔진들은 크롤링 봇을 이용해 특정 검색어에 대한 페이지의 노출 순위를 결정합니다.
일반적으로 크롤링 봇은 페이지의 html 파일에서 본문과 메타 정보를 읽어 페이지의 주제와 구조, 스캠성을 띄는지, 혹은 사이트 내외의 연관된 페이지 등을 분석합니다.
React는 모든 페이지에 대해 하나의 html 파일만을 갖고 js를 통해 주소에 맞는 내용을 렌더링시키는 Single Page App을 제공합니다.
각 페이지에 맞는 적절한 html 파일을 가질 수 없기에 검색 엔진 최적화에 불리합니다.
요즈음에는 크롤링 봇들이 js 파일을 실행할 수 있는 경우도 있지만, 실행하지 않는 경우도 여전히 많으며, js로 변경되는 동적 정보들이 오히려 SEO 점수를 낮추는 경우도 있습니다.
따라서 가장 먼저 해야 할 작업은 각 페이지에 적절한 정보를 채워 넣은 뒤, 그것을 정적인 html 파일으로 생성하는 것입니다.
백엔드의 데이터를 받아 사용자에게 보이는 화면을 구성할 때, React는 기본적으로 DOM의 body만을 조작합니다.
하지만 검색 엔진은 body tag 뿐 아니라, head tag 내부의 정보도 이용합니다.
React-Helmet-Async를 이용하면 head 내부의 정보를 손쉽게 관리하고, 동적으로 변경할 수 있게 해줍니다.
W3A에서는 head의 정보를 조작하는 컴포넌트들을 Helmet으로 분류하였습니다.
export const DetailHelmet = ({ title, architect, pageTitle, url, web3D_link, long_description, thumbnail }: props) => {
return (
<Helmet>
{title && architect && <title>{pageTitle}</title>}
<meta property="og:title" content={pageTitle} />
<meta name="twitter:title" content={pageTitle} />
<meta property="og:url" content={url} />
<meta property="twitter:url" content={url} />
<meta name="description" content={short_desc} />
<meta property="og:description" content={short_desc} />
<meta name="twitter:description" content={short_desc} />
<meta name="subject" content={'Architecture, Web 3d contents, VR supported, ' + title + ', ' + architect} />
<meta property="og:image" content={'../../og_image.webp'} />
<meta property="og:image:alt" content="Logo of Web 3d architrip" />
<meta name="twitter:image" content={'../../og_image.webp'} />
<meta name="twitter:image:alt" content="Logo of Web 3d architrip" />
<script type="application/ld+json">
{`{
"@context": "https://schema.org",
"@type": "ItemPage",
"@id": "${url}#webpage",
"url": "${url}",
"name": "${pageTitle}",
"about": { "@id": "${url}#contents" },
"description": "Step into the iconic '${title}' designed by the renowned architect ${architect} ...",
"breadcrumb": { "@id": "${url}#breadcrumb" },
"inLanguage": "en",
"mainContentOfPage": "${url}#contents",
"lastReviewed": "2023-08-02"
}`}
</script>
...
</Helmet>
);
};
React로 구성된 웹 사이트는 기본적으로 body 내부가 비어 있는 html을 서빙하고 클라이언트 측에서 js를 통해 화면을 그리도록 설계되어 있습니다.
이는 크롤링 봇이 js를 실행할 수 있는지와 관련 없이 SEO 점수에 악영향을 끼칠 수 있습니다
그렇기 때문에 js를 실행하지 않고도 서빙되는 시점부터 contentful한 html을 만들어야 합니다
이를 위해 사전 렌더링(Prerendering)이라는 과정을 진행합니다.
사전 렌더링에 관한 아티클을 보고, 처음에는 React-Snap을 이용하려 했으나, Vite과 호환이 잘 되지 않는 것 같다 판단해 Prerenderer로 렌더러를 변경하였습니다.
prerenderer는 웹 사이트 빌드 이후, 헤드리스 크롬과 그것을 조작하는 라이브러리, Puppeteer를 이용해 html 파일들을 생성합니다.
export default defineConfig({
plugins: [
react(),
prerender({
routes: pages,
renderer: '@prerenderer/renderer-puppeteer',
server: {
host: 'localhost',
listenHost: 'localhost',
},
rendererOptions: {
maxConcurrentRoutes: 1,
renderAfterTime: 500,
},
postProcess(renderedRoute) {
renderedRoute.html = renderedRoute.html
.replace(/http:/i, 'https:')
.replace(/(https:\/\/)?(localhost|127\.0\.0\.1):\d*/i, 'web3darchitrip.com');
},
}),
],
});
렌더러 옵션 중 maxConcurrentRoutes의 기본값은 0(제한없음)이지만 그 경우, 리액트-헬멧에서 충돌이 발생하는 것 같습니다.
따라서 시간이 좀 더 걸리더라도 하나씩 확실하게 렌더링하도록 설정해두었습니다.
또한, renderAfterTime을 너무 낮게 설정하거나 설정하지 않을 경우, 백엔드와의 통신이 완료되기 전에 렌더링이 진행되기 때문에 빈 Body, 혹은 레이아웃만 그려진 채, 내용은 없는 HTML을 출력합니다.
그런 경우, Prerender를 위한 새로운 대체제를 찾거나, Puppeteer를 이용해 직접 구현해야 합니다.
혹은, 유사한 인터페이스의 SSG(정적 웹 생성) 프레임워크로의 마이그레이션 역시 고려할 법 합니다.
위의 두 과정을 통해 페이지마다 적절한 정보를 포함한 html을 생성할 수 있습니다.
하지만 React의 라우팅 시스템은 정적 디렉터리의 경로와는 호환되지 않습니다.
따라서, 유저가 website.com/ko/about에 접근할 때, 서버의 root > ko > about.html을 전달하도록 직접 설정해 주어야 합니다.
이번 서비스는 AWS S3와 CloudFront를 통해 배포되었습니다
따라서 해당 작업을 위해 Lambda@Edge와 CloudFront Functions 중 보다 비용이 싼 CF를 선택했습니다.
CloudFront > 함수 > cleanURL
function handler(event) {
var request = event.request;
var uri = request.uri.replace('.com', "");
// 서버의 정적 에셋 등을 요청하는 경우 확장자가 포함될 것입니다
// e.g., index.js, favicon.svg, og.webp, ...
if(uri.includes(".")) {
return request
}
// website.com/about -> website.com/about/
// website.com/about/ -> website.com/about/
if (!uri.endsWith("/")) {
request.uri += "/"
}
request.uri += "index.html"
return request
}
이 함수를 CloudFront > 배포 > id > 동작에서 모든 경로에 대한 동작에 할당해주면 Router와 서버 경로가 정합하게 작동합니다.
본 프로젝트는 위 세 방법을 통해 SEO 측면에서의 React의 한계를 극복했습니다.
아래는 검색 엔진 최적화를 위해 추가로 도입한 기술들입니다.
페이지의 구조와 수정 일자, 페이지의 중요도 등을 직접적으로 제시하는 양식입니다.
SEO에 영향이 큰 필수 기술은 아니기에, Sitemap Generator 라이브러리가 많이 없었고, 가장 사용자가 많은 라이브러리는 프로젝트 환경과 호환되지 않았습니다.
적합한 라이브러리가 없다 판단하여, 직접 fs와 xmlbuilder2라는 모듈을 이용해 간단한 생성기를 만들었습니다.
const addPath = (root, path, freq) =>
root
.ele('url')
.ele('loc')
.txt(path)
.up()
.ele('lastmod')
.txt(moment().format('YYYY-MM-DD'))
.up()
.ele('changefreq')
.txt(freq ? freq : 'yearly')
.up()
.up();
const generateSitemapXml = () => {
const root = create({ version: '1.0', encoding: 'UTF-8' }).ele('urlset', {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
});
const BASE = 'https://web3darchitrip.com';
const [main, ...buildings] = pages;
addPath(root, BASE + main, 'weekly');
for (const url of buildings) {
addPath(root, BASE + url);
}
return root.end({ prettyPrint: true });
};
const sitemapXml = generateSitemapXml();
const filename = './public/sitemap.xml';
fs.writeFileSync(filename, sitemapXml);
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://web3darchitrip.com</loc>
<lastmod>2023-08-23</lastmod>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://web3darchitrip.com/building/1</loc>
<lastmod>2023-08-23</lastmod>
<changefreq>yearly</changefreq>
</url>
<url>
<loc>https://web3darchitrip.com/building/2</loc>
<lastmod>2023-08-23</lastmod>
<changefreq>yearly</changefreq>
</url>
<url>
<loc>https://web3darchitrip.com/building/3</loc>
<lastmod>2023-08-23</lastmod>
<changefreq>yearly</changefreq>
</url>
<url>
<loc>https://web3darchitrip.com/building/4</loc>
<lastmod>2023-08-23</lastmod>
<changefreq>yearly</changefreq>
</url>
</urlset>
기본적인 Meta Tag 보다 페이지에 대한 메타 정보를 자세하고 체계적, 구조적으로 제시하는 양식입니다.
페이지의 종류, 주제, 주소, 예상 동작 등, 페이지에 대한 정보 뿐 아니라, 장소, 인물, 단체, 행사 등 사이트에서 다루는 주제에 대한 내용까지도 자세하게 설명할 수 있습니다.
구조화된 데이터가 중요한 이유는 2가지 입니다.
구글에서 이 정보의 일부를 검색 결과 디자인에 반영한다.
구조화된 데이터 마크업의 작동 방식 소개 | Google 검색 센터 | 문서 | Google for Developers
그럼에도 많은 웹 사이트가 구조화된 데이터를 이용하지 않는다.
적절하게 구조화된 데이터를 적용하는 것으로 검색에서 유리한 순위를 배정받을 뿐만 아니라, 구글 검색 결과로 보여지는 내용이 더 풍부해지므로 검색자들의 클릭률도 높일 수 있을 것입니다.
재단에서 제공하는 공식 생성기는 10개 가량의 자주 쓰이는 대표적인 type의 Schema만 지원하며, 이외의 충분히 퀄리티 있는 오픈 소스 Schema Makrup Generator가 없어 직접 Docs를 읽으며 Helmet에 추가하였습니다.
...
<script type="application/ld+json">
[
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "https://web3darchitrip.com/#website",
"url": "https://web3darchitrip.com/",
"name": "WEB 3D ARCHITRIP",
"description": "Discover world-famous buildings in Web3D and VR at WEB 3D ARCHITRIP. Experience the beauty and historical value of architecture through realistic simulations and interactive experiences. Accessible on ANY DEVICE, NO INSTALL required, with FULL 3D and VR SUPPORT. Explore fascinating destinations anytime, anywhere, with just a web browser.",
"publisher": { "@id": "https://web3darchitrip.com/#organization" },
"inLanguage": "en"
},
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://web3darchitrip.com/#organization",
"name": "Ersatz",
"url": "https://ersatz.kr/",
"location": { "@type": "Place", "name": "Seoul / Korea" }
}
]
</script>
...
...
return (
<Helmet>
...
<script type="application/ld+json">
{`[{
"@context": "https://schema.org",
"@type": "ItemPage",
"@id": "${url}#webpage",
"url": "${url}",
"name": "${pageTitle}",
"about": { "@id": "${url}#contents" },
"description": "Step into the iconic '${title}' designed by the renowned architect ${architect} and experience it in mesmerizing Web3D and VR at WEB 3D ARCHITRIP. Immerse yourself in the brilliance of architectural design with lifelike simulations and interactive experiences. No need for installation, enjoy this masterpiece on ANY DEVICE with FULL 3D and VR SUPPORT. Explore the historical significance of the '${title}' and its captivating features through your web browser, anytime and anywhere.",
"breadcrumb": { "@id": "${url}#breadcrumb" },
"inLanguage": "en",
"mainContentOfPage": "${url}#contents",
"lastReviewed": "2023-08-02"
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"@id": "${url}#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "WEB 3D ARCHITRIP", "item": "${domain}" },
{ "@type": "ListItem", "position": 2, "name": "BUILDINGS", "item": "${domain}#items" },
{ "@type": "ListItem", "position": 3, "name": "${title}", "item": "${url}" }
]
},
{
"@context": "https://schema.org",
"@type": "Place",
"@id": "${url}#about",
"name": "${title} Built by ${architect}"
},
{
"@context": "https://schema.org",
"@type": "3DModel",
"@id": "${url}#contents",
"name": "A Web 3d content of ${title} by ${architect}",
"isResizable": "true",
"associatedArticle": { "@id": "${url}#article" },
"embedUrl": "${web3D_link}",
"playerType": "Web embed",
"productionCompany": { "@id": "${domain}#organization" },
"about": { "@id": "${url}#about" },
"accessMode": "visual",
"creator": { "@id": "${domain}#organization" },
"genre": "Architecture",
"interactivityType": "active",
"position": 1
},
{
"@context": "https://schema.org",
"@type": "Article",
"@id": "${url}#article",
"headline": "An article about ${title} by ${architect}",
"articleBody": "${long_description}",
"name": "Article about ${title} by ${architect} that generated by AI.",
"about": { "@id": "${url}#about" },
"accessMode": "textual",
"contentLocation": { "@id": "${url}#about" },
"image": "${thumbnail}",
"author": {
"@context": "https://schema.org",
"@type": "Organization",
"@id": "${url}#organization",
"name": "Ersatz",
"url": "${ERSATZ}",
"location": { "@type": "Place", "name": "Seoul / Korea" }
},
"genre": "Architecture",
"interactivityType": "expositive",
"keywords": ["Architecture", "${title}", "${architect}", "Web 3d", "VR", "3D Model", "Playable 3d"],
"position": 2,
"datePublished": "2023-08-14T08:00:00+08:00",
"dateModified": "2023-08-14T08:00:00+08:00"
}]`}
</script>
</Helmet>
)
}
...
return (
<Helmet>
...
<script type="application/ld+json">
{`[{
"@context": "https://schema.org",
"@type": "CollectionPage",
"@id": "${url}",
"url": "${url}",
"name": "${title}",
"isPartOf": { "@id": "${url}#website" },
"about": { "@id": "${url}#items" },
"description": "Discover world-famous buildings in Web3D and VR at WEB 3D ARCHITRIP. Experience the beauty and historical value of architecture through realistic simulations and interactive experiences. Accessible on ANY DEVICE, NO INSTALL required, with FULL 3D and VR SUPPORT. Explore fascinating destinations anytime, anywhere, with just a web browser.",
"breadcrumb": { "@id": "${url}#breadcrumb" },
"inLanguage": "en",
"mainContentOfPage": "${url}#items",
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"@id": "${url}#breadcrumb",
"itemListElement": [{ "@type": "ListItem", "position": 1, "name": "WEB 3D ARCHITRIP" }]
},
{
"@context": "https://schema.org",
"@type": "ItemList",
"@id": "${url}#items",
"numberOfItems": "${total}",
"itemListElement": [${
total > 0
? buildingList.map(
(building, index) => `{
"@type": "ListItem",
"position": ${index + 1},
"url": "${url}building/${building.id}",
"name": "${building.title} by ${building.architect}",
"description": "${building.short_description.replace(/["]/g, '')}",
"image": { "@type": "ImageObject", "url": "${building.thumbnail}" },
"item": { "@type": "Webpage", "@id": "${url}building/${building.id}/#webpage" }
}`
)
: []
}]
}]`}
</script>
</Helmet>
)
}
의미론적인 마크업을 사용하면 아래와 같은 이점이 있습니다
- 검색 엔진은 의미론적 마크업을 페이지의 검색 랭킹에 영향을 줄 수 있는 중요한 키워드로 간주합니다 (SEO 참조).
- 시각 장애가 있는 사용자가 화면 판독기로 페이지를 탐색할 때 의미론적 마크업을 푯말로 사용할 수 있습니다.
- 의미없는 클래스 이름이 붙여져있거나 그렇지 않은 끊임없는
div
들을 탐색하는 것보다, 의미있는 코드 블록을 찾는 것이 훨씬 쉽습니다.- 개발자에게 태그 안에 채워질 데이터 유형을 제안합니다
- 의미있는 이름짓기(Semantic naming)는 적절한 사용자 정의 요소 / 구성 요소의 이름짓기(namimg)를 반영합니다.
사용할 마크업에 접근할 때 스스로에게 물어보세요. "내가 채울 데이터를 가장 잘 설명하고 나타내는 요소는 무엇일까?" 예를 들어, 그 데이터는 정렬된 목록입니까? 정렬되지 않은 목록입니까?, 관련된 정보가 제외된 섹션이 있는 아티클(article)입니까?, 정의의 나열입니까?, 캡션이 필요한 그림 또는 이미지입니까?, 사이트 전체 머리글(header) 및 바닥글(footer) 외에 또 다른 머리글과 바닥글이 있어야합니까? 등등
출처 - MDN HTML 시맨틱
Semantics - MDN Web Docs 용어 사전: 웹 용어 정의 | MDN
HTML 요소 참고서 - HTML: Hypertext Markup Language | MDN
검색 엔진에서 웹 페이지를 자동으로 크롤링하지만 그 주기와 동작을 예상할 수 없기에, 많은 검색 엔진이 서치 콘솔을 지원합니다.
서치 콘솔에서는 사이트의 구조를 직접 제시하거나, 이미 크롤링 된 정보의 노출을 막는 등의 관리가 가능합니다.
대표적인 기능은 다음과 같습니다.
앞서 얘기했듯 이번 프로젝트의 SEO 중요성이 높았기에 백링크 빌딩 등을 제외한, 현실적으로 가능한 모든 방법을 사용했다 자신합니다.
물론, 처음 진행해본 SEO 작업이었기에 완벽하진 않았겠지만 최선을 다해 작업하였습니다.
이후 구글 분석의 데이터에서 인사이트를 얻어 키워드, 메타 정보의 내용 등을 고도화시켜나가는 것으로 검색 노출 점수를 올려가고자 합니다.
긴 글 끝까지 읽어주셔서 감사합니다.
좋은 하루 되세요!
좋은 글 감사합니다!