ML 프로젝트인데, ML을 안 넣기로 했습니다

한 학기 팀 프로젝트로 "잔솔"이라는 Android 앱을 만들었습니다. 공부 세션 중에 유튜브나 인스타를 열면 0.2초 안에 잔소리 팝업이 뜨고, 닫고 공부로 돌아가면 캐릭터가 코인을 받고, 무시하면 체력이 깎입니다.

ImageImage

왼쪽: 딴짓 감지 4초째에 뜬 잔소리 오버레이. 오른쪽: 홈 화면.

학교 수업 프로젝트였으니 원래는 ML이 들어갈 자리가 있었습니다. 저희는 학기 초에 그걸 빼기로 했고, 지금 돌아보면 그게 이 프로젝트에서 제일 잘한 판단이었습니다.

저는 원래 서버 만들던 사람입니다. 화면에 뭐가 뜨는지는 대체로 남의 일이었는데, 이번 학기엔 폰 화면 맨 위에 팝업 띄우는 일부터 했습니다.

왜 하필 잔소리인가

집중 앱은 시장에 이미 많고, 저도 써봤습니다. Forest는 좋은 앱인데 제가 먼저 세션을 켜야 작동합니다. 그 의지가 있는 날은 사실 앱이 없어도 공부가 됩니다. 스크린타임은 오늘의 도피를 내일 아침에 알려줍니다. 부검 보고서지, 개입이 아닙니다. 차단 앱은 확실히 강력한데, 답답해지는 순간 우회로를 찾거나 그냥 지웁니다. 지우는 데 3초면 됩니다.

셋 다 다른 앱인데 실패하는 지점은 같습니다. 자기조절에 실패한 사람에게, 자기조절을 전제한 도구를 준다는 것.

잔솔은 그 전제를 버렸습니다. 사용자의 의지는 개입의 조건이 아닙니다. 시스템이 먼저 알아채고 먼저 말을 겁니다. 도서관에서 옆자리 시선이 해주던 관찰자 노릇을, 혼자 있는 자취방에서는 소프트웨어가 대신하는 겁니다. 만들기 전에 대학생 34명한테 물어봤는데 85.3%가 공부하다 폰으로 도피한다고 답했습니다. 문제는 충분히 컸고, 도피가 일어나는 바로 그 순간을 잡는 도구는 비어 있었습니다.

만들고 싶었던 것: 사람을 가려 긁는 잔소리

사람마다 긁히는 잔소리가 다릅니다. 누구는 "막막하지? 한 줄만 써봐" 같은 달래는 말에 돌아오고, 누구는 "내일의 너한테 미안하지 않을까?" 같은 정곡에 돌아옵니다. 같은 문장이 누구한테는 리마인더고 누구한테는 그냥 알림 소음입니다.

그래서 진짜 만들고 싶었던 건 잔소리 앱이 아니라 사용자마다 먹히는 잔소리를 학습해서 골라주는 모델이었습니다. 찾아보니 이 문제에 정확히 들어맞는 기법도 있었습니다. Contextual Bandit이라고, 유저 반응을 보상 신호로 삼아 최적의 선택지를 학습하는 물건입니다. 문제와 기법이 딱 맞아떨어졌습니다.

계산을 해봤습니다. 잔소리 개인화가 통계적으로 의미가 있으려면 "이 사람한테 이 잔소리를 보여줬더니 돌아왔다/무시했다" 기록이 사용자당 최소 30회는 필요합니다.

당연하게도 이런 걸 얻을 데가 없다보니, 저희가 가진 기록은 0건이었습니다.

그래서 ML을 뺐습니다

이 벽에는 이름이 이미 붙어 있습니다. cold start. 추천 시스템 하는 사람들이 수십 년째 씨름하는 문제인데, 저희는 거기에 "16주 안에 돌아가는 물건 제출"이라는 조건까지 얹혀 있었습니다.

학교 팀 프로젝트가 이 지점에서 흔히 가는 길이 두 개 있습니다. 남의 공개 데이터셋을 가져다가 비슷한 문제를 흉내 내거나, 어떻게든 모델을 욱여넣고 보고서에 "데이터가 쌓이면 성능은 개선될 것으로 기대된다"라고 쓰거나. 둘 다 해봤는데, 어느 쪽도 남는 게 없습니다.

이번엔 문제를 뒤집었습니다. "어떤 잔소리가 최적인가"를 지금 푸는 게 아니라, "최적을 나중에 계산할 수 있게 만드는 시스템"을 먼저 만들기로.

그래서 이번 학기 버전(V1)에는 모델이 하나도 없습니다. 전부 규칙입니다. 대신 V1이 내보내는 모든 로그를 처음부터 미래 모델의 학습 데이터 포맷으로 설계했습니다. V1은 미완성 버전이 아니라 V2의 데이터 수집 파이프라인이고, 그러니까 ML을 뺀 게 아니라 순서를 바꾼 겁니다.

규칙은 회의실에서 정하면 안 된다

모델을 빼면 그 자리를 규칙이 채워야 합니다. 그럼 규칙은 어디서 와야할까요? 팀원 넷이 회의실에서 "30초 쯤이면 되지 않을까?" 하고 정하면 그건 규칙이 아니라 그저 저희들의 기분일 뿐입니다.

저희는 이 문제를 이미 사람 손으로 풀고 있는 전문가들을 찾아갔습니다. 학습상담사, 그리고 고등학교에서 자습 감독하는 교사. 딴짓하는 학생한테 무슨 순서로 말을 겁니까. 어떤 말에 돌아오고 어떤 말에 반항합니까.

답이 의외로 일관됐습니다. 처음부터 혼내는 사람이 없었습니다. 그걸 옮겨 적은 게 3단계 에스컬레이션입니다.

  • 즉시: "자료 하나만 찾아봐" — 일단 몸을 움직이게 하는 행동 제안
  • 30초 뒤: "막막하지? 한 줄만 써봐" — 공감하면서 난이도 낮추기
  • 2분 뒤: "내일의 너한테 미안하지 않을까?" — 화면 전체를 덮는 압박

시점 세 개도 감으로 정한 게 아닙니다. 즉시 개입은 사고 흐름이 영상 쪽으로 넘어가기 전, 그러니까 1초 안에 들어가야 하고, 30초는 화면에 오래 머물수록 못 빠져나온다는 screenertia 연구에서 짧은 체류와 고착이 갈리는 지점이고, 2분이면 이미 완전히 빨려 들어간 상태라 점잖은 말이 안 통합니다.

순서에도 이유가 있습니다. 딴짓하는 사람은 해야 할 걸 몰라서 안 하는 게 아니라 알면서 못 하는 상태입니다. 그런 사람한테 다짜고짜 "힘들지?" 하고 들어가면 위로가 아니라 방해가 됩니다. 행동 제안이 실패했다는 것 자체가 감정이 막혀 있다는 신호고, 그때가 공감 차례입니다. 압박은 맨 뒤로 뺐습니다. 왜냐하면 처음부터 혼내면 앱을 지울 거라고 생각했기 떄문입니다.

0.2초 — 서버가 죽어도 잔소리는 뜬다

서버 하던 사람 버릇인지, 설계하면서 장애 시나리오부터 생각하게 됩니다. 잔소리 앱한테 최악의 장애가 뭘까요? 서버가 죽는 게 아닙니다. 서버가 죽는 바람에 잔소리가 안 뜨는 겁니다.

그래서 서버를 개입 경로에서 빼버렸습니다. 감지→판정→오버레이로 이어지는 핵심 루프는 전부 폰 안에서 돕니다. 서버는 로그나 받아 가는 처지고, 개입 자체에는 발언권이 없습니다. 서버가 내려가 있어도 잔소리는 뜹니다. 장애를 겪어본 사람일수록 이해할 결정이라고 생각합니다.

감지는 두 겹으로 짰습니다. AccessibilityService의 화면 전환 이벤트가 딴짓 앱 진입의 찰나를 잡고, 30초 주기 폴링이 뒤를 받칩니다. 이벤트는 빠르지만 놓치면 그만이고, 폴링은 느리지만 반드시 돌아옵니다. 겹치면 평상시엔 즉시 반응, 최악의 경우에도 30초 안에 복구입니다. 폴링 주기가 하필 30초인 건 배터리 사정도 있지만 2차 개입 시점(30초)이랑 맞물리게 하려는 것이기도 합니다. 판정이 규칙 기반이라 모델 추론 지연 같은 변수는 애초에 없고요. 0.2초는 그렇게 나온 숫자입니다.

보상은 '복귀 버튼'이 아니라 '버틴 시간'에 걸었다

캐릭터랑 코인도 그냥 붙인 게임화가 아니라, 결정이 세 개 들어 있습니다.

먼저 페널티를 비대칭으로 뒀습니다. 복귀하면 코인을 받지만 무시하면 캐릭터 체력이 깎입니다. 사람은 같은 크기의 이득보다 손실을 두 배쯤 아프게 느낀다는 게 전망 이론이 하는 얘기고, 학생 앱의 보상 예산이라 해봐야 코인 몇 개라, 같은 예산이면 손실 쪽에 거는 게 더 멀리 갑니다.

다음으로 보상을 "복귀 버튼 누르기"가 아니라 "복귀한 뒤 딴짓 앱을 다시 안 열고 버틴 시간"에 비례해서 누적시켰습니다. 인센티브를 설계할 때는 어뷰징부터 생각해야 합니다. 보상이 버튼에 걸려 있으면 팝업 닫고 3초 뒤에 유튜브를 다시 여는 코인 파밍이 성립하는데, 유지 시간에 걸어버리면 그 꼼수는 정의상 성립을 못 합니다.

마지막으로 세션에 목표 시간을 없앴습니다. Pomodoro식 카운트다운은 "25분 못 채움"이라는 실패감을 만들고, 실패감은 다음 세션의 진입 장벽이 됩니다. 우리 지표는 복귀지 시간 채우기가 아니라서, 화면에는 경과 시간만 흐릅니다.

진짜 설계는 화면이 아니라 로그였다

저의 백엔드하던 버릇이 제일 크게 남은 데는 따로 있습니다. 팀이 화면 시안을 그리는 동안 저는 테이블 스키마부터 그렸습니다. 이 프로젝트에서 제일 공들인 설계를 하나만 꼽으라면 잔소리 문구도 캐릭터도 아니고 이 로그 스키마입니다.

잔소리 600개를 3단계 × 3톤(약하게/보통/세게) 9개 조합으로 만들고, 메시지 ID에 좌표를 그대로 박았습니다. s1t2_034면 2단계·독설 톤·34번 메시지. 잔소리가 뜰 때마다 이벤트가 한 줄 쌓입니다. 어떤 메시지가, 몇 번째 개입에서, 무슨 앱을 쓰다가 떴고, 유저가 몇 초 만에 어떻게 반응했는지.

핵심은 outcome 필드입니다. 잔소리가 뜨고 나서 유저가 공부로 돌아왔는지(returned) 무시했는지(dismissed)가 자동으로 찍힙니다. 아무도 라벨링하지 않았는데 라벨이 생기는 겁니다. 앱이 그냥 돌아가는 것만으로 미래의 Bandit이 먹을 보상 신호가 쌓이는 구조입니다.

Image

여담인데 잔소리 600개는 초안을 AI한테 시키고 팀이 전수 검수했습니다. 톤 분류가 틀린 걸 재배치하고, 선 넘는 문구를 빼고, 26자 넘으면 오버레이가 못 생겨져서 줄이고. 잔소리 600개를 하루 종일 읽다 보면 뭘 잘못했는지도 모르면서 일단 반성하게 됩니다.

배포: 팀 넷에서 152명으로

Google Play 내부 테스트로 배포하고 처음엔 팀 넷이서 도그푸딩을 했습니다. 이 시기 복귀율이 73.4%였는데, 사실상 팀 내 최다 사용자 한 명의 기록이나 다름없었습니다. 만든 사람들이 자기 앱에 순응하는 건 데이터가 아니라 애정입니다.

그래서 외부 유저를 받아서 판을 키웠습니다. 최종 집계는 이렇습니다. 등록 152명, 개입을 한 번이라도 받은 유저 91명, 주간 활성 47명. 4주간 개입 9,214건, 복귀 6,493건 — 복귀율 70.5%.

복귀율이 도그푸딩 시절보다 떨어졌습니다. 저는 이게 이 숫자에서 제일 마음에 드는 부분입니다. 152명 안에는 잔소리에 코웃음 치는 사람도 있고 알림을 통째로 꺼놓는 사람도 있습니다. 표본이 다양해지면 숫자는 내려가는 게 정상이고, 규모를 키웠는데 지표가 올랐다면 그게 오히려 수상한 겁니다. 숫자가 나빠진 게 아니라 정직해진 겁니다.

Image

분포가 재미있습니다. 개입의 93.9%가 1차 잔소리 선에서 끝났습니다. 만들 때 제일 살벌하게 공들인 3차 전체화면은 9,214건 중에 93번밖에 출동을 안 했습니다. 도그푸딩 때 어렴풋이 느꼈던 게 규모에서 확인됐는데, 딴짓의 상당수는 의지가 무너진 게 아니라 그냥 까먹은 겁니다. 잔소리가 뜨면 "아 맞다" 하고 돌아갑니다. 잔소리의 절반은 회초리가 아니라 알람이었던 거죠.

여기 함정이 하나 있습니다. 2차 개입 복귀율이 41.2%로 1차(72.2%)의 반토막인데, 처음엔 저도 "2차 잔소리가 구리네"라고 생각했습니다. 틀린 해석입니다. 1차에서 돌아올 사람은 이미 1차에서 돌아갔고, 2차까지 갔다는 건 첫 잔소리를 무시할 만큼 화면에 붙어버렸다는 뜻입니다. 남은 손님이 원래 어려운 손님인 겁니다. 생존자 편향이고, 2차 문구의 품질 문제가 아니라 표본의 성질이 변한 겁니다. 이걸 구분 못 하면 "2차 문구 갈아엎자" 같은 엉뚱한 데 공수를 씁니다.

"ㅋㅋ 공부한다고 해놓고" — 문구가 승부를 가른다

메시지 단위로 쪼개면 더 재미있어집니다. 같은 1차, 같은 톤 안에서도 문구별 성적이 갈립니다.

Image

"ㅋㅋ 공부한다고 해놓고"가 노출 142회에 복귀율 89%. "경쟁자는 지금 문제 풀고 있어"도 상위권입니다. 반대쪽 끝에는 "공부 안 하는 너 진짜 멋있다" 같은 비꼬기가 31%로 바닥을 깁니다. 정면 비아냥은 사람을 못 움직이고, 허를 찌르는 팩트("매일 후회하면서 매일 반복하네")는 움직입니다. 비꼬면 잔소리가 아니라 시비가 되는 모양입니다.

다만 선은 긋고 갑니다. 이건 관찰 데이터 랭킹이지 인과가 아닙니다. 좋은 문구가 좋은 순간에 우연히 더 노출됐을 수도 있습니다. 이 그래프에서 확정으로 가져갈 결론은 하나뿐입니다. 같은 조건에서도 문구에 따라 복귀율이 세 배 갈린다는 것, 그러니까 문구 선택은 최적화할 가치가 있는 변수라는 것. 그 최적화가 V2에 넘길 숙제입니다.

낚일 뻔한 그래프 석 장

규모가 생기면 그래프가 많아지고, 그래프가 많아지면 그럴싸한 거짓말도 많아집니다. 이번 분석에서 걷어낸 것 세 개만 보여드리겠습니다.

Image

첫째, 전체 복귀율 70.5%라는 "평균". 유저별로 열어보면 상위 10명이 전체 이벤트의 41%를 차지합니다. 헤비유저의 행동이 평균을 끌고 다닌다는 얘기입니다. 헤비유저 스큐 자체는 어느 앱에나 있어서 문제가 아닌데, 이걸 두고 "우리 유저는 평균적으로 이렇다"라고 말하는 순간 거짓말이 됩니다. 평균은 분모를 감춥니다. 유저 단위 지표는 분포로 봐야 합니다. (도그푸딩 시절엔 한 명이 8할이었으니 이 정도로 완만해진 것만도 장족의 발전이긴 합니다.)

Image

둘째는 톤입니다. 톤별 복귀율을 뽑으면 약하게 74.1%, 보통 71.3%, 세게 69.2%. "부드러운 잔소리가 더 효과적입니다!" — 발표 슬라이드에 넣기 딱 좋은 문장이죠. 못 넣습니다. 톤은 유저가 온보딩에서 직접 고르는데 152명 중 58%가 독한 톤을 골랐습니다. 다들 자기한테 엄격한 앱을 깔면서 독설을 고르고, 사흘 뒤에 후회합니다. 문제는 독한 톤을 고른 사람들이 애초에 딴짓이 심한 사람들일 수 있다는 겁니다. 자기선택이 끼어 있는 한 톤 비교는 n이 9,214건이어도 성립하지 않습니다. 표본을 키워도 selection bias는 안 사라집니다. 이걸 고치는 건 유저 수가 아니라 랜덤화입니다. 이 비교는 그래서 보고서에서 뺐습니다.

600개 중 507개만 무대에 올랐다

셋째. 정성껏 만든 잔소리 600개 중에 실제로 노출된 건 507개입니다. 꼬리 93개는 4주 동안 한 번도 무대에 못 올랐고, 노출된 것들도 중앙값 14회 수준이라 개별 메시지 성적은 아직 가설 후보지 결론이 아닙니다. 풀에서 랜덤으로 뽑는데도 이렇게 됩니다. 노출이 특정 단계·특정 톤 조합에 쏠리니까요.

석 장이 가리키는 방향은 하나입니다. 탐색을 사람과 우연에 맡기면 안 된다. 유저는 한 가지 톤에 고이고, 노출은 구석에 쏠리고, 비교는 오염됩니다. 노출 배분 자체를 시스템의 결정으로 만들어야 하고, 그게 정확히 Bandit이 하는 일입니다.

사람은 자기에게 먹히는 잔소리를 모른다

그래서 V2의 Bandit은 진짜 만들 가치가 있나. 실데이터의 톤 비교가 자기선택으로 오염된 이상, 정책끼리 공정하게 붙여볼 수 있는 데는 시뮬레이션뿐입니다. "공감형 유저는 부드러운 톤에 잘 돌아오고 압박형은 독설에 잘 돌아온다"는 식으로 유저 타입 가설 행렬을 세우고, 가상 유저 200명한테 개입 10,000회를 돌렸습니다. 이 절의 숫자는 전부 합성 시뮬레이션 결과입니다. 실서비스 성능이 아닙니다.

비교한 정책은 셋. 톤을 랜덤으로 뿌리기, 유저가 온보딩에서 고른 톤으로 고정하기, Thompson Sampling으로 유저별 학습하기.

Image

Bandit이 이긴 것 자체(72.0%, 랜덤 대비 +6.7%p, 이론 상한의 94.9%)는 예상 범위였고, 진짜 발견은 2등 싸움이었습니다.

유저가 직접 고른 톤(65.6%)이 아무거나 랜덤(65.3%)이랑 사실상 같았습니다.

시뮬레이션 설계상 유저마다 최적 톤이 분명히 존재하는데도 그렇습니다. 공감형인 사람이 자기를 압박형이라 믿고 독설을 고르는 식의 자기 인식 오류가 "직접 골랐다"는 이점을 다 상쇄해버리는 겁니다. 사람은 자기에게 먹히는 잔소리를 모릅니다. 앞에서 본 "온보딩에서 58%가 독설을 고른다"가 현상이라면, 이건 그 현상이 시스템 성능을 얼마나 깎는지에 대한 답입니다. 개인화를 설정 화면에 맡기면 안 되는 이유가 숫자로 나왔습니다.

학습이 진짜 일어나는지도 열어봤습니다.

Image

처음 1,000회 구간에서는 모든 유저 타입한테 톤이 비슷비슷하게 뿌려집니다. 마지막 1,000회 구간에서는 공감형한테 부드러운 톤이 58%, 압박형한테 독설이 48%로 쏠려 있습니다. 유저 타입은 시뮬레이션의 숨겨진 변수라서 시스템은 모릅니다. 돌아왔는지 무시했는지만 보고 알아낸 겁니다. 이 수렴이 개인화 학습이 작동한다는 직접 증거입니다.

지표 하나만 더 소개하겠습니다. 누적 후회(cumulative regret) — 매번 최적을 골랐을 때와 비교해 놓친 보상의 누적량입니다. 이름을 잘 지었다고 생각합니다.

Image

랜덤 정책의 후회는 직선으로 자랍니다. 1만 번째 실수에서도 첫 번째랑 똑같은 확률로 틀리니까요. 학습하는 정책의 후회 곡선은 갈수록 완만해집니다. 배운 만큼 덜 후회하는 겁니다.

시뮬레이션에서 실무용 답도 두 개 챙겼습니다. 초기 prior를 낙관적으로 주면(성공 쪽으로 기울인 Beta(5,2)) 서비스 초반 학습이 눈에 띄게 빨라진다는 것 — 신규 유저의 첫 경험을 탐색 비용으로 다 태우지 않아도 된다는 뜻입니다. 그리고 유저당 개입 20회 언저리부터 개인화가 의미를 갖기 시작한다는 것 — 저희 규모(유저당 평균 100회 수준)면 학습에 필요한 밀도는 이미 넘겼다는 확인입니다.

남은 것

V2로 넘어가는 조건은 두 개였습니다. 누적 이벤트 1,500건, 주간 활성 유저 50명. 하나는 여섯 배로 넘겼고(9,214건) 하나는 세 명이 모자랍니다(47명). 병목은 끝까지 기술이 아니라 사람이었습니다. 이벤트야 헤비유저가 쌓아주지만 Bandit이 배워야 하는 건 사람들 사이의 차이라서, 머릿수는 다른 걸로 대체가 안 됩니다.

대조군 없는 관찰 데이터라는 한계도 그대로 남아 있습니다. 70.5%가 잔소리의 효과인지, 잔소리 없이도 돌아왔을 사람들인지는 A/B 테스트 전까지 모릅니다.

그래도 학기 초의 그 결정 — 데이터 0건인 상태에서 모델을 욱여넣지 않고 모델이 설 자리를 먼저 만든 것 — 은 옳았다고 생각합니다. 덕분에 "ML 없는 ML 프로젝트"는 학기가 끝난 지금 만 건 규모의 라벨 달린 데이터와 검증된 수집 구조, 시뮬레이션으로 확인한 다음 스텝을 가진 프로젝트가 됐습니다.

모델은 아직 없으나 저희는 모델이 설 자리를 확고히 만들었습니다.

그 자리가 채워지면 제일 먼저 확인하고 싶은 게 있습니다. 학습을 마친 모델이 저한테는 어떤 잔소리를 할까요?