lysc.kr 메인 화면. 네오 브루탈리즘 스타일의 WE MAKE FUN GAMES 헤드라인과 VIEW ALL GAMES 버튼
다시 짠 뒤의 메인 화면. 이 화면을 포함한 사이트 전체가 JSON에서 생성된다.The main page after the rebuild. Every page on the site, including this one, is generated from JSON.

페이지가 늘어나면 손이 못 따라간다Manual Editing Stops Scaling Once Pages Multiply

이 사이트는 처음에 HTML 파일 몇 개로 시작했다. 메인 하나, 소개 하나, 게임 목록 하나. 이 정도면 손으로 고치는 게 가장 빠르다. 빌드 도구를 붙이는 시간이 아깝다.

This site started as a handful of HTML files: one home page, one about page, and one game list. At that scale, editing HTML by hand is the fastest way to work, and setting up a build tool is a waste of time.

문제는 페이지가 늘어나면서 시작됐다. 게임이 다섯 개가 되고 각각 상세 페이지가 생기자, 네비게이션 링크 하나를 바꾸는 데 파일을 열 개 넘게 열어야 했다. 그러다 한두 개를 빼먹고, 그 페이지만 예전 메뉴가 남아 있는 상태가 된다.

The trouble started as pages multiplied. Once there were five games, each with its own detail page, changing a single navigation link meant opening more than ten files. Inevitably I would miss one or two, leaving outdated menus on those pages.

더 나쁜 건 디자인 변경이었다. 버튼 스타일을 바꾸려면 모든 파일에서 같은 클래스를 찾아 고쳐야 했고, 결국 페이지마다 미묘하게 다른 버튼이 생겼다.

Design updates were worse. Changing a button style meant hunting down the same utility classes across every file, which eventually left each page with slightly different buttons.

전환: JSON 하나가 페이지가 된다The Switch: One JSON File Becomes One Page

그래서 구조를 바꿨다. 콘텐츠는 JSON에 두고, HTML은 스크립트가 만들게 했다.

So I changed the structure: keep content in JSON, and let Python scripts generate the HTML.

_content/posts/<날짜>-<슬러그>.json  ──►  build_posts.py   ──►  blog/posts/*.html
                                                            └►  blog/posts.json

_content/games/NN-<슬러그>.json      ──►  build_games.py   ──►  <슬러그>/index.html
                                                            ├►  메인/목록 카드 주입
                                                            └►  css/brutal-cards.css

                                          build_sitemap.py ──►  sitemap.xml
_content/posts/<date>-<slug>.json   ──►  build_posts.py   ──►  blog/posts/*.html
                                                            └►  blog/posts.json

_content/games/NN-<slug>.json       ──►  build_games.py   ──►  <slug>/index.html
                                                            ├►  Home / list card injection
                                                            └►  css/brutal-cards.css

                                          build_sitemap.py ──►  sitemap.xml

글을 쓸 때는 JSON에 블록을 나열한다. 제목, 문단, 코드, 표, 강조 박스. 목차와 읽는 시간, 이전/다음 글 링크, meta description, Open Graph, 구조화 데이터는 전부 스크립트가 붙인다.

When writing a post, I list content blocks in JSON: headings, paragraphs, code blocks, tables, and callouts. The scripts automatically generate the table of contents, reading time, previous/next links, meta description, Open Graph tags, and structured data.

{
  "slug": "example-post",
  "date": "2026-07-25",
  "category": "개발 일지",
  "title": "제목",
  "summary": "목록과 검색, 메타태그에 쓰이는 요약",
  "body": [
    { "type": "h2", "text": "소제목" },
    { "type": "p",  "text": "문단이다. **굵게** 와 `코드` 를 쓸 수 있다." },
    { "type": "code", "lang": "csharp", "code": "// 코드 블록" }
  ]
}
{
  "slug": "example-post",
  "date": "2026-07-25",
  "category": "Devlog",
  "title": "Post Title",
  "summary": "Summary used for cards, search index, and meta tags",
  "body": [
    { "type": "h2", "text": "Section Heading" },
    { "type": "p",  "text": "A paragraph supporting **bold** and `inline code`." },
    { "type": "code", "lang": "csharp", "code": "// Code block" }
  ]
}
기준"같은 내용을 두 곳 이상에서 고쳐야 하면, 그건 데이터로 빼야 할 때다." 게임 개발에서 하드코딩된 값을 ScriptableObject 로 빼는 것과 정확히 같은 판단이다.
Rule of Thumb"If you have to edit the same thing in two or more places, it belongs in data." It is the exact same principle as extracting hardcoded values into a ScriptableObject in game development.

카드 전체를 클릭 가능하게 만들기Making an Entire Card Clickable

구현 중에 재미있는 문제가 하나 있었다. 게임 카드를 아무 데나 눌러도 상세 페이지로 가게 만들고 싶은데, 카드 안에는 이미 "데모 플레이" 같은 별도 링크가 있다.

During implementation, I ran into an interesting markup problem. I wanted clicking anywhere on a game card to open its detail page, but the card already contained separate links like "Play Demo" inside it.

<a> 안에 <a> 를 넣는 건 HTML 규격 위반이라 브라우저가 태그를 강제로 끊어 버린다. 흔히 쓰는 해법은 stretched link 패턴이다. 투명한 링크의 가상 요소로 카드 전체를 덮고, 그 위에 올릴 버튼만 z-index 를 높인다.

Nesting an <a> inside another <a> violates the HTML specification, and browsers forcibly break the tags apart. The standard solution is the stretched link pattern: cover the entire card with a pseudo-element of the main link, and raise the z-index of the inner action buttons above it.

/* 카드는 기준점이 되어야 하므로 position: relative */
.game-card { position: relative; }

/* 이 링크의 가상 요소가 카드 전체를 덮는다 */
.stretched-link::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
}

/* 오버레이보다 위에 올라와야 하는 실제 버튼 */
.card-actions { position: relative; z-index: 10; }
/* The card acts as the positioning anchor */
.game-card { position: relative; }

/* This link's pseudo-element covers the entire card */
.stretched-link::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
}

/* Action buttons that must sit above the overlay */
.card-actions { position: relative; z-index: 10; }
여기서 한 번 틀렸다이 CSS를 상세 페이지의 <style> 안에만 넣어 두고 목록 페이지에는 빠뜨렸다. 그래서 목록에서 카드를 눌러도 아무 반응이 없었다. 에러가 안 나는 종류의 버그라 브라우저로 직접 클릭해 보기 전까지 몰랐다. 공용 CSS 파일로 빼서 해결했다.
A Mistake I Made HereI initially put this CSS inside the inline <style> of the detail page and forgot to include it on the list page. Clicking cards on the list page did nothing. Because missing CSS throws no console errors, I didn't notice until I clicked the card in a browser myself. Extracting it into a shared CSS file fixed the issue.

호스팅이 하는 일을 알고 있어야 한다Know What Your Hosting Provider Actually Does

`_` 로 시작하는 폴더가 저절로 빠지지는 않는다Folders Starting with `_` Are Not Excluded Automatically

빌드 스크립트와 원본 데이터를 사이트와 같은 레포에 두면 관리가 편하다. 이 사이트도 그렇게 두고 있다.

Keeping build scripts and source JSON in the same repository as the static output makes maintenance simple. That is how this repository is organized:

그런데 _ 로 시작하면 배포에서 빠진다는 건 특정 호스팅의 동작이지 일반 규칙이 아니다. Jekyll 을 거치는 호스팅에서는 그렇고, 정적 파일을 그대로 올리는 호스팅에서는 전부 그대로 공개된다. 이 사이트는 후자인데 전자라고 믿고 있었다.

However, excluding _-prefixed folders from deployment is a Jekyll convention, not a universal rule. Hosts that run Jekyll strip them out; hosts that serve static files as-is publish everything in the directory. My site runs on the latter, while I had assumed the former.

확인은 주소창으로robots.txt 에 Disallow 를 적어 뒀다고 안심하면 안 된다. 그건 크롤러에게 하는 부탁이지 접근 차단이 아니다. 브라우저에 직접 주소를 쳐 보는 것이 유일하게 확실한 확인 방법이다. 나는 /README.md 를 열어 보고 나서야 알았다.
Verify in the Address BarAdding Disallow to robots.txt is not access control—it is a polite request to crawlers. Typing the URL directly into the browser address bar is the only reliable check. I only realized my source files were public after opening /README.md in the browser.

서버 리다이렉트가 안 된다Server-Side Redirects Don't Work Out of the Box

정적 호스팅이라 301 리다이렉트를 직접 설정할 수 없다. 글 주소를 바꾸면 옛 주소는 그냥 404 가 된다. 앞단에 Cloudflare 를 쓰고 있다면 Redirect Rules 로 처리하는 게 가장 간단하다. 대시보드에서 규칙을 몇 개 넣으면 끝이고 추가 비용도 없다.

On basic static hosting, you cannot configure server-level 301 redirects directly in code. If you change a post URL, the old URL simply breaks. Since I use Cloudflare in front of the site, Redirect Rules in the Cloudflare dashboard solved this cleanly at no extra cost.

sitemap 은 자동으로 갱신되지 않는다`sitemap.xml` Does Not Update Itself

이번에 정리하면서 가장 크게 놀란 부분이다. sitemap.xml 에 등록된 글 주소 22개가 전부 존재하지 않는 파일을 가리키고 있었다. 예전에 글을 교체하면서 사이트맵을 갱신하지 않았기 때문이다.

This was the biggest surprise during the cleanup. All 22 blog URLs listed in sitemap.xml pointed to files that no longer existed. I had replaced old posts earlier without updating the sitemap.

검색 엔진 입장에서는 "사이트맵에 적힌 페이지가 대부분 없는 사이트"로 보인다. 그래서 사이트맵도 손으로 관리하지 않고, 실제 존재하는 파일만 훑어서 생성하도록 스크립트에 넣었다.

To a search engine, the site looked like a domain where most listed pages were dead. I stopped maintaining the sitemap by hand and wrote a script that scans the disk and includes only files that actually exist.

def build_post_entries() -> list[str]:
    """실제 파일이 존재하는 글만 사이트맵에 넣는다."""
    posts = json.loads(POSTS_JSON.read_text(encoding="utf-8"))
    entries = []

    for post in posts:
        file_path = SITE_ROOT / "blog" / "posts" / post["filename"]
        if not file_path.exists():
            print(f"[SKIP] 파일 없음, 사이트맵 제외: {post['filename']}")
            continue
        entries.append(build_entry(post["url"], POST_PRIORITY, POST_CHANGEFREQ,
                                   to_iso_date(post["date"])))
    return entries
def build_post_entries() -> list[str]:
    """Include only posts whose generated HTML file exists on disk."""
    posts = json.loads(POSTS_JSON.read_text(encoding="utf-8"))
    entries = []

    for post in posts:
        file_path = SITE_ROOT / "blog" / "posts" / post["filename"]
        if not file_path.exists():
            print(f"[SKIP] Missing file, excluded from sitemap: {post['filename']}")
            continue
        entries.append(build_entry(post["url"], POST_PRIORITY, POST_CHANGEFREQ,
                                   to_iso_date(post["date"])))
    return entries

확인은 브라우저로Always Test Over HTTP, Not `file://`

정적 사이트라도 HTML 파일을 더블클릭해서 확인하면 안 된다. file:// 로 열면 /image/... 같은 절대 경로가 전부 깨지고, fetch 도 막힌다. 글 목록이 통째로 비어 보이는데 코드에는 문제가 없는 상황이 생긴다.

Even for a static site, double-clicking an HTML file to open it via file:// breaks root-relative paths like /image/... and blocks fetch requests due to CORS. The post list appears completely empty even when the code is fine.

그래서 더블클릭 한 번으로 로컬 서버가 뜨도록 배치 파일을 만들어 뒀다.

To avoid that, I added a batch script that launches a local HTTP server with a double-click.

윈도우 배치 파일은 반드시 ASCII 로한글 주석을 넣은 UTF-8 .bat 파일이 실행되자마자 모든 줄이 깨진 명령으로 읽혔다. cmd 가 배치 파일을 현재 코드 페이지 기준으로 읽기 때문이다. 맨 위에 chcp 65001 을 넣어도 소용없다. 파일을 읽는 시점이 그보다 먼저다. 안내 문구까지 전부 영문으로 바꿔서 해결했다.
Keep Windows `.bat` Files Pure ASCIIA UTF-8 .bat file containing Korean comments failed immediately with garbled commands on every line. Windows cmd.exe reads batch files using the active system code page. Putting chcp 65001 at the top does not help because cmd parses the file before executing that line. Rewriting all comments and prompts in plain ASCII fixed it.

정리Takeaways

게임을 만들면서 배운 것들이 웹에서도 거의 그대로 통했다. 자주 바뀌는 값은 코드 밖으로, 같은 코드는 한 곳으로, 그리고 결국 직접 실행해서 확인하기. 도메인만 다를 뿐 같은 이야기였다.

Lessons from game development carried over directly to web development: move frequently changing data out of code, keep shared logic in one place, and always verify by running the actual build. Only the domain changed.