의존성 주입(DI, Dependency Injection)

의존을 최소화하려고 노력해야 한다.

만약 A 클래스가 B 클래스에 의존한다면, B가 바뀌었을 때 A한테도 영향을 끼친다. 그러다보니 아키텍터들은 의존성이 최대한 약하게 결합되도록 설계를 하려고 한다. 하지만 의존은 피할 수가 없다. 객체 간에 의존 없이 만들려면 하나의 클래스에 모든 코드를 다 때려넣어야 한다. 그렇게 되면 절차지향적 프로그램일 뿐이다. 따라서 우리가 고민해야 할 건 어떻게 의존을 최소화할 것인가이다.

의존을 맺는 방법

public class 학급 {
	private Student student
	...
}

학급이라는 클래스가 있고, 학급Student 클래스에 의존하고 있다고 가정하자. 여기서 학급이 Student 클래스에 의존하는 방법이 2가지가 있다.

1. **학급 클래스 내부에서 Student 객체를 생성해서 의존을 맺는 방법**

public class 학급 {
	public void test() {
		Student student = new Student();
		...
	}
}

2. 의존성 주입을 통해서 Student 객체와 의존을 맺는 방법

2.1. 생성자를 통해 의존성 주입 받기

public class 학급 {
	private Student student;

	public 학급(Student student) {
		this.student = student;
	}
}

2.2. setter를 통해서 의존성 주입 받기

setter를 통해 의존성을 주입 받는 방식은, 객체를 생성한 뒤에 의존 객체를 주입하는 방식이다. 그렇다보니 setter를 통해 의존성을 주입 받는 과정을 실수로 빠트릴 경우, 객체의 메섣르르 실행하는 과정에서 NullPointerException이 발생할 수 있게 된다.

public class 학급 {
	private Student student;

	public void setStudent(Student student) {
		this.student = student;
	}
}

2.3. 메서드의 파라미터로 의존성 주입 받기

public class 학급 {
	private Student student;

	public someMethod(Student student) {
		...
	}
}

의존성 주입의 장점

1. 테스트 하기 쉽게 만들어준다.

의존성 주입을 사용하지 않은 형태

public class DateMessage {
	public String getDateMessage() {
		// 내부에서 직접 객체를 생성해서 의존을 맺은 형태
		Calendar now = Calendar.getInstance(); 
		int hour = now.get(Calendar.HOUR_OF_DAY);
		
		if (hour < 12) {
			return "오전";
		}
		return "오후";
	}
}
public class DateMessageTest {
	@Test
	public void 오전() {
		DateMessage dateMessage = new DateMessage();
		assertEquals("오전", dateMessage.getDateMessage());
	}

	@Test
	public void 오후() {
		DateMessage dateMessage = new DateMessage();
		assertEquals("오후", dateMessage.getDateMessage());
	}
}

위 테스트를 실행하면 컴퓨터의 현재 시간에 따라서 테스트 메소드 중 둘 중의 하나는 반드시 실패한다. 위 테스트를 어떻게 하면 통과시킬 수 있을까? 의존성 주입을 사용하면 된다!

의존성 주입을 사용

public class DateMessage {
	**// 메서드의 파라미터로 의존성을 주입 받음**
	public String getDateMessage(**Calendar now**) {
		int hour = now.get(Calendar.HOUR_OF_DAY);
		
		if (hour < 12) {
				return "오전";
		}
		return "오후";
	}
}
public class DateMessageTest {
	@Test
	public void 오전() {
		DateMessage dateMessage = new DateMessage();
		assertEquals("오전", dateMessage.getDateMessage(**createCurrentDate(2)**));
	}

	@Test
	public void 오후() {
		DateMessage dateMessage = new DateMessage();
		assertEquals("오후", dateMessage.getDateMessage(**createCurrentDate(18)**));
	}

	**private Calendar createCurrentDate(int hourOfDay) {
		Calendar now = Calendar.getInstance();
		now.set(Calendar.HOUR_OF_DAY, hourOfDay);
		return now;
	}**
}

Calendar 인스턴스의 생성을 DateMessage 클래스가 결정하지 않고 외부로부터 전달받음으로써 테스트가 가능해졌다.

2. 유지 보수가 용이해진다.

(의존의 결합 정도가 느슨해진다.)

처음엔 요구사항으로 자동차가 ‘SnowTire‘를 가지고 있게 해달라고 했다. 하지만 이후에 ‘SnowTire‘가 아닌 ‘NormalTire‘로 바꿔 달라고 요청이 들어왔다고 가정하자. 또한 Tire라는 인터페이스가 있고, 이를 상속받은 SnowTireNormalTire가 있다고 가정하자.

public interface Tire {
	...
}

public SnowTire implements Tire {
	...
}

public NormalTire implements Tire {
	...
}

의존성 주입을 사용하지 않은 형태

  1. 자동차가 스노우 타이어를 가지고 있게 해달라고 요청이 들어옴

    Car 클래스

     public class Car {
         private Tire tire;
    
         public Car() {
             tire = new SnowTire();
         }
     }
    

    컨텍스트 클래스

     Car car = new Car();
    
  2. 자동차가 스노우 타이어가 아닌 일반 타이어로 바꿔 달라고 요청이 들어옴

    Car 클래스

     public class Car {
         private Tire tire;
    
         public Car() {
             tire = **new NormalTire();**
         }
     }
    

    컨텍스트 클래스

     Car car = new Car();
    
  • Car 클래스에서 타이어의 객체를 생성하는 역할을 가지고 있다보니, 객체를 생성하는 부분에 대해서 요구 사항이 바뀔 때마다 Car 클래스를 수정해야 하는 경우가 생긴다. 이러한 형태는 단일 책임 원칙(SRP)를 위반한 것이기도 하다.

의존성 주입을 사용한 형태

  1. 자동차가 스노우 타이어를 가지고 있게 해달라고 요청이 들어옴

    Car 클래스

     public class Car {
         private Tire tire;
    
         public Car(**Tire tire**) {
             **this.tire = tire;**
         }
     }
    

    컨텍스트 클래스

     Tire tire = new SnowTire();
     Car car = new Car(tire);
    
  2. 자동차가 스노우 타이어가 아닌 일반 타이어로 바꿔 달라고 요청이 들어옴

    Car 클래스

     public class Car {
         private Tire tire;
    
         public Car(Tire tire) {
                 this.tire = tire;
         }
     }
    

    컨텍스트 클래스

     **Tire tire = new NormalTire();**
     Car car = new Car(tire);
    
  • 의존성 주입을 받음으로써 Car 클래스에서 타이어의 객체를 생성하는 역할을 가지지 않게 되었다. 이 덕분에 객체를 생성하는 부분에 대해서 요구 사항이 바뀔 때마다 Car 클래스를 수정할 필요가 없어졌다. 즉, 유지보수가 좋아졌다.

3. 스노우 타이어를 가진 자동차와, 일반 타이어를 가진 자동차를 만들어 달라고 요청이 들어옴

Car 클래스

public class Car {
	private Tire tire;

	public Car(Tire tire) {
		this.tire = tire;
	}
}

컨텍스트 클래스

**Tire normalTire = new NormalTire();**
Car **normalTireCar** = new Car(**normalTire**);

Tire **snowTire** = new SnowTire();
**Car snowTireCar = new Car(snowTire);**
  • 의존성 주입을 사용하니 Car의 클래스의 코드를 수정 없이 위와 같이 사용할 수 있게 되어서 유지보수에 더욱 편하다는 것을 느낄 수 있다. 만약 의존성 주입을 사용하지 않았다면, SnowTireCar, NormalTireCar을 만들어야 했을 수도 있다. 이렇게 되면 중복되는 코드도 많이 발생했을 것이다.

정리

의존성 주입을 하게되면 요구 사항의 변경이 있음에도 불구하고, 보다 수월하게 유지보수를 할 수 있음을 보았다. 이를 보고 개체 간의 의존성이 훨씬 느슨해졌다(loosely coupled, decoupling)고 표현하기도 한다.

항상 의존성 주입을 사용하는 것이 좋은가 ?

Is dependency injection always a best practice?

의존성 주입은 항상 사용할 것을 권장한다. 의존성 주입을 통해 얻을 수 있는 이점이 굉장히 많기 때문이다. 또한 의존성 도입을 하는 데 있어서 큰 비용이 드는 것도 아니다. 하지만 만약 의존성 도입을 안 했다가 나중에 의존성 도입을 하려고 하다보면 유지보수 비용이 생각보다 클 것이다.

특히 의존성 주입은 초기 개발 단계에서 이점을 느끼지 못할 수도 있지만, 장기적인 관점에서는 이점이 있는 것이 너무 분명하다.

의존성 주입을 할 때 항상 인터페이스를 사용해야 하는가 ?

Stop overusing interfaces

의존성 주입을 할 때 항상 인터페이스를 사용할 필요는 없다. 인터페이스를 사용하라는 말을 많이 들어본 이유는 의존성 역전 원칙(DIP)에 입각한 말들일 것이다. 하지만 YAGNI에 따르면 항상 인터페이스를 만드는 것은 오버엔지니어링이다. 인터페이스가 필요해지는 상황이 생길 때, 그 때 인터페이스를 도입해도 절대 늦지 않다.

References

DI

What is dependency injection?