# 같은 일, 세 언어

예시 서버 셋은 맡은 일이 다르다(notes · tags · activity). 그래도 서버마다 같은 종류의 일을 하니 나란히 놓고 볼 수 있다:
목록을 HTTP 로 내주고, 문서를 만들고, DB 를 읽고, gRPC 를 주고받고, 실제 서버로 테스트한다.
서버의 언어는 그 서버의 선택이다. notes 가 Node 인 것은 규칙이 아니고 예시다.
네 번째 서버 gateway(Go)는 업무 · DB 없이 앱의 요청을 경로로 나눠 넘기기만 해서 이 비교에서 빠진다(`servers/gateway/AGENTS.md`).

| 일 | notes · Node | tags · FastAPI | activity · Spring |
|---|---|---|---|
| HTTP 라우트 · 검증 | zod 스키마 + `get` · `post` 도구 | pydantic 모델 + 함수 | record DTO + Bean Validation |
| 문서 만들기 (`pnpm gen`) | `src/openapi.ts` (zod → openapi) | `server/gen.py` (`app.openapi()`) | `OpenApiExport` (springdoc, 테스트 의존성) |
| gRPC | 클라이언트 (Connect-ES) | 서버 (grpcio) | 서버 (grpc-java · Spring gRPC) |
| DB 읽기 · 쓰기 | postgres.js 태그 템플릿 | psycopg 3 연결 풀 | JdbcClient |
| 마이그레이션 | node-pg-migrate (SQL 파일) | Alembic (`op.execute`) | Flyway (SQL 파일) |
| 테스트 | `node:test` + 실제 서버 | pytest + uvicorn(스레드) | JUnit + `@SpringBootTest(RANDOM_PORT)` |
| 다른 서버 대역 | 교체 지점 `useTags(fake)` | `app.dependency_overrides` (생기면) | `@TestConfiguration` 의 `@Primary` Bean (생기면) |
| lint · format | oxlint · oxfmt | ruff | spring-javaformat · `-Werror` |

규칙(레이어 · 오류 형식 · 테스트 이름)은 셋이 같다 → [architecture.md](/reference/architecture/) · [testing.md](/reference/testing/). 세부는 각 서버의 `AGENTS.md`.

## 목록 하나를 HTTP 로

검증 규칙 · 응답 모양을 타입 하나에 쓰면 그것이 곧 openapi 문서가 된다. 셋 다 같은 생각을 자기 도구로 한다.

```ts
// notes · servers/notes/src/api/http/note.ts
const NoteList = z.object({ notes: z.array(Note) }).meta({ id: 'NoteList' })

export const routes = {
  '/api/notes': {
    get: get({ response: NoteList }, async () => ({ notes: await notes.list() })),
  },
}
```

```python
# tags · servers/tags/server/api/routes/rules.py
class RuleList(BaseModel):
    rules: list[Rule]


@router.get('/api/tags/rules')
def list_rules() -> RuleList:
    return RuleList(rules=[Rule.model_validate(r) for r in service.find_all()])
```

```java
// activity · servers/activity/core/core-api/.../api/controller/ActivityController.java
record ActivityList(@NotNull List<Activity> activities) {
}

@GetMapping
ActivityList listActivity() {
	return new ActivityList(activity.list().stream().map(Activity::of).toList());
}
```

## gRPC: 한 proto, 서버 둘 · 클라이언트 하나

`contract/proto` 의 파일 하나에서 언어마다 코드를 만든다. tags · activity 는 서버로 구현하고 notes 는 클라이언트로 부른다.

```python
# tags · servers/tags/server/api/rpc/tags.py
class TagService(tag_pb2_grpc.TagServiceServicer):
    def Suggest(self, request, context):
        return tag_pb2.SuggestResponse(tags=rules.suggest(request.text))
```

```java
// activity · servers/activity/core/core-api/.../api/grpc/ActivityGrpcService.java
@Override
public void record(RecordRequest request, StreamObserver<RecordResponse> response) {
	if (request.getAction().isBlank() || request.getNoteId() <= 0) {
		response.onError(Status.INVALID_ARGUMENT.withDescription("action 과 양수 note_id 가 필요합니다").asException());
		return;
	}
	activity.record(request.getAction(), request.getNoteId());
	response.onNext(RecordResponse.getDefaultInstance());
	response.onCompleted();
}
```

```ts
// notes · servers/notes/src/clients/tags.ts (부르는 쪽)
const client = createClient(TagService, createGrpcTransport({ baseUrl: config.tagsGrpc, defaultTimeoutMs: 3_000 }))
const grpcTags: Tags = { suggest: async (text) => (await client.suggest({ text })).tags }
```

## 마이그레이션 하나

```sql
-- notes · servers/notes/migrations/0003_note_tags.sql
-- Up Migration
ALTER TABLE notes ADD COLUMN tags TEXT[] NOT NULL DEFAULT '{}';
```

```python
# tags · servers/tags/migrations/versions/0001_rules.py
def upgrade() -> None:
    op.execute('CREATE TABLE rules (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, keyword TEXT NOT NULL, tag TEXT NOT NULL)')
```

```sql
-- activity · servers/activity/storage/db-core/src/main/resources/db/migration/V1__activity.sql
CREATE TABLE activity (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, action TEXT NOT NULL, note_id BIGINT NOT NULL, at TIMESTAMPTZ NOT NULL DEFAULT now());
```

## 테스트 하나

셋 다 실제 서버를 랜덤 포트로 띄우고 밖에서 부른다. 이름은 조건과 결과를 한글_밑줄로.

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

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

  assert.equal(res.status, 502)
  assert.deepEqual(await list(), [])
})
```

```python
# tags · servers/tags/tests/grpc/TagService/Suggest_specs.py — 실제 gRPC 연결
def test_대소문자를_가리지_않고_같은_태그는_한_번만_반환한다(channel):
    rules.create('할 일', '할일')
    rules.create('todo', '할일')

    res = suggest(channel, 'TODO: 오늘 할 일')

    assert res.tags == ['할일']
```

```java
// activity · servers/activity/core/core-api/src/test/java/app/grpc/activityservice/Record_specs.java
@Test
void 올바른_기록을_보내면_DB에_저장된다() {
	var res = client.record(RecordRequest.newBuilder().setAction("note.created").setNoteId(7).build());

	assertThat(res).isEqualTo(RecordResponse.getDefaultInstance());
	assertThat(activity.findRecent(100)).extracting(ActivityEntity::action, ActivityEntity::noteId)
		.containsExactly(tuple("note.created", 7L));
}
```