자바는 객체 지향 프로그래밍 언어이다.
그리고 객체 지향 프로그래밍 언어는 객체 지향적으로 설계를 해야 한다.
그렇다면 객체 지향적으로 설계를 하는 것이 무엇일까?
객체 지향적으로 설계를 하려면 어떻게 설계해야 할까?
우선 예시 코드로 보자
class A {
private B b = new B();
public void greet() {
System.out.println(b.say()); // Hello가 출력된다.
}
}
class B {
public String say() {
return "Hello";
}
}
A라는 클래스가 있고, hello()
메서드에서 B의 sayHello()
메서드를 실행하여 로직을 수행한다.
이 경우 A가 B에 강하게 의존하고 있다.
즉, B에 대해 변경이 일어날 경우 A도 영향을 받는다.
class A {
private B b = new B();
public void greet() {
System.out.println(b.say()); // Hi로 출력이 변경된다.
}
}
class B {
public String say() {
return "Hi"; // Hello -> Hi
}
}
그러면 어떻게 이 문제를 해결해야 할까?
우선 A의 인스턴스 필드에서 초기화하는 코드를 제거하고, 외부에서 주입받는 코드로 변경해보자.
class A {
private B b;
public A(B b) {
this.b = b;
}
public void greet() {
System.out.println(b.say());
}
}
class B {
public String say() {
return "Hello";
}
}
A a = new A(new B());
a.greet();
이렇게 외부에서 의존성을 주입하는 것을 의존성 주입
즉 DI라고 한다.
의존성 주입의 의도는 위키피디아에 다음과 같이 나와 있다.
의존성 주입의 의도는 객체의 생성과 사용의 관심을 분리하는 것이다. 이는 가독성과 코드 재사용을 높여준다.
즉, 객체 간에 상호작용을 통한 코드의 재사용성을 높이고 유지보수성을 향상시키기 위해 의존 관계를 주입한다고 볼 수 있겠다.
DI는 객체 지향 언어가 추구하는 객체 간의 상호작용을 구현하기 위한 핵심인 셈이다.
하지만 여전히 A는 B에 의존하므로 변경이 필요할 때 B를 수정해야 하거나
B 대신 C라는 클래스로 변경이 되면 기존의 코드를 전부 수정해야 한다.
class A {
private C c; // 수정
public A(C c) { // 수정
this.c = c; // 수정
}
public void greet() {
System.out.println(c.say()); // 수정
}
}
또한 DI를 사용해도 B의 로직이 변경된다면 결국 A가 B를 사용하기에 DI를 하기 전의 코드와 별 차이가 없다.
전혀 재사용성이 없고, 유지보수성이 향상되지 않는 것 같다.
그렇다면 의존성 주입을 대체 왜 쓰는 걸까?
DI를 단순히 사용한다고 코드의 재사용성과 유지보수성을 향상하지 못한다.
그렇다면 DI를 어떻게 사용해야 할까?
위키피디아에 다음과 같이 설명되어있다.
의존성 주입은 프로그램 디자인이 결합도를 느슨하게 되도록 하고 의존관계 역전 원칙과 단일 책임 원칙을 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다.
여기서 의존관계 역전 원칙과 단일 책임 원칙을 따라야 한다는 내용이 나온다.
바로 객체 지향 프로그래밍에서 지켜야 하는 유명한 원칙인 SOLID
원칙이다.
그중에서 SRP(단일 책임 원칙)와 DIP(의존관계 역전 원칙)를 알아보자.
SRP (Single Responsibility Principle) 원칙
DIP (Dependency Inversion Principle) 원칙
SRP 원칙은 우리가 지킨 것 같다. (일단 A는 단순히 B의 로직을 위임하고, B는 특별한 로직이 없으므로)
그렇다면 DIP 원칙은 어떻게 지켜야 할까?
바로 인터페이스에 의존하도록 하면 된다. 즉 추상화에 의존하도록 하는 것이다.
따라서 B를 인터페이스로 바꾸어 보자
class A {
private B b;
public A(B b) {
this.b = b; // 인터페이스로 설계하여 B를 구현한 객체라면 수정하지 않고 변경할 수 있다.
}
public void greet() {
System.out.println(b.say());
}
}
interface B {
String say();
}
class C implements B {
@Override
public String say() {
return "Hello";
}
}
class D implements B {
@Override
public String say() {
return "Hi";
}
}
A a1 = new A(new C());
a1.greet(); // Hello
A a2 = new A(new D())
a2.greet(); // Hi
이렇게 DI를 사용하고 인터페이스에 의존하여 구현체가 어떤 객체이든 상관없이 변경에 유연하게 설계를 할 수 있다.
드디어 위에서 말한 DI의 이점을 얻을 수 있게 되었다!
과연 이제 객체 지향적인 설계를 했다고 할 수 있을까?
여기서 이제 SOLID 원칙 중 OCP 원칙을 알아보자
OCP (Open/Closed Principle) 원칙
OCP 원칙은 확장에는 열려있지만, 수정에는 닫혀 있어야 한다는 원칙이다.
하지만 확장하려면 기존의 코드를 수정해야 한다.
// A a = new A(new C()); // 기존의 코드 삭제
A a = new A(new D()) // C에서 D로 변경
a.greet();
확장을 위해 인터페이스를 사용하여 유연성을 확보했지만, 기존의 코드를 수정해야 하는 일이 생길 수밖에 없다.
지금은 A와 B 2개뿐이지만, 프로그램의 규모가 커져 의존하는 객체가 많아진다면 수정해야 하는 부분이 계속 늘어난다.
또한 확장되면 될수록 관리해야 하는 부분은 더 많아진다.
이렇게 된다면 확장에 자유로운 설계라고 할 수 있을까?
사용자가 의존성을 직접 주입한다면 OCP 원칙은 절대로 지킬 수 없는 원칙이다.
물론 SOLID 원칙은 말 그대로 원칙일 뿐 지키지 않는다고 프로그램이 실행되지 않거나 하지는 않는다.
하지만 SOLID 원칙은 객체 지향 프로그래밍에서 지켜야 하는 원칙이다.
SOLID 원칙을 지키지 않는다면 객체 지향 프로그래밍을 했다고 말 할 수 있을까?
어떻게 OCP 원칙을 지키고, 객체 지향 프로그래밍을 제대로 했다고 말 할 수 있을까?
바로 스프링이 제공하는 DI를 사용하면 가능하다.
스프링은 DI를 제공할 때 IoC Conainer
를 사용하여 의존성을 주입한다.
IoC Container?
IoC?
위키피디아의 내용에 따르면 다음과 같이 설명되어 있다.
inversion of control (IoC) is a design pattern in which custom-written portions of a computer program receive the flow of control from a generic framework.
in traditional programming, the custom code that expresses the purpose of the program calls into reusable libraries to take care of generic tasks, but with inversion of control, it is the framework that calls into the custom, or task-specific, code.
요약하자면
IoC란 프로그램에서 사용자의 작성 부분이 프레임워크
의 제어 흐름을 받는 디자인 패턴이고,
전통적인 프로그램에서 사용자의 코드가 작업을 처리하기 위해 라이브러리를 호출하지만, IoC에서는 프레임워크
가 사용자의 코드를 호출한다.
말 그대로 사용자가 라이브러리를 호출해서 어떠한 작업을 수행하는 것이 아닌, 주객전도가 되어 프레임워크가 사용자의 코드를 호출하여 작업을 수행하도록 한다.
위의 코드에선 기능을 변경하거나 확장하기 위해 기존의 코드를 변경해야 했다.
하지만 스프링을 사용하면 IoC Container가 관리하는 객체를 사용하고 객체 간의 의존성을 해결하여 우리가 직접 코드를 수정하지 않고 변경에 유연한 설계를 할 수 있도록 도와준다.
즉, 사용자가 직접 의존성 주입을 하는 게 아닌 프레임워크가 자동으로 의존성 주입을 하도록 하여, OCP 원칙을 지키게 할 수 있다!
스프링의 IoC를 사용하여 DI를 사용하면 다음과 같다.
@Component // 해당 에너테이션만 붙여주면 된다.
class A {
private B b;
public A(B b) {
this.b = b;
}
public void greet() {
System.out.println(b.say());
}
}
@Component // 해당 에너테이션만 붙여주면 된다.
class C implements B {
@Override
public String say() {
return "Hello";
}
}
끝이다.
그리고 다음과 같이 마법처럼 의존성 주입이 끝난 객체를 가져올 수 있다.
ApplicationContext ac = new AnnotationConfigApplicationContext(Application.class);
A a = ac.getBean("a", A.class);
a.hello(); // C의 로직 실행
만약 C가 아닌 D의 기능으로 변경하고 싶다면 다음과 같이 코드를 변경한다.
// @Component C는 더 이상 사용하지 않으므로 주석처리 하거나 지운다.
class C implements B {
@Override
public String say() {
return "Hello";
}
}
@Component // 해당 에너테이션만 붙여주면 된다.
class D implements B {
@Override
public String say() {
return "Hi";
}
}
ApplicationContext ac = new AnnotationConfigApplicationContext(Application.class);
A a = ac.getBean("a", A.class);
a.hello(); // D의 로직 실행
A를 실행하는 코드는 전혀 변경하지 않았다.
스프링의 DI를 사용하면 기존 코드의 변경 하나 없이 기능의 변경이 가능하다!
(물론 C의 코드는 수정해야겠지만, 수정하지 않을 경우 컴파일 에러가 발생하므로 컴파일이 되지 않는다.)
(해당 컴파일 에러는 @Qualifer 에너테이션 혹은 @Primary 에너테이션을 사용하여 해결이 가능하다.)
스프링에서 DI를 하는 방법은 매우 다양하고 복잡하다!
여기선 단순히ComponentScan
을 통한 자동 의존 주입을 사용했다.
@Configuration
을 사용한 수동 빈 등록도 있으니 찾아보길 바란다.
객체 지향 프로그래밍을 사용하며 더욱 효과적인 설계를 하기 위해 의존성 주입, 즉 DI를 사용해야 한다.
하지만 단순히 DI만 사용한다고 객체 지향 프로그래밍을 할 수 있는 것은 아니다.
DI를 사용하면서 구체적인 것에 의존하는 것이 아닌 추상화에 의존해야 효과적인 객체 지향 프로그래밍을 할 수 있다.
하지만 DI를 사용자가 직접 사용하게 된다면 SOLID 원칙을 지키기 어렵다.
따라서 스프링의 도움을 받아 IoC를 사용하여 SOLID 원칙을 지키며 더욱 객체 지향적인 프로그래밍을 할 수 있게 됐다.
즉, 스프링은 객체 지향 언어를 더욱 객체 지향답게 만들어주는 프레임워크라고 할 수 있겠다.
그밖에 스프링의 IoC 컨테이너에 빈을 등록하는 여러 가지 방법도 많으니 다른 방법을 찾아보길 바란다.
끝.