
Java를 배우고 Spring을 처음 접하면 가장 먼저 듣게 되는 용어 중 하나가 IoC와 DI입니다.
Spring으로 개발을 시작한 분들이 가장 많이 오해하는 부분은 IoC와 DI가 Spring에서 처음 만들어진 기능이라고 생각하는 것입니다.
하지만 IoC와 DI는 Spring만의 개념이 아닙니다.
즉, 객체지향적인 좋은 코드를 만들기 위한 개념이라고 이해하면 좋습니다.
설계 원칙과 디자인 패턴이라는 용어가 낯설다면 요리에 비유해보겠습니다.
맛있는 김치볶음밥을 만들기 위한 원칙은 다음과 같습니다.
이처럼 좋은 결과물을 만들기 위해 지켜야 하는 기본적인 원칙이 설계 원칙이라고 볼 수 있습니다.
맛있는 김치볶음밥을 만들기 위한 구체적인 레시피는 다음과 같습니다.
즉, 원칙을 실제 코드나 작업 과정에서 적용하기 위한 구체적인 방법이라고 생각하면 됩니다.
그렇다면 좋은 코드란 무엇일까요?
좋은 코드를 작성하기 위해서는 신경 써야 할 부분이 정말 많습니다.
Spring은 개발자가 Java를 사용하여 이러한 좋은 코드를 작성할 수 있도록 도와주는 프레임워크입니다.
실생활에 비유한다면 요리 도구와 재료가 어느 정도 준비되어 있는 밀키트라고 볼 수 있습니다.
그리고 그중에서도 IoC와 DI는 Spring의 핵심 개념이라고 할 수 있습니다.
Spring에서는 IoC Container를 통해 객체의 생성과 관리 등을 담당합니다.
Spring Docs에서는 IoC를 DI(Dependency Injection)와 같은 의미로 설명하기도 합니다.
이를 간단하게 정리하면 다음과 같습니다.
DI 패턴을 사용하여 IoC라는 설계 원칙을 구현한다.
Spring의 IoC Container와 Bean에 대해서는 이후에 자세하게 알아보겠습니다.
우리가 다리를 다쳐서 목발을 사용하여 걷게 되었다고 생각해봅시다.
이 경우 우리는 걷기 위해 목발에 의존하고 있습니다.
코드에서도 마찬가지입니다.
어떤 객체가 다른 객체를 사용해야만 정상적으로 동작한다면, 한 객체가 다른 객체에 의존하고 있다고 표현할 수 있습니다.
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의 코드도 계속 수정해야 합니다.
이러한 관계를 강한 결합 또는 강한 의존성이라고 합니다.
그렇다면 어떻게 하면 결합을 약하게 만들 수 있을까요?
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) 또는 약한 의존성이라고 합니다.
우리가 주사기를 통해 백신을 몸속에 주입하듯이,
코드에서의 주입(Injection)도 필요한 객체를 해당 객체에 전달하는 것을 의미합니다.
대표적인 주입 방법에는 다음과 같은 것들이 있습니다.
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에 주입하고 있습니다.
이번에는 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와 같은 메서드를 통해 필요한 객체를 전달하는 방식도 의존성 주입입니다.
마지막으로 생성자를 이용해 객체를 주입할 수 있습니다.
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());
이렇게 생성자를 통해 의존 객체를 전달하는 방식을 생성자 주입이라고 합니다.
처음 코드를 다시 살펴보겠습니다.
public class Consumer {
void eat() {
Chicken chicken = new Chicken();
chicken.eat();
}
}
여기서는 Consumer가 직접 Chicken 객체를 생성합니다.
즉,
Consumer
↓
Chicken 생성
↓
Chicken 사용
객체의 생성과 사용에 대한 제어권이 Consumer에게 있습니다.
따라서 새로운 음식이 필요하다면 Consumer의 코드를 직접 수정해야 합니다.
이번에는 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)이라고 합니다.
전체 흐름을 정리하면 다음과 같습니다.
Consumer
│
├── Chicken 객체 생성
│
└── Chicken 사용
Consumer가 직접 필요한 객체를 생성하기 때문에 구체적인 구현체에 강하게 결합되어 있습니다.
외부
│
├── Chicken 생성
│
└── Consumer에게 전달
│
↓
Food 사용
Consumer는 필요한 객체를 직접 생성하지 않고 외부에서 전달받습니다.
따라서 객체 생성에 대한 제어권이 Consumer에서 외부로 이동하게 됩니다.
이것이 제어의 역전(IoC)입니다.
그리고 외부에서 필요한 객체를 전달해주는 구체적인 방법이 의존성 주입(DI)입니다.
| 개념 | 의미 |
|---|---|
| 의존성 | 한 객체가 다른 객체를 필요로 하는 관계 |
| 약한 결합 | 구체적인 구현체보다 인터페이스 등에 의존하는 구조 |
| 주입(Injection) | 필요한 객체를 외부에서 전달하는 것 |
| DI | 객체가 필요한 의존성을 외부에서 주입받는 디자인 패턴 |
| IoC | 객체의 생성·사용 등에 |