지난 개발 일지 #2는 코드를 통째로 감사해서 점수와 보안을 조용히 깨뜨릴 수 있는 버그를 잡는 작업이었다. 신뢰성 쪽을 한 번 정리하고 나니, 다음으로 마음에 걸린 건 정반대 방향이었다. LDBD는 여전히 “정적인 순위표”에 가까웠다. 한 번 보고 나면 매일 다시 들어올 이유가 약했다. 이번 회차는 그 “다시 올 이유”를 만드는 성장 기능들을 만든 기록이다. 여기서 “성장”은 광고나 허영 지표가 아니라, 사람들이 다시 찾을 이유 — 피드, 팔로우, 공유, 차트 주석 같은 것들을 뜻한다.
그런데 이번 회차에서 가장 중요했던 깨달음은 따로 있었다. 사람들이 다시 오게 만들려면, 먼저 사람들이 믿고 볼 점수부터 바로잡아야 한다는 것이다. 기능은 그다음이었다.
다만 곧장 코딩부터 시작하지는 않았다. 떠오른 아이디어가 일곱 개쯤 있었는데, 그걸 먼저 “성장 기여도 · 구현 난이도 · 서로 간의 의존성”으로 평가해서 우선순위를 매긴 계획 문서를 한 장 만들었다. 기능 요청을 받자마자 만드는 게 아니라, 무엇을 왜 먼저 하는지부터 정리하고 들어간 것이다. 이 한 장이 중간에 길을 잃지 않게 잡아준 가장 큰 버팀목이었다.
점수부터 의심스러웠다
계획을 짜다 보니, 가장 먼저 손대야 할 게 새 기능이 아니라 이미 있던 리더보드 점수라는 결론이 나왔다. 새로 만들 프로필·피드·공유 카드가 전부 “이 사람의 대표 점수”를 보여줄 텐데, 정작 그 점수를 선뜻 믿기 어려웠다. 리더보드가 엉뚱한 사람을 위에 올리면, 그 위에 얹은 기능들까지 잘못된 신뢰를 퍼뜨린다. 기능을 붙일수록 비뚤어진 점수가 더 증폭되는 구조였다.
그때까지 대표 지표는 한 예측당 점수의 평균이었다. 그런데 그 점수에는 변동성 보정, 기간 가중치, 역베팅 보너스 같은 보정 장치가 잔뜩 붙어 있었다. 의심이 들어서, 막연한 직관 대신 실제 데이터로 확인해 보기로 했다. 프로덕션에 쌓인 종료된 예측 약 12만 7천 건을 그대로 읽어와여러 채점 방식으로 다시 매겨봤다(읽기만 하는 분석이라 실데이터는 건드리지 않았다).
결과는 꽤 분명했다. 그 점수는 “예측 실력”이 아니라 사실상 “변동성 큰 자산에, 상승장에, 얼마나 자주 붙어 있었나”를 재고 있었다. 리더보드 상위는 “항상 오른다”고만 찍는 베이스라인 봇들이 차지했고, 점수를 단순 합산해 보면 아무 실력 없이 무작위로 찍는 봇이 정확도가 더 높은 AI 봇을 이기기도 했다. 많이 찍을수록 총점이 커지니, 횟수가 실력을 덮어버린 것이다.
그래서 채점 단위 자체를 바꿨다. 한 건의 점수를 방향이 맞은 쪽의 로그 수익률로 잡았다. 쉽게 말해, “오른다”고 걸었는데 실제로 올랐으면 그 상승폭만큼 +로, 반대로 움직였으면 같은 크기만큼 −로 매긴다. “내린다”고 걸었을 땐 부호가 뒤집혀서, 떨어지면 +가 된다. 그 값을 다시 보유 기간으로 나눠 연율화(annualize)했다. 연율화는 “이 속도로 1년을 갔다면 몇 %”로 환산하는 것인데, 이렇게 하면 하루짜리 예측과 한 달짜리 예측을 같은 잣대로 비교할 수 있다. 그리고 합이 아니라 평균을 쓰니, 많이 찍는다고 유리하지 않다. 그제야 리더보드의 대표 숫자가 “이 예측 방향을 그대로 따라갔을 때의 연율화된 성과”에 가까운 값이 됐다. 재채점해 보니 무작위 봇은 0% 근처로 가라앉았고, “항상 상승” 봇들은 각 자산의 실제 장기 수익률 순서대로 정렬됐다.
솔직하게 적어둘 한계도 있다. 방향 예측의 우위라는 건 원래 작다. 그래서 표본이 수백 건 쌓이기 전에는 통계적으로 증명되지 않는다. 실제로 지금 상위에 보이는 AI 봇들의 +30%대 수치도 신뢰구간(진짜 값이 있을 만한 범위)이 0을 크게 가로질러서, 아직 “운이 좋은 한 해”와 구분되지 않는다.
흔한 해법은 표본이 충분히 쌓일 때까지 신규 참여자를 보드에서 빼두는 것이다. 하지만 그러면 갓 들어온 사람은 한동안 어디에도 보이지 않아 금방 흥미를 잃는다. 새로 들어온 사람은 일주일 안에 보드에서 자기 이름을 봐야 계속 참여한다. 그래서 신규를 숨기는 대신, 모두를 같은 정렬에 노출하되 신뢰도는 3단계 뱃지(신규 / 보정됨 / 검증됨)와 신뢰구간 표시로 전달하기로 했다. 불확실하면 숨기는 게 아니라, 불확실하다고 같이 보여주는 쪽이다.

피드가 작동하려면, 먼저 깔아둔 것들
계획서에서 가장 중요하다고 본 건 다음 단계인 피드였는데, 피드는 혼자서는 빛나지 않는다. 팔로우할 대상이 있어야 하고, 들어가 볼 만한 프로필이 있어야 하고, 끝까지 읽게 만드는 훅이 있어야 한다. 그래서 점수를 정리한 다음, 피드 전에 작고 빠른 기능 몇 개를 먼저 깔았다.
프로필에는 웹사이트와 소셜 링크(X·GitHub·쓰레드)를 붙일 수 있게 했다. 여기서 한 가지 배운 게 있다. 사용자는 브라우저에서 자기 프로필 데이터를 데이터베이스에 직접 쓸 수 있는 구조라, 입력 폼에서 아무리 검증해도 그건 우회할 수 있다. 그래서 진짜 보안 경계는 입력이 아니라 화면에 그릴 때다. 허용한 플랫폼과 https://로 시작하는 주소만 통과시키는 검사를 렌더링 시점에 두면, javascript:같은 위험한 링크나 엉뚱한 키는 애초에 화면에 닿지 못한다. “검증은 입력에서”라는 통념과 반대로, 이런 경우엔 표시하는 쪽이 마지막 방어선이다.
관심 자산(워치리스트)도 추가했다. 그동안은 사람·봇만 팔로우할 수 있었는데, 이제 종목 자체를 팔로우할 수 있다. 이게 나중에 피드와 알림을 “내가 관심 있는 것” 중심으로 채우는 바탕이 된다. 그리고 틀린 예측에 학습 루프를 붙였다. 내 예측이 빗나가게 끝나면, 같은 종목·같은 기간을 맞힌다른 사람의 근거를 아래에 보여준다. 이미 끝난 예측만 쓰니 베끼기 문제와는 무관하고, 톤도 “이렇게 본 사람도 있어요” 정도로 중립적으로 잡았다. 여기서도 독립 리뷰가 한 건 잡았는데, 매일 기계적으로 찍는 베이스라인 봇을 빼지 않으면 추천이 대부분 봇 근거로 채워진다는 점이었다.
가장 이야깃거리가 많았던 건 공유 카드다. 프로필이나 종목 링크를 X·카카오톡에 붙이면 밋밋한 텍스트 대신 성적이나 예측 분위기가 보이는 미리보기 카드가 뜨도록, 링크별로 이미지를 동적으로 만들었다. 자랑하고 싶은 콘텐츠가 무료 유입을 만드는 바이럴 루프를 노린 것이다. 그런데 첫 시도에서 한국 종목 이름이 카드에서 전부 □□□(두부 글자, tofu)로 깨졌다. 카드를 그리는 렌더러에 한글 폰트가 없었기 때문이다. 한글 폰트 전체를 싣자니 너무 무거워서, 그 카드에 실제로 들어가는 글자만 골라 받아오는방식(subset)으로 풀었다. 문제가 하나 더 있었다. 폰트를 못 받아왔을 때 빈 목록을 넘기면 렌더러가 통째로 죽어버려서(에러 500), 실패하면 폰트 지정을 아예 빼고 기본 폰트로라도 그리게 해 뒀다. 카드가 깨지면 링크 미리보기 자체가 망가지니까, “절대 죽으면 안 되는” 자리였다.
순위표를 피드로
점수를 정리한 다음에야 성장 기능들이 의미가 생겼다. 핵심은 근거(reason) 피드였다. 예측마다 작성자가 남긴 분석을 X(구 트위터)·쓰레드처럼 타임라인으로 흐르듯 보여주는 화면이다. 순위표는 한 번 보면 끝이지만, 피드는 매일 새 글이 올라온다.

여기서 가장 신경 쓴 건 LDBD의 핵심 규칙인 copycat 방지였다. 아직 종료되지 않은(open) 예측의 “누가 무엇에 걸었는지”를 그대로 보여주면, 남의 포지션을 베껴서 게임이 무너진다. 그래서 피드를 두 종류로 나눴다. 이미 종료된 예측은 결과가 확정됐으니 작성자·방향·근거·성적을 전부 공개하고(피드의 중심 줄기다), 진행 중 예측은 작성자를 가린 채 자산 종류·방향·근거만보여준다. 가린 카드에는 작성자 정보 자체가 데이터에 실리지 않게 했다. 노출하지 않는 게 아니라, 노출할 수 없게 만든 것이다.

만들면서 페이지네이션에서 한 번 데었다. 무한 스크롤로 다음 페이지를 불러오는데, “근거가 60자 이상” 같은 필터를 데이터를 받아온 뒤에 거는 바람에, 한 페이지가 비어 보이면 더 있는데도 피드가 멈춰버리거나 일부 카드가 건너뛰어졌다. 결국 각 피드 소스에서, 필터를 통과한 카드가 화면을 채울 만큼 쌓일 때까지 반복해서 더 가져오는 방식으로 바꿔서 풀었다.
차트 위에 직접 그리기
가장 무거웠던 건 마지막 두 개였다. 먼저 자산 페이지의 단순한 종가 선 그래프를 캔들스틱 차트로 바꿨다(거래량·이동평균·RSI 같은 보조지표 포함). 캔들을 그리려면 일별 고가·저가가 필요했는데 그동안 저장하지 않고 버리고 있어서, 컬럼을 추가하고 과거 2년치를 다시 받아 채워 넣었다.
그 위에 올린 게 차트 주석기능이다. 예측을 올릴 때 차트에 지지선·저항선·추세선· 박스를 직접 그려 분석을 붙일 수 있다. 처음에 고민한 건 “그냥 이미지 업로드를 받을까”였는데, 그러면 자산과 무관한 광고·짤·혐오물이 올라올 위험이 크다. 그래서 이미지 대신 그린 도형을 (날짜, 가격) 좌표의 구조화된 데이터(JSON)로 저장한다. 이미지를 아예 받지 않으니 검수할 콘텐츠가 없고, 좌표로 저장하니 차트를 확대·축소해도 정확히 다시 그려진다. 무엇보다 AI 봇이 그림을 그릴 필요 없이 JSON만 출력하면 똑같이 차트에 분석을 올릴 수 있다. 실제로 규칙 기반 봇이 최근 고저점과 이동평균으로 지지·저항선을 자동으로 그어 올리도록 연결했다.
드로잉 UI에서도 한 번 막혔다. 처음엔 차트 위에 투명한 캔버스를 덮어 클릭을 받게 했는데, 모달 안에서 좌표가 어긋나는지 아무리 클릭해도 선이 안 그려졌다. 차트 라이브러리가 자체적으로 주는 클릭 이벤트(클릭 지점의 가격과 날짜를 그대로 알려준다)를 쓰는 방식으로 바꾸니 깔끔하게 동작했다.
이번에도 반복된 검증 패턴
기능마다 같은 흐름을 지켰다. 기능 하나를 브랜치에서 만들고, 매번 별도의 AI에게 독립 리뷰를 받고, preview에서 직접 눈으로 확인한 다음에야 합쳤다. 이 독립 리뷰가 생각보다 자주 일을 했다. 예를 들어 차트용 고저가를 채워 넣는 백필 스크립트가, 의도와 달리 점수 계산에 쓰이는 다른 값까지 덮어쓸 뻔한 걸 리뷰가 잡아냈다. 화면상으론 멀쩡했을 종류의 버그다.
버그가 코드에만 있는 것도 아니었다. 모바일 화면에 쓰레드처럼 하단 고정 탭바(피드·리더보드· 예측·알림·프로필)를 붙였는데, preview에서 로그아웃 상태일 땐 잘 보이다가 로그인만 하면 탭바가 사라지는 것처럼 보였다. 한참 코드를 의심했는데 원인은 엉뚱한 데 있었다. Google 로그인을 누르면 인증 제공자가 정해진 주소로 다시 돌려보내는데, 그 주소가 preview가 아니라 production으로 잡혀 있어서, 로그인하는 순간 아직 그 기능이 없는 production 화면으로 튕겨버린 것이다. 코드가 아니라 “내가 지금 어디서 테스트하고 있는가”의 문제였다. preview로 검증할 때 로그인이 끼면 한 번쯤 의심해 볼 함정이다.
결국 #2 때와 같은 결론으로 돌아왔다. 가장 위험한 건 에러가 나는 버그가 아니라 에러 없이 조용히 틀리는 것이다 — 점수가 미묘하게 어긋나거나, 가려야 할 게 한 군데서 새거나, 백필이 엉뚱한 컬럼을 건드리거나. 그래서 만들 때마다 “빌드가 통과했다”에 멈추지 않고 실제로 직접 확인하고, 다른 모델에게 한 번 더 보여주는 단계를 끼워 넣었다.
이렇게 계획서의 기능들을 차례로 끝냈다. 피드, 워치리스트, 공유 카드, 캔들 차트까지 — 사람들이 다시 올 이유는 분명히 늘었다.
그런데 이번 회차에서 가장 잘한 결정은 그 화려한 기능들 중 하나가 아니라, 그것들을 얹기 전에 아래에 깔린 점수부터 다시 만든 일이었다. 새 기능은 결국 점수를 더 크게 비추는 장치였고, 토대가 비뚤어진 채로는 그 위에 무엇을 올려도 같이 기울었을 것이다.
화려한 보정식을 걷어내고 단순하고 해석 가능한 한 줄짜리 식으로 돌아오면서, 가장 단순한 사실을 다시 확인했다. 무엇을 재는지부터 똑바로 정하지 않으면, 그 위에 무엇을 쌓아도 비뚤어진다. 결국 “점수”와 “실력”은 같은 말이 아니었다.