|
코드의 가치를 높이는 필수 디자인 패턴
레거시 코드에 DI 적용하기
스프링이 자바 세계에 몰고 온 강력한 프로그래밍 모델인 DI는 이제 사실상 자바의 표준 프로그래밍 모델이자, 대부분의 기술이 저마다 지원한다고 홍보하는 필수 디자인 패턴이 되었다. 과연 DI란 무엇이고 왜 필요한 것이며 어떤 장점을 주는가? 기존에 작성된 레거시 코드에 DI를 적용하는 과정을 통해 DI가 무엇인지, 어떤 장점을 주는 것인지, 어떻게 기존 코드에 DI를 적용할 수 있는지 알아보자.
DI(Depdency Injection)는 이제 누구도 부인할 수 없는 자바의 표준 프로그래밍 모델이다. 최신 기술과 표준 치고 DI를 전면에 내세우지 않는 것이 없다. 얼마 전 최종 스펙이 모두 승인이 나고 공개된 차세대 엔터프라이즈 자바 표준인 Java EE 6에는 Dependency Injection이라는 단어가 들어간 스펙이 2개나 포함되었다. JSR-330(Dependency Injection for Java)과 JSR-299(Contexts and Dependency Injection for Java EE Platform)이다. JSR-330은 추상화된 범용적인 DI의 기본 애노테이션을 정의한 것이고, JSR-299는 이를 자바 엔터프라이즈 환경에 특화된 프로그래밍 모델로 좀 더 구체화시킨 것이다. 구글에서 개발되는 주요한 자바 프로젝트에는 구글주스(Google Guice)라는 DI 프레임워크가 사용된다.
구글주스는 심지어 모바일 환경인 안드로이드 플랫폼에서도 사용된다. DI를 소개하고 유행시킨 DI의 대명사이자 원조라고 불릴 수 있는 스프링 프레임워크는 이미 자바 엔터프라이즈의 사실상의 표준 플랫폼이라고 여겨질 만큼 보편화되고 있다. 비표준 오픈소스 DI 프레임워크인 스프링은 심지어 Java EE 표준을 지원하는 상용 서버를 개발하는 데 사용되기도 했다.
DI의 기본 - 기본으로 돌아가자
이렇게 표준, 비표준, 상용, 오픈소스 등을 가리지 않고 너도 나도 지원 경쟁을 벌이고 있는 DI란 과연 무엇일까? DI는 기술인가? 프레임워크 또는 서버 제품인가? 아니면 느슨한 프로그래밍 모델인가? 아니면 하나의 디자인 패턴인가? DI가 무엇인지를 설명할 수 있는 많은 방법과 이야기들이 있다. 나는 그 중에서 스프링을 창시한 로드 존슨이 이야기한 “J2EE보다는 자바가, 자바보다는 OO가 더 중요하다”라는 말에서 DI에 대한 기본을 설명할 수 있는 힌트를 얻을 수 있다고 생각한다.
DI는 기본적으로 자바 엔터프라이즈 기술이 아니다. DI 프레임워크나 기술이 가장 많이 적용되는 곳이 자바 엔터프라이즈긴 하지만, 그것은 자바가 엔터프라이즈 시스템 개발에 많이 사용되기 때문에 자연스럽게 그렇게 보일 뿐이다. 그렇다면 DI는 자바 기술인가? 그렇다고도 볼 수 없다. DI는 이미 자바 외에도 닷넷이나 파이썬과 같은 다른 객체지향(OO) 언어에도 널리 보급되어 있다.
결국 따지고 보면 DI는 객체지향에 관한 어떤 것이라고 볼 수 있다. 자바 언어 스펙의 서문에서 정의되어 있는 것처럼 자바는 객체지향 언어이다. 좋은 자바 프로그램은 객체지향적이다. 마찬가지로 좋은 자바 엔터프라이즈 애플리케이션도 객체지향적이어야 한다.
로드 존슨은 원래 DI 대신 IoC(Inversion of Control)라는 용어를 사용했다. 나중에 IoC를 구현하는 메커니즘에 따라서 다시 DL(Dependency Lookup)과 DI(Dependency Injection)로 구분했고, 그 중에서 더 많이 사용되는 DI가 대표적인 이름이 되었다. 이 IoC라는 용어는 사실 객체지향 설계에 등장하는 용어이다. 이 용어가 잘 설명된 것으로 자바가 공개되기도 전에 이미 출간된 책인 『Design Patterns: Elements of Reusable Object-Oriented Software』를 들 수 있다. 자바 개발자들도 잘 아는 GoF의 디자인패턴 책이다. 이 책의 개요에 해당하는 1장에 보면 바로 이 IoC에 대한 이야기가 나온다. 디자인 패턴의 핵심을 설명하면서 IoC를 이야기한 데서 중요한 힌트를 하나 얻을 수 있다. 그것은 바로 객체지향 디자인 패턴과 DI는 매우 밀접한 연관관계를 가진다는 사실이다.
DI하면 떠오르는 하나의 그림을 생각해 보자. GoF 책에 나오는 디자인 패턴들을 잘 살펴보면 <그림 1>이 매우 많이 나온다. 어떻게 보면 디자인 패턴의 솔루션들은 다 똑같은 구조를 가진 게 아닌가 싶을 만큼 유사하다. 하나의 클래스(Client)가 다른 인터페이스(Service)를 사용하는 구조이다. 클래스가 인터페이스를 사용한다는 말은 모델링 시점에서 보기에는 인터페이스에만 의존하고 있다는 뜻이다. 그래서 런타임 시에는 그 인터페이스를 구현한 어떤 클래스로도 변경할 수 있다. 런타임에서는 인터페이스가 의미 없다. 그 때는 오브젝트가 오브젝트를 사용해야 하고, 그러려면 2개의 구체 클래스가 필요하다.
디자인 패턴은 설계 과정에서 만나게 되는 반복적인 문제에 대해 객체지향 설계의 장점을 활용하기 위한 다양한 솔루션을 제공한 것이다. 객체지향 설계의 장점은 유연한 확장성과 재사용성이다. 기존에 개발한 코드는 그 자신의 기능이 바뀌지 않는 한 손대지 않고 그대로 재사용할 수 있으면서, 그 코드가 사용하는 기능, 즉 확장 기능은 얼마든지 새로운 것으로 바꿀 수 있다는 것이다. 이를 잘 표현한 용어가 객체지향 설계원칙에 등장하는 개방폐쇄 원칙(OCP)이다.
GoF의 디자인패턴은 바로 이 개방폐쇄 원칙을 잘 지키는 구조로 설계하면 많은 문제를 해결할 수 있다는 설계이론이다. <그림 1>의 구조를 생각해 보자. 객체지향 기술이란 오브젝트와 오브젝트 사이의 사용관계 또는 의존관계를 통해 프로그램을 개발하는 기술이다. GoF의 책은 이 2개의 오브젝트 사이의 의존관계를 오브젝트의 기반이 되는 클래스를 설계할 때 직접 연결시키지 말고 그 사이에 인터페이스를 두도록 하는 것이 매우 유용하다고 설명한다. 그러면 Client 코드는 구체적인 클래스, 예를 들어 ConcreteServiceA 또는 ConcreteServiceB에 의존하지 않게 된다. 물론 런타임 시에는 구체적인 클래스로 만든 오브젝트인 ConcreteServiceA 오브젝트나 ConcreteServiceB 오브젝트 중의 하나를 사용하게 될 것이다. 하지만 Client 클래스는 단지 인터페이스인 Service만을 사용하고 있기 때문에 런타임 시에 어떤 구체적인 클래스를 사용하든 상관없다. 나중에 Concrete ServiceC가 추가되고, ConcreteServiceD를 사용할 때가 되더라도 Client 코드는 단 한 줄도 수정하지 않을 수 있다. 이 구조를 다양한 문제에 적용하면 이상적인 솔루션이 나온다. 그래서 GoF 디자인 패턴에 나오는 패턴의 대부분은 바로 이런 구조(이를 Object Composition - 객체합성이라고 부른다)를 가지고 있다.
그런데 이 그림은 DI를 설명할 때 흔히 등장하는 그림이라고 이야기했다. 무슨 얘기인가 하면, 디자인 패턴에서 객체지향적인 설계를 이용해서 만든 유연한 확장성을 가지는 솔루션의 구조와 DI에서 말하는 구조가 똑같다는 이야기이다. 결국 DI란 결국 객체합성이라는 구조를 통해서 유연한 확장성을 가지는 객체지향 설계를 위한 것이라는 말이 된다.
DI에는 항상 사용하는 쪽(클라이언트)과 사용되는 쪽(서비스)이 존재한다. 위에서 말한 이런 구조를 통한 유연한 확장성을 얻기 위해서는 한 가지 중요한 전제가 있다. 그것은 모델링 시점 또는 코드를 작성하는 시점에서는 클라이언트 클래스는 서비스 인터페이스 외에는 다른 구체적인 클래스에 대한 정보를 가지고 있으면 안 된다는 것이다. ConcreteServiceA를 쓰게 될지 ConcreteServiceB를 쓰게 될지는 런타임 시점에서 알게 되어야지 미리 알고 있으면 안 된다는 것이다. 코드로 얘기하자면 클라이언트 클래스 안에는 new ConcreteServiceA()와 같은 코드가 절대 있으면 안 된다. Service 인터페이스와 그 메소드를 사용하는 것만 있어야 한다.
여기서 DI의 필요성이 대두되는 것이다. 코드에는 존재하지 않으나 런타임 시에는 필요로 한 정보, 어떤 Service 타입의 클래스를 사용할 것인지에 대한 작업을 외부에서 대신 해주는 런타임 의존관계 정보를 제공해 주는 존재가 필요하다. 마틴 파울러는 이를 어셈블러라고 불렀고, 스프링은 이를 빈 팩토리라고 했다. 다른 DI 기술에서는 이를 컨텍스트 또는 빈 매니저 등등의 다양한 이름으로 부르기도 한다. 이름이 어쨌든 상관없이 Client와 Service 인터페이스를 구현한 클래스에 대해 그 런타임 의존관계를 맺어주는 것이 바로 DI 기술이고 프레임워크이며 DI 패턴의 솔루션이고 프로그래밍 모델이다.
결국 DI는 객체합성 구조를 이용하는 다양한 객체지향 설계 패턴과 아키텍처에 대한 보조적인 도구이다. DI 기술은 2000년 대 중반 이후로 널리 보급된 최신의 첨단 기술일 수 있겠지만, DI가 추구하는 것은 이미 자바가 등장하기 전부터 보편화된 객체지향 설계와 개발 기술이다. 그래서 DI를 잘 사용한다는 것은 단지 DI 기술과 프레임워크의 사용법을 잘 아는 것으로는 충분하지 않다. 그보다는 개방폐쇄 원칙을 잘 따르는 유연한 설계를 하는 것이 우선이다. 그래서 애플리케이션이 적절하게 객체합성이라는 구조 속에서 잘 설계되어 있다면 DI를 적용하는 것은 아주 쉽다.
이렇게 DI와 객체지향 기술은 떼려야 뗄 수 없는 관계이고, DI를 더욱 잘 사용하고 그 장점을 얻으려고 노력하다 보면 자연스럽게 객체지향 설계 실력 또한 늘 수밖에 없다. 결국 DI를 적용한다는 것은 Java EE의 기반인 자바, 그리고 그 자바 언어의 기반인 객체지향 설계에 충실하게 되는 것이다. 최첨단 기술인 DI가 가장 기본 중의 기본인 객체지향 설계와 맞물려 있다는 것은 참 흥미롭다.
DI 적용 준비단계 - 유연한 객체지향 설계 적용하기
DI 적용 자체는 사실 특별한 것이 없다. 다양한 DI 기술이 제 각각 다른 설정방법을 제공하긴 하지만 그 핵심은 단 한 가지이다. <그림 1>과 같은 설계를 가졌다면 런타임 시에는 Client 오브젝트가 ConcreteServiceA 오브젝트를 사용할지 Concrete ServiceB를 사용할지, 아니면 또 다른 Service 인터페이스 구현 클래스를 사용할지를 정해주면 끝이다. 따라서 DI 적용 자체는 어려울 것이 하나도 없다. 어떤 DI 기술을 사용하더라도 그 때문에 코드와 설계가 바뀔 일도 없다.
그래서 DI 적용은 DI를 적용하기 위한 준비 단계에서 이미 99%는 끝났다고 봐도 된다. 반대로 DI 준비단계, 즉 객체지향 설계를 적용하지 않았다면 나머지 DI 프레임워크의 적용을 아무리 열심히 해봤자 결과는 시원찮을 것이다. 많은 개발자들이 그저 DI 프레임워크 사용법만 익히면 그만이겠거니 하고 그 준비단계를 무시하고 진행했다가 결과가 좋지 않자 “DI는 별게 아니구나”라고 오해하는 것이 바로 그런 이유이다.
DI 적용의 조건
이제 간단한 예제를 가지고 DI를 적용하기 위한 준비단계 작업을 해보자. <리스트 1>은 조금 길긴 하지만 내용은 간단하다. JDBC를 사용하는 전형적인 DAO 코드이다. User라는 사용자 정보를 담은 도메인 오브젝트를 이용해서 DB에 등록하는 것과 조회하는 기능을 담당하는 2개의 메소드를 가지고 있다.
이 DAO는 기능적으로는 아무런 문제가 없다. 물론 예외를 고려해서 try/catch/finally 구조로 만들었어야 마땅하지만 그러면 너무 코드 길이가 길어지므로 생략했다. 아무튼 이런 레거시 코드가 있다고 하자. 이런 코드에 DI를 적용할 수 있을까?
그 대답을 위해 DI를 위한 몇 가지 전제 조건을 생각해 보자. 일단 DI를 하려면 최소한 2개 이상의 클래스가 필요하다. DI란 오브젝트 간의 관계를 맺어주는 작업이므로 당연히 1개로는 불가능하다. 두 번째 조건은 DI에 참여할 오브젝트의 클래스들은 코드에서 서로의 존재를 직접 알고 있어서는 안 된다. Service 클래스는 ConcreteServiceA 클래스를 직접 알고 있으면 안 된다는 얘기다. 결국 인터페이스를 사이에 두고 객체 합성 구조로 연결되어 있어야 한다.
그렇다면 위의 코드에 DI를 적용할 수 있을까? 한 눈에 아님을 알 수 있다. DAO 클래스 하나를 가지고 DI를 얘기한다는 것은 말이 안 된다. 그런데, 사실은 DI 적용이 가능한 코드이다. 단지 그 준비과정이 필요한 레거시 코드일 뿐이다. 이 클래스에 객체지향적인 설계원칙들을 적용해 보기 시작한다면 DI를 위해 적절한 코드임이 금세 판명날 것이다.
역할에 따른 분리와 객체합성 구조 만들기
객체의 가장 중요한 특징은 데이터와 그것을 다루는 기능이 함께 들어 있어 그 자체로 독립적인 모듈을 구성한다는 것이다. 문제는 그 모듈을 나누는 단위이다. 얼마만큼의 기능을 모아서 하나의 모듈, 즉 하나의 객체로 구성할 것인가가 문제이다. 오브젝트의 구성을 정할 때 가장 먼저 생각해야 할 것은 하나의 오브젝트가 하나의 책임에만 충실한지를 따져보는 것이다. 이를 잘 설명한 것으로 단일책임원칙(SRP)이라는 객체지향 설계원칙을 들 수 있다. 단일책임 원칙은 말 그대로 ‘하나의 클래스 또는 모듈은 한 가지 책임만 가지고 있어야 한다‘는 뜻이다. 여기서 책임이란 ‘변경이 되는 이유’를 말한다. 하나의 책임을 가진 클래스는 그 변경이 일어나는 이유가 한 가지뿐이어야 한다. 만약 이를 위반한다면 SRP를 충족하지 못한 것이다. 하나의 클래스가 변경되는 이유가 2개라면 그 이유에 해당하는 코드를 분리해야 한다.
UserDao를 살펴보자. User라는 도메인 오브젝트를 받아서 저장하는 지극히 평범한 코드이다. 단순해 보이는 이 코드는 사실 두 가지 책임으로 구성되어 있다.
첫 번째 책임은 User라는 도메인 오브젝트에 담긴 정보를 JDBC를 이용해서 어떻게 저장할지에 대한 것이다. 그에 관련된 필드 이름이나 타입, API 사용 방법 등이 담겨져 있다. 만약 이 User 오브젝트 - DB 사이의 변환 방법이 바뀌면 클래스는 변경돼야 한다. 한 가지 이유이다.
그런데 한 가지 책임, 즉 변경돼야 할 이유를 더 발견할 수 있다. 바로 DB 연결방법이다. DriverManager.getConnection()을 이용해서 DB 연결 Connection을 만드는 것이 하나의 책임이 된다. 환경에 따라서 DB를 연결하는 방법은 다양하게 바뀔 수 있기 때문이다. 만약 단순한 DriverManager 대신 DB 연결 풀을 사용하는 것으로 연결방법이 바뀌게 되면 어떨까? 그때도 역시 이 클래스를 변경해야 한다. 애플리케이션에 포함된 DB 연결 풀 대신 서버가 제공하는 서버 DB 풀로부터 JNDI를 통해 DB 연결을 얻어 와야 한다면 어떨까? 역시 이 클래스는 수정된다.
결국 이 클래스가 변경되는 이유는 DB 연결방법이 바뀌는 것과 User를 DB에 매핑하는 방법이 변경되는 두 가지를 들 수 있다. UserDao가 변경될 이유가 2개이므로 단일책임 원칙을 위반했다. 그래서 이 두 가지 책임을 가진 코드를 분리해야만 한다. 분리할 때의 원칙은 DI 적용 조건 두 번째인 객체합성 구조를 통한 확장성이 고려돼야 한다는 말이다. 쉽게 말해서 DB 연결이라는 책임을 클래스로 분리시키면서 그 책임을 담은 클래스를 UserDao가 사용할 때 반드시 인터페이스를 이용해야지, 구체적인 클래스를 직접 알고 그것을 사용해서는 안 된다는 것이다.
DB 연결이라는 책임을 나타낼 수 있는 가장 좋은 인터페이스는 어떤 것일까? 간단히 하나 정의할 수도 있지만, 그보다는 이미 JDBC가 제공하는 것을 사용하는 것이 편리하다. 바로 JDBC 2.0부터 등장한 DataSource 인터페이스이다. DB 연결을 어떻게 하는지에 대한 구체적인 구현방법과 기술은 숨기고 getConnection()이라는 매우 단순한 메소드를 정의해서 이를 호출하기만 하면 되도록 만들어진 단순한 인터페이스이다. 이를 이용해서 <리스트 1>의 코드를 객체합성 구조로 변경해 보자. <그림 2>는 UserDao를 객체합성 구조를 적용해서 책임에 따라 분리한 클래스 구조이다.
UserDao의 코드는 이제 DataSource라는 인터페이스를 사용해서 DB 연결을 가져오도록 수정되었다. 인터페이스를 이용했으므로 구체적인 구현 클래스가 무엇으로 바뀌어도 상관없다. 자신이 변할 책임, 즉 이유가 발생하지 않는 한 UserDao의 코드는 변경되지 않는다. <리스트 2>는 이렇게 수정된 UserDao 코드이다. 코드를 지저분하게 했던 중복은 제거됐을 뿐더러 DB 연결의 구체적인 구현방식은 객체합성을 통해서 외부로 독립되었다. UserDao 자신은 DataSource라는 변하지 않을 인터페이스를 사용하는 코드가 되었다.
한 가지 눈 여겨 볼 것은 setDataSource()라는 수정자(setter) 메소드이다. 수정자 메소드를 두고 외부에서 DataSource 타입의 객체를 넣어줄 수 있도록 만들어 두는 것은 DI를 위해 꼭 필요한 준비사항이다. 이렇게 외부에서 사용할 객체를 넣어줄 수 있는 방법이 없다면, UserDao는 자기 스스로 구체적인 클래스 정보를 알고 이를 생성해야 하는데 그렇게 되면 객체합성의 핵심원칙과 가치가 깨져 버린다. 따라서 유연성 있는 확장이 가능한 객체합성을 위해서라도 반드시 외부에서 수정자를 이용해서 사용할 객체를 제공해 주는 통로가 있어야 한다. 수정자를 사용하는 것이 불편하다면 생성자를 이용하는 방법도 있다.
이제 독립시킨 DB 연결 부분을 살펴보자. DataSource 인터페이스는 구현해야 할 메소드가 여러 개이긴 하지만 가장 중요한 것은 DB 연결을 돌려주는 getConnection() 메소드이다. 앞에서 사용했던 DriverManager를 이용한 DB 연결 코드를 이용한 클래스를 만들어야 할 차례이다. 객체합성 구조는 개방폐쇄 원칙을 잘 지키는 코드를 만들어 준다. 그 말은 이제 UserDao 코드는 전혀 손대지 않고도 DB 연결 방식을 다양하게 만들고 바꿔서 사용할 수 있다는 뜻이다. <리스트 3>은 DriverManager를 이용한 단순한 DB 연결 구현방식을 적용한 클래스이다.
다음과 같이 SimpleDataSource 클래스의 인스턴스를 만든 뒤 UserDao의 setDataSource() 메소드에 전달하면 UserDao는 구체적인 클래스의 정체는 알지 못하지만 DataSource 인터페이스를 구현했기 때문에 아무런 문제없이 이 클래스의 오브젝트를 사용할 수 있다.
UserDao userDao = new UserDao();
DataSource dataSource = new SimpleDataSource();
userDao.setDataSource(dataSource); // Dependency Injection
DI는 사실 특별한 기술이 아니다. 위의 코드처럼 객체합성 구조에서 수정자 같은 주입 통로를 통해 사용할 객체를 런타임 시에 외부에서 넣어주는 것이 DI의 전부이다. DI 프레임워크란 위의 코드를 매번 작성하는 것이 번거롭기 때문에 XML과 같은 간단한 설정을 만들어주면 프레임워크가 알아서 이렇게 객체를 생성하고 주입하는 위 코드와 같은 작업을 대신 해 주는 것이다. 이제 DI를 위한 완벽한 준비가 끝났다. 원하는 DI 프레임워크를 선정하고 그 프레임워크의 사용방법을 따라서 런타임 시에 연결된 2개의 클래스를 설정하면 완벽하게 DI 적용 끝이다. 스프링이라면 다음과 같이 DI 작업을 위한 정보가 담긴 XML을 작성하면 될 것이다.
DI의 응용
객체지향 설계의 중요한 원칙을 적용해서 코드를 개선하다 보면 자연스럽게 DI를 위한 객체합성 구조가 만들어진다. 클래스 사이에 직접 연결점을 만들지 않고 인터페이스를 통하게 해서 자유로운 확장이 가능하도록 만드는 것까지 가능했다. 그리고 런타임 시에 실제 적용될 오브젝트를 만들고 오브젝트 사이에 수정자 등을 통해서 레퍼런스가 전달됨으로 런타임 의존관계가 형성이 되는 것까지 가능하게 되었다. 그렇다면 이렇게 DI가 가능하도록 레거시 코드를 개선해서 얻을 수 있는 장점이 무엇일까? 유연한 확장성이라는 장점을 줄곧 이야기했는데, 과연 그 유연한 확장성은 구체적으로 어떤 식으로 나타나는 것일까? 레거시 코드에 DI를 적용하는 수고를 한 덕에 얻을 수 있는 DI 구조의 장점과 응용방법을 알아보자.
구현내용 바꾸기
DI하면 가장 먼저 떠오르는 장점은 바로 코드를 수정하지 않은 채로 구현을 바꿀 수 있다는 점이다. 사용자가 폭발적으로 증가해서 SimpleDataSource로는 감당이 되지 않기 시작했다. 그래서 DB 연결 풀을 사용하는 구현으로 변경하기로 했다. 방법은 간단하다. 확장 포인트인 DataSource를 구현해서 DB 연결 풀을 구현하고 이 클래스를 DI의 설정 파일에 넣어주기만 하면 끝이다. DB 연결 풀은 아파치의 DBCP를 사용하도록 해보자. DBCP의 가장 단순한 BasicDataSource를 확장해서 다음과 같이 만들면 된다. BasicDataSource는 DataSource 인터페이스를 이미 구현한 것이므로 UserDao가 사용할 수 있다.
public class DbcpDataSource extends BasicDataSource {
public DbPoolDataSource() {
this.setDriverClassName(“org.apache.derby. jdbc.EmbeddedDriver”);
this.setUsername(“user”);
this.setPassword(“password”);
this.setUrl(“jdbc:derby:db”);
}
}
이제 사용하는 DI 프레임워크의 설정 파일에서 UserDao가 사용할 오브젝트의 클래스를 DbcpDataSource로 변경하면 된다. 기존 코드는 한 줄도 수정하지 않고 새로운 구현 방식을 적용할 수 있다. 이렇게 DI의 가장 기본적인 활용방법은 구현 클래스를 필요에 따라 바꿔가면서 사용하는 것이다. DI 방식으로 연결된 모든 객체 사이에 다 이 방법이 적용 가능하다.
단위 테스트를 위한 테스트 대역
DI의 가장 대표적인 적용방법은 구현 클래스를 변경하는 것이라고 했다. 그런데 어떤 시스템의 어떤 오브젝트는 한번 만들면 실전에서는 그 구현방식이 절대 변경되지 않는다면 어떨까? 세상에 ‘절대’라는 말만큼 별로 신뢰할 수 없는 말도 없겠지만, 일단 정말 그렇다고 해보자. 실전에서 DataSource의 구현 클래스를 영원히 변경하지 않고 한번 만든 것을 계속 사용할 것이다. 그렇다면 굳이 DI를 적용할 필요가 없었을까?
아니다. DI의 용도는 단지 운영 중에 사용할 클래스의 구현을 변경하는 것이 전부가 아니다. 만약 그랬다면 GoF 책에 나온 디자인 패턴 중에 <그림 1>과 같이 객체합성을 구조로 가지는 경우는 전략패턴(strategy pattern) 딱 하나 뿐이었을 것이다. 적어도 그 나머지 객체합성이 적용된 십여 개의 패턴과 또 그 책에는 등장하지 않는 객체합성을 응용한 수많은 기법에 모두 DI를 사용할 수 있다.
테스트를 생각해 보자. 테스트의 백미는 분명 단위 테스트이다. 단위 테스트는 고립 테스트라고도 불리는데 그 이유는 전체 시스템을 구성하는 수많은 객체들 중에서 테스트하고 싶은 대상 하나 또는 몇 개만을 완전히 고립시켜 놓고 테스트가 가능하기 때문이다. 고립 테스트는 테스트의 범위를 명확하게 해주고 빠르게 수행이 가능하기 때문에 많은 장점이 있다. 객체지향 세계의 객체는 항상 자신뿐 아니라 다른 객체와의 협력을 통해 동작한다. 다른 객체와 협력, 즉 다른 객체를 사용하지 않고 단독으로 모든 기능이 끝나는 객체는 기껏해야 단순한 유틸리티 객체뿐이다. 문제는 어떤 객체를 테스트하고자 할 때 그 테스트가 사용하는 다른 객체도 테스트에 자연스럽게 참여하게 된다는 것이다. 그러면 실제 테스트하고 싶은 객체에서 문제가 있지 않아도 그 객체가 사용하는 다른 객체, 또 그 다른 객체들이 사용하는 또 다른 객체들 때문에 테스트가 실패할 가능성이 있다. 그러면 테스트는 작성과 수행 모두 매우 어려워지고, 실패했을 경우 그 원인을 찾기도 힘들어진다. 그래서 테스트는 그 대상을 가능한 고립시켜야 한다.
테스트 관점에서 봤을 때 UserDao는 좀 복잡하다. UserDao의 기능을 수행시켜서 테스트를 하려면 반드시 DataSource라는 협력 객체가 필요하다. Connection 없이는 DAO 기능이 하나도 동작할 수 없다. 그리고 DataSource가 동작하기 위해서는 그 뒤에 사용할 DB와 그 드라이버도 필요하다. 만약 UserDao가 사용할 DataSource가 실전에서 사용할 메인 DB를 연결하도록 되어 있다고 해보자. 그것을 UserDao에 대한 테스트를 수행할 때마다 모두 참여하게 한다는 것은 매우 불합리하다. 만약 DB의 User 테이블 정보를 모두 삭제하는 기능을 테스트한다면 어떻게 될 것인가? 생각만 해도 끔찍하다.
그래서 테스트를 위해서는 별도로 준비된 빠르고 가벼운 DB를 사용하도록 해야 한다. 이왕이면 성능과 셋업에 불편이 없고 어디서든 실행 가능한 메모리 DB를 사용하는 것도 좋을 것이다. 특정 DB에 특화된 SQL을 사용하지만 않았다면 별 문제는 없다. 그렇다면 이때에도 DI는 빛을 발하게 된다. 테스트 환경에서는 UserDao가 메인 운영 DB를 연결하는 DbcpDataSource를 사용하지 않게 하고 대신 테스트 장비에 따로 준비된 가벼운 DB로 연결해주는 TestDataSource를 사용하도록 바꿔치기 한다. 테스트 환경에서 DI를 위한 XML을 따로 준비하기만 하면 된다. 역시 기존 코드는 전혀 수정할 것 없이 DI를 통해 다른 구현 클래스를 넣어주는 것만으로 UserDao가 테스트를 이용하게 만들 수 있게 되었다.
이렇게 테스트를 위해 특별히 준비된 TestDataSource와 같은 객체를 테스트 대역(Test Double)이라고 부른다. 좀 더 상세히 들어가면 목 오브젝트, 스텁, 스파이, 가짜 오브젝트 등으로 세분화할 수 있는데 상세한 것은 여기서 설명하지 않겠지만, 이런 테스트 대역의 한 가지 공통점은 바로 DI를 통해 적용된다는 점이다. DI를 위한 준비가 없었다면 불가능한 일이다. 이렇게 비록 운영 중에 새로운 구현으로 바꿔 사용하지 않는다 하더라도 DI는 테스트에서 사용되는 것만으로도 큰 의미와 가치가 있다.
투명한 부가기능의 추가
DI가 적용된 코드의 기원이라고 할 수 있는 GoF의 디자인 패턴을 잘 살펴보면 DI를 응용하는 기법을 많이 배울 수 있다. 그 중에서 가장 주목할 만한 것은 바로 데코레이터 패턴이다. 자바의 IO 패키지를 경험해본 사람이라면 데코레이터 패턴에 대해 잘 알 것이다. 데코레이터 패턴의 핵심은 기존 클라이언트가 서비스를 사용하는 구조와 그 구현 코드를 전혀 수정하지 않은 채로 그 사이에 참여해서 새로운 부가기능을 추가해주는 패턴이다. 데코레이터 패턴은 DI에 적용하기에 자연스럽고 매우 유용한 패턴이다. 데코레이터 패턴의 구조가 바로 객체합성이기 때문이다.
UserDao -> DataSource 구조를 그대로 유지한 채로 새로운 부가 기능을 넣는다고 생각해보자. 예를 들어 DAO의 성능과 사용성을 평가하기 위해 하루에 몇 번 DAO가 DB 연결을 요청하는지 통계를 내보려고 한다. 내용은 매우 간단하다. getCon nection() 메소드를 호출할 때마다 카운터를 하나씩 증가시켜 두기만 하면 된다. 그리고 나중에 그 카운터의 값을 가져올 수 있으면 된다. 이를 어떻게 구현해야 할까? 가장 무식한 방법은 SimpleDataSource나 DbcpDataSource의 코드를 뜯어고치는 일이다. 인스턴스 변수에 카운터를 달고 getConnection() 메소드가 호출될 때마다 하나씩 증가하도록 코드를 수정한다. 물론 구현은 특별한 건 아니지만 임시로 사용할 기능을 위해 핵심적인 코드를 수정한다는 것은 전혀 객체지향적이지 않은 나이브(naive)한 발상일 뿐이다. 이때는 데코레이터 패턴을 적용하면 간단히 원하는 결과를 얻을 수 있다. 데코레이터 패턴의 목적대로 기존 코드에는 영향을 주지 않으면서 새로운 부가 기능을 추가할 수 있기 때문이다.
데코레이터 패턴의 아이디어는 간단하다. Client->Service 구조에선 실제 Service에 해당하는 것은 Service를 구현한 어떤 클래스이다. 보통은 이것을 ConcreteServiceA와 같은 실제 Service 인터페이스가 정의한 내용을 구현한 클래스를 사용하지만, 원한다면 전혀 다른 목적으로 만들어진 클래스를 사용해도 된다. Service 인터페이스를 구현해야만 Client가 사용해줄 수 있다는 점만 확실히 하면 된다. 그래서 이를 Client -> Service (Decorator) -> Service(ConcreteServiceA)와 같은 구조로 만드는 것이다. 데코레이터가 마치 진짜 Service의 구현 객체인 것처럼 Client에게 전달된다. 그리고 데코레이터는 자신의 일을 하고 나서 실제 Service의 구현 객체를 다시 호출한다. 기존 호출구조에 자연스럽게 끼어드는 일종의 인터셉터와 같은 방식이다.
실제 코드를 살펴보자. <리스트 4>는 DB 연결 카운트를 해주는 데코레이터이다. DbConnectionCounter는 실제로 카운트를 증가시키는 것 외에는 아무런 특별한 일을 하지 않는다. 대신 자신에게 DI된 진짜 DataSource 구현 클래스에게 모든 DataSource 메소드의 요청을 그대로 위임할 뿐이다. 따라서 클라이언트인 UserDao가 실제 서비스인 DbcpDataSource를 사용하는 데는 아무런 문제가 없다. 기존과 동일한 방식으로 동작한다. 대신 이 데코레이터가 그 과정에 참여해서 DB 연결횟수 카운팅이라는 새로운 부가기능을 제공하는 것이다.
DI를 위한 설정은 <리스트 5>와 같이 만들면 된다. 당연히 기존 코드는 단 한 글자도 수정할 필요가 없다. 단지 DI 설정을 바꿔주기만 하면 바로 DB 연결 횟수를 알 수 있는 카운터 기능이 추가적으로 동작한다.
이렇게 추가한 데코레이터는 언제든지 필요 없어지면 제거해도 된다. 역시 코드는 한 줄도 수정하지 않고 말이다. 데코레이터를 DI에 응용하는 방법은 DI의 응용기술의 하나인 AOP의 핵심원리이기도 하다.
미래를 위한 작업 - 레거시 코드에 DI 적용하기
자바의 역사가 15년이 다 되어가지만 아직도 자바의 객체지향 언어로서의 특징과 장점을 잘 살린 코드를 작성하는 것은 쉬운 일이 아니다. 일단 기능이 동작하면 그만이라는 근시안적인 발상과 무책임한 태도가 낳은 전혀 객체지향적이지 않은 코드가 아직도 많이 존재한다. 유연한 확장성은 0에 가까우며 기능을 추가하고 코드에 손대기가 무서운 코드들도 즐비하다. 한 치 앞만 보고 코드를 만들었기 때문이다.
객체지향은 미래를 바라보게 하는 기술이다. 오늘만 쓰고 버릴 코드가 아니라면, 미래에도 재사용될 수 있고, 앞으로 기능이 바뀌고 추가되어도 아무런 문제가 없도록 할 수 있는 멋진 코드를 만들 수 있게 해주는 프로그래밍 패러다임이다. 자바 개발자라면 그 핵심가치를 포기하면 안 된다.
이미 기존에 작성된 레거시 코드도 마음먹는다면 충분히 미래에도 가치를 드러낼 수 있는 멋진 코드로 탈바꿈할 수 있다. 그 좋은 도구와 방법은 바로 DI의 적용이다. DI를 적용할 수 있는 준비과정을 철저하게 지켜나가고, 그리고 좋은 DI 도구와 프레임워크를 사용하도록 코드를 만들어 놓는다면 언젠가 그 코드를 다시 만지고 수정해야 할 날에 큰 보람을 느낄 수 있다. 혹시 자신은 떠나고 다른 개발자가 이어받았을 때 그에게서 감탄과 감사의 마음을 얻을 수도 있다. 그것만으로도 레거시 코드를 DI로 탈바꿈하는 수고에 대한 충분한 보상이 될 것이다.
참고자료
1. Expert One-on-One J2EE Design and Development : Rod Johnson : Wrox
2. Expert One-on-One J2EE Development without EJB : Rod Johnson : Wrox
3. Design Pattern: Elements of Reusable Object-Oriented Software: GoF: Addison-Wesley Professional
필자소개
이일민 tobyilee@gmail.com|뛰어난 오픈소스 기술을 엔터프라이즈 시스템에 적용할 수 있는 전략과 기술연구에 많은 관심을 가지고 있다. 스프링과 하이버네이트를 대표로 하는 오픈소스 프레임워크의 교육, 컨설팅, 기술지원을 제공하고 있는 Epril의 대표 컨설턴트로 활동하고 있다. 토비의 이프릴(toby.epril.com)이라는 블로그를 운영하고 있다.
출처 : 한국 마이크로 소프트웨어 [2010년 1월호]
|
첫댓글 좋은강좌 정말 감사합니다.