서버

Notes 서버

메모 CRUD, 앱이 부르는 진입점. 메모를 쓰기 전에 tags 서버에서 태그를 받고, 쓴 뒤 activity 서버에 기록한다(둘 다 gRPC 클라이언트). Node 24 가 .ts 를 빌드 없이 직접 실행한다. 런타임 의존성은 zod · Connect-ES(gRPC 클라이언트) · postgres(postgres.js) · node-pg-migrate(+ pg, 마이그레이션만). HTTP :8000 (web · lite · site 프록시 대상), gRPC 서버는 없다. DB 는 Postgres(docker/docker-compose.middleware.yaml, 먼저 루트에서 pnpm infra). 레포 전체 규칙은 루트 AGENTS.md, 이 파일이 이 서버에서는 우선한다.

구조

폴더 = 레이어 (docs/architecture.md 서버 레이어). 의존은 api → domain → storage · clients 방향만.

src/폴더 구조
  • 진입점: 마이그레이션 → HTTP listen
  • 환경 변수 (HOST · PORT · TAGS_GRPC · ACTIVITY_GRPC)
  • HTTP 서버: 경로 변수 · JSON 본문 검증 · 오류 변환 + 라우트 표(mergeRoutes)
  • 라우트 (zod 스키마 = 검증 = 문서)
  • 업무 로직 (다른 서버를 언제 부르고 실패를 어떻게 다루는지도 여기)
  • Postgres 연결 (PG* 환경 변수, 기본 docker 의 notes DB, 처음 쿼리할 때 연결)
  • migrations/ 적용 (node-pg-migrate, 기록은 pgmigrations 표)
  • 저장소 (SQL)
  • 웹 · 모바일 · 데스크톱 앱과 공통 API 클라이언트를 둡니다.
  • route.ts (get · post · put · del · mergeRoutes · match) · errors.ts (BadRequest → 400 · NotFound → 404)
  • pnpm gen: 라우트 표 → contract/openapi/notes.json
  • 공통 설정: 테스트 DB(notes_test) 마이그레이션, Fake 등록 · 비우기, 랜덤 포트 HTTP 서버, request · sendJson
  • 다른 서버 대역 (FakeTagService · FakeActivityService)
  • notes/GET_specs · notes/id/PUT_specs (경로 변수 = 하위 폴더)
  • 순수 함수 단위 테스트 (support/route.test.ts)

명령

pnpm dev          # node --watch src/main.ts
pnpm test         # node --test (specs + *.test.ts). DB 는 먼저 루트에서 pnpm infra
pnpm gen          # contract/openapi/notes.json (DB 없이)
pnpm migrate create <이름>   # migrations/NNNN_<이름>.sql. 적용은 서버 · 테스트가 시작할 때
pnpm typecheck    # tsc -p . (TS 7)
pnpm lint         # oxlint && oxfmt --check (이 폴더의 .oxlintrc.json · .oxfmtrc.json)
pnpm format       # oxfmt
docker build --build-context workspace=../.. .   # 이미지 (이 폴더에서)

규칙

  • 쓰기 본문은 JSON 만 (body 가 있는 라우트는 api/http/server.ts 의 json() 이 content-type 을 본다). 모르는 필드는 zod 가 버린다.
  • 오류는 { "error" }: BadRequest · 검증 실패 400, NotFound 404, 그 외(태그 서버 실패 등) 502, 없는 경로 404, 다른 메서드 405.
  • 바인딩은 HOST (기본 127.0.0.1). 컨테이너 등 밖에 열 때 0.0.0.0, 그 전에 인증 · TLS.
  • DB 연결은 PGHOST · PGPORT · PGDATABASE(기본 notes) · PGUSER · PGPASSWORD (storage/db.ts). 쿼리는 postgres.js 의 태그 템플릿(값이 자동으로 바인딩된다). BIGINT 는 문자열로 오니 저장소에서 숫자로 바꾼다.
  • 표는 migrations/ 의 SQL 로만 바꾼다. 서버 · 테스트가 listen 전에 migrate() 로 적용한다. 적용된 파일은 고치지 않고 새 파일을 더한다.

계약

  • 라우트는 get · post · put · del({ params, body, response }, 핸들러) (support/route.ts). 상태 코드는 200 · 201 · 200 · 204. 같은 zod 스키마가 입력 검증 · 핸들러 타입 · 문서를 만든다. 핸들러는 검증된 { params, body } 를 받는다.
  • 경로 변수는 '/api/notes/{id}' 처럼 쓰고 params: z.object({ id: z.coerce.number().int().positive() }) 로 검증한다 (실패 400). 문서에는 path parameter 로 풀어 쓰니 params 에는 이름을 붙이지 않는다.
  • body · response 스키마는 .meta({ id }) 로 이름을 붙인다. 이름 없는 스키마는 문서 생성이 실패시킨다.
  • 같은 경로(변수 이름만 다른 것도)는 mergeRoutes 가, 같은 스키마 id 는 문서 생성(zod)이 실패시킨다.
  • 검증 실패 메시지는 한국어(z.config(z.locales.ko())).

다른 서버 부르기

  • clients/tags.ts(TagService.Suggest) · clients/activity.ts(ActivityService.Record) 가 gRPC 로 부른다. 주소는 TAGS_GRPC (기본 http://127.0.0.1:9002) · ACTIVITY_GRPC(기본 http://127.0.0.1:9001). transport 는 처음 부를 때 연결하고 3초면 끊는다.
  • 루트 pnpm gen 이 contract/gen 에 TS 코드를 만든다(buf + protoc-gen-es). @app/contract/gen/app/<도메인>/v1/<도메인>_pb.js 로 import. int64 는 bigint.
  • 각 모듈은 작은 인터페이스(Tags · Activity)와 교체 지점만 밖에 보인다. domain 은 그 인터페이스만 부른다.
  • 실패 정책은 domain/note.ts: tags 를 못 부르면 저장하지 않고 502 {"error":"태그 서버에 연결할 수 없습니다"}. activity 는 저장 뒤라 실패해도 응답은 그대로(201 · 200 · 204), console.warn 한 줄만. GET 은 다른 서버를 부르지 않는다.
  • 테스트는 tests/support/fakes.ts 의 Fake 를 교체 지점(useTags · useActivity)에 넣는다. 정하는 것은 돌려줄 태그(fakeTags.tags) · 다음 한 번 실패(failNext()), 보는 것은 받은 인자(fakeTags.texts · fakeActivity.records).

테스트

  • tests/support/api.ts 를 import 하면 그 파일(= 프로세스 하나)에서 테스트 DB notes_test 에 마이그레이션을 적용하고, Fake 를 넣고, 랜덤 포트 서버가 뜨고 끝나면 닫힌다.

  • 묶음 이름 describe('GET /api/notes/{id}'), 테스트 이름 test('없는_id면_404_Not_Found를_반환한다').

  • 준비 · 저장 상태 확인은 저장소 함수(create · find · list)로 한다.

  • 외부 클라이언트는 clients/ 에 교체 지점을 두고 Fake 를 넣는다. mock.module 은 쓰지 않는다(hook 이 막는다).

    // src/clients/tags.ts
    export let tags: Tags = grpcTags
    /** 테스트가 Fake 로 바꿀 때만 쓴다 */
    export const useTags = (t: Tags) => (tags = t)
  • node --test 는 파일마다 프로세스가 따로라 서버 · Fake 는 파일끼리 섞이지 않지만 DB 는 같이 쓴다. 그래서 파일을 하나씩 돌리고 (--test-concurrency=1) DB 는 테스트 앞뒤로 pgmigrations 를 뺀 모든 표를 TRUNCATE 하고 Fake 를 reset() 한다. { concurrency: true } 는 쓰지 않는다.

  • TRUNCATE 는 IDENTITY 를 되돌리지 않는다. id 를 1 로 가정하지 말고 저장소가 돌려준 id 를 쓴다.

GitHub 에서 고치기