Source: https://stack.initializer.org/introduction/ # iStack 소개 **iStack**(Initializer Stack)은 여러 서버와 클라이언트를 함께 개발하는 모노레포 템플릿입니다. 서버와 클라이언트마다 언어와 프레임워크를 선택할 수 있습니다. API 문서 · 타입 · 공통 proto 는 `contract/` 에서 관리합니다. - 클라이언트 ↔ 서버: HTTP/JSON. 각 서버의 코드에서 OpenAPI 문서와 타입을 생성하고, 클라이언트는 생성된 타입으로 API 를 호출합니다. - 서버 ↔ 서버: gRPC. 모든 서버가 공통 proto 하나에서 코드를 만듭니다. HTTP 요청 · JSON · 기본적인 DB 사용과 한 가지 서버 언어를 익힌 개발자를 기준으로 설명합니다. 세 언어를 모두 알아야 시작할 수 있는 것은 아닙니다. 처음에는 notes 서버와 web 앱으로 흐름을 익히고, 같은 일을 다른 언어로 쓴 [같은 일, 세 언어](/reference/languages/)와 비교합니다. ## 학습 순서 | 순서 | 해 볼 일 | 확인할 결과 | |---|---|---| | [처음 실행하기](/getting-started/first-run/) | 서버 셋과 web 을 실행합니다 | 메모에 태그가 붙고 새로 고침 뒤에도 남으며 테스트 · 타입 검사가 통과합니다 | | [엔드포인트 추가](/guides/add-endpoint/) | 메모 길이 API 를 더하고 앱에서 부릅니다 | 실제 요청 테스트가 통과하고 생성된 계약에 새 경로가 나옵니다 | | [계약을 깨뜨려 보기](/guides/contract-lab/) | HTTP 필드와 proto 번호를 바꿨다가 되돌립니다 | 타입 검사와 호환 검사가 각각 실패하고 복구 뒤 통과합니다 | | [외부 서비스 실패 테스트](/guides/external-service-lab/) | 요약 API 에 Fake 를 넣습니다 | 실제 서버가 성공 · 외부 실패 · 없는 메모를 구분합니다 | | [다른 서버 부르기](/guides/call-server/) | notes 가 tags · activity 를 부르는 흐름을 따라갑니다 | 다른 서버가 실패할 때의 응답(502 · 경고만)을 테스트로 확인합니다 | ## 한눈에 보기 자세한 구조와 서버 레이어는 [레퍼런스 · 구조](/reference/architecture/)에 있습니다. ## 공통 규칙 | 무엇 | 어떻게 | 근거 | |---|---|---| | HTTP 계약 | 서버 코드가 기준, 문서는 서버마다 따로 | [ADR 0001](/reference/decisions/0001-code-first-http-contract/) | | 서버 간 통신 | gRPC, 공통 proto 하나 | [ADR 0002](/reference/decisions/0002-grpc-shared-proto/) | | 테스트 | 실제 서버 + Postgres, 외부만 Fake | [ADR 0003](/reference/decisions/0003-api-tests-with-fakes/) | | 서버 구조 | api → domain → storage · clients | [구조](/reference/architecture/) | | 도구 | 서버는 자기 언어 도구, 클라이언트는 공통 설정 · 카탈로그 | [클라이언트](/reference/clients/) | 규칙마다 왜 그렇게 정했는지, 무엇을 내주고 무엇을 얻는지는 [구조는 왜 이렇게 됐나](/concepts/structure/)와 [테스트는 왜 이렇게 됐나](/concepts/testing/)에 있습니다. ## 포함하지 않는 기능 인증, 배포 대상, 운영 모니터링은 없습니다. 예시 서버는 이 컴퓨터(127.0.0.1)에서만 열리고, 이미지는 만들지만 어디에 올릴지는 정하지 않았습니다. 밖에 열기 전에 인증과 TLS 를 붙입니다. 무엇을 정하고 어디에 붙일지는 [인증 붙이기](/guides/auth/)에 있습니다. ## 예시 서버 활용 예시 서버 셋은 맡은 일이 다릅니다. notes(Node)는 메모, tags(FastAPI)는 태그 규칙, activity(Spring)는 활동 기록을 맡고, 메모를 저장하면 notes 가 tags 에 태그를 묻고(gRPC) activity 에 기록을 남깁니다(gRPC). 앱은 gateway(Go) 하나로 서버를 부르고, 앱도 쓰는 사람과 자리로 나눴습니다: web(React) · admin(Vue) · site(Next) · lite(순수 TS) · mobile(Expo) · desktop(Tauri). 테이블은 서버마다 그 언어의 마이그레이션 도구(node-pg-migrate · Alembic · Flyway)로 만듭니다. 프로젝트에서는 필요한 서버와 앱을 남기고, 맡을 일에 맞게 [이름을 바꾼](/guides/add-server/#이름을-바꿀-때) 뒤 API 를 추가하거나 수정합니다. --- Source: https://stack.initializer.org/getting-started/ # 템플릿으로 저장소 만들기 ## 1. 저장소 만들기 GitHub 의 템플릿 저장소에서 **Use this template** 로 새 저장소를 만듭니다(커밋 기록 없이 시작합니다). clone 해서 쓴다면 `.git` 을 지우고 `git init` 으로 새로 시작합니다. ## 2. 이름 바꾸기 ```bash pnpm rename "My Shop" # slug 는 이름에서 만듭니다 (my-shop) pnpm rename "가계부" gagyebu # 이름이 영문이 아니면 slug 를 줍니다 ``` 템플릿 이름 `iStack` 과 slug `initializer-stack` 이 쓰인 곳(README · AGENTS · DESIGN 제목, 앱 title · 창 이름 · 앱 이름, 루트 `package.json`, compose 프로젝트, Expo slug, Tauri identifier)을 한 번에 바꾸고, 스크립트는 끝나면 스스로 지워집니다. 저장소 안에서만 쓰는 이름(`@app/*`, Java 패키지 `app`, DB 계정)은 그대로 둡니다. 밖에 보이지 않아 프로젝트 이름과 달라도 됩니다. 예외와 이유는 [레퍼런스 · 개발 → 이름 바꾸기](/reference/development/#이름-바꾸기)에 있습니다. ## 3. 쓰지 않는 예시 빼기 ```bash pnpm drop site lite mobile # 남길 것만 두고 뺍니다 pnpm install # lockfile 정리 ``` 폴더와 함께 그것을 가리키는 설정 줄(gen · contract · DB · compose · smoke · 포트)을 지웁니다. 남는 앱 · 서버가 부르는 서버는 빼지 않습니다. tags · activity 는 notes 와 admin 이, 서버 셋은 gateway 가 부르므로 빼려면 그 호출을 먼저 고칩니다. gateway 는 앱이 남아 있으면 빼지 않습니다. 문서의 설명은 고치지 않고 남은 언급이 있는 파일을 알려 줍니다. → [레퍼런스 · 개발 → 예시 빼기](/reference/development/#예시-빼기) ## 4. 맡을 일로 이름 바꾸기 폴더 이름은 맡은 일입니다(`notes` · `tags` · `activity`, `web` · `admin` · `site`). 예시를 본떠 다른 일을 맡기면 그 일의 이름으로 바꿉니다. 구현 언어 · 프레임워크는 이름에 넣지 않습니다. 이름이 들어간 곳을 찾는 명령과 순서는 [서버 추가 → 이름을 바꿀 때](/guides/add-server/#이름을-바꿀-때)에 있습니다. ## 5. README · LICENSE - README 는 템플릿 소개입니다. 프로젝트에 맞게 고쳐 씁니다. - 템플릿은 Apache License 2.0 입니다. 프로젝트의 라이선스는 직접 정하고, 템플릿 코드를 담아 배포할 때의 고지 조건은 Apache 2.0 을 따릅니다. ## 다음 - [처음 실행하기](/getting-started/first-run/) --- Source: https://stack.initializer.org/getting-started/first-run/ # 처음 실행하기 서버 셋(notes · tags · activity)과 gateway, web 앱으로 시작합니다. 메모를 저장할 때 notes 가 tags 에 태그를 묻고 activity 에 기록을 남기므로 서버 셋이 함께 뜨고, 앱은 gateway 하나로 서버를 부릅니다. 필요한 도구는 Node · pnpm · Docker · Java 25(activity) · uv(tags) · Go(gateway)이고, desktop 은 Rust 가 더 필요합니다. 버전은 [레퍼런스 · 개발 → 명령](/reference/development/#명령)에 있습니다. ## 최소 구성으로 시작하기 템플릿으로 만든 새 학습용 저장소에서 실행합니다. `drop` 은 지정한 예시 폴더와 연결 설정을 지우므로, 이미 그 안에 작업한 코드가 있다면 먼저 변경을 보관하거나 아래의 ‘전체 예시를 유지할 때’를 따릅니다. ```bash node scripts/drop.mjs admin site lite mobile desktop pnpm install pnpm dev # Postgres(docker) → 서버 셋 + gateway + web 앱, http://localhost:5280 ``` 처음에는 삭제 스크립트를 `node` 로 직접 실행하므로 다른 예시의 의존성을 설치하기 전에 줄일 수 있습니다. 서버는 빼지 않습니다. tags · activity 는 notes 가, 서버 셋은 gateway 가 부르므로 `drop` 이 멈춥니다. 화면에서 `내일 회의` 라는 메모를 만들면 `회의` 태그가 붙습니다. notes 가 tags 서버에 물어 받은 태그입니다. 새로 고침한 뒤에도 남는지, 삭제하면 목록에서 없어지는지 확인합니다. `pnpm dev` 를 둔 채 다른 터미널에서 다음을 실행합니다. ```bash pnpm test # 서버 셋의 HTTP · gRPC 테스트 pnpm typecheck # 계약 생성 + 남은 패키지의 타입 검사 pnpm lint ``` 테스트 이름과 성공 결과가 나오고, 타입 검사 · lint 가 종료 코드 0 으로 끝나면 첫 실행이 끝난 것입니다. 테스트는 서버마다 `<서버>_test` DB 와 랜덤 포트를 써서 개발 화면의 메모를 지우지 않습니다. notes 의 테스트는 tags · activity 를 띄우지 않고 Fake 로 바꿉니다. `pnpm dev` · `pnpm test` 는 먼저 `pnpm infra`(docker 의 Postgres)를 띄웁니다. 개발 서버는 터미널에서 Ctrl+C, Postgres 는 `pnpm infra:down` 으로 끕니다. DB 볼륨은 남아 있으므로 다음 실행에도 메모가 유지됩니다. ## 전체 예시를 유지할 때 `drop` 을 실행하지 않고 `pnpm install` 을 실행합니다. 기본 `pnpm dev` 는 그대로 서버 셋 + gateway + web 을 띄웁니다. 관리 화면은 `pnpm --filter @app/admin dev` → http://localhost:5281 에서 태그 규칙을 바꾸고 활동 기록을 봅니다. 서버 하나만 확인하려면 `pnpm infra` 다음 `pnpm --filter @app/notes test` 처럼 실행합니다. 다른 서버 · 앱도 `pnpm --filter @app/<패키지> <스크립트>` 로 고릅니다(예: `@app/activity dev`). ## 이미지로 실행하기 첫 테스트가 통과한 뒤 `pnpm stack` 으로 남은 서버 · 웹 앱의 이미지를 빌드하고 smoke 를 실행합니다. smoke 는 메모 하나로 notes → tags · activity 가 실제 이미지끼리 이어지는지 봅니다. 개발 서버와 기본 포트가 같으므로 자신이 띄운 `pnpm dev` 를 먼저 종료하거나, [포트 설정](/reference/development/#인프라-docker)으로 구분합니다. 끄려면 `pnpm stack:down` 입니다. 포트와 환경 변수는 [레퍼런스 · 개발 → 포트](/reference/development/#포트)에 있습니다. ## 다음 - API 를 바꾸면 `pnpm gen` 으로 계약을 다시 만들어 코드와 함께 커밋합니다. [엔드포인트 추가](/guides/add-endpoint/)가 그 흐름을 처음부터 끝까지 따라갑니다. - 생성된 타입과 proto 호환 검사를 직접 실패시켜 보려면 [계약을 깨뜨려 보기](/guides/contract-lab/), 외부 실패를 재현하려면 [외부 서비스 실패 테스트](/guides/external-service-lab/)로 이어갑니다. - 서버끼리 부르는 흐름은 [다른 서버 부르기](/guides/call-server/), 같은 일을 언어마다 어떻게 쓰는지는 [같은 일, 세 언어](/reference/languages/). - 막히면 [자주 겪는 문제](/guides/troubleshooting/). --- Source: https://stack.initializer.org/getting-started/agents/ # AI 와 개발하기 에이전트에게 "메모 길이를 돌려주는 API 를 더해 줘"라고 요청하면 동작하는 코드는 금방 나옵니다. 하지만 이 저장소의 규칙을 모르는 에이전트는 `contract/openapi/` 를 손으로 고치거나, 테스트에서 저장소를 mock 으로 바꾸거나, 사용자가 띄운 8000 포트 서버에 확인 요청을 보냅니다. 코드는 동작해도 CI 와 리뷰에서 다시 고쳐야 합니다. 그래서 템플릿은 규칙을 세 곳에 둡니다. 에이전트가 읽는 **지시 파일**, 정해진 절차를 묶은 **skill**, 어기면 편집을 막는 **hook** 입니다. ## 지시 파일 - 지시는 `AGENTS.md` 에 씁니다. 같은 폴더의 `CLAUDE.md` 는 그 파일로 가는 심볼릭 링크라 Claude Code 와 다른 도구가 같은 지시를 읽습니다. 새 `AGENTS.md` 를 만들면 `ln -s AGENTS.md CLAUDE.md`. - 루트 `AGENTS.md` 에는 저장소 공통 규칙을, 서버 · 앱의 파일(`servers/<서버>`, `clients/apps/<앱>`)에는 그 영역의 구조 · 명령 · 도구 · 테스트 방법을 씁니다. 지시가 서로 다르면 작업 폴더에서 가장 가까운 `AGENTS.md` 를 따릅니다. - 채택된 ADR 과 서버 · 앱의 규칙이 다르면 ADR 을 따릅니다. 설계를 바꾸려면 관련 ADR 을 먼저 고칩니다. → [설계 결정](/reference/decisions/) - 설명과 세부는 `docs/`(이 사이트의 가이드 · 레퍼런스)에 두고 `AGENTS.md` 에 되풀이하지 않습니다. - 개인 지시는 `CLAUDE.local.md`(gitignore) 나 `~/.claude/CLAUDE.md` 에 둡니다. ## 도구별 설정 `AGENTS.md` 하나를 여러 도구가 읽습니다. 도구마다 다른 점은 아래 표와 메모가 전부입니다. 각 도구의 공식 문서로 2026-10-17 에 확인했고, — 는 문서에서 확인하지 못한 항목입니다. | 도구 | 지시 파일 | 하위 폴더의 지시 | skill 부르기 | hook | |---|---|---|---|---| | [Claude Code](https://code.claude.com/docs/en/memory) | `CLAUDE.md` | 그 폴더의 파일을 열 때 읽음 | `/이름` | 적용 | | [Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md) | `AGENTS.md` | 루트부터 작업 폴더까지 이어 붙임 | `$이름` | — | | [Cursor](https://cursor.com/docs/context/rules) | `AGENTS.md` | 상위와 합치고 하위가 우선 | — | — | | [VS Code (Copilot)](https://code.visualstudio.com/docs/copilot/customization/custom-instructions) | `AGENTS.md` | 설정을 켜야 읽음 (실험 기능) | `/이름` | — | | [Gemini CLI](https://geminicli.com/docs/cli/gemini-md/) | `AGENTS.md` (설정 필요) | 다루는 파일의 폴더와 상위를 읽음 | — | — | skill 은 모두 `.agents/skills` 하나를 읽습니다. Codex 는 그 폴더를, Claude Code 는 그 폴더로 가는 링크인 `.claude/skills` 를, VS Code 는 둘 다 읽습니다. - **Claude Code**: `CLAUDE.md` 는 `AGENTS.md` 로 가는 링크입니다. v2.1.277 부터는 `CLAUDE.md` 가 없으면 `AGENTS.md` 를 직접 읽지만, 링크가 있어 그 전 버전도 같은 지시를 읽습니다. - **VS Code**: 하위 폴더의 `AGENTS.md` 는 `chat.useNestedAgentsMdFiles` 설정을 켜야 읽습니다. - **Gemini CLI**: 기본으로 `GEMINI.md` 를 읽습니다. `.gemini/settings.json` 에 다음을 두면 `AGENTS.md` 를 읽습니다. ```json { "context": { "fileName": ["AGENTS.md", "GEMINI.md"] } } ``` - **hook** 은 Claude Code 에서만 동작합니다. 다른 도구는 같은 규칙을 루트 `AGENTS.md` 의 문장으로 지킵니다. ## Skills `.agents/skills/` 에 있고 `.claude/skills` 는 그 폴더로 가는 링크입니다. | skill | 하는 일 | |---|---| | `repo-review` | diff 를 이 저장소 기준(계약 · ADR · 레이어 · 테스트 · 재사용 · 군더더기)으로 보고 P0 ~ P3 로 적습니다 | | `verify-local` | 바꾼 서버 · 앱을 개발 서버와 겹치지 않는 포트(+100)와 테스트 DB 로 띄워 실제 응답 · 화면 · 콘솔로 확인합니다 | | `write-specs` | 엔드포인트 · RPC 를 더하거나 바꿀 때 `{METHOD}_specs` 를 쓰고 가장 좁은 테스트부터 돌립니다 | ## Hook `.claude/hooks/check-patterns.py` 는 편집 전에 다음 변경을 막습니다. - 생성물 수정 (`contract/openapi`, `contract/gen`, `servers/tags/gen`) - `main` 에 들어간 Flyway 마이그레이션 수정 - API 문자열을 HTML 로 넣기 (`innerHTML` · `v-html` · `dangerouslySetInnerHTML`) - 외부 클라이언트를 mock 으로 바꾸기 hook 의 규칙을 바꾸면 `.claude/hooks/check-patterns.test.sh` 로 확인합니다. ## 일을 맡기는 요청 "API 를 더해 줘" 한 줄에는 범위와 완료 기준이 없습니다. 에이전트는 범위를 스스로 정하고, 테스트를 돌리지 않고도 완료라고 보고할 수 있습니다. 요청에는 다음 네 가지를 적습니다. | 항목 | 적을 것 | |---|---| | 목적 | 왜 필요한지. 범위 밖의 판단이 필요할 때 기준이 됩니다 | | 변경 범위 | 어느 서버 · 앱의 어느 경로까지 바꾸는지, 무엇은 바꾸지 않는지 | | 완료 기준 | 통과해야 할 `{METHOD}_specs`, 같은 커밋에 들어갈 `pnpm gen` 결과, 확인할 화면 | | 확인 방법 | 에이전트가 실행할 명령과 사람이 볼 것. 통과 · 실패 · 실행하지 않음을 나눠 보고하게 합니다 | ```text 목적: 메모 화면에 글자 수를 보여 주려고 한다. 변경 범위: notes 서버에 GET /api/notes/{id}/length 를 더하고 web 앱에서 부른다. tags · activity 와 다른 앱은 바꾸지 않는다. 완료 기준: tests/notes/id/length/GET_specs.ts 가 통과하고, 생성된 계약이 같은 커밋에 있다. 확인: pnpm --filter @app/notes test 와 pnpm typecheck 를 실행하고, 화면은 verify-local 로 띄워 확인할 주소를 알려 준다. 실행하지 못한 확인은 이유와 함께 따로 적는다. ``` 작업 순서는 [엔드포인트 추가](/guides/add-endpoint/)와 같습니다. 끝나면 `repo-review` 로 diff 를 저장소 기준에 맞춰 봅니다. 여러 서버에 걸치고 사람이 정할 것이 있는 작업은, [인증 붙이기](/guides/auth/)의 요청 예시처럼 결정을 ADR 로 제안하고 멈추게 합니다. ## 개발 서버와 분리된 검증 에이전트는 사람이 띄운 개발 서버를 끄거나 거기에 확인 요청을 보내지 않습니다. 확인용 서버는 개발 서버 포트에 100 을 더한 포트와 테스트 DB 로 띄웁니다. 확인이 끝나면 그 포트의 PID 를 확인해 확인용 프로세스와 직접 띄운 컨테이너만 끕니다. ## 이 문서를 AI 에 주기 템플릿으로 만든 저장소 안의 에이전트는 `AGENTS.md` 와 `docs/` 를 직접 읽으므로 따로 넣을 것이 없습니다. 이 사이트는 저장소 밖에서 AI 에 질문할 때 씁니다. - 페이지마다 제목 옆의 **페이지 복사**로 마크다운 원문을 복사하거나, 옆 메뉴에서 ChatGPT · Claude 로 엽니다. 주소 끝에 `.md` 를 붙여도 원문이 열립니다. - [`/llms.txt`](/llms.txt): 모든 페이지의 목록과 원문 주소입니다([llms.txt](https://llmstxt.org) 형식). - [`/llms-full.txt`](/llms-full.txt): 모든 페이지의 원문을 한 파일로 이었습니다. --- Source: https://stack.initializer.org/concepts/structure/ # 구조는 왜 이렇게 됐나 *언어가 다른 서버와 클라이언트를 함께 바꾸면서도 서로 묶이지 않게 하려면* notes 서버에 메모 하나의 길이를 돌려주는 `GET /api/notes/{id}/length` 를 더한다고 하자. 업무 로직 한 줄, 라우트 한 줄, 테스트 파일 하나를 쓰고 `pnpm gen` 을 돌리면 `contract/openapi/notes.json` 과 `notes.ts` 가 바뀐다. 클라이언트는 그 타입으로 새 경로를 부른다. 기능 하나에 손대는 곳이 네 군데다. 번거로워 보이는 이 순서는 두 요구를 함께 만족시키려고 생겼다. 하나는 **함께 바꾸는 것**이다. API 를 바꾸는 PR 하나에 서버 · 계약 · 클라이언트 수정이 같이 들어가고 CI 가 한 번에 확인한다. 다른 하나는 **서로 묶이지 않는 것**이다. 서버 하나를 다른 언어로 다시 써도 다른 서버와 클라이언트는 영향을 받지 않는다. 이 글은 각 구조가 두 요구를 어떻게 나눠 맡는지 설명한다. 지켜야 할 규칙은 [architecture.md](/reference/architecture/) · [contracts.md](/reference/contracts/), 결정의 기록은 [decisions/](/reference/decisions/) 에 있다. ## 잇는 것은 `contract/` 하나다 저장소를 서버 · 클라이언트마다 나누면 API 를 바꿀 때마다 계약 패키지를 배포하고, 각 저장소에서 버전을 올리고, 배포 순서를 맞춰야 한다. 한 저장소에서는 이 과정이 커밋 하나로 끝난다. 하지만 한 저장소 안에서 서로의 코드를 직접 부르기 시작하면 저장소를 나누지 않은 이점이 그대로 비용이 된다. 그래서 서버끼리는 코드를 import 하지 않는다. 공유 라이브러리는 언어마다 다시 써야 하고, 한 서버의 내부 변경이 다른 서버를 깨뜨린다. 서로를 잇는 것은 언어와 상관없이 읽을 수 있는 파일뿐이다. HTTP 는 OpenAPI JSON, gRPC 는 proto 다. 어떤 언어든 이 파일에서 코드를 만들 수 있으므로 **계약만 지키면 서버 안을 무엇으로 쓰든 다른 쪽은 알 필요가 없다.** ## 서버 이름은 맡은 일이다 예시 서버는 `notes` · `tags` · `activity` 다. 폴더 이름만 보고 무엇을 맡는지 안다. `node` · `spring` 처럼 언어로 이름을 지으면 폴더가 말하는 것은 구현 방법뿐이고, 같은 언어의 서버가 둘이 되는 순간 이름이 막힌다. 맡은 일로 나누면 서버끼리 부를 이유도 생긴다. 메모를 저장하는 notes 는 태그 규칙을 모르니 tags 에 묻고, 기록은 activity 에 맡긴다. 언어는 그 서버의 선택일 뿐이다. 같은 일을 언어마다 어떻게 쓰는지는 [같은 일, 세 언어](/reference/languages/) 에 나란히 있다. 앱이 들어오는 문은 하나다. 앱은 gateway(Go) 주소만 알고, gateway 가 `/api/notes` · `/api/tags` · `/api/activity` 를 맡은 서버로 넘긴다. 앱이 서버마다 주소를 들고 있으면 서버가 하나 늘 때마다 앱 여섯 곳을 고쳐야 하고, 인증을 붙일 자리도 흩어진다. gateway 에는 업무가 없다. 서버끼리는 gateway 를 거치지 않고 gRPC 로 직접 부른다. ## 계약의 방향은 그 계약을 읽는 쪽이 정한다 이 템플릿은 HTTP 계약을 서버 코드에서 만들고, gRPC 계약은 사람이 쓴 proto 에서 코드를 만든다. 각 서버가 자기 HTTP API 를 소유하고, 서버 간 메시지는 공통 proto 로 합의하기로 한 선택이다. HTTP 도 언어 중립 문서를 먼저 쓰는 contract-first 로 설계할 수 있다. 여러 언어를 쓴다는 사실만으로 방향이 정해지는 것은 아니다. ### HTTP: 서버 코드가 원본이다 (ADR-0001) 처음에는 손으로 쓴 `openapi.yaml` 하나에서 타입을 만들었다. Node 는 생성된 타입으로 컴파일 때 맞춰졌지만 Spring 은 사람이 문서를 보고 맞춰야 했고, 그 사이에 문서와 서버가 어긋날 수 있었다. 그래서 순서를 뒤집었다. 서버 코드가 원본이고 문서는 생성물이다. ``` 서버 코드 ──(pnpm gen)──▶ contract/openapi/<서버>.json ──▶ <서버>.ts ──▶ @app/client 의 createClient<서버.paths>() ``` 문서를 따로 손으로 고치는 중복을 줄이고, 생성된 타입과 클라이언트 코드가 어긋나면 컴파일 단계에서 드러난다. 실제 응답 값이나 이미 배포된 옛 클라이언트까지 이 검사로 보장하지는 않는다([계약의 보장 범위](/reference/contracts/#보장하는-범위)). 서버의 응답 필드 이름을 바꾸면 web · mobile · desktop 의 typecheck 가 모두 실패하는 것을 확인했다(ADR-0001). 화면이 비고 나서야 알게 되는 것보다 이르다. 생성물을 커밋하는 이유는 리뷰다. 리뷰어는 `contract/openapi/` 의 diff 로 이 PR 이 API 를 어떻게 바꾸는지 코드를 읽기 전에 본다. CI 는 생성물을 다시 만들어 커밋과 같은지 확인한다. 문서를 서버마다 따로 두고 하나로 합치지 않는 것도 같은 이유다. 서버마다 스키마 이름을 따로 짓는다. 문서를 합치려면 서버끼리 이름을 조율해야 하고, 그때부터 서버가 서로에게 묶인다. 따로 두면 TS 에서는 `notes.paths` · `tags.paths` 로 나뉜다. ### gRPC: proto 가 원본이다 (ADR-0002) 서버끼리 부를 때는 양쪽이 모두 서버다. 이 템플릿에서는 어느 한쪽의 구현을 기준으로 삼는 대신, 서로 합의한 proto 를 원본으로 두고 각 서버가 거기서 코드를 만든다. proto 는 복사하지 않는다. 필드 번호를 바꾸거나 다시 쓰지 않는 이유는 배포가 한 번에 일어나지 않기 때문이다. 옛 서버와 새 서버가 함께 도는 동안 메시지의 필드는 번호로만 구분된다. 어제 3번이 `email` 이고 오늘 3번이 `phone` 이면, 옛 서버는 전화번호를 메일 주소로 읽는다. `buf breaking` 이 PR 마다 이런 변경을 막는다. ## 서버 안의 층은 바뀌는 이유로 나눈다 모든 서버가 같은 네 층으로 나뉘고, 바깥에서 안으로만 부른다. ``` api (HTTP 라우트 · gRPC 서비스 · 보안) → domain (업무 로직, HTTP · gRPC 공용) → storage (DB) · clients (외부 시스템 하나당 하나) support: 오류 형식 · 라우트 도구처럼 레이어와 무관한 것 ``` 층마다 바뀌는 이유가 다르다. 프로토콜이 바뀌면 api 를, 업무 규칙이 바뀌면 domain 을, DB 를 바꾸면 storage 를(ADR-0004), 외부 서비스가 바뀌면 그 client 하나를 고친다. 진입점이 늘어도 업무 로직은 한 곳에 있다. tags 서버의 HTTP 라우트(admin 이 규칙을 관리)와 gRPC 서비스(notes 가 태그를 묻는다)는 같은 domain 을 부른다. ```python # server/api/routes/rules.py — GET /api/tags/rules def list_rules() -> RuleList: return RuleList(rules=[Rule.model_validate(r) for r in service.find_all()]) # server/api/rpc/tags.py — TagService.Suggest def Suggest(self, request, context): return tag_pb2.SuggestResponse(tags=rules.suggest(request.text)) # 둘 다 server/domain/rules.py 를 부른다 ``` domain 은 HTTP 를 모르기 때문에 gRPC 에서도, 나중에 생길 배치 작업에서도 그대로 쓸 수 있다. notes 의 `domain/note.ts` 에서 `list` 는 storage 를 그대로 부르는 한 줄이다. 그래도 이 층을 거치게 하는 이유는 나머지 함수를 보면 드러난다. `get` 은 "없는 메모면 `NotFound`" 라는 규칙을 더하고, api 가 그 오류를 HTTP 404 로 바꾼다. `create` 는 "태그를 못 받으면 저장하지 않는다", "기록은 실패해도 넘어간다" 는 다른 서버에 대한 규칙을 갖는다. 규칙은 domain 에 한 번 쓰고 프로토콜마다의 표현은 api 가 맡는다. 한 줄짜리 함수도 domain 을 거치게 두면 규칙이 생길 때 들어갈 자리가 이미 있고, api 가 storage 를 바로 부르는 지름길이 생기지 않는다. 세 서버가 같은 모양이라 처음 보는 언어의 서버에서도 어느 파일을 열어야 할지 알 수 있다. clients 는 교체 지점이다. notes 의 `clients/tags.ts` · `clients/activity.ts` 처럼 다른 서버 · 외부 시스템마다 하나를 두고, 밖에는 인터페이스만 보인다. 테스트가 그 자리에 Fake 를 넣을 수 있는 것도 이 구조 덕분이다. 이유는 [테스트는 왜 이렇게 됐나](/concepts/testing/) 에서 다룬다. ## 밖에서 보이는 모양은 서버마다 같다 서버 안의 도구는 서버마다 다르지만, 클라이언트가 보는 모양은 맞춘다. 클라이언트가 어느 서버를 부르는지 몰라도 되게 하려는 것이다. `@app/client` 하나로 모든 서버를 같은 방식으로 부를 수 있는 것도 이 때문이다. - **오류는 `{ "error": "..." }` 이고 400 과 502 를 구분한다.** 400 은 요청한 쪽이 입력을 고쳐야 한다는 뜻이고, 502 는 서버나 그 뒤의 시스템에 문제가 있다는 뜻이다. notes 서버는 `BadRequest` 만 400 으로 바꾸고 나머지 예외는 모두 502 로 바꾼다. tags 에 연결하지 못한 것도 502 다. 클라이언트는 상태 코드만 보고 다시 시도할지 정할 수 있다. - **쓰기(POST · PUT)는 JSON 본문만 받는다.** form 같은 단순 요청은 다른 사이트가 사용자의 브라우저를 통해 보낼 수 있다(CSRF). `application/json` 요청은 브라우저가 먼저 허락을 확인하는 preflight 를 거치므로 이런 요청이 막힌다. notes 의 `json()` 이 본문을 읽기 전에 content-type 부터 확인하는 이유다. - **문서에 없는 필드는 버린다.** 받아 두면 그 필드가 문서 밖의 계약이 된다. 입력은 신뢰 경계이므로 문서에 적힌 필드만 통과시킨다. - **서버마다 자기 DB 를 갖는다.** 다른 서버의 표를 직접 읽으면 그 표의 구조가 공개 계약이 되고, 열 하나를 바꾸는 데도 다른 팀과 맞춰야 한다. 다른 서버의 데이터는 gRPC 로 요청한다. notes 는 태그 규칙 표를 읽지 않고 tags 에 묻는다. - **스크립트 이름이 같다.** `dev` · `test` · `gen` · `lint` · `format` 이다. Java 서버도 `package.json` 이 `./gradlew` 를 감싼다. 그래서 루트의 `pnpm test` 하나로 언어와 상관없이 모든 서버를 돌린다. ## 도구는 그 코드를 쓰는 사람을 따른다 서버는 Gradle · uv · Node 처럼 그 언어의 표준 도구를 그대로 쓴다. 그 언어의 개발자에게 익숙하고, 업데이트도 그 생태계의 방식을 따르면 된다. 루트에 Java · Python 설정을 두면 모든 사람이 그 설정을 알아야 한다. 클라이언트는 모두 TS 라서 반대로 한다. lint · format 설정은 하나이고, 의존성 버전은 카탈로그 한 곳에서 정한다. 앱 여섯 개가 버전을 따로 고르면 `react` 와 `react-dom` 버전이 어긋나 테스트는 통과하고 화면만 비는 일이 생긴다([자주 겪는 문제](/guides/troubleshooting/)). ## 개발 · 테스트 · CI 는 같은 인프라를 쓴다 `docker/` 의 compose 파일을 개발 · 테스트 · CI 가 함께 쓴다. CI 의 Postgres 가 개발자 컴퓨터의 Postgres 와 같은 이미지 · 같은 `init.sql` 로 뜨므로, 한쪽에서만 재현되는 문제가 줄어든다. 전체 스택도 같은 파일에 서버 · 앱 이미지를 더해 띄운다. CI 도 job 하나(`CI`)가 로컬과 같은 루트 명령을 돌린다. 서버 · 앱을 더하거나 빼도 워크플로와 브랜치 보호 설정을 고치지 않는다. ## 에이전트도 같은 구조를 읽는다 - **`AGENTS.md` 를 폴더마다 둔다.** 에이전트는 작업하는 폴더에서 가까운 지시를 읽는다. 루트 파일은 모든 작업에서 읽히므로 꼭 지킬 것만 짧게 두고, 서버 · 앱의 세부는 그 폴더에, 설명은 `docs/` 에 둔다. 한 파일에 모두 쓰면 작업과 상관없는 지시까지 매번 읽힌다. - **문서만으로 지켜지지 않는 규칙은 hook 이 막는다.** 생성물을 직접 고치거나 외부 클라이언트를 mock 으로 바꾸는 것처럼, 사람과 에이전트가 습관적으로 하는 변경이다. - **세 서버의 구조가 같다.** 한 서버에서 익힌 패턴을 다른 서버에 그대로 적용할 수 있다. ## 이 구조가 맞지 않는 경우 이 구조는 비용을 내고 얻는 것이 있다. | 비용 | 얻는 것 | |---|---| | 엔드포인트 하나에 파일 서너 개와 `pnpm gen` | 문서 중복을 줄이고 현재 호출 코드의 타입 불일치를 잡는다 | | 여러 도구(Java · uv · Docker) | 서버마다 맞는 언어를 쓴다 | | gen 을 먼저 돌려 느려지는 `pnpm typecheck` | 타입 검사가 항상 최신 계약으로 돈다 | | 지금은 한 줄뿐인 domain 같은 층 | 모든 서버가 같은 모양이고, 규칙이 들어갈 자리가 있다 | 서버가 하나이고 앞으로도 하나라면 이 비용이 더 크다. 서버가 둘 이상이 되거나 언어가 섞이기 시작하면 얻는 것이 비용보다 커진다. --- Source: https://stack.initializer.org/concepts/testing/ # 테스트는 왜 이렇게 됐나 *테스트가 통과했을 때 무엇을 믿어도 되는가* notes 서버에서 `POST /api/notes` 의 업무 로직은 세 줄이다. tags 에 태그를 묻고, 저장하고, activity 에 기록을 남긴다. 이 세 줄을 단위 테스트로 검사하고 저장소와 다른 서버를 mock 으로 바꾸면 테스트는 쉽게 통과한다. 하지만 클라이언트가 실제로 겪는 실패는 대부분 그 세 줄 밖에서 생긴다. content-type 검사, 본문 크기 제한, 입력 검증, 오류를 400 · 502 로 바꾸는 코드, 그리고 SQL 이다. 함수마다 단위 테스트를 쓰고 의존성을 mock 으로 바꾸면 이 이음매가 모두 테스트 밖에 남는다. 그래서 이 템플릿의 기본 테스트는 **클라이언트가 부르는 그대로 부른다.** 실제 서버를 띄우고 실제 HTTP · gRPC 요청을 보낸다. 테스트가 통과하면 그 API 가 실제로 그렇게 동작한다고 볼 수 있다. 테스트 파일 이름이 `*_specs`(명세)인 이유다. 지켜야 할 규칙은 [testing.md](/reference/testing/), 결정의 기록은 [ADR-0003](/reference/decisions/0003-api-tests-with-fakes/) 에 있다. ``` HTTP · gRPC → 서버(랜덤 포트) → 본문 검증 → api → domain ─┬─ storage → 테스트 DB (docker 의 Postgres) HTTP · gRPC ← JSON 응답 ← 오류 변환(400/502) ←────────────────────────┴─ clients → Fake ``` 이 흐름에서 실제 구현이 아닌 것은 clients 자리의 Fake 하나다. ## 테스트 하나에 담긴 확인 notes 서버의 `tests/notes/POST_specs.ts` 에 있는 첫 테스트다. ```ts test('올바른_메모를_보내면_201_Created와_태그를_매긴_메모를_반환하고_DB에_저장된다', async () => { fakeTags.tags = ['회의', '할일'] // ① tags 서버가 돌려줄 값 const res = await sendJson('POST', '/api/notes', { text: '회의 할 일', junk: 1 }) // ② 실제 HTTP 요청 assert.equal(res.status, 201) // ③ 상태 코드 const { id } = res.body as Note const note = { id, text: '회의 할 일', tags: ['회의', '할일'] } assert.deepEqual(res.body, note) // ④ 응답 본문 assert.deepEqual(await list(), [note]) // ⑤ 저장 상태 assert.deepEqual(fakeTags.texts, ['회의 할 일']) // ⑥ tags 서버에 보낸 것 }) ``` 1. tags 서버는 띄우지 않는다. 그 자리의 Fake 에 돌려줄 태그만 정한다. 정하는 것은 바깥의 조건이고, 그 태그를 저장하고 응답에 싣는 것은 실제 코드다. 2. 요청의 `junk: 1` 은 문서에 없는 필드다. 서버는 이 필드 때문에 실패하지도, 이 필드를 저장하지도 않아야 한다. 입력이 신뢰 경계라는 규칙을 이 필드가 검사한다. 3. 만들기는 200 이 아니라 201 이다. 상태 코드도 계약의 일부라 따로 확인한다. 4. 본문은 일부가 아니라 전체를 비교한다(`deepEqual`). 응답에 필드가 하나 더 붙어도 클라이언트가 받는 계약은 바뀐 것이다. id 는 응답에서 꺼내 쓴다. 테스트 사이에 표를 비워도 id 는 되돌아가지 않으므로 1 이라고 가정하지 않는다. 5. 저장 상태는 SQL 을 다시 쓰지 않고 storage 의 `list()` 로 확인한다. 테스트가 묻는 것은 어떻게 저장했는지가 아니라 저장했는지이므로, 표 구조를 바꿔도 테스트는 그대로다. 6. tags 서버에 메모 내용을 보냈는지 본다. 다른 서버에 무엇을 보냈는지도 이 API 가 하는 일의 일부다. 같은 파일의 다른 테스트는 바깥이 실패하는 경우를 다룬다. tags 가 실패하면 502 를 받고 아무것도 저장되지 않는지, activity 가 실패해도 201 을 받는지. 잘못된 입력 여섯 가지(`null`, 배열, 문자열, 빈 객체, 숫자, 10,001 자)가 모두 400 을 받고 저장되지 않는지도 본다. 오류를 응답하면서 저장은 해 버리는 버그는 응답만 봐서는 드러나지 않는다. tags 의 `tests/tags/rules/POST_specs.py` 와 activity 의 `app.activity.GET_specs` 도 같은 모양이다. 실제 서버를 부르고 상태 코드 · 본문 전체 · 저장 상태를 본다. 언어가 달라도 명세를 쓰는 방법이 같다. ## 포트는 운영체제가 고른다 `tests/support/api.ts` 는 서버를 포트 0 으로 띄운다. 포트 0 을 요청하면 운영체제가 비어 있는 포트를 골라 준다. ```ts await new Promise((r) => s.listen(0, '127.0.0.1', r)) return `http://127.0.0.1:${(s.address() as AddressInfo).port}` ``` 8000 같은 고정 포트를 쓰면 실행 중인 개발 서버와 충돌하고, 세 서버의 테스트를 동시에 돌릴 수 없다. 랜덤 포트에서는 두 문제가 생기지 않는다. ## 테스트 DB 는 운영과 같은 엔진이다 H2 · SQLite 같은 인메모리 DB 는 빠르고 따로 설치할 필요가 없다. 하지만 운영과 다른 엔진이다. 예시 서버가 쓰는 `INSERT ... ON CONFLICT (key) DO UPDATE` 는 Postgres 문법이라 엔진마다 다르게 처리되거나 지원되지 않는다. 타입 · 제약 · 정렬 규칙도 다르다. 이 차이를 두면 테스트는 통과하고 운영에서 실패하거나, 테스트를 통과시키려고 운영 SQL 을 모든 엔진이 받는 문법으로 줄이게 된다. 그래서 테스트 DB 는 운영과 같은 Postgres 다. DB 를 하나로 정한 ADR-0004 도 같은 이유다. 대신 테스트에 Docker 가 필요하다(`pnpm infra`). 이 compose 파일은 개발 · CI 와 같은 파일이라 테스트를 위해 따로 관리할 것은 없다. 테스트 DB 는 `<서버>_test` 로 따로 있어 개발 데이터를 지우지 않는다. ## 데이터는 rollback 이 아니라 TRUNCATE 로 지운다 흔히 쓰는 방법은 테스트를 트랜잭션으로 감싸고 끝나면 rollback 하는 것이다(Spring 의 `@Transactional` 테스트). 이 구조에서는 이 방법이 통하지 않는다. 요청을 처리하는 것은 다른 스레드 · 다른 DB 연결에서 도는 실제 서버이고, 서버가 커밋한 데이터는 테스트의 트랜잭션 밖에 있다. rollback 으로는 지워지지 않는다. 그래서 테스트 앞뒤로 모든 표를 비운다. `pg_tables` 로 표를 찾아 `TRUNCATE` 한다. 앞에서 비우면 지난 실행이 중간에 멈춰 데이터가 남아 있어도 깨끗하게 시작한다. 뒤에서 비우면 다음 테스트 파일이 깨끗하게 시작한다. 같은 DB 를 쓰는 테스트가 동시에 돌면 한쪽의 TRUNCATE 가 다른 쪽의 데이터를 지운다. 그래서 한 서버 안의 테스트는 차례로 돈다(notes 는 `--test-concurrency=1`). 서버끼리는 DB 가 달라 동시에 돌아도 된다. ## 외부 시스템은 Fake 로 바꾼다 결제 · 메일 · 다른 서버를 테스트마다 띄울 수는 없으므로 대역이 필요하다. 여기서는 호출 기대값을 검사하는 방식과, 이 템플릿이 선택한 API 결과 중심의 방식을 비교한다. | | 호출 구조를 검사하는 mock 테스트 | 이 템플릿의 Fake 기반 API 테스트 | |---|---|---| | 바꾸는 방법 | 테스트 대역을 넣고 호출 기대값을 설정한다 | 같은 인터페이스를 구현한 작은 구현을 교체 지점에 넣는다 | | 검사하는 것 | 함수가 어떤 인자로 불렸는지(호출 구조) | 외부가 이렇게 응답하면 API 가 어떻게 동작하는지(결과) | | 구현을 리팩터링하면 | 기대값으로 고정한 호출 구조가 바뀌면 실패한다 | 내부 호출보다 API 결과에 의존하므로 영향을 줄인다 | | 실제 외부와 다르게 응답하면 | 그대로 통과할 수 있어 외부 클라이언트는 별도로 검사한다 | 그대로 통과할 수 있어 외부 클라이언트는 별도로 검사한다 | 이 표는 이 저장소가 선택한 테스트 방식을 비교한 것이며, 모든 mock 과 Fake 의 성질을 나눈 것은 아니다. `patch` 같은 교체 도구로 결과 중심 테스트를 쓸 수도 있고, Fake 로 호출 횟수에 의존하는 테스트를 쓸 수도 있다. 무엇으로 바꾸는가와 무엇을 assertion 하는가는 별개의 선택이다. 호출 구조를 세세히 고정하면 동작이 같은 리팩터링에도 테스트가 깨진다. 이 저장소는 외부 대역의 위치를 명시하고 결과를 검사하기 위해 인터페이스를 통한 교체와 Fake 를 기본으로 정했다(ADR-0003). hook 의 mock 금지도 이 저장소의 규칙이며, 모든 프로젝트에 필요한 규칙은 아니다. Fake 는 `clients/` 의 교체 지점에 테스트 공통 설정이 한 번 넣는다. notes 가 부르는 tags 서버가 예다. ```ts // src/clients/tags.ts — 밖에는 인터페이스와 교체 지점만 보인다 export interface Tags { suggest(text: string): Promise } export let tags: Tags = grpcTags /** 테스트가 Fake 로 바꿀 때만 쓴다 */ export const useTags = (t: Tags) => (tags = t) // tests/support/fakes.ts — 외부 조건만 통제한다 export class FakeTagService implements Tags { tags: string[] = [] // 돌려줄 값 readonly texts: string[] = [] // 받은 인자 기록 #fail = false failNext() { this.#fail = true } // 다음 호출 실패 async suggest(text: string) { if (this.#fail) { this.#fail = false; throw new Error('연결할 수 없음') } this.texts.push(text) return this.tags } reset() { this.tags = []; this.texts.length = 0; this.#fail = false } } ``` 이 Fake 로 "tags 가 실패하면 API 가 502 를 응답하고 저장하지 않는다"를 실제 domain 코드로 검사한다. 같은 모양의 `FakeActivityService` 로는 반대 정책, "activity 가 실패해도 201 이다"를 검사한다. 실패를 어떻게 다룰지는 domain 이 정하고 Fake 는 실패만 만든다. **Fake 에는 업무 규칙을 넣지 않는다.** "실패하면 세 번까지 다시 보낸다"를 Fake 안에 구현하면 테스트가 검사하는 대상이 Fake 가 된다. Fake 는 첫 호출은 실패하고 다음 호출은 성공하는 외부 조건만 만들고, 다시 보내는 일은 실제 domain 이 하게 둔다. 그래야 domain 의 재시도 코드가 망가졌을 때 테스트가 실패한다. Fake 가 보장하지 않는 것도 있다. `texts` 에 기록됐다고 해서 실제 tags 서버가 그 요청을 받아 같은 답을 준다는 뜻은 아니다. 받는 쪽은 tags 의 `Suggest_specs` 가 실제 gRPC 로 검사하고, 둘이 실제로 이어지는지는 전체 스택의 smoke 가 본다. 실제 서버에 Fake 를 넣어 성공 · 외부 실패 · 없는 메모를 검사하는 실습은 [외부 서비스 실패 테스트](/guides/external-service-lab/)에 있다. ## 이름이 테스트의 위치와 뜻을 알려 준다 테스트의 위치는 경로에서 정해진다. `POST /api/notes` 의 테스트는 `tests/notes/POST_specs.ts`, 경로 변수가 있는 `DELETE /api/notes/{id}` 는 `tests/notes/id/DELETE_specs.ts` 에 있다. 엔드포인트를 알면 테스트를 찾을 수 있고, 테스트 파일이 없는 엔드포인트는 목록에서 바로 보인다. 새 테스트를 어디에 둘지 사람도 에이전트도 고민하지 않는다. 테스트 이름에는 조건과 결과를 쓴다. `메모가_문자열이_아니거나_너무_길면_400_Bad_Request를_반환하고_저장하지_않는다` 처럼 쓰면 실패 메시지 한 줄로 무엇이 깨졌는지 알 수 있고, 테스트 목록을 위에서부터 읽으면 그 API 의 명세가 된다. 세 서버가 같은 이름을 쓰므로 서버를 다른 언어로 옮길 때 테스트 목록을 그대로 확인 목록으로 쓸 수 있다. ## 층마다 확인하는 것이 다르다 | 층 | 확인하는 것 | 실행하는 곳 | |---|---|---| | 서비스 테스트 (`*_specs`) | 경우마다의 동작: 성공 · 400 · 404 · 502 · 저장 상태 | `pnpm test`, CI | | 전체 스택 smoke | 이미지 · 컨테이너 네트워크 · 환경 변수 · 프록시의 연결 | `pnpm stack` (로컬) | | 브라우저 확인 | 화면 · 콘솔 · 실제 프록시 | `verify-local` skill | smoke 는 서버마다 메모 하나를 만들고 읽고 지우는 것만 확인한다. 경우의 수는 이미 서비스 테스트가 확인하므로, smoke 에서 같은 경우를 반복하면 느리고 불안정한 테스트만 늘어난다. smoke 는 서비스 테스트로 확인할 수 없는 것, 곧 이미지가 제대로 만들어졌는지와 컨테이너끼리 이름으로 서로를 찾는지만 확인한다. ## 이 방식이 맞지 않는 경우 | 비용 | 얻는 것 | |---|---| | 단위 테스트보다 느리다(서버 · DB) | 통과하면 API 가 실제로 그렇게 동작한다 | | Docker 가 필요하다 | 운영과 같은 SQL 을 검사한다 | | 한 서버 안에서는 병렬로 돌지 않는다 | 테스트끼리 데이터가 섞이지 않는다 | | Fake 를 직접 만든다 | 리팩터링해도 깨지지 않는 테스트 | 날짜 계산이나 파서처럼 입력과 출력만 있는 순수 함수에는 이 비용이 필요 없다. 이런 함수는 목적이 드러나는 이름으로 단위 테스트를 따로 둔다. --- Source: https://stack.initializer.org/guides/add-endpoint/ # 엔드포인트 추가 notes 서버(Node)에 메모 하나의 길이를 돌려주는 `GET /api/notes/{id}/length` 를 더하는 예. 업무 로직 → 라우트 → 테스트 → 계약 생성 → 화면 순서다. 명령은 모두 저장소 루트에서 돌린다. ## 1. 업무 로직 `servers/notes/src/domain/note.ts` 에 함수를 더한다. 업무 로직이 모이는 곳이다. 같은 파일의 `get` 이 없는 메모면 `NotFound` 를 던지므로, 그 위에 쌓으면 404 처리를 다시 쓰지 않는다. ```ts export const length = async (id: number) => (await get(id)).text.length ``` ## 2. 라우트 `servers/notes/src/api/http/note.ts` 의 `routes` 에 경로를 더한다. zod 스키마가 입력 검증 · 핸들러 타입 · 문서가 된다. 문서에 들어가는 응답 스키마는 `.meta({ id })` 로 서버 안에서 유일한 이름을 붙인다. 경로 변수는 같은 파일의 `Id`(양의 정수)를 그대로 쓴다. ```ts const NoteLength = z.object({ length: z.int() }).meta({ id: 'NoteLength' }) export const routes = { // '/api/notes' · '/api/notes/{id}' 는 그대로 '/api/notes/{id}/length': { get: get({ params: Id, response: NoteLength }, async ({ params }) => ({ length: await notes.length(params.id), })), }, } ``` - 메서드마다 도구가 있다: `get` · `put` 은 200, `post` 는 201, `del` 은 204(본문 없음). 반환값이 `response` 와 맞지 않으면 타입 오류다. - `id` 가 숫자가 아니면 400, `NotFound` 는 404, 그 밖의 예외는 502 로 서버가 바꾼다. - 새 도메인 모듈을 만들었다면 `src/api/http/server.ts` 의 `mergeRoutes(...)` 에 그 모듈의 `routes` 를 더한다. 같은 경로가 두 번 나오면 서버가 뜨지 않는다. ## 3. 테스트 경로가 곧 테스트 위치다: `/api/notes/{id}/length` → `tests/notes/id/length/GET_specs.ts`(경로 변수는 하위 폴더). 실제 서버가 랜덤 포트로 뜨고 테스트 DB(`node_test`)를 쓴다. ```ts describe('GET /api/notes/{id}/length', () => { test('저장된_메모의_길이를_반환한다', async () => { const { id } = await create('안녕', []) const res = await request(`/api/notes/${id}/length`) assert.equal(res.status, 200) assert.deepEqual(res.body, { length: 2 }) }) test('없는_id면_404_Not_Found를_반환한다', async () => { const res = await request('/api/notes/999999999/length') assert.equal(res.status, 404) assert.deepEqual(res.body, { error: '메모가 없습니다' }) }) }) ``` 준비는 storage 의 `create(text, tags)` 로 하고, id 는 돌려받은 값을 쓴다(테스트 사이에 표를 비워도 id 는 되돌아가지 않는다). ```bash pnpm infra (cd servers/notes && node --test tests/notes/id/length/GET_specs.ts) # 이 파일만 (괄호: 끝나면 루트로 돌아온다) pnpm --filter @app/notes test # 서버 전체 ``` 이름 · 본문 · Fake 규칙은 [testing.md](/reference/testing/). 에이전트에게는 `write-specs` skill 이 같은 절차를 준다. ## 4. 계약 생성 ```bash pnpm gen # 루트에서. servers/notes 안에서 돌리면 그 서버의 notes.json 만 만들고 클라이언트 타입(notes.ts)은 그대로다 ``` `contract/openapi/notes.json` 과 `notes.ts` 가 바뀐다. 손으로 고치지 않고 코드와 함께 커밋한다. 빠뜨리면 CI 가 실패한다. ## 5. 화면에서 부르기 `pnpm dev` 가 띄우는 web(React, `clients/apps/web/src/App.tsx`)에 붙인다. 메모마다 글자 수를 받아 카드에 보여 주는 컴포넌트를 더한다. 경로 · 경로 변수 · 응답 타입은 방금 만든 문서에서 오니, 경로를 틀리거나 `params` 를 빼면 타입 오류다. ```tsx // App.tsx 아래에 더한다 function Length({ id }: { id: number }) { const { data } = useQuery( api.query('/api/notes/{id}/length', { params: { id } }), ) return data && ({data.length}자) } ``` ```tsx // 카드의 메모 글 (원래

{n.text}

). 카드는 글 | 버튼 두 칸이라 글 안에 붙인다

{n.text}

``` ```bash pnpm typecheck # gen 뒤 web 까지 타입 검사 pnpm dev # 서버 셋 + web → http://localhost:5280 ``` 메모 `내일 회의` 를 추가하면 그 카드가 `내일 회의 (5자)` 가 되고 아래에 태그 `회의` 가 붙는다. 쿼리 키에 id 가 들어가 메모마다 따로 캐시된다. 프레임워크가 없는 lite · desktop 은 같은 호출을 `api.get('/api/notes/{id}/length', { params: { id } })` 로 그대로 부른다. mobile · desktop 처럼 브라우저 밖의 앱은 `createClient` 에 `base` 로 서버 주소를 준다. → [clients.md](/reference/clients/) ## 쓰기 엔드포인트 POST · PUT · DELETE 도 순서는 같다. 달라지는 곳: - 본문은 JSON 만 받는다(다른 사이트가 form 으로 몰래 보내지 못하게). notes(Node)는 라우트에 `body` 스키마를 주면 서버가 검사한다. activity(Spring)는 본문을 받는 매핑(`@PostMapping` · `@PutMapping`)마다 `consumes = MediaType.APPLICATION_JSON_VALUE` 를 단다. tags(FastAPI)는 `support/security.py` 가 POST · PUT 만 검사하니 다른 메서드로 본문을 받으면 거기에 더한다. - 테스트는 응답과 함께 DB 를 storage 함수로 읽어 확인한다. 만들기는 201 과 만든 것, 지우기는 204 와 남은 목록, JSON 이 아닌 본문은 400 과 저장되지 않음을 본다. → `tests/notes/POST_specs.ts` · `tests/notes/id/DELETE_specs.ts` - 클라이언트는 `api.post` · `put` · `delete` 를 부른다. TanStack Query 앱은 `useMutation` 으로 부르고 끝나면 바뀐 목록의 `queryKey` 로 `invalidateQueries`, 순수 TS 앱(lite · desktop)은 부른 뒤 목록을 다시 받는다. → [clients.md](/reference/clients/) ## 다른 서버에서 순서는 같고 도구만 다르다. 같은 일을 언어마다 나란히 놓은 것은 [languages.md](/reference/languages/). - **activity (Spring)**: 컨트롤러 메서드 + record DTO, 경로 변수는 `@PathVariable`. DTO 이름과 메서드 이름(operationId)은 서버 안에서 유일하게 짓는다. 테스트는 경로를 따른 패키지의 `GET_specs` 클래스에 `@CoreApiTest` (예: `app.activity.GET_specs`). → [servers/activity/AGENTS.md](/reference/servers/activity/) - **tags (FastAPI)**: `server/api/routes/` 에 함수와 pydantic 모델, 경로 변수는 함수 인자. DB 드라이버가 동기라 핸들러는 `def` 로 쓴다. 테스트는 경로를 따른 `tests/.../GET_specs.py` (예: `tests/tags/rules/GET_specs.py`). → [servers/tags/AGENTS.md](/reference/servers/tags/) ## 다음 실습 - [계약을 깨뜨려 보기](/guides/contract-lab/): 응답 필드와 proto 번호를 바꿨을 때 어떤 검사가 실패하는지 확인한다. - [외부 서비스 실패 테스트](/guides/external-service-lab/): 실제 API · DB 에 외부 요약 서비스와 Fake 를 연결한다. - [다른 서버 부르기](/guides/call-server/): notes 가 tags · activity 를 부르는 흐름을 처음부터 따라간다. --- Source: https://stack.initializer.org/guides/contract-lab/ # 계약을 깨뜨려 보기 *의도한 실패를 만들고, 무엇이 잡고 무엇은 잡지 못하는지 확인한다* 템플릿에서 만든 학습용 저장소를 기준으로 한다. `pnpm install` 을 마쳤고 `pnpm typecheck` 가 통과하는 상태에서 시작한다. 1 · 3 단계는 DB 와 개발 서버 없이 할 수 있다. 생성물은 직접 편집하지 않는다. 명령은 모두 저장소 루트에서 돌린다. ## 1. HTTP 응답의 필드를 바꾼다 `servers/notes/src/api/http/note.ts` 에서 목록 응답에만 쓰는 `NoteList` 의 `notes` 를 `items` 로 바꾼다. ```ts const NoteList = z.object({ items: z.array(Note) }).meta({ id: 'NoteList' }) ``` 같은 파일의 `GET /api/notes` 핸들러도 바꾼다. 개별 메모나 POST · PUT 응답은 그대로다. ```ts get: get({ response: NoteList }, async () => ({ items: await notes.list(), })), ``` notes 서버의 문서만 다시 만들고 타입을 검사한다. ```bash pnpm --filter @app/notes gen pnpm --filter @app/contract gen:types pnpm --filter @app/notes typecheck pnpm --filter @app/web typecheck ``` **예상:** 서버 타입 검사는 통과하고, web 은 이렇게 실패한다. ``` src/App.tsx(31,17): error TS2339: Property 'notes' does not exist on type '{ items: { id: number; text: string; tags: string[]; }[]; }'. ``` `contract/openapi/notes.json` 과 `notes.ts` 에는 `items` 가 들어 있다. lite · site 처럼 같은 목록을 읽는 다른 앱도 같은 이유로 실패한다. 타입을 손으로 적었거나 생성을 빼먹었다면 클라이언트 검사가 옛 정의를 보게 된다. CI 의 생성물 비교는 그 누락을 잡는 별도의 검사다. ## 2. 현재 클라이언트를 고친다 `clients/apps/web/src/App.tsx` 에서 목록을 그리는 한 줄을 바꾼다. ```tsx {data.items.map((n) => ( ``` ```bash pnpm --filter @app/web typecheck ``` **예상:** 이제 통과한다. 하지만 예전 앱은 여전히 `notes` 를 읽는다. 이 실습은 **현재 소스의 타입 불일치**를 잡았을 뿐, 옛 앱과의 호환이나 실제 응답 값까지 증명하지 않는다. notes 의 `tests/notes/GET_specs.ts` 는 응답 본문 전체를 비교하므로 그대로 돌리면 실패한다(DB 가 필요하다: `pnpm infra`). 타입 검사와 동작 테스트는 서로를 대신하지 않는다. 실습 뒤에는 서버 스키마와 핸들러의 `items` 를 `notes` 로, web 의 한 줄을 `{data.notes.map((n) => (` 로 되돌린 뒤 `pnpm gen` 을 실행한다. 다른 작업을 덮어쓰는 `git restore .` 대신 자신이 바꾼 줄만 되돌린다. ## 3. proto 의 필드 번호를 바꾼다 이번에는 현재 계약을 비교 기준으로 복사한다. 아래 변수와 명령은 같은 터미널에서 실행한다. Git 의 브랜치 상태와 무관하게 이번 변경 직전의 계약과 비교하기 위한 임시 폴더다. ```bash contract_baseline=$(mktemp -d) cp contract/buf.yaml "$contract_baseline/buf.yaml" cp -R contract/proto "$contract_baseline/proto" ``` `contract/proto/app/activity/v1/activity.proto` 의 **RecordRequest** 에서 `int64 note_id = 2;` 를 `int64 note_id = 3;` 으로 바꾼다. `action = 1` 은 바꾸지 않는다. 이 메시지는 notes(Node)가 보내고 activity(Spring)가 받는다. ```bash pnpm --filter @app/contract exec buf lint pnpm --filter @app/contract exec buf breaking --against "$contract_baseline" ``` **예상:** lint 는 통과하지만 breaking 은 실패한다. 문법과 이름 규칙을 지키는 것과 기존 계약과 호환되는 것은 다르다. ``` proto/app/activity/v1/activity.proto:13:1:Previously present field "2" with name "note_id" on message "RecordRequest" was deleted. ``` 정확한 문구는 Buf 버전에 따라 다르다. 옛 notes 가 2번에 담아 보낸 id 를 새 activity 는 3번에서 찾는다. 3번은 비어 0 이 되니, 배포가 섞이는 동안 기록이 `INVALID_ARGUMENT` 로 거절되고 notes 는 경고만 남긴다. 테스트는 모두 통과한 채로. `int64 note_id = 2;` 로 되돌리고 같은 breaking 명령을 다시 실행하면 통과해야 한다. 실습에서는 생성 코드를 사용하지 않으므로 proto 만 검사했다. 실제 변경에서는 `pnpm gen` 과 서버 테스트도 함께 실행한다. CI 도 `buf breaking` 을 실행한다. PR 은 대상 브랜치의 기준 커밋, merge queue 는 기준 커밋, main push 는 직전 커밋과 비교한다. 비교 대상 자체가 없는 첫 push 만 건너뛴다. 이 검사는 proto 의 구조적 호환을 검사하며, 같은 문자열 필드의 뜻을 바꾸는 것까지 알아내지는 못한다. ## 4. 원래 상태로 확인한다 ```bash pnpm gen pnpm typecheck pnpm test git diff ``` 테스트에는 Docker 의 Postgres 가 필요하다. 실습 변경과 생성물이 원래대로 돌아왔는지 diff 에서 확인한다. 그다음 [외부 서비스 실패 테스트](/guides/external-service-lab/)에서 실제 서버 · DB 와 Fake 의 경계를 확인한다. --- Source: https://stack.initializer.org/guides/external-service-lab/ # 외부 서비스 실패 테스트 *실제 서버와 DB 는 그대로, 외부의 성공과 실패만 Fake 로 정한다* notes 서버에 `GET /api/notes/{id}/summary` 를 더한다. 저장된 메모를 읽고 외부 요약 서비스에 보내는 API 다. 실습용 변경이므로 기본 템플릿에는 이 기능이나 외부 서비스 의존성이 들어 있지 않다. [엔드포인트 추가](/guides/add-endpoint/)를 따라 해 보았고, notes 서버의 기존 테스트가 통과하는 상태에서 시작한다. 명령은 모두 저장소 루트에서 돌린다. notes 는 이미 다른 서버(tags · activity)를 Fake 로 바꿔 테스트한다. 그 둘은 우리가 proto 로 합의한 서버이고, 이번 요약 서비스는 형식을 우리가 정하지 않은 밖의 HTTP 서비스다. 그래서 응답을 실행 중에 검사하는 것까지 해 본다. 완료 기준은 세 가지다. 성공하면 요약과 200, 외부가 실패하면 502, 메모가 없으면 외부를 부르지 않고 404 를 반환한다. 조회 API 이므로 어느 경우에도 메모는 바뀌지 않아야 한다. 실제 외부 서비스나 API 키 없이 테스트할 수 있다. ## 1. 외부 클라이언트와 교체 지점 `servers/notes/src/clients/summary.ts` 를 만든다. 옆의 `tags.ts` · `activity.ts` 와 같은 모양이다. 모듈을 import 할 때는 외부에 접속하지 않고 `summarize()` 를 호출할 때만 연결한다. ```ts export interface Summarizer { summarize(text: string): Promise } export const httpSummarizer: Summarizer = { async summarize(text) { const url = process.env.SUMMARY_URL if (!url) throw new Error('SUMMARY_URL 이 필요합니다') const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(5_000), }) if (!res.ok) throw new Error(`요약 서비스 오류: ${res.status}`) const body: unknown = await res.json() if ( typeof body !== 'object' || body === null || !('summary' in body) || typeof body.summary !== 'string' ) throw new Error('요약 응답 형식이 아닙니다') return body.summary }, } export let summarizer: Summarizer = httpSummarizer /** 테스트 공통 설정에서만 Fake 로 바꾼다 */ export const useSummarizer = (next: Summarizer) => (summarizer = next) ``` `SUMMARY_URL` 의 서비스가 `{ text: string }` 을 받아 `{ summary: string }` 을 반환한다고 가정한다. 여기서는 해당 서비스를 구현하거나 실행하지 않는다. 테스트에서는 아래 Fake 로 바꾼다. 응답의 `unknown` 검사는 외부 신뢰 경계의 런타임 검증이며, TypeScript 타입 단언과 다르다. ## 2. 업무 로직과 라우트 `servers/notes/src/domain/note.ts` 에 import 와 함수를 더한다. 메모가 없으면 기존 `get` 에서 멈춘다. ```ts export const summary = async (id: number) => { const note = await get(id) return { summary: await summarizer.summarize(note.text) } } ``` `servers/notes/src/api/http/note.ts` 에 응답 스키마를 더한다. ```ts const NoteSummary = z.object({ summary: z.string() }).meta({ id: 'NoteSummary' }) ``` 같은 파일의 `routes` 에 경로를 더한다. `get` · `Id` · `notes` 는 이미 있는 것을 쓴다. ```ts '/api/notes/{id}/summary': { get: get({ params: Id, response: NoteSummary }, async ({ params }) => notes.summary(params.id), ), }, ``` 오류를 Fake 안에서 HTTP 응답으로 바꾸지 않는다. 외부 실패는 기존 실제 서버의 오류 처리가 502 로, 없는 메모는 실제 domain 의 `NotFound` 와 서버의 오류 처리가 404 로 바꾼다. ## 3. Fake 와 공통 설정 `servers/notes/tests/support/fakes.ts` 에 Fake 를 더한다. 같은 파일의 `FakeTagService` 와 같은 모양이다. ```ts /** 외부 요약 서비스: 정한 요약을 돌려주고 받은 text 를 남긴다. failNext() 면 다음 한 번 실패 */ export class FakeSummarizer implements Summarizer { result = '요약 결과' readonly received: string[] = [] #fail = false failNext() { this.#fail = true } async summarize(text: string) { this.received.push(text) if (this.#fail) { this.#fail = false throw new Error('요약 서비스 연결 실패') } return this.result } reset() { this.result = '요약 결과' this.received.length = 0 this.#fail = false } } export const fakeSummarizer = new FakeSummarizer() ``` `servers/notes/tests/support/api.ts` 에서 다른 Fake 와 같은 자리에 넣고, 같은 `reset()` 에서 비운다. ```ts useTags(fakeTags) useActivity(fakeActivity) useSummarizer(fakeSummarizer) async function reset() { // ...표를 비우는 코드는 그대로 fakeTags.reset() fakeActivity.reset() fakeSummarizer.reset() } ``` 이 공통 설정을 사용하는 테스트는 외부 네트워크를 부르지 않는다. 같은 DB · Fake 를 공유하므로 기존처럼 순차 실행한다. Fake 는 지정한 결과와 실패만 내놓고, 메모 조회 · 404 판단 · 오류 응답 변환을 다시 구현하지 않는다. ## 4. 실제 HTTP 로 확인한다 `servers/notes/tests/notes/id/summary/GET_specs.ts` 를 만든다. 준비는 storage 의 `create(text, tags)` 로 한다. ```ts describe('GET /api/notes/{id}/summary', () => { test('요약을_반환하고_원문은_바꾸지_않는다', async () => { const note = await create('저장된 메모 원문', []) fakeSummarizer.result = '짧은 요약' const res = await request(`/api/notes/${note.id}/summary`) assert.equal(res.status, 200) assert.deepEqual(res.body, { summary: '짧은 요약' }) assert.deepEqual(fakeSummarizer.received, [note.text]) assert.deepEqual(await list(), [note]) }) test('외부가_실패하면_502를_반환하고_원문은_바꾸지_않는다', async () => { const note = await create('저장된 메모 원문', []) fakeSummarizer.failNext() const res = await request(`/api/notes/${note.id}/summary`) assert.equal(res.status, 502) assert.deepEqual(res.body, { error: '요약 서비스 연결 실패' }) assert.deepEqual(await list(), [note]) }) test('없는_메모면_404를_반환하고_외부를_부르지_않는다', async () => { const res = await request('/api/notes/999999999/summary') assert.equal(res.status, 404) assert.deepEqual(res.body, { error: '메모가 없습니다' }) assert.deepEqual(fakeSummarizer.received, []) assert.deepEqual(await list(), []) }) }) ``` ```bash pnpm infra (cd servers/notes && node --test tests/notes/id/summary/GET_specs.ts) # 이 파일만 pnpm --filter @app/notes test pnpm --filter @app/notes format pnpm gen pnpm typecheck pnpm lint ``` 세 시나리오와 기존 테스트가 통과하고 생성된 계약에 새 경로가 나오면 완료다. `pnpm gen` 은 새 API 를 정의에서 꺼내는 단계이지, 외부 서비스를 호출하는 단계가 아니다. --- Source: https://stack.initializer.org/guides/call-server/ # 다른 서버 부르기 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 } // 만들 때는 접속하지 않는다 (처음 부를 때 연결). 서버가 멈춰도 쓰기가 계속 기다리지 않게 시간 제한 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) ``` --- Source: https://stack.initializer.org/guides/add-server/ # 서버 추가 이름은 맡은 일로, 언어는 무엇이든 된다. 예시 서버 셋(notes · Node, tags · FastAPI, activity · Spring) 중 가까운 것을 본뜨고 아래 연결 지점을 채운다. 뺄 때는 같은 목록을 거꾸로. ## 1. 폴더와 스크립트 `servers/<이름>/package.json` 에 `dev` · `test` · `gen` · `lint` · `format` · `typecheck` 를 둔다. JS 가 아니면 자기 도구를 감싼다 (activity 는 `./gradlew`, tags 는 `uv run`). 그래서 루트의 `pnpm test` · `pnpm lint` 가 새 서버에도 돈다. 포트는 [development.md](/reference/development/#포트) 의 표와 겹치지 않게 고른다. 바인딩은 `HOST`, 기본 127.0.0.1. ## 2. 레이어와 공통 규칙 `api → domain → storage · clients` 로 나누고 바깥에서 안으로만 부른다. 쓰기(POST · PUT)는 JSON 본문만, 응답 코드와 오류 `{ "error" }` (400 · 404 · 405 · 502) 는 다른 서버와 같게 한다. → [architecture.md](/reference/architecture/) ## 3. 계약 - `gen` 스크립트가 서버 코드에서 `contract/openapi/<이름>.json` 을 쓴다. DB 없이 돌아야 한다(`pnpm typecheck` 도 gen 을 돌린다). - 루트 `package.json` 의 `gen` 과 `contract/index.ts` 에 한 줄씩 더한다: `export type * as <이름> from './openapi/<이름>.ts'`. - gRPC 를 쓰면 `contract/proto` 에서 코드를 만든다. proto 를 복사하지 않는다. → [contracts.md](/reference/contracts/) ## 4. DB `docker/postgres/init.sql` 에 두 줄을 더한다. ```sql CREATE DATABASE <이름>; CREATE DATABASE <이름>_test; ``` 이 파일은 볼륨을 처음 만들 때만 돈다. 이미 띄운 적이 있으면 같은 문장을 직접 실행한다. 연결은 `PGHOST` · `PGPORT` · `PGDATABASE` · `PGUSER` · `PGPASSWORD`, 테스트는 `PGDATABASE` 만 `<이름>_test` 로 바꾼다. 표는 그 언어의 마이그레이션 도구로 만든다. 다른 서버처럼 서버가 요청을 받기 전에 적용하고(테스트도 서버를 띄우며 같이), `gen` 은 DB 없이 돈다. 테스트의 DB 정리는 그 도구의 적용 기록 표를 남긴다. 도구와 파일 위치는 [database.md](/guides/database/#스키마) 의 표에 한 줄 더한다. ## 5. 테스트 실제 서버를 랜덤 포트로 띄워 HTTP · gRPC 로 부르고, 테스트마다 DB 를 비운다. 외부 클라이언트만 Fake 로 바꾼다. → [testing.md](/reference/testing/) ## 6. 이미지 · 스택 - `servers/<이름>/Dockerfile`: 컨텍스트는 그 폴더, 공유 파일은 `COPY --from=workspace`, 실행 사용자는 root 가 아니게. → [development.md](/reference/development/#이미지) - `docker/docker-compose.yaml` 에 서비스를 더한다. ```yaml <이름>: <<: *server build: { context: ../servers/<이름>, additional_contexts: { workspace: .. } } ports: ["127.0.0.1:${<이름>_PORT:-8003}:8003"] ``` - 앱이 이 서버를 부르면 gateway 의 경로 표(`servers/gateway`)에 접두사 한 줄을 더하고, compose 의 gateway 에 `<이름>_URL` 을 준다. 접두사는 다른 서버와 겹치지 않게. - `docker/smoke.sh` 에 그 서버의 덩어리(`# <이름>` 부터 빈 줄까지)를 더한다. 다른 서버를 부르는 흐름이 있으면 그 흐름이 이어지는지 하나만 본다. ## 7. 지시 파일 `servers/<이름>/AGENTS.md` 에 구조 · 명령 · 규칙 · 테스트 방법을 쓰고 `ln -s AGENTS.md CLAUDE.md`. ## 이름을 바꿀 때 폴더 이름은 맡은 일이다(`notes` · `tags` · `activity`). 예시를 본떠 다른 일을 맡기면 이름부터 바꾼다(예: `notes` → `orders`). 구현 언어 · 프레임워크는 이름에 넣지 않는다. 그건 그 폴더의 `AGENTS.md` 가 말한다. 1. 폴더를 옮긴다: `git mv servers/notes servers/orders` 2. 이름이 들어간 곳을 찾아 바꾼다. 서버 이름은 패키지 · 계약 · DB · compose · smoke · 환경 변수 · hook · 그 서버를 부르는 앱과 서버에 들어간다. ```bash n=notes; N=NOTES # 바꿀 이름 git grep -n -E "@app/${n}|servers/${n}|${n}\.paths|openapi/${n}\.|'${n}'|\"${n}\"|${n}_test|${N}_(PORT|GRPC)|//${n}:|\[${n}[],]|, ${n}\]|^ ${n}:|DATABASE ${n}|type ${n}[ ,}]|as ${n} " \ -- ':!pnpm-lock.yaml' ':!**/uv.lock' ':!*.md' ':!contract/openapi/*' ``` 같은 단어의 필드 · CSS 클래스(`tags: string[]` · `className = 'tags'`)는 서버 이름이 아니니 그대로 둔다. `scripts/drop.mjs` 의 `SERVERS` 목록도 바꾼다. 3. 루트에서 확인한다: `pnpm install`(lockfile) → 예전 `contract/openapi/<이름>.*` 를 지우고 `pnpm gen` → `pnpm typecheck` · `pnpm lint` · `pnpm test` → `node scripts/drop.mjs --check`. 4. 이미 띄운 Postgres 에는 새 이름의 DB 가 없다(`init.sql` 은 볼륨을 처음 만들 때만 돈다). 데이터를 옮기려면 `docker compose -f docker/docker-compose.middleware.yaml exec db psql -U app -d postgres -c 'ALTER DATABASE notes RENAME TO orders'`, 테스트 DB 는 `notes_test` → `orders_test`. 5. 설명은 `git grep -n -w notes -- '*.md'` 로 찾아 고친다. API 경로(`/api/notes`)는 자원 이름이라 서버 이름과 따로 정한다. ## 서버를 뺄 때 `pnpm drop <이름>` 이 같은 목록을 거꾸로 지운다: 폴더, 루트 `gen` 과 `contract/index.ts` 의 줄, `contract/openapi/<이름>.*`, init.sql, compose, smoke. 그 서버를 부르는 앱(`<이름>.paths`) · 서버(`<이름>_GRPC`)가 남아 있으면 멈춘다. → [development.md](/reference/development/#예시-빼기). 새 서버를 템플릿에 더했다면 `scripts/drop.mjs` 의 목록에도 더한다. --- Source: https://stack.initializer.org/guides/add-app/ # 앱 추가 이름은 누가 어디서 쓰는지로, 프레임워크는 무엇이든 된다. 예시 앱(web · React, admin · Vue, site · Next, lite · 순수 TS, mobile · Expo, desktop · Tauri) 중 가까운 것을 본뜨고 아래 연결 지점을 채운다. ## 1. 폴더와 의존성 `clients/apps/<이름>/` 에 `package.json` 을 둔다. - 의존성 버전은 `"catalog:"`. 버전은 `pnpm-workspace.yaml` 의 카탈로그 한 곳에서 정한다. Expo 가 정하는 버전은 `catalog:expo`, TS 의 JS API 가 필요한 도구(admin 의 vue-tsc · site 의 next build)는 `catalog:ts6`. - `react` · `react-dom` 은 저장소 전체가 정확히 같은 버전이다. 앱에서 따로 올리지 않는다. - 스크립트는 `lint` · `format` · `typecheck`. lint · format 설정은 `clients/` 의 공통 설정을 그대로 쓴다. - 개발 서버 포트는 [development.md](/reference/development/#포트) 의 표와 겹치지 않게 고른다. ## 2. 서버 부르기 부를 서버마다 클라이언트를 하나 만든다. 경로 · 응답 타입은 그 서버가 만든 문서에서 온다. ```ts const api = createClient({ name: 'notes' }) ``` - 같은 출처로 부르면 `base` 를 비우고 개발 서버의 프록시가 `/api` 를 gateway 로 넘기게 한다(예시 vite 앱은 `API_URL`, 기본 gateway :8003). 부르는 서버가 몇이든 규칙은 이 하나다. 서버는 gateway 가 경로로 고른다. - 네이티브 앱 · 서버 렌더링은 `base` 에 gateway 주소를 준다. - 서버 데이터는 TanStack Query 에 `api.query(경로)` 를 넘긴다. 저장 뒤에는 그 `queryKey` 로 `invalidateQueries`. - 화면은 [DESIGN.md](/reference/design/) 의 토큰 · 규칙을 따른다. → [clients.md](/reference/clients/) ## 3. 이미지 (웹 앱만) - 정적 앱은 `clients/apps/web/Dockerfile` 을 본뜬다. 빌드한 파일을 nginx 가 내주고 `/api` 는 `API_URL` 로 넘긴다(`clients/nginx.conf.template`). - 서버 렌더링 앱은 `clients/apps/site/Dockerfile` 을 본뜬다(standalone 출력). - `docker/docker-compose.yaml` 에 서비스를, `docker/smoke.sh` 에 그 앱의 덩어리(`# <이름>` 부터 빈 줄까지)를 더한다. mobile · desktop 처럼 네이티브로 빌드하는 앱은 이미지가 없다. ## 4. 지시 파일 `clients/apps/<이름>/AGENTS.md` 에 구조 · 명령 · 서버 부르는 방식을 쓰고 `ln -s AGENTS.md CLAUDE.md`. ## 이름을 바꿀 때 앱 이름은 누가 어디서 쓰는지다(`web` · `admin` · `site`). 프레임워크는 이름에 넣지 않는다. 바꾸는 순서는 [서버 추가의 "이름을 바꿀 때"](/guides/add-server/#이름을-바꿀-때)와 같고, 앱 이름이 들어가는 곳은 더 적다: `@app/<이름>`, `clients/apps/<이름>`, `<이름 대문자>_PORT`, compose 서비스 · smoke 덩어리, `scripts/rename.mjs` · `scripts/drop.mjs` 의 목록. ```bash n=web; N=WEB git grep -n -E "@app/${n}|clients/apps/${n}|${N}_PORT|^ ${n}:|^# ${n}" -- ':!pnpm-lock.yaml' ':!*.md' ``` ## 앱을 뺄 때 `pnpm drop <이름>` 이 폴더와 compose · smoke · 포트 줄을 지운다. → [development.md](/reference/development/#예시-빼기) --- Source: https://stack.initializer.org/guides/database/ # DB 바꾸기 표(스키마)는 서버마다 그 언어의 마이그레이션 도구로 바꾼다. 셋 다 서버가 뜰 때 아직 적용하지 않은 마이그레이션을 적용하고, 테스트도 테스트 DB 에 같은 마이그레이션을 적용한다. DB 엔진은 Postgres 가 기본이고 서버마다 따로 바꿀 수 있다(ADR-0004). ## 스키마 | 서버 | 도구 | 파일 | 새 마이그레이션 | |---|---|---|---| | notes (Node) | node-pg-migrate | `servers/notes/migrations/NNNN_설명.sql` (`-- Up Migration` · `-- Down Migration`) | `pnpm --filter @app/notes migrate create <설명>` | | activity (Spring) | Flyway | `servers/activity/storage/db-core/src/main/resources/db/migration/V{n}__설명.sql` | 다음 번호로 파일을 더한다 | | tags (FastAPI) | Alembic | `servers/tags/migrations/versions/NNNN_설명.py` (`op.execute` 로 SQL) | `uv run alembic revision -m <설명> --rev-id NNNN` (그 폴더에서) | - 스키마를 바꿀 때는 다음 번호의 새 파일을 더한다. `main` 에 들어간 마이그레이션을 고치면 이미 적용한 DB 의 기록과 달라진다. activity(Flyway)는 hook 도 막는다. - 브랜치에서 만든 마이그레이션은 main 을 받을 때마다 번호가 main 의 마지막보다 뒤인지 본다. 겹치면 서버가 뜨지 않을 수 있다 → [troubleshooting.md](/guides/troubleshooting/#브랜치를-바꾼-뒤-서버가-마이그레이션-오류로-뜨지-않는다). - 적용 기록은 표에 남는다: `pgmigrations` · `flyway_schema_history` · `alembic_version`. 테스트의 DB 정리는 이 표를 남기고 나머지만 비운다. - notes 의 마이그레이션이 바꾸는 종류마다 예다: `0002_notes` 는 데이터를 옮긴다(메모 하나를 담던 표 → 메모 목록 표 `notes`, 예전 표는 지운다), `0003_note_tags` 는 기존 행이 있는 표에 기본값과 함께 열을 더한다. - `pnpm gen` 은 마이그레이션을 돌리지 않는다. 문서 생성에는 DB 가 필요 없다. ## 개발 DB 를 처음부터 스키마가 꼬이면 그 서버의 개발 · 테스트 DB 를 다시 만든다. 서버가 다시 뜰 때 마이그레이션이 처음부터 적용된다. 그 서버의 개발 데이터는 지워지고 다른 서버의 DB 는 그대로다. 아래는 notes 의 예다. 다른 서버는 `notes` 를 그 서버 이름(`tags` · `activity`)으로 바꾼다. ```bash docker compose -f docker/docker-compose.middleware.yaml exec db psql -U app -d postgres \ -c 'DROP DATABASE notes WITH (FORCE)' -c 'CREATE DATABASE notes' \ -c 'DROP DATABASE notes_test WITH (FORCE)' -c 'CREATE DATABASE notes_test' ``` 모든 서버를 처음부터 하려면 Postgres 볼륨을 지우고 다시 띄운다. ```bash docker compose -f docker/docker-compose.middleware.yaml down -v && pnpm infra ``` ## 다른 DB 로 테스트도 그 엔진으로 돈다(ADR-0003). Postgres 에 묶인 곳: | | 고칠 곳 | |---|---| | 인프라 | `docker/docker-compose.middleware.yaml` 의 `db`(이미지 · healthcheck · 포트), `docker/postgres/init.sql`(서버마다 DB · `_test`), `docker/docker-compose.yaml` 의 `PGHOST`, `docker/.env.example` 의 `POSTGRES_PORT` | | notes | `package.json` 의 `postgres` · `pg`(드라이버), `src/storage/db.ts`(연결) · `migrate.ts`, `migrations/*.sql`, `src/storage/*.ts`(SQL), `tests/support/api.ts` 의 `reset` | | activity | `storage/db-core/build.gradle.kts` 의 `org.postgresql:postgresql` · `flyway-database-postgresql`, `application.properties` · `CoreApiTest.java` 의 `jdbc:postgresql` URL, 저장소 SQL · `db/migration/*.sql`, `CleanDatabase` | | tags | `pyproject.toml` 의 `psycopg`(드라이버), `server/storage/db.py`(연결 풀), `migrations/env.py`(`postgresql+psycopg` 연결) · `versions/*.py`, `server/storage/*.py`(SQL), `tests/conftest.py` 의 `database` · `clean_db` | | 공통 | 환경 변수 `PG*`(`servers/*/.env.template` 와 위 연결 코드), 문서(이 파일 · `development.md` · `architecture.md` · `testing.md` · 서버 `AGENTS.md`) | - 테스트 정리는 마이그레이션 기록 표를 뺀 모든 표를 비운다. 지금은 `pg_tables` 로 표를 찾고 `TRUNCATE` 한다. 다른 DB 는 `information_schema.tables` 로 찾고, `TRUNCATE` 가 없거나 외래 키에 막히면 `DELETE FROM`. - `BIGINT GENERATED ALWAYS AS IDENTITY` 와 `RETURNING` 은 Postgres 문법이다. MySQL 은 `AUTO_INCREMENT` 와 따로 읽기, SQLite 는 `INTEGER PRIMARY KEY`. - Postgres 에 묶인 곳을 새로 만들면 위 표에도 더한다. --- Source: https://stack.initializer.org/guides/auth/ # 인증 붙이기 *템플릿에는 인증이 없다. 무엇을 정하고 어디에 붙일지, 그리고 직접 해 볼 요청 예시* 예시 서버는 127.0.0.1 에서만 열리고, 요청이 누구의 것인지 묻지 않는다. 이대로 밖에 열면 주소를 아는 누구나 메모를 읽고 지울 수 있다. 템플릿이 인증을 넣지 않은 이유는 방식이 프로젝트마다 다르기 때문이다. 브라우저 앱만 있으면 쿠키 세션이 맞고, 모바일 · 데스크톱 앱이 있으면 토큰이 필요하다. 사용자를 직접 확인할 수도, 외부 제공자(OIDC)에 맡길 수도 있다. 하나를 골라 넣어 두면 다른 방식을 쓰는 프로젝트는 그것을 걷어내는 일부터 해야 한다. 이 글은 구현 대신 정할 것과 붙일 자리를 정리하고, 에이전트에게 맡길 때 쓸 요청 예시를 남긴다. ## 먼저 정할 것 - **누가 부르는가.** 브라우저 앱만 있는지, 모바일 · 데스크톱 앱도 있는지에 따라 쿠키 세션과 토큰 중 하나를 고른다. - **누가 사용자를 확인하는가.** 비밀번호를 직접 다루는지, OIDC 제공자의 토큰을 검증만 하는지 정한다. - **어느 서버가 검증하는가.** 서버마다 검증하는지, 앱이 들어오는 gateway 한 곳에서 하는지 정한다(gateway 에서 하면 검증한 사용자 id 를 헤더로 넘기고, 서버는 gateway 를 거친 요청만 받게 막는다). 사용자 · 로그인을 맡는 서버(예: `identity`)를 따로 두면 다른 서버는 그 서버가 준 토큰을 검증만 한다. 서버 ↔ 서버(gRPC)는 사용자 인증과 별개로 mTLS 나 서비스 토큰으로 막는다. - **데이터의 주인과 오류 코드.** 메모에 사용자 id 를 붙이고, 다른 사용자의 메모를 요청하면 404 로 숨길지 403 으로 알릴지 정한다. 인증이 없으면 401 이다. 이 선택은 여러 서버와 앱에 걸치고 되돌리기 어렵다. 구현 전에 [decisions/template.md](/reference/decisions/template/) 로 ADR 을 쓴다. ## 붙일 자리 인증은 api 층의 일이다. domain 은 HTTP 헤더를 모르고, 요청한 사용자의 id 를 인자로 받는다. | 층 | 할 일 | |---|---| | gateway (고르면) | 토큰을 검증하고 사용자 id 를 헤더로 넘긴다. 경로 표 앞에 한 단계를 둔다(`servers/gateway`). 서버의 api 는 그 헤더만 믿는다 | | api | 요청에서 사용자를 확인한다. notes(Node)는 `src/api/http/server.ts` 의 요청 처리 앞, activity(Spring)는 Spring Security 필터, tags(FastAPI)는 dependency 나 `server/support/security.py` | | domain | 사용자 id 를 받아 그 사용자의 메모만 다룬다 | | storage | `notes` 에 사용자 id 열을 더하는 새 마이그레이션 ([database.md](/guides/database/)) | | clients | 외부 제공자(토큰 검증 키 조회 등)를 부르는 코드. 테스트에서는 Fake 로 바꾼다 | - 계약: 서버의 openapi 문서에 인증 방식(securitySchemes)이 나오게 해서 클라이언트도 알 수 있게 한다. - 테스트: 각 `*_specs` 에 "토큰이 없으면 401", "다른 사용자의 메모는 404(또는 403)" 같은 경우를 더한다. 외부 제공자는 Fake 다([testing.md](/reference/testing/)). - 브라우저: 쿠키 세션을 쓰면 JSON 본문만 받는 규칙만으로는 부족하다. `SameSite` 와 CSRF 토큰을 함께 정한다. ## 요청 예시 아래는 notes 서버와 web 앱에만 토큰 인증을 붙이는 요청이다. 꺾쇠(`<...>`)를 자기 프로젝트의 값으로 바꿔 쓴다. 첫 단계에서 ADR 을 제안하고 멈추게 한 것은, 인증 방식이 사람이 정할 일이기 때문이다. ```text 목적: 메모를 사용자별로 나눈다. 로그인한 사용자만 자기 메모를 읽고 쓴다. 먼저: docs/decisions/template.md 로 인증 방식 ADR 을 "제안" 상태로 쓰고 멈춘다. 내가 확인한 뒤에 구현한다. 정할 것: 토큰 발급자 , 전달 방식(Authorization: Bearer), 다른 사용자의 메모를 요청했을 때 404 와 403 중 무엇을 줄지. 변경 범위: notes 서버와 web 앱만. tags · activity 와 다른 앱은 바꾸지 않는다. - notes api: 액세스 토큰(JWT)을 검증하고 사용자 id 를 domain 에 넘긴다. - notes clients: 토큰 검증 키(JWKS)를 가져오는 코드를 둔다. - DB: notes 에 user_id 열을 더하는 새 마이그레이션. 기존 마이그레이션은 고치지 않는다. - web: 로그인 후 받은 토큰을 API 요청 헤더에 붙인다. 제약: 테스트에서 실제 제공자를 부르지 않는다. 키 조회는 Fake 로 바꾸고 테스트용 키로 토큰을 만든다. mock 은 쓰지 않는다. 완료 기준: /api/notes 의 각 *_specs 에 "토큰이 없으면 401", "다른 사용자의 메모는 " 를 더하고, 기존 경우는 자기 토큰으로 지금처럼 통과한다. pnpm gen 결과가 같은 커밋에 있다. 확인: pnpm --filter @app/notes test 와 pnpm typecheck 를 실행한다. 화면은 verify-local 로 띄워 확인할 주소를 알려 준다. 보고: 실행한 확인과 결과, 실행하지 못한 확인과 이유, 범위 밖 변경이 필요했다면 그 이유와 대안을 나눠 적는다. ``` 요청을 나누는 기준은 [AI 와 개발하기](https://stack.initializer.org/getting-started/agents/#일을-맡기는-요청)의 "일을 맡기는 요청"과 같다. --- Source: https://stack.initializer.org/guides/troubleshooting/ # 자주 겪는 문제 설치 · 실행 · 테스트 중 자주 겪는 문제를 증상 · 원인 · 해결로 적는다. ## web(React) 화면이 비어 있다 - **증상**: 테스트 · 빌드는 통과하는데 브라우저에서 web 이 빈 화면이고 콘솔에만 오류가 난다. - **원인**: `react` 와 `react-dom` 버전이 다르다. `^` 범위로 두면 pnpm 이 둘을 따로 고를 수 있다. - **해결**: `pnpm-workspace.yaml` 카탈로그에서 두 패키지를 Expo 가 정한 같은 버전으로 고정한다. 올릴 때도 카탈로그에서 함께 바꾼다. ## site(Next)가 엉뚱한 서버를 부른다 - **증상**: `API_URL` 을 바꿔 띄웠는데 브라우저의 `/api` 요청이 예전 서버로 간다. - **원인**: `next.config.ts` 의 rewrites 주소는 `next build` 때 정해진다. `API_URL` 을 실행할 때 읽는 것은 서버 컴포넌트뿐이다. - **해결**: 빌드할 때 준다: `API_URL=http://127.0.0.1:8103 pnpm --filter @app/site build`. 이미지는 빌드 인자 `API_URL`. ## 앱의 요청이 502 나 프록시 오류로 실패한다 - **증상**: 화면에 `notes 서버에 연결할 수 없습니다` 같은 오류가 뜨거나, 개발 서버 로그에 `/api` 프록시 오류(`ECONNREFUSED 127.0.0.1:8003`)가 난다. - **원인**: 앱은 gateway 하나로 부른다. 앞의 것은 gateway 는 떴는데 그 뒤의 서버가 없고, 뒤의 것은 gateway 자체가 없다. 앱만 따로 띄우면(`pnpm --filter @app/web dev`) 둘 다 생긴다. - **해결**: 루트 `pnpm dev` 로 서버 셋과 gateway 를 같이 띄운다. 따로 띄운 서버에 붙일 때는 gateway 에 `NOTES_URL` · `TAGS_URL` · `ACTIVITY_URL` 을 준다. ## `pnpm install` 이 설치 스크립트 때문에 실패한다 - **증상**: `ERR_PNPM_IGNORED_BUILDS` 와 함께 설치가 멈춘다. - **원인**: pnpm 12 는 의존성의 설치 스크립트를 기본으로 막고, 막힌 것이 있으면 실패한다. - **해결**: [development.md](/reference/development/#의존성-설치) 처럼 `allowBuilds` 에 적는다. ## 테스트가 `ECONNREFUSED 127.0.0.1:5432` 로 실패한다 - **원인**: 테스트는 docker 의 Postgres 를 쓰는데 떠 있지 않다. - **해결**: `pnpm infra`. 루트의 `pnpm test` · `pnpm dev` 는 먼저 띄우지만 `pnpm --filter` 로 하나만 돌릴 때는 직접 띄운다. ## 5432 포트가 이미 쓰이고 있다 - **원인**: 이 컴퓨터에 다른 Postgres 가 떠 있다. - **해결**: `docker/.env.example` 을 `docker/.env` 로 복사해 `POSTGRES_PORT` 를 바꾸고, 서버 · 테스트에는 같은 값으로 `PGPORT` 를 준다. ## 이름을 바꾼 뒤 DB 가 비어 있다 - **원인**: `pnpm rename` 이 compose 프로젝트 이름을 바꿔서 Postgres 볼륨도 새 이름(`_db`)으로 만들어진다. - **해결**: 템플릿 상태로 띄운 적이 있다면 예전 볼륨이 남아 있다. 필요 없으면 지운다: `docker volume rm initializer-stack_db`. ## 브랜치를 바꾼 뒤 서버가 마이그레이션 오류로 뜨지 않는다 - **증상**: 브랜치를 바꾸거나 main 을 받은 뒤 서버 · 테스트가 시작하다 멈춘다. - **원인**: DB 의 적용 기록과 마이그레이션 파일이 다르다. 도구마다 멈추는 경우가 다르다: | 경우 | notes (node-pg-migrate) | activity (Flyway) | tags (Alembic) | |---|---|---|---| | 적용한 마이그레이션을 고쳤다 | 고친 내용을 무시하고 뜬다 | `Migration checksum mismatch for migration version 3` | 고친 내용을 무시하고 뜬다 | | main 에도 같은 번호가 생겼다 | main 의 것이 이름순으로 앞이면 `Not run migration 0003_a is preceding already run migration 0003_b` | `Found more than one migration with version 3` | `Multiple head revisions are present` | | 적용한 마이그레이션이 없는 브랜치로 왔다 | 뜬다 (만든 표는 남는다) | 뜬다 (만든 표는 남는다) | `Can't locate revision identified by '0003'` | - **해결**: - 고쳤다: 되돌리고 바꿀 것을 새 번호로 더한다. notes · tags 도 같다. 오류는 없지만 이미 적용한 DB 에는 고친 내용이 들어가지 않아 DB 마다 스키마가 달라진다. - 번호가 겹쳤다: 내 것을 main 의 마지막 다음 번호로 바꾼다 (tags 는 `revision` · `down_revision` 도). 내 DB 에는 예전 번호가 기록돼 있으니 그 서버의 개발 · 테스트 DB 를 다시 만든다 → [database.md](/guides/database/#개발-db-를-처음부터). - 브랜치를 오갔다: 마이그레이션이 있는 브랜치로 돌아가면 그대로 쓴다. 이 브랜치에서 계속 쓰려면 그 서버의 DB 를 다시 만든다. ## admin 의 vue-tsc · site 의 next build 가 TypeScript 7 에서 돌지 않는다 - **원인**: 저장소 기본은 TypeScript 7 인데 vue-tsc 와 next build 는 TS 의 JS API 를 쓰고, TS 7 에는 아직 그 API 가 없다. - **해결**: 그 앱만 `"typescript": "catalog:ts6"`. ## Expo 패키지를 설치할 수 없다 - **증상**: Expo 를 올리거나 `npx expo install --fix` 뒤 pnpm 이 너무 새 버전이라며 설치하지 않는다. - **원인**: pnpm 은 나온 지 하루가 안 된 버전을 설치하지 않는다(`minimumReleaseAge`). - **해결**: [development.md](/reference/development/#의존성-설치) 처럼 예외를 두지 않고 범위의 아래 끝을 그 전 버전으로 낮춘다. ## zsh 에서 정리 명령이 통째로 멈춘다 - **증상**: `rm -rf clients/apps/*/dist` 같은 명령이 `no matches found` 로 아무것도 하지 않는다. - **원인**: zsh 는 맞는 파일이 없는 `*` 를 만나면 명령 전체를 멈춘다. - **해결**: `find` 를 쓴다: `find clients/apps -maxdepth 2 -name dist -type d -prune -exec rm -rf {} +`. --- Source: https://stack.initializer.org/reference/ # 문서 규칙 · 참조: | 문서 | 내용 | |---|---| | [architecture.md](/reference/architecture/) | 레포 구조, 서버 레이어, 서버 공통 규칙 | | [contracts.md](/reference/contracts/) | HTTP(서버 코드 → openapi → 클라이언트 타입) · gRPC(공통 proto) 계약과 이름 규칙 | | [languages.md](/reference/languages/) | 같은 일(HTTP 목록 · gRPC · 마이그레이션 · 테스트)을 Node · FastAPI · Spring 으로 나란히 | | [clients.md](/reference/clients/) | 클라이언트 공통: createClient · TanStack Query · 카탈로그 · UI | | [testing.md](/reference/testing/) | 테스트 규칙: 실행 경계 · 이름 · 본문 · Fake · 격리 | | [development.md](/reference/development/) | 명령 · 포트 · 환경 변수 · 인프라(docker) · 이미지 · CI · 의존성 설치 · 이름 바꾸기 · 예시 빼기 | | [decisions/](/reference/decisions/) | 설계 결정(ADR) | 이해하기 (규칙이 왜 이렇게 됐는지): | 문서 | 내용 | |---|---| | [concepts/structure.md](/concepts/structure/) | 한 저장소 · 계약 하나 · 코드 기준 HTTP 와 proto 기준 gRPC · 서버 레이어 · 공통 규칙의 이유와 대가 | | [concepts/testing.md](/concepts/testing/) | 실제 서버 · 실제 Postgres · TRUNCATE · Fake(mock 이 아닌) · 이름 규칙의 이유, 테스트 하나 읽기 | 절차: | 문서 | 내용 | |---|---| | [guides/add-endpoint.md](/guides/add-endpoint/) | 엔드포인트 추가: 업무 로직 → 라우트 → 테스트 → 계약 생성 → 클라이언트, 쓰기 엔드포인트에서 달라지는 곳 | | [guides/contract-lab.md](/guides/contract-lab/) | HTTP 필드 · proto 번호를 바꿔 타입 검사와 호환 검사를 실패시키고 복구하기 | | [guides/external-service-lab.md](/guides/external-service-lab/) | 실제 서버 · DB 는 그대로 두고 외부 요약 서비스를 Fake 로 바꿔 실패 재현하기 | | [guides/call-server.md](/guides/call-server/) | 다른 서버 부르기: proto → 받는 서버 → clients → 실패했을 때 할 일 → Fake 테스트 → 연결 | | [guides/add-server.md](/guides/add-server/) · [add-app.md](/guides/add-app/) | 서버 · 앱을 더하거나 빼거나 이름을 바꿀 때 채우는 연결 지점 | | [guides/database.md](/guides/database/) | 스키마 바꾸기, 개발 DB 처음부터, 다른 DB 로 | | [guides/auth.md](/guides/auth/) | 인증 붙이기: 정할 것 · 붙일 자리 · 에이전트에게 맡기는 요청 예시 (템플릿에는 인증이 없다) | | [guides/troubleshooting.md](/guides/troubleshooting/) | 자주 겪는 문제: 증상 · 원인 · 해결 | 서버 · 앱마다의 세부(구조 · 명령 · 도구 · 테스트 방법)는 그 폴더의 `AGENTS.md`: `servers/<서버>/` · `clients/apps/<앱>/`. --- Source: https://stack.initializer.org/reference/architecture/ # 구조 pnpm 워크스페이스 하나에 서버 · 클라이언트를 여럿 둔다. 서버 · 클라이언트는 언어 · 프레임워크가 달라도 되고, 서로를 잇는 건 `contract/` 의 언어 중립 계약뿐이다: 클라이언트 ↔ 서버는 HTTP/JSON(각 서버가 코드에서 만든 `openapi/<서버>.json`), 서버 ↔ 서버는 gRPC(공통 `proto/`). 계약은 [contracts.md](/reference/contracts/). 왜 이렇게 나눴는지는 [concepts/structure.md](/concepts/structure/). ``` template/ ├── contract/ │ ├── openapi/<서버>.json · .ts # 서버가 코드에서 만든 HTTP API 문서 · 그 TS 타입 (`pnpm gen`, 직접 고치지 않음) │ ├── index.ts # @app/contract: 서버별 네임스페이스 (notes · tags · activity) │ ├── proto/app/{도메인}/v1/*.proto # 서버 ↔ 서버 gRPC 계약 (기준). 디렉터리 = 패키지 │ ├── buf.yaml · buf.gen.yaml # buf lint(STANDARD) · breaking(FILE) · TS 코드 생성 설정 │ └── gen/ # proto 의 TS 코드 (@app/contract/gen/..., 직접 고치지 않음) ├── servers/ # 서버 하나 = 폴더 하나, 이름은 맡은 일. 서버마다 자기 언어 · API · 도구를 갖는다 │ ├── notes/ # 메모, 앱의 진입점 — Node 24 · zod · Connect-ES (:8000) │ ├── activity/ # 활동 기록 — Spring Boot 4.1 · Java 25 · Gradle 멀티 모듈 (:8001 · gRPC :9001) │ ├── tags/ # 태그 규칙 · 태그 매기기 — FastAPI · Python 3.12 · uv (:8002 · gRPC :9002) │ └── gateway/ # 앱의 진입점: /api/<접두사> 를 서버로 — Go 표준 라이브러리 (:8003) ├── clients/ # 앱 여섯과 공용 API 클라이언트. lint · format 설정은 클라이언트 전체가 하나 │ ├── apps/ │ │ ├── web/ # 웹 앱 (React) │ │ ├── admin/ # 관리 앱 (Vue) │ │ ├── site/ # 공개 사이트 (Next) │ │ ├── lite/ # 가벼운 웹 앱 (순수 TS) │ │ ├── mobile/ # 모바일 앱 (Expo) │ │ └── desktop/ # 데스크톱 앱 (Tauri) │ ├── packages/client/ # @app/client: 서버가 만든 타입으로 API 를 부르는 공용 fetch │ ├── nginx.conf.template # 웹 앱 이미지: 정적 파일을 내주고 /api 를 넘긴다 │ └── .oxlintrc.json · .oxfmtrc.json # 클라이언트 공통 lint · format 설정 ├── docker/ # 인프라(Postgres) · 전체 스택 compose · smoke.sh. 개발 · 테스트 · CI 가 같이 쓴다 ├── .github/ # CI (workflows/ci.yml) · PR 템플릿 ├── scripts/ # rename.mjs(이름 바꾸기, 한 번 쓰고 지워진다) · drop.mjs(쓰지 않는 예시 서버 · 앱 빼기) ├── docs/ # 규칙 · 이해하기(concepts/) · 절차(guides/) · 설계 결정(decisions/) ├── DESIGN.md # UI 의 기준: 색 · 글자 · 간격 · 컴포넌트 토큰과 규칙 (DESIGN.md 형식) ├── .agents/skills/ # 에이전트 skill (.claude/skills 는 이 폴더로 가는 링크) ├── .claude/ # Claude Code 설정 · hook (settings.json, hooks/). 개인 설정은 settings.local.json (gitignore) ├── pnpm-workspace.yaml # 패키지 목록 · 클라이언트 의존성 카탈로그 · 설치 스크립트 허용 목록 └── tsconfig.base.json # TS 패키지 공통 설정 ``` 각 서버 · 클라이언트의 세부는 그 폴더의 `AGENTS.md`. 예시 서버 셋은 맡은 일이 다르고, 메모를 쓰는 요청 하나에 함께 움직인다. 앱은 gateway 하나만 알고, 서버끼리는 gateway 를 거치지 않고 gRPC 로 부른다. ``` 앱 ──HTTP /api/*──▶ gateway (Go) ──▶ /api/notes → notes (Node) ──gRPC──▶ tags (FastAPI) 저장 전에 태그를 묻는다 │ └───gRPC──▶ activity (Spring) 저장 뒤 기록을 남긴다 ├──▶ /api/tags → tags (admin: 태그 규칙) └──▶ /api/activity → activity (admin: 활동 기록) ``` 폴더 이름은 맡은 일이고 구현 언어는 그 서버의 선택이다. 같은 일을 언어마다 어떻게 쓰는지는 [languages.md](/reference/languages/). ## 서버 레이어 모든 서버가 같은 레이어로 나누고, 바깥에서 안으로만 부른다. ``` api (HTTP 라우트 · gRPC 서비스 · 보안) → domain (업무 로직, HTTP · gRPC 공용) → storage (DB) · clients (외부 시스템 하나당 하나) support: 오류 형식 · 라우트 도구처럼 레이어와 무관한 것 ``` | 레이어 | notes · Node (`src/`) | activity · Spring (모듈) | tags · FastAPI (`server/`) | | ------- | ------------------------- | -------------------------------------------------------------- | -------------------------- | | 진입점 | `main.ts` | `core/core-api` `CoreApiApplication` | `main.py` (uvicorn) | | api | `api/http/` | `core/core-api` `app.core.api.{controller,grpc}` | `api/routes/` · `api/rpc/` | | domain | `domain/` | `core/core-api` `app.core.domain.*` | `domain/` | | storage | `storage/` (postgres.js) · `migrations/` (node-pg-migrate) | `storage/db-core` (JdbcClient + Flyway) | `storage/` (psycopg 3) · `migrations/` (Alembic) | | clients | `clients/` (tags · activity 의 gRPC) | `clients/client-*` 모듈 (생기면) | `clients/` (생기면) | | support | `support/` | `core/core-api` `app.core.support.*` | `support/` | - domain 이 storage 를 그대로 넘기기만 하는 위임도 레이어라서 둔다. - gateway 는 이 레이어의 예외다(`servers/gateway/AGENTS.md`). 업무 로직 · DB · 자기 API 가 없고 경로 표 하나로 요청을 넘긴다. 업무가 생기면 그 일을 맡은 서버에 둔다. - clients 는 밖에 인터페이스(교체 지점)만 보이고 구현은 숨긴다. 테스트에서는 Fake 로 바꾼다 ([testing.md](/reference/testing/)). ## 서버 공통 규칙 언어가 달라도 다음은 같게 한다 (클라이언트가 서버를 가리지 않게). 서버마다 어디서 지키는지는 그 서버 `AGENTS.md` 의 "규칙": - `package.json` 에 `dev` · `test` · `gen` · `lint` · `format` (+ 있으면 `typecheck`) 스크립트. JS 가 아닌 서버는 자기 도구를 감싼다. 그래서 루트의 `pnpm -r` 이 모든 서버에 돈다. - lint · format · 의존성 버전은 서버마다 자기 언어 도구 · 자기 설정. 루트에 언어별 설정을 두지 않는다. 클라이언트는 공통 설정 · 카탈로그 ([clients.md](/reference/clients/)). - 포트는 `PORT`(HTTP) · `GRPC_PORT`(gRPC). 서버를 여럿 띄우면 서로 다르게 ([development.md](/reference/development/) 의 포트 표). 부르는 다른 서버의 주소는 `<서버>_GRPC`(예: notes 의 `TAGS_GRPC`). - 바인딩 주소는 `HOST`, 기본 127.0.0.1 (HTTP · gRPC 같이). 컨테이너 등 밖에 열 때는 `HOST=0.0.0.0`. 예시 서버에는 인증이 없으니 밖에 열기 전에 인증을 붙이고 gRPC 는 TLS(mTLS)로. - 쓰기(POST · PUT)는 JSON 본문만 받는다 (form 같은 단순 요청은 다른 사이트에서 몰래 보낼 수 있다). DELETE 는 본문 없이. 입력은 신뢰 경계: 문서에 적힌 필드 · 타입만 받고 모르는 필드는 버린다. - 응답 코드: 만들기 201(만든 것을 돌려준다), 지우기 204(본문 없음), 나머지 200. 오류는 `{ "error": "..." }`: 잘못된 입력 400, 없는 자원 · 라우트 404, 다른 메서드 405, 서버 · 다른 서버 · 외부 서비스 오류 502. - 다른 서버 · 외부 서비스가 실패할 때 어떻게 할지는 호출마다 정해 그 서버 `AGENTS.md` 에 적는다. 예시: notes 는 태그 없이는 저장하지 않으니 tags 실패면 502, 활동 기록은 저장을 막을 이유가 없으니 activity 실패면 경고만 남기고 응답한다. - DB 는 Postgres (`docker/docker-compose.middleware.yaml`, ADR-0004, 바꾸려면 [guides/database.md](/guides/database/)). 서버마다 자기 DB(`<서버>`, 테스트 `<서버>_test`)를 갖고, 다른 서버의 DB 를 직접 읽지 않는다(gRPC 로 부른다). 연결은 `PGHOST` · `PGPORT` · `PGDATABASE` · `PGUSER` · `PGPASSWORD`. - 서버마다 `Dockerfile` ([development.md](/reference/development/) 의 "이미지"). - 앱이 부르는 서버는 경로 접두사(`/api/<자원>`)가 서버마다 겹치지 않게 하고, gateway 의 경로 표에 한 줄을 더한다. - gateway 는 위 규칙 중 `gen` · DB 가 없다. 자기 API 가 없어 문서를 만들 것이 없다. ## 클라이언트 모두 JS/TS 이고 `@app/client` 의 `createClient<서버.paths>()` 로 서버를 부른다. 주소는 gateway 하나다(`API_URL`). 서버 데이터는 TanStack Query, 의존성 버전은 카탈로그. 공통 규칙은 [clients.md](/reference/clients/), 앱마다의 것은 그 앱의 `AGENTS.md`. --- Source: https://stack.initializer.org/reference/contracts/ # 계약 ## HTTP: 서버 코드가 기준 (ADR-0001) 서버가 코드에서 openapi 문서를 만들고, 클라이언트 타입은 그 문서에서 만든다. 손으로 쓰는 문서를 따로 관리할 때 생기는 중복을 줄이고, 생성된 타입과 클라이언트 코드가 어긋나면 typecheck 에서 잡는다. ``` 서버 코드 ──(pnpm gen)──▶ contract/openapi/<서버>.json ──▶ <서버>.ts ──▶ @app/client 의 createClient<서버.paths>() ``` - `pnpm gen` 순서: proto 코드 → 각 서버가 문서 생성 → TS 타입. `pnpm typecheck` 가 먼저 돌린다 (Java · uv 필요). - 생성물은 커밋하고 직접 고치지 않는다 (hook 이 막는다). CI 는 `pnpm gen && git diff --exit-code contract/` 로 갱신을 빠뜨린 것을 잡는다. - 서버마다 문서를 만드는 방법(zod · springdoc · FastAPI)은 그 서버의 `AGENTS.md`. - 생성기(openapi-typescript)는 TypeScript 의 JS API 가 필요해서 `contract` 패키지는 TS 6(`catalog:ts6`)을 쓴다 (TS 7 에는 아직 JS API 가 없다). - 다른 언어 클라이언트는 `contract/openapi/<서버>.json` 에서 클라이언트를 생성한다. ### 보장하는 범위 | 검사 | 확인하는 것 | 확인하지 않는 것 | |---|---|---| | 생성물 비교 | 지금 코드에서 만든 문서 · 타입이 커밋과 같은가 | 실제 서버가 모든 경우에 문서대로 응답하는가 | | 클라이언트 typecheck | 현재 저장소의 호출 코드가 생성된 타입에 맞는가 | 이미 배포된 옛 클라이언트와의 호환, 응답의 업무적 의미 | | 서비스 테스트 | 작성한 시나리오의 실제 HTTP · gRPC 응답과 저장 결과 | 작성하지 않은 경우, 실제 외부 서비스 · 배포 환경 | `@app/client` 는 JSON 응답을 생성된 타입으로 취급하며 런타임 스키마 검증을 하지 않는다. 서버 코드의 타입 단언이나 응답 스키마 선언이 실제 값과 다르면 생성만으로 잡히지 않을 수 있다. 입력 검증 · 응답 검증 · 타입 검사는 서로 다른 일이다. 외부에서 받은 응답을 실행 중에도 검증해야 하면 그 경계에 별도의 스키마 검증을 더한다. HTTP 의 과거 계약과의 호환을 자동 비교하는 검사도 현재 CI 에는 없다. 기존 소비자가 남아 있으면 필드를 바로 지우거나 이름을 바꾸지 말고, 새 필드를 함께 제공한 뒤 소비자를 옮기는 순서를 정한다. 같은 PR 에서 함께 수정할 수 있다는 것이 동시에 배포된다는 뜻은 아니다. ### 이름 (서버가 늘어도 안 부딪히게) - 문서는 서버마다 따로 두고 **하나로 합치지 않는다**. 서버끼리 같은 스키마 이름이 생겨도 TS 네임스페이스로 갈린다: `notes.components['schemas']['Note']` · `tags.components['schemas']['Rule']`. 게이트웨이용으로 꼭 합쳐야 하면 합칠 때 서버 이름을 접두사로 붙인다 (예: `redocly join --prefix-components-with-info-prop title`). - 한 서버 안에서는 경로 · 스키마 이름 · operationId 가 유일해야 한다. 서버마다 지키는 방법(그리고 조용히 덮어써지는 곳)은 그 서버의 `AGENTS.md`. - 서버마다 경로 접두사를 하나 두고(`/api/notes` · `/api/tags` · `/api/activity`) 겹치지 않게 한다. 앱은 gateway 하나로 부르고, gateway 의 경로 표가 접두사마다 서버를 고른다. gateway 는 경로 · 응답을 바꾸지 않으니 앱은 서버마다의 타입을 그대로 쓴다. ## gRPC: 공통 proto 가 기준 (ADR-0002) 서버 간 통신의 기본은 gRPC 다. 공통 proto 는 `contract/proto` 하나만 두고 서버마다 복사하지 않는다 (여러 언어가 같은 파일을 읽어야 해서 HTTP 와 달리 proto 가 기준이다). 언어별 생성 방법은 그 서버의 `AGENTS.md`. - buf 로 lint(STANDARD) · 호환 검사(FILE). 디렉터리 = 패키지(`app/tag/v1` → `app.tag.v1`). - 예시: `app.tag.v1.TagService/Suggest`(tags 가 구현, notes 가 부른다) · `app.activity.v1.ActivityService/Record`(activity 가 구현, notes 가 부른다). proto 하나를 Python(grpcio) · Java(grpc-java)가 서버로, TS(Connect-ES)가 클라이언트로 쓴다. - 언어별 옵션(`option java_multiple_files` 등)은 같은 proto 에 둔다. - 필드 번호는 바꾸거나 다시 쓰지 않는다 (지운 필드는 `reserved`). 배포된 서버끼리 버전이 섞이기 때문이다. CI 는 변경 전 커밋과 `buf breaking` 으로 비교한다. 로컬에서는 `contract/` 에서 `pnpm exec buf breaking --against '../.git#branch=main,subdir=contract'` 로 확인하고 꼭 필요하면 `v2` 패키지를 새로 만든다. - 오류는 gRPC status (잘못된 입력 `INVALID_ARGUMENT`, 없음 `NOT_FOUND`, 다른 서버 · 외부 실패 `UNAVAILABLE`). - 다른 서버의 gRPC 클라이언트는 외부 클라이언트다: `clients/` 에 두고 테스트에서는 Fake 로 바꾼다. 실패했을 때 응답을 어떻게 할지는 호출마다 정한다 ([architecture.md](/reference/architecture/) 의 서버 공통 규칙, 순서는 [guides/call-server.md](/guides/call-server/)). - 평문 HTTP/2(h2c)다. 서버 밖으로 열 때는 TLS(mTLS) · 인증부터. 필드 변경을 직접 실패시켜 보는 절차는 [계약을 깨뜨려 보기](/guides/contract-lab/)에 있다. --- Source: https://stack.initializer.org/reference/languages/ # 같은 일, 세 언어 예시 서버 셋은 맡은 일이 다르다(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 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 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)); } ``` --- Source: https://stack.initializer.org/reference/clients/ # 클라이언트 모든 앱이 같이 따르는 것. 앱마다의 구조 · 명령 · 서버 부르는 방식은 그 앱의 `AGENTS.md`(`clients/apps/<앱>/`). ``` clients/ ├── apps/{web,admin,site,lite,mobile,desktop}/ # React · Vue · Next · 순수 TS · Expo · Tauri ├── packages/client/ # @app/client: createClient<서버.paths>() → get · put · query ├── .oxlintrc.json · .oxfmtrc.json # 모든 앱 · 패키지가 같이 쓴다 (oxlint · oxfmt 가 위로 찾아 올라간다) └── nginx.conf.template # web · admin · lite 이미지의 nginx: 정적 파일 + /api → API_URL(gateway) ``` ## 서버 부르기 TS 앱은 부를 서버마다 클라이언트를 하나 만든다. 모든 서버의 타입이 열려 있지만 경로마다 생기는 코드는 없어서 API 가 늘어도 번들은 그대로다. 다른 언어 앱은 `contract/openapi/<서버>.json` 에서 클라이언트를 생성한다 ([contracts.md](/reference/contracts/)). ```ts const api = createClient({ name: 'notes', base: 'http://127.0.0.1:8003' }) // name · base(gateway) · fetch 는 선택 await api.get('/api/notes') // 경로 · 응답 타입은 notes 서버 문서에서 await api.post('/api/notes', { text }) // 201 본문(만든 메모)을 돌려준다. put 은 (경로, 본문, { params }) await api.delete('/api/notes/{id}', { params: { id } }) // 경로 변수가 있으면 params 가 필수(빼면 타입 오류), 204 는 void useQuery(api.query('/api/notes')) // TanStack Query (React · Vue). 키 = [name, 경로, params, query] ``` - `name`: 쿼리 키의 첫 칸. 경로가 같은 서버가 둘이어도 캐시가 섞이지 않게. 주소가 아니라 이름이라 서버 렌더링(절대 주소)과 브라우저(상대 경로)의 키가 같다. - `base`: 같은 출처(프록시)면 생략, 네이티브 앱 · 서버 렌더링은 서버 주소. `fetch`: CORS 없는 fetch 가 필요할 때(Tauri). - 주소는 gateway 하나다(`servers/gateway`). 같은 출처 프록시 · nginx 는 `/api` 를 `API_URL`(gateway)로 넘기고, gateway 가 경로 접두사로 서버를 고른다. 네이티브 앱의 `base` 도 gateway 주소다. - 서버를 둘 이상 부르면 서버마다 클라이언트를 하나씩 만든다(타입이 서버마다라서). admin 은 `createClient({ name: 'tags' })` · `createClient({ name: 'activity' })`. ## 서버 데이터 - TanStack Query (`@tanstack/react-query` · `@tanstack/vue-query`). 쿼리 옵션은 손으로 짜지 않고 `api.query()` 를 쓴다. - 만들기 · 고치기 · 지우기 뒤에는 `invalidateQueries({ queryKey: api.query('/api/notes').queryKey })` 로 목록을 다시 받는다. - 프레임워크가 없는 앱(lite · desktop)은 `api.get` · `post` · `put` · `delete` 그대로. - API 응답 문자열은 텍스트로만 렌더한다 (`innerHTML` · `v-html` · `dangerouslySetInnerHTML` 금지, hook 이 막는다). ## 의존성 · 도구 - 버전은 `pnpm-workspace.yaml` 카탈로그 한 곳에서 (`"react": "catalog:"`). Expo 가 정하는 버전은 `catalog:expo` (올릴 때 `npx expo install --fix`), TS 의 JS API 가 필요한 도구(admin 의 vue-tsc · site 의 next build)는 `catalog:ts6`. 서버는 카탈로그를 쓰지 않는다. - `react` · `react-dom` 은 워크스페이스 전체가 **정확히 같은 한 버전**(Expo 가 정한 것)이다. `^` 범위로 두면 pnpm 이 둘을 따로 골라, 테스트 · 빌드는 통과하고 브라우저에서만 화면이 빈다. - 모든 패키지에 `lint`(`oxlint && oxfmt --check`) · `format`, 앱에는 `typecheck`. 기본은 TS 7(`tsc -p .`). TS 의 JS API 를 쓰는 도구가 있으면 그 앱만 자기 TS 를 둔다(TS 7 에는 아직 안정된 JS API 가 없다). - 웹 앱(web · admin · site · lite)은 `Dockerfile` 이 있다. 정적 앱은 nginx 가 `/api` 를 gateway 로 넘기고(`nginx.conf.template`), site(Next)는 standalone. mobile · desktop 은 네이티브 빌드라 이미지가 없다 ([development.md](/reference/development/) 의 "이미지"). - 새 앱은 [guides/add-app.md](/guides/add-app/). ## UI 색 · 글자 · 간격 · 둥글기 · 컴포넌트와 그 쓰임은 저장소 루트의 [DESIGN.md](/reference/design/) 가 기준이다 ([DESIGN.md 형식](https://github.com/google-labs-code/design.md): 앞의 YAML 이 값, 본문이 규칙). 새 값이 필요하면 거기 먼저 더하고, `pnpm dlx @google/design.md@0.4.0 lint DESIGN.md` 로 확인한다. --- Source: https://stack.initializer.org/reference/testing/ # 테스트 언어와 상관없이 같은 규칙 (ADR-0003). 공통 설정 · 실제 서버 띄우기 · Fake 넣기 · 격리 방법은 그 서버의 `AGENTS.md`. 왜 이런 규칙인지, 테스트 하나를 줄마다 읽는 예는 [concepts/testing.md](/concepts/testing/). ## 실행 경계 기본은 **서비스 범위의 API 통합 테스트**다. 실제 서버를 랜덤 포트로 띄우고 HTTP · gRPC 로 요청한다. api · domain · storage 는 실제 구현을 쓰고, 외부 클라이언트(clients)만 Fake 로 바꾼다. ``` HTTP · gRPC → 서버(랜덤 포트) → 본문 검증 → api → domain ─┬─ storage → 테스트 DB (docker 의 Postgres) HTTP · gRPC ← JSON 응답 ← 오류 변환(400/502) ←────────────────────────┴─ clients → Fake ``` - 테스트 DB 는 운영과 같은 엔진, `docker/docker-compose.middleware.yaml` 의 Postgres 에 서버마다 `<서버>_test` 다. 먼저 `pnpm infra` (루트 `pnpm test` · CI 는 스스로 띄운다). 개발 DB `<서버>` 는 건드리지 않는다. - 인증 · 브라우저 · 프록시(vite · next) · 실제 외부 서비스는 이 테스트 범위 밖이다. 필요하면 따로 둔다 (브라우저까지는 `verify-local` skill). - 좁은 계층(예: 스트림 전송)만 검사할 때는 domain 을 대역으로 바꿀 수 있지만, 결과도 그 범위로만 해석한다. - gateway 는 업무 · DB 가 없다. 실제 gateway 를 랜덤 포트로 띄우고 뒤의 서버를 Fake HTTP 서버로 바꿔, 요청이 맞는 서버로 그대로 가고 없는 경로는 404, 닿지 못하면 502 인지 본다. 서버의 자원이 없으니 파일은 경로 표 하나(`gateway_specs_test.go`)다. ## 인프라 기반 확인 테스트가 기대는 인프라는 `docker/` 의 compose 한 벌이다. 개발 · 테스트 · CI 가 같은 파일로 띄운다. | 층 | 띄우는 것 | 돌리는 곳 | |---|---|---| | 서비스 테스트 (`{METHOD}_specs`) | 서버 프로세스 + Postgres (`docker-compose.middleware.yaml`) | `pnpm test`, CI | | 전체 스택 (`docker/smoke.sh`) | 서버 · 웹 앱 이미지 + compose 네트워크 + Postgres (`docker-compose.yaml`) | `pnpm stack` (로컬) | - 경우마다의 동작(성공 · 400 · 502 · 저장 상태)은 서비스 테스트가 본다. 전체 스택은 이미지 · 네트워크 · 환경 변수가 이어지는지만 본다: 메모 하나를 만들어 notes → tags(태그) → activity(기록) gRPC 흐름이 실제로 이어지는지, gateway 가 경로마다 맞는 서버로 넘기는지, 웹 앱은 화면 · `/api` 프록시 · site 서버 렌더링. 서버끼리 부르는 흐름이 새로 생기면 그 흐름 하나를 smoke 에 더한다. - 인프라(캐시 · 큐 · 스토리지)를 더하면 `docker-compose.middleware.yaml` 에 서비스를 더하고, 테스트용 자원(DB · 버킷 이름)을 서버마다 따로 둔다. 실제 외부 서비스(결제 · 메일)는 인프라가 아니라 Fake 다. ## 이름 - 디렉터리 = 리소스 경로, 파일 = `{METHOD}_specs` (대문자). Path 변수는 하위 디렉터리: `/api/notes` → `notes/GET_specs`, `/api/notes/{id}/tags` → `notes/id/tags/GET_specs` - 묶음 이름은 메서드 + 엔드포인트: `GET /api/notes/{id}`. - gRPC 는 `grpc/{Service}/{Rpc}_specs`, 묶음 이름은 gRPC 경로 `/app.tag.v1.TagService/Suggest`. 상태 코드 자리에 gRPC status 를 쓴다: `action이_비었으면_INVALID_ARGUMENT를_반환한다`. - 테스트 이름은 한글_밑줄로 조건과 기대 결과: `올바른_메모를_보내면_200_OK를_반환하고_DB에_저장된다`. 상태 코드가 핵심이면 코드명을, 저장 결과가 핵심이면 저장 결과를 넣는다. - 기존 API 의 새 사례는 그 `*_specs` 에 추가. 순수 함수 단위 테스트는 목적이 드러나는 이름으로 따로. - 준비 코드는 `{Domain}Fixture`, 외부 대역은 `Fake{Name}` 처럼 역할을 이름에 드러낸다. 다른 서버의 gRPC 클라이언트 대역도 같다: notes 의 `FakeTagService` · `FakeActivityService`. ## 본문 - Arrange → Act → Assert. 한 테스트는 시나리오 하나, assertion 은 여러 개여도 된다. - HTTP 응답 · 저장 상태 · 필요한 부수 효과를 함께 검사한다. 저장 상태는 storage 의 저장소로 확인한다 (테스트에 스키마 SQL 을 다시 쓰지 않는다). - 오류 응답은 문자열 조각이 아니라 JSON 필드로 비교한다 (`{ error: '...' }`). - 호출 횟수는 중복 방지 · 재시도처럼 횟수 자체가 계약일 때만 검사한다. ## Fake - 외부 클라이언트는 교체 지점을 하나 둔다. 실제 구현은 만들 때 외부에 접속하지 않게(처음 호출할 때 연결) 한다. - mock(`@MockitoBean`, `mock.module`, `patch`)으로 바꾸지 않는다. hook 이 막는다. - Fake 는 테스트 공통 설정 옆에 두고, 공통 설정에서 한 번 등록한다. - Fake 는 외부 조건만 통제한다: 반환값 · 다음 호출 실패(`failNext()`) · 받은 인자 기록 · 완료 보류. 업무 정책이나 재시도를 Fake 안에 다시 구현하지 않는다. 재시도 검증은 "첫 호출 실패, 다음 성공" 으로 설정하고 재시도는 실제 domain 이 하게 한다. - Fake 의 실패(예: 연결 실패)를 API 오류 계약(400/502)으로 바꾸는 건 실제 domain · 오류 처리다. 그 연결이 검증 대상이다. notes 의 예: `fakeTags.failNext()` 면 502 이고 저장되지 않는다, `fakeActivity.failNext()` 면 그래도 201 이다. - 상태(실패 플래그 · 카운터 · 마지막 인자 · 기록)는 `reset()` 으로 비우고 공통 설정의 테스트 전후에 부른다. - 메모리 기록은 실제 발송 성공의 근거가 아니다. 클라이언트 자체(요청 형식 · 인증 · 오류)는 실제 서버나 계약을 재현한 테스트 서버로 따로 검증한다. ## 격리 - 같은 Fake · DB 를 쓰는 테스트는 병렬로 돌리지 않는다 (한 테스트의 reset 이 다른 테스트를 지운다). - 서버 · 컨텍스트를 테스트끼리 같이 쓰면 DB · Fake 를 테스트마다 비운다. 서버는 다른 스레드라 rollback 을 가정하지 않는다. - 비울 때 마이그레이션 기록 표(`flyway_schema_history` · `pgmigrations` · `alembic_version`)는 남긴다. TRUNCATE 는 id 를 되돌리지 않으니 id 는 응답 · 저장소가 준 값을 쓴다. - 서버마다 테스트 DB 가 달라서 서버끼리는 같이 돌아도 된다 (`pnpm -r test`). ## 스트리밍 (SSE 를 추가하면) 응답을 다 받은 뒤 `data:` 를 검사하면 서버가 모아서 보내도 통과한다. 이벤트를 받은 시점에 서버 쪽 작업이 아직 끝나지 않았는지 확인한다 (완료를 latch · Promise 로 보류했다가 검사 후 풀기, 고정 sleep 금지, 대기 상한 둠). `X-Accel-Buffering: no` 헤더가 있다고 실제 프록시의 버퍼링까지 검증된 건 아니다. --- Source: https://stack.initializer.org/reference/development/ # 개발 ## 명령 ```bash pnpm install pnpm infra # docker 의 Postgres (dev · test 가 먼저 부른다). 끄기 pnpm infra:down pnpm dev # 기본 조합: 서버 셋(notes · tags · activity) + gateway + web → http://localhost:5280 pnpm test # 모든 서버의 test (activity = Java 25, tags = uv 필요) pnpm stack # 서버 이미지를 빌드해 전체 스택을 띄우고 docker/smoke.sh. 끄기 pnpm stack:down pnpm gen # proto → contract/gen, 각 서버 코드 → contract/openapi/<서버>.json → .ts pnpm typecheck # gen + 모든 패키지의 typecheck pnpm lint # 모든 패키지의 lint (각자 도구) pnpm format # 모든 패키지의 format pnpm --filter @app/<패키지> <스크립트> # 하나만. 예: @app/activity dev, @app/admin dev, @app/mobile ios, @app/desktop start ``` 필요한 도구: Node 24(`.nvmrc`) · pnpm 12 · Docker(compose v2.20+), activity 는 Java 25, tags 는 uv(Python 3.12), gateway 는 Go 1.25(`go.mod`), desktop 은 Rust(`rust-toolchain.toml` 이 버전을 고정, rustup 이 처음에 받는다). ## 포트 | | HTTP | gRPC | 확인용(`verify-local`, +100) | |---|---|---|---| | notes (Node) | 8000 | | 8100 | | activity (Spring) | 8001 | 9001 | 8101 · 9101 | | tags (FastAPI) | 8002 | 9002 | 8102 · 9102 | | gateway (Go) | 8003 | | 8103 | | web · admin · site · lite | 5280 · 5281 · 5282 · 5283 | | 5380 ~ 5383 | | desktop (Tauri 개발 서버) | 1420 | | | | Postgres (`pnpm infra`) | 5432 | | (같은 것을 쓰고 DB 만 `<서버>_test`) | - 서버 포트는 `PORT` · `GRPC_PORT`, 바인딩은 `HOST`(기본 127.0.0.1, 컨테이너는 0.0.0.0), 앱이 부르는 주소는 `API_URL`(gateway, 기본 `http://127.0.0.1:8003`. vite 는 dev 때, next 는 빌드 때)로 바꾼다. gateway 가 넘길 서버는 `NOTES_URL` · `TAGS_URL` · `ACTIVITY_URL`, notes 가 부르는 서버는 `TAGS_GRPC` · `ACTIVITY_GRPC`(기본 `http://127.0.0.1:9002` · `:9001`). - 에이전트는 사용자가 띄운 개발 서버를 끄거나 거기에 요청하지 않는다. 확인은 +100 포트 · 테스트 DB(`<서버>_test`)로 따로 띄우고, 끌 때는 그 포트의 PID · 자기가 띄운 컨테이너만 끈다 (`pkill -f` 금지: 같은 명령줄의 다른 프로젝트 서버까지 꺼진다). Postgres 는 사용자의 개발 DB 도 들어 있으니 `down -v` 로 지우지 않는다(사용자가 말할 때만). ## 환경 변수 `.env` 는 서버마다 선택 (`servers/<서버>/.env.template` 참고). `HOST` · `PORT` · `GRPC_PORT`, DB 는 libpq 와 같은 `PGHOST` · `PGPORT` · `PGDATABASE` · `PGUSER` · `PGPASSWORD` (기본: `pnpm infra` 의 127.0.0.1:5432, DB = 서버 이름, app/app). 테스트는 `PGDATABASE` 만 `<서버>_test` 로 바꾼다. ## 인프라 (docker/) ``` docker/ ├── docker-compose.middleware.yaml # 서버가 쓰는 인프라 (Postgres). pnpm infra, 테스트 · CI 도 이것 ├── docker-compose.yaml # 전체 스택: 위 파일 include + 서버 · 웹 앱 이미지. pnpm stack ├── postgres/init.sql # 서버마다 DB · 테스트 DB 를 만든다 (볼륨을 처음 만들 때만) ├── smoke.sh # 전체 스택이 뜬 뒤 notes → tags · activity 흐름, 웹 앱의 화면 · /api 프록시. 서버 · 앱마다 한 덩어리 └── .env.example # docker/.env 로 복사해 포트를 바꾼다 (POSTGRES_PORT · NODE_PORT …) ``` - 두 파일의 compose 프로젝트 이름(`name:`)이 같아서 같은 Postgres 를 쓴다. 데이터는 볼륨 `<이름>_db` 에 남는다. 처음부터 다시 만들기 · 다른 DB 로 바꾸기는 [guides/database.md](/guides/database/). - 전체 스택은 서버 · gateway 의 HTTP 와 웹 앱만 이 맥(127.0.0.1)에 연다. 서버끼리는 compose 네트워크 안에서 gRPC 로 부른다(notes → `tags:9002` · `activity:9001`). 개발 서버와 같이 띄우려면 `NODE_PORT` · `WEB_PORT` 등을 다르게 (`docker/.env.example`). - 5432 가 이미 쓰이면 `docker/.env` 에 `POSTGRES_PORT`, 서버 · 테스트에는 같은 값으로 `PGPORT`. ## 이미지 - 서버 · 웹 앱마다 `Dockerfile` (mobile · desktop 은 네이티브 빌드라 없다). 컨텍스트는 그 폴더이고, 저장소 루트의 공유 파일 (lockfile · `contract/` · `@app/client` · `tsconfig.base.json`)은 이름 붙인 컨텍스트 `workspace` 에서 `COPY --from=workspace` 로 받는다. BuildKit 은 COPY 한 경로만 보내서 저장소 전체가 넘어가지 않는다. gateway 는 공유 파일이 필요 없어 그 폴더만 쓴다. - 하나만: 그 폴더에서 `docker build --build-context workspace=../.. .` (앱은 `../../..`). compose 는 `additional_contexts`. - 단계를 나눠 실행 이미지에는 실행에 쓰는 것만(dev 의존성 · 빌드 도구 · 캐시 없음), 실행 사용자는 root 가 아니다. - 폴더를 통째로 COPY 하는 이미지는 그 폴더에 `.dockerignore` (activity: build · .gradle, tags: `__pycache__`). 나머지는 경로를 골라 COPY 한다 (`node_modules` 를 넘기면 이미지 안에서 설치한 것을 덮는다). - web · admin · lite: `dist` + nginx(`clients/nginx.conf.template`), `/api` → `API_URL`(실행할 때). site: standalone, rewrites 의 `API_URL` 은 빌드 인자 · 서버 컴포넌트는 실행할 때. 기본은 compose 의 gateway(`http://gateway:8003`). - gateway 이미지의 `NOTES_URL` · `TAGS_URL` · `ACTIVITY_URL` 은 compose 가 서비스 이름(`http://notes:8000` …)으로 준다. ## CI (.github/) `workflows/ci.yml` 의 job 하나(`CI`)가 PR · main push · merge queue 마다 루트 명령을 차례로 돌린다. 명령이 로컬과 같아서 실패하면 같은 명령을 로컬에서 돌려 본다. ``` pnpm install → proto 호환 → 템플릿 스크립트 목록(rename · drop --check) → pnpm infra → pnpm typecheck(gen 포함) → 생성물이 커밋과 같은가 → pnpm lint → pnpm -r test ``` - 브랜치 보호의 필수 검사는 `CI` 하나다. 서버 · 앱을 더하거나 빼도 워크플로를 고치지 않는다. - proto 호환(`buf breaking`)은 PR 의 대상 브랜치 커밋, merge queue 의 기준 커밋, main push 의 직전 커밋과 비교한다. 첫 push 는 이전 커밋이 없어 이 검사만 건너뛴다. - 전체 스택(이미지 · smoke)은 CI 에서 돌리지 않는다. 이미지 · 네트워크를 바꿨으면 로컬에서 `pnpm stack`. - 생성물이 다르다고 실패하면 로컬에서 `pnpm gen` 을 돌려 함께 커밋한다. - PR 은 `.github/pull_request_template.md` 의 확인 목록을 채운다. 한 PR 에는 한 작업만. - 같은 PR 의 이전 실행은 취소한다(concurrency). 권한은 `contents: read` 만. - 워크플로를 바꾸면 `actionlint` 로 확인한다 (`docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest`, git 저장소 안에서). ## 의존성 설치 - 설치 스크립트는 기본으로 막혀 있다(pnpm 12, 막힌 것이 있으면 `pnpm install` 이 실패한다). 스크립트 없이 동작하는지 확인한 것만 `pnpm-workspace.yaml` 의 `allowBuilds` 에서 `false` 로 끄고 이유를 주석에 쓴다. 꼭 돌려야 하는 것만 `true`. - pnpm 은 나온 지 하루가 안 된 버전을 설치하지 않는다(`minimumReleaseAge`). 막히면 예외(`minimumReleaseAgeExclude`)를 두지 않고 범위의 아래 끝을 그 전 버전으로 낮춘 뒤(예: `~58.0.14` → `~58.0.12`), 하루가 지나면 `pnpm update`. lockfile 에 이미 막힌 버전이 있으면 pnpm 이 lockfile 을 거부하니 `pnpm-lock.yaml` 을 지우고 `pnpm install` 로 다시 만든다 (범위 안에서만 바뀐다). - `pnpm-workspace.yaml` 을 통째로 다시 쓰면 pnpm 이 넣은 항목이 사라진다. 고칠 때는 덧붙인다. ## 이름 바꾸기 템플릿으로 저장소를 만든 뒤 한 번 `pnpm rename "<표시 이름>" [slug]`. 템플릿 이름 `iStack`(README · AGENTS · DESIGN 제목, 앱 title · Tauri 창 · Expo 앱 이름)과 slug `initializer-stack`(루트 package.json · compose 프로젝트, Expo slug `-mobile`, Tauri identifier `.desktop`)을 바꾸고 스크립트는 스스로 지워진다. slug 를 생략하면 표시 이름에서 만든다(`My Shop` → `my-shop`, 영문이 아니면 slug 를 준다). - 저장소 안에서만 쓰는 이름은 그대로 둔다: `@app/*` scope, Java 패키지 `app`, Postgres 계정 `app`. 밖에 보이지 않아 프로젝트 이름과 달라도 된다. - proto 패키지 `app.tag.v1` · `app.activity.v1` 은 gRPC 경로(`/app.tag.v1.TagService/Suggest`)로 밖에 보인다. 다른 저장소와 proto 를 나눠 쓰게 되면 첫 배포 전에 바꾼다: `contract/proto/<이름>/` 로 옮기고 `package` · 각 서버의 import · gRPC 테스트 이름을 고친 뒤 `pnpm gen`. - 템플릿에 보이는 이름을 더하면 `scripts/rename.mjs` 의 목록에도 더한다. CI 가 `--check` 로 목록이 파일과 맞는지 본다. 템플릿 이름 자체를 바꿀 때는 목록의 곳을 모두 고친 뒤 스크립트의 `FROM` 한 줄. ## 예시 빼기 쓰지 않는 예시 서버 · 앱은 `pnpm drop <이름...>` 으로 뺀다(예: `pnpm drop site lite mobile`). 폴더와 함께 그것을 가리키는 설정 줄을 지운다: 루트 `gen` · `dev`, `contract/index.ts` · `contract/openapi/<서버>.*`, `docker/postgres/init.sql`, compose 서비스, `smoke.sh` 포트, `docker/.env.example`. 끝나면 `pnpm install` 로 lockfile 을 정리한다. - 남는 것이 부르는 서버는 빼지 않는다: 앱은 `createClient<서버.paths>`, 서버는 `<서버>_GRPC` · `<서버>_URL` 로 찾는다. tags · activity 는 notes · admin · gateway 가 부르니 함께 빼거나 그 호출을 고친 뒤 다시 돌린다. gateway 는 앱이 하나라도 남으면 빼지 않는다(앱은 모두 gateway 로 부른다). - 문서(README · docs · AGENTS.md)의 설명은 고치지 않고, 남은 언급이 있는 파일을 알려 준다. ADR 은 기록이라 그대로 둔다. - 템플릿에 서버 · 앱을 더하면 `scripts/drop.mjs` 의 목록에도 더한다. CI 가 `--check` 로 지울 줄이 파일에 있는지 본다. --- Source: https://stack.initializer.org/reference/design/ # iStack 디자인 모든 클라이언트 앱(web · admin · site · lite · mobile · desktop)이 따르는 화면 규칙이다. 값은 위 토큰이 기준이고, 아래 글은 그 값을 언제 쓰는지다. 이름 끝의 `-dark` 는 다크 모드 값이다 (`prefers-color-scheme: dark`, React Native 는 `useColorScheme`). 컴포넌트의 `-dark` 는 바뀌는 값만 적는다. ## Overview Apple 시스템 앱처럼 조용하고 단정하다. 내용이 주인공이라 꾸밈은 내용을 가리지 않을 만큼만 둔다. 화면만 봐도 아는 것에는 설명 문구를 달지 않고, 출처 · 가정처럼 정직하게 밝혀야 할 것만 짧게 쓴다. 라이트 · 다크를 모두 지원하고, 둘 다 같은 정성으로 맞춘다. ## Colors 색은 Apple 시스템 색과 그룹 배경만 쓴다. 새 색이 필요하면 시스템 색에서 고르고 여기 토큰으로 더한다. - **Primary (systemBlue):** 누를 수 있는 것에만. 링크 · 주 버튼 · 선택 표시. - **Background (그룹 배경):** 페이지 바탕. 그 위에 surface 카드가 뜬다. - **Surface:** 카드 · 시트처럼 내용을 담는 면. - **On-surface:** 본문 글자. 보조 글자는 같은 색의 투명도로 만든다 (새 회색을 만들지 않는다). - **Fill:** 입력칸 · 버튼처럼 테두리 없이 구분되는 칸. - **Danger (systemRed):** 삭제처럼 되돌릴 수 없는 조작의 글자에만. - **Warning container:** 오류 · 실패 알림의 면. systemOrange(#FF9500 · 다크 #FF9F0A)를 surface 에 14% 만 섞는다 (글자는 on-surface). ## Typography 시스템 글꼴 하나(SF · Apple SD Gothic Neo)를 무게와 크기로만 나눈다. 웹 글꼴은 불러오지 않는다. - **Title (22px · 700):** 화면 제목, 한 화면에 하나. - **Headline (17px · 600):** 카드 · 구역 제목. - **Body (14px):** 본문 · 입력. 자간을 살짝 좁혀(-0.01em) 시스템 앱과 같은 밀도를 낸다. - **Caption (12px · 500):** 태그처럼 내용에 붙는 짧은 표시. - **Body-mobile (16px):** 모바일 입력칸. 16px 보다 작으면 iOS 가 입력할 때 화면을 확대한다. ## Layout 카드를 세로로 쌓는 단일 열이 기본이다. 내용 폭은 `page-max` 까지. 간격은 4px 단위: 요소 사이 `md`(12), 카드 사이 `lg`(16), 페이지 가장자리 `xl`(24). ## Elevation & Depth 깊이는 그림자보다 면의 톤으로 낸다: background → surface. 라이트에서만 카드에 아주 옅은 그림자(`0 1px 2px` · `0 4px 16px`, 검정 4%)를 두고, 다크에서는 그림자 없이 톤 차이로만. 스크롤해도 남는 머리글은 background 를 72% 만 칠하고 뒤를 흐리게 비춘다(`saturate(180%) blur(20px)`). ## Shapes 테두리선 없이 둥근 면으로 나눈다. 큰 면일수록 더 둥글게: 입력칸 `sm`(8) · 알림 `md`(12) · 카드 `lg`(18). ## Components - **Page:** background 바탕에 on-surface 본문. - **Card:** surface 면, 테두리 없음, 안쪽 20px. 한 카드에는 한 가지 일만 담는다. - **Input:** fill 칸, 테두리 없음. 포커스는 글자색 30% 의 3px 바깥선(`:focus-visible`). 모바일은 손가락으로 누르므로 안쪽 · 둥글기를 `md`(12)로 키운다. - **Button:** fill 칸에 primary 글자, 테두리 없음. 안쪽은 위아래 `sm`(8) · 좌우 `md`(12). 삭제처럼 되돌릴 수 없는 것은 글자만 danger. 요청이 끝나기 전에는 막고 40% 로 흐린다. 모바일은 손가락으로 누르므로 위아래도 `md`(12), 글자는 body-mobile. - **Tag:** fill 칸에 on-surface 글자(caption), 테두리 없음. 안쪽은 위아래 `xs`(4) · 좌우 `sm`(8), 칩 사이도 `xs`(4). 누를 수 없으니 primary 를 쓰지 않는다. 내용 아래에 줄을 바꿔 가며 늘어놓고, 태그가 없으면 칸도 두지 않는다. - **Alert:** warning container 면에 on-surface 글자. 오류 내용을 그대로 보여 준다 (`role="alert"`). - **Header:** 화면 제목과 그 화면 전체에 걸리는 조작만. 반투명 · 흐림으로 스크롤 위에 뜬다. - **Link:** primary 글자, 밑줄 없이. 누를 수 있는 글자에만 primary 를 쓴다. ## Do's and Don'ts - **Do:** 같은 정보 · 조작은 한 곳에만 둔다. - **Do:** 새 값이 필요하면 먼저 이 파일의 토큰을 더하고 코드에서 그 값을 쓴다. - **Do:** 라이트 · 다크를 둘 다 띄워 확인한다 (`verify-local` skill). - **Don't:** 장식용 색 · 그라디언트 · 테두리선 · 진한 그림자를 쓰지 않는다. - **Don't:** 화면만 봐도 아는 설명 문구, 같은 내용을 되풀이하는 제목 · 안내를 달지 않는다. - **Don't:** 시스템 글꼴 밖의 글꼴, 토큰에 없는 크기 · 간격 · 둥글기를 쓰지 않는다. ## Tokens ```yaml version: alpha name: iStack description: Apple 시스템 앱처럼 조용한 화면. 시스템 색 · 시스템 글꼴, 라이트 · 다크 둘 다 colors: primary: "#007AFF" primary-dark: "#0A84FF" background: "#F2F2F7" background-dark: "#000000" surface: "#FFFFFF" surface-dark: "#1C1C1E" on-surface: "#1D1D1F" on-surface-dark: "#F5F5F7" fill: "rgba(118, 118, 128, 0.12)" fill-dark: "rgba(118, 118, 128, 0.24)" danger: "#FF3B30" danger-dark: "#FF453A" warning-container: "color-mix(in srgb, #FF9500 14%, #FFFFFF)" warning-container-dark: "color-mix(in srgb, #FF9F0A 14%, #1C1C1E)" typography: title: fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Apple SD Gothic Neo', system-ui, sans-serif" fontSize: 22px fontWeight: 700 lineHeight: 1.45 letterSpacing: -0.025em headline: fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Apple SD Gothic Neo', system-ui, sans-serif" fontSize: 17px fontWeight: 600 lineHeight: 1.45 letterSpacing: -0.02em body: fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Apple SD Gothic Neo', system-ui, sans-serif" fontSize: 14px fontWeight: 400 lineHeight: 1.45 letterSpacing: -0.01em caption: fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Apple SD Gothic Neo', system-ui, sans-serif" fontSize: 12px fontWeight: 500 lineHeight: 1.45 body-mobile: fontFamily: "-apple-system, 'Apple SD Gothic Neo', system-ui, sans-serif" fontSize: 16px fontWeight: 400 lineHeight: 1.45 spacing: xs: 4px sm: 8px md: 12px lg: 16px xl: 24px xxl: 32px page-max: 1440px rounded: sm: 8px md: 12px lg: 18px components: page: backgroundColor: "{colors.background}" textColor: "{colors.on-surface}" typography: "{typography.body}" page-dark: backgroundColor: "{colors.background-dark}" textColor: "{colors.on-surface-dark}" card: backgroundColor: "{colors.surface}" rounded: "{rounded.lg}" padding: 20px card-dark: backgroundColor: "{colors.surface-dark}" input: backgroundColor: "{colors.fill}" textColor: "{colors.on-surface}" typography: "{typography.body}" rounded: "{rounded.sm}" padding: "{spacing.sm}" input-dark: backgroundColor: "{colors.fill-dark}" textColor: "{colors.on-surface-dark}" button: backgroundColor: "{colors.fill}" textColor: "{colors.primary}" typography: "{typography.body}" rounded: "{rounded.sm}" padding: "{spacing.sm}" button-dark: backgroundColor: "{colors.fill-dark}" textColor: "{colors.primary-dark}" button-danger: textColor: "{colors.danger}" button-danger-dark: textColor: "{colors.danger-dark}" tag: backgroundColor: "{colors.fill}" textColor: "{colors.on-surface}" typography: "{typography.caption}" rounded: "{rounded.sm}" padding: "{spacing.xs}" tag-dark: backgroundColor: "{colors.fill-dark}" textColor: "{colors.on-surface-dark}" alert: backgroundColor: "{colors.warning-container}" textColor: "{colors.on-surface}" rounded: "{rounded.md}" padding: "{spacing.md}" alert-dark: backgroundColor: "{colors.warning-container-dark}" textColor: "{colors.on-surface-dark}" header: backgroundColor: "color-mix(in srgb, #F2F2F7 72%, transparent)" padding: "{spacing.xl}" header-dark: backgroundColor: "color-mix(in srgb, #000000 72%, transparent)" link: textColor: "{colors.primary}" link-dark: textColor: "{colors.primary-dark}" ``` --- Source: https://stack.initializer.org/reference/servers/activity/ # Activity 서버 활동 기록: 다른 서버(notes)가 gRPC `ActivityService/Record` 로 남기고, 앱(admin)이 HTTP `GET /api/activity` 로 본다. Spring Boot 4.1 · Java 25 · Gradle 멀티 모듈. HTTP :8001 · gRPC :9001, DB 는 Postgres(`docker/docker-compose.middleware.yaml`, 먼저 `pnpm infra`). 레포 전체 규칙은 루트 `AGENTS.md`, 이 파일이 이 서버에서는 우선한다. 명령은 이 폴더에서 돈다. ## 구조 레이어(`docs/architecture.md` 서버 레이어)는 패키지와 Gradle 모듈로 나눈다. api · domain · support 는 실행 앱 `core/core-api` 의 패키지, storage · clients 는 모듈이다. 의존은 `api → domain → storage · clients` 방향만, 패키지는 모두 `app` 아래. ``` core/core-api/ # 실행 앱(bootJar) ├── build.gradle.kts # proto → gRPC 코드(protobuf 플러그인), openapi 태스크 └── src/main/ ├── java/app/ │ ├── CoreApiApplication.java # 진입점: app 아래 전부(core · storage)를 읽고, 엄격한 JSON(strictStrings) │ └── core/ │ ├── api/ # api 레이어 │ │ ├── controller/ActivityController.java # HTTP 라우트 + record DTO (Bean Validation = 검증 = 문서) │ │ └── grpc/ActivityGrpcService.java # gRPC 서비스 구현 + 요청 검증 │ ├── domain/activity/ActivityService.java # domain 레이어: 업무 로직 (HTTP · gRPC 공용) │ └── support/error/ApiErrors.java # support: HTTP 오류 형식 └── resources/application.properties # 포트 · 바인딩 · DB 연결 (환경 변수) storage/db-core/ # storage 레이어(모듈): *Repository(JdbcClient)와 그 행 *Entity(record)만 밖에 보인다 ├── src/main/java/app/storage/db/core/ActivityRepository.java · ActivityEntity.java └── src/main/resources/db/migration/V{n}__<이름>.sql # Flyway. 서버가 뜰 때 적용 clients/client-<이름>/ # clients 레이어(모듈): 외부 시스템 하나당 하나 (생기면). 밖에는 *Client 인터페이스만, 구현은 숨긴다 build.gradle.kts # 모듈 공통: Java 25 · Boot BOM · javaformat · -Xlint:all -Werror · JUnit. 모듈은 자기 의존성만 settings.gradle.kts # 모듈 목록 (모듈을 더하면 include) Dockerfile # 이미지 (.dockerignore 가 build · .gradle 을 뺀다) core/core-api/src/test/java/app/ ├── CoreApiTest.java · CleanDatabase.java # 공통 설정: 랜덤 포트 HTTP · gRPC, 테스트 DB(activity_test), 테스트 앞뒤로 TRUNCATE ├── OpenApiExport.java # pnpm gen: 실제 서버를 띄워 contract/openapi/activity.json (DB 없이) ├── {resource}/{METHOD}_specs.java # activity/GET_specs (경로 변수는 하위 패키지: activity/id/GET_specs) └── grpc/{service}/{Rpc}_specs.java # grpc/activityservice/Record_specs (패키지는 소문자) ``` ## 규칙 - 쓰기는 JSON 본문만 (`@PostMapping` · `@PutMapping(consumes = JSON)`, 아니면 `ApiErrors` 가 400). 모르는 필드는 record 가 버린다. - 오류는 `{ "error" }` (`ApiErrors`): 검증 실패 · 잘못된 JSON · 잘못된 경로 값(`{id}` 가 숫자가 아니거나 `@Positive` 위반) 400, 그 외 502, 없는 경로 404, 다른 메서드 405. - 경로 값에 제약(`@Positive`)이 있으면 본문 검증도 `HandlerMethodValidationException` 으로 온다(`MethodArgumentNotValidException` 이 아니다). - 바인딩은 `HOST` (`server.address` · `spring.grpc.server.address`, 기본 127.0.0.1). 밖에 열 때 `0.0.0.0`, 그 전에 인증 · TLS. - DB 연결은 `PGHOST` · `PGPORT` · `PGDATABASE`(기본 `activity`) · `PGUSER` · `PGPASSWORD` (`application.properties`). ## 명령 ```bash ./gradlew :core:core-api:bootRun # pnpm dev ./gradlew test # pnpm test ./gradlew :core:core-api:openapi # pnpm gen → contract/openapi/activity.json (DB 없이 돈다) ./gradlew checkFormat compileJava compileTestJava # pnpm lint (포맷 + 컴파일러 경고를 오류로) ./gradlew format # pnpm format (spring-javaformat, 탭) docker build --build-context workspace=../.. . # 이미지 (이 폴더에서) ``` ## 계약 - springdoc 이 컨트롤러 · record · Bean Validation(`@NotNull` = required, `@Size` = maxLength)에서 문서를 만든다. - springdoc 은 **테스트 의존성**이다. 운영 앱에는 문서 경로가 없고, `OpenApiExport` 가 실제 서버를 랜덤 포트에 띄워 `/v3/api-docs` 를 받아 쓴다. `@CoreApiTest` 를 쓰지 않고 Flyway 를 꺼서 DB 없이 돈다(`pnpm typecheck` 도 gen 을 먼저 돌린다). - 같은 단순 클래스명 둘은 springdoc 이 **경고 없이 하나로 덮어쓴다**. DTO 는 `{도메인}{역할}`(`Activity` · `ActivityList`, 저장소 행은 `ActivityEntity`)처럼 서버 안에서 유일하게. - 응답 record 도 필드에 `@NotNull` 을 붙여야 문서에서 required 다(원시 타입 `long` 도). `OffsetDateTime` 은 `string`(date-time). - operationId 는 메서드 이름이라 `get` 대신 `listActivity` 처럼 짓는다(겹치면 `get_1` 처럼 번호가 붙어 불안정해진다). - 문자열 필드에 숫자 · 불리언이 오면 400 이다(Jackson 강제 변환을 끔, `strictStrings`). 끄면 `{"text": 1}` 이 `"1"` 로 저장된다. ## gRPC - `core/core-api` 가 `../../../../contract/proto` 에서 생성한다. protobuf-gradle-plugin 에 `plugins { create("grpc") }` 선언만 두면 Boot 플러그인이 protoc · grpc-java 버전과 태스크를 맞춘다(직접 `generateProtoTasks` 를 쓰면 중복으로 실패한다). - 생성 코드(`build/generated`)는 포맷 검사에서 뺀다. - 요청 검증은 서비스 구현(`ActivityGrpcService`)에서 하고, 실패는 `onError(Status.INVALID_ARGUMENT...)` 로 바로 돌려준다. 여기서 처리하지 않은 예외는 UNKNOWN. domain 예외를 status 로 바꿔야 하면 `GrpcExceptionHandler` Bean 하나를 support 에 둔다. ## 테스트 - `@CoreApiTest`: `RANDOM_PORT` + `TestRestTemplate`, gRPC 포트 0, 테스트 DB `activity_test`(운영과 같은 Postgres), `CleanDatabase` 가 테스트 앞뒤로 모든 표를 `TRUNCATE` 한다(Flyway 이력은 둔다). 서버는 다른 스레드라 `@Transactional` rollback 을 가정하지 않는다. - 파일 `core/core-api/src/test/java/app/activity/GET_specs.java`, 경로 변수 `{id}` 는 하위 패키지(`app/activity/id/GET_specs.java`), gRPC 는 `app/grpc/activityservice/Record_specs.java`(Java 패키지는 소문자). `@DisplayName("GET /api/activity")` · `@DisplayName("/app.activity.v1.ActivityService/Record")`, 메서드 `void 기록을_최근_것부터_반환한다()`. - 준비 · 저장 상태 확인은 저장소(`ActivityRepository`)로. id 는 DB 가 매기고 `TRUNCATE` 가 되돌리지 않으니 저장소가 돌려준 id 를 쓴다(1 을 가정하지 않는다). - gRPC 클라이언트는 `@LocalGrpcServerPort` + `ManagedChannel` + blocking stub. - 응답은 `ParameterizedTypeReference>` 처럼 타입을 적어 받는다(원시 `Map` 은 `-Werror` 에 걸린다). 작은 JSON 정수는 `Integer` 로 읽히니 기대값도 `(int) a.id()`, 시각은 문자열이라 `OffsetDateTime.parse` 로 비교한다. 본문 없는 204 는 `String.class` 로 받아 `null` 을 본다. - 외부 클라이언트는 `@MockitoBean` 대신 Fake 를 `@TestConfiguration` 의 `@Primary` Bean 으로(`IntegrationTestConfig` 를 만들어 `@CoreApiTest` 에 `@Import`). 실제 Bean 이 만들 때 외부에 접속하면 조건부 등록으로 막는다. hook 이 `@MockitoBean *Client` 를 막는다. ## 주의 - 스키마 변경은 새 `V{n}__*.sql`. main 에 들어간 마이그레이션은 고치지 않는다(체크섬이 어긋나 서버가 뜨지 않는다, hook 이 막는다). - javac 경고는 오류다. `serial` 만 끈다(예외 클래스는 직렬화하지 않는다). --- Source: https://stack.initializer.org/reference/servers/gateway/ # 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 의 받은 요청 수로 본다. --- Source: https://stack.initializer.org/reference/servers/notes/ # 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/ ├── main.ts # 진입점: 마이그레이션 → HTTP listen ├── config.ts # 환경 변수 (HOST · PORT · TAGS_GRPC · ACTIVITY_GRPC) ├── api/http/server.ts # HTTP 서버: 경로 변수 · JSON 본문 검증 · 오류 변환 + 라우트 표(mergeRoutes) ├── api/http/<도메인>.ts # 라우트 (zod 스키마 = 검증 = 문서) ├── domain/<도메인>.ts # 업무 로직 (다른 서버를 언제 부르고 실패를 어떻게 다루는지도 여기) ├── storage/db.ts # Postgres 연결 (PG* 환경 변수, 기본 docker 의 notes DB, 처음 쿼리할 때 연결) ├── storage/migrate.ts # migrations/ 적용 (node-pg-migrate, 기록은 pgmigrations 표) ├── storage/<도메인>.ts # 저장소 (SQL) ├── clients/ # 다른 서버 · 외부 시스템 하나당 하나 (tags.ts · activity.ts, 교체 지점과 함께) ├── support/ # route.ts (get · post · put · del · mergeRoutes · match) · errors.ts (BadRequest → 400 · NotFound → 404) └── openapi.ts # pnpm gen: 라우트 표 → contract/openapi/notes.json migrations/ # NNNN_<이름>.sql (-- Up Migration / -- Down Migration). 서버가 뜰 때 적용 package.json # 스크립트 · 의존성. tsconfig.json · .oxlintrc.json · .oxfmtrc.json 은 타입 · lint · format 설정 .env.template # 환경 변수 (.env 로 복사, 선택) Dockerfile # 이미지 (실행 단계는 dev 의존성 없이) tests/ ├── support/api.ts # 공통 설정: 테스트 DB(notes_test) 마이그레이션, Fake 등록 · 비우기, 랜덤 포트 HTTP 서버, request · sendJson ├── support/fakes.ts # 다른 서버 대역 (FakeTagService · FakeActivityService) ├── {resource}/{METHOD}_specs.ts # notes/GET_specs · notes/id/PUT_specs (경로 변수 = 하위 폴더) └── **/*.test.ts # 순수 함수 단위 테스트 (support/route.test.ts) ``` ## 명령 ```bash 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 이 막는다). ```ts // 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 를 쓴다. --- Source: https://stack.initializer.org/reference/servers/tags/ # Tags 서버 키워드 → 태그 규칙을 관리하고(HTTP, admin 앱), 메모 내용에 맞는 태그를 준다(gRPC `TagService/Suggest`, notes 가 메모를 저장하기 전에 부른다). FastAPI · Python 3.12 · uv. HTTP :8002 · gRPC :9002 (같은 프로세스, gRPC 는 lifespan 에서 띄운다), DB 는 Postgres(psycopg 3 · 연결 풀). 레포 전체 규칙은 루트 `AGENTS.md`, 이 파일이 이 서버에서는 우선한다. ## 구조 폴더 = 레이어 (`docs/architecture.md` 서버 레이어). 의존은 `api → domain → storage · clients` 방향만. ``` server/ ├── __init__.py # gen/ 을 import 경로에 넣는다 (proto 패키지 app.tag.v1 그대로) ├── main.py # 앱 조립: 라우터 · gRPC(lifespan) · security · errors ├── api/routes/rules.py # HTTP 라우트 /api/tags/rules + pydantic 모델 (검증 = 문서) ├── api/rpc/tags.py # gRPC TagService (grpc.server, 스레드풀) ├── domain/rules.py # 업무 로직: 규칙 관리 · suggest (HTTP · gRPC 공용) ├── storage/db.py # Postgres 연결 풀 (PG* 환경 변수, 기본 docker 의 tags DB, 처음 부를 때 연다) · migrate() ├── storage/rules.py # 저장소 (SQL) ├── clients/ # 외부 시스템 (생기면) ├── support/errors.py # 오류 형식 · 422 → 400 · 문서 고치기 ├── support/security.py # JSON 본문만 └── gen.py # pnpm gen: proto → gen/, 앱 → contract/openapi/tags.json gen/ # proto 생성 코드 (gitignore) migrations/ # Alembic: env.py (연결) · versions/ (리비전, 원시 SQL). 서버가 뜰 때 적용 pyproject.toml # 의존성(런타임 · dev 그룹) · ruff · pytest · alembic 설정. uv.lock 은 고정된 버전 .env.template # 환경 변수 (.env 로 복사, 선택) Dockerfile # 이미지 (gen 단계에서 proto 생성 · 실행 단계는 dev 의존성 없이, .dockerignore 가 __pycache__ 를 뺀다) tests/ ├── conftest.py # 공통 설정: 랜덤 포트 uvicorn · gRPC, 테스트 DB(tags_test), fixture client · channel · clean_db ├── {resource}/{METHOD}_specs.py # tags/rules/GET_specs · tags/rules/id/DELETE_specs (경로 변수 = 하위 폴더) └── grpc/{Service}/{Rpc}_specs.py # grpc/TagService/Suggest_specs ``` 폴더 이름은 `api/routes` · `api/rpc` 다. `http` · `grpc` 로 지으면 표준 라이브러리 `http` · 라이브러리 `grpc` 와 헷갈린다. ## 규칙 - 쓰기(POST · PUT)는 JSON 본문만 (`support/security.py`). 모르는 필드는 pydantic 이 버린다. - 오류는 `{ "error" }` (`support/errors.py`): 검증 실패 400 (FastAPI 기본 422 아님), 없는 자원 404 (domain 이 `NotFound`), 그 외 502, 없는 경로 404, 다른 메서드 405. - 바인딩은 `HOST` (uvicorn `--host` · gRPC 같이, 기본 127.0.0.1). 밖에 열 때 `0.0.0.0`, 그 전에 인증 · TLS. - DB 는 `docker/docker-compose.middleware.yaml` 의 Postgres (루트에서 `pnpm infra`). 연결은 `PGHOST` · `PGPORT` · `PGDATABASE` · `PGUSER` · `PGPASSWORD` (기본 127.0.0.1:5432 · `tags` · app/app). - 스키마는 Alembic 리비전(`migrations/versions/`)이 정한다. 서버가 뜰 때 lifespan 이 `storage/db.py` 의 `migrate()` (`upgrade head`)로 요청을 받기 전에 맞춘다. 테스트도 서버를 띄우며 같이 돈다. `pnpm gen` 은 lifespan 을 돌리지 않아 DB 가 없어도 된다. - 리비전은 `op.execute(...)` 원시 SQL. SQLAlchemy 는 `migrations/env.py` 의 연결에만 쓰고 앱은 psycopg. 적용된 리비전은 고치지 않고 새 리비전을 더한다. downgrade 는 두지 않는다(Flyway 처럼 앞으로만). - 핸들러 · gRPC 메서드는 `def` 다. DB 드라이버가 동기라 스레드풀(FastAPI · `grpc.server(ThreadPoolExecutor())`)에서 돌린다. `async def` 안에서 DB 를 부르면 이벤트 루프가 막힌다. ## 명령 ```bash pnpm dev # gen proto → uvicorn --reload (${HOST:-127.0.0.1}:${PORT:-8002}). DB 는 먼저 루트에서 pnpm infra pnpm test # gen proto → pytest (DB 가 없으면 5초 안에 멈추고 pnpm infra 를 알린다) pnpm gen # gen proto + contract/openapi/tags.json pnpm typecheck # gen proto → pyrefly check pnpm lint # ruff check + ruff format --check pnpm format # ruff check --fix + ruff format uv run alembic revision -m rule_order --rev-id 0002 # 새 리비전 migrations/versions/0002_rule_order.py (DB 없이) uv run alembic history # 리비전 목록. 적용 상태는 PGDATABASE=tags_test uv run alembic current uv sync # 의존성 설치 (pyproject.toml · uv.lock) docker build --build-context workspace=../.. . # 이미지 (이 폴더에서) ``` ## 계약 - FastAPI 가 pydantic 모델 · 라우트에서 문서를 만든다. 모델 이름이 스키마 이름이라 서버 안에서 유일하게(같은 이름 둘은 모듈 경로가 붙은 긴 이름이 된다). - operationId 는 함수 이름의 camelCase(`list_rules` → `listRules`, `main.py` 의 `generate_unique_id_function`). 서버 안에서 유일하게. - 검증 실패는 FastAPI 기본 422 · `detail` 이 아니라 400 `{ error }` 다. 문서도 같게 고친다(`support/errors.py` 의 `openapi()`). - pydantic 은 문자열 자리에 숫자를 받지 않는다. 모르는 필드는 버린다. ## gRPC - `app.tag.v1.TagService/Suggest`: keyword 가 text 에 들어 있는 규칙의 tag (대소문자 무시, 같은 태그는 한 번, 규칙 id 순, 빈 text 는 []). 업무 로직은 `domain/rules.py` 의 `suggest()`, rpc 는 넘기기만 한다. - `python -m server.gen proto` 가 grpcio-tools 로 `gen/` 에 만든다. 타입 스텁은 mypy-protobuf(`--mypy_out` · `--mypy_grpc_out`). grpc 기본 생성 코드에는 타입이 없어 pyrefly 가 서비스 메서드를 잘못 본다. - dev · test · typecheck 가 먼저 proto 를 만든다(`gen/` 은 커밋하지 않는다). 이미지는 gen 단계에서 만든다. ## 테스트 - `conftest.py` 가 uvicorn 을 `port=0` 으로 스레드에서 띄우고 httpx 로 부른다. TestClient 는 소켓을 거치지 않아 쓰지 않는다. - 파일 `tests/tags/rules/id/DELETE_specs.py`, 모듈 docstring `"""DELETE /api/tags/rules/{id}"""`, 함수 `def test_없는_id면_404_Not_Found를_반환한다(client)` (pytest 가 `test_` 로 모은다). 같은 파일 이름이 여러 폴더에 있어서 `--import-mode=importlib`. - 외부 클라이언트는 `app.dependency_overrides` 나 교체 지점으로 Fake 를 넣는다. `patch('server.clients…')` 는 쓰지 않는다(hook 이 막는다). - DB 는 docker 의 Postgres 에 있는 `tags_test`(conftest 가 `PGDATABASE` 를 바꾼다). 개발 DB `tags` 는 건드리지 않는다. - 서버를 세션 동안 한 번 띄워 같이 쓰므로 DB · Fake 는 테스트마다 비운다(`clean_db` 는 autouse, `alembic_version` 을 뺀 테이블을 TRUNCATE). 마이그레이션이 넣은 예시 규칙도 지워지니 테스트는 필요한 규칙을 `storage/rules.py` 로 직접 만든다. TRUNCATE 는 id 를 되돌리지 않으니 id 는 저장소 · 응답이 준 값을 쓴다(1 이라고 가정하지 않는다). --- Source: https://stack.initializer.org/reference/apps/admin/ # 관리 앱 (Vue) 태그 규칙을 관리하고 활동 기록을 본다. Vite + Vue 3 SFC + TanStack Query (:5281). 클라이언트 공통 규칙은 `docs/clients.md`, 이 파일이 이 앱에서는 우선한다. ## 구조 ``` index.html # 진입 HTML src/ ├── main.ts # 진입점: VueQueryPlugin · style.css ├── App.vue # 관리 화면: 태그 규칙(목록 · 추가 · 삭제) · 활동 기록(최근 것부터) └── style.css # 색 토큰(라이트/다크) · 화면 스타일 vite.config.ts # 개발 서버 :5281 · /api 프록시 (→ gateway) package.json · tsconfig.json Dockerfile # 이미지: dist 를 nginx 가 내주고 /api 는 API_URL(실행할 때, 기본 http://gateway:8003)로 (clients/nginx.conf.template) ``` ## 명령 ```bash pnpm dev · build · typecheck · lint · format # vite (:5281) · vite build · vue-tsc -p . · oxlint · oxfmt docker build --build-context workspace=../../.. . # 이미지 (이 폴더에서) ``` ## 규칙 - 서버: tags 와 activity 둘. `createClient({ name: 'tags' })` · `createClient({ name: 'activity' })` (같은 출처). vite 프록시는 `/api` 를 `API_URL`(기본 gateway :8003)로 넘기고, gateway 가 `/api/tags` · `/api/activity` 를 각 서버로 나눈다. notes 는 부르지 않는다. - 화면은 `useQuery(api.query(경로))`, 규칙 추가 · 삭제는 `useMutation` 뒤 `invalidateQueries({ queryKey: tagsApi.query('/api/tags/rules').queryKey })`. 활동 기록은 읽기만 한다(쓰는 것은 notes 가 gRPC 로). - 타입체크는 `vue-tsc` 라 이 앱만 TS 6(`catalog:ts6`)이다. vue-tsc 는 TS 의 JS API 를 쓰는데 TS 7 에는 아직 없다. - 템플릿에서 `v-html` 은 쓰지 않는다(`{{ }}` 만). vue-query 가 쓰는 vue-demi 의 설치 스크립트는 꺼져 있다(기본 파일이 Vue 3 용). --- Source: https://stack.initializer.org/reference/apps/desktop/ # Tauri 데스크톱 앱 메모 앱. Tauri 2 (Vite + 순수 TS 창). 클라이언트 공통 규칙은 `docs/clients.md`, 이 파일이 이 앱에서는 우선한다. ## 구조 ``` index.html # 창(웹)의 진입 HTML src/ ├── main.ts # 메모 화면(태그 칩 · 추가 · 삭제): tauri-plugin-http 의 fetch 로 notes 서버를 부른다 └── style.css # 색 토큰(라이트/다크) · 화면 스타일 vite.config.ts # 창의 개발 서버 :1420 (tauri.conf.json 의 devUrl) src-tauri/ # Rust 쪽 ├── src/main.rs # 앱 시작: HTTP 플러그인 등록 ├── tauri.conf.json # 앱 이름 · identifier (pnpm rename 이 바꾼다) · 창 · devUrl ├── capabilities/default.json # 창이 부를 수 있는 URL 허용 목록 ├── icons/ # 앱 아이콘 ├── Cargo.toml · Cargo.lock · build.rs └── target/ # 빌드 캐시 (gitignore) rust-toolchain.toml # 이 앱의 Rust 버전 package.json · tsconfig.json ``` ## 명령 ```bash pnpm start # tauri dev (창이 뜬다, Vite :1420) pnpm build # tauri build (앱 번들) pnpm exec tauri build --debug --no-bundle # 창 없이 빌드만 확인 pnpm typecheck · lint · format ``` ## 규칙 - 창이 `tauri://` 출처라 브라우저 fetch 는 CORS 에 막힌다. Rust 쪽 `tauri-plugin-http` 의 fetch 를 `createClient({ base: 'http://127.0.0.1:8003', fetch })` 로 넘긴다(gateway). - 부를 수 있는 URL 은 `src-tauri/capabilities/default.json` 의 허용 목록이다. 서버 주소를 바꾸면 `src/main.ts` 와 같이 바꾼다. - Rust 버전은 `rust-toolchain.toml` 로 이 앱에만 고정한다(Tauri 2.12 는 1.95+, rustup 이 처음에 받는다). - 창은 화면에 뜨므로 사용자가 말할 때만 띄운다. --- Source: https://stack.initializer.org/reference/apps/lite/ # 가벼운 웹 앱 (순수 TS) 프레임워크 없는 메모 앱. Vite (:5283). 클라이언트 공통 규칙은 `docs/clients.md`, 이 파일이 이 앱에서는 우선한다. ## 구조 ``` index.html # 진입 HTML src/ ├── main.ts # 메모 화면: api.get · post · delete 뒤 목록(태그 칩)을 다시 그린다 └── style.css # 색 토큰(라이트/다크) · 여러 화면이 함께 쓰는 스타일 vite.config.ts # 개발 서버 :5283 · /api 프록시 package.json · tsconfig.json Dockerfile # 이미지: dist 를 nginx 가 내주고 /api 는 API_URL(실행할 때, 기본 http://gateway:8003)로 (clients/nginx.conf.template) ``` ## 명령 ```bash pnpm dev · build · typecheck · lint · format # vite (:5283) · vite build · tsc -p . (TS 7) · oxlint · oxfmt docker build --build-context workspace=../../.. . # 이미지 (이 폴더에서) ``` ## 규칙 - 서버: notes 하나. `createClient()` (같은 출처). vite 프록시가 `/api` 를 `API_URL`(기본 gateway :8003)로 넘기고, gateway 가 notes 로 보낸다. - 프레임워크가 없어 `api.get` · `api.post` · `api.delete` 를 그대로 쓰고, 쓴 뒤 목록을 다시 받는다. 화면 문자열(메모 · 태그)은 `textContent` 로만 넣는다. - 스타일: 전역 `src/style.css` 는 색 토큰(라이트/다크)과 여러 화면이 함께 쓰는 것(지금은 카드 · 입력칸 · 버튼 · 머리글)만. 한 컴포넌트만 쓰는 스타일은 옆의 `Component.css` 에 두고 그 컴포넌트에서 import 한다. 엔트리는 `style.css` 를 먼저 import. --- Source: https://stack.initializer.org/reference/apps/mobile/ # Expo 모바일 앱 메모 앱. Expo SDK 58 (React Native) + TanStack Query. 클라이언트 공통 규칙은 `docs/clients.md`, 이 파일이 이 앱에서는 우선한다. ## 구조 ``` index.ts # 진입점 (registerRootComponent) App.tsx # QueryClientProvider 하나 + 메모 화면(태그 칩 · 추가 · 삭제). 색은 시스템 설정(useColorScheme)을 따른다 app.json # Expo 설정: 앱 이름 · slug (pnpm rename 이 바꾼다) · 아이콘 assets/ # 앱 아이콘 (iOS · Android) package.json · tsconfig.json ``` ## 명령 ```bash pnpm start · ios · android · typecheck · lint · format # expo start · tsc -p . (Expo 의 TS 6) · oxlint · oxfmt CI=1 npx expo export --platform ios --output-dir /tmp/expo-export # 시뮬레이터 없이 번들까지 확인 ``` ## 규칙 - 버전은 Expo SDK 가 정한다: `expo` · `react` · `react-dom` · `react-native` · `@types/react` · `typescript` 는 `catalog:expo`. 올릴 때는 `npx expo install --fix` 뒤 카탈로그를 맞춘다. - 서버: notes 하나. `createClient({ name: 'notes', base })`. `base` 는 `EXPO_PUBLIC_API`(기본 gateway `http://127.0.0.1:8003`). iOS 시뮬레이터는 맥의 127.0.0.1 로 붙고, Android 에뮬레이터는 `adb reverse tcp:8003 tcp:8003`. 실제 기기는 서버를 `HOST=0.0.0.0` 으로 띄우고 `EXPO_PUBLIC_API` 에 맥 IP (인증 먼저). - Metro 가 워크스페이스 TS 패키지(`@app/client`)를 그대로 읽는다. - 시뮬레이터는 화면에 뜨므로 사용자가 말할 때만 띄운다. --- Source: https://stack.initializer.org/reference/apps/site/ # 공개 사이트 (Next) 메모 목록을 서버에서 그리는 읽기 전용 공개 페이지. Next 16 App Router + TanStack Query (:5282). 클라이언트 공통 규칙은 `docs/clients.md`, 이 파일이 이 앱에서는 우선한다. ## 구조 ``` app/ ├── layout.tsx # 루트 레이아웃: Providers · globals.css · metadata ├── page.tsx # 서버 컴포넌트: 안에서 prefetchQuery → HydrationBoundary ├── notes.tsx # 메모 목록(태그 칩), 클라이언트 컴포넌트: useQuery 만 (쓰기 없음) ├── providers.tsx # QueryClient (탭마다 하나, staleTime) ├── api.ts # createClient: 서버 컴포넌트는 gateway 를 직접, 브라우저는 같은 출처 /api └── globals.css # 색 토큰(라이트/다크) · 화면 스타일 next.config.ts # rewrites(/api → gateway) · transpilePackages · standalone(NEXT_OUTPUT) package.json · tsconfig.json Dockerfile # 이미지: standalone 출력을 node server.js 로 ``` ## 명령 ```bash pnpm dev · build · start · typecheck · lint · format # next dev/start (:5282) · next build · tsc --noEmit (TS 6) · oxlint · oxfmt docker build --build-context workspace=../../.. . # 이미지 (이 폴더에서) ``` ## 규칙 - 서버: notes 하나, `GET /api/notes` 만 부른다. 읽기 전용이라 만들기 · 고치기 · 지우기 화면과 `useMutation` 을 두지 않는다(쓰기는 web · lite). - `app/api.ts` 하나가 서버와 브라우저를 나눈다: 서버 컴포넌트는 gateway 를 직접(`API_URL`, 기본 :8003), 브라우저는 같은 출처 `/api`. 둘 다 gateway 가 notes 로 보낸다. 쿼리 키는 `name` 이 같아서 서버에서 미리 받은 데이터가 브라우저 캐시로 이어진다. - 서버 컴포넌트가 요청마다 미리 받는다: `` 안에서 `prefetchQuery` → `HydrationBoundary`. `cacheComponents` 라 캐시 안 된 fetch 는 `` 밖에 두지 않는다. 브라우저 쪽 `staleTime` 으로 붙자마자 다시 받지 않는다(`app/providers.tsx`). - 브라우저의 `/api` 는 `next.config.ts` 의 `rewrites` 가 gateway 로 넘긴다(오래된 목록을 다시 받을 때). **rewrites 주소는 `next build` 때 정해진다**: 다른 서버에 붙이려면 `API_URL=... pnpm build`. 서버 컴포넌트는 실행할 때 `API_URL` 을 읽는다. - `next build` 가 TS 의 JS API 로 타입검사를 해서 이 앱만 TS 6(`catalog:ts6`). 워크스페이스 패키지는 `transpilePackages`. - 빌드할 때 네트워크가 필요한 것(예: `next/font/google`)은 쓰지 않는다. - 이미지는 standalone 출력(`NEXT_OUTPUT=standalone` 일 때만 켠다, 로컬 `next start` 는 그대로)을 쓴다. rewrites 의 `API_URL` 은 빌드 인자, 서버 컴포넌트의 것은 실행할 때 env (둘 다 기본 `http://gateway:8003`). ## This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. --- Source: https://stack.initializer.org/reference/apps/web/ # 웹 앱 (React) 메모를 쓰고 태그를 본다. Vite + React 19 + TanStack Query (:5280). 클라이언트 공통 규칙은 `docs/clients.md`, 이 파일이 이 앱에서는 우선한다. ## 구조 ``` index.html # 진입 HTML src/ ├── main.tsx # 진입점: QueryClientProvider 하나 · style.css ├── App.tsx # 메모 화면: 목록(태그 칩) · 추가 · 삭제. useQuery · useMutation └── style.css # 색 토큰(라이트/다크) · 화면 스타일 vite.config.ts # 개발 서버 :5280 · /api 프록시 package.json · tsconfig.json Dockerfile # 이미지: dist 를 nginx 가 내주고 /api 는 API_URL(실행할 때, 기본 http://gateway:8003)로 (clients/nginx.conf.template) ``` ## 명령 ```bash pnpm dev · build · typecheck · lint · format # vite (:5280) · vite build · tsc -p . (TS 7) · oxlint · oxfmt docker build --build-context workspace=../../.. . # 이미지 (이 폴더에서) ``` ## 규칙 - 서버: notes 하나. `createClient({ name: 'notes' })` (같은 출처). vite 프록시가 `/api` 를 `API_URL`(기본 gateway :8003)로 넘기고, gateway 가 notes 로 보낸다. - 화면은 `useQuery(api.query(경로))`, 만들기 · 지우기는 `useMutation` 뒤 `invalidateQueries({ queryKey: api.query('/api/notes').queryKey })`. - 태그는 notes 가 저장할 때 붙여 돌려준다(tags 서버를 부르지 않는다). 화면은 DESIGN.md 의 Tag 칩으로만 보여 준다. - `react` · `react-dom` 버전은 카탈로그가 정한다(정확히 같은 버전, `docs/clients.md`). 이 앱에서 따로 올리지 않는다. --- Source: https://stack.initializer.org/reference/decisions/ # 설계 결정(ADR) 되돌리기 어렵거나 여러 영역에 걸친 결정을 짧게 남긴다. - 쓸 때: 새 통신 방식 · 저장소 · 인증 · 레이어 예외처럼 기반 패턴을 정하거나 바꿀 때, 서버 · 앱에서 채택된 ADR 과 다르게 해야 할 때(구현 전에). - 쓰는 법: `template.md` 를 복사해 다음 번호로, 상태 "제안" → 합의하면 "채택". 아래 표에 한 줄을 더한다. - 바뀐 결정은 지우지 않고 상태를 "대체됨(→ 새 번호)"으로 둔다. | 번호 | 결정 | 상태 | |---|---|---| | [0001](/reference/decisions/0001-code-first-http-contract/) | HTTP API 계약은 서버 코드가 기준, 문서는 서버마다 따로 | 채택 | | [0002](/reference/decisions/0002-grpc-shared-proto/) | 서버 간 통신은 gRPC, 계약은 공통 proto 하나 | 채택 | | [0003](/reference/decisions/0003-api-tests-with-fakes/) | 테스트는 실제 서버를 띄운 API 통합 테스트, 외부는 Fake | 채택 | | [0004](/reference/decisions/0004-default-db-postgres/) | DB 는 Postgres 하나로 정하고, 바꾸는 법은 문서로 둔다 | 채택 | --- Source: https://stack.initializer.org/reference/decisions/0001-code-first-http-contract/ # 0001. HTTP API 계약은 서버 코드가 기준, 문서는 서버마다 따로 - 상태: 채택 - 날짜: 2026-10-17 - 관련: `contract/openapi/`, `contract/index.ts`, `clients/packages/client`, `docs/contracts.md` ## 맥락 처음에는 손으로 쓴 `openapi.yaml` 하나에서 서버 · 클라이언트 타입을 만들었다(contract-first). Node 는 생성 타입으로 컴파일 때 맞춰졌지만 Spring 은 손으로 맞춰야 해서 문서와 서버가 어긋날 수 있었다. 서버는 언어가 여럿이고 API 도 서버마다 따로 자란다. 서버끼리 `Note` · `/api/note` 처럼 같은 이름이 나온다. ## 결정 - 서버가 자기 코드에서 openapi 문서를 만든다: notes(Node) = zod 스키마(검증과 같은 것), activity(Spring) = springdoc(테스트 의존성, 실제 서버를 랜덤 포트로 띄워 받음), tags(FastAPI) = FastAPI(검증 실패 400 으로 문서도 고침). `pnpm gen` 이 `contract/openapi/<서버>.json` 에 쓴다. - 클라이언트 타입은 그 문서에서 만든다(openapi-typescript → `<서버>.ts`). 손으로 쓰는 계약 문서는 두지 않는다. - 문서는 서버마다 따로 두고 하나로 합치지 않는다. TS 에서는 서버별 네임스페이스(`notes.paths`)로 갈린다. - 한 서버 안에서는 경로 · 스키마 이름 · operationId 가 유일해야 한다. - 생성물은 커밋한다. 직접 고치지 않는다(hook 이 막는다). CI 는 `pnpm gen && git diff --exit-code contract/`. ## 결과 - 문서를 따로 손으로 관리하는 중복을 줄이고, 생성된 타입과 클라이언트 코드가 어긋나면 typecheck 에서 걸린다(필드 이름을 바꿔 web · mobile · desktop 이 모두 깨지는 것을 확인). - 실제 응답의 런타임 검증과 배포된 옛 클라이언트의 호환은 별도다. 보장 범위는 `docs/contracts.md`. - `pnpm typecheck` 가 gen 을 먼저 돌려 Java · uv 가 필요하고 몇 초 더 걸린다. - 같은 API 를 여러 서버가 구현해도 서로 같은지는 보장하지 않는다. 필요하면 네임스페이스끼리 타입 동등성 검사를 둔다. - Spring(springdoc)은 같은 단순 클래스명이 문서에서 조용히 덮어써진다. 이름 규칙으로 막는다(`servers/activity/AGENTS.md`). - 게이트웨이용으로 합쳐야 하면 합칠 때 서버 이름을 접두사로 붙인다. --- Source: https://stack.initializer.org/reference/decisions/0002-grpc-shared-proto/ # 0002. 서버 간 통신은 gRPC, 계약은 공통 proto 하나 - 상태: 채택 - 날짜: 2026-10-17 - 관련: `contract/proto/`, `contract/buf.yaml`, 각 서버의 gRPC 서비스, ADR-0001 ## 맥락 서버가 여러 언어(TS · Java · Python)다. 서버끼리 부를 때도 메시지 형식이 맞아야 한다. 이 템플릿은 서버별 HTTP API 는 각 서버가 소유하고, 서버 간 메시지는 어느 한쪽의 구현과 별개인 공통 proto 로 합의한다. 여러 언어를 쓴다는 이유만으로 contract-first 가 필수인 것은 아니며, HTTP 도 문서를 먼저 쓰는 설계를 선택할 수 있다. ## 결정 - 서버 간 통신의 기본은 gRPC. 계약은 `contract/proto` 하나이고 서버마다 복사하지 않는다(여기서는 proto 가 기준, contract-first). - buf 로 lint(STANDARD) · 호환 검사(FILE). 디렉터리 = 패키지(`app/tag/v1`). - 생성: Node = buf + protoc-gen-es(Connect-ES), Spring = protobuf-gradle-plugin(`grpc` 선언만, 버전은 Boot), FastAPI = grpcio-tools + mypy-protobuf(타입 스텁). 예시에서는 tags(FastAPI) · activity(Spring)가 서버, notes(Node)가 클라이언트다. - 필드 번호는 바꾸거나 다시 쓰지 않는다. 지운 필드는 `reserved`, 깨지는 변경은 `v2` 패키지. - 오류는 gRPC status. 다른 서버의 gRPC 클라이언트는 외부 클라이언트로 보고 테스트에서 Fake 로 바꾼다(ADR-0003). ## 결과 - TS(Connect-ES) · Go(buf curl) → Java(grpc-java) · Python(grpcio) 호출을 확인했다. - 평문 HTTP/2(h2c)다. 서버 밖으로 열 때는 TLS(mTLS) · 인증부터. - HTTP(코드 기준)와 gRPC(proto 기준)의 방향이 반대다. 여러 언어가 같은 정의를 읽어야 하느냐가 기준이다. --- Source: https://stack.initializer.org/reference/decisions/0003-api-tests-with-fakes/ # 0003. 테스트는 실제 서버를 띄운 API 통합 테스트, 외부는 Fake - 상태: 채택 - 날짜: 2026-10-17 - 관련: `docs/testing.md`, `docker/`, `.claude/hooks/check-patterns.py` ## 맥락 외부 API · 스토리지 · 메일을 테스트마다 다 띄우기는 어렵고, 내부 로직까지 대역으로 바꾸면 검증하려던 흐름이 사라진다. 테스트가 통과했을 때 무엇을 보장하는지 정하고 그에 맞춰 경계와 대역을 고른다. ## 결정 - 기본은 서비스 범위의 API 통합 테스트: 실제 서버를 랜덤 포트로 띄우고 HTTP · gRPC 로 부른다. api · domain · storage 는 실제 구현, 외부 클라이언트(clients)만 Fake. - 테스트 DB 는 운영과 같은 엔진(Postgres)이다. `docker/` 의 compose(개발 · CI 와 같은 파일)에 서버마다 `<서버>_test` 를 두고 테스트 앞뒤로 비운다. 서버는 다른 스레드라 rollback 을 가정하지 않는다. - 이미지 · 네트워크는 전체 스택(`docker/docker-compose.yaml`, 서버 · 웹 앱)을 띄운 smoke 로 본다. - 이름: 디렉터리 = 리소스 경로, 파일 = `{METHOD}_specs`, gRPC 는 `grpc/{Service}/{Rpc}_specs`, 테스트 이름은 한글_밑줄 조건 · 결과. - Fake 는 외부 조건만 통제(반환값 · 다음 호출 실패 · 기록 · 보류)하고 `reset()` 한다. 업무 정책 · 재시도를 Fake 에 다시 구현하지 않는다. - mock 으로 외부 클라이언트를 바꾸지 않는다(`@MockitoBean`, `mock.module`, `patch`). hook 이 막는다. ## 결과 - HTTP 응답 · 저장 상태 · 부수 효과를 같은 테스트에서 본다. 저장 상태는 storage 의 저장소로 확인한다. - 테스트에 Docker 가 필요하다(`pnpm infra`). 대신 메모리 DB 와 운영 DB 의 차이(SQL 문법 · 타입 · 제약)를 테스트가 잡는다. - 인증 · 브라우저 · 프록시 · 실제 외부 서비스는 범위 밖이다. 필요하면 따로 둔다(verify-local skill 은 브라우저까지). - 같은 Fake · DB 를 쓰는 테스트는 병렬로 돌리지 않는다. --- Source: https://stack.initializer.org/reference/decisions/0004-default-db-postgres/ # 0004. DB 는 Postgres 하나로 정하고, 바꾸는 법은 문서로 둔다 - 상태: 채택 - 날짜: 2026-10-17 - 관련: `docs/guides/database.md`, `docs/architecture.md`, ADR-0003 ## 맥락 이 저장소는 템플릿이라 쓰는 쪽이 어떤 DB 를 고를지 모른다. 그렇다고 여러 DB 를 받게 하면(ORM · 쿼리 빌더 · DB 별 테스트 정리) 만들어진 프로젝트는 결국 하나만 쓰면서 그 추상화를 계속 진다. SQL 은 모든 DB 의 공통분모로 줄고, ADR-0003(테스트 DB = 운영 엔진) 때문에 테스트 · CI 도 DB 수 × 서버 수만큼 돈다. ## 결정 - 기본 DB 는 Postgres 하나. 서버 코드는 그 드라이버 · SQL(`ON CONFLICT` 등)을 그대로 쓰고, 여러 DB 를 위한 추상화를 두지 않는다. - DB 를 만지는 코드는 서버마다 `storage/` 와 테스트의 DB 정리에만 둔다. api · domain 은 저장소만 본다. - 서버마다 자기 DB 라서 서버 하나만 다른 DB 로 바꿔도 된다. 바꾸면 테스트도 그 엔진으로 돈다(ADR-0003). 인메모리 DB 로 대신하지 않는다. - 바꾸는 곳은 `docs/guides/database.md` 에 적는다. Postgres 에 묶인 곳을 새로 만들면 그 목록에도 더한다. ## 결과 - 예제 코드가 짧고 그 DB 의 기능을 쓴다. 테스트 · CI 는 엔진 하나. - 다른 DB 를 쓰려면 서버마다 드라이버 · SQL · 테스트 정리를 직접 고친다(서버당 파일 몇 개). - 다시 볼 조건: 한 프로젝트가 여러 DB 를 함께 지원해야 할 때(예: 고객이 DB 를 고르는 설치형 제품). --- Source: https://stack.initializer.org/reference/decisions/template/ # NNNN. 결정 한 줄 - 상태: 제안 | 채택 | 보류 | 대체됨(→ NNNN) - 날짜: YYYY-MM-DD - 관련: PR·문서 ## 맥락 무엇이 문제였고 어떤 제약이 있었나. ## 결정 무엇을 하기로 했나. 지켜야 할 규칙을 문장으로. ## 결과 좋아지는 것, 감수하는 것, 다시 볼 조건.