디자인 툴에서 레이어를 드래그하면 다른 레이어에 "착!" 하고 달라붙는 스냅 가이드. 당연하게 느껴지지만 직접 구현하려면 생각보다 고민할 게 많다.
요구사항 정의
내가 만들고 있는 pixelDiff는 이미지 비교 도구다. 사용자가 여러 이미지를 캔버스에 올려두고 위치를 조정하는데, 이때 정렬이 안 맞으면 비교가 힘들어진다. Figma처럼 자연스러운 스냅이 필요했다.
필요한 기능:
- 5개의 정렬 기준: left, right, centerX, top, bottom, centerY
- 다중 레이어 동시 드래그: 여러 개를 한 번에 선택해서 움직일 때도 스냅
- 줌 레벨 대응: 확대/축소 상태에서도 일관된 UX
- 시각적 가이드라인: 스냅될 때 어디에 맞춰지는지 보여주기
선택지 분석
스냅 로직을 어떻게 설계할지 몇 가지 방식을 검토했다.
| 방식 | 장점 | 한계 | 적합한 상황 |
|---|---|---|---|
| 그리드 스냅 | 구현이 단순함 | 레이어 간 정렬 불가 | 격자 기반 UI 빌더 |
| 레이어 간 스냅 | 직관적인 정렬 | O(n²) 비교 필요 | 디자인 툴 |
| 최근접 이웃 알고리즘 | 빠른 검색 | 초기 구현 복잡 | 수백 개 이상 오브젝트 |
그리드 스냅은 "10px 단위로만 이동" 같은 방식이다. 구현은 쉽지만 레이어끼리 맞추는 게 안 된다. 최근접 이웃(KD-Tree 등)은 레이어 수가 수백 개 이상일 때 유리하지만, 우리 서비스는 보통 10개 내외의 이미지를 다루므로 과한 최적화다.
레이어 간 스냅을 선택했다. O(n × m)이지만 n이 작아서 문제없고, 사용자 기대에 가장 부합한다.
핵심 설계
기준선(Edge) 정의
각 레이어에서 스냅 대상이 되는 기준선은 5개다.
┌─────────────────────┐
│ top │
│ │
│ left center right│
│ │
│ bottom │
└─────────────────────┘
X축: left, right, centerX
Y축: top, bottom, centerY
스냅 후보(Candidate) 생성
드래그 중인 레이어와 다른 모든 레이어를 비교해서 스냅 후보를 만든다.
interface SnapCandidate {
position: number; // 스냅 후 레이어의 x 또는 y 좌표
distance: number; // 현재 위치와 스냅 위치의 거리
guideline: number; // 가이드라인을 그릴 좌표
}
예를 들어 left-to-left 스냅이라면:
// A.left가 B.left에 스냅
{
position: otherLeft, // A의 x좌표를 여기로 이동
distance: Math.abs(dragLeft - otherLeft),
guideline: otherLeft, // 세로 가이드라인 표시
}
총 10가지 조합이 생긴다:
- X축: left↔left, left↔right, right↔left, right↔right, center↔center
- Y축: top↔top, top↔bottom, bottom↔top, bottom↔bottom, center↔center
임계값(Threshold) 기반 선택
모든 후보 중에서 threshold 이내면서 가장 가까운 것만 적용한다.
for (const candidate of xCandidates) {
if (candidate.distance <= threshold) {
if (!bestXSnap || candidate.distance < bestXSnap.distance) {
bestXSnap = candidate;
}
}
}
threshold 8px 기준이면:
- 5px 차이: 스냅 적용
- 12px 차이: 스냅 안 함
X축과 Y축은 독립적으로 계산된다. 가로로는 스냅되는데 세로로는 안 되는 상황이 가능하다.
다중 선택 스냅의 까다로운 점
단일 레이어 스냅은 간단하다. 문제는 여러 레이어를 선택해서 드래그할 때다.
Before:
┌───┐ ┌───┐
│ A │ │ B │ <- 둘 다 선택해서 오른쪽으로 드래그
└───┘ └───┘
┌───┐
│ C │ <- 스냅 대상
└───┘
어느 레이어 기준으로 스냅할 것인가?
해결책: 모든 엣지를 검사하되, 결과는 delta로 반환
export function calculateMultiLayerSnap(
draggingRects: LayerRect[],
others: LayerRect[],
threshold: number
): MultiSnapResult {
// ...
for (const dragging of draggingRects) {
for (const other of others) {
// X축 스냅 후보 (delta = 이동해야 할 거리)
const xCandidates: MultiSnapCandidate[] = [
{
delta: otherLeft - dragLeft, // position 대신 delta 사용
distance: Math.abs(dragLeft - otherLeft),
guideline: otherLeft,
},
// ...
];
}
}
return {
deltaX: bestXSnap?.delta ?? 0,
deltaY: bestYSnap?.delta ?? 0,
// ...
};
}
차이점:
- 단일 선택: 최종
position을 반환 - 다중 선택: 이동할
delta를 반환
delta를 받으면 모든 선택된 레이어에 동일하게 적용한다. A의 left가 C에 스냅되면 B도 같이 5px 이동하는 식이다.
줌 레벨 대응
캔버스를 50%로 축소하면 threshold도 조정해야 한다. 안 그러면 화면상 4px 차이에도 스냅이 걸려버린다.
calculate(dragging: LayerRect, others: LayerRect[], zoom: number = 1): SnapResult {
// 줌 아웃 시: threshold 증가 (캔버스 좌표계에서 더 넓은 범위)
// 줌 인 시: threshold 감소 (더 정밀한 스냅)
const dynamicThreshold = this.baseThreshold / zoom;
return calculateSnap(dragging, others, dynamicThreshold);
}
가이드라인 렌더링도 마찬가지:
private drawGuidelines(guidelines, zoom: number = 1) {
// 화면상 항상 1px로 보이도록
const dynamicWidth = 1 / zoom;
this.container.setStrokeStyle({
width: dynamicWidth,
color: getBrandAccent(),
alpha: 0.8,
});
for (const x of guidelines.vertical) {
this.container.moveTo(x, -5000 / zoom);
this.container.lineTo(x, 5000 / zoom);
}
}
줌 50%일 때 threshold 16px(캔버스 좌표), 화면상으로는 8px. 일관된 UX를 유지한다.
가이드라인 시각화
스냅이 발생하면 어디에 맞춰지는지 가이드라인으로 보여준다.
return {
x: bestXSnap.position,
snappedX: true,
guidelines: {
vertical: [bestXSnap.guideline], // 세로 선 (X축 스냅)
horizontal: [bestYSnap.guideline], // 가로 선 (Y축 스냅)
},
};
- X축 스냅 → 세로 가이드라인 (vertical)
- Y축 스냅 → 가로 가이드라인 (horizontal)
직관과 반대라서 헷갈릴 수 있는데, "X좌표가 맞춰졌다"는 걸 보여주려면 세로 선이 필요하다.
Pixi.js Graphics로 렌더링:
for (const x of guidelines.vertical) {
this.container.moveTo(x, -dynamicLength / 2);
this.container.lineTo(x, dynamicLength / 2);
}
this.container.stroke();
드래그가 끝나면 clear()로 숨긴다.
적용 흐름
실제 사용 흐름을 정리하면:
드래그 시작
→ dragStartPositionsRef에 초기 위치 저장
드래그 중 (onDragMove)
→ 현재 위치 계산
→ 단일/다중에 따라 calculate() 또는 calculateMulti() 호출
→ 스냅 결과로 최종 위치 결정
→ 가이드라인 자동 렌더링
드래그 종료
→ snapGuide.clear()
→ 서버에 위치 저장
핵심은 매 프레임마다 스냅을 계산한다는 점이다. 마우스가 조금씩 움직일 때마다 스냅 여부가 바뀌므로 부드러운 피드백이 가능하다.
스냅 가이드는 단순해 보이지만 다중 선택, 줌 대응, 시각적 피드백까지 고려하면 꽤 복잡해진다.
핵심 인사이트:
- 후보 생성 → 필터링 → 최선 선택 패턴이 깔끔하다
- 다중 선택은 position 대신 delta로 반환해야 일관성 있는 이동이 가능하다
- 줌 레벨에 따라 threshold와 렌더링 모두 동적으로 조정해야 한다
- X축 스냅과 Y축 스냅은 독립적으로 처리한다
이 패턴은 캔버스 기반 에디터라면 어디든 적용할 수 있다. 정렬 기준만 바꾸면 (예: 중앙선 추가, 마진 스냅 등) 확장도 쉽다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
