Sik.limited Logo

동작하는 코드, 잘 짠 코드의 차이는 이런 디테일로 나뉩니다.

3D 캔버스 위에 플로팅 탭바를 올리는 화면을 새로 짰다. 처음 동작하는 버전은 반나절이면 됐는데, '괜찮게 느껴지는' 버전을 만드는 데는 그 뒤로 한참이 걸렸다. 그 사이에 부딪힌 문제들과 거기서 배운 것들에 대한 기록.

· 6분 읽기

WebGL 캔버스 위에 3D 뷰를 항상 깔아두고, 그 위에 하단 탭바를 띄우는 화면을 새로 짰다.
탭을 누르면 3D 위로 패널이 올라오거나 페이지로 넘어가는.

처음 "일단 동작하는" 버전은 '딸깍'으로 만들었다. 그 버전은 동작만 했다. 누를 때마다 어딘가 어색했고, 뭔가 싸구려 같았다. 거기서부터 "괜찮게 느껴지는" 버전까지 가는 데 시간이 훨씬 더 걸렸다.

매번 만드는건 금방인데 다듬는게 배로 걸린다. 자동화하기도 그런게,, 하네스를 떡칠해도 만들어진 결과물이 마음에 들지 않는다.

결국 하나하나 수정해서 깎아내는게 훨 좋다.

움직이는 건 transform으로

맨 처음엔 패널이 올라올 때 높이를 스프링으로 키웠다. height를 매 프레임 바꾸는 방식. 부드러워 보일 줄 알았는데 누를 때마다 프레임이 떨어졌다.

이유는 단순했다. height를 매 프레임 바꾸면 브라우저는 매 프레임 레이아웃을 다시 계산한다. 게다가 그 패널엔 블러가 걸려 있었고, 뒤에는 실시간으로 도는 3D가 있었다. 매 프레임 레이아웃 + 블러 재계산 + 3D 합성이 한꺼번에 겹친 셈이다. 가장 나쁜 조합이었다.

브라우저 렌더 파이프라인은 Layout → Paint → Composite 순으로 돈다. width, height, top, left 같은 속성은 맨 앞 Layout부터 전부 다시 돌리지만, transformopacity는 마지막 Composite 단계만 건드린다. GPU가 이미 그려둔 레이어를 옮기기만 하면 되는 거다.

그래서 높이를 키우는 모핑을 버리고, 고정 높이 시트를 translateY로 슬라이드시키는 방식으로 바꿨다. 움직이는 패널에서는 블러도 빼고 솔리드 배경으로 갈았다. 라이브 3D 위에서 매 프레임 블러를 다시 계산하는 게 제일 비쌌기 때문이다.

인터랙션 중에 움직이는 속성은 transformopacity로 묶어두는 게 안전하다. 레이아웃 속성을 애니메이션하면 그 비용이 자식 트리 전체로 번진다.


선택 인디케이터(눌린 탭을 따라다니는 박스)가 자꾸 엉뚱한 곳에 떴다. 처음엔 각 버튼을 잡아서 offsetLeft, offsetWidth를 읽어 인디케이터를 그 위치로 옮겼다.

문제가 두 겹이었다. 하나는 타이밍. 측정은 레이아웃이 확정된 다음에야 정확한데, 마운트 타이밍이나 폰트 로딩 때문에 너비가 바뀌면서 측정 시점이 자꾸 어긋났다. 첫 측정이 0으로 굳어버리기도 했다. 다른 하나는 좌표계. offsetLeft는 부모의 border 박스 기준인데 절대배치한 인디케이터의 left:0은 padding 박스 기준이라, 부모 패딩만큼 늘 어긋났다. 그걸 보정하려고 더한 오프셋이 또 다른 케이스를 깨뜨렸다.

결국 깨달은 건, 레이아웃에서 읽어와서 다시 쓰는 패턴 자체가 깨지기 쉽다는 거였다. 읽는 그 순간에 레이아웃이 확정돼 있어야 하는데, 그 보장이 생각보다 어렵다.

그래서 측정을 통째로 버렸다. 버튼을 고정 크기 정사각으로 만들고, 인디케이터는 activeIndex만 가지고 순수 계산으로 배치했다. 인덱스에 스텝 크기를 곱하면 위치가 나온다. DOM을 한 줄도 안 읽으니 타이밍 문제도, 좌표계 어긋남도 사라졌다.

계산으로 알 수 있는 값을 굳이 측정하지 말 것. 레이아웃을 읽어서 반응하는 코드는 정말 최후의 수단이다.

Easy In & Out 잘 쓰기

패널이 "올라올 때는 괜찮은데 닫을 때 왜 확 닫히지?" 싶었다. 양방향 트랜지션에 감속 곡선(ease-out) 하나만 썼던 게 원인이었다. Svelte에서 자주 일어난다.

열 때는 감속 곡선이 부드럽게 안착해서 좋다. 그런데 닫을 때 같은 곡선을 진행도만 뒤집어서 재생하면, 값이 거의 다 열린 채로 버티다가 마지막에 훅 떨어진다. 시간상으로는 "거의 열려 있다가 끝에서 급하게 닫힘"이 된다. 

등장과 퇴장은 애초에 다른 움직임을 원한다. 들어올 때는 감속하며 안착하고, 나갈 때는 가속하며 빠져나가야 자연스럽다. 그래서 들어오는 모션엔 ease-out, 나가는 모션엔 ease-in을 따로 줬다. 시간은 240ms로 통일하고.

이징은 장식이 아니라 동작의 의미를 전달한다. 등장 ≠ 퇴장.

스프링은 마법이 아님

인디케이터를 스프링으로 옮겼더니 "노란 박스가 너무 늦게 따라온다"는 피드백이 왔다. 스프링은 물리 기반이라 목표를 천천히 쫓아온다. 부드럽긴 한데, 탭 하이라이트처럼 "누른 거의 즉시 붙어야" 하는 UI에선 그 지연이 굼떠 보였다. 미세하게 출렁이는 오버슈트도 거슬렸고, 마운트할 때 0에서 활성 위치로 슬라이드되는 원치 않는 초기 모션도 있었다.

스프링을 걷어내고 그냥 CSS 트랜지션으로 바꿨다. 200ms 빠른 ease-out. 탭에 바로 붙고, 연속으로 누르면 진행 중이던 트랜지션이 현재 위치에서 자연스럽게 방향을 튼다. 게다가 CSS 트랜지션은 첫 렌더의 초기값엔 안 걸리니까 마운트 시 슬라이드도 사라졌다.

상태 토글 하이라이트에는 물리 스프링보다 결정적이고 인터럽트 가능한 CSS 트랜지션이 더 맞다. 더 비싸고 복잡한 게 항상 더 좋은 건 아니다.

z-index 잘 쓰기

3D 위에 떠 있던 HTML 오버레이(캐릭터 머리 위 말풍선 같은 것)가 탭바와 패널보다 위로 튀어나왔다. 탭바 z를 아무리 올려도 안 졌다.

소스를 따라가 보니 그 오버레이는 라이브러리가 캔버스 위에 보이게 하려고 일부러 수백만 단위의 z-index를 주고 있었다. 그 부작용으로 다른 UI까지 전부 덮어버린 거다.

여기서 진짜 핵심은 스택 컨텍스트였다. 그 오버레이가 정말 페이지 루트에서 천만 z를 가진다면 탭바로는 절대 못 이긴다. 그런데 오버레이가 어떤 낮은 z 컨텍스트 안에 갇혀 있다면, 그 천만은 컨텍스트 내부에서만 의미가 있고 페이지 기준으론 그 컨텍스트의 z일 뿐이다. position + z-index로 만든 요소는 새 스택 컨텍스트를 만들고, 그 안의 자식 z는 바깥으로 새어나가지 못한다.

그래서 두 가지로 못 박았다. 하나는 라이브러리 오버레이의 z 범위를 직접 제한해서 "캔버스보다는 위, 플로팅 UI보다는 아래"로 가둬버린 것. 다른 하나는 전체 레이어 순서를 의도적으로 설계하고 주석으로 박제해둔 것. 캔버스 < 오버레이 < 백드롭 < 탭바·팝업 < 스플래시 < 토스트, 이런 식으로.

3D나 WebGL이 DOM과 섞이면 z-index는 "큰 숫자 경쟁"이 아니라 스택 컨텍스트 설계의 문제가 된다. 라이브러리가 천만 단위 z를 쓰면, 따라서 키우지 말고 가두는 게 답이다.

"이상하다"의 8할은 디테일 부족

"선택된 상태의 탭 버튼 디자인이 이상하다, 특히 애니메이션"라는 생각이 들었다. 

제일 큰 범인은 레이아웃 시프트였다. 비활성일 땐 아이콘만, 활성일 땐 아이콘+라벨 구조라서 탭을 누를 때마다 아이콘이 가운데에서 왼쪽으로 밀리며 재배치됐다. 거기에 라벨 페이드와 슬라이딩 인디케이터가 따로 놀아서 산만했다.

그래서 잘게 손봤다. 라벨을 아예 빼서 선택 시 아이콘이 점프하지 않게 했고, 바깥 라운드에서 패딩을 뺀 값으로 안쪽 라운드를 맞춰 동심원으로 정렬했다. 누를 때 살짝 줄어드는 피드백(0.96 정도, 0.95보다 줄이면 과장돼 보인다)을 넣고, 활성 아이콘은 살짝 키우고 선을 굵게. 하이라이트엔 바운스를 빼고, transition: all 대신 움직일 속성만 명시했다. 히트 영역은 최소 권장치를 넘겼다.

"이상하다"는 피드백의 대부분은 레이아웃 시프트, 어긋난 라운드, 피드백 부재 같은 미세한 것들의 합이다. 하나하나는 사소한데 합치면 "싸구려 느낌"이 된다.

네트워크, 생각해야겠제?

"이 화면 열 때마다 API로 다 가져오는 거 좀 거슬린다"는 느낌. 이건 픽셀 문제가 아니라 데이터 흐름 문제지만, 사용자가 느끼는 부드러움에 직결됐다.

패널이 닫히면 언마운트, 열리면 다시 마운트되는 구조라 열 때마다 전체 목록을 새로 받고 있었다. 로컬에 저장해둔 게 있어서 빈 화면은 안 떴지만, 어쨌든 매번 풀 페이로드를 다시 요청한 거다. 재밌는 건, 같은 화면의 다른 데이터들은 이미 잘 돼 있었다는 점이다. TTL 캐시 + 변이 시 무효화. 한 군데만 그 가드가 빠져 있었다.

그래서 빠진 곳에만 TTL 가드를 더했다. 일정 시간 안에 같은 조건으로 성공한 적이 있으면 네트워크를 생략하는 식. 콘텐츠가 거의 일 단위로만 바뀌니 짧은 TTL로 충분했다. 핵심은 stale-while-revalidate, 즉 캐시를 즉시 보여주되 변이(구매·삭제 같은 것)가 일어나면 그때 명시적으로 무효화하는 거다.

"열 때마다 새로"는 게으른 정답이다. 진짜 정답은 캐시 + 변이 무효화다. 그리고 이미 잘 돼 있는 코드는 안 건드리는 것도 엔지니어링이다.



 "확 닫힌다", "늦게 따라온다", "이상하다". 막연한 불편함을 파고들면 전부 구체적인 원인을 갖고 있었다. 결국 이 일은 그 피드백 루프를 얼마나 짧게 도느냐의 싸움인 것 같다. 클로드 코드와 머리싸움 하는건 재밌지만, 매번 이렇게 디테일 다듬는건 지친다. 기능 만드는게 더 재밌고 다듬는건 재미가 좀 반감된다.

동작하는 피쳐를 만드는 건 반나절이면 된다. 좋게 느껴지는 코드까지의 그 나머지 거리, 거기에 시간을 다 쓴다.


최신 글