흩어지는 횡단 관심사를 하나의 모듈로 관리하기

24. 07. 03. (5개월 전)

TL;DR: 반복적으로 사용되는 소리 재생 로직을 횡단 관심사로 인식하고, 이를 단일 모듈로 분리하여 재사용성을 높이고 유지보수가 용이한 React 컴포넌트를 작성하는 방법을 소개합니다.

소리 재생 로직의 문제점

Bendd 웹앱에는 조금 더 생동감 있는 경험을 제공하기 위해 소리를 활용하고 있습니다.

보통 사용자 인터랙션에 따라 소리를 재생하는 기능을 구현할 때, 흔히 다음과 같은 패턴을 사용합니다:

  1. 사용자의 소리 설정 확인
  2. useSound 훅을 사용하여 소리(ex: mp3) 에셋 로드
  3. 이벤트 핸들러(ex: onClick)에 소리를 재생하는 함수 추가
'use client';
 
import useSound from 'use-sound';
import { getSoundEnabled } from '../model/get-sound';
 
function TestSoundButton() {
  const isSoundEnabled = getSoundEnabled();
  const [playClickSound] = useSound('/sounds/test.mp3', {
    soundEnabled: isSoundEnabled,
  });
 
  const onClick = () => {
    playClickSound();
    alert('hello!');
  };
 
  return (
    <button onClick={onClick}>
      <span role="img" aria-label="trumpet">
        🎺
      </span>
    </button>
  );
}

이 방식은 소리 재생이 필요한 컴포넌트마다 반복되어 코드의 중복을 야기하고, 주요 로직과 소리 재생 로직이 혼재되어 가독성과 유지보수성을 저하시킵니다.

해결 방안: 횡단 관심사의 모듈화

이러한 문제를 해결하기 위해, 소리 재생 로직을 횡단 관심사(Cross-cutting concern)로 인식하고 별도의 모듈로 분리할 수 있습니다. React의 cloneElement API를 활용하여 기존 컴포넌트의 이벤트 핸들러를 확장하는 방식으로 구현합니다.

WithSound 컴포넌트 구현

'use client';
 
import { Children, cloneElement, isValidElement, type ReactNode } from 'react';
import useSound from 'use-sound';
 
import { useSoundStore } from '../model/sound-store';
 
type WithSoundProps = {
  children: ReactNode;
  assetPath: string;
};
 
export function WithSound({ children, assetPath }: WithSoundProps) {
  const child = Children.only(children);
  const isSoundEnabled = useSoundStore(state => state.isSoundEnabled);
  const [playClickSound] = useSound(assetPath, {
    soundEnabled: isSoundEnabled,
  });
 
  const processChild = (child: ReactNode): ReactNode => {
    if (isValidElement(child)) {
      return cloneElement(child, {
        onClick: (event: React.MouseEvent) => {
          if (child.props.onClick) {
            child.props.onClick(event);
          }
          playClickSound();
        },
      });
    }
    return child;
  };
 
  return processChild(child);
}

이 컴포넌트는 다음과 같은 장점을 제공합니다:

  1. 소리 재생 로직을 중앙화하여 코드 중복을 제거
  2. 기존 컴포넌트의 로직을 유지하면서 소리 재생 기능을 쉽게 추가
  3. 소리 설정 관리를 단일 위치에서 처리

WithSound 사용 예시

function TestSoundButton() {
  return (
    <WithSound assetPath="/sounds/test.mp3">
      <button onClick={() => alert('hello!')}>
        <span role="img" aria-label="trumpet">
          🎺
        </span>
      </button>
    </WithSound>
  );
}

결론

이렇게 횡단 관심사를 별도의 모듈로 분리함으로써 다음과 같은 이점을 얻을 수 있습니다:

  1. 핵심 비즈니스 로직과 부가 기능의 명확한 분리
  2. 유지보수성 향상
  3. 기능 확장 및 변경 용이

또 소리 재생뿐만 아니라 로깅, 성능 측정 등 다양한 횡단 관심사에도 적용할 수 있습니다.