가로로 스크롤되는 할인 카드 목록이 있었다. 손가락으로 한 번 튕기면 다음 카드에서 멈추기를 기대하는데, 실제로는 두 장씩 지나갔다. 조심스럽게 밀면 기대한 대로 됐다.
제보는 정확했다. 현상을 정확히 서술했고 재현 조건까지 담겨 있었다. 화면 녹화도 붙어 있었다.
한 시간 뒤 내게 도착한 문장은 이랬다.
카드 목록 snap scrolling 방식으로 변경 작업 가능하실까요?
이 글은 하루짜리 작업 기록이다. 원인이 사라지는 전달 과정, 그 처방을 나도 AI도 그대로 받아 구현한 것, 그리고 다시 확인하기 시작하면서 방향이 뒤집힌 과정을 적는다.
하나. 원인이 사라지는 데 걸린 시간, 한 시간
전달 경로를 시간순으로 놓으면 이렇다.
| 시각 | 내용 |
|---|---|
| 07:36 | 제보 — “한 번 튕기면 다음 카드가 포커스되길 기대하는데 획획 지나간다” |
| 08:07 | “확인해보겠습니다” + 디자인 담당 멘션 |
| 08:26 | “스크롤스냅: 한 개씩 포커싱 되도록 / 현재는 두 개씩 건너뛴다” |
| 08:39 | 개발자에게 — “snap scrolling 방식으로 변경 작업 가능하실까요?” |
세 번 전달되는 동안 현상은 점점 줄고 해법은 점점 또렷해졌다. 마지막 문장에는 “획획 지나간다”가 없다. 대신 CSS 명세에 실제로 존재하는 기능의 이름이 들어가 있다.
각자 자기 자리에서 한 단계씩 구체화한 결과다. 문제는 그 구체화가 원인 조사를 건너뛰고 일어났다는 것이다. “두 개씩 건너뛴다”는 관찰이고 “스크롤스냅으로 바꾸자”는 처방인데, 사이에 있어야 할 왜가 없다.
첫 문장에는 원인이 들어 있었다
다시 보면 제보에 이미 답이 있었다.
그냥 스크롤하면 획획 지나가고 조심스럽게 스크롤 해야 기대한 대로 됩니다
“조심스럽게 밀면 된다”는 건 이동 거리가 손가락 속도에 비례한다는 뜻이다. 나중에 소스에서 확인한 계산식이 정확히 그랬다.
목적지 = 현재 위치 + 속도 × 계수
제보자는 관성 문제를 평문으로 서술했다. 그런데 전달되면서 제일 먼저 사라진 게 그 부분이다. “두 개씩 건너뛴다”로 바뀌는 순간 속도 의존성이 지워지고 고정된 사실이 된다. 항상 두 장이라면 그건 관성이 아니라 스냅 단위 문제처럼 보인다. 처방이 그쪽으로 간 게 이상하지 않다.
그리고 그 확인은 이제 싸다
예전엔 라이브러리 내부를 읽는 게 개발자만 할 수 있는, 그것도 시간을 들여야 하는 일이었다. 지금은 “이 캐러셀에서 플릭 거리가 어떻게 정해지냐”고 묻는 데 몇 분이면 된다.
뒤에서 보겠지만 AI는 이 문제에서 한 번 크게 틀린다. 그런데 틀린 건 “그래서 안 된다”는 결론이었지 “왜 그런가”가 아니었다. 아무도 손대지 않은 기본값이 그대로 굴러왔다는 사실 규명은 처음부터 정확했다. 전달하는 쪽에 필요했던 건 딱 거기까지다.
둘. 우리도 그대로 받았다
여기서 내가 잘한 게 없다.
받은 대로 했다. Splide를 걷어내고 네이티브 스크롤 컨테이너에 scroll-snap-type: x mandatory / scroll-snap-stop: always를 얹었다. 카드마다 스냅 정렬을 주고, 선택 카드 포커스 동기화를 좌표 계산으로 다시 짰다. 192줄이 바뀌었다.
AI도 묻지 않았다. 나는 처음부터 AI와 함께 작업했는데, 요청을 같이 읽고 곧바로 설계에 들어갔다. “왜 두 장씩 가는가”를 아무도 묻지 않았다. 처방이 담긴 요청이 들어오면 AI도 그 처방부터 구현한다.
동작은 했다. 브라우저로 확인했고 테스트도 붙였다. 원인을 모른 채 증상이 사라진 상태였다.
전달 체인에서 원인이 지워졌다면, 마지막 사람이 그걸 복원할 마지막 기회였다. 나도 그냥 지나쳤다. 192줄은 그 대가다.
셋. 다시 확인하기 시작했다
구현을 마치고 나서야 뒤늦게 물었다. Splide 옵션으로는 안 되나?
AI가 소스를 읽고 답했다.
- 플릭 목적지는
현재 위치 + velocity × (flickPower ?? 600)이고, 상한은listSize() × (flickMaxPages ?? 1)이다 - 그런데
listSize()는.splide__list즉 전체 슬라이드 리스트 너비다. 카드 열 장이면 2,000px가 넘는다.flickMaxPages: 1은 이미 적용 중이었고, 제한 역할을 못 하고 있었다 - 목적지 인덱스를 구하는
toDest도clamp(closest, 0, getEnd())라 유효 범위만 자를 뿐 “인접 한 칸” 클램프가 없다 - 남는 손잡이는
flickPower하나뿐인데 이건 속도 비례 상수라 모든 속도 구간에서 한 장을 보장할 수 없다
라인 번호까지 붙어 있었다. 하나하나 확인해봤고 전부 사실이었다. 나도 납득했다.
그런데 한 번 더 물었다. 정말 설정으로는 안 되나?
같은 답이 돌아왔다. 더 자세한 근거와 함께.
세 번째로 물었다.
넷. 그래서 브라우저를 직접 몰았다
세 번째 질문에서 AI가 답을 반복하는 대신 재보자고 했다. 여기서부터가 이 작업에서 가장 값진 부분이다.
터치 플릭을 합성했다. 마우스 드래그로는 재현되지 않는다. 관성이 붙지 않기 때문이다. Playwright로 실제 페이지를 띄우고 CDP의 Input.synthesizeScrollGesture를 gestureSourceType: 'touch', preventFling: false로 호출했다. 진짜 fling 이벤트가 들어간다.
cdp.send('Input.synthesizeScrollGesture', {
x, y, xDistance: -90, yDistance: 0,
speed: 1600, gestureSourceType: 'touch', preventFling: false,
});
“몇 장 넘어갔나”는 활성 슬라이드에 붙는 클래스(.is-active)의 인덱스 변화로 셌다. 사람이 눈으로 세는 대신 숫자가 나온다.
측정 규모는 flickPower 5종(600·100·50·25·1) × 제스처 5종(속도·드래그 거리 조합) × 3회 반복.
넘어간 카드 수는 이렇게 나왔다.
| 플릭 세기 | 기본값 600 | 100 | 50 | 25 | 1 |
|---|---|---|---|---|---|
| 아주 약하게 | 2 | 0 | 0 | 0 | 0 |
| 약하게 | 2 | 1 | 1 | 0 | 0 |
| 보통 | 2 | 1 | 1 | 1 | 1 |
| 세게 | 2 | 2 | 1 | 1 | 1 |
| 아주 세게 | 3 | 3 | 2 | 2 | 1 |
됐다. 기본값이 전 구간에서 2장이었다는 것부터가 제보 문구(“두 개씩 건너뛴다”)와 정확히 일치했고, flickPower: 50이면 실사용 세기 구간 전체에서 한 장이었다.
접었던 대안도 같은 조건으로 재봤다. scroll-snap 버전과 옵션 조정 버전을 동일한 제스처 세트로 비교했더니 실사용 구간 결과가 같았다. 아주 세게 튕길 때는 오히려 옵션 조정 쪽이 나았다.
192줄이 한 줄이 됐다.
측정 장치가 거짓말하는 지점도 스스로 찾았다
측정이 한 번에 깨끗하게 나온 건 아니다.
첫 시도에서 한 라운드 안의 제스처가 누적돼 캐러셀 끝에 도달했고, 마지막 값이 0으로 찍혔다. 더 못 간 것이지 안 움직인 게 아닌데 표만 보면 구분이 안 된다. 제스처마다 처음 위치로 되감도록 고쳤더니, 이번엔 되감기 애니메이션이 끝나기 전에 시작 인덱스를 읽어서 같은 제스처가 0/1/2로 흔들렸다.
이 한계를 숨기지 않고 그대로 기록했다. 결과표에는 지금도 노이즈로 보이는 0이 몇 칸 남아 있고, 그게 계측 문제인지 실제 취약성인지 이 측정 장치로는 갈라내지 못했다고 적어뒀다.
결정적인 것이 하나 더 있다. AI는 처음에 이동 거리를 계산으로 예측했다. 속도 × 계수니까 보통 플릭에서 4.3장쯤 갈 것이라고. 실제로 재보니 2장이었다. 합성 제스처의 속도 파라미터와 라이브러리가 측정하는 순간 속도가 1:1로 대응하지 않았기 때문이다.
계산은 깔끔했고 현실과 어긋났다. 이걸 발견한 것도 재봤기 때문이다.
배포하고 나서도 같은 자로 쟀다
한 번 재고 끝내지 않았다. 로컬에서 재고, QA 환경에 반영해서 같은 제스처 세트로 재고, 운영 배포 후에 또 쟀다.
| 플릭 세기 | 수정 전 | 로컬 | QA | 운영 |
|---|---|---|---|---|
| 보통 | 2장 | 1장 | 1장 | 1장 |
| 세게 | 2장 | 1장 | 1장 | 1장 |
세 환경 수치가 일치했다. 운영은 카드가 열네 장 떠서 조건이 더 까다로웠는데도 같았다. 포커스 이동 중 페이지 세로 스크롤이 변하지 않는 것(539 → 539)까지 같은 방식으로 확인했다.
이걸 사람이 손으로 하면 몇 시간이다. 그리고 아마 안 했을 것이다.
다섯. 사람의 눈이 한 일
이 작업에서 내가 한 일은 세 가지였다.
세 번 되물은 것. AI의 논증은 설득력이 있었고 인용도 정확했다. 한 번에 수긍했으면 192줄짜리 리라이트가 그대로 나갔을 것이다. “정말 안 되나”를 두 번 더 물은 게 이 작업에서 제일 잘한 일이다.
보수적인 쪽을 고른 것. 실측 결과 두 방식의 결과가 같았을 때, scroll-snap 방식에는 고유한 장점이 있었다. 기기·속도와 무관한 브라우저 계약이라 재튜닝이 필요 없고, 네이티브 스크롤이라 접근성 이점도 있다. 그래도 결제 화면에서 1줄과 192줄의 회귀 위험 차이가 그 장점보다 컸다. 되돌리기도 쉽다.
작업을 지우지 않은 것. 접은 구현을 커밋 히스토리에 남겼다. 실측 비교표도 함께 남겨서, 재튜닝이 반복되면 그쪽으로 돌아갈 수 있게 했다. 채택하지 않은 것이 틀린 것은 아니다.
진짜 원인은 더 허탈했다
측정하면서 알게 된 것들이다.
flickPower는 이 캐러셀에서 한 번도 설정된 적이 없었다. 도입 시점부터 1년 4개월간 Splide 기본값 600이 그대로 굴러왔다. 누가 잘못 정한 게 아니라 아무도 정한 적이 없었다.
최초 구현자에게 의도는 있었다. 옵션에 perPage: 1 / perMove: 1이 들어가 있다. 그런데 같은 객체의 autoWidth: true가 슬라이드 크기 계산 경로를 통째로 우회시킨다. 의도는 처음부터 있었고 메커니즘만 걸리지 않은 채로 유지된 것이다.
저장소를 훑어보니 같은 옵션이 열세 곳에 설정돼 있었다. 팀은 기본값이 과하다는 걸 이미 알고 있었다. 이 목록과 바로 옆 목록, 두 곳만 관행에서 빠져 있었다.
그리고 이 지면은 도입 당일 검수를 거쳤다. 그날 커밋에 좌우 여백과 정렬 기준이 추가돼 있다. 화면을 보고 잡을 수 있는 것들이다. 눈으로 보이는 것은 그날 잡혔고, 손가락으로 만져야 아는 것은 1년 4개월을 갔다.
그 사이에 있었던 것들
여기까지 쓰고 보니 너무 깔끔하다. 실제로는 이런 것들이 섞여 있었다.
개발 서버가 두 번 죽었다. 4GB 힙을 다 쓰고 OOM으로 넘어갔다. 힙을 8GB로 올려 다시 띄웠다. 측정하다 말고 이걸 두 번 했다.
AI가 로컬 브랜치를 확인 전에 지웠다. 절차상 “원격에 없는 커밋이 보이면 멈추고 보고” 하게 돼 있었는데, 개수를 세는 명령과 지우는 명령이 한 줄에 붙어 있어서 세자마자 지워버렸다. 다행히 그 커밋은 이미 다른 경로로 반영돼 있어서 잃은 건 없었지만, 아니었으면 사고다. AI가 곧바로 스스로 알아채고 복구 경로를 찾아왔다는 게 그나마다.
접근을 바꾼 뒤에도 제목이 옛날 그대로였다. PR 제목에 한동안 “스크롤 스냅 전환”이 남아 있었다. 코드는 이미 한 줄짜리 옵션 조정인데. 브랜치 이름은 지금도 그렇다. 머지되면 사라지니 그냥 뒀다.
기록을 남길 자리를 한 번 잘못 골랐다. 실측 데이터를 어디에 둘지 고민하다 머지되지 않는 브랜치에 문서를 커밋했다. 만들어놓고 보니 최악의 자리였다. 1년 뒤 누가 “이 숫자 뭐야”를 추적하면 코드 주석 → PR → 티켓 순으로 가지, orphan 브랜치의 루트 마크다운을 뒤질 리가 없다. PR 코멘트로 옮기고 브랜치는 지웠다.
실기기로 재보겠다고 시뮬레이터를 띄웠다. iOS 시뮬레이터와 Android 에뮬레이터를 순서대로 부팅했다. WebKit은 진짜 터치 물리를 쓰니까 합성 제스처보다 나은 근거가 나올 거라고 봤다. 그런데 시뮬레이터에서 테스트 환경 접속이 막혔고, 그러는 사이 판단이 섰다 — 한 줄짜리 수정에 에뮬레이터 두 대는 배보다 배꼽이다. 전부 내리고 이미 쌓인 근거로 갔다. 시도 자체는 아까웠지만 접은 게 맞았다.
이런 게 반나절 사이에 다 있었다. 결과만 보면 “옵션 한 줄”인데 거기 도달하는 과정은 이렇게 지저분하다.
같은 실수가 세 번 있었다
전달 과정에서 — “스크롤스냅으로 바꾸면 된다”는 그럴듯한 처방이 원인 조사를 대신했다.
내 쪽에서 — 처방이 담긴 요청을 받고 처방부터 구현했다. 되물을 마지막 자리였는데 지나쳤다.
AI 쪽에서 — “소스상 옵션으로는 불가능하다”는 그럴듯한 논증이 측정을 대신했다.
셋 다 틀린 말을 하지 않았다. 스크롤스냅은 실제로 그 문제를 푸는 기능이 맞고, AI가 인용한 코드도 전부 실재하고, 내가 만든 192줄도 동작했다. 다만 검증되지 않은 채 다음 단계로 넘어갔다는 점이 같다.
그리고 셋 다 같은 것으로 풀렸다. 재보는 것.
AI가 틀린 지점과 AI가 결정적이었던 지점이 같은 대화 안에 있다. 논증을 만들 때는 그럴듯하게 틀렸고, 재보라고 하니 사람이 몇 시간 걸릴 측정을 몇 분에 해냈다. 도구를 어디에 쓰느냐의 문제지 믿느냐 마느냐의 문제가 아니었다.
다음의 나에게
가르치려는 게 아니라 잊지 않으려고 적는다.
처방이 담겨 온 요청은 출처를 되짚는다. “무엇이 어떻게 안 되나요”를 한 번 더 묻는 비용은 몇 분이고, 잘못된 방식으로 구현하는 비용은 며칠이다. 이번엔 192줄이었다.
AI에게는 논증보다 측정을 시킨다. 소스 인용이 붙은 논증은 설득력이 높지만, 인용이 사실인 것과 결론이 옳은 것은 별개다. 브라우저를 몰아서 숫자를 만드는 건 AI가 사람보다 압도적으로 잘하는 일이고, 그쪽으로 쓸 때 실수가 훨씬 적었다.
측정 장치가 현실과 어긋나는 지점을 먼저 찾는다. 처음에 사람 손가락의 열 배 속도로 제스처를 합성해놓고 “네 장이나 지나간다”고 오판했다. 조건을 현실적인 범위로 낮추자 숫자가 달라졌다.
되돌아갈 자리를 지우지 않는다. 채택하지 않은 구현도, 실패한 측정도, 측정 장치의 한계도 그대로 남겼다. 다음에 이 값을 건드릴 사람이 나일 확률이 제일 높다.
남은 것
한 줄로 끝났지만 완전히 닫히진 않았다. 채택한 flickPower: 50에 원리적 근거가 없다. 실측으로 고른 숫자다. 기기의 터치 샘플링이 다르면 체감이 달라질 수 있어서 조정 가이드를 상수 주석에 남겼다 — 가벼운 플릭이 안 넘어가면 올리고, 여전히 여러 장 건너뛰면 내린다.
재조정이 반복되면 접었던 scroll-snap 방식으로 돌아가는 게 맞다. 그건 기기·속도와 무관한 브라우저 계약이라 튜닝이 필요 없다. 되돌아갈 자리를 지우지 않은 이유다.
마지막으로 하나. flickPower: 0을 넣으면 관성이 사라질 것 같지만, Splide 내부가 options.flickPower || 600이라 기본값으로 되돌아간다. 관성을 없애려면 1을 써야 한다. 이것도 재보다가 알았다.