본문 바로가기

Spring Framework

[Spring] IoC/DI 컨테이너

반응형

이번 글은 스프링의 IoC/DI 개념에 대해 학습하고 정리하는 글을 적어보려고 합니다. 글의 전개 방향은 간단한 예제를 반복적인 리팩터링을 거치면서 IoC/DI의 의미와 중요성을 체감할 수 있도록 작성해 보도록 노력해 보겠습니다.

 

(이 글은 토비의 스프링 3.1 Vol.1 스프링의 이해와 원리 책의 내용을 기반하여 작성했습니다. 더 자세한 내용에 대해 궁금하신 분들은 책을 구입하시는 것을 추천드립니다!)

 

목차
    1. 예제 설명 
    2. 관심사의 분리 과정
    3. 제어의 역전(IoC)과 스프링의 IoC
    4. 의존 관계 주입(DI)

 

1. 예제 설명

이번 글에서 사용할 예제는 "사용자의 정보를 넣고 관리할 수 있는 기능을 관리" 하는 상황입니다.  코드를 통해 간단하게 소개하자면 다음과 같습니다. 

 

UserDao 클래스 예시

해당 예시에서는 사용자 정보를 관리하는 UserDao 클래스에서 사용자의 정보를 생성하고 조회하는 예시입니다. DB에 접근하기 위해 JDBC를 사용해야 하는데 JDBC를 사용하기 위해서는 다음과 같은 일련의 과정을 거쳐야 합니다.

1. DB 연결을 위한 Connection을 가져온다.
2. SQL을 담은 StateMent(또는 PreStatement를 만든다)
3. 만들어진 Statement를 실행한다.
4. 조회의 경우 SQL 쿼리의 실행 결과를 ResultSet으로 받아서 정보를 저장할 오브젝트에 옮겨준다.
5. 작업 중에 생성된 Connection, Statement, ResultSet 같은 리소스는 작업을 마친 후 반드시 닫아준다.
6. JDBC API가 만들어지는 예외를 잡아서 직접 처리하거나 메서드에 throws를 선언해서 예외가 발생하면 메서드 밖으로 던지게 한다. 

 

이 예시에서 주목할 점은 "UserDao의 관심사항"입니다. UserDao의 관심사항은 다음과 같습니다. 

1. DB와 연결을 위한 커넥션을 어떻게 가져올 것인가?
2. 사용자 등록을 위해 DB에 보낼 SQL 문장을 담을 Statement를 만들고 실행
3. 작업이 끝난 후 Connection, Statemnt, ResultSet과 같은 공유 리소스를 시스템에 반환

 

이 상황에서 만약 DB가 MySQL에서 Oracle로 변경되었다고 가정한다면 add, get 등 메서드에서 DB connection을 관리하는 모든 코드를 수정해야 하는 상황이 발생할 것이다. (add, get과 같은 메서드가 늘어난다면 DB가 변경될 때마다 수정되어야 할 코드의 양이 증가하겠네요...ㅜ)

 

변화는 대체로 한 가지 관심에 대해 일어나지만 그에 따른 작업은 한곳에 집중되지 않는 경우가 많기 때문에(위의 예제처럼) 관심사를 분리시켜 관심이 한 군데에 집중될 수 있도록 코드를 설계하는 것이 중요합니다.


2. 관심사의 분리 과정

 

위에서 가정한 예시에서 발생할 수 있는 대참사를 막기 위해 먼저 커넥션을 가져오는 중복 코드(첫 번째 관심사)를 분리할 필요가 있습니다. 가장 쉽게 생각할 수 있는 방법은 중복 코드 메서드를 추출하는 방법입니다. 

 

 

위의 코드처럼 수정했을 때 Connection을 가져오는 중복 코드를 추출할 수 있습니다. 

 

이번에는 더 나아가 변화에 반기는 (변화가 발생하는 것을 오히려 즐기는?) 방법으로 코드를 수정해보겠습니다. 예를 들어 N사, D사에 UserDao를 구매한다고 했을 때 N사, D사의 Connection 방식이 다르다고 가정해 보겠습니다. 또한 UserDao를 구매한 이후에도 UserDao가 계속해서 변경될 가능성이 크다고 합니다. 

 

우리는 이런 상황에서 기존 코드를 어떻게 수정할 수 있을까요?

 

첫 번째 방법은 상속을 통한 확장에 있습니다. 

UserDao : 상속을 통한 확장(NUserDao, DUserDao는 구현 클래스)

아래의 그림처럼 UserDao는 슈퍼 클래스로써 기본적인 로직의 흐름을 만들고 그 기능의 일부를 추상 메서드나 오버 라이딩이 가능한 메서드로 만든 뒤 서브 클래스에서 이런 메서드(getConnection 메서드)를 필요에 따라 구현해서 사용하도록 만드는 방법을 사용할 수 있는데 이런 방법을 디자인 패턴에서는 템플릿 메서드 패턴(template method pattern)이라고 합니다.

 

 

하지만 상속을 사용하는 방식에는 다음과 같은 한계가 있습니다. 

DB 커넥션을 생성하는 코드를 다른 DAO에서 적용할 수 없기 대문에 UserDao 외의 DAO 클래스들이 계속 만들어진다면 그때는 상속을 통해서 만들어진 getConnection()의 구현 코드가 매 DAO 클래스마다 중복될 수 있습니다.

 

이 문제를 해결하기 위해서 UserDao에서 DB Connection과 관련된 코드를 아예 다른 클래스 분리하는 방법을 사용할 수 있습니다. 이때 주의해야 할 점은 N사와 D사의 Connection은 구현의 차이가 있기 때문에 UserDao와 Connection을 관리할 새로운 클래스 사이에 긴밀한 관계가 생기지 않도록 하는 것입니다.

 

말이 조금 어려웠던 것 같은데 간단히 말하자면 Connection을 담당할 클래스는 확장 가능성을 염두에 두고 설계를 해야 한다는 것입니다. Connection을 담당할 클래스는 UserDao에서 관심사를 분리하는 것에는 성공했지만 UserDao와 긴밀하게 연결되어 있다면 (특정 Connection을 사용하도록 구현되어 있다면) N사와 D사의 요구사항을 모두 충족할 수 없을 것입니다. 

 

때문에 클래스를 분리하면서도 이런 문제를 해결하기 위해서는 추상적인 느슨한 연결고리를 만들어 주어야 하는데 자바가 추상화를 위해 제공할 수 있는 가장 유용한 도구는 인터페이스(Interface)입니다. 

 

인터페이스를 통해 접근하게 하면 설계 구현 클래스를 바꿔도 UserDao 입장에서는 신경 쓰지 않아도 됩니다. UserDao는 인터페이스를 통해 사용할 기능에만 관심을 가지면 되지, 그 기능을 어떻게 구현했는지는 관심을 둘 필요가 없어지게 됩니다. 

 

 

하지만 이 코드 역시 치명적인 문제점이 존재한다. UserDao의 모든 곳에서 인터페이스를 이용하게 만들어서 DB 커넥션을 제공하는 클래스에 대한 구체적인 정보는 모두 제거했지만 초기에 한번 어떤 클래스의 오브젝트를 사용할지를 결정하는 생성자의 코드는 제거되지 않고 남아있습니다. 

 

이 문제를 해결하기 위해 주목해야 할 점은 UserDao에 아직 제거하지 못한 관심사입니다. new DConnectionMaker()라는 짧은 코드는 독립적인 "관심사"를 가지고 있는데, UserDao에서 어떤 ConnectionMaker 구현 클래스의 "오브젝트"를 이용할지를 결정하는 것입니다.

 

UserDao 오브젝트가 동작하려면 특정 클래스의 오브젝트와 관계를 맺어야 합니다. 인터페이스에는 자체적인 기능이 없기 때문에 결국 특정 클래스의 오브젝트와 관계를 맺어야 합니다. 

 

하지만 중요한 점은 클래스 사이에 관계가 만들어지는 것이 아니고, 단지 오브젝트 사이에 다이내믹한 관계가 만들어지는 것입니다. 

 

오브젝트 사이의 관계는 코드에서 특정 클래스를 전혀 알지 못하더라도 해당 클래스가 구현한 인터페이스를 사용했다면, 그 클래스의 오브젝트를 인터페이스 타입으로 받아서 사용할 수 있습니다.

 

때문에 이제 UserDaoTest라는 클래스를 새로 만들어서 UserDao가 사용할 ConnectionMaker 클래스를 선정하는 책임을 전가하는 방법으로 수정할 수 있습니다.

 

위의 도식화를 구현한 코드는 다음과 같습니다. 

 

 

앞에서 사용했던 상속을 통한 확장 방법보다 더 깔끔하고 유연한 방법으로 UserDao와 ConnectioMaker 클래스를 분리하고 서로 영향을 주지 않으면서도 필요에 따라 자유롭게 확장할 수 있는 구조로 탈바꿈하게 되었습니다. 

 

지금까지의 반복적인 리팩터링을 거쳐 왔던 이유는 개방 폐쇄 원칙을 위해서 였습니다. 개방 폐쇄 원칙이란 클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다는 객체 지향 설계 원리 중 하나입니다. 우리는 지금 까지 코드를  리팩터링 하면서 개방 폐쇄 원칙을 항상 생각하며 코드를 설계해야 하는 것이 왜 중요한지에 대해 생각해볼 수 있었습니다. 

 

이제부터 나올 내용은 UserDaoTest에 대해 이야기해보려고 합니다. 사실 UserDaoTest는 지금 까지 우리가 설계한 코드가 정상적으로 동작하기 위한 "관심사"를 담당하던 코드였습니다. 하지만 지금은 UserDao가 ConnectionMaker 인터페이스를 구현한 특정 클래스로부터 완벽하게 독립할 수 있도록 UserDaoTest가 그 수고를 담당하게 되었습니다. 

 

지금 까지 우리는 관심사를 분리하는 작업을 해왔기 때문에 UserDaoTest에서 UserDao와 ConnectionMaker 클래스의 오브젝트를 만드는 것과, 그렇게 만들어진 두개의 오브젝트가 연결되어 사용될 수 있도록 관계를 맺어주는 새로운 관심사를 분리해 보려고 합니다. 


3. 제어의 역전(IoC)과 스프링의 IoC

분리 시킬 기능을 담당할 새로운 클래스 DaoFactory를 만들어 보려고 합니다. 이 클래스의 역할은 객체의 생성 방법을 결정하고 그렇게 만들어진 오브젝트를 돌려주는 것인데 이런 일을 하는 오브젝트를 흔히 "팩토리"라고 합니다 .

 

위의 코드처럼 DaoFacotory는 오브젝트 생성 방법을 결정하고 만들어진 오브젝트를 돌려주는 역할을 담당합니다. 

 

아래의 도식화는 DaoFactory를 추가한 구조입니다. 

오브젝트 팩토리를 활용한 구조

여기서 놀라운 사실은 이미 우리는 IoC에 대한 개념을 적용하고 있었습니다. 여기서 잠깐 IoC라는 용어를 정리하고 가겠습니다. IoC는 Inversion of Control의 약자로 제어의 역전 이른 뜻을 가지고 있습니다. 

 

그럼 여기서 궁금한 점은 "제어"는 어떤 것을 "제어"라고 부를까요? 기존의 코드를 되돌아보겠습니다. 모든 오브젝트(UserDao, ConnectionMaker 등..)가 능동적으로 자신이 사용할 클래스를 결정하고, 언제 어떻게 그 오브젝트를 만들지를 스스로 관장하는 구조였습니다. 아래의 코드처럼 말이죠.

 

 ConnectionMaker connectionMaker = new DConnectionMaker();

 

즉, 모든 종류의 작업을 사용하는 쪽에서 제어하는 구조 였습니다.

 

제어의 역전이란 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하고 생성하지는 않는다는 점에서 다릅니다. 또한 자신도 어떻게 만들어지고 어디서 사용되는지를 알 수 없죠. 아래의 코드 처럼 말이죠.

 

public UserDao userDao(ConnectionMaker connectionMaker) {
	this.connectionMaker = connectionMaker;
}

 

자연스럽게 관심을 분리하고 책임을 나누어 유연하게 확장 가능한 구조로 만들기 위해 DaoFactory를 도입하는 과정이 IoC를 적용하는 작업이었던 것입니다. 

 

이제 스프링에서 IoC에 대해 이야기 해보려고 합니다. 스프링에서는 스프링이 제어권을 가지고 직접 만들고 관계를 부여하는 오브젝트를 빈이라고 합니다. 빈의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트 (DaoFactory같은 오브젝트)를 빈 팩토리라고 합니다. 

 

보통 빈 팩토리 보다는 이를 좀더 확장한 애플리케이션 컨텍스트(Application context)를 주로 사용합니다. 빈 팩토리라고 말할 때는 빈을 생성하고 관계를 설정하는 IoC의 기본 기능에 초점을 맞춘 것이고, 애플리케이션 컨텍스트라고 말할 때는 애플리케이션 전반에 걸쳐 모든 구성요소의 제어 작업을 담당하는 IoC 엔진이라는 의미가 좀 더 부각된다고 보면 될것 같습니다. 

 

다음의 도식화는 애플리케이션 컨텍스트가 동작하는 방식입니다. 

Application Context가 동작하는 방식

애플리케이션 컨텍스트는 DaoFactory 클래스를 설정정보로 등록해두고 @Bean이 붙은 메서드 이름을 가져와 빈 목록을 만듭니다. 클라이언트가 애플리케이션 컨텍스트의 getBean() 메서드를 호출하면 자신의 빈 목록에서 요청한 이름이 있는지 찾고, 있다면 빈을 생성하는 메서드를 호출해서 오브젝틀르 생성시킨후 클라이언트에게 돌려줍니다. 

 


4. 의존관계 주입(DI)

 

스프링의 IoC 기능의 대표적인 동작 원리는 주로 의존관계 주입이라고 불립니다. 스프링이 여타 프레임워크와 차별화돼서 제공해주는 기능은 의존관계 주입이라는 용어에서 비롯됩니다. 

 

여기서 의존관계란, A(클래스 또는 모듈)이 B에 의존하고 있을 때 B가 변한다면 A에 영향을 미치는 관계를 의미입니다. (예를 들어 B에서 정의된 메서드를 A가 사용하고 있는 경우 B의 메서드가 변경됬을때 A가 영향을 받는 것처럼요!)

 

의존 관계 주입은 구체적인 의존 오브젝트(DConnectionMaker)와 그것을 사용할 주체(클라이언트 = UserDao)를 런타임시에 연결해 주는 작업을 의미합니다. 

 

의존 관계 주입의 3가지 충족 조건은 다음과 같습니다. 

 

1. 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. (인터페이스에만 의존해야함.)
2. 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재(관계 설정 책임을 가진 코드를 분리해서 만들어진 오브젝트)가 결정
3. 의존 관계는 사용할 오브젝트에 대한 래퍼런스를 외부에서 제공해줌으로써 만들어진다.

 

위에서 봤던 IoC가 적용된 코드가 곧 DI를 적용한 코드의 예라고 볼 수 있습니다. DI는 자신이 사용할 오브젝트에 대한 선택과 생성 제어권을 외부로 넘기고 자신은 수동적으로 주입받은 오브젝트를 사용한다는 점에서 IoC 개념에 잘 들어맞습니다. 

 

지금 까지 우리는 코드를 지속적으로 리팩토링 하면서 관심사를 분리하고 알게 모르게 IoC/DI 개념에 대해 이해할 수 있었습니다. 관심사를 분리하고 IoC/DI 개념을 적용하는 것은 변화에 닫혀있고 확장에 열려있는 개방폐쇄 원칙을 실현할 수 있는 좋은 도구 라고 생각되는것 같습니다. 앞으로의 코드를 설계 함에 있어서 오늘 정리한 내용을 항상 상기하면서 코드를 설계해보려고 합니다.  

 

예시가 많아 글의 길이가 길어져서 스프링에서 DI를 사용하는 구체적인 예시에 대한 글은 따로 적도록 하겠습니다. 지금까지 내용을 일고 잘못된 내용이 있다면 댓글에 남겨주시면 감사드리겠습니다!

반응형

'Spring Framework' 카테고리의 다른 글

[Spring] PSA(Portable Service Abstraction)  (1) 2022.11.05
[Spring] 예외를 처리하는 방법  (0) 2022.11.01
[Spring] JdbcTemplate  (0) 2022.10.22
[Spring] TDD란?  (0) 2022.10.14
[Spring] Gradle vs Maven  (2) 2022.09.25