[wandr] 개발 일지 - 기능은 다 만들었는데, 내가 쓰기 싫었다
여행 계획을 친구와 같이 짜는 웹앱을 만들고 있다. AI가 일정 초안을 잡아주고, 지도에 동선이 뜨고, 링크로 초대해서 같이 편집하고, 공유 링크로 다른 사람에게 보여줄 수도 있다. 기능만 놓고 보면 다 됐다.
그런데 몇 달째 진도가 안 나갔다. 이유는 단순했다. 내가 쓰기 싫었다.
테스트를 다 짜놓고 나서야 알았다
그전까지는 테스트 자동화에 매달렸다. "친구와 공책 하나 펴고 같이 계획 짜는 느낌"이라는 기준을 잡고, 그걸 검증할 시나리오 7개를 전부 브라우저 자동화로 만들었다. AI 위저드 완주, 직접 일정 짜기, 실수하고 되돌리기, 초대와 공유, 에러 상황, 모바일 화면까지.
이 과정에서 계속 같은 종류의 버그를 만났다. 테스트가 거짓말을 하고 있었다.
가장 어이없었던 건 모바일 시나리오였다. "가로 스크롤이 발생하지 않는다"를 검증하는 코드를 이렇게 썼다.
document.documentElement.scrollWidth - document.documentElement.clientWidth문서 전체가 가로로 넘치는지 재는 코드다. 그런데 이 앱은 레이아웃 곳곳에 overflow: hidden이 걸려 있어서, 안쪽 요소가 아무리 넘쳐도 이 값이 절대 0을 넘지 않는다. 실제로 어떤 버튼이 화면 밖으로 10px 튀어나가 있는 상태에서도 이 검증은 "이상 없음"을 반환했다. 즉 이 테스트는 실패할 수 있는 조건 자체가 존재하지 않았다.
비슷한 일이 시나리오마다 반복됐다. 공유 기능 테스트에서는 이런 코드를 썼는데,
await expect(page.getByText("공개")).toBeVisible();"공개"라는 글자를 찾는 이 검증은, 화면에 비공개라고 적혀 있어도 통과한다. 부분 문자열로 매칭하기 때문이다. 토글을 눌러 상태가 바뀌었는지 확인하려던 코드가, 사실은 아무것도 확인하지 못하고 있었다.
이때부터 테스트를 쓸 때마다 스스로에게 묻는 습관이 생겼다. "내가 지금 확인하려는 값을, 내가 지금 하는 조작이 물리적으로 바꿀 수 있는가?" 답이 "아니오"면 그 테스트는 초록불이어도 아무 의미가 없다.
이걸 확인하는 방법도 하나로 굳었다. 고친 코드를 일부러 되돌려서 테스트가 진짜로 빨간불이 되는지 눈으로 본 다음 다시 복구한다. 예를 들어 정렬 로직을 검증하는 테스트를 새로 짜고, 정렬 함수를 잠깐 무력화시켜봤다.
시간 변경 후 배열 순서:
[0] 점심 장소 08:00
[1] 아침 장소 09:00
→ 정렬 무력화하면 "15:00, 09:00" 순서 그대로 → 테스트 실패 확인
→ 복구 후 재실행 → 통과이 왕복을 거치지 않은 테스트는 이제 믿지 않는다.
배포하고 폰으로 열어봤더니
테스트가 다 초록불이 되고, 배포까지 했다. 이제 내 폰으로 실제 여행을 계획해보려고 앱을 열었다.
3분 만에 닫았다.
일정 하나를 눌렀더니 뭔가 수정창이 떴는데, 교통편을 눌렀을 때와 다르게 생겼다. 거기서 시간을 고치고 이어서 금액을 입력했더니 엉뚱한 일정에 금액이 들어갔다.
원인 1 — 항목을 "몇 번째"로 기억하고 있었다
코드를 뜯어보니 원인이 명확했다. 일정 항목은 세 종류인데, 장소만 고유 식별자가 없었다. 교통편과 메모는 각자 ID를 갖고 있어서 "이 ID를 가진 항목"으로 정확히 찾는데, 장소는 배열의 몇 번째인지로 찾고 있었다.
문제는 시간을 수정하면 그 즉시 시간순으로 재정렬된다는 것이다. 재정렬되면 순서가 바뀌는데, 열려 있는 수정창은 낡은 위치값을 계속 들고 있다. 최소 재현 스크립트를 만들어 확인했다.
클릭 시점 대상: 점심 장소 12:00
시간을 08:00으로 변경 (아침 장소보다 이르게)
→ 재정렬 발생
[0] 점심 장소 08:00
[1] 아침 장소 09:00
같은 창에서 비용 1,200,000 입력 (여전히 "1번째"를 가리킴)
→ 아침 장소에 비용이 들어감
→ 점심 장소는 그대로내가 겪은 현상 그대로였다. 그리고 왜 교통편에서는 이 문제가 안 났는지도 설명됐다. UI가 제각각이라 편집 경험이 갈라진 게 아니라, 데이터 모델이 애초에 두 갈래여서 UI도 두 갈래가 된 것이었다.
원인 2 — 여행 전체를 하나의 덩어리로 저장하고 있었다
더 큰 문제가 있었다. 일정 저장 구조가 이랬다.
CREATE TABLE itinerary (
trip_id UUID PRIMARY KEY,
days JSONB NOT NULL -- 여행 전체 일정이 여기 통째로
);자바로 치면 여러 테이블로 나눠야 할 것을 @Lob String 컬럼 하나에 통째로 밀어넣고, 저장할 때마다 전체를 덮어쓰는 구조다.
같이 편집하는 상황을 생각해보면 답이 나온다. 내가 첫날 아침 시간을 고치고, 동생이 셋째 날 크루즈 비용을 입력한다. 전혀 다른 걸 건드렸는데도 나중에 저장한 쪽만 남고 다른 쪽 수정은 통째로 사라진다. 예전에 이 문제를 한 번 겪어서 플래그 하나로 임시 방어해둔 흔적도 코드에 남아 있었다.
"공동 편집이니까 자바처럼 락을 걸어야 하나" 싶었는데, 따져보니 락이 아니라 저장 단위가 문제였다. 항목마다 각자의 행으로 쪼개면 이렇게 된다.
-- 내 저장
UPDATE items SET time = ? WHERE id = 'A';
-- 동생 저장
UPDATE items SET cost = ? WHERE id = 'B';서로 다른 행이라 애초에 부딪힐 일이 없다. 락도, 버전 체크도 필요 없는 평범한 UPDATE 두 개일 뿐이다.
정리해보니 위험이 남는 경우는 두 가지뿐이었다. 같은 항목의 같은 필드를 동시에 고칠 때, 그리고 내가 수정하는 중에 상대가 그 항목을 지울 때. 둘 다 실시간 동기화로 화면에 바로 반영되니 사람이 알아챌 수 있다. 여행 계획에서 이 정도면 충분하다.
웹에서 비관적 락을 쓰면 오히려 손해다. 사용자가 편집하다 브라우저를 그냥 닫으면 락이 안 풀려서 좀비 락 처리를 따로 만들어야 하고, "다른 사람이 편집 중입니다, 기다려주세요" 같은 대기 화면은 친구와 같이 계획 짜는 경험과 정반대다. 구글 독스도 노션도 락을 쓰지 않는다.
내가 실제로 쓰던 노션을 뜯어봤다
이 앱을 만들기 전에는 노션 데이터베이스로 여행을 계획했다. 그럼 나는 노션에서 대체 뭘 하고 있었나 싶어서, 작년 가족 여행 페이지를 열어 구조를 하나씩 확인했다.
한 페이지에 독립된 표들이 나란히 꽂혀 있었다. 항공권, 숙박, 일정, 결제 내역, 사진첩, 참고 사이트. 이게 내가 말하던 "게시판"이었구나 싶었다. 지금 만든 앱은 이 중 「일정」 하나만, 그것도 훨씬 빈약하게 구현한 셈이었다.
그런데 진짜 수확은 내가 의식하지 못했던 것들이었다.
결제 내역 표가 원화용과 현지통화용 두 벌이었다. 숙박 표에도 가격과 가격(원화)이 각각 손으로 입력하는 칸으로 따로 있었다. 환율 계산이 자동으로 안 되니까 같은 지출 하나를 최대 세 번 적고 있었던 것이다. "노션은 손이 너무 많이 간다"고 막연히 느꼈던 게 여기 그대로 찍혀 있었다.
숙박 표에는 「후보」라는 연결 항목이 있었다. 호텔 후보를 여러 개 늘어놓고 비교한 뒤 하나를 고르는 흐름이었다. 그런데 내가 만든 앱에는 "아직 정하지 않은 상태"라는 개념이 아예 없었다. 계획이란 원래 이것저것 늘어놓고 고민하다 정하는 건데, 앱은 처음부터 확정된 일정만 받아들이고 있었다.
활동 분류도 비교해보니 노션에서는 15가지를 쓰고 있었다. 식사를 아침·점심·저녁·야식으로 나누고, 유적지와 박물관을 구분하고, 「1주일 전 예약」 같은 준비 작업까지 일정처럼 다뤘다. 앱은 세 가지뿐이었다. 실제로 쓰던 해상도가 앱보다 다섯 배 높았다.
공간을 나누니 오히려 단순해졌다
여기까지 오니 욕심이 나기 시작했다. 항공권 섹션도 필요하고, 숙박도, 예산도, 위시리스트도, 참고 링크도, 비자 서류도... 이러다 감당 못 하겠다 싶던 참에 생각이 하나 스쳤다.
친구와 머리 맞대고 이것저것 늘어놓는 공간과, 확정된 일정을 정리하는 공간은 성격이 다르다.
이 둘을 나눴더니 오히려 항목이 줄었다.
| 늘리려던 것 | 어디로 갔나 |
|---|---|
| 가고 싶은 곳 위시리스트 | → 아이디어 공간 |
| 숙소 후보 비교 | → 아이디어 공간 |
| 참고 링크 모음 | → 아이디어 공간 |
| 비자·서류 | → 준비물 (기한 있는 할 일) |
| 환전·현금 | → 준비물 + 예산 |
여섯 개로 정리됐다. 아이디어 공간, 일정, 숙박, 이동, 예산, 준비물.
그리고 "코르크판에 메모지 꽂듯이"라는 내 원래 비유가 제자리를 찾았다. 자유롭게 꽂고 빼는 코르크판은 아이디어 공간이고, 확정 일정은 정돈된 표다. 하나의 앱 안에 어질러도 되는 곳과 깔끔해야 하는 곳이 분리되어 공존한다. 노션이 엉망이 되던 진짜 이유는 이 둘이 한 공간에 섞여 있어서였다.
데이터로 보면 놀랄 만큼 단순해진다.
항목 테이블 하나
├ 날짜 = 없음 → 아이디어 공간에 뜸
└ 날짜 = 3월 12일 → 일정에 뜸「일정에 넣기」는 날짜를 채우는 것이고, 「일정에서 빼기」는 비우는 것이다. 지워지지 않고 아이디어 공간으로 돌아온다. 분류도 사진도 링크도 메모도 전부 그대로 따라가니, 옮기면서 다시 입력할 게 없다.
메모를 댓글로 바꿨더니 충돌이 사라졌다
메모를 어디까지 자유롭게 만들지 고민하다가, 문득 댓글처럼 쌓으면 어떨까 싶었다. 누가 남겼는지 표시되고, 여러 개가 시간순으로 쌓이고, 각각은 여러 줄 쓸 수 있게.
취향 문제인 줄 알았는데 따져보니 기술적으로 결정적인 이점이 있었다.
한 덩어리 글을 두 사람이 동시에 고치면 누가 이기는지 정해야 하고, 제대로 풀려면 무거운 동기화 기술이 필요하다. 그런데 댓글은 "추가"만 있고 "고쳐쓰기"가 없다. 두 사람이 동시에 남겨도 그냥 둘 다 남는다. 충돌이라는 개념 자체가 성립하지 않는다.
덤으로 왜 그렇게 정했는지가 기록으로 남는다. 아버지가 "멀미약 챙겨라" 한 것이 3주 뒤에도 그대로 남아 있다. 노션 본문에 덮어쓰기로 적으면 이런 대화의 흔적이 사라진다. 여행 계획은 원래 의논하다가 정하는 건데 말이다.
AI 비용을 실측해봤다
AI 사용 한도를 월 3회로 잡아뒀었다. 비용이 무서워서 보수적으로 정한 숫자였다.
그런데 "AI가 짜준 동선을 보고 마음에 안 드는 걸 고친 다음, 다시 최적화를 돌리고, 또 고치고" 같은 자유로운 흐름을 만들고 싶어졌다. 이러면 3회로는 여행 하나도 못 짠다.
그래서 호출 기록에 남은 실제 토큰 수로 계산해봤다.
| | |
|---|---|
| 평균 입력 | 1,016 토큰 (그중 817이 캐시 적중 — 80%) |
| 평균 출력 | 4,125 토큰 |
| 호출 1회 | 약 83원 |
두 가지가 드러났다.
비용의 98%가 출력 토큰이었다. 입력은 사실상 공짜다. 즉 "몇 번 호출하느냐"가 아니라 "얼마나 길게 만들어내느냐"가 곧 비용이다. 내가 막고 있던 게 비용과 별 상관없는 축이었던 셈이다.
캐시가 이미 80% 적중하고 있었다. 반복 호출일수록 유리하다는 뜻이다. 내가 만들고 싶던 "고치고 다시 돌리기" 흐름과 오히려 궁합이 맞았다.
여행 하나를 마음껏 주무르며 계획해도 (통짜 생성 1회 + 재최적화 3회 + 추천 5회 + 빈틈 채우기 5회) 1,200원 정도였다. 월 3회 제한으로 아낀 돈은 한 달에 몇백 원이었고, 대신 앱을 쓸 수 없게 만들고 있었다.
이제 기능별로 소모량을 다르게 매기기로 했다. 장소 추천은 1, 빈틈 채우기는 3, 동선 최적화는 4, 통째로 생성은 5. 버튼마다 얼마나 쓰는지 미리 보이니 예측 가능하다. 지금은 나 혼자 쓰니 한도를 넉넉히 열어두고, 진짜 안전장치는 앱이 아니라 API 콘솔의 월 지출 상한에 걸어뒀다.
그래서 갈아엎기로 했다
정리하고 보니 사실상 2차 버전이다. 저장 구조를 전부 쪼개야 하고, API 대부분을 다시 써야 하고, 편집 화면은 새로 만들어야 한다.
그래도 로그인, 초대와 공유, 지도, 장소 검색, 환율, 그리고 공들여 만든 브라우저 자동화 테스트 7종은 그대로 쓴다. 바닥은 튼튼하다.
순서는 아이디어 공간부터 만들기로 했다. 완전히 새 기능이라 기존 데이터를 안 건드리고, 새로운 저장 구조를 안전한 곳에서 먼저 검증할 수 있다. 검증이 끝난 구조로 그다음에 일정을 옮긴다. 가장 아픈 곳(편집 화면)부터 고치고 싶은 마음도 있었지만, 그러면 위험한 작업이 첫 단계에 다 몰린다.
버전도 여기서부터 다시 매기기로 했다. 지금까지가 "일단 돌아가게" 만드는 단계였다면, 이제부터는 "내가 실제로 쓰고 싶은가"를 기준으로 삼는다.
돌아보면 이번에 배운 건 두 가지다.
초록불이 정답은 아니다. 테스트가 전부 통과해도, 그 테스트가 실패할 수 없는 구조라면 아무것도 보장하지 않는다.
그리고 기능이 다 됐다는 것과 쓸 만하다는 것은 완전히 다른 얘기다. 나는 후자를 확인하지 않은 채 전자만 쌓아 올리고 있었다. 몇 달을 잃고 나서야 내 폰으로 3분 써보는 걸로 알아챘다.