
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 패키지가 아주 유용합니다.
지정한 고루틴들을 그룹으로 묶어서 에러 처리를 한 번에 해결해줍니다.
사용법은 다음과 같습니다.
errgroup.Group타입 변수를 준비합니다 (zero value 사용 가능).errgroup.Group.Go()함수로error를 반환하는 함수를 고루틴으로 실행합니다.errgroup.Group.Wait()함수로errgroup.Group.Go()에서 실행된 모든 고루틴이 종료될 때까지 기다리고, 가장 먼저 반환된 non-nilerror를 반환합니다.
가장 먼저 반환된 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 정말 편리합니다!
'Go' 카테고리의 다른 글
| Go 이미지 생성: 완벽한 테스트 전략으로 버그 없는 코드 작성하기 (0) | 2024.09.20 |
|---|---|
| Go로 작성된 로컬 파일 처리 CLI 도구 테스트 방법 3가지 (0) | 2024.09.20 |
| Go 언어에서 nil 포인터로 인한 오류를 미리 잡는 방법: 정적 분석 도구 활용법 (0) | 2024.09.20 |
| Go 언어에서 Templ 또는 일반 템플릿: 무엇을 선택해야 할까요? (0) | 2024.09.19 |
| Go 언어에서 구조체 필드를 반드시 지정하여 초기화하는 방법 알아볼까요? (0) | 2024.09.19 |