Home

Develop

Life

About

SVG를 활용한 물결 모양 시각화 컴포넌트 구현

2025.02.20,

10 min.

디프만 15기에서 Swimie 팀의 웹 파트로 참여하며, 내가 맡아 진행했던 달력 컴포넌트중 물결 모양 시각화 기능에 대한 이야기를 다뤄보려고 한다.
(디프만 Medium 기술 블로그에도 실렸지만, 좀더 다듬어 노션에 작성해두었었다.)

Swimie

Swimie는 수영인들의 수영 기록을 위한 서비스로, 수영에 대한 기록을 크게 3가지로 나누어 기록을 할 수 있다.

  1. 단순히 수영을 했음을 알려주는 “오늘 수영 완료!” 스티커 마크
  2. 영법과 상관없이 “총거리” 만을 기록
  3. 자유형, 평영, 접영 등 오늘 내가 “어떤 영법으로, 또 얼마나 했는지” 를 기록

이후 기록을 바탕으로 내가 설정해둔 목표 거리와 비교하여 시각화를 진행하게 되는데,
기록한 날짜에 목표 거리 이상으로 수영을 했다면, 달력에서 해당하는 날짜의 영역이 기록으로 꽉 차게 된다.
그리고 만약 목표 거리보다 적게 수영을 했다면, 영법에 따라 물결 모양으로 시각화가 된다.

그 결과, 아래와 같이 달력을 만들어 나갈 수 있다.

Swimie의 달력 시각화 기능 화면Swimie의 달력 시각화 기능 화면

🌊 물결과의 첫 만남

시각화라는 기능을 듣고 나서는 단순히 사각형이 쌓이거나 그래프 형태로 보여질 것이라 예상했었다.
하지만 처음 디자인 시안을 받았을 때 예상과 달리, 달력에 아주 이쁜 물결 모양으로 시각화가 들어가 있었다.
이 부분이 참 좋으면서도 난감(?)했다..🥲
이때 많은 생각을 해보았던 것 같다 🤔

이미지 여러 개를 포개서 보여줄까?
그럼 영법에 따라 물결 이미지를 두고 영법 거리로 높이를 계산해야할텐데?
저 좁은 칸에 이미지 여러 개를 넣고 넘치는 부분은 잘라 안보여주면 좀 비효율적이지 않나?
로딩이 늦는 등 최적화에서는 문제가 발생하지 않을까?
여러 물결 사이에 빈 공간은 어떻게 구현하지?

혼자서 계속 생각해보다 팀원들과 이야기를 나눠보기도 하였지만, 다들 이미지 외에는 선뜻 의견을 내지 못하는 상황이였다.
그러던 중, ‘아이콘들도 SVG를 활용하여 그리는데 까짓거 한번 그려보자!’ 라는 생각을 가지고 시작하게 되었다.

🤔 SVG요?

사실 SVG를 직접 다뤄서 무언가를 그리거나 해본 경험은 없었다.

크기가 커져도 화질이 깨지지 않는다는 장점 때문에 SVG 파일을 사용하기는 했다.
하지만 아이콘 같은 경우에도 항상 피그마나 인터넷에서 가져와 내용을 그대로 사용했지 직접 만들어 사용하지는 않았기 때문이다.

기껏 해봐야 색을 변경하거나 크기를 바꿔주는 정도?
그래서 SVG에 대해 알아보기 시작했다.

📝 SVG란

“Scalable Vector Graphics” — 그래픽을 마크업하기 위한 특수한 언어

말그대로 그래픽을 그리는데 사용되는 태그이다.
내부에 rect, circle, text 등을 정의하여 기본 도형이나 텍스트를 사용할 수도 있다고 한다.

그러던 중 <path>가 눈에 들어왔다.
path 를 이용하면 여러 개의 직선과 곡선을 합쳐 복잡한 도형을 그릴 수 있다.

Path 태그

path에는 d라는 속성이 존재한다.

d 속성은 아래의 내용들로 구성된 문자열이다.

  1. 도형을 그리기 위해 여러 개의 대소문자로 구분되는 명령어
  2. 해당 명령에 파라미터로 주어질 좌표값들
<svg class="shapes" xmlns="<http://www.w3.org/2000/svg>">
  <path d="
    M 100 150
    C 100 150 300 50 500 300
    C 500 300 450 600 200 600"></path>
</svg>

따라서 위와 같은 모습을 보이게 된다.

그리고 d 에는 다음과 같은 8개의 명령어들이 존재한다.

  1. 입력 받은 위치로 이동하는 M (Move to)
  2. 입력 받은 위치까지 직선을 그리는 L (Line to), H (Horizon to) , V (Vertical to)
  3. 3차 베지어 곡선을 그리는 C (Cubic Bézier)
  4. 좀더 간단한 형태의 3차 베지어 곡선을 그리는 S (Smooth cubic Bézier)
  5. 2차 베지어 곡선을 그리는 Q (Quadratic Bézier)
  6. 부드러운 2차 베지어 곡선을 그리며, 이전 Q 명령어의 제어점을 자동으로 대칭적으로 사용하는 T (Smooth quadratic Bézier)
  7. 타원 호를 그리는 A (Arc Curve)
  8. 그리기를 종료하는 Z (Close path)

(곡선에 필요한 점은 총 3가지가 필요한데 시작점, 중간점(제어점), 끝점이다. 베지어 곡선은 중간점에 따라 모양이 변하는 곡선을 말한다.)

물결 모양 시각화 디자인 설명물결 모양 시각화 디자인 설명

명령어들을 간단히 알아본 후, 위의 시안을 다시 보며 필요한 명령어들을 다음과 같이 추려보았다.

  1. 그리기 준비를 위한 M
  2. 물결의 곡선을 그리기 위한 Q와 T
  3. 높이를 그리기 위한 V
  4. 그리기 종료를 위한 Z

그리고 곧바로 구현을 시작하였고, 결과는 아래와 같다.

const generateFirstPath = (heightRatio: number, offsetY: number) => {
  const waveHeight = height * heightRatio; // (1)
  offsetY -= waveHeight; // (2)
 
  return `
	  M 0 ${offsetY}
	  Q ${width / 4} ${offsetY + waveAmplitude} ${width / 2} ${offsetY}
	  T ${width} ${offsetY}
	  V ${offsetY + waveHeight}
	  T 0 ${offsetY + waveHeight}
	  Z
  `;
};

파라미터로 요소의 얼만큼을 차지할지 heightRatio를 통해 받고, 현재 어디 위치에 커서가 존재하는지를 표현하는 offsetY를 전달 받는다.
그리고 물결의 출렁거림(?)을 위한 진폭을 현재 물결이 그려질 요소의 높이와 너비의 비율로 계산하여 waveAmplitude 변수에 저장한다.
이후 실제 물결의 높이를 계산하고(1), 그리기 시작을 위해 offsetY를 새로 계산해준다(2).

다음은 path의 d 속성에 들어갈 문자열을 구성해주게 된다.
d 속성의 라인별 명령 내용은 다음과 같다.

  1. 물결이 시작할 위치로 이동 (M)
  2. 오른쪽 방향으로 현재 요소 너비의 시작 지점에서 1/2 지점까지 1/4 지점에 계산한 진폭을 적용한 곡선을 그림 (Q)
  3. 현재 요소 너비의 끝지점까지 2번 과정에서 사용한 진폭의 반대로 곡선을 그림 (T)
  4. 계산한 물결의 높이만큼 수직 아래로 직선을 그림 (V)
  5. 왼쪽 방향으로 수평으로 직선을 그림 (T)
  6. 그리기 종료 (Z)

위 과정을 통해 총거리만 입력한 경우, 그리고 영법별 거리중 가장 아래의 물결을 완성할 수 있었다.

그럼 이제 여러 물결을 쌓는 경우도 살펴보자.
필요한 명령은 위와 동일하였고, 위에서 시행착오를 많이 겪어 개발 공수는 그리 많이 들지 않았다.

const generatePath = (heightRatio: number, offsetY: number) => {
	const waveHeight = height * heightRatio;
  offsetY -= waveHeight;
 
  return `
	  M 0 ${offsetY}
	  Q ${width / 4} ${offsetY + waveAmplitude} ${width / 2} ${offsetY}
	  T ${width} ${offsetY}
	  V ${offsetY + waveHeight}
	  Q ${(width / 4) * 3} ${offsetY + waveHeight - waveAmplitude} ${width / 2} ${offsetY + waveHeight}
	  T 0 ${offsetY + waveHeight}
	  Z
	`;
};

마찬가지로 d 속성의 라인별 명령 내용을 살펴보면 다음과 같다.

  1. 물결이 시작할 위치로 이동 (M)
  2. 오른쪽 방향으로 현재 요소 너비의 시작 지점에서 1/2 지점까지 1/4 지점에 계산한 진폭을 적용한 곡선을 그림 (Q)
  3. 현재 요소 너비의 끝지점까지 2번 과정에서 사용한 진폭의 반대로 곡선을 그림 (T)
  4. 계산한 물결의 높이만큼 수직 아래로 직선을 그림 (V)
  5. 2, 3번과 반대로 현재 요소의 너비 끝 지점에서 1/2지점까지 3/4 지점에서 계산한 진폭을 적용한 곡선을 그림 (Q)
  6. 현재 요소 너비의 시작지점까지 5번에서 사용한 진폭의 반대로 곡선을 그림 (T)
  7. 그리기 종료 (Z)

이렇게 다양한 영법에 대한 여러 시각화 요소가 쌓이는 물결까지 모두 구현했다.

하지만 이 물결들을 모두 쌓는 과정에서 또 문제가 발생했다.
물결들이 겹치거나 너무 딱 붙어 정확하게 높이가 계산되어 쌓여있는지 확인이 어려워지는 것이다.

따라서 반복문을 통해 물결을 쌓는 과정에서 물결의 높이를 살짝 줄이고, 물결 사이에 임의로 간격을 위한 값을 추가하고 계산하여 이를 해결할 수 있었다.

const Waves = ({
  waves,  // 물결의 높이 정보를 담은 배열
  width,  // 물결이 그려질 요소의 너비
  height, // 물결이 그려질 요소의 높이
}: {
  waves: Array<{ color: string; waveHeight: number }>;
  width: number;
  height: number;
}) => {
 
 /* ... */
 
  let offsetY = height;
  const waveGap = (height / 100) * 2;
 
  return (
    <svg
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
      style={{ background: 'none' }}
    >
      {waves.map((wave, index) => {
        const { color, waveHeight } = wave;
        const pathData =
          index !== 0
            ? generatePath(waveHeight, offsetY)
            : generateFirstPath(waveHeight, offsetY);
        offsetY -= height * waveHeight + waveGap;
        return <path key={index} d={pathData} fill={color} />;
      })}
    </svg>
  );
};

따라서 완성된 코드는 위와 같다.

path에서 (0, 0)은 좌상단 이기 때문에 offsetY를 요소의 높이로 초기화하여 좌하단, 즉, 일반적인 좌표 평면에서의 (0, 0)에서 시작할 수 있도록 하였다.
그리고 현재 요소의 2% 정도를 쌓일 물결 간의 간격으로 잡았다.
이후 waves 배열을 활용하여 map을 통해 물결들을 쌓게 된다.
이때 물결을 쌓은 후 다음 물결이 쌓일 위치를 현재 쌓은 물결의 높이와 간격을 고려하여 offsetY에 계산하여 갱신해주면서 SVG 컴포넌트를 완성한다.

마무리하며

간만에 진폭과 같은 물리 용어도 찾아보고, 수학적으로 계산도 생각하며 재밌게 문제를 해결해 볼 수 있었던 경험이었다 😁
처음에는 정말 무서운 시각화 디자인이였지만, 하면 된다! 를 느낄 수 있었던 경험이기도 하다.
또 당시에 유저들 혹은 주변 지인들이 달력에 수영 기록을 하며 달력을 이쁘게 채워 놓을 때마다 정말 큰 성취감을 얻을 수 있었다 👍

허준영.

profile image