의존관계 주입(Dependency Injection)이란
의존관계 주입(DI)이란 구체적인 의존 오브젝트와 그것을 사용할 주체 오브젝트를 런타임 시에 연결해주는 작업을 말한다.
조금 더 구체적으로는 다음과 같은 세 가지 조건을 충족하는 작업을 말한다.
- 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다.
- 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제 3의 존재가 결정한다.
- 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 주입해줌으로써 만들어진다.
이러한 의존관계 주입은 유연성, 재사용성, 테스트 용이성 등 여러 가지 장점을 가져다주는데, 여기서는 객체 지향 설계 원칙인 단일 책임 원칙(SRP)과 개방-폐쇠 원칙(OCP)을 지킬 수 있도록 도와주는 의존관계 주입에 대해 알아보도록 하자.
단일 책임 원칙(Single Responsibility Principle)
- 하나의 클래스는 한 가지 책임을 가져야 한다.
- 하나의 모듈이 바뀌는 이유는 한 가지여야 한다.
개방-폐쇠 원칙(Open/Closed Principle)
- 클래나 모듈은 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
의존관계 주입 도입하기
먼저 다음 예제 코드를 보도록 하자.
public class UserService {
private UserDao userDao;
private DataSource dataSource;
public UserService(UserDao userDao, DataSource dataSource) {
this.userDao = userDao;
this.dataSource = dataSource;
}
public void upgradeLevels() {
// JDBC 트랜잭션 추상 오브젝트 생성
PlatformTransactionManager transactionManager =
new DataSourceTransactionManager(dataSource);
// 트랜잭션 시작
// 필요에 따라 DB 커넥션을 가져오는 작업도 같이 수행
TransactionStatus status =
transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
...
}
UserDao의 구현체는 현재 JDBC를 사용하고 있다고 가정하자.
위 코드는 업그레이드 조건에 만족하는 사용자들의 레벨을 업그레이드하는 메서드로, 일련의 작업을 하나의 트랜잭션으로 묶기 위해 스프링이 제공하는 트랜잭션 경계 설정을 위한 추상 인터페이스인 PlatformTransactionManager를 사용하여 트랜잭션의 경계를 설정하고 있다.
JDBC의 로컬 트랜잭션을 이용한다면 위와 같이 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 된다.
그런데 여기서 UserDao의 구현체가 하이버네이트를 사용하도록 바뀌었다면 어떻게 될까?
PlatformTransactionManager의 구현체를 DataSourceTransactionManager에서 HibernateTransactionManager로 변경해야 할 것이다.
PlatformTransactionManager transactionManager =
new HibernateTransactionManager();
여기서 다시 UserDao의 구현체가 바뀐다면 또 PlatformTransactionManager의 구현체를 바꿔줘야 할 것이다. 결국 트랜잭션 관리 방법이 달라질 때마다 UserService의 코드가 변경되게 되는데, 이는 어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 직접 알고 있기 때문에 발생하는 문제점이며, DI를 도입하여 개선해볼 수 있다.
즉, 자신이 사용할 구체적인 클래스를 스스로 결정하여 생성하지 말고 외부에서 제공받게 하는 것이다. 아래 코드를 통해 확인해보도록 하자.
@Service
public class UserService {
private UserDao userDao;
private PlatformTransactionManager transactionManager;
public UserService(UserDao userDao, PlatformTransactionManager transactionManager) {
this.userDao = userDao;
this.transactionManager = transactionManager;
}
public void upgradeLevels() {
// 트랜잭션 시작
// 필요에 따라 DB 커넥션을 가져오는 작업도 같이 수행
TransactionStatus status =
transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
}
...
}
@Configuration
public class TransactionConfig {
@Autowired
private DataSource dataSource;
@Bean
public PlatformTransactionManager platformTransactionManager() {
return new DataSourceTransactionManager(dataSource);
}
}
UserService에 PlatformTransactionManager 인터페이스 타입의 인스턴스 변수를 선언하고, 생성자를 통해 DI가 가능하도록 하였다. 이를 통해 이제는 트랜잭션 매니저의 구현 클래스가 변경되더라도 UserService 의 코드는 전혀 손 댈 필요가 없어졌다!
의존관계 주입의 장점
그렇다면 이렇게 의존관계 주입을 도입하였을 때 어떠한 장점이 따라올까?
앞서 언급하였듯이 의존관계 주입은 단일 책임 원칙(SRP)과 개방-폐쇠 원칙(OCP)을 지킬 수 있도록 도와준다. 즉, 객체 지향적인 프로그래밍이 가능하도록 도와준다는 것이다.
사실 의존관계 주입 사용 시 객체 지향 설계 원칙 중 의존관계 역전 원칙(DIP)도 지켜진다.
의존관계 역전 원칙(Dependency Inversion Principle)
- 구체화에 의존하지 말고 추상화에 의존해야 한다는 것으로, 쉽게 말하면 구현 클래스에 의존하지 말고 인터페이스에 의존하라는 것이다.
단일 책임 원칙(SRP)
먼저 단일 책임 원칙에 대해 알아보면, 이는 하나의 클래스는 한 가지 책임을 가져야 한다는 것이다.
의존관계 주입을 적용하기 전에 코드를 보면 UserService는 아래와 같이 두 가지 책임을 가지고 있다.
책임 1. 어떻게 트랜잭션을 관리할 것인가
PlatformTransactionManager transactionManager =
new DataSourceTransactionManager(dataSource);
책임2. 어떻게 사용자 레벨을 관리할 것인가
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
두 가지 책임을 가지고 있다는 것은 UserService가 수정되는 이유가 두 가지라는 것이다.
사용자의 레벨 업그레이드 정책과 같은 사용자 관리 로직이 바뀐다면 당연히 UserService를 수정해야 한다. 그런데 사용자 관리 로직이 아닌 트랜잭션 관리 로직을 변경해야 한다면 어떨까? 이때도 다른 트랜잭션 매니저를 사용하도록 UserService의 코드를 변경해야 한다.
결국 UserService는 변경의 이유가 두 가지가 되기 때문에 단일 책임 원칙을 지키지 못하고 있다.
하지만 DI를 적용하여 트랜잭션 매니저를 외부에서 주입받도록 변경한 뒤로는 어떻게 됐을까?
UserService가 오직 사용자 관리 로직에 대한 책임만을 가지고 있다. 즉, UserService가 바뀔 이유가 한 가지뿐이라는 것이다. 사용자 관리 로직이 바뀌거나 추가되지 않는 한 UserService의 코드는 손댈 이유가 없다.
트랜잭션 기술이 바뀌거나 UserDao의 구현 기술이 JDBC에서 JPA로 바뀌더라도 UserService 코드는 수정할 필요가 없고, 결과적으로 단일 책임 원칙을 지킬 수 있게 된 것이다.
개방-폐쇠 원칙(OCP)
개방-폐쇠 원칙은 확장에는 열려 있으나, 변경에는 닫혀 있어야 한다는 것이다. 의존관계 주입을 적용하기 전에는 트랜잭션 매니저의 구현체를 직접 의존하고 있었기 때문에 다른 트랜잭션 매니저로 변경하려면 UserService의 코드를 수정해야만 했다. 즉, 변경에 닫혀있지 않았다.
// PlatformTransactionManager transactionManager =
// new DataSourceTransactionManager(dataSource);
PlatformTransactionManager transactionManager =
new HibernateTransactionManager(); // 구현체 변경 시 코드 변경이 일어남
하지만 DI를 적용한 뒤에는 생성자를 통해 외부에서 트랜잭션 매니저를 주입받기 때문에 사용하려는 트랜잭션 매니저의 구현체가 변경되더라도 UserService에는 전혀 영향을 미치지 않는다. 결과적으로 UserService의 코드를 수정하지 않으면서(변경에는 닫혀있고) 트랜잭션 관리 방법을 확장할 수 있게 된 것이다(확장에는 열려있다).
정리
의존관계 주입(DI)는 이처럼 개발자가 객체 지향적으로 개발할 수 있도록 도와준다. 의존관계 주입을 사용하면 인터페이스를 두고 사용할 오브젝트에 대한 레퍼런스를 외부에서 주입해줌으로써 런타임에 다이나믹하게 의존관계가 맺어지기 때문에 유연성을 확보할 수 있고, 객체 간의 결합도를 낮출 수 있다.
이로 인해 서로의 변경이 영향을 주지 않으면서 자유롭게 확장될 수 있는 구조를 만들 수 있고, 결국 관심, 책임, 성격이 다른 코드를 깔끔하게 분리할 수 있게 된다.
Reference
'Backend > Spring' 카테고리의 다른 글
테스트 격리하기 (0) | 2022.05.04 |
---|---|
트랜잭션 전파 알아보기 (0) | 2022.04.26 |
AOP와 빈 후처리기를 이용한 부가 기능 분리 (0) | 2022.03.26 |
다이내믹 프록시를 활용한 JPA QueryCounter 구현기 (0) | 2022.03.20 |
다이내믹 프록시를 이용한 부가 기능 분리 (0) | 2022.03.12 |