부들잎의 이것저것

전체 글

이 프로젝트는 걱정하는 부모의 마음에서 시작했다. 마지막 이야기.


들어가며

3월 14일, 내 앱은 정말로 "세상" 밖으로 나왔다.

"로블록스 워처" — 이제는 어떤 부모라도 가서 써볼 수 있는 서비스.


마지막 준비물들

출시 전, 당연해 보이지만 쉽게 지나칠 수 있는 것들을 챙겨야 했다.

SEO 최적화

검색 엔진에 내 앱이 떠야 한다.

<!-- 메타 태그 -->
<meta name="description" content="자녀의 로블록스 게임 시간을 실시간으로 모니터링하세요. 시간 제한 알림, 주간 리포트, 카카오 로그인 지원.">
<meta name="keywords" content="로블록스 모니터링, 게임시간 제한, 자녀 관리, 부모 통제">
<meta property="og:title" content="로블록스 워처 - 자녀 게임 시간 관리 서비스">
<meta property="og:image" content="https://roblox.cardbin.net/og-image.png">

sitemap을 생성했다:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://roblox.cardbin.net/</loc>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://roblox.cardbin.net/pricing</loc>
    <priority>0.8</priority>
  </url>
  <url>
    <loc>https://roblox.cardbin.net/features</loc>
    <priority>0.8</priority>
  </url>
</urlset>

Google에 sitemap을 제출했다. 몇 시간 후, Google 검색에 내 페이지가 떠올랐다.

관리자 대시보드

나 자신을 모니터링하기 위한 도구도 필요했다.

  • 가입한 사용자 수
  • 일일 활성 사용자
  • 결제 현황
  • 시스템 에러 로그
  • API 사용량
# lib/roblox_watcher_web/controllers/admin_controller.ex
def dashboard(conn, _params) do
  stats = %{
    total_users: Repo.aggregate(User, :count, :id),
    active_users: get_active_users_today(),
    total_revenue: calculate_total_revenue(),
    api_calls_today: Cache.get("api_calls_today", 0),
    error_rate: calculate_error_rate()
  }

  render(conn, :dashboard, stats: stats)
end

나는 매일 아침 이 대시보드를 체크했다. "오늘 몇 명이 가입했나?"


보안 강화

이제 "생면부지의" 사용자들이 접속한다. 보안을 신경 써야 한다.

API에서 민감한 정보 제거

# ❌ 나쁜 예: 관리자 플래그 노출
def render_user(user) do
  %{
    id: user.id,
    name: user.name,
    is_admin: user.is_admin,  # 이걸 API에서 노출하면 안 됨
    email: user.email
  }
end

# ✅ 좋은 예: 민감한 정보 제거
def render_user(user, _opts) do
  %{
    id: user.id,
    name: user.name,
    # is_admin은 포함하지 않음
    # email도 필요한 경우만 포함
  }
end

누군가가 API를 분석해서 "is_admin: true"를 자신의 계정에 추가할 수도 있다. 그래서 API 응답에서 완전히 빼버렸다.

레이트 리미팅

API를 제한 없이 노출하면, 누군가가 "brute force" 공격을 할 수 있다. 무차별로 요청을 보내서 시스템을 다운시키거나, 타인의 계정을 해킹하려고.

그래서 ETS를 사용해서 IP별 요청을 제한했다:

# lib/roblox_watcher/rate_limiter.ex
defmodule RobloxWatcher.RateLimiter do
  def check(ip, max_requests \\ 100) do
    key = "rate_limit:#{ip}"
    count = ETS.get(key, 0)

    if count >= max_requests do
      {:error, "Too many requests"}
    else
      ETS.put(key, count + 1)
      {:ok}
    end
  end
end

이제 같은 IP에서 분당 100번 이상 요청하면 차단된다.


에러 처리와 로깅

실제 운영 환경에선 언제나 에러가 생긴다.

# lib/roblox_watcher_web/plugs/error_handler.ex
def handle_errors(conn, %{kind: _kind, reason: reason, stack: _stack}) do
  # 에러를 로깅
  Logger.error("Request error: #{inspect(reason)}")

  # Telegram 알림 (심각한 에러만)
  if critical_error?(reason) do
    Notifications.alert_admin("Critical error: #{reason}")
  end

  # 사용자에게 친화적인 메시지 표시
  conn
  |> put_status(500)
  |> json(%{"error" => "서비스에 일시적 문제가 발생했습니다. 잠시 후 다시 시도해주세요."})
end

에러가 발생해도, 사용자는 "문제가 생겼어요" 정도만 본다. 기술적 세부사항은 관리자(나)에게만 보인다.


실제 사용자의 첫 호응

처음 20명의 사용자가 가입했다.

일주일 뒤, 한 아빠가 후기를 남겼다:

"아이가 게임하는 모습을 주시만 하는 게 아니라, '넌 이 게임을 이 정도 시간 했어'라고 객관적인 데이터로 대화할 수 있게 됐어요. 싸움이 줄고 대화가 늘었습니다."

내 앱이 "강압의 도구"가 아니라 "이해의 다리"가 되었다.


운영 중 배운 것들

1. 완벽함은 적의 완성도다

내가 미루고 미룬 게 뭐냐고? "완벽한 출시".

한국 사업가들은 "완벽하게 준비되면 출시한다"고 말한다. 근데 완벽함은 영원히 안 온다.

내가 3월 14일에 출시하지 않고 4월까지 기다렸다면, "더 좋은 기능" 때문에 또 미뤘을 것이다. 그리고 6월이 됐을 것이다.

결국 "70% 수준에서 출시, 피드백받으면서 30% 추가"가 정답이었다.

2. 코드가 존재하는 것 ≠ 작동하는 것

episode 4에서 배운 교훈이 여기서도 떠올랐다. 나는 구독 기능을 구현했지만, 실제로는 어디서도 호출하지 않았다.

출시 후 일주일째, 한 사용자가 불평했다: "구독을 취소했는데 왜 여전히 결제되나요?"

원인? "구독 취소 버튼"을 만들지 않았다. 기능은 백엔드에 있었지만, 프론트엔드에서 호출할 방법이 없었다.

급하게 버튼을 추가했다.

교훈: 기능의 "전체 경로"를 확인하라. 백엔드만 되고, 프론트엔드 버튼이 없으면 다니다.

3. 처음부터 다시 쓰는 게 낫기도 하다

Rust에서 Elixir로 갈아탄 결정이 맞았다. Rust로 짠 코드는 이제 한 줄도 남지 않았지만, 그 결정이 없었다면 지금 이 빠른 백엔드는 없었을 것이다.

"레거시 코드"에 집착하지 않는 것도 중요하다.

4. 성능은 "더하기"가 아니라 "빼기"다

초기엔 "더 강력한 서버를 사자"고 생각했다. 그런데 결국은 "불필요한 API 호출을 줄이고", "불필요한 JS 파일을 줄이고", "불필요한 쿼리를 줄임"으로 해결했다.

빠른 앱 = 강력한 앱이 아니라 = 불필요한 일을 하지 않는 앱.

5. 기술보다 사용자를 먼저 보다

내가 가장 자랑스럽게 여겼던 부분:

  1. GenServer를 사용한 병렬 모니터링
  2. ETS 캐싱
  3. SSE 스트리밍

이것들 모두 기술적으로 멋진 것들이다.

하지만 사용자가 가장 좋아한 건: "게임 중" 상태에 초록 테두리 표시.

기술 수준과 사용자 만족도는 상관없다.


미래

이제 여기서 멈추진 않는다.

사용자들이 요청한 기능들:

  • 더 많은 게임 추천: "우리 애가 이 게임에 중독되는 이유가 뭘까?"
  • AI 분석: "패턴이 이상해졌어요"
  • 다른 플랫폼 지원: Roblox 외에도 Steam, Nintendo Switch...
  • 학교와의 연계: 낮시간에는 학생용 모드로 자동 제한

이 모든 기능이 가능하다. 기술은 이미 충분히 갖춰져 있다.


마지막으로

처음 이 프로젝트를 시작할 때, 나는 "내 아이의 게임 시간이 너무 많다"고 걱정했다.

그런데 끝나가는 지금, 깨달았다. "이건 내 문제만이 아니구나."

다른 부모들도 같은 고민을 한다. 같은 답답함을 느낀다. 그리고 같은 "대화하고 싶은" 마음을 가지고 있다.

내 앱이 그 대화의 다리가 될 수 있다면, 이 6개월간의 고생은 의미가 있다.


마지막 독백

이 10화 여정을 정리하면:

  1. 완벽함은 출시를 미룬다 - 70%에서 시작하라
  2. 기능 구현 ≠ 기능 완성 - 전체 경로를 확인하라
  3. 재작성도 때론 정답 - 레거시에 집착하지 마라
  4. 성능은 빼기다 - 더 강력한 기술이 아니라 더 적은 일을 하는 것이다
  5. 기술은 수단 - 사용자의 마음을 먼저 이해하면 기술은 자연스럽게 따라온다

그리고 가장 중요한 것:

이 프로젝트는 걱정하는 부모의 마음에서 시작했다. 성공은 "몇 명이 썼나"가 아니라 "누군가의 마음이 조금이라도 편해졌는가"로 측정된다.


에필로그: "로블록스 워처"를 시작한 것도, 책임감 때문이다. "내 아이의 시간을 이해하고 싶다"는 책임감. 그리고 그 책임감이 다른 부모들에게도 전해지길 바란다. 대화 없는 통제는 갈등만 만든다. 하지만 이해 위의 대화는 미래를 만든다. —개발자일 때도, 부모일 때도.

[끝]


미주: 로블록스 워처는 계속된다

이 10화가 끝이지만, 로블록스 워처는 계속 진화한다.

  • 다음 목표: AI 기반 게임 패턴 분석
  • 그 다음: 다중 플랫폼 지원
  • 그 다음: 가족 AI 상담 시스템

만약 당신도 자녀의 게임 시간을 걱정하는 부모라면, roblox.cardbin.net에 들어와서 시작해보세요.

무료로.

당신의 자녀를 감시하는 게 아니라, 이해하는 것이 목표입니다.


글을 읽어주셔서 감사합니다. 혹시 Elixir, SvelteKit, 또는 스타트업 개발에 관심이 있다면, 아래 댓글로 얘기 나눌 수 있습니다.

반응형

기술은 작동했다. 하지만 부모님들이 이해하지 못하면 소용없었다.


들어가며

테스트 유저 중 한 명, 50대 아빠가 피드백을 남겼다:

"화면에 뜬 영어 단어들이 뭔 뜻인지 모르겠어. 내가 IT 사람도 아니고..."

그 순간 깨달았다. "아, 내가 개발자를 위해 만든 건 아니구나."

내 고객은 부모다. 부모가 이해할 수 있어야 한다.


영어의 벽

내 대시보드는 처음에 이런 용어를 쓰고 있었다:

  • InGame — 게임 중
  • Online — 온라인
  • Offline — 오프라인
  • 150 minutes — 150분
  • Session — 세션
  • Status — 상태

기술자에겐 "당연한" 단어들이 부모에겐 "외계어"였다.

그래서 완전히 바꿨다:

const STATE_DISPLAY = {
  "InGame": "🎮 게임 중",
  "Online": "💻 온라인",
  "Offline": "🌙 오프라인",
  "InStudio": "🛠️ 스튜디오 (개발 중)"
}

"150분"을 "2시간 30분"으로 표시했다:

function formatPlayTime(minutes) {
  const hours = Math.floor(minutes / 60)
  const mins = minutes % 60

  if (hours > 0 && mins > 0) {
    return `${hours}시간 ${mins}분`
  } else if (hours > 0) {
    return `${hours}시간`
  } else {
    return `${mins}분`
  }
}

// 150 분 → "2시간 30분"
// 45 분 → "45분"
// 3 시간 → "3시간"

PascalCase의 저주

내 Elixir 백엔드에서는 상태를 "atom"으로 관리했다:

:in_game, :online, :offline, :in_studio

하지만 JSON API 응답에선 PascalCase로 변환했다:

{
  "status": "InGame",
  "playtime": 150
}

그리고 SSE(실시간 이벤트)에서는 snake_case를 썼다:

{
  "to_state": "in_game",
  "timestamp": 1708000000
}

혼란스럽다. 프론트엔드에서 이들을 모두 처리해야 했다.

그래서 "StateMachine" 모듈을 만들어서 모든 변환을 중앙화했다:

# lib/roblox_watcher/state_machine.ex
defmodule RobloxWatcher.StateMachine do
  @state_to_korean %{
    :in_game => "게임 중",
    :online => "온라인",
    :offline => "오프라인",
    :in_studio => "스튜디오 (개발 중)"
  }

  def state_to_korean(state) when is_atom(state) do
    Map.get(@state_to_korean, state, "알 수 없음")
  end

  def state_to_pascal(state) when is_atom(state) do
    state |> to_string() |> Macro.camelize()
  end

  def state_to_snake(state) when is_atom(state) do
    state |> to_string()
  end
end

이제 프론트엔드에서는:

// Roblox API 응답에서 받은 상태 → 한글로 표시
const state = response.status // "InGame"
const korean = state_map[state] // "게임 중"

카카오 소셜 로그인

한국 부모들은 "KakaoTalk"으로 모든 것을 한다.

결제, 메시지, 뱅킹... 모두 KakaoTalk 안의 미니앱으로 해결한다.

그래서 당연스럽게 Kakao OAuth 로그인을 요청받았다.

# lib/roblox_watcher/auth/kakao.ex
defmodule RobloxWatcher.Auth.Kakao do
  def get_authorization_url do
    "https://kauth.kakao.com/oauth/authorize?" <>
    "client_id=#{System.get_env("KAKAO_CLIENT_ID")}&" <>
    "redirect_uri=#{System.get_env("KAKAO_REDIRECT_URI")}&" <>
    "response_type=code"
  end

  def get_access_token(code) do
    Req.post!("https://kauth.kakao.com/oauth/token", [
      {"grant_type", "authorization_code"},
      {"client_id", System.get_env("KAKAO_CLIENT_ID")},
      {"client_secret", System.get_env("KAKAO_CLIENT_SECRET")},
      {"code", code},
      {"redirect_uri", System.get_env("KAKAO_REDIRECT_URI")}
    ]).body
  end

  def get_user_info(access_token) do
    Req.get!("https://kapi.kakao.com/v2/user/me", [
      headers: [{"Authorization", "Bearer #{access_token}"}]
    ]).body
  end
end

구현하고 보니, 이제 사용자들이 "비밀번호를 기억할 필요"가 없었다. KakaoTalk 앱이 이미 한국 부모라면 누구나 깔려있다.


시각적 개선: 초록 테두리

작은 디자인 변화였지만 효과는 컸다.

계정이 "게임 중" 또는 "온라인"이면, 카드의 테두리를 초록색으로 표시했다:

<!-- Dashboard.svelte -->
<div class={`card ${account.status === 'InGame' || account.status === 'Online' ? 'border-green-500 border-2' : ''}`}>
  <!-- 계정 정보 -->
</div>

부모들이 한눈에 "아이가 지금 뭐 하는지" 알 수 있게 했다.

빨강 테두리 (없음) → 온라인 상태 아님
초록 테두리 → 온라인 또는 게임 중

아바타 이미지 추가

또 다른 개선: 아바타 이미지.

부모들은 자녀가 게임에서 만든 "캐릭터 모습"을 알고 싶어했다. 숫자 ID(12345)가 뭐냐는 것.

Roblox API에서 계정 정보를 가져올 때, 아바타 썸네일 URL도 함께 가져왔다:

def get_account_info(roblox_user_id) do
  {:ok, user} = RobloxAPI.get_user(roblox_user_id)

  %Account{
    roblox_id: user["id"],
    name: user["name"],
    avatar_url: "https://www.roblox.com/bust-thumbnails/#{user["id"]}/100x100.png"
  }
end

프론트엔드에서 표시:

<img src={account.avatar_url} alt={account.name} class="w-16 h-16 rounded-full" />
<span>{account.name}</span>

이제 부모가 "이애가 OO 캐릭터다" 라고 한눈에 알 수 있었다.


주간 리포트 차트 추가

가장 큰 UX 개선은 주간 리포트였다.

<!-- 주간 플레이 시간 -->
<WeeklyBarChart data={weeklyPlaytime} />

<!-- 게임별 플레이 시간 분포 -->
<GameBreakdownPieChart data={gameBreakdown} />

<!-- 시간대별 활동 패턴 -->
<HourlyActivityChart data={hourlyActivity} />

데이터를 시각화하니까, 부모들이 한눈에 패턴을 이해했다:

  • "우리 애는 주말에 더 많이 게임한다"
  • "주로 저녁 7~9시에 활동한다"
  • "로블록스 빌딩 게임을 제일 많이 한다"

숫자보다는 차트가 부모들에게 훨씬 더 먹혔다.


자연어 시간 표시

"3시간 전"이라는 표시도 추가했다. 부모들은 정확한 타임스탐프(2026-03-07 14:23:15)를 읽기 싫어한다.

function formatRelativeTime(timestamp) {
  const now = new Date()
  const then = new Date(timestamp)
  const diff = Math.floor((now - then) / 1000) // 초 단위

  if (diff < 60) return `${diff}초 전`
  if (diff < 3600) return `${Math.floor(diff / 60)}분 전`
  if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`

  // 어제 이전엔 날짜 표시
  return then.toLocaleDateString('ko-KR')
}

// 사용 예
// 3 분 전 → "3분 전"
// 5 시간 전 → "5시간 전"
// 2025-03-01 → "3월 1일"

감정 표현 완화

용어를 완화했다:

변경 전 변경 후
"logout" "로그아웃 (자동)"
"game_pause" "게임 일시 중지"
"banned" "계정 이용 중지"

부모들도 "우리 애가 블랙리스트에 올랐다" 같은 공식적인 느낌의 단어를 싫어했다.


실제 부모 피드백

한 엄마가 피드백을 남겼다:

"이제 우리 아이에게 '오늘 30분 더 했네? 어제가 2시간이었는데?' 이렇게 대화할 수 있어요. 싸우기만 했는데, 이제는 대화가 되네요."

그 말이 기술적 최적화보다 더 큰 성과였다.

내 앱이 "감시 도구"가 아니라 "대화의 시작점"이 되었다.


다음 화의 예고

이제 기술도 작동하고, UX도 부모 친화적이다. 마지막 할 일은?

세상에 내놓는 것.

도메인 설정, SEO, 관리자 대시보드, 보안 강화... 모두 준비했다.

3월 14일, 나는 처음으로 다른 부모들에게 "로블록스 워처"를 소개했다.

다음 화: "드디어 세상에 내놓다" (3월 14일)


마지막 독백: 부모들은 개발자가 아니다. 그들은 "내 아이의 게임 시간을 이해하고 싶다"는 마음으로 온다. 그 마음에 맞춰 설계하면, 기술은 자연스럽게 따라온다.

반응형

"대시보드 로드가 3초나 걸려요." 이 한마디가 모든 걸 바꿨다.


들어가며

테스트 유저 한 명이 메시지를 보냈다:

"대시보드 로드가 3초나 걸려요. 왜 이렇게 느려요?"

내 노트북에선 1초였는데?

그때 깨달았다. "성능도 버그다."


백엔드: API 호출 지옥

내 앱의 동작 방식을 정리해보니, 문제가 명확했다.

현재 구조:

  • 유저 A가 대시보드에 접속
  • 대시보드는 유저 A가 모니터링하는 자녀들의 상태를 요청
  • 백엔드는 각 자녀마다 Roblox API를 호출 (동기적)
  • 자녀 3명 = 3번의 Roblox API 호출
  • 각 호출이 500ms 걸리면 = 총 1,500ms 대기
# ❌ 나쁜 패턴: 순차적 API 호출
def get_users_presence(user_id) do
  user = get_user(user_id)

  # 각 감시 대상에 대해 개별 호출
  Enum.map(user.monitored_accounts, fn account ->
    RobloxAPI.get_presence(account.roblox_id)  # 각각 500ms
  end)
  # 3개면 1,500ms 소요
end

그런데 문제는 더 있었다.

모니터링 시스템의 비효율:

내 모니터링 GenServer는 매 30초마다 각 유저의 "각 자녀"에 대해 Roblox API를 호출했다.

# 현재 구조 (비효율적)
- 유저 1, 자녀 3명 → 3번 호출
- 유저 2, 자녀 2명 → 2번 호출
- 유저 3, 자녀 3명 → 3번 호출
---
총 30초마다 8번의 Roblox API 호출

만약 100명이 서비스를 쓴다면? 초당 800 API 호출? 이건 Roblox의 rate limit에 걸린다.


배치 폴링(Batch Polling)의 혁명

그때 떠올렸다. Roblox API는 사실 "배치 조회"를 지원한다.

GET /users/batch-presence
?userIds=123,456,789,101112

# 응답:
{
  "userPresences": [
    {"userId": 123, "userPresenceType": "InGame"},
    {"userId": 456, "userPresenceType": "Online"},
    {"userId": 789, "userPresenceType": "Offline"}
  ]
}

아! 한 번에 여러 유저를 조회할 수 있다. 왜 이걸 못 봤을까?

그래서 모니터링 로직을 완전히 뜯어고쳤다:

# ✅ 개선된 패턴: 배치 폴링
def poll_all_accounts do
  # 1단계: 모든 모니터링 계정 ID를 모은다
  all_account_ids = get_all_monitored_account_ids()
  # [123, 456, 789, ... 1000개]

  # 2단계: 배치로 나눈다 (API 제한: 한 번에 100개)
  batches = Enum.chunk_every(all_account_ids, 100)

  # 3단계: 각 배치에 대해 한 번씩 API 호출
  Enum.reduce(batches, %{}, fn batch, acc ->
    {:ok, presences} = RobloxAPI.batch_presence(batch)  # 1번의 호출
    Map.merge(acc, presences)
  end)
end

결과?

API 호출이 95% 줄었다.

  • 이전: 30초마다 8번 호출 (1000명 사용 시 800번)
  • 이후: 30초마다 1번 호출 (배치 처리, 같은 1000명)

프론트엔드: 72개의 JavaScript 파일

백엔드를 최적화했는데, 이번엔 프론트엔드에서 유저가 불평했다.

"그 다음 페이지 로드가 또 느려요."

프론트엔드 성능 분석을 했다. Chrome DevTools를 켜니, 로드되는 JavaScript 파일이 장난이 아니었다.

72개의 청크(chunk).

Vite(번들러)가 자동으로 코드를 쪼개서 만든 파일들이었다. 각 파일마다 HTTP 요청이 필요하니까, 오버헤드가 컸다.

chunk-1.js (5KB)
chunk-2.js (3KB)
chunk-3.js (7KB)
...
chunk-72.js (2KB)

이걸 줄이려면 "청크 최적화"를 해야 했다.

Vite 설정을 수정했다:

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['svelte', 'lucide-svelte'],
          'charts': ['recharts', 'd3'],
          'ui': ['shadcn-svelte']
        }
      }
    }
  }
}

명시적으로 "이 라이브러리는 이 청크에 들어가"라고 지정했다.

결과: 72개 → 30개

로드 시간도 줄었다.


아이콘 라이브러리의 함정

그런데 또 다른 문제가 있었다.

아이콘 라이브러리 lucide-svelte를 쓰고 있었는데, 매 파일마다 전체 라이브러리가 import되고 있었다.

<!-- Dashboard.svelte -->
<script>
  import { Home, Settings, Bell } from 'lucide-svelte'
  // ❌ 실제론 lucide-svelte 전체 (200KB)가 메모리에 올라옴
</script>

이걸 "barrel import"라고 부른다. 한 파일에서 모든 exports를 가져오니까, 번들러가 전체를 포함시킨다.

해결책은 트리 셰이킹(tree shaking) — 사용하지 않는 코드를 제거하는 기법.

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        format: 'es'
      }
    }
  }
}

그리고 라이브러리 문서를 다시 읽었다. lucide-svelte는 이미 tree-shaking을 지원했다. 내 설정이 문제였다.


Google Fonts의 병목

또 다른 성능 병목: Google Fonts.

내 앱은 "Noto Sans KR" 폰트를 썼는데, 이 폰트 파일이 엄청 컸다. 그리고 동기적으로 로드되니까, 폰트를 다운로드할 때까지 텍스트가 안 보였다.

<!-- ❌ 동기적 로드: 폰트 다운로드 완료까지 텍스트 미표시 -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR" rel="stylesheet">

그래서 비동기 로드로 변경했다:

<!-- ✅ 비동기 로드: 폰트가 없어도 텍스트가 먼저 보임 -->
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR" onload="this.onload=null;this.rel='stylesheet'">

그리고 폰트 가중치를 줄였다. Regular(400)만 써도 충분했는데, Bold(700), Light(300)까지 로드했다.

// ✅ 필요한 가중치만 로드
import '@fontsource/noto-sans-kr/400.css'
import '@fontsource/noto-sans-kr/700.css'

백엔드: N+1 쿼리 문제

SSE(Server-Sent Events) 연결을 통해 실시간으로 계정 상태를 전송할 때, 데이터베이스에서 정보를 읽어야 한다.

# ❌ 나쁜 패턴: N+1 쿼리
def get_monitored_accounts(user_id) do
  user = Repo.get(User, user_id)

  # N번의 쿼리
  Enum.map(user.monitored_accounts, fn account ->
    Repo.get(Account, account.id)  # 각각 1개 쿼리
  end)
end

1명의 유저가 3명의 자녀를 모니터링하면:

  • 쿼리 1: 유저 정보 조회
  • 쿼리 2~4: 각 계정 정보 조회
  • 총 4개 쿼리

이를 1개로 줄였다:

# ✅ 좋은 패턴: 한 번의 쿼리
def get_monitored_accounts(user_id) do
  Repo.all(
    from a in Account,
    join: u in assoc(a, :user),
    where: u.id == ^user_id,
    preload: [:user]
  )
end

JOIN을 사용해서 한 번의 쿼리로 모든 정보를 가져온다.


Gzip 압축

아, 그리고 간단한 것도 있다. HTTP 응답을 압축하는 것.

# config/prod.exs
config :plug_cowboy, :gzip, true

텍스트 응답은 보통 60~80% 줄어든다. JSON도 마찬가지.

POST /api/accounts/presence
- 압축 전: 15KB
- 압축 후: 3KB

한 줄의 설정으로 80% 줄일 수 있다.


공개 페이지는 인증 스킵

이건 보안과의 트레이드오프였다.

랜딩 페이지, 가격 페이지, 로그인 페이지는 public인데, 모두 JWT 검증을 하고 있었다.

# ❌ 모든 요청에 인증 체크
pipeline :api do
  plug :authenticate_token  # 모든 엔드포인트에 적용
end

이걸 라우트별로 나눴다:

# ✅ 필요한 곳에만 인증 체크
scope "/api" do
  pipe_through [:api]  # 인증 없음
  get "/pricing", PricingController, :show
  post "/auth/login", AuthController, :login
end

scope "/api/dashboard" do
  pipe_through [:api, :authenticate_token]  # 인증 필요
  get "/accounts", AccountController, :list
end

JTW 검증 자체는 빠르지만, 불필요한 검증을 줄이면 더 빠르다.


성능 수치

최적화 전과 후:

메트릭 개선
API 응답 시간 1.5초 200ms 87% ↓
JS 번들 크기 450KB 180KB 60% ↓
첫 로드 시간 3초 800ms 73% ↓
API 호출 수 (30초 주기) 8회 1회 87.5% ↓

이제 대시보드가 정말 빨랐다.


다음 화의 예고

성능도 좋아졌다. 근데 이제 다른 문제가 생겼다.

부모들이 피드백을 주기 시작했다:

"왜 '게임 중'이라고 표시되는데, 아이는 게임을 끝냈대?"
"'150분 플레이'가 뭔 말이에요? '2시간 30분'으로 표시해주세요."
"이 영어 단어들, 다 한국말로 바꿔주실 수 없나요?"

기술은 작동했지만, 부모들이 이해하지 못했다.

다음 화에서는 UX(사용자 경험)를 완전히 뜯어고친다. 단어를 바꾸고, 디자인을 부드럽게 하고, 차트를 추가했다.

다음 화: "부모님도 쓸 수 있게" (3월 7일)


마지막 독백: 빠른 앱도 이해하지 못하면 쓰지 않는다. 속도와 이해도, 둘 다 중요하다.

반응형

결제 시스템을 세 번 갈아엎었다. 현실은 드라마보다 복잡하다.


들어가며

앱이 잘 돌고 있다. 근데 갑자기 현실이 스며들었다.

서버비가 든다.

DigitalOcean 클라우드 서버: 월 6달러
PostgreSQL 관리형 데이터베이스: 월 15달러
Redis: 월 7달러...

총 한 달에 30달러쯤 나간다. 미미한 금액이지만, 내가 낸다면? 손해다. 누군가는 이 비용을 내야 한다.

그때 깨달았다. "아, 내가 사업을 하는 구나."


세 가지 요금제 설계

고민했다. 어떻게 가격을 책정할까?

한국 부모들의 대표적인 지출: 자녀 교육비, 휴대폰 요금, 각종 구독 서비스... 마지막 카테고리에 내 앱도 들어가야 했다.

결정했다:

  • 무료(Free): 1명의 자녀 모니터링 (광고 없음, 기본 기능만)
  • 베이직(Basic): 2,900원/월 - 자녀 2명 모니터링
  • 프리미엄(Premium/Family): 4,900원/월 - 자녀 3명, 주간 리포트, 우선 지원

2,900원... 아메리카노 한 잔 정도다. "이 정도면 부모들이 낼 수 있겠지?"

그런데 문제가 하나 있었다.

나는 결제를 해본 적이 없었다.


첫 번째 시도: "직접 만들어보자"

"내가 KCP, NICE 결제 게이트웨이랑 직접 연동하면 되지 않을까?"

한국 결제 시스템은 복잡하다. KCP, NICE, 토스페이먼츠... 각각 다른 API, 다른 인증 방식, 다른 에러 처리. 결제 후 웹훅(callback)을 받아서 구독을 활성화하는 로직도 짜야 한다.

2주를 허비했다. 문서를 읽고, 코드를 짜고, 테스트하고, 또 읽고...

결국 포기했다. "이건 너무 복잡하다."


두 번째 시도: TossPayments

누군가 추천해줬다. "토스페이먼츠 쓰면 간단해."

정말 간단했다. SDK를 import하고, 버튼 하나 누르면 결제창이 떴다. 웹훅도 자동으로 처리해줬다.

"오, 이게 맞는 방법이구나!"

그렇게 1주일을 TossPayments로 개발했다.

# TossPayments 연동 (작동했다)
def create_payment(user_id, plan_id) do
  # 토스 결제 API 호출
  {:ok, payment} = call_toss_api(user_id, plan_id)
  {:ok, payment}
end

근데... 3개월 후였다. TossPayments가 API 구조를 바꿨다. 내 통합이 깨졌다. 그들은 알림도 없이 변경했고, 지원팀은 "알아서 고쳐" 라는 식이었다.

또 다른 문제: 수수료. 거래액의 3~5%를 가져간다. 2,900원 결제를 받으면, 실제로는 2,700원쯤만 손에 남는다.

"이것도 아니다."


세 번째 시도: PortOne V2

업계에서 가장 유명한 결제 중개업체인 PortOne(포트원)으로 넘어가기로 결정했다.

# PortOne V2 연동
def create_payment(user_id, plan_id) do
  payment_id = generate_payment_id()

  # PortOne API 호출
  {:ok, response} = PortOne.create_payment(%{
    "paymentId" => payment_id,
    "amount" => plan_price(plan_id),
    "orderName" => "로블록스 워처 #{plan_id}",
    ...
  })

  {:ok, response}
end

PortOne도 처음엔 좋았다. 문서가 잘 정리되어 있고, SDK도 깔끔했다.

근데 벽에 부딪혔다.

"주문번호는 40자 이상이면 안 됩니다."

내가 생성한 주문번호가 너무 길었다. UUID를 썼으니까. 550e8400-e29b-41d4-a716-446655440000 — 이건 36자인데, 추가 정보를 붙이면 40자를 초과한다.

한참을 고민했다. 왜 이런 제한이 있을까? KCP의 내부 DB 컬럼이 40자로 제한되어 있나보다. 2000년대 설계의 유산이다.

그래서 주문번호를 바꿨다:

# UUID 대신 타임스탬프 기반 ID
def generate_payment_id do
  timestamp = System.system_time(:millisecond)
  user_id = current_user().id
  "#{user_id}-#{timestamp}"  # 예: "12345-1708036800000" (훨씬 짧음)
end

여전히 불안정했다. 타임스탬프가 중복될 수 있으니까. 결국 user_id의 "숫자 부분만" 사용했다.


결제 버그의 끝없는 지옥

결제를 연동했다고 해서 끝이 아니었다.

버그 1: "결제했는데 구독 안 됨"

사용자가 3,000원을 내고도 구독이 활성화되지 않았다. 왜?

로그를 봤다. PortOne 웹훅(결제 완료 알림)을 받지 못했다. 네트워크 에러? 타임아웃?

결국 수동으로 구독을 활성화해줘야 했다. 사용자는 "이 앱 왜 이래?" 라고 생각했을 것이다.

버그 2: "같은 주문으로 2번 결제됨"

사용자가 실수로 버튼을 두 번 눌렀나? 아니면 내 코드가 버그였나?

# ❌ 잘못된 패턴
def handle_payment_click(user_id, plan_id) do
  create_payment(user_id, plan_id)  # 첫 번째
  create_payment(user_id, plan_id)  # 실수로 두 번?
end

사용자는 버튼을 한 번만 눌렀는데, 네트워크 지연 때문에 프론트엔드가 두 번 요청을 보냈을 수도 있다.

그래서 idempotency key를 도입했다. 같은 키로 요청하면, 같은 결과를 반환하는 방식.

버그 3: "환불은 어떻게 하는데요?"

누군가가 요청했다. "실수로 결제했어요, 환불 가능한가요?"

당황했다. 나는 환불 로직을 짜지 않았다!

급하게 PortOne 대시보드에서 수동으로 환불 처리했다. 그리고 환불 정책을 이용약관에 추가했다:

제7조 환불 정책
1. 결제 후 7일 이내 환불 신청 가능
2. 환불 수수료 없음
3. 환불 신청은 support@roblox.cardbin.net 으로 메일

이용약관의 무게

결제를 받기 시작하니, 법적 책임도 생겼다.

이용약관: "이 서비스를 어떻게 써야 하는가"
개인정보처리방침: "사용자 정보를 어떻게 관리하는가"
환불 정책: "돈을 어떻게 돌려주는가"

이걸 안 써도 서비스는 돌아간다. 하지만 나중에 문제가 생기면? 내가 질 수 있다.

변호사를 쓸 형편은 아니니, 구글 템플릿을 참고해서 직접 썼다.

제1조 서비스 범위
로블록스 워처는 사용자의 자녀 Roblox 게임 활동을 모니터링하는 서비스입니다.

제2조 요금제
- 무료: 1명 모니터링
- 베이직: 2,900원/월, 2명 모니터링
- 프리미엄: 4,900원/월, 3명 모니터링

...

제7조 환불 정책
결제 후 7일 이내 환불 가능합니다.

이런 문서를 쓰면서, "아, 내가 진짜 사업을 하는 거구나" 를 느꼈다.


구독 라이프사이클의 복잡성

사용자가 업그레이드할 수도, 다운그레이드할 수도, 취소할 수도 있다.

  • 업그레이드: 베이직(2,900원) → 프리미엄(4,900원) - 차액 결제
  • 다운그레이드: 프리미엄 → 베이직 - 환불 처리
  • 취소: 구독 종료 - 월말 또는 즉시?

각 경우마다 다른 로직이 필요했다.

def upgrade_subscription(user_id, new_plan_id) do
  user = get_user(user_id)
  current_price = plan_price(user.plan_id)
  new_price = plan_price(new_plan_id)
  difference = new_price - current_price

  if difference > 0 do
    # 차액 결제 필요
    create_payment(user_id, difference)
  else
    # 환불 필요
    refund(user_id, abs(difference))
  end

  update_subscription(user_id, new_plan_id)
end

이런 로직이 제대로 작동하지 않으면, 사용자가 돈을 잃을 수 있다. 그래서 여러 번 테스트했다.


다음 화의 예고

이제 결제도 작동한다. 구독도 관리한다. 근데 한 가지 문제가 더 있었다.

사용자들이 불평하기 시작했다:

"대시보드가 너무 느려요."
"여러 계정을 감시하면 더 느리네요."

내 백엔드가 매 30초마다, 모니터링 대상 "각각"에 대해 Roblox API를 호출하고 있었다. 10명의 사용자가 자녀 10명씩 모니터링하면? 100번의 API 호출. 이건 너무 비효율적이었다.

그래서 "배치 폴링(Batch Polling)"이라는 최적화 기법을 도입했다. 한 번에 100명을 조회하는 대신, 한 번에 모두 조회한다. API 호출이 95% 줄었다.

다음 화: "서버가 느려요" (2월 28일)


마지막 독백: 결제는 단순한 기술 문제가 아니다. 법적 책임, 사용자 신뢰, 돈의 흐름이 얽혀 있다. 그래서 더 신중해야 한다.

반응형

내 노트북에서 돌아가는 것과, 진짜 서버에서 돌아가는 것은 완전히 다른 이야기였다.


들어가며

로컬에서 완벽하게 돌아가던 앱을 처음 실제 서버에 올렸을 때, 나는 정말 "이게 되나?" 하는 생각으로 한참 화면을 바라봤다. 내 노트북에서는 1초 만에 대시보드가 로드되던데, 진짜 사람들이 접속하는 서버에서도 그럴까?

Docker. 이 단어는 내게 처음엔 마치 외계어처럼 들렸다. 그런데 배포를 하려면 피할 수 없었다.


"배송 박스에 넣는다고?"

Docker의 개념을 가장 잘 설명해준 건, 결국 "배송 박스"라는 비유였다.

당신이 만든 물건(코드)을 사람에게 보낼 때, 박스에 넣으면 어디에 보내든 똑같이 동작한다. 내 노트북에서 "작동한다"는 건 내 노트북에만 필요한 라이브러리나 설정이 모두 갖춰져 있기 때문이다. 근데 그걸 다른 서버에 옮기면? 엉망이 된다. Python 버전이 다르다, 라이브러리가 없다, 포트가 충돌한다...

Docker는 "모든 게 들어있는 박스"를 만들어준다. 내 앱이 필요한 모든 것을 담아서, 어디 서버에 옮겨도 같은 상태로 돌아간다.

처음 만들었던 Dockerfile을 기억한다:

FROM elixir:1.19.5-alpine AS build
RUN apk add --no-cache git build-base
WORKDIR /app
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix local.hex --force && mix local.rebar --force && mix deps.get --only prod
...

보이나? 이게 바로 "레시피"다. "이 베이스 이미지에서 시작해서, 이 패키지 깔고, 이 코드 복사해서, 이거 실행해라" 라는 명령의 연쇄.


447MB의 악몽

첫 Docker 이미지를 빌드했을 때, 크기를 확인하고 한참을 멍했다.

447MB.

내 앱의 전체 소스코드는 고작 몇 MB인데, 컨테이너 이미지는 447MB?

"왜 이렇게 커?"

음... 생각해보니 이미지에는 Elixir 런타임, Erlang VM, 컴파일 도구, 라이브러리... 모든 게 들어있었다. 마치 냉장고 전체를 배송하는 거랑 같았다. 내가 필요한 건 음식(앱)인데, 냉장고까지 함께 보내는 거였다.

그래서 다단계 빌드(multi-stage build)라는 걸 배웠다.

Stage 1: 빌드

  • Elixir 런타임, 컴파일 도구 필요 (무겁다)
  • 앱을 컴파일하고 release 아티팩트 생성

Stage 2: 런타임

  • 컴파일된 바이너리만 필요 (가볍다)
  • 실제 서버에서 실행할 때 필요한 것만 포함
FROM elixir:1.19.5-alpine AS build
RUN apk add --no-cache git build-base
...
RUN mix release

FROM alpine:3.23 AS runtime
RUN apk add --no-cache libstdc++ openssl ncurses-libs
COPY --from=build /app/_build/prod/rel/roblox_watcher ./

보이나? 첫 번째 이미지(build)에서 컴파일했으니까, 두 번째 이미지(runtime)에는 컴파일 도구를 안 쓸 수 있다. 그냥 실행할 바이너리만 복사하면 된다.

이렇게 최적화한 후:

447MB → 173MB → 50MB

마지막엔 Alpine Linux(초경량 리눅스)를 기반으로 최적화해서 50MB까지 줄였다. 냉장고를 배송한 게 아니라, 이제 작은 도시락 정도가 되었다.


프로덕션에서 일어나는 일들

로컬에선 없던 버그들이 프로덕션에서 튀어나왔다.

버그 1: 환경 변수가 안 들어온다?

로컬에선 .env 파일을 읽어서 환경 변수가 자동으로 로드되었다. 그런데 서버에서는?

# 로컬
export DATABASE_URL=postgres://...
mix phx.server

# 서버 (Docker)
docker run --env-file .env.prod ...

Docker 컨테이너 안에서는 .env 파일이 존재하지 않는다. 파일을 복사하거나, 명시적으로 환경 변수를 전달해야 했다.

결국 docker-compose.prod.ymlenv_file: .env.prod를 추가해서 해결했다.

버그 2: Nginx와 포트 충돌

서버에 Nginx를 깔아서 리버스 프록시로 쓰고 있었는데, 내 백엔드 컨테이너가 port 8080을 쓰려고 했더니, 이미 다른 서비스가 쓰고 있었다.

environment:
  PORT: "4201"  # Nginx가 프록시할 포트
  PHX_SERVER: "true"

이렇게 명시적으로 포트를 지정하고, Nginx 설정도 맞춰야 했다. 로컬에선 신경 안 썼던 부분인데, 여러 서비스가 함께 도는 프로덕션에선 필수였다.


발렌타인 데이의 작은 사랑

2월 14일, 내 앱이 처음으로 실제 URL(https://roblox.cardbin.net)에서 떴다.

손가락으로 터치할 수 있는, 진짜로 존재하는 서버 위의 내 앱.

로컬에서 개발할 때와는 다른 느낌이었다. localhost:4000은 내 노트북이었지만, 이건 세상에 나온 거였다. 누군가가 URL을 입력하면 내가 만든 대시보드가 떠오를 것이다.

(물론 아직 사람들이 쓰진 않았지만.)

나는 한참 대시보드 로드 속도를 지켜봤다. 1초... 1.5초... 좋다. 작동한다.

그날 밤, 나는 가족에게 URL을 보내줬다. "이거, 나 만들었어."


다음 화의 예고

이제 앱이 서버에서 돌고 있다. 그런데 문제가 하나 있다.

서버는 돈이 든다.

AWS, DigitalOcean, 어디든 매달 돈을 내야 한다. 그럼 누가 그 돈을 내지?

다음 화에서는 "결제 시스템"이라는 악몽에 빠져든다. 결제는 3번을 바꿨다. 첫 번째는 너무 복잡했고, 두 번째는 금방 사라졌고, 세 번째는 40자 제한이라는 괴상한 버그와 싸웠다.

다음 화: "결제가 안 된다고요?" (2월 21일)


마지막 독백: 서버에 코드를 올리는 순간, 그건 더 이상 "내 노트북"이 아니다. 세상 밖으로 나온 물건이다. 그래서 더 책임감 있게 다뤄야 한다.

반응형

+ Recent posts