블로그

2026년 8월 15일 · 9분 읽기

1인 개발 아키텍처 회고: 26편을 쓰고 확인한 구조와 남은 경로

iPhone·iPad 유니버설 앱 하나와 쿠버네티스 네임스페이스 14개를 혼자 운영하는 구조를 정리했습니다. 26편의 원인이 대부분 같은 종류였고, 서버에서 기기로 알리는 경로만 없었습니다.

  • 1인 개발
  • 아키텍처
  • 셀프호스팅
  • 쿠버네티스
  • Nostr

2026년 8월 23일 업데이트

앞선 26편은 각각 하나의 사건이었습니다. 메모의 세 글자를 두 코드 경로가 다르게 읽었고, 노드는 Ready인데 메트릭이 없었고, 콘솔은 구독을 pro로 보는데 앱은 free로 봤습니다.

한 편씩 쓸 때는 그 자리만 봤습니다. 26편을 마치고 전체 구조를 정리했습니다.

정리하게 된 계기는 알림이었습니다. 고객센터에 문의가 들어오면 자리에 앉아 있는 동안에만 알 수 있었습니다. 알림 하나를 추가하려다 기기와 서버의 경계를 다시 확인하게 됐습니다.

기기에서 클러스터까지

한눈에 보기

문제 앞선 26편은 각각 하나의 사건이었습니다.
결정 그래서 알림을 앱에 속한 작업으로 정했습니다.
결과 릴레이 위에서 정본과 권한을 어떻게 확인했는지는 따로 다뤘습니다.
제약 그래서 서버 쪽에서 대신 관측하는 구성이 필요합니다.

제품 표면은 앱 하나입니다

JustSend는 iOS 26을 타깃하는 유니버설 앱입니다. TARGETED_DEVICE_FAMILY1,2이므로 iPhone과 iPad가 같은 바이너리입니다. iPad는 네 방향 회전을 전부 지원합니다.

유니버설로 만들면 레이아웃을 분기해야 합니다. 처음에는 horizontalSizeClass로 갈랐습니다. 넓으면 사이드바, 좁으면 하단 탭입니다.

이 기준은 정확하지 않았습니다. 코드에 남긴 주석이 이유를 그대로 담고 있습니다.

// JustSend/Sources/Memory/Stream/StreamScreen.swift:215-219
/// 폭으로 판정할 수 없다 — split view 칼럼 안에서는 iPad 가로에서도
/// `horizontalSizeClass`가 `.compact`로 내려온다(실측: detail 427pt). 기기 idiom도
/// 답이 아니다: Slide Over로 좁게 띄운 iPad 창에서는 아이폰 도크가 맞다.
private var sidebarOwnsSurface: Bool { externalHomeTab != nil }

iPad를 가로로 놓고 split view를 열면 detail 칼럼이 427pt로 좁아져 compact가 내려옵니다. 기기 종류로 판정하면 Slide Over로 좁게 표시한 iPad에서 iPad용 레이아웃이 적용되어 어긋납니다.

폭도 기기도 답이 아니었습니다. 답은 누가 그 선택을 소유하는가였습니다. 사이드바가 표면 선택을 갖고 있으면 하단 세그먼트를 그리지 않습니다. 같은 선택을 두 곳에서 그리지 않는 것이 조건입니다.

앱 안에서 무거운 일은 기기에서 처리합니다. 요약과 분류는 온디바이스 모델이 하고, 검색은 로컬 인덱스가 답합니다. 앱과 위젯과 공유 확장이 하나의 App Group 데이터베이스를 씁니다.

서버로 나가는 것은 계정과 동기화, 그리고 첨부 저장소뿐입니다.

내가 보는 것은 데스크톱 둘입니다

Mac에서는 두 개를 씁니다. 원고를 쓰는 것과 고객센터 대화를 보는 것입니다.

둘 다 Tauri입니다. 하나는 app.solomd이고 다른 하나는 고객센터 클라이언트입니다. 웹 기술로 화면을 만들고 무거운 처리는 Rust 쪽에 두는 구조가 같습니다.

같은 구조를 두 번 고른 것은 의도한 결과입니다. 혼자 만들면 도구를 고를 때 배우는 비용이 가장 큰 항목이 됩니다. 같은 구조를 두 번 쓰면 첫 번째에서 배운 것이 두 번째에서 그대로 쓰입니다.

기기와 데스크톱을 나눈 기준은 사용 주체입니다. 제품이 앱이고, 작업 도구가 데스크톱입니다. 이 둘을 하나로 만들려고 하지 않았습니다.

서버는 클러스터 하나입니다

네임스페이스가 14개입니다. 서비스별로 나눠 두고 GitOps로 관리합니다.

네임스페이스 워크로드 무엇
앱 플랫폼 16 웹·API·백엔드
고객센터 11 릴레이·에이전트·지표 DB
모니터링 6 메트릭·알럿
GitOps 7 동기화 컨트롤러
메일 1 SMTP·IMAP

나머지는 인증 게이트, 인증서 발급, 라우터, 이슈 트래커, 실험용입니다.

클러스터를 하나로 둔 이유는 관리 대상을 늘리지 않는 것입니다. 네임스페이스는 늘어도 컨트롤 플레인은 하나입니다. 업그레이드도 백업도 한 번만 합니다.

고객센터만 프로토콜이 다릅니다

앱과 백엔드는 HTTP로 통신합니다. 고객센터만 다릅니다. Nostr 릴레이를 세우고 사람과 에이전트가 같은 소켓으로 붙습니다.

기반은 Buzz입니다. Block이 Apache-2.0으로 공개한 Rust 워크스페이스이고 crate가 30개입니다. 릴레이와 인증과 에이전트 어댑터와 푸시 게이트웨이가 따로 나뉘어 있고 Helm 차트가 함께 옵니다. docs/nips/에 표준이 다루지 않는 것을 정의한 초안 16개가 있습니다.

상용 고객센터 대신 릴레이를 고른 이유는 대화가 어디에 남는지였습니다. 외부 저장소에 쌓이면 이후 내보내기 기능이 허용하는 범위만 가져올 수 있습니다.

21시간 연속 가동 시점의 이벤트 분포입니다.

kind 무엇
7 67 리액션
5 64 삭제
9 59 채널 메시지
0 37 프로필
39000 · 39001 · 39002 각 23 채널 메타·관리자·멤버
9007 6 채널 생성

kind:9가 핵심입니다. 사람이 데스크톱에서 보낸 메시지와 에이전트가 보낸 메시지가 같은 kind입니다. 에이전트에게 관리 API를 열어 주지 않았습니다.

에이전트도 클라이언트입니다. 같은 소켓, 같은 인증, 같은 이벤트 종류를 씁니다. 특별한 통로를 만들면 그 통로만 감사 범위 밖으로 나갑니다.

릴레이 위에서 정본과 권한을 어떻게 확인했는지는 따로 다뤘습니다.

외부와 닿는 지점은 하나로 모았습니다

에이전트가 붙는 외부는 5개입니다. 앱스토어 리뷰, 커뮤니티 포럼, 메일 서버, 클러스터 상태 읽기, 지표 DB입니다.

에이전트 6인이 각자 API 클라이언트를 갖는 대신 MCP 서버 하나를 지납니다. 역할별로 등록되는 도구 목록이 다릅니다. 읽기만 하는 역할에는 쓰기 도구가 애초에 등록되지 않습니다.

쓰기는 두 겹입니다. 환경 게이트가 열려 있고, 승인자 목록에 있는 사람이 승인해야 실행됩니다. 이 부분은 MCP 서버 역할별 권한에서 다뤘습니다.

붙이는 데 걸린 것은 대부분 스킴과 인수였습니다

릴레이는 200을 응답하고 에이전트 파드는 Running이었지만 대화가 되지 않았습니다. 걸린 것을 순서대로 적습니다.

첫째, 평문 ws로 붙으면 전부 튕깁니다. NIP-42는 AUTH 이벤트의 relay 태그를 릴레이가 아는 자기 주소와 비교하는데 buzz-auth::normalize_relay_url이 스킴까지 봅니다. 클러스터 내부이므로 평문으로 충분하다고 본 판단이 잘못됐습니다. 인증이 문자열 비교로 주소를 확인하는 구조에서는 내부와 외부의 구분이 없습니다. TLS 종단이 인그레스이므로 파드가 hostAliases로 릴레이 도메인을 인그레스의 클러스터 내부 주소에 걸고 wss로 접속하게 했습니다.

둘째, MCP 실행 명령에 인수를 붙일 수 없습니다. 에이전트 어댑터는 환경변수에 담긴 문자열 전체를 실행 파일 경로로 보고 spawn합니다. args를 따로 넘기지 않습니다. 그래서 care-mcp --role reviews처럼 쓰면 그 이름의 파일이 없어 실패합니다.

실패가 드러나지 않는 것이 문제였습니다. 에러가 뜨는 대신 세션에 도구가 하나도 붙지 않은 상태로 대화가 시작됩니다. 에이전트는 답을 하는데 아무것도 조회하지 못합니다. 역할별 래퍼를 이미지에 넣어 인수 없는 경로로 만들었습니다.

셋째, 권한 모드를 올리면 세션이 생성되지 않습니다. 우회 모드를 요청했더니 어댑터가 그 요청을 구현하지 않아 세션 생성 자체가 실패했습니다. 턴이 아예 돌지 않습니다. 기본값으로 두면 어댑터가 권한 요청을 자동 승인합니다. 권한을 넓히려던 설정이 실행을 막았습니다.

넷째, 모델 지정은 어댑터가 광고하는 id로만 됩니다. 어댑터가 노출하는 id는 기본·상위·경량 세 가지뿐이어서 구체적인 모델명을 넣으면 경고와 함께 무시됩니다. 모델은 어댑터 설정이 아니라 프로바이더 환경변수로 고정했습니다.

다섯째, 채널은 멤버십 이벤트가 있어야 보입니다. 릴레이 초대 코드만으로는 채널 목록이 빕니다. kind:39002를 발행해야 조회에 걸립니다. 사람도 에이전트도 같습니다.

5가지 모두 코드가 틀린 것이 아니라 설정이 계약과 달랐던 경우입니다. 로그에 원인이 그대로 찍힌 것은 첫 번째뿐이었습니다.

기기 알림은 앱이 맡는 자리입니다

정리하면서 빠진 경로가 드러났습니다. 서버에서 기기로 먼저 말을 거는 수단이 없었습니다.

Mac에서는 필요하지 않았습니다. 데스크톱 앱이 떠 있는 동안 소켓이 살아 있고 새 이벤트가 그 소켓으로 들어옵니다. macOS는 백그라운드 프로세스를 오래 살려 둡니다.

모바일은 다릅니다. 모바일 운영체제는 백그라운드 소켓을 수 초 안에 종료합니다. Nostr는 pull 기반이라 클라이언트가 필터를 걸고 물어봐야 이벤트를 받습니다. 소켓이 없으면 물어볼 수 없습니다.

그래서 서버 쪽에서 대신 관측하는 구성이 필요합니다. 클라이언트가 없는 동안 필터를 유지하고, 매치가 발생하면 플랫폼 푸시로 기기를 깨우는 역할입니다.

처음에는 고객센터 프로젝트에 포함된 모바일 클라이언트를 붙이려 했습니다. 그럴 이유가 없었습니다. iPhone과 iPad에서 동작하는 앱이 이미 있고, 알림 하나를 위해 앱을 더 설치하게 만들 근거가 없습니다.

그래서 알림을 앱에 속한 작업으로 정했습니다. 고객센터는 릴레이까지 책임지고, 기기를 깨우는 일은 앱이 맡습니다. 전체 구조를 한 장에 놓기 전까지는 이 경계가 보이지 않았습니다.

푸시 경로에는 내용을 싣지 않습니다

경계를 정하면서 푸시 구조를 함께 확정했습니다. 푸시는 애플을 지납니다. 릴레이를 직접 올린 이유가 대화를 남의 저장소에 두지 않는 것이었으므로, 푸시에 내용을 담으면 한 경로를 통제하고 다른 경로로 같은 내용을 내보내는 결과가 됩니다.

Buzz가 이 문제를 NIP-PL 초안으로 정의해 뒀고, 그 구조를 그대로 쓰기로 했습니다.

기존 방식은 APNs 기기 토큰을 자체 HTTP API에 등록하거나, 콜백 URL로 이벤트 JSON 전체를 받습니다. 둘 다 플랫폼 배관을 프로토콜의 중심에 둡니다. NIP-PL은 프로토콜 객체를 전송 수단이 아니라 권한으로 잡습니다. 서명되고 만료되고 폐기할 수 있는 필터이고, 어느 벤더가 깨우기를 실행하는지는 그 권한의 실행 프로필일 뿐입니다.

푸시가 깨우기만 하는 경로

lease는 kind:30350 이벤트입니다.

  • d 태그는 설치본마다 다른 무작위 id입니다. 기기끼리 묶이지 않아 하나를 폐기해도 나머지가 영향받지 않습니다
  • expiration은 공개이고 필수입니다. 만료 없는 푸시 권한을 만들 수 없습니다
  • 전송 엔드포인트와 구독 필터는 실행자의 공개 키로 암호화됩니다

핵심은 페이로드 규칙입니다. 푸시로 나가는 것은 전송 서비스가 작성한 고정된 재접속 지시뿐입니다. 릴레이가 준 바이트, 이벤트 id, 본문, URL, 암호문이 전부 금지입니다. 기기는 깨어나서 자기가 아는 릴레이에 다시 붙고 정상 조회로 정본을 가져옵니다. 푸시는 유실될 수 있고 중복될 수도 있으므로 릴레이가 유일한 정본이라는 전제가 유지됩니다.

게이트웨이 코드에도 같은 제약이 주석으로 박혀 있습니다.

// crates/buzz-push-gateway/src/apns.rs:76-78
/// Closed APNs transport controls. No field can be serialized into application
/// content; the concrete transport always uses `APNS_RECONNECT_PAYLOAD`.

어떤 필드도 애플리케이션 콘텐츠로 직렬화될 수 없고, 실제 전송은 항상 고정 페이로드를 쓴다는 뜻입니다. 규칙을 문서에만 적고 구현에서 누락하는 경우가 흔한데 여기서는 타입으로 제한해 뒀습니다.

손댈 지점은 세 곳입니다. 앱의 알림 권한과 토큰 등록, 게이트웨이 배포, 앱의 lease 발행입니다. 세 곳의 위치는 모두 앞의 경계 판단에서 정해졌습니다.

앞선 26편의 원인은 대부분 같은 종류였습니다

구조를 정리한 뒤 앞선 글들을 다시 읽었습니다. 사건은 모두 달랐지만 원인의 형태가 비슷했습니다.

  • 코어를 별도 패키지로 분리했는데 빌드는 저장소 안 사본을 읽고 있었습니다
  • 색을 토큰으로 정리했는데 호출부 367곳은 옛 이름을 그대로 부르고 있었습니다
  • 구독 상태를 서버가 판정한다고 정했는데 앱에도 판정 코드가 남아 있었습니다
  • 노드는 Ready이고 exporter는 13개 다 살아 있는데 메트릭이 오지 않았습니다
  • 파드는 Running인데 컨테이너 하나가 readiness를 붙잡고 있었습니다
  • 역할별 권한을 프롬프트에 적었는데 도구 목록에는 반영되지 않았습니다

전부 선언한 것과 실행되는 것이 갈린 자리입니다. 어느 쪽도 코드가 틀리지 않았습니다. 두 개가 다 맞는데 서로 다른 것을 맞다고 하고 있었습니다.

혼자 만들면 이런 불일치가 발생하기 쉽습니다. 선언한 사람과 구현한 사람이 같으면 그 사이에 확인 단계가 없습니다. 한 사람의 인식 안에서는 두 값이 하나이므로, 어긋난 지점을 확인할 계기가 없습니다.

이번 알림도 같은 종류였습니다. 고객센터를 세우면서 사람이 대화를 본다고 정했는데, 사람이 자리를 떠난 경우를 어디에도 적지 않았습니다. 존재하지 않는 것은 불일치로 나타나지 않습니다. 항목을 나열해야 빠진 것이 확인됩니다.

글쓰기가 그 확인을 대신했습니다. 처음부터 의도한 목적은 아니었습니다.

한 편을 쓰려면 수치를 다시 측정하고 코드를 다시 읽어야 합니다. 그 과정에서 문서와 구현이 일치하지 않는 지점이 드러났습니다. 검색 글을 쓰다가 세 글자 조건이 본문과 코드에서 다르게 적혀 있는 것을 찾았고, 첨부 저장소 글을 쓰다가 서버가 무엇을 갖는지에 대한 설명이 두 편에서 서로 달랐던 것을 찾았습니다. 권한 글을 쓰려고 역할별 도구를 세어 보다가 읽기 전용이어야 하는 역할에 쓰기 도구가 붙어 있는 것을 찾았습니다.

설명하려면 정확히 알아야 하고, 확인하는 과정에서 어긋난 지점이 드러납니다. 코드 리뷰를 대신할 목적으로 쓴 것은 아니지만 결과는 유사했습니다.

이번에는 구조를 먼저 놓고 경계를 정했습니다.

2026-08-23 이후 달라진 경계

Mac 앱은 번들 MCP helper의 App Store 배포 제약 때문에 Developer ID 서명과 공증된 DMG 배포로 전환했습니다. iOS와 코어 코드는 공유하지만 배포, 로그인, 구매 경계는 서로 다릅니다.

JustSend MCP는 비동기 기록 앵커와 계정 범위가 어긋나 노트가 버려지는 구조를 다시 설계하고 1.2.0을 배포했습니다. 작업 기록에는 대표 이미지와 목록 썸네일, 완료 결과 계약도 붙었습니다.

API 표면은 등록 경로와 실제 소비자를 대조한 뒤, 사용 흔적이 없어도 곧바로 삭제하지 않고 기존 유지·보류 결정을 다시 확인했습니다. 현재 아키텍처를 설명할 때는 기기, 서버, 배포뿐 아니라 작업 기록과 검증 계약도 같은 경계로 봐야 합니다.

같은 상황을 만나면

  • 서버가 기기에 먼저 말을 거는 경로가 있습니까? 없다면 어느 쪽 작업으로 분류돼 있습니까?
  • 플랫폼 푸시에 실어 보내는 페이로드에 본문이나 이벤트 식별자가 들어 있습니까?
  • 인증이 주소를 문자열로 비교합니까? 그렇다면 내부 호출도 같은 스킴으로 붙습니까?
  • 외부 프로세스에 넘기는 실행 명령에 인수를 붙일 수 있습니까? 붙일 수 없는데 붙이면 무엇이 조용히 비어 있습니까?
  • 권한을 넓히는 설정이 오히려 세션 생성을 막고 있지는 않습니까?
  • 선언한 경계와 빌드가 실제로 읽는 경로가 같습니까?