indistreet을 이루는 기술

로토

자기소개

  • 원래는 게임개발쪽을 공부하다,
    2009년 Java - Spring으로 웹 개발에 입문했습니다.
  • 공군 중앙전산소에서 프로그램 개발병으로 일했습니다당시 연봉 106만원





     
  • Struts 2 + Spring 2 + ibatis라는, 당시에는 꽤 상당히 힙스터 스택으로 일했었습니다.

발표자 소개

  • RIA가 유행하던 시절, 실버라이트나 플렉스를 멀리하고
    ext.js를 통해 JavaScript만으로도 충분히 리치하고 멋진
    UI를 만들 수 있음에 매료되어 JavaScript를 꾸준히 파왔습니다.
  • Front-end라는 직군이 자리잡기 시작했을 무렵, 더 이상 Java가 하기 싫어 JavaScript를 비롯한 Front-end쪽 코드를 짜는 쪽에 더 흥미를 느껴
    아예 이쪽 직군으로 전환해서 지금까지 이어오고 있습니다.

Programmers에서 JS 관련 강의를 하고

있습니다.

고양이 네 마리와
함께 살고 있습니다.

홍대에서 펑크밴드를
하고 있습니다.

구글과 유튜브에서
이디어츠를 검색해보세요!

다시 본론으로 돌아갑시다.
indistreet 소개

시작은 밴드 홈페이지에서

밴드 내에 IT 노동자가 있다면 홈페이지, SEO,
공연 데이터 기록 등에
집착하게 되는데요.

밴드 데이터를 쌓다보니 다른 밴드들의 데이터들도 쌓아두면 재밌을 거 같다는 생각이 들었습니다.

SEO 집착의 결과

예전부터 존재했던 사이트지만 운영이 중단이 되었었는데,
원작자분에게  url을 인계 받아서
2021년 2월부터 새로 만들기 시작했습니다.

국내 인디 밴드들의 정보, 공연장에 대한 정보 등을

제공합니다.

오늘 다룰 것들

Jamstack?

대충_잼이_쌓여있는_사진.png

Jamstack

  • JavaScript
  • APIs
  • Markup

그전에, 렌더링의 역사에 대해
살짝 알아봅시다.

태초에 Server Template이 있었으니

  • 정적인 HTML을 제공하는 게 아닌, Server Application에서
    여러가지 조건과 로직에 따라 HTML을 생성해서 내려주는 방식
  • Form 기반의 데이터 전송
  • UI가 복잡하지 않던 시절에는 이것으로 충분했습니다.
  • HTML은 Server에서 내려주고 브라우저에서 렌더링하지만,
    인터랙션을 넣거나 하려면 JavaScript를 Client에서
    실행하여 처리합니다.
  • 기능 구현에 따라 같은 뷰 로직이 Server side에서도 구현되어야했고 Client에서도 구현이 되어야 했었습니다.
    • ajax를 통해 데이터를 불러와 Client에서 렌더링을 추가로 하는 경우 때문이죠.
  • 결국 이렇게 렌더링 시점이 뒤섞이게 되면서, 복잡한 인터랙션의 UI일 수록
    ​고통은 가중이 됩니다.

...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>

express + handlebars sample

<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>

첫 렌더링은 Server side에서

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()
  }
})

똑같은 로직이 Server와
Client에 중복!

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>

여러가지 방법을 통해 Client side에서
Server side의 템플릿을 쓸 수는 있지만,
동작을 넣기 위해서 JS 양념을 쳐야하는
것은 피할 수 없습니다.

Client Side Rendering

  • 브라우저 성능의 발전과 JS 스펙의 고도화로, 아예 렌더링을
    Client에서 다 처리해버리자는 움직임이 등장합니다.
  • API와 웹 애플리케이션이 완전히 분리되어 있고, 웹 애플리케이션은
    HTML, CSS, JS만 있는 형태
  • 따라서 뷰 로직은 전부 Client에 있고, Server에선 API 형태로
    데이터만 내려주는 형태가 됩니다.
  • 배포는 빌드된 JS와 CSS, 그리고 HTML을 CDN에 배포하는 형식이
    ​주로 사용됩니다.
    • aws s3 + cloudfront
    • netlify
    • github pages
    • firebase hosting
    • vercel

Client Side Rendering

  • Server 상에서 실행하는 코드 없이, 브라우저에서 JS를 실행해서
    돌아가는 게 대부분이기 때문에 배포를 쉽고 빠르게 할 수 있는
    장점이 있습니다.
    • 이로 인해 별도의 서버 관리 없이 운영할 수 있습니다.
  • 애플리케이션에 트래픽이 폭발해도 Server 상에서 실행하는 코드 없이
    정적인 자원만 내려 주기 때문에 별도의 스케일 업-아웃이 필요 없습니다.
  • 별도의 Web Application Server가 없기 때문에,
    URL에 따른 처리도 전부 Client에서 하게 ​됩니다.
    • 이른바 Single Page Application이 나타난거죠.

Client Side Rendering - 문제점

  • SPA 형태로 만들어진 애플리케이션의 경우 어느 페이지를 접속해도
    매번 같은 index.html을 내려주기 때문에 meta:og 태그 관련
    ​문제가 발생합니다.
    • 별도의 Web Application Server가 없다고 했었죠?
    • 그렇기에 서버를 통해 동적으로 생성하는 부분이 없습니다.
    • aws의 경우 lambda edge, firebase의 경우 function 이용해서
      어느정도 보완은 가능합니다.
      관련 내용: ODC를 구축한 기술, Firebase functions
    • 다만 이 경우에도 한계는 명확합니다.
  • lighthouse 점수가 낮게 나오는 문제가 있습니다.
    • 초기 body가 거의 텅 비어있다가, JS가 돌면서 렌더링이 되는 방식입니다.
    • 따라서 Client에서 초기에 실행해야하는 JavaScript의 양이 많아질 수 밖에
      없는데, 이것이 점수에 큰 영향을 줍니다.
  • 메모리 누수에 취약합니다

Client Side Rendering - 문제점

다시 시대는 흘러..

Server Side Rendering

  • node.js의 발전으로 Client와 Server를 같은 언어를 쓸 수 있게 되면서
    다시 렌더링의 책임을 Server로 일부 돌리는 움직임이 나타납니다.
  • 이는 기존의 Python, Java와 같은 언어에서 하던 방식과는 근본적으로
    다른 방식으로, Client, Server에 따라 각각의 언어로 뷰 로직을
    구현해야했던 과거의 방식과는 달리 거의 대부분의 뷰 로직을
    Client와 Server에서 공유합니다.
  • 의미론적으로는 앞에서 이야기 했던 Server Template 기반의 렌더링과 같습니다.

Server Side Rendering

  • 더 이상 serverless가 아니게 되므로 server 관리 책임이 생깁니다.
  • 관리해야하는 server가 있기 때문에 트래픽이 몰릴 경우
    스케일 업, 스케일 아웃 등의 관리가 필요할 수 있습니다.
  • docker 등으로 말아서 배포할 경우 빌드 및 배포 시간이 CSR 대비해서 더
    늘어납니다.
  • 세팅이 복잡합니다.
  • CSR 경험만 있던 사람들에게는 다소 개념이 복잡하게 느껴질 수 있습니다.

그런데 누군가 이런 생각을 합니다.

SSR이든 CSR이든 콘텐츠 변경이 별로 없다면
렌더링 해둔채로 캐싱해버리면 되지 않을까?

SSR의 경우 렌더링 결과를 redis에 캐시해서 제공하는 식으로
최적화 했었는데, 이걸 그냥 HTML 파일로 만들어버린다면?

그런데 누군가 이런 생각을 합니다.

CSR이고 SSR이고 그냥 렌더링
될 수 있는 모든 경우의 수를
미리 만들어 놓아버리면 어떨까요?

Static Site Generator

  • 동적인 페이지를 포함해서 사이트의 모든 페이지를
    미리 정적으로 만들어버리는 방법
  • 모든 동적인 케이스에 따라 사이트를 만들어버리므로,
    Server가 다시 필요없게 됩니다.
    • 당연히 meta  tag 문제도 해결이 됩니다. 
  • 렌더링 해둔 결과물을 제공하기 때문에 JavaScript 실행시간도
    줄일 수 있습니다.
  • 또한 이미 생성된 정적인 페이지를 내려주므로 CDN을 통해
    제공할 수 있어, 속도에서도 큰 이득을 얻을 수 있습니다.

Static Site Generator

  • 동적으로 렌더링해야하는 컨텐츠가 많을 경우
    빌드시간이 컨텐츠 갯수에 비례해서 증가합니다.
  • 배포된 이후에 컨텐츠의 내용이 바뀌면, 변경된 내용을
    반영하려면 다시 빌드하고 배포해야 합니다.

Distributed Persistent Rendering

  • SSG의 단점을 보완하기 위한 방법으로, 정적 생성을 하는 점은 동일합니다.
  • 일부 페이지만 정적으로 생성하고, 비교적 중요하지 않은 페이지는 최초 접근 시
    렌더링하고 그 결과를 정적 파일로 만들어버리는 개념입니다.
    • 이렇게 하면 모든 페이지를 미리 빌드할 필요가 없어 빌드 시간이 많이 줄어들게
      됩니다.
  • 이 경우 빌드하는 순간 때문에 Server가 필요하긴 하지만, SSR 보다는 Server 의존도가 낮습니다.

Incremental Static Regeneration

  • next.js에서는 DPR을 ISR이라는 개념으로 지원합니다.
  • 정적으로 생성되는 페이지에 필요시 revalidate 라는 값으로 유효시간을 설정합니다.
  • 해당 값이 지난 이후 요청이 들어오면. 최신 데이터를 반영해
    다시 렌더링 합니다.
    • 이렇게 하면 콘텐츠의 변경이 실시간으로 반영이 되진 않지만,
      다시 빌드하고 배포를 해야하는 부담이 많이 사라집니다.
    • revalidate를 강제로 시킬 수 있는 기능도 추가됐습니다.

앞에서 이야기했던 렌더링 흐름에 대해 생각해봅시다.

  • indistreet은 현재 next.js 기반으로 되어 있습니다.
  • next.js의 getStaticProps를 이용해 SSG와 ISR 중심으로
    사용하고 있습니다.
  • 각 페이지 당 2~3분 정도의 캐시타임을 적용해두었습니다.
    • strapi의 webhook을 이용해 콘텐츠가 변경되었을 경우
      페이지를 다시 빌드하도록 처리하고 있습니다.

headless cms

CMS는 Content Management System을 말합니다.

wordpress나 제로보드 등을 생각하면 편해요.

headless는 Content를 표현하는 별도의 화면이 없음을 이야기합니다.

headless chrome이라는 이야기 들어보셨나요?

즉, headless cms라고 하면 콘텐츠를 관리하는 도구이지만,
해당 콘텐츠를 표현하는 특정 화면은 없다.. 라고 생각하시면 됩니다.

headless cms로 나온 도구는 아니지만, REST API 등으로 Content에
접근 가능하게 만든 경우에도 headless cms로 치기도 하는 거 같습니다.

 

  • 어드민에서 클릭 몇번으로 모델을 생성하고, 필드를 추가하고 할 수 있습니다.
    • 이렇게 만들어진 모델은 REST 기반으로 CRUD API가 자동으로 생깁니다.
  • 기본으로 제공되는 어드민의 완성도가 높습니다.
  • graphql을 기본 플러그인으로 지원합니다.
  • vercel을 통해 배포하고 있습니다.
  • SSG 혹은 ISR 된 파일의 경우, vercel을 이용하면
    알아서 CDN 처리를 해줍니다.
  • 캐시 만료 후 다시 빌드가 필요할 때는 aws lambda 형태로
    돌아가기 때문에 Server 부담이 적습니다.
  • PR 별로 배포본이 따로 생기기 때문에 기능별 테스트를
    쉽게 할 수 있습니다.
  • 대부분의 페이지에서 graphql을 통해 페이지 렌더링에 필요한 데이터를
    명시하고 있습니다.
  • 기존 REST API에서는 상황에 따라 한 페이지를 렌더링 하기 위해 여러개의
    API 호출이 필요했지만, graphql 환경에서는 한번에 다 가져오므로 뷰 로직이
    더욱 더 단순해집니다.
  • Apollo Client를 통해 로컬에서 쿼리 결과를 캐싱합니다.
  • graphql-codegen을 통해 TypeScript의 타입, 쿼리 등을 자동으로 생성합니다.
  • Server Side에서 렌더링에 필요한 쿼리를 미리 호출해놓고 캐싱합니다.
  • 이후 렌더링 흐름에 따라 해당 쿼리 데이터가 필요한 곳에서는 쿼리를 호출하는
    hooks를 호출합니다.
  • 이때, 캐시된 쿼리 결과를 불러오기 때문에 쿼리는 1회만 호출이 됩니다.
    • useSWR, react-query의 경우를 생각해보시면 됩니다.

보너스: graphql codegen

graphql query 기반으로 TypeScript의 타입 정의와 apollo 호출을
위한 react hooks를 자동으로 만들어줍니다.

이렇게 정의해 둔 graphql query는

indistreet app에서 그대로 재사용 하고 있습니다. 

보너스: stellate

graphql end point 기준으로 쿼리별 응답을
통으로 캐시해줍니다.

indistreet에서 ISR에 대한 흐름
훑어보기

공연 상세 페이지를 예시로

.. 이전 코드 생략

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 })
}

결론

  • 빠른 웹 페이지 렌더링을 위해서는  CSR, SSR 양쪽에 대한 이해가
    필수입니다.
  • SSG와 ISR을 활용하는 것은 SSR 시절 렌더링 결과를 별도로 캐시하던 것을 아예 정적인 파일로 캐싱한다고 이해하면 편합니다.
  • next.js와 vercel을 조합하면 빌드 및 배포에 많은 시간을 절약할 수 있습니다.
  • next.js의 경우, 첫 렌더링은 SSR or SSG로 생성된 결과물을 내려주고
    이후 페이지 이동은 SPA 처럼 동작하기 때문에 SSR의 장점과 SPA의 반응성을
    모두 누릴 수 있습니다.
  • strapi + graphql 플러그인을 통해 Back-end Application에 들이는 시간을
    줄일 수 있습니다.

TODO

  • SSG로 모든 페이지를 대응할 수는 없습니다.
  • 대응이 힘든 페이지들의 경우, React Server Component를 도입할
    예정입니다.
    • 대표적으로 아티스트 목록 페이지, 공연 목록 페이지 등이 있습니다.
    • 이런 곳은 querystring에 따라 최초 렌더링 할 페이지를 결정해야 합니다.
    • 그래서 이 부분은 현재 SSR로 되어 있습니다.

여러분은 어떤 서비스를 만들고 싶으신가요?

Q&A

감사합니다!

indistreet 많이 써주세요!!