LIGHT
IT 2026. 6. 12. 21:39

티스토리 블로그 꽃단장 가이드 및 이슈 노트(w/ Claude Design)

Tistory 커스텀 스킨 제작 가이드

— 디자인 시안부터 실서비스 적용까지, 삽질 기록과 해결법

대상 독자: HTML/CSS/JS 기본기가 있는 1~3년차 개발자.
이 문서는 실제로 블로그(hyos.blog) 스킨을 디자인 시안부터 만들어 Tistory에 적용하면서
겪은 이슈와 해결 과정을 정리한 것입니다.


0. 전체 흐름 요약

1) HTML/CSS로 디자인 시안 제작 (홈 + 상세 2종)
2) 시안을 Tistory 스킨(skin.html) 구조로 변환
3) 치환자(템플릿 변수) 적용 → 업로드 → 깨지는 부분 디버깅 (대부분의 시간이 여기)
4) JS 폴백/정규화 레이어 추가로 마무리

핵심 교훈을 먼저 말하면:

  • Tistory 치환자는 문서대로 동작하지 않는 경우가 많다. 변수가 빈값으로 치환되거나, 아예 치환되지 않고 텍스트가 그대로 남기도 한다.
  • 그래서 최종 구조는 "치환자 우선 + JS 폴백"의 2중 구조가 됐다. 서버가 제대로 렌더하면 그대로 쓰고, 실패하면 JS가 DOM/RSS/모바일 페이지에서 데이터를 긁어와 채운다.
  • Tistory가 주입하는 기본 스타일과 싸우려면 !important가 생각보다 자주 필요하다.

1. 스킨 파일 구조

Tistory 스킨은 최소 두 파일이다.

파일 역할
skin.html 페이지 전체 구조. 치환자 포함
style.css 스타일 (skin.html 안에 <style>로 넣어도 동작함)

우리는 관리 편의상 skin.html 하나에 CSS/JS를 전부 인라인으로 넣었다.
업로드는 블로그 관리 → 꾸미기 → 스킨 편집 → html 편집에서 한다.

페이지 1장으로 모든 화면을 처리한다

Tistory는 홈/카테고리/태그/검색/글상세가 전부 같은 skin.html로 렌더링된다.
<s_list>(목록)와 <s_article_rep>(글)을 같은 파일에 두고, 현재 페이지 타입에 따라
Tistory가 알아서 한쪽만 채워준다. 우리는 여기에 더해 JS로 상세/목록 레이아웃을 토글했다.


2. 치환자(템플릿 변수) 기초

치환자는 형태이고, 반복/조건 블록은 <s_블록명>...</s_블록명> 태그다.

<s_list>                          <!-- 목록 페이지에서만 렌더 -->
  <s_list_rep>                    <!-- 글 개수만큼 반복 -->
    <a href=""></a>
  </s_list_rep>
</s_list>

중요한 규칙: 변수는 지정된 블록 안에서만 치환된다.
블록 밖에 변수를 쓰면 치환되지 않고 raw 텍스트가 그대로 노출된다. (아래 이슈 4 참고)


3. 겪은 이슈와 해결법

이슈 1 — 홈 목록에서 글 전문이 다 보임

증상: 홈 화면 카드에 미리보기 2줄이 아니라 본문 전체가 출력됨.

원인: 목록에서 본문 변수를 그대로 쓰면 전문이 들어온다.

해결: CSS line-clamp로 강제 제한. Tistory가 어떤 HTML을 넣어주든 2줄로 자른다.

.preview{
  display:-webkit-box;
  -webkit-line-clamp:2;
  -webkit-box-orient:vertical;
  overflow:hidden;
}
/* 본문 안의 어떤 태그가 와도 스타일 통일 */
.preview *{
  font-size:14px !important;
  margin:0 !important;
  background:none !important;
}

포인트: 미리보기 안에는 <p>, <h2>, <figure> 등 무엇이든 들어올 수 있으므로
* 셀렉터 + !important로 전부 평탄화해야 한다.

이슈 2 — 상세 페이지와 목록 페이지 구분

증상: "마지막 페이지에 글이 1개만 있으면 상세 페이지로 보임."

원인(우리 실수): 처음에 "카드가 1개면 상세"라는 휴리스틱으로 판별했다.
목록 페이지의 마지막 페이지에 글이 1개 남자 상세로 오인.

해결: Tistory 공식 변수 tt-body-page를 사용한다.

<body id="b" class="tt-body-page">

렌더되면 tt-body-index(홈), tt-body-page(글 상세), tt-body-category,
tt-body-tag, tt-body-search 같은 클래스가 붙는다.

var bodyCls = document.body.className || '';
var isDetail;
if (/tt-body-/.test(bodyCls)) {
  isDetail = /tt-body-page(\s|$)/.test(bodyCls);   // 정식 판별
} else {
  isDetail = cards.length === 1;                    // 변수 미처리 시 폴백
}

교훈: 휴리스틱을 쓰더라도 항상 "정식 방법 우선, 휴리스틱은 폴백"으로 겹쳐라.

이슈 3 — 다크모드 토글이 페이지 판별을 깨뜨림

증상: 다크모드를 켜면 상세 페이지 레이아웃이 풀림.

원인: 토글 코드가 body.className = 'dk'클래스를 통째로 덮어써서
tt-body-page 클래스가 날아갔다.

해결: classList.toggle 사용.

// BAD
document.body.className = dk ? 'dk' : '';
// GOOD
document.body.classList.toggle('dk', dk);

사소해 보이지만 실제로 한참 헤맨 버그. 남의 플랫폼 위에서는 body/root 요소의
클래스를 절대 통째로 덮어쓰지 말 것.

이슈 4 — 태그가 절대 안 나옴 (최대 난관)

증상: 글 하단 태그 영역이 항상 빈값. ,
어떤 변수를 써도 안 나옴.

원인이 3겹이었다:

  1. 블록 누락: 태그 변수는 반드시 <s_tag_label> 블록 안에 있어야 치환된다.

    <s_tag_label>
      <div class="atags"></div>
    </s_tag_label>

    블록 없이 변수만 쓰면 영원히 빈값/raw 텍스트.

  2. 길이 필터에 걸림: JS 정리 로직에서 "50자 넘는 태그는 쓰레기값"으로 버렸는데,
    실제로는 사용자가 태그 입력란에 NLP #자연어처리 #프롬프트엔지니어링 #...처럼
    #로 이어 적은 80자짜리 태그 1개가 등록되어 있었다. 통째로 필터에 걸려 0개가 됨.

  3. 입력 방식 자체의 문제: Tistory 에디터 하단 태그 입력란에서는
    태그 하나 입력 후 엔터로 구분해야 개별 태그가 된다.
    #로 이어 적으면 전부 한 덩어리 태그로 저장되고, /tag/ 검색도 덩어리째로만 잡힌다.

해결:

// '#'로 이어 적은 멀티 태그를 분리해서 개별 칩으로 표시
var add = function (t) {
  t = (t || '').trim();
  if (!t) return;
  if (t.indexOf('#') > 0 || /^#.+#/.test(t)) {
    t.split('#').forEach(function (p) {
      p = p.trim();
      if (p && !seen[p] && p.length <= 80) { seen[p] = 1; tagSet.push(p); }
    });
    return;
  }
  t = t.replace(/^#/, '');
  if (!t || seen[t] || t.length > 80) return;
  seen[t] = 1; tagSet.push(t);
};

교훈:

  • 변수가 안 나오면 먼저 해당 변수의 필수 블록(<s_...>)을 확인하라.
  • "데이터가 안 온다"고 단정하기 전에 내 필터 로직이 데이터를 버리고 있는지 의심하라.
  • 디버깅할 땐 기존(잘 동작하는) 스킨의 렌더 결과 HTML을 직접 까보는 게 가장 빠르다.
    개발자도구에서 동작하는 스킨의 태그 영역 HTML을 복사해 비교하면서 원인을 찾았다.

이슈 5 — 사이드바 태그 클라우드의 반복 단위

증상: <s_random_tags>로 감싼 TAGS 섹션이 뜨긴 뜨는데 구조가 깨지거나,
섹션 제목까지 태그 개수만큼 복제됨.

원인: <s_random_tags>반복 블록이다. 섹션 전체를 감싸면 섹션이 통째로 반복된다.

<!-- BAD: 섹션 전체가 태그 수만큼 반복됨 -->
<s_random_tags>
  <div class="ss">
    <h3>TAGS</h3>
    <div class="tc"><a href=""></a></div>
  </div>
</s_random_tags>

<!-- GOOD: 반복시킬 <a> 하나만 감싼다 -->
<div class="ss">
  <h3>TAGS</h3>
  <div class="tc">
    <s_random_tags><a href=""></a></s_random_tags>
  </div>
</div>

교훈: <s_..._rep> 류 블록을 만나면 "이 블록의 반복 단위가 무엇인가"를 먼저 정하라.

이슈 6 — 페이지네이션 링크가 href= 로 깨짐

증상: 페이지 번호를 누르면 https://blog.com/href= 같은 이상한 URL로 이동.

원인: 는 URL이 아니라 href="..." 속성 전체를 출력하는
변수다. href="[##_...]"처럼 감싸면 href="href=..."로 깨진다.

<!-- BAD -->
<a href=""></a>
<!-- GOOD -->
<a ></a>

교훈: 치환자마다 출력 형식(URL만 vs 속성 전체)이 다르다. 깨지면
렌더된 HTML을 개발자도구로 열어 변수가 뭘 뱉었는지 확인하는 게 정답.

이슈 7 — ‹ › 버튼이 없는 페이지로 이동

증상: 이전/다음 화살표를 누르면 "없는 페이지" 에러.

해결: 서버 변수에 의존하지 않고 JS로 페이지네이션을 재구성했다.

  1. 서버가 렌더한 번호 링크에서 현재 페이지(href 없는 번호), 전체 페이지 수, URL 패턴을 수집
  2. ‹ 1 … 4 5 6 … 23 › 형태로 다시 그림 (현재 ±2 윈도우 + 첫/끝 + 줄임표)
  3. 첫 페이지의 , 마지막 페이지의 pointer-events:none + 흐림 처리
var win = 2, lo = Math.max(1, cur - win), hi = Math.min(total, cur + win);
var pages = [1];
if (lo > 2) pages.push('…');
for (var i = lo; i <= hi; i++) { if (i > 1 && i < total) pages.push(i); }
if (hi < total - 1) pages.push('…');
if (total > 1) pages.push(total);

이슈 8 — 댓글 수가 항상 0

증상: 목록 카드의 댓글 카운트 변수가 4종
(rp_cnt, comment_count, comment_cnt, reply_cnt) 전부 빈값/0.

해결: RSS 폴백. Tistory RSS(/rss)의 각 <item>에는 댓글 수가 들어있다.

fetch('/rss').then(r => r.text()).then(xml => {
  var doc = new DOMParser().parseFromString(xml, 'text/xml');
  doc.querySelectorAll('item').forEach(it => {
    var link = it.querySelector('link').textContent;
    var slash = it.getElementsByTagNameNS(
      'http://purl.org/rss/1.0/modules/slash/', 'comments')[0];
    var cnt = slash ? parseInt(slash.textContent, 10) : 0;
    // link의 pathname으로 카드와 매칭해서 카운트 표시
  });
});

같은 도메인이라 CORS 문제 없음. 단, RSS 공개 설정(블로그 관리 → 콘텐츠)이 켜져 있어야 하고
RSS에 노출되는 글 수 제한이 있다는 점은 감안할 것.

이슈 9 — 신형 댓글/프로필은 React 마운트 방식

증상: 댓글 영역이 아예 비어 있음. 직접 <form>을 그려도 동작 안 함.

원인: 최신 Tistory는 댓글·프로필 카드를 React 앱으로 클라이언트에서 마운트한다.
스킨은 마운트 포인트만 제공해야 한다.

<div data-tistory-react-app="Comment"></div>

div를 두면 Tistory 스크립트가 댓글 목록+입력창을 통째로 렌더해준다.
디자인 통일은 마운트된 DOM을 CSS로 덮는 방식으로 처리:

#hyos-cmt [class*="tt_"] { font-family:'Noto Sans KR',sans-serif !important; }

주의: React가 비동기로 마운트되므로, 관련 DOM을 읽는 JS는
setTimeout 재시도(예: 0ms / 800ms / 2500ms)를 걸어야 한다.

이슈 10 — 첨부파일 영역 깨짐 + 확장자 아이콘

증상: <s_attachment> 영역이 Tistory 기본 스타일과 충돌해 깨짐.

해결:

  1. 파일명에서 확장자를 JS로 파싱해 data-ext 속성 부여
  2. 확장자별 색상 칩(PDF=빨강, HWP=파랑, XLS=초록...)을 CSS로 표시
  3. Tistory가 background를 덮어써서 칩 색상에 !important 필수였다
.file-ext-ico[data-ext="pdf"]{ background:#c0392b !important; color:#fff !important; }
.file-ext-ico[data-ext="hwp"]{ background:#2e6ab0 !important; color:#fff !important; }

이슈 11 — 본문 표(table)가 안 보임

증상: 에디터에서 만든 표가 본문에서 투명/무스타일로 렌더됨.

원인: 에디터는 <table data-ke-align="..."> 형태로 저장하는데, 스킨 CSS에
table 스타일이 전혀 없었고 리셋 CSS가 보더를 다 지워버렸다.

해결: 본문 영역(.pe) 한정으로 table 스타일을 명시.

.pe table{border-collapse:collapse;width:100%;margin:20px 0;}
.pe th,.pe td{border:1px solid var(--bd);padding:10px 14px;text-align:left;}
.pe thead th{background:var(--tb);font-weight:600;}

교훈: 본문에는 에디터가 생성하는 모든 마크업(표, 인용, 코드, 이미지 캡션...)이
들어올 수 있다. 스킨 만들 때 에디터로 온갖 요소를 넣은 테스트 글을 하나 만들어두면
디버깅이 훨씬 빨라진다.


4. 최종 아키텍처: "치환자 우선 + JS 폴백"

여러 이슈를 거치며 정착한 패턴. 모든 동적 데이터에 동일하게 적용했다.

┌─ 1순위: Tistory 치환자가 정상 렌더 → 그대로 사용
├─ 2순위: DOM에 남은 흔적(미처리 변수, 링크 등)을 JS로 정규화
├─ 3순위: 다른 소스에서 페치 (RSS, 모바일 페이지 /m/...)
└─ 실패: 해당 섹션을 깔끔하게 숨김 (빈 껍데기 노출 금지)

체크 코드 패턴:

var raw = el.innerHTML || '';
if (/\[##_/.test(raw)) {
  // 치환자가 처리되지 않음 → 폴백 또는 숨김
}

[##_ 문자열이 남아있으면 변수 미처리라는 뜻이다. 이 체크 하나로
"raw 변수 텍스트가 사용자에게 노출되는" 최악의 상황을 방지할 수 있다.


5. 디버깅 워크플로우 (시간 아끼는 법)

  1. 개발자도구 → Elements가 진실이다. 스킨 코드가 아니라 렌더된 결과를 봐라.
    변수가 뭘로 치환됐는지, 빈값인지, raw로 남았는지 바로 보인다.
  2. console.log를 스킨 JS에 심어라. 우리는 [hyos] 접두사로 통일해서
    [hyos] 태그 추출 결과: [] 처럼 각 폴백 단계의 결과를 찍었다.
  3. 잘 동작하는 다른 스킨과 비교하라. 같은 블로그에 기본 스킨을 잠깐 적용해보고
    해당 영역의 렌더 HTML을 복사해두면, 내 스킨에서 뭐가 다른지 즉시 비교된다.
  4. 테스트용 글을 만들어라. 표, 코드블록, 이미지, 첨부파일, 태그 여러 개,
    댓글 있는 글 — 전부 들어간 글 하나면 회귀 테스트가 된다.
  5. 스킨 저장 후 강력 새로고침(Ctrl+Shift+R). Tistory는 캐시가 꽤 끈질기다.

6. 치환자 빠른 참조 (이번에 실제로 쓴 것들)

치환자 / 블록 용도 함정
tt-body-page 페이지 타입 (tt-body-page 등) body class에 넣고 classList로만 조작
<s_list> / <s_list_rep> 글 목록 / 반복 반복 단위에 주의
<s_article_rep> 글 상세
<s_tag_label> + 글 태그 블록 필수. 변수 단독으론 절대 안 나옴
<s_random_tags> + 사이드바 태그 클라우드 반복 블록임 — <a> 하나만 감싸기
페이지 링크 href= 포함 속성 전체 출력. href="..."로 감싸면 깨짐
<s_attachment> 첨부파일 Tistory 기본 스타일과 충돌 → !important
data-tistory-react-app="Comment" 댓글 (신형) React 비동기 마운트. JS 재시도 필요
/rss 댓글 수 폴백 slash:comments 네임스페이스 파싱
/m/{글번호} 모바일 렌더 페이지 태그 등 데이터 폴백 소스로 활용 가능

7. 마지막 조언

  • 처음부터 Tistory 구조에 맞춰 디자인하지 마라. 순수 HTML 시안을 먼저 완성하고
    (디자인 의사결정이 자유로움), 그 다음 스킨으로 이식하는 편이 결과물이 훨씬 좋다.
  • 이식은 한 섹션씩. 헤더 → 목록 → 상세 → 사이드바 → 댓글 순으로 하나씩 옮기고
    매번 업로드해서 확인해라. 한 번에 다 옮기면 어디서 깨졌는지 못 찾는다.
  • 플랫폼과 싸우지 말고 폴백을 쌓아라. 치환자가 안 먹는 건 흔한 일이다.
    "정식 방법 → 정규화 → 대체 소스 → 숨김"의 사다리를 만들면 어떤 상황에도 깨지지 않는다.

행운을 빕니다. 🛠

'IT' 카테고리의 다른 글

블로그 꽃단장 파일 공유(w/claude)  (1) 2026.06.12
[Claude] Claude 엄청남, 엄청남, 엄청남, 평서문  (2) 2026.04.30
h

hyos

IT · 스타트업 · 개인기록. 만들고, 실패하고, 기록합니다.

COMMENTS