왜 스프링을 쓰는 걸까??(IoC, DI)

요약

의존 역전과 유연함을 위해서 DI하도록 객체를 설계하는 경우,
매번 해당 객체를 사용할 때마다 필요한 객체를 찾아서 주입해줘야 한다.
스프링 프레임워크는 DI를 대신해주는 역할을 한다.

유연한 설계를 위해서

객체지향 설계 원칙 중 SOLID 원칙이 있다.
그 중 D에 해당하는 의존 역전 원칙은 상위 모듈이 하위 모듈의 구현에 의존하지 말고, 상위 모듈이 정한 추상 타입에 하위 모듈들이 의존해야 한다는 규칙이다.

쉽게 말하면 상위 기능에서 정한 인터페이스나 추상 클래스 타입으로 하위 클래스들이 협력해야 한다는 의미이다.
이렇게 하면 상위 모듈은 하위 모듈의 내부 구현이 달라져도 상관하지 않게 되고 기능이 확장에 열리게 된다.

예시로 좀 더 알아보자.

1
2
3
4
5
6
7
public class FileReader {

public String readNames() {
//여기서 파일을 읽는 로직 작성됐음을 가정
return "Read some Files";
}
}

FileReader에서 파일을 읽어서 문자열을 반환하는 책임을 한다고 하자.
이 클래스와 협력하는 NamePrinter를 보자.

1
2
3
4
5
6
7
public class NamePrinter {

public void printNames() {
FileReader fileReader = new FileReader();
System.out.println(fileReader.readNames());
}
}

FileReader 객체를 만들어서 readName을 호출해서 출력한다!

그런데 이름을 읽어오는 방법이 파일만 있을까?

그렇다. 파일만 이름을 저장하라는 법은 없다.
이번엔 DBReader를 만들어보자.

1
2
3
4
5
6
7
public class DBReader {

public String readNames() {
//DB에서 이름을 읽어오는 로직이 존재한다고 가정
return "Names From DB...";
}
}

이제 DBReader를 만들었어도 아직 DB에서 읽은 이름을 출력할 수 없다.
왜냐면 NamePrinter는 FileReader에 의존하고 있어서 파일에서 읽어온 이름만 출력할 수 있다.
그러면 NamePrinter의 FileReader를 DBReader로 바꾸면?
읽는 방식이 바뀌었다고 상위 기능인 출력 기능을 담당하는 NamePrinter를 수정해야 되면 문제가 있다.

변경이 많은 곳을 추상화

이름을 읽어오는 곳이 경우의 수가 다양하니까 인터페이스로 추상화하자.

1
2
3
4
public interface NameReader {

String readNames();
}

이제 FileReader와 DBReader가 해당 인터페이스를 구현해서 의존하도록 하자.

1
2
3
4
5
6
7
public class FileReader implements NameReader {

public String readNames() {
//여기서 파일을 읽는 로직 작성됐음을 가정
return "Read some Files";
}
}
1
2
3
4
5
6
7
public class DBReader implements NameReader {

public String readNames() {
//DB에서 이름을 읽어오는 로직이 존재한다고 가정
return "Names From DB...";
}
}

아직 할 일이 남았다.
상위 모듈인 NamePrinter도 추상타입인 NameReader에 의존하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
public class NamePrinter {

private final NameReader nameReader;

public NamePrinter(NameReader nameReader) {
this.nameReader = nameReader;
}

public void printNames() {
System.out.println(nameReader.readNames());
}
}

여기서는 생상자의 인자로 의존하도록 했다.
인자로 구현체를 전달해주어서 원하는 방식으로 이름을 읽어들이면 된다!

물론 의존성 주입은 생성자로만 하는 건 아니다. Setter 메서드로도 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
public class NamePrinter {

private NameReader nameReader;

public void printNames() {
System.out.println(nameReader.readNames());
}

public void setNameReader(NameReader nameReader) {
this.nameReader = nameReader;
}
}

이제 실행해보자.

1
2
3
4
5
6
7
8
void dependencyInjection() {
NamePrinter namePrinter = new NamePrinter();
namePrinter.setNameReader(new FileReader());
namePrinter.printNames(); //File에서 읽은 이름 출력

namePrinter.setNameReader(new DBReader());
namePrinter.printNames(); //DB에서 읽은 이름 출력
}

의존성을 주입하는 방식으로 변경하니, NamePrinter는 어떤 곳에서 이름을 읽어오던 이름을 출력할 수 있게 됐다!

좋은 설계를 쉽게 사용하기 위해서

DI를 활용해서 유연하고 확장하기 좋은 설계를 만들었다.
그런데 조금 걸리는게 있다.

클라이언트가 PrintName을 통해 이름을 출력하고 싶으면 의존성을 주입해줘야 한다.
setter를 쓰던 생성자를 통해 주입을 해주던 매번 해줘야 된다는 의미다.

1
2
3
4
5
6
7
//생성자 주입을 하는 경우
NamePrinter namePrinter = new NamePrinter(new DBReader());
namePrinter.readNames();
//세터 주입을 하는 경우
NamePrinter namePrinter = new NamePrinter();
namePrinter.setNameReader(new FileReader());
nameReader.readNames();

흠. 꽤 번거롭다. 지금은 두개의 객체가 협력하는 책임이라 그나마 봐줄만 하지만,
만약 추상화 수준이 높은 객체의 책임을 실행하려면 수많은 객체를 주입해줘야 할 것이다.

이 문제를 스프링이 해결해줄 수 있다.
스프링은 객체를 생성하는 과정을 프로그래머가 아닌 자신이 한다.
그리고 만들어진 객체를 스프링이 관리한다.

예제를 통해 확인하기

스프링을 통해 객체를 관리하려면 스프링에게 어떤 객체를 관리할 것인지 알려줘야 한다.
자세히보면 @Component 어노테이션을 붙였다.
이 객체를 만들고 관리하는 일은 스프링이 해달라는 표시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class NamePrinter {

private NameReader nameReader;

public NamePrinter(NameReader nameReader) {
this.nameReader = nameReader;
}

public void printNames() {
System.out.println(nameReader.readNames());
}

public void setNameReader(NameReader nameReader) {
this.nameReader = nameReader;
}
}

근데 잠깐만, 스프링이 객체를 생성하려면 NameReader 객체가 필요하다.(생성자가 그것 뿐이니,,)
그러면 NameReader 객체는 어디서 가져오는건가?!

1
2
3
4
5
6
7
8
@Component
public class FileReader implements NameReader {

public String readNames() {
//여기서 파일을 읽는 로직 작성됐음을 가정
return "Read some Files";
}
}

이렇게 필요했던 NameReader의 구현체에도 @Component 어노테이션을 붙여서 스프링이 이 객체를 생성해서 관리하게 하면 된다!

즉!

일단 스프링 어플리케이션을 실행하면 먼저 컴포넌트 스캔을 실행한다.
컴포넌트 스캔은 @Component가 붙은 클래스를 객체로 만들어 IoC 컨테이너에 올려둔다.
이때 중요한 건! 만약 DI 받는 객체가 있는 경우, 스프링이 IoC 컨테이너에 있는 객체일 경우 자동으로 주입해준다!!

반면 생성자로 주입받으려는 객체가 IoC 컨테이너에 없는 경우 컴파일에러를 일으킨다.

한 가지 궁금한 점!

아까 DI를 활용하면 유연하고 확장 가능한 구조를 얻을 수 있다고 했다.
하지만 스프링을 통해 아까 예제에 있던 NameReader의 두 구현체 모두에게 @Component를 붙여 관리하도록 하면,
컴파일 에러가 생긴다. 즉 NamePrinter가 어떤 구현체를 주입해서 객체를 생성해야 할 지 결정내리지 못하는 문제가 생긴다.

이 문제는 추후 더 알아보도록 하자!

Share