오라클 클라우드 ARM 서버에 Discourse 게시판 붙이기 — 삽질 기록

운영 중인 사이트에 자유게시판을 하나 추가하기로 했다. 요구사항은 단순했다.

  • 글쓰기와 댓글이 가능한 자유게시판
  • 구글 계정으로만 로그인 가능
  • 도배/악성 유저는 자동으로 차단
  • 기존 사이트 톤과 어느 정도는 맞출 것

처음엔 “그냥 게시판 하나 다는 건데 얼마나 걸리겠어” 싶었는데, 결과적으로 반나절짜리 트러블슈팅이 됐다. 기록 삼아 정리한다.

1. CMS 선택: 그누보드 vs Discourse

처음엔 한국형 게시판인 그누보드를 생각했다. 한글 UI가 기본이고 커스터마이징 자유도가 높다는 게 장점이었다. 그런데 요구사항을 하나씩 따져보니 얘기가 달라졌다.

  • 구글 로그인 강제 → 그누보드는 플러그인 설치 + 이메일가입 폼 숨기기 같은 추가 작업 필요
  • 신고 누적 자동 차단 → 그누보드는 기본 미지원, 직접 스크립트 짜야 함
  • Discourse는 이 두 가지가 전부 관리자 설정 화면에서 클릭 몇 번으로 끝남

디자인 통일성은 그누보드가 유리하지만, “손 안 가게 굴러가는 것”이 최우선 목표였기 때문에 결국 Discourse로 결정했다.

2. ARM 서버라는 변수

서버가 오라클 클라우드의 ARM(Ampere) 인스턴스였다. Discourse 공식 도커 이미지는 기본적으로 amd64용이라, 그대로 받으면 아키텍처 불일치 에러가 난다.

해결 방법은 세 가지 정도였다.

  1. QEMU 에뮬레이션으로 amd64 이미지 실행 (성능 저하 큼, 비추천)
  2. ARM 서버에서 로컬로 직접 빌드
  3. Discourse만 x86 인스턴스로 분리

리소스가 넉넉한 서버였어서 로컬 빌드를 선택했다. discourse_docker의 launcher rebuild 명령은 이미지를 pull하는 게 아니라 소스부터 컴파일하기 때문에, 시간은 좀 걸려도(사양에 따라 20분~1시간) 아키텍처 문제는 자연스럽게 해결된다.

3. 포트 충돌 — 예상 못 한 복병

Discourse standalone 컨테이너는 기본적으로 호스트의 80/443 포트를 직접 점유하려고 한다. 문제는 이미 이 서버에 다른 서비스들(WordPress, Nextcloud 등)이 떠 있었고, 그 앞단에서 호스트에 직접 설치된 Nginx가 리버스 프록시로 80/443을 이미 쓰고 있었다는 점이다.

sudo ss -tlnp | grep -E ':80|:443'

이 명령으로 기존 Nginx가 포트를 물고 있는 걸 확인하고, Discourse는 내부 포트(예: 127.0.0.1:18025)로만 열어서 기존 Nginx가 리버스 프록시하도록 구조를 바꿨다.

nginx

server {
    listen 443 ssl;
    server_name board.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:18025;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

다행히 메인 도메인에 와일드카드 SSL 인증서(*.example.com)를 이미 쓰고 있어서, 서브도메인용 인증서를 새로 받을 필요는 없었다.

4. Cloudflare 프록시가 DNS 검증을 막다

discourse-setup 마법사가 DNS 검증 단계에서 계속 실패했다. 원인은 도메인이 서버 IP가 아니라 Cloudflare 프록시 IP를 가리키고 있었기 때문이었다.

bash

dig board.example.com +short
# → Cloudflare IP가 나옴 (서버 IP 아님)

Cloudflare DNS 설정에서 해당 레코드의 프록시 상태를 **주황 구름(Proxied) → 회색 구름(DNS only)**으로 바꾸니 바로 해결됐다. 이후 SSL은 어차피 서버의 Nginx가 처리하기 때문에 굳이 Cloudflare 프록시를 켤 필요도 없었다.

5. 이메일 발송 — 삽질의 8할

Discourse는 회원가입 인증, 알림 등에 이메일이 필수다. Gmail SMTP는 발송량 제한과 스팸 처리 위험 때문에 배제하고, 무료 트랜잭션 이메일 서비스인 Brevo를 SMTP 서버로 골랐다.

여기서부터 순서대로 에러를 하나씩 만났다.

① SMTP 인증 실패 (535 5.7.8) 발급받은 SMTP Key를 정확히 복사하지 못한 게 원인이었다. 키는 발급 시점에만 전체 값이 보이고 이후엔 다시 볼 수 없어서, 재발급 후 복사 아이콘으로 정확히 복사하니 해결됐다.

② 발신자 미인증 거부 인증은 통과했는데 이번엔 이런 에러가 났다.

Sending has been rejected because the sender you used is not valid.
Validate your sender or authenticate your domain

Brevo는 SMTP 인증과 별개로, 발신 주소로 쓰인 도메인이 인증됐는지를 따로 검사한다. 해결을 위해 Brevo에서 도메인 인증 절차를 밟았다.

  • DKIM 레코드 2개 (CNAME)
  • DMARC 레코드 (TXT)

를 도메인 DNS에 추가하고 인증을 완료했다. 이때도 CNAME은 DNS only로 설정해야 하는 걸 깜빡하지 않도록 주의가 필요했다.

③ 서브도메인은 별도 취급 루트 도메인(example.com)은 인증됐는데, 발신 주소에 쓴 서브도메인([email protected])은 별도로 미인증 상태로 남아 같은 에러가 반복됐다. 가장 간단한 해결은 발신 주소를 이미 인증된 루트 도메인으로 통일하는 것이었다.

yaml

DISCOURSE_NOTIFICATION_EMAIL: [email protected]

이렇게 바꾸고 나서야 비로소 인증 메일이 정상적으로 도착했다.

정리

돌이켜보면 삽질의 8할은 CMS 자체 문제가 아니라 기존 인프라와의 통합 문제였다.

  • 이미 Nginx가 쓰고 있던 포트와의 충돌
  • Cloudflare 프록시 설정
  • 이메일 발신자 도메인 인증

Discourse 자체 설치는 명령어 몇 줄이면 끝나지만, “이미 여러 서비스가 돌아가고 있는 서버에 새 서비스를 얹는 것”은 늘 이런 식으로 예상 못 한 지점에서 걸리는 것 같다. 다음에 비슷한 서비스를 추가할 땐 이번 트러블슈팅 순서(포트 확인 → DNS 프록시 확인 → 이메일 발신 도메인 인증)를 체크리스트 삼아 미리 확인하고 시작해야겠다.

앞으로 남은 작업은 구글 로그인 연동과 신고 기반 자동 차단 설정, 그리고 AdSense 광고 붙이기다. 이어서 정리할 예정이다.