제 목 :
우리집 비서 만들기 프로젝트 #7 - 서버편: 허브와 모듈의 약속

3편부터 6편까지는 Secretary의 문지기를 만들었다. 요청마다 HMAC 서명을 확인하고, 가족 단위로 사람과 기기를 나누고, 기기 scope와 역할 상한으로 할 수 있는 일을 제한했다.
이제 허브의 나머지 절반, 모듈과 이어지는 길을 만든다. 7편부터 10편까지 허브와 모듈 사이의 약속을 정하고, 모든 모듈을 부르는 명령 입구와, 되돌릴 수 없는 명령을 붙잡아 두는 승인 대기열, 모듈이 먼저 소식을 알려 오는 이벤트, 그리고 NAS에 올리기 위한 Dockerfile, docker-compose.yml, Jenkinsfile까지 작성한다. 진짜 모듈은 아직 하나도 없다. 첫 번째 모듈인 메일은 12편에서 이 약속대로 만든다.
이번 편에서는 먼저 누가 무엇을 했는지 남기는 감사 로그를 만들고, 허브와 모듈 사이의 약속을 정하고, 허브가 모듈을 부르는 법까지 만든다.
이번에도 코드가 꽤 많다. 하나씩 따라가 보자.
Step 1. 감사 로그 (audit.py)
"""감사 로그. 한 줄에 JSON 하나(JSONL).
비밀값과 메일 본문은 남기지 않는다. 누가(사람, 기기), 언제, 무엇을, 결과가 어땠는지만 남긴다."""import jsonimport loggingimport threadingimport timefrom pathlib import Path
logger = logging.getLogger("secretary.audit")_lock = threading.Lock()
class AuditLog: """감사 로그 기록기. 파일 끝에 한 줄씩 덧붙이기만 하고, 고치거나 지우지 않는다."""
def __init__(self, path: Path): """로그 파일 경로를 기억하고, 폴더가 없으면 만든다.""" self.path = path path.parent.mkdir(parents=True, exist_ok=True)
def write(self, event: str, *, user: str | None = None, device: str | None = None, ip: str | None = None, **fields) -> None: """사건 하나를 JSON 한 줄로 기록한다. 서버 로그(docker logs)에도 같은 줄을 남긴다.
event : 무슨 일인지. 예) "auth.fail", "approval.approve" user, device, ip : 누가, 어떤 기기로, 어디서 **fields : 그 밖의 정보. 예) reason="bad_signature". 비밀값과 메일 본문은 넣지 않는다. """ record = {"ts": int(time.time()), "event": event, "user": user, "device": device, "ip": ip, **fields} line = json.dumps(record, ensure_ascii=False, default=str) with _lock: with self.path.open("a", encoding="utf-8") as f: f.write(line + "\n") logger.info(line)한 줄에 JSON 하나씩 남기는 JSONL 형식이다. 나중에 grep이나 jq로 찾기 편하다. 실제로 남는 로그는 이렇게 생겼다.
{"ts": 1793650406, "event": "approval.create", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "approval": "nCCzRFogNQvd", "action": "mail.send", "rule": "same_user"}{"ts": 1793650406, "event": "auth.forbidden", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "scope": "approvals:decide", "path": "/v1/approvals"}{"ts": 1793650406, "event": "auth.fail", "user": null, "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "reason": "bad_signature", "path": "/v1/me"}감사 로그는 컨테이너 안의 /data/audit.log에 쌓인다. 한 줄에 JSON 하나라서 grep만으로도 원하는 줄을 골라 볼 수 있다. 아빠(dad)가 한 일만 보고 싶으면 이렇게 한다.
$ docker exec secretary grep '"user": "dad"' /data/audit.log{"ts": 1793650406, "event": "approval.create", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "approval": "nCCzRFogNQvd", "action": "mail.send", "rule": "same_user"}{"ts": 1793650406, "event": "auth.forbidden", "user": "dad", "device": "dad-glasses-7ebb279d", "ip": "127.0.0.1", "scope": "approvals:decide", "path": "/v1/approvals"}
"user":"dad"처럼 붙여 쓰면 아무것도 안 나온다.json.dumps는 기본으로:뒤에 공백을 하나 넣기 때문에, 로그에는"user": "dad"로 남는다. 서명이 틀려서 누구인지 모르는 요청은"user": null로 남으니 필터링 된다.
누가(사람과 기기), 언제, 무엇을 했는지는 남기지만, Secret과 명령의 입력값(메일 주소나 본문 같은)은 절대 남기지 않는다. 허브는 입력값이 무슨 뜻인지도 모르니, 모르는 걸 함부로 남기지 않는 게 맞다. 이건 뒤에서 테스트로도 확인한다.
**fields가 뭐지?write(self, event, *, user=None, device=None, ip=None, **fields). 인자 목록 끝에 별표 두 개가 붙은 **fields가 있다.
함수를 정의할 때 **fields를 쓰면, 이름을 정해 두지 않은 “이름=값” 인자를 전부 딕셔너리 하나로 모아서 받는다. 그래서 감사 로그는 사건마다 남길 정보가 달라도(reason=, approval=, rule= …) 함수 하나로 받을 수 있다. 반대로 함수를 부를 때나 딕셔너리 안에서 **딕셔너리를 쓰면, 딕셔너리를 풀어서 넘긴다. 앞쪽의 별표 하나(*,)는 “이 뒤의 인자는 반드시 이름을 붙여서 넘겨라”라는 표시다. write("x", "dad")처럼 순서로 넘기면 에러가 나고, user="dad"로 넘겨야 한다.
def write(event, *, user=None, **fields): print(event, user, fields)
write("approval.create", user="dad", approval="nCCzRFogNQvd", rule="same_user")
info = {"approval": "nCCzRFogNQvd", "action": "mail.send"}write("approval.approve", user="mom", **info)
print({**info, "rule": "admin"})$ python3 kwargs_demo.pyapproval.create dad {'approval': 'nCCzRFogNQvd', 'rule': 'same_user'}approval.approve mom {'approval': 'nCCzRFogNQvd', 'action': 'mail.send'}{'approval': 'nCCzRFogNQvd', 'action': 'mail.send', 'rule': 'admin'}첫 줄은 모으기, 둘째 줄은 풀어서 넘기기, 셋째 줄은 딕셔너리 안에서 풀어서 합치기다. 테스트 코드의
{**CF, **build_headers(...)}도 셋째 줄과 같은 모양이다. Cloudflare 헤더와 서명 헤더를 한 딕셔너리로 합친다.
Step 2. 허브와 모듈의 약속 (docs/module-contract.md)
3편에서 그린 큰 그림을 다시 보자. 허브는 메일이 뭔지 모르고, 모듈은 사람이 누구인지 모른다. 그럼 둘은 어떻게 같이 일할까? 약속(계약) 을 정해 두면 된다. 허브와 모듈은 이 약속만 지키면 되고, 서로의 코드는 몰라도 된다. 모듈은 어떤 언어로 만들어도 상관없다.
모듈이 열어 두는 API는 딱 네 개다.
| API | 하는 일 |
|---|---|
GET /manifest |
모듈 소개서. “나는 누구고, 무엇을 할 수 있다” |
POST /actions/{액션}/validate |
실행 전에 입력을 검사하고, 승인 화면에 보여 줄 한 줄 요약을 돌려준다 |
POST /actions/{액션} |
실제로 실행한다 |
GET /healthz |
살아 있는지. Docker 헬스체크용 |
소개서(manifest)는 이렇게 생겼다. 12편에서 만들 메일 모듈의 소개서를 미리 보면 이렇다.
{ "name": "mail", "title": "메일", "actions": { "send": { "title": "메일 보내기", "approval": true, "roles": null, "params": { "type": "object", "required": ["to", "subject", "body"] } } }}여기에 4편의 권한 이야기가 연결된다.
- 필요한 scope는 따로 적지 않는다.
<모듈>:<액션>, 그러니까mail:send로 자동으로 정해진다. approval:true면 허브가 바로 실행하지 않고 승인 대기열에 올린다. 되돌릴 수 없는 일인지는 모듈이 제일 잘 안다. 그래서 모듈이 선언하고, 허브가 집행한다.roles: 이 액션을 쓸 수 있는 역할.null이면 모든 역할이다. 현관문 열기 같은 건["admin"]으로 막으면 된다.params: 입력 형식 설명. 앱이 입력 화면을 그릴 때 참고하는 용도고, 실제 검사는 모듈이 한다.
허브가 모듈에 보내는 요청 본문은 항상 같은 모양이다. 입력값(params)과 누가 시켰는지(caller)만 넘긴다. 기기 키나 Secret 같은 인증 정보는 절대 넘기지 않는다. 인증은 허브에서 이미 끝났으니까.
{ "params": { "to": ["grandma@family.example"], "subject": "퇴근", "body": "지금 출발해요" }, "caller": { "user_id": "dad", "name": "아빠", "role": "admin", "email": "dad@example.com" }}입력이 괜찮으면 200 {"summary": "메일 → grandma@family.example : 퇴근"}, 틀렸으면 422 {"message": "허용되지 않은 수신자가 있습니다."}를 돌려준다. 허브는 422의 메시지를 사용자에게 그대로 보여 주고, 그 밖의 실패(연결 안 됨, 500)는 자세한 이유를 서버 로그에만 남긴다.
그리고 허브와 모듈도 서로를 확인한다. 모듈마다 내부 토큰을 하나씩 만들어 허브와 나눠 가지고, 허브는 모듈을 부를 때 Authorization: Bearer <토큰>을 붙인다. 모듈은 토큰이 틀리면 401. 토큰이 모듈마다 달라서, 모듈 하나가 털려도 다른 모듈을 부를 수는 없다.
명령 하나가 처리되는 흐름을 그림으로 그리면 이렇다. 메일 모듈을 예로 들었다.
이 약속은 저장소의 글로 정리해 뒀다. 새 모듈을 만들 때는 이 문서 하나만 보면 된다. 모듈 약속 문서 확인하기
Step 3. 허브가 모듈을 부르는 법 (catalog.py)
이제 허브 쪽이다. 허브는 설정에 적힌 모듈 목록(SECRETARY_MODULES)을 보고, 모듈마다 소개서를 받아 와서 기억해 둔다. 이 일을 하는 게 모듈 카탈로그다.
"""모듈 카탈로그. 허브가 아는 모듈 목록과, 모듈을 부르는 HTTP 클라이언트.
허브는 모듈의 코드를 import 하지 않는다. 설정에 적힌 주소로 manifest(소개서)를 받아 오고,명령이 오면 그 주소로 HTTP 요청을 보낼 뿐이다. 약속(계약)은 docs/module-contract.md 참고."""import loggingimport reimport time
import httpxfrom pydantic import BaseModel, Field, ValidationError, field_validator
logger = logging.getLogger("secretary.catalog")
MANIFEST_TTL_SECONDS = 60# 액션 이름은 URL 경로와 scope에 들어가므로 영문 소문자, 숫자, _, - 만ACTION_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
class ModuleError(Exception): """모듈을 부르다 실패했을 때. status는 클라이언트에게 돌려줄 HTTP 상태 코드."""
def __init__(self, status: int, message: str): super().__init__(message) self.status = status self.message = message
class ActionSpec(BaseModel): """manifest 안의 액션 하나. 필요한 scope는 따로 적지 않고 "<모듈>:<액션>"으로 정해진다."""
title: str = Field(max_length=60) approval: bool = False roles: list[str] | None = None params: dict | None = None
class Manifest(BaseModel): """모듈 소개서. GET /manifest 응답을 이 모양으로 검사한다. 모양이 틀린 모듈은 쓰지 않는다."""
name: str title: str = Field(max_length=60) actions: dict[str, ActionSpec]
@field_validator("actions") @classmethod def _action_names(cls, actions: dict) -> dict: """액션 이름 규칙(ACTION_RE)에 맞지 않는 액션이 하나라도 있으면 manifest 전체를 거부한다.""" bad = [a for a in actions if not ACTION_RE.fullmatch(a)] if bad: raise ValueError(f"bad action names: {bad}") return actions
class ModuleCatalog: """설정에 등록된 모듈들의 manifest를 기억하고, 모듈에 요청을 보낸다."""
def __init__(self, endpoints: dict[str, tuple[str, str]], timeout: float = 10.0, transport=None): """endpoints: 모듈 이름 → (주소, 토큰). transport는 테스트에서 가짜 모듈을 붙일 때만 넣는다.""" self.endpoints = endpoints self._http = httpx.AsyncClient(timeout=timeout, transport=transport) self._cache: dict[str, tuple[float, Manifest]] = {}
async def close(self) -> None: """HTTP 연결을 닫는다. 서버가 꺼질 때 부른다.""" await self._http.aclose()
def _headers(self, name: str) -> dict: """그 모듈의 내부 토큰을 붙인 헤더. 모듈은 이 토큰이 맞을 때만 요청을 받는다.""" return {"Authorization": f"Bearer {self.endpoints[name][1]}"}
async def manifest(self, name: str) -> Manifest: """모듈의 manifest를 돌려준다. 1분 동안은 기억해 둔 것을 쓰고, 지나면 다시 받아 온다.
모듈이 꺼져 있으면 503. 다시 받아 오다 실패해도 전에 받아 둔 manifest가 있으면 그걸 쓴다. """ if name not in self.endpoints: raise ModuleError(404, "없는 모듈입니다.") cached = self._cache.get(name) if cached and time.monotonic() - cached[0] < MANIFEST_TTL_SECONDS: return cached[1] url = self.endpoints[name][0] try: res = await self._http.get(f"{url}/manifest", headers=self._headers(name)) res.raise_for_status() manifest = Manifest.model_validate(res.json()) if manifest.name != name: raise ValueError(f"manifest name mismatch: {manifest.name}") except (httpx.HTTPError, ValidationError, ValueError) as e: logger.warning("module %s manifest failed: %s", name, e) if cached: return cached[1] raise ModuleError(503, "모듈에 연결할 수 없습니다.") from e self._cache[name] = (time.monotonic(), manifest) return manifest
async def action(self, name: str, action: str) -> ActionSpec: """모듈의 액션 하나를 찾는다. 없으면 404.""" spec = (await self.manifest(name)).actions.get(action) if spec is None: raise ModuleError(404, "없는 액션입니다.") return spec
async def _post(self, name: str, path: str, params: dict, caller: dict) -> dict: """모듈에 POST를 보내고 JSON 응답을 돌려준다.
모듈이 422를 주면 그 메시지를 그대로 사용자에게 보여 준다(입력이 틀린 것이므로). 그 밖의 실패(연결 안 됨, 500, 401)는 자세한 내용을 서버 로그에만 남기고 502로 바꾼다. """ url = self.endpoints[name][0] try: res = await self._http.post(f"{url}{path}", json={"params": params, "caller": caller}, headers=self._headers(name)) except httpx.HTTPError as e: logger.warning("module %s %s failed: %s", name, path, e) raise ModuleError(502, "모듈에 연결할 수 없습니다.") from e if res.status_code == 422: try: message = str(res.json().get("message", ""))[:200] except ValueError: message = "" raise ModuleError(422, message or "요청 내용이 올바르지 않습니다.") if res.status_code != 200: logger.warning("module %s %s returned %s", name, path, res.status_code) raise ModuleError(502, "모듈 실행에 실패했습니다.") return res.json()
async def validate(self, name: str, action: str, params: dict, caller: dict) -> str: """실행 전에 입력을 검사받고, 승인 화면에 보여 줄 한 줄 요약을 받아 온다.""" data = await self._post(name, f"/actions/{action}/validate", params, caller) return str(data.get("summary", f"{name}.{action}"))[:200]
async def execute(self, name: str, action: str, params: dict, caller: dict) -> str: """모듈에 실제 실행을 맡기고 결과 문구를 돌려준다.""" data = await self._post(name, f"/actions/{action}", params, caller) return str(data.get("result", "완료"))[:200]
def caller_context(user) -> dict: """모듈에 넘길 "누가 시켰는지" 정보. 기기 키나 Secret 같은 인증 정보는 절대 넘기지 않는다.""" return {"user_id": user.id, "name": user.name, "role": user.role, "email": user.email}- 소개서는 1분 동안 기억한다. 명령이 올 때마다 소개서를 다시 받아 오면 느리다. 1분이 지나면 다시 받아 오는데, 그때 모듈이 잠깐 꺼져 있어도 전에 받아 둔 소개서로 버틴다.
- 소개서의 모양도 검사한다.
Manifest,ActionSpec은 Pydantic 모델이라, 모양이 틀린 소개서나 이상한 액션 이름(../admin같은)은 받아들이지 않는다. 액션 이름은 URL 경로와 scope에 들어가기 때문이다. - 모듈이 꺼져 있으면 503, 모듈이 실패하면 502. 자세한 이유는 서버 로그에만 남긴다.
- 테스트에서는
transport로 가짜 모듈을 붙인다. 진짜 네트워크 없이 테스트할 수 있다(9편).
class Manifest(BaseModel)처럼 BaseModel을 상속하고 필드마다 타입을 적어 두면, 그게 Pydantic 모델이다. “데이터는 이런 모양이어야 한다”는 설계도다. 들어온 데이터가 설계도와 맞으면 객체가 만들어지고, 안 맞으면 ValidationError가 난다. if문을 하나도 안 썼는데 검사가 된다.
- 타입과 기본값:
approval: bool = False는 “참/거짓이어야 하고, 없으면False”. 기본값이 없는title은 꼭 있어야 한다. Field(...): 타입만으로 부족한 조건을 단다.Field(max_length=60)은 60자까지.- 모델 안의 모델:
actions: dict[str, ActionSpec]라고 적으면, 딕셔너리 안의 값 하나하나도ActionSpec설계도로 검사한다. model_validate(딕셔너리): 바깥에서 받은 JSON(res.json())을 설계도에 대 본다. 모듈이 보낸 소개서를 믿지 않고 검사하는 게 이 줄이다.@field_validator: 타입으로는 표현할 수 없는 규칙을 직접 단다. 액션 이름 규칙(ACTION_RE)이 그렇다. 규칙에 어긋나면ValueError를 던지면 되고, Pydantic이 이걸ValidationError로 바꿔 준다.
그러니까 간단하게 이야기하면 필드에 타입을 지정할 수 있게 된다는거다. 나 처럼 꼰대 개발자는 동적 타입 언어를 싫어해서 Python에서 조차 타입을 지정한다.
catalog.py의 모델을 줄여서, 소개서 세 개를 넣어 봤다.
import re
from pydantic import BaseModel, Field, ValidationError, field_validator
ACTION_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
class ActionSpec(BaseModel): title: str = Field(max_length=60) approval: bool = False
class Manifest(BaseModel): name: str actions: dict[str, ActionSpec]
@field_validator("actions") @classmethod def _action_names(cls, actions: dict) -> dict: bad = [a for a in actions if not ACTION_RE.fullmatch(a)] if bad: raise ValueError(f"bad action names: {bad}") return actions
def check(data: dict) -> None: try: m = Manifest.model_validate(data) print("통과:", m.actions["send"].title, m.actions["send"].approval) except ValidationError as e: for err in e.errors(): print("거부:", err["loc"], err["msg"])
check({"name": "mail", "actions": {"send": {"title": "메일 보내기", "approval": True}}})check({"name": "mail", "actions": {"send": {"approval": "글쎄"}}})check({"name": "mail", "actions": {"../admin": {"title": "관리"}}})$ python3 manifest_demo.py통과: 메일 보내기 True거부: ('actions', 'send', 'title') Field required거부: ('actions', 'send', 'approval') Input should be a valid boolean, unable to interpret input거부: ('actions',) Value error, bad action names: ['../admin']첫 번째는 설계도와 맞아서 통과다. 통과한 뒤에는
m.actions["send"].title처럼 딕셔너리가 아니라 속성으로 꺼내 쓸 수 있다. 두 번째는title이 없고,approval이 참/거짓이 아니다. 틀린 곳이 두 개면 두 개 다 알려 주고,loc에 어디가 틀렸는지 경로까지 나온다. 세 번째는 모양은 맞지만 액션 이름이@field_validator규칙에 걸렸다.catalog.py는 이렇게 거부된 소개서를 쓰지 않고, 이유는 서버 로그에만 남긴다.
정리하면
- 감사 로그: 누가(사람, 기기) 언제 무엇을 했는지 JSONL로. 비밀값과 입력값은 남기지 않는다
- 약속: 허브와 모듈은
/manifest,/actions/{액션}/validate,/actions/{액션},/healthz네 개로만 이어진다. 승인 여부와 역할 제한은 모듈이 선언하고 허브가 집행한다 - 모듈 부르기: 소개서는 1분 동안 기억하고, 모양까지 검사한다. 모듈이 꺼져 있으면 503, 실패하면 502
이제 허브는 모듈이 누구고 무엇을 할 수 있는지 알고, 모듈을 부를 수 있다. 다음 8편에서는 클라이언트가 모든 모듈을 부르는 명령 입구와, 되돌릴 수 없는 명령을 붙잡아 두는 승인 대기열을 만든다.
그럼 안녕!

꼬리말을 불러오는 중...