lysc.kr 메인 화면. 네오 브루탈리즘 스타일의 WE MAKE FUN GAMES 헤드라인과 VIEW ALL GAMES 버튼
다시 짠 뒤의 메인 화면. 이 화면을 포함한 사이트 전체가 JSON에서 생성된다.

페이지가 늘어나면 손이 못 따라간다

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

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

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

전환: JSON 하나가 페이지가 된다

그래서 구조를 바꿨다. 콘텐츠는 JSON에 두고, 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

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

{
  "slug": "example-post",
  "date": "2026-07-25",
  "category": "개발 일지",
  "title": "제목",
  "summary": "목록과 검색, 메타태그에 쓰이는 요약",
  "body": [
    { "type": "h2", "text": "소제목" },
    { "type": "p",  "text": "문단이다. **굵게** 와 `코드` 를 쓸 수 있다." },
    { "type": "code", "lang": "csharp", "code": "// 코드 블록" }
  ]
}
기준"같은 내용을 두 곳 이상에서 고쳐야 하면, 그건 데이터로 빼야 할 때다." 게임 개발에서 하드코딩된 값을 ScriptableObject 로 빼는 것과 정확히 같은 판단이다.

카드 전체를 클릭 가능하게 만들기

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

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

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

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

/* 오버레이보다 위에 올라와야 하는 실제 버튼 */
.card-actions { position: relative; z-index: 10; }
여기서 한 번 틀렸다이 CSS를 상세 페이지의 <style> 안에만 넣어 두고 목록 페이지에는 빠뜨렸다. 그래서 목록에서 카드를 눌러도 아무 반응이 없었다. 에러가 안 나는 종류의 버그라 브라우저로 직접 클릭해 보기 전까지 몰랐다. 공용 CSS 파일로 빼서 해결했다.

호스팅이 하는 일을 알고 있어야 한다

`_` 로 시작하는 폴더가 저절로 빠지지는 않는다

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

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

확인은 주소창으로robots.txtDisallow 를 적어 뒀다고 안심하면 안 된다. 그건 크롤러에게 하는 부탁이지 접근 차단이 아니다. 브라우저에 직접 주소를 쳐 보는 것이 유일하게 확실한 확인 방법이다. 나는 /README.md 를 열어 보고 나서야 알았다.

서버 리다이렉트가 안 된다

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

sitemap 은 자동으로 갱신되지 않는다

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

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

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

확인은 브라우저로

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

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

윈도우 배치 파일은 반드시 ASCII 로한글 주석을 넣은 UTF-8 .bat 파일이 실행되자마자 모든 줄이 깨진 명령으로 읽혔다. cmd 가 배치 파일을 현재 코드 페이지 기준으로 읽기 때문이다. 맨 위에 chcp 65001 을 넣어도 소용없다. 파일을 읽는 시점이 그보다 먼저다. 안내 문구까지 전부 영문으로 바꿔서 해결했다.

정리

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