티스토리 블로그 꽃단장 가이드 및 이슈 노트(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겹이었다:
블록 누락: 태그 변수는 반드시
<s_tag_label>블록 안에 있어야 치환된다.<s_tag_label> <div class="atags"></div> </s_tag_label>블록 없이 변수만 쓰면 영원히 빈값/raw 텍스트.
길이 필터에 걸림: JS 정리 로직에서 "50자 넘는 태그는 쓰레기값"으로 버렸는데,
실제로는 사용자가 태그 입력란에NLP #자연어처리 #프롬프트엔지니어링 #...처럼
#로 이어 적은 80자짜리 태그 1개가 등록되어 있었다. 통째로 필터에 걸려 0개가 됨.입력 방식 자체의 문제: 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로 페이지네이션을 재구성했다.
- 서버가 렌더한 번호 링크에서 현재 페이지(href 없는 번호), 전체 페이지 수, URL 패턴을 수집
‹ 1 … 4 5 6 … 23 ›형태로 다시 그림 (현재 ±2 윈도우 + 첫/끝 + 줄임표)- 첫 페이지의
‹, 마지막 페이지의›는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 기본 스타일과 충돌해 깨짐.
해결:
- 파일명에서 확장자를 JS로 파싱해
data-ext속성 부여 - 확장자별 색상 칩(PDF=빨강, HWP=파랑, XLS=초록...)을 CSS로 표시
- 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. 디버깅 워크플로우 (시간 아끼는 법)
- 개발자도구 → Elements가 진실이다. 스킨 코드가 아니라 렌더된 결과를 봐라.
변수가 뭘로 치환됐는지, 빈값인지, raw로 남았는지 바로 보인다. console.log를 스킨 JS에 심어라. 우리는[hyos]접두사로 통일해서[hyos] 태그 추출 결과: []처럼 각 폴백 단계의 결과를 찍었다.- 잘 동작하는 다른 스킨과 비교하라. 같은 블로그에 기본 스킨을 잠깐 적용해보고
해당 영역의 렌더 HTML을 복사해두면, 내 스킨에서 뭐가 다른지 즉시 비교된다. - 테스트용 글을 만들어라. 표, 코드블록, 이미지, 첨부파일, 태그 여러 개,
댓글 있는 글 — 전부 들어간 글 하나면 회귀 테스트가 된다. - 스킨 저장 후 강력 새로고침(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 |