[번역] at://의 위치: — overreacted

I
Inkyu Oh

Back-End2025.12.22

Dan Abramov - 2024년 10월 1일



AT 프로토콜 URI 해석하기

AT 프로토콜(AT protocol)에 대해 들어보셨을 것입니다. (아직 못 들어보셨다면, 이 글을 읽어보세요!)
AT 프로토콜을 사용하는 모든 서버는 함께 애트모스피어(the atmosphere)를 구성합니다. 이는 하이퍼링크로 연결된 JSON의 웹입니다. 애트모스피어에 있는 각 JSON 조각은 고유한 at:// URI를 가집니다.
그런데 이들은 정확히 어디를 가리키고 있을까요?
at:// URI가 주어졌을 때, 그에 해당하는 JSON을 어떻게 찾을 수 있을까요?
이 포스트에서는 at:// URI를 해석(Resolve)하는 정확한 과정을 단계별로 보여드리겠습니다. 알고 보니, 이것은 at://가 어떻게 작동하는지 세부 사항을 배우는 아주 좋은 방법이기도 합니다.
먼저 URI 자체의 구조부터 시작해 보겠습니다.



The User as the Authority

이미 알고 계시겠지만, URI는 종종 스킴(Scheme, 예: https://), 권한(Authority) (예: wikipedia.com), 경로(Path, 예: /Main_Page), 그리고 쿼리(Query)를 포함합니다.
https://를 포함한 대부분의 프로토콜에서 권한 부분은 데이터를 호스팅(Hosting)하는 주체를 가리킵니다. 이 데이터를 생성한 사람은 아예 나타나지 않거나 경로에 포함되어 있습니다.

at:// 프로토콜은 이를 뒤집습니다.
at:// URI에서 데이터를 생성한 사람이 가장 문자 그대로의 의미에서 권한(Authority)이 됩니다.


사용자가 자신의 데이터에 대한 권한입니다. 데이터를 호스팅하는 주체는 시간이 지남에 따라 바뀔 수 있으며, at:// URI에 직접 포함되지 않습니다. 해당 JSON을 호스팅하는 실제 물리적 서버를 찾으려면 몇 가지 단계를 거쳐야 합니다.



애트모스피어의 포스트

at:// URI가 나타내는 JSON 조각으로 해석해 봅시다.

at:// URI를 해석하는 쉬운 방법은 SDK나 클라이언트 앱을 사용하는 것입니다. 예를 들어 pdsls, Taproot, 또는 atproto-browser 같은 온라인 클라이언트를 사용해 보세요. 이들은 해당 JSON이 현재 호스팅되고 있는 물리적 서버를 찾아내어 그 JSON을 보여줄 것입니다.
위의 at:// URI는 현재 호스팅되고 있는 위치에 상관없이 다음 JSON을 가리킵니다.
{
"uri": "at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z",
"cid": "bafyreiae4ehmkk4rtajs5ncagjhrsv6rj3v6fggphlbpyfco4dzddp42nu",
"value": {
"text": "posting from did:web, like a boss",
"$type": "app.bsky.feed.post",
"langs": ["en"],
"createdAt": "2025-09-29T12:53:23.048Z"
}
}
$type 필드가 "app.bsky.feed.post"인 것을 보고 이것이 일종의 포스트(Post)임을 짐작할 수 있습니다(이것이 왜 textlangs 같은 필드를 가지고 있는지 설명해 줍니다).
하지만 이 JSON 조각은 웹 페이지나 어떤 앱의 일부가 아니라, 특정 소셜 미디어 포스트 그 자체를 나타낸다는 점에 유의하세요. 이것은 UI의 일부가 아니라 JSON 형태의 순수 데이터입니다. $type은 데이터 *형식(Format)*을 명시한다고 생각할 수 있습니다. app.bsky.* 접두사는 bsky.app 애플리케이션이 이 데이터를 어떻게 처리해야 할지 알고 있을 것임을 알려줍니다. 다른 애플리케이션들도 이 형식의 데이터를 소비하거나 생성할 수 있습니다.
주의 깊은 독자라면 JSON 블록의 uri 또한 at:// URI이지만, 우리가 처음에 요청했던 원래의 at:// URI와는 약간 다르다는 것을 눈치챘을 것입니다.
// at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z 에는 무엇이 있나요?
{
"uri": "at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z",
// ...
}
특히, 짧은 ruuuuu.de 권한이 더 긴 did:web:iam.ruuuuu.de 권한으로 확장되었습니다. 혹시 이것이 물리적 호스트일까요?
사실 아닙니다. 이것도 물리적 호스트가 아닙니다. 이것은 아이덴티티(Identity)라고 불리는 것입니다. 알고 보니 at:// URI를 해석하는 과정은 세 가지 뚜렷한 단계로 이루어집니다.
  1. 핸들(Handle)을 아이덴티티로 해석합니다. ("당신은 누구입니까?")
  1. 해당 아이덴티티를 호스팅으로 해석합니다. ("누가 당신의 데이터를 가지고 있습니까?")
  1. 해당 호스팅에서 JSON을 요청합니다. ("데이터는 무엇입니까?")
각 단계가 어떻게 작동하는지 살펴보겠습니다.



핸들에서 아이덴티티로

앞서 보신 at:// URI들은 핸들을 사용하기 때문에 취약합니다.
여기서 ruuuuu.de, danabra.mov, tessa.germnetwork.com은 핸들입니다.
(도메인을 "인터넷 핸들"로 사용하는 것에 대해 여기에서 더 읽어보세요.)
사용자는 나중에 자신의 at:// 핸들을 변경하기로 선택할 수 있으며, 이때 네트워크에 이미 존재하는 JSON 조각들 사이의 링크가 깨지지 않는 것이 중요합니다.
이것이 바로 at:// URI를 저장하기 전에, 핸들을 절대 변하지 않는 것인 아이덴티티로 해석하여 정규 형식(Canonical form)으로 변환해야 하는 이유입니다. 아이덴티티는 계정 ID와 같지만, 전역적이며 웹 전체를 위해 고안된 것입니다. 핸들을 아이덴티티(또는 "DID")로 해석하는 데는 두 가지 메커니즘이 있습니다.
  1. _atproto.<handle>에서 did=???를 찾는 DNS TXT 레코드를 쿼리합니다.
  1. https://<handle>/.well-known/atproto-did로 HTTPS GET 요청을 보냅니다.
우리가 찾고 있는 DID는 did:something:whatever와 같은 형태를 가집니다. (이것이 무엇을 의미하는지는 나중에 다시 다루겠습니다.)


예를 들어, DNS 메커니즘을 통해 ruuuuu.de를 해석해 봅시다.
$ nslookup -type=TXT _atproto.ruuuuu.de
Server: 192.168.1.254
Address: 192.168.1.254#53
Non-authoritative answer:
_atproto.ruuuuu.de text = "did=did:web:iam.ruuuuu.de"
찾았습니다!
ruuuuu.de 핸들은 did:web:iam.ruuuuu.de에 의해 소유된다고 주장합니다. 이 시점에서 우리가 알고 싶었던 것은 이것이 전부입니다.

이것이 아직 그들의 연관성을 증명하는 것은 아니라는 점에 유의하세요. did:web:iam.ruuuuu.de 아이덴티티를 제어하는 주체가 ruuuuu.de가 자신의 핸들이라는 것에 "동의"하는지 확인해야 합니다. 이 매핑은 양방향입니다. 하지만 이는 나중 단계에서 확인할 것입니다.


이제 DNS 경로를 사용하여 danabra.mov를 해석해 봅시다.
$ nslookup -type=TXT _atproto.danabra.mov
Server: 192.168.1.254
Address: 192.168.1.254#53
Non-authoritative answer:
_atproto.danabra.mov text = "did=did:plc:fpruhuo22xkm5o7ttr2ktxdo"
이것도 작동했습니다! danabra.mov 핸들은 did:plc:fpruhuo22xkm5o7ttr2ktxdo 아이덴티티에 의해 소유된다고 주장합니다.

이 DID는 이전에 본 것과는 조금 다르게 생겼지만, 역시 유효한 DID입니다. 다시 한번 강조하지만, 우리는 아직 이 연관성을 확인하지 않았습니다.


barackobama.bsky.social과 같은 서브도메인도 핸들이 될 수 있습니다.
해석해 봅시다.
$ nslookup -type=TXT _atproto.barackobama.bsky.social
Server: 192.168.1.254
Address: 192.168.1.254#53
Non-authoritative answer:
*** Can't find _atproto.barackobama.bsky.social: No answer
DNS 메커니즘이 작동하지 않았으므로 HTTPS로 시도해 보겠습니다.
$ curl https://barackobama.bsky.social/.well-known/atproto-did
did:plc:5c6cw3veuqruljoy5ahzerfx
작동했습니다! 이는 barackobama.bsky.social 핸들이 did:plc:5c6cw3veuqruljoy5ahzerfx 아이덴티티에 의해 소유된다고 주장함을 의미합니다.

이제 감이 오실 겁니다. 핸들을 보면 DNS와 HTTPS로 조사하여 특정 아이덴티티(DID)에 의해 소유된다고 주장하는지 확인할 수 있습니다. DID를 찾았다면, (1) 실제로 그 핸들을 소유하고 있는지 확인하고, (2) 해당 DID의 데이터를 호스팅하는 서버를 찾을 수 있습니다. 그리고 그 서버가 우리가 JSON을 요청할 서버가 됩니다.
실제로 AT로 무언가를 구축하고 있다면, 직접 핸들/DID 해석 캐시를 배포하거나 기존 캐시를 사용하고 싶을 것입니다. (여기 하나의 구현체가 있습니다.)



AT 퍼머링크 (Permalinks)

이제 핸들이 아이덴티티(DID)로 어떻게 해석되는지 알게 되었습니다. 시간이 지남에 따라 변하는 핸들과 달리, DID는 절대 변하지 않는 불변(Immutable)의 값입니다.
핸들을 사용하는 이러한 at:// 링크들은 사람이 읽기 쉽지만 취약합니다.
우리 중 누군가가 핸들을 다시 바꾸면 이 링크들은 깨질 것입니다.
반면, DID를 사용하는 아래의 at:// 링크들은 우리가 계정을 삭제하거나, 레코드를 삭제하거나, 호스팅을 영구적으로 중단하지 않는 한 깨지지 않습니다.
따라서 이것이 실제로 at:// URI의 "진정한 형태"입니다.

DID가 포함된 at:// 링크를 "퍼머링크(Permalinks)"라고 생각하세요. at:// URI를 저장하는 모든 애플리케이션은 우리가 핸들을 바꾸거나 호스팅을 바꿔도 JSON 조각들 사이의 논리적 링크가 깨지지 않도록 이 정규 형식으로 저장해야 합니다.
이제 핸들을 DID로 해석하는 방법을 알았으니, 다음 두 가지를 하고 싶을 것입니다.
  1. 이 DID를 소유한 주체가 실제로 그 핸들을 사용하는지 확인합니다.
  1. 이 DID의 모든 데이터를 호스팅하는 서버를 찾습니다.
DID 문서(DID Document)라고 불리는 JSON 조각을 가져옴으로써 이 두 가지를 모두 할 수 있습니다. 이것을 특정 DID에 대한 일종의 "여권"이라고 생각할 수 있습니다.
그 방법은 어떤 종류의 DID인지에 따라 다릅니다.



아이덴티티에서 호스팅으로

현재 AT 프로토콜에서 지원하는 DID 종류(이를 DID 메서드라고 함)는 두 가지입니다. did:web (W3C 초안)과 Bluesky가 명시did:plc입니다.
이들을 비교해 봅시다.

did:web

ruuuuu.de 핸들은 did:web:iam.ruuuuu.de에 의해 소유된다고 주장합니다.

이 주장을 확인하기 위해 did:web:iam.ruuuuu.de에 대한 DID 문서를 찾아봅시다. did:web 메서드는 이를 위한 알고리즘을 명시하고 있습니다.
요약하자면, DID에서 did:web: 부분을 잘라내고 끝에 /.well-known/did.json을 붙인 뒤 HTTPS GET 요청을 실행하면 됩니다.
$ curl https://iam.ruuuuu.de/.well-known/did.json | jq
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:web:iam.ruuuuu.de",
"alsoKnownAs": [
"at://ruuuuu.de"
],
"verificationMethod": [
{
"id": "did:web:iam.ruuuuu.de#atproto",
"type": "Multikey",
"controller": "did:web:iam.ruuuuu.de",
"publicKeyMultibase": "zQ3shWHtz9QMJevcGBcffZBBqBfPo55jJQaVDuEG7ZwerALGk"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://blacksky.app"
}
]
}
이 DID 문서는 졸음이 올 정도로 복잡해 보이지만, 세 가지 중요한 정보를 알려줍니다.
  • 그들을 어떻게 부를 것인가. alsoKnownAs 필드는 did:web:iam.ruuuuu.de를 제어하는 주체가 실제로 @ruuuuu.de를 핸들로 사용하고 싶어 함을 확인해 줍니다. ✅
  • 그들 데이터의 무결성을 어떻게 검증할 것인가. publicKeyMultibase 필드는 그들의 데이터에 대한 모든 변경 사항이 서명되는 공개 키를 알려줍니다.
  • 그들의 데이터가 어디에 저장되어 있는가. serviceEndpoint 필드는 그들의 데이터가 있는 실제 서버를 알려줍니다. Rudy의 데이터는 현재 https://blacksky.app에 호스팅되어 있습니다.
DID 문서는 정말 아이덴티티를 위한 인터넷 여권과 같습니다. 여기에는 그들의 핸들, 서명, 그리고 위치가 담겨 있습니다. 이는 아이덴티티 소유자가 핸들이나 호스팅 중 어느 하나를 변경할 수 있게 하면서도 핸들을 호스팅에 연결해 줍니다.

애트모스피어의 서로 다른 앱에서 @ruuuuu.de와 상호작용하는 사용자들은 그의 DID나 현재 호스팅(그리고 그것이 이동하는지 여부)에 대해 알거나 신경 쓸 필요가 없습니다. 그들의 관점에서는 그의 현재 핸들이 유일하게 유효한 식별자입니다. 개발자의 경우, 편리하게도 절대 변하지 않는 DID로 그를 참조하게 됩니다.
이 모든 것이 훌륭해 보이지만, did:web 아이덴티티에는 한 가지 큰 단점이 있습니다. 만약 did:web:iam.ruuuuu.deiam.ruuuuu.de 도메인에 대한 제어권을 잃게 되면, 그는 자신의 DID 문서에 대한 제어권을 잃게 되고, 결과적으로 자신의 아이덴티티 전체를 잃게 됩니다.
이 문제를 피할 수 있는 did:web의 대안을 살펴봅시다.

did:plc

우리는 이미 danabra.mov 핸들이 did:plc:fpruhuo22xkm5o7ttr2ktxdo 아이덴티티(사실 저입니다!)에 의해 소유된다고 주장한다는 것을 알고 있습니다.

이 주장을 확인하기 위해 did:plc:fpruhuo22xkm5o7ttr2ktxdo에 대한 DID 문서를 찾아봅시다.
did:plc 메서드는 이를 위한 알고리즘을 명시하고 있습니다.
기본적으로 https://plc.directory 서비스에 GET 요청을 보내야 합니다.
$ curl https://plc.directory/did:plc:fpruhuo22xkm5o7ttr2ktxdo | jq
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:plc:fpruhuo22xkm5o7ttr2ktxdo",
"alsoKnownAs": ["at://danabra.mov"],
"verificationMethod": [
{
"id": "did:plc:fpruhuo22xkm5o7ttr2ktxdo#atproto",
"type": "Multikey",
"controller": "did:plc:fpruhuo22xkm5o7ttr2ktxdo",
"publicKeyMultibase": "zQ3shopLMtAvvVrSsmWPE2pstFWY4xhGFBjkdRuETieUBozgo"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://morel.us-east.host.bsky.network"
}
]
}
DID 문서 자체는 똑같은 방식으로 작동합니다. 다음을 명시합니다.
  • 나를 어떻게 부를 것인가. alsoKnownAs 필드는 did:plc:fpruhuo22xkm5o7ttr2ktxdo를 제어하는 주체가 @danabra.mov를 핸들로 사용함을 확인해 줍니다. ✅
  • 내 데이터의 무결성을 어떻게 검증할 것인가. publicKeyMultibase 필드는 내 데이터의 모든 변경 사항이 서명되는 공개 키를 알려줍니다.
  • 내 데이터가 어디에 저장되어 있는가. serviceEndpoint 필드는 내 데이터가 있는 실제 서버를 알려줍니다. 현재 https://morel.us-east.host.bsky.network에 있습니다.
시각화 해봅시다:

제 핸들은 @danabra.mov이지만, 제 데이터를 실제로 저장하고 있는 서버는 현재 https://morel.us-east.host.bsky.network입니다. 저는 그곳에 계속 호스팅하는 것에 만족하지만, 나중에는 제가 직접 제어하는 호스트로 옮길 생각도 있습니다. 저는 소셜 앱에 지장을 주지 않으면서 제 핸들과 호스팅을 모두 변경할 수 있습니다.
did:web 아이덴티티를 가진 Rudy와 달리, 저는 웹 도메인에 저 자신을 돌이킬 수 없게 묶어두지 않기 위해 did:plc(Bluesky에서 계정을 만들 때 기본값)를 고수했습니다. "PLC"는 공식적으로 "Public Ledger of Credentials(공개 자격 증명 원장)"의 약자입니다. 기본적으로 DID 문서를 위한 npm 레지스트리와 같습니다. (재미있는 사실: 원래 PLC는 "placeholder(자리 표시자)"를 의미했지만, 그들은 이것이 좋은 절충안이라고 결정했습니다.)
did:plc 아이덴티티의 장점은 도메인 갱신을 잊어버리거나 TLD의 최상위 레벨에서 나쁜 일이 발생하더라도 제 아이덴티티를 잃지 않는다는 것입니다.
did:plc 아이덴티티의 단점은 PLC 레지스트리를 운영하는 주체가 제 아이덴티티에 대해 어느 정도의 통제권을 갖는다는 것입니다. 모든 버전이 이전 버전의 해시로 재귀적으로 서명되고, 모든 과거 버전을 조회할 수 있으며, 초기 버전의 해시가 DID 자체이기 때문에 그들이 제 아이덴티티를 완전히 바꿀 수는 없습니다.
하지만 이론적으로 PLC 레지스트리를 운영하는 주체는 제 DID 문서 업데이트 요청을 거부하거나, 그에 대한 일부 정보 제공을 거부할 수 있습니다. Bluesky는 이러한 우려 중 일부를 해결하기 위해 현재 PLC를 스위스의 독립 법인으로 옮기고 있습니다. AT 커뮤니티 또한 이에 대해 고민하고 실험하고 있습니다.



호스팅에서 JSON으로

지금까지 다음 방법을 배웠습니다.
  • 핸들을 DID로 해석하기.
  • 해당 DID에 대한 DID 문서 가져오기.
이것만으로도 at:// URI를 통해 JSON을 가져오기에 충분합니다!
각 DID 문서에는 실제 호스팅인 serviceEndpoint가 포함되어 있습니다. 그것이 바로 해당 주체가 저장하는 모든 JSON 레코드를 가져오기 위해 HTTPS로 접속할 수 있는 서비스입니다.
예를 들어, @ruuuuu.de 핸들은 did:web:iam.ruuuuu.de로 해석되고, 그 DID 문서의 serviceEndpointhttps://blacksky.app을 가리킵니다.
at://ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z 레코드를 가져오려면, at:// URI의 각 부분을 파라미터로 전달하여 https://blacksky.app 서버의 com.atproto.repo.getRecord 엔드포인트에 접속하면 됩니다.
$ curl "https://blacksky.app/xrpc/com.atproto.repo.getRecord?\
repo=ruuuuu.de&collection=app.bsky.feed.post&rkey=3lzy2ji4nms2z" | jq
그러면 결과가 나옵니다.
{
"uri": "at://did:web:iam.ruuuuu.de/app.bsky.feed.post/3lzy2ji4nms2z",
"cid": "bafyreiae4ehmkk4rtajs5ncagjhrsv6rj3v6fggphlbpyfco4dzddp42nu",
"value": {
"text": "posting from did:web, like a boss",
"$type": "app.bsky.feed.post",
"langs": [
"en"
],
"createdAt": "2025-09-29T12:53:23.048Z"
}
}
  • @danabra.mov 핸들은 did:plc:fpruhuo22xkm5o7ttr2ktxdo로 해석됩니다.
  • did:plc:fpruhuo22xkm5o7ttr2ktxdo의 DID 문서는 현재 호스팅으로 https://morel.us-east.host.bsky.network를 가리킵니다.
접속해 봅시다.
$ curl "https://morel.us-east.host.bsky.network/xrpc/com.atproto.repo.getRecord?\
repo=danabra.mov&collection=sh.tangled.feed.star&rkey=3m23ddgjpgn22" | jq
결과는 다음과 같습니다.
{
"uri": "at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/sh.tangled.feed.star/3m23ddgjpgn22",
"cid": "bafyreiaghm4ep5eeqx6yf55z43ge65qswwis7aiwc67rt7ni54jj6pg6fa",
"value": {
"$type": "sh.tangled.feed.star",
"subject": "at://did:plc:dzmqinfp7efnofbqg5npjmth/sh.tangled.repo/3m232u6xrq222",
"createdAt": "2025-09-30T20:09:02Z"
}
}
이것이 at:// URI를 해석하는 방법입니다.
연습 문제: 위의 레코드에서 subject는 다른 레코드에 대한 링크입니다. 그 소유자의 핸들과 해당 레코드의 내용을 알아내 보세요. pdsls를 사용하여 정답을 확인해 보세요.



결론

임의의 at:// URI를 해석하려면 다음 세 단계를 따라야 합니다.
  1. 핸들을 아이덴티티로 해석합니다(DNS 및/또는 HTTPS 사용).
  1. 해당 아이덴티티를 호스팅으로 해석합니다(DID 문서 사용).
  1. 해당 호스팅에서 JSON을 요청합니다(getRecord 호출).
클라이언트 앱이나 작은 프로젝트를 만들고 있다면 SDK가 이 모든 것을 대신 처리해 줄 것입니다. 하지만 좋은 성능을 위해서는 매 요청마다 DNS/HTTPS 조회를 수행하는 대신 해석 캐시(Resolution cache)를 사용하는 것이 좋습니다. QuickDID가 그러한 캐시 중 하나입니다. 또한 pdsls 소스를 확인하여 해석을 정확히 어떻게 처리하는지 볼 수도 있습니다.
실제로 많은 앱은 웹소켓을 통해 네트워크로부터 데이터를 수신하고 이를 로컬 데이터베이스에 집계하기 때문에 at:// URI를 해석하거나 JSON 레코드를 로드할 필요가 없습니다. 그런 방식을 사용하더라도 at:// URI를 사용자가 생성한 데이터의 고유 식별자로 계속 사용하게 되지만, 데이터 자체는 여러분이 가져오는(Pull) 것이 아니라 여러분에게 밀려 들어오는(Push) 방식이 됩니다. 그럼에도 불구하고 필요할 때 데이터를 가져올 수 있다는 사실을 아는 것은 유용합니다.
AT 프로토콜은 근본적으로 HTTP, DNS, JSON에 대한 추상화입니다. 하지만 이러한 조각들이 어떻게 맞물리는지 표준화함으로써(사용자를 권한의 위치에 두고, 아이덴티티를 호스팅에서 분리하며, 데이터를 이식 가능하게 만듦으로써), 웹을 여러분의 콘텐츠가 이를 표시하는 앱이 아니라 여러분에게 속하는 장소로 바꿉니다.
애트모스피어에는 탐험할 것이 더 많지만, 이제 여러분은 at://가 어디에 있는지 알게 되었습니다.
0
9

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글