[번역] React ProseMirror를 정말, 정말 빠르게 만들기

Shane Friedman - 2026년 3월 23일


React ProseMirror는 여러 면에서 매우 평범한 프로젝트입니다. 이 라이브러리는 React와 ProseMirror를 통합합니다. 라이브러리가 실제로 노출하는 API 표면적은 상당히 작은데, 그 이유는 사용자가 평소처럼 React를 사용하고 평소처럼 ProseMirror를 사용하되, 이러한 통합에서 흔히 발생하는 모든 부자연스러운 문제들을 걱정할 필요가 없게 한다는 전제를 가지고 있기 때문입니다.
하지만 제가 위에 링크한 글을 읽어보셨다면, 이 작업을 올바르게 수행하는 것이 놀라울 정도로 어렵다는 사실을 알고 계실 것입니다. 사실 너무나 어려워서, "정답"에 가까운 해결책은 prosemirror-view의 렌더링 엔진을 React로 완전히 다시 구현하는 것이었습니다. 이 방식이 "나머지 부분은 알아서 그리세요" 식의 무책임한 접근처럼 보일 수도 있겠지만, 원래의 문제 정의는 아주 잘 해결해 줍니다. React가 상태 관리와 렌더링을 모두 담당하게 되면, 더 이상 상태 불일치(State tearing) 문제에 직면할 일이 없기 때문입니다.
하지만 이는 새로운 문제를 야기합니다. 바로 "어떻게 하면 빠르게 만들 것인가?"입니다. 여기서 빠르다는 것은 정말, 정말 빠른 것을 의미합니다. 사용자가 60Hz 디스플레이를 사용하고 있다면, 애플리케이션은 디스플레이가 업데이트되기 전까지 다음 렌더링 상태를 생성하는 데 16밀리초(ms)의 시간밖에 없습니다. 이 목표를 놓치면 사용자는 입력에 대한 반응을 보기까지 최대 32밀리초를 기다려야 할 수도 있으며, 이는 눈에 띄는 지연(Lag)의 영역으로 들어가기 시작합니다.
처음에 저는 이것이 큰 걱정거리가 아니라고 생각했습니다. Dskrpt 사용자들은 React ProseMirror를 사용하여 법률 교과서의 전체 장을 작성하고 있었지만, 지연에 대해 불평하는 사람은 아무도 없었습니다. 게다가 그들은 독일어로 글을 쓰고 있었습니다. 독일어는 결코 간결함으로 유명한 언어가 아니죠!
하지만 로컬 우선(Local-first) 마크다운 에디터를 만드는 Moment 팀과 협업하던 중, 그들은 React ProseMirror의 성능이 충분하지 않다고 주장했습니다. 증거로 그들은 해리 포터 소설 2권 전체를 붙여넣은 문서를 저에게 보냈습니다. 저는 비웃으려 했지만(정말로요!), 그들은 꽤 설득력 있는 사용 사례를 제시했습니다. "만약 누군가 Moment에서 W3C 스펙(Spec)을 작성하려고 하면 어떡하죠? 우리 로컬 우선 마크다운 에디터는 파일이 너무 커서 처리할 수 없다고 미안하다고 말해야 할까요?"
아니요. 그렇게 말할 수는 없습니다. 그래서 대신 우리는 React ProseMirror를 훨씬, 훨씬 더 빠르게 만들어야 했습니다.
이 문제의 규모를 파악하기 위해, 이 여정을 시작하기 의 React ProseMirror 기반 에디터부터 살펴보겠습니다.
아래 에디터에 타이핑을 해보세요. 성능 작업을 시작하기 전의 마지막 릴리스 후보인 @nytimes/[email protected]을 사용하고 있습니다.
참고: 이것은 매우 오래된 버전의 React ProseMirror입니다. 제가 강조하려는 성능 문제 외에도 다른 버그가 있을 수 있습니다. 특히 일부 안드로이드 기기에서 제대로 작동하지 않더라도 양해 부탁드립니다!
허먼 멜빌의 *모비 딕(Moby-Dick; or, The Whale)*은 제가 임의의 텍스트가 필요할 때 즐겨 찾는 소스입니다. 이는 뉴욕 타임즈의 Oak 팀으로부터 물려받은 관습이며, 저는 한 번도 의문을 품지 않았습니다.
음, 약간 느리네요. 솔직히 모비 딕이 워낙 긴 책이라 이 정도로 작동하는 것도 조금 놀랍긴 합니다! 하지만 텍스트 에디터로서 사용하기에는 확실히 무리가 있습니다. 키를 한 번 누를 때 177밀리초가 걸릴 수 있으며, React Profiler를 보면 그 이유가 명확해집니다.
단일 커밋에 177밀리초가 소요되는 것을 보여주는 React Profiler 플레임차트. 수천 개에 달하는 각 NodeView 컴포넌트가 재렌더링되며 각각 약 1밀리초씩 소요되고 있습니다.

또 다른 참고: 당시에는 별생각이 없었지만, React Profiler를 사용하기 위해 이 테스트들을 개발 모드에서 실행해야 했습니다. React는 프로덕션 모드에서 훨씬 빠르기 때문에, 여러분이 읽고 계신 이 블로그 포스트 버전에서도 여전히 약간의 지연이 느껴지겠지만, 저의 초기 반응은 훨씬 더 사용하기 힘든 에디터를 기반으로 한 것이었습니다!
재렌더링이 정말 많네요! 대부분의 경우 각 NodeView 컴포넌트가 재렌더링되는 데 1밀리초 이하가 걸리지만, 이 문서에는 2,560개의 문단이 있습니다. 결과적으로 키를 누를 때마다 총 5,124개의 노드(텍스트 노드와 간혹 섞인 인용문 포함)를 렌더링해야 합니다. 각 노드는 두세 개의 실제 React 엘리먼트로 감싸져 있으므로, 키를 한 번 누를 때마다 약 15,000개의 React 엘리먼트를 렌더링하고 있는 셈입니다. 사실 저는 이제 이 정도로 작동한다는 사실에 감탄하고 있습니다. 모든 것을 고려했을 때 React는 정말 빠릅니다.
하지만 이 경우에는 충분히 빠르지 않습니다! 할 일이 좀 있겠네요.
우리는 컴포넌트들을 메모이제이션(Memoization)해야 합니다. 그것도 전부 다요. 다행히 컴포넌트 종류가 그렇게 많지는 않습니다. 첫 번째 단계는 모든 곳에서 동일하게 보입니다.
// 변경 전
export function NodeView({
// 변경 후
export const NodeView = memo(function NodeView({
outerDeco,
pos,
node,
innerDeco,
...props
}: NodeViewProps) {
하지만 메모이제이션이 실제로 효과를 보게 하려면, 어떤 props도 불필요하게 변경되지 않도록 해야 합니다. 당장 pos prop에서 문제에 부딪힐 것입니다. pos prop은 이 NodeView 노드의 시작 부분에 해당하는 ProseMirror 위치입니다. ProseMirror는 위치를 정수로 모델링하며, 텍스트 문자, 리프(Leaf) 노드, 그리고 리프가 아닌 노드의 경계는 모두 위치를 1씩 증가시킵니다.
즉, 사용자가 새 문자를 입력하거나 삭제할 때마다, 그 변경된 위치 이후의 모든 NodeView는 새로운 위치 값을 갖게 됩니다. 그리고 이는 React.memo를 사용하더라도 props가 변경되었기 때문에 재렌더링을 유발한다는 것을 의미합니다. 이에 대한 뾰족한 해결책은 없습니다. 키를 누를 때마다 수천 개의 NodeView 컴포넌트가 재렌더링되는 것을 막으려면 pos를 prop으로 전달할 수 없습니다.
0
이것은 몇 개의 문단으로 이루어진 간단한 문서입니다. 각 문단 옆에는 시작 위치를 나타내는 숫자가 있습니다.
122
여기에 몇 글자를 입력해 보세요.
158
앞쪽 문단에서 타이핑을 하면, 입력 중인 문단 이후의 모든 문단 위치가 업데이트되는 것을 확인해 보세요.
이제 이 pos를 없애봅시다!
React ProseMirror가 수행해야 하는 균형 잡기 중 하나는, React와 ProseMirror 사용자 모두의 기대를 최대한 저버리지 않으면서 적절한 API를 노출하는 것입니다. prosemirror-view에서 노드 뷰 생성자(Constructor)는 getPos 함수를 전달받습니다.
function getPos(): number;
이 함수는 노드 뷰 생성자가 언제든지 자신의 노드 위치에 접근할 수 있는 수단 역할을 합니다. React에서는 이것이 이상하게 느껴졌습니다. 문서가 변경될 때마다 노드 뷰 컴포넌트가 재렌더링되므로, 위치를 그냥 prop으로 받아야 한다고 생각했습니다. 그래서 props.pos가 탄생한 것이죠.
하지만 이제 우리는 문서가 변경될 때마다 노드 뷰 컴포넌트가 재렌더링되는 것을 원치 않으므로, 노드의 위치를 prop으로 전달할 수 없습니다. 대신 getPos를 전달하면 어떨까요?
이전에는 노드의 위치를 결정하는 코드가 ChildNodeViews 컴포넌트에 있었고, 대략 다음과 같았습니다 (복잡한 부분은 일부 생략했습니다).
export function ChildNodeViews({
pos,
node,
innerDecorations,
}: {
pos: number;
node: Node | undefined;
innerDecorations: DecorationSource;
}) {
// 이 노드의 첫 번째 자식의 시작 위치입니다.
const innerPos = pos + 1;
if (!node) return null;
const children: ReactNode[] = [];
// 노드의 자식들을 순회하며 각 자식의 데코레이션과
// 부모에 대한 상대적 위치를 추적하는 편의 메서드입니다.
iterateChildren(
node,
innerDecorations,
(child, outerDeco, innerDeco, offset) => {
children.push(
<NodeView
node={child}
outerDeco={outerDeco}
innerDecorations={innerDeco}
pos={innerPos + offset}
/>,
);
},
);
return <>{children}</>;
}
단순하게 첫 번째 단계로, posgetPos 함수로 교체해 보겠습니다.
export function ChildNodeViews({
getPos,
node,
innerDecorations,
}: {
getPos: () => number;
node: Node | undefined;
innerDecorations: DecorationSource;
}) {
// 이 노드의 첫 번째 자식의 시작 위치를 가져오는 함수입니다.
const getInnerPos = () => getPos() + 1;
if (!node) return null;
const children: ReactNode[] = [];

iterateChildren(
node,
innerDecorations,
(child, outerDeco, innerDeco, offset) => {
children.push(
<NodeView
node={child}
outerDeco={outerDeco}
innerDecorations={innerDeco}
getPos={() => getInnerPos() + offset}
/>,
);
},
);
return <>{children}</>;
}
이 방식은 작동은 하지만 이전보다 더 나쁩니다. 이제 노드의 위치가 실제로 변경되지 않았더라도 매 렌더링마다 새로운 getPos 함수를 생성하기 때문입니다. useMemo로 메모이제이션할 수도 있겠지만, 그러면 노드 앞에서 타이핑할 때마다 값이 업데이트되는 원래의 문제로 돌아가게 됩니다.
우리에게 필요한 것은 렌더링 시에 반환 값은 업데이트되지만, 함수 참조(Reference)는 안정적인 함수입니다. 즉, Ref가 필요합니다.
export function ChildNodeViews({
getPos,
node,
innerDecorations,
}: {
getPos: MutableRefObject<() => number>;
node: Node | undefined;
innerDecorations: DecorationSource;
}) {
if (!node) return null;
// 이 노드의 첫 번째 자식의 시작 위치를 가져오는 Ref입니다.
const getInnerPos = useRef(() => getPos.current() + 1);
const children: ReactNode[] = [];

iterateChildren(
node,
innerDecorations,
(child, outerDeco, innerDeco, offset) => {
children.push(
<ChildElement
node={child}
outerDeco={outerDeco}
innerDecorations={innerDeco}
getInnerPos={getInnerPos}
offset={offset}
/>,
);
},
);
return <>{children}</>;
}

function ChildElement({
node,
outerDeco,
innerDecorations,
offset,
getInnerPos,
}: {
node: Node;
outerDeco: Decoration[];
innerDecorations: DecorationSource;
offset: number;
getInnerPos: () => number;
}) {
const getPos = useCallback(() => {
return getInnerPos() + offset;
}, [getInnerPos, offset]);

return (
<NodeView
node={node}
outerDeco={outerDeco}
innerDeco={innerDecorations}
getPos={getPos}
/>
);
}
여기서 새로운 엘리먼트를 도입해야 합니다. 또 다른 메모이제이션 계층이 필요하고, 훅 내부에서 useCallback을 호출할 수 없기 때문입니다.
하지만... 아직 문제를 완전히 해결하지 못했습니다. offset이 변경될 때마다 여전히 새로운 getPos 함수를 생성하고 있습니다. 이전보다는 낫습니다. 이제 첫 번째 문단에서 타이핑을 해도 첫 번째 문단 이후의 모든 문단에 있는 모든 텍스트 노드를 재렌더링할 필요는 없으니까요. 하지만 여전히 첫 번째 문단의 모든 텍스트 노드와 첫 번째 문단 이후의 모든 문단 노드는 재렌더링해야 합니다. 15,000번의 렌더링은 아니겠지만, 여전히 6,000번 정도는 될 것입니다!
이 문제는 근본적인 것입니다. getPos 함수는 offset에 의존하며, 이는 단어 그대로의 의미에서 의존성(Dependency)입니다. Offset은 Ref가 아니므로 값이 변경되면 새로운 값을 캡처(Close over)하기 위해 새로운 getPos 함수를 만들어야 합니다. 이 문제를 우회하려면... 약간의 편법을 써야 할지도 모릅니다.
React에는 많은 규칙이 있습니다. React 컴포넌트가 함수형 프로그래밍 패러다임을 기반으로 설계되었기 때문입니다. 하지만 JavaScript는 값을 수정하고 재할당하는 것을 매우 쉽게 만듭니다. React 컴포넌트가 함수형 패러다임을 따르는 주된 이유는 "동시성 모드(Concurrent Mode)"를 가능하게 하기 위해서입니다. 이는 React가 렌더링을 중단하거나 포기할 수 있게 해주는 기능 모음입니다. 규칙들은 동시성 모드를 안전하게 유지해 줍니다.
이러한 규칙 중 하나는 렌더링 사이클 동안 Ref를 수정해서는 안 된다는 것입니다 (한 가지 예외 제외). React가 Ref를 수정하는 코드를 실행한 후 렌더링을 포기할 수 있고, 그렇게 되면 나머지 렌더링 결과가 DOM에 반영(Commit)되지 않은 채 Ref만 수정된 상태로 남을 수 있기 때문입니다. 극단적으로 꾸며낸 예시를 들어보겠습니다.
function BadRefUpdateDemo({ value }: { value: number }) {
const valueRef = useRef(value);
// prop과 동기화하기 위해 렌더링 중에 Ref 값을 업데이트합니다.
valueRef.current = value;

const onClick = useCallback(() => {
saveValue(valueRef.current);
}, []);

return <button onClick={onClick}>Save ({value})</button>;
}
흐름은 다음과 같습니다.
  1. 사용자가 어떤 동작을 하여 value(prop)가 3에서 4로 변경됩니다.
  1. React가 새로운 value<BadRefUpdateDemo />를 재렌더링하며 valueRef.current를 4로 업데이트합니다.
  1. 더 높은 우선순위의 업데이트가 들어와서 React가 렌더링을 포기합니다.
  1. 사용자는 여전히 버튼에서 "Save (3)"을 보고 클릭합니다.
  1. Ref는 이미 값 4로 업데이트되었기 때문에, 백엔드에는 값 4가 저장됩니다.
말씀드렸듯이 이는 다소 꾸며낸 이야기지만 요점은 전달됩니다. 렌더링 중에 Ref를 업데이트하는 것은 기술적으로 부작용(Side effect)이며, 따라서 사용자 인터페이스가 기저의 상태와 일치하지 않는 일종의 상태 불일치를 유발할 수 있습니다.
하지만 우리는 getPos Ref가 언제 업데이트되고 어떻게 사용되는지에 대해 많은 제어권을 가지고 있기 때문에 약간의 여유가 있습니다. 렌더링 중에 getPos에 접근하는 것은 이미 좋지 않은 생각입니다. React ProseMirror는 위치가 변경될 때 노드 뷰 컴포넌트를 재렌더링하지 않도록 설계되었기 때문입니다(그것이 우리의 목표입니다!). useEffectuseLayoutEffect 콜백에서 Ref를 읽는 것(렌더링 중에 쓰인 것이라도)은 안전합니다. 렌더링이 포기되면 이 콜백들은 아예 실행되지 않기 때문입니다.
유일한 우려 사항은 위의 예시와 같은 상황, 즉 콜백 함수 내에서 getPos에 접근하는 경우입니다. 하지만 이 경우에도 우리는 안전할 가능성이 높습니다. 트랜잭션을 디스패치하거나 특정 위치의 DOM을 조사하려면(노드의 위치 접근이 실제로 필요한 두 가지 사례), EditorView에 대한 접근을 제공하는 useEditorEventCallback을 사용해야 합니다. EditorView의 내부 상태 역시 렌더링 중에 동기화되므로, 콜백에서 EditorView로부터 읽어온 현재 위치 관련 상태는 getPos 값과 동기화된 상태일 것입니다.
이것이 완벽할까요? 개념적으로는 그렇지 않다고 생각합니다. 하지만 그만한 가치가 있을까요? 저는 그렇다고 생각합니다. React ProseMirror가 평소에는 React의 규칙을 매우 엄격하게 따르려고 노력함에도 불구하고 말이죠. 이 방식은 완벽한 메모이제이션의 문을 열어주어, 변경 후 실제로 바뀐 텍스트 노드(및 그 조상들)만 정확히 재렌더링할 수 있게 해줍니다. 이는 15,000개의 엘리먼트 대신 6개의 엘리먼트만 렌더링한다는 의미입니다. 그리고 비용은 미미해 보입니다. 사용자가 중단된 렌더링과 그 성공적인 후속 렌더링 사이의 찰나에 이벤트 콜백을 트리거할 가능성은 매우 낮습니다. 특히 아무도 React ProseMirror를 Action 내에서 업데이트하지 않기 때문입니다. 사용자가 상호작용하는 동안 즉시 업데이트되어야 하니까요!
자, 이제 약간의 편법을 쓰기로 한 결정에 대해 스스로를 납득시켰으니 다시 본론으로 돌아가 봅시다.
시작하기 전에, 이 퍼즐의 중요한 조각이 하나 더 있습니다. React ProseMirror에는 변경 사항 전반에 걸쳐 ProseMirror 노드를 추적하기 위한 "React keys 플러그인"이라는 시스템이 있습니다. 이것은 생각보다 간단하지 않습니다. ProseMirror 노드는 문서 내의 위치로만 고유하게 식별될 수 있지만, 당연히 그 위치는 트랜잭션이 디스패치됨에 따라 변경될 수 있습니다. 그래서 React keys 플러그인은 위치와 고유 키 사이의 맵을 저장하고, 트랜잭션을 통해 위치를 매핑함으로써 이를 최신 상태로 유지합니다.
/**
* 트랜잭션 전반에 걸쳐 노드 키를 안정적으로 유지합니다.
*
* 이를 위해 각 노드 위치를 트랜잭션을 통해 정방향 매핑하여
* 현재 위치를 식별하고, 해당 새 위치에 키를 할당합니다.
* 노드가 삭제된 경우 키를 삭제합니다.
*/
apply(tr, value, _, newState) {
if (!tr.docChanged || composing) {
return value;
}
const next = {
posToKey: new Map<number, string>(),
keyToPos: new Map<string, number>(),
};
const posToKeyEntries = Array.from(value.posToKey.entries()).sort(
([a], [b]) => a - b
);
for (const [pos, key] of posToKeyEntries) {
const { pos: newPos, deleted } = tr.mapping.mapResult(pos)
if (deleted) continue;
next.posToKey.set(newPos, key);
next.keyToPos.set(key, newPos);
}
newState.doc.descendants((_, pos) => {
if (next.posToKey.has(pos)) return true;
const key = createNodeKey();
next.posToKey.set(pos, key);
next.keyToPos.set(key, pos);
return true;
});
return next;
},
이것은 React ProseMirror가 작동하는 데 매우 중요합니다. React가 상태를 잘못 할당하거나 엘리먼트를 조기에 언마운트하지 않도록 트리의 각 엘리먼트에 안정적인 키를 할당할 수 있어야 하기 때문입니다.
또한 이는 getPos를 구현하는 것과 같은 다른 작업에도 꽤 유용하다는 것이 밝혀졌습니다.
이제 조금 기괴해질 것입니다.
export function ChildNodeViews({
getPos,
node,
innerDecorations,
}: {
getPos: MutableRefObject<() => number>;
node: Node | undefined;
innerDecorations: DecorationSource;
}) {
const reactKeys = useReactKeys();
const getInnerPos = useCallback(() => getPos() + 1, [getPos]);
const childMap = useRef(new Map<string, Child>()).current;
if (!node) return null;
const keysSeen = new Map<string, number>();

iterateChildren(
node,
innerDecorations,
(child, outerDeco, innerDeco, offset) => {
const key = reactKeys.posToKey.get(getInnerPos() + offset);
const childData = {
node: child,
offset,
key,
innerDeco,
outerDeco,
};
// 지난 렌더링에서의 이 자식 정보(키 기준)입니다.
const prevChild = childMap.get(key);
// 이 자식이 (위치 외에) 변경되지 않았다면,
// 이전 자식 객체의 위치를 업데이트하고 재사용합니다.
// 이는 ChildElement의 불필요한 재렌더링을 방지하면서
// getPos 구현을 최신 상태로 유지합니다.
if (prevChild && areChildrenEqual(prevChild, childData)) {
prevChild.offset = offset;
} else {
childMap.set(key, childData);
}
keysSeen.set(key, keysSeen.size);
},
);

for (const key of childMap.keys()) {
if (!keysSeen.has(key)) {
childMap.delete(key);
}
}

const children = Array.from(childMap.values())
.sort((a, b) => keysSeen.get(a.key)! - keysSeen.get(b.key)!)
.map((child) => (
<ChildElement key={child.key} child={child} getInnerPos={getInnerPos} />
));

return <>{children}</>;
}

/**
* prosemirror-view가 커스텀 노드 뷰의 update 메서드 호출 여부를
* 결정할 때 사용하는 것과 동일한 로직입니다.
*/
function areChildrenEqual(a: Child, b: Child) {
return (
a.key === b.key &&
a.node.eq(b.node) &&
sameOuterDeco(a.outerDeco, b.outerDeco) &&
(a.innerDeco as InternalDecorationSource).eq(b.innerDeco)
);
}

function ChildElement({
child,
getInnerPos,
}: {
child: Child;
getInnerPos: () => number;
}) {
// 우리는 사실상 `child`를 Ref처럼 사용하고 있습니다.
// 노드와 데코레이션이 변경되지 않는 한 안정적인 참조를 유지하지만,
// offset은 최신 상태로 유지됩니다.
const getPos = useCallback(() => {
return getInnerPos() + child.offset;
}, [getInnerPos, child]);

return (
<NodeView
node={child.node}
outerDeco={child.outerDeco}
innerDeco={child.innerDeco}
getPos={getPos}
/>
);
}
좀 지저분해 보이죠?
하지만 들어보세요, 정말 빠릅니다.
참고: Gboard나 기본 안드로이드 키보드가 아닌 다른 키보드를 사용하는 안드로이드 환경에서는 여전히 몇 가지 문제가 발생할 수 있습니다. 또한 일부 안드로이드 기기(아마도 메모리 제한 때문일까요?)는 최적화 여부나 React 사용 여부와 관계없이 이 정도 양의 콘텐츠가 있는 에디터를 매우 힘겨워하는 것 같습니다. 브라우저가 종료되었다면 사과드립니다!
단일 커밋에 16밀리초가 소요되는 것을 보여주는 React Profiler 플레임차트. 수천 개의 NodeView 컴포넌트 중 오직 첫 번째 것만 재렌더링되었습니다.

위의 에디터에서 모비 딕의 텍스트를 복사하여 Firefox의 prosemirror.net 데모 에디터에 붙여넣으면, 솔직히 저도 아직 조금 놀라운 광경을 보게 될 것입니다. React ProseMirror 데모보다 훨씬 느립니다! Chrome에서는 재현되지 않는 것으로 보아 Firefox의 내장 contenteditable 구현의 성능 문제라고 의심됩니다. 하지만 저는 이것이 꽤 흥미롭다고 생각합니다. React의 가상 DOM 업데이트 알고리즘이 너무 빨라서 실제로는 네이티브 구현보다 눈에 띄게 앞서고 있는 것입니다!
우리는 새로운 리치 텍스트 에디팅 프레임워크인 Pitter Patter를 만들고 있습니다! 우리는 정말 훌륭한 것을 만들 수 있다고 믿으며, 여러분의 도움이 필요합니다. 후원에 관심이 있으시다면 언제든지 연락해 주세요!
0
3

댓글

?

아직 댓글이 없습니다.

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

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글