배경

내 헤드리스 Mac mini를 24/7로 운영하면서, 두 가지 문제를 마주했다.

첫 번째는 상시 대시보드다. 헤드리스 서버에는 모니터가 없으니 현재 상태를 한눈에 볼 방법이 없었다. (웹 대시보드가 있기는 했지만, 생각보다 접근성이 떨어짐) 세션 현황, cron 실행 결과, 날씨, 하드웨어 상태 등을 항상 표시해두는 전용 디스플레이가 필요했다.

두 번째는 오디오다. 알람, BGM 재생 등 오디오 출력 기능이 있는데, macOS에서 잠금 화면이 활성화되거나 디스플레이가 슬립되면 pygame 기반 오디오 재생이 실패할 가능성이 있다. macOS의 OS 수준 제약이라 소프트웨어로 우회하기 어렵다.

결론은 독립적인 I/O 관리 장치가 필요하다는 것이었다. Mac mini를 보조할 Raspberry Pi 4를 추가하기로 했다.

구성 목표

  • 24/7 가동되는 키오스크 디스플레이 (대시보드)
  • 독립 Audio Server (Mac에서 WebSocket으로 제어)
  • Mac mini와 CAT6 LAN 직결 (Wi-Fi 불필요)

하드웨어 구성

Mac mini (M4)가 Wi-Fi로 인터넷에 연결되고, Pi는 Mac과 LAN 직결이다. Pi는 Mac을 통해 NAT로 인터넷에 접근한다. Wi-Fi 모듈은 쓰지 않는다.


OS 설치

OS 선택: Lite를 선택한 이유

Desktop 대신 Raspberry Pi OS Lite (64-bit, Trixie) 를 선택했다. 이유는 명확하다.

키오스크 디스플레이의 최우선 조건은 알림, 패널, 시스템 팝업 등 외부 interrupt가 없는 것이다. Desktop 환경 위에서 Chromium 키오스크를 띄우면 시스템 알림이나 업데이트 팝업이 끼어들 수 있다. Lite + cage (단일 앱 전용 Wayland 컴포지터)를 쓰면 구조적으로 단 하나의 앱만 풀스크린으로 표시되므로, interrupt 요소가 들어올 방법 자체가 없다.

리소스 측면에서도 Lite는 약 50MB RAM을 사용하는 반면 Desktop은 300MB+다. 24/7 구동 환경에서 유리하다.

플래싱

# SD 카드 확인
diskutil list
 
# 이미지 다운로드 및 SHA256 검증
curl -L -o setup/assets/raspios-lite-arm64.img.xz \
  "https://downloads.raspberrypi.com/raspios_lite_arm64/images/..."
shasum -a 256 setup/assets/raspios-lite-arm64.img.xz
 
# 압축 해제 및 플래싱
xz -dk setup/assets/raspios-lite-arm64.img.xz
diskutil unmountDisk /dev/disk5
sudo dd if=setup/assets/raspios-lite-arm64.img of=/dev/rdisk5 bs=4m status=progress

/dev/rdisk5 vs /dev/disk5

raw disk(rdisk)를 사용하면 block device보다 쓰기 속도가 훨씬 빠르다.

플래싱 후 bootfs 파티션이 자동 마운트된다. cloud-init은 첫 부팅에만 실행되므로 여기서 초기 설정을 전부 넣어야 한다.

user-data 파일에 사용자, SSH 키, 실행 명령을 설정했다.

bootfs/user-data
#cloud-config
 
hostname: rpi-kiosk
 
ssh_pwauth: true
 
users:
- name: piuser
  gecos: Pi User
  groups: sudo, audio, video, render, input, plugdev, netdev
  shell: /bin/bash
  sudo: ALL=(ALL) NOPASSWD:ALL
  lock_passwd: false
  passwd: "$6$salt$hash..."
  ssh_authorized_keys:
  - <Mac의 SSH 공개키>
 
runcmd:
- systemctl enable ssh
- systemctl start ssh

트러블슈팅: Pi 4 하드웨어 불량

첫 번째로 꺼낸 Pi 41가 부팅되지 않았다.

  • 빨간 전원 LED ON, 초록 LED는 항상 solid (깜빡임 없음)
  • HDMI 출력 없음 (hdmi_safe=1 적용해도 동일)
  • 네트워크 응답 없음

시도한 것:

  • 순정 Trixie 이미지 → 실패
  • 순정 Bookworm 이미지 → 실패
  • EEPROM recovery 이미지 → 실패

결론: SD 카드 슬롯 하드웨어 불량. 새 Pi 4로 교체 후 정상 부팅 확인. 정상 Pi는 부팅 시 초록 LED가 깜빡이고, 모니터에 무지개 화면 → 콘솔 순으로 표시된다.

트러블슈팅: cloud-init SSH 설정

1. SSH 서비스 미시작

Trixie의 cloud-init은 ssh_pwauth: true만으로는 SSH 서비스가 자동 시작되지 않는다. runcmd에 명시적으로 추가해야 한다.

runcmd:
- systemctl enable ssh
- systemctl start ssh

2. 비밀번호 해시 깨짐

passwd 필드의 SHA-512 해시에는 $ 문자가 포함된다. YAML에서 이를 그대로 쓰면 변수 치환으로 해석되어 해시가 깨진다.

# Bad — $ 문자가 YAML에서 해석됨
passwd: $6$salt$hash...
 
# Good — 쌍따옴표로 감싸야 함
passwd: "$6$salt$hash..."

3. 인터페이스명 혼재

Trixie(Debian 13)에서 이더넷 인터페이스가 eth0 또는 end0일 수 있다. network-config에 양쪽을 모두 명시하고 optional: true로 설정해야 부팅 지연이 없다.

bootfs/network-config
network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true
      optional: true
    end0:
      dhcp4: true
      optional: true

네트워크 설정

목표 구성은 다음과 같다.

인터페이스IP
Mac en0 (Ethernet)192.168.4.1/24
Pi eth0192.168.4.2/24

Mac이 게이트웨이 역할을 하며 Pi는 Mac의 Wi-Fi를 통해 NAT로 인터넷에 접근한다.

Mac 측 설정

# en0에 고정 IP 할당
sudo ifconfig en0 192.168.4.1 netmask 255.255.255.0 up
 
# IP 포워딩 활성화
sudo sysctl -w net.inet.ip.forwarding=1
 
# NAT 규칙 (pf)
echo 'nat on en1 from 192.168.4.0/24 to any -> (en1)' | sudo tee /tmp/pf-pi.conf
sudo pfctl -N -f /tmp/pf-pi.conf -e
 
# 검증
sudo pfctl -s nat
# nat on en1 inet from 192.168.4.0/24 to any -> (en1) round-robin

이 설정은 재부팅 시 초기화된다. Mac이 재부팅될 때마다 수동으로 실행하거나, LaunchDaemon으로 자동화해야 한다.

Pi 측 설정

Pi에 SSH로 접속해 NetworkManager로 고정 IP를 설정한다. nmcli를 사용해야 한다. netplan 파일을 직접 수정하면 NetworkManager가 덮어쓴다.

sudo nmcli connection modify netplan-eth0 \
  ipv4.method manual \
  ipv4.addresses 192.168.4.2/24 \
  ipv4.gateway 192.168.4.1 \
  ipv4.dns '8.8.8.8 8.8.4.4'
sudo nmcli connection up netplan-eth0

Pi 측 NetworkManager 설정은 재부팅 후에도 유지된다.

Mac 네트워크 자동화 (LaunchDaemon)

Mac 재부팅 시 en0 IP, IP 포워딩, NAT가 모두 초기화된다. LaunchDaemon으로 부팅 시 자동 적용한다.

setup/pi-network.sh
#!/bin/bash
ifconfig en0 192.168.4.1 netmask 255.255.255.0 up
sysctl -w net.inet.ip.forwarding=1
echo "nat on en1 from 192.168.4.0/24 to any -> (en1)" > /tmp/pf-pi.conf
pfctl -N -f /tmp/pf-pi.conf -e 2>/dev/null || true
/Library/LaunchDaemons/com.pi-station.network.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.pi-station.network</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/pi-station/setup/pi-network.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
</dict>
</plist>
sudo cp setup/com.pi-station.network.plist /Library/LaunchDaemons/
sudo launchctl bootstrap system /Library/LaunchDaemons/com.pi-station.network.plist

이제 Mac 재부팅 후에도 Pi와의 연결이 자동으로 복구된다.


키오스크 환경 설정

대시보드를 항상 표시할 키오스크 환경을 구성한다. 구성 요소는 단순하다.

  • cage: 단일 앱 전용 Wayland 컴포지터
  • Chromium: kiosk 모드로 대시보드 URL을 표시
sudo apt install -y cage chromium wtype

패키지명 주의

Debian Trixie에서는 chromium-browser가 아닌 chromium이다. chromium-browser는 존재하지 않는다.

시도 1: systemd 서비스 방식 — 실패

처음에는 systemd 서비스로 cage를 직접 실행하려 했다. 직관적이고 관리가 편해 보였기 때문이다.

[Service]
Type=simple
ExecStart=/usr/bin/cage -- chromium --kiosk http://...

결과: cage 프로세스는 시작되지만 Chromium을 fork하지 못한다. Tasks: 0 상태로 멈춘다.

원인은 TTY 세션과 DRM 장치 접근 권한 문제다. systemd 서비스는 정상적인 로그인 세션 컨텍스트가 없어서, DRM(Direct Rendering Manager)에 대한 권한을 얻지 못한다. TTYPath, StandardInput=tty-force, PAMName=login 등을 설정해봤지만 해결되지 않았다.

시도 2: autologin + bash_profile 방식 — 성공

autologin으로 tty1에 piuser를 자동 로그인하고, ~/.bash_profile에서 cage를 시작하는 방식이다. 정상적인 로그인 세션을 통해 cage가 실행되므로 DRM 권한 문제가 없다.

# tty1 autologin 설정
sudo mkdir -p /etc/systemd/system/getty@tty1.service.d
sudo tee /etc/systemd/system/getty@tty1.service.d/override.conf << 'EOF'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin piuser --noclear %I $TERM
EOF
sudo systemctl daemon-reload
~/.bash_profile
if [ "$(tty)" = "/dev/tty1" ]; then
  export WLR_LIBINPUT_NO_DEVICES=1
  (sleep 20 && while true; do WAYLAND_DISPLAY=wayland-0 wtype -k Escape; sleep 30; done) &
  exec cage -s -- chromium \
    --disable-wayland-ime \
    --kiosk \
    --no-first-run \
    --disable-translate \
    --noerrdialogs \
    --disable-infobars \
    --disable-session-crashed-bubble \
    http://192.168.4.1/pi-dashboard
fi

exec로 실행하므로 cage가 login 셸을 대체한다. cage가 종료되면 getty가 재로그인하고, bash_profile이 다시 실행되어 cage가 재시작된다. 자동 복구 구조다.

옵션설명
WLR_LIBINPUT_NO_DEVICES=1입력 장치 없어도 cage 시작 허용
cage -s마지막 앱 종료 시 cage도 종료 (getty 재시작 유도)
--kioskChromium 전체화면, UI 요소 숨김
--disable-wayland-imeWayland IME 바인딩 비활성화

트러블슈팅: IME 언어 toast

키오스크를 켜면 Chromium 좌상단에 “English”라는 언어 표시 토스트가 나타났다. Chromium이 Wayland의 zwp_text_input 프로토콜에 바인딩하면서 발생하는 것이다.

시도한 것:

  • --disable-features=ImeThread,VirtualKeyboard,WaylandTextInputV1,WaylandTextInputV3 → 효과 없음
  • --disable-virtual-keyboard → 효과 없음
  • Chromium 정책 (TouchVirtualKeyboardEnabled, VirtualKeyboardEnabled) → 효과 없음
  • /etc/chromium.d/00-rpi-vars에서 --force-renderer-accessibility 제거 → 효과 없음
  • Chromium 프로필 초기화 → 효과 없음
  • --disable-wayland-ime → toast는 여전히 뜨지만 Escape 키로 닫을 수 있음

--disable-wayland-ime을 적용하면 toast가 완전히 없어지지는 않지만, Escape 키로 닫힌다는 것을 발견했다. 그리고 toast는 주기적으로 재출현한다.

해결: wtype -k Escape로 toast를 주기적으로 자동으로 닫는다. bash_profile에서 cage 시작 전 백그라운드 루프를 돌린다.

(sleep 20 && while true; do WAYLAND_DISPLAY=wayland-0 wtype -k Escape; sleep 30; done) &

20초 대기 후(Chromium 초기화 완료 대기) 30초 간격으로 Escape를 누른다.

트러블슈팅: 마우스 커서 숨기기

키오스크에 마우스 커서가 표시되는 문제가 있었다. cage에는 커서 숨김 옵션이 없고, XCURSOR_SIZE=1이나 CSS cursor: none은 Wayland 환경에서 효과가 없었다.

해결: Adwaita 커서 테마의 모든 커서 파일을 1×1 투명 Xcursor로 교체한다.

# 1×1 투명 PNG 생성
python3 -c "
import struct, zlib
def chunk(t, d):
    c = t + d
    return struct.pack('>I', len(d)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
with open('/tmp/1x1.png', 'wb') as f:
    f.write(b'\x89PNG\r\n\x1a\n')
    f.write(chunk(b'IHDR', struct.pack('>IIBBBBB', 1, 1, 8, 6, 0, 0, 0)))
    f.write(chunk(b'IDAT', zlib.compress(b'\x00\x00\x00\x00\x00')))
    f.write(chunk(b'IEND', b''))
"
 
# Xcursor 파일 생성
echo "1 0 0 /tmp/1x1.png" > /tmp/transparent.cfg
xcursorgen /tmp/transparent.cfg /tmp/transparent
 
# Adwaita 커서 교체 (백업 후)
sudo cp -r /usr/share/icons/Adwaita/cursors /usr/share/icons/Adwaita/cursors.bak
for f in /usr/share/icons/Adwaita/cursors/*; do
  sudo cp /tmp/transparent "$f"
done

주의

이 작업 후에는 마우스를 연결해도 커서가 보이지 않는다. 원복이 필요하면 cursors.bak을 복원해야 한다.


마무리

설정이 완료된 후 전체 스택은 다음과 같이 동작한다.

  • Pi 부팅 → tty1 autologin → cage 시작 → Chromium 키오스크
  • Mac 재부팅 → LaunchDaemon → en0 IP + IP forwarding + NAT 자동 설정
  • 대시보드 → Mac에서 pm2로 구동 중인 pi-dashboard-api, pi-dashboard-app을 Caddy 리버스 프록시로 통합, Pi 키오스크에서 http://192.168.4.1/pi-dashboard로 접근

Mac 재부팅 후에는 ./init.sh 한 줄로 전체 상태를 복구할 수 있다.

./init.sh        # 전부 (네트워크 + 대시보드 + 오디오)
./init.sh -n     # 네트워크만
./init.sh -c     # 상태 확인만

설정 과정에서 가장 시간을 많이 쓴 부분은 역설적으로 Pi 하드웨어 불량(교체 필요)과 Chromium IME toast였다. OS, 네트워크, kiosk 설정 자체는 문서화가 잘 되어 있어서 시행착오가 있었어도 방향을 잡기는 어렵지 않았다. 다만 cage + Wayland 조합에서 systemd 서비스 방식이 동작하지 않는 부분은 레퍼런스가 거의 없어서 직접 삽질로 알아낸 부분이다.

Footnotes

  1. 번개장터에서 중고로 구매했는데… 참으로 운이 없었다.