Go 언어로 HTTP 서버 기본 구조 깔끔하게 잡기: `errgroup` 활용!

Go 언어로 HTTP 서버 기본 구조 깔끔하게 잡기: errgroup 활용!

안녕하세요! 요즘 제가 자주 작성하는 Go 언어 HTTP 서버 기본 코드를 소개해드리려고 합니다.

 

코드를 보는 게 이해하기 더 빠를 테니 바로 전체 코드를 보여드릴게요!

목표

  • localhost:8888에서 HTTP 서버 실행
  • SIGINT 신호를 받으면 HTTP 서버 Graceful Shutdown
  • 여러 곳에서 ctx.Done() 처리
  • 위 내용들을 혼동 없이 깔끔하게 작성
package main

import (
        "context"
        "fmt"
        "net/http"
        "os"
        "os/signal"
        "syscall"
        "x/sync/errgroup"
)

func main() {
        os.Exit(run(context.Background()))
}

func run(ctx context.Context) int {
        var eg *errgroup.Group
        eg, ctx = errgroup.WithContext(ctx)

        eg.Go(func() error {
                return runServer(ctx)
        })
        eg.Go(func() error {
                return signalHandler(ctx)
        })
        eg.Go(func() error {
                <-ctx.Done()
                return ctx.Err()
        })

        if err := eg.Wait(); err != nil {
                fmt.Println(err)
                return 1
        }
        return 0
}

func signalHandler(ctx context.Context) error {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT)

        select {
        case <-ctx.Done():
                signal.Reset()
                return nil
        case sig := <-sigCh:
                return fmt.Errorf("signal received: %v", sig.String())
        }
}

func runServer(ctx context.Context) error {
        s := &http.Server{
                Addr:    ":8888",
                Handler: nil,
        }

        errCh := make(chan error)
        go func() {
                defer close(errCh)
                if err := s.ListenAndServe(); err != http.ErrServerClosed {
                        errCh <- err
                }
        }()

        select {
        case <-ctx.Done():
                return s.Shutdown(context.Background())
        case err := <-errCh:
                return err
        }
}

해설

x/sync/errgroup

단순히 HTTP 서버 하나만 띄우는 거라면 필요 없지만, 이번처럼 HTTP 서버도 띄우고, 시그널 핸들링도 하고, 다른 서버도 띄우고 싶고, 에러 처리까지 깔끔하게 하고 싶을 때 x/sync/errgroup 패키지가 아주 유용합니다.

 

지정한 고루틴들을 그룹으로 묶어서 에러 처리를 한 번에 해결해줍니다.

 

사용법은 다음과 같습니다.

  1. errgroup.Group 타입 변수를 준비합니다 (zero value 사용 가능).
  2. errgroup.Group.Go() 함수로 error를 반환하는 함수를 고루틴으로 실행합니다.
  3. errgroup.Group.Wait() 함수로 errgroup.Group.Go()에서 실행된 모든 고루틴이 종료될 때까지 기다리고, 가장 먼저 반환된 non-nil error를 반환합니다.

가장 먼저 반환된 non-nil error를 강조했는데요, 모든 고루틴의 error를 잡아내고 싶다면 x/sync/errgroup은 사용할 수 없다는 점에 주의해야 합니다!


// 이럴 땐 sync.WaitGroup 등을 사용해서 직접 처리해야 합니다.

 

errgroup.WithContext()로 초기화하면 가장 먼저 non-nil error가 반환되는 순간 context가 취소됩니다.


이 코드에서는 run() 함수가 이 부분을 처리합니다.

 

runServer()error를 반환하거나 signalHandler()error를 반환하면 ctx 취소 처리를 통해 다른 함수도 종료 처리를 할 수 있도록 구성했습니다.

func run(ctx context.Context) int {
        var eg *errgroup.Group
        eg, ctx = errgroup.WithContext(ctx)

        eg.Go(func() error {
                return runServer(ctx)
        })
        eg.Go(func() error {
                return signalHandler(ctx)
        })
        eg.Go(func() error {
                <-ctx.Done()
                return ctx.Err()
        })

        if err := eg.Wait(); err != nil {
                fmt.Println(err)
                return 1
        }
        return 0
}

runServer() / signalHandler()

다음은 errgroup.Group.Go()에서 호출되는 함수입니다.


단순히 http.ListenAndServe()를 실행해도 되지만, ctx 취소 대기 처리를 함께 하기 위해

http.ListenAndServe()error를 채널을 통해 주고받습니다.

func runServer(ctx context.Context) error {
        // 서버 초기화
        s := &http.Server{Addr: ":8888", Handler: nil}
        // error 전달용 chan
        errCh := make(chan error)
        go func() {
                defer close(errCh)
                if err := s.ListenAndServe(); err != http.ErrServerClosed {
                        // error를 chan을 통해 전달
                        errCh <- err
                }
        }()
        select {
        case <-ctx.Done(): // 상위에서 ctx가 취소되면 graceful shutdown
                return s.Shutdown(context.Background())
        case err := <-errCh: // `s.ListenAndServe()`가 error를 반환하면 그대로 error를 반환
                return err
        }
}

 

시그널 대기 처리도 마찬가지입니다. 시그널을 받는 채널과 ctx.Done()select 문으로 기다리도록 작성합니다.

 

func signalHandler(ctx context.Context) error {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT)

        select {
        case <-ctx.Done():
                signal.Reset()
                return nil
        case sig := <-sigCh:
                return fmt.Errorf("signal received: %v", sig.String())
        }
}

직접 해보니

시그널 핸들링도 x/sync/errgroup에 넣어서 제어 흐름을 단순하게 만들 수 있었습니다.

 

제가 실제로 적용했던 환경에서는 HTTP 서버와 gRPC 서버가 하나의 코드에서 동작하고 있었는데, 추가로 다른 포트에 gRPC 서버를 띄워야 하는 경우가 생겼습니다.

 

이때도 eg.Go() 부분과 runServer() 부분만 추가하면 됐고, gRPC 서버 자체의 후처리도 runServer()에서 처리할 수 있어서 쉽게 추가할 수 있었습니다.

 

x/sync/errgroup 정말 편리합니다!