c2s에 대한 오해와 구현 방식이 가져올 제약을 짚고, 데이터 호스팅과 해석을 분리하는 방향으로 c2s의 이점을 살리는 설계 제안을 정리한다.
URL: https://w.on-t.work/activitypub/c2s#fn:constantly_stalled_and_effectively_abandoned
나는 사람들이 c2s가 어떻게 동작하고 어떻게 구현되어야 하는지에 대해 갖는 인식이 걱정된다. 구현체들이 충분히 깊이 생각하지 않은 채로 이행해 버리면, 꽤 멋진 동작들을 스스로 불가능하게 만들어 버릴지도 모른다는 점이 특히 걱정이다.
이 변화는 (클라이언트 개발자에게만 해당되더라도) 필연적으로 파괴적일 것이고, 그 파괴가 “그냥 그랬으니까” 같은 이유가 아니라 실제 이점으로 이어졌으면 한다.
위에서 내가 “변화는 필연적으로 파괴적일 것”이라고 말한 것에 반대할 수도 있다. 결국 기존 소프트웨어가 그냥 게시물을 as:Note로 노출하고, 타임라인을 GET /users/[you]/inbox의 컬렉션으로 내보이면 되는 것 아닌가?
하지만 이것이 바로 내가 언급한 c2s에 대한 인식 문제의 일부다. c2s는 단지 폰 앱이 쓰는 또 하나의 클라이언트 API가 아니다. 마스토돈 API의 완전한 대체물도 아니다. 물론 그렇게 구현할 수는 있고, 실제로 그렇게 구현한 구현체들도 존재한다. 하지만 그런 구현은 c2s의 진짜 이점을 드러낼 수 없고, 불필요한 전환 비용만 만들어 개발자들을 좌절시키는 역할만 한다.
나는 이전에 이를 “json-ld 맛 마스토돈 api” 문제라고 부른 적이 있다. 왜냐하면 오늘날의 대부분 구현 위에 c2s를 얹어 구현하면 기존 API만큼이나 엄격하고 제한적인 API가 되기 쉬운데, 그럼에도 불구하고 (이 글 원문이 끊겨 있어 여기서 무엇이 사라지는지 명시적으로 이어지진 않지만) 결과적으로 얻는 것이 별로 없기 때문이다.
(수정되지 않은) 마스토돈이 자신의 API를 c2s로 그냥 바꾸기만 한다면, 여전히 게시물에 litepub:EmojiReact를 보낼 수 없을 것이다. (수정되지 않은) 마스토돈 데이터베이스는 …
c2s의 진짜 이득은 데이터 호스팅 과 데이터 해석 을 분리할 수 있다는 점이다. 이 부분이야말로 완전히 다른 인터페이스들에 로그인할 수 있게 해 준다. 이 부분이야말로 클라이언트에게 자기 마음대로 할 자유를 준다.
이 마이그레이션은 어떤 기존 소프트웨어들에겐 더 쉬울 수도 있다. 플레로마 계열은 내부적으로 이미 activitystreams와 유사한 형식으로 데이터를 저장하고, 이미 c2s의 부분집합을 지원한다. 하지만 그들 방언은 마스토돈이 ActivityPub로 전환하기 이전부터 존재했기 때문에, 여전히 상당한 호환성 변환 작업을 수행해야 한다.
그렇다면 내가 제안하는 해결책은? c2s 서버는 자기가 호스팅하는 데이터에 대해
정말로 거의 아무것도 “알지” 말아야 한다. 사실상 컬렉션 페이지네이션, HTTP 시그니처, WebFinger를 처리할 수 있는 ‘강화된 정적 사이트’ 정도여야 한다.
데이터의 해석은 클라이언트가 유지하는 별도의 인덱스에서 이루어져야 한다. 다행히 서버에서 클라이언트 인덱스로 데이터를 가져오는 방법은 이미 있다. 바로 액터와 인박스다. 그러면 클라이언트는 데이터를 원하는 대로 해석할 수 있고, 자신이 장려하고 싶은 사용자 경험을 위해 자체 API 엔드포인트를 노출할 수도 있다.
좋다, 잠깐만. 사실 우리는 클라이언트 인덱스로 데이터를 가져오는 “제대로 된” 방법을 정말로 갖고 있지는 않다. 적어도 데이터가 비공개일 때는 그렇다. c2s 서버에 물어볼 수는 있겠지만, 서버가 거짓말을 할 수도 있고, 그 결과 어떤 액터 하나가 특정 오브젝트에 대한 클라이언트 전체의 지식을 오염시킬 수도 있다. 나는 이에 대한 해결책에 관심이 있는지 타진해 봤지만, 별로 진전되진 않았다. 뭐, 어쩔 수 없다.
여기서 cache-control을 “개인 캐시”로 해석하는 방식으로 어느 정도는 버틸 수 있을지도 모르겠다. 하지만 오늘날의 대부분 배포는 cache-control을 자기 앞단의 공개 미들웨어 캐시를 지시하는 용도로만 쓰고 있다. 그래서 캐시 적중률은, 현재 구현들이 프록시한 오브젝트를 오래 캐싱하는 방식에 비하면 형편없을 것이고, 그로 인해 준비되지 않은 인스턴스들에 부하가 증가할 수 있다.
이제 ActivityPub 인증에서 흥미로운 문제가 하나 있다. 같은 오브젝트라도 서로 다른 액터에 대해 다르게 보이게 만들 수 있다. 이것이 authorized fetch의 기반이며, “$INSTANCE$host” 같은 해킹도 이런 특성에 기대고 있다.
따라서,
… 클라이언트는 대신 c2s 서버에게 더 많은 오브젝트들을 자기 대신 프록시해 달라고 요청해야 한다. 이건 클라이언트 인덱스가 ID만 반환하고 프론트엔드가 proxyUrl을 계속 두드려서 오브젝트를 가져오게 하는 식으로 할 수는 있지만, 오브젝트가 많고 연결이 느리면 …
이를 위해 나는 세 가지 제안을 갖고 있다:
(수화/하이드레이션을 위한) 특수한 “제어 프로퍼티”를 두어, c2s 서버가 여러 요청을 합치는 복잡성을 처리하게 하자. 그러면 배칭을 해도 최소 2번은 해야 하는 요청을, 필요한 데이터를 한 번의 요청으로 받을 수 있다.
커스텀 컨텍스트를 이용한 json-ld 재-컴팩팅으로, c2s 서버가 json-ld의 복잡성을 처리하고 더 엄격한 구조를 클라이언트에 반환하게 하자. 또한 컨텍스트에 없는 프로퍼티들에 대해 @vocab 폴백을 정의하지 않으면, 필요 없는 프로퍼티들을 걸러낼 수도 있다.
이제 흥미로운 반전이 있다. “가능한 한 적게 이해하자”에는 스펙의 일부를 구현하지 않는 것도 포함된다. 특히 as:Like 같은 액티비티의 부작용(사이드 이펙트) 말이다. 대신 클라이언트가 likes 컬렉션(및 동등한 것들)의 관리자가 되게 해서, 어떤 좋아요가 “유효한지/유효하지 않은지”를 자체 로직으로 결정하게 하자.
여기엔 흥미로운 문제가 있다: 누가 팔로워와 블록을 관리하는가? 아니, 잠깐만. 너무 앞서갔다. 누가 컬렉션들을 관리하는가? 클라이언트겠지? 그럼 어느 클라이언트? 사용자가 여러 클라이언트를 쓴다면, 어떤 클라이언트가 오브젝트들을 유지·관리할 책임이 있는지 어떻게 알 수 있을까?
여기서 나는 클라이언트가 오브젝트의 소유권을 갖게 하자고 제안한다. 그리고 이를 인가(authorization)에 연결하여, 클라이언트가 자신이 소유하지 않은 오브젝트를 제어할 수 있는지 여부를 결정할 수 있다. 그러면 c2s 서버 전용 관리 인터페이스를 통해, 예를 들어 기존 클라이언트를 더 이상 사용할 수 없어서 다른 클라이언트가 그 오브젝트들을 수정해야 하는 경우에 오브젝트 소유권을 재지정할 수도 있다.
다만 이 방식은 following/followers 컬렉션과 blocks 문제는 여전히 해결하지 못한다. 이 부분은 모든 클라이언트에 영향을 주므로, 책임을 c2s 서버 쪽에 남겨야 한다고 생각한다.
그리고 비슷한 맥락에서, 가능하면 액터 전역 상태(actor-global state)를 더 이상 정의하지 말자. 특히 각종 기능 협상(feature negotiation) 스펙들 말이다.
위 제안들이 클라이언트가 액티비티에 반응하고, 데이터를 인덱싱하고, 자신만의 맞춤형 API 엔드포인트를 노출하는 서버 컴포넌트를 갖는다는 강한 가정을 하고 있다는 것을 눈치챘을 것이다. 나는
지금도 2018년 말에 나온 폰을 사용한다. 2018년 기준 “중급 예산 플래그십”이긴 하지만 배터리 수명은 …
게다가 내가 사는 나라에는 사실상 무제한 모바일 데이터 요금제가 거의 없다. 그렇기 때문에,
…
이런 환경에서 홈 타임라인을 열 때마다 인박스를 순회하면서 모든 as:Like를 걸러내고 as:Create(as:Note)와 as:Announce(as:Note)만 보여 주되, 내가 팔로우하지도 않는 사람에게 단 답글이라면 또 제외하는 처리를 한다고 상상해 보라. 이 구성이 그걸 얼마나 잘 해낼지는 당신이 알아서 상상해도 된다.
그리고 as:Note를 클릭했을 때, 각 답글이 개별적으로 로드되길 기다리고 싶지 않다. 마스토돈 API는 트리를 한 번에 주는데, 왜 퇴행(regression)을 감수해야 하나?
그리고 가장 큰 것:
셀프호스팅을 하거나 대형 범용 인스턴스에 있지 않다면, 연합우주(fedi) 인스턴스는 로컬 타임라인, 버블 타임라인, 모더레이션 결정 등을 가진 커뮤니티 그 자체다. c2s 세계에서, 이 커뮤니티는 어디에 존재하는가? 모더레이션은 어디에 존재하는가?
클라이언트를 서버로 만들고, 타임라인과 답글 트리 같은 것들을 유지하게 하면, 그 레이어가 모더레이션 개입을 위한 층으로 작동할 수 있다. 스태프는 게시물과 사용자를 숨기고,
… 전반적으로 자기들이 적절하다고 생각하는 방식으로 커뮤니티를 큐레이션할 수 있다.
그리고 마음에 들지 않는다면? 그냥 다른 클라이언트로 로그인하면 된다. 잠깐만—서로 다른 모더레이션이 답글 트리에 어떤 영향을 주는지 보기 위해서든, 결정에 동의할 수 없어서 영구적으로 옮기기 위해서든. 당신의 데이터를 모더레이터들의 네트워크 해석과 분리하면, (심지어 셀프호스팅을 하더라도) 자신에게 적용되길 원하는 모더레이션을 선택할 자유를 얻는다.
c2s 서버는 여전히 접근 제어 인프라가 필요할 가능성이 크다. 최악 중의 최악을 막기 위해서든, 혹은 클라이언트의 모더레이션 레이어 위에 개인적인 모더레이션 레이어를 얹기 위해서든. 하지만 전체적으로는 대부분의 모더레이션이 “클라이언트가 보는 네트워크” 쪽으로 이동할 것이라고 예상한다.
또한 c2s 서버가 클라이언트가 소유한 오브젝트에 대해 클라이언트의 접근 제어를 강제하도록 하는 것은 흥미로운 탐구 영역일 수 있지만, 지금 당장은 그게 어떻게 동작해야 하는지에 대한 제안이 없다.
다듬기는 중요하다. 당신은 당신의 클라이언트를 사용하는 사람이 실제로 그 사용을 좋아하길 원한다. 나는 사람들이 고려해 주길 바라는 클라이언트 동작에 대한 의견이 몇 가지 있는데, 그중 하나는 URL이 읽기 쉬워야 한다는 것이다. URL이 불투명한 UUID이거나, 쿼리 파라미터의 덩어리이거나, 혹은 두 URL을 그냥 이어 붙인 것이라면 어색하다. 특히 소셜 미디어에서는 더 그렇다. 사람들은 URL을 여기저기 공유하고, 채팅과 블로그에 링크하고, 가짜 인용(quote) 게시물을 만든다.
여기서 내 제안은 다음과 같다:
항상 액터의 오브젝트들을 액터의 id 아래로 네임스페이싱하라.
클라이언트를 위한 사람이 읽기 좋은 URL을 구성할 때, 액터 id의 접두(prefix)를 WebFinger 핸들로 바꿔라.
예시가 도움이 될 수 있다. 이는 다음 오브젝트가:
https://wetdry.world/users/kopper/statuses/114189828144196201
WebFinger 핸들이 @kopper@wetdry.world이고 액터가 https://wetdry.world/users/kopper인 경우, 다음 URL로 접근 가능하다는 뜻이다:
https://client.example/@kopper@wetdry.world/statuses/114189828144196201
만약 액터 id가 URL의 접두가 아니거나, 액터가 WebFinger 핸들을 갖지 않는다면, 전체 오브젝트 id를 경로로 직접 사용하는 방식으로 폴백할 수 있다. 아무것도 안 하는 것보다 나쁠 게 없다.
솔직히 이런 네임스페이싱은 필수여야 한다고 생각한다. 두 오브젝트가 같은 곳에서 왔는지 검증할 수 있는
… 방법이 없는 것이, 내가 보기엔 사람들이 결함 있는 보안 모델인 오리진 기반 보안 모델 같은 것에 의존하게 되는 주된 이유다. 단일 사용자 혹은 사용자 없는 ActivityPub 소프트웨어는 하나의 액터를 웹 오리진의 루트에 두어 지금과 같은 동작을 얻을 수 있다. 하지만 멀티테넌트 소프트웨어는 네임스페이싱을 해야 한다.
또한 네임스페이싱을 하면 클라이언트가 id를 선택하게 할 수 있고, 이를
… 와 결합하면 as:Create로 오브젝트와 그 오브젝트가 참조하는(예: likes/shares/replies 컬렉션) 다른 오브젝트들을 한 번의 웹 요청으로 생성할 수 있어, 클라이언트 반응성을 개선할 수 있다.
https://github.com/swicg/activitypub-api/issues의 오픈 이슈들을 읽어보면, 사람들은 c2s가 초안(drafts), 트렌드(trends), 타임라인 같은 것들을 위한 엔드포인트를 표준화하길 원하는 것 같다. 나는 그게 도움이 되지 않을 거라고 생각한다.
내가 옹호하는 모델에서는, 서버가 아니라 클라이언트가 어떤 기능을 제공할지 결정한다. 이는 클라이언트가 기능과 사용자 경험의 미묘한 부분들에서, 엔드포인트를 표준화할 때는 생각하기 어려운 혁신을 할 수 있게 하고, 가치가 없다고 보는 기능은 거부할 자유도 준다. 물론 어떤 관례를 정의하는 건 여전히 도움이 될 수 있다. 예를 들어 as:OrderedCollectionPage는 어떤 클라이언트 API 페이지네이션에서도 유용할 수 있다고 생각한다.
내가 할 말은 이게 전부인 것 같다. c2s에 대한 내 비전을 더 알고 싶다면 https://codeberg.org/outpost 여기저기에 흩어져 있는 내용을 찾아보면 된다. 특히 ois 저장소의 두 브랜치와 README를 보라(하지만 다른 저장소들도 퍼즐의 흥미로운 조각들이다!). 이 난장판은 그런 생각들을 한 곳에 모으려는 시도지만, 여기에는 빠진 디테일들도 분명 있을 것이다. 어떤 사람들은 그것들이 흥미로울지도 모른다.
나는 ois와 더 넓은 outpost 프로젝트를 “연합”에 가깝게 만드는 것조차, 하물며 프로덕션 준비 상태로 만드는 것은 더더욱, 아마도 해내지 못할 가능성이 크다. 나는 내가 책임지고 있는 기존 연합우주 소프트웨어를 유지보수하는 것조차 간신히 하고 있다(미안, mia. 언젠가 마이그레이션 고치는 것부터 하겠다).
그래도 내 코드를 가져다가, 자신의 포크에서 마음껏 해 봐도 된다. 한 가지 팁: HTTP 시그니처 코드는 middleap의 것으로 바꾸는 게 좋을지도 모른다. 나는 그걸 복사해 오면서 빠진 조각들을 여러 개 발견했고, 아마 고쳤다고 생각한다. 첫 번째 이슈에 남은 작업 목록이 도움이 된다.
그냥 ‘바이브 코딩’만은 하지 말아라. 우리는 그보다 나은 걸 받을 자격이 있다.