🐝매일 한입
개발일지📖 13분 읽기

Utakoto — 내 요청만 보이던 곡 요청을 모두가 투표하는 보드로, 부스트는 빼고

곡 요청을 공개 보드로 바꾸고, 포인트로 내 요청 순위를 올리던 부스트 대신 한 곡에 하루 한 표 투표를 넣었습니다. 서버 배포가 막힌 날 OTA 를 미룬 이유와 아직 확인하지 못한 회원 투표까지.

📖 13분 읽기
#Utakoto#곡 요청#커뮤니티#React Query#Expo#OTA#1인개발

요청은 받는데, 아무도 그걸 볼 수 없었다

Utakoto 에는 10월 6일부터 곡 요청 기능이 있었습니다. 찾는 곡이 카탈로그에 없으면 검색 결과 0건 자리에 "「검색어」 요청하기" 카드가 뜨고, 곡명·아티스트·YouTube 링크를 적어 보내면 서버가 같은 곡끼리 묶어 "N명이 요청했어요"로 쌓는 구조였습니다. 곡이 들어오면 요청한 사람에게 푸시가 가고요. 거기에 두 가지를 얹었습니다. 회원이 50P 를 써서 내 요청의 우선순위를 올리는 부스트, 그리고 대기 요청 상위 8곡으로 8강·4강·결승을 치르는 이번 주 곡 월드컵입니다.

열하루 뒤인 10월 17일에 쓴 intent 의 문제 정의는 이 구조를 거의 그대로 뒤집습니다.

  • 곡 요청은 내 요청만 보인다. 남의 요청을 보거나 힘을 보탤 길이 없고, 보드로 들어가는 입구도 검색 0건 카드 하나뿐이다.
  • 월드컵 카드는 대기 8곡부터 열리게 해 두어서, 지금 규모에서는 뜨지 않는다.
  • 부스트는 내 요청의 순위를 포인트로 올리는 개인 기능이다.
  • 요청이 곡이 되는 과정이 보이지 않아, 요청하고 기다릴 이유가 약하다.

요청을 받는 창구는 만들었는데, 그 창구는 각자 혼자 쓰는 방이었습니다. 그날 제가 적은 방향은 두 줄이었습니다. 곡 요청을 커뮤니티 기능처럼, 그리고 요청 페이지가 더 앞으로 나와서 들어가기 쉽게.

바꾼 것: 하나의 보드, 한 곡에 하루 한 표

이날 정한 값은 intent 맨 위에 적어 두었습니다. 부스트는 없앤다. 투표는 포인트로 하되 1표에 10P, 한 요청에 하루 1표. 게스트는 보기만 한다. 매주 월요일 상위 2곡을 뽑는다.

화면은 /songs/requests 에 새로 만든 요청 보드 하나로 모았습니다. 위에서부터 이번 주 선정 → 투표 중(표 많은 순, 순위) → 최근 추가됨이 이어지고, "내 요청"은 따로 화면을 두지 않고 보드 안의 필터가 됐습니다. 예전 /me/requests 경로는 지우지 않고 보드로 넘기는 Redirect 로 남겼습니다. 이미 나간 '들어왔어요' 푸시가 그 주소를 들고 있어서, ?preview= 를 그대로 붙여 보내야 했습니다.

입구도 다 보드로 돌렸습니다. 홈 탐색 섹션 맨 앞, 이번 주 인기보다 위에 "같이 고르는 다음 곡" 카드를 두었고, 검색 0건 카드·목록 끝 한 줄·나 탭의 행이 모두 보드로 갑니다. 검색 0건 카드는 예전엔 그 자리에서 요청 시트를 열었는데, 이제는 보드로 이동하면서 검색어를 넘깁니다.

  const go = () => {
    const p = new URLSearchParams({ via: variant === 'empty' ? 'search_empty' : 'list_end' });
    if (q) p.set('request', q);
    router.push(`/songs/requests?${p.toString()}` as never);
  };

보드가 request= 를 받으면 검색어가 채워진 요청 시트를 바로 엽니다. 시트를 닫으면 그 아래가 곧 보드라서, 요청을 보낸 뒤 다른 요청과 표 수를 따로 찾아갈 필요가 없습니다.

투표 버튼의 상태는 순수 함수 하나로 정했습니다. 부스트 때의 boostState 자리를 voteState 가 이어받았습니다.

export function voteState(
  r: Pick<BoardItem, 'id' | 'status'>,
  opts: { isGuest: boolean; votedTodayIds: readonly string[]; limits: Pick<BoardLimits, 'voteCost'> }
): VoteState {
  if (r.status !== 'pending') return { kind: 'hidden' };
  const label = `투표 ${opts.limits.voteCost}P`;
  if (opts.isGuest) return { kind: 'guest', label };
  if (opts.votedTodayIds.includes(r.id)) return { kind: 'voted_today', label: '오늘 투표함' };
  return { kind: 'available', label };
}

게스트에게도 버튼은 똑같이 "투표 10P"로 보입니다. 숨기지 않고, 누르면 가입 유도 시트가 뜹니다. 비용 숫자는 코드에 박지 않고 서버가 내려 준 limits 에서 읽습니다. 잔액이 모자란지, 오늘 이미 투표했는지의 최종 판정도 서버가 합니다. intent 의 제약에 "얇은 클라이언트 — 투표 가능 여부·오늘 표·순위·선정은 서버 값만 그린다"고 적어 둔 그대로입니다.

투표가 성공하면 보드 캐시에서 그 행만 바로 고쳐 숫자가 즉시 바뀌고, 뒤에서 보드·요약·포인트를 다시 받아옵니다.

    onSuccess: (res, requestId) => {
      qc.setQueryData<BoardResponse>(songRequestKeys.board, (prev) => {
        if (!prev) return prev;
        const patch = (list: BoardResponse['open']) =>
          list.map((it) => (it.id === requestId ? { ...it, myVotes: res.myVotes, voteCount: res.voteCount, weight: res.weight } : it));
        return {
          ...prev,
          open: patch(prev.open),
          mine: patch(prev.mine),
          picks: patch(prev.picks),
          me: { ...prev.me, balance: res.balance, votedTodayIds: [...new Set([...prev.me.votedTodayIds, requestId])] },
        };
      });
    },

같은 요청이 '투표 중'과 '내 요청'에 동시에 들어 있을 수 있어서 세 목록을 모두 고칩니다.

부스트를 뺀 이유, 월드컵을 내린 이유

부스트를 뺀 이유는 intent 의 문제 정의 한 줄이 전부입니다. 내 요청의 순위를 포인트로 올리는 개인 기능이라는 것. 요청을 여러 사람이 함께 고르는 일로 바꾸려는데, 순위를 혼자 끌어올리는 장치가 같이 있으면 방향이 서로 엇갈립니다. 투표도 포인트를 쓰지만, 남의 요청에도 표를 줄 수 있고 한 곡에 하루 한 표로 막혀 있다는 점이 다릅니다. 포인트 이력의 사유도 song_request_boost 에서 song_request_vote·song_request_vote_refund 로 바꿨습니다.

월드컵은 카드만 내리고 화면 코드는 남겼습니다. 대기 8곡이라는 문턱이 지금 규모에서 넘어지지 않는다는 게 기록된 이유입니다. 화면을 왜 지우지 않고 남겼는지는 intent 에 "코드는 남겨도 된다"는 한 줄뿐이고, 그 이상의 이유는 기록에 없습니다.

같은 날 보드 행을 한 번 더 고쳤습니다(d5dcb49). 처음엔 둘째 줄에 "3명이 요청 · 12표", 요청자가 없는 운영 추천 곡은 "운영 추천 · 12표"를 썼습니다. 커밋에 남긴 이유는 한 줄입니다. "운영 추천·1명이 요청" 대신 바로 투표 수. 투표를 모으는 보드에서 행이 먼저 말해야 할 건 누가 올렸는지가 아니라 지금 몇 표인지라고 봤습니다.

/** 보드 행의 둘째 줄 — 표 수만("12표", 0 도 "0표"). 요청자 수·운영 추천은 보이지 않는다(PO 2026-10-17: 바로 투표 수). */
export function voteLine(r: Pick<BoardItem, 'voteCount' | 'myVotes'>): string {
  return r.myVotes > 0 ? `${r.voteCount}표 · 내 표 ${r.myVotes}` : `${r.voteCount}표`;
}

투표 버튼도 행 아래에서 행 오른쪽 끝, 표 수 바로 옆으로 옮겼습니다. 이어서 최근 추가됨의 담기·열기 버튼도 같은 자리로 맞췄습니다(ecd391a). plan 에는 이 두 수정을 운영 API 를 붙인 시뮬레이터에서 확인했다고 적혀 있습니다.

서버가 없는 날에는 OTA 를 내지 않았다

이번 변경은 JS 만 바뀌어 OTA 로 나갈 수 있었습니다. 앱 쪽 코드는 10월 17일에 main 에 올렸고, plan 에 남긴 검증은 tsc --noEmit 0, jest 707개 통과, 바꾼 파일 eslint 0 입니다.

그런데 그 시점에 백엔드 배포가 막혀 있었습니다. 운영 서버의 GET /api/song-requests/summary 가 404 였습니다. plan 에는 OTA 를 보류한 이유를 이렇게 적었습니다. 이 상태로 OTA 를 내면 요청 진입점이 전부 보드로 가서, 곡 요청 자체가 막힌다. 예전 앱은 서버 응답이 없으면 카드가 조용히 숨는 정도였지만, 새 앱은 검색 0건 카드부터 나 탭까지 모든 길이 보드 하나로 모입니다. 입구를 한곳으로 모았다는 건, 그 한곳이 비면 갈 곳이 없어진다는 뜻이기도 했습니다.

배포가 막힌 원인은 저장소가 팀 밖의 다른 배포 계정에 연결돼 있던 것이었고, 다시 연결해 배포한 뒤 summary 가 200 을 돌려주는 걸 확인하고 나서 같은 날 밤 OTA f4bbf033 을 냈습니다. runtime 1.11.0, iOS·Android 둘 다입니다. 1.10.0 런타임에는 더 내지 않기로 했습니다.

확인한 것과 못 한 것도 plan 에 나눠 적었습니다. 시뮬레이터에서 게스트로 홈 카드 → 보드 → 투표 탭 → "가입하면 투표할 수 있어요" 시트 → 요청 시트 → 내 요청 빈 상태까지 봤습니다. 회원 투표, 즉 포인트 차감·"오늘 투표함"·포인트 부족 시트는 시뮬레이터에 회원 세션이 없어 확인하지 못했습니다. 서버 쪽 투표 처리는 SQL 로 검증했다고 적혀 있지만, 앱에서 회원이 실제로 누른 기록은 아직 없습니다.

intent 의 Open questions 세 가지도 그대로 남아 있습니다. 보드 이름을 "요청 보드"로 할지 "다음 곡 고르기"로 할지, 투표 성공을 토스트로 알릴지 카드 안 숫자가 올라가는 움직임으로 보일지, 내 요청을 보드 안 필터로 둘지 별도 탭으로 둘지. 마지막은 기본값대로 보드 안 필터로 만들었고, 앞의 두 개는 기록상 아직 답이 없습니다.

Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.

📚 관련 글

💬 댓글