React

[React/리액트] SVG 아이콘 스타일링: fill vs stroke 그리고 디자인 시스템

solfa 2025. 2. 1. 06:22

유어슈에서 만든 디자인 시스템인 handy를 기반으로 새 프로젝트에 쓰이는 컴포넌트를 만들기로 했다.
그러다 handy right icon에 아이콘이 겹쳐 나타나는 문제가 생겼고 이를 해결하기 위한 과정과 삽질기(ㅠㅠ)를 공유하고자 한다.

 



문제 상황

현재 handy라는 디자인 시스템을 사용하고 있는데 여기에서 BoxButton을 사용중이다.
BoxButton 스토리북은 밑에 링크에서 확인 가능하다.
http://handy-react-storybook.s3-website.ap-northeast-2.amazonaws.com/2.1.0/?path=/docs/components-boxbutton--docs

이렇게 BoxButton에는 아이콘을 넣어서 사용할 수 있는데
제목 그대로



이런식으로 아이콘이 겹쳐서 나타나는 문제이다. ChevronUp 아이콘 뒤에 세모난 무언가… 보이는가?!!

코드를 보면 이해가 바로 갈텐데

const StateButton = ({
  options,
  width,
  selectedValue,
  onSelect,
  variant = "filledSecondary",
}: StateButtonProps) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div style={{ position: "relative" }}>
      <StyledBoxButton
        size="small"
        variant={variant}
        onClick={() => setIsOpen(!isOpen)}
        rightIcon={<ChevronUp />}
        selectedvalue={selectedValue}
      >
        {selectedValue}
      </StyledBoxButton>
      <Dialog
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        options={options}
        onSelect={onSelect}
        width={width}
      />
    </div>
  );
};

 

 

이 코드를 보면 rightIcon에 lucide-icon 라이브러리에서 쓰는 ChevronUp 아이콘을 넣어둔 상태이다.

 

이렇게 생긴 아이콘인데 저 아이콘 뒤에 모르는 아이콘이 겹쳐있는 듯한 상태이다. 대체 이게 무엇인가!!!


문제 원인 (삽질의 시작)

interface StyledBoxButtonProps {
  selectedvalue: string;
}

const StyledBoxButton = styled(BoxButton)<StyledBoxButtonProps>`
  ${(props) => {
   ...
  }}
`;

 

BoxButton을 감싸는 StyledBoxButton를 만들어서 추가 스타일링을 진행하고 있는데 이 부분에서 문제가 나는 것 같다.

문제의 원인

먼저 문제의 원인을 파악하기 위해 디버깅을 해봤다. 이런 상황에서 할 수 있는 디버깅은 이정도가 있다.

1. `StyledBoxButton`이나 `BoxButton`을 렌더링할 때 `props`를 `console.log`로 출력
2. DOM 트리를 검사해서 중복된 `<svg>` 태그가 있는지 확인
3. 다른 아이콘으로 테스트도 해보기
4. handy의 BoxButton 코드 살펴보고 문제점 찾기

 

1. `StyledBoxButton`이나 `BoxButton`을 렌더링할 때 `props`를 `console.log`로 출력

...

export const StyledBoxButton = styled(BoxButton)<StyledBoxButtonProps>`
  ${(props) => {
    console.log("StyledBoxButton received props:", props);
   ...
  }}
`;

 

이렇게 StyledBoxButton이 받는 `props`를 콘솔로 찍어 확인하는 방법이다.

 

근데 확인을 하려고 해도 그 어디에도 보이지 않는다. 나를 반기는 건 수많은 프로토타입 뿐 ㅜㅜ
찾아보니 styled-components는 컴포넌트를 감싸는 방식으로 동작하기 때문에 가려져서 Props가 안보일 수도 있다고 한다.
그래서 실제 DOM 요소를 확인하거나 React DevTools를 사용해 정보를 살펴봤다. 로깅해도 된다!

...
export const StateButton = ({
  options,
  selectedValue,
  onSelect,
  variant = "filledSecondary",
}: StateButtonProps) => {
  const iconRef = useRef(null);

  useEffect(() => {
    // SVG 요소를 찾아서 로깅
    const svgElement = iconRef.current?.querySelector("svg");
    console.log("SVG properties:", svgElement);
  }, []);

  return (
    <GenericDialog options={options} onSelect={onSelect}>
      <div ref={iconRef}>
        <StyledBoxButton
          size="small"
          variant={variant}
          rightIcon={<ChevronUp />}
          $selectedValue={selectedValue}
        >
          {selectedValue}
        </StyledBoxButton>
      </div>
    </GenericDialog>
  );
};

 

이렇게 useRef를 사용해서 svg를 로깅하는 방법도 있다.

2. DOM 트리를 검사해서 중복된 `<svg>` 태그가 있는지 확인
아니면 그냥 직접 찾는 방법도 있다.


아무튼 이렇게 해서 찾은 svg는 이렇게 생겼다.

<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" 
stroke-linejoin="round" class="lucide lucide-chevron-up">
<path d="m18 15-6-6-6 6"></path></svg>

 

큰 문제는 없어보인다.

3. 다른 아이콘으로 테스트도 해보기


다른 아이콘을 집어넣어도 뭔가 이상하고

return (
    <div style={{ position: "relative" }}>
      <StyledBoxButton
        size="small"
        variant={variant}
        onClick={() => setIsOpen(!isOpen)}
        rightIcon={<img src={Logo} width="16" height="16" alt="logo" />}
        selectedvalue={selectedValue}
      >
        {selectedValue}
      </StyledBoxButton>
      <Dialog
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        options={options}
        onSelect={onSelect}
        width={width}
      />
    </div>
  );
};

 

근데 이렇게 직접 svg 저장해서 넣어주는건 된다


위 아이콘은 라이브러리고 밑에는 에셋으로 저장해서 집어넣은 차이가 있긴 하다.


4. handy의 BoxButton 코드 살펴보고 문제점 찾기

그래서 BoxButton의 구현을 찾아보았다.

 

그러다가 발견한 문제의 원인

 

1. Handy 디자인 시스템의 BoxButton이 fill 스타일의 아이콘을 사용하도록 설계되어 있었음

     svg {
              fill: ${({ theme }) => theme.semantic.color.iconBasicWhite};
            }


2. 근데 Lucide 라이브러리의 아이콘은 stroke 스타일을 사용함

    <!-- 위에서 찾은 아이콘의 fill과 stroke가 기억나시나요 -->
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 
    fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" 
    stroke-linejoin="round" class="lucide lucide-chevron-up">
    <path d="m18 15-6-6-6 6"></path></svg>

   

3. 이로 인해 fill과 stroke 스타일이 중첩되어 아이콘이 겹쳐 보이는 현상이 발생했다.

 


해결 방법

해결 방법은 간단하다. rightIcon에 넣기 전에 wrapper를 만들어서 stroke 스타일을 강제 지정해주는것!

const IconWrapper = styled.div`
  svg {
    stroke: currentColor;
    fill: none;
  }
`;

 

이렇게 fill은 다시 none으로 바꿨고 stroke설정을 추가했다.

rightIcon={
  <IconWrapper>
    <ChevronUp />
  </IconWrapper>
}


그리고 이렇게 ChevronUp 아이콘을 감싸서 rightIcon에 넣어주었더니

와~
아이콘이 매우 잘 나오는 것을 확인할 수 있다!

근데 열심히 찾은 거랑 별개로… icon이 handy에 만들어져 있었다. ㄹㅈㄷ

 

걍 문서랑 피그마 열심히 안 본 업보빔맞음 ㄹㅈㄷ

근데 이 방법이 매우 불편하다는 생각이 들었다. 나중에 핸디에 없는 아이콘을 쓸 수도 있을 것이고 애초에 fill과 stroke를 둘 다 쓰도록 디자인 시스템에서 설정하면 안되는 건가 라는 생각을 했다.

 


다른 방법을 찾아서

다른 유명한 디자인 시스템들의 구현 코드를 살펴보았다.

1. Material-UI (MUI)
https://github.com/mui/material-ui/blob/v6.4.1/packages/mui-material/src/SvgIcon/SvgIcon.js

const SvgIconRoot = styled('svg', {
...
    variants: [
      {
        props: (props) => !props.hasSvgAsChild,
        style: {
          // the <svg> will define the property that has `currentColor`
          // for example heroicons uses fill="none" and stroke="currentColor"
          fill: 'currentColor',
        },
      },
      {


- 기본적으로 `fill="currentColor"` 사용
- 서드파티 아이콘을 쓸 때는 래핑하는 SvgIcon 존재함

2. Ant Design

// Ant Design의 IconBase 구현
const IconBase = (props: IconBaseProps) => {
  const {
    style,
    className,
    children,
    viewBox,
    component: Component,
    ...restProps
  } = props;

  const svgBaseProps = {
    width: '1em',
    height: '1em',
    fill: 'currentColor',  // 역시 fill만 사용
    'aria-hidden': 'true',
    focusable: 'false',
  };

  return (
    <span role="img" {...restProps} className={className}>
      {Component
        ? <Component {...svgBaseProps} {...restProps} viewBox={viewBox}>
            {children}
          </Component>
        : children}
    </span>
  );
};


- 자체 아이콘 라이브러리가 있음
- fill 방식으로 통일

3. Chakra UI

// Chakra UI의 Icon 컴포넌트
export const Icon = forwardRef<IconProps, "svg">((props, ref) => {
  const {
    as: element,
    viewBox,
    color = "currentColor",
    focusable = false,
    children,
    className,
    ...rest
  } = props

  const _className = cx("chakra-icon", className)

  const shared = {
    ref,
    focusable,
    className: _className,
    ...rest,
  }

  const Component = element || "svg"

  if (element && typeof element !== "string") {
    return <Component {...shared} />
  }

  return (
    <Component
      verticalAlign="middle"
      viewBox={viewBox || "0 0 24 24"}
      {...shared}
    >
      {children}
    </Component>
  )
})


- 기본적으로는 stroke 스타일 선호

⇒ 대부분의 메이저 디자인 시스템은 단일 스타일(fill 또는 stroke)을 선택하고 있었다. 

 

그래서 나는

1. fill만 사용하더라도 대부분의 상황에서 충분히 활용 가능하고 
2. 핸디에는 내장 아이콘이 이미 있는 상태이고
3. 필요한 경우 특정 컴포넌트에서만 예외적으로 처리 (wrapper 만들어 사용)하는 방법이 더 나을 수도 있다고

 

이렇게 결론을 내렸다.

그렇지만 핸디와 Lucide 라이브러리와 같은 stroke 스타일 아이콘을 같이 사용할 일이 있을 때를 대비하여 문서에 한 줄 정도는 남겨두는 건 어떤지… 생각을 해본다. (걍 내 잘못ㅇㅣ긴 함)

 


얻은 교훈

1. 문서 좀 읽자...
    - Handy에 이미 필요한 아이콘이 있었는데 문서를 제대로 안 봐서 못 찾은 문제다 🥲
2. SVG 아이콘은 fill vs stroke를 잘 구분하자
    - fill과 stroke의 차이점   
        SVG에서 fill과 stroke는 그래픽을 그리는 두 가지 주요 방식이다.
        fill (채우기)
        - 도형의 내부를 채우는 방식
        - MUI나 Ant Design이 주로 사용하는 방식
        stroke (테두리)
        - 도형의 윤곽선을 그리는 방식
        - Lucide, Feather Icons가 주로 사용하는 방식
    - IconWrapper 같은 해결책으로 스타일 충돌 해결하는 방법
3. 디자인 시스템의 철학 이해하기
    → 자체 아이콘 라이브러리가 있다면 구현이 fill이든 storke든 중요하진 않다. 

        대신 추후에 서드파티 아이콘 쓸 일이 있을 때 핸디의 svg는 기본값이 fill이라는 것에 대한 문서화는 필요할 것 같다.

728x90