IoC(제어의 역전), DI(의존성 주입) 이해하기

Eugenius1st·2026년 9월 22일

Spring

목록 보기
5/5

IoC(제어의 역전), DI(의존성 주입) 이해하기

1. Spring의 IoC와 DI

💡 IoC, DI… 누구냐 넌?

Java를 배우고 Spring을 처음 접하면 가장 먼저 듣게 되는 용어 중 하나가 IoC와 DI입니다.

Spring으로 개발을 시작한 분들이 가장 많이 오해하는 부분은 IoC와 DI가 Spring에서 처음 만들어진 기능이라고 생각하는 것입니다.

하지만 IoC와 DI는 Spring만의 개념이 아닙니다.

  • IoC(Inversion of Control) → 설계 원칙
  • DI(Dependency Injection) → 디자인 패턴

즉, 객체지향적인 좋은 코드를 만들기 위한 개념이라고 이해하면 좋습니다.

설계 원칙과 디자인 패턴이라는 용어가 낯설다면 요리에 비유해보겠습니다.


2. 설계 원칙과 디자인 패턴

🧑‍🍳 김치볶음밥 맛있게 만드는 방법 — 설계 원칙

맛있는 김치볶음밥을 만들기 위한 원칙은 다음과 같습니다.

  • 신선한 재료를 사용한다.
  • 신 김치를 사용한다.
  • 밥과 김치의 비율을 잘 맞춘다.
  • 볶을 때 재료를 넣는 순서가 중요하다.

이처럼 좋은 결과물을 만들기 위해 지켜야 하는 기본적인 원칙이 설계 원칙이라고 볼 수 있습니다.

🍳 김치볶음밥 레시피 — 디자인 패턴

맛있는 김치볶음밥을 만들기 위한 구체적인 레시피는 다음과 같습니다.

  1. 오일을 두른 팬에 채 썬 파를 볶아 파 기름을 만든다.
  2. 준비한 햄을 넣고 볶다가 간장 한 스푼을 넣어 풍미를 낸다.
  3. 설탕에 버무린 김치를 넣고 함께 볶는다.
  4. 미리 식혀 둔 밥을 넣어 함께 볶는다.
  5. 참기름 한 스푼을 넣어 마무리한다.

즉, 원칙을 실제 코드나 작업 과정에서 적용하기 위한 구체적인 방법이라고 생각하면 됩니다.


3. 좋은 코드를 위한 Spring의 IoC와 DI

그렇다면 좋은 코드란 무엇일까요?

  • 논리가 간단해야 한다.
  • 중복을 제거하고 표현을 명확하게 해야 한다.
  • 코드를 처음 보는 사람도 쉽게 이해하고 수정할 수 있어야 한다.
  • 의존성을 최소화해야 한다.
  • 새로운 기능을 추가하더라도 기존 구조를 크게 변경하지 않아야 한다.
  • ...

좋은 코드를 작성하기 위해서는 신경 써야 할 부분이 정말 많습니다.

Spring은 개발자가 Java를 사용하여 이러한 좋은 코드를 작성할 수 있도록 도와주는 프레임워크입니다.

실생활에 비유한다면 요리 도구와 재료가 어느 정도 준비되어 있는 밀키트라고 볼 수 있습니다.

그리고 그중에서도 IoC와 DI는 Spring의 핵심 개념이라고 할 수 있습니다.

Spring에서는 IoC Container를 통해 객체의 생성과 관리 등을 담당합니다.

Spring Docs에서는 IoC를 DI(Dependency Injection)와 같은 의미로 설명하기도 합니다.

이를 간단하게 정리하면 다음과 같습니다.

DI 패턴을 사용하여 IoC라는 설계 원칙을 구현한다.

Spring의 IoC Container와 Bean에 대해서는 이후에 자세하게 알아보겠습니다.


4. 의존성이란?

💡 DI를 이해하려면 먼저 '의존성'을 이해해야 합니다.

우리가 다리를 다쳐서 목발을 사용하여 걷게 되었다고 생각해봅시다.

이 경우 우리는 걷기 위해 목발에 의존하고 있습니다.

코드에서도 마찬가지입니다.

어떤 객체가 다른 객체를 사용해야만 정상적으로 동작한다면, 한 객체가 다른 객체에 의존하고 있다고 표현할 수 있습니다.


4-1. Consumer와 Chicken의 강한 결합

public class Consumer {

    void eat() {
        Chicken chicken = new Chicken();
        chicken.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();
        consumer.eat();
    }
}

class Chicken {

    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

위 코드에서 Consumer와 Chicken의 관계를 살펴보겠습니다.

Consumer가 직접 Chicken 객체를 생성하고 있습니다.

Chicken chicken = new Chicken();

즉, Consumer가 Chicken이라는 구체적인 클래스에 강하게 의존하고 있습니다.

현재 코드가 실행되는 데에는 문제가 없습니다.

하지만 만약 Consumer가 치킨이 아니라 피자를 먹고 싶어진다면 어떻게 해야 할까요?

Chicken chicken = new Chicken();

이 부분을 Pizza로 변경해야 합니다.

만약 음식의 종류가 계속 추가된다면 Consumer의 코드도 계속 수정해야 합니다.

이러한 관계를 강한 결합 또는 강한 의존성이라고 합니다.


5. Interface를 이용한 약한 결합

그렇다면 어떻게 하면 결합을 약하게 만들 수 있을까요?

Java의 Interface와 다형성을 활용할 수 있습니다.

public class Consumer {

    void eat(Food food) {
        food.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();

        consumer.eat(new Chicken());
        consumer.eat(new Pizza());
    }
}

interface Food {
    void eat();
}

class Chicken implements Food {

    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food {

    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

이번에는 Consumer가 Chicken이나 Pizza에 직접 의존하지 않습니다.

대신 Food라는 인터페이스에 의존하고 있습니다.

void eat(Food food) {
    food.eat();
}

Chicken과 Pizza는 모두 Food를 구현하고 있기 때문에 Consumer는 어떤 음식이 들어오더라도 사용할 수 있습니다.

consumer.eat(new Chicken());
consumer.eat(new Pizza());

이처럼 인터페이스를 활용한 다형성을 사용하면 구체적인 구현체에 대한 의존성을 줄일 수 있습니다.

이러한 관계를 약한 결합(Loose Coupling) 또는 약한 의존성이라고 합니다.


6. 주입이란?

💡 그렇다면 '주입'은 무엇일까요?

우리가 주사기를 통해 백신을 몸속에 주입하듯이,

코드에서의 주입(Injection)도 필요한 객체를 해당 객체에 전달하는 것을 의미합니다.

대표적인 주입 방법에는 다음과 같은 것들이 있습니다.

  1. 필드 주입
  2. 메서드 주입
  3. 생성자 주입

6-1. 필드에 직접 주입

public class Consumer {

    Food food;

    void eat() {
        this.food.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();

        consumer.food = new Chicken();
        consumer.eat();

        consumer.food = new Pizza();
        consumer.eat();
    }
}

interface Food {

    void eat();
}

class Chicken implements Food {

    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food {

    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

Consumer가 직접 Chicken을 생성하여 사용하는 것이 아니라,

외부에서 Food 객체를 전달받아 사용하고 있습니다.

consumer.food = new Chicken();

또는

consumer.food = new Pizza();

즉, 필요한 객체를 Consumer의 food에 주입하고 있습니다.


6-2. 메서드를 통한 주입

이번에는 setter 메서드를 이용해 객체를 주입해보겠습니다.

public class Consumer {

    Food food;

    void eat() {
        this.food.eat();
    }

    public void setFood(Food food) {
        this.food = food;
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();

        consumer.setFood(new Chicken());
        consumer.eat();

        consumer.setFood(new Pizza());
        consumer.eat();
    }
}

interface Food {

    void eat();
}

class Chicken implements Food {

    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food {

    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

이번에는 다음과 같은 setFood() 메서드를 만들었습니다.

public void setFood(Food food) {
    this.food = food;
}

그리고 외부에서 원하는 객체를 전달합니다.

consumer.setFood(new Chicken());

또는

consumer.setFood(new Pizza());

이처럼 setter와 같은 메서드를 통해 필요한 객체를 전달하는 방식도 의존성 주입입니다.


6-3. 생성자를 통한 주입

마지막으로 생성자를 이용해 객체를 주입할 수 있습니다.

public class Consumer {

    Food food;

    public Consumer(Food food) {
        this.food = food;
    }

    void eat() {
        this.food.eat();
    }

    public static void main(String[] args) {

        Consumer consumer = new Consumer(new Chicken());
        consumer.eat();

        consumer = new Consumer(new Pizza());
        consumer.eat();
    }
}

interface Food {

    void eat();
}

class Chicken implements Food {

    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food {

    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

Consumer의 생성자를 살펴보면 다음과 같습니다.

public Consumer(Food food) {
    this.food = food;
}

그리고 객체를 생성할 때 필요한 Food 객체를 전달합니다.

Consumer consumer = new Consumer(new Chicken());

또는

Consumer consumer = new Consumer(new Pizza());

이렇게 생성자를 통해 의존 객체를 전달하는 방식을 생성자 주입이라고 합니다.


7. 제어의 역전(IoC)이란?

💡 마지막으로 제어의 역전은 무엇일까요?

처음 코드를 다시 살펴보겠습니다.

public class Consumer {

    void eat() {
        Chicken chicken = new Chicken();
        chicken.eat();
    }
}

여기서는 Consumer가 직접 Chicken 객체를 생성합니다.

즉,

Consumer
   ↓
Chicken 생성
   ↓
Chicken 사용

객체의 생성과 사용에 대한 제어권이 Consumer에게 있습니다.

따라서 새로운 음식이 필요하다면 Consumer의 코드를 직접 수정해야 합니다.


7-1. 의존성 주입을 사용하면?

이번에는 Consumer가 필요한 Food를 외부에서 전달받도록 변경했습니다.

public class Consumer {

    void eat(Food food) {
        food.eat();
    }
}

이제 Consumer는 어떤 구체적인 음식이 만들어지는지 직접 알 필요가 없습니다.

외부에서 만들어진 객체를 전달받아 사용하기만 하면 됩니다.

consumer.eat(new Chicken());
consumer.eat(new Pizza());

즉, 객체의 생성과 전달에 대한 제어가 Consumer 밖으로 이동했습니다.

이를 제어의 역전(IoC, Inversion of Control)이라고 합니다.


8. IoC와 DI 정리

전체 흐름을 정리하면 다음과 같습니다.

기존 방식

Consumer
   │
   ├── Chicken 객체 생성
   │
   └── Chicken 사용

Consumer가 직접 필요한 객체를 생성하기 때문에 구체적인 구현체에 강하게 결합되어 있습니다.

DI를 적용한 방식

외부
 │
 ├── Chicken 생성
 │
 └── Consumer에게 전달
          │
          ↓
       Food 사용

Consumer는 필요한 객체를 직접 생성하지 않고 외부에서 전달받습니다.

따라서 객체 생성에 대한 제어권이 Consumer에서 외부로 이동하게 됩니다.

이것이 제어의 역전(IoC)입니다.

그리고 외부에서 필요한 객체를 전달해주는 구체적인 방법이 의존성 주입(DI)입니다.


⭐ 핵심 정리

개념의미
의존성한 객체가 다른 객체를 필요로 하는 관계
약한 결합구체적인 구현체보다 인터페이스 등에 의존하는 구조
주입(Injection)필요한 객체를 외부에서 전달하는 것
DI객체가 필요한 의존성을 외부에서 주입받는 디자인 패턴
IoC객체의 생성·사용 등에
profile
자신만의 속도로

0개의 댓글