이해하기

구조는 왜 이렇게 됐나

구조는 왜 이렇게 됐나

언어가 다른 서버와 클라이언트를 함께 바꾸면서도 서로 묶이지 않게 하려면

notes 서버에 메모 하나의 길이를 돌려주는 GET /api/notes/{id}/length 를 더한다고 하자. 업무 로직 한 줄, 라우트 한 줄, 테스트 파일 하나를 쓰고 pnpm gen 을 돌리면 contract/openapi/notes.json 과 notes.ts 가 바뀐다. 클라이언트는 그 타입으로 새 경로를 부른다. 기능 하나에 손대는 곳이 네 군데다.

번거로워 보이는 이 순서는 두 요구를 함께 만족시키려고 생겼다. 하나는 함께 바꾸는 것이다. API 를 바꾸는 PR 하나에 서버 · 계약 · 클라이언트 수정이 같이 들어가고 CI 가 한 번에 확인한다. 다른 하나는 서로 묶이지 않는 것이다. 서버 하나를 다른 언어로 다시 써도 다른 서버와 클라이언트는 영향을 받지 않는다. 이 글은 각 구조가 두 요구를 어떻게 나눠 맡는지 설명한다. 지켜야 할 규칙은 architecture.md · contracts.md, 결정의 기록은 decisions/ 에 있다.

잇는 것은 contract/ 하나다

저장소를 서버 · 클라이언트마다 나누면 API 를 바꿀 때마다 계약 패키지를 배포하고, 각 저장소에서 버전을 올리고, 배포 순서를 맞춰야 한다. 한 저장소에서는 이 과정이 커밋 하나로 끝난다. 하지만 한 저장소 안에서 서로의 코드를 직접 부르기 시작하면 저장소를 나누지 않은 이점이 그대로 비용이 된다.

그래서 서버끼리는 코드를 import 하지 않는다. 공유 라이브러리는 언어마다 다시 써야 하고, 한 서버의 내부 변경이 다른 서버를 깨뜨린다. 서로를 잇는 것은 언어와 상관없이 읽을 수 있는 파일뿐이다. HTTP 는 OpenAPI JSON, gRPC 는 proto 다. 어떤 언어든 이 파일에서 코드를 만들 수 있으므로 계약만 지키면 서버 안을 무엇으로 쓰든 다른 쪽은 알 필요가 없다.

서버 이름은 맡은 일이다

예시 서버는 notes · tags · activity 다. 폴더 이름만 보고 무엇을 맡는지 안다. node · spring 처럼 언어로 이름을 지으면 폴더가 말하는 것은 구현 방법뿐이고, 같은 언어의 서버가 둘이 되는 순간 이름이 막힌다.

맡은 일로 나누면 서버끼리 부를 이유도 생긴다. 메모를 저장하는 notes 는 태그 규칙을 모르니 tags 에 묻고, 기록은 activity 에 맡긴다. 언어는 그 서버의 선택일 뿐이다. 같은 일을 언어마다 어떻게 쓰는지는 같은 일, 세 언어 에 나란히 있다.

앱이 들어오는 문은 하나다. 앱은 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 은 사람이 문서를 보고 맞춰야 했고, 그 사이에 문서와 서버가 어긋날 수 있었다. 그래서 순서를 뒤집었다. 서버 코드가 원본이고 문서는 생성물이다.

HTTP 계약 생성
1 / 4

각 서버가 자기 코드에서 OpenAPI 문서를 생성합니다. 별도의 계약 문서를 손으로 작성하지 않습니다.

생성물은 코드와 함께 커밋합니다. CI 가 누락된 변경을 검사합니다.

문서를 따로 손으로 고치는 중복을 줄이고, 생성된 타입과 클라이언트 코드가 어긋나면 컴파일 단계에서 드러난다. 실제 응답 값이나 이미 배포된 옛 클라이언트까지 이 검사로 보장하지는 않는다(계약의 보장 범위). 서버의 응답 필드 이름을 바꾸면 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 마다 이런 변경을 막는다.

서버 안의 층은 바뀌는 이유로 나눈다

모든 서버가 같은 네 층으로 나뉘고, 바깥에서 안으로만 부른다.

서버 안의 호출 방향
storageDB데이터 저장과 조회
clients외부 시스템교체 가능한 연결 지점
1 / 3

HTTP 라우트 · gRPC 서비스 · 보안을 담당합니다. 요청을 검증하고 domain 을 호출합니다.

support 에는 오류 형식 · 라우트 도구처럼 레이어와 무관한 코드를 둡니다.

층마다 바뀌는 이유가 다르다. 프로토콜이 바뀌면 api 를, 업무 규칙이 바뀌면 domain 을, DB 를 바꾸면 storage 를(ADR-0004), 외부 서비스가 바뀌면 그 client 하나를 고친다.

진입점이 늘어도 업무 로직은 한 곳에 있다. tags 서버의 HTTP 라우트(admin 이 규칙을 관리)와 gRPC 서비스(notes 가 태그를 묻는다)는 같은 domain 을 부른다.

# 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 를 넣을 수 있는 것도 이 구조 덕분이다. 이유는 테스트는 왜 이렇게 됐나 에서 다룬다.

밖에서 보이는 모양은 서버마다 같다

서버 안의 도구는 서버마다 다르지만, 클라이언트가 보는 모양은 맞춘다. 클라이언트가 어느 서버를 부르는지 몰라도 되게 하려는 것이다. @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 버전이 어긋나 테스트는 통과하고 화면만 비는 일이 생긴다(자주 겪는 문제).

개발 · 테스트 · 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 같은 층 모든 서버가 같은 모양이고, 규칙이 들어갈 자리가 있다

서버가 하나이고 앞으로도 하나라면 이 비용이 더 크다. 서버가 둘 이상이 되거나 언어가 섞이기 시작하면 얻는 것이 비용보다 커진다.

GitHub 에서 고치기