⚙️ 비버 게임 '팀버본'으로 데이터베이스를 구축하는 상상, 현실이 되다! (Feat. HTTP 레버)
2026. 3. 29.
⚙️ 비버 게임 '팀버본'으로 데이터베이스를 구축하는 상상, 현실이 되다! (Feat. HTTP 레버)
최근 제가 푹 빠져 있는 게임 중 하나인 팀버본(Timberborn)이 드디어 1.0 버전을 출시하며 얼리 액세스를 벗어났습니다.
혹시 이 게임을 모르는 분들을 위해 설명하자면, 팀버본은 도시 건설 전략 게임입니다. 플레이어는 인류(게임 내에서는 '호만즈'라고 불리죠)가 사라진 지구에서 비버 식민지의 지도자 역할을 맡게 됩니다. 자원 채집부터 비버들의 다양한 니즈 충족, 복잡한 수력 발전 네트워크 구축, 공장 건설, 운송 시스템 등 그야말로 없는 게 없는 게임입니다.
...적어도 대부분의 플레이어에게는 그럴 겁니다. 하지만 제게는, 데이터베이스였죠.
이 게임은 놀라운 3D 물리학 엔진을 기반으로 물의 흐름을 사실적으로 구현하고, 가뭄 같은 건기와 오염된 물이 유입되는 '나쁜 해류(bad tide)' 같은 계절 변화까지 담아냈습니다.
2021년 초기 출시 이후 정말 많은 기능이 추가되었는데, 최근 업데이트에서 추가된 ‘자동화 기능’은 제게 게임을 또 다른 차원으로 끌어올렸습니다. 여기에는 나쁜 해류 감지, 수류 감지 등 다양한 센서들이 포함되어 있습니다. 심지어 로직 게이트와 HTTP 레버까지 추가되었더군요.
이건 못 참지!
정말 못 참을 지경이었습니다. 이런 기능을 그냥 지나칠 수는 없죠. 게임 내에서 HTTP 레버는 이렇게 생겼습니다.

보시다시피, 레버마다 이름(이 경우 "HTTP Lever 1"), "Switch-on URL", 그리고 "Switch-off URL"이 있습니다.
원래 이 기능은 스트리머들이 트위치 웹훅 같은 다른 서비스와 연동해서 사용하는 것이 목적입니다. 예를 들어, 시청자가 '좋아요'를 누르면 게임 내에서 불꽃놀이가 터진다거나 하는 식으로요. 하지만... 누가 이걸 딱 하나만 쓰라고 했나요?

제가 무슨 생각을 하는지 짐작이 가시죠?
아... 진짜 못 참지!
네, 맞습니다. 이 게임은 약 1000개의 HTTP 레버를 어느 정도 처리할 수 있습니다. 물론 제가 이 실험을 하는 동안 윈도우가 "시스템에 문제가 발생했습니다"라는 메시지를 두 번이나 띄웠으니, 처음부터 실용적인 시도는 아니었습니다. 뭐, 애초에 실용성을 추구한 건 아니었지만요. 실무에서 이런 식으로 무리한 시도를 하면 윈도우뿐 아니라 서버 전체가 비명을 지르는 경험이 있는데, 팀버본도 예외는 아니었습니다.
각 레버에는 켜는 엔드포인트와 끄는 엔드포인트가 하나씩 있습니다. 아쉽게도 일괄 처리(batch processing) 기능은 없지만, 아마 모드가 나올지도 모르겠습니다. 다른 엔드포인트는 현재 맵에 있는 모든 레버의 상태를 반환하는데, 데이터는 대략 이런 식입니다.
[
{
"name": "HTTP Lever 829",
"state": false,
"springReturn": false
},
{
"name": "HTTP Lever 154",
"state": true,
"springReturn": false
},
{
"name": "HTTP Lever 839",
"state": false,
"springReturn": false
},
{
"name": "HTTP Lever 164",
"state": true,
"springReturn": false
}
]
두 가지 상태를 가진 HTTP 레버는 단일 비트(bit)로 볼 수 있습니다. 필요한 것은 이것들을 읽고 쓰는 방법뿐입니다.
팀버본 웹 인터페이스: 작동 방식
기본 아이디어는 간단합니다. 사용자 입력을 JSON으로 변환하고, 이를 ASCII 인코딩을 통해 이진수로 바꿉니다. 그리고 각 비트를 하나씩 HTTP 레버의 상태(켜짐/꺼짐)에 매핑하여 설정하는 거죠. 데이터를 읽을 때는 모든 레버 상태를 한 번에 가져와 비트 시퀀스로 재배열하고, 8비트 덩어리로 나눈 다음 다시 문자로 디코딩하여 결과 JSON을 파싱합니다. 그러면 짜잔! 읽기/쓰기 가능한 데이터 저장소가 완성되는 겁니다.
먼저 간단한 HTML부터 시작해 봅시다.
<form method="POST" id="form">
<div>
<input type="text" id="title">
</div>
<div>
<textarea id="text"></textarea>
</div>
<button type="submit">Store in Timberborn</button>
</form>
<button type="button" id="load">
Load data from Timberborn
</button>
이제 재미있는 부분입니다. 기본적인 JS 스캐폴딩으로 시작합니다.
const title = document.querySelector('#title')
const text = document.querySelector('#text')
const form = document.querySelector('#form')
const load = document.querySelector('#load')
const chunkSize = 8 // 나중에 비트를 나눌 때 필요합니다.
const numberOfLevers = 1000 // 게임 내 레버 수와 정확히 일치해야 안정적으로 작동합니다.
다음으로, 폼 제출 이벤트를 수신하고 두 필드에서 JSON 문자열을 생성합니다.
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = {
title: title.value,
text: text.value,
}
const json = JSON.stringify(data)
// ...
})
JSON 문자열을 얻었으니, 이제 이를 일련의 이진 문자열로 변환할 수 있습니다.
form.addEventListener('submit', async (event) => {
// ...
const json = JSON.stringify(data)
const asciiEncoded = json.split('')
.map(c => c.charCodeAt(0))
const binary = asciiEncoded.map(
num => num.toString(2).padStart(chunkSize, '0')
)
// ...
})
이렇게 하면 0과 1로 이루어진 8비트 길이의 문자열 배열이 생성됩니다.

다음으로 이 비트들을 하나로 합쳐 거대한 최종 비트 문자열을 만듭니다. 이 비트들을 불리언(boolean)으로 변환한 다음 API URL로 만들고, fetch를 이용해 호출합니다.
form.addEventListener('submit', async (event) => {
// ...
const bits = binary.join('')
// 현재 데이터 끝에 남아있는 찌꺼기 데이터가 없도록
.padEnd(numberOfLevers, '0')
.split('')
.map(b => b === '1')
const allUrls = bits.map((bit, key) =>
`http://localhost:8080/api/switch-${bit ? 'on' : 'off'}/HTTP Lever ${key + 1}`
)
await Promise.all(allUrls.map(url => fetch(url)))
console.log('done!')
})
이렇게 데이터를 저장하면 게임의 엔드포인트로 총 1000개의 HTTP 요청이 트리거됩니다. 윈도우가 좋아하지 않은 이유가 있었죠.
하지만! 작동은 합니다! 비록 비효율적일지언정, 이렇게 기상천외한 방식으로 데이터가 오가는 모습을 보니, 개발자로서 알 수 없는 희열을 느꼈습니다. '될까?' 싶었던 아이디어가 눈앞에서 실제로 작동하는 순간은 언제나 짜릿하죠.

(GIF 이미지가 로드되는 데 몇 초가 걸릴 수 있습니다...)
팀버본에서 데이터 읽기
다음으로 데이터를 읽어야 합니다. 예시에서 보셨듯이 레버 상태는 한 번에 가져올 수 있지만, 순서가 뒤섞여 있습니다. 모든 것을 먼저 로드한 다음 정렬하여 이 문제를 해결할 수 있습니다. 그런 다음 모든 것을 다시 비트 문자열로 변환하고, 청크로 나눈 후 다시 ASCII로 디코딩합니다.
load.addEventListener('click', async () => {
const response = await fetch('http://localhost:8080/api/levers')
const json = await response.json()
const sorted = json.sort((a, b) => {
const aNumber = Number(a.name.replace('HTTP Lever ', ''))
const bNumber = Number(b.name.replace('HTTP Lever ', ''))
return aNumber - bNumber
})
const bitString = sorted.map(l => l.state ? '1' : '0')
const chunks = []
for (let i = 0; i < bitString.length; i += chunkSize) {
chunks.push(bitString.slice(i, i + chunkSize).join(''))
}
const numbers = chunks.map(c => Number.parseInt(c, 2))
// 0으로만 이루어진 데이터는 쓰레기 데이터일 가능성이 높으므로 필터링합니다.
.filter(n => n > 0)
const letters = numbers.map(n => String.fromCharCode(n))
const data = JSON.parse(letters.join(''))
title.value = data.title
text.value = data.text
})
이것으로 끝입니다!
기술적으로 말하면, 이제 팀버본은 클라우드 스토리지로 간주될 수 있습니다. 스팀(Steam)이 세이브 파일을 클라우드에 업로드하고, 팀버본이 HTTP 레버를 상태 저장(stateful)으로 처리(즉, 게임 저장 시 상태를 함께 저장)하기 때문에 모든 데이터는 영구적으로 보존됩니다.
혹시 저보다 더 많은 데이터를 팀버본에 저장하는 데 성공하는 분이 계시다면 꼭 연락 주세요. 이 방법으로 어떻게든 드루팔(Drupal) 데이터베이스 어댑터를 만들 수 있을 것이라고 확신합니다! 이 프로젝트를 통해 우리는 기술이 단순히 주어진 매뉴얼대로만 작동하는 것이 아니라, 창의적인 시각으로 접근할 때 얼마나 무한한 가능성을 가질 수 있는지 다시 한번 깨닫게 됩니다.
(추신: AI 이미지 사용에 대해 미안하지만 미안하지 않습니다. 언젠가는 시도해봐야 할 것 같아서요!)
(추신 2: 이 글은 어떤 후원도 받지 않았습니다. 하지만 팀버본 개발자 여러분, 이 글을 읽으신다면, 저는 자동화 기능을 활용한 훨씬 더 많은 기행(?)을 벌일 준비가 되어 있습니다! :D)
이 글을 쓰는 동안 제가 즐거웠던 만큼 여러분도 즐겁게 읽으셨기를 바랍니다! 마음에 드셨다면 ❤️ 눌러주세요! 저는 자유 시간에 기술 관련 글을 쓰고 가끔 커피를 마시는 것을 좋아합니다.
제 노력을 지지하고 싶으시다면, 커피 한 잔 사주세요 ☕! 페이팔을 통해서도 직접 후원하실 수 있습니다! 또는 블루스카이 🦋에서 저를 팔로우해주세요!
원문: https://dev.to/thormeier/how-to-use-timberborn-yes-the-beaver-city-building-game-as-a-database-489c 수집일: 2026-03-29 05:54:20
