스프링이란? 좋은 객체 지향 설계란? (SOLID, 스프링 컨테이너, IoC, DI)
스프링(Spring)이란?
“겨울(EJB)이 지나가고 봄(spring)이 오다”
당시 자바 기반의 새로운 애플리케이션 프레임워크인 스프링(Spring)의 어원이자 탄생 배경이다. 기존에 자바 진영에서 사용하던 EJB(Enterprise Java Bean)를 겨울에 빗대어, 차가웠던 자바 엔터프라이즈 진영에 새로운 시작을 알리겠다는 포부를 담고 있다.
스프링의 탄생 역사
EJB(Enterprise Java Bean)
EJB는 자바를 기업 환경의 서버 시스템을 구축하기 위해 Sun Microsystems사가 발표한 모델이다. 엔터프라이즈급 서버를 개발하는데 있어 필요한 기능들을 대부분 담고 있었다. 자바 객체(Java Bean)를 관리 및 재사용하게 해주는 컨테이너, 트랜잭션 관리, 분산 처리 시스템 지원, 심지어 ORM 기술도 탑재하고 있다.
하지만 그 만큼 비용과 복잡도가 상상 이상으로 컸으며, 성능도 생각보다 좋지 못했다. 특히나 EJB에 의존적인 설계 방식 때문에 객체 지향적인 이점을 누릴 수 없었다.
POJO(Plain Old Java Object)
2000년대 초반, 이를 감당하기 벅차진 자바 개발자들은 이전의 가볍고 순수한 자바로 돌아가자는 POJO(Plain Old Java Object) 방식을 선호하기 시작했다.
우리는 사람들이 자기네 시스템에 보통의 객체를 사용하는 것을 왜 그렇게 반대하는지 궁금하였는데, 간단한 객체는 폼 나는 명칭이 없기 때문에 그랬던 것이라고 결론지었다. 그래서 적당한 이름을 하나 만들어 붙였더니, 아 글쎄, 다들 좋아하더라고. - 마틴 파울러(Martin Fowler)
POJO의 핵심은 객체 지향 원리에 충실하면서, 특정 환경과 기술에 종속되지 않는 자바 객체를 설계하는 것으로 POJO의 탄생 배경과 맞닿아 있다.
위 그림은 Spring CTO를 맡았던 아드리안 콜리어(Adrian Colyer)가 스프링의 핵심 기술을 설명하기 위한 삼각형이다. 그림에서도 알 수 있듯이, POJO 개념은 스프링의 핵심이며, IoC/DI, AOP, PSA같은 기술들이 POJO를 보조해 주는 기술들이다.
이러한 분위기 속에서 2002년, EJB를 대놓고 디스하는 책이 출판하게 되는데, 이 책에 담긴 약 30,000줄의 소스코드가 훗날 스프링의 근간이 된다.
Expert One-on-One J2EE Design and Development - 로드 존슨(Rod Johnson)
로드 존슨은 그의 책에서 EJB의 문제점을 지적하는 한편, EJB 없이 순수한 자바만으로도 좋은 객체 지향 애플리케이션을 개발할 수 있다는 것을 보여주기 위해 예제 코드를 담았다. 이 코드 안에는 DI 컨테이너 뿐만 아니라 스프링의 핵심 기술들이 담겨있다.
이게 그 당시 자바 개발자이 보기에 얼마나 좋은 객체 지향적인 코드였으면, 본인들의 프로젝트에 예제 코드를 그대로 옮겨 사용하기까지 했다. 더 나아가 유겐 휠러(Juergen Hoeller)와 얀 카로프(Yann Caroff)는 오픈 소스를 하자고 제안했고, 2003년 6월에 최초 공개된다.
스프링의 핵심 컨셉
어떻게 스프링이 지금처럼 거대한 생태계를 가질 수 있게 됐을까? 왜 아직도 수 많은 서버 개발자가 스프링을 사용할까? 어떻게 하면 스프링을 잘 쓸 수 있을까? 이런 근본적인 질문들의 답은 스프링이 가진 핵심 컨셉에 있다. 그리고 이를 이해하기 위해서는 스프링이 왜 나오게 됐는지 알아야 하기에, 앞서 조금은 장황하게 탄생 역사를 알아봤다. 랜디 존슨은 왜 스프링의 근간인 예제 코드들을 선보였을까?
위에서 언급했듯이, 스프링 이전에 사용하던 자바 엔터프라이즈 기술인 EJB는 자바를 기반으로한 프레임워크지만 EJB에 의존적으로 개발해야했기 때문에 좋은 객체 지향 설계가 불가능했다. 그리고 좋은 객체 지향 설계를 위해서는 POJO 방식의 접근이 필요했다. 더 나아가 POJO는 순수한 자바 객체로 돌아가자는데 의의가 있다.
자바라는 언어는 객체 지향 언어로써, 객체 지향적인 설계를 했을 때 꽃이 피는 언어다. 그리고 스프링은 자바 언어 기반의 프레임워크다. 즉, 스프링이 지금까지 자바 진영에서 최고의 프레임워크로 살아남은 이유는 자바를 자바답게 사용하고 있는 최고의 프로젝트라는 증거다. 다른 말로, 객체 지향 언어가 가진 강력한 특징들을 가장 잘 살려내는 프레임워크고, 좋은 객체 지향 애플리케이션을 개발할 수 있도록 “도와주는” 프레임워크다. 이게 위 질문들에 대한 답이 될 수 있다.
스프링은 객체 지향적인 설계를 하는데 있어 최고의 자바 기반 프레임워크이며, 스프링을 잘 사용한다는 의미는 객체 지향적으로 잘 개발한다는 것
그렇다면 우리가 스프링을 제대로 이해하고, 잘 사용하기 위해서는 선배 자바 개발자들이 그토록 매달리던 좋은 객체 지향이 뭔지를 알아야 한다. 도대체 좋은 객체 지향 프로그래밍이랑 무엇일까?
좋은 객체 지향이란?
우선 객체 지향을 간단히 설명하자면, 컴퓨터 프로그램을 명령어들의 모임으로 보기보다는, 객체들의 모임으로 파악한다. 각각의 객체는 데이터를 처리하고, 서로 메세지를 주고받을 수 있다. 여기서 객체는 실 세계의 사물이나 어떤 추상적인 개념 등 거의 모든 것이 될 수 있다. 더 나아가 좋은 객체 지향이란 유연하고 변경이 용이해야 한다. 이는 세계를 역할과 구현으로 나눌 때 가능하다.
예를 들어 뮤지컬 시카고의 벨마와 빌리 배역이 있다고 하자. 벨마 역으로는 최정원, 정선아 배우가 맡을 예정이고, 빌리 역에는 박건형, 최재림 배우가 맡을 것이다. 여기서도 역할과 구현이 나뉘어져 있다.
역할 | 구현 |
벨마 | 최정원, 정선아 |
빌리 | 박건형, 최재림 |
사실 뮤지컬 대본에는 벨마 역에 최정원 배우가 오든, 정선아 배우가 오든, 아무 영향이 없다. 빌리도 마찬가지다. 이는 역할과 구현이 명백하게 분리되어 있고, 각 역할에 해당하는 벨마와 빌리가 구현에 의존하지 않고 서로의 역할에 의존하고 있기 때문이다. 심지어 무명 배우가 빌리 역을 맡더라도 벨마 입장에서는 빌리와 주고받는 대사나 춤에 변함이 없을 것이다. 바로 유연하고 변경이 용이하다.
여기서 간과하지 말아야 할 점은 객체(역할)가 서로 협력하고 있다는 것이다. 만약 뮤지컬에 벨마 역만 있었으면 사실 역할이나 구현을 나눈 의미가 크게 없을 것이다. 개인 방송을 하는 스트리머들을 떠올려 보자. 그들은 역할과 구현을 나눌 필요가 없다. 스스로가 역할이며 구현이 되면 된다. 유연하고 변경이 용이할 필요가 없기 때문에.
하지만, 우리는 다양한 클라이언트들과 서버들이 요청 및 응답을 하는, 수 많은 객체들이 협력하는 프로그램을 만들어야 한다. 요구사항의 변경에 대처해야 한다. 기술 스택을 바꿔야 할 수도 있다. 클라이언트의 종류가 추가될 수도 있다. 그 때마다 일일이 수 많은 코드들에 손을 댈 수는 없다. 때문에 변경이 많아 지더라도 최대한 코드의 변화가 적게 설계해야 한다는 것이고, 그 첫걸음이 좋은 인터페이스(역할)를 설계하고 그로부터 구현을 나누는 것이다.
좋은 객체 지향 프로그램 == 유연하고 변경이 용이한 프로그램 == 역할과 구현이 잘 설계된 프로그램
이는 객체 지향의 특성 중 다형성(Polymorphism)에 해당되며, (전) 우아한형제들 기술이사 김영한님이 말하길,다형성은 객체 지향의 꽃이라고 하셨다. 자바에서는 역할 == 인터페이스, 구현 == 구현 클래스로 대입시킬 수 있다. 그리고 객체 지향에는 다형성을 포함한 4대 특성(추상화, 상속, 다형성, 캡슐화)이 있다. 그리고 이를 올바르게 사용하도록 도와주는 원칙이 존재한다.
좋은 객체 지향 설계의 5가지 원칙(SOILD)
모든 일은 많은 사람들에 의해 수 차례 반복되고, 연구되면서, 여러가지의 방법론이나 원칙이 성립된다. 그 중에서 가장 효율적이고 합리적인 방법론이 정립되고, 그 일을 수행하는데 일반적으로 적용하는 대표 방법론 또는 원칙이 된다. 객체 지향 언어를 이용해 객체 지향 프로그램을 올바르게 설계해 나가는 방법이나 원칙이 존재한다.
로버트 C. 마틴(Robert C. Martin)이 2000년대 초반 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙으로 제시한 것을 마이클 페더스(Michael Feathers)가 두문자어로 소개한 SOLID라는게 있다다.
- SRP(Single Responsibility Principle) : 단일 책임 원칙
- OCP(Open Closed Principle) : 개방 폐쇄 원칙
- LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
- ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
- DIP(Dependency Inversion Principle) : 의존 역전 원칙
위 원칙들은 객체 지향의 4대 특성(추상화, 상속, 다형성, 캡슐화)을 올바르게 사용하는 방법이라고도 할 수 있다.또한 응집도는 높이고 결합도는 낮추라는 고전 원칙이 결국은 핵심이 된다. SOLID를 소프트웨어에 잘 녹여내면 좀 더 유지보수하기 쉽고, 유연하고, 확장이 쉬우며, 논리적으로 정연하다. 또한 디자인 패턴과 스프링 프레임워크의 근간이 된다.
1. SRP(Single Responsibility Principle) : 단일 책임 원칙
“There should never be more than one reason for a class to change”
(의역) “어떤 클래스를 변경해야 하는 이유는 오직 하나 뿐이어야 한다” - 로버트 C. 마틴(Robert C. Martin)
작성된 클래스는 하나의 기능만 가지며 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는 데 집중되어 있어야 한다는 원칙이다. 이는 어떤 변화에 의해 클래스를 변경할 때 파급 효과가 적어야 한다는 의미다.
아래와 같이 남자 클래스에 의존하고 있는 다양한 클래스가 있다고 가정해 보자.
언뜻 봐도 남자 클래스는 역할과 책임이 너무 많다. 당장 여자친구와 헤어지면 키스하기()
나 기념일챙기기()
같은 메서드는 필요가 없어진다. 또한 직장상사로써 출근하기()
나 아부하기()
외의 메서드들은 사용할 일이 없다. 더군다나 사원이면서 소대원인 상황은 있을 수 없다.
위 클래스에 SRP를 적용해 책임을 분리해 보자.
남자라는 클래스를 각 역할과 책임에 따라 네 개의 클래스로 분리했다. 역할과 클래스 명도 일치해 이해하기도 편해졌다.
또 다른 예를 들어보자. 남자는 군대를 가야하고, 여자는 가지 않는 나라가 있다고 가정했다.
class 사람 {
private String 군번;
private String 주민등록번호;
public void set군번(String 군번) {
this.군번 = 군번
}
// ...
}
// ...
사람 로미오 = new 사람();
사람 줄리엣 = new 사람();
줄리엣.set군번("22-70008124"); // 잘못 작성된 코드일 확률이 매우 높음
줄리엣.set군번("22-70008124")
코드는 줄리엣이 여자라면 프로그래머의 실수로 작성된 코드일 것이다. 하지만 현재의 클래스는 구조적으로 이를 제제할 방법이 없다. 사람이라는 클래스가 남자와 여자의 책임을 모두 가지고 있기 때문이다. SRP를 적용하여 클래스를 다시 설계해 보자.
class 사람 {
private String 주민등록번호;
// ...
}
class 남자 extends 사람 {
private String 군번;
// ...
}
class 여자 extends 사람 {
// ...
}
// ...
사람 로미오 = new 남자();
사람 줄리엣 = new 여자();
줄리엣.set군번("22-70008124"); // 에러
사람 클래스를 남자와 여자 클래스로 분리한 뒤, 각자 사람 클래스를 상속 받도록 설계했다. 공통 속성은 사람 클래스가 갖게 하고, 군번
속성은 남자 클래스만 가지게 했다. 결과적으로 여자 클래스는 사용하지 않는 군번
속성을 가지지 않으므로 프로그래머의 실수를 예방함과 동시에 응집도도 올라갔다.
2. OCP(Open Closed Principle) : 개방 폐쇄 원칙
“You Should be able to extend a classes behavior, without modifying it”
(의역) ”클래스 형태를 변경하지 않고 확장할 수 있어야 한다” - 로버트 C. 마틴(Robert C. Martin)
소프트웨어의 구성 요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다. 뭔가 모순같은 말이다. 이를 좀 더 쉽게 풀어보면, 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다는 의미다. 소프트웨어 설계 관점에서는 변경을 위한 비용은 가능한 줄이고 확장을 위한 비용은 가능한 극대화해야 한다. 따라서 요구사항의 변경이나 추가사항이 발생하더라도, 기존 구성 요소는 수정 없이 쉽게 확장해서 재사용할 수 있어야 한다.
OCP를 적용하는 방법은 아래와 같다.
- 변경(확장)될 것과 변하지 않을 것을 엄격히 구분한다.
- 이 두 모듈이 만나는 지점에 인터페이스를 정의한다.
- 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성한다.
예를 들어, 한 운전자가 마티즈(수동 기어)을 타고 다녔다고 하자.
그러다 어느 날 좀 더 편한 소나타(자동 기어)로 차를 바꿨다.
위의 경우 운전자는 차량의 변화에 따라 기어 변경 방법이 달라진다. 또한 키로시동걸기()
는 두 차량에 중복 정의를 해야 하는 비효율도 생긴다. 위 예시에서는 생략되었지만, 속성의 경우 대부분이 중복되는 속성일 것이며, 만약 자동차의 종류가 더 많아지게 되면 그 만큼 비효율적인 코드가 비약적으로 증가할 것이다.
따라서 OCP를 적용해 아래와 같이 클래스 설계를 변경하는 것이 바람직하다.
운전자는 차량의 변화에 관계 없이 시동걸기()
와 기어조작()
을 통해 차량을 사용할 수 있게 됐다. 또한 버튼으로 시동을 거는 자동차 등, 다양한 자동차가 생긴다고 해도 메서드 오버라이딩을 통해 차량에 맞게 재구현을 한 뒤, 공통 속성이나 메서드는 상속받으면 된다. 즉, 운전자는 자동차의 변화에는 닫혀 있고, 자동차는 자신의 확장에 대해서는 열려 있게 되는 것이다.
또 다른 예로 JDBC 인터페이스가 있다.
JDBC는 데이터베이스의 종류에 관계 없이 프로그래머에게 동일한 인터페이스를 제공함으로써 프로그래머에게는 데이터베이스 변화에 닫혀 있게 되고, JDBC는 자신의 확장에 열려 있게 된다. 이로써 객체 지향 특성 중 상속과 다형성을 활용해 재사용성과 유지 보수를 극대화 할 수 있게 된다.
3. LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
“Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it”
(의역) “서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다” - 로버트 C. 마틴(Robert C. Martin)
이름은 어려워 보이지만 사실 상속(extends
, implemets
)의 원칙을 잘 지키는 것이 LSP를 잘 지키고 있는 것이다.
- 하위 클래스 is a kind of 상위 클래스 : 하위 클래스는 상위 클래스의 한 종류이다.
- 구현 클래스 is able to 인터페이스 : 구현 클래스는 인터페이스할 수 있어야 한다.
예를 들어 상속을 배울 때 종종 사용되는 가족 이나 회사의 조직도는 사실 리스코프 치환 원칙을 어기는 사례이다.
- 아빠는 할아버지의 한 종류이다. (x)
- 딸은 아빠의 한 종류이다. (x)
- 대리는 부장의 한 종류이다. (x)
이보다는 동물 종의 분류도같은 사례가 올바르다.
- 포유류는 동물의 한 종류이다. (o)
- 참새는 조류의 한 종류이다. (o)
또 다른 예로 자바의 컬렉션 프레임워크가 있다.
언뜻 보면 OCP를 훌륭하게 구현한 예제 같기도 한데, LSP는 OCP를 구성하기 위해 선행되는 구조기 때문이다.
LSP는 다형성과 확장성을 극대화 하려는 노력으로, 이를 위해서는 하위 클래스를 사용하는 것보다 상위의 클래스(인터페이스)를 사용하는 것이 더 좋다. 일반적으로 선언은 상위 클래스로 생성은 하위 클래스로 대입한다.
이를 토대로 마틴의 말을 다시 의역해 보면,
“하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.”
(참고로 LSP는 바바라 리스코프가 자료 추상화와 계층 (Data abstraction and hierarchy) 이라는 제목으로 기조연설을 한 1987년 컨퍼런스에서 처음 소개한 내용이라고 한다.)
4. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
“Clients should not be forced to depend upon interfaces that they do not use”
(의역) “클라이언트는 자신이 사용하지 않는 인터페이스에 의존 관계를 맺으면 안 된다” - 로버트 C. 마틴(Robert C. Martin)
클래스는 자신이 사용하지 않는 인터페이스(메서드)는 구현하지 말아야 한다는 원리로, 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스 기능만 사용해야 한다는 원칙이다.
만약 어떤 클래스를 이용하는 클라이언트가 여러 개고, 해당 클래스의 특정 부분 집합만을 이용한다면, 이들을 각각 인터페이스로 빼내어 클라이언트가 기대하는 메세지만을 전달할 수 있도록 해야 한다.
public interface Operatable {
void 일반풍();
void 자연풍();
void 수면풍();
}
public class 요즘선풍기 implements Operatable {
@Override
public void 일반풍(); {
// 구현
}
@Override
public void 자연풍(); {
// 구현
}
@Override
public void 수면풍(); {
// 구현
}
}
위와 같은 예시가 있다고 해 보자. 요즘선풍기
는 Operatable
에 있는 기능들을 모두 사용하기 때문에 문제가 없다. 하지만 이번에는 옛날선풍기
클래스를 구현해야 된다.
public class 옛날선풍기 implements Operatable {
@Override
public void 일반풍(); {
// 구현
}
@Override
public void 자연풍(); {
// 사용안함
}
@Override
public void 수면풍(); {
// 사용안함
}
}
옛날선풍기
에는 자연풍
과 수면풍
기능이 필요 없지만, 어쩔 수 없이 오버라이드 해야 된다. 이를 ISP를 적용해 인터페이스를 여러 개로 나눔으로써 해결할 수 있다.
interface BaseOperatable {
void 일반풍();
}
interface SmartOperatable {
void 자연풍();
void 수면풍();
}
SRP가 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다.
5. DIP(Dependency Inversion Principle) : 의존 역전 원칙
“High level modules should not depend upon low level modules. Both should depend upon abstractions.”
”Abstractions should not depend upon details. Details should depend upon abstractions.”
(의역) “고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다”
”추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다” - 로버트 C. 마틴(Robert C. Martin)
말이 어려울 수 있는데, 최대한 쉽게 풀어 얘기하면 자신보다 변하기 쉬운 것에 의존하지 마라는 원칙이다.
예를 들어 자동차가 스노우 타이어에 의존한다고 해보자.
스노우 타이어는 자동차에 비해 계절에 따라 자주 바뀌게 되는 저차원의 모듈이다. 만약 일반 타이어나 산악용 타이어로 바꿔야 할 경우 자동차는 의존 관계를 다시 맺어줘야 한다.
객체 지향에서는 변하기 쉬운 클래스들을 추상화한 인터페이스나 상위 클래스를 설계함으로써 이를 의존하던 클래스가 변화에 영향을 받지 않게 한다.
이제 자동차는 스노우타이어나 일반 타이어라는 구체적인 것에 의존하지 않고, 추상화된 타이어에 의존함으로써 어떤 타이어를 쓰든 타이어에 관해서는 변하지 않아도 된다. 스노우타이어 입장에서는 의존성의 방향이 역전된 것처럼 보이기 때문에 의존 역전 원칙이다. 이 예제 역시 OCP와 매우 유사하다고 느낄 수 있는데, 객체 지향 특성인 상속과 다형성을 활용한 방법이기 때문에 어찌 보면 당연하다.
이렇게 좋은 객체 지향을 만들어 주는 SOLID를 막상 어떻게 우리가 코드에 잘 녹여내서 개발해야할 지 막막할 수 있다. 만약 우리가 순수한 자바만으로 애플리케이션을 개발해야 한다면, 더더욱 그럴 것이다. 물론 좋은 자바 개발자가 되기 위해서는 꼭 해야만 하는 고민이다. 설명에서 느꼈겠지만, SOLID를 잘 적용시키는게 결국 자바의 상속이나 인터페이스 등을 효과적으로 사용하는 것이기 때문이다. 그리고 옛날 선배 개발자들도 이런 원칙들을 지켜가며 개발을 해왔고, 다음과 같은 문제도 맞닥뜨리게 된다.
interface 타이어 {
void roll();
}
class 일반타이어 implements 타이어 {
@Override
public void roll() {
}
}
class 스노우타이어 implements 타이어 {
@Override
public void roll() {
}
}
class 자동차 {
// private 타이어 tire = new 일반타이어();
private 타이어 tire = new 스노우타이어(); // 일반타이어에서 스노우타이어로 갈아 끼움
}
위 에시는 인터페이스와 구현 클래스를 잘 나눈 뒤, 자동차 클래스가 타이어 인터페이스를 의존하고 있기 때문에 OCP, DIP를 잘 지켜낸 코드 같다. 하지만 막상 일반 타이어에서 스노우타이어로 갈아 끼워야 할 때, 코드의 변경이 일어난다. 왜 그럴까?
사실 자동차 클래스는 타이어 인터페이스만 의존하고 있는게 아니라, new
로 인터페이스를 직접 생성하고 있기 때문에 스노우타이어라는 구체 클래스에도 의존하고 있던 것이다. 그렇다고 구현 클래스 넣어주지 않으면 인터페이스만으로는 기능이 동작할 수 없다. 이 문제를 해결하기 위해서는 제 3자가 필요한데, 이걸 구현하다 보면 스프링 DI 컨테이너를 만들게 된다.
결국 과거 훌륭한 선배 개발자들의 피와 땀이 녹아있는 스프링 프레임워크가 있어 좋은 객체 지향으로 가는 우리의 짐이 조금은 덜어진 것이다. 다시 한 번 강조하면, 스프링은 좋은 객체 지향 설계를 도와주는 프레임워크다. 스프링이 애초에 좋은 객체 지향으로 가다 보니 만들어 진 하나의 부산물이기 때문이다. 스프링을 “잘” 사용하면 자연적으로 객체 지향적인 개발을 잘할 수 있게 된다. 그럼 스프링의 첫걸음이었던 스프링 DI 컨테이너를 알아보자.
스프링 컨테이너(Spring Container)와 스프링 빈(Spring Bean)
객체를 다루는 일반적인 방법은 프로그래머가 프로그램 로직 상 필요한 객체를 그때그때 직접 생성하고 필요한 메서드를 호출하는 것이다. 만약 다루는 언어가 자바라면 new
키워드를 통해 필요한 곳에 인스턴스를 생성하는 로직을 넣을 것이다.
하지만 스프링에서는 클래스들의 인스턴스(객체)를 일일이 프로그래머가 다루는 것이 아니라 스프링에게 관리를 맡기게 된다. 이것이 스프링의 주요 특징 중 하나인 IoC(Inversion of Control)이다.
IoC는 우리나라 말로 제어의 역전이라고 하며, 쉽게 말해 제어의 흐름을 바꾸는 것이다. 필요한 객체를 프로그래머가 직접 생성하는 것이 아니라 스프링이 생성해 놓은 객체를 가져와서 쓰게 된다. 객체에 대한 제어권이 스프링에게로 넘어가 버린 것이다.
IoC(Inversion of Control) : 제어의 역전, 객체를 개발자가 아닌 외부(스프링 컨테이너)에서 생성, 관리
💡참고로 이렇게 제어가 역전되는 경우가 보통 프레임워크다. 프레임워크를 사용하게 되면 개발자는 핵심 로직들만 작성한 뒤, 프레임워크가 자신만의 사이클이나 규칙대로 프로그램을 실행한다. 반면, 라이브러리는 사용자가 필요한 것들을 가져와 작성한뒤 실행까지 담당한다.
이때 스프링이 객체들을 미리 생성해 관리하는 공간을 스프링 컨테이너라고 하며, 이렇게 스프링에 의하여 생성되고 관리되는 자바 객체를 스프링에선 빈(Bean)이라고 한다.
스프링 컨테이너(Spring Container) : 스프링에서 자바 객체들을 관리하는 공간
스프링 빈(Spring Bean) : 스프링에 의하여 생성되고 관리되는 자바 객체
즉, 스프링 컨테이너에서는 이 빈의 생성부터 소멸까지를 개발자 대신 관리해주는 곳이다. 그리고 스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 디폴트가 싱글톤(Singleton)으로 등록한다(유일하게 하나만 등록해서 공유한다). 따라서 같은 스프링 빈이면 모두 같은 인스턴스다. 설정으로 싱글톤이 아니게 설정할 수 있지만, 특별한 경우를 제외하면 대부분 싱글톤을 사용한다.
(이 이유에 대해서는 https://mangkyu.tistory.com/151 참고)
의존 관계와 DI(Dependancy Injection)
위와 같이 외부에서 객체를 생성하고 관리함으로써 얻을 수 있는 장점을 이해하려면 의존 관계에 대해 알아야 한다.
의존 관계는 의존 대상 B가 변하면, 그것이 A에 영향을 미칠 때 A는 B와 의존 관계라고 한다. 쉽게 말해 B가 변경되었을 때 그 영향이 A에 미치는 관계를 말하며, 보통 한 클래스 내에 다른 클래스의 인스턴스를 생성할 때 의존 관계가 만들어 진다.
기존의 방식대로 개발자가 A 클래스에 B 클래스의 어떤 기능이 필요해서 A 클래스 안애 B 클래스를 직접 생성하여 사용하게 되면 A와 B 클래스의 의존성은 높아진다. 의존성이 높다는 것은 결합도가 높다는 걸로 바꿔 말할 수 있는데, 서로의 변화가 서로에게 큰 영향을 미칠 수 있다는 의미다.
이러한 문제를 해결하는 방법 중 하나가 클래스 내부에 객체를 생성하는 것이 아니라, 외부에서 미리 생성된 객체를 활용하는 것이다. 마치 자동차를 만들 때, 자동차 엔진을 만드는 공정과 타이어를 만드는 공정을 분리시켜 나중에 완성된 부품을 조립하는 것처럼 말이다. 이것이 DI(Dependancy Injection)다.
위에서 얘기했던, 컨테이너가 미리 생성해 놓은 객체(빈)를 개발자가 가져다가 사용한다는 말을 바꿔 말하면, 스프링 컨테이너가 외부에서 미리 생성해 놓은 객체를 주입시킨다고 할 수 있으며, 이것을 Dependancy Injection이라고 한다.
DI(Dependancy Injection) : 의존 관계를 외부에서 결정(주입)해주는 것
만약 자동차 타이어의 종류가 바뀐다면 타이어만 갈아 끼우면 된다. 엔진을 공정하는 것에는 아무런 영향이 없다. 심지어 타이어를 갈아 끼우는 것도 내가 직접 하는 것이 아니라 전달만 해 놓으면 기술자 또는 기계가 대신 끼워준다. 이는 자동차 엔진과 타이어 공정의 의존 관계가 최소화 됐기 때문이고, 제어를 외부에 맡겼기 때문이다.
스프링에서는 스프링 컨테이너가 이 역할을 수행해 주기 때문에 DI 컨테이너라고도 불린다. 이렇게 객체를 생성하고 의존 관계를 맺어주는 제 3자인 조립자, 설정자가 필요한 이유에는 앞서 설명했던 OCP를 잘 지키기 위해서이기도 하다. SOLID 마지막 부분에서 해결하지 못했던 예시를 코드를 다시 가져와 보자.
interface 타이어 {
void roll();
}
class 일반타이어 implements 타이어 {
@Override
public void roll() {
}
}
class 스노우타이어 implements 타이어 {
@Override
public void roll() {
}
}
class 자동차 {
// private 타이어 tire = new 일반타이어();
private 타이어 tire = new 스노우타이어(); // 일반타이어에서 스노우타이어로 갈아 끼움
}
OCP 원칙에 맞게 인터페이스로 잘 설계한 것 같은데 막상 클라이언트인 자동차 클래스가 일반타이어에서 스노우타이어로 갈아 끼우려고 하니까 코드를 변경해 줘야 한다. 직접 New
키워드로 인스턴스를 생성하니까 결합도와 의존도가 높아졌기 때문이다. 이를 해결하기 위해서는 제 3자인 스프링 컨테이너가 개입해 줘야 한다.
스프링 컨테이너는 내부에서 객체를 생성 및 조립해주기 때문에 클라이언트가 직접 인스턴스를 생성할 필요가 없다.
@Configuration
public class SpringConfig {
@Bean
public 자동차 자동차() {
return new 자동차(타이어());
}
@Bean
public 타이어 타이어() {
// return new 일반타이어();
return new 스노우타이어();
}
}
class 자동차 {
private 타이어 tire;
자동차(타이어 tire) {
this.tire = tire;
}
}
이런식으로 스프링 설정 파일인 SpringConfig
에 생성자를 등록해 두면, 스프링 컨테이너가 생성자들을 사용해 인스턴스를 생성한 뒤 스프링 빈으로 관리한다. 간단하게나마 스프링 빈으로 등록되는 과정을 살펴보면,
자동차()
호출자동차(타이어())
호출타이어()
호출스노우타이어()
호출 및 반환- 타이어 인터페이스 타입인 스노우 인터페이스를 스프링 빈으로 등록
자동차(타이어)
호출 및 반환→ 이때 타이어는 스프링 컨테이너가 관리하는 스프링 빈을 DI- 타이어 구현체로 스노우타이어를 장착한 자동차 인스턴스가 스프링 빈으로 등록
타이어를 갈아 끼울 때는 설정 파일의 타이어()
생성자에서 반환되는 구현 클래스 생성자를 스노우타이어()
→ 일반타이어()
로 바꿔주기만 하면 된다. 이러면 타이어의 구현 클래스가 아무리 바뀌더라도 클라이언트인 자동차 입장에서는 코드를 전혀 바꾸지 않아도, 심지어 구현체가 어떤 건지도 알 필요도, 알 수도 없다.
💡 위 방법은 자바 코드로 직접 스프링 빈을 관리하는 방법이다. 다른 방법으로는 @Component와 @Autowired를 이용한 컴포넌트 스캔이 있다. 정형화된 service나 repository같은 곳에는 컴포넌트 스캔을 주로 이용하고, 아직 요구사항이 정해지지 않았거나 변경이 필요한 경우에는 따로 설정 파일은 만들어 관리하는 편이 유지보수에 좋다.