Mat Ryer의 Go Programming Blueprints

- 내 평점: 8 / 10
- 구매 링크
저는 Mat Ryer의 작업을 좋아하며, 그의 블로그 글은 제가 Go를 프로그래밍하는 방식에 큰 영향을 주었습니다. 이 책은 장마다 편차가 컸습니다. 어떤 장은 흥미롭고 값진 Go 교훈을 주었지만, 다른 장은 지루했고 서드파티 라이브러리의 세세한 내용에 너무 매몰되어 있었습니다. 전반적으로는 자신을 Go 초급 혹은 중급 프로그래머라고 생각하는 분이라면 누구에게든 추천하고 싶습니다.
좋았던 점
- 다양한 예제 앱이 현실적인 시나리오에서 Go의 기능을 잘 보여주었습니다.
- 아주 우아한 Go 코드가 등장해 몇 가지 새로운 관용적 패턴을 배울 수 있었습니다.
- Go 표준 라이브러리를 흥미로운 방식으로 활용합니다.
- 예전에는 전혀 이해하지 못했던 HTTP 콘텍스트가 이 책 덕분에 비로소 이해되었습니다.
- DRM 없는 형식으로 제공됩니다.
아쉬웠던 점
- 대부분의 예제가 제가 주로 작성하는 단일 서버 Go 애플리케이션이 아니라 대규모 확장성을 염두에 둔 애플리케이션에 집중되어 있습니다.
- 책이 무거운 Google 라이브러리(예: Google Maps, OAuth, gRPC, AppEngine)에 지나치게 의존하고 있다는 느낌이 들었습니다.
- 많은 예제가 Go와 관련된 해결책의 핵심보다는 특정 라이브러리의 세세한 내용에 깊이 파고들었습니다.
- 몇 가지 매우 안전하지 못한 소프트웨어 관행을 권장합니다:
- 어떤 권한을 부여해야 할지 모를 때 기본 비트마스크로
0777을 사용하라고 조언합니다. - 디렉터리 트래버설 공격에 대한 방어가 없어 예제 애플리케이션에서 임의 쓰기 취약점이 발생하고, 이는 원격 코드 실행으로 이어질 수 있습니다.
- 사용자 업로드에 대한 단순한 서비스 거부 공격을 방어하지 못합니다.
- 어떤 권한을 부여해야 할지 모를 때 기본 비트마스크로
- 본문 편집과 코드의 오류 처리가 부실합니다.
- 부주의한 문법 오류와 코드 실수가 상당히 많았습니다.
- 사용자들이 수정 사항을 제출했지만 수년째 반영되지 않고 있습니다.
- 바닐라 JavaScript로도 충분하거나 더 나은 부분에서 jQuery를 써서 예제를 복잡하게 만듭니다.
- bash 스크립트 예제가 다소 조잡하게 느껴졌습니다.
- 책 전반에 걸쳐 코드 품질이 일정하지 않았습니다.
- 어떤 예제는 우아하고 직관적인 반면, 어떤 예제는 초고처럼 느껴집니다.
- 두 개의 독립적인 GitHub 저장소가 있습니다. 하나는 저자가 만든 것이고 다른 하나는 출판사가 만든 것입니다.
- 저자의 저장소가 올바른 것으로 보입니다.
- 예제를 Windows에서 실행하는 방법이 안내되어 있지만, 테스트되지 않은 채 덧붙인 느낌입니다.
- 일부 예제는 서드파티 의존성이 사라져 더 이상 컴파일되지 않습니다.
핵심 정리
Go 언어와 표준 라이브러리 팁
시그널 채널
- 시그널 채널은 Go에서 스레드 안전한 이벤트를 구현하는 관용적인 방법입니다.
- 시그널 채널은 단순히 타입이
struct{}인chan입니다- 시그널 채널은 데이터를 전달하지 않고, 단지 이벤트가 발생했음을 알립니다.
- 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패키지는 테스트가 프로덕션 패키지의 공개 멤버에만 접근하도록 보장합니다.- 이를 통해 테스트가 내부 구현 세부사항이 아니라 외부에 노출되는 동작을 검증하도록 유도합니다.
함수 인자는 파라미터 목록의 맨 뒤에 두기
함수가 함수 파라미터를 받는다면, 그 파라미터를 목록의 맨 끝에 두십시오. 그렇지 않으면 독자가 어떤 인자가 내부 함수에 속하고 어떤 인자가 외부 함수에 속하는지 파악하기 어렵습니다.
잘못된 인자 순서
값의 변경을 주기적으로 폴링해 로컬 복사본을 갱신하는 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)’ 개념을 다루지만, 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 핸들러에서 콘텍스트 사용하기
저는 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"}제가 아직 완전히 이해하지 못한 이유 때문인지, 키는 단순한 문자열이 아니라 문자열을 포함하는 구조체여야 합니다.
업데이트(2023-01-02): 처음에는 contextKey가 왜 단순한 문자열이 아니라 문자열을 담은 구조체인지 혼란스러웠습니다. 책에서 Ryer는 이 결정이 같은 값을 가진 다른 키와의 충돌을 방지한다고 설명하지만, 개발자가 단순히 같은 키를 다른 용도로 재사용하지 않으면 되지 않을까 하는 의문이 들었습니다. Matthew Riley가 이 동작을 명확히 설명해 주었고, 덕분에 로컬 타입이 패키지 간의 충돌을 막아주는 반면 단순 문자열은 그렇지 못하다는 점을 깨닫게 되었습니다.
만약 const contextKeyToken := "token" 같은 콘텍스트 키를 사용하고, 같은 요청을 처리하는 다른 패키지에서도 "token"이라는 키를 사용한다면 서로의 콘텍스트 값을 덮어쓰게 됩니다. 패키지 내부에 커스텀 타입을 정의하면, 타입이 다르기 때문에 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 미들웨어의 하류에 위치하므로 요청 콘텍스트에서 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의 헬퍼 메서드 아이디어는 좋지만, 얻는 이점에 비해 추상화 비용이 너무 크다고 생각합니다. 웹 앱의 인코딩 방식을 얼마나 자주 전면 교체하겠습니까?
게다가 라우트 핸들러가 형식에 대해 몰라야 함에도 구조체에 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) 조각을 반복하게 되지만, 한 줄에 불과하므로 큰 문제는 아닙니다.
클라이언트로부터 내부 구조체 세부사항 숨기기
모든 언어에 공통된 웹 개발 함정 중 하나는 의도치 않은 데이터 노출입니다. 사용자 데이터를 나타내는 내부 구조체가 있다고 가정해 보겠습니다:
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"
}여기까지는 문제없습니다. 그런데 한 달 뒤, 사용자의 이메일 주소나 비밀번호 해시 같은 추가 데이터를 내부에서 주고받기 위해 내부 구조체를 수정하고 싶어집니다:
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 구조체에 필드를 추가할 때 handleUsersGet을 건드리지 않으므로, 애플리케이션의 원시 HTTP 트래픽을 주기적으로 확인하지 않으면 노출 사실을 알아차리기 어렵습니다.
저는 제 앱에서 이런 종류의 실수를 하는 것에 대해 상당히 민감해서, 다른 사람들이 어떻게 대처하는지 항상 궁금합니다.
Ryer의 Public 메서드 패턴
Ryer는 내부 표현과 외부 표현을 모두 갖는 구조체에 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 코드에서는 외부에 노출되는 데이터에 대해 별개의 구조체를 사용하는 것을 선호합니다. 외부 클라이언트에 데이터를 공개해야 할 때는 내부 구조체의 데이터를 외부 구조체로 복사합니다.
보통은 인라인으로 선언하는 익명 구조체를 사용하므로 별도의 네임드 타입조차 필요하지 않습니다:
// 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,
})
}제가 이 방법을 선호하는 이유는 몇 가지 있습니다:
- 의도치 않은 노출에 대한 추가 보호 계층이 생깁니다.
- 누군가 실수로 내부 구조체를 외부 타입에 포함하더라도, 내부 구조체 필드에 JSON 태그가 없으므로 아무것도 출력되지 않습니다.
- 반환하는 데이터가 더 명시적으로 드러납니다.
- 데이터에 대해 더 세밀한 제어가 가능합니다.
Public패턴에서는 해당 타입을 포함하는 모든 엔드포인트가 같은 형식으로 데이터를 반환해야 하는 반면, 위 방법을 사용하면 각 엔드포인트가 어떤 필드를 어떤 형식으로 노출할지 결정할 수 있습니다.
주목할 만한 흥미로운 장들
WebSocket을 이용한 채팅 애플리케이션
- 고루틴과 WebSocket을 활용한 멋진 데모입니다.
사용자 계정 추가
- HTTP 핸들러를 체이닝하는 좋은 예제입니다.
분산 시스템 구축과 유연한 데이터 다루기
- 이 장만으로도 책값을 충분히 한다고 할 수 있습니다.
- 수평적 확장: 노드를 추가해 안정성이나 성능을 높이는 방식으로 시스템을 확장하는 것
- 수직적 확장: 개별 노드의 자원(예: RAM이나 CPU 추가)을 늘려 시스템을 확장하는 것
- 수평적으로 확장 가능한 서비스들을 결합한 멋진 예제입니다.
- 메시지 발행을 위해 NSQ를 사용합니다.
- Twitter 스트리밍 API를 사용해 Twitter에서 실시간 데이터를 읽어옵니다.
- MongoDB를 사용해 데이터를 저장합니다.
- 아주 확장성이 높으면서도 단순한 부품들로 이루어진 시스템을 보는 것은 매우 흥미롭습니다.
- HTTP 연결에서 커스텀 트랜스포트 함수를 사용해 하위 TCP 연결의 저수준 동작을 커스터마이즈합니다.
- 운영체제로부터
SIGINT나SIGTERM시그널을 받았을 때 커스텀 정리를 수행하도록 기본 시그널 핸들러를 재정의하는 좋은 예제입니다.
글을 무작위로 읽기