- 오래 유지될 Go HTTP 서비스는 명시적 의존성 전달, 한곳에 모은 라우트, 테스트 가능한
run함수로 구성할 때 유지보수와 검증이 쉬워짐 - 핸들러는 서버 구조체 메서드보다 필요한 값을 클로저로 받는
http.Handler반환 함수로 만들고, 공통 미들웨어는 서버 생성과 라우트 등록 단계에서 조합함 func main()을 얇게 유지하고run()에context.Context, 인자, 환경 접근, 표준 입출력을 주입하면 종료 처리와 테스트 제어가 단순해짐- 요청/응답 인코딩, 검증, 미들웨어 어댑터,
sync.Once지연 초기화는 반복 코드를 줄이면서도 Go 표준net/http흐름을 유지함 - 테스트는 개별 핸들러보다 실제 API 호출에 가까운 엔드투엔드 방식을 선호하며, 각 테스트가 자체 서버를 띄우고
/healthz나/readyz로 준비 상태를 확인함
서버 생성과 서비스 진입점
NewServer생성자는 서비스의 핵심http.Handler를 만드는 함수로 둠- 보통 서비스마다 하나를 두고, 내부 라우트가 요청을 각 핸들러로 분기함
- 로거, 설정, 저장소, 외부 클라이언트 같은 의존성을 모두 인자로 받음
- 가능하면
http.Handler를 반환하고, 복잡한 경우 전용 타입을 사용할 수 있음 - 자체 muxer를 구성한 뒤
routes.go의 라우트 등록 함수로 넘김
- 모든 엔드포인트에 공통으로 필요한 HTTP 처리는
NewServer에서 묶음- CORS
- 인증 미들웨어
- 로깅
- 추적 ID 미들웨어
- 의존성 인자 목록이 길어져도 함수 인자로 명시하는 방식을 선호함
- 구조체 필드를 빠뜨려도 컴파일러가 막지 못할 수 있지만, 함수 인자는 필요한 값을 넘기지 않으면 호출할 수 없음
- 긴 인자 목록은 세로로 포맷하면 읽기 쉬움
- 특정 테스트에서 쓰지 않는 의존성은
nil로 넘겨 사용되지 않는다는 신호로 삼음
routes.go에 API 표면 모으기
routes.go는 서비스의 모든 라우트를 한곳에서 확인하는 파일로 둠- 프로젝트마다 API 표면을 훑어볼 단일 위치가 생김
NewServer의 큰 의존성 목록 때문에addRoutes에도 비슷한 인자 목록이 생길 수 있음- Go 타입 검사가 누락되거나 순서가 틀린 인자를 잡아줌
addRoutes는 가능하면 단순하고 평평하게 유지함- 오류가 날 수 있는 작업은
run함수에서 먼저 처리함 - 핸들러 등록 단계에서는
mux.Handle,mux.HandleFunc,http.NotFoundHandler같은 라우팅에 집중함 - 핸들러 자체가 오류를 반환해야 하는 설계라면
addRoutes도 오류를 반환할 수 있음
- 오류가 날 수 있는 작업은
main은 run만 호출하기
func main()은run()을 호출하고, 오류가 있으면stderr에 쓰고 비정상 종료하는 얇은 함수로 둠run은context.Context, 인자, 입출력, 환경 접근 함수 같은 운영체제 기본 요소를 인자로 받음run이 오류를 반환하므로 일반 Go 코드처럼 오류 처리가 가능함
run에 넘길 수 있는 값의 예시는 다음과 같음os.Args: 프로그램 실행 인자와 플래그 파싱에 사용os.Stdin: 입력 읽기os.Stdout: 출력 쓰기os.Stderr: 오류 로그 쓰기os.Getenv: 환경 변수 읽기os.Getwd: 현재 작업 디렉터리 얻기
signal.NotifyContext는run안에서 설정함Ctrl+C같은 종료 신호가 들어오면 컨텍스트가 취소됨run이nil을 반환하면 정상 종료함- 오류를 반환하면
main이 오류를 출력하고 0이 아닌 코드로 종료함
- 전역 상태를 피하면 더 많은 테스트에서
t.Parallel()을 사용할 수 있음- 여러 번
run을 호출해도 각 실행이 서로 간섭하지 않음 - 플래그는 전역
flag대신run내부의flags.NewFlagSet으로 처리함 - 환경 변수는 실제 환경을 바꾸는 대신
getenv func(string) string을 주입해 제어함 - 이 방식은
t.SetEnv와 달리 병렬 테스트를 계속 사용할 수 있음
- 여러 번
종료와 준비 상태 처리
- 컨텍스트는 서비스 전 계층에 전달해야 함
- 종료 신호가 오면 컨텍스트가 취소됨
- 긴 작업이나 반복 작업은
ctx.Err()나ctx.Done()을 확인하고 중단함 - 다른 고루틴을 시작한 경우에도 컨텍스트로 멈출 시점을 판단함
- HTTP 서버는 종료 시
Shutdown을 호출해 우아하게 멈춤- 예시에서는 별도 고루틴에서
ctx.Done()을 기다림 - 종료 컨텍스트에
10 * time.Second타임아웃을 둠 - 종료 중 오류가 있으면
stderr에 기록함
- 예시에서는 별도 고루틴에서
- 테스트에서 서버가 실제로 준비됐는지 확인하려면
/healthz나/readyz엔드포인트를 둠- 별도 채널로 준비 신호를 만들 수도 있지만, 실제 HTTP 요청으로 확인하는 방식을 선호함
- 준비 확인 루프는
200 OK가 올 때까지 요청함 - 컨텍스트 취소나 타임아웃에 도달하면 오류를 반환함
- 예시 루프는 요청 사이에
250ms를 쉼
핸들러 구성 방식
- 핸들러 함수는
http.Handler나http.HandlerFunc를 직접 구현하기보다 반환함- 예:
func handleSomething(logger *Logger) http.Handler - 핸들러별 클로저 환경을 만들 수 있음
- 초기화한 값을 요청 처리 시 사용할 수 있음
- 예:
- 공유 데이터는 읽기 전용으로만 사용하는 것이 안전함
- 핸들러가 값을 수정한다면 mutex 같은 보호 장치가 필요함
- 프로그램 상태를 클로저에 저장하는 방식은 보통 권장되지 않음
- 클라우드 환경에서는 인스턴스가 오래 유지된다고 가정하기 어려움
- 서버가 리소스 절약을 위해 내려가거나, 다른 이유로 크래시할 수 있음
- 여러 인스턴스가 동시에 실행되고 요청이 예측하기 어려운 방식으로 분산될 수 있음
- 실제 프로젝트의 지속 상태는 데이터베이스나 별도 저장 API에 두는 편이 나음
요청/응답 인코딩과 검증
- 모든 서비스에는 요청 본문 디코딩과 응답 본문 인코딩이 필요하므로
encode/decode헬퍼를 둠- 예시는 JSON
Content-Type을 설정하고 상태 코드를 쓴 뒤json.NewEncoder(w).Encode(v)를 호출함 - 디코딩은
json.NewDecoder(r.Body).Decode(&v)를 감싸고 오류에 문맥을 붙임 - 제네릭을 쓰면
encode(w, r, http.StatusOK, obj)처럼 타입 추론이 가능함 decode는 반환 타입이므로decode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)처럼 기대 타입을 명시해야 함
- 예시는 JSON
- 검증에는 단일 메서드 인터페이스를 사용함
Validator인터페이스는Valid(ctx context.Context) map[string]string형태임- 문제가 없으면 길이 0의 map이 반환됨
- 문제가 있는 필드는 필드명을 key로, 사람이 읽을 수 있는 설명을 value로 둠
- 검증 대상은 빠른 필드 확인에 적합함
- 필수 필드가 비어 있지 않은지
- 이메일 같은 특정 문자열 형식이 맞는지
- 숫자가 허용 범위 안에 있는지
- 데이터베이스 조회처럼 더 복잡한 검사는 별도 위치에서 처리함
- 그런 검사는 빠른 검증 함수 안에 숨기기에는 중요도가 큼
- 제네릭 버전의
decodeValid[T Validator]는 타입T가 반드시Validator를 구현하도록 강제함 nilmap에len(problems)를 호출해도 0이므로 panic이 나지 않음
미들웨어 어댑터 패턴
- 미들웨어는
http.Handler를 받아 새http.Handler를 반환함- 원래 핸들러 호출 전후에 코드를 실행할 수 있음
- 조건에 따라 원래 핸들러를 호출하지 않을 수도 있음
- 예시의
adminOnly는 관리자가 아니면HTTP 404 Not Found를 반환하고 원래 핸들러를 호출하지 않음
- 미들웨어 적용 위치는 보통
routes.go로 둠- 엔드포인트 목록만 봐도 어떤 라우트에 어떤 미들웨어가 붙었는지 알 수 있음
- 미들웨어 목록이 길어지면 여러 줄로 나눠 읽기 쉽게 만듦
- 의존성이 많은 미들웨어는 미들웨어를 반환하는 함수로 감쌈
newMiddleware(logger, db, slackClient, rroll)이func(http.Handler) http.Handler를 반환함- 라우트 등록 코드에서는
middleware(handleSomething(...))처럼 간결하게 사용함 - 별도
type middleware func(h http.Handler) http.Handler를 정의할 수도 있지만, 반환 타입을 직접 쓰면 코드 읽기가 더 명확함
요청/응답 타입의 범위 줄이기
- 특정 엔드포인트에서만 쓰는 요청/응답 타입은 핸들러 함수 내부에 정의할 수 있음
- 전역 네임스페이스가 깨끗해짐
- 다른 핸들러가 안정적이라고 보장하지 않은 타입에 의존하는 일을 막음
- 테스트 코드에서 같은 타입이 필요하면 마찰이 생길 수 있음
- 이 경우 타입을 밖으로 빼는 것도 타당함
- 요청/응답 타입이 핸들러 안에 있으면 테스트에서는 새 익명 구조체나 로컬 타입을 선언할 수 있음
- 테스트의 로컬 타입은 의도를 드러냄
- 예를 들어
/greet엔드포인트가Person전체가 아니라Name필드만 필요로 한다면, 테스트 입력 구조체에Name만 둠 - 테스트를 읽는 사람이 해당 엔드포인트가 어떤 필드에 관심 있는지 바로 알 수 있음
- 예를 들어
sync.Once로 초기화 지연
- 핸들러 준비 중 비용이 큰 작업은
sync.Once로 첫 요청 시점까지 미룸- 애플리케이션 시작 시간이 줄어듦
- 핸들러가 호출되지 않으면 비용 큰 작업이 실행되지 않음
- 예시는 템플릿 파일 파싱을 첫 요청에서 한 번만 수행함
sync.Once는 코드가 한 번만 실행되도록 보장함- 동시에 들어온 다른 요청은 초기화가 끝날 때까지 대기함
- 오류 확인은
init.Do밖에서 수행해 오류를 계속 표면화함
- 이 방식은 초기화 시간을 시작 시점에서 런타임의 첫 엔드포인트 접근 시점으로 옮김
- Google App Engine을 많이 쓰는 환경에서는 이 방식이 적합할 수 있음
- 배포 환경에 따라 어디에서 언제
sync.Once를 사용할지 판단해야 함
테스트 전략
- 이 구조는 테스트 용이성을 중요한 목표로 삼음
run함수는 테스트 코드에서 프로그램을 직접 실행할 수 있게 함- 테스트는 프로그램 동작을 이해하기 쉬운지, 변경 시 깨질 걱정을 줄이는지, 통과 후 프로덕션 배포 신뢰를 줄 수 있는지를 기준으로 삼음
- 핸들러만 독립적으로 테스트할 수도 있음
- 핸들러 생성 함수를 호출하고 필요한 의존성을 넘김
httptest.NewRecorder와http.NewRequest로 요청과 응답을 구성함- 상태 코드, 응답 본문, 헤더를 검증함
- 이 방식은 인증 같은 미들웨어를 건너뛰고 핸들러 코드로 바로 들어감
- 더 선호하는 방식은 엔드투엔드 테스트에 가까움
run을 호출해 실제 실행 방식에 가깝게 프로그램을 띄움- 인자 파싱, 의존성 연결, 데이터베이스 마이그레이션, 서버 시작까지 포함함
- 테스트가 API를 호출하면 모든 계층과
routes.go도 함께 검증됨 - 실제 데이터베이스와 상호작용할 수 있음
- 이 방식은 반복 테스트를 줄이는 데 도움이 됨
- 모든 계층을 따로 테스트하면 같은 내용을 조금씩 다른 방식으로 여러 번 검증할 수 있음
- 엔드투엔드 테스트는 사용자와 시스템의 상호작용을 설명하는 중심 테스트 세트를 제공함
- TDD 등으로 이미 생긴 단위 테스트는 적절하면 유지하되, 엔드투엔드 테스트와 같은 내용을 반복하면 삭제할 수 있음
- 각 테스트는 자체 프로그램 인스턴스를 실행할 수 있음
- 테스트마다 다른 인자, 플래그, 표준 입출력, 환경 변수를 넘김
context.WithCancel로 취소 함수를 만들고t.Cleanup(cancel)에 등록함- 테스트가 끝나면 컨텍스트가 취소되고 프로그램이 우아하게 종료됨
- Go 1.14의
t.Cleanup은 직접defer를 쓰는 대체 수단으로 사용됨
실제 적용 범위와 조직 맥락
- 단순 API를 만들 때 이 패턴은 읽기 쉽고 확장하기 쉬운 코드를 목표로 함
- 패턴을 복사해 확장하기 쉬움
- 새 사람이 작업하기 쉬움
- 변경 시 불안이 줄어듦
- 마법 같은 동작 없이 명시적으로 구성됨
- 코드 생성 도구를 쓰더라도 이 방식은 유지될 수 있음
- 예시로 템플릿 기반 보일러플레이트 생성을 위해 Oto package를 사용할 수 있음
- 큰 프로젝트나 큰 조직에서는 기존 기술 선택이 결정을 바꿀 수 있음
- Grafana Labs 같은 조직에서는 특정 도구와 추상화가 이미 널리 쓰일 수 있음
- gRPC가 그런 예시임
- 확립된 패턴과 경험이 있는 경우 그 흐름을 따르는 실용적 선택을 하게 됨
- Grafana IRM 제품군 맥락도 포함됨
- Grafana IRM은 Grafana Labs에서 구축 중인 제품군임
- Grafana Alerting은 메트릭이 허용 범위를 벗어나면 알림을 보냄
- Grafana OnCall은 일정과 에스컬레이션 규칙으로 적절한 사람에게 연락하는 과정을 자동화함
- Grafana Incident는 Zoom 방, 전용 Slack 채널, 이벤트 타임라인을 만들고 사고 대응을 돕음
- Slack 채널에서 로봇 얼굴 이모지 반응을 단 항목은 타임라인에 추가됨