포트폴리오

티스토리 스킨 프레임워크, 티도리는 어떻게 동작할까?

[티도리 프레임워크 개발 - 1부]

 

티도리 프레임워크의 대략적인 개요는 설명하지 않는다. 티도리 프레임워크 개발 리뷰 포스트는 티도리 프레임워크의 그 내부와 내가 왜 프레임워크를 이렇게 구성했는지 의도는 물론이고 기술적인 부분도 설명한다. 티도리 프레임워크란게 애초에 나 혼자 개인이 만든 것이고 그 용도 또한 티스토리 스킨 개발로 극히 타겟팅을 분명히 하고 있어서 사용층은 적은 편이라 사실 이 포스트가 도움이 될까 싶기는 하지만, 포트폴리오 용도이니 상관없을 것 같아서 그냥 적기로 했다.

 

https://tidory.com 

 

티스토리 스킨 프레임워크, 티도리(TIDORY)

오직 티스토리 스킨만을 위한 프레임워크

tidory.com

프레임워크의 동작을 알아보기 전에, 티도리 프레임워크를 구성하는 기술들과 레포들을 살펴보면 좋다. 이전에 작성해둔 포스트가 있어서 코드든 글이든 중복으로 쓰는 것은 영 좋지 않으므로 아래의 포스트를 통해 티도리 프레임워크의 개요를 간단하게 살펴보면 좋을 것 같다. 중복을 싫어하는 것은 개발자 특인가보다. 먼저 언급하자면, 티도리 프레임워크는 웹팩(Webpack)이 중점적으로 사용되고, 바벨과 같은 라이브러리가 부가적으로 사용된다.

 

https://pronist.tistory.com/64?#Tidory%20Framework

 

내가 개발한 티스토리 프로젝트 정리!

이 포스팅은 여지껏 내가 개발한 티스토리 프로젝트를 정리하기 위한 것이다. 회사를 2017년 가을에 그만두고 무려 3년간 백수생활을 해오면서 만든 것이 아래에 있는 프로젝트다. 만드는데만 3

pronist.tistory.com

tidory/clitidory/tidory 템플릿과 같이 상호작용하는 자바스크립트 어플리케이션이다. tidory/tidorytidory/cli 가 없는 이상 빈 프로젝트와 다름없다. 하지만 개발자가 스킨을 작성할 때 실질적으로 스킨 코드를 작성하는 것은 템플릿에서 하기 때문에 이 또한 중요하다. 일반적으로 티도리 프레임워크를 사용하여 티스토리 스킨을 개발하면 다음과 같은 순서를 따른다.

1. 티도리 프레임워크 다운받기 (tidory/create-tidory-app)
2. 다운받은 티도리 템플릿(tidory/tidory)에 스킨 코드 작성 시작하기
3. 티도리 템플릿에서 티도리 CLI(tidory/cli)를 사용하여 개발, 빌드 및 배포하기

 

 

위의 그림에서도 보았듯, 웹팩(Webpack)은 티도리 프레임워크의 중추와 같은 역할을 한다. 템플릿에 포함된 파일들은 대부분 웹팩에서 쓰이고 해석된 파일들은 티스토리 스킨 API 를 거쳐 개발, 프리뷰 서버를 켜거나 티스토리 스킨 저장소에 저장하거나 배포할 수 있다.

 

티도리 프레임워크를 개발하면서 가장 늦게 나온 기능이 프리뷰 기능이다. 프리뷰를 하나 하기 위해서는 고려해볼만한 것이 많기 때문이다. 지금이야 정리가 된 상태이기 때문에 단순화했지만, 연구 단계에서는 상당히 머리를 굴려야했다. 티도리 프레임워크를 처음 기획할 때 까지만해도 티스토리 스킨 API 까지 뜯어볼 생각은 없었으니까.

tidory/tidory

tidory/tidory티도리 프레임워크의 프로젝트 템플릿이다. 아래의 그림과 같이 구성하기 위한 의도를 가지고 있다. 티도리 공식 홈페이지에 따라 디렉토리 구조는 다음과 같다. 내가 티도리 프레임워크를 만들 때 가장 먼저 생각한 아이디어는 skin.html 에 모든 기능을 몰아서 작성하던 것을, 주제별로 나누어 서로 다른 파일로 분리할 수 있게하자는 것이었다. 

├── assets/ 
│ └── app.js 
├── docs/ 
│ ├── index.xml 
│ ├── preview256.jpg 
│ ├── preview560.jpg 
│ └── preview1600.jpg 
├── images/ 
├── views/ 
├── .env 
├── app.pug 
├── index.pug 
└── tidory.config.js

위의 디렉토리는 각자 다음과 같은 다이어그램에 따라 구조화되도록 의도했다. 티도리 프레임워크의 문서에도 아래와 같은 형태로 사용하길 권고하고 있다. 사실 사용자가 어떠한 형태로 사용할 지는 잘 모르겠지만 말이다. 그 외에 각 폴더나 파일이 의미하는 것은 티도리 프레임워크 공식 문서에 내가 잘 적어두었다. 눈치가 빠르다면, 폴더의 이름만 보더라도 어떤 의미인지 알 수 있을 것이다.

 

 

index.pug 파일은 티스토리 스킨의 관점에는 최상위에 위치하는 파일이어서 app.pug 와는 위치가 다르기 때문에 views 폴더에 넣어버릴까 고민도 많이 했지만, 그냥 똑같이 최상위에 두기로 했다. images, docs 폴더는 웹팩이 빌드할 때 복사하여 배포 파일에 포함시켜주고 assets 폴더는 리액트나 뷰 컴포넌트, 템플릿 같은 것을 포함하여 assets/app.js 에서 포함시키거나 템플릿에서 사용할 수 있도록 하기 위함이다.

 

.env, tidory.config.js 파일은 둘 다 환경설정이지만, 의미하는 바가 다른데, .env 는 템플릿과 assets/app.js 에서 사용하도록 하기 때문에 빌드시 특정 값으로 치환되거나 템플릿을 일부 제어할 수 있다. 이에 비해 tidory.config.js 파일은 프레임워크 차원의 환경설정이며 빌드할 때 해당 프로젝트에 대한 설정을 의미한다. 프리뷰 모드의 세션을 설정하거나 스킨옵션을 시뮬레이션한다거나 할 때 사용할 수 있다.

tidory/cli

tidory/cli 에서는 많은 일을 처리한다. 다이어그램에도 나와있듯 아래와 같은 일을 처리한다. 완전히 정확한 순서대로 나열한 것은 아니지만, 대체로 아래와 같은 순서로 진행된다.

1. images, docs 디렉토리를 복사한다. (CopyWebpackPlugin ― Build, Production)
2. .env 파일을 해석한다. (Dotenv)
3. 템플릿을 해석한다. (pug-plain-loader)
4. 템플릿에 있는 script, style 태그를 분리한다. (코드를 압축하거나 예쁘게 만든다.) (TidoryWebpackPlguin)
6. script.js, style.css 를 생성한다. (TidoryWebpackPlguin ― Build, Production)
7. assets/app.js 파일을 기반으로 app.js 파일을 생성한다. (Webpack)

서로간에는 이전단계에서 나온 결과값을 다음 단계의 파라매터로 넣어주는 형태도 볼 수 있다. 예를 들어 script, style 태그를 분리하고 분리된 결과를 압축하는 것이다.

 

웹팩이 중점이기에 webpack.*.conf 파일들을 소개해주면 좋을 것 같지만, 이미 다이어그램에서 사용된 로더와 플러그인들을 나열해주고 있어서 생략한다. 기본적인 공통 플러그인과 로더가 설정된 webpack.base.conf 를 기반으로 개발서버를 실행할 때는 webpack.dev.conf 를, 빌드할 때는 webpack.prod.conf 를 사용한다.

tidory.cofnig.js

tidory.config.js 를 외부에서 받아온 것을 그대로 사용하는 것은 아니다. 일부 값이 덮어씌워지는데, 바로 기본 값을 할당하고 추가적인 경로를 지정해주는 일이다. 이는 어플리케이션 차원에서 경로를 한 곳에서 관리하여 하나를 변경하면 다른 곳은 수정하지 않아도 된다는 이점이 있다.

여기서부터 작성된 코드는 핵심만 전달하기 위해 일부 require 함수를 빼고 작성하였다.
const $ = require('cheerio').load(
  require('fs').readFileSync(path.join(wd, 'docs/index.xml')), {
    normalizeWhitespace: true,
    xmlMode: true
  }
)

const tidoryConfig = require(path.resolve(wd, './tidory.config'))

module.exports = Object.freeze(Object.assign(Object.assign({
  ts_session: null,
  url: null,
  preview: {
    mode: 'index',
    variableSettings: {},
    homeType: 'NONE',
    coverSettings: []
  },
  alias: {},
  build: {
    public_path: null
  }
}, tidoryConfig || {}), {
  skinname: $('skin > information > name').text(),
  path: {
    dist: './dist',
    entry: './assets/app.js',
    template: './index.pug',
    docs: './docs',
    index: './skin.html',
    stylesheet: './style.css',
    script: `./images/script.${randomstring.generate(20)}.js`,
    publicPath: './images'
  }
}))

webpack.base.conf

webpack.base.conf 에서 살펴보아야 할 사항이 있다면, publicPath 를 처리하는 일과 웹팩 설정을 확장하는 일이다. 이것은 tidory.config.js 에서 얻어와 처리를 하게 될 것이다.

tidoryConfig.build.public_path

publicPath, 이 친구는 좀 특별하다. 티스토리는 기본적으로 skin.html 에 포함된 리소스 경로는 알아서 CDN 경로로 바꿔준다. 하지만, 그 외에 대해서는 티스토리가 자체적으로 바꿔주거나 하지 않는다. 스킨 옵션 치환자를 style.css 에서 처리해줄 수 없듯이, .js 에 포함된 리소스 경로를 티스토리에서 자체적으로 바꿔줄 수 없다. 따라서 외부의 설정에 의해 조절될 수 있도록 처리한다. 기본적으로는 자동으로 처리한다.

require('dotenv').config()

const tidoryConfig = require('../tidory.config')

module.exports = async env => {
  const fileLoaderConfig = {
    loader: require.resolve('file-loader'),
    options: {
      publicPath: (env.build || env.production)
        ? tidoryConfig.build.public_path || await publicPath(tidoryConfig)
        : '/'
    }
  }
  const webpackBaseConfig = { /* ... */ }
  if (tidoryConfig.extends && typeof tidoryConfig.extends === 'function') {
    tidoryConfig.extends(webpackBaseConfig)
  }
  return webpackBaseConfig
}

이 경우, Build, Production 에서만 처리할 필요가 있다. 프리뷰나 개발 서버에서는 바꿔주지 않고 로컬에서만 처리해도 좋다. 또한 코드에 보면 publicPath() 라는 함수를 호출하고 있는 모습을 볼 수 있는데, 코드는 아래와 같다. 스킨을 준비하고, 스킨의 이름을 얻어와 tidory.config.js 에 설정된 tidoryConfig.url 에 따라 publicPath 를 자동으로 설정하도록 한다.

/**
 * Get asserts public path
 *
 * @param {object} tidoryConfig
 *
 * @return {string}
 */
module.exports = async tidoryConfig => {
  const skin = new TistorySkin(tidoryConfig.url, tidoryConfig.ts_session)

  /** Prepare twice for getting skin number */
  await skin.prepare()
  const { skinname } = await skin.prepare()

  return `https://tistory1.daumcdn.net/tistory/${skinname.split('/')[1]}/skin/images`
}

tidoryConfig.alias

별칭에 대한 설정도 여기서 진행한다. 기본적으로 @tidory 별칭이 정의되어 있어서, 이는 티도리 패키지를 부를 때 사용한다. pug-plain-loader 가 아래와 같이 설정되어 있어서 별칭을 지정한다. @tidory 별칭의 구현은 그다지 중요한 사항은 아니므로 생략.

{
  loader: require.resolve('pug-plain-loader'),
  options: {
    basedir: wd,
    plugins: [
      pugAliasPlugin(Object.assign(tidoryConfig.alias || {},
        {
          '@tidory': require('../lib/@tidory')
        }
      ))
    ]
  }
}

여기에 있는 pugAliasPlugin() 은 잠깐 살펴보고자 한다. 내용은 별거 없을지 모르지만 @tidory 가 어떤식으로 들어갈 수 있는지는 짐작할 수 있을 것이다. 별칭들을 넘어주고, 그게 함수이면 함수를 호출하고 그렇지 않으면 그냥 치환만 해주는 형태로 되어있다.

/**
 * Pug plugin for aliases
 *
 * @param {Array} aliases
 */
module.exports = aliases => ({
  resolve (filename, source, loadOptions) {
    for (const alias in aliases) {
      if (filename.indexOf(alias) === 0) {
        return path.resolve(
          aliases[alias] instanceof Function
            ? aliases[alias](filename)
            : filename.replace(alias, aliases[alias])
        )
      }
    }
    return load.resolve(filename, source, loadOptions)
  }
})

TidoryWebpackPlugin

기본적으로 하나의 템플릿에는 Markup, 그리고 style, script 태그가 모두 들어가는데, 그것이 웹팩으로 처리되면서 style.css, script.js 로 분리되게 해놓았다. 이는 웹팩의 기본적인 동작이 아니며 직접 구현해야 한다. 그리고 그것의 시작은 HtmlWebpackPlugin 에서 시작되고. TidoryWebpackPlugin 에서 처리된다.

 

cheerio 를 사용하여 해석된 HTML 문자열을 넣고, 여기서 style(), script() 함수로 분리하여, html() 함수로 마무리짓는다. 이 과정이 끝나고 나면 resource() 함수로 script.js, style.css 를 생성하는 것이다. 여기서 주의해야 할 점은 scoped 속성이 부여되어 있으면 분리되지 않는다는 것이다.

/**
 * Tidory webpack plugin
 *
 * process.env.NODE_ENV
 * -> development
 * -> preview
 * -> build
 * -> production
 */
module.exports = class {
  /**
   * Create tidory webpack plugin instance
   *
   * @param {object} env
   */
  constructor (env) { /* ... */ }

  /**
   * Apply plugin
   *
   * @param {object} compiler
   */
  apply (compiler) {
    compiler.plugin('compilation', compilation => {
      compilation.plugin('html-webpack-plugin-after-html-processing', async (htmlPluginData, cb) => {
        this.$ = cheerio.load(htmlPluginData.html)
        
        /**
         * style, script 태그를 분리합니다.
         */
        this.css = style(this.$, this.env)
        this.js = script(this.$, this.env)
        
        // 모드에 따라 다르게 처리
        htmlPluginData.html = await html(this.$, this.css, this.js, this.env)
        cb(null, htmlPluginData)
      })
    })
    compiler.plugin('done', () => resource(this.css, this.js, this.env))
  }
}

이 과정을 처리하면서 다이어그램에 나와있는 Compress, or Beautify Codes, Generate style.css, script.js 를 처리하게 될 것이다.

style()

이 함수는 style 태그를 분리한다. 주의해야 할 점은, ::before, ::after 에서 주로 나타나는 content 를 별도로 처리해준다는 점이다. 이것을 제대로 해주지 않으면 티스토리에서 해석을 올바르게 해주지 못해서 버그가 발생한다는 것을 확인하였다. Build 인 경우 일반적으로 CleanCSS 를 사용하여 압축하며 그렇지 않은 경우에는 예쁘게 바꿔주는 것으로 처리한다. 이는 개발 및 프리뷰 모드에도 적용하게 된다.

/**
 * Separate 'style'
 *
 * @param {CheerioStatic} $
 * @param {object} env
 *
 * @return {string}
 */
module.exports = ($, env) => {
  return separate($, 'style', style => {
    style.html(style.html().replace(/content:\s?'([^\\]*?)'/gim,
      (_, content) => `content:'${cssesc(content)}'`
    ))
    style.html(new CleanCss({ format: env.build ? false : 'beautify' }).minify(style.html()).styles)
  }, env)
}

script()

Babel 을 사용하여 ES6 이상의 코드들은 죄다 ES5 로 만들어 브라우저 호환성을 향상시킨다. 마찬가지로 Build 인 경우 압축, 그렇지 않은 경우 예쁘게 바꾼다. 압축을 할 때는 UglifyJS, 예쁘게 바꿀 때는 beautify 를 쓴다.

/**
 * Separate 'script'
 *
 * @param {CheerioStatic} $
 * @param {object} env
 *
 * @return {string}
 */
module.exports = ($, env) => {
  return separate($, 'script:not([src])', js => {
    js.html(babel.transform(js.html(), {
      presets: [
        'babel-preset-es2015-nostrict'
      ].map(require.resolve)
    }).code)
    js.html(env.build ? UglifyJS.minify(js.html()).code : beautify(js.html(), { indent_size: 2 }))
  }, env)
}

html()

이 함수는 중요하다. 위의 함수를 살펴보면서 느낀 것은, 그럼 프리뷰 모드와 개발 모드에서는 어떻게 분리된 값들을 이용할까에 대한 것이다.

 

style 의 경우, Build, Production 모드에서는 ./style.css 형태로 포함하므로 저렇게 해도 되지만, 프리뷰 모드에서는 다소 의문일 것이다. 프리뷰 모드에서는 기본적으로 HTML, CSS 를 skin.change() 함수로 티스토리 서버에 변경사항을 보낸다. 그렇게 되면 프리뷰 모드에서 ./style.css 경로는 CDN 으로 바뀌게 된다. 그냥 프리뷰 모드에서도 인라인으로 넣으면 되지 않을까? 싶어도 CSS 를 빈 상태로 티스토리 서버에 요청하면 오류 메시지를 던진다. 

 

script 는 어떨까? 간단하게 개발 및 프리뷰 모드에서는 코드에 직접 넣고, 그렇지 않으면 script.js 의 경로를 넣어주면 된다.

const tidoryConfig = require('../tidory.config')

/**
 * HTML
 *
 * @param {CheerioStatic} $
 * @param {string} css
 * @param {string} js
 * @param {object} env
 *
 * @return {string}
 */
module.exports = async ($, css, js, env) => {
  if (env.build || env.production || env.preview) {
    $('head').append(`<link rel="stylesheet" href="${tidoryConfig.path.stylesheet}">`)
  } else {
    $('head').append(`<style>${css}</style>`)
  }
  if (env.preview || env.development) {
    $('body').append(`<script type="text/javascript">${js}</script>`)
  } else {
    $('body').append(`<script type="text/javascript" src="${tidoryConfig.path.script}">`)
  }
  if (env.preview) {
    /** Tistory Skin */
    const skin = new TistorySkin(tidoryConfig.url, tidoryConfig.ts_session)

    await skin.prepare()
    await skin.upload(path.join(process.cwd(), tidoryConfig.path.docs, 'index.xml'))
    await skin.change(pretty(he.decode($.html()), { ocd: true }), css, true)

    /**
     * FOR PREVIEW
     *
     * Replace TISTORY CDN PATH to local for preview
     *
     * <img src="https://tistory1.daumcdn.net/tistory/2710108/skin/images/logo.png" /> -> <img src="images/logo.png" />
     */
    return (await skin.preview(tidoryConfig.preview)).replace(
      /(src|href)=["']https?:\/\/tistory[0-9]{1}.daumcdn.net\/tistory\/[0-9]*\/skin\/(images\/.*?)["']/gim,
      '$1="$2"'
    )
  } else {
    const html = he.decode($.html())
    return env.production ? pretty(html, { ocd: true }) : html
  }
}

프리뷰 모드를 주목하자. 다이어그램에서는 Webpack 으로 처리된 결과가 pronist/tistory-skin 의 입력값인 것처럼 되어있지만 실질적으로는 TidoryWebpackPlguin 에 코드가 위치해 있다. 다만, 논리적으로 보았을 때는 다이어그램에서 나타난 형태로 보는 것이 맞을 것이다.

 

skin.prepare() 는 스킨을 준비시킨다. 스킨 편집을 하기 전에 반드시 스킨을 준비시켜야 하며, docs 디렉토리에 있는 파일에서 index.xml 파일도 업로드한다. 이렇게 하면 스킨 옵션과 홈 커버를 시뮬레이션 할 수 있다. 그리고, HTML, CSS 코드의 변경점을 티스토리 서버에 요청한다.

 

프리뷰에서는 이미지 경로가 CDN 으로 바뀐 형태가 오는데, 그것을 로컬로 바꾸어주는 작업이 반드시 필요하다. 프리뷰를 할 때 images 폴더 내부의 리소스를 업로드하는 행위는 그다지 바람직하지 않으므로 리소스는 로컬에 위치하기 때문이다. skin.preview() 함수는 반환값으로 치환자가 해석된 HTML 문자열을 뱉는다. tidoryConfig.preview 에 작성한 내용들이 파라매터로 들어간다는 점도 눈여겨 볼만한 점이다.

resource()

resource() 의 경우는 그저 받은 것을 기반으로 파일을 생성할 뿐이다. 하지만 한 가지 중요한 사실이 있다면 script.js, style.css 를 생성하는 것은 오직 Build, Production 모드일 때만 한다는 것이다.

const tidoryConfig = require('../tidory.config')

/**
 * Create resource files
 *
 * @param {string} css
 * @param {string} js
 * @param {object} env
 */
module.exports = (css, js, env) => {
  if (env.build || env.production) {
    fs.writeFileSync(path.join(tidoryConfig.path.dist, tidoryConfig.path.stylesheet), css)
    fs.writeFileSync(path.join(tidoryConfig.path.dist, tidoryConfig.path.script), js)
  }
}

tidory

마지막으로 티도리 명령어가 어떻게 구성되어 있는지 살펴보자. 티도리는 tidory start, tidory build, tidory production, tidory deploy, tidory store 로 구성되어있으며 commander.js 를 통해 이를 구현하였다. 각 명령어에 맞는 행동을 취한다. tidory deploy, tidory store 의 경우에는 온전히 webpack 의 결과물을 pronist/tistory-skin 에 사용한다.

#!/usr/bin/env node

const tidory = require('commander')

tidory.version(pkg.version)

/**
 * -> tidory start
 */
tidory
  .command('start')
  .description('Start development server')
  .action(() => {
    shelljs.exec(`node ${webpackDevServer} --config ${webpackDevConfig} --env.development`)
  })

/**
 * -> tidory preview
 */
tidory
  .command('preview')
  .description('Start preview server')
  .action(() => {
    shelljs.exec(`node ${webpackDevServer} --config ${webpackDevConfig} --env.preview`)
  })

/**
 * -> tidory build
 */
tidory
  .command('build')
  .description('Build tidory project')
  .action(() => {
    shelljs.exec(`node ${webpack} --config ${webpackProdConfig} --env.build`)
  })

/**
 * -> tidory production
 */
tidory
  .command('production')
  .description('Build tidory project for production')
  .action(() => {
    shelljs.exec(`node ${webpack} --config ${webpackProdConfig} --env.production`)
  })

/**
 * -> tidory deploy
 */
tidory
  .command('deploy')
  .description('Deploy tistory skin')
  .action(async () => {
    await skin.clear()
    await skin.deploy(dist)
  })

/**
 * -> tidory store
 */
tidory
  .command('store')
  .description('Store tistory skin on skin storage')
  .action(async () => {
    await skin.storage.clear()
    await skin.storage.store(dist, tidoryConfig.skinname)
  })

tidory.parse(process.argv)

마치며

티도리가 라이브러리가 아닌 프레임워크인 이유는 스킨을 개발하는 개발자는 내가 정한 규칙에 따라 스킨을 개발해야 하기 때문이다. 리소스는 어떤 폴더에 담아야 하며, 뷰는 어떤식으로 작성하고, 다른 자바스크립트 프레임워크와는 어떻게 상호작용할 수 있는지를 정의한다. 즉, 프레임워크가 사용자의 행동을 규정하기 때문에 이는 프레임워크라 칭한다.

 

티도리 프레임워크는 정말 공들여서 만들었고, 이를 만드는 동안 실력향상은 물론 삽질도 정말 많이했다. 알게모르게 티스토리 스킨에 대한 연구도 덤으로 되었던 것 같다. 티도리 프레임워크는 이미 어느정도 안정화 버전이라, 새로운 기능을 추가할 건덕지가 별로 없기 때문에 이렇게 리뷰 글을 작성하게 되었다. 아직 이야기하지 않은 부분, 특히 pronist/tistory-skin 에 대한 것들은 2부에서 다룬다.