# 다른 서버 부르기

notes 가 메모를 저장하기 전에 tags 에 태그를 묻는 흐름이 예다. 새 호출도 순서가 같다:
proto → 받는 서버 구현 → 부르는 쪽 clients → 실패했을 때 할 일 → 테스트 → 연결(환경 변수 · compose · smoke). 명령은 모두 저장소 루트에서 돌린다.

```
web ──HTTP──▶ gateway ──▶ notes ──gRPC TagService.Suggest──▶ tags
                                └───gRPC ActivityService.Record──▶ activity
```

서버끼리는 gateway 를 거치지 않는다. gateway 는 앱이 들어오는 문이고, 서버 사이는 gRPC 로 직접 부른다(ADR-0002).

## 1. proto

`contract/proto/app/<도메인>/v1/<도메인>.proto` 에 서비스 · 메시지를 쓴다. 디렉터리가 패키지다(`app/tag/v1` → `app.tag.v1`).

```proto
service TagService {
  rpc Suggest(SuggestRequest) returns (SuggestResponse);
}
message SuggestRequest { string text = 1; }
message SuggestResponse { repeated string tags = 1; }
```

`pnpm gen` 이 buf lint 뒤 언어마다 코드를 만든다. 필드 번호는 바꾸거나 다시 쓰지 않는다(ADR-0002). → [contracts.md](/reference/contracts/#grpc-공통-proto-가-기준-adr-0002)

## 2. 받는 서버

받는 서버의 api 층에 서비스를 구현하고 domain 을 부른다. tags(FastAPI)는 `server/api/rpc/tags.py`, activity(Spring)는 `api/grpc/ActivityGrpcService.java`.
테스트는 실제 gRPC 연결로 부른다: `tests/grpc/TagService/Suggest_specs.py`, 묶음 이름은 gRPC 경로 `/app.tag.v1.TagService/Suggest`.

## 3. 부르는 쪽: clients 레이어

다른 서버는 외부 시스템이다. 부르는 서버의 `clients/` 에 서버 하나당 모듈 하나를 두고, 밖에는 작은 인터페이스와 교체 지점만 보인다.

```ts
// servers/notes/src/clients/tags.ts
export interface Tags {
  suggest(text: string): Promise<string[]>
}

// 만들 때는 접속하지 않는다 (처음 부를 때 연결). 서버가 멈춰도 쓰기가 계속 기다리지 않게 시간 제한
const client = createClient(TagService, createGrpcTransport({ baseUrl: config.tagsGrpc, defaultTimeoutMs: 3_000 }))
const grpcTags: Tags = { suggest: async (text) => (await client.suggest({ text })).tags }

export let tags: Tags = grpcTags
/** 테스트가 Fake 로 바꿀 때만 쓴다 */
export const useTags = (t: Tags) => (tags = t)
```

주소는 환경 변수 `<서버>_GRPC`(여기서는 `TAGS_GRPC`, 기본 `http://127.0.0.1:9002`)로 받는다.

## 4. 실패하면 어떻게 할지

다른 서버는 언젠가 멈춘다. 그때 응답을 어떻게 할지는 domain 이 호출마다 정한다. 예시의 두 호출은 답이 다르다.

| 호출 | 실패하면 | 이유 |
|---|---|---|
| tags `Suggest` (저장 전) | 저장하지 않고 502 `태그 서버에 연결할 수 없습니다` | 태그는 메모의 일부다. 태그 없이 저장하면 나중에 채울 방법이 없다 |
| activity `Record` (저장 뒤) | 경고만 남기고 201 · 200 · 204 | 기록은 부가 정보다. 기록 때문에 이미 저장한 메모를 실패로 돌려주면 사용자는 다시 보내고 메모가 둘이 된다 |

```ts
// servers/notes/src/domain/note.ts
export async function create(text: string) {
  const note = await notes.create(text, await suggest(text)) // 실패하면 던진다 → 502
  await record('note.created', note.id) // 실패하면 console.warn 만
  return note
}
```

다시 시도 · 큐에 넣고 나중에 보내기 같은 선택도 있다. 템플릿에는 없고, 고르면 ADR 로 남긴다. 정한 것은 그 서버 `AGENTS.md` 에 적는다.

## 5. 테스트: 다른 서버는 Fake

부르는 서버의 테스트는 다른 서버를 띄우지 않는다. 교체 지점에 Fake 를 넣고, Fake 로는 외부 조건(돌려줄 값 · 다음 한 번 실패 · 받은 인자)만 정한다.
실패를 502 로 바꾸는 것은 실제 domain 이 한다. 그 연결이 검증 대상이다. → [testing.md](/reference/testing/#fake)

```ts
// servers/notes/tests/notes/POST_specs.ts
test('태그_서버에_연결할_수_없으면_502_Bad_Gateway를_반환하고_저장하지_않는다', async () => {
  fakeTags.failNext()

  const res = await sendJson('POST', '/api/notes', { text: '안녕' })

  assert.equal(res.status, 502)
  assert.deepEqual(res.body, { error: '태그 서버에 연결할 수 없습니다' })
  assert.deepEqual(await list(), [])
})
```

Fake 는 `tests/support/fakes.ts` 에 두고 `tests/support/api.ts` 가 한 번 넣고 테스트마다 `reset()` 한다. mock(`mock.module`)은 쓰지 않는다(ADR-0003).

## 6. 연결

- 부르는 서버의 `.env.template` 에 주소(`# TAGS_GRPC=http://127.0.0.1:9002`).
- `docker/docker-compose.yaml` 의 부르는 서버에 `TAGS_GRPC: "http://tags:9002"` (compose 네트워크 안의 서비스 이름).
- `docker/smoke.sh` 에 흐름 하나: 실제 이미지끼리 이어지는지만 본다(notes 덩어리가 태그가 붙는지, activity 에 기록이 남는지 본다).
- `pnpm drop` 은 `<서버>_GRPC` 로 부르는 쪽을 찾아, 부르는 서버가 남아 있으면 받는 서버를 빼지 않는다.
- 꼭 필요한 호출이면 루트 `pnpm dev` 의 기본 조합에 받는 서버를 넣는다(지금은 notes · tags · activity 를 같이 띄운다).

## 확인

```bash
pnpm gen && pnpm typecheck
pnpm --filter @app/tags test     # 받는 쪽: 실제 gRPC
pnpm --filter @app/notes test    # 부르는 쪽: Fake 로 성공 · 실패
pnpm stack                       # 실제 이미지끼리 (smoke)
```