Mat Ryer의 Go Programming Blueprints
원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

- 내 평점: 8 / 10
- 구매 링크
나는 Mat Ryer의 작업을 좋아하며, 그의 블로그 글은 내가 Go를 프로그래밍하는 방식에 큰 영향을 미쳤다. 이 책은 호불호가 갈렸다. 어떤 장은 흥미롭고 Go에 대한 값진 교훈을 주었지만, 다른 장은 지루했고 서드파티 라이브러리의 사소한 세부사항에 너무 매몰되어 있었다. 전체적으로, 스스로를 Go 초급 혹은 중급 프로그래머라고 여기는 사람이라면 누구에게든 여전히 추천하고 싶다.
좋았던 점
- 다양한 예제 앱이 현실적인 시나리오에서 Go의 기능을 잘 보여주었다.
- 아주 우아한 Go 코드를 담고 있어 몇 가지 새로운 관용적 언어 패턴을 배울 수 있었다.
- Go 표준 라이브러리를 흥미로운 방식으로 활용한다.
- 그동안 전혀 이해하지 못했던 HTTP context를 마침내 이해하게 해주었다.
- DRM-free 포맷으로 제공된다.
아쉬웠던 점
- 대부분의 예제가 내가 보통 작성하는 단일 서버 Go 애플리케이션보다는 고도로 확장 가능한 애플리케이션에 초점을 맞추고 있다.
- 책이 무거운 Google 라이브러리(예: Google Maps, OAuth, gRPC, AppEngine)에 지나치게 의존한다는 느낌이 들었다.
- 많은 예제가 솔루션의 Go와 관련된 부분보다는 특정 라이브러리의 세세한 내용에 깊이 파고들었다.
- 몇 가지 심각하게 안전하지 않은 소프트웨어 관행을 권장한다:
- 어떤 권한을 부여해야 할지 모를 때 개발자에게 기본 비트 마스크로
0777을 사용하라고 조언한다. - 디렉터리 트래버설 공격에 대한 방어에 실패하여, 예제 애플리케이션에 원격 코드 실행으로 이어질 수 있는 임의 쓰기 취약점을 만들었다.
- 사용자 업로드에 대한 간단한 서비스 거부 공격에 대한 방어에 실패했다.
- 어떤 권한을 부여해야 할지 모를 때 개발자에게 기본 비트 마스크로
- 글의 편집과 코드의 오류 검사가 부실하다.
- 부주의한 문법 및 코드 실수가 많았다.
- 사용자들이 수정 사항을 제출했지만 수년간 무시되었다.
- 바닐라 JavaScript로도 충분히 혹은 더 잘 동작할 곳에서 jQuery를 사용해 예제를 복잡하게 만들었다.
- bash 스크립트 예제가 허술하게 느껴졌다.
- 책 전반에 걸쳐 코드 품질이 일관되지 않았다.
- 어떤 예제는 우아하고 직관적인 반면, 다른 예제는 초안처럼 느껴진다.
- 두 개의 독립적인 GitHub 저장소가 있다: 하나는 저자가 만든 저장소이고 하나는 출판사가 만든 저장소이다.
- 저자의 저장소가 올바른 것으로 보인다.
- Windows에서 예제를 실행하는 방법이 안내되어 있지만, 테스트되지 않은 채 덧붙인 느낌이다.
- 일부 예제는 사라진 서드파티 의존성 때문에 더 이상 컴파일되지 않는다.
핵심 정리
Go 언어와 표준 라이브러리 팁
시그널 채널
- 시그널 채널은 Go에서 스레드 안전 이벤트를 구현하는 관용적인 방법이다.
- 시그널 채널은 그저
chan타입이struct{}인 채널일 뿐이다- 시그널 채널은 데이터를 전달하지 않는다 — 그저 이벤트가 발생했음을 알릴 뿐이다.
- Twitter votes 앱은 시그널 채널을 다음과 같이 활용하는 좋은 예이다:
- 클라이언트가 서버를 중단할 수 있도록 허용한다.
- 백그라운드 프로세스가 작업을 완료했음을 나타낸다.
time.Ticker
나는 이전에 time.Ticker 타입을 본 적이 없어서 실수로 내가 직접 만든 버전을 구현한 적이 있다. 정해진 간격으로 코드를 실행하는 간단한 방법이다:
for range time.NewTicker(5 * time.Minute).C {
// Execute this code every five minutes.
}나는 PicoShare에서 주기적인 데이터베이스 유지보수를 예약하기 위해 time.Ticker를 사용한다.
flags.Duration은 놀라울 정도로 유연하다
flags.Duration은55s나10m같은 다양한 시간 단위를 네이티브로 지원한다.- 즉,
flags.Duration을 커맨드라인 플래그로 사용하면, 커맨드라인 인터페이스에서--interval 10m같은 플래그를 받을 수 있고,flags패키지가 이를time.Duration으로 네이티브하게 파싱해 준다.
- 즉,
테스트 패키지와 프로덕션 코드 분리
- 테스트를 프로덕션 코드와 별도의 패키지로 작성하면 더 나은 테스트를 만들 수 있다.
- 예: 같은 디렉터리에서
foo패키지에 대한 테스트를foo_test라는 패키지에 작성한다. - 보통 Go 도구는 같은 폴더에 여러 패키지를 두는 것을 금지하지만, 테스트에 대해서는 예외를 둔다.
- 예: 같은 디렉터리에서
- 별도의
_test패키지는 테스트가 프로덕션 패키지의 public 멤버에만 접근하도록 보장한다.- 이는 테스트가 내부 구현 세부사항보다는 클라이언트에 노출되는 동작을 검증하도록 유도한다.
함수 인자는 파라미터 목록 끝에 두기
함수가 함수 파라미터를 받는다면, 파라미터 목록의 끝에 두어라. 그렇지 않으면 독자가 어떤 인자가 내부 함수에 속하고 어떤 것이 외부 함수에 속하는지 파악하기 어렵다.
나쁜 인자 순서
값의 변경을 폴링하여 로컬 복사본을 주기적으로 업데이트하는 updateValue 함수가 있어 SetValFn을 받아야 한다고 가정해 보자:
type SetValFn func(key, value string) boolSetValFn 파라미터가 첫 번째 인자라면, 함수 정의에서는 모든 것이 괜찮아 보인다:
func updateValue(setFn SetValFn, interval time.Duration) {
for range time.NewTicker(interval).C {
value := fetchValue()
setFn("somekey", value)
}
}하지만 updateValue를 호출할 때가 되면, 호출부가 읽기 어려워진다:
updateValue(func(key, value string) bool {
if err := DB.SetKey(key, value); err != nil {
return false
}
return true
}, 5*time.Minute) // Which function call is this for?미묘한 점은 5*time.Minute가 updateValue의 인자이지만 SetValFn의 인라인 함수 정의 전체 뒤에 나오기 때문에 updateValue와의 연관성을 알아차리기 어렵다는 것이다.
더 나은 인자 순서
위의 예제를 더 잘 고쳐 쓰는 방법은 함수 인자가 목록의 마지막에 오도록 하는 것이다:
// Reorder arguments so that SetValFn is last
func updateValue(interval time.Duration, setFn SetValFn) {그렇게 하면 호출부에서 두 인자 모두 updateValue에 대한 것임이 더 분명해진다:
updateValue(5*time.Minute, func(key, value string) bool {
if err := DB.SetKey(key, value); err != nil {
return false
}
return true
})코드에서 가시성(Line of Sight)을 우선하기
책에서는 “line of sight” 개념을 다루지만, Ryer가 블로그에서 그 개념을 더 잘 설명한다고 생각한다.
컨텍스트와 조건문이 깊게 중첩되면 코드를 읽기 어려워지고, 조건문의 분기들이 멀리 떨어져 있으면 컨텍스트를 유지하기 어렵다. Ryer는 로직이 화면의 왼쪽 가장자리 가까이에 머물도록 코드를 구조화할 것을 권장한다.
가시성이 나쁜 경우
가시성이 나쁜 경우, 로직이 깊게 중첩되고 조건문 블록이 크다:
if something.OK() {
something.Lock()
defer something.Unlock()
err := something.Do()
if err == nil {
stop := StartTimer()
defer stop()
log.Println("working...")
doWork(something)
<-something.Done()
log.Println("finished")
return nil
} else {
return err
}
} else {
return errors.New("something not ok")
}가시성이 좋은 경우
가시성을 개선하려면 조건문의 로직을 뒤집어 오류 시 일찍 반환하고, 나머지 로직은 조건문 밖에 두면 된다:
if !something.OK() { // flipped
return errors.New("something not ok")
}
something.Lock()
defer something.Unlock()
err := something.Do()
if err != nil { // flipped
return err
}
stop := StartTimer()
defer stop()
log.Println("working...")
doWork(something)
<-something.Done()
log.Println("finished")
return nilHTTP 핸들러에서 context 활용하기
나는 5년 동안 취미로 Go 웹 프로그래밍을 해왔지만, 이 책을 읽기 전까지는 HTTP 핸들러에서 context.Context의 의미를 전혀 이해하지 못했다. 6장에서는 이에 대해 잘 설명하고 있지만, 여기서 요약해 보려 한다.
웹 앱에서 모든 HTTP 요청마다 사용자가 API 키를 제공해야 한다고 가정해 보자. 헤더나 URL 쿼리 파라미터 혹은 쿠키일 수 있지만, 단순화를 위해 쿼리 파라미터라고 하자. 사용자가 /foo?key=abc123 같은 키로 API를 호출하기를 기대한다. 그리고 모든 엔드포인트를 보호하기 위해 요청에 올바른 API 키가 있는지 확인하고 싶다.
이를 위해 HTTP 미들웨어 함수를 만들 수 있다. 미들웨어 함수들은 체인 형태로 동작하므로, 여러 미들웨어 함수가 동일한 HTTP 요청을 차례로 처리할 수 있다. 미들웨어 함수들은 context.Context를 사용해 후속 HTTP 핸들러에 데이터를 전달한다.
API 키를 강제하려면, 먼저 Context 객체에 API 키를 저장하기 위한 키를 만들어야 한다:
type contextKey struct {
name string
}
var contextKeyAPIKey = &contextKey{"api-key"}아직 완전히 이해하지 못한 이유로, 키는 단순한 문자열이 아니라 문자열을 포함하는 struct여야 한다.
업데이트 (2023-01-02): 처음에는 왜 contextKey가 단순한 문자열이 아니라 문자열을 담은 struct인지 혼란스러웠다. 책에서 Ryer는 이러한 결정이 같은 값을 가진 다른 키와의 충돌을 방지한다고 설명하지만, 개발자가 왜 그냥 다른 용도로 같은 키를 재사용하지 않으면 되지 않는지 이해하지 못했다. Matthew Riley가 이 동작을 명확히 설명해 주었고 로컬 타입이 패키지 간의 충돌을 방지한다는 것을 깨닫게 해주었다. 단순한 문자열과 달리 말이다.
만약 const contextKeyToken := "token" 같은 context 키를 사용하고 다른 패키지가 동일한 요청을 처리하면서 역시 "token"이라는 키를 사용한다면, 서로의 context 값을 덮어쓰게 될 것이다. 패키지에 로컬한 커스텀 타입을 정의하면, 타입이 다르기 때문에 Context가 다른 패키지의 토큰을 당신의 토큰과 동일하다고 평가하지 않음이 보장된다.
이제 context 키를 정의했으니, 다음과 같은 미들웨어 함수를 만든다:
func withAPIKey(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key != "abc123" {
http.Error(w, "Invalid API key", http.StatusUnauthorized)
return
}
// Add the API key to the request context.
ctx := context.WithValue(r.Context(), contextKeyAPIKey, key)
fn(w, r.WithContext(ctx))
}
}라우트를 정의할 때, 요청 핸들러를 withAPIKey 미들웨어로 감싼다:
mux := http.NewServeMux()
mux.HandleFunc("/foo", withAPIKey(s.handleFoo))withAPIKey 미들웨어는 요청의 API 키가 유효하고 존재함을 보장한다. withAPIKey 하류에 있는 요청 핸들러 중 API 키에 접근해야 하는 것이 있다면, 이 헬퍼 함수를 호출할 수 있다:
func APIKey(ctx context.Context) string {
k := ctx.Value(contextKeyAPIKey)
if k == nil {
panic("no API key in request")
}
key, ok := k.(string)
if !ok {
panic("API key in request is not a string")
}
return key
}handleFoo 핸들러는 withAPIKey 미들웨어의 하류에 있으므로, 요청 context에서 API 키에 접근할 수 있다:
func (s *Server) handleFoo(w http.ResponseWriter, r *http.Request) {
log.Printf("handling /foo, API key=%v", APIKey(r.Context()))
}HTTP 헬퍼 함수
Mat Ryer의 HTTP 인코딩 헬퍼 패턴
Ryer는 인코딩 형식을 추상화하여 HTTP 핸들러가 교환 형식에 구애받지 않도록 할 것을 주장한다. 그렇게 하면 인터페이스가 JSON을 사용하다가 protobuf로 바꿔야 할 때 단 하나의 파일만 수정하면 된다.
Ryer는 인코딩 세부사항을 숨기기 위해 decode와 respond 헬퍼 함수를 사용하므로, 라우트 핸들러는 다음과 같이 보인다:
func handleFooPost(w http.ResponseWriter, r *http.Request) {
var payload struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}
if err := decode(r, &payload); err != nil {
respondErr(ctx, w, r, err, http.StatusBadRequest)
return
}
// Do something with the request.
response := struct {
ID string `json:"id"`
}{
ID: "1234",
}
respond(ctx, w, r, response, http.StatusOK)
}그리고 decode와 respond가 각각 JSON 역직렬화와 직렬화를 처리한다:
// decode parses JSON from an HTTP request body.
func decode(r *http.Request, v interface{}) error {
err := json.NewDecoder(r.Body).Decode(v)
if err != nil {
return err
}
if valid, ok := v.(interface {
OK() error
}); ok {
err = valid.OK()
if err != nil {
return err
}
}
return nil
}
// respond serializes response data to JSON in the body of an HTTP request.
func respond(ctx context.Context, w http.ResponseWriter, r *http.Request, v interface{}, code int) {
var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(v)
if err != nil {
respondErr(ctx, w, r, err, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_, err = buf.WriteTo(w)
if err != nil {
log.Errorf(ctx, "respond: %s", err)
}
}Ryer의 인코딩 헬퍼 패턴을 내가 변형한 방법
나는 Ryer의 헬퍼 메서드 아이디어가 마음에 들지만, 얻는 이점에 비해 추상화 비용이 너무 크다고 생각한다. 웹 앱을 다른 인코딩 방식으로 다시 작성하는 일이 얼마나 자주 있는가?
게다가 라우트 핸들러가 형식에 대해 아무것도 몰라야 하는데도 struct에 JSON 태그를 지정해야 하므로 어차피 추상화가 누출된다.
나는 또한 오류 메시지를 JSON으로 작성하는 것을 좋아하지 않는다. Go HTTP 스택의 대부분 구성 요소는 평문 오류로 실패하므로, JSON 형식의 오류는 클라이언트가 잘 구성된 JSON과 평문 모두에서 오류를 찾아야 함을 의미하기 때문이다. 오류 메시지는 항상 평문으로 보내는 것이 더 쉽다.
성공적인 JSON 응답을 위해서는 다음과 같이 respondJSON이라는 함수를 사용한다:
func respondJSON(w http.ResponseWriter, data interface{}) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Fatalf("failed to encode JSON response: %v", err)
}
}그리고 JSON 디코딩은 인라인으로 처리하므로, 나의 handleFooPost는 다음과 같이 보인다:
func handleFooPost(w http.ResponseWriter, r *http.Request) {
var payload struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "JSON is invalid", http.StatusBadRequest)
return
}
// Do something with the request.
respondJSON(w, struct {
ID string `json:"id"`
}{
ID: "1234",
})
}결국 json.NewDecoder(r.Body).Decode(&payload) 스니펫을 반복하게 되지만, 한 줄일 뿐이므로 큰 문제는 아니다.
클라이언트에게 내부 struct 세부 정보 숨기기
모든 언어에 영향을 미치는 웹 개발의 함정 중 하나는 의도치 않은 데이터 노출이다. 사용자에 대한 데이터를 나타내는 내부 struct가 있다고 가정해 보자:
type User struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}/user?id=1234 같은 JSON API를 노출하고 싶어서 다음과 같이 작성한다:
func handleUserGet(w http.ResponseWriter, r *http.Request) {
user, err := loadUser(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "Failed to load user", http.StatusInternalServerError)
return
}
respondJSON(w, user)
}사용자가 /user 라우트를 조회하면, 사용자에 대한 공개 정보를 받게 된다:
curl https://example.com/user?id=1234{
"username": "alice123",
"displayName": "Alice"
}지금까지는 좋다. 그런데 한 달 뒤, 사용자의 이메일 주소나 비밀번호 해시 같은 더 많은 데이터를 전달하기 위해 내부 struct를 수정하고 싶어졌다고 해보자:
type User struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"` // Add these for
PasswordHash string `json:"passwordHash"` // internal operations.
}handleUserGet을 건드리지 않았음에도, 이제 사용자가 /user 라우트를 호출하면 많은 새로운 정보를 받게 된다:
curl https://example.com/users?id=1234{
"username": "alice123",
"displayName": "Alice",
"email": "[email protected]",
"passwordHash": "$2a$10$J5zqqeQgH80ScyOSeCNCD.1V3ApJ1ULYMwMEhOjG6j4SM1mqL84YO"
}이런! 모든 사람의 이메일 주소와 비밀번호 해시를 그대로 노출해 버렸다.
내가 예전에 모의 해킹을 할 때, 현실에서 이러한 실수를 저지르는 여러 회사를 발견했다. 이는 교묘한 버그인데, 개발자 관점에서는 handlerUserGet을 구현했을 때 모든 것이 의도대로 동작했기 때문이다. User struct에 필드를 추가할 때 handleUsersGet을 건드리지 않으므로, 애플리케이션의 원시 HTTP 트래픽을 주기적으로 확인하지 않는 한 노출을 알아차리지 못한다.
나는 내 앱에서 이러한 종류의 실수를 하는 것에 대해 편집증적일 정도로 조심하므로, 다른 사람들이 이를 어떻게 처리하는지 항상 궁금하다.
Ryer의 Public 메서드 패턴
Ryer는 내부 및 외부 표현을 모두 가진 struct에 Public 메서드를 추가하여 위 문제를 해결할 것을 제안한다:
type obj struct {
value1 string
value2 string
value3 string
}
func (o *obj) Public() interface{} {
return map[string]interface{}{"one": o.value1, "three": o.value3}
}
func TestPublic(t *testing.T) {
is := is.New(t)
o := &obj{
value1: "value1",
value2: "value2",
value3: "value3",
}
v, ok := meander.Public(o).(map[string]interface{})
is.Equal(true, ok)
is.Equal(v["one"], "value1")
is.Nil(v["two"])
is.Equal(v["three"], "value3")
}나는 Mat Ryer의 기법을 좋아하고, 코드베이스에 그 관례를 정립한다면 잘 동작한다고 생각하지만, Go에서 이 문제에 대한 내가 가장 선호하는 해결책은 아니다.
Ryer의 기법에 대한 나의 주요 불만은 캡슐화를 위반한다는 점이다. 나는 내부 타입을 가능한 한 단순하게 유지하고 클라이언트가 데이터를 어떻게 사용할지에 대한 가정을 최소화하는 것을 선호한다. Public 메서드를 추가한다는 것은 타입이 클라이언트가 데이터를 어떻게 사용할지를 미리 예상한다는 뜻이며, 모든 엔드포인트가 동일한 필드를 노출하도록 강제한다.
내가 선호하는 세부 정보 숨기는 방법
내 Go 코드에서는 외부에 노출되는 데이터에 대해 별개의 struct를 선호한다. 외부 클라이언트에 데이터를 게시해야 할 때, 내부 struct의 데이터를 외부 struct로 복사한다.
보통은 인라인으로 선언하는 익명 struct를 사용하므로 다른 이름 있는 타입조차 필요하지 않다:
// my internal data
type User struct {
Username string
DisplayName string
Email string
PasswordHash string
}
func handleUserGet(w http.ResponseWriter, r *http.Request) {
user, err := loadUser(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "Failed to load user", http.StatusInternalServerError)
return
}
// Copy the fields from User that I want to publish into a new anonymous
// struct.
respondJSON(w, struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
}{
Username: user.Username,
DisplayName: user.DisplayName,
})
}나는 몇 가지 이유로 이 방법을 선호한다:
- 의도치 않은 노출에 대한 추가적인 보호 계층이 있다.
- 누군가 실수로 내부 struct를 외부 타입에 포함하더라도, 내부 struct 필드에 JSON 태그가 없으므로 아무것도 출력되지 않는다.
- 반환하는 데이터를 더 명시적으로 만든다.
- 데이터에 대해 더 세밀한 제어를 제공한다.
Public패턴에서는 해당 타입을 포함하는 모든 엔드포인트가 동일한 형식으로 데이터를 반환해야 하는 반면, 위의 방법에서는 각 엔드포인트가 어떤 필드를 어떤 형식으로 노출할지 결정한다.
흥미로운 장들에 대한 언급
WebSocket을 활용한 채팅 애플리케이션
- 고루틴과 WebSocket을 활용한 멋진 데모.
사용자 계정 추가
- HTTP 핸들러를 체이닝하는 좋은 예제.
분산 시스템 구축과 유연한 데이터 다루기
- 이 장만으로도 책 값을 할 만했다.
- 수평적 확장: 노드를 추가하여 안정성이나 성능을 개선하기 위해 시스템을 확장하는 것
- 수직적 확장: 개별 노드의 리소스를 늘려(예: RAM이나 CPU 추가) 시스템을 확장하는 것
- 수평적으로 확장 가능한 서비스들을 결합한 멋진 예제.
- NSQ를 사용해 메시지를 게시한다.
- Twitter 스트리밍 API를 사용해 Twitter에서 실시간 데이터를 읽는다.
- MongoDB를 사용해 데이터를 저장한다.
- 매우 확장성이 높으면서도 단순한 부품들로 이루어진 시스템을 보는 것은 매우 흥미롭다.
- HTTP 연결에서 커스텀 transport 함수를 사용하여 하위 TCP 연결의 저수준 동작을 커스터마이즈한다.
- 앱이 운영체제로부터
SIGINT또는SIGTERM시그널을 받을 때 커스텀 정리를 수행하기 위해 기본 시그널 핸들러를 재정의하는 좋은 예제이다. 이 코드를 참고하라.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기