Code › tail-villain

제품을 쪼개지 않고 코딩 라운드 넣기

코딩 라운드를 독립 기능으로 만들면서 기존 연습 흐름과 세션 상태를 유지한 과정

코딩 라운드는 로드맵 인터뷰 허브 안에 들어 있었다.

로드맵을 만들고 주제를 고른 뒤 인터뷰하는 기존 흐름 안에서도 코딩 문제를 연습할 수 있었지만, 진입 경로가 너무 깊었다. 사용자가 코딩 문제 하나를 풀고 싶어도 로드맵 맥락을 먼저 지나야 했고, 대시보드에서도 기능의 존재가 잘 보이지 않았다.

그래서 코딩 라운드를 독립 기능으로 빼기로 했다. 다만 별도 제품처럼 갈라지면 안 됐다. tail-villain의 핵심은 여러 연습 도구를 한 대시보드 안에서 이어주는 것이고, 코딩 라운드도 그 흐름 안에 있어야 했다.


가장 먼저 정리한 것은 세션 모델이었다.

기존 InterviewSession은 로드맵 세션을 전제로 하고 있었다. 코딩 라운드를 같은 모델로 다루려면 roadmapId가 없어도 세션이 존재할 수 있어야 해서 roadmapId를 nullable로 만들고, sessionKind로 roadmap 세션과 coding round 세션을 구분했다. 코딩 문제 카탈로그도 별도 모델로 두고, Top 150 문제를 시드 데이터로 넣었다.

이 방식은 새 기능을 빠르게 붙이기에 좋았지만, 경계를 잘못 잡으면 바로 섞인다. 실제로 findActiveRoadmapSession이 sessionKind를 보지 않으면 코딩 세션을 로드맵 세션으로 착각할 수 있었다. 같은 테이블을 공유할수록 쿼리는 더 명시적이어야 한다. roadmap 문맥을 찾는 함수는 roadmap 세션만 찾아야 하고, coding round 목록은 별도의 페이지와 API에서 다뤄야 했다.

프론트에서는 dashboard/coding 페이지를 만들었다. 활성 세션이 있으면 Resume을 보여주고, 다른 문제를 시작하려면 확인을 받게 했다. 세션 목록은 클라이언트에서 최근 몇 개를 자르는 대신 서버 커서 페이지네이션으로 바꿨고, abandoned와 completed 상태도 따로 표시했다. 코딩 라운드는 짧게 시작하고 버릴 수 있는 기능이기 때문에, abandon 상태를 completed처럼 뭉개면 사용자의 흐름을 읽기 어려워진다.


대시보드도 같이 바꿨다.

코딩 라운드를 페이지 하나로 빼기만 하면 사용자는 새 기능을 어디서 찾는지 다시 배워야 하므로, 대시보드를 두 영역으로 정리해 왼쪽에는 오늘 볼 로드맵과 내 로드맵 목록을 두고 오른쪽에는 코딩 라운드와 전체 통계를 묶었다. 기존의 세 개짜리 stat card는 정보가 흩어져 보여서 bento 구조로 합쳤다.

로드맵 목록도 큰 카드 대신 얇은 row로 바꿨다. 사용자가 매번 로드맵을 읽는 것이 아니라 오늘 무엇을 이어서 할지 빠르게 찾는 화면이기 때문이다. 새 로드맵 버튼은 패널 아래로 내려서 안내 문구와 함께 두었고, 로드맵이 없을 때는 왼쪽 패널을 숨겨 빈 상태 hero가 흐름을 맡도록 했다.

이때 코딩 라운드는 Practice Tools 안에 들어갔다. Writing과 Speaking은 Coming Soon으로 남겼고, Coding Practice만 활성 기능으로 열었다. 중요한 건 코딩 라운드를 로드맵 밖으로 꺼내되, 제품 밖으로 꺼내지 않는 것이었다.


코딩 세션을 분리하자 작은 상태 문제가 따라왔다.

뒤로 가기 경로는 세션 종류에 따라 달라야 했다. 코딩 라운드에서 나온 사용자는 로드맵 상세가 아니라 dashboard/coding으로 돌아가야 하고, AI가 문제 설명을 만들 때도 사용자의 언어를 명시해야 했다. 그렇지 않으면 한국어 흐름에서도 영어 문제 설명이 나올 수 있다. 오프닝 메시지도 마크다운이 아니라 plain text로 제한해, 채팅 화면에 의도하지 않은 rich text가 렌더링되는 일을 막았다.

브랜드 이름도 seed catalog에서 정리했다. UI에서만 지우면 원본 데이터가 계속 제품 밖의 이름을 끌고 들어오기 때문에, 카탈로그 이름부터 제품이 소유하는 표현으로 바꾸는 편이 낫다.

이런 수정들은 작아 보이지만, 새 세션 종류가 기존 모델에 붙을 때 필요한 기본 정리였다. 모델을 재사용하면 개발은 빨라지지만, sessionKind를 기준으로 한 분리 규칙이 없으면 사용자의 현재 위치가 흐려진다.


다음 날에는 모바일에서 그 흐름이 깨졌다.

대시보드 bento 레이아웃은 데스크톱에서는 정리되어 보였지만, 375px 화면에서는 전략이 아니었다. xl 기준으로만 만든 그리드는 모바일에서 그대로 무너졌다. 로드맵 상세도 사이드바와 본문이 좁은 화면에 같이 남아 있었고, 인터뷰 헤더는 nav와 상태 배지가 한 줄에 몰렸다.

그래서 모바일 패스를 따로 했다. 대시보드는 한 컬럼으로 접히고, Goals와 Backgrounds 카드는 full width로 바뀌었다. 로드맵 상세는 사이드바가 아래로 내려가고 토픽 카드는 세로로 쌓였으며, 인터뷰 세션 헤더는 navigation row와 status row로 나눴고, 채팅 말풍선은 좁은 화면에서 최대 폭을 제한했다.

이 작업을 하면서 DESIGN_SYSTEM도 문서로 고정했다. 색과 표면 계층을 매번 코드에서 추적하는 방식은 오래 못 가기 때문에, page background, panel, card, input이 어떤 톤을 가져야 하는지 문서로 정리하고 이후 UI 수정 전에 그 문서를 먼저 보도록 규칙을 세웠다.

모바일은 단순히 화면을 줄이는 일이 아니었다. 터치했을 때 반응이 보이는지, 입력창을 눌렀을 때 화면이 확대되지 않는지, fixed 버튼이 iOS Safari에서 제자리에 있는지도 봐야 했다.


5월 28일에는 상태 무결성 쪽 문제가 이어졌다.

리포트 다이얼로그에서 nextReviewAt이 비어 보였다. 처음에는 InterviewSession에 컬럼을 추가하는 쪽으로 갔다가 곧 되돌렸다. 로드맵 레벨 세션에는 topic이 없고, 다음 복습일은 원래 Topic 쪽의 spaced repetition 상태에 속하기 때문이다. InterviewSession에 컬럼을 추가하면 다음 복습일의 소유 위치가 Topic과 Session으로 나뉜다.

결국 DTO 레이어에서 필요한 값을 유도하는 방식으로 돌아왔다. 값이 파생 가능하면 먼저 파생으로 해결할 수 있는지 확인해야 하고, 컬럼을 추가하는 것은 쉬워 보여도 migration 이력은 가볍지 않다.

로드맵 상세에서는 폴링이 모바일 스크롤을 얼리는 문제도 있었다. 3초마다 무조건 setRoadmap을 호출하니 화면이 계속 다시 그려져서, fingerprint를 만들어 analysisStatus, topicGenerationStatus, topics length, roadmap updatedAt, 그리고 각 topic의 최신 updatedAt이 바뀔 때만 상태를 갱신하게 했다. 처음에는 leaf node의 updatedAt을 빼먹어서 인터뷰 완료 후 HP나 score badge가 늦게 갱신될 수 있었고, 그 값까지 넣으면서 맞춰졌다.

운영 환경의 429도 같은 맥락이었다. HEAVY throttle tier가 모든 undecorated endpoint에 기본 적용되면서, 3초마다 폴링하는 GET 요청이 30초 만에 시간당 10회 제한을 다 써버렸다. 비싼 LLM 엔드포인트에만 적용되어야 할 제한이 일반 조회 API까지 덮은 것이다. HEAVY는 명시적으로 opt-in한 곳에서만 작동하도록 바꾸고, 프론트 폴링에는 429 backoff를 넣었다.


코딩 라운드를 넣는 일은 알고리즘 문제 페이지를 하나 추가하는 일이 아니었다.

새 연습 도구가 생기면 세션 종류, 활성 세션 탐색, 뒤로 가기, 목록 페이지네이션, 대시보드 배치, 모바일 레이아웃, 폴링, throttle까지 같이 흔들린다. 독립 기능으로 보여야 하지만, 제품의 나머지 흐름과 같은 상태 언어를 써야 한다.

기능을 분리하는 것과 제품을 쪼개는 것은 다르다.

코딩 라운드는 로드맵 안에 갇혀 있으면 발견하기 어렵고, 별도 섬처럼 만들면 tail-villain의 연습 흐름에서 떨어진다. 이번 작업은 그 사이의 경계를 잡는 일이었다. 사용자는 대시보드에서 자연스럽게 코딩 연습으로 들어가고, 세션은 자기 종류를 잃지 않으며, 모바일에서도 같은 흐름을 끊기지 않고 이어가야 했다.