블로그 개발 경험과 기술 사항
다음의 두 개 글에서 이어집니다!
들어가며
이 글은 기술사항을 다루는 글이지만, 2026년 오늘날 본인의 노력과 역량을 증명하기 위해 쓰는 것은 이제 큰 의미가 없다고 생각합니다. 특히 이 글을 쓰기 시작한 것이 8월 19일인데, 9월 4일 GPT-6 Astra가 출시해 큰 반향을 일으키더니 9월 22일에는 Claude Opus 5.5가 공개되고 또 한 챕터로 넘어가고 있는 시점입니다.
코드를 직접 읽는 일의 효용이 눈에 띄게 떨어지고 있고, 사람이 주관을 담아 설계한 프로젝트라고 해서 의미가 있을지 의문이 들고 있습니다. 많은 사람들의 시선이 AGI로 옮겨가고 있는 지금, 기술 글을 쓰는 것에 회의감이 들어 초안은 초안대로 보존하되 했던 생각, 겪었던 경험 위주로 정리했습니다.
프레임 효과의 구현
배경
이 테마의 디자인상 핵심에 가까운 프레임 효과를 만들어야 했습니다. 테마 전환, RSS, 언어 설정 같은 기능을 좁은 공간에 담으면서도, 화면 가장자리를 두르는 프레임의 안쪽 모서리는 일반적인 라운딩과 달리 바깥쪽으로 파고드는 역방향 라운딩으로 자연스럽게 마감해야 했습니다.
구현
프레임은 Frame.astro라는 별개의 컴포넌트로 구현되어 모든 레이아웃에서 호출되도록 만들었습니다. 4개의 프레임을 먼저 만들었고, 각 안쪽 모서리를 radial-gradient를 이용한 가상 요소로서의 역방향 라운딩으로 채워 완성했습니다. 대략 다음의 느낌입니다.
/* 좌측의 역방향 라운딩 */
.left-action::after {
background: radial-gradient(circle at 100% 100%,
transparent var(--frame-radius),
var(--color-frame) var(--frame-radius));
}
CSS는 접해본 경험이 적어 지금도 다루는 것이 익숙하지는 않지만 LLM의 도움으로 개념의 구현 방법을 적절히 찾을 수 있었습니다. 다만 여기서 background를 사용하는 것은 AI의 보조를 떠나서 제가 의도적으로 작성한 것인데, 이유를 남겨두고 싶습니다.
모바일 삼성 브라우저가 다크모드 환경에서 웹 CSS를 강제로 조정하는 문제가 있었습니다. 그리고 그 결과로 이 테마의 디자인 핵심 요소인 프레임이 회색으로 나타나는 문제를 겪었습니다. 실험해보니 color-scheme 메타 태그로도 피할 수 없는 문제였는데, 삼성 측도 기반 브라우저인 크로미움 측도 관련 문서를 제공하지 않았고 다만 삼성의 자사 공식 개발자 블로그에서 “웹 개발자의 동의 없이 페이지 색상에 자체 변환을 적용해 다크 테마로 전환할 수 있다”라는 내용을 찾은 것이 전부였습니다.
그래도 제 경우 다행히도 프레임 내부의 버튼은 정상적으로 표시되고 있는 것을 단서삼아 뭐가 이 문제를 우회하는지 확인해보니 background-color 속성을 이용하는 것이 정답이었습니다. 프레임을 해당 속성을 갖춘 별도 박스 요소로 리팩토링했고, 현재는 잘 표시되고 있습니다.
특이사항
해당 사항 반영 후 특정 줌 레벨에서 서브픽셀 반올림 오차로 1px짜리 틈새가 만들어지는 문제가 있어 프레임과 프레임 내 버튼이 1px씩 겹치도록 조정했습니다.
다국어 최적화 작업
언어 설정과 로케일
배경
다국어 지원 부분을 만들며 처음 알게 된 부분입니다. 로케일 표현에는 다음과 같은 세 가지가 있습니다.
| 표현 | 형태 예시 | 설명 | 참조한 문서 |
|---|---|---|---|
language | en | URL 라우팅에서 사용됨. URL은 단순명료한 것으로 충분함. | Unifying content under multilingual templates |
ogLocale | en_US | og:locale는 xx_XX 형식을 강제함. | The Open Graph protocol |
bcp47 | en-US | hreflang와 같은 메타데이터/매니페스트 | - |
결정이 필요한 사항은 다음의 두 가지였습니다. 하나, 가능하다면 지원 언어를 선언하는 방법 대신 대안을 만들어 사용할 것. 둘, xx, xx_XX, xx-XX 세 가지 형식을 서로 연관되게 처리하는 곳을 만들 것.
구현
로케일 표현은 별도의 config 파일에서 배열로 선언하는 대신 페이지의 지원 언어가 유동적으로 결정할 수 있도록 만들었고, 포스트 프론트매터에서 글 작성자가 bcp47 형식으로 언어를 한 번 정의하도록 만들었습니다. 입력된 로케일 정보는 다음과 같이 Intl.Locale을 경유하여 xx, xx_XX로의 형식 변환을 거치고 곳곳에 할당됩니다.
export function deriveLocaleMeta(code: string): LocaleMeta {
const m = new Intl.Locale(code).maximize();
const region = m.region ?? '';
const bcp47 = m.language + (region ? `-${region}` : '');
const ogLocale = `${m.language}_${region}`;
return { code, bcp47, language: m.language, region, ogLocale };
}
사이트맵 최적화
구현
사이트맵은 루트 페이지에서 단 한 개만 생성되며, 언어별 페이지는 서로를 alternate로, 루트를 x-default로 관계를 맺습니다. 이 글을 쓰는 시점에서 제 블로그는 모국어인 한국어를 제외하고 6개 언어를 지원하고 있으므로 예를 들어 로컬 서버에서 다음과 같이 표시됩니다.
<url>
<loc>http://127.0.0.1:4321/</loc>
<xhtml:link rel="alternate" hreflang="ko-KR" href="http://127.0.0.1:4321/"/>
<xhtml:link rel="alternate" hreflang="en-US" href="http://127.0.0.1:4321/en/"/>
<xhtml:link rel="alternate" hreflang="ru-RU" href="http://127.0.0.1:4321/ru/"/>
<xhtml:link rel="alternate" hreflang="fr-FR" href="http://127.0.0.1:4321/fr/"/>
<xhtml:link rel="alternate" hreflang="es-ES" href="http://127.0.0.1:4321/es/"/>
<xhtml:link rel="alternate" hreflang="ja-JP" href="http://127.0.0.1:4321/ja/"/>
<xhtml:link rel="alternate" hreflang="zh-CN" href="http://127.0.0.1:4321/zh/"/>
<xhtml:link rel="alternate" hreflang="x-default" href="http://127.0.0.1:4321/"/>
</url>
특이사항
사이트맵 길이가 언어를 하나 지원할 때 대비 휴리스틱으로 잡아도 지원 언어 수의 제곱으로 폭증합니다. 실제로 확인해보면 구 사이트맵에 비해 지나치게 길다는 느낌이 있지만, 그럼에도 유지하는 이유는 구글이 안내하는 가이드라인을 준수하기 위함입니다. Google Search Central의 Google에 페이지의 현지화된 버전 알리기 - 언어 버전을 명시하는 모든 방법에 관한 가이드라인을 확인해보면 다음의 두 가지 사항이 명시되어 있습니다.
- 각 언어 버전은 해당 언어뿐만 아니라 다른 모든 언어 버전을 나열해야 합니다.Each language version must list itself as well as all other language versions.
- 두 페이지가 서로를 가리키지 않는 경우 태그가 무시됩니다. 이는 다른 사이트에 있는 사용자가 페이지의 대체 버전으로 이름을 지정하여 태그를 임의로 만들 수 없도록 하기 위함입니다.If two pages don’t both point to each other, the tags will be ignored. This is so that someone on another site can’t arbitrarily create a tag naming itself as an alternative version of one of your pages.
약간 당황스러웠지만 그래서 뚱뚱한 사이트맵을 유지해두기로 했습니다. 사이트맵이 기계를 대상으로 한 문서라는 것은 먼저 들었지만, 웹어드바이저 서비스들이 이 비효율을 문제로 제기하지 않는 것을 보고 그 내용을 납득할 수 있었습니다.
조건별 RSS 생성
배경
RSS 2.0은 hreflang에 해당하는 표준 태그가 없기도 하거니와, 피드를 언어별, 페르소나별로 분리하는 것이 테마 정체성에 잘 부합하기 때문에 이 테마에서 RSS는 여러 개가 생성됩니다. 여기에 기본 언어는 URL에 언어 정보를 명시하지 않는다는 라우팅 원칙을 RSS에도 일관되게 적용해야 했습니다.
구현
언어와 페르소나별의 조합에 따라 다음의 4가지 경우의 수가 만들어집니다.
| 구분 | 형태 |
|---|---|
| 기본 언어 포스트에 대해 | /rss.xml |
| 기본 언어에서, 특정 작가 포스트에 대해 | /{author}/rss.xml |
| 어떤 외국어 포스트에 대해 | /{lang}/rss.xml |
| 어떤 외국어에서, 특정 작가 포스트에 대해 | /{lang}/{author}/rss.xml |
언어별 피드 /[lang]/rss.xml는 [lang]이 동적 라우트 파라미터이므로, 정적 빌드 시점에 어떤 lang 값이 존재하는지를 getStaticPaths로 미리 알려주어야 합니다. 기본 언어는 /rss.xml이 따로 담당하기 때문에 지원 로케일에서 defaultLocale을 제외하고, 남은 로케일마다 localePath가 반환하는 경로 /lang에서 슬래시 없이 lang만 넘기는 구조를 만들었습니다.
// [lang]/rss.xml.ts
export async function getStaticPaths() {
const locales = availableLocales.map((l) => l.code).filter((code) => code !== defaultLocale);
return locales.map((code) => ({
params: { lang: localePath(code).slice(1) }, // '/en' → 'en'
props: { lang: code },
}));
}
작가별 피드 중 외국어 쪽 /{lang}/{author}/rss.xml은 작가와 비기본 언어의 모든 조합이 필요합니다. 그래서 작가 목록 ALL_AUTHORS를 순회하면서 각 작가마다 비기본 언어 전체를 짝지어 경로를 만들고, flatMap으로 이를 하나의 평탄한 배열로 합치도록 만들었습니다. 여기서 기본 언어의 작가별 피드는 이 목록에 포함되지 않습니다.
// [lang]/[author]/rss.xml.ts
export async function getStaticPaths() {
const locales = availableLocales.map((l) => l.code).filter((code) => code !== defaultLocale);
return ALL_AUTHORS.flatMap((author) =>
locales.map((code) => ({
params: { lang: localePath(code).slice(1), author: author.id },
props: { author, lang: code },
}))
);
}
특이사항
중국어는 zh-CN과 zh-TW를 동시에 지원하는 경우 둘 다 /zh/가 되어 충돌하는 문제가 있었고, 언어 서브태그가 유일하면 /zh/와 같이 짧은 코드로, 같은 언어를 공유하는 로케일이 둘 이상이면 /zh-cn/, /zh-tw/와 같이 bcp47 형식으로 승격, 그래도 충돌이 남으면 throw로 빌드를 중지하는 구조로 보완했습니다.
llms.txt 시범 지원
배경
웹사이트 맥락 수집에 있어 토큰을 덜 소비할 방법으로서 별도의 llms.txt를 지원하자는 아이디어는 단지 제안 사항일 뿐이고, 현존하는 LLM 서비스 중에 주어진 웹사이트 링크에 대해 llms.txt부터 탐색하는 경우는 글을 쓰는 지금도 확인하기 어렵습니다. 그런데 2025년 말, 2026년 초 부근부터 다양한 서드파티 SEO 서비스가 llms.txt를 지원해야 한다고 지적하기 시작하더니 최근에는 구글 서치 콘솔 페이지에 llms.txt 항목이 추가됐습니다.
구현
구현 비용이 낮다는 점에 더불어 이 테마도 llms.txt를 지원하기로 했습니다. llms.txt는 모든 포스트를 한 곳에서 소개할 목적으로 동적으로 생성되며, AI용 RSS 정도의 역할을 목표로 설계했습니다. 이 테마의 주 정보, 페르소나 정보와 포스트 제목, 본문 일부와 URL이 마크다운 문법으로 감싸져 동적으로 제공되며, 제 블로그를 예를 들어 다음과 같이 제공됩니다.
# HYNGNG
> Greetings 🔥
Default language: ko-KR. This document is written in English for LLM accessibility. Available locales: ko-KR, en-US, es-ES, fr-FR, ja-JP, ru-RU, zh-CN.
## Sections
- [Home](http://127.0.0.1:4321/)
- [RSS](http://127.0.0.1:4321/rss.xml)
## Authors
- [hyngng.art](http://127.0.0.1:4321/en/art/): Organizing artwork and thoughts about art.
- [hyngng.photography](http://127.0.0.1:4321/en/photography/): Organizing photos and thoughts about photography.
- [hyngng.dev](http://127.0.0.1:4321/en/dev/): Recording programming and development experiences.
- [hyngng.blog](http://127.0.0.1:4321/en/blog/): Organizing blog management records.
- [hyngng.essay](http://127.0.0.1:4321/en/essay/): Recording essays and thoughts.
### hyngng.art
- [Middle School Drawing Archive and Quick Review](http://127.0.0.1:4321/en/art/second-drawing-archive/): This is the second drawing archive. I have easily twice as much material as what I'm posting here, but for reasons of privacy, subject overlap, embarras...
- [Digital Drawing #6: Things I Drew in a Week](http://127.0.0.1:4321/en/art/sixth-drawings/): It's been a year. After such a long time, I actually felt like drawing again. I warmed up a bit and then just drew whatever came to mind. I think I had...
- [Digital Drawing #5: Casual Fantasy and More](http://127.0.0.1:4321/en/art/fifth-drawings/): As always with drawing, most of the time goes into deciding what to draw rather than actually drawing. I make a habit of jotting down ideas whenever the...
...(이하 생략)
특이사항
llms.txt는 정해진 언어에 대해 한 번만 생성됩니다.site.config.ts에서llms.txt의 언어 설정값을 변경할 수 있으나, 대다수 글로벌 프론티어 LLM은 영어로 제공된 프롬프트에서 가장 높은 정확도를 보이는 것으로 알려져 있고 이를 반영해en-US의 기본값으로 제공됩니다.llms.txt주소는 LLM에게 알릴 목적으로 모든 경로의<head>태그에서 명시됩니다. 다만, 몇 번 테스트해보니 모델을 가리지 않고 가시성이 크게 좋아지지는 않는 것으로 보입니다.
이미지 종횡비 처리
배경
이 테마의 본문 이미지는 로딩 전에 16:9의 Shimmer를 먼저 보여주고, 이미지를 다 불러오면 부드러운 전환 애니메이션을 거치고 실물로 인계하는 구조입니다. 문제는 Shimmer의 비율이 16:9로 고정되어 있어, 실제 이미지의 비율이 다를 때 Shimmer와 실제 이미지간 전환을 자연스럽게 처리할 필요가 있었습니다.
구현
처음에는 이미지를 전부 불러온 뒤 .loaded 클래스를 붙이면서 aspect-ratio: auto로 실제 비율에 맞추는 방식으로 만들었고, 로딩이 끝나는 순간 16:9 박스가 실제 비율로 한 번에 튀는 동작이 되는 문제가 있었습니다. 새로운 전략을 찾기 시작했고, 브라우저는 이미지를 전부 내려받기 전이라도 헤더만 파싱되면 naturalWidth와 naturalHeight를 즉시 채운다는 특성을 알게 되었습니다. 이를 이용해 requestAnimationFrame으로 값이 채워지는 첫 프레임을 잡아 래퍼에 실제 종횡비 aspect-ratio를 인라인으로 설정, CSS의 transition: aspect-ratio 0.25s ease를 통해 16:9에서 실제 비율로 부드럽게 연결되도록 만들었습니다.
// src/utils/image-reveal.ts
const MAX_POLL_FRAMES = 60;
function syncAspectRatio(img: HTMLImageElement, target: HTMLElement): boolean {
if (target.style.aspectRatio) return true;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
target.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
return true;
}
return false;
}
function pollAspectRatio(img: HTMLImageElement, target: HTMLElement): void {
let frames = 0;
const tick = () => {
if (!img.isConnected) return;
if (syncAspectRatio(img, target)) return;
if (img.complete && img.naturalWidth === 0) return;
if (++frames >= MAX_POLL_FRAMES) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
특이사항
첫 패킷에서 naturalWidth와 naturalHeight를 받아 채운다는 점에서 최종 이미지가 로딩되기까지의 시간이 좀 개선되지 않을까 기대했지만 큰 효과는 없었습니다.
미실현 아이디어
페이지 빌드 과정에서 기존의 HTML에 더해 주석 제거 등 일부 처리를 거친 별도의 .md 문서를 생성하고, 접속자가 AI 에이전트일 경우 토큰 절약을 위해 HTML이 아닌 .md 원본을 보여주고자 하는 아이디어가 있었습니다. 간단히 찾아보니 이미 많은 웹페이지가 실제로 적용중인 사항이기도 하나, 정적 웹페이지가 아니라 서버단에서 해결해야 하는 문제로서 실현되지 못했습니다.
마치며
v1.0은 아니고 한 v0.8정도 만들었을 때, AI Slop이 어디에 얼마나 있는지 의심스러우므로 코드 건전성과 아키텍처를 모두 일일이 검토할 필요가 있겠다고 느꼈지만, 코드를 읽기 시작하면서 딜레마를 마주했습니다. 제 기준에서 느슨한 책임 분리와 강결합, A->B->A로 이어지는 땜빵 코드 등 규약 위반이나 불건전성은 실제로 꽤 발견할 수 있었습니다. 그렇다면 남은 건 수정인데, 여기서 AI에게 작업을 위임해야 하는지 혼자 해결해야 하는지의 문제가 있습니다.
당연히 혼자 할 수 있지만 프로젝트 규모가 작지 않고 비효율적으로 시간을 할애해야 합니다. 이는 초심을 원점으로 되돌립니다. 제 생각에는, 조금 더 추상적으로 볼 때 이 문제의 근원은 AI로 증강된 개인이 더 넓은 영역으로 나아간다면 역설적으로 AI 없이 지속되기 힘들어진다는 점에 있습니다. 사람이 감독 역할을 내려놓을 순 없으므로 책임 비율을 어느정도로 조정해야 하는지 고민입니다. 최적값 하나를 찾기보다는 AI의 성능이 유동적이므로 앞으로 사람 부분이 점점 줄어들 것으로 예상됩니다.