← 뒤로가기

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-status와 visits 두 개뿐이지만,
앞으로 붙을 실시간 기능들의 공통 입구로는 꽤 괜찮은 출발점이다.

이어서 보기

▲