> ## Content Index
> Fetch the complete content index at: https://sanghunkang.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# 고스트 CMS 다국어 블로그: 8. #multi 포스트 hreflang 자동화
- URL: https://sanghunkang.com/ko/multilingual-ghost-cms-8-automatic-hreflang-and-tag-detection-ko/
- Published: 2026-08-20T10:51:16.000Z
- Updated: 2026-09-26T14:41:34.000Z
- Author: Sanghun Kang
- Tags: Ghost, #ko, #multi

💡

Managed Ghost hosting 서비스에서는 Ghost Core를 수정하거나 custom Handlebars helper를 등록할 수 없다. 이 제약 안에서 반복적인 Code Injection을 없애기 위해 선택한 방식이다.

포스트마다 Ghost Admin의 Code Injection에 `hreflang`을 직접 넣을 수는 있다. 그러나 번역 포스트를 발행할 때마다 영어 URL과 한국어 URL을 양쪽 포스트에 반복해서 입력해야 한다. URL을 잘못 복사하거나 한쪽 포스트에만 넣을 가능성도 있다.

0.0.8에서는 이 작업을 테마가 자동으로 처리하도록 변경했다. 함께 드러난 태그 순서 의존 문제도 제거했다.

![Ghost Admin에서 확인한 한국어 번역 포스트의 slug와 internal tag](https://sanghunkang.com/content/images/2026/08/0.0.8-admin-slug-tags.png)

실제 공개 글을 로컬 Ghost로 가져온 화면이다. slug는 `orca-codex-threads-chatgpt-ko`로 끝나며, 태그에는 `#ko`와 `#multi`가 함께 설정되어 있다.

[0.0.8 · Automated bilingual hreflang and tag detection](https://github.com/sanghunka/ghost-multilingual-theme/commit/5492342?ref=sanghunkang.com)

## 다국어 포스트 규칙

자동화를 위해 다음 규칙을 사용한다.

- 영어 포스트 slug는 `-en`으로 끝난다.
- 한국어 포스트 slug는 `-ko`로 끝난다.
- 번역본이 모두 준비된 포스트에 `#multi` internal tag를 붙인다.
- 각 포스트에는 자신의 언어를 나타내는 `#en` 또는 `#ko` internal tag가 있어야 한다.

예를 들어 아래 두 포스트는 하나의 번역 쌍이다.

```text
English slug: multilingual-ghost-cms-en
한국어 slug:  multilingual-ghost-cms-ko

```

실제 URL은 다음과 같이 만들어진다.

```text
https://sanghunkang.com/multilingual-ghost-cms-en/
https://sanghunkang.com/ko/multilingual-ghost-cms-ko/

```

## partials/hreflang-multi.hbs

포스트와 Page의 다국어 URL을 만드는 코드를 별도의 partial로 분리했다.

먼저 지원 언어와 기본 언어를 설정한다.

```javascript
var LANGS = ["en", "ko"];
var DEFAULT_LANG = "en";

```

그다음 포스트의 모든 태그 이름을 JavaScript 배열로 만든다.

```handlebars
var tagNames = [
    {{#foreach tags visibility="all"}}
        "{{name}}"{{#unless @last}},{{/unless}}
    {{/foreach}}
];

```

`visibility="all"`을 지정하는 이유는 `#multi`, `#en`, `#ko`가 모두 internal tag이기 때문이다. 이 옵션이 없으면 Ghost는 internal tag를 loop 결과에서 제외한다.

`#multi`가 없으면 아무 작업도 하지 않는다.

```javascript
if (tagNames.indexOf("#multi") === -1) return;

```

현재 포스트의 언어도 태그 배열에서 찾는다.

```javascript
var lang = null;
LANGS.forEach(function(l) {
    if (tagNames.indexOf("#" + l) !== -1) lang = l;
});

if (!lang) return;

```

slug 마지막의 `-en` 또는 `-ko`를 제거하면 번역 포스트가 공유하는 stem을 얻을 수 있다.

```javascript
var suffixPattern = new RegExp("-(" + LANGS.join("|") + ")$");
var stem = slug.replace(suffixPattern, "");

```

이 stem에 각 언어 suffix와 URL prefix를 다시 붙여 언어별 URL을 만든다.

```javascript
function buildUrl(l) {
    var prefix = (l === DEFAULT_LANG) ? "" : "/" + l;
    return siteUrl + prefix + "/" + stem + "-" + l + "/";
}

```

마지막으로 `<head>`에 `hreflang` 링크를 추가한다.

```javascript
var html = "";

LANGS.forEach(function(l) {
    html += '<link rel="alternate" hreflang="' + l + '" href="' + buildUrl(l) + '"/>';
});

html += '<link rel="alternate" hreflang="x-default" href="' + buildUrl(DEFAULT_LANG) + '"/>';

document.head.insertAdjacentHTML("beforeend", html);

```

## post와 page context에서 partial 호출

`default.hbs`의 `<head>`에서 현재 화면이 Post 또는 Page일 때 partial을 호출한다.

```handlebars
{{#is "post"}}
    {{#post}}
        {{> "hreflang-multi"}}
    {{/post}}
{{/is}}

{{#is "page"}}
    {{#page}}
        {{> "hreflang-multi"}}
    {{/page}}
{{/is}}

```

`{{#post}}`와 `{{#page}}` context 안에서 호출해야 partial이 현재 콘텐츠의 `{{slug}}`와 `{{tags}}`를 사용할 수 있다.

## 태그 순서에 의존하던 문제

기존 Tag 페이지는 두 번째 태그가 언어 태그라는 규칙을 사용했다.

```handlebars
data-language="{{tags.[1].name}}"

```

이 방식에서는 태그 순서가 바뀌면 언어 필터링이 깨진다.

```text
[Ghost, #ko, #multi]  -> 정상
[#multi, Ghost, #ko]  -> 실패

```

0.0.8에서는 각 포스트의 모든 태그를 `data-tags`에 넣는다.

```handlebars
<div class="post-card" data-tags='[{{#foreach tags visibility="all"}}"{{name}}"{{#unless @last}},{{/unless}}{{/foreach}}]'>

```

JavaScript는 배열 전체에서 언어 태그를 찾는다.

```javascript
let cardLang = null;

LANGS.forEach(l => {
    if (tagNames.indexOf('#' + l) !== -1) {
        cardLang = l;
    }
});

```

이제 `#en` 또는 `#ko`가 몇 번째에 있는지는 중요하지 않다.

Ghost의 `{{#has tag="..."}}` helper도 internal tag 판별에는 사용하지 않았다. internal tag의 화면 표시 이름은 `#multi`이지만 실제 slug는 `hash-multi`이므로 이름과 slug를 혼동하면 조건이 조용히 실패할 수 있다. 모든 internal tag 이름을 배열로 넘긴 뒤 JavaScript에서 명시적으로 비교하는 쪽이 예측 가능했다.

## 결과 확인

`#multi` 포스트에서 개발자 도구를 열고 아래 코드를 실행한다.

```javascript
[...document.querySelectorAll('link[rel="alternate"][hreflang]')]
  .map(link => [link.hreflang, link.href]);

```

영어 포스트와 한국어 포스트 양쪽에서 `en`, `ko`, `x-default` 세 링크가 나오고 URL이 각각 올바른 번역 포스트를 가리켜야 한다.

![0.0.8 한국어 #multi 포스트의 hreflang 검사 결과](https://sanghunkang.com/content/images/2026/08/0.0.8-post-hreflang.png)

한국어 Orca 포스트에서 DOM을 검사한 결과다. `en`은 영어 번역 글, `ko`는 현재 한국어 글, `x-default`는 영어 번역 글을 가리킨다. 검은 패널은 실제 DOM 조회 결과를 화면에 겹쳐 표시한 것이다.

Tag 페이지에서는 포스트의 태그 순서를 변경해도 선택한 언어의 포스트만 정상적으로 보여야 한다.

## 주의할 점

`#multi`는 모든 언어 버전이 실제로 존재할 때만 붙여야 한다. 한국어 포스트만 있는데 `#multi`를 붙이면 테마는 규칙에 따라 영어 URL도 만들고, 그 URL은 404가 된다.

또한 포스트의 `hreflang`은 브라우저 JavaScript가 `<head>`에 추가한다. Google은 JavaScript를 렌더링하므로 이 방식을 처리할 수 있지만 JavaScript 렌더링이 약한 일부 crawler는 링크를 놓칠 수 있다.

---

0.0.8부터는 번역 포스트에 언어 태그와 `#multi`만 정확히 설정하면 `hreflang`이 자동으로 만들어진다.