이 시리즈의 이전 글에서는 Go의 net/http 패키지와 이를 활용해 프로덕션 수준의 웹 애플리케이션을 구축하는 방법을 폭넓게 다루었습니다. 당시에는 주로 라우팅 측면과 http.ServeMux 타입의 다양한 기능과 특성에 초점을 맞췄습니다.
이번 글에서는 기본 라우터를 사용해 미들웨어 함수를 구현하는 방법을 통해 ServeMux에 대한 논의를 마무리하고, Go로 웹 서비스를 개발할 때 반드시 유용하게 쓰일 다른 표준 라이브러리 패키지들을 소개합니다.
Go에서의 미들웨어(Middleware)
많은 수 혹은 모든 HTTP 요청에 대해 공통으로 실행되어야 하는 기능을 설정하는 관행을 미들웨어(middleware)라고 부릅니다. 인증, 로깅, 쿠키 검증 같은 작업들은 흔히 미들웨어 함수로 구현되며, 일반적인 라우트 핸들러가 실행되기 전이나 후에 요청을 독립적으로 처리합니다.
Go에서 미들웨어를 구현하려면 http.Handler 인터페이스를 만족하는 타입이 필요합니다. 일반적으로 이는 해당 타입에 ServeHTTP(http.ResponseWriter, *http.Request) 시그니처를 가진 메서드를 붙여야 한다는 의미입니다. 이 메서드만 갖추면 어떤 타입이든 http.Handler 인터페이스를 충족할 수 있습니다.
간단한 예제를 살펴보겠습니다:
package main
import "net/http"
type helloHandler struct {
name string
}
func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello " + h.name))
}
func main() {
mux := http.NewServeMux()
helloJohn := helloHandler{name: "John"}
mux.Handle("/john", helloJohn)
http.ListenAndServe(":8080", mux)
}
/john 경로로 들어온 모든 요청은 곧바로 helloHandler.ServeHTTP 메서드로 전달됩니다. 서버를 시작한 후 https://localhost:8080/john 에 접속하면 직접 확인할 수 있습니다.
http.Handler를 구현할 때마다 커스텀 타입에 ServeHTTP 메서드를 추가하는 것은 상당히 번거로운 작업입니다. 그래서 net/http 패키지는 일반 함수를 HTTP 핸들러처럼 사용할 수 있게 해주는 http.HandlerFunc 타입을 제공합니다.
필요한 것은 함수가 func(http.ResponseWriter, *http.Request) 시그니처를 따르도록 만든 뒤, 이를 http.HandlerFunc 타입으로 변환하는 것뿐입니다.
package main
import "net/http"
func helloJohnHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello John"))
}
func main() {
mux := http.NewServeMux()
mux.Handle("/john", http.HandlerFunc(helloJohnHandler))
http.ListenAndServe(":8080", mux)
}
심지어 위 main 함수의 mux.Handle 줄을 mux.HandleFunc로 바꾸고 함수를 직접 넘겨줄 수도 있습니다. 이전 글에서 우리는 오직 이 패턴만 사용했습니다.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/john", helloJohnHandler)
http.ListenAndServe(":8080", mux)
}
다만 이 시점에서는 이름이 문자열에 하드코딩되어 있습니다. 핸들러를 호출하기 전에 main 함수에서 이름을 지정할 수 있었던 이전 방식과는 다르죠. 이 한계를 없애기 위해 아래와 같이 핸들러 로직을 클로저(closure) 안에 넣을 수 있습니다.
package main
import "net/http"
func helloHandler(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello " + name))
})
}
func main() {
mux := http.NewServeMux()
mux.Handle("/john", helloHandler("John"))
http.ListenAndServe(":8080", mux)
}
helloHandler 함수 자체는 http.Handler 인터페이스를 만족하지 않지만, 인터페이스를 충족하는 익명 함수를 생성해 반환합니다. 이 익명 함수는 name 매개변수를 클로저로 감싸고 있어, 호출될 때마다 해당 값에 접근할 수 있습니다. 이제 helloHandler 함수는 필요한 만큼 여러 다른 이름에 대해 재사용할 수 있습니다.
그렇다면 이것이 미들웨어와 무슨 관련이 있을까요? 사실 미들웨어 함수를 만드는 방식은 위에서 본 것과 동일합니다. 예제에서처럼 클로저에 문자열을 전달하는 대신, 체인상의 다음 핸들러를 인자로 전달하면 됩니다.
완전한 패턴은 다음과 같습니다:
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 미들웨어 로직이 여기에 들어갑니다...
next.ServeHTTP(w, r)
})
}
위의 middleware 함수는 핸들러를 받아서 핸들러를 반환합니다. 익명 함수를 http.HandlerFunc 타입으로 캐스팅함으로써 http.Handler 인터페이스를 만족시킬 수 있다는 점에 주목하세요. 익명 함수의 마지막에서는 ServeHTTP() 메서드를 호출하여 제어권을 next 핸들러로 넘깁니다. 인증된 사용자의 ID처럼 핸들러 간에 값을 전달해야 한다면, Go 1.7에서 도입된 http.Request.Context() 메서드를 활용할 수 있습니다.
이 패턴을 보여주는 간단한 미들웨어 함수를 작성해 보겠습니다. 이 함수는 요청 객체에 requestTime이라는 속성을 추가하고, 이후 helloHandler가 이를 활용해 요청의 타임스탬프를 표시합니다.
package main
import (
"context"
"net/http"
"time"
)
func requestTime(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, "requestTime", time.Now().Format(time.RFC3339))
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
func helloHandler(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responseText := "<h1>Hello " + name + "</h1>"
if requestTime := r.Context().Value("requestTime"); requestTime != nil {
if str, ok := requestTime.(string); ok {
responseText = responseText + "\n<small>Generated at: " + str + "</small>"
}
}
w.Write([]byte(responseText))
})
}
func main() {
mux := http.NewServeMux()
mux.Handle("/john", requestTime(helloHandler("John")))
http.ListenAndServe(":8080", mux)
}

우리의 미들웨어 함수는 http.Handler 타입을 받아서 http.Handler 타입을 반환하기 때문에, 미들웨어 함수들을 서로 중첩시켜 무한한 체인을 만드는 것이 가능합니다.
예를 들면 다음과 같습니다:
mux := http.NewServeMux()
mux.Handle("/", middleware1(middleware2(appHandler)))
Alice 같은 라이브러리를 사용하면 위 구조를 다음과 같이 더 읽기 좋은 형태로 변환할 수 있습니다:
alice.New(middleware1, middleware2).Then(appHandler)
템플릿(Templating)
싱글 페이지 애플리케이션(SPA)의 등장으로 템플릿 사용이 줄어들긴 했지만, 여전히 완성도 높은 웹 개발 솔루션에서 중요한 부분을 차지합니다.
Go는 템플릿 작업을 위한 두 가지 패키지를 제공합니다: text/template과 html/template입니다. 두 패키지는 동일한 인터페이스를 가지지만, 후자는 코드 삽입(code injection) 공격을 막기 위해 내부적으로 인코딩 처리를 수행합니다.
Go 템플릿이 가장 표현력이 뛰어난 것은 아니지만, 실무에 충분히 쓸 만하며 프로덕션 애플리케이션에서도 사용할 수 있습니다. 실제로 인기 있는 정적 사이트 생성기인 Hugo의 템플릿 시스템도 Go 템플릿을 기반으로 합니다.
html/template 패키지를 사용해 HTML 출력을 웹 요청에 대한 응답으로 보내는 방법을 간단히 살펴보겠습니다.
템플릿 만들기
main.go 파일과 같은 디렉터리에 index.html 파일을 생성하고 다음 코드를 추가합니다:
<ul>
{{ range .TodoItems }}
<li>{{ . }}</li>
{{ end }}
</ul>
그다음 main.go 파일에 아래 코드를 추가합니다:
package main
import (
"html/template"
"log"
"os"
)
func main() {
t, err := template.ParseFiles("index.html")
if err != nil {
log.Fatal(err)
}
todos := []string{"Watch TV", "Do homework", "Play games", "Read"}
err = t.Execute(os.Stdout, todos)
if err != nil {
log.Fatal(err)
}
}
go run main.go로 프로그램을 실행하면 다음과 같은 출력을 볼 수 있습니다:
<ul>
<li>Watch TV</li>
<li>Do homework</li>
<li>Play games</li>
<li>Read</li>
</ul>
축하합니다! 방금 첫 번째 Go 템플릿을 만들었습니다. 템플릿 파일에서 사용한 문법을 간단히 설명하면 다음과 같습니다:
- Go는 템플릿에서 데이터 평가와 제어 구조(액션(action)이라 불림)를 구분하기 위해 이중 중괄호(
{{와}})를 사용합니다. range액션은 슬라이스 같은 자료구조를 순회할 때 사용합니다..은 현재 컨텍스트를 나타냅니다.range액션에서 현재 컨텍스트는todos슬라이스이며, 블록 내부에서{{ . }}은 슬라이스의 각 요소를 가리킵니다.
main.go 파일에서 template.ParseFiles 메서드는 하나 이상의 파일로부터 새 템플릿을 생성합니다. 이후 이 템플릿은 template.Execute 메서드로 실행되며, 이 메서드는 io.Writer와 템플릿에 적용할 데이터를 인자로 받습니다.
위 예제에서는 템플릿을 표준 출력으로 실행했지만, io.Writer 인터페이스를 만족하기만 하면 어떤 대상으로든 출력할 수 있습니다. 예를 들어 웹 요청의 응답으로 결과를 반환하고 싶다면, 아래와 같이 템플릿을 ResponseWriter 인터페이스로 실행하기만 하면 됩니다.
package main
import (
"html/template"
"log"
"net/http"
)
func main() {
t, err := template.ParseFiles("index.html")
if err != nil {
log.Fatal(err)
}
todos := []string{"Watch TV", "Do homework", "Play games", "Read"}
http.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err = t.Execute(w, todos)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
http.ListenAndServe(":8080", nil)
}

이 섹션은 Go의 템플릿 패키지에 대한 간단한 소개에 불과합니다. 더 복잡한 사용 사례에 관심이 있다면 text/template과 html/template의 공식 문서를 꼭 확인해 보세요.
Go의 템플릿 방식이 마음에 들지 않는다면 Plush 라이브러리 같은 대안도 존재합니다.
JSON 다루기
JSON 객체를 다뤄야 한다면 좋은 소식이 있습니다. Go의 표준 라이브러리는 encoding/json 패키지를 통해 JSON 파싱과 인코딩에 필요한 모든 것을 제공합니다.
기본 타입 매핑
Go에서 JSON 객체를 인코딩하거나 디코딩할 때 다음 타입들이 사용됩니다:
- JSON 불리언 →
bool - JSON 숫자 →
float64 - JSON 문자열 →
string - JSON null →
nil - JSON 객체 →
map[string]interface{} - JSON 배열 →
[]interface{}
인코딩(Encoding)
자료구조를 JSON으로 인코딩하려면 json.Marshal 함수를 사용합니다. 예제를 살펴보겠습니다:
package main
import (
"encoding/json"
"fmt"
)
type Person struct {
FirstName string
LastName string
Age int
email string
}
func main() {
p := Person{
FirstName: "Abraham",
LastName: "Freeman",
Age: 100,
email: "abraham.freeman@hey.com",
}
json, err := json.Marshal(p)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(json))
}
위 프로그램에는 네 개의 필드를 가진 Person 구조체가 있습니다. main 함수에서는 모든 필드를 초기화한 Person 인스턴스를 생성하고, json.Marshal 메서드로 p 구조체를 JSON으로 변환합니다. 이 메서드는 바이트 슬라이스 또는 에러를 반환하므로, JSON 데이터에 접근하기 전에 에러를 먼저 처리해야 합니다.
Go에서 바이트 슬라이스를 문자열로 변환하려면 위 예제에서 보듯이 타입 변환(type conversion)을 수행해야 합니다. 프로그램을 실행하면 다음과 같은 출력이 나옵니다:
{"FirstName":"Abraham","LastName":"Freeman","Age":100}
보시다시피 원하는 용도로 활용할 수 있는 유효한 JSON 객체가 생성되었습니다. 주목할 점은 email 필드가 결과에서 빠져 있다는 것입니다. 그 이유는 이 필드가 소문자로 시작하기 때문에 Person 객체 외부로 노출(export)되지 않기 때문입니다.
기본적으로 Go는 구조체의 속성 이름을 그대로 결과 JSON 객체의 필드 이름으로 사용합니다. 하지만 구조체 필드 태그(struct field tag)를 사용하면 이를 변경할 수 있습니다.
type Person struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Age int `json:"age"`
email string `json:"email"`
}
위의 구조체 필드 태그는 JSON 인코더가 구조체의 FirstName 속성을 JSON 객체의 first_name 필드에 매핑하도록 지정합니다. 이전 예제에 이 변경을 적용하면 다음과 같은 출력이 나옵니다:
{"first_name":"Abraham","last_name":"Freeman","age":100}
디코딩(Decoding)
JSON 객체를 Go 구조체로 디코딩할 때는 json.Unmarshal 함수를 사용합니다. 시그니처는 다음과 같습니다:
func Unmarshal(data []byte, v interface{}) error
이 함수는 JSON 데이터의 바이트 슬라이스와 디코딩된 데이터를 저장할 대상을 받습니다. 디코딩이 성공하면 반환되는 에러는 nil입니다.
다음과 같은 JSON 객체가 있다고 가정해 보겠습니다:
json := "{\"first_name\":\"John\",\"last_name\":\"Smith\",\"age\":35, \"place_of_birth\": \"London\", gender:\"male\"}"
아래와 같이 이를 Person 구조체의 인스턴스로 디코딩할 수 있습니다:
func main() {
b := `{"first_name":"John","last_name":"Smith","age":35, "place_of_birth": "London", "gender":"male", "email": "john.smith@hmail.com"}`
var p Person
err := json.Unmarshal([]byte(b), &p)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", p)
}
실행 결과는 다음과 같습니다:
{FirstName:John LastName:Smith Age:35 email:}
Unmarshal은 대상 타입에 존재하는 필드만 디코딩합니다. 이 경우 place_of_birth와 gender는 Person 구조체의 어떤 필드에도 매핑되지 않으므로 무시됩니다. 이 동작을 활용하면 큰 JSON 객체에서 특정 필드 몇 개만 골라 담을 수 있습니다. 앞서와 마찬가지로, 대상 구조체의 비노출(unexported) 필드는 JSON 객체에 대응하는 필드가 있더라도 영향을 받지 않습니다. 그래서 JSON 객체에 email 값이 있음에도 출력에서는 빈 문자열로 남아 있는 것입니다.
데이터베이스
database/sql 패키지는 SQL(또는 SQL 유사) 데이터베이스를 위한 범용 인터페이스를 제공합니다. 이 패키지는 반드시 데이터베이스 드라이버와 함께 사용해야 하며, 드라이버를 임포트할 때는 초기화를 위해 밑줄(_) 접두사를 붙여야 합니다.
예를 들어 MySQL 드라이버 패키지를 database/sql과 함께 사용하는 방법은 다음과 같습니다:
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
내부적으로 드라이버는 database/sql 패키지에서 사용 가능하다고 스스로를 등록하지만, 우리 코드에서 직접 사용되지는 않습니다. 이를 통해 특정 드라이버에 대한 의존성을 줄여 최소한의 노력으로 다른 드라이버로 쉽게 교체할 수 있습니다.
데이터베이스 연결 열기
데이터베이스에 접근하려면 아래와 같이 sql.DB 객체를 생성해야 합니다:
func main() {
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/hello")
if err != nil {
log.Fatal(err)
}
}
sql.Open 메서드는 나중에 사용할 수 있도록 데이터베이스 추상화를 준비할 뿐입니다. 실제로 데이터베이스에 연결을 설정하거나 연결 매개변수를 검증하지 않습니다. 데이터베이스가 즉시 사용 가능하고 접근 가능한지 확인하려면 db.Ping() 메서드를 사용하세요:
err = db.Ping()
if err != nil {
log.Fatal(err)
}
데이터베이스 연결 닫기
데이터베이스 연결을 닫으려면 db.Close()를 사용합니다. 일반적으로는 데이터베이스 연결을 연 함수(보통 main 함수)가 종료될 때 닫히도록 defer로 지연 실행하는 것이 좋습니다:
func main() {
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/hello")
if err != nil {
log.Fatal(err)
}
defer db.Close()
}
sql.DB 객체는 장기간 유지되도록 설계되었으므로 자주 열고 닫으면 안 됩니다. 그렇게 하면 연결의 재사용 및 공유 저하, 사용 가능한 네트워크 리소스 고갈, 간헐적인 실패 같은 문제가 발생할 수 있습니다. 가장 좋은 방법은 sql.DB 객체를 전역으로 만들거나 여기저기 전달하여 사용하고, 프로그램이 해당 데이터 저장소에 대한 접근을 마칠 때만 닫는 것입니다.
데이터베이스에서 데이터 조회하기
테이블 조회는 세 단계로 수행할 수 있습니다. 첫째, db.Query()를 호출합니다. 둘째, 행(row)들을 순회합니다. 셋째, rows.Scan()으로 각 행을 변수에 추출합니다. 예제는 다음과 같습니다:
var (
id int
name string
)
rows, err := db.Query("select id, name from users where id = ?", 1)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
err := rows.Scan(&id, &name)
if err != nil {
log.Fatal(err)
}
log.Println(id, name)
}
err = rows.Err()
if err != nil {
log.Fatal(err)
}
쿼리가 단일 행만 반환한다면 db.Query 대신 db.QueryRow 메서드를 사용해 이전 코드 조각의 긴 보일러플레이트 코드를 피할 수 있습니다:
var (
id int
name string
)
err = db.QueryRow("select id, name from users where id = ?", 1).Scan(&id, &name)
if err != nil {
log.Fatal(err)
}
fmt.Println(id, name)
NoSQL 데이터베이스
Go는 Redis, MongoDB, Cassandra 같은 NoSQL 데이터베이스도 잘 지원하지만, 이를 위한 표준 인터페이스는 제공하지 않습니다. 따라서 각 데이터베이스에 맞는 드라이버 패키지에 전적으로 의존해야 합니다. 몇 가지 예는 다음과 같습니다.
- https://github.com/go-redis/redis (Redis 드라이버)
- https://github.com/mongodb/mongo-go-driver (MongoDB 드라이버)
- https://github.com/gocql/gocql (Cassandra 드라이버)
- https://github.com/Shopify/sarama (Apache Kafka 드라이버)
마치며
이번 글에서는 Go로 웹 애플리케이션을 구축할 때 꼭 알아야 할 핵심 요소들을 살펴보았습니다. 이제 많은 Go 프로그래머가 표준 라이브러리를 강력히 신뢰하는 이유를 이해하셨을 겁니다. 표준 라이브러리는 매우 포괄적이며, 프로덕션 수준의 서비스에 필요한 도구 대부분을 제공합니다.
다룬 내용 중 명확하지 않은 부분이 있다면 Twitter로 메시지를 보내주세요. 이 시리즈의 다음이자 마지막 글에서는 go 도구와 이를 활용해 Go 개발 과정의 일반적인 작업을 처리하는 방법을 다룰 예정입니다.
읽어주셔서 감사합니다. 즐거운 코딩 되세요!