본문 바로가기
백엔드

[Spring] IoC와 DI - 1. 오브젝트와 의존관계(토비의 스프링)

by BeforB 2021. 9. 21.
728x90

목차

  1. 스프링이란?
  2. 오브젝트와 의존 관계
  3. 용어 정리

 

 

 

포스팅을 시작하며..

SSAFY에서 2학기 프로젝트를 진행할 때 컨설턴트님께서 스프링에 대해 깊게 공부하고 싶다면 꼭 읽어봐야 할 책으로 '토비의 스프링'을 추천해주셨다. 오래된 책이지만 스프링에 대해서는 수학의 정석과도 같은 책이라고 추천해주셔서 두 권을 모두 샀었는데 1년이 지난 지금에야 제대로 읽어보고 있다.

사실 그동안 수많은 면접 준비를 하면서 IoC와 DI에 대해 개념적으로 달달 외울 뿐 이해가 전혀 되지 않았었는데 직접 예제를 읽어보고 하나하나 코드를 살펴보니 이제야 정리가 되는 기분이다. 왜 이 책을 이제야 읽어보았는지 아쉬우면서도 이제라도 읽어서 다행이라고 생각한다. 스프링에 대해 이해하고 싶고 공부하고 싶은 사람이라면 한 번쯤 읽어보는 걸 추천한다.

 

 

이 포스팅을 시작으로 토비의 스프링과 직접 찾아보고 공부한 내용을 바탕으로 스프링에 대해 포스팅을 할 예정이다. 이번 글은 토비의 스프링 중 중요한 내용들을 정리해보았다.

 

 

1. 스프링이란?

스프링에 대한 첫 포스팅이니, 우선 스프링이 무엇인지 짧게 정리하고 넘어가보자. 스프링은 자바 엔터프라이즈 애플리케이션 개발에 사용되는 애플리케이션 프레임워크로 애플리케이션 개발을 바르고 효율적으로 할 수 있도록 틀과 공통 프로그래밍 모델, 기술 API 등을 제공해준다.

 

 

* 스프링 컨테이너 == 애플리케이션 컨텍스트

스프링은 스프링 컨테이너라고 불리는 스프링 런타임 엔진을 제공한다. 스프링 컨테이너는 설정정보를 참고로 해서 애플리케이션을 구성하는 오브젝트를 생성하고 관리한다.

 

 

1) 스프링의 핵심 프로그래밍 모델

프레임워크는 틀을 제공해줄 뿐만 아니라 애플리케이션 코드가 어떻게 작성되어야 하는지에 대한 기준인 '프로그래밍 모델'도 함께 제시해준다. 스프링은 아래의 세 가지 핵심 프로그래밍 모델을 지원한다.

 

1) IoC/DI

오브젝트의 생명주기와 의존 관계에 대한 프로그래밍 모델이다. IoC와 DI는 유연하고 확장성이 뛰어난 코드를 만들 수 있게 도와주는 객체지향 설계 원칙과 디자인 패턴의 핵심 원리를 담고 있다.

스프링이 제공하는 모든 기술과 API, 컨테이너는 모두 IoC/DI 방식으로 작성되어 있다.

 

2) 서비스 추상화

스프링을 사용하면 환경이나 서버, 특정 기술에 종속되지 않고 이식성이 뛰어나며 유연한 애플리케이션을 만들 수 있는데 이를 가능하도록 해주는 것이 서비스 추상화이다. 구체적인 기술과 환경에 종속되지 않도록 유연한 추상 계층을 두는 방법이다.

 

3) AOP

애플리케이션 코드에 산재해서 나타나는 부가적인 기능을 독립적으로 모듈화하는 프로그래밍 모델이다. 스프링은 AOP를 이용하여 깔끔한 코드를 유지할 수 있도록 해준다.

 

 

 

 

2. 오브젝트와 의존관계

스프링은 자바를 기반으로 한 기술로 객체지향 기술의 진정한 가치를 회복시키고, 그로부터 객체지향 프로그래밍이 제공하는 폭넓은 혜택을 누릴 수 있도록 기본으로 돌아가자는 것이 핵심 철학이다. 따라서 스프링을 이해하기 위해서는 '오브젝트'에 깊은 관심을 가져야 한다. 오브젝트의 생성, 사용, 소멸까지의 라이프 사이클과 같은 기술적인 특징과 사용방법을 넘어 오브젝트 설계까지 여러 응용 기술과 지식이 요구된다.

스프링은 객체지향 설계와 구현에 관해 특정 모델과 기법을 강요하지는 않지만 오브젝트를 어떻게 효과적으로 설계하고 구현하며 사용하고 개선해나가는 방향에 대한 명쾌한 기준을 마련해준다.

 

 

1) 상속을 통한 확장

변화에 유연한 코드를 만들기 위해 템플릿 메소드 패턴 혹은 팩토리 메소드 패턴과 같은 방식으로 상속을 통해 유연한 코드를 만들 수 있다. 이 방식은 편리하고, 중복된 코드를 방지할 수 있다.

하지만 '상속'을 이용하였다는 단점이 있다. 자바에서는 클래스의 다중상속을 허용하지 않기 때문에 이미 해당 클래스가 다른 목적으로 클래스를 상속받고 있을 경우 다른 상속을 적용할 수 없다는 한계가 있다. 또한 상속을 통한 상하위 클래스의 관계는 생각보다 밀접하다는 문제점도 존재한다. 기능을 분리하긴 했지만 상속관계는 여전히 긴밀하게 결합되어 있기 때문이다.

서브클래스가 아니라 아예 별도의 클래스로 만들어 객체를 생성하여 사용할 수도 있지만 이 문제도 종속성의 영향을 받는다. 이를 해결하기 위해 '인터페이스'를 도입한다.

 

 

2) 오브젝트

오브젝트 사이의 관계는 런타임 시에 한 쪽이 다른 오브젝트의 레퍼런스를 갖고 있는 방식으로 만들어진다.

아래 예제는 DB접속용 인터페이스 ConnectionMaker를 구현한 D사의 DB접속용 클래스(DConnectionMaker)의 생성자를 호출해서 오브젝트를 생성하는 코드이다. 다음 코드에서 오브젝트 사이의 관계를 살펴보면, UserDao에서 DConnectionMaker의 오브젝트의 레퍼런스를 connectionMaker 변수에 넣어서 사용하게 함으로써 두 개의 오브젝트가 '사용'이라는 관계를 맺게 해준다.

public class UserDao {
    public UserDao() {
    	// 만일 N사의 NConnectionMaker()를 사용하고 싶을 경우 UserDao의 관련 코드를 수정해주어야 함
    	ConnectionMaker connectionMaker = new DConnectionMaker();
    }
}

 

 

* 오브젝트 간의 결합도

오브젝트 사이의 관계가 만들어지기 위해선 만들어진 오브젝트가 있어야 하는데 예제와 같이 1) 직접 생성자를 호출해서 오브젝트를 생성하는 방법이 있고, 2) 외부에서 만들어준 것을 가져와서 사용하는 방법이 있다. 외부에서 생성된 오브젝트를 전달받으려면 메소드 파라미터나 생성자 파라미터를 이용하면 된다. 

 

위의 예제처럼 직접 생성자를 호출해서 오브젝트를 생성할 경우 ConnectionMaker에 수정이 일어날 경우 UserDao도 함께 수정해야 한다는 문제점이 있다. 위와 같이 한 군데서만 ConnectionMaker를 사용한다면 괜찮지만, 만일 여러 군데서 사용하게 된다면 매우 번거로운 작업이 될 것이고 수정하지 않아 시스템의 오류를 발생시킬 수도 있다. 이러한 문제가 발생하는 원인은 바로 '오브젝트 간의 결합도'가 높기 때문이다.

 

오브젝트 간의 결합도를 낮추기 위해서는 UserDao의 모든 코드는 ConnectionMaker 인터페이스 외에는 어떤 클래스와도 관계를 가져서는 안되도록 해야 한다. 그래야만 UserDao의 수정 없이 클래스를 변경할 수 있다.

 

 

결국 예제에서 인터페이스를 사용했음에도 불구하고 userDao에서 DConnection이라는 클래스를 알고 있기 때문에 UserDao와 ConnectionMaker는 완벽하게 분리되지 않았다.

위 예제의 관계를 파악하기 위한 전체적인 코드를 아래에 정리해 두었다.

package springbook.user.dao;

public interface ConnectionMaker {
  public Connection makeConnection() throws ClassNotFoundException, SQLException;
}
package springbook.user.dao;

public class DConnectionMaker implements ConnectionMaker {
  public Connection makeConnection() throws ClassNotFoundException, SQLException {
    // D 사의 독자적인 방법으로 Connection을 생성하는 코드
  }
}
public class UserDao {
  // 인터페이스를 통해 오브젝트에 접근하므로 구체적인 클래스 정보를 알 필요가 없다.
  private ConnectionMaker connectionMaker;
  
  public userDao() {
    // UserDao가 클래스 이름을 알고 있음 - 문제가 되는 부분!!!
    connectionMaker = new DConnectionMaker(); 
  }
  public void add(User user) throws ClassNotFoundException, SQLException {
    // 인터페이스에 정의된 메소드를 사용하므로 클래스가 바뀐다고 해도 메소드 이름이 변경될 걱정은 없다.
    Connection c = connectionMaker.makeConnection();
  }
  public User get(String id) throws ClassNotFoundException, SQLException {
    Connection c = connectionMaker.makeConnection();
  }
}

 

 

 

3) 관계 설정 책임의 분리

UserDao에는 어떤 ConnectionMaker 구현 클래스를 사용할지 결정하는 코드가 남아 있기 때문에 인터페이스를 이용한 분리에도 불구하고 여전히 UserDao의 변경 없이 DB 커넥션 기능의 확장이 자유롭지 못하다는 불편함이 남아있다.

 

해결 방법은 코드에 다른 클래스 이름이 나타나는 클래스 간의 관계가 아니라 오브젝트와 관계를 맺는 것이다. 오브젝트 사이의 관계는 코드에서 특정 클래스를 전혀 알지 못하더라도 해당 클래스의 오브젝트를 인터페이스 타입으로 받아서 사용할 수 있다. 바로 객체지향 프로그램의 '다형성'이라는 특징 덕분이다.

이렇게 할 경우 런타임 시 두 오브젝트 사이의 의존관계가 만들어진다.

 

 

위의 예제를 오브젝트 간의 관계를 맺는 방식으로 변경하려면 어떻게 해야 할까? UserDao 생성자에서 ConnectionMaker를 파라미터로 받을 수 있도록 하고, UserDao의 클라이언트를 생성해서 클라이언트에게 오브젝트 간 관계를 맺는 책임을 넘겨버리면 된다.

여기서 클라이언트의 책임은 바로 런타임 오브젝트 관계를 갖는 구조로 만들어주는 것이다. 여기서는 main 메소드가 UserDao의 클라이언트라고 생각하면 된다. UserDaoTest는 UserDao와 ConnectionMaker 구현 클래스와의 런타임 오브젝트 의존 관계를 설정하는 책임을 담당한다.

 

UserDao의 클라이언트를 이용한 의존성 주입

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

  public static void main(String[] args) throws ClassNotFoundException, SQLException {
    // UserDao가 사용할 ConnectionMaker 구현 클래스를 결정하고 오브젝트를 만든다.
    ConnectionMaker connectionMaker = new DConnection();
    
    UserDao dao = new UserDao(connectionMaker);
    
    // 1. UserDao 생성
    // 2. 사용할 ConnectionMaker 타입의 오브젝트 제공. 결국 두 오브젝트 사이의 의존 관계 설정 효과
    ...
  }
}

 

 

 

 

 

 

이쯤에서 정리하는 용어들!!

* DAO(Data Access Object)

DB를 사용하여 데이터를 조회하거나 조작하는 기능을 전담하도록 만든 오브젝트

 

* Java Bean(자바빈)

다음 두 가지 관례를 따라 만든 오브젝트를 가리킨다.

1) 디폴트 생성자 : 자바빈은 디폴트 생성자를 갖고 있어야 한다. 툴이나 프레임워크에서 리플렉션을 이용하여 오브젝트를 생성하기 때문에 반드시 필요하다.

2) 프로퍼티 : 자바빈이 노출하는 이름을 가진 속성을 프로퍼티라고 한다. 프로퍼티는 setter와 getter를 이용하여 수정 및 조회가 가능하다.

 

* 개방폐쇄 원칙(OCP, Open-Closed Principle)

개방 폐쇄 원칙은 깔끔한 설계를 위해 적용 가능한 객체지향 설계 원칙 중의 하나이다. 간단히 정의하면 '클래스나 모듈은 확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다.'고 할 수 있다.

UserDao는 DB 연결 방법이라는 기능을 확장하기 위해 UserDao에 전혀 영향을 주지 않고도 얼마든지 확장이 가능하기 때문에 확장에 열려있다고 할 수 있다. 동시에 자신의 핵심 기능을 구현한 코드는 그러한 변화에 영향을 받지 않고 유지할 수 있기 때문에 변경에는 닫혀 있다고 할 수 있다.

 

 

* 객체지향 설계 원칙(SOLID)

객체지향 설계 원칙은 객체지향의 특징을 잘 살릴 수 있는 설계의 특징을 말한다. 절대적인 기준이라기보다는 가이드라인과 같은 것이다. 디자인 패턴은 특별한 상황에서 발생하는 문제에 대한 좀 더 구체적인 솔루션이라고 한다면 객체지향 설계 원칙은 좀 더 일반적인 상황에서 적용 가능한 설계 기준이라고 볼 수 있다.

 

 

* 높은 응집도와 낮은 결합도

개방 폐쇄 원칙은 높은 응집도와 낮은 결합도라는 소프트웨어 개발의 고전적인 원리로도 설명이 가능하다.

 

높은 응집도(High Coherence)

응집도가 높다는 것은 하나의 모듈이나 클래스가 하나의 책임 혹은 관심사에만 집중되어 있다는 뜻이다. 불필요하거나 직접 관련이 없는 외부 관심과 책임이 얽혀 있지 않으며 하나의 공통 관심사는 하나의 클래스에 모여 있다.

응집도가 높다는 것은 변화가 일어날 때 모듈에서 변하는 부분이 크다는 것으로 설명할 수 있다. 만일 모륟의 일부분에만 변경이 일어나도 된다면 모듈 전체에서 어떤 부분이 바뀌어야 하는지 파악해야 하고 그 변경으로 인해 바뀌지 않는 부분에는 다른 영향을 미치지는 않는지 확인해야 하는 이중의 부담이 생긴다.

이전 예제에서 UserDao 클래스는 데이터를 처리하는 기능이 흩어져있지 않고 DAO 안에 깔끔하게 모여있다. 그 자체로 자신의 책임에 대한 응집도가 높은 것이다. ConnectionMaker도 자신의 기능에 충실하도록 독립되어 일부를 수정하더라도 작업이 전체적으로 일어나고 무엇을 변경할지 명확하다. 또한 수정을 하더라도 UserDao 등 다른 클래스의 수정을 요구하지 않는다.

 

낮은 결합도(Low Coupling)

결합도란 '하나의 오브젝트가 변경이 일어날 때에 관계를 맺고 있는 다른 오브젝트에서 변화를 요구하는 정도'이다. 이전 예제에서 ConnectionMaker 인터페이스의 도입으로 인해 DB 연결 기능을 구현한 클래스가 바뀌더라도 UserDao의 코드는 영향을 받지 않게 되었다. 이는 ConnectionMaker와 UserDao의 결합도가 낮아졌음을 의미한다.

책임과 관심사가 다른 오브젝트나 모듈과는 느슨하게 연결된 형태를 유지하는 것이 바람직하다. 꼭 필요한 최소한의 방법만 간접적인 형태로 제공하고 나머지는 서로 독립적이고 알 필요 없도록 만들어주는 것이다.

결합도가 낮아지면 변화에 대응하는 속도가 높아지고, 구성이 깔끔해지며 확장에 용이해진다. 반대로 결합도가 높아지면 변경에 따르는 작업량이 많아지고 변경으로 인하여 버그가 발생할 가능성이 높아진다.

 

 

* 전략 패턴(Strategy Pattern)

UserDaoTest - UserDao - ConnectionMaker 구조를 디자인 패턴의 시각으로 보면 전략 패턴에 해당한다.

자신의 기능 맥락(Context)에서 필요에 따라 변경이 필요한 알고리즘(* 독립적인 책임으로 분리가 가능한 기능)을 인터페이스를 통해 통째로 외부로 분리시키고 이를 구현한 구체적 알고리즘 클래스를 필요에 따라 바꿔서 사용할 수 있도록 하는 디자인 패턴이다. 전략 패턴은 개방 폐쇄의 원칙 실현에도 가장 잘 들어맞는 패턴이다.

전략 패턴의 적용 방법은 컨텍스트(UserDao)를 사용하는 클라이언트(UserDaoTest)는 컨텍스트가 사용할 전략(ConnectionMaker를 구현한 클래스, DConnectionMaker)을 컨텍스트의 생성자 등을 통해 제공해주는 것이 일반적이다.

 

 

 

 

 

 

 

728x90

댓글