DI(Dependency Injection) 이란?
클래스의 역할을 명확히 구분하여 구현한 뒤, 각각의 클래스가 서비스를 구성하도록 서로 참조하도록 하는 것이다.
이를 DI, 의존관계 주입이라고 부른다.
- 컴파일 타임 의존성 (Compile-time Dependency)
- 컴파일 시점에 결정되는 의존성으로, 코드가 컴파일될 때 의존하는 모든 클래스와 라이브러리가 필요함.
- 고정된 의존성: 컴파일 시점에 모든 의존성이 결정되므로, 의존성을 변경하려면 코드를 수정하고 재컴파일 필요.
- 조기 오류 발견: 잘못된 의존성은 컴파일 시점에 발견되어 런타임 오류를 줄일 수 있음.
- 런타임 의존성 (Run-time Dependency)
- 런타임 시점에 결정되는 의존성으로, 프로그램이 실행되는 동안 동적으로 의존성이 주입됨.
- 유연한 의존성 관리: 의존성을 실행 중에 변경할 수 있어, 코드 변경 없이도 의존성을 교체 가능.
- 지연된 오류 발견: 잘못된 의존성은 런타임 시점에 발견될 수 있어, 컴파일 시점에 오류를 잡기 어려움.
DI의 장점은 다음과 같다.
- 클래스들은 자신의 핵심 기술에 집중할 수 있게 해준다.
- 의존성 관리가 중앙화되어 유지보수가 쉬워진다.
- 각 컴포넌트의 결합도가 낮아져 유연성과 확장성이 증가한다.
스프링에서 DI를 사용하여 서비스를 구현하는 것은 아주아주 흔한 일이다. 특히 @Autowired
, @Controller
, @Service
, @Repository
와 같은 애노테이션을 지원하여 매우 쉽게 의존관계 주입을 할 수 있다.
또한 주입과정에서 타입을 인터페이스로 지정하고, 구현체를 인터페이스의 구현 클래스를 주입하여 추후 요구사항 변화에 대비할 수 있는 것이 DI의 장점인 것은 스프링 하는 사람이라면 다들 잘 알 것이다.
그럼 스프링을 사용하여 코드를 짤 때, 반드시 인터페이스가 존재해야 할까?
막상 코드를 짜보면, Interface를 사용하는 것 자체로 이미 추상화 비용이 발생한다. 그리고 상당히 크다.
나도 가장 최근 프로젝트에선, JDBC에서 Hibernate & JPA로의 변경을 고려하여 Repository 클래스만 인터페이스를 만들고, 나머지 IoC 컨테이너의 객체는 클래스를 바로 구현했다.
AOP 란?
관점지향으로도 불리는 AOP(Aspect Oriented Programming)은,
어떤 로직이 있을 때 이를 핵심적인 관점, 부가적인 관점으로 나누어서 생각하고 그 관점을 기준으로 각각 모듈화 하겠다는 프로그래밍 철학이다.
핵심적인 관점 : 개발자가 구현하고자 하는 핵심적인 비즈니스 로직
부가적인 관점 : 트랜잭션 처리, 로깅, 파일 입출력 등등...
일반적으로 Dependency Injection을 한다면 AOP를 떠올리지 않아도 된다.
그러나 대부분의 서비스 개발에는 트랜잭션 경계가 사용되며, @Transactional
애노테이션을 사용한다는 것은 관점지향 프로그래밍의 시작이다.
Reflection 이란?
Reflection은 런타임에 클래스의 동작을 검사하거나 조작할 때 사용되는 프로세스이다.
Java 코드는 컴파일 후에 바이트코드라고 불리는 .class
파일로 변환되게 되는데, 애플리케이션을 실행 중에 일부 인터페이스, 클래스, 메서드, 필드에 대해 바이트코드를 수정할 필요가 존재한다.
이를 위해 Java 코드는 Reflection을 제공하여 코드의 변경을 가능하게 해준다.
또한, 이걸 이용하면 런타임에 객체를 동적으로 생성할 수도 있다.
Spring이나 Hibernate, Lombok 같은 프레임워크는 이미 Reflection을 포함한다.
이 Reflection의 연장선에, Proxy와 Dynamic Proxy가 있다.
Proxy 란?
Java에서 대리자 역할을 하는 객체로서, 실제 객체에 대한 접근을 제어하거나 추가적인 기능을 제공하는데 Proxy 객체를 사용한다.
이로 인해 실제 객체에 부가기능을 추가하지 않고도 프로그래밍이 가능해지며, SOLID원칙 중 SRP 원칙을 지켜 코드를 설계할 수 있다.
그러나 Java에서 Proxy 기능을 직접 활용하려면, 고려해야 할 점이 매우 많아지며 코드의 중복을 피할 수 없다.
인터페이스 기반 Proxy
인터페이스를 구현하는(implements) 실제 객체를 참조하는 Proxy 객체를 추가로 생성한다.
// interface public interface BookService { void printTitle(Book book); } // RealSubject public class BookServiceImpl implements BookService{ @Override public void printTitle(Book book) { System.out.println("book.getTitle() = " + book.getTitle()); } } // proxy public class BookServiceImplProxy implements BookService{ BookService bookService = new BookServiceImpl(); @Override public void printTitle(Book book) { System.out.println("prev prev method"); bookService.printTitle(book); System.out.println("prev after method"); } } // main public static void main(String[]] args) { BookService BookService = new BookServiceImplProxy(); bookServiceImplProxy.printTitle(new Book("foobar")); // output // prev prev method // book.getTitle() = foobar // prev after method }
이는 코드의 중복을 피할 수 없고, 구현과정에서 고려해야 하는 부분이 매우 많기 때문에 실제로는 Java의 Dynamic Proxy를 사용한다.
클래스 기반 Proxy
인터페이스가 아닌 클래스를 상속하는 방식으로 프록시를 만들 수도 있다.
이때 CGLib이라는 라이브러리를 사용하며, 클래스의 메서드를 오버라이드하여 프록시 기능을 구현한다.
CGLIB는 바이트코드를 조작하여 런타임에 프록시 객체를 생성한다.
JDK Dynamic Proxy 란?
Proxy의 단점을 극복하기 위해 만들어진 API이다.
Dynamic Proxy란 이전과 같이 Proxy 객체를 직접 생성을 하는 것이 아니라 Runtime(애플리케이션이 실행되는 중)에 Interface를 구현하는 Class or 인스턴스를 만들어내는 것을 이야기한다.
public static Object newProxyInstance( ClassLoader loader, // 1 Class<?>[]] interfaces, // 2 InvocationHandler h // 3 ) throws IllegalArgumentException
- Proxy객체 정의하기 위한 Class Loader를 지정한다. (Proxy 객체가 구현할 Interface의 Class Loader를 얻어오는 것이 일반적)
- newProxyInstance()를 통해 생성될Proxy 객체가 구현할 Interface를 정의한다.
- 메서드 호출을 디스패치하기 위한 호출 핸들러. (디스패치: 어떤 메서드를 호출할 것인가를 결정하여 그것을 실행하는 과정을 이야기함.)
Invoke() 메서드 란?
Dynamic Proxy를 사용할 때, 프록시 객체가 특정 인터페이스를 구현하고 그 인터페이스의 메서드가 호출되면, 해당 호출은 자동으로 InvocationHandler 인터페이스의 invoke 메서드로 전달된다.
즉, 모든 메서드 호출은 invoke 메서드를 통해 처리된다.
이를 통해 AOP(관점지향프로그래밍)가 가능해진다.
공통된 Aspect를 다양한 클래스에 포함하여 구현할 때, Invoke() 메서드를 통해 공통 Aspects 들을 적용하고 기존의 호출을 처리하는 방식으로 진행이 된다.
그런데 스프링은 인터페이스를 반드시 포함하지 않아도 돌아간다?
위에서 언급했듯이, 나는 가장 최근에 진행한 프로젝트에서 IoC 컨테이너로 로딩되는 몇몇 클래스에 대해 인터페이스를 따로 정의하지 않고 구현했다.
그래도 돌아가더라. 스프링은 이걸 어떻게 처리하는 걸까?
JDK Dynamic Proxy를 이용한 스프링의 AOP Proxy의 동작원리
스프링은 자체적인 검증 로직을 통해 해당 클래스가 특정 인터페이스를 구현하는지 여부를 파악하고, JDK Dynamic Proxy를 사용할지, CGLIB을 사용할지 결정한다고 한다.
이때 만약 타깃이 하나 이상의 인터페이스를 구현하고 있는 클래스라면 JDK Dynamic Proxy의 방식으로 생성되고 인터페이스를 구현하지 않은 클래스라면 CGLIB의 방식으로 AOP 프록시를 생성한다.
자체 검증 로직을 거쳐 JDK Dynamic Proxy는 인터페이스를 기준으로 Proxy Bean에 객체를 등록하기 때문에, 반드시 @Autowired
를 사용한 Proxy Bean 등록에 있어서, 타입을 인터페이스로 지정해주어야 한다.
그렇지 않으면 아래와 같은 코드를 작성하여 런타임 에러를 발생할 수 있다고 한다.
@Controller public class UserController{ @Autowired private MemberService memberService; // <- Runtime Error 발생... ... } @Service public class MemberService implements UserService{ @Override public Map<String, Object> findUserId(Map<String, Object> params){ ...isLogic return params; } }
런타임 에러가 발생하는 이유는 다음과 같다.
MemberService가 UserService를 implements 하기 때문에 JDK Dynamic Proxy를 사용하여 Proxy Bean에 등록되는데, Controller에서 DI를 받는 타입이 MemberService이기 때문에 런타임 에러가 발생한다.
invoke()의 성능의 차이
위에서 언급한 Class 기반 Proxy에서는 CGLib이라는 바이트코드 조작 라이브러리를 사용하여 Dependency Injection이 가능하도록 해준다.
CGLib을 사용하여 바이트코드를 조작하는 방식은, 조작하는 타깃의 정보를 제공받으므로 결과적으로는 성능에 있어서 JDK Dynamic Proxy 보다 좋다고 한다.
초반엔 CGLib의 사용이 보편적이지 않았다.
- default 생성자가 반드시 필요하던 문제,
- 생성자가 2번 호출되는 문제점들이 존재했는데 이제는 시간이 지나 이러한 문제점들이 개선되었고 Spring Core 패키지에 포함되어 스프링은 성능이 좋은 CGLib을 현재 사용 중이다.
CGLib이 Spring Framework에 공식적으로 포함된 시점은 3.2 버전이며, 2012년 12월이다.
즉 이후에도 매우 최적화가 되어 이제는 CGLib을 사용하는 것 자체는 문제가 되지 않는다.
결론
JDK Dynamic Proxy는 비교적 단순한 구조로 이루어지는대신, invoke() 메서드의 method call 시간이 상대적으로 길다.
CGLib은 바이트코드 조작이라는 복잡한 구조로 진행되어 메모리 비효율적일 수 있지만 시간이 지나며 많은 개선이 이루어졌고, 현재는 스프링 프레임워크 또한 공식적으로 이를 포함한다.
그러나 Spring Framework는 Java를 기반으로 하며, 객체지향 프로그래밍의 SOLID 원칙을 따라 프로그래밍하는 것이 Spring의 설계철학에 올바른 행위라고 생각된다.
실제로 Spring Framework 3.2는 CGLib을 포함했지만 여전히 검증로직에 의해 JDK Dynamic Proxy를 최우선으로 사용한다.
즉 인터페이스를 포함시켜서 프로그래밍 하는것이 여전히 권장되는 점이라고 생각한다.
CGLib의 시작은 쉽게 건들지 손을 댈 수 없는 레거시 클래스나 변경이 불가능한 클래스가 있을 때에도 스프링의 AOP를 적용할 수 있도록, 자바 언어의 기본적인 다이나믹 프록시 대신 cglib을 활용해서 클래스 상속을 통해서 억지로 동일 타입의 다른 구현을 하나 넣은, 일종의 트릭이라는 의견도 있었다.
느낀 점
역시 처음 할 때가 제일 쉬운듯하다... 동작원리에 대해 고민하고 이런저런 글들을 읽으면서 정리해 보니 이해가 되는데 머릿속은 좀 더 복잡해진 느낌이다. 그래도 성장하지 않았을까?...ㅎ
또 이런 주제까지 깊게 들어올 줄 몰랐다. 동아리에 있는 초고수 선배님들이 멋있어 보였다.
결국 돌고 돌아 객체지향프로그래밍이다. 어떻게 하면 이 코드가 좋은 코드가 될 수 있는지에 대한 고민은 결국 SOLID원칙을 기반으로 시작되는 고민인 것 같다. ~공부하자~
참고 링크
1. https://velog.io/@dev_leewoooo/Reflection이란
2. https://velog.io/@dev_leewoooo/Proxy-pattern이란-with-Java
3. https://velog.io/@dev_leewoooo/Dynamic-Proxy란
4. https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html