임베드가 “토론 로딩 중…”에 멈추거나 주제가 생성되지 않나요? 해결 방법 안내

며칠 동안 많은 분들이 댓글 임베드 관련 해결책을 검색하고 게시하고 있는 것을 알게 되었습니다. 저도 그중 한 명입니다. 이 글이 같은 상황에 처한 분들에게 도움이 되기를 바랍니다.

저는 Discourse를 처음 사용하므로, 제가 제공한 정보에 대한 심층적인 전문 지식을 보충해 주실 분은 자유롭게 참여해 주세요.

해당 주제에 대한 수많은 게시글을 검토해 본 후, 제가 말씀드릴 수 있는 것은 문제의 원인이 매우 다양할 수 있다는 것입니다. 저와 같은 상황에 계신 분들을 위해, 해결책을 소개합니다!

문제

  1. "Loading discussion…"라고 표시되는 임베드를 발견합니다.
  2. Discourse 주제가 자동으로 생성되지 않습니다.

해결책

허용된 내부 호스트 목록에 도메인을 추가해 보세요.

이것은 관리자 영역에서 찾을 수 있는 사이트 설정입니다. Discourse 사이트의 다음 경로에서 찾을 수 있습니다:

/admin/site_settings/category/all_results

제가 언급하는 설정으로 바로 이동하는 링크는 다음과 같습니다:

/admin/site_settings/category/all_results?filter=allowed_internal_hosts

Rails 콘솔을 보고 계신 분들을 위해, 다음을 확인해 주세요:

SiteSetting.allowed_internal_hosts

이 설정은 파이프(|)로 구분된 도메인 이름 목록입니다.

배경

제 Discourse 인스턴스는 공개되어 있지만, 내부 DNS가 일부 도메인을 로컬로 해석합니다. 이는 Docker, Kubernetes 또는 내부 DNS가 있는 환경에서 발생할 수 있습니다.

Discourse를 처음 사용하게 된 터라, 지금은 당연하게 보이는 것이 처음에는 정말로 명확하지 않았다고 말씀드리고 싶습니다.

Discourse 내부 구조에 익숙하지 않은 우리는 2017년에 SSRF 보호 기능이 구현되었다는 사실이나 그 보호 기능의 구체적인 사항조차 알지 못했습니다. 사후에야 그 발표가 이 연결고리를 명확하게 보여준다는 것을 알게 되었습니다.

이것은 잘 구현된 기능이지만, 매우 단순한 이유로 인해 꽤나 깊은 늪(rabbit hole)이었습니다.

알아두어야 할 것

도메인이 로컬 IP로 해석되는 경우, Discourse는 임베드를 위해 주제를 생성하지 않습니다.

아직 소리 지르지 마세요. 이것은 좋은 일입니다. 왜 그런지 알아보려면 SSRF에 대해 읽어보시고, 이를 심각하게 받아들이는 Discourse 개발자들에게 감사의 뜻을 전할 수도 있습니다.

문제는 Discourse가 주제를 생성하지 않는 이유와 "loading discussion…"에서 멈추는 이유를 알려주는 피드백을 제공하지 않는다는 것입니다.

추가 읽을거리

그렇다면 로컬 IP가 정확히 무엇일까요? 관심 있는 분들을 위해, 답은 바로 Discourse 코드에서 찾을 수 있습니다. GitHub의 해당 파일로 가는 직접 링크를 제공합니다.

예를 들어, super-forum[dot]com의 Discourse 인스턴스가 cool-blog[dot]net을 호스팅하는 네트워크에 있는 경우, 내부 DNS가 cool-blog[dot]net을 로컬 IP로 해석할 수 있으며, 이는 허용 목록(allowlist)에 등록되지 않는 한 Discourse가 거부합니다.

이 글이 다른 누군가에게 몇 시간의 고민을, 어쩌면 몇 가닥의 머리카락까지도 절약해 주기를 바랍니다.

오늘 업무를 재개하면서 관리 페이지에서 몇 가지 눈에 띄는 부분이 있었습니다. 개선할 수 있는 부분에 대한 몇 가지 생각을 정리해 보았습니다.

임베딩 설정 — /admin/customize/embedding/settings

allowed_internal_hosts는 비공개 환경에서 임베딩이 안정적으로 작동하기 위한 핵심 설정입니다. 이 섹션에서 관련 설정으로 명시적으로 나열되어야 하며, 그 중요성에 대해서는 의문의 여지가 없습니다.

임베딩 호스트 — /admin/customize/embedding

제공된 구성 스니펫은 많은 유용한 정보를 포함하고 있어 매우 도움이 됩니다. 이를 활용하여 추가적인 가이드를 제공할 수 있다고 생각합니다.

첫 번째 문단

현재:

다음 HTML 코드를 사이트에 붙여넣어 Discourse 토픽을 생성하고 임베드하세요. EMBED_URL을 임베드하려는 페이지의 정준 URL(canonical URL)로 교체하십시오.

대안:

댓글이 표시될 페이지의 원하는 위치에 다음 HTML을 붙여넣으세요.
discourseEmbedUrl은 해당 페이지의 URL이며, Discourse에서 링크로 연결될 대상입니다. 페이지가 처음 로드될 때 Discourse는 해당 URL에 대한 토픽을 찾거나 생성하고 콘텐츠로 다시 링크합니다.

두 번째 문단

현재:

스타일을 사용자 정의하려면 주석 해제 후 CLASS_NAME을 테마의 임베디드 CSS에 정의된 CSS 클래스로 교체하십시오.

대안:

className 속성을 사용하여 임베드된 iframe 내부의 <html> 태그에 사용자 정의 클래스를 추가합니다. 스타일을 적용하려면 /admin/customize/themes로 이동하여 테마의 편집 버튼을 클릭한 뒤 코드 편집 버튼을 누르고 고급 보기를 선택하세요. 사용자 정의 CSS를 임베디드 CSS 섹션에 추가합니다.

세 번째 문단

현재:

DISCOURSE_USERNAME을 토픽을 생성해야 할 작성자의 Discourse 사용자 이름으로 교체하십시오. Discourse는 name 속성이 discourse-username 또는 author로 설정된 <meta> 태그의 content 속성을 통해 사용자를 자동으로 조회합니다. discourseUserName 매개변수는 비활성화(deprecated)되었으며 Discourse 3.2에서 제거될 예정입니다.

대안:

참고: 토픽은 실제 Discourse 사용자가 생성합니다. 표시 이름이나 작성자 문자열이 아닙니다. 유효하고 존재하는 계정이여야 합니다. 어떤 사용자가 사용되는지를 결정하는 방법은 세 가지가 있습니다:

  1. 기본 폴백/admin/customize/embedding/posts_and_topics에서 설정
  2. 호스트별 오버라이드/admin/customize/embedding/ 하위에서 설정
  3. URL별 제어 — 페이지에 존재하는 Discourse USERNAME을 가진 <meta name="discourse-username" content="USERNAME"> 태그를 추가

기존 Discourse 사용자의 사용자 이름만 작동합니다. 메타 태그의 사용자가找不到되지 않으면 Discourse는 호스트 수준 또는 글로벌 기본값으로 폴백합니다. 여기에 소개된 <meta> 태그 방식은 토픽 생성에 사용되는 Discourse 사용자를 프로그램 방식으로 URL별로 제어할 수 있게 해줍니다. 예를 들어, 사이트의 블로그 글 작성자를 일치하는 Discourse 사용자 계정으로 매핑할 수 있습니다.

구성 스니펫 섹션

접이식 “구성 스니펫” 섹션은 놓치기 쉽습니다. 시각적으로 제목과 유사하며, 미묘한 화살표는 직관적이지 않습니다. 눈에 띄는 색상으로 처리된 “자세히 알아보기” 링크와 달리, 이 섹션은 마치 숨겨져 있는 것처럼 보입니다.

이것이 논쟁의 여지가 있다는 것을 알겠습니다. 일부는 UI가 깔끔하고 충분하다고 느낄 수 있습니다. 하지만 저는 이것이 클릭 가능한 것을 깨달을 때까지 이 섹션을 너무 자주 놓쳤습니다. 이는 특히 예시에 의존하는 초보자를 위해 약간의 시각적 단서 개선이나 비접기(non-collapsed) 기본값 설정이 큰 도움이 될 수 있음을 보여줍니다.

스니펫

현재:

<div id='discourse-comments'></div>
  <meta name='discourse-username' content='DISCOURSE_USERNAME'>

  <script type="text/javascript">
    DiscourseEmbed = {
      discourseUrl: 'https://discourse.your-site.com/',
      discourseEmbedUrl: 'EMBED_URL',
      // className: 'CLASS_NAME',
    };

    (function() {
      var d = document.createElement('script'); d.type = 'text/javascript'; d.async = true;
      d.src = DiscourseEmbed.discourseUrl + 'javascripts/embed.js';
      (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(d);
    })();
  </script>

대안:

<div id="discourse-comments"></div>
<!-- 선택 사항: 토픽을 생성할 Discourse 사용자 계정을 지정 -->
<!-- 생략할 경우, Discourse는 호스트별 또는 글로벌 기본 사용자로 폴백합니다 -->
<meta name="discourse-username" content="DISCOURSE_USERNAME" />

<script type="text/javascript">
  DiscourseEmbed = {
    discourseUrl: 'https://discourse.mydomain.com/', // 끝부분 슬래시 필수
    discourseEmbedUrl: window.location.href, // 또는 하드코딩된 정준 URL 문자열
    // className: 'my-iframe-theme another-class',
    // discourseReferrerPolicy: 'strict-origin-when-cross-origin',
    // topicId: '1234',
  };

  (function() {
    const d = document.createElement('script');
    d.type = 'text/javascript';
    d.async = true;
    d.src = `${DiscourseEmbed.discourseUrl}javascripts/embed.js`;
    document.head.appendChild(d);
  })();
</script>

DiscourseEmbed 옵션 — 간략한 설명

  • discourseUrl필수
    Discourse 인스턴스의 전체 URL입니다. 끝부분에 슬래시가 있어야 하며, 예: https://discourse.mydomain.com/

  • discourseEmbedUrl필수
    댓글이 임베드되는 현재 페이지의 전체 URL입니다. Discourse가 이를 통해 토픽을 식별하고 콘텐츠에 링크합니다.

  • className선택 사항
    iframe 내부<html> 요소에 사용자 정의 CSS 클래스를 추가합니다. Discourse 테마의 “임베디드 CSS” 섹션에서 스타일을 정의하세요.

  • discourseReferrerPolicy선택 사항
    기본값은 no-referrer-when-downgrade입니다. 참고: Referrer-Policy

  • topicId선택 사항
    설정된 경우, Discourse는 해당 토픽을 직접 사용합니다. 그렇지 않으면 discourseEmbedUrl과 일치하는 토픽을 찾거나, 없으면 생성합니다.

DiscourseEmbed 컨텍스트 문서화

discourseEmbedUrl은 Discourse 서버에서 접근 가능해야 합니다. iframe이 로드될 때 Discourse는 해당 URL의 페이지를 가져와 토픽을 생성하거나 위치를 파악합니다. 이는 공개 플랫폼에 호스팅된 웹사이트에서 원활하게 작동합니다.

하지만 로컬에서 개발 중이라면, 임베딩이 "토론 로드 중…"에 멈추거나 아예 표시되지 않을 수 있습니다. 이는 로컬 개발 URL(예: localhost)이 Discourse의 SSRF 보호force_https 옵션, 또는 누락된 임베드 가능 호스트에 의해 차단될 수 있기 때문입니다.

기존 사이트의 새로운 기능을 개발 중이라면, discourseEmbedUrl을 프로덕션 URL로 지정하는 것이 하나의 해결책입니다. embed_any_origin이 활성화되어 있으면, iframe이 다른 오리진에서 서빙되더라도 Discourse는 임베딩이 작동하도록 허용합니다. 댓글이 존재하면 로드되고, 없으면 “토론 계속” 버튼이 표시됩니다.

대안적으로, 로컬 도메인(예: localhost)을 임베딩 호스트에 추가했다면 embed_any_origin이 전혀 필요 없을 수 있습니다. 하지만 여전히 localhost임베드 가능 호스트로 추가해야 합니다.

:warning: 주의할 점: force_https 설정이 활성화되어 있고 개발 사이트가 TLS를 사용하지 않으면 임베딩이 실패합니다. 이 경우 개발 중 force_https를 비활성화하거나 테스트를 위해 별도의 Discourse 인스턴스를 구동하는 것을 고려해 보세요.

참고: discourseEmbedUrl이 공개적으로 접근 가능하고 임베딩이 여전히 "토론 로드 중…"을 표시하면서 토픽을 생성하지 않는다면, 도메인이 Discourse의 SSRF 보호에 의해 차단되었을 수 있습니다.

이러한 문제는 로컬 DNS 해석이 이루어지는 환경(예: Docker, Kubernetes, 내부 DNS 서버가 있는 LAN)에서 Discourse 인스턴스가 실행될 때 자주 발생합니다. 이 경우 Discourse는 사이트의 도메인을 로컬 IP 주소(예: 127.0.0.1 또는 192.168.x.x)로 해석하고 이를 안전하지 않다고 판단할 수 있습니다.

접근을 허용하려면 도메인을 allowed_internal_hosts 사이트 설정에 추가하세요. 이는 도메인을 안전하게 가져올 수 있도록 명시적으로 표시하여 SSRF 필터링을 우회합니다.

차단된 IP 범위의 전체 목록은 Discourse 소스 코드에서 확인할 수 있습니다.

허용된 내부 호스트 — 사이트 설정 설명

현재:

oneboxing 등 기타 목적으로 Discourse가 안전하게 크롤링할 수 있는 내부 호스트 목록

대안:

내부 IP로 해석되는 호스트를 Discourse가 크롤링할 수 있도록 허용합니다. 사이트가 로컬 DNS(예: Docker, LAN, Kubernetes) 뒤에 실행될 때 필요합니다. SSRF 보호가 액세스를 차단하는 경우 댓글 임베딩, 토픽 생성, oneboxing에 필수적입니다.

고려 사항

이러한 제안 중 일부는 공식 문서에 대한 링크로 더 적합할 수 있습니다. 사실, 이 게시물만으로도 충분한 역할을 할 수 있으며, 다른 게시물처럼 인덱싱되어야 하기 때문입니다. 다른 제안들은 적절한 풀 리퀘스트를 필요로 할 수 있으며, 저는 언젠가 처리하겠지만 오늘은 아닐 것입니다.

그럼에도 불구하고, 제가 작성한 내용에는 기술적 부정확성이 있을 수 있습니다. 대부분은 실무 경험을 바탕으로 했지만, 동작을 오해했거나 캐싱에 속았거나(아, 캐싱…), 단순히 무언가를 놓쳤을 수 있습니다. 그 부분은 숙련된 Discourse 베테랑들의 판단에 맡기겠습니다.