안녕하세요 ![]()
@Damian_Boon님의 방식과는 조금 다른 접근을 취했습니다. iframe을 사용하지 않고, Discourse 자체의 Post/포스트 스트림 메커니즘을 재사용하는 네이티브 Glimmer 컴포넌트로 모든 것을 실행합니다. 스레드에서 몇 가지 구체적인 질문이 올라와서, 각 문제를 어떻게 해결했는지 정리해 보았습니다.
왜 iframe을 쓰지 않는가
iframe은 이를 구현하는 가장 빠르고 쉬운 방법입니다. 전체 토픽 라우트, 컴포저, 모더레이션 기능이 모두 무료로 제공됩니다. 그러나 @nicolsdennis의 MessageBus 질문에서 지적했듯, 그 대가는 페이지 내에 두 개의 활성 Discourse 인스턴스가 존재하게 된다는 점입니다. 두 개의 포스트 스트림, 두 개의 MessageBus 연결, 두 개의 컴포저가 생기는 것이죠.
저는 다른 길을 선택했습니다. 모달은 store.createRecord("topic", …)로 생성된 postStream을 대상으로 실제 Post/PostSmallAction 컴포넌트를 렌더링하는 DModal입니다. 동일한 앱, 동일한 MessageBus 연결, 동일한 컴포저를 공유합니다. 즉, 모달이 열려 있는 동안 핵심 싱글턴 서비스들(modal, bookmarkApi, DiscourseURL.routeTo, 앰비언트 controller:topic)을 모달의 토픽으로 가리켜야 합니다:
// Core components (e.g. PostBookmarkManager) read/write topic.bookmarks
// from controller:topic. Point it at our topicModel while modal is open.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
모든 패치는 닫기, 파괴, 또는 다른 곳으로 이동할 때 멱등(idempotent)한 restoreServicePatches() 호출로 되돌려지므로, 모달이 사라진 후 앱의 나머지 부분에 아무것도 새어 나가지 않습니다.
직접 링크 및 내부 라우팅
두 가지 부분이 있습니다. 첫째, 모달은 항상 1번 포스트에서가 아니라 독자가 실제로 읽기를 중단했던 위치에서 열려야 합니다:
const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
1,
Math.min(lastRead + 1, highestPostNumber)
);
둘째, 포스트 내부에서 같은 토픽으로 돌아가는 링크는 모달 밖으로 빠져나와서는 안 됩니다. 모달이 열려 있는 동안 DiscourseURL.routeTo가 패치됩니다:
this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
if (typeof path === "string") {
const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
if (match && parseInt(match[1], 10) === this.topicId) {
this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
return Promise.resolve();
}
}
this.restoreServicePatches();
return this.originalRouteTo(path, opts);
};
다른 곳으로 향하는 링크는 먼저 패치를 복원한 후 일반 내비게이션으로 처리되므로, 모달을 떠나도 오래된 라우팅 상태가 남지 않습니다.
답글 케이스 (20개 이상의 답글)
아래쪽은 센티넬(sentinel) + IntersectionObserver를 이용한 무한 스크롤, 위쪽에는 대칭적인 “이전 로드” 기능(미리보기가 스레드 중간에서 열릴 수 있으므로)을 제공합니다. 이 둘은 실제 토픽 라우트가 사용하는 것과 정확히 동일한 postStream.appendMore() / prependMore()를 구동합니다:
sentinel = modifier((element) => {
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
this.loadBelow();
}
},
{
root: document.querySelector(".topic-preview-modal .d-modal__body"),
rootMargin: "200px",
}
);
obs.observe(element);
return () => obs.disconnect();
});
첫 번째 페인트(First paint)
스피너 대신 스크레톤(아바타 + 줄 플레이스홀더, 시머 효과)이 첫 번째 페인트를 채우며, 포스트 목록 자체는 한꺼번에 렌더링되지 않고 단계별로 렌더링됩니다. 먼저 목표 포스트에 도달할 수 있는 충분한 수의 포스트만 렌더링하고, 나머지 포스트는 requestIdleCallback을 통해 5개씩 추가 렌더링하여 나머지가 따라오도록 합니다:
const renderRemainingPosts = () => {
const totalPosts = this.postStream?.posts?.length || 0;
if (this.renderLimit < totalPosts) {
this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
if (this.renderLimit < totalPosts) {
if (window.requestIdleCallback) {
window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
} else {
setTimeout(renderRemainingPosts, 30);
}
return;
}
}
this.renderLimit = Number.MAX_SAFE_INTEGER;
};
각 포스트 래퍼에는 content-visibility: auto와 대략적인 contain-intrinsic-size가 적용되어, 화면 밖의 포스트에 대해서는 브라우저가 레이아웃을 완전히 건너뜁니다:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
이 부분이 제가 가장 강하게 주장하고 싶은 부분입니다. 클릭 후 인스턴스를 재사용하는 대신, 클릭 전에 미리 가져오기(prefetch)를 수행합니다. 각 토픽 목록 행은 자신의 가시성을 모니터링하고, 빠른 스크롤 시 여러 요청이 발생하지 않도록 200ms 디바운스를 적용하며, 최대 2개의 동시 fetch로 제한된 작은 공유 큐로 작업을 인계합니다:
function createTopicPrefetchQueue(maxConcurrent) {
const pending = [];
const activeIds = new Set();
function drain() {
while (activeIds.size < maxConcurrent && pending.length) {
const { id, task } = pending.shift();
activeIds.add(id);
Promise.resolve()
.then(task)
.finally(() => {
activeIds.delete(id);
drain();
});
}
}
return {
schedule(id, task) {
if (activeIds.has(id) || pending.some((item) => item.id === id)) {
return;
}
pending.push({ id, task });
drain();
},
cancel(id) {
const index = pending.findIndex((item) => item.id === id);
if (index !== -1) {
pending.splice(index, 1);
}
},
};
}
미리 가져온 데이터는 의도적으로 핵심 topic_${id} 키가 아닌 비공개 PreloadStore 키 아래에 저장됩니다. last_read+1 범위의 부분적인 JSON을 실제 전체 토픽 내비게이션에 새어 나가지 않도록 하기 위함입니다. 실제 클릭 시에는 모달 로더가 기대하는 키로 승격(promote)됩니다:
promoteTopicPrefetch(topic) {
if (!topic?.id) {
return;
}
try {
const key = this.prefetchStoreKey(topic.id);
const prefetched = PreloadStore.get?.(key);
if (prefetched != null) {
PreloadStore.remove?.(key);
PreloadStore.store(`topic_${topic.id}`, prefetched);
}
} catch {
// best-effort - modal falls back to a fresh load
}
}
클릭 전에 행이 화면 밖으로 스크롤되어 나간다면, 대기 중인 fetch는 취소되고 캐시된 결과는 폐기되므로, 현재 화면에 실제로 있는 토픽의 데이터만 보유하게 됩니다.
추가로 하나, 스레드에서 아직 언급되지 않은 것: 중첩 모달
몇 가지 모달 플로우(명시적인 콜백 속성이 showSubModal()에 연결된 것이 아니라 코어가 자체적으로 여는 것들)는 modal 서비스를 직접 호출합니다. 이러한 플로우가 독자의 미리보기 모달을 뒤에서 닫아버리지 않도록, 미리보기가 열려 있는 동안 modal.show()를 패치하여 대상 모달을 작은 로컬 서브 모달 슬롯으로 선택적으로 리다이렉트합니다:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
이렇게 하면 대상 플로우들이 미리보기를 대체하는 대신 그 위에 겹쳐지고, 읽음 추적(/topics/timings)은 IntersectionObserver로 추적된 가시 포스트 번호 세트의 주기적 플러시를 통해 아래에서 계속 실행되므로, 닫은 후에도 일반 토픽 방문과 마찬가지로 읽지 않은 개수가 정확히 유지됩니다.
모달, 토픽 목록 트리거, 스타일링에 걸쳐 상당한 양의 코드(합계 약 1700줄)이며, 이 글은 그 중 약 3분의 1을 다루고 있습니다. 가장 까다로운 부분은 단연 서비스 패칭과 이미지 로딩이 완료된 후 포스트 높이가 변할 때 content-visibility가 올바르게 동작하도록 만드는 것이었습니다. 관심 있으시면 더 자세한 내용을 설명하거나 오픈소스로 공개할 수도 있습니다.