2026. 6. 10. 16:20ㆍ개발로그
1. 들어가며
타임라인 리전 드래그 성능을 보면서 처음에는 “Canvas redraw를 어떻게 줄일까”를 먼저 생각했다. 이전 실험에서는 requestAnimationFrame으로 redraw를 묶고, React state 경로를 우회하고, 드래그 중 임시 이동 상태를 별도 저장소에 두는 방식으로 비용을 줄였다. 그 접근은 의미가 있었다. 실제로 Canvas draw 호출 수와 React 렌더 비용이 줄었다.
그런데 후속 최적화를 하면서 문제를 조금 다르게 보게 됐다. 드래그 중에 Canvas를 더 효율적으로 다시 그리는 것보다, 드래그 중 Canvas가 다시 그릴 필요가 없도록 구조를 바꾸는 편이 더 낫지 않을까라는 생각이었다.
결론부터 적으면, 현재 구조는 드래그 중 원본 리전과 Canvas를 움직이지 않는다. 대신 별도의 overlay가 포인터를 따라가고, 실제 리전 위치는 드래그가 끝난 뒤 한 번만 확정한다. 이 글은 그 판단이 어떻게 나왔고, 왜 오버레이 방식이 적합했는지 정리한 기록이다.
2. 이전 실험의 한계
이전 실험의 핵심은 드래그 중 임시 이동 상태를 실제 리전 데이터와 분리한 것이었다. 실제 리전 데이터를 바로 바꾸지는 않고, “현재 이만큼 이동 중이다”라는 값을 별도 저장소에 저장했다. Canvas와 SVG는 이 값을 구독해서 화면을 따라갔다. 흐름은 대략 이랬다.
드래그 중 마우스 이동
→ 임시 이동 상태 저장
→ Canvas / SVG가 임시 이동량 구독
→ Canvas redraw 또는 화면 위치 갱신
→ 드래그 완료 시 실제 위치 확정
이 방식은 실제 데이터와 임시 화면 표시를 분리했다는 점에서 나쁘지 않았다. 하지만 여전히 드래그 중 타임라인 렌더링 경로가 임시 이동을 따라가야 했다. 즉, draw 횟수를 줄일 수는 있어도 “드래그 중 Canvas나 SVG가 이동을 반영해야 한다”는 전제는 그대로였다. 여기서 다음 질문이 생겼다.
드래그 중 원본 리전이 실제로 움직여야 하는가?
Canvas가 드래그 중 이동을 매번 반영해야 하는가?
사용자에게 필요한 것은 실제 데이터 변경인가, 이동 중이라는 시각적 피드백인가?
이 질문을 기준으로 보면, 드래그 중 필요한 것은 실제 데이터 변경이 아니었다. 사용자는 “내가 잡은 리전이 포인터를 따라 움직이고 있다”는 피드백만 받으면 된다. 실제 위치는 마우스를 놓은 뒤 확정해도 된다.
3. 오버레이 방식으로 관점 바꾸기
오버레이 방식은 원본 리전을 직접 이동시키지 않는다. 원본 리전은 제자리에 남아 있고, 별도의 시각 요소가 마우스를 따라간다. 실제 데이터는 드래그가 끝난 시점에만 바뀐다.
드래그 시작
→ 원본 리전은 제자리에 남김
드래그 중
→ overlay가 마우스를 따라감
→ 실제 리전 데이터는 아직 변경하지 않음
→ Canvas redraw 없음
드래그 완료
→ 최종 위치 계산
→ 실제 리전 위치 확정
→ 필요한 구간만 Canvas redraw
이 구조에서 overlay는 실제 데이터가 아니다. 드래그 중이라는 사실을 보여주는 임시 시각 피드백이다. 원본 리전의 실제 시작 위치는 드래그 중에는 바뀌지 않고, 드래그가 끝났을 때만 확정된다. 레이어로 보면 역할은 이렇게 나뉜다.
┌────────────────────────────┐
│ Overlay │
│ - 드래그 중 위치 표시 │
│ - 선택 테두리 │
├────────────────────────────┤
│ Canvas │
│ - 파형 │
│ - 썸네일 │
│ - 확정된 리전 │
└────────────────────────────┘
Canvas는 파형, 썸네일, 확정된 리전처럼 무거운 시각 결과를 담당한다. Overlay는 드래그 중 위치 표시처럼 자주 바뀌는 조작 상태를 담당한다. 핵심은 간단하다.
드래그 중:
→ Overlay만 갱신
→ Canvas redraw 없음
→ 실제 데이터 변경 없음
드래그 완료:
→ 실제 데이터 변경
→ Canvas redraw
4. 측정으로 확인한 변화
현재 브랜치에서 같은 시나리오로 벤치마크를 돌렸다. 리전 11개를 만들고, 선택 리전을 +120px / 50 step으로 드래그한 뒤 3회 반복한 중앙값을 비교했다.
| 지표 | 블로그 Before | 블로그 After, 시도 1 | 현재 |
|---|---|---|---|
| React commit 수 | 105 | 101 | 53 |
| actualDuration 평균 | 2.87ms | 1.98ms | 1.46ms |
| canvas clearRect | 104 | 55 | 0 |
| canvas drawImage | 25,300 | 12,903 | 0 |
| FPS | 51.6 | 51.2 | 약 60 |
가장 중요한 변화는 clearRect와 drawImage가 0회가 됐다는 점이다. 이것은 단순히 redraw를 더 잘 묶었다는 뜻이 아니다. 드래그 move 중 Canvas가 관여하지 않는 구조가 됐다는 뜻이다.
다만 React commit 수와 actualDuration은 측정 도구 차이가 있으므로 절대값 비교는 조심해야 한다. 예전 글과 현재 벤치마크는 측정 방식이 완전히 같지 않다. 그래도 드래그 중 Canvas draw가 0으로 떨어진 것은 현재 구조와 직접적으로 맞아떨어진다.
5. 오버레이 방식이 해결한 문제
이전 실험은 Canvas redraw를 줄였다.
clearRect: 104 → 55
drawImage: 25,300 → 12,903
현재 구조는 move 중 Canvas redraw 자체를 없앴다.
clearRect: 0
drawImage: 0
그래서 이 개선을 단순히 “Canvas redraw 최적화”라고만 부르면 조금 부족하다. 더 정확히는 드래그 중 시각적 이동을 Canvas의 책임에서 overlay의 책임으로 옮긴 구조 변경이다. 비용 구조도 바뀌었다.
이전:
무거운 Canvas redraw 중심
현재:
가벼운 overlay 갱신 중심
물론 모든 비용이 사라진 것은 아니다. overlay 위치를 갱신하는 비용은 남아 있고, 필요한 경우 React 갱신이나 스타일 업데이트가 발생할 수 있다. 현재 측정에서도 React commit 수는 53회 남아 있다. 다만 이전처럼 Canvas가 드래그 중 계속 다시 그려지는 구조는 아니다.
6. 구현하면서 신경 쓴 부분
오버레이 방식은 원본 리전과 overlay가 동시에 보인다. 원본 위치를 남겨 두면 출발 위치를 알 수 있지만, 원본과 overlay가 같은 강도로 보이면 두 개의 리전이 있는 것처럼 보일 수 있다.
그래서 원본 리전은 opacity를 낮춰 정지된 상태로 보여준다. 현재 구현에서는 원본 리전을 opacity: 0.3으로 낮췄다. 이 처리는 overlay가 현재 조작 중인 대상을 대표하고, 원본은 출발 위치를 나타낸다는 시각적 구분을 만든다.
또 하나 중요한 부분은 overlay에서 보이는 위치와 실제 확정 위치의 정합성이다. overlay는 어떤 위치에 놓일 것처럼 보였는데, 마우스를 놓은 뒤 다른 위치로 확정되면 사용자는 동작을 신뢰하기 어렵다. 그래서 화면에서 보이는 이동과 실제 확정 결과가 같은 기준을 따라야 한다.
마우스 이동
→ 타임라인 위치로 변환
→ 이동 가능한 위치 계산
→ 실제 리전 위치 확정
overlay는 단순히 마우스를 따라가고, 최종 배치 규칙은 드래그 완료 시점에만 적용할 수도 있다. 또는 드래그 중에도 놓일 수 있는 위치를 미리 보여줄 수도 있다. 어느 쪽이든 중요한 것은 사용자가 보는 이동과 실제 확정 결과가 납득 가능해야 한다는 점이다.
7. 같은 아이디어로 떠올린 Trim 최적화
드래그 오버레이 방식을 정리하다 보니 trim도 같은 원칙으로 볼 수 있었다. trim handle을 움직이는 동안 실제 리전 시작 시간이나 길이를 계속 바꿀 필요가 없다. 사용자에게 필요한 것은 “이만큼 잘릴 예정이다”라는 preview다. 기존 trim 방식은 조작 중 실제 데이터를 계속 바꾸는 흐름에 가까웠다.
trim 중 마우스 이동
→ 실제 리전 시작 위치 / 길이 변경
→ React 리렌더
→ Canvas redraw
오버레이 방식으로 보면 trim 중에는 실제 데이터를 바꾸지 않고, overlay에서 잘릴 예정인 상태만 보여주면 된다.
trim 시작
→ trim 대상과 edge 저장
trim 중
→ 잘릴 예정인 범위 계산
→ overlay에서 잘린 것처럼 표시
→ 실제 region 데이터 변경 없음
→ Canvas redraw 없음
trim 완료
→ 실제 시작 위치 / 길이 갱신
→ 필요한 구간만 Canvas redraw
→ overlay 제거
trim 중 미리보기는 실제 데이터를 자른 것이 아니다. 화면에서만 일부를 가리거나 잘린 것처럼 보여주는 것이다.
원래 리전:
[====================]
trim 중 미리보기:
[===============]
trim 완료:
[===============]
다만 trim은 드래그보다 미리보기와 실제 결과의 일치가 더 중요하다. 미리보기는 1.2초 위치에서 잘리는 것처럼 보였는데, 마우스를 놓은 뒤 최소 길이 제약 때문에 1.0초로 확정되면 사용자는 조작 결과가 밀렸다고 느낄 수 있다. 그래서 trim overlay에서는 미리보기 계산과 실제 확정 계산이 같은 규칙을 사용해야 한다.
trim 중 잘릴 예정인 범위 계산
→ 최소 길이와 경계 조건을 미리보기에도 반영
→ overlay에서 잘린 것처럼 표시
→ trim 완료 시 같은 규칙으로 실제 시작 위치 / 길이 확정
이 글의 주된 주제는 드래그 오버레이 최적화다. Trim 최적화는 드래그에서 세운 원칙을 정리하는 과정에서 함께 떠올린 추가 확장에 가깝다.
8. 남은 정리
오버레이 방식으로 바뀌면서 예전에 쓰던 임시 이동 상태 저장소는 핵심 경로에서 빠졌다. 현재 코드에는 다음 잔재가 남아 있다.
| 코드 | 현재 상태 |
|---|---|
regionDragVisualStore |
정의는 있지만 호출 0회 |
setRegionDragVisualTransform |
정의는 있지만 호출 0회 |
dragVisualToken = useSyncExternalStore(...) |
구독은 남아 있지만 저장소가 갱신되지 않아 실제 효과가 없음 |
useSegmentHorizontalDrag |
wiring은 남아 있으나 region body drag는 오버레이 방식이 가로챔 |
이런 코드는 즉시 성능 문제를 만들지 않을 수 있다. 하지만 읽는 사람에게 “아직 이 경로가 쓰이나?”라는 혼란을 준다. 오버레이 방식이 안정화된 뒤에는 호출 경로를 확인하고, 더 이상 쓰지 않는 임시 이동 상태 관련 코드를 제거하는 편이 좋다.
9. 마치며
이번 작업을 돌아보면 처음에는 문제를 조금 좁게 보고 있었다. “Canvas redraw가 많다”는 사실을 확인하고 나서, 자연스럽게 redraw를 줄이는 방법부터 떠올렸다. requestAnimationFrame으로 묶고, React 경로를 우회하고, draw 호출 수를 줄이는 방식은 분명히 효과가 있었다. 하지만 그때는 아직 “이 draw가 꼭 필요한가”까지는 충분히 묻지 못했다.
오버레이 방식으로 바꾸면서 가장 크게 깨달은 점은, 성능 개선이 항상 더 빠르게 처리하는 방향일 필요는 없다는 것이다. 때로는 일을 더 잘하는 것보다, 그 일을 하지 않아도 되는 구조로 바꾸는 편이 더 근본적이다. 이번 경우에는 드래그 중 Canvas를 빠르게 다시 그리는 것이 아니라, 드래그 중 Canvas가 관여하지 않아도 되게 만드는 것이 핵심이었다.
부족했던 점도 분명하다. 처음부터 드래그 중 필요한 것이 실제 데이터 변경인지, 아니면 임시 시각 피드백인지 더 빨리 나눴다면 시행착오를 줄일 수 있었을 것 같다. 임시 이동 상태를 별도 저장소로 분리한 방식도 의미 있는 중간 단계였지만, 그 구조 안에서는 여전히 타임라인 렌더링 경로가 드래그를 따라가야 했다. 그 전제를 더 일찍 의심했어야 했다.
또 하나는 정리의 문제다. 오버레이 방식으로 바뀐 뒤에도 예전 임시 이동 상태 관련 코드가 일부 남아 있다. 당장 큰 비용을 만들지 않더라도, 다음에 코드를 읽는 사람에게는 “아직 쓰이는 경로인가?”라는 혼란을 줄 수 있다. 성능 개선은 새 구조를 넣는 것에서 끝나는 게 아니라, 더 이상 중심 경로가 아닌 코드를 정리하는 데까지 이어져야 한다.
앞으로 비슷한 문제를 만나면 먼저 세 가지를 확인하려고 한다.
1. 지금 자주 바뀌는 값이 실제 데이터인가, 임시 시각 상태인가?
2. Canvas나 React가 이 변경을 꼭 처리해야 하는가?
3. 조작 중 미리보기와 조작 완료 후 실제 데이터 변경을 분리할 수 있는가?
정리하자면 다음과 같다.
리전 드래그 중 실제 데이터와 원본 렌더링 경로는 멈춰 두고, 사용자가 필요한 임시 시각 피드백만 overlay로 분리했다. 더 짧게 적으면 이렇다.
드래그는 움직이는 척만 한다.
진짜 데이터 변경과 Canvas redraw는 마우스를 놓은 뒤 한 번만 한다.
이 원칙을 정리하고 나서 trim도 같은 방향으로 볼 수 있었다. trim은 잘리는 척만 하고, 실제 시작 위치와 길이 변경은 마우스를 놓은 뒤 한 번만 하면 된다. 다음에는 이런 공통 원칙을 더 빨리 발견하고, 특정 기능 하나의 최적화가 아니라 입력 처리 전체의 구조로 확장할 수 있는지 먼저 살펴보려고 한다.
'개발로그' 카테고리의 다른 글
| 상태 관리 도구 선택 기준과 Store 분리의 중요성 (0) | 2026.07.01 |
|---|---|
| Electron에서 여러 창의 상태를 하나의 진실로 맞추기 (0) | 2026.06.13 |
| [드래그 최적화 시도 2] 회고록: 정말 최선이었을까? (0) | 2026.06.10 |
| [드래그 최적화 시도 1] React 경로를 줄이고 한 프레임에 한 번만 그리기 (0) | 2026.06.09 |
| [썸네일 생성 최적화 시도 3] Web Worker를 다시 의심하고 검증하기 (0) | 2026.06.09 |