규칙
같은 일, 세 언어
같은 일, 세 언어
예시 서버 셋은 맡은 일이 다르다(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 · testing.md. 세부는 각 서버의 AGENTS.md.
목록 하나를 HTTP 로
검증 규칙 · 응답 모양을 타입 하나에 쓰면 그것이 곧 openapi 문서가 된다. 셋 다 같은 생각을 자기 도구로 한다.
// 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() })),
},
}
# 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()])
// 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 는 클라이언트로 부른다.
# 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))
// 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();
}
// 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 }
마이그레이션 하나
-- notes · servers/notes/migrations/0003_note_tags.sql
-- Up Migration
ALTER TABLE notes ADD COLUMN tags TEXT[] NOT NULL DEFAULT '{}';
# 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)')
-- 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());
테스트 하나
셋 다 실제 서버를 랜덤 포트로 띄우고 밖에서 부른다. 이름은 조건과 결과를 한글_밑줄로.
// 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(), [])
})
# 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 == ['할일']
// 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));
}