내부 private repo 작업을 바탕으로 썼다. 외부 접근이 불가한 저장소라 링크 없이, 조직·시스템·사람을 식별할 수 있는 정보는 걷어내고 구조만 남긴다.
한동안 글을 쉬었다. 복귀 글로 큰 주제를 잡는 대신, 지난주에 시작해 오늘 배포한 작은 티켓 하나를 그대로 적기로 했다. 작아서 좋은 점이 있다. 코드 얘기가 짧으니 코드 바깥에서 시간이 어디로 갔는지가 잘 보인다.
티켓은 이렇다. 업체가 배송비를 착불로 받는 상품이 있는데, 서버는 그 상품의 배송비를 0 으로 내려준다. 화면은 0 을 보고 무료 라고 썼다. 무료로 알고 주문했는데 착불이었다는 문의가 들어왔다. 고칠 것은 네 지면(장바구니, 주문서, 주문완료, 주문 상세)의 배송비 문구다. 착불 상품만 있으면 착불배송, 섞여 있으면 무료 (착불배송비 별도) 에 안내 툴팁을 붙인다.
코드는 하루에 끝났다. PR 을 연 날부터 운영 배포까지는 엿새였다.
TL;DR
- QA 배포본 재검증을 세 번 돌렸다. 처음 base·head 대조에 쓴 스모크 스펙을 대상만 바꿔 그대로 돌렸기 때문에 세 번이 부담이 아니라 증적이 됐다.
- 디자인 가이드는 모바일 프레임뿐이었고 지면마다 굵기 규칙이 달랐다. 실측 표로 “같은 것” 과 “다른 것” 을 갈라 예/아니오 질문 하나로 좁혔더니 답이 한 줄로 왔다.
- 툴팁이 아이콘 왼쪽에 떠서 문구를 덮었다. 디자인 시스템 툴팁의 flip 설정이 가로 넘침을 옆으로 보내고 있었다. 답은 정렬 하나(
top-end). - 같은 E2E 항목이 다섯 번 죽었다. 로그는 “90초 초과” 뿐이었고, 실패 영상에서 프레임 세 장을 뽑아 보니 원인이 둘 겹쳐 있었다. 폴백에도 timeout 이 있어야 한다.
1. 검증은 세 번 돌았고, 갈수록 싸졌다
이 저장소에는 PR 마다 브라우저 스모크를 돌려 증적을 남기는 절차가 있다. QA 가 조용할 때는 흐름을 기록한다 에서 적은 그 절차다. 같은 Playwright 스펙을 변경 전(base)과 변경 후(head)에 각각 실행해서, 새 동작은 base 에서 실패하고 head 에서 통과하는지, 기존 동작은 양쪽에서 통과하는지를 표로 남긴다. 이번 티켓은 브라우저 항목 13개, 유닛으로 갈음한 항목 5개였다.
여기까지는 평소와 같다. 다른 점은 그 뒤였다. 글자 굵기 규칙이 세 번에 걸쳐 바뀌었다. 처음은 QA 화면을 본 내 눈, 다음은 디자이너의 답, 마지막은 디자인 QA 티켓이었다. 매번 QA 에 다시 배포했고, 매번 다시 확인해야 했다.
세 번째쯤 되면 손으로 눌러 보는 방식으로는 못 한다. 대신 같은 스모크 스펙을 QA 배포본 URL 을 대상으로 그대로 돌렸다. 스펙은 대상 URL 과 “이 실행이 어느 커밋의 증적인가” 를 환경 변수로 받으니, 대상만 바꾸면 된다. 여기에 두 가지를 더했다.
- 계산된 스타일을 함께 기록했다. 이번 수정의 절반은 글자 굵기였다. 스크린샷은 굵기 700 과 500 을 구분해 주지 않는다. 배송비 행 안의 텍스트 조각마다
getComputedStyle의fontWeight와fontSize를 찍어 표로 남겼다. “무료 500/16px, 괄호 400/16px” 처럼 적히니 디자이너와 나 사이에 해석이 끼어들 자리가 없었다. - 전후 비교 이미지를 만들었다. 이전 배포본 캡처와 새 배포본 캡처에서 같은 행을 잘라 나란히 놓고 캡션을 붙였다. 슬랙에 올릴 때 설명이 한 줄로 줄었다.
세 번 검증한 시간을 다 합쳐도 한 시간이 안 됐다. 스펙이 대상을 가리지 않게 만들어 둔 덕이다. 처음 한 번은 비싸고, 두 번째부터는 공짜에 가깝다.
2. 디자인 규칙이 없어서 만들었다
디자인 가이드에는 모바일 프레임만 있었다. 그리고 지면마다 굵기가 달랐다. 장바구니는 무료 만 SemiBold 이고 금액은 Regular, 주문완료는 금액도 Medium, 주문 상세는 무료 만 Medium 이었다. 같은 지면 안에서도 무료 (착불배송비 별도) 의 무료 는 굵은데 3,000원 (착불배송비 별도) 의 금액은 Regular 였다. 한 단어 안에서 6,500 은 Regular 이고 원 은 Medium 인 프레임도 있었다.
기존 코드도 지면마다 달랐다. 장바구니는 배송비 값이 행 단위로 굵었고, 주문서 모바일은 “배송비가 0 이면 굵게” 라는 조건이 있었고, 주문완료와 주문 상세는 강조가 없었다.
이걸 그대로 물으면 답이 늦게 온다. “지면마다 다른 게 의도인가요?” 는 디자이너에게 규칙을 새로 세우라는 요청이다. 대신 이렇게 했다.
- 가이드의 지면별 값과 실제 화면의 조각별 굵기를 한 표에 놓았다(실제 화면 쪽은 1절에서 말한 그 표다).
- 표를 “같은 것” 과 “다른 것” 으로 갈랐다. 괄호 문구는 전 지면 Regular 로 같다. 금액은 세 지면이 Regular 이고 주문완료만 Medium 이다.
착불배송도 주문완료만 Medium 이다.무료는 두 지면이 Medium 이고 장바구니만 SemiBold 다. - 그 세 줄을 그대로 보내고 마지막에 한 줄만 물었다. “이게 의도하신 규칙이 맞을까요?”
답은 한 줄로 왔다. “무료 만 굵게, 괄호 문구는 Regular.” 나머지는 그 규칙에서 기계적으로 따라 나왔다. 디자이너가 QA 화면을 보고 티켓 세 건을 더 냈는데(툴팁 위치, 주문완료·주문 상세의 무료 굵기, PC 장바구니 글자 크기) 셋 다 같은 실측 표와 전후 이미지로 닫았다.
여기서 배운 것은 질문의 형태다. 실측이 있으면 질문이 “정해 주세요” 에서 “맞나요” 로 바뀐다. 상대가 답하기 쉬운 질문이 빨리 돌아온다.
3. 툴팁이 왼쪽으로 튀었다
디자인 QA 첫 티켓은 툴팁 위치였다. 안내 아이콘 위에 떠야 할 툴팁이 아이콘 왼쪽에, 행 높이 가운데로 떠서 배송비 문구를 덮고 있었다.
코드는 placement="top" 이었다. 위에 뜨라고 했는데 왜 왼쪽으로 갔는가. 디자인 시스템의 Tooltip 이 floating-ui 를 감싸면서 flip 미들웨어에 fallbackAxisSideDirection: 'start' 를 켜 두고 있었다. 이 옵션은 원래 축(위/아래)에서 자리가 없으면 교차 축(왼쪽/오른쪽)으로도 보낸다. 그리고 flip 의 crossAxis 는 기본값이 true 라 교차 축의 넘침도 “자리 없음” 으로 센다.
안내 아이콘은 행의 오른쪽 끝에 있다. 227px 짜리 툴팁을 16px 아이콘 중앙에 맞추면 오른쪽 절반이 화면 밖으로 나간다. flip 은 “위는 안 되겠군” 하고 아래를 보고, 아래도 같은 이유로 안 되니 왼쪽으로 보낸다. 위아래 공간은 충분했는데 가로 넘침 때문에 옆으로 간 것이다.
답은 top-end 였다. 툴팁의 오른쪽 끝을 아이콘의 오른쪽 끝에 맞추면 넘치지 않고, flip 이 개입할 이유가 없어진다. 디자인 가이드도 그렇게 그려져 있었다. 가이드의 툴팁은 화면 프레임의 자식이 아니라 그 위에 떠 있는 별도 요소라서 프레임 캡처에는 안 나왔고, 좌표를 빼서 위치를 확인해야 했다. 이 부분은 다음에 또 겪을 것 같아 메모로 남겼다.
일반화하면 이렇다. 행 끝에 붙는 아이콘의 툴팁은 top 이 아니라 정렬을 명시한 top-end 로 두고, 디자인 시스템 툴팁의 flip 설정이 교차 축으로 넘어가게 돼 있는지 한 번 확인한다.
4. 다섯 번 죽은 항목과 프레임 세 장
QA 배포본 재검증에서 주문완료 모바일 항목이 다섯 번 연속 죽었다. 로그는 매번 Test timeout of 90000ms exceeded 였고 멈춘 줄만 달랐다. 처음엔 locator.hover 였다. 툴팁 아이콘에 마우스를 올리지 못한 채 테스트 제한 시간을 다 썼다는 뜻이다. 무엇이 막았는지는 로그에 없다.
로그 뒤쪽에는 카드 안내 바텀시트가 포인터 이벤트를 가로챈다는 줄이 있었다. 실패 영상의 마지막 프레임에도 시트가 화면을 덮고 있었다. 여기서부터 헤맸다. 한 번은 그냥 다시 돌렸다. 실패. 시트 말고 상단 고정 요소가 행을 덮을 수도 있다고 보고 스크롤을 가운데로 바꿨다. 실패. 로그는 다시 시트가 가로챈다고 했다. 짧게 시도하고 사이에 시트를 닫는 루프를 넣었다. 실패. 이번엔 시트의 다음에 버튼 클릭에서 멈춰 있었다. 그 클릭에 제한 시간을 두고 DOM 클릭 폴백을 붙였다. 또 실패. 로그는 여전히 90초 초과였다.
다섯 번째 실패 뒤에는 로그를 그만 보고 영상 전체를 봤다. Playwright 는 테스트마다 영상을 남긴다. 90초짜리 영상을 다 볼 필요는 없다. ffmpeg 로 10초, 40초, 80초 지점 프레임만 뽑아 봤다.
for t in 10 40 80; do
ffmpeg -y -loglevel error -ss $t -i video.webm -frames:v 1 frame-$t.png
done
세 장이 똑같았다. 시트는 없었고, 페이지는 배송비 행이 상단 고정 헤더 밑에 깔리는 위치에 멈춰 있었다. 시트를 잡으려던 다섯 번째 실패는 시트 때문에 죽은 게 아니었다. 원인은 둘이었다.
첫째, 결제 완료 화면의 바텀시트는 데이터가 그려진 뒤에 뜬다. 스펙은 페이지 진입 직후 3초만 기다리고 시트가 없다고 판단해 넘어갔고, 그 뒤에 뜬 시트가 hover 를 가로챘다. 그리고 시트가 올라오는 애니메이션 동안 닫기 버튼은 Playwright 의 actionability 검사 기준으로 “안정되지 않은” 요소라, 제한 시간 없는 클릭은 테스트 제한 시간까지 기다렸다.
둘째, 내가 세 번째 수정에서 넣은 폴백이 새 문제를 만들었다. 클릭이 안 되면 DOM 에서 직접 click() 을 부르는 locator.evaluate 를 썼는데, 여기에 timeout 을 안 줬다. 그 사이 시트가 이미 닫혀 요소가 사라지면 evaluate 는 요소가 붙기를 기다리며 제한 시간까지 매달린다. 다섯 번째 실패의 진짜 이유는 시트가 아니라 이것이었고, 그래서 프레임에 시트가 없었다. 그 위에 scrollIntoViewIfNeeded 가 행을 위 가장자리에 맞춰 고정 헤더 밑에 깔아 둔 것이 겹쳐 있었다.
고친 것은 셋이다. 툴팁 열기를 짧은 hover 와 click 을 세 번 시도하는 루프로 바꾸고 사이마다 시트를 닫는다. 시트 닫기의 클릭에 3초 제한을 두고, DOM 클릭 폴백의 evaluate 에도 2초 제한을 준다. 행은 위 가장자리가 아니라 가운데로 스크롤한다. 그 뒤로 같은 항목은 한 번에 통과했다.
교훈은 둘이다. 폴백에도 timeout 을 준다. 폴백은 정상 경로가 실패한 뒤에 도는 코드라 더 이상한 상태에서 실행되고, 거기서 무한 대기하면 원인이 폴백 자신인지 알 수 없게 된다. 그리고 로그가 답을 안 주면 프레임을 본다. 같은 제한 시간 초과로 다섯 번 실패했다면 메시지는 원인을 안 알려주는 것이다. 영상에서 프레임 세 장을 뽑는 데 1분이 안 걸린다.
5. 리뷰 봇의 지적 하나
짧게 하나 더. 이 저장소의 자동 리뷰가 지적을 하나 남겼다. 주문서에서 착불 여부를 판정하려고 장바구니를 조회하는데, 조회 중에는 문구를 비워 두도록 isPending 으로 막아 뒀다. 봇은 “그 게이트는 캐시가 없는 첫 진입에서만 동작한다” 고 했다. 장바구니와 주문서는 클라이언트 전환이라 QueryClient 가 살아 있고, 캐시가 남은 채 다시 들어오면 재조회는 하지만 그동안 이전 데이터로 그리므로 isPending 은 false 다. 그 사이 무료 가 잠깐 보인다.
맞는지 먼저 확인했다. 첫 진입 후 언마운트하고, 다시 마운트해서 첫 렌더의 isPending 을 보는 테스트를 썼다. 현재 코드에서 실패했다. 캐시가 남아 있었다. 그 다음 gcTime: 0 을 주니 통과했다. 주문서를 나가면 캐시를 버리므로 재진입도 첫 진입과 같아진다. 봇이 같이 제안한 isFetching 게이트는 쓰지 않았다. 창 포커스 재조회마다 문구가 비어 버린다.
봇 지적을 받아들이는 기준은 같은 스냅숏에서 교차 검증한다 에 적은 것과 같다. 재현되면 고치고, 안 되면 근거를 적어 닫는다. 이번엔 재현됐다. gcTime 의 의미는 TanStack Query 의 캐시 문서에 있다.
얻은 것
- 스모크 스펙이 대상 URL 을 가리지 않으면 검증 횟수는 비용이 아니다. 로컬 base, 로컬 head, QA 배포본에 같은 스펙을 돌리고, 계산된 스타일까지 표로 남긴다.
- 디자인 규칙이 없을 때는 실측 표로 “같은 것 / 다른 것” 을 갈라 예/아니오로 묻는다. 정해 달라는 질문은 늦게 돌아오고, 맞냐는 질문은 빨리 돌아온다.
- 행 끝 아이콘의 툴팁은 정렬을 명시한다. 디자인 시스템의 flip 이 교차 축으로 넘어가는지 확인한다.
- 폴백에도 timeout. 로그가 답을 안 주면 프레임.
엿새 중 코드가 하루였다는 사실이 처음엔 불편했는데, 적고 나니 나머지 닷새가 다음 티켓에서 줄어들 시간이라는 게 보인다. 그래서 적었다.