← 뒤로가기

RESTful API는 무엇이고, muabow.com에는 어떻게 붙였는가

RESTful API가 무엇인지, 이번 사이트에 왜 붙였는지, Go와 nginx로 /api/device-status와 /api/visits를 어떻게 구현했는지 따라할 수 있게 정리했다.

이번에는 muabow.com에 작은 RESTful API를 붙였다.

지금 공개된 경로는 두 가지다.

  • GET /api/device-status
  • GET /api/visits

첫 번째는 라즈베리파이의 현재 상태를 JSON으로 돌려주고,
두 번째는 오늘 방문 수와 총 방문 수를 간단하게 응답한다.

단순히 화면만 보는 것이 아니라, 이제는 브라우저 밖에서도 같은 정보를 읽을 수 있게 된 셈이다.

RESTful API는 무엇인가

아주 어렵게 말할 필요는 없다.

RESTful API는 대체로 아래 감각으로 이해하면 충분하다.

  • URL은 무엇을 다루는지 드러나게 만든다.
  • HTTP 메서드는 행동을 나눈다.
  • 응답은 보통 JSON으로 준다.
  • 서버 상태를 억지로 기억하기보다, 요청 하나만 보고 처리할 수 있게 만든다.

예를 들면 이런 식이다.

GET /api/device-status
GET /api/visits
POST /api/visits

이번 사이트에서는 읽기 중심이기 때문에 GET 위주로 두었고,
방문자 수 집계만 내부 스크립트에서 POST /api/visits를 사용한다.

왜 붙였는가

이전까지는 실시간 정보가 있어도 브라우저 화면 안에서만 보였다.

예를 들어 /info 대시보드는 잘 돌아가지만,
다른 프로그램이나 터미널에서 같은 정보를 재사용하려면 다시 긁어야 했다.

그래서 방향을 이렇게 잡았다.

  1. 대시보드가 보는 최신 장치 상태를 그대로 JSON으로도 공개한다.
  2. 사이트 footer에서 쓰는 방문자 수도 JSON으로 읽을 수 있게 둔다.
  3. 앞으로 실시간 기능이 늘어나면 같은 /api/... 패턴으로 확장한다.

즉 이번 API는 거대한 백엔드가 아니라,
이미 운영 중인 사이트 기능을 바깥에서도 읽기 좋게 정리한 첫걸음이다.

지금 구조

현재 흐름은 이렇다.

Go collector
  -> NATS subject: muabow.info.metrics
  -> Go web server keeps latest payload
  -> /info        HTML dashboard
  -> /info/events SSE stream
  -> /api/device-status JSON API
  -> /api/visits  visit counter API

즉 수집은 Go가 하고,
실시간 전달은 NATS와 SSE가 맡고,
공개 API는 같은 Go 서비스가 그대로 응답한다.

어떤 API를 열었는가

현재 공개 엔드포인트는 아래와 같다.

1. 장치 상태

GET https://muabow.com/api/device-status

응답 예시는 이런 형태다.

{
  "collectedAt": "2026-05-15T03:14:14+09:00",
  "hostname": "device-host",
  "os": "linux",
  "arch": "arm64",
  "uptimeSeconds": 388411,
  "load": {
    "one": 0.05,
    "five": 0.04,
    "fifteen": 0.10
  },
  "cpu": {
    "usagePercent": 0,
    "cores": 4
  },
  "memory": {
    "totalBytes": 4246405120,
    "availableBytes": 3561111552,
    "usedBytes": 685293568,
    "usedPercent": 16.14
  },
  "disk": {
    "path": "/",
    "totalBytes": 62408265728,
    "freeBytes": 51915681792,
    "usedBytes": 10492583936,
    "usedPercent": 16.81
  },
  "temperatureC": 54,
  "services": [
    { "name": "nginx", "active": "active" },
    { "name": "cloudflared", "active": "active" },
    { "name": "nats", "active": "active" },
    { "name": "admin-app", "active": "active" },
    { "name": "info-app", "active": "active" }
  ]
}

2. 방문자 수

GET https://muabow.com/api/visits

응답은 단순하다.

{
  "today": 12,
  "total": 248
}

Go에서는 어떻게 구현했는가

핵심 아이디어는 새로 계산하지 않는 것이다.

이미 Go 서비스는 NATS에서 장치 상태를 받아 최신 payload를 메모리에 들고 있었다.
그래서 /api/device-status는 그 최신 JSON을 그대로 돌려주면 된다.

구현 흐름은 이렇다.

  1. broker가 NATS 메시지를 받을 때 최신 payload를 저장한다.
  2. latestPayload()로 가장 최근 JSON을 꺼낸다.
  3. GET /api/device-status 요청이 오면 그 JSON을 응답한다.

핸들러는 대략 이런 구조다.

mux.HandleFunc("/api/device-status", func(w http.ResponseWriter, r *http.Request) {
    handleDeviceStatus(w, r, b)
})

그리고 실제 응답은 아래처럼 매우 얇게 처리한다.

func handleDeviceStatus(w http.ResponseWriter, r *http.Request, b *broker) {
    if r.Method != http.MethodGet {
        w.Header().Set("Allow", "GET")
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }

    payload := b.latestPayload()
    if len(payload) == 0 {
        http.Error(w, "device status unavailable", http.StatusServiceUnavailable)
        return
    }

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.Header().Set("Cache-Control", "no-store")
    _, _ = w.Write(payload)
}

포인트는 두 가지다.

  • GET만 허용한다.
  • 아직 수집값이 없으면 503 Service Unavailable을 준다.

이 정도만 해도 API의 태도가 꽤 분명해진다.

방문자 수 API는 어떻게 만들었는가

방문자 수는 메모리에만 두면 재부팅 때 사라진다.
그래서 JSON 파일 하나를 저장소처럼 사용했다.

방문자 수는 로컬 JSON 파일 하나에 저장한다.

동작은 단순하다.

  • GET /api/visits는 현재 수치 조회
  • POST /api/visits는 오늘 방문과 총 방문을 증가

사이트 footer에서는 브라우저가 이 API를 사용한다.

fetch("/api/visits", options)

그리고 같은 브라우저에서 하루에 한 번만 증가시키기 위해 localStorage 키를 사용했다.

즉 방문자 수는 “로그 기반 분석”이 아니라,
사이트 안에 가볍게 붙는 작은 카운터라는 성격에 가깝다.

nginx는 왜 필요한가

Go 앱은 외부에 직접 열지 않았다.

실제로는 라즈베리파이 로컬에서만 아래 주소로 뜬다.

내부 루프백 주소의 Go 앱 포트

그리고 nginx가 공개 경로만 프록시한다.

현재 설정은 이런 식이다.

location /api/device-status {
  proxy_pass http://127.0.0.1:<internal-app-port>;
  proxy_http_version 1.1;
  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;
}

location /api/visits {
  proxy_pass http://127.0.0.1:<internal-app-port>;
  proxy_http_version 1.1;
  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;
}

이 구조의 장점은 분명하다.

  • 공개 URL은 muabow.com 하나로 유지된다.
  • 내부 앱은 로컬 포트에만 묶어둘 수 있다.
  • 정적 사이트와 작은 동적 기능을 한 도메인에서 같이 운영할 수 있다.

따라 하려면 무엇이 필요한가

비슷하게 만들고 싶다면 순서는 이 정도면 충분하다.

  1. 장치 상태를 수집하는 작은 Go 앱을 만든다.
  2. 그 값을 JSON 구조체로 정리한다.
  3. 최신 payload를 메모리에 유지한다.
  4. GET /api/... 핸들러를 붙인다.
  5. nginx에서 해당 경로를 Go 앱으로 프록시한다.
  6. curl로 실제 응답을 확인한다.

테스트는 아래처럼 바로 할 수 있다.

curl https://muabow.com/api/device-status
curl https://muabow.com/api/visits

라즈베리파이 내부에서는 이렇게 볼 수 있다.

curl http://127.0.0.1:<internal-app-port>/api/device-status
curl http://127.0.0.1/api/device-status

이번 구현에서 중요했던 점

이번에 일부러 지킨 기준은 이렇다.

  • API 경로는 /api/... 아래로 모은다.
  • 읽기용 경로는 GET만 허용한다.
  • 브라우저 화면에서 보이는 값과 API 응답이 따로 놀지 않게 한다.
  • 이미 있는 Go 서비스 위에 얇게 덧붙인다.

이게 꽤 중요하다.

화면용 데이터와 API용 데이터를 따로 계산하기 시작하면,
나중에 값이 달라지거나 관리 포인트가 늘어나기 쉽다.
이번에는 같은 원본 payload를 재사용해서 그 문제를 피했다.

앞으로 늘릴 수 있는 것

이제 패턴은 만들어졌다.

나중에는 이런 것도 같은 방식으로 붙일 수 있다.

  • GET /api/recent-events
  • GET /api/photo-feed
  • GET /api/deploy-status
  • GET /api/services

muabow.com은 정적 블로그이면서도,
조금씩 자기 상태를 내놓는 작은 서버로 자라나는 중이다.

정리

이번 작업으로 RESTful API를 거창하지 않게 시작했다.

핵심은 단순하다.

  1. 이미 수집하고 있는 데이터를 재사용한다.
  2. 의미가 드러나는 /api/... 경로로 공개한다.
  3. nginx로 안전하게 프록시한다.
  4. 브라우저와 터미널에서 같은 정보를 읽을 수 있게 한다.

지금은 device-statusvisits 두 개뿐이지만,
앞으로 붙을 실시간 기능들의 공통 입구로는 꽤 괜찮은 출발점이다.

이어서 보기