사용하지 않는 이미지를 삭제하려는 고스트

사용하지 않는 이미지를 삭제하려는 고스트

self-hosted 고스트(Ghost)에서 사용하는 않는 이미지를 일괄 삭제하는 방법

BoniK's WORKS 2026년 8월 31일
사용하지 않는 이미지를 삭제하려는 고스트

고스트의 이미지 파일 관리

고스트를 사용하다 보면 올렸던 이미지를 다시 업로드하거나 포스트를 삭제하는 과정에서 사용되지 않는 이미지가 생깁니다. 따라서 시간이 지날 수록 DB내부에는 존재하지 않지만 파일시스템(로컬 디스크)에는 존재하는 고아(orphan) 이미지들이 쌓입니다.

고스트에는 이러한 미사용 이미지를 자동으로 삭제하는 기능이 없습니다. 따라서 미사용 이미지를 조회, 삭제하는 외부의 도구가 필요합니다.

ghost-purge-images를 이용한 미사용 이미지 삭제 방법

ghost-purge-images라는 node.js 패키지(cli 방식)를 통해 미사용 이미지를 삭제할 수 있습니다.

DB에 접근해야 하므로 도커로 설치한 고스트라면 도커 내부에 설치해야 합니다. 도커 이미지를 다시 빌드하거나 도커 내부에 접근해서 사용할 때마다 설치해야 합니다. (도커를 비활성화 하면 휘발됨) ghost-purge-images를 사용하려면 고스트 관리자 웹페이지의 Integrations에서 content key, admin key를 발급해야 합니다. 그런데 현재 고스트 최신 버전이 6인데 이 스크립트 나온지가 오래돼서 잘 작동하지 않는 것 같습니다.

제가 해봤을 때 다음과 같은 오류가 나왔습니다.

❌ Error: AxiosError: Request failed with status code 403

ghost-purge-images github에 있는 글들을 보니 오랫동안 업데이트, 이슈 해결이 안되고 있는 것 같습니다. 포크 버전으로 제대로 된 게 있을 수도 있지만 간단한 작업이라 그냥 제가 스크립트를 하나 만들기로 했습니다.

도커 방식의 self-hosted 고스트

self-hosted 사용자들은 주로 도커를 사용해서 Ghost를 설치합니다. 도커로 설치해서 사용하는 고스트라면 도커 내부에 패키지를 추가 설치할 필요없이 DB에 접근해서 사용된 이미지 리스트만 조회하는 방법을 쓸 수 있습니다. 실제 파일 삭제 작업은 Host(도커 외부)에서 수행하면 됩니다.

제가 도커를 설치한 방법은 다음과 같습니다. 암호는 .env파일에 저장되어 있습니다.

services:
  ghost:
    image: ghost:6.56.0
    restart: always
    ports:
      - 2368:2368
    environment:
      database__client: mysql
      database__connection__host: db
      database__connection__database: ghost
      database__connection__user: ghost
      database__connection__password: "${MYSQL_PASSWORD}"
      url: https://bonik.me
    volumes:
      - './content:/var/lib/ghost/content'
      - './config.production.json:/var/lib/ghost/config.production.json'

  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
    volumes:
      - ./ghost-db:/var/lib/mysql

self-hosted 고스트의 미사용 이미지를 조회, 삭제하는 파이썬 스크립트

간단한 작업인데 api발급하고 그러는게 귀찮아서 파이썬 스크립트를 새로 만들어 봤습니다. 스크립트 이름은 purge_unused_images.py입니다. 리눅스에서 python은 기본 설치되어 있으므로 파이썬 스크립트만 실행하면 어떤 파일이 미사용 이미지인지 쉽게 알 수 있습니다.

purge_unused_images.py 스크립트가 하는 일은 다음과 같습니다.

  • mysql 비밀번호를 .env에서 불러오기
  • mysql쿼리 명령을 통해 콘텐츠와 테마가 사용하고 있는 이미지를 조회
  • content/image 내부의 이미지와 비교
  • 사용하지 않는 이미지를 필터링
  • 특정 폴더(icon, thumbnail)의 이미지는 결과에서 제외
  • 자동생성되는 family 이미지는 결과에서 제외
  • 테스트를 위한 --dry지원, 파일로 내보내기 --output지원

스크립트 설치법

  • 설치랄 건 없고 ghost가 설치되어 있는 도커 폴더에 이 스크립트를 작성해서 넣고 실행하면 됩니다.
  • 저의 경우는 도커 앱을 /home/docker에 설치하기 때문에 이걸 기준으로 설명드립니다.
  • 스크립트 만들기
cd /home/docker/ghost
nano purge_unused_images.py
# 아래 스크립트 내용을 붙여넣기 하고 저장
  • 스크립트 수정(env파일명이 다르거나 제외할 폴더명이 다른 경우 필요한 부분을 수정합니다.)

스크립트 사용법

고스트의 미사용 이미지를 조회해서 txt파일로 저장

기능이 간단한 만큼 사용법도 간단합니다. 위 이미지는 고스트의 미사용 이미지를 정리해서 txt에 기록한 과정을 보여줍니다.

  • 스크립트 실행
# docker-compose.yml, .env 파일이 있는 곳에서 실행
cd /home/docker/ghost

# 삭제될 파일 리스트를 터미널에서 확인
python3 purge_unused_images.py --dry

# 삭제될 파일 리스트를 파일로 출력
python3 purge_unused_images.py --output unused.txt

# 파일 삭제 실행
python3 purge_unused_images.py

purge_unused_images.py 스크립트 내용

고스트에서 미사용 이미지(고아 이미지)를 조회, 제거하는 스크립트 파일의 내용입니다. 텍스트 붙여넣기 방식으로 사용하시면 됩니다. 혹은 첨부파일을 다운로드 해서 사용해도 됩니다.

#!/usr/bin/env python3
"""
Ghost 미사용 이미지 탐지/삭제 스크립트

특징:
- MySQL DB에서 Ghost가 저장한 /content/images/... 경로를 실제로 추출
- 절대 URL / 상대 URL / JSON escaped URL / URL encoding 처리
- Ghost responsive image:
    /content/images/size/w600/2026/08/foo.jpg
  를
    /content/images/2026/08/foo.jpg
  와 같은 이미지 family로 취급
- Ghost 최적화 원본:
    foo_o.jpg
  를
    foo.jpg
  와 같은 family로 취급
- jpg/png/webp/avif 등 확장자가 달라도 같은 stem이면 같은 family로 취급
- DB뿐 아니라 content/themes 안의 테마 파일도 검사
- 사용 중인 family의 모든 파생 파일은 보존
- --dry / --output 지원
"""

import argparse
import html
import os
import posixpath
import re
import subprocess
import sys
from urllib.parse import unquote, urlsplit


# ============================================================
# 설정
# ============================================================

COMPOSE_DB_SERVICE = "db"
MYSQL_DATABASE = "ghost"
MYSQL_USER = "root"
MYSQL_PASSWORD_ENV = "MYSQL_ROOT_PASSWORD"

IMAGES_ROOT = "./content/images"
THEMES_ROOT = "./content/themes"
ENV_FILE = ".env"


# DB에서 검사할 Ghost 관련 테이블.
# 실제로 존재하는 테이블/컬럼만 INFORMATION_SCHEMA에서 자동 선택한다.
DB_TABLES = (
    "posts",
    "posts_meta",
    "users",
    "tags",
    "newsletters",
    "settings",
    "members",
    "tiers",
    "offers",
    "app_fields",
    "app_settings",
    "integrations",
    "integration_settings",
)

# 이미지 URL이 들어 있을 가능성이 있는 텍스트 계열 컬럼
DB_DATA_TYPES = (
    "char",
    "varchar",
    "tinytext",
    "text",
    "mediumtext",
    "longtext",
    "json",
)

# 테마에서 실제 텍스트로 볼 파일
THEME_TEXT_EXTENSIONS = {
    ".hbs",
    ".html",
    ".htm",
    ".css",
    ".scss",
    ".sass",
    ".less",
    ".js",
    ".mjs",
    ".cjs",
    ".ts",
    ".json",
    ".md",
    ".txt",
    ".xml",
    ".yaml",
    ".yml",
}

# /content/images/ 를 포함한 실제 Ghost 이미지 URL/path 탐지
IMAGE_REF_RE = re.compile(
    r"""(?i)
    (?<![A-Za-z0-9_-])
    (?:
        (?:(?:https?:)?//[^<>"'\s]+)?
    )
    /?content/images/
    [^<>"'\s]+
    """,
    re.VERBOSE,
)

# Ghost responsive image 경로
SIZE_RE = re.compile(r"^size/w\d+/", re.IGNORECASE)

# Ghost 최적화 원본 suffix
ORIGINAL_SUFFIX = "_o"


# ============================================================
# ENV
# ============================================================

def load_env_file(path: str) -> None:
    """os.environ에 없는 값만 .env에서 읽는다."""
    if not os.path.isfile(path):
        return

    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()

            if not line or line.startswith("#") or "=" not in line:
                continue

            key, _, value = line.partition("=")

            key = key.strip()
            value = value.strip()

            if (
                len(value) >= 2
                and value[0] == value[-1]
                and value[0] in ("'", '"')
            ):
                value = value[1:-1]

            os.environ.setdefault(key, value)


# ============================================================
# MYSQL
# ============================================================

def run_mysql(sql: str) -> str:
    password = os.environ.get(MYSQL_PASSWORD_ENV)

    if not password:
        sys.exit(
            f"환경변수 {MYSQL_PASSWORD_ENV} 가 설정되어 있지 않습니다."
        )

    # 기존처럼 -pPASSWORD 사용.
    # 비밀번호가 복잡한 문자열이어도 하나의 argv로 전달되므로 shell injection은 없음.
    cmd = [
        "docker",
        "compose",
        "exec",
        "-T",
        COMPOSE_DB_SERVICE,
        "mysql",
        "--batch",
        "--skip-column-names",
        "--raw",
        f"-u{MYSQL_USER}",
        f"-p{password}",
        MYSQL_DATABASE,
        "-e",
        sql,
    ]

    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
    )

    if result.returncode != 0:
        sys.exit(
            "DB 쿼리 실패:\n"
            + (result.stderr.strip() or "(stderr 없음)")
        )

    return result.stdout


def get_existing_text_columns():
    """
    Ghost DB에서 실제 존재하는 관련 테이블의 text 계열 컬럼을 검색한다.
    Ghost 버전별 schema 차이 때문에 고정 컬럼명을 직접 사용하지 않는다.
    """

    table_names = ", ".join(
        "'" + table.replace("'", "''") + "'"
        for table in DB_TABLES
    )

    type_names = ", ".join(
        "'" + data_type + "'"
        for data_type in DB_DATA_TYPES
    )

    sql = f"""
SELECT
    TABLE_NAME,
    COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME IN ({table_names})
  AND DATA_TYPE IN ({type_names})
ORDER BY TABLE_NAME, ORDINAL_POSITION;
"""

    output = run_mysql(sql)

    columns = []

    for line in output.splitlines():
        if not line.strip():
            continue

        parts = line.split("\t", 1)

        if len(parts) != 2:
            continue

        table_name, column_name = parts

        columns.append((table_name, column_name))

    return columns


def fetch_db_text() -> str:
    """
    관련 Ghost 테이블의 모든 text 계열 컬럼을 하나의 텍스트로 가져온다.

    Base64/HEX를 사용하지 않는다.
    따라서 기존 코드의 TO_BASE64() 76-character line wrapping 문제가 없다.
    """

    columns = get_existing_text_columns()

    if not columns:
        sys.exit("Ghost DB에서 검사 가능한 텍스트 컬럼을 찾지 못했습니다.")

    selects = []

    for table_name, column_name in columns:
        table = f"`{table_name.replace('`', '``')}`"
        column = f"`{column_name.replace('`', '``')}`"

        selects.append(
            f"""
SELECT CAST({column} AS CHAR)
FROM {table}
WHERE {column} IS NOT NULL
  AND {column} <> ''
""".strip()
        )

    sql = "\nUNION ALL\n".join(selects)

    return run_mysql(sql)


# ============================================================
# TEXT NORMALIZATION
# ============================================================

def normalize_text(text: str) -> str:
    """
    JSON/HTML 등에 의해 escaped 된 URL을 최대한 원래 형태로 되돌린다.
    """

    # JSON escaped slash
    text = text.replace("\\/", "/")

    # 흔히 등장하는 JSON unicode escapes
    replacements = {
        r"\u002F": "/",
        r"\u002f": "/",
        r"\u0026": "&",
        r"\u003F": "?",
        r"\u003f": "?",
        r"\u0023": "#",
    }

    for old, new in replacements.items():
        text = text.replace(old, new)

    # HTML entity
    text = html.unescape(text)

    return text


def decode_url(value: str) -> str:
    """
    URL encoding을 여러 번 decode한다.
    예:
        %2Fcontent%2Fimages%2F...
        %252Fcontent%252Fimages%252F...
    """

    previous = value

    for _ in range(3):
        current = unquote(previous)

        if current == previous:
            break

        previous = current

    return previous


# ============================================================
# IMAGE PATH NORMALIZATION
# ============================================================

def normalize_image_reference(raw: str):
    """
    DB/테마에서 추출한 이미지 URL을
    Ghost의 content/images 기준 상대경로로 변환한다.

    예:
        https://bonik.me/content/images/2026/08/a.jpg
        /content/images/2026/08/a.jpg
        content/images/2026/08/a.jpg

    모두:

        2026/08/a.jpg

    """

    value = raw.strip()

    # URL 끝에 붙을 수 있는 HTML/Markdown/JSON punctuation 제거
    value = value.rstrip(".,;:!?)]}'\"`>")

    value = normalize_text(value)
    value = decode_url(value)

    # query / fragment 제거
    value = value.split("?", 1)[0]
    value = value.split("#", 1)[0]

    # 앞뒤 공백/quotes 제거
    value = value.strip(" \t\r\n'\"")

    if not value:
        return None

    # 절대 URL / protocol-relative URL
    if value.startswith(("http://", "https://", "//")):
        try:
            path = urlsplit(value).path
        except Exception:
            return None
    else:
        path = value

    # 역슬래시 -> 슬래시
    path = path.replace("\\", "/")

    # Ghost 경로의 content/images 위치 찾기
    lower_path = path.lower()
    marker = "/content/images/"

    index = lower_path.find(marker)

    if index >= 0:
        relative = path[index + len(marker):]
    elif lower_path.startswith("content/images/"):
        relative = path[len("content/images/"):]
    else:
        return None

    relative = decode_url(relative)
    relative = relative.lstrip("/")

    # path traversal 방지
    relative = posixpath.normpath(relative)

    if relative in ("", ".", "..") or relative.startswith("../"):
        return None

    return relative


def canonical_family_from_relative(relative: str):
    """
    Ghost image의 logical family key 생성.

    다음은 모두 같은 family가 된다:

        2026/08/foo.jpg
        2026/08/foo_o.jpg
        size/w600/2026/08/foo.jpg
        size/w1200/2026/08/foo.webp
        size/w1600/2026/08/foo.avif

    -> 2026/08/foo
    """

    relative = relative.replace("\\", "/")
    relative = relative.lstrip("/")

    # Ghost responsive image variant 제거
    while True:
        match = SIZE_RE.match(relative)

        if not match:
            break

        relative = relative[match.end():]

    relative = posixpath.normpath(relative)

    if relative in ("", ".") or relative.startswith("../"):
        return None

    directory, filename = posixpath.split(relative)

    if not filename:
        return None

    stem, _ = posixpath.splitext(filename)

    if not stem:
        return None

    # Ghost optimized original: foo_o.jpg
    if stem.endswith(ORIGINAL_SUFFIX):
        stem = stem[:-len(ORIGINAL_SUFFIX)]

    if directory:
        return f"{directory}/{stem}"

    return stem


def family_from_reference(relative: str):
    return canonical_family_from_relative(relative)


def family_from_file(path: str):
    """
    실제 파일의 상대경로를 logical family로 변환.
    """

    relative = os.path.relpath(path, IMAGES_ROOT)

    # Windows에서도 동일하게 비교할 수 있도록 POSIX 형태로 변환
    relative = relative.replace(os.sep, "/")

    return canonical_family_from_relative(relative)


# ============================================================
# REFERENCE EXTRACTION
# ============================================================

def extract_references(text: str):
    """
    텍스트에서 /content/images/... 참조를 찾아
    family -> 실제 발견된 참조 URL/path 집합
    형태의 dict를 만든다.
    """

    result = {}

    text = normalize_text(text)

    for match in IMAGE_REF_RE.finditer(text):
        raw = match.group(0)

        relative = normalize_image_reference(raw)

        if not relative:
            continue

        family = family_from_reference(relative)

        if not family:
            continue

        result.setdefault(family, set()).add(relative)

    return result


def merge_references(target: dict, source: dict):
    for family, refs in source.items():
        target.setdefault(family, set()).update(refs)


# ============================================================
# THEME SCAN
# ============================================================

def scan_themes():
    """
    content/themes 아래의 텍스트 파일에서
    직접 /content/images/... 를 참조하는 경우를 찾는다.
    """

    references = {}
    scanned_files = 0

    if not os.path.isdir(THEMES_ROOT):
        return references, scanned_files

    for dirpath, dirnames, filenames in os.walk(THEMES_ROOT):
        # 불필요한 대형 디렉터리
        dirnames[:] = [
            d
            for d in dirnames
            if d not in {".git", "node_modules"}
        ]

        for filename in filenames:
            ext = os.path.splitext(filename)[1].lower()

            if ext not in THEME_TEXT_EXTENSIONS:
                continue

            path = os.path.join(dirpath, filename)

            try:
                with open(
                    path,
                    "r",
                    encoding="utf-8",
                    errors="ignore",
                ) as f:
                    text = f.read()

                scanned_files += 1

                found = extract_references(text)
                merge_references(references, found)

            except OSError:
                continue

    return references, scanned_files


# ============================================================
# FILE SCAN
# ============================================================

def scan_image_files():
    """
    content/images 전체 파일을 family별로 분류한다.
    """

    files = []

    if not os.path.isdir(IMAGES_ROOT):
        sys.exit(f"이미지 디렉터리가 없습니다: {IMAGES_ROOT}")

    for dirpath, _, filenames in os.walk(IMAGES_ROOT):
        for filename in filenames:
            path = os.path.join(dirpath, filename)

            try:
                if os.path.isfile(path) or os.path.islink(path):
                    files.append(path)
            except OSError:
                continue

    return files


def find_unused_files(used_families):
    """
    사용 중인 logical family에 포함된 모든 파일은 보존한다.

    또한 다음 디렉터리의 파일은 미사용으로 판단되어도 제외한다:
        ./content/images/icon
        ./content/images/thumbnail
    """

    all_files = scan_image_files()

    unused = []
    used = []

    excluded_dirs = {
        os.path.normpath(os.path.join(IMAGES_ROOT, "icon")),
        os.path.normpath(os.path.join(IMAGES_ROOT, "thumbnail")),
    }

    for path in all_files:
        normalized_path = os.path.normpath(path)

        # icon / thumbnail 디렉터리는 항상 보존
        if any(
            normalized_path == d or normalized_path.startswith(d + os.sep)
            for d in excluded_dirs
        ):
            used.append(path)
            continue

        family = family_from_file(path)

        if family and family in used_families:
            used.append(path)
        else:
            unused.append(path)

    # 가나다순 정렬
    unused.sort(key=lambda x: x.casefold())

    return all_files, used, unused


# ============================================================
# DELETE
# ============================================================

def delete_files(paths):
    deleted = 0

    for path in paths:
        try:
            os.remove(path)
            print(f"삭제됨: {path}")
            deleted += 1

        except OSError as e:
            print(f"삭제 실패: {path} ({e})")

    return deleted


# ============================================================
# ARGS
# ============================================================

def parse_args():
    parser = argparse.ArgumentParser(
        description="Ghost 미사용 이미지 탐지/삭제"
    )

    parser.add_argument(
        "--dry",
        action="store_true",
        help="삭제하지 않고 미사용 파일만 출력",
    )

    parser.add_argument(
        "--output",
        metavar="파일명.txt",
        help="미사용 파일 목록을 파일로 저장하고 삭제하지 않음",
    )

    parser.add_argument(
        "--no-theme-scan",
        action="store_true",
        help="content/themes 직접 참조 검사를 하지 않음",
    )

    return parser.parse_args()


# ============================================================
# MAIN
# ============================================================

def main():
    args = parse_args()

    load_env_file(ENV_FILE)

    print("[1/4] Ghost DB 검사 중...")

    db_text = fetch_db_text()
    db_refs = extract_references(db_text)

    print(f"  DB에서 발견한 이미지 family: {len(db_refs)}")

    # --------------------------------------------------------
    # Theme
    # --------------------------------------------------------

    theme_refs = {}
    scanned_theme_files = 0

    if not args.no_theme_scan:
        print("[2/4] Ghost 테마 검사 중...")

        theme_refs, scanned_theme_files = scan_themes()

        print(f"  검사한 테마 파일: {scanned_theme_files}")
        print(f"  테마에서 발견한 이미지 family: {len(theme_refs)}")
    else:
        print("[2/4] 테마 검사 생략")

    # --------------------------------------------------------
    # Merge
    # --------------------------------------------------------

    used_families = {}

    merge_references(used_families, db_refs)
    merge_references(used_families, theme_refs)

    print(f"[3/4] 총 사용 중 이미지 family: {len(used_families)}")

    # --------------------------------------------------------
    # Files
    # --------------------------------------------------------

    print("[4/4] content/images 검사 중...")

    all_files, used_files, unused_files = find_unused_files(
        used_families
    )

    print()
    print("========================================")
    print(f"전체 파일        : {len(all_files)}")
    print(f"사용 중 파일     : {len(used_files)}")
    print(f"미사용 파일      : {len(unused_files)}")
    print("========================================")

    # --------------------------------------------------------
    # No unused files
    # --------------------------------------------------------

    if not unused_files:
        print("\n미사용 파일이 없습니다.")
        return

    # --------------------------------------------------------
    # Output
    # --------------------------------------------------------

    if args.output:
        with open(
            args.output,
            "w",
            encoding="utf-8",
        ) as f:
            for path in unused_files:
                f.write(path + "\n")

        print(
            f"\n-> {args.output} 에 저장했습니다."
            " 삭제는 실행하지 않았습니다."
        )

        return

    # --------------------------------------------------------
    # Dry run
    # --------------------------------------------------------

    print("\n미사용 파일 목록:")

    for path in unused_files:
        print(path)

    if args.dry:
        print("\n[DRY RUN] 삭제하지 않았습니다.")
        return

    # --------------------------------------------------------
    # Confirmation
    # --------------------------------------------------------

    answer = input(
        f"\n위 {len(unused_files)}개 파일을 삭제하시겠습니까? [y/N] "
    ).strip().lower()

    if answer != "y":
        print("삭제를 취소했습니다.")
        return

    deleted = delete_files(unused_files)

    print()
    print(f"삭제 완료: {deleted}개")
    print(f"삭제 실패: {len(unused_files) - deleted}개")


if __name__ == "__main__":
    main()
****

태그

BoniK

협업, 의뢰, 레슨 등 문의 : mail@bonik.me | https://open.kakao.com/me/bonik