제목 : IoC, DI, DIP 들은 무엇인가? - Spring
부제목 : 코드로 보는 제어의 역전
혹시 궁금한 점이 있으시다면, 언제든지 질문 주시면 환영합니다
- Email :
rhdwhdals8765@gmail.com
이 글을 작성하는 이유란
제어의 역전이라는 단어는 다양한 프로그래밍 언어를 초월하여,
Well Made 된 Back-End 프레임워크에 포함되어 있는 개념이다.
제어의 역전이라는 의미는 우리가 작성하는 코드를 프로그램에서 관리하도록 만든다는
그러한 추상적인 개념 또한 포함하지만,
정작 프로그래밍 언어를 많이 접하지 못하고, Spring 을 접했을 때는 이해하지 못했다.
Spring, 즉, Spring Boot 는 매우 방대한 라이브러리가 포함된 프레임워크이다.
내가 NodeJS 분야에서 NestJS 로 프로젝트를 2 번 실행했었다.
그 당시에는 필요한 라이브러리를 따로 어디서 다운받아야 하는지 외워야 하는 상황이 있었는데,
Spring 은 검증되지 않은 Third-Party 제품(프로그램)이 대부분 필요가 없으며,
전문화 된 조직이 운영하는 검증된 라이브러리로 거의 모든 것을 해결 할 수 있다.
이는 전문화 된 조직이나 특정 단체가 "도메인"(Domain) 을 운영하고,
이 도메인을 기반으로 Maven Registry 에 업로드하기 때문인데,
이 때문에 NPM 과는 비교할 수 없는 오픈소스 업로드 난이도가 존재한다.
그러나, 이러한 난이도 장벽이 존재하여 역설적으로 Maven 라이브러리의 신뢰도는 굉장히 높다.
조각조각 나뉘어져 있는 기능들을 어느 정도는 외워 의존성을 적용시켜야 하는 nodejs 와는 다르게,
방대한 기능들을 Package 하여 다운로드 하는 것이 NodeJS 와 Java 프레임워크의 차이점이다.
다시 주제로 돌아와서,
제어의 역전이라는 개념 자체에 집중 할 필요가 있다.
제어의 역전이라는 것이 무엇일까?
프로그래머가 된 우리들은 모두 Spring 및 백엔드 프레임워크를 사용하지 않더라도,
"제어의 역전"(Invertion of Control) 을 수없이 경험했다.
Spring 에서의 제어의 역전은,
Spring 프로젝트를 실행하며 생성되는 "스프링 컨테이너" 에,
우리가 사용할 비즈니스 로직이나, 내가 직접 만든 Bean or Component 를 위임하여
대신 제어하도록 맡기는 행위를 의미한다.
Spring 에서의 제어의 역전이 어떤 역할을 하는가? 한다면 여러 대답이 생각나기는 하는데,
- Spring 사용자가 구축해야 할 비즈니스 논리에만 집중하도록 도와준다.
- 작성된 비즈니스 논리(Stateless) 클래스가 재사용 될 수 있게 만들어 준다.
- 그렇게 만들지 않으면 1 번의 요청마다 수없이 많은 객체를 재생성 하게 되는 대참사가 벌어진다.
- 코드 자체에서도 굉장한 이점이 있는데, 즉, 관심사에 따라서 Logic 을 분리할 수 있다.
- 추상화시키는 것이 왜 좋냐면, 우리가 어떤 DB 를 선택 할 줄 미리 알고 있을까?
- 또한, 우리가 선택한 DB 를 영원히 사용하게 될까? - 중간에 바꿀 수도 있음.
- 기능이 추가되거나 제거 될 때, 수십, 수백번의 수정을 미리 예방하는 중요한 개념
- 맨 처음 말했듯, 비즈니스 논리에만 집중할 수 있게, Annotation 을 통해 메타데이터 프로그래밍을 이루어 놨다.
맨 처음 제어의 역전이 "주" 를 이루는 라이브러리 혹은 프레임워크를 사용한다면,
이해하기가 매우 어려울 것이라고 판단하는데,
가장 중요한 프로그래밍 이라는 개념을 배우기 위해 우리가 하는 초기 행위에 정답이 있다고 생각한다.
어떠한 프로그래밍 언어를 선택해도, "우리가 제어하는" Logic 을 작성하기 때문이다.
이건 당연한 공부 행위이다.
역설적이게도, IoC 라는 개념 자체가 프로그램에 적용되면, 그 코드를 분해해서 이해하는 것은
난이도가 정말 높은 행동이다.
TypeScript 를 통해 배웠었던 이전의 IoC 기능
나는 JavaScript(ECMAScript) 에 적극적으로 도입된 Decorator 기능을 통해,
메타프로그래밍이 어떤 것인지, 어떻게 적용되는지 직접 글을 작성했었다.
단순히 이해하고 넘기는 것이 아니라, 그나마 이해가 되도록 글로 만들어내는 것은,
절대로 쉽지 않은 작업이라고 단언할 수 있다.
위의 글은 IoC(제어의 역전) 이라는 개념에 대해서 상세하게 다루지는 않고,
TypeScript 의 Decorator 라는 기능은 무엇인지,
TypeScript 가 어떻게 JavaScript 로 트랜스파일 하는지 알기 위해 작성한 글이다.
이 과정에서 TypeScript 의 Decorator(데코레이터) 나,
Java 의 Annotation(애너테이션) 의 기능이 매우매우 흡사하다는 것을 알게 되었다.
물론, 디테일하게 보면 기능적인 부분이 조금씩은 다르다.
Spring 이 IoC 를 위한 컨테이너를 만드는 간단한 과정
Spring 은 개발자들이 비즈니스 논리, 및 관심사 분리 등등 에 집중하기 위해
IoC, 즉, 개발자가 선언한 클래스를 담아두는 컨테이너를 생성한다.
프로그래머들은 프로젝트에 사용될 논리를 Annotation(@) 을 작성하여
Spring 컨테이너가 인식 할 수 있도록 만들어 놓는데,
흔하게
@Controller@Service@Repository
가 있으며,
@Configuration@Bean@Component@Scope
등등 이 존재한다.
만약에 Java 에서 제공하는 Default(기본 제공) 애너테이션을 알고 싶다면,
이곳을 참조하는 것을 추천한다.
Spring 은 내가 위에서 따로 예시로 적어둔 Annotation 들을 읽고,
우리가 만들어 둔 클래스, 혹은 메서드, 변수 단위의 "메타데이터" 를 읽는다.
즉, 애너테이션 선언을 통해 Spring 에게 내 코드에 대한 전반적인 정보를 제공하는 것이다.
Spring 은 애너테이션과 함께 작성된 코드가 어떤 식으로 생성되어야 하는지,
어떤 "시점" 에 생성되어야 하는지 분석하고,
또한 "어떤 정보" 를 주입(DI - Dependency Injection) 해야 하는지 분석한다.
그리고 Spring Container 는, @Scope 를 통해 생명주기가 직접 주어지지 않은 이상,
Singleton 으로 분석한다.
이 말은, 스프링 서버가 가동하고 나서, "꺼질 때 까지" 1 개로 공유한다는 의미이다.
즉, 우리가 특정 기능을 사용하기 위해 Spring 프레임워크 안에서
@Component
public class TestComponent {
public String returnSomeText() {
return "테스팅 컴포넌트를 사용했습니다.";
}
}
/** 파일이 분리되어 있거나, 혹은 함께 있거나. */
@RestController
public class Test1Controller {
private final TestComponent component;
@Autowired
public Test1Controller (TestComponent component) {
this.component = component;
}
@GetMapping("/test1")
public testRoute() {
return component.returnSomeText();
}
}
/** 다른 파일에 분리된 컨트롤러라고 가정 */
@RestController
public class Test2Controller {
private final TestComponent component;
@Autowired
public Test2Controller (TestComponent component) {
this.component = component;
}
@GetMapping("/test2")
public testRoute() {
return component.returnSomeText();
}
}
아직 인터페이스도 구축하지도 않은 아주 간단한 위의 코드가
Spring 에서 작성되고, 실행되었다고 가정 해 보자.
Spring 코드는 위의 코드를 가져와서 분석 할 때,
각 Controller 클래스의 생성자를 실행 할 때,
TestComponent 는 어떠한 형태로 주입될까?
Spring 은 실행 되면서,
애너테이션이 붙은 클래스들을 단순한 메타데이터의 형태로 "저장되어 있다" 라는 가정으로,
controller 클래스 내부에 @Autowired 가 있는 것을 발견한다.
해당 생성자의 인자 메타데이터를 가져오는데,
어라? TestComponent 구현체 or 클래스 타입을 필요로 한다.
이 때, Spring Container 는 TestComponent 를 검색한다.
Spring 컨테이너는 TestComponent 의 객체 주소를
각 컨트롤러의 생성자에 "인자로" 넘겨 준다.
여기서 이해가 안 갈 수도 있는 부분
우리가 어떤 프로그래밍 언어를 사용하던, 인자를 넘겨준다는 행위는,
직접적으로 코드를 작성하여 객체를 생성하거나, 메서드를 실행한다는 의미이다.
그런데, Spring 에서는(특정 상황을 제외)
우리가 Controller 클래스에 인자로 TestComponent 를 전해줘야 할 것 같지만,
우리는 그렇게 하지 않았다. 아니, 그런 적이 없다.
우리가 한 것은, 단지 생성자, 혹은 클래스 의존성 주입 객체(DI) 인자에,
@Autowired 를 선언 해 준 것 뿐이다.
Spring 에서는 마법이라도 부린 듯, 매우 간편하게 필요한 객체를 "보이지 않는 곳" 에서
주입(DI - Dependency Injection) 한 것이다.
Spring 은 자신만의 Container 에서 클래스들을 보관하고 있다.
그리고, @Autowired` 를 보고, 선언된 메타데이터를 읽어
이것이 "변수" 인지, "생성자" 인지 구분한다. (두 상황 모두 거의 비슷함)
그리고 필요로 하는 Type(Class), or Interface 를 읽어
Spring Container 가 보존하고 있는 "객체의 주소" 를 할당한다.
그렇게 되면, 선언된 2 개의 컨트롤러 객체는
따로따로 생성된 TestComponent 를 할당받는 것이 아니라,
이미 Spring Container 에 등록된 TestComponent 를 "공유"하는 것이다.
Autowired 가 그래서 의미하는 것은,
Spring Container 에 등록된 클래스 객체를,
개발자가 "코드로 직접" 넣어주는 것이 아니라,
Spring 이 이를 인식하여 자동으로 "의존성 주입" 을 해주는 것을 의미한다.
Autowired, 즉, 자동으로 연결해 주는 것이다.
두 컨트롤러에 요청을 넣는다면,
TestComponent 라는 1 개의 단일 객체가 사용된다.
매우 중요한 개념, DIP 란?
내가 Spring Container 가 어떻게 IoC, 및 DI 를 하는지에 대한 예시는
간략하게 보여주었다고 생각하지만, 아주 중요한 점을 간과한 예제이다.
그건 바로 DIP 라는 개념인데,
Depencency Inversion Principle,
즉, 의존 관계 역전 이라는 매우매우 중요한 기능이 빠져있다.
이게 무엇일까?
개념적으로 접근하자면, 인터페이스를 통해서
명확한 의존관계가 설정되는 것을 "최대한 피하는" 로직이다.
"의존 관계 역전."
이해하면 매우 간략하며 잘 설명된 단어라고 생각이 들지만,
정확히 이해하지 않으면 뭔 소린지 알기 힘들다.
예를 들어, Spring 프로젝트와 함께 사용될 Database 가,
개발 상황에서 SQLite, 프로덕션에서는 MySQL 이 사용된다고 가정 해 보자.
(@Repository 를 사용하면 헷갈릴 것 같아서 @Component 로 대체하겠스빈다.)
@Component
public class SQLite {
public String whatDB() {
return "현재 데이터베이스는 " + getClass.getSimpleName();
}
}
@Component
public class MySQL {
public String whatDB() {
return "현재 데이터베이스는 " + getClass.getSimpleName();
}
}
/** 개발 중이라고 가정 */
@RestController
public class TestController {
private final SQLite db;
@Autowired
public TestController (SQLite db) {
this.db = db;
}
@GetMapping("/current-db")
public currDB() {
return db.whatDB();
}
}
만약에 .../current-db GET 요청을 넣는다면?
현재 데이터베이스는 SQLite
위의 코드를 Spring 에서 실행한다면, 오류는 없이 잘 실행 될 것이다.
그러나, 이는 개발 생산성에 있어 중대한 오류를 낳는다.
위 예시에서는 "개발 상황이니까", TestController 에서 SQLite 를 직접 사용했다.
따라서, 개발 DB 로 사용될 Database 를 사용하게 되는 것이다.
그러나, 데이터베이스는 Spring 프로젝트 자체로 보았을 때,
매우 특정된 상황에서만 사용되는 것이 아니라, 매우 광범위한 장소에서 사용된다.
수십, 수백개의 파일이 SQLite 라는 선언에 "의존" 하고 있는 것이다.
상상해보자
수십, 수백개의 서비스가 "개발" 중이라서 SQLite 를 선언했다고 가정하고,
이제 프로덕션으로 내놓기 위해 MySQL 을 사용한다.
이제, 이를 만든 개발자는 수십, 수백개의 파일에 걸쳐 "모든" 선언을 MySQL 로 바꾸어야 한다.
정말 상상하기 어려울 정도의 어지러운 상황이 펼쳐지는 건데,
이러한 상황이 "왜?" 나오게 되었을까?
이는 종속성 적인 시각으로 보지 않았을 때 나오게 되는 것인데,
만약에 "사용하는 모듈" 로 보게 된다면,
flowchart TB
TestController("TestController")
TestController --> SQLite
로 해석 할 수 있다.
즉, 상위 모듈이 하위 모듈을 참조하는 형태가 된다.
그러나, 의존적 시각 으로 바라보자 :
flowchart TB
SQLite
com1("컴포넌트1")
com2("컴포넌트2")
com3("컴포넌트3")
com4("컴포넌트4")
com5("등등 수십개")
SQLite <--> com1 & com2 & com3 & com4 & com5
데이터베이스에 접근하는 컴포넌트가 "수십 개" 이며, 추상화(interface) 를 사용하지 않았다면,
우리가 "직접적으로" 선언한 모든 데이터베이스 객체를 "전부" MySQL 로 바꿔야 한다.
대참사를 막는 interface 의 중요성.
다시 언급하자면,
DIP 는 상위 모듈이, 하위 모듈에 접근하여
특정 의존성을 형성되는 것을 "방지하는" 원칙이다.
자주 사용되며, 특정 상황에 따라 변할수도 있는 "관심사"가 있다면,
해당 영역을 어떻게든 추상화 시켜야 한다는 것이다.
DIP 원칙을 지킨 코드로 변경 해 보자면,
// 추상화 레벨로 접근 할 수 있도록 인터페이스를 선언한다.
public interface MainDB() {
public String whatDB();
}
@Component
@Profile("dev")
public class SQLite implements MainDB {
@Override
public String whatDB() {
return "현재 데이터베이스는 " + getClass.getSimpleName();
}
}
@Component
@Profile("prod")
public class MySQL implements MainDB {
@Override
public String whatDB() {
return "현재 데이터베이스는 " + getClass.getSimpleName();
}
}
@RestController
public class TestController {
// 인터페이스 타입을 의존
private final MainDB db;
// 인터페이스로 인자를 받기
@Autowired
public TestController (MainDB db) {
this.db = db;
}
@GetMapping("/current-db")
public currDB() {
return db.whatDB();
}
}
위의 코드의 변경점을 통해, 데이터베이스를 접근하기 위해서 interface 를 사용한다.
즉 추상화를 통해서 모듈 자체의 Level 을 맞춘 것이다.
@Profile 은 Spring 의 개발 혹은 제품 상태에 따라서
MainDB 가 선언되는 코드는 "이 상황에서" 해당 객체가 주입되도록 만든 것이다.
데이터베이스에 접근하는 두 객체, SQLite, MySQL
두 객체 모두 MainDB 라는 인터페이스를 구현했다.
두 객체는 Spring Container 에게 Scanning 되는 과정에서 등록되며,
서비스 객체에서 단순 MainDB 를 조회하게 될 때,
상황에 맞게 '알아서' 적절한 MainDB -"구현체"- 를 전달 해 준다.
이를 그래프로 표현 해 보자면,
flowchart TB;
SQLite
MySQL
MainDB("MainDB - 인터페이스")
SQLite & MySQL -.-> MainDB
com1("컴포넌트1")
com2("컴포넌트2")
com3("컴포넌트3")
com4("등등 수십개")
MainDB <--> com1 & com2 & com3 & com4
위의 그래프대로 형성된다.
위의 그래프가 효과적으로 보여준 것 - 관심사의 분리
이제 DB 전담 interface, 그리고 이를 구현한 DB class 덕분에
Spring 객체들은 데이터베이스에 접근하기 위해서 구체적으로 Class 명을 선언하지 않아도 된다.
그렇기 때문에, 우리는 때에 맞춰 데이터베이스를 갈아끼우기만 하면 된다.
기존 코드를 수정 할 필요가 없으며, 그저 코드 몇 줄로 끝나는 상황이 되었다.
그렇다면, 이로 인해 효과적으로 드러난 점은 무엇일까?
그건 바로, "관심사의 분리" 이다.
나는 방금 "데이터베이스" 라는 관심사를 interface 로 추상적으로 구분 한 것이다.
백엔드라는 분야에서, 접근해야 할 기능과 infra 들은 수없이 많다.
하나의 infra 가 몇 가지 기능을 모두 담당하다가,
성능을 위해 또 다른 infra 에게 또 다른 기능을 위임할 수도 있다.
사용자의 이미지를 저장하는 과정에서,
날 것의 데이터베이스의 blob 형태를 이용하다가,
AWS S3 를 사용하여 요청 형태로 저장하는 방식을 선택 할 수도 있다.
기존의 기능을 다른 기능으로 교체하는 행동은 매우 흔하다.
또한, 이러한 기능을 서로 복잡하게 연계하여 사용하는 일은 비일비재하다.
관심사를 추상화 시키지 않고 사용하게 된다면,
서로 엮여있는 코드를 풀기 매우 힘들어지는데,
이는 내가 이전에 부트캠프에서 팀 프로젝트를 수행했을 때,
백엔드 담당을 맡으면서 느꼈었던 부분이었다. (NestJS 를 사용했었을 때 경험입니다)
요약하자면,
IoC
"제어의 역전" 을 의미하며,
Spring 도메인에서는 Spring Container 에 저장되는 singleton Scope 객체를 의미한다.
singleton 은, 서버가 꺼질 때 까지 없어지지 않는 객체를 의미하며, 단일 객체이다.
물론, request 와 같은 요청 생명주기 기반의 객체도 Spring Container 에 담긴다.
사용자는 사용되는 객체의 생명주기를 코드로 직접 작성하는 것이 아니라,
그 객체들의 생명주기를 Spring Container 가 관리한다는 것이
IoC 를 나타낼 수 있는 구체적인 나의 생각이 아닐까 판단된다.
DI
"의존성 주입" 을 의미한다.
컨트롤러는 주로 서비스를 요구하며,
서비스는 주로 "또다른 서비스" 나, DB 접근 객체를 요구한다.
이 과정에서 객체에 선언되는 객체는
@Autowired 를 선언하여 선언한 객체에 "주입" 한다.
이러한 의존성 주입을 "Spring 이 대신" 한다는 것이 중점이다.
DIP
"의존성 역전 원칙" 을 의미한다.
유연함과 확장성을 지키기 위한 전략이며,
코드의 수정 및 변경을 가장 많이 축소시켜주는 개념이다.
컨트롤러, 서비스와 같은 대표적인 상위 레벨의 모듈이,
아주 정확한 기능만을 담당하는 하위 레벨의 모듈에 의존하게 만들지 않으며,
아주 정확한 기능(MySQL or SQLite or PostgreSQL) 들을 추상화시킨 인터페이스를 구현하여
해당 기능을 요구하는 컴포넌트들이 "추상화 된 인터페이스" 를 호출하도록 만든다.
따라서, 이는 소프트웨어에 유연성과 확장성을 부여한다.
개인적으로, 변경 및 확장 될 수 있는 객체는
대부분 인터페이스를 통한 구현이 필요하지 않을까 생각이 들 정도의 중요한 개념이라고 생각한다.
추상화 된 인터페이스를 호출하게 만들어, 의존 레벨을 동등하게 만드는 것을 의미한다.
마무리
현재 나는 Udemy 의 외국 Spring 강의를 듣고 있다.
(Spring Boot 3, Spring 6 & Hibernate for Beginners) 라는 이름의 강의이다.
이 분은 꽤 세심하게 기초를 닦아 올라 갈 수 있게 설명해 주신다.
그러나, 강의를 들으며 따로 조사를 해야만 이해 할 수 있는 커다란 질문들을 모아
미리 Markdown 파일로 "제목" 까지만 정해두고 쌓고 있다.
바로바로 작성하지 않는 이유는, 내가 70% 정도만 아는 상태에서
그저 공부용으로 작성한 이 글이 누군가에게 95% 의 진실로 다가올 수 있기 때문이다.
정보의 불일치는 개발자에게 치명적일 수 있다.
이를 경계하여 Spring 에서의 IoC 도메인의 이해가 95 퍼센트가 되었을 때,
이 글을 작성 한 것이다.
누군가에게는 이 글이 도움이 되기를 빕니다.
참조 사이트
위키피디아 - Java Annotation
https://en.wikipedia.org/wiki/Java_annotation