Go 가 벌써 버전 1.16 이 되었다. Go 1.11 부터 소개된 Go Module 이래로 많은 Go 개발 Workflow 가 바뀐 듯 하다.

많은 기능들이 있고, Release Note 를 살펴보면 추가된 기능을 볼 수 있다.

제일 마음에 드는 기능은 embed. 사용법은 워낙 간단하다.

1.16 부터 Go Module 이 기본이라 go.mod 가 없으면 실행도 안된다.

한 개의 파일 임베딩

하나의 파일만 임베딩 시켜서 Go 내의 변수로 사용해보자. 어떤 경우에 사용하면 적합할까? 다양한 경우가 있겠지만 내 경우는 Windows 어플리케이션 제작 시 필요한 .ico 파일이나 웹 어플리케이션 서버 제작시 필요한 logo.png 등, 일부 글로벌하게 사용되는 경우에 주로 사용하게 되었다.

$ go mod init myproject
$ tree .
.
├── file.txt
├── go.mod
└── main.go

main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
    "fmt"

    _ "embed"
)

//go:embed file.txt
var file string

func main() {
    fmt.Printf("Contents of file.txt: %s\n", file)
}

file.txt

1
Hello, World!

Contents of file.txt: Hello, World!

embed 모듈을 import 해서 사용했고, 사용하고자 하는 파일 위에 주석 같은 //go:embed 파일명 을 기재해주면 된다.

file.txt 파일이 없으면 실행이 되지 않는다.

 9
10
//go:embed file.txt
var file string

string 을 사용하였는데, []byte 를 사용할 수도 있다.

3
4
5
6
7
import (
    "fmt"

    _ "embed"
)

하나의 파일만을 임베딩하였고, embed 모듈 내의 패키지를 사용하지 않았으므로, 초기화 시킨 기능만 사용하기 위하여 _ embed 으로 import 하였다.

여러 개의 파일 임베딩

여러 개의 파일을 임베딩 하는 경우는 대개 웹 어플리케이션 제작 시 필요한 템플릿 파일들의 임베딩, 웹 프론트엔드에서 사용되는 assets 디렉토리 임베딩, 혹은 public 디렉토리 임베딩이 일반적이었다. 정적 링크로 인해 단일 바이너리가 나오는 Go 의 빌드 특성상, 바이너리 내에 필요한 모든 웹 프론트엔드 자원들을 포함시켜서 빌드하는 경우 말이다.

혹시나 그렇게 사용하신다면 조금 주의하실 점이 있는데, 많은 용량을 바이너리에 담으면 프로그램을 실행할 때에 런타임에서의 메모리 사용량이 급격하게 늘어난다. 그래서 테스트를 충분히 거친 후 허용 가능한 만큼의 파일, 혹은 정말 필수불가결한 정도만큼을 담도록 하자. 혹은 컨테이너화 시켜서 임베딩을 제거할 수도 있다..!! 이에 대해선 밑에서 언급하는 조건적 임베딩을 참고하시면 된다.

$ go mod init myproject
$ tree .
.
├── go.mod
├── main.go
└── public
    ├── assets
    │   ├── css
    │   │   └── style.css
    │   └── js
    │       └── script.js
    └── index.html

main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "embed"
    "fmt"
    "io/fs"
    "log"
    "net/http"
)

//go:embed public
var publicBox embed.FS

const PORT = 8080

func main() {
    publicSubBox, _ := fs.Sub(publicBox, "public")

    log.Printf("server is listening on :%d", PORT)
    http.Handle("/", http.FileServer(http.FS(publicSubBox)))
    if err := http.ListenAndServe(fmt.Sprintf(":%d", PORT), nil); nil != err {
        log.Fatalln(err)
    }
}

public/index.html

1
2
3
4
5
6
7
8
<!DOCTYPE html>
<html>
  <head>
    <link href="assets/css/style.css" rel="stylesheet">
    <script src="assets/js/script.js"></script>
  </head>
  <body></body>
</html>

public/assets/css/style.css

1
h1 { color: orange; }

public/assets/js/script.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
window.addEventListener('load', function () {
  insertTitle();
  insertBody();
});
function insertTitle() {
  const title = document.createElement('title');
  title.innerHTML = 'Hello, Title!';
  document.head.appendChild(title);
}
function insertBody() {
  const h1 = document.createElement('h1');
  h1.innerHTML = 'Hello, World!'
  document.body.appendChild(h1);
}

Hello, World!

하나의 파일을 임베딩 할 때와는 다르게 embed 패키지를 직접 import 하였고, publicBox embed.FS 변수를 선언하여 해당 변수를 http 패키지의 FileServer 로 사용하였다. embed.FS 구조체는 Go 1.16 에 새롭게 추가된 io/fs 패키지의 fs.FS 인터페이스의 구현체이다.

빌드 태그를 이용한 조건적 임베딩

조건에 따라 파일을 임베딩 하고 싶지 않은 경우가 있다. 컨테이너 이미지로 빌드할 때나, assets 파일들은 별도의 CDN 에서 공급하려는 경우 등이 있을 것이다. 위에서 언급하였듯, 파일의 크기가 커지면 바이너리로 빌드 시 메모리 사용량이 많아진다. 파일 블롭을 전부 메모리에 적재해서 로드하니 당연한 일.

임베딩을 Go 프로그램 상에서 진행하면 별 문제가 없다. if else 로 나누면 되니까 말이다. 근데 얘는 주석 같은 태그를 사용하니, 이를 어떻게 해야 한단 말인가?

관련해서 이슈를 남겨보았더니, 빌드 태그를 이용하면 된다는 현답을 주셨다. 그래서 다음과 같이 임베딩을 하였다.

$ go mod init myproject
$ tree .
.
├── go.mod
├── main.go
└── public
    ├── assets
    │   ├── css
    │   │   └── style.css
    │   └── js
    │       └── script.js
    ├── box_embed.go
    ├── box.go
    ├── box_noembed.go
    └── index.html

main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
    "fmt"
    "log"
    "net/http"

    "myproject/public"
)

const PORT = 8080

var httpRoot http.FileSystem

func main() {
    if public.IsEmbedded {
        httpRoot = http.FS(public.Box)
    } else {
        httpRoot = http.Dir("public/")
    }

    log.Printf("server is listening on :%d", PORT)
    http.Handle("/", http.FileServer(httpRoot))
    if err := http.ListenAndServe(fmt.Sprintf(":%d", PORT), nil); nil != err {
        log.Fatalln(err)
    }
}

public/box.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package public

import "embed"

var Box embed.FS

var IsEmbedded bool

func init() {
    Box = ebox
}

public/box_embed.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// +build embed

package public

import "embed"

//go:embed * index.html
var ebox embed.FS

func init() {
    IsEmbedded = true
}

public/box_noembed.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// +build !embed

package public

import "embed"

var ebox embed.FS

func init() {
    IsEmbedded = false
}

위와 같이 3개의 go 파일을 두고, embed 와 noembed 가 아닐때를 구분하여서 사용하면, 빌드 시 embed 태그를 명시적으로 줄 때에만 파일을 임베딩 한다.

$ go build -tags embed main.go

내가 프로젝트를 할 때에 Go 를 이용한 웹 프로젝트의 경우 React 를 프론트엔드로 사용하기 때문에, Node.js 와 yarn 을 반드시 사용하므로 빌드 스크립트로 Node.js 를 사용하였다. 대략 다음과 같은 모양이다.

전체적인 빌드를 package.json 에 scripts 로 적당히 등록해놓는다.

package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "name": "project",
    "private": true,
    "dependencies": {},
    "...": "...",
    "scripts": {
        "build": "node scripts/build/index.js",
        "build:server": "node scripts/build/server.js",
        "build:ui": "node scripts/build/ui.js"
    }
}

scripts/build/server.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ...

const IS_ASSETS_EMBEDDING = process.env.NO_EMBED.toLowerCase() !== 'true';

// ...

try {
    const serverBuildEnv = [
        'GOOS=linux', 'GOARCH=amd64',
        ...buildEnvs,
    ];
    const serverBuildArgv = [
        'build', '-v', '-o', outputBinary,
        '-ldflags', ldFlag,
        ...buildArgs,
    ];

    if (IS_ASSETS_EMBEDDING) {
        serverBuildArgv.push('-tags');
        serverBuildArgv.push('embed');
    }

    // execute go build with serverBuildEnv, serverBuildArgv
}

// ...

그리고 컨테이너 이미지 빌드 시에는 embedding 이 필요 없으므로, Dockerfile 에서는 임베딩을 하지 않도록 설정하였다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM <SOME IMAGE ONLY TO BUILD CONTAINER> AS builder

WORKDIR /workspace
COPY package.json .

# install dependencies
RUN yarn

# build
ENV NO_EMBED="true"
ENV GOPROXY="<PRIVATE GO PROXY>"
ENV GONOSUMDB="<PRIVATE GO REPOSITORY>"

COPY . .

RUN yarn build

FROM alpine:latest

RUN apk add --no-cache libc6-compat curl tzdata

# ...

더욱 자세한 스펙은 공식 홈페이지의 embed 관련 문서 을 참조하도록 하자.