Skip to main content
← 블로그

UX를 다듬은 하루 — 생성 직후의 한 프레임부터 기여자에게 보내는 카드까지

VauDium·

새 기능은 없었습니다. 만든 직후의 빈 순간을 채웠고, 제안은 넓히되 화면은 비웠고, 글자 세는 단위를 맞췄고, 시간 휠을 가장자리에서 떼어 놓았고, 기여자가 된 사람에게 처음으로 소식을 전했습니다. 다듬기만 한 하루입니다.

UX를 다듬은 하루 — 생성 직후의 한 프레임부터 기여자에게 보내는 카드까지

오늘은 새 기능을 하나도 만들지 않았습니다. 대신 앱과 서버에 커밋이 스물네 개 쌓였고, 전부 이미 있는 경험을 손보는 일이었습니다. 무언가를 만든 직후 목록이 비어 보이던 한 프레임, 개선 제안이 보지 못하던 필드들, 이모지 뒤에서 줄 서식이 사라지던 에디터, 시간을 고르다 화면이 통째로 내려오던 iOS, 그리고 기여자로 지정된 사람에게 페칫이 아무 말도 하지 않던 일. 다듬기는 보통 더하는 일로 보이지만, 오늘은 채우기·넓히기·비우기·떼어 놓기·알리기가 골고루 섞였습니다.

1. 만든 직후의 한 프레임 — 자리표시자를 착지 자리에

할 일을 만들고 작성 화면을 닫으면, 서버 응답이 오기까지 목록에 잠깐 아무것도 없습니다. 짧지만 “저장이 된 건가?“를 묻게 하는 순간이에요. 오늘 이 순간을 채웠습니다. 작성 화면을 닫는 순간 반투명하고 조작할 수 없는 임시 행이 목록에 먼저 그려지고, 실제 행이 도착하면 그 자리를 넘겨줍니다.

처음엔 임시 행을 목록 맨 위에 고정했는데, 실제 행이 도착하면 다른 자리로 튀어 들어갔습니다. 그래서 규칙을 바꿨습니다. 임시 행은 실제 행이 착지할 자리에 그린다. 진행 중 구간이 있으면 그 안에, 아니면 정렬 규칙이 정하는 자리에. 방금 완료한 할 일을 목록에 끼워 넣던 정렬 헬퍼를 꺼내 임시 행과 공유했습니다.

한 프레임짜리 점프도 하나 잡았습니다. 임시 행을 지우는 것과 실제 행을 넣는 것이 다른 프레임에 일어나면 목록이 잠깐 비어 보였다가 채워집니다. 로컬 캐시 재조회를 useEffect에서 useLayoutEffect로 옮겨 두 일이 같은 프레임에 반영되게 했습니다.

그 다음은 범위였습니다. 할 일 하나로 끝내지 않고 모바일의 생성 흐름 스무 곳에 같은 문법을 깔았습니다. 메모, 할 일·메모 템플릿, 프로젝트, 꼬리표, 연락처, 준비물 카탈로그, 마일스톤, 단계, 그리고 문서 상세 안의 웹 링크·준비물·대응 지침·연락처 SNS 항목까지. 달력의 주간·월·일 화면과 칸반에도 임시 모델이 합류합니다. 루틴 고스트와 같은 반투명 문법이라 화면에 낯선 요소가 늘지는 않았습니다.

여기서 지킨 규칙이 둘 있습니다. 임시 id는 렌더 전용입니다. 로컬 캐시에도, 서버로 가는 요청에도 절대 들어가지 않습니다. 그리고 드래그로 순서를 바꾸는 목록에서는 임시 행을 데이터 밖에 그립니다. 칸반 카드, 단계 목록, 대응 지침, 준비물 분류처럼 이웃 순서를 계산해 서버로 보내는 곳에서는 임시 행이 데이터 배열에 끼면 순서 계산에 섞여 들어가거든요. 그래서 그런 목록은 footer에 따로 그립니다. 길드만 제외했습니다. 유료 생성이고 만들면 바로 이동하니 자리표시자가 설 자리가 없습니다.

2. 개선 제안 — 신호는 넓히고, 화면은 비우고

페칫의 개선 제안은 할 일을 실행한 기록과 그 원본 템플릿을 비교해 “이번 실행에서 달라진 것 중 템플릿에 반영할 가치가 있는 것”을 골라 보내는 기능입니다. 지금까지 비교 대상은 제목·설명·단계 내용 같은 몇 가지였습니다.

오늘 비교 항목을 여덟 개 더했습니다. 현재·희망·난관·위험 부담이라는 의도 필드 넷, 장소, 알림, 난이도, 그리고 단계 순서. 현재와 희망은 처음에 “회차마다 달라지는 값이라 템플릿으로 갈 게 아니다”라고 판단해 뺐는데, 결정은 “모두 다”였습니다. 사용자가 정한 범위를 제 분류로 좁히지 않는 것이 맞았습니다. 단계 순서는 신중하게 갔습니다. 갈래가 없는 직선 사슬일 때만 비교하고, 갈래가 있으면 침묵합니다. 틀린 제안을 하는 것보다 안 하는 게 낫습니다.

반대로 하나는 빼기로 확정했습니다. 시작 시간대입니다. 루틴이 발화할 때 시각은 클라이언트가 “다음 정시”로 정하고 템플릿의 시작 시각을 읽지 않습니다. 그러니 템플릿에 시작 시각을 반영해도 다음 발화가 달라지지 않습니다. 적용해도 나아지는 게 없는 제안은 장식이라서 넣지 않았습니다.

화면은 반대 방향으로 갔습니다. 보내기 화면에 있던 “n/N 선택” 카운트 행과 전체 선택·전체 해제 버튼을 전부 걷어냈습니다. 기본이 전체 선택이니, 빼고 싶은 것만 항목별 체크박스로 해제하면 됩니다. 탑바와 헤더는 비웠습니다. 그리고 각 작문 필드 에디터에서 “템플릿에 반영”을 누르면 그 필드의 제안만 뜨도록 스코프를 좁혔습니다. 전체 diff는 ⋯ 메뉴에서 그대로 볼 수 있습니다. 서버·모바일·데스크톱 전수 리뷰도 같은 날 했는데, 요청이 실패하면 스피너가 멈춰 있던 것, 이미 보낸 건수가 다른 종류까지 세던 것, 실시간 재연결 뒤 배지가 안 맞던 것 같은 자잘한 어긋남을 정리했습니다. 정책도 하나 정했습니다. 프로젝트 멤버는 자기 기록으로 소유자 템플릿에 제안을 보낼 수 있고, 즉시 반영은 소유자만 할 수 있습니다.

3. 글자를 세는 단위 — 이모지 뒤에서 사라지던 서식

모바일 설명 에디터는 네이티브로 짜여 있고, iOS와 Android와 JS가 같은 문서 포맷을 각자 파싱합니다. 리뷰에서 나온 가장 아픈 버그는 이거였습니다. 이모지를 쓴 줄 뒤에서 다음 키를 누르면 불릿이나 체크박스가 평범한 문단으로 바뀐다.

원인은 글자를 세는 단위였습니다. iOS 쪽 모델이 오프셋을 눈에 보이는 글자 수로 세고 있었는데, JS·Android·속성 문자열은 전부 UTF-16 단위로 셉니다. 이모지 하나가 한쪽에선 1이고 다른 쪽에선 2가 되니, 이모지 뒤의 모든 위치가 밀려서 줄 타입과 서식 범위가 어긋난 겁니다. iOS 오프셋을 전부 UTF-16으로 통일했습니다. Android에도 짝이 있었습니다. 표 한 줄을 두 글자로 세고 있어서, 표 뒤의 오프셋이 전부 하나씩 밀렸습니다. 표 뒤 체크박스가 안 눌리고, 두 번째 표가 안 지워지던 이유입니다.

입력 쪽도 손봤습니다. 한글을 조합하는 중에 툴바 명령이나 체크 탭을 누르면 조합을 먼저 확정하고 명령을 수행합니다. 조합 중에는 화면 재구성이 무시되어 모델만 바뀌고 화면은 그대로인 어긋남이 있었거든요. 포커스를 잃을 때 저장하는 값도 prop이 아니라 네이티브가 마지막으로 준 문서로 바꿨습니다. 자동교정이 blur와 같은 틱에 먼저 도착하면 옛 값을 저장하던 누락이 있었습니다. 이 모듈에 손댈 때 지킬 불변식이 몇 개 생겼는데, 오프셋은 UTF-16 API만 쓰기, 조합 중 명령은 확정 먼저, 두 가지가 핵심입니다.

4. 프로젝트 — 사라지지 않아야 할 것과 사라져야 할 것

프로젝트 영역도 전수 리뷰를 했고, 모바일 일곱 건과 데스크톱 여섯 건과 서버 몇 건을 고쳤습니다. UX로 읽히는 것만 몇 개 적습니다.

데스크톱에서 워크스페이스 스코프가 가끔 혼자 “나”로 돌아가는 일이 있었습니다. 스코프를 검증하는 코드가 모든 오류를 “삭제됐거나 추방됐다”로 취급해서, 네트워크가 잠깐 끊기거나 개발 서버가 리로드될 때도 저장된 스코프를 지웠던 겁니다. 이제 403과 404일 때만 복귀합니다. 반대로 사라져야 할 것도 있었습니다. 추방되거나 삭제된 프로젝트가 목록에 그대로 남아 있던 것을 실시간 이벤트로 즉시 지웁니다.

모바일 리뷰 화면에서 만족도를 탭하면 쓰고 있던 결과·회고가 서버 값으로 덮이던 것도 고쳤습니다. 입력 중인 글이 사라지는 건 어떤 이유로도 정당화되지 않습니다. 스코프 전환 화면의 프로젝트 행 오른쪽에는 > 버튼을 달아 스코프는 유지한 채 프로젝트를 열 수 있게 했고, 프로젝트 스코프에서는 어시스턴트 탭을 숨겼습니다. 데스크톱에는 일지 서브뷰와 정보 모달을 새로 얹어 모바일과 대칭을 맞췄습니다. 문구도 하나 정했습니다. 멤버를 내보내는 건 “추방”입니다.

5. 시간 휠을 끌면 화면이 내려온다 — 범인은 앱 밖에 있었다

제보는 이랬습니다. “날짜 시트에서 시간을 맞추다가 잘못 끌면 화면 전체가 아래로 내려오는 그 기능이 켜지는데, 끌 수 없나?”

날짜 시트는 바닥에서 올라오는 시트이고, 시간 휠은 그 시트의 맨 아래 요소입니다. 휠 아래에는 홈 인디케이터 높이만큼의 안전 여백만 있었습니다. 그러니 휠의 맨 아래 항목을 잡고 아래로 끌면, 손가락의 출발점이 화면 하단 가장자리에 아주 가깝습니다.

그 자리에서 아래로 쓸어내리는 동작은 iOS의 **간편 접근(Reachability)**입니다. 화면 전체를 반쯤 끌어내려 위쪽을 한 손으로 닿게 해 주는 시스템 기능이죠. 앱 코드는 이 동작에 관여하지 않고, 끌 수 있는 API도 없습니다. 사용자가 설정에서 직접 꺼야만 사라집니다.

그래서 “끄는” 대신 떼어 놓기로 갔습니다. 휠 아래에 20pt 여백을 넣어 드래그 출발점을 가장자리 판정 구역 밖으로 밀어냈습니다. 그런데 여백만 넣으니 시트 바닥이 그냥 비어 보였습니다. 그래서 여백 위에 시트의 다른 행들과 같은 0.5pt 구분선을 그었습니다. 이제 그 자리는 빈 공간이 아니라 시트의 바닥 띠로 읽힙니다. 화면의 가장자리는 우리 것이 아닙니다. 시스템이 먼저 듣는 띠가 있고, 자주 만지는 컨트롤은 그 띠에서 떨어져 있어야 합니다.

6. 사용 시간 분석 — 만들 수 있어서 만드는 건 아니다

이건 코드가 아닙니다. “일간·주간 사용 시간 분석을 넣으면 어떤 모습일까”라는 질문이 나왔습니다.

먼저 “사용 시간”이 무엇인지 갈랐습니다. 앱을 켜 둔 시간이라면, 페칫은 사람이 앱에 오래 머무는 걸 목표로 삼지 않으니 보여 줄 이유가 없습니다. 할 일을 시작해서 완료하기까지의 시간이라면 데이터는 이미 있습니다. 통계 화면의 평균 완료 시간 카드가 같은 값을 쓰고 있으니, 요일별 막대 일곱 개짜리 카드를 그리는 건 어렵지 않았습니다.

그런데 그 카드가 결과를 더 낫게 만드는 지점을 찾지 못했습니다. 시작을 누르고 잊은 할 일, 며칠에 걸친 할 일이 하루 합계를 크게 왜곡합니다. 평균 카드도 그 이유로 설명 모달을 따로 달아야 했는데, 합계는 왜곡에 더 약합니다. 그리고 “이번 주 몇 시간 했다”는 숫자는 실행 하나에 대한 피드백이 아니라 사람에 대한 점수가 되기 쉽습니다. 적은 주에 죄책감을 주고, 다음 실행을 바꾸지는 못하는 종류의 숫자요. 그래서 넘어갔습니다. 만들 수 있다는 사실과 만들어야 한다는 판단은 다른 문장입니다.

7. 기여자가 되었는데 아무도 말해 주지 않았다

마지막은 정말로 비어 있던 자리였습니다.

페칫에는 기여자가 있습니다. 관리자가 지정하면 무료 사용 상한이 전부 면제되는 계정이에요. 그런데 확인해 보니, 관리자가 누군가를 기여자로 지정하면 서버는 플래그 하나를 켜고 관리자 행동 이력을 남길 뿐이었습니다. 실시간 이벤트도, 알림도, 화면 연출도 없었습니다. 지정된 사람은 우연히 설정 화면을 열어야만 “기여자라서 제한 없이 쓸 수 있어요”라는 문장을 보게 됩니다.

비교 대상이 바로 옆에 있었습니다. 관리자가 사용권을 지급하면 다음 접속 때 별이 터지는 카드가 뜹니다. 그 카드가 “이미 봤는지”를 판단하는 방식이 이번 설계의 뼈대가 됐습니다. 사용권은 장 단위로 봤는지를 판단합니다. 지급된 장에는 “아직 안 봄” 표시가 붙고, 사용자가 카드에서 버튼을 누르면 그 장의 표시를 끕니다. 구매한 장이나 옛 지급분에는 표시 자체가 없어서 “본 것”으로 취급되고, 그래서 배포 후 옛 카드가 뒤늦게 터지지 않습니다. “봤다”의 기준은 카드가 그려진 시점이 아니라 버튼을 눌러 닫은 시점입니다.

기여자는 장이 여러 개 쌓이는 물건이 아니라 한 계정에 켜지거나 꺼지는 상태 하나입니다. 그래서 어치버 문서에 플래그 하나를 더 얹었습니다. “기여자인가”와 “지정을 아직 안 봤는가”, 둘의 조합으로 카드가 결정됩니다.

상황 카드
새로 지정됨 뜬다
확인 버튼을 누름 안 뜬다
이미 기여자인데 다시 지정 안 뜬다
해제됨 안 뜬다
해제 후 다시 지정 다시 뜬다

카드 자체는 사용권 카드와 같은 문법입니다. 같은 별 버스트, 같은 팡 등장, 확인 버튼 하나. 아이콘은 하트를 골랐습니다. 기여는 고마움의 자리니까요. 문장은 두 줄입니다.

기여자가 되었어요 페칫을 함께 만들어 주셔서 고마워요. 이제 모든 항목을 제한 없이 쓸 수 있어요.

사용권 카드와 기여자 카드가 같은 접속에 둘 다 대기 중일 수 있어서, 기여자 카드는 사용권 카드가 떠 있는 동안 기다립니다. 두 카드가 동시에 터지면 축하가 아니라 소음입니다. 모바일과 데스크톱에 같은 카드를 같은 규칙으로 넣었습니다.

그 외 작은 것들

할 일 목록과 칸반에 꼬리표 필터를, 메모에 생성일 기간 필터를 더했습니다(모바일·데스크톱). 그리고 생성 시각이 없는 레코드가 들어오면 날짜 구분선에서 화면 전체가 죽던 크래시를 막았습니다. 구분선은 생략하고 직전 그룹에 붙이되, 어느 경로로 그런 레코드가 들어왔는지는 소스 태그를 달아 진단을 남겼습니다. 방어와 진단은 한 쌍입니다. 방어만 하면 원인이 영영 묻힙니다.

오늘의 교훈

UX를 다듬는 일은 여러 방향으로 갑니다. 채우기 — 만든 직후의 빈 프레임은 실제 행이 착지할 자리에 임시 행을 두면 사라집니다. 넓히되 비우기 — 제안은 사용자가 정한 범위 그대로 넓히고, 화면의 조작은 기본값이 좋으면 버튼을 지웁니다. 단위 맞추기 — 세 파서가 같은 포맷을 각자 구현하면 글자 세는 단위 하나로 데이터가 조용히 깨집니다. 떼어 놓기 — 화면 가장자리는 시스템의 땅이라 끌 수 없습니다. 그리지 않기 — 데이터가 있어도 그 숫자가 다음 실행을 바꾸지 못하면 화면에 올리지 않습니다. 알리기 — 인정은 실제로 일어난 일에 정확히 대응해야 하는데, 그 전에 일어났다는 사실을 전하는 것부터가 인정입니다. 기여자는 오늘부터 그 소식을 받습니다.