AT Protocol: Bluesky를 떠받치는 탈중앙 소셜 프로토콜

소셜 서비스를 옮겨본 적 있으신가요? 계정을 새로 파고, 팔로워를 처음부터 다시 모으고, 그동안 쓴 글은 옛 서비스에 그대로 남겨두고 나오게 됩니다. 계정도 데이터도 인맥도 전부 그 회사 서버 안에 있으니 당연한 일이죠. 서비스가 마음에 안 들어도 쉽게 못 떠나는 이유이기도 하고요. 😅
AT Protocol(atproto)은 이 구조를 뒤집어보려는 시도입니다. Bluesky를 떠받치는 프로토콜로 알려져 있지만, 정확히 말하면 Bluesky는 이 프로토콜 위에 올라간 여러 앱 중 하나일 뿐입니다. 신원과 데이터를 앱 바깥으로 꺼내 놓고, 앱은 그걸 읽어가는 쪽으로 설계했거든요.
핸들과 DID로 나뉜 신원에서 출발해 서명된 리포지토리와 Lexicon 스키마를 거쳐, 네트워크 전체가 어떻게 조립되는지까지 따라가보겠습니다. 말로만 들으면 감이 잘 안 오는 대목이 많은데, 다행히 인증 없이 부를 수 있는 공개 API가 열려 있어서 하나씩 직접 확인해볼 수 있어요.
핸들과 DID, 신원이 두 겹인 이유
atproto에서 계정은 식별자를 두 개 가집니다. 하나는 핸들(handle)로 atproto.com처럼 생긴 도메인 이름이고, 다른 하나는 DID(Decentralized Identifier)라는 영구 식별자입니다.
왜 굳이 둘로 나눴을까요? 사람이 읽고 기억할 수 있는 이름은 바뀌기 마련이라서 그렇습니다. 도메인을 옮기거나 회사를 그만두면 핸들도 따라 바뀌는데, 그때마다 팔로우 관계가 끊기면 곤란하겠죠. 그래서 사람이 보는 이름과 시스템이 쓰는 이름을 분리해 뒀습니다. 핸들은 언제든 갈아 끼울 수 있는 별명이고, DID가 진짜 계정입니다.
핸들에서 DID를 찾는 건 공개 엔드포인트 호출 한 번이면 됩니다.
curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=atproto.com"
{
"did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
}
그런데 이 응답을 믿어도 되는지가 문제입니다. 아무 서버나 “이 핸들은 내 DID야”라고 답하면 사칭이 되니까요. 그래서 핸들 검증은 도메인 소유자만 할 수 있는 방식으로 이뤄집니다. DNS TXT 레코드에 DID를 적어두거나, 그 도메인의 /.well-known/atproto-did 경로에서 DID를 텍스트로 응답하게 하는 두 가지 방법이 있어요. 앞의 응답이 진짜인지 DNS로 직접 확인해보겠습니다.
dig +short TXT _atproto.atproto.com
"did=did:plc:ewvi7nxzyoun6zhxrhs64oiz"
_atproto 서브도메인에 같은 DID가 박혀 있죠. 도메인의 DNS를 고칠 수 있는 사람만 이 레코드를 만들 수 있으니, 도메인 소유권이 곧 핸들 소유권을 증명하는 셈입니다. 이미 갖고 있는 개인 도메인을 그대로 소셜 계정 이름으로 쓸 수 있다는 뜻이기도 하고요.
이제 반대 방향으로 DID를 풀어보면 계정의 실체가 나옵니다. did:plc:로 시작하는 DID는 PLC 디렉터리에서 조회합니다.
curl -s "https://plc.directory/did:plc:ewvi7nxzyoun6zhxrhs64oiz"
{
"id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"alsoKnownAs": ["at://atproto.com"],
"verificationMethod": [
{
"type": "Multikey",
"publicKeyMultibase": "zQ3shunBKsXixLxKtC5qeSG9E4J5RkGN57im31pcTzbNQnm5w"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://enoki.us-east.host.bsky.network"
}
]
}
DID 문서(DID document)라고 부르는 이 JSON에 계정 정보가 다 들어 있는데요. alsoKnownAs는 이 DID가 주장하는 핸들이고, verificationMethod는 이 계정이 쓴 글에 서명할 때 쓰는 공개 키입니다. service는 데이터가 실제로 어디 저장돼 있는지 가리키는 주소고요. 앞서 DNS로 확인한 핸들과 여기 적힌 alsoKnownAs가 서로를 가리켜야 검증이 완료됩니다. 한쪽만으로는 안 되고 양방향이 맞아떨어져야 하죠.
DID 방식은 두 가지를 지원합니다. 방금 본 did:plc는 별도 디렉터리 서비스가 키 회전과 이력을 관리하는 방식이고, did:web은 도메인의 /.well-known/did.json을 그대로 DID 문서로 쓰는 방식입니다. did:web이 더 단순하지만 도메인을 잃으면 계정도 함께 잃습니다.
데이터는 서명된 리포지토리에 담깁니다
DID 문서의 serviceEndpoint가 가리키던 주소가 이 계정의 PDS(Personal Data Server)입니다. 사용자가 쓴 글, 누른 좋아요, 팔로우 목록이 전부 여기 저장되는데, 저장 단위를 리포지토리(repository)라고 부릅니다. Git 저장소와 이름이 같고 성격도 꽤 닮았어요. 레코드를 하나 추가할 때마다 커밋이 만들어지고, 그 커밋에는 계정의 개인 키로 만든 서명이 붙거든요.
리포지토리 안이 어떻게 생겼는지 들여다보겠습니다.
curl -s "https://enoki.us-east.host.bsky.network/xrpc/com.atproto.repo.describeRepo?repo=atproto.com" \
| jq '.collections'
[
"app.bsky.actor.profile",
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.graph.follow",
"app.bsky.graph.list",
"chat.bsky.actor.declaration",
"community.lexicon.calendar.event",
"community.lexicon.calendar.rsvp",
"place.stream.livestream",
"site.standard.publication"
]
리포지토리는 컬렉션(collection) 단위로 나뉘고, 각 컬렉션에 같은 종류의 레코드가 쌓입니다. 여기서 눈여겨볼 대목은 app.bsky로 시작하지 않는 항목들입니다. community.lexicon.calendar.event는 일정 관리 앱이, place.stream.livestream은 라이브 스트리밍 앱이, site.standard.publication은 글 발행 앱이 만든 레코드예요. 계정 하나의 리포지토리 안에 서로 다른 앱의 데이터가 나란히 들어 있습니다.
이게 atproto가 노리는 그림입니다. 기존 서비스라면 캘린더 앱을 쓰려고 가입하고 스트리밍 앱을 쓰려고 또 가입해야 하죠. 여기서는 계정이 앱보다 위에 있으니 앱을 갈아타도 신원과 데이터가 그대로 따라옵니다.
개별 레코드는 at:// URI로 가리킵니다. 형식은 at:// 다음에 DID, 컬렉션, 레코드 키가 슬래시로 이어지는 구조예요.
at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3mtmhd6gngt2p
맨 뒤의 3mtmhd6gngt2p가 레코드 키(record key)인데, TID라고 부르는 시각 기반 식별자입니다. 생성 시각순으로 사전식 정렬이 되도록 만들어져서, 키를 정렬하는 것만으로 시간순 조회가 됩니다.
리포지토리 내부는 머클 검색 트리(Merkle Search Tree, MST)로 구성됩니다. 레코드 하나가 바뀌면 그 경로의 해시가 줄줄이 바뀌고 최종적으로 루트 해시가 달라지죠. 커밋 객체에는 계정 DID(did), MST 루트를 가리키는 링크(data), 단조 증가하는 리비전(rev), 그리고 서명(sig)이 들어갑니다. 서명 대상이 루트 해시 하나뿐이라, 이 서명 검증만 통과하면 리포지토리 전체가 위조되지 않았다는 게 보장됩니다. 중간에서 데이터를 중계하는 서버가 내용을 몰래 바꿀 수 없다는 뜻이라, 뒤에 나올 릴레이 구조가 성립하는 근거가 됩니다.
Lexicon: 스키마 하나가 곧 API가 됩니다
레코드 형식과 API를 정의하는 스키마 언어가 Lexicon입니다. JSON Schema로 데이터 모양을 기술하고 OpenAPI로 엔드포인트를 기술하는 걸 하나로 합쳐 놓았다고 보면 얼추 맞습니다.
좋아요 레코드의 실제 스키마를 보겠습니다.
{
"lexicon": 1,
"id": "app.bsky.feed.like",
"defs": {
"main": {
"type": "record",
"description": "Record declaring a 'like' of a piece of subject content.",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
id 값인 app.bsky.feed.like가 NSID(Namespaced ID)입니다. 자바 패키지명처럼 도메인을 뒤집은 형태인데, 이름 충돌을 막는 동시에 누가 이 스키마의 주인인지를 드러냅니다. app.bsky로 시작하면 Bluesky가, community.lexicon으로 시작하면 커뮤니티가 정의한 스키마죠. 중앙 등록소에 신청할 필요 없이 자기 도메인만 있으면 새 레코드 타입을 만들 수 있습니다.
API도 같은 이름 체계를 씁니다. NSID가 그대로 URL 경로가 되거든요.
GET /xrpc/com.atproto.repo.listRecords
GET /xrpc/app.bsky.actor.getProfile
이 규칙을 XRPC라고 부릅니다. 별도의 라우팅 규약을 만드는 대신 /xrpc/ 뒤에 NSID를 붙이기로 정한 게 전부라, 사실상 평범한 HTTPS 호출입니다. 앞에서 호출한 주소들도 전부 이 형태였죠. 조회는 query로 정의해 GET에, 변경은 procedure로 정의해 POST에 대응하고, 이벤트 스트림은 subscription으로 정의해 웹소켓에 대응합니다.
정리하면 스키마 파일 하나가 저장 형식과 API 경로와 검증 규칙을 동시에 결정합니다. 실제로 레코드를 하나 읽어보면 스키마가 그대로 데이터에 반영된 걸 확인할 수 있어요.
curl -s "https://enoki.us-east.host.bsky.network/xrpc/com.atproto.repo.listRecords\
?repo=atproto.com&collection=app.bsky.feed.post&limit=1" | jq '.records[0]'
{
"uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3mtmhd6gngt2p",
"cid": "bafyreibe4xqxyd7jxlaqp4qr7d4tgwl3pxtazutcbtwhb7c4jy7y664oda",
"value": {
"$type": "app.bsky.feed.post",
"text": "We've got a special AMA with Bluesky CEO @toni.bsky.team.",
"createdAt": "2026-08-21T19:05:35.545Z"
}
}
레코드마다 $type 필드가 붙어 자기가 어떤 Lexicon을 따르는지 밝히고, cid는 이 버전의 내용 해시입니다. 내용이 한 글자라도 바뀌면 CID가 달라지니, 특정 시점의 레코드를 정확히 가리킬 때 쓰입니다.
PDS, 릴레이, AppView로 나뉜 세 겹
여기까지가 계정 한 개의 이야기였습니다. 그런데 소셜 네트워크를 만들려면 남의 글도 보여줘야 하고, 좋아요 개수를 세야 하고, 검색도 돌아가야 하죠. PDS는 자기 사용자 데이터만 갖고 있으니 이런 걸 혼자 할 수 없습니다.
atproto는 이 일을 세 종류의 서비스로 쪼갰습니다.
flowchart TB
accTitle: AT Protocol의 데이터 흐름
accDescr: 클라이언트가 쓴 글은 사용자의 PDS에 저장되고, 릴레이가 여러 PDS의 변경 사항을 모아 파이어호스로 내보내며, AppView가 이를 색인해 클라이언트에 피드와 검색 결과를 제공한다.
client["클라이언트 앱"]
pds1["PDS A"]
pds2["PDS B"]
pds3["직접 운영하는 PDS"]
relay["릴레이<br/>변경을 파이어호스로 방송"]
appview["AppView<br/>색인, 집계, 검색"]
client -- "글 작성" --> pds1
pds1 --> relay
pds2 --> relay
pds3 --> relay
relay -- "이벤트 스트림" --> appview
appview -- "피드, 프로필" --> client
classDef hub stroke-width:2px
class relay,appview hub
PDS는 사용자의 저장소와 서명 키를 보관하고 인증을 처리합니다. 릴레이(relay)는 네트워크의 모든 PDS를 구독해 변경 사항을 하나의 스트림으로 합칩니다. 이 스트림이 파이어호스(firehose)입니다. AppView는 파이어호스를 받아 색인을 만들고, 좋아요 수를 집계하고, 타임라인을 조립해 클라이언트에 내려줍니다.
이 분리가 왜 중요할까요? 각 층을 따로 바꿔 끼울 수 있기 때문입니다. 데이터는 그대로 두고 AppView만 다른 걸 쓰면 같은 글을 완전히 다른 알고리즘으로 볼 수 있고, PDS만 옮기면 인맥과 글을 유지한 채 호스팅 업체를 바꾸는 셈이 됩니다. 추천 알고리즘이 마음에 안 들면 서비스를 떠나는 것 말고는 방법이 없던 구조와 여기서 갈립니다.
AppView도 인증 없이 호출해 볼 수 있습니다.
curl -s "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=atproto.com" \
| jq '{handle, displayName, did}'
{
"handle": "atproto.com",
"displayName": "AT Protocol Developers",
"did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
}
서드파티 앱이 사용자를 대신해 PDS에 글을 쓸 때는 OAuth로 인가를 받는데, 이때 쓰는 클라이언트 등록 방식이 좀 독특합니다. 사전 등록 없이 클라이언트 메타데이터 URL을 client_id로 쓰는 CIMD 방식인데, 처음 만나는 서버끼리 붙는 개방형 네트워크에서는 미리 등록해 둘 방법이 없기 때문이죠.
파이어호스 직접 받아보기
파이어호스는 말로 설명하는 것보다 한 번 열어보는 게 빠릅니다. 원본 파이어호스는 DAG-CBOR로 인코딩돼 있어 파싱이 번거로운데, Jetstream이라는 JSON 버전 게이트웨이가 있어서 웹소켓만 열면 바로 읽을 수 있어요. Bun에 웹소켓 클라이언트가 내장돼 있으니 파일 하나면 충분합니다.
const ws = new WebSocket(
"wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post",
);
let count = 0;
ws.onmessage = (event) => {
const { commit } = JSON.parse(event.data as string);
// 삭제 이벤트에는 record가 없으므로 생성만 걸러냅니다
if (commit?.operation !== "create") return;
console.log(`[${commit.record.langs ?? "?"}] ${commit.record.text}`);
if (++count >= 3) ws.close();
};
bun run jetstream.ts
실행하면 지금 이 순간 네트워크 전체에 올라오는 글이 쏟아집니다. 실제로 받아본 이벤트를 보면 이런 모양이에요.
{
"did": "did:plc:dgnmx2ikz7y6qyeft45fhwpk",
"kind": "commit",
"commit": {
"rev": "3mtquz3ztms2y",
"operation": "create",
"collection": "app.bsky.feed.post",
"rkey": "3mtquyykeqc2z",
"record": {
"$type": "app.bsky.feed.post",
"text": "@kicker.de könntet ihr endlich mal das Foto erneuern.",
"langs": ["de"],
"createdAt": "2026-08-23T13:21:05.261Z"
}
}
}
wantedCollections 파라미터로 관심 있는 컬렉션만 걸러 받을 수 있고, API 키도 가입도 필요 없습니다. 공개 데이터는 애초에 누구나 읽을 수 있다는 전제로 설계됐거든요. 새 앱을 만들 때 사용자 확보 전에도 네트워크 전체 데이터를 색인할 수 있다는 점이 꽤 큰 차이를 만듭니다.
계정 이동이 실제로 가능한 이유
atproto 설계에서 자주 언급되는 표현이 크레더블 엑싯(credible exit)입니다. 나가겠다고 마음먹었을 때 원래 서버의 협조 없이도 나갈 수 있어야 한다는 뜻이에요.
이게 성립하려면 세 가지가 필요합니다. 우선 데이터를 통째로 들고 나올 수 있어야 하는데, 리포지토리 전체를 CAR 파일로 내려받는 엔드포인트가 열려 있습니다. 그리고 새 PDS에서도 같은 계정임을 증명해야 하는데, 데이터가 이미 서명돼 있으니 서명만 검증하면 됩니다. 마지막으로 DID 문서의 serviceEndpoint를 새 주소로 갱신해야 하고요.
핵심은 마지막 단계의 권한이 어디 있느냐입니다. did:plc에는 서명 키와 별도로 회전 키(rotation key)가 있는데, 이걸 사용자가 종이에 적어 보관하는 식으로 직접 쥐고 있으면 PDS가 협조를 거부해도 DID 문서를 갱신할 수 있습니다. 팔로워 목록은 상대방 리포지토리에 들어 있고 그건 DID를 가리키니, 호스팅만 바뀌고 인맥은 그대로 유지됩니다.
ActivityPub과는 무엇이 다를까요
탈중앙 소셜이라고 하면 Mastodon이 쓰는 ActivityPub이 먼저 떠오르실 텐데, 접근 방식이 꽤 다릅니다.
| 구분 | ActivityPub | AT Protocol |
|---|---|---|
| 계정 식별 | 서버에 종속(@user@server) | DID로 서버와 분리 |
| 데이터 위치 | 각 서버가 원본을 보관 | 서명된 리포지토리로 이동 가능 |
| 전파 방식 | 서버 간 푸시로 팔로워에게 전달 | 릴레이가 전체를 파이어호스로 방송 |
| 전역 조회 | 내 서버가 받은 것만 보임 | 파이어호스 구독자는 전부 조회 |
| 알고리즘 | 서버가 제공하는 것 | AppView를 골라서 교체 |
ActivityPub은 서버끼리 메시지를 주고받는 연합 모델이라, 내 서버가 모르는 글은 검색되지 않고 서버를 옮기면 글이 따라오지 않습니다. atproto는 데이터를 자체 검증 가능한 형태로 만들어놓고 릴레이가 전부 중계하는 쪽을 택했습니다. 덕분에 전역 검색과 계정 이동이 자연스러운 대신, 릴레이를 돌리려면 네트워크 전체 데이터를 감당해야 하니 운영 비용은 훨씬 큽니다.
어느 쪽이 낫다기보다 무엇을 포기했는지가 다르다고 보는 편이 맞습니다. ActivityPub은 서버 운영 부담을 낮추는 대신 전역성을 포기했고, atproto는 전역성을 얻는 대신 중계 계층에 비용을 몰아줬습니다.
마치며
지금까지 AT Protocol을 신원, 저장, 스키마, 네트워크 순으로 살펴봤습니다. 핸들과 DID로 이름과 정체를 분리하고, 데이터는 서명된 리포지토리에 담아 서버에서 떼어냈죠. Lexicon으로 레코드와 API를 한꺼번에 정의하고, PDS와 릴레이와 AppView로 역할을 갈라놓았고요. 계정을 앱보다 위에 두겠다는 목표에서 나머지 설계가 줄줄이 따라 나온 셈입니다.
다행히 진입 장벽은 낮은 편입니다. 이 글의 모든 예제가 인증 없이 도는 것에서 보셨듯, 공개 데이터를 읽는 데는 준비물이 없습니다. 파이어호스를 열어놓고 관심 있는 레코드를 걸러 보는 것부터 시작해보시면 감이 빨리 잡힐 거예요. 직접 무언가를 만들어보고 싶다면 자기 도메인으로 NSID를 정해 새 레코드 타입을 설계하는 쪽이 이 프로토콜의 개방성을 체감하기에 가장 좋습니다.
더 자세한 내용은 AT Protocol 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0