제 목 :
우리집 비서 만들기 프로젝트 #6 - 서버편: 가족 등록과 테스트

5편에서 문지기를 세우고 앱을 조립했다. 이번에는 그 문을 쓸 가족을 등록하고, 문이 정말 잘 막히는지 확인한다. 허브의 문지기 이야기는 여기서 마무리다.
Step 1. 가족 등록 CLI (cli.py)
사용자 등록, 키 발급과 폐기는 외부 API로 열지 않았다. NAS 안에서 docker exec로만 할 수 있다. 당장은 관리자 페이지가 아직 개발 전 이라서 CLI(Command Line Interface)로만 명령하게 만들었다.
"""사용자·기기 관리 CLI. NAS 안에서만 실행한다(외부 API로는 키를 발급할 수 없다).
# 사용자 (최대 SECRETARY_MAX_USERS명, 기본 4명. 첫 사용자는 admin) docker exec -it secretary python -m app.cli user add dad "아빠" --role admin --email dad@example.com docker exec -it secretary python -m app.cli user add kid1 "첫째" --role child docker exec -it secretary python -m app.cli user list docker exec -it secretary python -m app.cli user disable kid1 # 그 사람의 기기 키도 전부 폐기 docker exec -it secretary python -m app.cli user delete typo-id # 기기·승인 기록이 없는 사용자만 완전 삭제
# 기기 docker exec -it secretary python -m app.cli device add dad phone --preset phone docker exec -it secretary python -m app.cli device list docker exec -it secretary python -m app.cli device revoke <access_key> docker exec -it secretary python -m app.cli device revoke-all # 비상 정지
# 모듈 docker exec -it secretary python -m app.cli module list # 연결 상태와 액션(= scope) 목록"""import argparseimport sysfrom datetime import datetime
import httpx
from .audit import AuditLogfrom .auth.registry import Registry, RegistryErrorfrom .catalog import Manifestfrom .auth.scopes import HUB_SCOPES, PRESETS, ROLES, effective_scopesfrom .config import get_settingsfrom .db import Databasefrom .approvals import ApprovalQueue
def _fmt(ts: int | None) -> str: """유닉스 시각(초)을 '2026-11-04 21:30' 같은 글자로 바꾼다. 값이 없으면 '-'.""" return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "-"
def build_parser() -> argparse.ArgumentParser: """명령어 구조를 정의한다. user/device 아래에 add, list ... 같은 하위 명령이 붙는다.
argparse가 잘못된 입력을 걸러 주고, -h 도움말도 자동으로 만든다. """ parser = argparse.ArgumentParser(prog="secretary") sub = parser.add_subparsers(dest="cmd", required=True)
usr = sub.add_parser("user", help="사용자 관리").add_subparsers(dest="action", required=True) ua = usr.add_parser("add", help="사용자 추가") ua.add_argument("user_id", help="영문 소문자 ID. 예: dad, mom, kid1") ua.add_argument("name", help="표시 이름. 메일 보낸 사람 이름으로도 쓰인다") ua.add_argument("--role", choices=ROLES, required=True) ua.add_argument("--email", help="본인 이메일. 모듈에 함께 전달된다 (메일 모듈은 Reply-To와 본인에게 보내기 허용에 쓴다)") usr.add_parser("list", help="사용자 목록") ud = usr.add_parser("disable", help="사용자 비활성화 (기기 키도 전부 폐기)") ud.add_argument("user_id") udel = usr.add_parser("delete", help="사용자 완전 삭제 (기기·승인 기록이 없을 때만)") udel.add_argument("user_id")
dev = sub.add_parser("device", help="기기 관리").add_subparsers(dest="action", required=True) da = dev.add_parser("add", help="기기 키 발급") da.add_argument("user_id") da.add_argument("name", help="영문 소문자 기기 이름. 예: phone, watch, glasses") da.add_argument("--preset", choices=sorted(PRESETS)) da.add_argument("--scope", action="append", default=[], help=f"직접 지정 (여러 번 가능): {', '.join(sorted(HUB_SCOPES))}, <모듈>:<액션>, <모듈>:*, modules:*") dev.add_parser("list", help="기기 목록") dr = dev.add_parser("revoke", help="기기 키 폐기") dr.add_argument("access_key") dev.add_parser("revoke-all", help="모든 기기 키 폐기 (비상 정지)")
mod = sub.add_parser("module", help="모듈 확인").add_subparsers(dest="action", required=True) mod.add_parser("list", help="등록된 모듈의 연결 상태와 액션 목록") return parser
def main(argv: list[str] | None = None) -> int: """CLI 시작점. 명령을 해석해 _user, _device, _module로 넘긴다.
돌려주는 값은 종료 코드: 0 성공, 1 대상 없음, 2 입력 오류(규칙 위반). """ args = build_parser().parse_args(argv) settings = get_settings() if args.cmd == "module": return _module(settings) reg = Registry(settings.registry_path, settings.max_users) audit = AuditLog(settings.audit_path)
try: if args.cmd == "user": return _user(args, reg, audit) return _device(args, reg, audit) except RegistryError as e: print(e, file=sys.stderr) return 2
def _user(args, reg: Registry, audit: AuditLog) -> int: """user add / list / disable / delete 를 처리한다. 바꾼 내용은 감사 로그에 via=cli로 남긴다.""" if args.action == "add": user = reg.add_user(args.user_id, args.name, args.role, args.email) audit.write("user.add", user=user.id, via="cli", role=user.role) print(f"사용자를 추가했습니다: {user.id} ({user.name}, {user.role})") return 0 if args.action == "list": users = reg.users() active = sum(1 for u in users if not u.disabled) print(f"활성 사용자 {active}/{reg.max_users}명") for u in users: state = "비활성" if u.disabled else "활성" print(f" {u.id:<12} {u.name:<10} {u.role:<7} {state:<4} {u.email or '-'}") return 0 if args.action == "disable": count = reg.disable_user(args.user_id) audit.write("user.disable", user=args.user_id, via="cli", revoked_devices=count) print(f"비활성화했습니다. 기기 키 {count}개를 폐기했습니다.") return 0 if args.action == "delete": settings = get_settings() db = Database(settings.db_path) try: history = ApprovalQueue(db, settings.approval_ttl_seconds).has_history(args.user_id) finally: db.close() user = reg.delete_user(args.user_id, has_history=history) audit.write("user.delete", user=user.id, via="cli", role=user.role) print(f"삭제했습니다: {user.id} ({user.name}, {user.role})") return 0 return 1
def _device(args, reg: Registry, audit: AuditLog) -> int: """device add / list / revoke / revoke-all 을 처리한다.
add는 Secret을 이때 한 번만 화면에 보여 준다. 역할 상한 때문에 빠지는 scope가 있으면 같이 알려 준다. """ if args.action == "add": scopes = set(PRESETS.get(args.preset, set())) | set(args.scope) if not scopes: print("--preset 또는 --scope 중 하나는 지정해야 합니다.", file=sys.stderr) return 2 device = reg.add_device(args.user_id, args.name, scopes) user = reg.user(args.user_id) effective = effective_scopes(device.scopes, user.role) audit.write("device.add", user=user.id, device=device.access_key, via="cli", scopes=device.scopes) print("기기 키를 발급했습니다. Secret은 지금 한 번만 보여 줍니다.\n") print(f" 사용자 : {user.id} ({user.name}, {user.role})") print(f" Access Key : {device.access_key}") print(f" Secret : {device.secret}") print(f" Scopes : {', '.join(sorted(effective))}") dropped = set(device.scopes) - effective if dropped: print(f" (역할 {user.role} 상한 때문에 실제로는 쓸 수 없는 scope: {', '.join(sorted(dropped))})") return 0 if args.action == "list": for d in reg.devices(): state = "폐기됨" if d.revoked else "사용 중" print(f"{d.access_key:<32} {d.user:<10} {state:<5} 발급 {_fmt(d.created_at)} {', '.join(d.scopes)}") return 0 if args.action == "revoke": ok = reg.revoke(args.access_key) if ok: audit.write("device.revoke", device=args.access_key, via="cli") print("폐기했습니다." if ok else "해당 키가 없거나 이미 폐기됐습니다.") return 0 if ok else 1 if args.action == "revoke-all": count = reg.revoke_all() audit.write("device.revoke_all", via="cli", count=count) print(f"{count}개 키를 폐기했습니다.") return 0 return 1
def _module(settings) -> int: """module list 를 처리한다. 모듈마다 manifest를 받아 와서 액션과 필요한 scope를 보여 준다.
기기에 어떤 --scope 를 줘야 할지 모를 때 이 목록을 보면 된다. """ endpoints = settings.module_endpoints if not endpoints: print("등록된 모듈이 없습니다. SECRETARY_MODULES 를 확인하세요.") return 0 for name, (url, token) in endpoints.items(): try: res = httpx.get(f"{url}/manifest", headers={"Authorization": f"Bearer {token}"}, timeout=5) res.raise_for_status() manifest = Manifest.model_validate(res.json()) except Exception as e: # 어떤 이유든 "연결 안 됨"으로 보여 주고 다음 모듈로 print(f"{name:<12} 연결 안 됨 {url} ({type(e).__name__})") continue print(f"{name:<12} 정상 {url} ({manifest.title})") for action, spec in manifest.actions.items(): notes = (["승인 필요"] if spec.approval else []) + ([f"역할: {', '.join(spec.roles)}"] if spec.roles else []) print(f" {name}:{action:<20} {spec.title}{' (' + ', '.join(notes) + ')' if notes else ''}") return 0
if __name__ == "__main__": sys.exit(main())CLI 명령 설계와 작성은 GUI 개발방식에 찌들어있는 내게 노가다와 다름이 없었다. 너무 고통스러웠다. 게다가 정상 적으로 잘 동작 하는지 중간중간 계속 확인하고 의도대로 잘 굴러가는지 확인 하는 과정이 고문과도 같았다.

그래도 코드엔 열심히 주석을 달아놨으니 보면서 이해를 바란다. 코드에 대한 자세한 설명은 거기에 두고 왔다.

위의 코드를 잘 작성했다면 아래의 명령들도 잘 동작할 거다. 예를 들어 가족 구성원들을 등록하면 이렇게 된다.
$ docker exec -it secretary python -m app.cli user add mom "엄마" --role member첫 사용자는 관리자(admin)여야 합니다.
$ docker exec -it secretary python -m app.cli user add dad "아빠" --role admin --email dad@example.com사용자를 추가했습니다: dad (아빠, admin)
$ docker exec -it secretary python -m app.cli user add kid1 "첫째" --role child사용자를 추가했습니다: kid1 (첫째, child)
$ docker exec -it secretary python -m app.cli device add kid1 phone --preset phone기기 키를 발급했습니다. Secret은 지금 한 번만 보여 줍니다.
사용자 : kid1 (첫째, child) Access Key : kid1-phone-1b207d04 Secret : **************************************** Scopes : modules:*, system:read (역할 child 상한 때문에 실제로는 쓸 수 없는 scope: approvals:decide)
$ docker exec -it secretary python -m app.cli user list활성 사용자 2/4명 dad 아빠 admin 활성 dad@example.com kid1 첫째 child 활성 -
$ docker exec -it secretary python -m app.cli user disable dad마지막 관리자는 비활성화할 수 없습니다.관리자보다 먼저 엄마를 등록하려고 하면 막히고, 아이 폰에 phone 프리셋을 주면 승인 권한은 역할 상한 때문에 잘렸다고 알려 준다. 마지막 관리자는 비활성화할 수 없다.
Cloudflare 서비스 토큰처럼, Secret은 발급할 때 한 번만 보여 준다. 잘 적어 두자. 혹여 Secret을 분실했다면 계정을 다시 생성하면 된다.
기기에 어떤 --scope를 줘야 할지 모르겠으면 module list를 보면 된다. 허브가 붙어 있는 모듈마다 액션과 그 액션에 필요한 scope를 보여 준다. 다만 지금은 붙은 모듈이 하나도 없어서 이렇게만 나온다.
$ docker exec -it secretary python -m app.cli module list등록된 모듈이 없습니다. SECRETARY_MODULES 를 확인하세요.모듈이 허브에 자기를 소개하는 방법은 7편에서, 메일 모듈을 붙인 뒤의 모습은 12편에서 본다.
Step 2. 테스트 (인증)
보안 코드는 “잘 되는지”보다 “잘 막히는지”를 테스트해야 한다. 2편에서도 강조했던 얘기다. 그래서 테스트 대부분이 막히는 경우다.
import jsonimport time
import httpximport pytestfrom fastapi.testclient import TestClient
from app.auth.registry import Registryfrom app.auth.scopes import ADMIN, CHILD, MEMBER, PRESETSfrom app.auth.signing import build_headersfrom app.config import Settingsfrom app.main import create_appfrom tests import fake_module
CF = {"Cf-Access-Jwt-Assertion": "test-jwt"}DOWN_TOKEN = "x" * 40
@pytest.fixturedef env(tmp_path): settings = Settings( data_dir=tmp_path, require_cf_access=True, lockout_threshold=5, modules="demo=http://fake/demo,down=http://fake/down", module_tokens=f"demo={fake_module.TOKEN},down={DOWN_TOKEN}", ) reg = Registry(settings.registry_path, settings.max_users) reg.add_user("dad", "아빠", ADMIN, "dad@example.com") reg.add_user("mom", "엄마", MEMBER, "mom@example.com") reg.add_user("kid", "첫째", CHILD) d = { "dad_phone": reg.add_device("dad", "phone", PRESETS["phone"]), "dad_watch": reg.add_device("dad", "watch", PRESETS["watch"]), "dad_glasses": reg.add_device("dad", "glasses", PRESETS["glasses"]), "mom_phone": reg.add_device("mom", "phone", PRESETS["phone"]), "mom_watch": reg.add_device("mom", "watch", PRESETS["watch"]), "kid_phone": reg.add_device("kid", "phone", PRESETS["phone"]), # 역할 상한으로 승인 권한은 잘린다 "dad_ping": reg.add_device("dad", "ping-only", {"demo:ping"}), } module_app, executed = fake_module.build() app = create_app(settings, module_transport=httpx.ASGITransport(app=module_app)) with TestClient(app) as client: yield client, settings, reg, d, executed
def call(client, device, method, path, body=None, query="", headers=None): raw = json.dumps(body).encode() if body is not None else b"" h = {**CF, **build_headers(device.access_key, device.secret, method, path, query, raw)} if body is not None: h["Content-Type"] = "application/json" h.update(headers or {}) url = f"{path}?{query}" if query else path return client.request(method, url, content=raw, headers=h)
def command(client, device, action="send", module="demo", **params): return call(client, device, "POST", "/v1/commands", {"module": module, "action": action, "params": params})
def request_send(client, device, to="grandma@family.example"): return command(client, device, "send", to=to)
def pending_ids(client, device): r = call(client, device, "GET", "/v1/approvals", query="status=pending") assert r.status_code == 200 return [a["id"] for a in r.json()["items"]]
# ── 인증 ─────────────────────────────────────────────────────fake_module은 진짜 모듈 대신 약속대로만 대답하는 가짜 모듈이다. 허브는 모듈 코드를 모르니까, 테스트에서도 HTTP로 대답해 주는 가짜만 있으면 된다. httpx.ASGITransport로 붙여서 네트워크 없이 바로 부른다. 가짜 모듈의 생김새와 명령·승인 테스트는 9편에서 본다. call()은 서명을 붙여서 요청을 보내는 도우미 함수다.
테스트에는 세 식구가 등장한다. 관리자 아빠(dad), 어른 가족 엄마(mom), 아이 첫째(kid). 아이 폰에는 일부러 phone 프리셋을 줘서, 역할 상한이 제대로 권한을 잘라 내는지 확인한다.
재전송 공격 테스트는 이렇게 생겼다. 똑같은 헤더로 두 번 보내면 두 번째는 막혀야 한다.
def test_replay_rejected(env): client, _, _, d, _ = env h = {**CF, **build_headers(d["dad_phone"].access_key, d["dad_phone"].secret, "GET", "/v1/me")} assert client.get("/v1/me", headers=h).status_code == 200 assert client.get("/v1/me", headers=h).status_code == 401인증과 사용자 관리 쪽 테스트는 이렇다.
| 테스트 | 확인하는 것 |
|---|---|
test_healthz_is_open |
헬스체크는 인증 없이 열려 있다 |
test_signed_request_succeeds |
올바른 서명은 통과, 누구인지 알려 준다 |
test_requires_cloudflare_access_header |
Cloudflare를 안 거친 요청은 401 |
test_bad_signature_rejected |
틀린 Secret은 401 |
test_tampered_body_rejected |
본문을 바꿔치기하면 401 |
test_replay_rejected |
같은 요청을 다시 보내면 401 |
test_old_timestamp_rejected |
1시간 전 서명은 401 |
test_revoked_key_rejected |
폐기된 키는 401 |
test_lockout_after_repeated_failures |
실패가 몰리면 맞는 서명도 잠시 401 |
test_disabled_user_devices_rejected |
비활성화된 사람의 기기는 401 |
test_device_scope_enforced |
워치는 모듈 명령 불가, demo:ping만 받은 기기는 ping만 (403) |
test_role_caps_device_scopes |
아이 폰은 프리셋과 상관없이 승인 불가 |
test_first_user_must_be_admin |
첫 사용자는 관리자 |
test_max_users |
5번째 사용자는 등록 불가, 비활성화하면 자리 생김 |
test_cannot_disable_last_admin |
마지막 관리자는 비활성화 불가 |
test_disable_user_revokes_devices |
사람을 비활성화하면 기기 키도 폐기 |
test_names_must_be_ascii |
한글 ID와 기기 이름은 거부 |
test_registry_file_is_private |
저장소 파일 권한 600 |
test_scope_shapes |
mail, Mail:Send 같은 잘못된 모양의 scope는 거부 |
test_wildcard_scopes |
mail:*는 mail:send만, modules:*는 승인 권한을 주지 않음 |
test_client_signature_matches_server |
예제 클라이언트 서명 = 서버 서명 |
$ cd services/hub && pytest -q.......................................... [100%]42 passed in 1.86s허브 테스트 42개가 전부 통과. 이 중 21개가 3편부터 여기까지의 인증과 사용자 쪽이고, 나머지 21개는 모듈 명령, 승인, 이벤트, 그리고 배포하다가 추가한 사용자 삭제 쪽이다. 명령과 승인 테스트는 9편에서 본다.
Step 3. 예제 클라이언트로 호출해 보기
다른 클라이언트(Flutter, G2, Zepp)를 만들 때 참고할 수 있게, 표준 라이브러리만으로 서명해서 호출하는 예제를 만들었다. 설치할 게 없어서 Mac에서 바로 돌릴 수 있다.
#!/usr/bin/env python3"""Secretary 호출 예제 클라이언트. 표준 라이브러리만 쓴다.
다른 클라이언트(Flutter, G2, Zepp)도 이 파일의 sign_headers()와 같은 방식으로 서명하면 된다.
환경 변수: SECRETARY_URL https://api.example.house CF_CLIENT_ID Cloudflare Access 서비스 토큰 ID CF_CLIENT_SECRET Cloudflare Access 서비스 토큰 Secret SECRETARY_ACCESS_KEY Secretary 기기 키 SECRETARY_SECRET Secretary 기기 Secret
사용 예: python3 secretary_client.py GET /v1/me python3 secretary_client.py GET /v1/modules python3 secretary_client.py POST /v1/commands '{"module":"mail","action":"send","params":{"to":["me@example.com"],"subject":"테스트","body":"안녕"}}' python3 secretary_client.py GET "/v1/approvals?status=pending" python3 secretary_client.py POST /v1/approvals/<id>/approve"""import base64import hashlibimport hmacimport jsonimport osimport secretsimport sysimport timeimport urllib.errorimport urllib.parseimport urllib.request
USER_AGENT = "secretary-client/1.0"
def sign_headers(access_key: str, secret: str, method: str, path: str, query: str, body: bytes) -> dict: """요청 하나에 붙일 서명 헤더 4개를 만든다. 서버의 app/auth/signing.py와 똑같은 방식.
다른 언어로 클라이언트를 만들 때 이 함수만 옮겨 적으면 된다. """ timestamp = str(int(time.time())) nonce = secrets.token_hex(16) target = f"{path}?{query}" if query else path canonical = "\n".join([method.upper(), target, timestamp, nonce, hashlib.sha256(body).hexdigest()]) digest = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest() return { "X-Access-Key": access_key, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.urlsafe_b64encode(digest).decode().rstrip("="), }
def main() -> int: """명령줄 인자로 받은 요청을 보내고, 상태 코드와 응답 JSON을 보기 좋게 출력한다.
Cloudflare Access 서비스 토큰 헤더 + Secretary 서명 헤더를 둘 다 붙인다. 400 이상이면 종료 코드 1. """ if len(sys.argv) < 3: print(__doc__) return 2 method, target = sys.argv[1].upper(), sys.argv[2] body = sys.argv[3].encode() if len(sys.argv) > 3 else b"" parsed = urllib.parse.urlsplit(target)
env = os.environ headers = { # urllib 기본 User-Agent(Python-urllib/3.x)는 Cloudflare Browser Integrity Check에 막힌다 (error code: 1010) "User-Agent": USER_AGENT, "CF-Access-Client-Id": env["CF_CLIENT_ID"], "CF-Access-Client-Secret": env["CF_CLIENT_SECRET"], **sign_headers(env["SECRETARY_ACCESS_KEY"], env["SECRETARY_SECRET"], method, parsed.path, parsed.query, body), } if body: headers["Content-Type"] = "application/json"
req = urllib.request.Request(env["SECRETARY_URL"].rstrip("/") + target, data=body or None, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as res: status, payload = res.status, res.read() except urllib.error.HTTPError as e: status, payload = e.code, e.read() print(status) try: print(json.dumps(json.loads(payload), ensure_ascii=False, indent=2)) except ValueError: print(payload.decode(errors="replace")[:500]) return 0 if status < 400 else 1
if __name__ == "__main__": sys.exit(main())로컬에서 서버를 띄우고, 아빠의 안경(dad glasses)으로 발급한 키로 호출해 봤다.
$ python3 secretary_client.py GET /v1/me200{ "user": {"id": "dad", "name": "아빠", "role": "admin"}, "device": {"access_key": "dad-glasses-7ebb279d", "name": "glasses"}, "scopes": ["modules:*", "system:read"]}
$ python3 secretary_client.py GET "/v1/approvals?status=pending"403{"code": "HTTP_403", "message": "권한이 없습니다."}
$ SECRETARY_SECRET=wrong python3 secretary_client.py GET /v1/me401{"code": "HTTP_401", "message": "인증에 실패했습니다."}서명이 맞으면 내가 누구인지와 실제 권한을 돌려준다(200). 아빠는 관리자지만 안경 기기에는 승인 권한이 없으니 승인 대기열은 볼 수 없다(403). Secret이 틀리면 왜 틀렸는지 말해 주지 않는다(401). 그리고 응답 헤더를 보면 Server 헤더가 없다. uvicorn을 --no-server-header로 띄웠기 때문이다. 시큐어 인프라 글에서 얘기한 “서버의 이름표는 떼어 둔다”다.
이 안경으로 메일 모듈에 명령을 보내는 건 12편에서 메일 모듈을 만든 뒤에 해 본다.
정리하면
3편부터 여기까지 한 일을 정리하면 이렇다.
- 구조: 허브는 사람, 권한, 승인, 명령 전달만. 실제 일은 모듈이. 둘은 HTTP 약속으로만 이어진다
- 인증: 기기별 키 + 요청마다 HMAC 서명 + 타임스탬프 + Nonce + 실패 잠금. 실패 이유는 로그에만
- 가족: 사람 층을 두고 관리자, 어른, 아이 역할로. 최대 4명
- 권한: 실제 권한은 기기 scope AND 역할 상한. 모듈 권한은
<모듈>:<액션>으로 자동으로 생기고, 요청과 승인은 다른 scope로 나눴다 - 관리: 사용자 등록과 키 발급은 NAS 안에서 CLI로만. 외부 API로는 열지 않는다
- 오류 응답: 무슨 일이 있어도 정해진 형식으로만, 원인은 서버 로그에만
여기까지 오는 동안에도 삽질이 있었다. 한글 기기 이름 때문에 절대 통과할 수 없는 키가 만들어졌다. 그리고 설계를 한 번 크게 고쳤다. “나 혼자”에서 “우리 가족”으로. 클라이언트를 만들기 전에 알아챈 게 다행이다. 역시 보안 코드는 “되는지”보다 “막히는지”를 봐야 한다.
다음 7편부터는 허브의 나머지 절반을 만든다. 허브와 모듈 사이의 약속을 정하고, 모든 모듈을 부르는 명령 입구와, 되돌릴 수 없는 명령을 붙잡아 두는 승인 대기열까지.
그럼 안녕!

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