EN 버튼을 눌렀더니 주소 끝에 슬래시가 두 개 붙어 있었다.
회사 홈페이지 한국어 메인에서 오른쪽 위 언어 전환을 누르면 영문 메인으로 가야 한다. 화면은 영문으로 잘 바뀌었다. 그런데 주소창이 /en// 였다. 회사소개나 서비스 페이지에서 누르면 /en/about/ 처럼 멀쩡했다. 메인에서만, 그것도 영문으로 갈 때만 그랬다.
화면이 멀쩡하니 그냥 넘어갈 수도 있는 일이었다. 문제는 이 주소를 만든 함수가 언어 전환 버튼만 쓰는 게 아니라는 데 있었다. 그 이야기를 하려면 이 홈페이지가 어떻게 생겼는지부터 꺼내야 한다.
회사 홈페이지에 서버가 필요할까
회사 홈페이지가 하는 일을 적어 보면 생각보다 단출하다. 회사 소개, 하는 일, 포트폴리오, 직접 만든 도구 소개, 문의하기. 로그인도, DB 에서 꺼내 올 데이터도 없다. 내용은 내가 문구를 고칠 때만 바뀐다.
그래서 처음부터 서버 코드를 두지 않기로 했다. 페이지를 요청받을 때마다 만드는 대신, 빌드할 때 한 번 다 만들어 두고 그 결과만 올린다. 이렇게 만든 사이트는 웹 서버 입장에서 그냥 HTML 파일 묶음이다. 런타임이 없으니 업데이트할 프레임워크도, 막아 둘 관리자 화면도, 백업할 DB 도 없다. IIS 의 응용 프로그램 풀도 "관리 코드 없음"으로 둔다. .NET 런타임을 띄울 일 자체가 없다.
도구는 Astro 를 골랐다. 컴포넌트로 화면을 짜고, 빌드하면 컴포넌트가 사라지고 HTML 만 남는다. 따로 스크립트를 쓰지 않은 컴포넌트는 자바스크립트를 내보내지 않는 게 기본이라, 회사 소개 페이지 같은 곳에 잘 맞았다. 스타일은 Tailwind CSS v4 로 잡았다. 로고에서 뽑은 파란색과 회색을 @theme 에 brand-*, ink-* 단계로 넣어 두니 화면 어디서나 같은 색 이름을 쓸 수 있었다.
빌드 결과인 dist/ 를 열어 보면 index.html, about/index.html, en/about/index.html 같은 파일이 폴더째로 들어 있다. 설정에서 trailingSlash: "always" 로 모든 주소를 슬래시로 끝나게 했기 때문이다. /about/ 요청은 about 폴더의 index.html 로 떨어지고, IIS 는 기본 문서만 index.html 로 잡아 주면 된다.
페이지는 껍데기, 본문은 뷰
한국어와 영어 두 벌을 만들어야 했다. 한국어는 접두사 없이 루트(/)에, 영어는 /en/ 아래에 둔다. Astro 의 i18n 설정에서 기본 언어를 ko 로 하고 prefixDefaultLocale: false 를 주면 이 모양이 된다.
문제는 페이지가 두 벌이 된다는 점이다. pages/about.astro 와 pages/en/about.astro 에 같은 마크업을 복사해 두면, 카드 하나 고칠 때마다 두 곳을 고쳐야 하고 언젠가 한쪽을 빼먹는다. 그래서 폴더를 역할별로 나눴다.
---
// src/pages/en/about.astro — 한국어 쪽은 lang 이 "ko" 인 것만 다르다
import BaseLayout from "../../layouts/BaseLayout.astro";
import AboutView from "../../views/AboutView.astro";
import { useTranslations } from "../../i18n/ui";
const lang = "en" as const;
const t = useTranslations(lang);
---
<BaseLayout lang={lang} title={t.meta.about.title} description={t.meta.about.description}>
<AboutView lang={lang} />
</BaseLayout>
pages/ 의 파일은 껍데기다. 언어를 정하고, 레이아웃에 뷰를 끼우는 일만 한다. 실제 본문은 views/ 에 있고, 한국어 페이지와 영어 페이지가 같은 뷰를 쓴다. 뷰는 lang 하나만 받아서 문구는 i18n/ui.ts 에서, 목록은 data/ 에서 꺼낸다. 화면 문구는 한·영 모두 ui.ts 한 파일에 있다. 서비스, 포트폴리오, 공유도구처럼 항목이 늘어나는 것은 data/services.ts, data/works.ts, data/tools.ts 의 배열이다. 배열에 객체를 하나 넣으면 목록 페이지, 메인의 요약, 카드가 같이 바뀐다. 헤더·푸터·카드처럼 여러 뷰가 같이 쓰는 조각은 components/ 에 둔다.
공유도구 상세처럼 주소가 데이터에서 나오는 페이지는 한·영 두 껍데기가 getStaticPaths() 로 같은 배열을 돌고 같은 뷰를 쓴다. 도구 하나를 넣으면 두 언어 상세 페이지가 함께 생긴다.
회사 정보(대표자, 주소, 사업자번호)는 consts.ts 한 곳에만 둔다. 푸터, 회사소개, 검색엔진용 JSON-LD 가 다 여기서 읽는다.
슬래시 두 개
두 언어를 오가려면 경로를 바꾸는 함수가 필요하다. /about/ 에 있으면 영어 버튼은 /en/about/ 로, /en/about/ 에 있으면 한국어 버튼은 /about/ 으로 가야 한다. 지금 주소에서 언어 접두사를 떼어 내는 stripLangPrefix 와, 접두사 없는 기준 경로에 언어를 붙이는 localizePath 를 만들었다.
이 함수는 언어 전환 버튼만 쓰지 않았다. 레이아웃의 <head> 에서도 같은 함수로 주소를 만든다. 지금 페이지의 canonical 과 og:url, 그리고 두 언어 버전을 서로 가리키는 <link rel="alternate" hreflang> 이다. 한국어 페이지는 "영문판은 여기", 영문 페이지는 "한국어판은 여기"라고 검색엔진에 알려 주는 표시이고, 언어를 정하지 않은 사람에게 보여 줄 기본값으로 x-default 를 한국어 쪽에 걸었다. 헤더의 로고 링크, 푸터의 홈 링크도 localizePath("/", lang) 이었다.
처음 함수는 이랬다.
// 고치기 전
const clean = `/${path.replace(/^\/+|\/+$/g, "")}`;
const prefixed = lang === DEFAULT_LANG ? clean : `/${lang}${clean}`;
return prefixed === "/" ? "/" : `${prefixed}/`;
// 고친 뒤
const inner = path.replace(/^\/+|\/+$/g, "");
const segments = [lang === DEFAULT_LANG ? "" : lang, inner].filter((s) => s !== "");
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
/about 을 넣으면 clean 이 /about, 영어면 /en/about, 끝에 슬래시를 붙여 /en/about/. 맞다. 루트를 넣으면 얘기가 달라진다. 앞뒤 슬래시를 다 떼면 빈 문자열이 남고, clean 은 / 가 된다. 한국어면 / 이니 첫 번째 분기에 걸려 그대로 돌려준다. 영어면 /en 에 / 를 붙여 /en/, 그리고 마지막 줄이 슬래시를 한 번 더 붙인다. /en//. 루트일 때 끝 슬래시를 붙이지 않는 예외를 한국어 루트에만 걸어 둔 셈이었다.
그래서 영향 범위가 컸다. 한국어 메인의 EN 버튼, 한국어 메인이 영문판을 가리키는 hreflang, 영문 메인 자신의 canonical 과 og:url, 모든 영문 페이지의 로고와 하단 홈 링크, 404 페이지의 영문 홈 버튼이 전부 /en// 였다.
재미있는 건 사이트맵은 멀쩡했다는 점이다. 사이트맵은 Astro 의 sitemap 통합이 따로 만들고, 거기서는 영문 메인이 정확히 /en/ 이었다. 사이트맵은 /en/ 이 영문 메인이라고 하고, 그 페이지는 자기 canonical 을 /en// 라고 했다. 검색엔진 입장에서는 대표 주소를 두고 두 곳이 다른 말을 하는 셈이다.
고친 함수는 문자열을 이어 붙이는 대신 조각을 모은다. 언어 접두사와 경로를 배열에 넣고 빈 조각은 버린 다음 슬래시로 잇는다. 조각이 하나도 없으면 /, 있으면 앞뒤에 슬래시를 하나씩. 루트든 아니든, 한국어든 영어든 같은 규칙을 탄다. 예외 분기가 사라지니 예외를 빠뜨릴 곳도 없어졌다.
확인은 빌드 결과로 했다. 고치기 전과 후의 dist/ 를 통째로 비교해서, 바뀐 곳이 /en// 가 /en/ 이 된 자리뿐이고 다른 링크나 사이트맵은 그대로인 것을 봤다. 결과물이 그냥 파일이라 비교하면 끝이다.
없는 페이지는 404 여야 한다
404.astro 는 한 장뿐이다. IIS 가 없는 주소에 돌려줄 파일은 하나만 지정할 수 있고, 그 요청이 한국어 쪽에서 왔는지 영어 쪽에서 왔는지 정적 파일은 알 수 없다. 그래서 404 화면에는 홈으로 가는 버튼을 언어 수만큼 놓았다. 한국어 홈과 영문 홈. 위의 버그 때문에 영문 홈 버튼도 한동안 /en// 로 가고 있었다.
디자인한 404 페이지를 IIS 에 연결하는 데에는 함정이 하나 있다.
<httpErrors errorMode="Custom" existingResponse="Replace">
<remove statusCode="404" />
<error statusCode="404" path="404.html" responseMode="File" />
</httpErrors>
responseMode 를 ExecuteURL 로 두면 정적 파일을 대상으로 했을 때 본문은 404 화면이 나오는데 상태 코드가 200 으로 바뀐다. 그러면 검색엔진은 없는 주소를 정상 페이지로 색인한다. File 로 두면 그 파일 내용을 404 상태 그대로 내보낸다. web.config 는 public/ 에 두면 빌드할 때 dist/ 로 그대로 복사되니, 웹 서버 설정도 코드와 같이 저장소에서 관리된다. 해시가 붙은 빌드 자산(/_astro/)은 1년, HTML 은 5분만 캐시하는 설정도 여기 있다.
dist 를 서버로, 인증서는 스크립트로
배포는 dist/ 폴더를 서버의 IIS 사이트와 똑같이 맞추는 일이다. Web Deploy(msdeploy)의 sync 로 한다. 동기화라서 서버에만 있고 dist/ 에 없는 파일은 지운다. 편하지만, 지우면 안 되는 것까지 지울 수 있다는 뜻이기도 하다.
지우면 안 되는 첫 번째는 .well-known 폴더다. 인증서는 Let's Encrypt 로 발급하는데, 갱신할 때 인증 기관이 이 폴더 아래 파일을 웹으로 읽어 도메인 소유를 확인한다. 이 폴더는 서버에만 있다. 배포 스크립트는 이 폴더를 동기화 대상에서 뺀다.
$msArgs = @(
'-verb:sync',
"-source:contentPath=$dist",
"-dest:contentPath=$SiteName,computerName=$AgentUrl,...", # 계정은 환경변수에서
# 인증서 챌린지 폴더는 서버에만 있으므로 지우지 않는다
'-skip:objectName=dirPath,absolutePath=\\\.well-known$',
'-retryAttempts:2'
)
if ($WhatIf) { $msArgs += '-whatif' }
-WhatIf 를 주면 서버는 건드리지 않고 바뀔 파일 목록만 보여 준다. 배포 계정은 스크립트에 적지 않고 개발 PC 의 사용자 환경변수에서 읽는다. msdeploy 출력에 비밀번호가 섞여 나올 수 있어서 화면에 찍기 전에 *** 로 바꾼다. 나중에 공유도구 내려받기 파일이 생기면서 검사가 하나 늘었다. 용량 때문에 zip 파일은 저장소에 넣지 않는데, 파일이 없는 PC 에서 배포하면 동기화가 서버의 zip 을 지워 버린다. 그래서 tools.ts 에 적힌 파일이 dist/ 에 있는지, 크기와 SHA-256 까지 같은지 확인하고 아니면 배포를 멈춘다.
인증서 발급은 따로 스크립트로 만들었다. 앱 풀을 관리 코드 없이 만들고, 사이트와 호스트 바인딩을 추가하고, 배포 폴더에 앱 풀 계정 읽기 권한을 주고, win-acme 로 인증서를 발급해 443 바인딩과 자동 갱신 예약까지 건다. 이미 있는 풀·사이트·바인딩은 건너뛰게 짜서 여러 번 돌려도 안전하다.
web.config 의 리다이렉트 규칙도 인증서와 얽혀 있다. www 없는 주소는 www 로, http 는 https 로 301 한다. 그런데 인증 기관은 갱신 확인을 http 로 하러 온다. 그 요청까지 https 로 돌려 보내면 갱신이 깨진다. 그래서 /.well-known/acme-challenge/ 를 그대로 통과시키는 규칙을 모든 리다이렉트보다 위에 두었다.
배포 스크립트의 한글이 깨졌다
배포 스크립트를 돌렸더니 메시지가 깨져 있었다. "빌드 중...", "배포 완료" 같은 한글 안내가 알아볼 수 없는 글자로 찍혔다. 스크립트는 UTF-8 로 저장돼 있었고, 편집기에서는 멀쩡했다.
원인은 Windows 에 기본으로 깔린 Windows PowerShell 5.1 이었다. 5.1 은 BOM 없는 .ps1 파일을 UTF-8 로 보지 않고 시스템의 ANSI 코드 페이지로 읽는다. 한국어 Windows 라면 CP949 다. UTF-8 로 저장된 한글 바이트를 CP949 로 해석하니 글자가 엉뚱하게 나왔다. 파일 맨 앞에 UTF-8 BOM(EF BB BF) 세 바이트가 있으면 5.1 도 UTF-8 로 읽는다. PowerShell 7 은 BOM 이 없어도 UTF-8 이 기본이지만, 어느 PC 의 기본 PowerShell 로 돌려도 되게 하려면 스크립트 쪽을 맞추는 게 맞았다.
고친 내용은 두 스크립트에 BOM 을 넣은 것뿐이다. 그래서 diff 가 재미있다. 두 파일 모두 첫 줄 <# 가 지워지고 똑같아 보이는 <# 가 추가된 것으로 나온다. 눈에 보이지 않는 세 바이트가 앞에 붙었을 뿐이다.
서버 없는 홈페이지라고 신경 쓸 게 없는 건 아니었다. 다만 신경 쓸 곳이 전부 빌드 결과물과 그 앞단, 그러니까 파일과 웹 서버 설정에 모여 있었고, 둘 다 저장소에서 비교하고 고칠 수 있었다. 슬래시 하나도 dist/ 를 비교해서 잡았다.
정적 사이트의 약점은 사용자에게서 무언가를 받아야 할 때 드러난다. 다음 편은 서버 없는 홈페이지에 문의 폼을 붙인 이야기다. 그 뒤로 A/S 진행 조회, 그리고 같은 틀로 만든 두 번째 홈페이지와 홈페이지가 하는 일들을 이어서 다룬다.