Ghost 상세 이미지는 보이는데 목록 썸네일이 안 나올 때
상세 페이지의 이미지가 정상이어도, 목록이 요청하는 축소 이미지까지 정상이라는 뜻은 아니다. 대표 이미지가 등록돼 있는지 확인한 뒤, 목록과 상세에서 실제로 선택한 이미지 주소와 HTTP 응답을 비교하면 문제 범위를 좁힐 수 있다.
2026년 11월 13일 mLog에서 이 차이를 놓쳤다. 글 상세에는 이미지가 보였지만 홈 목록의 같은 글 카드에는 빈 공간이 남았다. 처음에는 상세와 미리보기만 확인해 목록의 실패를 발견하지 못했다. 이후 목록의 이미지 요청을 따로 조사했다.
이 글은 그때의 관찰과 복구 과정을 기록한다. 특정 Ghost 버전의 버그를 확정하거나, 재업로드가 모든 이미지 오류를 해결한다고 주장하는 글은 아니다. 문서와 공개 응답은 2026-11-15에 다시 확인했다.
1. 먼저 대표 이미지와 본문 이미지를 구분한다
본문 안에 넣은 이미지가 보인다는 사실만으로 대표 이미지가 설정됐다고 판단하지 않는다. Ghost 편집기의 대표 이미지 영역을 먼저 확인한다. 기본적인 테마 구성에서는 글의 feature_image를 읽어 카드 이미지를 만든다. 테마에 따라 본문 이미지나 별도 규칙을 사용할 수도 있으므로 실제 템플릿을 기준으로 판단한다.
Ghost의 img_url 공식 문서는 이미지 속성과 크기 옵션을 구분한다. 예를 들어 다음 템플릿은 대표 이미지의 특정 크기 URL을 출력한다.
<img src="{{img_url feature_image size="card"}}"
alt="{{feature_image_alt}}">
여기서 card는 임의로 붙여도 되는 고정 명령어가 아니다. 해당 테마의 package.json에 정의된 이미지 크기 이름과 맞아야 한다. 아래는 설명을 위한 설정 일부이며 전체 package.json을 대체하는 파일이 아니다.
{
"config": {
"image_sizes": {
"card": { "width": 600 },
"detail": { "width": 1000 }
}
}
}
Ghost 반응형 이미지 문서에 따르면 테마는 여러 크기를 정의하고 srcset을 구성할 수 있다. 따라서 같은 대표 이미지로 만든 카드와 상세가 서로 다른 파일 주소를 요청할 수 있다. w600이 모든 Ghost 테마의 목록 크기라는 뜻은 아니다.
대표 이미지 자체가 비어 있다면 먼저 올바른 이미지를 등록한다. 이미 등록돼 있는데 목록만 비어 있다면 다음 단계로 간다. 이 시점에 테마나 서버 전체 설정부터 바꿀 필요는 없다.
2. 목록에서 실제 선택한 주소를 확인한다
문제가 있는 카드를 화면에 보이게 스크롤한 뒤 개발자 도구의 Elements와 Network에서 이미지 요소와 요청을 찾는다. 지연 로딩 이미지라면 아직 요청을 시작하지 않은 상태와 실패한 상태를 구분해야 한다.
Elements에 srcset이 있다면 src만 복사해서 검사하지 않는다. 브라우저가 실제 선택한 주소는 currentSrc로 확인한다. MDN 설명에서도 currentSrc는 선택한 URL이며, 로딩 성공 여부를 보장하는 값은 아니라고 구분한다.
다음 코드는 문서의 <img> 상태를 읽어 표로 보여 준다. 페이지 내용을 바꾸거나 외부 서버로 전송하지 않는다.
console.table(
[...document.images].map((img) => {
const rect = img.getBoundingClientRect();
return {
alt: img.alt,
src: img.getAttribute('src'),
currentSrc: img.currentSrc,
complete: img.complete,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
displayedWidth: Math.round(rect.width),
displayedHeight: Math.round(rect.height)
};
})
);
CSS의 background-image로 그린 카드는 이 표에 나오지 않는다. 그런 테마는 카드 요소의 computed style과 Network 요청을 확인한다. 결과를 다른 사람에게 공유할 때는 비공개·서명 URL이 섞였는지도 확인한다.
complete: true만 보고 성공이라고 판단하면 안 된다. MDN의 complete 정의에는 로딩에 실패한 이미지도 true가 될 수 있다고 명시돼 있다. mLog의 실패한 카드도 complete: true였지만 naturalWidth와 naturalHeight는 0이었다.
반대로 폭이 0이라는 이유만으로 서버 장애를 확정하지도 않는다. naturalWidth 문서는 이미지의 고유 폭 정보를 얻을 수 없을 때 0을 반환한다고 설명한다. 요청 시점과 응답을 함께 봐야 한다. 폭·높이가 양수여도 CSS로 가려져 있을 수 있으므로 최종 확인은 실제 화면에서 한다.
3. mLog에서는 목록용 w600 요청만 실패했다
아래는 2026-11-13 당시 확인한 결과다. 같은 이름의 원본을 바탕으로 한 크기별 주소를 비교했다.
| 대상 | 당시 관찰 |
|---|---|
| 업로드된 원본 | 정상 수신, 1920×1080 WebP |
| w300 | HTTP 200, 300×169 WebP |
| 목록의 w600 | HTTP 502, 응답 본문은 이미지가 아닌 HTML 오류 페이지 |
| w1000 | HTTP 200, 1000×562 WebP |
| 다른 글의 w600 | HTTP 200, 정상 이미지 |
이 조합으로 이미지가 아예 업로드되지 않은 상황, 목록이 대표 이미지 주소를 전혀 출력하지 않은 상황과 구분할 수 있었다. 문제 카드에는 이미지 요소와 주소가 있었지만 그 요청의 결과가 실패했다.
HTTP 표준에서 502 Bad Gateway는 게이트웨이 또는 프록시가 요청 처리 중 상위 서버에서 유효하지 않은 응답을 받은 상태를 뜻한다. 502와 오류 화면의 openresty 표기만으로 Ghost 변환기·NPM·저장 공간·캐시 중 어느 하나를 원인으로 확정할 수는 없다. 당시 기록으로 확인한 것은 크기별 요청 결과의 차이까지다.
4. GET 요청으로 상태와 실제 본문을 함께 확인한다
개발자 도구에서 복사한 이미지 URL을 아래 변수에 넣는다. 파일은 새 임시 디렉터리에 저장하므로 기존 파일을 덮어쓰지 않는다. 목록용 주소를 먼저 실행하고, 원본·상세용 주소도 각각 같은 방법으로 비교한다.
image_url='여기에 개발자 도구에서 복사한 이미지 URL을 넣으세요'
probe_dir=$(mktemp -d)
curl -sS --connect-timeout 5 --max-time 20 \
-L --max-redirs 5 \
-D "$probe_dir/headers.txt" \
-o "$probe_dir/body.bin" \
-w 'status=%{http_code}\ntype=%{content_type}\nredirects=%{num_redirects}\nfinal_url=%{url_effective}\n' \
"$image_url"
curl_exit=$?
printf 'curl_exit=%s\nresult_dir=%s\n' "$curl_exit" "$probe_dir"
cat "$probe_dir/headers.txt"
file "$probe_dir/body.bin"
이 예시는 Bash 계열 셸과 curl, file 명령이 있는 환경을 전제로 한다. 임시 파일을 확인한 뒤 보관 여부는 직접 결정한다. 다운로드한 body.bin은 실행하지 않는다.
curl 공식 매뉴얼의 -D, -o, -L, --max-redirs, --write-out 옵션을 사용했다. -I로 헤더만 받는 대신 GET 응답 본문을 보관해, 이미지 확장자의 URL이 실제로 HTML을 돌려주는지도 확인한다. 헤더 파일에는 리디렉션 중간 응답이 함께 들어갈 수 있으므로 출력된 최종 URL과 상태도 본다.
위 명령은 의도적으로 --fail을 사용하지 않는다. curl 종료 코드가 0이어도 HTTP 502일 수 있다. 전송 도구의 종료 코드와 HTTP 상태를 따로 읽는다. HTTP 200이라도 type=text/html이면 정상 썸네일이라고 볼 수 없다. MIME과 file의 형식 판정도 표시 성공을 단독으로 보장하지 않으므로 브라우저에서 이미지가 디코딩되고 보이는지까지 확인한다.
5. 관찰 결과에 따라 조치 범위를 나눈다
| 관찰 | 먼저 확인할 것 |
|---|---|
| 대표 이미지가 미등록이거나 카드에 주소가 없음 | 편집기의 대표 이미지, 테마의 이미지 속성과 조건문 |
| 주소는 있지만 원본까지 404 | 저장된 URL과 실제 파일 위치, 이전 도메인·이전 경로 |
| 원본은 정상인데 특정 크기만 4xx·5xx | 해당 URL의 응답·리디렉션, 같은 시각의 프록시와 Ghost 로그 |
| 리디렉션 횟수 제한에 걸림 | 응답의 Location과 공개 URL·HTTPS 전달 설정 |
| 200·이미지 형식인데 화면에 안 보임 | 브라우저 Console 차단 메시지, 로딩 시점, CSS 크기·가림·투명도 |
| 홈을 포함한 서비스 요청 전반이 502 | 컨테이너 상태와 NPM→업스트림 연결부터 점검 |
서비스 전반의 연결 실패는 Nginx Proxy Manager 502 진단에서 네트워크·포트·HTTP/HTTPS 순서로 확인한다. 계속 다른 주소로 이동하는 문제는 NPM 무한 리다이렉트와 Ghost HTTPS의 GET·Location 비교로 분리한다.
서버에 접근할 수 있다면 실제 Compose 파일과 서비스명을 확인하고 오류가 발생한 시각의 로그를 좁혀 본다. 아래는 예시이며 경로와 이름을 그대로 복사해 실행하지 않는다.
docker compose -f /실제/ghost/compose.yaml logs \
--since 10m --tail 100 --timestamps ghost
docker compose -f /실제/npm/compose.yaml logs \
--since 10m --tail 100 --timestamps app
docker compose logs 공식 문서를 기준으로 시간·행 수를 제한했다. NPM의 개별 Proxy Host 로그가 컨테이너 표준 출력에 모두 나타난다고 가정하지 않는다. 해당 배포에서 사용하는 요청·오류 로그도 확인한다. 글을 작성하는 이번 점검에서는 운영 서버의 내부 로그를 조회하지 않았으며, 특정 로그가 있었다고 만들어 넣지 않았다.
6. 이 사례에서는 원본 재등록으로 표시를 복구했다
mLog에서는 원본이 정상임을 확인한 뒤, 같은 이미지 파일을 새 파일명으로 업로드하고 기존 글의 대표 이미지를 바꿨다. 글을 새로 발행한 것이 아니다. 대체 텍스트를 유지하고 변경을 저장한 다음, 홈의 실제 이미지 주소와 상세의 실제 이미지 주소를 다시 확인했다.
새 목록용 주소는 HTTP 200과 600×338 WebP를 반환했고 브라우저에서도 카드 이미지가 보였다. 상세 이미지도 정상 표시됐다. 2026-11-15 재점검에서는 홈이 여전히 교체한 주소를 사용하고 있었고, 해당 이미지가 정상 파일로 수신·디코딩됐다.
이 조치로 표시가 복구됐다는 사실과 이전 주소에서 502가 발생한 내부 이유를 알아냈다는 주장은 구분해야 한다. 재등록 성공만으로 캐시 손상이나 권한 문제를 입증한 것은 아니다. 같은 증상이 반복되거나 여러 글에서 발생하면 파일명을 계속 바꾸기보다 크기별 실패 URL과 발생 시각을 모아 서버 로그를 조사한다.
이미지를 바꾸기 전에는 원본, 기존 주소, 대체 텍스트와 캡션을 보관한다. 필요하면 기존 대표 이미지를 다시 선택할 수 있게 한다. 파일 저장소나 테마를 크게 변경할 때는 Ghost·MySQL 백업 무결성 점검처럼 백업과 복원 가능성을 먼저 확인한다. 이번 복구에는 DB 수정이나 이미지 디렉터리 전체 삭제가 필요하지 않았다.
7. 복구 완료는 목록까지 확인한 뒤 판단한다
편집기에 이미지가 보이거나 상세 페이지가 정상이라는 것만으로 작업을 끝내지 않는다.
- 공개 홈에서 문제 카드가 실제로 보이는 위치까지 스크롤한다.
- 목록의
currentSrc가 의도한 주소인지 확인한다. - 이미지 요청의 최종 응답과 브라우저 디코딩 상태를 확인한다.
- 상세 페이지에도 같은 의미의 대표 이미지가 정상 표시되는지 본다.
- 모바일 화면과 관련 태그 목록도 확인한다. 화면 크기에 따라 다른 후보 주소가 선택될 수 있다.
이 글의 명령은 Linux에서 curl 8.14.1로 확인했다. 별도의 로컬 HTTP 시험 서버로 정상 이미지, 502 응답, 502 본문 보존, 200인 HTML, 리디렉션, 리디렉션 반복 제한의 6개 검사를 통과했다. 이는 진단 명령의 동작 검증이다. Ghost 이미지 변환 실패나 운영 NPM 장애를 재현한 실험은 아니며, 이 로컬 시험이 모바일 표시를 검증한 것은 아니다.
상세와 목록이 어긋날 때는 먼저 두 화면이 같은 URL을 요청하는지부터 확인하면 된다. 실패한 요청을 특정한 뒤 작은 범위로 복구하고, 목록과 상세에서 각각 결과를 확인하는 것이 이 사례의 핵심이다.