캔버스 기반의 2D 맵을 React에서 구현해야 할 때, Konva와 react-konva는 강력한 선택지입니다. 이 글에서는 실제 구현 경험을 바탕으로, Konva를 처음 사용하는 프론트엔드 개발자가 실용적인 2D 시각화를 구현할 수 있도록 핵심 패턴을 소개합니다.
💡 이 글에서 다루는 내용
- Konva란 무엇인가, 어떤 것을 만들 수 있는가
- Konva가 지원하는 다양한 도형과 선 그리기
- 이벤트 시스템 (클릭, 마우스, 키보드)
- 드래그 & 드롭 구현
- Konva의 핵심 개념 (Stage / Layer / Shape 계층 구조)
- 가상 좌표계 + Scale 기반 반응형 구현 패턴
- 실제 단위(m, km 등) → 픽셀 좌표 변환
- 인접 도형 경계선 겹침 해결 (INSET 패턴)
- ResizeObserver로 컨테이너 크기 반응형 감지
- 다중 레이어로 오버레이 구성
Konva는 HTML5 Canvas를 쉽게 다룰 수 있게 해주는 JavaScript 라이브러리입니다. 브라우저의 <canvas> API를 직접 사용하면 명령형 코드(ctx.fillRect(...))로만 작성해야 하지만, Konva는 도형을 객체로 관리하고 이벤트, 애니메이션, 드래그&드롭을 간단히 추가할 수 있게 해줍니다.
react-konva는 Konva를 React 컴포넌트 방식으로 사용할 수 있게 해주는 공식 바인딩입니다.
| 분야 | 예시 |
|---|---|
| 데이터 시각화 | 차트, 맵, 다이어그램, 히트맵 |
| 인터랙티브 UI | 드래그 가능한 보드, 칸반, 화이트보드 |
| 그래픽 편집기 | 이미지 편집, 도형 편집기, 설계 도구 |
| 게임 | 2D 게임, 퍼즐, 시뮬레이션 |
| 시트 배치 | 좌석 배치도, 평면도 편집 |
💡 핵심 특징
- 도형을 객체로 관리 → 개별 클릭/이벤트 처리 가능
draggableprop 하나로 드래그&드롭 즉시 활성화- 레이어 분리로 부분 렌더링 최적화
- React, Vue, Svelte, Angular 모두 지원
Konva는 다양한 기본 도형(Primitive)을 제공합니다.
| 도형 | 컴포넌트 | 주요 props |
|---|---|---|
| 사각형 | Rect | x, y, width, height, cornerRadius |
| 원 | Circle | x, y, radius |
| 타원 | Ellipse | x, y, radiusX, radiusY |
| 선 / 다각형 / 곡선 | Line | points, closed, tension |
| 화살표 | Arrow | points, pointerLength, pointerWidth |
| 정다각형 | RegularPolygon | x, y, sides, radius |
| 별 | Star | numPoints, innerRadius, outerRadius |
| 텍스트 | Text | text, fontSize, fontStyle, fontFamily |
| 이미지 | Image | image (HTMLImageElement) |
| SVG Path | Path | data (SVG path d 문자열) |
| 호 / 부채꼴 | Arc | innerRadius, outerRadius, angle |
| 도넛(링) | Ring | innerRadius, outerRadius |
| 부채꼴 | Wedge | radius, angle |
import { Stage, Layer, Rect, Circle, Ellipse, Line, Arrow, Star, RegularPolygon, Text } from 'react-konva'
function ShapeShowcase() {
return (
<Stage width={600} height={400}>
<Layer>
{/* 사각형 (둥근 모서리) */}
<Rect x={20} y={20} width={100} height={60} fill="#3b82f6" cornerRadius={8} />
{/* 원 */}
<Circle x={200} y={60} radius={40} fill="#10b981" />
{/* 타원 */}
<Ellipse x={320} y={60} radiusX={60} radiusY={30} fill="#f59e0b" />
{/* 직선 */}
<Line points={[20, 150, 200, 150]} stroke="#6b7280" strokeWidth={2} />
{/* 꺾인 선 (다각형 외곽선) */}
<Line
points={[20, 200, 80, 160, 140, 200, 140, 260, 20, 260]}
closed
stroke="#ef4444"
strokeWidth={2}
fill="rgba(239,68,68,0.1)"
/>
{/* 부드러운 곡선 (tension 사용) */}
<Line
points={[200, 180, 260, 140, 320, 200, 380, 160]}
stroke="#8b5cf6"
strokeWidth={3}
tension={0.5}
/>
{/* 화살표 */}
<Arrow
points={[20, 320, 150, 320]}
stroke="#1d4ed8"
fill="#1d4ed8"
strokeWidth={2}
pointerLength={10}
pointerWidth={8}
/>
{/* 정삼각형 (RegularPolygon, sides=3) */}
<RegularPolygon x={260} y={310} sides={3} radius={40} fill="#f97316" />
{/* 별 */}
<Star
x={380} y={310}
numPoints={5}
innerRadius={20}
outerRadius={40}
fill="#eab308"
/>
</Layer>
</Stage>
)
}💡 **
Line**의 활용 범위:closedprop으로 닫힌 다각형을,tensionprop으로 부드러운 곡선을 그릴 수 있습니다. 하나의 컴포넌트로 직선·꺾은선·곡선·다각형을 모두 표현할 수 있습니다.
<Rect
x={50} y={50} width={200} height={100}
// 채우기
fill="#3b82f6"
opacity={0.8}
// 테두리
stroke="#1d4ed8"
strokeWidth={2}
// 그림자
shadowColor="rgba(0,0,0,0.3)"
shadowBlur={10}
shadowOffsetX={4}
shadowOffsetY={4}
// 회전 / 변환
rotation={15} // 도(degree) 단위
scaleX={1.2}
offsetX={100} // 회전 기준점 이동
/>Konva의 모든 도형은 이벤트 핸들러를 직접 연결할 수 있습니다. DOM 이벤트와 비슷한 방식이라 직관적입니다.
| 이벤트 | 설명 |
|---|---|
onClick / onTap | 클릭 (모바일 탭) |
onDblClick | 더블클릭 |
onMouseEnter / onMouseLeave | 호버 인/아웃 |
onMouseMove | 마우스 이동 |
onMouseDown / onMouseUp | 마우스 버튼 |
onContextMenu | 우클릭 컨텍스트 메뉴 |
onDragStart / onDragMove / onDragEnd | 드래그 이벤트 |
import { useState } from 'react'
import { Stage, Layer, Circle, Text } from 'react-konva'
function InteractiveCircle() {
const [label, setLabel] = useState('도형 위에 마우스를 올려보세요')
const [color, setColor] = useState('#3b82f6')
return (
<Stage width={500} height={300}>
<Layer>
<Text x={10} y={10} text={label} fontSize={16} fill="#374151" />
<Circle
x={250} y={180} radius={60}
fill={color}
// 호버 시 색상 변경 + 커서 변경
onMouseEnter={(e) => {
setColor('#1d4ed8')
setLabel('호버 중!')
// 캔버스 커서 스타일 변경
e.target.getStage()!.container().style.cursor = 'pointer'
}}
onMouseLeave={(e) => {
setColor('#3b82f6')
setLabel('도형 위에 마우스를 올려보세요')
e.target.getStage()!.container().style.cursor = 'default'
}}
// 클릭 이벤트
onClick={() => setLabel('클릭됨!')}
// 우클릭
onContextMenu={(e) => {
e.evt.preventDefault() // 브라우저 기본 메뉴 방지
setLabel('우클릭됨!')
}}
/>
</Layer>
</Stage>
)
}⚠️ 커서 변경 주의: Konva 도형은
<canvas>위에 그려지므로 CSScursor스타일이 적용되지 않습니다. 대신e.target.getStage().container().style.cursor로 캔버스 DOM 요소의 커서를 직접 변경해야 합니다.
Konva에서 드래그&드롭을 활성화하는 방법은 매우 간단합니다. draggable prop 하나면 됩니다.
import { useState } from 'react'
import { Stage, Layer, Rect } from 'react-konva'
function DraggableBox() {
const [pos, setPos] = useState({ x: 50, y: 50 })
return (
<Stage width={500} height={400}>
<Layer>
<Rect
x={pos.x}
y={pos.y}
width={100}
height={100}
fill="#3b82f6"
draggable // ← 이것만 추가하면 드래그 활성화
onDragEnd={(e) => { // 드래그 종료 시 위치 저장
setPos({ x: e.target.x(), y: e.target.y() })
}}
/>
</Layer>
</Stage>
)
}import { useState } from 'react'
import { Stage, Layer, Star, Text } from 'react-konva'
type ShapeItem = { id: string; x: number; y: number; isDragging: boolean }
const initialItems: ShapeItem[] = [
{ id: 'a', x: 100, y: 100, isDragging: false },
{ id: 'b', x: 250, y: 150, isDragging: false },
{ id: 'c', x: 400, y: 100, isDragging: false },
]
function DraggableStars() {
const [items, setItems] = useState(initialItems)
const handleDragStart = (id: string) => {
setItems((prev) =>
prev.map((item) => ({ ...item, isDragging: item.id === id }))
)
}
const handleDragEnd = (id: string, x: number, y: number) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, x, y, isDragging: false } : item
)
)
}
return (
<Stage width={600} height={300}>
<Layer>
<Text text="별을 드래그해보세요" x={10} y={10} fontSize={16} />
{items.map((item) => (
<Star
key={item.id}
x={item.x}
y={item.y}
numPoints={5}
innerRadius={20}
outerRadius={40}
fill="#eab308"
draggable
// 드래그 중일 때 크기와 그림자로 시각 피드백
scaleX={item.isDragging ? 1.3 : 1}
scaleY={item.isDragging ? 1.3 : 1}
shadowBlur={item.isDragging ? 15 : 0}
shadowColor="rgba(0,0,0,0.4)"
onDragStart={() => handleDragStart(item.id)}
onDragEnd={(e) => handleDragEnd(item.id, e.target.x(), e.target.y())}
/>
))}
</Layer>
</Stage>
)
}<Rect
x={100} y={100} width={80} height={80}
fill="#10b981"
draggable
// 스테이지 경계 안으로만 이동 가능하도록 제한
dragBoundFunc={(pos) => ({
x: Math.max(0, Math.min(pos.x, 500 - 80)),
y: Math.max(0, Math.min(pos.y, 400 - 80)),
})}
/>
{/* 수평 방향으로만 이동 */}
<Rect
x={100} y={250} width={80} height={40}
fill="#f59e0b"
draggable
dragBoundFunc={function(pos) {
return { x: pos.x, y: this.absolutePosition().y }
}}
/>💡
dragBoundFunc: 드래그할 때마다 호출되며, 반환한{ x, y }값으로 도형의 위치가 결정됩니다. 경계 제한, 그리드 스냅, 축 고정 등 다양한 제약을 구현할 수 있습니다.
Konva는 HTML5 Canvas를 추상화한 라이브러리입니다. react-konva는 Konva를 React 컴포넌트 방식으로 사용할 수 있게 해주는 바인딩입니다.
계층 구조는 다음과 같습니다:
Stage (캔버스 전체 영역)
└── Layer (도형 그룹)
└── Shape (Rect, Circle, Text, Group 등)<canvas> DOM 요소. 실제 픽셀 크기(width, height)와 전역 변환(scaleX, scaleY, x, y)을 담당합니다.<canvas>가 생성되어 독립적으로 렌더링됩니다.Rect, Circle, Text, Line, Group 등 실제로 그려지는 요소입니다.기본 사용 예시:
import { Stage, Layer, Rect, Text } from 'react-konva'
function SimpleCanvas() {
return (
<Stage width={800} height={600}>
<Layer>
<Rect x={10} y={10} width={200} height={100} fill="#3b82f6" />
<Text x={20} y={20} text="Hello Konva" fontSize={18} fill="white" />
</Layer>
</Stage>
)
}⚠️ 주의:
Stage의width/height는 실제 DOM 픽셀 크기입니다.scaleX/scaleY는 내부 좌표계 전체를 스케일합니다. 이 두 개념을 구분하는 것이 핵심입니다.
캔버스를 반응형으로 만드는 방법은 여러 가지가 있습니다. 가장 실용적인 패턴은 가상 좌표계(virtual coordinate system) 를 고정하고, Stage의 scaleX/scaleY로 화면에 맞게 스케일하는 방식입니다.
💡 이 패턴의 핵심 아이디어 모든 도형은 고정된 가상 픽셀 공간(예: 1000px 기준)에서 좌표를 계산하고,
Stage가 이 공간을 실제 화면 크기에 맞게 자동으로 늘리거나 줄입니다.
이 방식의 장점:
const VIRTUAL_WIDTH = 1000 // 가상 캔버스 기준 너비
const CANVAS_PADDING = 30 // 여백
function calcCanvasScale(
containerW: number,
containerH: number,
mapW?: number,
mapH?: number,
) {
// 맵 비율에 맞는 가상 높이 계산
const virtualH = mapW && mapH
? VIRTUAL_WIDTH * (mapH / mapW)
: 600
// 컨테이너에 맞는 스케일 계산 (패딩 제외)
const scaleX = (containerW - CANVAS_PADDING * 2) / VIRTUAL_WIDTH
const scaleY = (containerH - CANVAS_PADDING * 2) / virtualH
// 비율 유지를 위해 더 작은 스케일 사용
const scale = Math.min(scaleX, scaleY)
return {
scaleX: scale,
scaleY: scale,
offsetX: CANVAS_PADDING,
offsetY: CANVAS_PADDING,
}
}<Stage
width={containerWidth}
height={containerHeight}
scaleX={scaleX}
scaleY={scaleY}
x={offsetX}
y={offsetY}
>
<Layer>
{/* 도형은 가상 좌표(0~1000)로만 계산 */}
<Rect x={100} y={50} width={300} height={200} fill="#3b82f6" />
</Layer>
</Stage>Rect의 x={100}은 가상 픽셀 기준입니다. Stage의 scaleX가 이를 실제 화면 픽셀로 자동 변환합니다.
실제 데이터가 미터, 킬로미터 같은 단위를 사용할 때는 이를 가상 픽셀로 변환해야 합니다.
예를 들어 지도의 가로 폭이 50m이고, 가상 캔버스 너비가 1000px이라면:
const VIRTUAL_WIDTH = 1000
function calcPixelsPerUnit(mapWidthInMeters: number) {
// 1미터당 가상 픽셀 수
return VIRTUAL_WIDTH / mapWidthInMeters
}
// 사용 예시
const pxPerMeter = calcPixelsPerUnit(50) // → 20
// 맵에서 x=3m, y=4m 위치, 5m x 6m 크기인 영역의 픽셀 좌표
const region = {
x: 3 * pxPerMeter, // → 60
y: 4 * pxPerMeter, // → 80
width: 5 * pxPerMeter, // → 100
height: 6 * pxPerMeter, // → 120
}이 변환 함수를 컴포넌트 외부에 분리해 두면, 좌표 계산 로직이 UI 렌더링과 명확히 분리됩니다.
실제로 맵에 영역을 그릴 때는 Group으로 관련 도형들을 묶어 관리합니다.
import { Group, Rect, Text } from 'react-konva'
interface RegionData {
layout: { x: number; y: number; width: number; height: number } // 미터 단위
name: string
color: string
}
interface RegionShapeProps {
region: RegionData
pxPerUnit: number // 단위당 픽셀 수
}
const LABEL_OFFSET = 16
const BORDER_STROKE = 2
const FILL_ALPHA = 0.15
const INSET = 3
function RegionShape({ region, pxPerUnit }: RegionShapeProps) {
const { x: xUnit, y: yUnit, width: wUnit, height: hUnit } = region.layout
// 미터 → 가상 픽셀 변환
const px = xUnit * pxPerUnit
const py = yUnit * pxPerUnit
const pw = wUnit * pxPerUnit
const ph = hUnit * pxPerUnit
return (
<Group>
{/* 채우기 사각형 */}
<Rect
x={px + INSET}
y={py + INSET}
width={pw - INSET * 2}
height={ph - INSET * 2}
fill={region.color}
opacity={FILL_ALPHA}
/>
{/* 테두리 사각형 (별도 레이어처럼 분리) */}
<Rect
x={px + INSET}
y={py + INSET}
width={pw - INSET * 2}
height={ph - INSET * 2}
stroke={region.color}
strokeWidth={BORDER_STROKE}
fill="transparent"
/>
{/* 라벨 */}
<Text
x={px + LABEL_OFFSET}
y={py + LABEL_OFFSET}
text={region.name}
fontSize={18}
fontStyle="bold"
fill={region.color}
/>
</Group>
)
}맵처럼 영역들이 인접해 있는 경우, 경계선이 서로 겹쳐 두 배로 두꺼워 보이는 문제가 생깁니다.
❌ 문제: 인접한 두 영역의 테두리가 겹침
┌──────┐┌──────┐
│ A ││ B │ ← 중간 경계선이 2px × 2 = 4px처럼 보임
└──────┘└──────┘
✅ 해결: 각 영역을 INSET만큼 안쪽으로 축소
┌──────┐ ┌──────┐
│ A │ │ B │ ← 간격이 생겨 자연스러운 경계
└──────┘ └──────┘구현은 간단합니다. 모든 Rect의 x, y에 INSET을 더하고, width, height에서 INSET * 2를 빼면 됩니다:
const INSET = 3 // 가상 픽셀 단위
<Rect
x={px + INSET}
y={py + INSET}
width={pw - INSET * 2}
height={ph - INSET * 2}
fill={color}
/>💡 CSS의
box-sizing: border-box와 유사한 개념을 캔버스에 직접 적용한 패턴입니다. Canvas API에는 CSS box model이 없으므로 직접 계산해야 합니다.
Stage는 고정 픽셀 크기가 필요합니다. 컨테이너 크기를 동적으로 감지하려면 ResizeObserver를 사용합니다.
import { useEffect, useRef, useState } from 'react'
function useContainerDimensions() {
const ref = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
setDimensions({ width, height })
})
observer.observe(el)
// 초기 크기 즉시 설정
const rect = el.getBoundingClientRect()
setDimensions({ width: rect.width, height: rect.height })
return () => observer.disconnect()
}, [])
return { ref, ...dimensions }
}💡
getBoundingClientRect()로 초기 크기를 즉시 설정하는 이유는ResizeObserver가 첫 콜백을 발화하기 전에 크기가 0인 채로 렌더링되는 것을 방지하기 위함입니다.
Konva의 Layer는 별도의 <canvas>로 분리되어 독립적으로 업데이트됩니다. 이 특성을 활용해 기본 맵과 오버레이를 분리할 수 있습니다.
<Stage width={width} height={height} scaleX={scale} scaleY={scale}>
{/* 레이어 1: 기본 맵 영역 (정적, 자주 변경 안 됨) */}
<Layer>
{regions.map((region) => (
<RegionShape key={region.id} region={region} pxPerUnit={pxPerUnit} />
))}
</Layer>
{/* 레이어 2: 동적 오버레이 (자주 업데이트) */}
<Layer>
{regions.map((region) => renderOverlay?.(region, pxPerUnit))}
</Layer>
</Stage>✅ 성능 팁: 자주 변경되는 요소를 별도 레이어에 분리하면, 해당 레이어만 재렌더링되어 성능이 향상됩니다. 정적인 배경과 동적인 마커를 레이어로 분리하는 것이 좋은 패턴입니다.
위 패턴들을 모두 조합한 완성 예시입니다:
import { Layer, Stage } from 'react-konva'
const VIRTUAL_WIDTH = 1000
const CANVAS_PADDING = 30
function calcPxPerUnit(mapWidthInUnits: number) {
return VIRTUAL_WIDTH / mapWidthInUnits
}
function calcScale(containerW: number, containerH: number, mapW: number, mapH: number) {
const virtualH = VIRTUAL_WIDTH * (mapH / mapW)
const sx = (containerW - CANVAS_PADDING * 2) / VIRTUAL_WIDTH
const sy = (containerH - CANVAS_PADDING * 2) / virtualH
const scale = Math.min(sx, sy)
return { scaleX: scale, scaleY: scale, offsetX: CANVAS_PADDING, offsetY: CANVAS_PADDING }
}
interface MapData {
widthInMeters: number
heightInMeters: number
regions: RegionData[]
}
interface MapViewerProps {
map: MapData
renderOverlay?: (region: RegionData, pxPerUnit: number) => React.ReactNode
}
function MapViewer({ map, renderOverlay }: MapViewerProps) {
const { ref, width, height } = useContainerDimensions()
const pxPerUnit = calcPxPerUnit(map.widthInMeters)
const { scaleX, scaleY, offsetX, offsetY } = calcScale(
width, height,
map.widthInMeters, map.heightInMeters
)
const aspectRatio = map.widthInMeters / map.heightInMeters
return (
<div
ref={ref}
style={{ width: '100%', aspectRatio: String(aspectRatio) }}
>
{width > 0 && height > 0 && (
<Stage
width={width}
height={height}
scaleX={scaleX}
scaleY={scaleY}
x={offsetX}
y={offsetY}
>
<Layer>
{map.regions.map((region) => (
<RegionShape
key={region.id}
region={region}
pxPerUnit={pxPerUnit}
/>
))}
</Layer>
{renderOverlay && (
<Layer>
{map.regions.map((region) => renderOverlay(region, pxPerUnit))}
</Layer>
)}
</Stage>
)}
</div>
)
}| 패턴 | 설명 | 효과 |
|---|---|---|
| 가상 좌표계 + Stage scale | 모든 도형은 고정 가상 픽셀로 계산 | 반응형 자동 처리 |
| 단위 → 픽셀 변환 함수 | pxPerUnit = VIRTUAL_WIDTH / 실제너비 | 실측 데이터를 좌표로 변환 |
| INSET 패턴 | 각 도형을 안쪽으로 축소 | 인접 경계선 겹침 방지 |
| ResizeObserver | 컨테이너 크기 동적 감지 | 리사이즈 대응 |
| 다중 Layer | 정적/동적 요소 분리 | 렌더링 성능 최적화 |
| CSS aspectRatio | 컨테이너 비율 고정 | 맵 비율 보장 |
✅ 요약: react-konva에서 반응형 맵을 구현할 때의 핵심은 "도형은 가상 좌표로, 변환은 Stage가"입니다. 개별 도형이 화면 크기를 알 필요 없이, Stage의 scale만 조정하면 전체가 자동으로 맞춰집니다.
npm install konva react-konva⚠️ react-konva는 React 18 기준으로
react-konva@18.x를 사용하세요. React 버전과 react-konva 메이저 버전을 맞춰야 합니다.