1. 커맨드 라인 애플리케이션 작성
- 함수에서 read, write를 할 때 인자로 reader와 writer를 받아와서 하는 게 좋다. 직접 Stdin, Stdout 변수를 참조하면 유닛 테스트 작성이 매우 어렵다.
- 모든 성공적이지 않은 실행은 종료시
os.Exit()
를 이용해서 0이 아닌 종료 코드를 반환해야 한다. - 사용자 정의 에러를 잘 활용하자.
var errPosArgsSpecified = errors.New("Positional arguments specified") ... c, err := parseArgs(os.Stderr, os.Args[1:]) if err != nil { if errors.Is(err, errPosArgSpecified) { fmt.Fprintln(os.Stdout, err) } os.Exit(1) }
2. 고급 커맨드 라인 애플리케이션 작성
- 서브커맨드는 개별의 기능에 대해 별도의 커맨드와 옵션, 인수를 가지도록 논리적으로 분리한다.
- 커맨드 라인 애플리케이션 개발에서 메인 패키지는 최소화하고 실질 기능은 별도 패키지로 분리하거나 서브커맨드로 분리하는 것이 확장성에 있어서 좋다.
- 애플리케이션 내에서 사용자의 동작에 타임아웃을 강제할 수 있어야 한다.10초의 제한이 있는 context를 만들고,
defer
를 이용, 종료되기 전에는 context를 해제해 주어야 한다.고루틴에서getName()
함수를 실행해서 함수가 반환되면 에러를 채널에 쓴다. select
구문을 통해 context가done
이거나 error가 채널에 쓰여지면return
한다.func getNameWithContext(ctx context.Context) (string, error) { var err error name := "Default Name" c := make(chan error, 1) go func() { name, err = getName(os.Stdin, os.Stdout) c <- err }() select { case <-ctx.Done(): return name, ctx.Err() case err := <-c: return name, err } }
func main() { ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second) defer cancel() name, err := getNameWithContext(ctx) if err != nil && errors.Is(err, context.DeadlineExceeded) { fmt.Println("context exceeded") } }
WithTimeout
은 현재 시간 기준으로 상대적인 어느 시점에 만료되는 컨텍스트를,WithDeadline
은 절대 시간 기준으로 만료되는 컨텍스트를 설정할 수 있다.- 사용자 시그널 역시 채널을 이용해서 받을 수 있다.
os.Signal
을 받는 채널을 만들어서signal.Notify
를 통해 받아올 시그널을 등록한다. - 고루틴에서
cancelFunc
를 실행한다. func setupSignalHandler(w io.Writer, cancelFunc context.CancelFunc) { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) go func() { s := <- c fmt.Fprintf(w, "Got signal: %v\n", s) cancelFunc() }() }
3. HTTP 클라이언트 작성
- fetch, post 등의 action에서 response 객체의 body를 defer를 활용해서 항상 닫아줘야 한다.
- 언마샬링(역직렬화): 데이터 바이트를 애플리케이션이 이해할 수 있는 데이터 구조로 변환하는 작업. byte 데이터를 데이터 구조체로 변환.
- 마샬링(직렬화): 데이터 구조체를 네트워크 전송, 또는 파일 저장을 위해 데이터 바이트로 변환하는 작업.
- HTTP multipart/form-data를 이용하면 text value와 binary 데이터를 포함한 body를 전송할 수 있다.여기서 --로 시작하는 91f~~는 무작위로 생성되는 바운더리 문자열로 각 부분을 구분해준다.
--91f~~ Content-Disposition: form-data; name="name" mypackage --91f~~ Content-Disposition: form-data; name="filedata"; filename="mypackage0.1.tar.gz" Content-Type: application/octet-stream <binary data> --91f~~
4. 고급 HTTP 클라이언트
- http 패키지의 DefaultClient는 Timeout 필드의 값을 지정하지 않으면 최대 시간이 강제되지 않아 클라이언트는 서버 응답까지, 혹은 클라이언트와 서버의 연결이 종료될 때까지 기다린다.
- 서버에서 HTTP 리다이렉트를 응답하는 경우 기본 HTTP 클라이언트는 자동으로 최대 10회까지 리다이렉트를 따라가고, 이후에는 종료한다.다음과 같은 함수를
http.Client
의CheckRedirect
에 넣어주면 custom redirect 정책을 따라가게 된다.nil
이 아닌error
을 반환할 때까지 리다이렉트를 따라간다. func redirectPolicyFunc(req *http.Request, via []*http.Request) error { if len(via) >= 1 { return errors.New("stopped after 1 redirect") } return nil }
- 서버 애플리케이션에서 미들웨어는 클라이언트로부터 받은 요청을 서버가 처리하기 위해 실행되는 코드다. 클라이언트 애플리케이션에서는 서버 애플리케이션으로 HTTP 요청을 보내기 위해 실행되는 코드다.
- http.Client 구조체에 Transport 필드는 다음과 같이 정의된다.로깅을 예시로 들어보자.로그를 찍은 후 요청이 전송된다. 유사하게 레이턴시 메트릭, 200 이외의 오류 등을 로그로 출력할 수 있다.
type LoggingClient struct { log *log.logger } func (c LoggingClient) RoundTrip(r *http.Request) (*http.Response, error) { c.log.Printf("Sending %s request to %s over %s\n", r.Method, r.URL, r.Proto) resp, err := http.DefaultTransport.RoundTrip(r) c.log.Printf("Got reponse over %s\n", resp.Proto) return resp, err }
type Client struct { Transport RoundTripper } type RoundTripper interface { RoundTrip(*Request) (*Response, error) }
- RoundTripper는 어느 시점이든 하나 이상의 인스턴스가 실행되고 있다는 가정으로 구현되어야 한다. 병렬 처리에 안전해야 한다.
- RoundTripper는 요청이나 응답 자체를 변경해서는 안된다.
- RoundTripper를 이용해 header를 추가해서 보내줄 수 있다. 직접 원본 요청을 건들지 않고,
Clone()
으로 원본 요청을 복제한 후 복제된 요청에 헤더를 추가하자. - 요청을 하나 맺을 때마다 TCP 연결이 하나 생성되는데, TCP 연결을 맺는 비용은 비싸다. 서비스 지향적인 아키텍처 같은 경우 여러 번의 요청을 보낼 때마다 TCP 연결을 맺으면 매우 비용이 비싸다. 이 경우 존재하는 TCP 연결을 재사용할 수 있도록 커넥션 풀을 관리할 수 있다.
- DNS가 동적으로 변경될 수 있기 때문에 너무 길게 연결을 유지하면 문제가 생길 수 있다.
5. HTTP 서버 작성
- DefaultServerMux는 함수의 기본 핸들러인데, 글로벌 객체이기 때문에 서드 파티 패키지에서 접근할 수도 있고, 병렬성에 문제가 생길 수 있고, 예기치 않은 동작이 발생할 수 있다. 따라서 새 ServeMux를 생성하자.
mux := http.NewServeMux()
- 요청을 받은후 핸들러 함수가 해당 요청을 처리할 수 있다면, 핸들러 함수는 별도의 고루틴에서 실행된다. 따라서 서버가 병렬적으로 여러 요청을 처리할 수 있음을 보장할 수 있다.
- HTTP 기본 인증을 사용하는 경우에는 요청 URL이
http://user:pass@example.com/api/?name=jane&age=25#frag
형태가 된다. - 핸들러 함수가 처리하는 모든 요청에는 컨텍스트가 존재한다. 이 컨텍스트의 생명주기는 요청의 생명주기와 같다. 이 컨텍스트에 값을 부착해서 요청 생명주기 동안 존재하는 데이터를 저장하는데 유용하게 사용할 수 있다. (ex:
RequestID
)- key, value 형태로 사용할 수 있지만, 키는 string과 같은 기본 데이터 타입이면 안된다. 그래서 custom struct type으로 많이 활용한다.
type requestContextKey struct{} type requestContextValue struct { requestID string } func addRequestID(r *http.Request, requestID string) *http.Request { c := requestContextValue{ requestID: requestID, } newCtx := context.WithValue(r.Context(), requestContextKey{}, c) return r.WithContext(newCtx) }
- JSON 데이터가 스트림으로 들어오면 일반적인 방식으로 디코딩할 수 없다.
type logLine struct { UserIP string `json:"user_ip"` Event string `json:"event"` } func decodeHandler(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) for { var l logLine err := dec.Decode(&l) ... } }
Decode()
함수는 올바른 JSON 데이터 객체를 찾을 때까지r.Body
에서 데이터를 읽은 후 l 객체로 언마샬링한다. 이때, JSON 데이터로 식별될 수 없는 문자를 발견하거나 특정 객체로 변환에 실패한 경우 에러를 반환한다. {"user_ip": "172.121.19.21", "event": "click_on_add_cart"}{"user_ip": "172.121.19.21", "event": "click_on_checkout"}
io.Pipeline
을 이용하면 long-running job의 경우 producer와 customer로 관심사를 분리할 수 있다.
6. 고급 HTTP 서버 애플리케이션
- 로깅을 위해 초기화된 로거 객체, 연결 맺고 있는 데이터베이스 연결 객체 등을 사용하려면 서버가 시작될 때 한 번 초기화되고 HTTP 핸들러 함수 사이에 전역적으로 공유되어 사용되어야 한다.위와 같이 전역적으로 필요한 config들을 handler에 넘겨주어 handler에서 사용할 수 있게 된다.
type appConfig struct { logger *log.logger } type app struct { config appConfig handler func( w http.ResponseWriter, r *http.Request, config appConfig, ) } func (a app) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.handler(w, r, a.config) }
- 서버 사이드 미들웨어는 요청을 처리할 때 사용되는 일반적인 동작을 자동으로 수행할 수 있도록 해준다. 예를 들어, 모든 요청을 로그로 남기거나, 요청 식별자를 부착하거나, 요청에 인증과 관련된 크리덴셜 정보가 포함되었는지 확인한다.
func loggingMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { startTime := time.Now() h.ServeHttp(w, r) log.Printf("path=%s method=%s duration=%s", r.URL.Path, r.Method, time.Now().Sub(startTime).Seconds(), ) }) }
- 서버 애플리케이션의 공통 기능들을 미들웨어로 구현하면 로깅이나 에러 핸들링, 인증 등과 같은 공통 기능들을 서버의 비즈니스 로직으로부터 분리(관심사 분리)할 수 있다.
- 미들웨어 체이닝을 활용하면 하나 이상의 미들웨어를 처리할 수 있게 된다. 가장 내부에 위치한 미들웨어는 요청을 처리할 때 가장 먼저 처리되게 된다.
7. 실서비스 가능한 HTTP 서버
- 특정 기능에 대해 시간이 오래 걸린다면, 요청을 처리하는 핸들러에 대해 타임아웃을 강제할 수 있다.
- 예상외의 처리가 오래 걸리는 요청이 발생하더라도 서버의 자원은 요청에 묶이지 않을 수 있다.
- 클라이언트도 요청이 정상적으로 완료되지 않는다는 응답을 더 빨리 받아 재시도해볼 수 있다.위와 같이 timeout을 건다면 client는 약 14초 후에 503 Service Unavailable 에러 응답을 받는다.
요청의 context에서 에러가 발생했다면 연결이 끊어졌으므로 핸들러 함수 역시 그대로 반환해야 한다.func handleUserAPI(w http.ResponseWriter, r *http.Request) { if r.Context().Err() != nil { return } ... }
- 하지만, 서버사이드에서는 요청이 그대로 수행되게 된다. 타임아웃 핸들러가 동작한 후 핸들러 함수의 동작을 중단하려면 요청 처리를 중단하는 작업이 필요하다.
userHandler := http.HandlerFunc(handleUserAPI) hTimeout = http.TimeoutHandler(userHandler, 14 * time.Second, "ran out") mux := http.NewServeMux() mux.Handle("/api/users/", hTimeout)
- 요청이 길어졌을 때 서버가 아닌, 클라이언트에서도 요청을 중단할 수 있다. 이런 경우 채널과 고루틴을 이용해서 처리할 수 있다.
- 핸들러별로 timeout을 설정하는 것과 별개로, 서버 자체에도 read/write 타임아웃을 설정할 수 있다.
s := http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, }
- 스트리밍 요청을 하는 경우라면 ReadTimeout과 WriteTimeout을 설정하면 스트리밍 요청을 읽을 수 없다. 헤더를 읽는 데에만 timeout을 거는 ReadHeaderTimeout을 고려해보자.
- http 서버가 종료될 때
http.Server
에 정의된Shutdown()
을 사용하면- 새로운 요청을 받지 않고
- 현재 남은 유휴 연결을 종료하고
- 현재 요청 핸들러가 반환되어 완료될 때까지 대기한다.
- TLS를 사용하면 클라이언트와 서버 간 통신을 평문의 HTTP로 통신하지 않고, HTTPS에서 통신할 수 있도록 해준다.
- 사설 인증서는 동일 조직 내와 같이, 특정 범위 내에서 통신하기 위해 사용된다.
- 범위 외에서 사설 인증서를 사용한다면 인증서를 신뢰할 수 없다는 오류가 발생하면서 정상적인 접근이 불가능하다.
- 인증 기관(CA)는 사설에서도, 밖에서도 사용 가능하다.
8. gRPC를 사용한 RPC 애플리케이션 개발
- 원격 프로시저 콜: 함수가 애플리케이션 바이너리에 포함되지 않고 네트워크를 넘어 어느 다른 서비스에 정의되어 있는 것을 의미한다. RPC 프레임워크는 두 가지 중요한 고민을 하게 만든다.
- 함수 요청이 네트워크 상의 요청으로 어떻게 변환될지
- 요청이 어떻게 전송될지
- JSON을 데이터 교환 언어로 사용하는 데는 제약이 존재한다. 직렬화와 역직렬화에 비용이 많이 필요하다는 것과 데이터 타입이 원격 시스템에서 네이티브하게 동작하리라는 보장을 못 한다는 것이다.
- 메세지는 클라이언트와 서버 사이에 전송되는 것으로, 메세지 내의 필드는 필드 타입과 필드 이름, 번호(태그) 3가지를 지정해야 한다.
- 필드 번호는 상위/하위 호환성을 위해 신중하게 부여해야 한다.
message User { string id = 1; string first_name = 2; string last_name = 3; int32 age = 4; }
- 기본적으로 프로토콜 버퍼 메세지의 필드는 선택적이다.
- bufconn 패키지를 이용하면 테스트 중에 실제로 네트워크를 사용하여 서버와 클라이언트를 구성할 필요 없이 인메모리 채널로 서버와 클라이언트가 동작하기 위한 로직을 테스트할 수 있다.
- 어느 애플리케이션에서 gRPC 애플리케이션과 통신을 해야 하는 경우 바이트 데이터를 프로토콜 버퍼로, 프로토콜 버퍼를 바이트 데이터로 변환해야 한다.
- 상위 호환성/하위 호환성을 위해서
- 절대로 필드의 번호를 변경해서는 안된다.
- 절대로 필드의 이름을 변경해서는 안된다. 변경하고 싶다면 새 필드를 추가 후 모든 클라이언트와 서버가 해당 필드를 사용하게 된 이후에 기존 필드를 제거해야 한다.
- 서비스 이름 변경을 지양하자. 호환성이 필연적으로 깨지게 된다.
- 함수의 이름을 변경할 수 없다. 새 함수 추가 후 기존 함수를 제거하자.
repeated
로 정의된 경우 0개 이상의 인스턴스를 포함하게 된다. (golang에서는 slice로 처리)messagge RepoGetReply { repeated Repository repo = 1; }
9. 고급 gRPC 애플리케이션
- 커뮤니케이션 패턴에는
- 서버 사이드 스트리밍: 서버에서 하나 이상의 응답 메세지를 보낼 수 있다.
- 클라이언트 사이드 스트리밍: 클라이언트에서 하나 이상의 요청 메세지를 보낼 수 있다.
- 양방향 스트리밍
- 이 존재한다.
- 스트리밍을 하는 쪽에 stream 키워드를 붙이면 된다.
service Repo { rpc GetRepos (RepoGetRequest) returns (stream RepoGetReply) {} }
- 여러 객체의 배열을 보내는 것보다 객체를 스트리밍으로 보내는 것이 더 효율적일 가능성이 높다.
- 양방향 스트리밍의 경우 읽는 순서나 쓰는 순서가 강제되지 않기 때문에 순서가 보장되지 않는다.
- 바이너리 데이터를 보내는 경우를 위해 bytes 필드가 존재한다. 이 경우 문맥 정보가 필요하다면 스트림의 첫 번째 메세지에만 문맥 정보(ex: creator_id, name)를 보내고, 이후 메세지에 data 필드만 포함하는 것을 권장한다. 이를 위해 oneof라는 키워드가 존재한다.
message RepoCreateRequest { oneof body { RepoContext context = 1; bytes data = 2; } }
- gRPC에서 미들웨어를 구현하려면 인터셉터라는 컴포넌트를 작성한다. 인터셉터를 이용해서 필드 외에도 context에 metadata를 실어 보낼 수 있다. (ex: request-id)
10. 실서비스 가능한 gRPC 애플리케이션
- 서버가 시작할 때 연결 수립에 수 초가 필요하고, 과부화된 서버는 새로운 요청을 받기 힘들다. 두 상황에 대해 서버가 현재 건강한 상태인지 판별할 수 있는 RPC 메서드를 추가하면 좋다.
- gRPC의 헬스 체크 프로토콜은 Health라는 명세를 정의한다.
- Check: 단발성
- Watch: Recv를 호출시 현재 헬스 상태를 응답으로 받고, 서비스의 헬스 상태에 변화가 생기면 응답을 받는다.
- 연결이란 클라이언트와 서버 사이에 생성된 채널로, 다섯 가지의 상태 중 하나를 가진다.
- CONNECTING
- READY
- TRANSIENT_FAILURE
- IDLE
- SHUTDOWN
- 연결은 CONNECTING 상태에서
- 호스트네임 해석
- TCP 구성
- TLS 핸드셰이크
- 를 진행한다.
- RPC의 경우 클라이언트와 서버 프로세스 사이에 단 하나의 연결을 갖는다. 따라서 RPC 메서드 호출 시에 첫 번째 호출 이외의 호출은 연결 객체를 생성하고 연결 수립의 비용이 발생하지 않는다. 이에 따라 로드 밸런싱에 문제가 있을 수 있는데, 오픈소스 리버스 프록시 서버들은 이를 해결한다.
A. 애플리케이션을 관측 가능하게 만들기
- 로깅 시스템에서 텍스트를 사용하여 로그를 찾아보기는 힘들다. 각 로그라인이 특정한 자료구조를 가지도록 해야 한다. space로 키-밸류 값들을 구분하거나, JSON 인코딩하는 것이 바람직하다.
- 메트릭은 애플리케이션 관점에서 분리되어 있어야 한다. 가령 모니터링 시스템을 변경하더라도 애플리케이션 코드는 변하지 않아야 한다.
- 트레이스는 시스템 내의 트랜잭션을 추적하기 위한 데이터이다. 트레이스 데이터는 동일한 트랜잭션 식별자를 가져서 전체 소요시간 및 개별 작업을 확인할 수 있어야 한다.
B. 애플리케이션 배포하기
- 민감하지 않은 정보는 환경설정 파일을 이용하자. 이를 위해 환경설정 파일을 읽는 코드를 구현해야 한다. 비밀번호 같이 민감 정보는 환경 변수를 사용하자.
- Go 빌드 툴은 GOOS와 GOARCH의 환경변수를 읽어서 빌드할 타겟 운영체제 시스템과 아키텍처를 인식한다.
- 애플리케이션이 준비가 되었는지 알기 위해 로드 밸런서에서 일반적으로 헬스 체크를 이용한다.
- 단순한 헬스체크는 애플리케이션이 응답을 할 준비가 되었는지를 확인한다.
- 깊은 헬스체크는 애플리케이션이 다른 서비스나 데이터베이스 등 애플리케이션 의존성에도 문제가 없는지 확인한다. 많은 정보를 알 수 있지만 매우 복잡하기 때문에 대개 애플리케이션이 시작할 때 한번만 해줘도 괜찮다.
- 타임아웃 설정값은 배포시에 세심하게 주의를 기울일 필요가 있다.
- 로드밸런서 앞단과 클라이언트 사이에 TLS를 맺어야 하고, 뒷단의 애플리케이션과는 TLS가 필수는 아니다. 다만 모든 네트워크상에서 발생하는 통신에는 보안 통신이 적용되어야 한다.
'golang' 카테고리의 다른 글
goroutine 파헤치기 (0) | 2023.09.23 |
---|---|
Formatting in golang (0) | 2023.09.03 |
Internal package in golang (0) | 2023.09.03 |
Go 언어를 활용한 분산 서비스 개발 (0) | 2023.07.23 |
[후기] entgo 두달 사용 후기 (0) | 2022.07.23 |