로컬서버에 화상회의 세우기

조직 기계 한 대로 시간 제한 없는 회의방을 엽니다

조직에 로컬서버가 이미 있을 때, 그 위에 줌을 대신할 화상회의(Jitsi)를 세우는 방법입니다. 품앗이 사무실 서버(2026-08)와 한 협동조합 연합회 서버(2026-10) 두 곳에 실제로 세우며 확인한 것을 정리했습니다. 로컬서버를 세우는 과정은 로컬서버, 조직 명의의 기계에 있습니다.

1. 왜 직접 세우는가

줌 무료판은 40분에 끊깁니다. 유료로 넘어가면 계정마다 매달 돈이 나갑니다. 남의 무료 화상회의 서버(meet.jit.si 같은)는 공짜지만, 회의가 남의 기계를 지나갑니다.

조직에 상시 켜 두는 서버가 이미 있다면 따져 볼 비용이 거의 없습니다. 품앗이가 처음 검토할 때 「셀프호스팅은 서버비 때문에 탈락」이라는 판단이 나왔지만, 그 전제가 틀렸습니다. 서버는 이미 돌고 있었고, 화상회의 한 벌은 메모리 2GB 안팎이면 됩니다. 추가 비용은 0원이었습니다.

항목내용
시간 제한없음
인원서버와 회선이 허락하는 만큼. 품앗이 사무실 회선(업로드 약 90Mbps)으로 어림하면 8명 720p는 여유 있고, 화질을 낮추면 15명 안팎까지 갑니다. 실제로 그 인원을 모아 재 보지는 않았습니다
계정회의를 여는 사람만 로그인. 참가자는 주소만 누르면 됩니다
비용서버가 이미 있으면 0원

2. 먼저 확인할 것 다섯 가지

설치보다 이 다섯 가지가 먼저입니다. 이게 맞으면 설치 자체는 어렵지 않습니다.

  1. 서버에 Docker가 있는가. 없으면 apt install docker.io docker-compose-v2로 깝니다.
  2. 여유 자원. 메모리 2~4GB, 디스크 몇 GB면 됩니다.
  3. 이미 쓰는 포트. ss -tuln으로 봅니다. Jitsi 기본 웹 포트(8000)와 내부 포트(8080)는 다른 서비스와 자주 겹칩니다. 한 현장에서는 8000을 백업 수신부가, 8080을 파일서버(Nextcloud)가 쓰고 있어 옮겼습니다.
  4. 웹 주소를 낼 길. 품앗이는 Cloudflare 터널을 씁니다. 이미 터널로 다른 서비스를 내고 있다면 주소 한 줄만 더하면 되고, 인증서 발급이나 443 포트 개방이 필요 없습니다.
  5. 영상이 들어올 길(UDP 10000). 가장 자주 막히는 곳입니다. 다음 절에서 따로 다룹니다.

3. 관문은 공유기다

화상회의의 웹 화면은 터널로 나갈 수 있지만 영상·음성은 못 나갑니다. 터널은 웹(HTTP)을 나르는 통로이고, 영상은 UDP로 오가기 때문입니다. 그래서 바깥에서 오는 UDP 10000번이 공유기를 지나 서버까지 들어오게 해야 합니다.

방법은 둘이고, 쉬운 쪽부터 봅니다.

3-1. UPnP — 공유기 비밀번호 없이 연다

국내 사무실 공유기(ipTIME 등)는 대개 UPnP가 켜져 있습니다. UPnP는 공유기 안의 기기가 「이 포트를 나한테 열어 달라」고 공유기에 직접 요청하는 기능입니다. 켜져 있으면 공유기 관리자 비밀번호가 없어도 서버가 스스로 포트를 열 수 있습니다.

품앗이 사무실에서는 공유기 관리자 비밀번호가 기록 어디에도 없어 하루가 막혔습니다. 협동조합 연합회 현장에서는 UPnP를 먼저 봤고, 비밀번호 없이 몇 분 만에 열었습니다.

켜져 있는지는 서버에서 이렇게 봅니다. 응답에 InternetGatewayDevice가 나오면 켜진 것입니다.

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(4)
s.sendto(b'M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\n'
         b'MX: 2\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n',
         ("239.255.255.250", 1900))
print(s.recvfrom(4096))

켜져 있으면 아래 스크립트로 UDP 10000을 서버로 엽니다. 별도 설치가 필요 없는 파이썬 표준 라이브러리만 씁니다. LOC는 위 응답의 LOCATION: 값으로 바꿉니다.

#!/usr/bin/env python3
"""공유기에 UPnP 로 UDP 10000 → 이 기계 를 연다. 크론으로 주기 재등록."""
import re, socket, sys, urllib.request
PORT = 10000
LOC = "http://192.168.0.1:43980/rootDesc.xml"   # SSDP 응답의 LOCATION
BASE = LOC.split("/", 3)[:3]; BASE = "/".join(BASE)
 
def soap(ctrl, stype, action, args):
    body = "".join(f"<{k}>{v}</{k}>" for k, v in args.items())
    env = (f'<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
           f's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
           f'<u:{action} xmlns:u="{stype}">{body}</u:{action}></s:Body></s:Envelope>')
    req = urllib.request.Request(ctrl, env.encode(), {"Content-Type": 'text/xml; charset="utf-8"',
                                                      "SOAPAction": f'"{stype}#{action}"'})
    try:
        return urllib.request.urlopen(req, timeout=8).read().decode()
    except urllib.error.HTTPError as e:
        return "ERR " + e.read().decode()
 
desc = urllib.request.urlopen(LOC, timeout=8).read().decode()
m = re.search(r"<serviceType>(urn:schemas-upnp-org:service:WAN(?:IP|PPP)Connection:\d)</serviceType>"
              r".*?<controlURL>([^<]+)</controlURL>", desc, re.S)
stype, ctrl = m.group(1), BASE + m.group(2)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.connect((BASE.split("//")[1].split(":")[0], 1))
lan = s.getsockname()[0]
ext = re.search(r"<NewExternalIPAddress>([^<]*)", soap(ctrl, stype, "GetExternalIPAddress", {}))
print("wan", ext.group(1) if ext else "?", "lan", lan)
r = soap(ctrl, stype, "AddPortMapping", {"NewRemoteHost": "", "NewExternalPort": PORT, "NewProtocol": "UDP",
     "NewInternalPort": PORT, "NewInternalClient": lan, "NewEnabled": 1,
     "NewPortMappingDescription": "jitsi-jvb", "NewLeaseDuration": 0})
print("add", "OK" if "AddPortMappingResponse" in r else r[:300])
sys.exit(0 if "AddPortMappingResponse" in r else 1)

여기서 두 가지를 같이 확인합니다.

  • wan 값이 진짜 공인 IP인가. curl ifconfig.me로 본 값과 같아야 합니다. 100.64.~이나 10.~처럼 사설 대역이면 통신사가 여러 집에 주소 하나를 나눠 주는 방식(CGNAT)이라 포트를 열어도 바깥에서 못 들어옵니다. 이때는 3-3으로 갑니다.
  • 공유기가 재부팅되면 열어 둔 포트가 사라질 수 있습니다. 크론에 걸어 주기적으로 다시 엽니다.
*/30 * * * * /usr/bin/python3 /srv/admin-tools/upnp_map.py >/dev/null 2>&1
@reboot sleep 60 && /usr/bin/python3 /srv/admin-tools/upnp_map.py >/dev/null 2>&1

3-2. 공유기 관리 화면에서 직접 연다

UPnP가 꺼져 있으면 공유기 관리 화면에서 포트포워딩 규칙을 만듭니다. UDP 10000 → 서버 내부 IP:10000입니다.

  • 바깥 포트와 안쪽 포트를 같은 번호로 둡니다. Jitsi는 자기가 찾은 「공인 IP:10000」을 참가자에게 알려 주는데, 번호를 바꿔 두면 그 안내가 틀립니다.
  • 서버 내부 IP를 고정합니다. 공유기의 DHCP 고정 할당에 서버 MAC 주소를 등록합니다. 안 그러면 서버가 재부팅 후 다른 IP를 받아, 포워딩이 아무 소리 없이 죽습니다.
  • 공유기 관리자 비밀번호가 기록에 없으면, 설치업체가 와이파이 비밀번호와 같게 맞춰 둔 경우가 많습니다.

3-3. 둘 다 안 되면

공인 IP가 아니거나 공유기를 만질 수 없으면, 서버가 바깥 중계 서버(TURN)로 나가서 영상을 주고받게 합니다. Cloudflare Realtime TURN은 매달 1TB까지 무료입니다. 설정이 복잡해서 품앗이는 아직 써 보지 않았습니다.

3-4. 열렸는지는 반드시 바깥에서 잰다

같은 사무실 안에서 자기 공인 IP로 쏘면 공유기가 안에서 돌려보내기 때문에(헤어핀 NAT) 판정이 안 됩니다. 다른 회선(다른 사무실 서버, 휴대폰 핫스팟에 붙인 노트북 등)에서 쏩니다.

# 서버에서: 10000번에서 기다린다 (Jitsi 설치 전에)
python3 -c "import socket;s=socket.socket(2,2);s.bind(('0.0.0.0',10000));print(s.recvfrom(100))"
# 바깥 회선에서: 공인 IP로 쏜다
echo hello > /dev/udp/<공인IP>/10000

서버 쪽에 hello와 보낸 쪽 주소가 찍히면 관문이 열린 것입니다.

4. 설치

공식 배포판 docker-jitsi-meet을 씁니다. 품앗이는 두 현장 모두 stable-11146-2 판을 썼습니다. 이미 돌아가는 판을 그대로 옮기는 편이 새 판을 시험하는 것보다 안전합니다.

cd /srv && mkdir jitsi && cd jitsi
curl -L https://github.com/jitsi/docker-jitsi-meet/archive/refs/tags/stable-11146-2.tar.gz | tar xz --strip-components=1
cp env.example .env
./gen-passwords.sh          # 내부 비밀값 자동 생성

.env에서 바꿀 것:

설정값이유
CONFIG/srv/jitsi/cfg설정·계정이 남을 자리
PUBLIC_URLhttps://meet.<조직도메인>터널로 낼 주소
HTTP_PORT127.0.0.1:8090루프백에만 묶어 터널만 받게 한다. 기본 8000이 겹치면 다른 빈 포트로
HTTPS_PORT127.0.0.1:8453쓰지 않지만 바깥에 안 보이게 루프백에 묶는다
ENABLE_LETSENCRYPT켜지 않는다(기본값)인증서는 터널이 맡는다
ENABLE_AUTH / AUTH_TYPE / ENABLE_GUESTS1 / internal / 1여는 사람만 로그인, 참가는 자유
JVB_ADVERTISE_IPS비워 둔다공인 IP를 STUN으로 스스로 찾게 한다. 사무실 회선 IP가 바뀌어도 따라간다
JVB_COLIBRI_PORT빈 포트(예 8091)기본 8080이 겹칠 때만
DEFAULT_LANGUAGEko
TZAsia/Seoul

docker-compose.yml은 고치지 않습니다. 웹 포트는 위 HTTP_PORT 값으로 루프백에 묶이고, 바깥에 열리는 것은 UDP 10000 하나뿐입니다.

docker compose up -d
docker compose ps             # web·prosody·jicofo·jvb 넷이 Up

첫 기동 때 걸리는 것 하나. prosody와 web이 '/var/lib/prosody' is not writable by the container user (uid 1000)을 내며 재시작을 반복할 수 있습니다. Docker가 cfg/ 아래 폴더를 root 소유로 만들어서입니다.

sudo chown -R 1000:1000 /srv/jitsi/cfg/storage /srv/jitsi/cfg/tmp
docker compose restart

회의 개설 계정

docker compose exec prosody prosodyctl --config /run/prosody/config/prosody.cfg.lua \
  register <아이디> meet.jitsi <비밀번호>

공식 문서에 나오는 /config/prosody.cfg.lua 경로는 이 판에서 틀립니다. 위 경로를 씁니다. 비밀번호는 서버의 권한 600 파일에만 두고, 단체 대화방에는 올리지 않습니다. 회의를 여는 사람에게 개인 대화로 따로 전합니다.

웹 주소 내기 (Cloudflare 터널)

이미 쓰는 터널의 공개 호스트 이름에 한 줄을 더합니다. meet.<조직도메인> → http://localhost:8090. 원격 관리 터널이면 Cloudflare 대시보드에서, 설정 파일 터널이면 config.yml의 ingress에 넣고 cloudflared를 다시 띄웁니다. 기존 줄은 건드리지 않고, 맨 끝 http_status:404 줄 바로 앞에 넣습니다.

5. 다 됐는지 확인하기

「띄웠다」와 「회의가 된다」는 다릅니다. 아래를 하나씩 잽니다.

확인방법통과 기준
컨테이너docker compose ps넷 다 Up
웹curl -o /dev/null -w '%{http_code}' https://meet.<도메인>/200
공인 IP 탐지docker compose logs jvb | grep -E 'Discovered public address|stunDiscoveryFailed'실제 공인 IP가 찍히고 stunDiscoveryFailed=false
영상 경로바깥 회선에서 UDP 10000으로 쏘고 서버에서 sudo tcpdump -ni any udp port 10000패킷이 JVB 컨테이너 주소까지 찍힌다
로그인개설 계정으로 실제 로그인맞는 비밀번호는 통과, 틀린 비밀번호는 거부
실통화서로 다른 회선의 기기 두 대(휴대폰 LTE + 사무실 PC)영상·음성이 양쪽에서 나온다
  • 바깥에서 쏜 UDP에 응답이 없는 것은 정상입니다. Jitsi의 영상 서버는 회의 참가 정보가 없는 요청을 조용히 버립니다. 무응답을 고장으로 읽지 않습니다. 도착했는지만 봅니다.
  • 다른 서비스가 같은 서버에 있으면, 설치 뒤에 그 서비스들이 그대로 열리는지도 같이 봅니다.

6. 운영하면서

  • 웹은 되는데 영상만 안 나오면 공인 IP가 바뀐 경우가 많습니다. 영상 서버는 기동할 때 한 번만 공인 IP를 찾습니다. docker compose restart jvb 한 번이면 돌아옵니다. 자주 반복되면 IP 변경을 감지해 재시작하는 타이머를 겁니다.
  • 공유기가 재부팅됐을 때. UPnP로 열었다면 크론이 30분 안에 다시 엽니다. 수동 포워딩이면 규칙은 남지만, 서버 IP가 고정돼 있어야 합니다.
  • 계정은 사람마다. 한 계정을 돌려 쓰면 누가 회의를 열었는지 남지 않습니다. 필요할 때마다 4절 명령으로 하나씩 만듭니다.

7. 되돌리기

세운 것을 걷어 내는 순서입니다. 위에서 아래로 하면 서버에 흔적이 남지 않습니다.

  1. 터널에서 meet. 호스트 이름 줄을 지우고, DNS 레코드도 지웁니다.
  2. cd /srv/jitsi && docker compose down (이미지·설정은 남습니다. 완전히 지우려면 폴더째 삭제)
  3. 크론의 UPnP 두 줄을 지우고, 공유기에서 UDP 10000 규칙을 지웁니다.

관련