Hyun1008.log

재연의 개발블로그

#스터디 #VanillaJS

모르는 내용은 아니지만, 매번 코드를 작성할 때 잊게 되는 것 같아 정리 겸 확실히 하기 위해 적습니다.

  1. false: Boolean
  2. 0: 정수 0
  3. -0: 정수 -0
  4. 0n: BigInt
  5. "": 빈 문자열
  6. null
  7. undefined
  8. NaN

0n: BigInt

정수 리터럴의 뒤에 n을 붙이거나(10n) 함수 BigInt()를 호출해 BigInt를 생성할 수 있습니다. 일반적으로 Int 에서 가장 큰 정수는 9007199254740991 이지만, 그 뒤에 n을 붙이거나 BigInt() 로 감싸면 그 이상의 숫자 9007199254740992n 도 담을 수 있습니다.

undefined에 대한 판정

예를 들어, 어떤 변수에 값이 정의되어 있는지 아닌지에 따라 if 문을 써야 한다면, 절대로

if ( 변수이름 ) {
} else {
}

이렇게 판정하면 안 되고, === 라든가 !== 라든가 typeof 같은 걸 사용하는 것이 맞습니다.

1. 초코스프레드 리포지토리 포크

초코스프레드 리포지토리를 포크 합니다. Repository name에 원하는 이름을 입력하세요. 만약 페이지를 처음 사용하시는 경우 (username).github.io 로 설정하시는 것도 좋은 방법입니다.

2. .env 파일 작성

포크된 리포지토리의 루트 디렉토리에 있는 .env 파일을 수정합니다.

# General Wiki Setting:
WIKI_TITLE = 위키 제목
FRONT_PAGE = 대문 페이지. 기본값은 '대문' 입니다.
WIKI_URL = 위키의 기본 도메인

# From your google spreadsheet URL:
SPREADSHEET = 구글 스프레드시트 ID

# From OAuth Client:
CLIENT_ID = OAuth 2.0 client ID

# From service account JSON file:
PRIVATE_KEY_ID = 서비스 계정 Private Key ID
CLIENT_EMAIL = 서비스 계정 이메일

# You need to put your PRIVATE_KEY from JSON file in Github secret.

구글 스프레드시트 아이디를 따는 방법은 3절에서, OAuth 2.0 클라이언트 아이디를 만드는 방법은 4절에서, 서비스 계정 JSON 파일을 내려받는 방법은 5절에서 다룹니다.

그 전에, 첫번째 문단인 General Wiki Setting의 내용을 채워주세요. 따옴표 없이 그냥 값만 입력해 주시면 됩니다.

3. 구글 스프레드시트 생성

  • 구글 스프레드시트를 생성합니다. 이 링크는 브라우저에서 구글에 첫번째로 로그인된 유저의 계정으로 시트를 생성하니 참조해주세요.
  • 파일의 제목을 수정해 주세요. 리포지토리 이름과 같은 제목이면 나중에 알아보기 편합니다.
  • 이제 주소창에는 다음과 같은 형식의 주소가 찍혀 있습니다.
https://docs.google.com/spreadsheets/d/{sheetId}/edit?gid=0#gid=0
  • {sheetId} 부분을 복사해서 SPREADSHEET 에 저장합니다.

4. OAuth 2.0 클라이언트 아이디 생성

  • 전부 만들어졌으면 이런 화면이 보일 것입니다. Publish App 을 누릅니다.

  • 다시 Credentials 페이지로 돌아갑니다. 위쪽에서 CREARTE CREDENTIAL 을 누르시고, OAuth Client ID 를 눌러주세요.
  • 아래와 같이 설정합니다. redirect URI 에서 /signin/ 을 잊지 마세요.

  • 저장하면 클라이언트 ID가 생성되어 있습니다. 복사해서 CLIENT_ID 에 저장합니다.

6. 서비스 계정 JSON 파일 생성

  • Credentials 페이지로 돌아갑니다. 위쪽에서 CREARTE CREDENTIAL 을 누르시고, Service account 를 누른 다음, 서비스 계정을 하나 만들어 줍니다.
  • 이름을 제외하곤 별다른 설정을 하지 않아도 괜찮습니다.
  • 만들어진 계정에 들어가서, 세번째 탭인 'KEY' 를 누른 다음 키를 하나 생성해 줍니다. ADD KEY – Create New Key

  • 그러면 JSON 파일이 하나 자동으로 다운로드 될 것입니다. 이 파일을 열어서 private_key_idclient_email 을 복사하여 PRIVATE_KEY_ID CLIENT_EMAIL 에 붙여넣습니다. 이제 이 .env 파일은 그대로 커밋(저장)하셔도 됩니다.
  • private_key 는 깃허브 시크릿으로 리포지토리에 안전하게 보관할 것입니다. 리포지토리에서 Settings – (왼쪽 메뉴의) Secrets and Variables – Actions 를 눌러줍니다.
  • New Repository Secret 을 눌러준 다음, Name에 PRIVATE_KEY, Secret에 JSON 파일의 private_key 를 복사하여 그대로 붙여넣습니다. 이때 따옴표가 섞이지 않도록 조심해 주시고, 따옴표를 제외하고는 모든 문자를 그대로 붙여주셔야 합니다.
  • 아까 생성했던 구글 스프레드시트에 들어가셔서, Share/공유 버튼을 누릅니다. 그리고 People with access 에 방금 생성한 client_email 을 붙여넣고 서비스 계정의 접속을 허용해 주세요.

5. 기타 파일 수정, 그리고 배포

리포지토리의 static/css/main.css 에서 테마 컬러를 수정할 수 있습니다. (물론 다른 것두요!)

:root {
    --accent: (여기!!!);
    --bg: #f8f8f8;
    line-height: 1.6rem;
}

이제 배포를 해 볼 겁니다.

  • 리포지토리에서 Actions 탭에 들어갑니다. Workflows aren’t being run on this forked repository 알림이 뜰 텐데요, 초록색 버튼을 눌러 워크플로우를 활성화 시킵니다.
  • Deploy Nuxt site to Pages에 들어가 보면 disable 되어 있습니다. 이것도 Enable Workflow 버튼을 눌러 활성화 시킵니다.
  • Run Workflow – 초록 버튼을 눌러 워크플로우를 실행시킵니다.
  • 리포지토리의 Settings – 왼쪽 메뉴의 Pages에서 Build and deployment – Source – Github Actions 를 눌러주세요.
  • Custom domain에서 도메인을 연결할 수 있습니다.
  • 도메인에 접속했을 때 아래와 같은 페이지가 출력되면 성공입니다. 이제 로그인하고 대문 문서를 생성하면 되는데요, 페이지에 실제로 반영되기까지는 10분 정도가 걸리니 이 점 참조해 주세요. (방금 테스트해 보았는데, 캐시까지 지워야 합니다...)

#프로젝트 #NodeJS #블로그

샘플 위키 깃허브 리포지토리

  • 개발 시작: 2024.09.06. – 2024.09.09.
  • 개발 기간: 4일

정적 위키?

Github의 정적 페이지 호스팅 서비스(pages)를 사용하여 배포하는 위키입니다. 데이터베이스로는 구글 스프레드시트를 사용해요.

그러니까 사실은 구글 스프레드시트 파서 에 가깝다고 해도, 할 말이 없습니다.

이전 프로젝트의 취약점

이전 프로젝트는 클라이언트 사이드에서 바닐라JS만 사용하여, API키가 그대로 노출되는 문제점이 있었습니다.

또 사소한 문제였지만 라우팅 기능이 없었기 때문에 경로가 아닌 쿼리스트링 상에서 모든 것을 처리했던 문제도 있었습니다. 그래서 각 문서들의 주소가 복잡했어요.

Nuxt.js와 Github Actions

이번 버전의 초코스프레드 위키는 Nuxt로 작성되었습니다.

Nuxt는 Vue 기반의 프레임워크로, 일단 node를 사용하지만, Github pages로 배포할 경우에는 Actions에 의해 정적 페이지로 렌더된 사이트가 배포됩니다.

이 방식은 서버를 사용하지는 않지만, secret에 입력한 프라이빗 키를 가지고 Action에서 자동으로 렌더 되기 때문에 훨씬 안전하고, 페이지 로딩도 빠릅니다.

또 정적 페이지긴 하지만 나름대로의 동적 라우팅도 구현할 수 있습니다.

개선점 1. 인증키

스프레드시트의 보기/편집 권한을 얻기 위해서 두 가지 인증 방식을 사용합니다.

  1. 위키를 보여주는 서비스 계정의 인증
  2. 위키 편집을 위한 사용자 계정의 인증

(1) 은 Google Action을 통해 서버사이드에서 처리한 것을 정적 렌더링하기 때문에 인증키가 보이지 않고, (2) 의 경우 구글 공식문서에서 제시하고 있는 클라이언트 사이드 인증을 사용하기 때문에 인증키가 필요없어요.

스프레드시트의 권한 설정을 제한된 편집자들에게만 공개하고, 외부 공유 링크는 막아둘 수 있다는 점도 개선된 점 중 하나입니다.

개선점 2. 라우팅

예전 버전의 초코스프레드 위키에서 대문 문서로 접속하기 위한 주소는 다음과 같았습니다.

https://wiki.rongo.moe/?d=대문

하지만 지금은 기존의 다른 위키 서비스들과 동일하게,

https://wiki.rongo.moe/대문/

으로 접속할 수 있습니다.

그래서 문서 제목은 / 를 포함할 수 없습니다. 샘플 위키의 경우 . 으로 /의 역할을 대신하고 있습니다.

아쉬운 점

물론 서버를 사용하지 않기 때문에, 실제 위키의 동작을 완전히 따라하지 못하는 면이 있습니다.

대표적인 것이 편집 내용이 실시간으로 반영되지 않는다는 것입니다. 현재 리포지토리에서 설정된 자동 배포는 5분마다이며, 실제로는 5분에서 10분 정도의 간격으로 배포됩니다. 스프레드시트에서 편집된 내용이 위키에 반영되기까지는 최대 10분 정도가 소요됩니다.

편집자들이 스프레드시트, 즉 데이터베이스에 직접 접속할 수 있다는 점도 한계라고 할 수 있습니다. 스프레드시트에 직접 접속해 시트를 삭제하거나, 내용을 전부 없던 일로 만들어버리거나 버전을 왜곡할 수 있습니다. 하지만 이런 것은 스프레드시트 자체의 버전 관리 기능을 통해 어느 정도 극복할 수 있습니다.

그럼에도 불구하고 무료로 위키를 제작할 수 있기 때문에, 한정된 주제를 가진 최대 3-4인 정도 되는 소규모 그룹에서 운영된다면 나름 좋은 솔루션이 될 수 있다고 생각합니다.

#개발과정 #NodeJS #블로그

Github Pages에서 Nuxt 배포하기

초코스프레드는 별도의 서버 호스팅을 받을 필요 없이 무료로 위키를 올릴 수 있도록, 저예산/소규모 위키를 목표로 하고 있는 프로젝트입니다. (사실상 스프레드시트 파서입니다.)

기존 초코스프레드는 정적 html 페이지를 그대로 Github pages로 deploy했었습니다.

그런데 이렇게 페이지를 만들면 API 키가 전부 노출이 되어 안전하지도 않고, 쿼리스트링 형태로 문서 제목이 붙어 어딘가 너저분한 느낌이 들고 불편했습니다.

그래서 이 두 가지를 개선하고자 Nuxt를 사용하게 된 것입니다.

Nuxt에서는 pages/ 폴더 아래에 vue파일을 집어넣으면 그게 그대로 라우팅이 됩니다. 즉 pages/index.vue/ 로, pages/intro.vue/intro/ 로 라우팅이 되는 형태입니다.

그리고 이게 Github 액션을 사용한 빌드에서도 적용됩니다.

그런데 동적 라우팅, 예를 들어서

jyhyun1008.github.io/posts/123

같은 경우는 어떻게 할까요?

pages/posts 폴더 아래에 _id.vue 와 같이 밑줄+파라미터 이름 을 넣으면 됩니다.

그리고 당연하게도, 정적 페이지 빌드를 하면 이 페이지는 빠지게 됩니다(...)

위키는 대문 페이지를 제외하고는 거의 대부분의 페이지가 동적 페이지로 되어 있는데, 만들었던 위키 페이지를 정적 페이지로 generate 하니, 대문을 제외한 모든 페이지가 날아가 있었습니다.

그래서 이 문제를 해결하고자 했는데, 공식 문서 를 찾아보는 것으로 의외로 간단하게 해결이 됐습니다.

아래는 공식 문서에서 제시하는 예시입니다.

//nuxt.config.js
import axios from 'axios'

export default {
  generate: {
    routes(callback) {
      axios
        .get('https://my-api/users')
        .then(res => {
          const routes = res.data.map(user => {
            return '/users/' + user.id
          })
          callback(null, routes)
        })
        .catch(callback)
    }
  }
}

generate → routes 아래에 fetch를 통해 어떤 페이지를 라우팅할 지 배열을 불러와서 저장하는 맥락입니다.

그러니 사실은...

동적 라우팅처럼 보이는 것에 가까울 것 같습니다. 유저가 접속할 수 있는 모든 경우의 수의 페이지만큼 정적 파일을 생성하고, 이걸 deploy하는 것이기 때문입니다.

저는 axios는 사용 안하고... 그냥 async + await fetch 로 했습니다. 아래 코드에서 secret이라든지 저에게 맞추어진 id같은 부분은 줄을 그었습니다.

구글 아이디 인증을 하고, 스프레드 시트를 불러와서 시트의 제목마다 라우터를 만들어주는 구조입니다.

//nuxt.config.js

  generate: {
    async routes(callback) {
      var secretKey = process.env.PRIVATE_KEY.replace(/\\n/gm, '\n')

      const token = jwt.sign(
          { "iss": "-------------", "scope": "https://www.googleapis.com/auth/spreadsheets", "aud": "https://oauth2.googleapis.com/token" },
          secretKey,
          { algorithm: 'RS256', expiresIn: "1h", keyid: "------------" }
      );

      const googleAuthUrl = 'https://oauth2.googleapis.com/token'
      const googleAuthParam = {
              method: 'POST',
              headers: {
                  'content-type': "application/x-www-form-urlencoded",
              },
              body: querystring.stringify({
                  grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
                  assertion: token
              })
          }

      var authData = await fetch(googleAuthUrl, googleAuthParam)
      var authRes = await authData.json()

      const googleSheetUrl = `https://sheets.googleapis.com/v4/spreadsheets/------------/`
      const googleSheetParam = {
          method: 'GET',
          headers: {
              "content-type": "application/json",
              Authorization: "Bearer " + authRes.access_token,
          },
      }
      var sheetData3 = await fetch(googleSheetUrl, googleSheetParam)
      var sheetRes3 = await sheetData3.json()
      var wikiListArray = sheetRes3.sheets
      var wikiList = []
      for (let i=0; i<wikiListArray.length; i++) {
          wikiList.push('/'+encodeURIComponent(wikiListArray[i].properties.title))
      }

      const routes = wikiList
      callback(null, routes)
    }
  }

#개발과정 #NodeJS #블로그

제가 쓰는 Nuxt 버전은 다음과 같습니다.

    "nuxt": "^2.15.8",
    "vue": "^2.7.10",

원래 평소와 똑같이 marked 를 임포트해서 처리하려고 했는데... 계속 계속 오류가 뜨길래 Nuxt에서 마크다운을 렌더링하는 기본적인 방법이 없나 찾아보았습니다.

결국 끝장을 보긴 했어요.

일단 @nuxtjs/markdownit 모듈을 깔아줍니다.

npm i @nuxtjs/markdownit

그다음 nuxt.config.js에서 모듈 부분을 다음과 같이 바꿉니다.

//nuxt.config.js
  // Modules: https://go.nuxtjs.dev/config-modules
  modules: ['@nuxtjs/markdownit'],
  markdownit: {
    runtime: true // Support `$md()`
  },

마크다운 렌더를 원하는 부분에 $md.render() 를 붙이면 끝.

// index.vue
<template>
    <div v-html="$md.render(wikiBody1)"></div>
</template>

다만 marked.js보다는 별로라고 생각합니다. 아주 기본적인 마크다운 렌더는 지원하지만, 체크리스트 같은 게 제대로 렌더되지 않았다는 점을 고려해 보면, <sub> 등의 구문도, 접었다 펼치는 것도 지원하지 않을 거라고 생각해요.

이 부분은 어차피 replace 같은 걸로 따로 구현할 수 있는데, 따로 구현할지 아니면 그냥 둘지는 고민입니다.

#스터디 #TS

원본 영상

개발환경 구성

sudo npm install -g typescript ts-node

타입스크립트 컴파일러를 사용할 수 있습니다.

tsc --init

tsconfig.json 파일을 생성합니다.

여기에서 tsconfig 파일을 만드는 이유는 컴파일 되기 전의 ts파일과 컴파일 되고 나서의 js 파일을 구분해서 저장하기 위해서입니다.

/src 폴더와 /build 폴더를 만들어줍니다. ts 파일은 /src 폴더에, js 파일은 /build 폴더에 저장되도록 하고자 합니다.

    "rootDir": "./src",    
    "outDir": "./build",     

저장한 후, /src 폴더에서 ts 파일을 생성한 다음 터미널에서 tsc 명령어를 쳐주면, /src 폴더에 있는 ts 파일의 컴파일된 js 파일이 /build 폴더에 생성됩니다.

npm init -y

package.json 파일을 생성합니다.

npm i nodemon concurrently

nodemon과 concurrently 모듈을 설치합니다.

그리고 package.json 에서

//package.json
  "scripts": {
    "start:build": "tsc -w",
    "start:run": "nodemon build/index.js",
    "start": "concurrently npm:start:*"
  },

이렇게 설정하면 npm start 명령어로 컴파일과 실행이 이루어집니다.

“tsc -w” 는 컴파일 과정을 콘솔에서 확인하겠다는 의미입니다.

//index.ts
console.log('Hello World!');

index.ts 를 저장하고 npm start 명령어를 입력하면 index.js 가 컴파일되고, 콘솔에 헬로월드가 찍히게 됩니다!

JS의 경우 – 클래스

class TodoItem {
    constructor(id, task, complete) {
        this.id = id;
        this.task = task;
        this.complete = complete;
    }

    printDetails() {
        console.log(`${this.id}\t${this.task}\t${this.complete ? '\t(complete)': ''}`)
    }
}

참고: this.complete ? '\t(complete)': ''는, this.complete 값이 true 이면 (complete) 라는 문자열을, 그렇지 않으면 빈 문자열을 반환하겠다는 것입니다.

TS의 경우 – 클래스

같은 클래스입니다.

class TodoItem {
    constructor(public id: number, public task: string, public complete: boolean) {
        this.id = id;
        this.task = task;
        this.complete = complete;
    }

    printDetails(): void { 
        console.log(`${this.id}\t${this.task}\t${this.complete ? '\t(complete)': ''}`)
    }
}

export default TodoItem

public이라고 된 부분은 접근지정자라고 하는데, 이 경우 어디서나 접근 가능한 값이라고 합니다. 참고 링크

TS의 함수

함수를 정의할 때 파라미터와 리턴값에 타입을 지정합니다. (리턴값이 없으면 void 지정)

JS는 가변인자를 통한 함수 호출이 가능했지만, TS는 가변인자를 지원하지 않습니다.

대신 함수의 오버로딩을 통해 가변인자와 같은 효과를 구현할 수 있습니다.

body 가 없는 추상함수 형태의 자료형이 명시된 코드 와 any type을 받는 코드가 정의되어야 합니다.

function add(fp: string, sp: string): string
function add(fp: number, sp: number): number

function add(fp: any, sp: any): any {
    console.log(fp + sp)
}

add(10, 20)
add('10', '20')

#개발과정 #NodeJS #블로그

초코스프레드 위키를 Nuxt.js로 옮기게 되었습니다.

왜 Next.js가 아니라 Nuxt.js 였냐 하면....별 이유는 없고 그냥 깃허브 액션에 Next가 있다는 것을 늦게 보았습니다 (...)

하여튼, Nuxt.js는 뷰 기반이니만큼 뷰 기준으로 설명하겠습니다.

//index.vue
<template>
  <div>{{ wikiBody }}</div>
</template>

템플릿에는 가져온 스프레드시트의 컨텐츠를 담을 수 있게 준비했습니다.

//index.vue
<script>
const jwt = require('jsonwebtoken');
const querystring = require("querystring");

const secretKey = "(private_key)"

index.vue가 아니어도 상관없지만, nuxt 기준으로 pages 아래에 있는 뷰 파일에다가 작업을 해 주어야 합니다.

jsonwebtoken 이라는 모듈을 설치하고 불러옵시다. 참고로 이 jsonwebtoken의 경우, 9.0.0이 아니라 8.5.1 버전을 설치해야 합니다. 그렇지 않으면 오류가 납니다. querystring 모듈은 나중에 POST 요청을 보내기 위해 필요합니다.

secretKey 변수에 받아온 json 파일의 private key를 집어넣습니다. 일단 로컬에서 실행하기 때문에 그대로 넣어주었습니다. 이후 env 파일로 옮길 예정입니다. 이대로 커밋만 안 하면 됩니다(...)

참고로 이 json 파일은 구글 API에서 새 서비스 계정을 생성해서 받아왔습니다.

//index.vue
const token = jwt.sign(
    { "iss": "(client_email)", "scope": "https://www.googleapis.com/auth/spreadsheets", "aud": "https://oauth2.googleapis.com/token" },
    secretKey,
    { algorithm: 'RS256', expiresIn: "1h", keyid: "(private_key_id)" }
    );

jsonwebtoken 모듈을 사용해서 사인을 해봅니다. 간단합니다.

jwt.sign(payload, privateKey, options)

Google에서 요구하는 JWT는 헤더에 alg (RS256), kid가 포함되어야 하고, 페이로드에 iss, scope, aud, iat, exp가 포함되어 있어야 합니다.

이걸 일일이 변수로 만들어줘도 되지만, jwt 모듈에서는 그냥 option에 필요한 값만 넣어주면 알아서 사인을 해줍니다.

예를 들어 algalgorithm, kidkeyid 입니다. expexpiresIn 이에요. iat는 자동으로 넣어줍니다.

만들어진 JWT는 https://jwt.io/ 에서 검증할 수 있습니다만, 구글 JWT의 경우 아마 유효하지 않다고 나올 겁니다. 하지만 이게 맞습니다. 구글에서 예시로 던져준 JWT도 유효하지 않다고 나옵니다.

//index.vue
export default {
    async asyncData () {

        const googleAuthUrl = 'https://oauth2.googleapis.com/token'
        const googleAuthParam = {
                                    method: 'POST',
                                    headers: {
                                        'content-type': "application/x-www-form-urlencoded",
                                    },
                                    body: querystring.stringify({
                                        grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
                                        assertion: token
                                    })
                                }

        var authData = await fetch(googleAuthUrl, googleAuthParam)
        var authRes = await authData.json()

그 다음에 fetch를 하는 단계 입니다. nuxt이기 때문에 asyncData() 훅을 사용해서 데이터를 받아온 다음 템플릿에 붙일 수 있게 해 보았습니다.

fetch는 다른 사이트로의 fetch들과 똑같이 진행하면 되는데요(그야, 그냥 평범한 POST니까요...), body 에서 grant_type 이 자주 오류가 나더라구요.

이유는 잘 모르겠지만 JSON.stringifyquerystring.stringify 로 바꾸었더니 잘 인증이 되었습니다.

//index.vue
        const googleSheetUrl = 'https://sheets.googleapis.com/v4/spreadsheets/(구글 시트 주소)/values/(시트 및 범위)'
        const googleSheetParam = {
            method: 'GET',
            headers: {
                "content-type": "application/json",
                Authorization: "Bearer " + authRes.access_token,
            },
        }
        var sheetData = await fetch(googleSheetUrl, googleSheetParam)
        var sheetRes = await sheetData.json()
        var wikiBody = sheetRes.values[sheetRes.values.length - 1][2]
        return { wikiBody }
    }
}
</script>

내친김에 받은 액세스 토큰으로 구글 시트까지 가져와 보았습니다.

가장 어려운 인증(...) 부분을 뚫었으니 이제는 다른 사이트와 똑같이 제대로 된 엔드포인트에다가 값만 잘 넘겨 주면 됩니다. 특히 시트 값 가져오는 건 GET이니까요.

초코숲을 만들다가 또 막히는 일이 있으면 포스트를 써 보도록 하겠습니다.

#스터디 #TS

원본 영상

타입스크립트의 필요성

자바스크립트의 타입(자료형)은 크게 원시타입과 객체타입으로 구분됩니다.

Primitive Type

  • number
  • boolean
  • string
  • null
  • undefined
  • symbol – ES6
  • void – null, undefined

Object Type

  • function
  • array
  • class

자바스크립트는 타입의 제한이 느슨한 언어로, 자바스크립트에서 선언한 변수에는 어떤 타입의 값도 들어갈 수 있습니다. (동적 타입)

이는 프로그램의 유연성은 보장 하지만, 예측 가능하고 안전한 코드를 구현하기는 어렵게 됩니다.

타입스크립트

타입스크립트는 자바스크립트의 슈퍼셋 언어로서, 기존 자바스크립트에 Type System을 적용한 것입니다.

타입스크립트는 정적 타입을 지원하므로, 변수의 정의와 함께 대입할 타입의 값을 지정합니다.

함수의 패러미터, 반환값에는 지정된 타입의 값만 대입할 수 있습니다. 그렇지 않으면 컴파일 시점에 에러를 발생합니다.

타입 추론(Type Inference)

타입 추론이란, 정적으로 타입을 명시하지 않고 대입하는 값을 통해 타입을 유추해 결정하는 것입니다.

let name = 'Kim' // 처음 가진 값이 string

name 이라는 변수는 string 타입이 됩니다. 이 변수는 이후의 코드에서도 계속 string 타입만 담을 수 있습니다.

let name // 처음에 값을 대입 하지 않음
name = 'Kim'

name 이라는 변수는 any 타입이 되어 자바스크립트 처럼 어떠한 타입도 담을 수 있는 변수가 됩니다.

이 경우 컴파일 시점에 해당 변수에 대한 타입 검사를 실행하지 않는다고 합니다.

처음부터 변수가 any 타입이라고 선언할 수도 있습니다.

let name: any

#개발과정 #NodeJS #버튜버

1. 모티베이션

VMC Mixer 까지는 만들었는데, 이 신호를 모션캡쳐 파일로 저장해줄만한 괜찮은 소프트웨어가 없었습니다. BVH 저장이 가능한 일반 소프트웨어들은 Hips 부터 시작해서 Root 본이 누락되어 있었고(그래서 정작 중요한 캐릭터의 위치가 잡히지 않습니다), 블렌더 애드온인 VMC4B는 각 내삽에 오일러 각을 사용하는지 자주 뒤집히며 불완전했습니다.

깃허브에 VMCtoBVH 소프트웨어가 있었지만, .exe 파일이라서 맥에서는 기록이 불가능했습니다.

어쩔 수 없이 BVH 포맷에 대해 찾아보았고, 이정도면 만들기 수월하겠는데? 싶어서 결국 직접 만드는 데까지 이르게 되었습니다.

2. BVH 포맷

BVH 파일의 생김새입니다.

2.1 HIERARCHY

관절 목록이 적혀 있는 부분입니다.

맨 앞의 관절은 ROOT, 그 다음부터는 쭉 JOINT로 이어져 있습니다.

각 JOINT에는 오프셋과 채널 목록이 적혀 있는데, 오프셋은 그 joint의 크기를 결정한다고 봐도 될 것 같고, 채널 목록은 후술할 모션을 받아들이는 채널입니다.

주로 로테이션 값이 지정되어 있으며, 루트 본만 6채널로 해서 따로 포지션을 저장할 수 있게 되어 있습니다.

이 채널 목록의 순서가 매우 중요합니다!

2.2 MOTION

먼저 첫줄에는 프레임 숫자, 두번째 줄에는 프레임당 몇 초를 소요할 것인지(그래서, 0.0333334 라면 초당 30프레임을 뜻합니다) 적혀 있습니다.

세번째 줄부터는 실제 모션이 들어가는데, 앞에서 선언한 채널의 순서대로 값을 지정할 수 있습니다. 한 줄에 채널 전체 숫자만큼 숫자가 들어가며, 다음 프레임으로 넘어갈 때 줄바꿈이 들어갑니다.

제가 받은 파일에서는, 소수점 아래 6자리까지 지정되어 있고, (–) 부호가 붙지 않은 경우 스페이스가 하나 들어가 있어서 그것도 똑같이 구현했습니다.

참고로, 이거 꽤 오래된 포맷이라고 합니다. 그래서인지, 시간 단위는 , 각도 단위는 degree 입니다.

3. 쿼터니온을 BVH degree로 변환하기

이건, vmc2bvh 리포지토리의 코드 에서 가져왔습니다.

원래는 Quaternion.js의 함수를 사용하려고 했는데, 오일러 변환 중 XYZ의 순서를 어떻게 지정해야 할지 잘 모르기 때문에 기존의 성공적인 코드를 빌려올 수밖에 없었습니다...

function qtod_rot(r11, r12, r21, r31, r32) {
    F = (180.0 / Math.PI);

	// zyx
	var zrot   = Math.atan2(r31, r32) * F;
	var yrot = Math.asin(r21) * F;
	var xrot  = Math.atan2(r11, r12) * F;

    return [xrot, yrot, zrot]
}

function qtod(x, y, z, w) {

    var angles = qtod_rot(-2 * (y * z - w * x), w * w - x * x - y * y + z * z, 2 * (x * z + w * y), -2 * (x * y - w * z), w * w + x * x - y * y - z * z);

    return angles
}

원본은 위키백과에 있는 다음 식인 것 같습니다.

(Y값 구하는 데 두번째 공식 사용)

참고로 이 포맷 계산에서는 원본 쿼터니온에서 Qx와 Qy에 -1을 곱한 값을 사용하며, (아마 cw와 ccw의 차이일 것 같습니다.) 루트 계산에서는 이 쿼터니온 값을 제곱한 새로운 쿼터니온을 사용하더군요.

이걸 구한 다음 Xrotation Yrotation Zrotation 순서대로 파일에 저장해 주었습니다.

#개발과정 #NodeJS #버튜버

Slerp 개념 이해하기

사실, 지금껏 '평균' 이라고 했지만 '내삽' 이라는 좀더 일반적이고 깔끔한 용어가 있었다는 것을 잊고 있었습니다.

지난 포스트에서 쿼터니온을 최대한 건드리지 않고 오일러 각도로 변환한 뒤 각도의 평균을 내 주는 방법을 사용했다고 했죠.

그런데 이게 부정확했던 겁니다. 계속 값이 튀었어요. 모듈로 계산도 써 보고 머리를 싸매다가, 결국 스택오버플로우에서 검색을 해봤어요.

Yes, only quaternions are appropriate for inter/extrapolation.

네? 내삽/외삽을 게산하는 데 쿼터니온만이 적합하다고 합니다.

Quaternion Slerps are commonly used to construct smooth animation curves ...

아... 그랬던 겁니다. 애초에 제가 하는 스무딩 작업은 오일러 각도이든 축각 각도이든 한계가 있었고, 쿼터니온의 Slerp 라는 방법을 써야 각도의 내삽이 가능했던 거였어요.

다음은 위키백과의 Slerp 문서에서 가져온 사진입니다.

네.. 제가 원한 게 바로 이거였거든요. 두 개의 벡터의 단순 평균을 내 주는 게 아니라, 각도의 평균을 내 주는 작업이죠.

쿼터니온의 Slerp은 다음과 같은 계산과정을 거치는 모양입니다.

하지만 여기서는 Slerp의 개념만 이해하고 넘어가기로 했습니다. 계산과정은 모듈에게 맡기기로 합니다.

quaternion.js 모듈에 내삽과 관련된 함수가 있는지 찾아봅시다. (없으면 구현해야 하니까요...-_–)

아예 대놓고 Slerp 함수가 있었네요.... 지금이라도 찾아서 다행이라고 생각하며 적용해 보았어요.

적용해보기

우선 오일러 각으로 변환하던 기존 코드들을 전부 주석처리한 뒤,

var q1, q2
q1 = new Quaternion(result1[6], result1[3], result1[4], result1[5])
q2 = new Quaternion(result2[6], result2[3], result2[4], result2[5])

//평균을 내 줘야 하는 곳에서
var q3 = q1.slerp(result2[6], result2[3], result2[4], result2[5])(0.5)

//0.5 부분에는 0-1까지의 float가 들어갈 수 있습니다.
var index = [1,2,3,4,5,6,7,8,9]
for await (let i of index) {
    var qResult = qformer.slerp(q3_w, q3_xyz[0], q3_xyz[1], q3_xyz[2])(i/9)
}

쿼터니온의 성분은 기존 코드에서 가져왔습니다.

근데 저렇게 하면 q2 변수는 왜 필요한거냐...? 사실은 오일러각 계산에 필요해서 저장해두고 있습니다.

모든 오일러각을 전부 없앤 게 아니고, 오일러각으로 다루는 게 편한 부분은 오일러각으로, 쿼터니온으로 다루는 게 편한 부분은 쿼터니온으로, 둘 다 비슷하다면 쿼터니온으로 계산하고 있어요.