본문 바로가기
golang

실무에 바로 쓰는 Go 언어 핸즈온 가이드

by marble25 2023. 7. 12.

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.ClientCheckRedirect에 넣어주면 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 에러 응답을 받는다.
      func handleUserAPI(w http.ResponseWriter, r *http.Request) {
            if r.Context().Err() != nil {
                    return
            }
            ...
      }
      요청의 context에서 에러가 발생했다면 연결이 끊어졌으므로 핸들러 함수 역시 그대로 반환해야 한다.
    • 하지만, 서버사이드에서는 요청이 그대로 수행되게 된다. 타임아웃 핸들러가 동작한 후 핸들러 함수의 동작을 중단하려면 요청 처리를 중단하는 작업이 필요하다.
    • 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