Go에서 API 서버를 작성하기 시작하면 먼저 router 또는 framework를 선택하고 싶습니다. 하지만 정말 먼저 결정해야 할 것은 거기가 아니다. 먼저 고정하고 싶은 것은 실행 모델, 책임 분할, 에러 경계, 정지 순서.
router는 나중에 바뀐다. 정지 순서가 망가지고 있는 설계는, 나중에 고치는 것이 귀찮다. 여기를 먼저 굳혀 두는 것이 편하다고 생각하고 있다.
이 기사는 Go의 일반론보다는 다음에 자신이 API 서버를 구현할 때 망설이지 않기 위한 기본 방침 메모.
먼저 정책 고정
| 항목 | 정책 |
|-------|
| 최종 아티팩트 | 우선 cobra의 단일 바이너리에 넣기 |
| HTTP 서버 시작 | myapp serve과 같이 serve 부속 명령으로 시작 |
| 센터 단위 | Operation[In, Out]를 중심으로 둔다 |
| transport | HTTP와 CLI는 어댑터로 나뉩니다 |
| framework | chi / gin / Echo는 transport/http 밖으로 새지 않는다 |
| cross-cutting | HTTP middleware와 Operation interceptor는 분리됩니다 |
| error | 내부 오류는 boundary 로 안전하게 translate 한다 |
| shutdown | server.Shutdown을 호출하는 것만으로 끝나지 않고 중지 순서를 deterministic로 설정 |
요컨대, HTTP framework 중심이 아니라 Operation과 lifecycle 중심에서 조립한다.
HTTP 서버도 CLI로 취급
HTTP 서버도 실태는 CLI로 기동한다. 그렇다면 처음부터 cobra에 가서 1 바이너리로 정리하는 것이 알기 쉽다.
추천하는 공개 커맨드 체계는 다음의 형태.
./myapp serve./myapp health./myapp users get 123./myapp users create --name aliceserver run 와 같은 이단 자세는 중복이라고 생각하고 있다. serve 로 의미가 완결되어 있고 타입량도 줄어든다.
단일 바이너리를 먼저 추진하는 이유는 간단.
- 배포물이 1개로 끝난다
- 설정 로드, logger, DB 초기화, error handling 을 1 계통으로 할 수 있다
- HTTP 서버와 CLI에서 bootstrap을 공유하기 쉽습니다.
- 나중에 2 바이너리화하고 싶어도
cmd/를 얇게 늘리면 끝난다
반대로, 처음부터 분리 바이너리를 선택하는 것은, 서버용 컨테이너를 극단적으로 작게 하고 싶은, server 와 cli 로 의존이나 권한이 상당히 다른, 릴리스 빈도를 나누고 싶다, 라고 하는 이유가 보이고 나서 좋다.
구현의 중심은 Operation에 놓는다.
HTTP handler를 중심으로 설계하면, router나 framework의 사정이 바로 안쪽까지 들어간다. 그러면 CLI를 만들고 싶을 때 동일한 처리를 다시 작성하기 쉽습니다.
중심은 Operation로 하는 것이 좋다.
type Operation[In any, Out any] interface { Execute(ctx context.Context, in In) (Out, error)}users get이든 users create이든 HTTP와 CLI는 동일한 Operation을 호출합니다. HTTP 는 request/response 를 변환하는 adapter, CLI 는 flag/stdin/stdout 를 변환하는 adapter 라고 나눈다.
이 형태로 두면 transport를 늘려도 애플리케이션의 중심은 변하지 않는다.
framework는 transport/http에 갇힐 수 있습니다.
chi, gin, Echo 중 어느 것을 사용하는지는 transport 이야기입니다. 도메인이나 Operation의 이야기가 아니다.
router 의 선정으로 하고 싶은 것은, API 서버의 책무를 결정하는 것이 아니라, HTTP 에 어떻게 태울 수 있을지를 결정하는 것뿐입니다. 그러므로 framework 차이는 internal/transport/http 부하에 갇힌다.
이미지는 이런 형태.
internal/ bootstrap/ operation/ interceptor/ transport/ http/ cli/ domain/ infra/ apperr/operation에서 chi 또는 gin.Context가 보이면 아마 새는 것입니다. 여기가 새기 시작하면 framework를 바꾸는 이야기가 아니라 앱 전체를 다시 쓰는 이야기가 된다.
HTTP middleware와 Operation interceptor를 혼합하지 마십시오.
여기도 나누어 생각하는 것이 깔끔하다.
| 층 | 두는 것 | |-------| | HTTP middleware | request id, access log, recover, authn, CORS 등 HTTP 관련 | | Operation interceptor | validation, authz, timeout, metrics, 유스 케이스 단위 logging |
HTTP middleware는 transport의 편리함. Operation interceptor 는 어플리케이션의 편리. 같은 logging 하지만 두는 장소의 의미가 다르다.
이 두 가지를 함께 사용하면 HTTP에 닫힌 디자인이되기 쉽습니다. CLI를 더한 순간에 validation과 authz를 다른 구현으로 재작성하게 된다.
error는 boundary로 안전하게 변환합니다.
내부 에러를 그대로 HTTP response 나 CLI 의 표준 출력에 출력하지 않는다. 여기는 처음부터 룰화해 두는 것이 편하다.
앱 내에서는 최소한 다음 4가지 오류를 사용합니다.
| 항목 | 역할 |
|-------|
| Code | bad_request 및 not_found 등의 분류 |
| Public | 클라이언트에게 반환할 수 있는 문장 |
| Internal | 내부에 대한 자세한 오류 |
| Meta | 로그 및 보조 정보 |
HTTP에서는 status code 와 JSON 에 translate 한다. CLI에서는 stderr과 exit code로 translate한다. 중요한 것은, translate 는 boundary 로 하는 것. Operation 측에서 HTTP status 를 생각하기 시작하지 말 것.
graceful shutdown 은 정지 순서까지 포함해 설계한다
여기는 Go 의 API 서버 구현에서 꽤 중요한 포인트. srv.Shutdown(ctx)를 부르는 것만으로 “graceful shutdown 대응 완료”라고 취급하지 않는다.
net/http 공식 문서에서 Shutdown은 청취자를 닫고 새 접수를 중지하고 idle connection을 닫고 active connection이 idle로 돌아갈 때까지 기다립니다. 반면에 ListenAndServe은 Shutdown 또는 Close을 호출 한 후 ErrServerClosed을 반환합니다. 즉, 그 반환만 보고 main 을 끝내면(자), 정지 도중에 프로세스가 끝날 수 있다.
또한 Shutdown는 hijacked connection을 기다리지 않습니다. 필요한 경우 RegisterOnShutdown을 사용하고 handler 쪽으로 shutdown을 전파하려면 BaseContext을 사용하십시오. os/signal 의 NotifyContext 로 signal 을 context 화해 두면 종료 처리의 기점도 정렬하기 쉽다.
Kubernetes 전제라면 정지 순서는 더 중요하다. Pod lifecycle에서는 Pod에 graceful termination 기간이 있고 container lifecycle hooks에서는 PreStop 실행중에도 그 카운트다운이 진행된다. 그러므로 멈추는 방법을 잡으면 drain 하기 전에 시간이 끊어진다.
자신의 기본 방침은 이 순서.
| 순서 | 할 일 |
|-------|
| 1 | signal.NotifyContext에서 종료 신호 수신 |
| 2 | readiness를 떨어뜨려 새로운 유입을 멈춘다 |
| 3 | queue consumer / cron / background worker 입구 닫기 |
4 | 조금 drain 하고 로드 밸런서 반영을 기다린다 |
| 5 | handler 에 context cancel 전파 |
| 6 | server.Shutdown(timeoutCtx) 전화 |
| 7 | WaitGroup / errgroup 에서 in-flight job 완료 대기하기 |
| 8 | DB / cache / broker producer를 마지막으로 닫습니다 |
| 9 | timeout 초과시에만 server.Close()을 최종 수단으로 설정 |
이 이야기의 핵심은 Go 고유보다는 정지 시퀀스를 deterministic 로 설계하라는 점이라고 생각한다.
health는 두 가지 유형이 있습니다.
여기도 혼동하기 쉽다.
| 이름 | 의미 |
|-------|
| myapp health | 로컬에서 종속성을 직접 진단하는 CLI |
| GET /api/health | 시작 중 서버의 HTTP 상태 끝점 |
같은 health에서도 역할이 다르다. CLI 측은 진단, HTTP 측은 readiness / liveness 에 가깝다. 특히 shutdown 중에는 HTTP 측의 health 가 503 을 돌려줄 수 있는 설계가 안전.
최소 디렉토리 정책
최초의 골격은 이 정도로 충분.
cmd/ myapp/internal/ apperr/ bootstrap/ domain/ infra/ interceptor/ operation/ health/ users/ transport/ cli/ http/ version/cmd/myapp은 얇게 유지한다. 초기화는 bootstrap, 유스 케이스는 operation, HTTP와 CLI는 transport에 가깝다. 나중에 2 바이너리화하고 싶어져도, 이 절단 방법이라면 cmd/myapp-server 를 더하는 것만으로 끝나기 쉽다.
고정하고 싶은 문장
Go 의 API 서버 구현으로 먼저 결정해야 할 것은 framework 는 아니고, 실행 모델, 책무 분할, 에러 경계, 정지 순서.
이 한 문장으로 대체로 충분하다. chi 를 사용할 것인가 Echo 를 사용할 것인가는, 그 후에 결정하면 된다.
요약
자신의 기본 방침은 이렇게. 우선 cobra 의 단일 바이너리로 정리해 serve 로 HTTP 서버를 기동한다. 중심은 Operation에 위치하고 HTTP와 CLI는 adapter로 분리됩니다. framework는 transport/http에 갇혀 있습니다. error 는 boundary 로 안전하게 변환한다. shutdown은 server.Shutdown로 끝나지 않고 readiness, drain, worker 정지, resource cleanup까지 순서를 설계한다.
이 형태로 하면 나중에 framework를 바꾸어도 CLI를 더해도 worker를 늘려도 설계의 축이 흔들리기 어렵다.
hsb.horse