React에서의 의존성 주입(Dependency Injection)

리액트 의존성 주입

현재 웹 프론트엔드 생태계에서는 대부분의 애플리케이션이 React를 기반으로 개발되고 있다.

필자는 개인적으로 의존성 주입(Dependency Injection) 패턴을 선호하지만, 리액트를 처음 접하는 개발자들 중 상당수는 이 개념에 익숙하지 않거나, 심지어 본인이 이미 의존성 주입과 유사한 패턴을 사용하고 있으면서도 그것이 무엇인지 인지하지 못한 채 개발을 진행하는 경우가 많다.

예를 들어, 많은 사람들이 Context API를 이야기할 때 이를 단순히 props drilling을 피하기 위한 수단 정도로만 이해한다.

물론 깊어진 컴포넌트 트리로 인해 props가 여러 단계로 전달되는 문제는 컴포넌트 구조와 위계를 적절히 조정하는 것만으로도 대부분 완화할 수 있다고 생각한다.

하지만 이 글에서는 그런 수준을 조금 넘어, Context API전역 상태 관리 도구가 아닌, 의존성 주입(Dependency Injection)을 구현하기 위한 메커니즘으로 바라보는 관점을 소개하고자 한다.

흥미로운 점은, 이러한 의존성 주입 패턴이 React 위주의 웹 프론트엔드 개발자들보다는, 오히려 안드로이드, iOS, 백엔드 개발자들에게 더 익숙하고 강조되는 패턴이라는 것이다.

이들 생태계에서는 서비스, 리포지토리, 클라이언트 등의 의존성을 외부에서 주입받는 구조가 비교적 자연스럽게 받아들여지고, 다양한 DI 프레임워크도 활발히 사용된다.

이번 포스팅에서는 먼저 의존성 주입이 무엇인지를 간단히 정리한 뒤, 이를 리액트 애플리케이션에서 활용했을 때 어떤 실질적인 이점을 얻을 수 있는지 단계적으로 살펴보려고 한다.


의존성 주입(Dependency Injection)이란 무엇인가?

아주 기초적인 수준에서 의존성 주입(DI)은 코드 내부에서 특정 객체를 직접 생성하거나 박아넣는(Hard-coding) 대신, 외부에서 부품을 갈아끼우듯 주입해주는 디자인 패턴이다.

백엔드에서 이러한 패턴을 흔하게 활용한다고 했으니 한번 결제 서비스를 예시로 들어 설명해보겠다.

DI를 사용하지 않은 경우 (강한 결합)

만약 의존성 주입을 모른다면, 보통 주문 처리 함수 안에 특정 결제사(예: 토스 페이먼츠) 코드를 직접 박아버린다.

import { tossPayments } from '@tosspayments/sdk'; 

async function processOrder(amount: number) {
  console.log('주문 처리를 시작합니다.');

  // ❌ 함수 안에서 특정 결제 모듈을 직접 사용 (강한 결합)
  // 이 코드는 평생 토스 페이먼츠만 써야 한다.
  // Stripe로 바꾸려면 이 파일을 뜯어고쳐야 한다.
  await tossPayments.pay(amount); 
}ts

이 코드의 문제점은 유연성이 없다는 것이다.

지금 코드만 보면 뭐가 문제인지라고 생각할 수 있다. 해당 블로그는 정말 간단한 예시로 작성했기에 실제로는 processOrder 함수는 더 복잡할 것이다.

“우리는 이제 해외로 갈거에요, 해외 고객을 위해 Stripe도 붙여주세요.”라는 요구사항이 온다면 processOrder 함수를 전부 뜯어고쳐야 한다.

또한 테스트할 때마다 실제 돈이 결제되는, 있어서는 안 되는 상황이 생길 수 있다.

실제로 우리는 이러한 코드를 재사용성이 떨어지고, 유지보수하기 어려운 코드라고 부른다.

DI를 사용한 경우 (느슨한 결합)

DI(의존성 주입)의 핵심은 processOrder 함수가 “어떤 결제사를 쓰는지” 구체적으로 알 필요가 없다는 것이다.

그저 결제 기능(Interface)이 있는 부품이 들어온다는 것만 알면 된다.

  1. 규격(Interface) 정의 : 먼저 모든 결제 모듈이 지켜야 할 규격(Interface)을 정한다.
// ✅ "결제는 이렇게 하는 것이다"라는 규격 정의
interface PaymentProcessor {
  pay(amount: number): Promise<void>; 
} ts
  1. 부품(Implementation) 만들기 : 규격에 맞춰서 실제 부품들을 만든다.
// 부품 A: 토스 페이먼츠 결제 (한국)
const tossProcessor: PaymentProcessor = {
  pay: async (amount) => {
    console.log(`[TossPayments] ${amount}원 결제 완료`);
    // 실제 토스 페이먼츠 SDK 호출 로직...
  },
};

// 부품 B: Stripe 결제 (해외)
const stripeProcessor: PaymentProcessor = {
  pay: async (amount) => {
    console.log(`[Stripe] $${amount} 결제 완료`);
    // 실제 Stripe SDK 호출 로직...
  },
};

// 부품 C: 테스트용 가짜 결제 (돈 안나감)
const mockProcessor: PaymentProcessor = {
  pay: async (amount) => {
    console.log(`[TEST] ${amount}원 결제 완료 (실제 청구 X)`);
  },
};ts
  1. 조립해서 사용하기 (Injection) : 이제 processOrder 함수는 구체적인 결제사를 직접 사용하지 않고, 인자로 주입받는다.
// ⭐️ 핵심: 함수가 PaymentProcessor를 인자로 주입받는다
async function processOrder(
  paymentProcessor: PaymentProcessor, 
  amount: number
) {
  // 들어온 부품이 뭐든 간에 .pay()만 호출하면 된다.
  await paymentProcessor.pay(amount); 
}

// --- 실제 사용 시점 (조립) ---

// 상황 1: 한국 서비스 런칭할 땐 토스 페이먼츠 사용
processOrder(tossProcessor, 10000); 

// 상황 2: 해외 서비스 런칭할 땐 Stripe 사용
processOrder(stripeProcessor, 100); 

// 상황 3: 개발자가 로컬에서 테스트할 땐 가짜 부품 사용
processOrder(mockProcessor, 5000); ts

보다시피 processOrder 함수 코드는 단 한 줄도 수정하지 않았는데, 외부에서 무엇을 넣어주느냐(주입하느냐)에 따라 동작이 완전히 달라진다.

이것이 바로 DI(의존성 주입)이 주는 강력한 교체의 자유다.

테스트 코드 작성이 쉬워진다

이제 테스트를 작성해보면 DI(의존성 주입)의 장점이 더 명확해진다.

실제 결제 모듈 대신 가짜 모듈을 주입해서 함수의 동작을 검증할 수 있다.

describe('processOrder', () => {
  it('결제 처리가 정상적으로 호출되어야 한다', async () => {
    // 가짜 결제 모듈 생성 (모킹)
    const mockProcessor: PaymentProcessor = {
      pay: vi.fn(), 
    }; 

    await processOrder(mockProcessor, 5000); 

    // pay 함수가 5000원으로 호출되었는지 검증
    expect(mockProcessor.pay).toHaveBeenCalledWith(5000);
    expect(mockProcessor.pay).toHaveBeenCalledTimes(1);
  });
});ts

테스트할 때 실제 토스 페이먼츠나 Stripe API를 호출할 필요가 없다. vi.fn()으로 가짜 함수를 만들어 주입하면, 실제 결제 없이도 processOrder 함수가 올바르게 동작하는지 검증할 수 있다.

물론 대부분의 결제 모듈은 테스트 환경에서 사용할 수 있도록 가상 환경을 제공하고 있다.


React에서의 의존성 주입은 어떻게 작동할까?

React에서는 Context API를 활용해 제어의 역전(IoC)을 구현할 수 있다. prop drilling 없이 컴포넌트 트리 어디서든 의존성을 주입받을 수 있어 깔끔하고 유지보수하기 좋은 코드를 만들 수 있다.

강한 결합의 문제점

영화 상세 페이지 컴포넌트를 예시로 들어보자.

// ❌ API 클라이언트를 직접 import - 강한 결합
import { movieApi } from './api/movie'; 

function MovieDetailPage() {
  const { id } = useParams<{ id: number }>();

  const { data: movie } = useQuery({
    queryKey: ['movie', id],
    queryFn: () => movieApi.getDetail(id), 
  });

  return (
    <div>
      <h1>{movie?.title}</h1>
      <p>{movie?.overview}</p>
    </div>
  );
}ts

이 코드는 movieApi와 강하게 결합되어 있다. 왜 문제일까?

문제 1: 테스트가 네트워크에 의존한다

test('영화 제목을 보여준다', async () => {
  render(<MovieDetailPage />);
  // 😞
  expect(screen.getByText('아이언맨')).toBeInTheDocument();
});ts

이 테스트는 실제 API를 호출한다. 서버의 영화 정보가 바뀌거나 삭제되면? 네트워크가 끊기면? 테스트는 실패한다.

문제 2: 예외 상황을 테스트할 수 없다

test('서버 에러 시 에러 문구를 보여준다', () => {
  render(<MovieDetailPage />);
  // 영원히 실패
  expect(screen.getByText('점검 중입니다')).toBeInTheDocument();
});ts

실제 서버가 정상(200 OK)이면 에러 화면을 테스트할 방법이 없다.

문제 3: 모듈 모킹은 지저분하다

억지로 테스트를 작성할 수는 있다.

import * as movieApiModule from './api/movie'; 

vi.mock('./api/movie'); 

test('영화 제목을 보여준다', async () => {
  vi.mocked(movieApiModule.movieApi.getDetail).mockResolvedValue({
    title: '테스트용 영화',
  });

  render(<MovieDetailPage />);

  vi.clearAllMocks(); 
});ts

하지만 이 방식은 문제가 많다:

  • 경로 의존성: ./api/movie.ts 파일을 옮기면 모든 테스트의 vi.mock() 경로를 수정해야 한다
  • 보일러플레이트: vi.mock, mockResolvedValue, clearAllMocks 같은 설정 코드가 테스트 로직보다 길어진다

해결책: 의존성 주입

테스트할 때는 실제 API 없이도 마치 호출한 것처럼 동작하는 환경을 만들고 싶다.

의존성 주입(DI)을 사용하면 스텁(Stub) API 클라이언트를 주입해서 네트워크 요청 없이 테스트할 수 있다.


React에서 제어의 역전(Inversion of Control)을 도입하는 단계별 방법

다음 단계를 거쳐 진행한다:

  1. 인터페이스 정의 - API 클라이언트의 형태(shape) 정의
  2. 실제 구현체 생성 - 인터페이스를 구현하는 클래스 작성
  3. Context 및 Provider 생성 - 의존성을 담을 컨테이너
  4. 최상위에서 구현체 주입 - Composition Root 패턴
  5. 컴포넌트에서 의존성 사용 - useContext 헬퍼 훅 활용

현재 예시는 복잡한 요구사항이 없다고 가정하여, Repository/Service 같은 별도 레이어 없이 단일 인터페이스로 작성하였다.


1단계: 인터페이스 정의

먼저 주입할 의존성의 형태(shape)를 정의하는 인터페이스를 만든다.

// types.ts
interface MovieClientService {
  getDetail(id: number): Promise<MovieDetail>; 
} ts

2단계: 실제 구현체 생성

인터페이스를 구현하는 실제 클래스를 만든다.

// MovieClient.ts
class MovieClient implements MovieClientService {
  async getDetail(id: number): Promise<MovieDetail> {
    const response = await fetch(`/api/movies/${id}`);
    return response.json();
  }
}ts

3단계: Context 및 Provider 생성

의존성을 담을 Context와 Provider를 만든다.

const MovieClientContext = createContext<MovieClientService | null>(null); 

function useMovieClient(): MovieClientService {
  const context = use(MovieClientContext); 
  if (!context) {
    throw new Error('MovieClientProvider가 필요하다');
  }
  return context; 
}ts

Provider는 주입만 담당하고, 기본값을 직접 생성하지 않는다. 이렇게 하면 Provider가 특정 구현체에 의존하지 않아 완전한 DI가 가능하다.

function MovieClientProvider({
  client, 
  children,
}: {
  client: MovieClientService; 
  children: ReactNode;
}) {
  return (
    <MovieClientContext.Provider value={client}>
      {' '}
      {children}
    </MovieClientContext.Provider>
  );
}ts

4단계: 최상위에서 구현체 주입 (Composition Root)

래퍼 컴포넌트를 만들어 구현체 생성과 Provider를 캡슐화한다. 이 패턴을 Composition Root라고 부른다.

// ApiClientProvider.tsx
function ApiClientProvider({ children }: PropsWithChildren) {
  const movieClient = useState(() => {
    return new MovieClient(); 
  }); 

  return (
    <MovieClientProvider client={movieClient}>{children}</MovieClientProvider>
  );
}

// App.tsx
function App() {
  return (
    <ApiClientProvider>
      {' '}
      <QueryClientProvider client={queryClient}>
        <Router />
      </QueryClientProvider>
    </ApiClientProvider>
  );
}ts

useState의 초기화 함수를 사용해 컴포넌트 마운트 시 한 번만 인스턴스를 생성한다.

App.tsx는 래퍼만 사용하면 되므로 더 깔끔해진다.


5단계: 컴포넌트에서 주입된 의존성 사용

컴포넌트에서는 useMovieClient 훅으로 주입된 의존성을 사용한다.

function MovieDetailPage() {
  const { id } = useParams<{ id: number }>();
  const movieClient = useMovieClient(); 

  const { data: movie } = useQuery({
    queryKey: ['movie', id],
    queryFn: () => movieClient.getDetail(id), 
  });

  return (
    <div>
      <h1>{movie?.title}</h1>
      <p>{movie?.overview}</p>
    </div>
  );
}ts

컴포넌트는 MovieClientService 인터페이스만 알고, 실제로 어떤 구현체가 주입되었는지는 모른다.

이것이 바로 느슨한 결합이다.


컴포넌트 테스트 방법

Provider에 Mock 객체를 주입해서 네트워크 요청 없이 테스트할 수 있다.

describe('MovieDetailPage', () => {
  it('영화 상세 정보를 렌더링한다', async () => {
    // Mock 클라이언트 생성
    const mockClient = {
      getDetail: vi.fn().mockResolvedValue({
        title: '인셉션', 
        overview: '꿈 속의 꿈', 
      }), 
    }; 

    // 실제 앱과 동일하게 커스텀 Provider 사용
    render(
      <MovieClientProvider client={mockClient}>
        {' '}
        <QueryClientProvider
          client={
            new QueryClient({
              defaultOptions: {
                queries: { retry: false },
              },
            })
          }
        >
          <MovieDetailPage />
        </QueryClientProvider>
      </MovieClientProvider>
    );

    // 검증
    await waitFor(() => {
      expect(screen.getByText('인셉션')).toBeInTheDocument();
    });
  });
});ts

이렇게 “외부 의존성”을 제거함을 통해 테스트 코드의 신뢰도를 상승시킬 수 있다.
이 말은, 리팩토링의 내구성이 높아진다 라는 말과 일맥상통하다.

또한 이런 구조를 도입하는 과정에서, 우리는 의도치 않게 단일 책임 원칙(SRP)을 지키게 되고, 관심사 분리(Separation of Concerns) 역시 자연스럽게 따라오게 된다.

어떤 모듈은 **“무엇을 할지”**에만 집중하고, 어떤 모듈은 **“어떻게 할지”**에만 집중하게 되면서,
각자의 책임이 분명한, 읽기 쉽고 변경에 강한 코드로 조금씩 다가가게 된다.

“리팩토링”은 결국 기능 변화없이 테스트 코드 결과가 변하지 않는다는 것이다.


무엇을 외부 의존성으로 봐야 할까?

Uncle Bob의 클린 아키텍처 다이어그램
Uncle Bob의 클린 아키텍처 다이어그램

외부의 경계를 어디에 둘지가 핵심이다.

클린 아키텍처를 엄격히 따르면 React조차 Frameworks & Drivers 영역이다. 하지만 모든 것을 추상화하면 서비스는 완성하지 못하고 인터페이스만 정의하다 끝난다.

DI의 목적은 개발을 안전하고 빠르게 만드는 것이다. 추상화가 오히려 생산성을 떨어뜨린다면 의미가 없다.

이러한 의존성 주입 구조에 대한 많은 글들이 있다.
실용적으로 바깥 영역에 두는 것에 대한 것들은 보통 아래와 같다.

  • Network - API 통신
  • Analytics - 사용자 행동 추적, 로깅
  • Storage - localStorage, sessionStorage, 쿠키
  • Bridge - 다른 프레임워크와의 통신 (웹뷰 등)
  • Runtime - User Agent, Browser History

의존성 주입에 대한 고민

의존성 주입이 만능은 아니다. 실제로 적용하면서 마주하는 어려움이 있다.

경계 식별의 어려움

무엇을 외부 의존성으로 볼 것인지 명확한 기준이 필요하다. 기존 코드베이스가 크다면 리팩토링 비용도 만만치 않다. (기존에 구조를 잘 잡을껄 후회한다)

DI Container의 부재

Spring 같은 백엔드 프레임워크와 달리, 프론트엔드에는 대중적인 DI Container가 없다. tsyringe, inversify, Awilix 같은 라이브러리가 있지만, 각각 사용성 대비 번들 크기 측면이나, 실제 기능면에서 아쉬운 점이 있다. 결국 React Context API를 활용한 수동 주입이 현실적인 선택이라고 느끼기는 하지만, 아직도 고민 중이다.

서버/클라이언트 환경 분리

Next.js 같은 프레임워크에서는 서버 컴포넌트와 클라이언트 컴포넌트를 구분해야 한다. Context는 클라이언트 전용이므로, 서버에서 의존성을 주입하려면 별도의 전략이 필요하다.


마무리

서비스의 변경 속도가 빨라질수록 테스트 코드는 선택이 아닌 생존 전략이 된다.

이번 리팩토링 과정을 통해, 의존성 주입(DI)이 단순히 테스트를 위한 도구를 넘어 유연하고 지속 가능한 애플리케이션을 만드는 핵심 열쇠임을 다시 한번 확인할 수 있었다.

물론 완벽한 아키텍처는 존재하지 않는다.

나 또한 수많은 기술 블로그와 레퍼런스를 탐독하며 우리 팀의 상황에 맞는 최적의 해답을 찾기 위해 부단히 애썼고, 그 과정에서 얻은 인사이트를 이 글에 정리해 보았다.

여전히 해결해야 할 과제들은 남아있지만, 적어도 ‘어제보다 더 나은 코드’를 작성하고 있다는 확신은 생겼다.

이 글이 리액트 아키텍처와 테스트 도입을 망설이는 개발자들에게 실질적인 도움이 되기를 바란다.

더 나은 방법에 대한 제언이나 각자의 경험담은 언제나 환영한다.

참고 자료