티스토리 뷰

728x90
반응형

[Golang] How to add static files (for Web serving) to the golang binary without dependencies

참고

과연 이런 작업이 필요한 것일까?

특히 고객에게 전달되어야 하는 최종 산출물일 경우라면 이런 접근 방법에 대한 명확한 이유를 설명할 수 있어야 한다.

  • 장점

    • "안정성" : 바이너리에 추가된 파일은 다시 추출하기 어렵기 때문에 안정성이 있다.
    • "편리" : 배포 또는 소스를 전달할 떄 웹 서비스를 위한 추가적인 폴더들을 제공할 필요가 없다.
  • 단점

    • "크기" : 추가된 만큼 바이너리 자체의 크기가 증가한다.
    • "성능" : 그다지 큰 이슈가 될 것 같지는 않지만, Admin Web과 같이 정적 파일을 운영할 경우는 별도로 웹을 구성해서 서비스하는 것보다는 바이너리내의 파일을 서비스하는 개념이기 때문에 아주 약간의 성능 상 이슈가 있을 수 있다. 틱 단위의 서비스가 중요한 서비스가 아니라 내부 관리자 용이라면 이런 걱정은 하지 않아도 될만한 차이다.

가능한 원리는 무엇일까?

프로그램으로 프로그램 코드를 작성하는 "제너레이터"를 이용하는 것이다. (이전에는 "golang.org/x/tools/cmd/goyacc"를 이용했었다)

golang 1.4 부터는 이런 작업을 편하게 처리할 수 있도록 "go generate" 명령이 추가되었다. 이를 통하면 소스 상에 특수한 주석으로 명기된 명령을 검색해서 처리해주는 방식으로 운영할 수 있다.

구현해 보자.

이제 아주 간단한 실제 동작하는 샘플을 통해서 검증을 해 보자.

모듈 구성

go module을 사용하는 프로젝트 구성

# go mod init <project module path>

$ go mod init gitlab.com/ccambo/samples/golang/embedded

위의 명령으로 프로젝트 폴더에 "go.mod" 파일이 생성되었을 것이다.

빌드 구성 (makefile)

이제 makefile을 구성해 보자. (./makefile)

.PHONY: generate

generate:
    @go generate ./...
    @echo "[OK] 정적 파일들이 추가되었습니다!"

security:
    @gosec ./...
    @echo "[OK] 보안 검증이 완료되었습니다!"

build: generate security
    @go build -o ./build/server ./cmd/app/*.go
    @echo "[OK] 어플리케이션 바이너리가 생성되었습니다.!"

run:
    @./build/server

tip 팁!

코드에 GoSecurityChecker 사용을 권장한다. 이를 통해서 코드의 보안문제를 사전에 검증할 수 있다.

사용을 위한 설치는 다음과 같이 수행한다.

$ go get github.com/securego/gosec/v2/cmd/gosec

아직 동작하지 않는 상태이므로 아래의 나머지를 구성한 후에 테스트를 진행할 때 확인해 보도록 한다.

적용할 코드 구성

아래의 코드는 실제 Generate할 때 사용될 메서드들을 지정하는 것이다. 실제 사용할 코드가 아니라 정적 파일들을 추가하고 관리하는데 사용할 것이기 때문에 Box라고 패키지를 구성했다. (./internal/box.go)

//go:generate go run generator.go

// Package box - Embedded resource helper
package box

// ===== [ Constants and Variables ] =====

var (
    // 기본 Embedding Box 인스턴스 생성
    box = newEmbedBox()
)

// ===== [ Types ] =====

type (
    // Embedding 대상 파일들의 정보를 관리하기 위한 저장소 구조
    embedBox struct {
        storage map[string][]byte
    }
)

// ===== [ Implementations ] =====

// Add - Embedded Box 정보에 지정한 경로와 byte 정보 추가
func (e *embedBox) Add(file string, content []byte) {
    e.storage[file] = content
}

// Get - Embedded Box 정보에서 지정한 파일을 byte로 반환
func (e *embedBox) Get(file string) []byte {
    if f, ok := e.storage[file]; ok {
        return f
    }
    return nil
}

// Has - Embedded Box 정보내에 지정한 파일이 존재하는지 검증
func (e *embedBox) Has(file string) bool {
    if _, ok := e.storage[file]; ok {
        return true
    }
    return false
}

// ===== [ Private Functions ] =====

// newEmbedBox - Embedding할 파일들을 관라하기 위한 인스턴스 생성
func newEmbedBox() *embedBox {
    return &embedBox{storage: make(map[string][]byte)}
}

// ===== [ Public Functions ] =====

// Add - Embedded Box 정보에 지정한 경로와 byte 정보 추가
func Add(file string, content []byte) {
    box.Add(file, content)
}

// Get - Embedded Box 정보에서 지정한 파일을 byte로 반환
func Get(file string) []byte {
    return box.Get(file)
}

// Has - Embedded Box 정보내에 지정한 파일이 존재하는지 검증
func Has(file string) bool {
    return box.Has(file)
}

위의 코드에서 1번째 줄을 보면 "//go:generate go run generator.go" 주석이 있는 것이 보일 것이다.
이 주석을 통해서 다른 코드를 생성하는데 이 파일의 메서드가 사용될 수 있도록 처리하는 것이다.

즉, go build를 통해서 빌드 작업을 하는 중에 이 주석을 만나게 되면 go run generate.go가 동작하게 되며 현재 파일 (box.go)의 메서드들이 호출되어 사용된다.

BLOB 파일 템플릿 구성

이제 실제 Generate 작업을 수행하는 코드를 구성해 보자. Generating 동작이 수행될 떄의 Entry point가 된다. (./internal/generator.go)

//+build ignore

// Package main - Generator Entry point
package main

import (
    "bytes"
    "fmt"
    "go/format"
    "io/ioutil"
    "log"
    "os"
    "path/filepath"
    "strings"
    "text/template"
)

// ===== [ Constants and Variables ] =====

const (
    // 생성될 파일 명
    blobFileName string = "blob.go"
    // 처리 대상 정적 파일 경로
    embedFolder string = "../static"
)

var (
    // 템플릿 처리에서 호출할 함수 맵
    conv = map[string]interface{}{"conv": fmtByteSlice}

    // 코드 구성을 위한 템플릿
    tmpl = template.Must(template.New("").Funcs(conv).Parse(`package box
// Code generated by go generate; DO NOT EDIT.
func init() {
    {{- range $name, $file := . }}
        box.Add("{{ $name }}", []byte{ {{ conv $file }} })
    {{- end }}
}`),
    )
)

// ===== [ Types ] =====
// ===== [ Implementations ] =====
// ===== [ Private Functions ] =====

// fmtByteSlice - 지정한 []byte를 문자로 반환
func fmtByteSlice(s []byte) string {
    builder := strings.Builder{}

    for _, v := range s {
        builder.WriteString(fmt.Sprintf("%d,", int(v)))
    }

    return builder.String()
}

func main() {
    // 정적 파일 경로 확인
    if _, err := os.Stat(embedFolder); os.IsNotExist(err) {
        log.Fatal("지정한 정적 경로가 존재하지 않습니다!")
    }

    // 정적 파일들의 파일명과 파일 내용 관리용 맵 생성
    configs := make(map[string][]byte)

    // 정적 파일 경로를 기준으로 파일 검증 및 처리
    err := filepath.Walk(embedFolder, func(path string, info os.FileInfo, err error) error {
        // 축약 경로 구성
        relativePath := filepath.ToSlash(strings.TrimPrefix(path, embedFolder))

        // 폴더 여부 검증
        if info.IsDir() {
            // 현재는 폴더는 생략
            log.Println(path, "는 디렉토리이므로 현재 지원되지 않습니다.")
            return nil
        } else {
            // 파일만 처리
            log.Println(path, "는 파일이므로 추가합니다.")

            b, err := ioutil.ReadFile(path)
            if err != nil {
                // 파일 내용 오류
                log.Printf("Error reading %s: %s", path, err)
                return err
            }

            // 경로와 내용을 맵에 추가
            configs[relativePath] = b
        }

        return nil
    })
    if err != nil {
        log.Fatal("정적 폴더를 처리하는 중에 오류 발생:", err)
    }

    // 출력할 대상 파일 생성
    f, err := os.Create(blobFileName)
    if err != nil {
        log.Fatal("처리 대상 파일 생성 중에 오류 발생:", err)
    }
    defer f.Close()

    // 버퍼 구성
    builder := &bytes.Buffer{}

    // 정적 파일 정보를 기준으로 템플릿 처리
    if err = tmpl.Execute(builder, configs); err != nil {
        log.Fatal("템플릿 실행 중에 오류 발생", err)
    }

    // 생성된 코드 포맷 처리
    data, err := format.Source(builder.Bytes())
    if err != nil {
        log.Fatal("생성된 코드의 포맷 처리 중에 오류 발생", err)
    }

    // 생성된 코드 출력
    if err = ioutil.WriteFile(blobFileName, data, os.ModePerm); err != nil {
        log.Fatal("생성된 코드를 파일로 저장 중에 오류 발생", err)
    }
}

// ===== [ Public Functions ] =====

위의 코드에도 "//+build ignore" 주석이 있는 것을 확인할 수 있다. 이 주석은 모듈 프로젝트의 빌드 작업이 진행될 떄 처리할 대상이 아니라는 것을 지정한 것이다. 즉, 다른 코드를 생성하는 처리에만 필요하고 기본 빌드에는 포함될 필요가 없다는 의미다.

generator 코드를 통해서 생성된 파일을 blob.go 파일이고, 이 파일은 실제 빌드과정에 포함되어 빌드되게 된다. 나중에 테스트를 통해서 확인할 수 있지만, 생성된 파일에는 (../../static) 폴더내의 모든 파일들이 바이트 조각으로 포함되게 된다.

검증 대상 만들기 (Static Files)

이제 빌드 과정을 통해서 바이너리로 포함될 정적 파일을 구현하도록 한다. 실제 바이너리에 포함될 파일이다. (./static/index.html)

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>{{.Title}}</title>
  </head>
  <body>
    <h1>{{.Heading}}</h1>
    <p>{{.Description}}</p>
  </body>
</html>

위의 코드는 단순하게 정보를 출력하는 HTML 파일로 위의 코드에서 볼 수 있는 것과 같이 Go Template을 사용했다.

테스트 및 검증

이제 테스트를 위한 어플리케이션 진입점을 만들도록 한다. (./cmd/app/main.go)

// Package main - Entry point of application
package main

import (
    "log"
    "net/http"
    "text/template"

    box "gitlab.com/ccambo/samples/golang/embedded/internal"
)

// ===== [ Constants and Variables ] =====
// ===== [ Types ] =====

type (
    // PageData - 정적 파일에 전달할 데이터 구조
    PageData struct {
        Title       string
        Heading     string
        Description string
    }
)

// ===== [ Implementations ] =====
// ===== [ Private Functions ] =====

func main() {
    // 바이너리로 추가했던 정적 페이지 호출 (바이너리로 포함된 축약 경로)
    index := string(box.Get("/index.html"))

    // 템플릿 처리
    tmpl := template.Must(template.New("").Parse(index))

    // 웹 액세스 핸들러 설정
    http.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
        data := PageData{
            Title:       "외부 의존성 없이 정적 파일을 바이너리에 포함시키기",
            Heading:     "샘플 코드",
            Description: "블로그의 샘플을 잘 보고 활용하면, 더 많은 작업들을 할 수 있다",
        }

        // 템플릿 실행
        if err := tmpl.Execute(rw, data); err != nil {
            log.Fatal(err)
        }
    })

    // 웹 서버 실행
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

// ===== [ Public Functions ] =====

이제 간단하지만 실행 가능한 모든 소스들을 구성했으므로 빌드 작업을 진행해 보도록 하자.

$ make build
2020/09/22 16:20:00 ../static 는 디렉토리이므로 현재 지원되지 않습니다.
2020/09/22 16:20:00 ../static/index.html 는 파일이므로 추가합니다.
[OK] 정적 파일들이 추가되었습니다!
[gosec] 2020/09/22 16:20:00 Including rules: default
[gosec] 2020/09/22 16:20:00 Excluding rules: default
[gosec] 2020/09/22 16:20:00 Import directory: /Users/morris/Workspaces/Samples/Golang/modules/embedded/cmd/app
[gosec] 2020/09/22 16:20:00 Checking package: main
[gosec] 2020/09/22 16:20:00 Checking file: /Users/morris/Workspaces/Samples/Golang/modules/embedded/cmd/app/main.go
[gosec] 2020/09/22 16:20:00 Import directory: /Users/morris/Workspaces/Samples/Golang/modules/embedded/internal
[gosec] 2020/09/22 16:20:00 Checking package: box
[gosec] 2020/09/22 16:20:00 Checking file: /Users/morris/Workspaces/Samples/Golang/modules/embedded/internal/blob.go
[gosec] 2020/09/22 16:20:00 Checking file: /Users/morris/Workspaces/Samples/Golang/modules/embedded/internal/box.go
Results:


Summary:
   Files: 3
   Lines: 127
   Nosec: 0
  Issues: 0

[OK] 보안 검증이 완료되었습니다!
[OK] 어플리케이션 바이너리가 생성되었습니다.!

위와 같이 정적 파일 생성, 보안 검사, 바이너리 생성 등의 작업이 진행된 것을 볼 수 있다.

이제 생성된 정적 파일을 포함해서 생성된 소스 파일 (./internal/blob.go)을 확인해 보도록 하자.

package box

// Code generated by go generate; DO NOT EDIT.
func init() {
    box.Add("/index.html", []byte{60, 33, 68, 79, 67, 84, 89, 80, 69, 32, 104, 116, 109, 108, 62, 10, 60, 104, 116, 109, 108, 32, 108, 97, 110, 103, 61, 34, 107, 111, 34, 62, 10, 10, 60, 104, 101, 97, 100, 62, 10, 32, 32, 32, 32, 60, 109, 101, 116, 97, 32, 99, 104, 97, 114, 115, 101, 116, 61, 34, 85, 84, 70, 45, 56, 34, 32, 47, 62, 10, 32, 32, 32, 32, 60, 109, 101, 116, 97, 32, 110, 97, 109, 101, 61, 34, 118, 105, 101, 119, 112, 111, 114, 116, 34, 32, 99, 111, 110, 116, 101, 110, 116, 61, 34, 119, 105, 100, 116, 104, 61, 100, 101, 118, 105, 99, 101, 45, 119, 105, 100, 116, 104, 44, 32, 105, 110, 105, 116, 105, 97, 108, 45, 115, 99, 97, 108, 101, 61, 49, 46, 48, 34, 32, 47, 62, 10, 32, 32, 32, 32, 60, 109, 101, 116, 97, 32, 104, 116, 116, 112, 45, 101, 113, 117, 105, 118, 61, 34, 88, 45, 85, 65, 45, 67, 111, 109, 112, 97, 116, 105, 98, 108, 101, 34, 32, 99, 111, 110, 116, 101, 110, 116, 61, 34, 105, 101, 61, 101, 100, 103, 101, 34, 32, 47, 62, 10, 32, 32, 32, 32, 60, 116, 105, 116, 108, 101, 62, 123, 123, 46, 84, 105, 116, 108, 101, 125, 125, 60, 47, 116, 105, 116, 108, 101, 62, 10, 60, 47, 104, 101, 97, 100, 62, 10, 10, 60, 98, 111, 100, 121, 62, 10, 32, 32, 32, 32, 60, 104, 49, 62, 123, 123, 46, 72, 101, 97, 100, 105, 110, 103, 125, 125, 60, 47, 104, 49, 62, 10, 32, 32, 32, 32, 60, 112, 62, 123, 123, 46, 68, 101, 115, 99, 114, 105, 112, 116, 105, 111, 110, 125, 125, 60, 47, 112, 62, 10, 60, 47, 98, 111, 100, 121, 62, 10, 10, 60, 47, 104, 116, 109, 108, 62})
}

제대로 처리가 되었는지는 아래의 명령으로 실행을 검증해 보면 된다.

$ make run

정상적이라면 위와 같이 HTTP 서비스 상태가 된다. 로컬의 브라우저를 열어서 "http://localhost:8080" 으로 출력을 확인해 보면 된다.

결론

아주 간단하게 단순 파일을 바이너리에 추가하고, 이를 서비스하는 방법에 대해 정리해 보았다.

아직 실제 사용하기에는 많은 부분이 부족하다. 하지만 최소한 어떻게 접근하면 되는지에 대한 방법을 찾았기 때문에 아래의 참조 정보들을 검토해서 원하는 결과물을 생성하면 된다.

728x90
반응형
댓글
댓글쓰기 폼