# Gateway 서버

앱이 부르는 HTTP 진입점. 경로 접두사로 서버를 골라 요청을 그대로 넘긴다. 업무 로직 · DB · 자기 API 가 없다.
Go 1.25 표준 라이브러리만(`net/http` · `httputil.ReverseProxy`), 의존성은 없다. HTTP :8003, gRPC 는 없다.
서버 레이어(api → domain → storage · clients)의 예외다. 자기 API 가 없어 openapi 를 만들지 않으니 `gen` 도 없다.
레포 전체 규칙은 루트 `AGENTS.md`, 이 파일이 이 서버에서는 우선한다.

## 구조

```
main.go                  # 진입점: 환경 변수(HOST · PORT · <서버>_URL) → New → listen
gateway.go               # 경로 표(routes) · New(Config) http.Handler · 서버마다 ReverseProxy · 오류 { "error" }
gateway_specs_test.go    # 경로 표의 줄마다 specs (FakeUpstream · 랜덤 포트의 실제 gateway)
go.mod                   # module gateway, go 1.25 (의존성 없음, go.sum 없음)
package.json             # 스크립트 (go 명령을 감싼다)
.env.template            # 환경 변수 (.env 로 복사, 선택)
Dockerfile               # 이미지 (golang 으로 빌드, distroless nonroot 에서 실행. 컨텍스트는 이 폴더)
```

## 명령

```bash
pnpm dev          # go run . (.env 가 있으면 읽는다). 넘길 서버가 떠 있어야 응답이 온다, 아니면 502
pnpm test         # go test ./... (DB · 다른 서버 없이)
pnpm typecheck    # go vet ./...
pnpm lint         # gofmt -l 이 비어 있는지 + go vet
pnpm format       # gofmt -w .
docker build .    # 이미지 (이 폴더에서)
```

## 규칙

- 경로 표는 `gateway.go` 의 `routes` 하나다. 접두사는 서버마다 하나이고 겹치지 않는다.

  | 접두사 | 서버 | 주소 (환경 변수, 개발 기본) |
  | --- | --- | --- |
  | `/api/notes` | notes | `NOTES_URL`, `http://127.0.0.1:8000` |
  | `/api/tags` | tags | `TAGS_URL`, `http://127.0.0.1:8002` |
  | `/api/activity` | activity | `ACTIVITY_URL`, `http://127.0.0.1:8001` |

- 앱이 부를 서버를 더하면 `routes` 에 `{접두사, 이름, 개발 기본 주소}` 한 줄을 더한다. 주소 변수(`<이름>_URL`) · 테스트의 Fake 는 그 줄에서 따라온다.
  `.env.template` · `docker/docker-compose.yaml` 의 gateway `environment` 에도 주소를 적는다.
- 접두사는 경로 조각 단위로 맞춘다(`http.ServeMux` 에 `/api/notes` · `/api/notes/` 둘 다 등록). `/api/notesx` 는 notes 로 가지 않는다.
  `..` · `//` 가 든 경로는 ServeMux 가 정리한 경로로 301 을 준다.
- 메서드 · 경로 · 쿼리 · 본문 · 헤더를 그대로 넘기고(`SetURL` · `SetXForwarded`, `X-Forwarded-*` 는 gateway 가 다시 쓴다),
  서버의 상태 코드 · 헤더 · 본문을 그대로 돌려준다. 서버의 오류(400 · 404 · 502)도 바꾸지 않는다.
- gateway 가 만드는 오류는 둘, 다른 서버와 같은 `{ "error" }` (`application/json; charset=utf-8`):
  표에 없는 경로(`/` 포함) 404 `{"error":"not found"}`, 서버에 닿지 못하면 502 `{"error":"<이름> 서버에 연결할 수 없습니다"}`(원인은 로그 한 줄).
- 바인딩은 `HOST`(기본 127.0.0.1) · `PORT`(기본 8003). 이미지는 `HOST=0.0.0.0`. 밖에 열기 전에 인증 · TLS.
- 시간 제한은 `ReadHeaderTimeout` · `IdleTimeout` 만 둔다. `ReadTimeout` · `WriteTimeout` 은 긴 응답(스트리밍)을 끊으니 두지 않는다.
- 업무 로직은 두지 않는다. 생기면 그 일을 맡은 서버로. 인증 · 요청 제한 · 접근 로그를 붙인다면 여기 `New` 가 자리다(`docs/guides/auth.md`).
- 서버 ↔ 서버(gRPC)는 gateway 를 거치지 않는다.

## 테스트

- 자기 자원이 없어 specs 는 경로 표의 줄마다 묶는다: `TestNotes` = `/api/notes → notes` · `TestTags` · `TestActivity` · `TestUnknownPath`.
  테스트 이름은 한글_밑줄 조건 · 결과(`t.Run("하위_경로의_DELETE를_tags_서버로_넘기고_204를_돌려준다", ...)`).
- `startGateway(t)` 가 `routes` 의 서버마다 `FakeUpstream`(httptest)을 띄우고, 그 주소를 가리키는 실제 `New(cfg)` 를 랜덤 포트로 띄운다.
  테스트마다 새로 띄우고 `t.Cleanup` 으로 닫으니 `reset()` 이 없다. mock 은 쓰지 않는다(ADR-0003).
- Fake 가 정하는 것은 돌려줄 상태 코드 · 본문(`respond`), 보는 것은 받은 요청(`requests()`: 메서드 · 경로 · 쿼리 · 본문 · 헤더 일부).
  서버가 꺼진 경우는 Fake 를 `Close()` 하고 부른다.
- 넘긴 응답 본문은 바이트 그대로 비교하고, gateway 의 오류는 JSON 필드로 비교한다. 엉뚱한 서버를 부르지 않았는지는 Fake 의 받은 요청 수로 본다.