로토
밴드 내에 IT 노동자가 있다면 홈페이지, SEO,
공연 데이터 기록 등에
집착하게 되는데요.
밴드 데이터를 쌓다보니 다른 밴드들의 데이터들도 쌓아두면 재밌을 거 같다는 생각이 들었습니다.
SEO 집착의 결과
예전부터 존재했던 사이트지만 운영이 중단이 되었었는데,
원작자분에게 url을 인계 받아서
2021년 2월부터 새로 만들기 시작했습니다.
국내 인디 밴드들의 정보, 공연장에 대한 정보 등을
제공합니다.
대충_잼이_쌓여있는_사진.png
JavaScript
APIs
Markup
...so many many many more...
Header
LiveList
LoadMoreButton
<html>
<head>
<title>이디어츠의 공연 목록</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/public/style.css">
</head>
<body>
<div class="app">
<header>
밴드 이디어츠 공연 일정
</header>
<div class="container">
<div class="grid">
{{#each lives}}
<a href={{link}}>
<div class="item">
<img src="{{posterUrl}}" />
<div class="description">
<div class="title">{{title}}</div>
<div class="place">{{date}} / {{club.name}}</div>
</div>
</div>
</a>
{{/each}}
</div>
<button class="load-more-button">Load More!!</button>
</div>
</div>
<script>
let page = 1
let isLoading = false
const $loadMoreButton = document.querySelector('.load-more-button')
const $grid = document.querySelector('.grid')
const enableLoading = () => {
isLoading = true
$loadMoreButton.setAttribute('disabled', '')
$loadMoreButton.textContent = 'Loading...'
}
const disableLoading = () => {
isLoading = false
$loadMoreButton.removeAttribute('disabled')
$loadMoreButton.textContent = 'Load More!!'
}
const fetchNextLives = async () => {
page = page + 1
const res = await fetch(`https://admin.idiots.band/lives?_limit=6&_start=${page * 6}`)
const result = await res.json()
const lives = result.map(live => ({
bands: live.bands,
title: live.title,
club: live.club,
date: live.date,
link: `https://idiots.band/live/${live.slug}/`,
posterUrl: `https://admin.idiots.band${live.posters[0].url}`,
}))
renderLives(lives)
}
const renderLives = (lives) => {
const htmlString = lives.map(live => {
return `
<div class="item">
<img src="${live.posterUrl}" />
<div class="description">
<div class="title">${live.title}</div>
<div class="place">${live.date} / ${live.club.name}$</div>
</div>
</div>
`
}).join('')
$grid.innerHTML = $grid.innerHTML + htmlString
}
$loadMoreButton.addEventListener('click', () => {
if (!isLoading) {
fetchNextLives()
}
})
</script>
</body>
</html>
<div class="grid">
{{#each lives}}
<a href={{link}}>
<div class="item">
<img src="{{posterUrl}}" />
<div class="description">
<div class="title">{{title}}</div>
<div class="place">{{date}} / {{club.name}}</div>
</div>
</div>
</a>
{{/each}}
</div>
express
<div class="grid">
<a href=https://idiots.band/live/idiots-1st-ep/>
<div class="item">
<img src="https://admin.idiots.band/uploads/e0508d58eff745d1b91d126e0271c57c.jpg" />
<div class="description">
<div class="title">이디어츠 1st EP 발매기념 공연</div>
<div class="place">2019-01-19 / Club Sharp</div>
</div>
</div>
</a>
<a href=https://idiots.band/live/live-in-nov-2019/>
<div class="item">
<img src="https://admin.idiots.band/uploads/e254b553e6064a059bfde51e975cbb5f.jpg" />
<div class="description">
<div class="title">LIVE in NOV 2019</div>
<div class="place">2019-11-28 / Club FF</div>
</div>
</div>
</a>
<a href=https://idiots.band/live/live-in-dec-2019/>
<div class="item">
<img src="https://admin.idiots.band/uploads/f0d39298e857495c843a78d3cd216ed9.jpg" />
<div class="description">
<div class="title">LIVE in DEC 2019</div>
<div class="place">2019-12-12 / Club FF</div>
</div>
</div>
</a>
<a href=https://idiots.band/live/o-rot-han-live/>
<div class="item">
<img src="https://admin.idiots.band/uploads/337773b57bd04e81b7a0314af5b0a7d1.jpg" />
<div class="description">
<div class="title">오롯한 라이브와 함께</div>
<div class="place">2020-01-09 / Club 빵</div>
</div>
</div>
</a>
<a href=https://idiots.band/live/live-in-feb-2020/>
<div class="item">
<img src="https://admin.idiots.band/uploads/1e10bed885c54225a733097fd73cd5d2.jpg" />
<div class="description">
<div class="title">LIVE in FEB</div>
<div class="place">2020-02-06 / Club FF</div>
</div>
</div>
</a>
<a href=https://idiots.band/live/2020-02-bbang/>
<div class="item">
<img src="https://admin.idiots.band/uploads/bf132f0c488a42e68f24b7f3e2651d82.jpg" />
<div class="description">
<div class="title">2020.02.13 클럽빵 공연</div>
<div class="place">2020-03-13 / Club 빵</div>
</div>
</div>
</a>
</div>
const fetchNextLives = async () => {
page = page + 1
const res = await fetch(`https://admin.idiots.band/lives?_limit=6&_start=${page * 6}`)
const result = await res.json()
const lives = result.map(live => ({
bands: live.bands,
title: live.title,
club: live.club,
date: live.date,
link: `https://idiots.band/live/${live.slug}/`,
posterUrl: `https://admin.idiots.band${live.posters[0].url}`,
}))
renderLives(lives)
}
const renderLives = (lives) => {
const htmlString = lives.map(live => {
return `
<a href="${live.url}">
<div class="item">
<img src="${live.posterUrl}" />
<div class="description">
<div class="title">${live.title}</div>
<div class="place">${live.date} / ${live.club.name}$</div>
</div>
</div>
</a>
`
}).join('')
$grid.innerHTML = $grid.innerHTML + htmlString
}
$loadMoreButton.addEventListener('click', () => {
if (!isLoading) {
fetchNextLives()
}
})
const renderLives = (lives) => {
const htmlString = lives.map(live => {
return `
<a href="${live.url}">
<div class="item">
<img src="${live.posterUrl}" />
<div class="description">
<div class="title">${live.title}</div>
<div class="place">${live.date} / ${live.club.name}$</div>
</div>
</div>
</a>
`
}).join('')
$grid.innerHTML = $grid.innerHTML + htmlString
}
<div class="grid">
{{#each lives}}
<a href={{link}}>
<div class="item">
<img src="{{posterUrl}}" />
<div class="description">
<div class="title">{{title}}</div>
<div class="place">{{date}} / {{club.name}}</div>
</div>
</div>
</a>
{{/each}}
</div>
SSR이든 CSR이든 콘텐츠 변경이 별로 없다면
렌더링 해둔채로 캐싱해버리면 되지 않을까?
SSR의 경우 렌더링 결과를 redis에 캐시해서 제공하는 식으로
최적화 했었는데, 이걸 그냥 HTML 파일로 만들어버린다면?
CMS는 Content Management System을 말합니다.
wordpress나 제로보드 등을 생각하면 편해요.
headless는 Content를 표현하는 별도의 화면이 없음을 이야기합니다.
headless chrome이라는 이야기 들어보셨나요?
즉, headless cms라고 하면 콘텐츠를 관리하는 도구이지만,
해당 콘텐츠를 표현하는 특정 화면은 없다.. 라고 생각하시면 됩니다.
headless cms로 나온 도구는 아니지만, REST API 등으로 Content에
접근 가능하게 만든 경우에도 headless cms로 치기도 하는 거 같습니다.
보너스: graphql codegen
graphql query 기반으로 TypeScript의 타입 정의와 apollo 호출을
위한 react hooks를 자동으로 만들어줍니다.
이렇게 정의해 둔 graphql query는
indistreet app에서 그대로 재사용 하고 있습니다.
보너스: stellate
graphql end point 기준으로 쿼리별 응답을
통으로 캐시해줍니다.
공연 상세 페이지를 예시로
.. 이전 코드 생략
export const getStaticProps: GetStaticProps<{ liveId: string }> = async ({ params }) => {
const liveId = getParamsValue(params, 'liveId')
if (isNil(liveId)) {
return {
notFound: true,
}
}
const apolloClient = initializeApollo({})
const res = await apolloClient.query<FindOneLiveQuery>({
query: FindOneLiveDocument,
variables: {
id: liveId,
},
})
if (!res.data.live) {
return {
notFound: true,
}
}
return {
props: {
initialApolloState: apolloClient.cache.extract(),
liveId,
},
revalidate: 60 * 5,
}
}
export default function LiveDetailPage({ liveId }: InferGetStaticPropsType<typeof getStaticProps>) {
const { data } = useFindOneLiveQuery({
skip: !liveId,
variables: {
id: liveId,
},
})
... 코드 생략
}
/live/{id} 페이지로 접근 시, 렌더링 된 페이지가 있으면
해당 페이지를 바로 내려줍니다.
없거나 revalidate 시간이 초과된 경우 백그라운드에서
해당 페이지를
다시 렌더링하고 생성합니다.
현재 코드에선 5분이 기준입니다.
강제 revalidate 시키기
strapi 데이터 변경 시 api 호출
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
...
// stellate 사용 중이면 요쪽 캐시도 날려줘야 함
const result = await axios.post(
GRAPHCDN_PURGE_URL,
{
query: `
mutation {
${purgeMutation}(soft: true, id: [${entry.id}])
}
`,
},
{
headers: {
'graphcdn-token': GRAPHCDN_PURGE_KEY,
},
}
)
// revalidate 조건에 맞으면 아래 호출로 캐시 날릴 수 있음
await res.revalidate('/live/2893')
return res.json({ revalidated: true })
}
indistreet 많이 써주세요!!