2P by GN⁺ | ★ favorite | 댓글 1개
  • 오래 유지될 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도 오류를 반환할 수 있음

mainrun만 호출하기

  • func main()run()을 호출하고, 오류가 있으면 stderr에 쓰고 비정상 종료하는 얇은 함수로 둠
    • runcontext.Context, 인자, 입출력, 환경 접근 함수 같은 운영체제 기본 요소를 인자로 받음
    • run이 오류를 반환하므로 일반 Go 코드처럼 오류 처리가 가능함
  • run에 넘길 수 있는 값의 예시는 다음과 같음
    • os.Args: 프로그램 실행 인자와 플래그 파싱에 사용
    • os.Stdin: 입력 읽기
    • os.Stdout: 출력 쓰기
    • os.Stderr: 오류 로그 쓰기
    • os.Getenv: 환경 변수 읽기
    • os.Getwd: 현재 작업 디렉터리 얻기
  • signal.NotifyContextrun 안에서 설정함
    • Ctrl+C 같은 종료 신호가 들어오면 컨텍스트가 취소됨
    • runnil을 반환하면 정상 종료함
    • 오류를 반환하면 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.Handlerhttp.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)처럼 기대 타입을 명시해야 함
  • 검증에는 단일 메서드 인터페이스를 사용함
    • Validator 인터페이스는 Valid(ctx context.Context) map[string]string 형태임
    • 문제가 없으면 길이 0의 map이 반환됨
    • 문제가 있는 필드는 필드명을 key로, 사람이 읽을 수 있는 설명을 value로 둠
  • 검증 대상은 빠른 필드 확인에 적합함
    • 필수 필드가 비어 있지 않은지
    • 이메일 같은 특정 문자열 형식이 맞는지
    • 숫자가 허용 범위 안에 있는지
  • 데이터베이스 조회처럼 더 복잡한 검사는 별도 위치에서 처리함
    • 그런 검사는 빠른 검증 함수 안에 숨기기에는 중요도가 큼
    • 제네릭 버전의 decodeValid[T Validator]는 타입 T가 반드시 Validator를 구현하도록 강제함
    • nil map에 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.NewRecorderhttp.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 채널에서 로봇 얼굴 이모지 반응을 단 항목은 타임라인에 추가됨

댓글과 토론

Hacker News 의견들
  • Valid 메서드처럼 검증기를 따로 두는 방식도 써봤지만, Lexi Lambda의 “Parse, Don’t Validate” [0]를 읽은 뒤로는 Go의 타입 검사기를 활용하는 쪽이 오류가 훨씬 적다고 느꼈음
    예를 들어 사용자가 꺾쇠괄호가 들어간 불법 사용자명을 절대 지정하지 못하게 하려면, 검증기 방식에서는 신뢰할 수 없는 입력에서 사용자명이 오는 모든 코드 경로에서 검증기를 호출해야 함
    대신 Username 타입과 NewUsername(username string) (Username, error) 생성자를 두면, Username 객체가 존재한다는 사실만으로 이미 검증을 통과했다는 보장이 생김
    [0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...

    • 타입 시스템을 제대로 쓰면 코드가 좋아진다는 게 새삼 놀라움. 모든 걸 string으로 넘기지 말고, 파싱해서 타입을 붙여야 함
    • 좋은 설계 패턴이지만 너무 이른 검증은 조심해야 함
      이 패턴은 검증을 언제든 이르게도 늦게도 할 수 있게 해주지만, 언제 해야 하는지는 알려주지 않음. 보통은 더 큰 객체를 파싱/검증하는 과정의 일부로 하는 편이 가장 나을 때가 많음
      UI 맥락에서 검증되지 않은 데이터를 다루는 아이디어는 Steven Witten의 “I is for Intent” [1]가 참고할 만함
      [1] https://acko.net/blog/i-is-for-intent/
    • 개념적으로는 오래된 private 생성자팩토리 메서드 기법과 같음
    • 관련 링크들: Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - 2023년 3월, Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - 2021년 6월, Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - 2019년 11월, Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - 2019년 11월
    • Go에서는 이 패턴을 적용하기가 어려웠음. Username이 구조체 안에 포함되어 있고 값을 설정하는 걸 잊으면, 제약을 위반할 수 있는 zero value가 들어가게 됨
  • 전체 시스템 설정을 나타내는 Config 객체를 받아서 여기저기 가변으로 넘기는 방식이 가장 싫은 패턴 중 하나임
    그렇게 하면 모든 것이 설정 객체를 통해 결합됨. 어떤 시스템에서는 누군가 전달받은 설정 객체에 다시 값을 써서, 정상 동작하려면 각 부분을 특정 순서로 설정해야 했음
    또 다른 경우에는 어떤 서브시스템이 나중에 읽을 데이터를 설정 객체에 써버려서, 시스템 일부를 비활성화할 수 없었음
    “설정이 하나의 큰 가변 값”인 패턴은 Go뿐 아니라 다른 언어에서도 꽤 성가신 패턴임

    • 핵심은 일반적인 설정 데이터 객체가 아니라 가변 설정 객체
      Python 프로젝트에서는 불변 설정 dataclass를 많이 쓰고 여러 모듈에 넘기는데, 여러 함수가 여러 값에 의존할 때 각각을 함수 인자로 넘기고 타입을 따로 정의하는 대신, dataclass 한 곳에 모든 변수와 타입 정의가 있어서 꽤 편리한 설계 패턴이 됨
    • 이걸 막는 가장 좋아하는 방식은 설정을 진짜 불변으로 만들되, Option 함수들로 구성 가능하게 하는 것임
      내부 options는 생성 중에만 바꾸고, 외부에는 Config와 접근자만 노출함. 예를 들어 config.New(config.Name("Emanon"))처럼 만들고 cfg.Name()으로 읽게 할 수 있음
    • 각 패키지마다 Config 구조체를 만들고, configs.Config는 각 패키지의 Config를 모아둔 형태로 만드는 편임
      Go 모범 사례는 아닐 수 있지만, 시작 시에는 전체 시스템 설정을 하나의 엔티티로 만들 수 있고, 각 패키지에는 필요한 최소 의존성만 넘길 수 있어서 좋음
      테스트할 때도 한 패키지만 테스트하려고 전체 설정을 가짜로 만들 필요가 없어져 조금 쉬워짐
    • 동의함. 예전에 최상위 설정 객체를 바꿨다가 심각 장애를 낸 적이 있음
      절대 수정하면 안 됨. 어디서 어떻게 쓰이는지 알 수 없어서 낭비되는 인건비가 아깝고, 변경이 필요하면 원본에서 파생한 값을 만드는 편이 낫다
      웃긴 건 설계상 설정 객체가 어느 정도 불변이었고, 수정하려면 WARNING_DO_NOT_USE API를 써야 했는데도 그걸로 객체를 바꿨다가 장애를 냈음
    • 타당한 비판이라고 봄. 더 쓰기 편한 패턴은 뭐라고 생각하는지 궁금함
  • Mat Ryer의 작업을 정말 좋아하고, 이 글의 2018년 버전에 나온 아이디어 대부분을 그 이후 모든 Go 프로젝트에 적용해왔음
    다만 NewServer가 모든 의존성을 인자로 받는 큰 생성자이고, 테스트에서 필요 없는 의존성은 쓰지 않는다는 신호로 nil을 넘긴다는 부분은 늘 찜찜했음
    그 결과 코드의 큰 부분이 불필요한 공유 상태를 많이 갖게 됨. 실제로는 요청 사용자가 리소스에 접근 가능한지 확인하고 데이터 저장소의 함수 하나만 호출하면 되는 HTTP 핸들러도 많은데, 부모 서버가 가진 모든 객체와 데이터 저장소 전체에 접근 가능한 거대한 덩어리의 일부가 되어버림
    두 메서드만 모킹해서 테스트하고 싶어도 단순한 테스트를 작성하기 어렵고, Mat Ryer의 패턴은 지금까지 본 것 중 최고지만 더 나은 해법이 있을 것 같다는 느낌이 남음

    • 작업하는 저장소에서 이런 높은 유입 결합도 지점에 점점 민감해졌고, 특히 Bazel 세계에 깊이 들어갈수록 의존성 관리와 물리적 설계가 작성하는 코드에 미치는 영향이 커짐
      가능하다면 플러그인은 좋은 코드 경계 전략임. 플러그인 아키텍처는 기본적으로 선택 해제 상태이고, 명시적으로 선택해야 드러나기 때문에 모든 가능성을 어떤 코드 덩어리에 강요하지 않음
      이런 성질의 소프트웨어를 “일품요리식”이라고 부르고 있음. 일반적으로는 “무엇이든 하기 위해 모든 것을 하는” 상황을 피해야 함
    • 대부분의 로직을 패키지로 작성하는 편임. 예를 들어 HN을 만든다면 users 패키지나 comments 패키지처럼 둠
      이 패키지들은 HTTP 인터페이스가 전혀 없지만, 각각 자기 main과 일종의 CLI 인터페이스를 가짐. 그 파일 주석의 //go:build ignore가 유용함
    • 그냥 클로저를 씀
      func HandleX(w http.ResponseWriter, req *http.Request)처럼 핸들러를 정의하는 대신, func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc 형태로 필요한 의존성을 받고 내부에서 실제 http.HandlerFunc를 반환하게 함
      그리고 진입점에서 한 번 초기화함
    • NewServer가 만든 객체가 너무 많은 일을 하고 있다는 뜻임. 너무 많은 데이터 타입과 동작이 결합되어 있을 가능성이 큼
      간단한 예로 로거를 생성자 의존성에 추가하면, 객체는 처음의 단순한 구현보다 조금 더 많은 일을 하게 됨. 그 자체는 괜찮지만, 단순한 것의 구현을 고치지 않고 로깅하는 방법을 찾지 못하면 아쉬움
      고차 함수, 예를 들어 로거 데코레이터가 합성을 가능하게 해주지만 그것도 단점은 있음. 그래도 다룰 수 있는 구조의 한 형태이지 실수는 아님
    • 오랫동안 같은 느낌을 받았고, 지금은 선택적 설정 구조체를 쓰는 접근으로 옮겼음
      핵심은 NewServer 안에서 선택적 설정 구조체의 값들을 검증한 뒤 서버 구조체로 복사하는 방식임. 그러면 더 적은 의존성만 모킹할 수 있어 테스트가 훨씬 쉬워짐
      기능 옵션 패턴도 여러 사람이 권하듯 많이 시도했지만 결국 포기했음. 조금 과하게 영리해서 읽기 어렵고, 설정 구조체 + 검증 후 복사 패턴보다 보일러플레이트도 더 많다고 느꼈음
      [0] https://news.ycombinator.com/item?id=39320170
  • 어떤 언어의 HTTP 서비스든 이 생각이 더 널리 받아들여졌으면 함: 핸들러가 의존성이 필요하면 인자로 직접 요구해야지, 서버 구조체에 매달린 메서드로 두어 테스트할 때 깜짝 의존성이 생기게 하면 안 됨
    HTTP 서비스의 핸들러에는 대개 비즈니스 로직이 많이 들어가고, 그 로직은 많은 의존성을 가질 가능성이 큼. 실제로 단일 핸들러가 DB, 캐시, 블롭 저장소, 엔드포인트 특화 권한 검사, 라이선스 검사기, 큐, 특수 로거, 지표 클라이언트 등을 쓰는 경우를 자주 봄
    매개변수가 9개 이상이 될 수 있고 린터나 경험칙은 보통 이를 막으려 하지만, 의존성이 사라지는 게 아니라 server 클래스/구조체에 숨겨놓고 메서드 시그니처가 짧으니 의존성이 적다고 속이는 것뿐임
    시간이 갈수록 20개가 되더라도 모든 의존성이 함수/메서드 시그니처에 드러나는 코드가 더 낫다고 느낌. 그래야 코드 복잡도가 커지는 걸 속이지 않게 됨

    • 항상 핸들러를 라우트/요청을 처리하는 메서드를 가진 개별 구조체로 둠
      예를 들어 CreateUser 구조체 안에 store, cache, logger, pub 같은 해당 작업에 필요한 의존성만 넣고 ServeHTTP를 구현함
      main.go나 의존성을 설정하는 곳에서 각 작업을 만들며 필요한 의존성만 넘김. 이렇게 하면 특정 작업/핸들러의 헬퍼 메서드를 그 구조체의 private 메서드로 유지할 수 있어서 좋음
      다만 어떤 작업이 다른 작업을 필요로 하면 서로 넘기기 시작하거나 별도 패키지/서비스로 뽑아야 해서 번거로워질 수 있음
    • 꼭 9개 이상의 별도 인자일 필요는 없음. 어떤 언어에서는 핸들러에 필요한 것만 담은 단일 context/env 객체로 둘 수 있음
      예를 들어 handleHello({ db, cache, blobStore, authz }, req, res)처럼 쓰면, 두 핸들러가 정확히 같은 컨텍스트를 쓰는 경우 재사용할 수 있고, 호출 지점에서 핸들러별 컨텍스트를 선언하기도 쉬움
  • 이 글의 많은 부분에 동의하며 몇 가지를 더 보태고 싶음
    앱 컨텍스트와 함께 WaitGroup을 서비스 구조체에 넘기면, 인터럽트가 컨텍스트를 통해 앱 종료를 트리거하고 메인 고루틴은 실제 종료 전에 WaitGroup을 기다릴 수 있음
    CLI 프로그램이라면 stdout, stdin, stderr, args, env 등을 테스트하는 게 유용하지만, HTTP 서버에서는 덜 그렇다고 봄. run 함수에는 구조화된 설정을 넘겨 테스트가 더 집중되게 하겠음
    핸들러에서 sync.Once로 템플릿을 파싱하는 방식에는 반대함. 핸들러가 템플릿 파싱을 해서는 안 된다고 보고, 앱 시작 시점에 해야 함. 템플릿을 파싱할 수 없다면 앱은 요청을 받을 준비 상태가 되면 안 되고 0이 아닌 종료 코드로 끝나야 함

    • 첫 번째 지점이 흥미로움. 컨텍스트 전파와 서버 종료 대기로 해결되는 문제 아닌지 궁금함
  • 최근 ogen을 가지고 놀아보고 있음: https://github.com/ogen-go/ogen
    OpenAPI 정의를 작성하면 라우팅, 구조체 정의, JSON 스키마 검증 등을 해줌. 내가 할 일은 서비스를 구현하는 것뿐임
    쿼리 문자열 정수 범위 검증 같은 건 너무 지루하고, 직접 작성하면 오타 내기도 너무 쉬움
    아직은 가지고 노는 단계라 나쁜 부분은 발견하지 못했음

    • 이 접근의 문제는 OpenAPI를 처음부터 손으로 작성하는 과정이 엄청 지루하다는 것임
      Protobuf, capnproto 같은 비슷한 IDL을 작성하는 쪽이 훨씬 생산적으로 느껴짐
    • Go 코드에서 OpenAPI 명세를 내보내는 쪽을 선호한다면 danielgtaylor/huma[1]와 swaggest/rest[2]도 괜찮음
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • 비슷하게 oapigen으로 시작했음: github.com/deepmap/oapi-codegen
      명세 작성이 지루할 줄 알았는데 생각보다 훨씬 괜찮았고, 어차피 명세가 필요하니 미리 작성하는 편이 낫다고 봄
  • fx(https://github.com/uber-go/fx)가 애플리케이션을 설계하는 데 아주 단순하면서도 다재다능한 도구라고 느낌
    글의 조언은 여전히 유용하지만, “Y가 필요로 할 때 X가 초기화되어 있음을 어떻게 보장할까”라는 부분을 완전히 제거해 줌. N*M 문제가 N 문제로 줄어, 각 조각을 어떻게 초기화할지만 신경 쓰면 되고 조각들 사이의 초기화 동기화는 걱정하지 않아도 됨
    여러 언어에서 의존성 주입 라이브러리를 꽤 써봤고 직접 구현도 해봤지만, fx의 단순함과 범용성이 지금까지 가장 마음에 듦

    • 이런 의존성 주입 프레임워크는 정말 싫음
      잘 설계된 시스템에서는 이 문제는 사소해야 함. 무언가를 쓰고 싶을 때 초기화되어 있음을 보장하는 건 생성자 인자로 넘길 수 있게 준비되어 있느냐의 문제임
      stockService := NewStockService(), orderService := NewOrderService(), orderProcessor := NewOrderProcessor(stockService, orderService)처럼 만들면 됨
      초기화의 “동기화” 같은 건 필요 없어야 하고, 잘못하면 컴파일되지 않음. 순환 의존성을 추가해도 올바른 순서로 구성할 수 없으니 명확히 드러남
  • 흥미로운 아이디어가 많은 훌륭한 글임. signal.NotifyContext를 몰랐다는 게 믿기지 않음
    이제 프로젝트마다 복붙하지 않고도 시그널 처리 방법을 기억할 수 있겠음

  • 여기서 한 방식이 많이 마음에 들지만, 내 테스트는 조금 다름
    newTestServer()에서 의존성들을 가짜로 넣은 서버를 띄우고, 의존성 오류를 테스트하고 싶으면 해당 속성을 오류를 반환하는 가짜로 바꿈
    그래서 오류 경로, 로그 항목, 지표 발행, 타임아웃, graceful shutdown까지 검증할 수 있음
    서버가 시작된 뒤에는 어떤 포트에 바인딩됐는지 확인함. 기본값이 :0이라 실제 할당된 포트를 기다려야 하기 때문임
    “단위” 테스트는 핸들러 수준이나 HTTP 수준에서 할 수 있고, 미들웨어 전체를 통과시키거나 전혀 통과시키지 않으면서 사용자가 보게 될 방식대로 코드를 충분히 테스트할 수 있음. N개 인스턴스를 띄워 병렬로 테스트하는 것도 가능함

  • Go를 쓰지는 않지만 이런 패턴들은 마음에 듦. 테스트 가능한 코드에 꽤 보편적으로 적용되는 느낌임
    의존성을 암묵적/정적/테스트 불가능하게 다루는, 특히 Python 빠른 시작 가이드는 다시 보고 싶지 않음