SVG를 알아보자
2025-02-01이전 회사에서 도넛 차트와 막대 차트를 구현할 일이 있었어요. 처음엔 라이브러리를 쓰려고 했는데 요청받은 디자인을 반영할 수 없어서, SVG로 직접 구현했었거든요. 그땐 만들기 급급해서 SVG의 자세한 내용을 분석하지 못한 아쉬움이 있어서, 이번 기회에 제대로 정리해보려고 해요.
SVG 기본 구조
SVG(Scalable Vector Graphics)는 XML 형식으로 벡터 그래픽을 표현하는 표준이에요. 래스터 이미지(PNG, JPG)와 달리 좌표와 수식으로 도형을 기술하니까, 어떤 크기로 확대해도 픽셀이 깨지지 않아요. HTML 문서 안에 <svg> 태그를 직접 삽입하면 DOM의 일부가 돼서 CSS와 JavaScript로 제어할 수 있고요.
<svg width="200" height="200">
<circle
cx="100"
cy="100"
r="80"
fill="skyblue"
stroke="black"
stroke-width="2"
/>
</svg>
cx, cy는 원의 중심 좌표이고 r은 반지름이에요. 위 코드는 파란 내부와 검은 외곽선을 가진 원을 그려요.
기본 도형 요소
<rect>는 사각형을 그려요. x, y로 시작 위치를 지정하고, rx, ry로 모서리를 둥글게 만들 수 있어요.
<svg width="200" height="200">
<rect
x="20" <!-- 왼쪽 상단 모서리의 x 좌표 -->
y="20" <!-- 왼쪽 상단 모서리의 y 좌표 -->
width="150"
height="100"
fill="lightgreen"
stroke="black"
stroke-width="2"
rx="10" <!-- 가로 방향 모서리 곡률 -->
ry="10" <!-- 세로 방향 모서리 곡률 -->
/>
</svg>
<line>은 두 점을 잇는 선이에요. 시작점 (x1, y1)과 끝점 (x2, y2)를 지정해요.
<svg width="200" height="200">
<line x1="10" y1="10" x2="190" y2="190" stroke="green" stroke-width="4" />
</svg>
<path>는 SVG에서 가장 강력한 요소예요. d 속성에 경로 명령어를 조합하면 어떤 형태든 그릴 수 있거든요.
<svg width="200" height="200">
<path
d="M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80"
fill="none"
stroke="red"
stroke-width="2"
/>
</svg>
주요 경로 명령어는 이래요.
| 명령어 | 설명 |
|---|---|
| M x y | 시작 위치 이동 (Move to) |
| L x y | 직선 그리기 (Line to) |
| C x1 y1, x2 y2, x y | 큐빅 베지어 곡선 (두 개의 제어점과 끝점을 이용한 곡선) |
| S x2 y2, x y | 부드러운 곡선 (이전 곡선의 연장) |
stroke-dasharray와 stroke-dashoffset
SVG에서 선을 점선으로 표현할 때 stroke-dasharray를 써요. stroke-dasharray: 5, 10이면 5px 선분과 10px 빈 공간이 반복돼요. 값을 하나만 주면 선과 빈 공간이 같은 길이로 반복되고요.
stroke-dashoffset은 이 점선 패턴의 시작 위치를 조정해요. offset이 클수록 패턴이 뒤로 밀려나는데, 이걸 애니메이션으로 활용하면 선이 그려지는 효과를 만들 수 있어요.
<svg width="200" height="200">
<circle cx="100" cy="100" r="80" fill="none" stroke="red" stroke-width="4" stroke-dasharray="502" stroke-dashoffset="502">
<style>
circle {
animation: draw 3s linear infinite;
}
@keyframes draw {
from {
stroke-dashoffset: 502;
}
to {
stroke-dashoffset: 0;
}
}
</style>
</svg>
원의 둘레는 2πr = 2 × 3.14 × 80 ≈ 502예요. stroke-dasharray="502"로 둘레와 같은 길이의 점선 하나를 만들면, 사실상 원 전체를 덮는 선분 하나가 돼요. 여기에 stroke-dashoffset="502"를 주면 패턴이 완전히 밀려나 선이 보이지 않는 상태가 되고요. offset을 0으로 줄여가면 선이 점진적으로 나타나는 애니메이션이 되는 거죠.
도넛 차트 구현
도넛 차트는 이 stroke-dasharray와 stroke-dashoffset의 조합으로 만들어요. 각 세그먼트를 별도의 <circle>로 구성하고, 각 원의 strokeDasharray를 [해당 세그먼트 길이, 나머지 길이]로 설정해요. 그리고 strokeDashoffset으로 각 세그먼트가 시작할 위치를 조정하고요.
type DonutSegment = {
value: number; // 전체에서 차지하는 퍼센트 (0~100)
color: string;
};
type DonutChartProps = {
data: DonutSegment[];
size?: number;
strokeWidth?: number;
};
const DonutChart = ({ data, size = 200, strokeWidth = 30 }: DonutChartProps) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
let accumulatedOffset = 0;
return (
<svg width={size} height={size}>
{data.map((segment, i) => {
const dash = (segment.value / 100) * circumference;
// 이전 세그먼트만큼 오프셋을 당겨서 시작 위치를 조정
const offset = circumference - accumulatedOffset;
accumulatedOffset += dash;
return (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={segment.color}
strokeWidth={strokeWidth}
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={offset}
// SVG 원의 0도는 3시 방향 → 12시 방향에서 시작하려면 -90도 회전
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
/>
);
})}
</svg>
);
};
rotate(-90deg)가 필요한 이유는 처음엔 직관적으로 와닿지 않을 수 있어요. SVG에서 stroke-dashoffset의 기준점은 3시 방향(오른쪽)이거든요. 도넛 차트는 보통 12시 방향에서 첫 번째 세그먼트가 시작하기를 기대하니까, 전체 원을 -90도 회전시켜야 해요. 처음에 이 부분을 몰라서 차트가 옆으로 누운 채로 나와 당황했어요.
Safari에서는 style={{ transformOrigin: "center" }}가 의도대로 동작하지 않을 수 있어요. SVG 요소의 transform 기준점은 기본적으로 SVG 뷰포트 원점(0, 0)이라, CSS의 center 키워드가 제대로 해석되지 않는 경우가 있거든요. transform-box: fill-box를 CSS에 추가하거나, transform="rotate(-90, ${center}, ${center})"처럼 SVG 자체 속성으로 처리하면 안전해요.
막대 차트 구현
막대 차트에서 가장 헷갈렸던 부분은 SVG 좌표계의 방향이에요. SVG의 y축은 위에서 아래로 증가하거든요. 화면 좌측 상단이 원점(0, 0)이고, 아래로 갈수록 y 값이 커지죠. 막대 차트는 아래에서 위로 막대가 자라야 하는데, 이 좌표계에서 막대의 y 속성은 "막대의 위쪽 끝" 좌표를 의미해요. 그래서 y = height - barHeight로 계산해야 막대가 차트 하단에서 시작해요.
type BarSegment = {
label: string;
value: number;
color: string;
};
type BarChartProps = {
data: BarSegment[];
width?: number;
height?: number;
padding?: number;
};
const BarChart = ({ data, width = 400, height = 300, padding = 8 }: BarChartProps) => {
const maxValue = Math.max(...data.map((d) => d.value));
const totalBars = data.length;
const barWidth = (width / totalBars) * 0.6;
const gap = (width / totalBars) * 0.4;
return (
<svg width={width} height={height}>
{data.map((item, i) => {
const barHeight = (item.value / maxValue) * (height - padding);
// SVG y축은 위에서 아래 방향이므로 아래서 위로 막대를 그리려면 이렇게 계산
const x = i * (barWidth + gap);
const y = height - barHeight;
return (
<g key={i}>
<rect x={x} y={y} width={barWidth} height={barHeight} fill={item.color} rx={4} />
<text
x={x + barWidth / 2}
y={height}
textAnchor="middle"
fontSize={12}
fill="currentColor"
>
{item.label}
</text>
</g>
);
})}
</svg>
);
};
<text> 태그의 y 좌표도 같은 이유로 y={height}로 설정해야 차트 하단에 레이블이 붙어요. 처음에 y = barHeight로 하면 막대가 위에서 아래로 그려지는 뒤집힌 차트가 나와요. 이 좌표계를 한 번 이해하고 나면 이후엔 헷갈리지 않는데, 처음엔 머릿속으로 그림을 그려가며 계산해야 해요.
라이브러리 없이 직접 구현하면 디자인 제약 없이 원하는 대로 만들 수 있다는 장점이 있어요. 다만 SVG 좌표계와 stroke-dashoffset 계산 방식은 한 번 제대로 이해해두지 않으면 매번 시행착오를 반복하게 돼요.