레시피 목록

SVG를 알아보자

2025년 2월 1일 5분 읽기
SVG

이전 회사에서 도넛 차트와 막대 차트를 구현할 일이 있었습니다. 처음엔 라이브러리를 쓰려고 했는데 요청받은 디자인을 반영할 수 없어서, 결국 SVG로 직접 구현했습니다.

그땐 만들기 급급해서 SVG의 자세한 내용을 분석하지 못한 아쉬움이 남았습니다. 이번 기회에 제대로 정리해보려고 합니다.

좌표와 수식으로 그리는 그림

SVG(Scalable Vector Graphics)는 XML 형식으로 벡터 그래픽을 표현하는 표준입니다. 래스터 이미지(PNG, JPG)와 달리 좌표와 수식으로 도형을 기술하니까, 어떤 크기로 확대해도 픽셀이 깨지지 않습니다. HTML 문서 안에 <svg> 태그를 직접 삽입하면 DOM의 일부가 돼서 CSS와 JavaScript로 제어할 수 있고요.

html
<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로 모서리를 둥글게 만들 수 있습니다.

html
<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)를 지정합니다.

html
<svg width="200" height="200">
  <line x1="10" y1="10" x2="190" y2="190" stroke="green" stroke-width="4" />
</svg>

<path>는 SVG에서 가장 강력한 요소입니다. d 속성에 경로 명령어를 조합하면 어떤 형태든 그릴 수 있으니까요.

html
<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부드러운 곡선 (이전 곡선의 연장)

점선 두 속성이 차트의 전부였다

SVG에서 선을 점선으로 표현할 때 stroke-dasharray를 씁니다. stroke-dasharray: 5, 10이면 5px 선분과 10px 빈 공간이 반복됩니다. 값을 하나만 주면 선과 빈 공간이 같은 길이로 반복되고요.

stroke-dashoffset은 이 점선 패턴의 시작 위치를 조정합니다. offset이 클수록 패턴이 뒤로 밀려나는데, 이걸 애니메이션으로 활용하면 선이 그려지는 효과를 만들 수 있습니다.

html
<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-dasharraystroke-dashoffset의 조합으로 만듭니다. 각 세그먼트를 별도의 <circle>로 구성하고, 각 원의 strokeDasharray[해당 세그먼트 길이, 나머지 길이]로 설정합니다. 그리고 strokeDashoffset으로 각 세그먼트가 시작할 위치를 조정하고요.

tsx
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 자체 속성으로 처리하면 안전합니다.

y축이 아래로 자란다는 사실

막대 차트에서 가장 헷갈렸던 부분은 SVG 좌표계의 방향입니다. SVG의 y축은 위에서 아래로 증가합니다. 화면 좌측 상단이 원점(0, 0)이고, 아래로 갈수록 y 값이 커지죠. 막대 차트는 아래에서 위로 막대가 자라야 하는데, 이 좌표계에서 막대의 y 속성은 "막대의 위쪽 끝" 좌표를 의미합니다. 그래서 y = height - barHeight로 계산해야 막대가 차트 하단에서 시작합니다.

tsx
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 계산 방식은 한 번 제대로 이해해두지 않으면 매번 시행착오를 반복하게 되는 것 같습니다. 차트 라이브러리가 디자인을 못 따라간다면, 한 번쯤 직접 그려보는 것도 나쁘지 않은 선택일지도 모르겠습니다.