Facebook 스타일 주제 모달 - 더 나은가요?

다른 사이트들과 다른 느낌을 내고자 이것저것 시도해 보다가 페이스북 스타일의 토픽 모달을 만들었습니다. 제 환경에서는 로딩이 더 빠르고, 데스크톱에서 더 부드럽게 동작하는 것 같습니다.

여러분은 어떻게 생각하시나요?

테스트 링크는 https://www.carptalk-online.co.uk/ 입니다. (데스크톱에서만 작동합니다)

잘했어요! 저는 페이스북을 안 쓰는데도 꽤 마음에 들어요. 멋진 사이트네요. :star_struck:

그리고, 붕어가 이렇게까지 커질 줄 몰랐어요! :fishing_pole:

정말 멋지네요! 답글이 20개 이상 있는 토픽에서 테스트해 보셨나요? 스크롤을 내리면 게시글의 추가 "페이지"가 로드되는데, 제 경험상 이런 대체 뷰에서 가장 큰 복잡성을 유발하는 부분 중 하나입니다.

아니, 지금 이걸 하고 보고할게

문제없이 작동합니다 - URL 삭제됨

말씀하신 대로 페이지 로딩이 보이긴 하지만, 예상대로 잘 작동합니다.

직접 링크를 클릭할 때 모달에 주제가 표시되지 않습니다. 하지만 주제 목록에서는 정상적으로 작동합니다.

그래, 그게 다음에 살펴봐야 할 부분이긴 한데, 계속 고민을 해봤는데, 이게 중요한 건가? 페이스북도 똑같이 동작하잖아. 직접 링크를 클릭하면 실제 게시물로 가고, 브라우저에서 클릭하면 모달이 열리지.

좋은 개념이네요. 과거 메시지를 불러오는 동안에도 새 메시지에 대해 메시지 버스와의 연결을 일관되게 유지할 수 있나요?

네, 모달이 커스텀 복사본을 렌더링하는 대신 iframe 안에 일반적인 Discourse 주제 애플리케이션을 로드하기 때문입니다. iframe은 자체 MessageBus 연결을 설정하고, Discourse는 이전 게시글을 불러오거나 새로 게시된 게시글을 받는 것을 포함하여 게시글 스트림을 정상적으로 계속 처리합니다.

부모 페이지도 모달 뒤에서 여전히 활성화된 상태이므로, 모달이 열려 있는 동안에는 기술적으로 두 개의 Discourse 인스턴스와 두 개의 MessageBus 연결이 존재합니다. 이는 동작은 하지만, 완전히 네이티브인 구현 방식보다는 약간 더 무겁습니다. 모달을 닫으면 iframe과 해당 연결이 제거됩니다.

중요한 건지 모르겠고, 저는 페이스북을 안 쓰거든요 :stuck_out_tongue:

링크를 클릭했을 때 모달로 토픽이 표시되지 않은 게 좀 의외였어요. 모달에서 작동하는 걸 보여주기 위해 정확히 그걸로 게시하셨잖아요.

제 원본 게시글은 홈페이지 링크였는데, 크리스가 토픽을 테스트하는 제 답글을 클릭한 거 맞죠? 하하. 근데 요즘 어때요? 한동안 못 봤네요.

모바일 버전을 마침내 완료했는데, 전체 작동 방식에 만족합니다. 다음으로 할 일은 이 부분을 더 나은 방식으로 개선하고 로딩 속도를 낮추는 것입니다. 혹시 제안해 주실 분이 계신가요?

추가 테스트를 거친 결과, 구글이 인식하는 비-JS 버전을 사용했는데, 이 버전은 모달에서 즉시 로드됩니다. 만약 이 버전의 내용을 어떻게든 활용할 수 있다면, 이 프로세스가 훨씬 빨라질 것입니다.

PC 버전에서는 매우 잘 보입니다.

하지만 모바일에서는 로딩 속도가 다소 느립니다. 탭하면 내용 없는 페이지와 오른쪽 상단의 닫기 버튼만 표시되고, 빈 화면을 꽤 오래 기다려야 하는 것 같습니다.

지금 이걸 작업하고 있어요 :slight_smile: 지금은 아직 현실로 만들고 싶은 개념 단계일 뿐이에요. 다만 제 기기에서는 모바일에서 discourse 스피너 로딩 아이콘과 함께 2초 만에 로딩되는데, 무슨 일이 일어나고 있는지 잘 모르겠어요. 아직 CDN이 없고 서버가 영국 런던에 있기 때문인 것 같습니다.

그러면 거리가 너무 멀기 때문에 네트워크 전송에 시간이 걸릴 수밖에 없습니다.

이제 모달에서 주제가 즉시 로드되도록 했습니다. 이제 첫 번째 discourse 로드 인스턴스를 답장 등에 사용하는 방법을 찾아야 합니다. (이것은 테스트 서버에서 진행 중입니다)

주요 사이트에서는 현재 가입자가 증가하고 있어 이 기능을 꺼뒀고, 테스트 서버로 옮겼습니다. 모달에 관심이 있으신 분들을 위해 8월 중순까지 완료할 수 있기를 바랍니다.

안녕하세요 :waving_hand:

@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가 올바르게 동작하도록 만드는 것이었습니다. 관심 있으시면 더 자세한 내용을 설명하거나 오픈소스로 공개할 수도 있습니다.