Zeno ZENO
회사 개발일지 / 레거시 생존기 · 조회 6 · 좋아요 0

Chart.js 없이 SVG와 JavaScript로 도넛 그래프 만들기

Chart.js 같은 라이브러리 없이 SVG와 JavaScript만으로 도넛 그래프를 만드는 방법을 초보자도 이해할 수 있게 정리했습니다. viewBox, circle, 원의 둘레 공식, stroke-dasharray, rotate 개념까지 함께 설명합니다.

Chart.js 없이 SVG와 JavaScript로 도넛 그래프 만들기

웹 페이지에서 작업 현황, 통계 요약, 진행 상태 등을 보여줄 때 원형 그래프나 도넛 그래프를 자주 사용한다. 보통은 Chart.js 같은 차트 라이브러리를 사용하지만, 간단한 그래프라면 HTML, CSS, JavaScript만으로도 충분히 만들 수 있다.

이 글에서는 SVG와 JavaScript를 사용해서 도넛 그래프를 직접 구현하는 방법을 정리한다. 단순히 코드만 복사하는 것이 아니라, SVG가 무엇인지, 원의 둘레 공식이 왜 필요한지, JavaScript가 어떤 방식으로 원 조각을 만들어 넣는지까지 기초부터 설명한다.


1. 만들 예제

예제 데이터는 일반적인 작업 현황 데이터로 구성한다.

전체 작업 수: 10개

완료: 5
진행중: 2
실패: 1
보류: 1
기타: 1

이 데이터를 도넛 그래프로 표현한다.


2. 완성 코드

먼저 전체 코드를 확인해보자.

<article class="summary-card">
  <h3>작업 현황 요약</h3>

  <div class="summary-content">
    <div class="chart-box">
      <svg id="donutChart" width="120" height="120" viewBox="0 0 120 120">
        <circle
          cx="60"
          cy="60"
          r="45"
          fill="none"
          stroke="#eeeeee"
          stroke-width="14">
        </circle>
      </svg>

      <div class="chart-center-text">10개</div>
    </div>

    <ul class="legend-list">
      <li><span class="legend-dot done"></span>완료</li>
      <li><span class="legend-dot progress"></span>진행중</li>
      <li><span class="legend-dot fail"></span>실패</li>
      <li><span class="legend-dot hold"></span>보류</li>
      <li><span class="legend-dot etc"></span>기타</li>
    </ul>
  </div>

  <div class="summary-info">
    <div>
      <span>완료</span>
      <strong>5</strong>
    </div>

    <div>
      <span>미완료</span>
      <strong>5</strong>
    </div>
  </div>
</article>

<style>
  .summary-card {
    width: 280px;
    padding: 20px;
    border: 1px solid #ddd;
    border-radius: 16px;
    font-family: sans-serif;
  }

  .summary-card h3 {
    margin: 0 0 20px;
    font-size: 18px;
  }

  .summary-content {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  .chart-box {
    position: relative;
    width: 120px;
    height: 120px;
  }

  #donutChart {
    display: block;
  }

  .chart-center-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);

    font-size: 18px;
    font-weight: 700;
  }

  .legend-list {
    list-style: none;
    margin: 0;
    padding: 0;
  }

  .legend-list li {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 8px;
    font-size: 14px;
  }

  .legend-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    display: inline-block;
  }

  .done {
    background-color: #33a896;
  }

  .progress {
    background-color: #1a62a9;
  }

  .fail {
    background-color: #ff4d4d;
  }

  .hold {
    background-color: #aaaaaa;
  }

  .etc {
    background-color: #ffd24c;
  }

  .summary-info {
    margin-top: 20px;
    padding-top: 16px;
    border-top: 1px solid #eee;
  }

  .summary-info div {
    display: flex;
    justify-content: space-between;
    margin-bottom: 8px;
  }

  .summary-info span {
    color: #666;
  }
</style>

<script>
  document.addEventListener("DOMContentLoaded", () => {
    const data = [
      { label: "완료", value: 5, color: "#33a896" },
      { label: "진행중", value: 2, color: "#1a62a9" },
      { label: "실패", value: 1, color: "#ff4d4d" },
      { label: "보류", value: 1, color: "#aaaaaa" },
      { label: "기타", value: 1, color: "#ffd24c" }
    ];

    const total = data.reduce((sum, item) => sum + item.value, 0);

    const radius = 45;
    const circumference = 2 * Math.PI * radius;

    const svg = document.getElementById("donutChart");

    if (!svg) return;

    let accumulatedPercent = 0;

    data.forEach(item => {
      if (item.value === 0) return;

      const percent = item.value / total;

      const dashLength = circumference * percent;
      const emptyLength = circumference - dashLength;

      const angle = accumulatedPercent * 360 - 90;

      const circleHTML = `
        <circle
          cx="60"
          cy="60"
          r="${radius}"
          fill="none"
          stroke="${item.color}"
          stroke-width="14"
          stroke-dasharray="${dashLength} ${emptyLength}"
          transform="rotate(${angle} 60 60)">
        </circle>
      `;

      svg.insertAdjacentHTML("beforeend", circleHTML);

      accumulatedPercent += percent;
    });
  });
</script>

3. 도넛 그래프는 어떻게 만들어질까?

도넛 그래프는 실제로 원을 여러 조각으로 자르는 방식이 아니다. 코드에서는 같은 위치에 원을 여러 개 겹쳐 놓고, 각각의 원에서 필요한 길이만큼의 테두리만 보이게 만든다.

예를 들어 완료, 진행중, 실패, 보류, 기타 데이터가 있다면 내부적으로는 다음과 같은 원들이 만들어진다.

완료용 원
진행중용 원
실패용 원
보류용 원
기타용 원

이 원들은 모두 같은 중심 좌표를 가진다. 대신 각 원마다 보이는 선의 길이와 시작 각도를 다르게 해서 도넛 그래프처럼 보이게 만든다.


4. SVG란?

SVG는 웹에서 도형을 그릴 수 있는 그래픽 영역이다. HTML에서 일반적인 박스는 div로 만들고, 문장은 p 태그로 만들지만, 원이나 선 같은 도형은 SVG를 사용해서 만들 수 있다.

<svg width="120" height="120" viewBox="0 0 120 120">
</svg>

위 코드는 가로 120px, 세로 120px 크기의 SVG 영역을 만든다. 쉽게 말하면 도형을 그릴 수 있는 작은 도화지를 만든 것이다.


5. viewBox란?

viewBox="0 0 120 120"

viewBox는 SVG 내부에서 사용할 좌표 기준을 정하는 속성이다.

viewBox는 네 개의 숫자로 구성된다.

viewBox="시작X 시작Y 가로크기 세로크기"

따라서 다음 코드는,

viewBox="0 0 120 120"

SVG 안에 x좌표 0부터 120까지, y좌표 0부터 120까지의 좌표판을 만든다는 뜻이다.

왼쪽 위: 0, 0
가운데: 60, 60
오른쪽 아래: 120, 120

그래서 원을 정중앙에 놓으려면 원의 중심 좌표를 60, 60으로 잡으면 된다.


6. circle 태그 이해하기

<circle
  cx="60"
  cy="60"
  r="45"
  fill="none"
  stroke="#eeeeee"
  stroke-width="14">
</circle>

circle 태그는 SVG 안에서 원을 그리는 태그다.

cx: 원 중심의 x좌표
cy: 원 중심의 y좌표
r: 원의 반지름
fill: 원 내부 색상
stroke: 원 테두리 색상
stroke-width: 원 테두리 두께

여기서 도넛 그래프를 만들 때 가장 중요한 것은 fill과 stroke다.

fill="none"

fill을 none으로 설정하면 원 내부를 채우지 않는다. 그래서 가운데가 비어 있는 도넛 형태를 만들 수 있다.

stroke="#eeeeee"
stroke-width="14"

stroke는 테두리 색상이고, stroke-width는 테두리 두께다. 도넛 그래프는 원 안쪽을 색칠하는 것이 아니라, 원의 테두리를 두껍게 만들어 표현한다.


7. 왜 반지름을 45로 했을까?

SVG 크기는 120x120이다. 중심 좌표는 60, 60이다.

이론적으로 반지름을 60으로 하면 SVG를 꽉 채우는 원이 된다. 하지만 실제로는 원이 잘릴 수 있다.

이유는 stroke-width 때문이다. stroke-width는 원의 기준선에서 안쪽과 바깥쪽으로 퍼진다.

반지름: 45
테두리 두께: 14
테두리 절반: 7

원의 중심에서 가장 바깥쪽까지의 거리는 대략 45 + 7 = 52가 된다. 중심 좌표가 60이므로 SVG 안쪽에 여유 공간이 생긴다.

60 - 52 = 8
60 + 52 = 112

SVG 좌표는 0부터 120까지이므로, 8부터 112 사이에 원이 들어온다. 그래서 원이 잘리지 않는다.


8. 중앙 텍스트 배치하기

<div class="chart-box">
  <svg id="donutChart" width="120" height="120" viewBox="0 0 120 120">
    ...
  </svg>

  <div class="chart-center-text">10개</div>
</div>

SVG 위에 중앙 텍스트를 겹치려면 부모 요소를 기준점으로 만들어야 한다.

.chart-box {
  position: relative;
  width: 120px;
  height: 120px;
}

부모인 chart-box에 position: relative를 준다. 그러면 그 안에 있는 absolute 요소가 이 박스를 기준으로 움직일 수 있다.

.chart-center-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

top: 50%, left: 50%는 부모 박스의 가운데 지점으로 이동한다는 뜻이다. 하지만 이 상태에서는 텍스트의 왼쪽 위 모서리가 가운데에 위치한다.

그래서 transform: translate(-50%, -50%)를 사용한다. 이것은 텍스트 자신의 크기 절반만큼 왼쪽과 위쪽으로 당기는 역할을 한다. 결과적으로 텍스트가 정확히 가운데에 배치된다.


9. JavaScript 데이터 준비하기

const data = [
  { label: "완료", value: 5, color: "#33a896" },
  { label: "진행중", value: 2, color: "#1a62a9" },
  { label: "실패", value: 1, color: "#ff4d4d" },
  { label: "보류", value: 1, color: "#aaaaaa" },
  { label: "기타", value: 1, color: "#ffd24c" }
];

data는 그래프에 사용할 데이터를 담은 배열이다. 배열은 여러 값을 순서대로 담을 수 있는 자료 구조다.

배열 안에는 객체가 들어 있다.

{ label: "완료", value: 5, color: "#33a896" }

이 객체는 하나의 그래프 항목을 의미한다.

label: 항목 이름
value: 항목 값
color: 그래프 색상

즉, 완료라는 항목은 값이 5이고, 그래프에서는 초록색 계열로 표시된다.


10. const란?

const data = [...];

const는 JavaScript에서 변수를 만드는 키워드다. 변수는 값을 담아두는 이름 있는 상자라고 생각하면 된다.

const total = 10;

위 코드는 total이라는 이름에 10이라는 값을 담는다는 뜻이다. const로 만든 변수는 다시 다른 값으로 재할당할 수 없다.

const total = 10;
total = 20; // 오류 발생

그래서 중간에 바뀌면 안 되는 값에는 const를 주로 사용한다.


11. 전체 합계 구하기

const total = data.reduce((sum, item) => sum + item.value, 0);

이 코드는 data 배열 안에 있는 value 값을 모두 더한다.

완료: 5
진행중: 2
실패: 1
보류: 1
기타: 1

전체 합계는 다음과 같다.

5 + 2 + 1 + 1 + 1 = 10

따라서 total에는 10이 저장된다.


12. reduce란?

reduce는 배열 안의 값을 하나의 결과로 합칠 때 사용하는 메서드다.

const numbers = [1, 2, 3];

const total = numbers.reduce((sum, number) => {
  return sum + number;
}, 0);

위 코드는 배열 안의 숫자를 모두 더해서 6을 만든다.

처음 sum = 0

1번째 반복:
sum = 0
number = 1
결과 = 1

2번째 반복:
sum = 1
number = 2
결과 = 3

3번째 반복:
sum = 3
number = 3
결과 = 6

이번 예제에서도 같은 방식으로 data 안의 value 값을 모두 더한다.


13. 원의 둘레 공식

const radius = 45;
const circumference = 2 * Math.PI * radius;

도넛 그래프를 만들려면 원의 전체 둘레 길이를 알아야 한다. 왜냐하면 전체 둘레 중에서 데이터 비율만큼만 선을 보여줘야 하기 때문이다.

원의 둘레 공식은 다음과 같다.

원의 둘레 = 2 × π × 반지름

JavaScript에서 π는 Math.PI로 사용할 수 있다.

Math.PI // 약 3.141592

반지름이 45라면 계산은 다음과 같다.

2 × 3.141592 × 45 = 약 282.74

따라서 반지름 45짜리 원의 둘레는 약 282.74다. 이 값이 circumference에 저장된다.


14. stroke-dasharray 이해하기

stroke-dasharray는 SVG 선을 점선처럼 만드는 속성이다.

stroke-dasharray="20 10"

위 코드는 20만큼 선을 그리고, 10만큼 비운다는 뜻이다.

20 보임
10 비움
20 보임
10 비움
20 보임
10 비움

도넛 그래프에서는 이 속성을 이용해서 원의 일부만 보이게 만든다.

stroke-dasharray="보일길이 숨길길이"

예를 들어 원의 전체 둘레가 100이라고 가정하면,

50%만 보이게 하기: stroke-dasharray="50 50"
30%만 보이게 하기: stroke-dasharray="30 70"
70%만 보이게 하기: stroke-dasharray="70 30"

15. 데이터 비율 계산하기

const percent = item.value / total;

현재 항목이 전체에서 차지하는 비율을 계산한다.

완료 데이터는 5이고 전체는 10이다.

5 / 10 = 0.5

즉, 완료는 전체의 50%다.

JavaScript에서는 50%를 50으로 계산하기보다 0.5로 계산하는 경우가 많다.

50% = 0.5
20% = 0.2
10% = 0.1

16. 보일 선 길이와 숨길 선 길이 계산하기

AD

제휴 광고 · 일부 링크는 수수료를 받을 수 있습니다

화물차리스 전문 화물박사 - 1톤트럭, 특장차 즉시출고

화물차 리스 전문 화물박사

const dashLength = circumference * percent;
const emptyLength = circumference - dashLength;

dashLength는 실제로 보일 선의 길이다. emptyLength는 보이지 않게 비워둘 선의 길이다.

완료 항목을 예로 들면 다음과 같다.

전체 둘레: 약 282.74
완료 비율: 0.5

보일 길이:
282.74 × 0.5 = 141.37

숨길 길이:
282.74 - 141.37 = 141.37

그래서 완료 원 조각에는 대략 이런 값이 들어간다.

stroke-dasharray="141.37 141.37"

이 뜻은 141.37만큼 보이고, 141.37만큼 비운다는 의미다.


17. forEach 반복문

data.forEach(item => {
  ...
});

forEach는 배열 안의 값을 하나씩 꺼내서 반복 실행하는 메서드다.

data 배열에는 다음 항목들이 들어 있다.

완료
진행중
실패
보류
기타

forEach는 이 항목들을 하나씩 꺼내서 item이라는 이름으로 사용할 수 있게 해준다.

첫 번째 반복 item:
{ label: "완료", value: 5, color: "#33a896" }

두 번째 반복 item:
{ label: "진행중", value: 2, color: "#1a62a9" }

세 번째 반복 item:
{ label: "실패", value: 1, color: "#ff4d4d" }

이렇게 각 데이터를 하나씩 처리하면서 원 조각을 만든다.


18. 값이 0이면 건너뛰기

if (item.value === 0) return;

값이 0인 데이터는 그래프에 그릴 필요가 없다. 값이 0이면 보일 선의 길이도 0이기 때문이다.

원의 둘레 × 0 = 0

그래서 value가 0이면 return으로 현재 반복을 종료하고 다음 항목으로 넘어간다.


19. 누적 비율 accumulatedPercent

let accumulatedPercent = 0;

accumulatedPercent는 지금까지 그려진 조각들의 비율을 기억하는 변수다.

이 값이 필요한 이유는 각 조각이 이전 조각 다음 위치에서 시작해야 하기 때문이다.

만약 모든 조각이 같은 위치에서 시작하면 원 조각들이 서로 겹친다.

완료도 12시에서 시작
진행중도 12시에서 시작
실패도 12시에서 시작

우리가 원하는 것은 다음과 같은 흐름이다.

완료가 먼저 그려짐
진행중은 완료가 끝난 지점에서 시작
실패는 진행중이 끝난 지점에서 시작
보류는 실패가 끝난 지점에서 시작
기타는 보류가 끝난 지점에서 시작

그래서 이전 조각들이 차지한 비율을 accumulatedPercent에 저장한다.


20. 회전 각도 계산하기

const angle = accumulatedPercent * 360 - 90;

원 한 바퀴는 360도다. 비율에 360을 곱하면 해당 비율만큼의 각도를 구할 수 있다.

50% = 0.5 × 360 = 180도
20% = 0.2 × 360 = 72도
10% = 0.1 × 360 = 36도

여기서는 현재 조각의 크기를 구하는 것이 아니라, 현재 조각이 어디서 시작할지를 구한다. 그래서 현재 항목의 percent가 아니라 accumulatedPercent를 사용한다.


21. 왜 -90을 할까?

const angle = accumulatedPercent * 360 - 90;

SVG의 원은 기본적으로 3시 방향에서 시작한다. 하지만 일반적인 도넛 그래프는 12시 방향에서 시작하는 것이 자연스럽다.

SVG 기본 시작점: 3시 방향
원하는 시작점: 12시 방향

3시 방향에서 12시 방향으로 옮기려면 반시계 방향으로 90도 돌려야 한다. 그래서 -90을 빼준다.


22. transform rotate 이해하기

transform="rotate(${angle} 60 60)"

transform은 SVG 요소를 변형할 때 사용하는 속성이다. rotate는 회전을 의미한다.

SVG에서 rotate는 다음 형식으로 사용할 수 있다.

rotate(각도 중심X 중심Y)

이번 예제에서는 중심 좌표가 60, 60이다. 그래서 다음처럼 작성한다.

rotate(각도 60 60)

이 뜻은 60, 60 지점을 기준으로 해당 원을 회전시키라는 의미다. 중심 좌표를 지정하지 않으면 원이 이상한 기준점을 중심으로 돌아가면서 위치가 틀어질 수 있다.


23. SVG 원 조각 만들기

const circleHTML = `
  <circle
    cx="60"
    cy="60"
    r="${radius}"
    fill="none"
    stroke="${item.color}"
    stroke-width="14"
    stroke-dasharray="${dashLength} ${emptyLength}"
    transform="rotate(${angle} 60 60)">
  </circle>
`;

이 코드는 JavaScript 안에서 SVG circle 태그를 문자열로 만드는 부분이다.

백틱을 사용하면 문자열 안에 JavaScript 변수를 넣을 수 있다.

const name = "SVG";
const text = `나는 ${name}를 공부한다.`;

결과는 다음과 같다.

나는 SVG를 공부한다.

이번 코드에서도 radius, item.color, dashLength, emptyLength, angle 같은 값을 문자열 안에 넣는다.


24. SVG 안에 원 조각 추가하기

svg.insertAdjacentHTML("beforeend", circleHTML);

insertAdjacentHTML은 특정 요소 안에 HTML 문자열을 추가하는 메서드다.

여기서는 SVG 안쪽 마지막 위치에 circleHTML을 추가한다.

<svg id="donutChart">
  <circle>배경 원</circle>

  여기에 새 circle이 계속 추가됨
</svg>

beforeend는 선택한 요소의 안쪽 마지막에 넣겠다는 뜻이다.


25. 누적 비율 업데이트하기

accumulatedPercent += percent;

현재 조각을 그렸다면, 그 조각의 비율만큼 누적 비율을 증가시킨다.

예제 데이터 기준으로 흐름은 다음과 같다.

처음:
accumulatedPercent = 0

완료 50%를 그림:
accumulatedPercent = 0.5

진행중 20%를 그림:
accumulatedPercent = 0.7

실패 10%를 그림:
accumulatedPercent = 0.8

보류 10%를 그림:
accumulatedPercent = 0.9

기타 10%를 그림:
accumulatedPercent = 1

마지막에는 1이 된다. 1은 전체 100%를 의미한다.


26. 전체 JavaScript 흐름 정리

전체 JavaScript 흐름은 다음과 같다.

1. HTML이 모두 로드될 때까지 기다린다.
2. 그래프에 사용할 data 배열을 만든다.
3. value 값을 모두 더해서 total을 구한다.
4. 원의 반지름을 정한다.
5. 원의 둘레를 구한다.
6. SVG 요소를 찾는다.
7. data 배열을 하나씩 반복한다.
8. 현재 항목의 비율을 구한다.
9. 비율만큼 보일 선 길이를 구한다.
10. 나머지 숨길 선 길이를 구한다.
11. 이전 조각 다음 위치에서 시작하도록 각도를 구한다.
12. circle 태그를 문자열로 만든다.
13. SVG 안에 circle 태그를 추가한다.
14. 현재 비율을 accumulatedPercent에 더한다.
15. 모든 데이터가 처리되면 도넛 그래프가 완성된다.

27. 데이터 하나가 실제 원 조각으로 변하는 예시

완료 데이터는 다음과 같다.

{ label: "완료", value: 5, color: "#33a896" }

전체 값은 10이다.

total = 10

완료의 비율은 다음과 같다.

5 / 10 = 0.5

원의 둘레가 약 282.74라면,

보일 길이 = 282.74 × 0.5 = 141.37
숨길 길이 = 282.74 - 141.37 = 141.37

그래서 완료 데이터는 대략 이런 circle 태그로 변한다.

<circle
  cx="60"
  cy="60"
  r="45"
  fill="none"
  stroke="#33a896"
  stroke-width="14"
  stroke-dasharray="141.37 141.37"
  transform="rotate(-90 60 60)">
</circle>

이 원은 전체 원 중 절반만 보인다.


28. 핵심 개념 요약

SVG:
웹에서 도형을 그릴 수 있는 영역

circle:
SVG 안에서 원을 그리는 태그

viewBox:
SVG 내부 좌표 기준

cx, cy:
원의 중심 좌표

r:
원의 반지름

fill:
원 내부 색상

stroke:
원 테두리 색상

stroke-width:
원 테두리 두께

원의 둘레:
2 × Math.PI × 반지름

stroke-dasharray:
선을 보이는 부분과 비우는 부분으로 나누는 속성

forEach:
배열 데이터를 하나씩 반복 처리하는 메서드

percent:
현재 데이터가 전체에서 차지하는 비율

accumulatedPercent:
이전 조각들이 차지한 누적 비율

rotate:
원 조각의 시작 위치를 회전시키는 속성

insertAdjacentHTML:
HTML 문자열을 특정 요소 안에 추가하는 메서드

29. 마무리

SVG 도넛 그래프는 처음 보면 복잡해 보이지만, 핵심 원리는 단순하다.

1. 원을 만든다.
2. 원의 둘레를 구한다.
3. 데이터 비율만큼 원의 선을 보이게 한다.
4. 각 원 조각을 회전시켜 이어 붙인다.

Chart.js 같은 라이브러리를 사용하면 그래프를 더 빠르게 만들 수 있다. 하지만 SVG와 JavaScript로 직접 구현해보면 원형 그래프가 내부적으로 어떤 원리로 그려지는지 이해할 수 있다.

특히 stroke-dasharray, 원의 둘레 공식, 비율 계산, rotate를 이해하면 간단한 통계 카드나 대시보드 UI를 직접 커스텀해서 만들 수 있다.

AD

'레거시 생존기' 카테고리의 다른 글

전체보기