제어 역전(IoC)과 의존성 주입(DI)

IoC(Inversion of Control)와 DI(Dependency Injection)는 종종 특정 프레임워크의 핵심 기능처럼 설명된다. 특히 Spring을 경험한 개발자라면, 이 두 개념을 자연스럽게 Spring 컨테이너와 함께 떠올린다.

하지만 이 개념들의 본질은 프레임워크에 있지 않다. IoC와 DI는 프로그램의 제어권을 어디에 둘 것인가에 대한 구조적 선택이다. 그리고 우리는 이미, 프레임워크를 쓰지 않아도 이 선택을 매일 하고 있다.

우리는 이미 IoC를 쓰고 있다 — React에서

React에서 IoC는 특별한 패턴이 아니다. props, hook, context는 모두 구현을 내부에 고정하지 말고, 조립 단계에서 결정하라는 같은 메시지를 담고 있다.

1) Hook 인자 주입 = DI

type User = { id: string; name: string }
type FetchUser = (id: string) => Promise<User>

function UserName({ userId, fetchUser }: { userId: string; fetchUser: FetchUser }) {
  const [name, setName] = React.useState("")

  React.useEffect(() => {
    fetchUser(userId).then(u => setName(u.name))
  }, [userId, fetchUser])

  return <span>{name}</span>
}

이 컴포넌트는 데이터를 어떻게 가져오는지 모른다. HTTP 요청인지, 캐시 조회인지, 테스트용 mock인지도 관심 없다.

이 컴포넌트는 의존성을 소유하지 않는다.

어떤 구현을 쓸지는 부모 컴포넌트가 결정한다. 이 결정권의 이동이 IoC이며, props를 통해 전달되는 구현이 DI다.

2) 조립(Composition) 단계가 컨테이너 역할을 한다

function createHttpFetchUser(baseUrl: string): FetchUser {
  return async (id) => {
    const res = await fetch(`${baseUrl}/users/${id}`)
    return res.json()
  }
}

export function App() {
  const fetchUser = createHttpFetchUser("/api")
  return <UserName userId="42" fetchUser={fetchUser} />
}

React에서는 컴포넌트 트리 상단이 의존성을 조립하는 지점이 된다. 별도의 DI 컨테이너가 없어도 구조는 동일하다.

3) Context Provider = 런타임 DI 컨테이너

type Deps = { fetchUser: FetchUser }
const DepsContext = React.createContext<Deps | null>(null)

function useDeps() {
  const ctx = React.useContext(DepsContext)
  if (!ctx) throw new Error("DepsProvider missing")
  return ctx
}

function UserName({ userId }: { userId: string }) {
  const { fetchUser } = useDeps()
  React.useEffect(() => {
    fetchUser(userId)
  }, [userId, fetchUser])
  return <span />
}

export function App() {
  const deps: Deps = { fetchUser: createHttpFetchUser("/api") }
  return (
    <DepsContext.Provider value={deps}>
      <UserName userId="42" />
    </DepsContext.Provider>
  )
}

Context는 React에서의 런타임 DI 컨테이너에 가깝다. Provider를 교체하는 것만으로 테스트 환경과 실행 환경을 분리할 수 있다.

4) React 내부의 IoC — 호출권은 프레임워크에 있다

function Button({ onClick }: { onClick: () => void }) {
  return <button onClick={onClick}>+</button>
}

개발자는 onClick을 전달하지만, 언제·어떻게 호출할지는 React가 결정한다. useEffect 역시 동일하다. 실행 시점과 순서는 컴포넌트가 아니라 프레임워크가 소유한다.

이것이 React 내부에 이미 내재된 IoC다.

IoC의 핵심은 “누가 결정하는가”다

전통적인 구조에서는 다음과 같은 흐름이 자연스러웠다.

  • 함수나 컴포넌트가 직접 객체를 생성하고
  • 구현을 선택하며
  • 실행 시점까지 소유한다

이 구조는 단순하지만, 변경과 테스트에 취약하다. 구현이 코드 내부에 고정되기 때문이다.

IoC는 이 흐름을 뒤집는다.

  • 컴포넌트는 무엇이 필요한지만 선언하고
  • 어떤 구현을 쓸지는 외부에서 결정한다

의존성 주입(DI)은 이 선택을 구현하는 여러 방법 중 하나일 뿐이다. 중요한 것은 ‘주입’이라는 행위가 아니라, 결정 주체가 이동했다는 사실이다.

그렇다면 Spring은 무엇을 했는가

Spring은 IoC나 DI를 발명하지 않았다. Spring이 한 일은 이 구조적 선택을 대규모 시스템에서도 유지 가능하게 만든 것이다.

객체 수가 수백, 수천 개로 늘어나면 다음과 같은 문제가 생긴다.

  • 생성 시점은 언제인가
  • 생명주기는 어떻게 관리할 것인가
  • 어떤 조합이 허용되는가

Spring의 컨테이너는 이 복잡도를 감당하기 위한 자동화 장치다. 개념의 근원이 아니라, 개념을 확장한 구현체에 가깝다.

내가 DI를 이해하는 방식

나는 DI를 “의존성을 주입받는 기술”로 이해하지 않는다. DI는 제어권을 어디에 둘 것인가에 대한 설계 결정이다.

  • 구현을 내부에서 고정할 것인가
  • 외부에서 선택하게 할 것인가
  • 그리고 그 선택을 누가 책임질 것인가

Spring은 그 결정을 돕는 강력한 도구다. 하지만 그 도구를 사용한다고 해서, 자동으로 구조를 이해하게 되는 것은 아니다.

마무리

프레임워크는 변한다. 하지만 제어권을 어떻게 배치할 것인가라는 문제는 변하지 않는다.

IoC와 DI를 프레임워크 기능이 아닌 프로그램 구조의 문제로 이해할 때, 우리는 도구를 단순히 사용하는 단계를 넘어, 왜 이런 도구가 필요해졌는지를 설명할 수 있게 된다.