ActivityPlug와 함께 걸어본 이야기
ActivityPlug라는 도구가 있어요. 아직 README도 없는, 태어난 지 두 달쯤 된 어린 프로젝트예요. 그런데 막상 안을 들여다보니 설계가 무척 조용하고 단단했어요. 이 글은 그 옆을 나란히 걸어본 기록이에요. 소개라기보다는 산책에 가까워요.
무엇을 풀려는 도구인가
페디버스 서버들은 서로 대화하는 방식(ActivityPub)은 맞춰져 있지만, 클라이언트가 쓰는 API는 제각각이에요. Mastodon은 REST와 Link 헤더를 쓰고, Misskey는 POST투성이인 자체 RPC를 쓰고, Hackers' Pub은 GraphQL을 써요. 클라이언트를 만드는 사람은 이 틈을 매번 스스로 메워야 했어요.
ActivityPlug는 바로 그 틈을 메우는 층이에요. 쓰는 방법은 두 가지예요.
- 라이브러리로 — TypeScript 어댑터(
@activityplug/mastodon,@activityplug/misskey, pleroma, hollo, hackerspub)를 내 코드에 바로 꽂아요 - 서버로 — 통일된 GraphQL / REST API를 말하는 프록시를 세우고, 어느 서버든 그 창을 통해 들여다봐요
아직 npm에는 올라가 있지 않아서, 지금은 clone 받아서 pnpm build부터 시작해야 해요. Node 24와 pnpm 10만 있으면 전부 조립돼요.
라이브러리로 꽂아보기
먼저 저희 서버(sukhi.f3liz.casa — Mastodon 호환 API를 쓰지만 Mastodon은 아닌 서버)에 mastodon 어댑터를 꽂아봤어요.
import { createActivityPlugClient } from "@activityplug/core";
import { createMastodonAdapter } from "@activityplug/mastodon";
const client = createActivityPlugClient({
adapter: createMastodonAdapter(),
origin: "https://sukhi.f3liz.casa",
});
const page = await client.timelines.public({ page: { limit: 3 } });인증 없이도 이게 전부예요. 그런데 돌아온 첫 "게시물"을 보고 살짝 웃음이 났어요.
text: 이 서버는 연합 타임라인(네트워크의 모든 공개 게시물이
흘러드는 탁류)을 내보내지 않습니다. …sukhi는 연합 타임라인을 흘려보내는 대신 안내문 한 건만 돌려주는, 조금 별난 설계를 갖고 있어요. 그 별난 대답이 다듬어지지도 감춰지지도 않은 채 그대로 창 너머로 도착하니, "어?" 싶었어요. 이 층은 각 서버의 버릇을 있는 그대로 옮겨오는구나, 하고요.
local: true로 바꾸면 진짜 로컬 타임라인이 흘러오고, 페이지 넘기기도 평범하게 잘 작동해요.
const page2 = await client.timelines.public({
local: true,
page: { limit: 3, after: page1.pageInfo.endCursor },
});ID가 봉투에 담겨 있어요
게시물 ID를 열어보면, 날것의 ID가 아니라 이런 모양이에요.
ap_1_WyJtYXN0b2RvbiIsImh0dHBzOi8vc3VraGkuZjNsaXouY2FzYSIsInBvc3QiLCI1MTY4MzI0ODA2NTg3NDcyIl0base64를 풀어보면 안에는 ["mastodon", "https://sukhi.f3liz.casa", "post", "5168324806587472"]가 들어 있어요. 어느 어댑터의, 어느 서버의, 무슨 ID인지를 스스로 기억하는 봉투인 셈이에요. 커서(apc_1_…)도 마찬가지로 어떤 작업에서 나온 것인지까지 함께 묶여 있어서, 로컬 타임라인의 커서를 실수로 홈 타임라인에 꽂는 식의 착오를 타입 시스템이 미리 막아줘요.
Misskey의 ID와 Mastodon의 ID, 그리고 UUID가 한 앱 안에서 뒤섞여본 적이 있다면, 이 봉투가 얼마나 고마운지 바로 알 수 있을 거예요.
서버로 세워보기
다음은 프록시 쪽이에요.
node packages/server/dist/bin.mjs --port 4100 \
--allow-origin https://sukhi.f3liz.casa \
--allow-origin https://misskey.io \
--allow-origin https://hackers.pub명시적으로 허용한 서버하고만 대화해요. 먼저 "이 서버, 정체가 뭐야?" 하고 물어봐요.
curl -X POST localhost:4100/api/v1/instances/detect \
-d '{"origin":"https://misskey.io"}'misskey.io는 nodeinfo를 근거로 misskey 어댑터라고 판정되고, 기능 목록표가 함께 돌아와요. 이 도구에서 가장 정직한 부분이 바로 여기예요. 모든 기능에 supported / unsupported / unknown, 세 가지 값과 그 이유가 함께 붙어 있거든요.
{ "name": "posts.update", "status": "unsupported",
"reason": "Misskey does not expose stable note editing." }
{ "name": "instance.oauthMetadata", "status": "unknown" }"할 수 없어요"에 이유까지 적어주는 API는 흔치 않아요.
저희 sukhi는 nodeinfo에서 스스로를 sukhi-fedi라고 소개하기 때문에 자동 판정이 되지 않았어요. 그런데 그것도 정직한 실패라서, "adapter": "mastodon"이라고 직접 알려주면 곧바로 통해요. 호환 서버가 "저를 Mastodon으로 대해주세요"라고 말하는 걸 추측이 아니라 명시로 받는 모양새예요.
같은 쿼리가 다른 서버에도 통해요
통일 GraphQL 쪽이 아마 가장 인상적일 거예요. 이 쿼리 하나가 서로 다른 서버에 그대로 통하거든요.
query ($origin: String!, $adapter: AdapterKind) {
publicTimeline(origin: $origin, adapter: $adapter,
local: true, page: { limit: 2 }) {
nodes { ref { id rawId } author { handle } createdAt }
pageInfo { hasNextPage }
}
}origin: "https://sukhi.f3liz.casa"(내부는 Mastodon 호환 REST)에도, origin: "https://hackers.pub"(내부는 GraphQL)에도 이 쿼리 그대로 통해요. 한쪽은 snowflake ID를 쓰고 다른 쪽은 UUID를 쓰는데, 둘 다 같은 ap_1_ 봉투에 담겨 같은 모양으로 나란히 돌아왔어요. 서로 다른 세계에서 온 우편이 한 우체통에 도착하는 느낌이었어요.
로그인, 그리고 정직한 에러
토큰을 이미 갖고 있다면 그대로 꽂으면 돼요. OAuth 흐름도 따로 마련되어 있어요.
const session = await client.auth.injectToken({ accessToken: token });
const viewer = await client.auth.verifyCredentials(session);
// → logged in as: shiro_mudita / シロ제 계정으로 로그인이 됐어요. 그다음엔 일부러 안 되는 일을 시켜봤어요. Mastodon 계열 서버에 Misskey식 이모지 리액션을 부탁해본 거예요.
await client.social.react({ session, postId, emoji: "🍵" });
// → ActivityPlugError: UNSUPPORTED_OPERATION | social.reactionnull을 돌려주고 입을 다무는 것도 아니고, 500 에러로 넘어지는 것도 아니었어요. "그 작업은 이 서버에 없어요"라고, 타입이 명확히 붙은 에러로 돌아왔어요. capability 목록의 세 가지 값과 같은 태도가 실행 시점의 에러에서도 그대로 일관돼 있었어요.
다 걷고 나서
ActivityPlug는 아직 어린 도구예요. 버전은 0.0.0이고, npm에도 아직 없고, README도 아직 없어요. 하지만 실제 서버 다섯 종류(리비전 고정)를 상대로 한 E2E 테스트가 있고, 문서를 영어·한국어·일본어 세 형제로 나란히 써나가는 문화가 있고, "할 수 없는 건 할 수 없다고 말한다"는 태도가 설계의 한가운데를 꿰뚫고 있어요.
여러 종류의 페디버스 서버를 다루는 무언가를 만들 때 — 봇이든, 클라이언트든, 다리 역할을 하는 것이든 — 이 층이 아래에 있어주기를 바라게 되는 순간이 아마 적지 않을 거예요.
(이 글의 관찰은 2026-07-02, 커밋 5eed56d 시점의 것이에요. 아직 어린 프로젝트라서 모양은 앞으로도 계속 바뀔 거예요.)
다음 이야기에서 또 만나요.