GreedyRunner 리뉴얼: 화면 흐름부터 점수 알고리즘까지
흩어져 있던 홈·시간대·준비물·팁 화면을 하나의 러닝 준비 흐름으로 잇고, 한여름에 어긋나던 달리기 점수 알고리즘까지 다시 손본 과정을 정리했습니다.
여름 밤, 시원한 공기를 기대하며 창밖을 내다보지만 감이 잘 오지 않아 GreedyRunner를 켰습니다. GreedyRunner는 현재 날씨와 대기질을 바탕으로 “지금 달려도 될까?”를 0~100점으로 알려주는 러닝 컨디션 확인 서비스입니다.

그런데 기온이 28°C를 가리키는 날, 화면에는 90점대 점수와 함께 “달리기 최적”에 가까운 평가가 떠 있었습니다. 공기는 깨끗했지만 몸으로 느끼는 날씨는 분명 달리기 좋은 상태와는 거리가 있었습니다. 화면의 숫자와 실제 체감이 어긋난 순간이었습니다.
점수만의 문제는 아니었습니다. 사용자는 점수를 확인한 뒤 자연스럽게 다음 질문으로 넘어갑니다.
“그럼 몇 시에 나가지?”
“뭘 입고 나가지?”
“오늘 같은 날 조심할 건 없나?”
하지만 리뉴얼 전 GreedyRunner의 화면들은 이 질문의 흐름을 따라가지 못했습니다. 홈, 시간대, 준비물, 팁 화면은 각각 기능을 갖고 있었지만 서로 느슨하게 떨어져 있었습니다. 사용자가 직접 화면을 오가며 정보를 조합해야 했습니다.
이번 리뉴얼은 이 두 가지 문제에서 출발했습니다. 하나는 흩어진 화면을 하나의 러닝 준비 흐름으로 잇는 것, 다른 하나는 체감과 어긋난 점수 알고리즘을 다시 보정하는 것이었습니다.
리뉴얼의 방향
이번 리뉴얼은 크게 네 가지 축으로 진행했습니다.


첫째, 화면 흐름을 다시 설계했습니다. 홈에서 현재 상태를 확인하고, 시간대에서 나가기 좋은 시간을 고른 뒤, 준비물과 팁으로 이어지는 흐름을 만들었습니다.
둘째, 팁의 역할을 분리했습니다. 오늘 조건에 맞춰 보여주는 코칭형 팁과, 평소에 둘러볼 수 있는 전체 팁 라이브러리를 분리했습니다.
셋째, 디자인 톤을 통일했습니다. 화면마다 직접 지정하던 색상 값을 시맨틱 디자인 토큰으로 정리해 라이트 모드와 다크 모드가 자연스럽게 맞물리도록 했습니다.
넷째, 점수 알고리즘을 보정하고 테스트로 고정했습니다. 한여름 더위와 고온다습한 조건이 점수에 충분히 반영되지 않던 문제를 수정하고, 같은 문제가 다시 생기지 않도록 회귀 테스트를 추가했습니다.
겉으로 보이는 UX와 안쪽의 점수 로직을 따로 고친 것이 아니라, 둘 다 같은 방향을 바라보게 하는 작업이었습니다. 목표는 단순했습니다. 화면이 말하는 러닝 컨디션이 사용자의 실제 체감과 더 가까워지게 만드는 것이었습니다.
부족했던 건 기능이 아니라 흐름이었다
리뉴얼 전 GreedyRunner는 기능이 부족한 앱은 아니었습니다. 러닝 지수도 있었고, 시간대별 예보도 있었고, 준비물과 팁도 있었습니다. 문제는 이 기능들이 하나의 사용 흐름으로 이어지지 않았다는 점입니다.
사용자가 실제로 앱을 쓰는 순서는 꽤 분명합니다. 먼저 “지금 나가도 되나?”를 확인합니다. 애매하면 “그럼 언제가 더 좋지?”를 봅니다. 시간을 정하면 “그 시간엔 뭘 챙겨야 하지?”를 궁금해합니다. 마지막으로 “오늘 같은 날 조심할 점은 없나?”를 확인합니다.
하지만 기존 화면은 이 순서를 안내하지 않았습니다. 각각의 화면은 정보를 갖고 있었지만, 다음 질문으로 자연스럽게 이어지지 않았습니다. 그래서 이번 개편의 핵심을 “기능 추가”가 아니라 이미 있는 기능을 사용자의 생각 순서대로 연결하는 것으로 잡았습니다.
화면 수를 늘리기보다 화면 사이의 맥락을 만드는 데 집중했습니다.
오늘에서 시간대, 준비물, 팁까지 잇기


가장 먼저 한 일은 네 개의 주요 화면을 하나의 흐름으로 엮는 것이었습니다.
홈에서는 현재 러닝 지수를 보여줍니다. 사용자가 “지금은 애매한데 다른 시간은 어떨까?”라고 생각할 수 있도록 시간대 화면으로 이어지는 흐름을 만들었습니다.
시간대 화면에서는 가장 좋은 시간대를 고를 수 있습니다. 시간을 고르면 그 선택이 준비물 화면으로 이어집니다. 예를 들어 “오후 8시에 달리기 좋다”는 판단을 했다면, 준비물 화면도 오후 8시 조건을 기준으로 옷차림, 수분, 자외선, 비 여부 등을 보여줘야 자연스럽습니다.
준비물 화면 아래에는 오늘 조건에 맞는 팁으로 이어지는 CTA를 두었습니다. 팁 화면에서는 상황별 조언을 확인하고, 필요하면 전체 팁 목록으로 넘어갈 수 있게 했습니다.
이 과정에서 중요한 판단이 하나 있었습니다. 홈은 언제나 “현재 상태”를 보여주는 입구로 두되, 시간대·준비물·팁 화면은 사용자가 선택한 시각을 공유하게 한 것입니다.
처음에는 각 화면이 각자 자기 기준으로 데이터를 해석해도 충분해 보였습니다. 하지만 실제 사용 흐름에서는 그렇지 않았습니다. 사용자가 시간대 화면에서 오후 8시를 골랐다면, 그 다음 화면들도 같은 오후 8시를 기준으로 말해야 합니다. 그래야 화면들이 따로 노는 느낌이 사라집니다.
결국 화면을 잇는다는 것은 단순히 버튼을 추가하는 일이 아니었습니다. 화면들이 같은 맥락을 공유하게 만드는 일이었습니다.
팁은 ‘오늘의 답’과 ‘라이브러리’로 나누기
리뉴얼 과정에서 팁 화면은 한 번 방향을 바꿨습니다.
처음에는 하단 탭의 팁 메뉴를 누르면 오늘 조건에 맞춘 팁 상세가 바로 뜨도록 만들었습니다. 지금 날씨에 맞는 조언을 빠르게 보여주려는 의도였습니다.
그런데 직접 써 보니 어색했습니다. 하단 탭의 “팁”은 사용자가 전체 팁을 둘러보는 입구처럼 기대하는 메뉴였습니다. 그런데 매번 오늘 조건에 맞춘 하나의 팁 상세로 바로 들어가면, 전체 팁 라이브러리를 탐색하기 어려웠습니다.
그래서 팁을 두 갈래로 나눴습니다.
오늘 조건 팁은 현재 기온, 습도, 자외선, 대기질 등을 바탕으로 지금 필요한 조언을 하나 골라 보여주는 화면입니다. 홈이나 준비물 화면처럼 현재 상황을 보고 있는 흐름 안에서 진입하게 했습니다.
반면 전체 팁 목록은 평소에 둘러볼 수 있는 라이브러리로 두었습니다. 페이스, 기상, 영양, 장비, 회복, 호흡 같은 카테고리와 검색 기능을 통해 사용자가 원하는 팁을 직접 찾을 수 있게 했습니다. 하단 탭바의 팁 메뉴도 이 전체 목록으로 연결했습니다.
같은 “팁”이라도 사용 목적은 달랐습니다. 하나는 지금 상황에 대한 답이고, 다른 하나는 지식을 쌓아 두는 라이브러리입니다. 진입 경로를 분리하니 각 화면의 역할이 더 분명해졌습니다.
색을 역할로 부르기
화면 흐름을 정리하고 나니, 이번에는 디자인 톤의 불일치가 눈에 들어왔습니다. 일부 화면은 따뜻한 종이 느낌의 배경을 쓰고 있었고, 일부 요소는 Tailwind의 text-gray-800, bg-blue-500 같은 색상 클래스를 직접 사용하고 있었습니다.
이 방식은 빠르게 만들 때는 편하지만, 화면이 늘어나면 관리가 어려워집니다. 특히 다크 모드에서는 문제가 더 잘 드러납니다. 특정 화면에서는 어둡게 잘 보이던 색이 다른 화면에서는 튀거나, 다크 모드에서 대비가 깨지는 일이 생깁니다.
그래서 색을 실제 색상명이 아니라 역할 기준으로 다시 정리했습니다.
// 하드코딩된 색상
<p className="text-gray-800">...</p>
<button className="bg-blue-500 text-white">...</button>
// 역할 기반 시맨틱 토큰
<p className="text-ink">...</p>
<button className="bg-accent text-white">...</button>
text-gray-800은 “짙은 회색”이라는 겉모습을 말합니다. 반면 text-ink는 “본문 색”이라는 역할을 말합니다.
배경은 paper, 카드 표면은 panel, 본문은 ink, 보조 텍스트는 muted와 faint, 강조색은 accent처럼 정리했습니다. 실제 색상 값은 CSS 변수로 두고, 라이트 모드와 다크 모드에서 각각 다르게 매핑했습니다.
이렇게 바꾸니 화면마다 dark: 예외를 계속 붙이지 않아도 전체 톤이 자연스럽게 맞았습니다. 색을 “무슨 색인가”가 아니라 “어떤 역할인가”로 부르기 시작하니, 일관성은 별도로 관리해야 하는 일이 아니라 기본 구조가 되었습니다.
준비물 카드에 이미 있던 데이터를 다시 쓰기
시안에는 준비물 카드마다 옷, 모자, 러닝화, 물방울 같은 아이콘이 있었습니다. 그런데 실제 홈 화면의 준비물 미리보기는 알약 모양 배지만 나열하고 있었습니다. 처음에는 아이콘을 새로 만들어야 하나 생각했습니다.
확인해 보니 준비물 데이터에는 이미 icon 필드가 있었습니다. shirt, glasses, footprints, droplets 같은 값이 들어 있었고, 전체 준비물 화면에서는 이 값을 아이콘 컴포넌트로 연결해 사용하고 있었습니다. 문제는 데이터가 없던 것이 아니라, 홈 미리보기 카드가 그 데이터를 충분히 활용하지 못하고 있던 것이었습니다.
그래서 홈의 준비물 카드를 다시 구성했습니다. 새 아이콘을 만들지 않고 기존 icon 값을 그대로 사용했습니다. 여기에 짧은 맥락값도 함께 붙였습니다. 옷차림에는 24°C, 모자에는 UV 보통, 수분에는 습도 58%처럼 사용자가 판단할 수 있는 값을 함께 보여줬습니다.
좋은 조건은 강조색으로, 주의가 필요한 항목은 경고색으로 표시했습니다. 덕분에 사용자는 준비물 목록을 읽기 전에 먼저 “오늘은 무엇을 조심해야 하는지”를 한눈에 파악할 수 있게 됐습니다.
이번 작업에서 얻은 교훈은 단순했습니다. 새로 만들기 전에 이미 있는 데이터를 다시 확인해야 한다는 것입니다. 필요한 것은 새 코드가 아니라, 기존 데이터의 표현 방식을 바꾸는 일일 때가 많았습니다.
한여름에 어긋나던 점수 바로잡기
이번 리뉴얼에서 가장 오래 붙잡은 부분은 점수 알고리즘이었습니다. 특히 28°C 여름날에 90점대가 나오던 문제가 컸습니다.
알고리즘을 따라가 보니 원인은 분명했습니다. 러닝 점수는 대기질 70%, 기상 30% 비중으로 계산하고 있었습니다. 그중 기온의 영향은 10%에 불과했습니다.
기온 자체의 점수는 낮게 나오더라도, 최종 점수에서는 영향이 작았습니다. 예를 들어 28°C가 기온 점수에서 30점 정도의 페널티를 만든다 해도, 최종 점수에서는 약 3점 정도만 깎이는 구조였습니다. 자외선이 낮은 흐린 날이나 저녁에는 더위가 거의 반영되지 않았고, 공기만 깨끗하면 96~97점까지 올라갈 수 있었습니다.
단순히 기온 가중치를 키우는 방법도 생각해 볼 수 있었습니다. 하지만 그러면 전체 점수 체계가 흔들릴 수 있습니다. 미세먼지, 초미세먼지, 오존, 강수, 자외선 등 다른 요소들과의 균형도 다시 조정해야 합니다.
그래서 가중치를 키우는 대신 상한을 두는 방식을 적용했습니다. 이미 미세먼지, 자외선, 강수 조건에서 사용하던 방식입니다. 특정 요인이 극단적이면 아무리 다른 조건이 좋아도 최종 점수가 일정 수준 이상 올라가지 못하게 막는 방식입니다.
더위에도 같은 원리를 적용했습니다. 25°C부터 점수 상한을 낮추기 시작해, 35°C에서는 최대 30점까지만 받을 수 있도록 했습니다.
// 무더위 상한
// 25°C에서는 최대 90점, 35°C에서는 최대 30점
// 35°C 이상은 30점으로 고정
if (w.temperature >= 25) {
cap = Math.min(cap, Math.max(30, 90 - (w.temperature - 25) * 6))
}
이 로직은 점수를 무조건 깎는 것이 아니라 천장을 씌우는 장치입니다. 공기질이 아무리 좋아도 한여름 더위라면 “최적”까지는 올라가지 못하게 합니다. 반대로 이미 다른 요인 때문에 점수가 낮은 경우에는 불필요하게 더 낮추지 않습니다.
그 결과 28°C 흐린 날의 점수는 90점대에서 72점으로 내려왔습니다. “달리기 최적”이 아니라 “달리기 좋음” 정도의 판단이 된 것입니다. 체감과 훨씬 가까워졌습니다.
습도는 더위와 함께 봐야 했다
기온을 손보고 나니 습도도 같은 문제를 갖고 있다는 것이 보였습니다.
습도는 기존 알고리즘에서 비중이 낮았습니다. 기온보다도 영향이 작았고, 습도와 더위가 함께 있을 때의 위험은 자외선이 강한 낮에만 크게 반영되고 있었습니다.
하지만 실제로 힘든 날은 꼭 햇볕이 강한 낮만이 아닙니다. 장마철처럼 흐린데 덥고 습한 날, 또는 저녁인데도 후텁지근한 날도 달리기에는 부담이 큽니다. 땀이 잘 증발하지 않아 체온 조절이 어려워지기 때문입니다.
그래서 습도 로직을 단독 요소가 아니라 기온과 결합된 체감 더위로 다시 봤습니다. 자외선은 조건이 아니라 증폭 요소로만 남겼습니다.
핵심은 세 가지였습니다.
- 20°C 미만에서는 습도가 높아도 큰 위험으로 보지 않는다.
- 습도 60% 이하에서는 습열 감점을 주지 않는다.
- 기온과 습도가 함께 올라갈수록 감점이 커지게 한다.
// 습열 감점
// 기온과 습도의 결합으로 체감 더위를 계산하고,
// 자외선은 강한 햇볕일 때의 증폭 요소로만 사용한다.
if (temp < 20) return 0
const tempExcess = Math.min(temp - 20, 15)
const humExcess = humidity > 60 ? Math.min((humidity - 60) / 40, 1) : 0
let penalty = tempExcess * humExcess * 5.5
수정 전후를 비교하면 차이가 더 분명합니다.
| 조건 | 이전 | 이후 |
|---|---|---|
| 28°C · 저UV · 청정 공기 | 96~97점, 최적 | 72점, 좋음 |
| 28°C · 습도 45% | 약 72점 | 72점, 좋음 |
| 28°C · 습도 85% | 약 72점 | 51점, 주의 |
| 33°C · 습도 80% · 흐림 | 약 96점, 최적 | 39점, 자제 |
| 20°C · 습도 95% | 96점 | 96점, 유지 |
이번 보정에서 가장 중요하게 본 것은 건조한 28°C와 습한 28°C를 구분하는 것이었습니다. 같은 기온이라도 습도가 높으면 달리기 부담은 훨씬 커집니다. 반대로 20°C처럼 비교적 시원한 날은 습도가 높아도 같은 방식으로 위험하게 볼 필요는 없습니다.
가중 평균만으로는 이런 차이가 잘 드러나지 않았습니다. 비중이 낮은 요소는 쉽게 묻힙니다. 그래서 실제 체감에 큰 영향을 주는 조건에는 상한과 강제 감점 같은 별도 장치를 두었습니다. 덕분에 전체 점수 체계를 크게 흔들지 않으면서도 더위와 습도의 목소리를 점수에 반영할 수 있었습니다.
테스트로 판단을 고정하기
점수 알고리즘은 계수 하나만 바뀌어도 결과가 크게 달라집니다. 지금은 체감과 맞게 고쳐 놓았더라도, 다음 수정에서 다시 어긋날 수 있습니다. 그래서 이번 리뉴얼에서는 백엔드 테스트 환경을 새로 깔고, 이번에 고친 판단들을 회귀 테스트로 고정했습니다.
Jest와 Supertest를 붙였습니다. 설치 과정에서 작은 문제가 하나 있었습니다. 처음에는 최신 Jest가 설치되면서 내부 패키지 버전이 맞지 않아 clearMocksOnScope is not a function 오류가 났습니다. Jest 버전을 한 단계 낮춰 주변 도구와 맞추니 정상적으로 실행됐습니다. 최신 버전이 항상 가장 안정적인 선택은 아니라는 점을 다시 확인한 순간이었습니다.
테스트는 네 가지로 나눴습니다.
- 무더위 상한과 습열 감점을 검증하는 점수 알고리즘 테스트
- 좌표와 검색어 입력값을 검증하는 스키마 테스트
- 지역 검색 랭킹과 최근접 좌표를 확인하는 Mock 클라이언트 테스트
- 실제 HTTP 요청으로 엔드포인트를 확인하는 통합 테스트
통합 테스트는 외부 API에 의존하지 않도록 구성했습니다. 테스트 실행 전 API 키를 비워 Mock 클라이언트가 사용되게 강제했습니다. 덕분에 네트워크 상태나 외부 API 응답에 흔들리지 않고 항상 같은 결과를 확인할 수 있습니다.
현재는 네 개 스위트, 총 42개 테스트가 통과합니다.
테스트를 짜는 과정에서 부작용도 하나 발견했습니다. 건조한 22°C 조건이 100점이 아니라 96점으로 나오는 문제가 있었습니다. 습열 감점이 의도보다 넓게 적용되고 있던 것입니다. 테스트 덕분에 이 문제를 발견했고, 건조한 날에는 습열 감점이 작동하지 않도록 다시 조정했습니다.
테스트는 이미 고친 것을 지키는 안전망이기도 하지만, 동시에 미처 보지 못한 부작용을 드러내는 돋보기이기도 했습니다.
남은 작업
이번 리뉴얼로 화면 흐름과 점수 로직의 큰 방향은 정리됐습니다. 하지만 아직 남은 작업도 있습니다.
먼저 실제 대기질·기상 API 클라이언트 테스트가 필요합니다. 지금은 도메인 로직, 입력 스키마, 엔드포인트 테스트까지는 갖췄지만, 외부 API 응답을 파싱하고 결측값이나 지연 상황에 대응하는 로직은 충분히 검증하지 못했습니다. 다음 단계에서는 fetch를 목킹해 응답 지연, 누락 필드, 비정상 응답을 재현하는 테스트를 추가할 계획입니다.
점수 알고리즘도 계속 다듬어야 합니다. 이번에는 기온과 습도에 집중했지만, 바람과 대기질의 조합처럼 아직 체감과 비교해 봐야 할 구간이 남아 있습니다. 무더위 상한을 25°C부터 적용하는 것이 적절한지도 실제 여름 데이터를 더 보며 조정할 여지가 있습니다.
결국 점수 알고리즘은 한 번에 완성되는 규칙이라기보다, 계절을 지나며 체감과 계속 대조해야 하는 모델에 가깝습니다. 이번 리뉴얼은 그 기반을 다시 잡은 작업이었습니다.
마치며
이번 GreedyRunner 리뉴얼은 화면과 로직을 함께 손본 작업이었습니다.
겉으로는 홈, 시간대, 준비물, 팁으로 흩어져 있던 화면을 사용자의 생각 순서에 맞게 다시 이었습니다. 안쪽으로는 한여름과 고온다습한 조건에서 과하게 높게 나오던 점수 알고리즘을 보정했습니다.
돌아보면 중요한 판단은 세 가지였습니다.
첫째, 부족한 것은 기능이 아니라 흐름이라고 본 것입니다. 기능을 더 넣기보다 이미 있는 기능을 사용자의 의사결정 순서대로 연결하는 데 집중했습니다.
둘째, 색을 겉모습이 아니라 역할로 부르기로 한 것입니다. gray, blue 같은 색상값 대신 ink, paper, panel, accent 같은 역할 기반 토큰을 사용하니 화면 간 일관성을 훨씬 쉽게 유지할 수 있었습니다.
셋째, 낮은 비중의 위험 요소에는 단순 가중치보다 상한과 강제 감점이 더 적절할 수 있다는 점입니다. 기온과 습도는 전체 점수에서 비중이 작더라도 실제 러닝 체감에는 큰 영향을 줍니다. 그래서 단순 평균 안에 묻히게 두지 않고, 특정 조건에서는 점수의 천장을 낮추거나 별도 감점을 적용했습니다.
개인 프로젝트를 오래 다듬다 보면 기능은 많은데 이상하게 손이 가지 않는 순간이 있습니다. 그럴 때는 기능이 부족한지보다 먼저, 사용자의 생각 흐름과 화면의 흐름이 맞는지 보는 편이 더 도움이 되는 것 같습니다.
이번 리뉴얼도 결국 그 질문에서 시작했습니다.
“사용자는 이 화면 다음에 무엇을 궁금해할까?”
그 질문을 따라가다 보니 화면의 연결도, 점수 알고리즘의 기준도 조금 더 자연스러운 방향으로 정리할 수 있었습니다.
러닝 팁은 지금도 계속 보완해 가고 있습니다. 언젠가는 초보 러너가 달리기 전 궁금한 것을 구글에 검색했을 때, 첫 페이지 어딘가에서 GreedyRunner를 만날 수 있으면 좋겠습니다.
