본문 바로가기
IT

React에서 의존성 주입(DI)을 실용적으로 구현하는 방법: Context API vs Custom Hook 패턴 완전 정복

by 굿센스굿 2025. 7. 25.
반응형

 

🔍 의존성 주입(DI)이란 무엇인가요?

**의존성 주입(Dependency Injection, 이하 DI)**은 어떤 객체가 필요한 의존 객체를 스스로 생성하지 않고 외부에서 주입받는 설계 패턴입니다. 대표적으로 Java의 Spring Framework에서 많이 사용되며, 제어의 역전(Inversion of Control, IoC) 원칙을 따릅니다.

✅ DI를 사용하면 얻을 수 있는 이점

  • 결합도 감소: 객체 간의 연결을 약하게 유지해 유연한 구조 설계 가능
  • 테스트 용이성: 테스트 시 모킹(mocking) 처리하기 쉬움
  • 확장성: 구현체 교체가 자유로워짐

하지만, React는 전통적인 클래스 기반 프레임워크가 아닙니다. 그래서 자연스럽게 “React에서 DI가 필요할까?”라는 질문이 나옵니다.


⚛️ React에서도 의존성은 존재한다

React는 함수형 컴포넌트를 중심으로 설계되어 있어, DI 컨테이너나 생성자 기반 주입이 자연스럽진 않습니다. 그럼에도 불구하고 다음과 같은 의존성은 분명히 존재합니다.

🎯 흔히 마주치는 React의 의존성 예시

  • API 호출 모듈 (axios, fetch wrapper 등)
  • 유틸 함수, 설정값 (예: API base URL)
  • 로깅, 분석 툴
  • localStorage, sessionStorage 접근 로직

문제는 이 의존성들을 각 컴포넌트에서 직접 import해 사용하는 순간, 테스트와 유지보수가 어려워진다는 점입니다.


❌ 직접 import 방식이 가지는 문제점

import { userService } from './userService.js';

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getUser(userId).then(setUser);
  }, [userId]);

  ...
}

😨 어떤 문제가 발생할까요?

  1. 강한 결합: 컴포넌트가 특정 구현체(userService)에 직접 의존
  2. 유지보수성 하락: 서비스 로직 변경 시 다수 컴포넌트 수정 필요
  3. 테스트 어려움: Mocking이 까다롭고 복잡
  4. 환경별 대응 미흡: dev, prod 구분이 어려움
  5. 재사용성 부족: 컴포넌트의 범용성이 떨어짐

이 문제는 “props로 서비스 객체를 내려주는 방식”으로 개선할 수 있지만, 또 다른 문제인 Props Drilling으로 이어집니다.


🧩 Props Drilling의 딜레마

Props를 통해 서비스 인스턴스를 전달하는 방식은 단기적으로는 괜찮지만, 하위 트리로 갈수록 문제가 됩니다.

🙅‍♂️ Props Drilling의 문제점

  • 불필요한 전달: 중간 컴포넌트들이 필요 없는 props를 받음
  • 확장성 부족: 새로운 의존성 추가 시 모든 경로 수정
  • 가독성 저하: 어떤 props가 어디에서 필요한지 추적 어려움

🧠 그렇다면 React에서 의존성 주입을 어떻게 구현할 수 있을까?

React에서는 아래 두 가지 패턴을 통해 DI의 효과를 실현할 수 있습니다.

1. Context API

전역적으로 의존성을 주입하고, 필요한 곳에서 useContext로 꺼내 쓰는 방식.

2. Custom Hooks

서비스 접근 로직을 캡슐화하여 필요한 기능만 내보내는 방식.


💡 Context API를 통한 의존성 주입

🌱 구조

App
 └── [Provider] UserServiceContext
       └── UserProfile
             └── UserSettings

🔧 구현 예시

// UserServiceContext.js
import { createContext } from 'react';
import { userService } from '../services/userService';

export const UserServiceContext = createContext(null);

export function UserServiceProvider({ children }) {
  return (
    <UserServiceContext.Provider value={userService}>
      {children}
    </UserServiceContext.Provider>
  );
}
// App.jsx
import { UserServiceProvider } from './contexts/UserServiceContext';

function App() {
  return (
    <UserServiceProvider>
      <UserProfile userId={1} />
    </UserServiceProvider>
  );
}
// UserProfile.jsx
import { useContext, useEffect, useState } from 'react';
import { UserServiceContext } from '../contexts/UserServiceContext';

function UserProfile({ userId }) {
  const userService = useContext(UserServiceContext);
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getUser(userId).then(setUser);
  }, [userId]);

  ...
}

✅ 장점

  • 하나의 지점에서 의존성 관리 가능
  • Props Drilling 제거
  • 전역 설정이나 공통 서비스에 유리

⚠️ 한계

  • 구체 구현체에 여전히 의존
  • Provider 남용 시 리렌더링 부담

🧪 테스트는 어떻게 될까?

😵 기존 방식의 문제점

jest.mock('./userService', () => ({
  userService: {
    getUser: jest.fn(),
    updateUser: jest.fn()
  }
}));
  • 모듈 단위로 Mock 처리 → 구조 복잡
  • 여러 호출 케이스 관리 어려움
  • 서비스 로직과 테스트 코드의 결합도 증가

Context API 방식은 value에 Mock 객체를 주입하면 해결됨:

render(
  <UserServiceContext.Provider value={mockService}>
    <UserProfile userId={1} />
  </UserServiceContext.Provider>
);

🧰 Custom Hook으로 DI 캡슐화하기

서비스 로직을 훅 내부로 감싸 UI와 서비스 레이어를 분리

🛠 구현 예시

// useUser.js
import { useEffect, useState } from 'react';
import { userService } from '../services/userService';

export function useUser(userId) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getUser(userId).then(setUser);
  }, [userId]);

  const updateUser = (newData) =>
    userService.updateUser(userId, newData);

  return { user, updateUser };
}
// UserProfile.jsx
import { useUser } from '../hooks/useUser';

function UserProfile({ userId }) {
  const { user, updateUser } = useUser(userId);

  return (
    <div>
      <h2>{user.name}</h2>
      <UserSettings user={user} updateUser={updateUser} />
    </div>
  );
}

✅ 장점

  • 로직 캡슐화: API 변경 시에도 훅 내부만 수정
  • 재사용성 강화: 다양한 컴포넌트에서 동일한 훅 사용 가능
  • 의도 명확화: useUser, useAuth, useTodos 등 네이밍만으로 역할 이해 가능

⚠️ 한계

  • 훅의 인터페이스에 컴포넌트가 강하게 결합됨
  • 여전히 userService는 내부에서 사용되므로 완전한 추상화는 아님

🎯 어떤 방식이 더 나을까?

구분 Context API Custom Hook

목적 전역 의존성 관리 캡슐화 및 로직 재사용
유연성 적당함 좋음
테스트 용이성 높음 (mocking 용이) 높음 (hook 자체를 mock 가능)
Props Drilling 없음 없음
재사용성 제한적 높음
리렌더링 부담 있을 수 있음 상대적으로 적음
적용 난이도 낮음 중간

🧵 결론 및 정리

React는 전통적인 DI 컨테이너는 없지만, 의존성 문제는 여전히 존재합니다. 그리고 이를 해결하기 위해 Context APICustom Hook을 실용적인 방식으로 사용할 수 있습니다.

🧭 핵심 요약

  • 전역 공유 & 관리가 목적이라면 Context API
  • 재사용 가능한 캡슐화된 API가 필요하다면 Custom Hook
  • 두 방식을 조합하여 사용하는 하이브리드 방식도 권장
  • TypeScript를 함께 사용하면 구조화 및 타입 안정성 확보

📌 실무 팁

  • Service Interface 정의 → 여러 구현체 테스트 가능
  • MockService 만들기 → 테스트 환경에서 context value 주입
  • 훅의 리턴 값은 명확한 구조로 만들기 ({ data, loading, error, mutate } 패턴)

✨ 마무리하며

React에서의 의존성 주입은 ‘완전한 DI’가 아니라 ‘실용적인 DI’입니다. 완벽한 추상화보다도 변경에 강하고 테스트하기 좋은 구조를 만드는 것이 중요합니다. 오늘 소개한 두 가지 방식(Context API, Custom Hook)을 잘 활용하면 유지보수성과 생산성을 동시에 잡을 수 있습니다.

 

반응형