1. Introduction
📌 Motivation
문득 최근 블로그 포스팅 목록을 보여주는 뱃지를 깃헙에 두고 싶다는 생각을 하게 됐다.
구글에 이미 만들어둔 사람이 있긴 했으나, 아이콘만 보여주거나, 보여줄 게시물을 직접 명시해줘야 한다는 단점이 있어서 그냥 내가 만들기로 결정했다.
원래는 누구나 사용할 수 있도록 만들고 싶었으나, 크롤링할 때 블로그 템플릿마다 html 태그가 다르길래
깔끔하게 포기하고 hELLO 티스토리 스킨 한정으로 구현했다.
GitHub - psychology50/github-tistory-badge: 🔖 티스토리 정보를 github readme에 띄워보자
🔖 티스토리 정보를 github readme에 띄워보자. Contribute to psychology50/github-tistory-badge development by creating an account on GitHub.
github.com
모든 코드는 위에서 확인 가능합니다.
2. Design
📌 Usecase
- 클라이언트로부터 블로그 닉네임을 받으면, 해당 블로그의 최근 포스팅 정보(카테고리, 작성일, 제목) 4개를 UI에 보여준다.
- 사용자는 원하는 테마를 지정할 수 있다.
허허, 심플하기 그지없다.
뱃지 만드는 게 처음이라 불필요하게 기능을 불필요하게 늘리지 않는 게 목적이었다.
사용은 여타 다른 Badge들과 다를 것 없이, 다음과 같이 사용하도록 만들 것이다.
![Tistory](https://{api 경로}/api/badge?name=your-blog-name)
📌 Architecture
구조도 단순하기 그지 없는데, 누군가 github을 방문하면 크롤러한테 svg 파일을 반환받으면 된다.
Github은 보안상 이유로 HTML, JS를 허용하지 않는다고 알고 있기에, 동적 컨텐츠를 보여주려면 svg를 반환하는 게 최선이라고 판단했다.
문제는 api를 어디에 배포하냐는 건데, 일단 기능이 워낙 단순해서 serverless 형태로 구성할 수 있다.
그래서 AWS Lambda를 쓸까 했는데, 전부터 프론트 팀이 사용한다던 vercel을 써보고 싶어서 이걸 쓰기로 결정했다.
깃헙 방문자가 많을 수록 Tistory 크롤링 빈도가 잦아질 텐데, 아무래도 그러면 카카오가 날 싫어할 거 같았다.
그래서 cache 정책도 고민해볼 필요가 있다.
(vercel kv 사용하려고 했지만, Upstatsh KV라는 걸로 대체되었다길래 귀찮아서 안 쓰기로 했다.)
📌 Tech Stack
- 애플리케이션: Typescript + Next v15.1.6
- 크롤러: cheerio v1.0.0
- 배포 환경: vercel
예전에 js로 크롤링하다가 된통 당한 적이 있어서, fast api를 사용해야하나 정말 고민을 많이 했었다.
그런데 다른 프로젝트에서 이미 신나게 사용 중인데, 이런 때 아니면 언제 또 next 다뤄볼까 싶어서 요걸 사용했다. (typescript도 진짜 써보고 싶었다!!)
그리고 배포 환경을 vercel로 할 거면, next가 훨씬 통합이 쉬울 거 같은데, 그렇다고 fast api가 호환성이 안 좋냐고 묻는다면 거기까진 잘 모르겠다.
📌 Directory Structure
github-tistory-badge/
├── src/
│ ├── pages/
│ │ └── api/ # API 엔드포인트
│ │ └── badge.ts # 뱃지 생성 API
│ │
│ └── lib/ # 유틸리티 함수들
│ ├── types.ts # 타입 정의
│ ├── crawler.ts # 크롤링 로직
│ └── badge.ts # SVG 뱃지 생성
│
├── public/ # 정적 파일들
│ └── error-badge.svg # 에러시 기본 뱃지
│
├── package.json
├── tsconfig.json
└── next.config.js
프로젝트 기본 구조는 위와 같다.
원래 처음 설계는 node.js 시절 생각하면서 만들었었는데,
next는 프로젝트 만들자마자 기본으로 src 잡고 시작하길래 적당히 어울려주다보니 이렇게 됐다.
3. API
📌 Project Setting
우선 프로젝트를 시작하려면, next 프레임워크를 받아와야 한다.
1️⃣ 프로젝트 생성
npx create-next-app@latest ${프로젝트_이름}
위 명령어를 실행하면 기본 파일들을 알아서 다 생성한다.
설치받을 때 CLI에서 옵션 설정을 할 수 있는데, 나는 다음과 같이 설정했다.
- TypeScript → Yes
- ESLint → Yes
- Tailwind CSS → No
- use `src/` directory? → Yes
- Turbopack → No
- App Router → No
- customize the default import alias → No
어디까지나 초간단 애플리케이션 구현이 목적이라, 필요 없는 건 다 제외했다.
2️⃣ 추가 라이브러리 설치
npm install cheerio # 크롤러
npm install --save-dev @types/webpack # web pack
npm install -g vercel # vercel CLI
여기까지 하면 설정은 모두 끝난다.
📌 Type
// src/lib/types.ts
export interface BlogPost {
title: string;
url: string;
date: string;
category: string;
summary: string;
}
export type Theme = 'default' | 'dark' | 'vue' | 'blue' | 'kakao';
우선 뱃지에 필요한 정보와 뱃지 테마를 정해주었다.
summary는 지금 생각해보니 필요도 없는데, 왜 넣었는지 모르겠다.
대충 필요한 정보들을 정의해주자.
📌 Crawler
크롤링을 하기 전에 Tistory에서 어떻게 정보를 반환하는 지 알아야 한다.
여러 블로그들을 방문해보니, 모두 https://{username}.tistory.com/ 으로 GET 요청을 보내서 정보를 받아오고 있었다.
응답 body를 살펴보면, 내가 원하는 정보들이 모두 들어가 있다는 것을 알 수 있다.
다만, 블로그 스킨을 뭘 사용하냐에 따라서 html 구조와 class 변수 이름이 조금씩 다르니 주의하자.
혹시나 CORS 설정같은 게 있을까 싶어서 커맨드로 보내봤는데, 아무런 문제 없이 응답을 받아온다.
그렇다면, 사용자 닉네임만으로도 충분히 블로그 정보들을 모두 가져올 수 있다는 말이 된다.
import { BlogPost } from './types';
import * as cheerio from 'cheerio';
export async function crawlBlog(name: string): Promise<BlogPost[]> {
try {
// 티스토리 블로그 URL 생성 (예: https://jaeseo0519.tistory.com)
const url = `https://${name}.tistory.com`;
// fetch API를 사용하여 블로그 HTML 가져오기
const response = await fetch(url);
// HTTP 응답이 성공적이지 않은 경우 에러 throw
if (!response.ok) {
throw new Error(`❌ 블로그 주소(${url})에 접근할 수 없습니다.`);
}
// HTML 문자열로 변환
const html = await response.text();
// cheerio로 HTML 파싱
const $ = cheerio.load(html);
const posts: BlogPost[] = [];
// 최근 4개의 포스트만 선택하여 순회
$('.post').slice(0, 4).each((_, element) => {
const $post = $(element);
// 포스트 링크 추출 (없으면 빈 문자열)
const link = $post.find('a.link').attr('href') || '';
// BlogPost 타입에 맞게 데이터 추출 및 객체 생성
const post: BlogPost = {
title: $post.find('div.tit').text(), // 제목
url: link.startsWith('http') ? link : `${url}${link}`, // 절대 URL 또는 상대 URL을 절대 URL로 변환
date: $post.find('.date').text(), // 작성일
category: $post.find('.category').text(), // 카테고리
summary: $post.find('.summary').text(), // 요약
};
posts.push(post);
});
return posts;
} catch (error) {
// 에러 발생 시 콘솔에 로깅하고 상위로 에러 전파
console.error("❌ 크롤링 중 오류 발생:", error);
throw error;
}
}
짜잔, 테스트 코드로 확인해보고 싶었지만, 시간을 많이 쏟을 계획은 없으므로 next 애플리케이션 실행해서 데이터를 잘 받아오는 걸 직접 확인했다.
사실 이 부분만 본인 블로그 형식에 맞게 잘 수정한다면, 누구나 본인 블로그에 맞춰서 구현할 수 있다.
📌 UI
크롤링 결과를 받아서, svg 형태로 바꿔주는 기능을 만들어야 한다.
애초에 UI 만드는 건 별로 관심이 없는 영역이라, 대충 원하는 UI를 손으로 그린 후에 클로드한테 떠넘겼다.
사이즈를 맞출 때 팁이 있다면, github과 내가 만든 UI를 같은 사이즈로 맞췄을 때 어떻게 보이는 지 확인하면 좋다.
처음에 계속 이렇게 나왔는데, 여기서 글씨가 육안으로 안 보일 정도면 수정이 필요한 상태임을 의미한다.
그리고 cheerio로 html을 파싱했더니, 공백문자나 특수 기호들이 svg가 이해하지 못 해서 에러가 발생했었다.
function escapeXml(unsafe: string): string {
return unsafe.replace(/[&<>"']/g, (char) => {
switch (char) {
case '&': return '&';
case '<': return '<';
case '>': return '>';
case '"': return '"';
case "'": return ''';
default: return char;
}
});
}
그 때는 위 함수를 이용해서 전처리를 해주면 끝난다.
👇 전체 코드
import { BlogPost, Theme } from './types';
interface ThemeColors {
bg: string;
border: string;
text: string;
link: string;
}
const ThemeColors: Record<Theme, ThemeColors> = {
default: {
bg: '#ffffff',
border: '#e4e2e2',
text: '#333333',
link: '#0366d6'
},
dark: {
bg: '#2f3542',
border: '#4a4a4a',
text: '#ffffff',
link: '#58a6ff'
},
vue: {
bg: '#42b883',
border: '#35495e',
text: '#35495e',
link: '#35495e'
},
blue: {
bg: '#007ec6',
border: '#005a8d',
text: '#ffffff',
link: '#ffffff'
},
kakao: {
bg: '#f9e000',
border: '#ffcd00',
text: '#3d3d3d',
link: '#3d3d3d'
}
};
function escapeXml(unsafe: string): string {
return unsafe.replace(/[&<>"']/g, (char) => {
switch (char) {
case '&': return '&';
case '<': return '<';
case '>': return '>';
case '"': return '"';
case "'": return ''';
default: return char;
}
});
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
function generatePostItem(post: BlogPost, index: number, colors: ThemeColors): string {
const escapedTitle = escapeXml(truncateText(post.title, 65));
const escapedCategory = escapeXml(truncateText(post.category, 35));
return `<g transform="translate(0, ${index * 75})">
<!-- 호버 배경 -->
<rect x="-10" y="-20" width="830" height="65"
fill="transparent"
rx="4">
<animate attributeName="fill"
from="transparent" to="${colors.border}22"
begin="mouseover" dur="0.2s" fill="freeze"/>
<animate attributeName="fill"
from="${colors.border}22" to="transparent"
begin="mouseout" dur="0.2s" fill="freeze"/>
</rect>
<!-- 카테고리 (상단) -->
<text font-family="Arial" font-size="18" fill="${colors.text}" opacity="0.7">
${escapedCategory}
</text>
<!-- 날짜 (상단 우측) -->
<text x="820" y="0" text-anchor="end" font-family="Arial" font-size="18"
fill="${colors.text}" opacity="0.7">
${post.date}
</text>
<!-- 제목 (하단) -->
<a href="${post.url}" target="_blank">
<text font-family="Arial, sans-serif" font-size="20" fill="${colors.link}"
x="0" y="40">
${escapedTitle}
</text>
</a>
</g>`;
}
export function generateBadge(posts: BlogPost[], theme: Theme = 'default'): string {
const colors = ThemeColors[theme];
return `<svg xmlns="http://www.w3.org/2000/svg" width="880" height="400"
viewBox="0 0 880 400">
<!-- 배경 -->
<rect width="880" height="400" rx="10" fill="${colors.bg}" stroke="${colors.border}" stroke-width="1.5"/>
<!-- 헤더 -->
<text x="30" y="50" font-family="Arial" font-size="24" font-weight="bold" fill="${colors.text}">
🔥 Recent Blog Posts
</text>
<!-- 구분선 -->
<line x1="30" y1="70" x2="850" y2="70"
stroke="${colors.border}" stroke-width="1.5" opacity="0.5"/>
<!-- 포스트 목록 -->
<g transform="translate(30, 110)">
${posts.map((post, index) => generatePostItem(post, index, colors)).join('')}
</g>
</svg>`;
}
export function generateErrorBadge(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100">
<rect width="400" height="100" rx="10" fill="#ff5555" stroke="#ff0000" stroke-width="2"/>
<text x="200" y="55" text-anchor="middle" font-family="Arial" font-size="16" fill="#ffffff">
Failed to load blog posts
</text>
</svg>`;
}
📌 API
핵심 기능들은 다 만들었으니, 클라이언트에게서 요청/응답을 처리해줄 port만 열어주면 끝난다.
- 입력
- 블로그 닉네임 (필수)
- 테마 (설정 안 하면 기본값 default)
- 출력
- svg 형식의 뱃지
// 요청 유효성 검사
function validateRequest(req: NextApiRequest): { name: string; theme: Theme } | null {
if (req.method !== 'GET') return null;
const name = req.query.name as string;
const theme = (req.query.theme as Theme) || 'default';
if (!name || Array.isArray(name)) return null;
return { name, theme };
}
// 뱃지 생성
async function handleBadgeGeneration(name: string, theme: Theme) {
const posts = await crawlBlog(name);
return generateBadge(posts, theme);
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
// 1. 요청 유효성 검사
const validation = validateRequest(req);
if (!validation) {
res.setHeader('Content-Type', 'image/svg+xml');
return res.status(400).send(generateErrorBadge());
}
const { name, theme } = validation;
// 2. svg 생성
const svg = await handleBadgeGeneration(name, theme);
// 3. 반환
res.setHeader('Content-Type', 'image/svg+xml');
res.send(svg);
} catch (error) {
console.error('뱃지 생성 중 ❌ 오류 발생:', error);
res.setHeader('Content-Type', 'image/svg+xml');
res.status(500).send(generateErrorBadge());
}
}
별로 어려운 내용이 없어서, 뭘 설명해야 할 지도 모르겠다. 허허
📌 cache
github을 방문할 때마다 tistory를 크롤링할 수는 없으니, 캐싱이 필요한 건 당연하다.
이를 위해서 vercel kv를 사용하면 될 거라고 생각해서 맘 놓고 있었는데, 나중에 막상 사용하려고 보니
Vercel KV가 마켓 플레이스로 이전되면서, 새로운 프로젝트는 직접 Upstash KV를 설정해야 한다고 한다.
근데...진짜 귀찮고 하기 싫었다.
어차피 해당 api는 HTTP 프로토콜의 GET 요청밖에 존재하질 않는다.
그래서 그냥 GET 메서드 자체의 캐시 기능을 사용하는 게 훨씬 효율적이라고 판단해서 노선을 틀었다.
예를 들어, API에서 응답을 돌려줄 때 헤더를 다음과 같이 설정하는 것이다.
// 브라우저 캐시 1분 + 1시간 동안 stale-while-revalidate
res.setHeader('Cache-Control', 'max-age=60, stale-while-revalidate=3600');
그런데 이걸 알아보다가 재밌는 걸 찾았는데,
vercel에 배포를 했다면, 나는 기본적으로 Edge Network를 사용하도록 동작한다고 한다.
(CDN까지 퍼주면 vercel은 대체 뭐가 남나요 ㅠㅠ)
공식 문서의 Cache-Control Headers에는 CDN을 더 잘 사용하는 방법도 알려주고 있다.
- s-maxage
- Edge Network의 캐시 유효 기간 설정
- 1초에서 1년(31536000초)까지 가능
- Edge Network에서만 해석되고, 클라이언트로는 전달되지 않음
- stale-while-revalidate
- 캐시가 만료되어도 백그라운드에서 새로운 컨텐츠를 가져오는 동안 기존 캐시 제공
- 콘텐츠 갱신이 느리거나, 자주 변경되지 않는 경우에 유용
- 마찬가지로 Edge Network에서만 해석됨
맛있어 보이는데, 사용하지 않을 이유가 없다.
vercel에서 권장하는 설정은 다음과 같다.
max-age=0, s-maxage=86400
max-age를 0으로 잡는 이유는 브라우저가 캐시를 못 하게 막기 위함이다.
그럼 언제나 Edge Network까지 요청이 도달할 것이고, 애플리케이션을 새로 배포했을 때 cache를 사용하는 불상사를 막을 수 있다고 한다.
확인해보면 브라우저에서 캐싱을 안 하고 있으므로 무조건 cache miss가 발생하긴 하나,
CDN에서 캐싱 응답을 반환하므로 304 Not Modified를 반환하고 있다.
그런데 배포해보면 알겠지만, 진짜 별의 별 곳에서 계속 요청해대서 묘하게 거슬린다.
난 서버에 요청되는 횟수를 최소화하고 싶기 때문에 max-age를 추가로 설정해주었다.
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
그럼 이제 브라우저에서도 캐싱을 하므로, X-Vercel-Cache에서 HIT가 발생하는 걸 확인할 수 있다.
4. Deployment
📌 Vercel Login
Vercel: Build and deploy the best web experiences with the Frontend Cloud – Vercel
Vercel's Frontend Cloud gives developers the frameworks, workflows, and infrastructure to build a faster, more personalized web.
vercel.com
vercel에 회원가입하고, 프로젝트 연동을 해주자.
그냥 딸깍 몇 번하면 될 정도로 간단하게 만들어놨다.
vercel이 처음이면 그냥 웹 페이지에서 연동하는 게 편하다.
이미 사용해본 사용자라면 커맨드 명령어에 vercel만 치면 된다.
프로젝트 연동이 완료되면, Overview에서 확인할 수 있다.
📌 Deploy
배포만큼 간단한 게 없다.
vercel이 github 프로젝트를 연동했기 때문에, 그냥 브랜치 변경사항이 발생하면 알아서 배포까지 해준다.
📌 Use
사용할 때는 다음과 같이 마크다운에 추가해주면 된다.
![Tistory](https://{vercel api 경로}/api/badge?name=jaeseo0519&theme=dark)
name을 넣으면 이를 기반으로 블로그 정보를 추론할 것이고, theme은 설정 안 하면 default 값을 갖는다.
vercel 도메인 주소는 내 프로젝트에서 확인 가능하다.
5. Conclusion
📌 Fun but Dull
typescript도 써보고, next도 처음 다뤄본데다, 빠르게 badge 하나 만들어서 github에 게시해 놓으니 뿌듯하긴 하다.
근데 생각보다 시시한 작업이어서 별 감흥은 안 생긴다.
그래도 badge 스스로 만들어 볼 사람이 있으면 참고가 되길 바라서 포스팅으로 남겨두고 끝!