메뉴

문서정보

GoLang를 이용한 템플릿 엔진

목차

Joinc의 위키엔진

Joinc 사이트는 PHPNuke로 시작했다. 그러다가 모니위키(moniwiki)로 갈아탔다. 그렇게 거의 15년 정도를 모니위키로 운영하다가 직접 개발한 위키엔진(엔진의 이름은 gowiki다. gowiki인 이유는 golang으로 개발해서다.) 으로 갈아탔다. 갈아탄 이유는 아래와 같다.

gowiki로 갈아탔던 때가 2018년 쯤이었던 거 같은데, 모니위키에 쌓인 컨텐츠가 3,000 페이지 이상이었기 때문에 마이그레이션에도 신경을 써야 했다. 마이그레이션을 위해서 크게 3 부분을 신경썼다.

  1. 파일기반을 데이터베이스 기반으로 변경
  2. 모니위키의 마크다운 문서를 해석하기 위한 템플릿 엔진 : 문자열 처리 노가다라고 보면 되겠다.
  3. 모니위키의 플러그인 확장 시스템을 Go로 구현
1과 2는 마이그레이션 할 때, 일반적으로 해야 하는 것들이니 큰 이슈가 없었다. 3번이 약간 문제였다.

모니위키를 이용 할 때 가장 맘에 들었던게 플러그인을 이용한 기능의 확장이었다. 이론적으로 플러그인을 이용하면, 위키엔진의 Core를 건드리지 않고 무한대로의 확장이 가능하다. 아래는 플러그인을 이용한 확장을 보여주고 있다.

 플로그인을 이용한 확장

플러그인은 HTML 문서의 헤더, 바디, 테일 어느 곳에든 추가 할 수 있어야 하며, 어떤 경우에는 미들웨어처럼 작동 할 수 있어야 한다.

나는 기존 모니위키의 플러그인 문법을 따르면서, 유연하게 확장 가능한 플러그인 시스템을 만들어야 했다.

Joinc의 플러그인 엔진

Joinc의 플러그인 엔진은 두 가지 상태가 있다. 1. 최종적으로 완성하고 싶었던 모습, 2. 현재의 모습. 내 목표와 현재의 구현이 다르다는 이야기다. 이유는 "자원의 한계" 때문이었다. Joinc는 개인 사이트로, 투입해야 하는 비용과 시간을 적절하게 조절해야 할 필요가 있었다. 이 때문에 "좀 더 쉽고 빠르게 구현"할 수 있는 방법을 선택했다.

하여 최종완성하고 싶었던 모습과, 타협해서 만든 지금의 모습을 따로 설명할 계획이다.

원했던 구현

플러그인 시스템이 뭐 대단한 것 처럼 보이지만 원리는 간단하다. 프로세스는 아래와 같다.

 Plugin 프로세스

HTML Document는 마크다운 형식으로 플러그인을 포함하고 있다. 서버는 이 문서를 읽어서 플러그인을 식별하고, 플러그인을 실행한다. 뷰(View)가 있는 플러그인이라면, HTML, CSS, Javascript 코드를 만들어낼 것이다. 이 문서가 클라이언트에 전달되고, 클라이언트는 Javascript로 기능을 호출한다.

Plugin Proxy 서버는 클라이언트의 요청을 받아서, 적당한 플러그인으로 라우팅하는 역할을 한다.

테스트 코드를 이용해서 어떻게 작동하는지 확인해 보자. 아래는 Plugin 을 포함한 원본 문서다.
<HTML>
<HEAD>
	<TITLE>테스트</TITLE>
</HEAD>
<BODY>
안녕하세요. 오늘은 [[Date]] 입니다.
</BODY>
Date 오늘의 시간을 알려주는 플러그인이다. 오늘의 시간은 Javascript로도 알아낼 수 있겠으나 별도의 플러그인 서버로 구현할 것이다.

 구현

  1. HTML 템플릿 문서는 Template 엔진이 읽어서 HTML Document로 변환한다.
  2. 변환된 HTML Document는 HTML, CSS, Date API를 호출하기 위한 Javascript를 모두 포함한다.
  3. 클라이언트(웹 브라우저)는 HTML 문서를 읽고, Javascript를 호출하여 Date를 출력한다.
우리는 3가지를 구축해야 한다.

  1. HTML 템플릿 엔진 : HTML 템플릿 문서를 읽어서 HTML Document 로 변환
  2. Plugin Proxy Server : Plugin API 요청을 받아서 Plugin 을 호출하는 Proxy 서버
  3. Plugin Server : Plugin 기능을 제공하는 서버.

개념 구현

그럼 Plugin을 구현해보자. 테스트에 사용할 Template 문서는 아래와 같다. 이 파일의 이름은 tmplate.html이다.
<HTML>
	<HEAD>
	    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
	    <TITLE>테스트</TITLE>
	</HEAD>
	<BODY>
	안녕하세요. 오늘은 [[Date]] 입니다.
	</BODY>
</HTML>	

아래 코드는 템플릿을 읽어서 플러그인을 해석하고 HTML Document를 만드는 코드다.
	http.HandleFunc("/createdoc", func(w http.ResponseWriter, r *http.Request) {
		file, err := os.Open("tmplate.html")
		if err != nil {
			log.Fatal(err)
		}
		defer file.Close()

		scanner := bufio.NewScanner(file)

		re := regexp.MustCompile(`\[\[([a-zA-Z0-9]+)\]\]`)
		docs := []string{}
		for scanner.Scan() {
			if re.MatchString(scanner.Text()) {
				resp, _ := http.Get("http://localhost:8081/plugin/tmpl")
				data, _ := ioutil.ReadAll(resp.Body)
				fmt.Println(re.ReplaceAllString(scanner.Text(), string(data)))
				docs = append(docs, re.ReplaceAllString(scanner.Text(), string(data)))
			} else {
				docs = append(docs, scanner.Text())
			}
		}

		if err := scanner.Err(); err != nil {
			log.Fatal(err)
		}
		fmt.Fprintf(w, strings.Join(docs, "\n"))
	})
원래코드는 변환된 템플릿을 파일 혹은 데이터베이스에 저장하겠으나, 여기에서는 그냥 출력한다.

이제 Date 플러그인 코드를 만들면 된다. 이 플러그인 코드는 2개의 API를 가지고 있다.
  1. 플러그인 템플릿을 리턴하는 API
  2. 플러그인 코드를 실행하고 리턴하는 API. 이 코드는 "날짜"를 리턴할 것이다.
아래 api는 플러그인의 템플릿을 리턴한다.
http.HandleFunc("/plugin/tmpl", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, tmpl)
})

var tmpl = `
	<style>
	#app {
		background: yellow;
		padding: 2px;
		font-family:arial;
	      }
	</style>
	<span id="app">
		{{message}}
	</span>
	<script type="text/javascript">
		var app = new Vue({
		  el: '#app',
		  data() {
			return {
		    		message : null 
			}
		  },
		  created() {
			  fetch('http://localhost:8081/plugin/date')
			  .then((response) => {
				if(response.ok) {
					return response.json();
				}
			  })
			  .then((json) => {
				  this.message = json.date 
			  })
		  }
		});
	</script>
`
지금은 매우 단순하게 구현했다. 보통 플러그인은 유저가 커스터마이징 할 수 있도록 작성되기 때문에 좀 더 복잡한 모습을 가진다. 좀 더 복잡하다고 해서 크게 어려울 건 없다. 어차피 문자열 치환작업이다. CSS, HTML 코드를 수정 할 수 있으며, /plugin/date API를 호출하기 위한 도메인을 수정해야 할 수 있다.

템플릿을 보면, 날짜 API인 /plugin/date를 호출하게 되 있다. /plugin/date 함수를 호출하고 그 결과를 <span id="app">에 출력한다.

아래는 템플릿함수의 코드이다.
	type DateResponse struct {
		Date string `json:"date"`
	}

	http.HandleFunc("/plugin/date", func(w http.ResponseWriter, r *http.Request) {
		t := time.Now()
		w.Header().Add("Content-Type", "application/json")
		date := DateResponse{Date: t.Format(time.RFC3339)}
		resp, _ := json.Marshal(date)
		fmt.Fprint(w, string(resp))
	})
/plugin/date를 호출하면, 현재 시긴을 읽어서 RFC3339 형식으로 리턴한다.

아래는 완전히 작동하는 코드다. 원래는 템플릿 API와 HTML Document를 만드는 코드를 분리해야 겠으나, 하나의 코드에 모두 밀어 넣었다. 테스트하는데 문제 없을 것이다. 파일의 구조는 아래와 같다.
.
├── main.go
└── tmplate.html

package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"regexp"
	"strings"
	"time"
)

func main() {
	http.HandleFunc("/createdoc", func(w http.ResponseWriter, r *http.Request) {
		file, err := os.Open("tmplate.html")
		if err != nil {
			log.Fatal(err)
		}
		defer file.Close()

		scanner := bufio.NewScanner(file)
		// optionally, resize scanner's capacity for lines over 64K, see next example
		re := regexp.MustCompile(`\[\[([a-zA-Z0-9]+)\]\]`)
		docs := []string{}
		for scanner.Scan() {
			if re.MatchString(scanner.Text()) {
				resp, _ := http.Get("http://localhost:8081/plugin/tmpl")
				data, _ := ioutil.ReadAll(resp.Body)
				fmt.Println(re.ReplaceAllString(scanner.Text(), string(data)))
				docs = append(docs, re.ReplaceAllString(scanner.Text(), string(data)))
			} else {
				docs = append(docs, scanner.Text())
			}
		}

		if err := scanner.Err(); err != nil {
			log.Fatal(err)
		}
		fmt.Fprintf(w, strings.Join(docs, "\n"))
	})

	http.HandleFunc("/plugin/tmpl", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, tmpl)
	})

	http.HandleFunc("/plugin/date", func(w http.ResponseWriter, r *http.Request) {
		t := time.Now()
		w.Header().Add("Content-Type", "application/json")
		date := DateResponse{Date: t.Format(time.RFC3339)}
		resp, _ := json.Marshal(date)
		fmt.Fprint(w, string(resp))
	})

	log.Fatal(http.ListenAndServe(":8081", nil))
}

var tmpl = `
	<style>
	#app {
		background: yellow;
		padding: 2px;
		font-family:arial;
	      }
	</style>
	<span id="app">
		{{message}}
	</span>
	<script type="text/javascript">
		var app = new Vue({
		  el: '#app',
		  data() {
			return {
		    		message : null 
			}
		  },
		  created() {
			  fetch('http://localhost:8081/plugin/date')
			  .then((response) => {
				if(response.ok) {
					return response.json();
				}
			  })
			  .then((json) => {
				  this.message = json.date 
			  })
		  }
		});
	</script>
`

type DateResponse struct {
	Date string `json:"date"`
}
프로그램을 실행하고 웹 브라우저로 "http://localhost:8801/createdoc"에 접근해보자.

 플러그인 테스트

잘 작동한다. 지금은 하나의 코드이지만 이것들을 분리해서 구성하면 완전한 플러그인 시스템을 만들 수 있을 것이다.

현재의 (타협한) 구현

하지만 돈이 없고 귀찮은 관계로 지금 joinc의 플러그인 시스템은 reflect를 이용해서 작동하고 있다. HTML 템플릿을 읽어서 플러그인 문자열을 플러그인 템플릿으로 치환하는 것 까지는 까지는 동일한데, API 대신 함수로 구현을 하는 방식이다. 이 방식으로도 플러그인 코드는 분리 할 수 있지만, 매번 컴파일해야 하는 불편함이 있어서, 확장성 측면에서 그리 좋지는 (물론 빠르기는 하다)않다.