토비의 스프링 8장 (스프링이란 무엇인가?)

스프링이란 무엇인가?

스프링의 정의

스프링이란 어떤 것이다라고 한마디로 정의하기는 쉽지 않다. 스프링에 대해 가장 잘 알려진 정의는 이렇다.

자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량급 애플리케이션 프레임워크

정의를 봐도 스프링이 무엇인지 감이 바로 오지는 않는다. 하지만 이 정의에는 스프링의 중요한 특징이 잘 담겨 있다.

  • 애플리케이션 프레임워크

    일반적으로 라이브러리나 프레임워크는 특정 업무 분야나 한 가지 기술에 특화된 목표를 가지고 만들어진다. 그러나 애플리케이션 프레임워크는 조금 다르다.
    애플리케이션 프레임워크는 특정 계층이나, 기술, 업무 분야에 국한되지 않고 애플리케이션의 전 영역을 포괄하는 범용적인 프레임워크를 말한다. 애플리케이션 프레임워크는 애플리케이션 개발의 전 과정을 빠르고 편리하며 효율적으로 진행하는데 일차적인 목표를 두는 프레임워크다.
    단지 여러 계층의 다양한 기술을 한데 모아뒀기 때문에 애플리케이션 프레임워크라고 불리는 건 아니다. 애플리케이션의 전 영역을 관통하는 일관된 프로그래밍 모델과 핵심 기술을 바탕으로 해서 각 분야의 특성에 맞는 필요를 채워주고 있기 때문이다.

  • 경량급

    스프링이 경량급이라는 건 스프링 자체가 아주 가볍다거나 작은 규모의 코드로 이뤄졌다는 뜻은 아니다.
    그럼에도 가볍다고 하는 이유는 불필요하게 무겁지 않다는 의미다. 특히 스프링이 처음 등장하던 시절의 자바 주류 기술이었던 예전의 EJB 같은 과도한 엔지니어링이 적용된 기술과 스프링을 대비시켜 설명하려고 사용했던 표현이다.
    스프링은 가장 단순한 서버환경인 톰캣(Tomcat)이나 제티(Jetty)에서도 완벽하게 동작한다. 단순한 개발툴과 기본적인 개발환경으로도 엔터프라이즈 개발에서 필요로 하는 주요한 기능을 갖춘 애플리케이션을 개발하기에 충분하다. 스프링의 장점은 그런 가볍고 단순한 환경에서도 복잡한 EJB와 고가의 WAS를 갖춰야만 가능했던 엔터프라이즈 개발의 고급 기술을 대부분 사용할 수 있다는 점이다.
    결과적으로 스프링은 EJB를 대표로 하는 기존의 많은 기술이 불필요하게 무겁고 복잡했음을 증명한 셈이고, 그런 면에서 스프링은 군더더기 없이 깔끔한 기술을 가진 ‘경량급’ 프레임워크라고 불린 것이다.
    만들어진 코드가 지원하는 기술수준은 비슷하더라도 그것을 훨씬 빠르고 간편하게 작성하게 해줌으로써 생산성과 품질 면에서 유리하다는 것이 바로 경량급이라는 말로 표현되는 스프링의 특징이다.

  • 자바 엔터프라이즈 개발을 편하게

    스프링은 근본적인 부분에서 엔터프라이즈 개발의 복잡함을 제거해내고 진정으로 개발을 편하게 해주는 해결책을 제시한다. 단순히 편리한 몇 가지 도구나 기능을 제공해주는 차원이 아니다.
    편리한 애플리케이션 개발이란 개발자가 복잡하고 실수하기 쉬운 로우레벨 기술에 많은 신경을 쓰지 않으면서도 애플리케이션의 핵심인 사용자의 요구사항, 즉 비즈니스 로직을 빠르고 효과적으로 구현하는 것을 말한다.

  • 오픈소스

스프링의 목적

스프링을 사용해서 엔터프라이즈 애플리케이션 개발을 편하게 하려는 이유는 뭘까? 원래 엔터프라이즈 개발이란 편하지 않기 때문이다.

엔터프라이즈 개발의 복잡함

자바 엔터프라이즈(JavaEE) 개발이 실패하는 가장 대표적인 이유는 ‘엔터프라이즈 시스템 개발이 너무 복잡해져서’였다.

복잡합의 근본적인 이유

엔터프라이즈 시스템 개발이 복잡한 원인은 크게 두 가지가 있다. (엔터프라이즈 시스템이란 서버에서 동작하며 기업과 조직의 업무를 처리해주는 시스템을 말한다.)

  • 기술적인 제약조건과 요구사항이 늘어가기 때문이다.
  • 엔터프라이즈 애플리케이션이 구현해야 할 핵심기능인 비즈니스 로직의 복잡함이 증가하기 때문이다.

전통적인 자바 엔터프라이즈 개발 기법은 대부분 비즈니스 로직의 복잡한 구현 코드와 엔터프라이즈 서비스를 이용하는 기술적인 코드가 자꾸 혼재될 수 밖에 없는 방식이었다. 결국 개발자가 동시에 그 두 가지를 모두 신경 써서 개발해야 하는 과도한 부담을 줬고, 그에 따라 전체적인 복잡함은 몇 배로 가중됐다.

복잡함을 해결하려는 도전

제거될 수 없는 근본적인 복잡함

엔터프라이즈 개발의 근본적인 복잡함의 원인은 제거할 대상은 아니다. 현실적으로는 불가능하기 때문이다.

근본적으로 엔터프라이즈 개발에 나타나는 복잡함의 원인은 제거 대상이 아니다. 대신 그 복잡함을 효과적으로 상대할 수 있는 전략과 기법이 필요하다. 문제는 비즈니스 로직의 복잡함을 효과적으로 다루기 위한 방법과 기술적인 복잡함을 효과적으로 처리하는 데 적용되는 방법이 다르다는 점이다. 따라서 두 가지 복잡함이 코드에 한데 어우러져 나타나는 전통적인 개발 방식에서는 효과적으로 복잡함을 다루기가 힘들다. 따라서 가장 먼저 할 일은 성격이 다른 이 두 가지 복잡함을 분리해내는 것이다.

복잡함을 상대하는 스프링의 전략

스프링의 기본적인 전략은 비즈니스 로직을 담은 애플리케이션 코드와 엔터프라이즈 기술을 처리하는 코드를 분리시키는 것이다. 이 분리를 통해 두 가지 복잡함의 문제를 효과적으로 공략하게 해준다.

기술적 복잡함을 상대하는 전략

기술적인 복잡함을 분리해서 생각하면 그것을 효과적으로 상대할 수 있는 적절한 전략을 발견할 수 있다. 스프링은 엔터프라이즈 기술을 적용했을 때 발생하는 복잡함의 문제를 두 가지로 분류하고 각각에 대한 적절한 대응 방법을 제공한다.

  • 첫 번째 문제 : 기술에 대한 접근 방식이 일관성이 없고, 특정 환경에 종속적이다.

    일관성 없는 기술과 서버환경의 변화에 대한 스프링의 공략 방법은 바로 서비스 추상화다. 앞에서 보았던 트랜잭션 추상화, OXM 추상화, 데이터 액세스에 관한 일관된 예외변환 기능, 데이터 액세스 기술에 독립적으로 적용 가능한 트랜잭션 동기화 기법 등이 대포적인 예다. 기술적인 복잡합은 일단 추상화를 통해 로우레벨의 기술 구현 부분과 기술을 사용하는 인터페이스를 분리하고, 환경과 세부기술에 독립적인 접근 인터페이스를 제공하는 것이 가장 좋은 해결책이다.

  • 두 번째 문제 : 기술적인 처리를 담당하는 코드가 성격이 다른 코드에 섞여서 등장한다.

    책임에 따라 계층을 구분하고 그 사이에 서로의 기술과 특성에 의존적인 인터페이스나 예외처리 등을 최대한 제거한다고 할지라도 근본적으로 엔터프라이즈 서비스를 적용하는 한 이런 문제는 쉽게 해결할 수 없다. 이런 기술과 비즈니스 로직의 혼재로 발생하는 복잡함을 해결하기 위한 스프링의 접근 방법은 바로 AOP다.
    AOP는 최후까지 애플리케이션 로직을 담당하는 코드에 남아 있는 기술 관련 코드를 깔끔하게 분리해서 별도의 모듈로 관리하게 해주는 강력한 기술이다.

비즈니스와 애플리케이션 로직의 복잡함을 상대하는 전략

비즈니스 로직의 복잡함을 상대하는 전략은 자바라는 객체지향 기술 그 자체다. 스프링은 단지 객체지향 언어의 장점을 제대로 살리지 못하게 방해했던 요소를 제거하도록 도와줄 뿐이다.

핵심 도구 : 객체지향과 DI

객체지향의 설계 기법을 잘 적용할 수 있는 구조를 만들기 위해 DI 같은 유용한 기술을 편하게 적용하도록 도와주는 것이 스프링의 기본 전략이다.

지금까지 보았듯이 기술적인 복잡함을 효과적으로 다루게 해주는 기법은 모두 DI를 바탕으로 하고 있다. 서비스 추상화, 템플릿/콜백, AOP와 같은 스프링의 기술은 DI 없이는 존재할 수 없는 것들이다.

그리고 DI는 객체지향 설계 기술 없이는 그 존재의미가 없다. DI란 특별한 기술이라기보다는 유연하게 확장할 수 있는 오브젝트 설계를 하다 보면 자연스럽게 적용하게 되는 객체지향 프로그래밍 기법일 뿐이다. 스프링은 단지 그것을 더욱 편하고 쉽게 사용하도록 도와줄 뿐이다.

기술적인 복잡함을 해결하는 문제나 기술적인 복잡함이 비즈니스 로직에 침범하지 못하도록 분리하는 경우에도 DI가 바탕이 된 여러 가지 기법이 활용된다. 반면에 비즈니스 로직 자체의 복잡함을 해결하려면 DI보다는 객체지향 설계 기법이 더 중요하다.

POJO 프로그래밍

스프링 핵심 개발자들은 “스프링의 정수(essence)”는 엔터프라이즈 서비스 기능을 POJO에 제공하는 것”이라고 했다. 엔터프라이즈 서비스라고 하는 것은 보안, 트랜잭션과 같은 엔터프라이즈 시스템에서 요구되는 기술을 말한다. 이런 기술을 POJO에 제공한다는 말은, 뒤집어 생각해보면 엔터프라이즈 서비스 기술과 POJO라는 애플리케이션 로직을 담은 코드를 분리했다는 뜻이기도 하다. ‘분리됐지만 반드시 필요한 엔터프라이즈 서비스 기술을 POJO 방식으로 개발된 애플리케이션 핵심 로직을 담은 코드에 제공한다’는 것이 스프링의 가장 강력한 특징과 목표다.

스프링의 핵심 : POJO

스프링 애플리케이션은 POJO를 이용해서 만든 애플리케이션 코드와, POJO가 어떻게 관계를 맺고 동작하는지를 정의해놓은 설계정보로 구분된다. DI의 기본 아이디어는 유연하게 확장 가능한 오브젝트를 만들어두고 그 관계는 외부에서 다이내믹하게 설정해준다는 것이다. 이런 DI의 개념을 애플리케이션 전반에 걸쳐 적용하는 것이 스프링의 프로그래밍 모델이다.

스프링의 주요 기술인 IoC/DI, AOP, 서비스추상화는 애플리케이션을 POJO로 개발할 수 있게 해주는 가능기술이라고 불린다.

POJO란 무엇인가?

POJO는 Plain Old Java Object의 첫 글자를 따서 만든 약자다.

POJO의 조건

단순하게 보자면 그냥 평범한 자바오브젝트라고 할 수 있지만 좀 더 명확하게 하자면 적어도 다음의 조건을 충족해야 POJO라고 불릴 수 있다.

  • 특정 규약에 종속되지 않는다.

    POJO는 자바 언어와 꼭 필요한 API 외에는 종속되지 않아야 한다. 따라서 EJB2와 같이 특정 규약을 따라 비즈니스 컴포넌트를 만들어야 하는 경우는 POJO가 아니다. 특정 규약을 따라 만들게 하는 경우는 대부분 규약에서 제시하는 특정 클래스를 상속하도록 요구한다. 그럴 경우 자바의 단일 상속 제한 때문에 더 이상 해당 클래스에 객체지향적인 설계 기법을 적용하기가 어려워지는 문제가 생긴다. 또한 규약이 적용된 환경에 종속적이 되기 때문에 다른 환경으로 이전이 힘들다는 문제점이 있다.

  • 특정 환경에 종속되지 않는다.

    어떤 경우는 특정 벤더의 서버나 특정 기업의 프레임워크 안에서만 동작 가능한 코드로 작성되기도 한다. 또 환경에 종속적인 클래스나 API를 직접 쓴 경우도 있다. 순수한 애플리케이션 로직을 담고 있는 오브젝트 코드가 특정 환경에 종속되게 만드는 경우라면 그것 역시 POJO라고 할 수 없다. POJO는 환경에 독립적이어야한다.
    특히 비즈니스 로직을 담고 있는 POJO 클래스는 웹이라는 환경정보나 웹 기술을 담고 있는 클래스나 인터페이스를 사용해서는 안된다. 비즈니스 로직을 담은 코드에 HttpServletRequest나 HttpSession, 캐시와 관련된 API가 등장하거나 웹 프레임워크의 클래스를 직접 이용하는 부분이 있다면 그것은 진정한 POJO라고 볼 수 없다.
    단지 자바의 문법을 지키고, 순수하게 JavaSE API만을 사용했다고 해서 그 코드를 POJO라고 할 수는 없다. POJO는 객체지향적인 자바 언어의 기본에 충실하게 만들어져야 하기 때문이다.

POJO의 장점

POJO가 될 수 있는 조건이 그대로 POJO의 장점이 된다.

특정한 기술과 환경에 종속되지 않는 오브젝트는 그만큼 깔끔한 코드가 될 수 있다. 로우레벨의 기술과 환경에 종속적인 코드가 비즈니스 로직과 함께 섞여 나오는 것만큼 지저분하고 복잡한 코드도 없다.

POJO로 개발된 코드는 자동화된 테스트에 매우 유리하다. 환경의 제약은 코드의 자동화된 테스트를 어렵게 한다. 컨테이너에서만 동작을 확인할 수 있는 EJB 2는 테스트하려면 서버의 구동 및 빌드와 배치 과정까지 필요하다. 자동화된 테스트가 불가능한 건 아니지만 매우 복잡하고 번거로우므로 대부분 수동 테스트 방식을 선호한다. 그에 반해 어떤 환경에도 종속되지 않은 POJO 코드는 매우 유연한 방식으로 원하는 레벨에서 코드를 빠르고 명확하게 테스트할 수 있다.

객체지향적인 설계를 자유롭게 적용할 수 있다는 것도 큰 장점이다.

POJO 프레임워크

스프링은 POJO를 이용한 엔터프라이즈 애플리케이션 개발을 목적으로 하는 프레임워크이다. POJO 프로그래밍이 가능하도록 기술적인 기반을 제공하는 프레임워크를 POJO 프레임워크라고 한다. 스프링은 엔터프라이즈 애플리케이션 개발의 모든 영역과 계층에서 POJO 방식의 구현이 가능하게 하려는 목적으로 만들어졌다.

스프링을 이용하면 POJO 프로그래밍의 장점을 그대로 살려서 엔터프라이즈 애플리케이션의 핵심 로직을 객체지향적인 POJO를 기반으로 깔끔하게 구현하고, 동시에 엔터프라이즈 환경의 각종 서비스와 기술적인 필요를 POJO 방식으로 만들어진 코드에 적용할 수 있다.

스프링은 비즈니스 로직의 복잡함과 엔터프라이즈 기술의 복잡함을 분리해서 구성할 수 있게 도와준다. 하지만 자신은 기술영역에만 관여하지 비즈니스 로직을 담당하는 POJO에서는 모습을 감춘다. 데이터 액세스 로직이나 웹 UI 로직을 다룰 때만 최소한의 방법으로 관여한다. POJO 프레임워크로서 스프링은 자신을 직접 노출하지 않으면서 애플리케이션을 POJO로 쉽게 개발할 수 있게 지원해준다.

스프링의 기술

제어의 역전(IoC) / 의존관계 주입(DI)

왜 두 개의 오브젝트를 분리해서 만들고, 인터페이스를 두고 느슨하게 연결한 뒤, 실제 사용할 대상은 DI를 통해 외부에서 지정하는 것일까? 직접 자신이 사용할 오브젝트를 new 키워드로 생성해서 사용하는 강한 결합을 쓰는 방법보다 나은 점은 무엇일까?

가장 간단한 답변은 **’유연한 확징이 가능하게 하기 위함’**이다. DI는 개방 폐쇄 원칙(OCP)이라는 객체지향 설계 원칙으로 잘 설명될 수 있다. 유연한 확장이라는 장점은 OCP의 ‘확장에는 열려 있다(개방)’에 해당한다. DI는 역시 OCP의 ‘변경에는 닫혀 있다(폐쇄)’라는 말로도 설명이 가능하다. 폐쇄 관점에서 볼 때 장점은 ‘재사용이 가능하다’라고 볼 수 있다.

DI의 활용 방법

  • 핵심기능의 변경

    DI의 가장 대표적인 적용 방법은 바로 의존 대상의 구현을 바꾸는 것이다. 디자인 패턴의 전략 패턴이 대표적인 예다. 실제 의존하는 대상이 가진 핵심기능을 DI 설정을 통해 변경하는 것이 대표적인 DI의 활용 방법이다.

  • 핵심기능의 동적인 변경

    두 번째 활용 방법은 첫 번째랑 비슷하게 의존 오브젝트의 핵심 기능 자체를 바꾸는 것이다. DI도 기본적으로는 런타임 시에 동적으로 의존 오브젝트를 연결해주는 것이긴 하지만, 일단 DI 되고 나면 그 후로는 바뀌지 않는다. 즉 동적인 방식으로 연결되지만 한 번 DI되면 바뀌지 않는 정적인 관계를 맺어주는 것이다. 하지만 DI를 잘 활용하면 애플리케이션이 동작하는 중간에 그 의존 대상을 다이내믹하게 변경할 수 있다.

  • 부가기능의 추가

    세 번째 활용 방법은 핵심기능은 그대로 둔 채로 부가기능을 추가하는 것이다. 데코레이터 패턴을 생각해보면 된다. 인터페이스를 두고 사용하게 하고, 실제 사용할 오브젝트는 외부에서 주입하는 DI를 적용해두면 데코레이터 패턴을 쉽게 적용할 수 있다. 그래서 핵심기능과 클라이언트 코드에는 전혀 영향을 주지 않으면서 부가적인 기능을 얼마든지 추가할 수 있다.

  • 인터페이스의 변경

    사용하려고 하는 오브젝트가 가진 인터페이스가 클라이언트와 호환되지 않는 경우가 있다. 이렇게 클라이언트가 사용하는 인터페이스와 실제 오브젝트 사이에 인터페이스가 일치하지 않는 경우에도 DI가 유용하다. 디자인 패턴에서 말하는 오브젝트 방식의 어댑터 패턴의 응용이라고 볼 수 있다.
    이를 좀 더 일반화해서 아예 인터페이스가 다른 다양한 구현을 같은 방식으로 사용하도록, 중간에 인터페이스 어댑터 역할을 해주는 레이어를 하나 추가하는 방법도 있다. 서비스 추상화(PSA)가 그런 방법이다. PSA는 클라이언트가 일관성 있게 사용할 수 있는 인터페이스를 정의해주고 DI를 통해 어댑터 역할을 하는 오브젝트를 이용하게 해준다. 이를 통해 다른 인터페이스를 가진 로우레벨의 기술을 변경하거나 확장해가면서 사용할 수 있는 것이다.

  • 프록시

    필요한 시점에서 실제 사용할 오브젝트를 초기화하고 리소스를 준비하게 해주는 지연된 로딩(lazy loading)을 적용하려면 프록시가 필요하다. 원격 오브젝트를 호출할 때 마치 로컬에 존재하는 오브젝트처럼 사용할 수 있게 해주는 원격 프록시를 적용하려고 할 때도 프록시가 필요하다. 두 가지 방법 모두 DI를 필요로 한다.

  • 템플릿과 콜백

    템플릿/콜백 패턴은 DI의 특별한 적용 방법이다. 반복적으로 등장하지만 항상 고정적인 작업 흐름과 그 사이에서 자주 바뀌는 부분을 분리해서 템플릿과 콜백으로 만들 고 이를 DI 원리를 응용해 적용하면 지저분하게 매번 만들어야 하는 코드를 간결하게 만들 수 있다.

  • 싱글톤과 오브젝트 스코프

    DI가 필요한 중요한 이유 중 한 가지는 DI 할 오브젝트의 생명주기를 제어할 수 있다는 것이다. DI를 프레임워크로 이용한다는 건 DI 대상 오브젝트를 컨테이너가 관리한다는 의미다. 오브젝트의 생성부터 관계 설정, 이용, 소멸에 이르기까지의 모든 과정을 DI 컨테이너가 주관하기 때문에 그 오브젝트의 스코프를 자유롭게 제어할 수 있다.
    스프링의 DI는 기본적으로 싱글톤으로 오브젝트를 만들어서 사용하게 한다. 컨테이너가 알아서 싱글톤으로 만들고 관리하기 때문에 클래스 자체는 싱글톤을 고려하지 않고 자유롭게 설계해도 된다는 장점이 있다.

  • 테스트

    다른 오브젝트와 협력해서 동작하는 오브젝트를 효과적으로 테스트하는 방법은 가능한 한 고립시키는 것이다. 즉 다른 오브젝트와의 사이에서 일어나는 일을 테스트를 위해 조작할 수 있도록 만든다. 그래야만 테스트 대상인 오브젝트의 기능에 충실하게 테스트가 가능하다. 복잡한 테스트할 대상에 의존하는 오브젝트를, 테스트를 목적으로 만들어진 목 오브젝트로 대체하면 유용하다.

애스펙트 지향 프로그래밍(AOP)

AOP도 스프링의 3개 기술중의 하나다. 사실 애스펙트 지향 프로그래밍은 객체지향 프로그래밍(OOP)처럼 독립적인 프로그래밍 패러다임이 아니다. AOP와 OOP는 서로 배타적이 아니라는 말이다.

객체지향 기술은 매우 성공적은 프로그래밍 방식임에 분명하다. 하지만 한편으로는 복잡해져 가는 애플리케이션의 요구조건과 기술적인 난해함을 모두 해결하는데 한계가 있기도 하다. AOP는 바로 이러한 객체지향 기술의 한계와 단점을 극복하도록 도와주는 보조적인 프로그래밍 기술이다.

IOC/DI를 이용해서 POJO에 선언적인 엔터프라이즈 서비스를 제공할 수 있지만 일부 서비스는 순수한 객체지향 기법만으로는 POJO의 조건을 유지한 채로 적용하기 힘들다. 바로 이런 문제를 해결하기 위해 AOP가 필요하다.

AOP 적용 기법

AOP를 자바 언어에 적용하는 기법은 크게 두 가지로 분류할 수 있다.

  • 스프링과 같이 다이내믹 프록시를 사용하는 방법

    이 방법은 기존 코드에 영향을 주지 않고 부가기능을 적용하게 해주는 데코레이터 패턴을 응용한 것이다. 만들기 쉽고 적용하기 간편하지만 부가기능을 부여할 수 있는 곳은 메소드의 호출이 일어나는 지점뿐이라는 제약이 있다. 인터페이스와 DI를 활용하는 데코레이터 패턴이 기반원리이기 때문이다.

  • 자바 언어의 한계를 넘어서는 언어의 확장을 이용하는 방법

    AspectJ라는 유명한 오픈소스 AOP 툴이 있다. AspectJ는 프록시 방식의 AOP에서는 불가능한 다양한 조인포인트를 제공한다. 메소드 호출뿐 아니라 인스턴스 생성, 필드 액세스, 특정 호출 경로를 가진 메소드 호출 등에도 부가기능을 제공할 수 있다. 이런 고급 AOP 기능을 적용하려면 자바 언어와 JDK의 지원만으로는 불가능하다. 그 대신 별도의 AOP 컴파일러를 이용한 빌드 과정을 거치거나, 클래스가 메모리로 로딩될 때 그 바이트 코드를 조작하는 위빙과 같은 별도의 방법을 이용해야 한다.

포터블 서비스 추상화(PSA)

세 번째 기능기술은 환경과 세부 기술의 변화에 관계없이 일관된 방식으로 기술에 접근 할 수 있게 해주는 PSA(Portable Service Abstraction)다. POJO로 개발된 코드는 특정 환경이나 구현 방식에 종속적이지 않아야 한다. 스프링은 JavaEE를 기존 플랫폼으로 하는 자바 엔터프라이즈 개발에 주로 사용된다. 따라서 다양한 JavaEE 기술에 의존적일 수밖에 없다 .특정 환경과 기술에 종속적이지 않다는 게 그런 기술을 사용하지 않는다는 뜻은 아니다. 다만 POJO 코드가 그런 기술에 직접 노출되어 만들어지지 않는다는 말이다. 이를 위해 스프링이 제공하는 대표적인 기술이 바로 일관성 있는 서비스 추상화 기술이다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

토비의 스프링 6장 (AOP)

AOP

AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술중 하나다.

AOP를 바르게 이용하려면 OOP를 대체하려고 하는 것처럼 보이는 AOP라는 이름 뒤에 감춰진, 그 필연적인 등장배경과 스프링이 그것을 도입한 이유, 그 적용을 통해 얻을 수 있는 장점이 무엇인지에 대한 충분한 이해가 필요하다.

스프링에 적용된 가장 인기 있는 AOP의 적용 대상은 바로 선언적 트랜잭션 기능이다. 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP를 이용해 더욱 세련되고 깔끔한 방식으로 바꿔보자.

트랜잭션 코드의 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void upgradeLevels() throws Exception {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

try {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}

this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e;
}
}

얼핏 보면 트랜잭션 경계설정 코드와 비즈니스 로직 코드가 복잡하게 얽혀 있는듯이 보이지만, 자세히 살펴보면 뚜렷하게 두 가지 종류의 코드가 구분되어 있음을 알 수 있다. 비즈니스 로직 코드를 사이에 두고 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치하고 있다.

또, 이 코드의 특징은 트랜잭션 경계설정의 코드와 비즈니스 로직 코드 간에 서로 주고받는 정보가 없다는 점이다. 다만 이 비즈니스 로직을 담당하는 코드가 트랜잭션의 시작과 종료 작업 사이에서 수행돼야 한다는 사항만 지켜지면 된다.

DI를 이용한 클래스의 분리

DI 적용을 이용한 트랜잭션 분리

지금 UserService는 UserServiceTest가 클라이언트가 되어서 사용하고 있다. 현재 구조는 UserService 클래스와 그 사용 클라이언트 간의 관계가 강한 결합도로 고정되어 있다. 그래서 UserService를 인터페이스로 만들고 기존 코드는 UserService 인터페이스의 구현 클래스를 만들어넣도록 한다. 그러면 클라이언트와 결합이 약해지고, 직접 구현 클래스에 의존하고 있지 않기 때문에 유연한 확장이 가능해진다.

그런데 보통 이렇게 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 쓰는 이유는, 일반적으로 구현 클래스를 바꿔가면서 사용하기 위해서다.

하지만 꼭 그래야 한다는 제약은 없다. 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내려는 것이다.

그래서 다음과 같은 구조를 생각해볼 수 있다. UserService를 구현한 또 다른 구현 클래스를 만든다. 이 클래스는 사용자 관리 로직을 담고 있는 구현 클래스인 UserServiceImpl을 대신하기 위해 만든 게 아니다. 단지 트랜잭션의 경계설정이라는 책임을 맡고 있을 뿐이다. 그리고 스스로는 비즈니스 로직을 담고 있지 않기 때문에 또 다른 비즈니스 로직을 담고 있는 UserService의 구현 클래스에 실제적인 로직 처리 작업은 위임하는 것이다.

UserService 인터페이스 도입

먼저 기존의 UserService 클래스를 UserServiceImpl로 이름을 변경한다. 그리고 클라이언트가 사용할 로직을 담은 핵심 메소드만 UserService 인터페이스로 만든 후 UserServiceImpl이 구현하도록 만든다.

UserService 인터페이스의 구현 클래스인 UserServiceImpl은 기존 UserService 클래스의 내용을 대부분 그대로 유지하면 된다. 단, 트랜잭션 관련된 코드는 독립시키기로 했으니 모두 제거한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserServiceImpl implements userService {
UserDao userDao;
MailSender mailSender;

public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}

...
}

분리된 트랜잭션 기능

비즈니스 트랜잭션 처리를 담은 UserServiceTx를 만들어보자. UserServiceTx는 기본적으로 UserService를 구현하게 만든다. 그리고 같은 인터페이스를 구현한 다른 오브젝트에게 고스란히 작업을 위임하게 만들면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserServiceTx implements UserService {
UserService userService;

// UserService를 구현한 다른 오브젝트를 DI 받는다.
public void setUserService(UserService userService) {
this.userService = userService;
}

// DI 받은 UserService 오브젝트에 모든 기능을 위임한다.
public void add(User user) {
userService.add(user);
}

public void upgradeLevels() {
userService.upgradeLevels();
}
}

UserServiceTx는 UserService 인터페이스를 구현했으니, 클라이언트에 대해 UserService 타입 오브젝트의 하나로서 행세할 수 있다. UserServiceTx는 사용자 관리라는 비즈니스 로직을 전혀 갖지 않고 고스란히 다른 UserService 구현 오브젝트에 기능을 위임한다. 이렇게 준비된 UserServiceTx에 트랜잭션의 경계설정이라는 부가적인 작업을 부여해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class UserServiceTx implements UserService {
UserService userService;
PlatformTransactionManager transactionManager;

public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}

public void setUserService(UserService userService) {
this.userService = userService;
}

public void add(User user) {
userService.add(user);
}

public void upgradeLevels() {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

try {
userService.upgradeLevels();

this.transactionManager.commit(status);
} catch (RuntimeException e) {
this.transactionManager.rollback(status);
throw e;
}
}
}

트랜잭션을 위한 DI 설정

클라이언트가 UserService라는 인터페이스를 통해 사용자 관리 로직을 이용하려고 할 때 먼저 트랜잭션을 담당하는 오브젝트가 사용돼서 트랜잭션에 관련된 작업을 진행해주고, 실제 사용자 관리 로직을 담은 오브젝트가 이후에 호출돼서 비즈니스 로직에 관련된 작업을 수행하도록 만든다.

트랜잭션 경계설정 코드 분리의 장점

  1. 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할 때는 트랜잭션과 같은 기술적인 내용에는 전혀 신경 쓰지 않아도 된다. 트랜잭션의 적용이 필요한지도 신경 쓰지 않아도 된다.
  2. 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있다.

고립된 단위 테스트

가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다.

하지만 현재 UserService는 UserDao, TransactionManager, MailSender라는 세 가지 의존관계를 갖고 있다. 따라서 그 세 가지 의존관계를 갖는 오브젝트들이 테스트가 진행되는 동안에 같이 실행된다. UserService라는 테스트 대상이 테스트 단위인 것처럼 보이지만 사실은 그 뒤에 의존관계를 따라 등장하는 오브젝트와 서비스, 환경 등이 모두 합쳐져 테스트 대상이 되는 것이다.

테스트 대상 오브젝트 고립시키기

그래서 테스트의 대상이 환경이나, 외부 서버, 다른 클래스의 코드에 종속되고 영향을 받지 않도록 고립시킬 필요가 있다. 테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 테스트를 위한 대역을 사용하는 것이다.

테스트를 위한 UserServiceImpl 고립

의존 오브젝트나 외부 서비스에 의존하지 않는 고립된 테스트 방식으로 만든 UserServiceImpl은 아무리 그 기능이 수행돼도 그 결과가 DB 등을 통해서 남지 않으니, 기존의 방법으로는 작업 결과를 검증하기 힘들다. upgradeLevels()처럼 결과가 리턴되지 않는 경우는 더더욱 그렇다.

그래서 이럴 땐 테스트 대상인 UserServiceImpl과 그 협력 오브젝트인 UserDao에게 어떤 요청을 했는지를 확인하는 작업이 필요하다. 테스트 중에 DB에 결과가 반영되지는 않았지만, UserDao의 update() 메소드를 호출하는 것을 확인할 수 있다면, 결국 DB에 그 결과가 반영될 것이라고 결론을 내릴 수 있기 때문이다. UserDao와 같은 역할을 하면서 UserServiceImpl과의 사이에서 주고받은 정보를 저장해뒀다가, 테스트의 검증에 사용할 수 있게 하는 목 오브젝트를 만들 필요가 있다.

단위 테스트와 통합 테스트

단위 테스트의 단위는 정하기 나름이다. 사용자 관리 기능 전체를 하나의 단위로 볼 수도 있고 하나의 클래스나 하나의 메소드를 단위로 볼 수도 있다. 중요한 것은 하나의 단위에 초점을 맞춘 테스트라는 점이다.

‘테스트 대상 클래스를 목 오브젝트 등의 테스트 대역을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트 하는 것’을 단위 테스트라고 부른다. 반면에 두 개 이상의, 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트는 통합 테스트라고 부른다. 통합 테스트란 두 개 이상의 단위가 결합해서 동작하면서 테스트가 수행되는 것이라고 보면 된다. 스프링의 테스트 컨텍스트 프레임워크를 이용해서 컨텍스트에서 생성되고 DI된 오브젝트를 테스트하는 것도 통합 테스트다.

아래는 단위 테스트와 통합 테스트 중에서 어떤 방법을 쓸지 어떻게 결장할 것인지에 대한 가이드 라인이다.

  • 항상 단위 테스트를 먼저 고려한다.
  • 하나의 클래스나 성격과 목적이 같은 긴밀한 클래스 몇 개를 모아서 외부와의 의존관계를 모두 차단하고 필요에 따라 스텁이나 목 오브젝트 등의 테스트 대역을 이용하도록 테스트를 만든다. 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며 테스트 대상 외의 코드나 환경으로부터 테스트 결과에 영향을 받지도 않기 때문에 가장 빠른 시간에 효과적인 태스트를 작성하기에 유리하다.
  • 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트로 만든다.
  • 단위 테스트로 만들기가 어려운 코드도 있다. 대표적인 게 DAO다. DAO는 그 자체로 로직을 담고 있기보다는 DB를 통해 로직을 수행하는 인터페이스와 같은 역할을 한다. SQL을 JDBC를 통해 실행하는 코드만으로는 고립된 테스트를 작성하기가 힘들다. 작성한다고 해도 가치가 없는 경우가 대부분이다. 따라서 DAO는 DB까지 연동하는 테스트로 만드는 편이 효과적이다. DB를 사용하는 테스트는 DB에 테스트 데이터를 준비하고, DB를 사용하는 테스트는 DB에 테스트 데이터를 준비하고, DB에 직접 확인을 하는 드의 부가적인 작업이 필요하다.
  • DAO 테스트는 DB라는 외부 리소스를 사용하기 때문에 통합 테스트로 분류된다. 하지만 코드에서 보자면 하나의 기능 단위를 테스트하는 것이기도 하다. DAO를 테스트를 통해 충분히 검증해두면, DAO를 이용하는 코드는 DAO 역할을 스텁이나 목 오브젝트로 대체해서 테스트할 수 있다. 이후에 실제 DAO와 연동했을 때도 바르게 동작하리라고 확신할 수 있다. 물론 각각의 단위 테스트가 성공했더라도 여러 개의 단위를 연결해서 테스트하면 오류가 발생할 수도 있다. 하지만 충분한 단위 테스트를 거친다면 통합 테스트에서 오류가 발생할 확률도 줄어들고 발생한다고 하더라도 쉽게 처리할 수 있다.
  • 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담은 상대적으로 줄어든다.
  • 단위 테스트를 만들기가 너무 복잡하다고 판단되는 코드는 처음부터 통합 테스트를 고려해본다. 이때도 통합 테스트에 참여하는 코드 중에서 가능한 한 많은 부분을 미리 단위 테스트로 검증해두는 게 유리하다.
  • 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트다. 간으하면 스프링의 지원 없이 직접 코드 레벨의 DI를 사용하면서 단위 테스트를 하는게 좋겠지만 스프링의 설정 자체도 테스트 대상이고, 스프링을 이용해 좀 더 추상적인 레벨에서 테스트해야 할 경우도 종종 있다. 이럴 땐 스프링 테스트 컨텍스트 프레임워크를 이용해 통합 테스트를 작성한다.

목 프레임워크

단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트의 사용이 필수적이다. 의존관계가 없는 단순한 클래스나 세부 로직을 검증하기 위해 메소드 단위로 테스트할 때가 아니라면, 대부분 의존 오브젝트를 필요로 하는 코드를 테스트하게 되기 때문이다.

목 오브젝트를 만드는 일은 번거로울 수 있다. 그러나 이런 번거로운 목 오브젝트를 편리하게 작성하도록 도와주는 다양한 목 오브젝트 지원 프레임워크가 있다.

Mockito 프레임워크

Mockito라는 프레임워크는 사용하기도 편리하고, 코드도 직관직이라 최근 많은 인기를 끌고 있다.

Mockito와 같은 목 프레임워크의 특징은 목 클래스를 일일이 준비해둘 필요가 없다는 것이다. 간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있다.

USerDao 인터페이스를 구현한 테스트용 목 오브젝트는 다음과 같이 Mockito의 스태틱 메소드를 한 번 호출해주면 만들어진다.

1
UserDao mockUserDao = mock(UserDao.class);

이렇게 만들어진 목 오브젝트는 아직 아무런 기능이 없다. 여기에 먼저 getAll() 메소드가 불려올 때 사용자 목록을 리턴하도록 스텁 기능을 추가해줘야 한다.

1
when(mockUserDao.getAll()).thenReturn(this.users);

mockUserDao.getAll()이 호출됐을 때(when), users 리스트를 리턴해주라(thenReturn)는 선언이다.

Mocktio를 통해 만들어진 목 오브젝트는 메소드의 호출과 관련된 모든 내용을 자동으로 저장해두고, 이를 간단한 메소드로 검증할 수 있게 해준다.

테스트를 진행하는 동안 mockUserDao의 update() 메소드가 두 번 호출됐는지 확인하고 싶다면, 다음과 같이 검증 코드를 넣어주면 된다.

1
verify(mockUserDao, times(2)).update(any(User.class));

User 타입의 오브젝트를 파라미터로 받으며 update() 메소드가 두 번 호출됐는지(times(2)) 확인하라(verify)는 것이다.

Mockito 목 오브젝트는 다음의 네 단계를 거쳐서 사용하면 된다. 두 번째와 네 번째는 각각 필요할 경우에만 사용할 수 있다.

  • 인터페이스를 이용해 목 오브젝트를 만든다.
  • 목 오브젝트가 리턴할 값이 있으면 이를 지정해준다. 메소드가 호출되면 예외를 강제로 던지게 만들 수도 있다.
  • 테스트 대상 오브젝트에 DI 해서 목 오브젝트가 테스트 중에 사용되도록 만든다.
  • 테스트 대상 오브젝트를 사용한 후에 목 오브젝트의 특정 메소드가 호출됐는지, 어떤 값을 가지고 몇 번 호출됐는지를 검증한다.

ArgumentCaptor는 목 오브젝트에 전달된 파라미터를 가져와 내용을 검증하기 위해 사용한다. 파라미터를 직접 비교하기 보다는 파라미터의 내부 정보를 확인해야 하는 경우에 유용하다.

다이내믹 프록시와 팩토리빈

프록시와 프록시 패턴, 데코레이터 패턴

부가기능 외의 나머지 모든 기능은 원래 핵심기능을 가진 클래스로 위임해줘야 한다. 핵심 기능은 부가기능을 가진 클래스의 존재 자체를 모른다. 따라서 부가기능이 핵심기능을 사용하는 구조가 되는 것이다

문제는 이렇게 구성했더라도 클라이언트가 핵심기능을 가진 클래스를 직접 사용해 버리면 부가기능이 적용될 기회가 없다는 점이다. 그래서 부가기능은 마치 자신이 핵심 기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐서 핵심기능을 사용하도록 만들어야 한다. 그러기 위해서는 클라이언트는 인터페이스를 통해서만 핵심기능을 사용하게 하고, 부가기능 자신도 같은 인터페이스를 구현한 뒤에 자신이 그 사이에 끼어들어야 한다.

이렇게 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요처을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 **프록시(proxy)**라고 부른다. 그리고 프록시를 통해 최종적으로 요처을 위임받아 처리하는 실제 오브젝트를 타깃(target) 또는 실체(real subject)라고 부른다.

프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다.

프록시는 사용 목적에 따라 두 가지로 구분할 수 있다. 첫째는 클라이언트가 타깃에 접근하는 방법을 제어하기 위해서다. 두 번째는 타깃에 부가적인 기능을 부여해주기 위해서다. 두 가지 모두 대리 오브젝트라는 개념의 프록시를 두고 사용한다는 점은 동일하지만, 목적에 따라서 디자인 패턴에서느 다른 패턴으로 구분한다.

데코레이터 패턴

데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시에 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다. 다이내믹하게 기능을 부여한다는 의미는 컴파일 시점, 즉 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않다는 뜻이다. 데코레이터 패턴에서는 프록시가 꼭 한 개로 제한되지 않는다. 데코레이터 패턴에서는 같은 인터페이스를 구현한 타겟과 여러 개의 프록시를 사용할 수 있다.

프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다. 그래서 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 외부에서 런타임 시에 주입받을 수 있도록 만들어야 한다.

인터페이스를 통한 데코레이터 정의와 런타임 시의 다이내믹한 구성 방법은 스프링의 DI를 이요하면 아주 편리하다. 데코레이터 빈의 프로퍼티로 같은 인터페이스를 구현한 다른 데코레이터 또는 타깃 빈을 설정하면 된다.

데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법이다.

프록시 패턴

일반적으로 사용하는 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분할 필요가 있다. 전자는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 말한다면, 후자는 프록시를 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 가리킨다.

프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.

프록시 패턴은 타깃의 기능 자체에는 관여하지 않으면서 접근하는 방법을 제어해주는 프록시를 이용하는 것이다. 구조적으로 보자면 프록시와 데코레이터는 유사하다. 다만 프록시는 코드에서 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다. 생성을 지연하는 프록시라면 구체적인 생성 방법을 알아야 하기 때문에 타깃 클래스에 대한 직접적인 정보를 알아야 한다. 물론 프록시 팽턴이라고 하더라도 인터페이스를 통해 위임하도록 만들 수도 있다. 인터페이스를 토앻 다음 호출 대상으로 접근하게 되면 그 사이에 다른 프록시나 데코레이터가 계속 추가될 수 있기 때문이다.

앞으로는 타깃과 동일한 인터페이스를 구현하고 클라이언트와 타깃 사이에 존재하면서 기능의 부가 또는 접근 제어를 담당하는 오브젝트를 모두 프록시라고 부르겠다. 하지만 그때마다 사용의 목적이 기능의 부가인지, 접근 제어인지를 구분해보면 각각 어떤 목적으로 프록시가 사용됐는지, 그에 따라 어떤 패턴이 적용됐는지 알 수 있을 것이다.

다이내믹 프록시

목 오브젝트를 만드는 불편함을 목 프레임워크를 사용해 편리하게 바꿨던 것처럼 프록시도 일일이 모든 인터페이스를 구현해서 클래스를 새로 정의하지 않고도 편리하게 만들어서 사용할 방법이 있다.

자바에는 java.lang.reflect 패키지 안에 프록시를 손쉽게 만들 수 있도록 지원해주는 클래스들이 있다. 일일이 프록시 클래스를 정의하지 않고도 몇 가지 API를 이용해 프록시처럼 동작하는 오브젝트를 다이내믹하게 생성하는 것이다.

프록시의 구성과 프록시 작성의 문제점

프록시는 다음의 두 가지 기능으로 구성된다.

  • 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.
  • 지정된 요청에 대해서는 부가기능을 수행한다.

프록시를 만들기가 번거로운 이유는 두 가지가 있다.

  • 첫째는 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭다는 점이다. 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 한다. 또, 타깃 인터페이스의 메소드가 추가되거나 변경될 때마다 함께 수정해줘야 한다는 부담도 있다.
  • 두 번째 문제점은 부가기능 코드가 중복될 가능성이 많다는 점이다.

첫 번째 문제인 인터페이스 메소드의 구현과 위임 기능 문제는 간단해 보이지 않는다. 바로 이런 문제를 해결하는 데 유용한 것이 바로 JDK의 다이내믹 프록시다.

리플렉션

다이내믹 프록시는 리플렉션 기능을 이용해서 프록시를 만들어준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.

자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 갖고 있다. ‘클래스이름.class’라고 하거나 오브젝트의 getClass() 메소드를 호출하면 클래스 정보를 담은 Class 타입의 오브젝트를 가져올 수 있다. 클래스 오브젝트를 이용하면 크래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package springbook.learningtest.jdk;
...
public class ReflectionTest {
@Test
public void invokeMethod() throws Exception() {
String name = "Spring";

// length()
assertThat(name.length(), is(6));

Method lengthMethod = String.class.getMethod("length");
assertThat((Integer)lengthMethod.invoke(name), is(6));

// charAt()
assertThat(name.charAt(0), is('S'));

Method charAtMethod = String.class.getMethod("charAt", int.class);
assertThat((Character)charAtMethod.invoke(name, 0), is('S'));
}
}

다이내믹 프록시 적용

dynamic-proxy

다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어진다. 클라이언트는 다이내믹 프록시 오브젝트를 타깃 인터페이스를 통해 사용할 수 있다. 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어주기 때문이다.

다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로서 필요한 부가기능 제공 코드는 직접 생성해야 한다. 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담는다. InvocationHandler 인터페이스는 다음과 같은 메소드 한 개만 가진 간단한 인터페이스다.

1
public Object invoke(Object proxy, Method method, Object[] args)

invoke() 메소드는 리플렉션의 MEthod 인터페이스를 파라미터로 받는다. 메소드를 호출할 때 전달되는 파라미터도 args로 받는다. 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.

InvocationHandler 구현 오브젝트가 타깃 오브젝트 레퍼런스를 갖고 있다면 리플렉션을 이용해 간단히 위임 코드를 만들어 낼 수 있다.

InvocationHandler 인터페이스를 구현한 오브젝트를 제공해주면 다이내믹 프록시가 받는 모든 요청을 InvocationHandler의 invoke() 메소드로 보내준다.

다이내믹 프록시로부터 요청을 전달받으려면 InvocationHandler를 구현해야 한다. 메소드는 invoke() 하나뿐이다. 다이내믹 프록시가 클라이언트로부터 받는 모든 요청은 incoke() 메소드로 전달된다. 다이내믹 프록시를 통해 요청이 전달되면 리플렉션 API를 이용해 타깃 오브젝트의 메소드를 호출한다. 타깃 오브젝트는 생성자를 통해 미리 전달받아 둔다.

다이내믹 프록시의 생성은 Proxy 클래스의 newProxyInstance() 스태틱 팩토리 메소드를 이용하면 된다.

1
2
3
4
5
6
7
8
// 생성된 다이내믹 프록시 오브젝트는 Hello 인터페이스를 구현하고 있으므로 Hello 타입으로 캐스팅해도 안전하다.
Hello proxiedHello = (Hello)Proxy.newProxyInstance(
// 동적으로 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
getClass().getClassLoader(),
// 구현할 인터페이스
new Class[] { Hello.class },
// 부가기능과 위임 코드를 담은 InvocationHandler
new UppercaseHandler(new HelloTarget()));

다이내믹 프록시의 확장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UppercaseHandler implements InvocationHandler {
// 어떤 종류의 인터페이스를 구현한 타깃에도 적용 가능하도록 Object 타입으로 수정
Object target;
private UppercaseHandler(Object target) {
this.target = target;
}

public Object invoke(Object proxy, Method method, Object[] args) thorws Throwable {
Object ret = method.invoke(target, args);
if (Ret instanceof String) {
return ((String)ret).toUpperCase();
} else {
return ret;
}
}
}

다이내믹 프록시를 이용한 트랜잭션 부가기능

트랜잭션 InvocationHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class TransactionHandler implements InvocationHandler {
private Object target;
private PlatformTransactionManager transactionManager;
private String pattern;

public void setTarget(Object target) {
this.target = target;
}

public void setTransactionManager(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}

public void setPattern(String pattern) {
this.pattern = pattern;
}

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().startsWith(pattern)) {
return invokeInTransaction(method, args);
} else {
return method.invoke(target, args);
}
}

private Object invokeTransaction(Method method, Object[] args) throws Throwable {
TransactionStatus = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
Object ret = method.invoke(target, args);
this.transactionManager.commit(status);
return ret;
} catch (InvocationTargetException e) {
this.transactionManager.rollback(status);
throw e.getTargetException();
}
}
}

TransactionHandler와 다이내믹 프록시를 이용하는 테스트

1
2
3
4
5
6
7
8
9
10
@Test
public void upgradeAllOrNothing() throws Exception {
...
TransactionHandler txHandler = new TransactionHandler();
txHandler.setTarget(testUserService);
txHandler.setTransactionManager(transactionManager);
txHandler.setPattern("upgradeLevels");
UserService txUSerService = (UserService)Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {UserService.class }, txHandler);
...
}

다이내믹 프록시를 위한 팩토리 빈

이제 TransactionHandler와 다이내믹 프록시를 스프링의 DI를 통해 사용할 수 있도록 만들어야 할 차례다.

스프링의 빈은 기본적으로 클래스 이름과 프로퍼티로 정의된다. 스프링은 지정된 클래스 이름을 가지고 리플렉션을 이용해서 해당 클래스의 오브젝트를 만든다. 클래스의 이름을 갖고 있다면 다음과 같은 방법으로 새로운 오브젝트를 생성할 수 있다. Class의 newInstance() 메소드는 해당 클래스의 파라미터가 없는 생성자를 호출하고, 그 결과 생성되는 오브젝트를 돌려주는 리플렉션 API다.

1
Date now = (Date) Class.forName("java.util.Date").newInstance();

스프링은 내부적으로 리플렉션 API를 이용해서 빈 정의에 나오는 클래스 이름을 가지고 빈 오브젝트를 생성한다. 문제는 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않는다는 점이다. 사실 다이내믹 프록시 오브젝트의 클래스가 어떤 것인지 알 수도 없다. 클래스 자체도 내부적으로 다이내믹하게 새로 정의해서 사용하기 때문이다. 따라서 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링의 빈에 정의할 방법이 없다. 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.

팩토리 빈

스프링은 클래스 정보를 가지고 디폴트 생성자를 통해 오브젝트를 만드는 방법 외에도 빈을 만들 수 있는 여러 가지 방법을 제공한다. 대표적으로 팩토리 빈을 이용한 빈 생성 방법을 들 수 있다. 팩토리 빈이란 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 특별한 빈을 말한다.

팩토리 빈을 만드는 방법에는 여러 가지가 있는데, 가장 간단한 방법은 스프링의 FactoryBean이라는 인터페이스를 구현하는 것이다.

사실 스프링은 private 생성자를 가진 클래스도 빈으로 등록해주면 리플렉션을 이용해 오브젝트를 만들어준다.

1
2
3
4
5
6
7
package org.springframework.beans.factory;

public interface FactoryBean<T> {
T getObject() throws Exception; // 빈 오브젝트를 생성해서 돌려준다.
Class<? extends T> getOBjectType(); // 생성되는 오브젝트의 타입을 알려준다.
boolean isSingleton(); // getObject()가 돌려주는 오브젝트가 항상 같은 싱글톤 오브젝트인지 알려준다.
}

다이내믹 프록시를 만들어주는 팩토리 빈

Proxy의 newPRoxyInstance() 메소드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트는 일반적인 방법으로는 스프링의 빈을 등록할 수 없다. 대신 팩토리 빈을 사용하면 다이내믹 프록시 오브젝트를 스프링의 빈으로 만들어줄 수가 있다. 팩토리 빈의 getObject() 메소드에 다이내믹 프록시 오브젝트를 만들어주는 코드를 넣으면 되게 때문이다.

dynamic-proxy-with-factorybean

스프링 빈에는 팩토리 빈과 UserServiceImpl만 빈으로 등록한다. 팩토리 빈은 다이내믹 프록시가 위임할 타깃 오브젝트인 UserServiceImpl에 대한 레퍼런스를 프로퍼티를 통해 DI 받아둬야 한다. 다이내믹 프록시와 함께 생성할 TransactionHandler에게 타깃 오브젝트를 전달해줘야 하기 때문이다. 그 외에도 다이내믹 프록시나 TransactionHandler를 만들 때 필요한 정보는 팩토리 빈의 프로퍼티로 설정해뒀다가 다이내믹 프록시를 만들면서 전달해줘야 한다.

프록시 팩토리 빈 방식의 장점과 한계

다이내믹 프록시를 생성해주는 팩토리 빈을 사용하는 방법은 여러 가지 장점이 있다. 한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있기 때문이다.

프록시 팩토리 빈 방식의 장점

다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는클래스를 일일이 만드는 번거로움을 제거할 수 있다. 하나의 핸들러 메소드를 구현하는 것만으로도 수많은 메소드에 부가기능을 부여해줄 수 있으니 부가기능 코드의 중복 문제도 사라진다. 다이내믹 프록시에 팩토리 빈을 이용한 DI까지 더해주면 번거로운 다이내믹 프록시 생성 코드도 제거할 수 있다. DI 설정만으로 다양한 타깃 오브젝트에 적용도 가능하다.

프록시 팩토리 빈의 한계

프록시를 통해 타깃에 부가기능을 제공하는 것은 메소드 단위로 일어나는 일이다. 하나의 크래스 안에 존재하는 여러 개의 메소드에 부가기능을 한 번에 제공하는 건 어렵지 않게 가능했다. 하지만 한 번에 여러 개의 크래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으로는 불가능하다.

하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제다. 프록시 팩토리 빈 설정이 부가기능의 개수만큼 따라 붙어야 한다.

스프링의 프록시 팩토리 빈

ProxyFactoryBean

자바에는 JDK에서 제공하는 다이내믹 프록시 외에도 편리하게 프록시를 만들 수 있도록 지원해주는 다양한 기술이 존재한다. 따라서 스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다. 생성된 프록시는 스프링의 빈으로 등록돼야 한다. 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공해준다.

스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.

ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. MethodInterceptor는 InvocationHandler와 비슷하지만 한 가지 다른 점이 있다. InvocationHandler의 invoke() 메소드는 타깃 오브젝트에 대한 정보를 제공하지 않는다. 따라서 타깃은 InvocationHandler를 구현한 크래스가 직접 알고 있어야 한다. 반면에 MethodInterceptor의 invoke() 메소드는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받는다. 그 차이 덕분에 MethodInterceptor는 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있다. 따라서 MethodInterceptor 오브젝트는 타깃이 다른 여러 프록시에서 함께 사용할 수 있고, 싱글톤 빈으로 등록 가능하다.

어드바이스: 타깃이 필요 없는 순수한 부가기능

MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다.

MethodInterceptor처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스(advice)라고 부른다.

ProxyFactoryBean은 기본적으로 JDK가 제공하는 다이내믹 프록시를 만들어준다. 경우에 따라서는 CGLib이라고 하는 오픈소스 바이트코드 생성 프레임워크를 이용해 프록시를 만들기도 한다.

어드바이스는 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트라는 사실을 잘 기억해두자.

포인트컷: 부가기능 적용 대상 메소드 선정 방법

MethodInterceptor 오브젝트는 여러 프록시가 공유해서 사용할 수 있다. 그러기 위해서 MethodInterceptor 오브젝트는 타깃 정보를 갖고 있지 않도록 만들었다. 그 덕분에 MethodInterceptor를 스프링의 싱글톤 빈으로 등록할 수 있었다. 그런데 여기에다 트랜잭션 적용 대상 메소드 이름 패턴을 넣어주는 것은 곤란하다. 트랜잭션 적용 메소드 패턴은 프록시마다 다를 수 있기 때문에 여러 프록시가 공유하는 MethodInterceptor에 특정 프록시에만 적용되는 패턴을 넣으면 문제가 된다.

comparison

InvocationHandler는 타깃과 메소드 선정 알고리즘 코드에 의존하고 있지만, 스프링의 ProxyFactoryBean 방식은 두 가지 확장 기능인 부가기능(Advice)과 메소드 선정 알고리즘(Pointcut)을 활용하는 유연한 구조를 제공한다.

스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 부르고, 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다. 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용된다. 두 가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능하다.

프록시는 클라이언트로부터 요청을 받으면 먼저 포인트컷에게 부가기능을 부여할 메소드인지를 확인해달라고 요청한다. 포인트컷은 Pointcut 인터페이스를 구현해서 만들면 된다. 프록시는 포인트컷으로부터 부가기능을 적용할 대상 메소드인지 확인받으면, MethodInterceptor 타입의 어드바이스를 호출한다. 어드바이스는 JDK의 다이내믹 프록시의 InvocationHandler와 달리 직접 타깃을 호출하지 않는다.

어드바이스가 일종의 템플릿이 되고 타깃을 호출하는 기능을 갖고 있는 MethodInvocation 오브젝트가 콜백이 되는 것이다. 템플릿은 한 번 만들면 재사용이 가능하고 여러 빈이 공유해서 사용할 수 있듯이, 어드바이스도 독립적인 싱글톤 빈으로 등록하고 DI를 주입해서 여러 프록시가 사용하도록 만들 수 있다.

프록시로부터 어드바이스와 포인트컷을 독립시키고 DI를 사용하게 한 것은 전형적인 전략 패턴 구조다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void pointcutAdvisor() {
ProxyFactoryBean pfBean = new ProxyFactoryBean();
pfBean.setTarget(new HelloTarget());

// 메소드 이름을 비교해서 대상을 선정하는 알고리즘을 제공하는 포인트컷 생성
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
// 이름 비교조건 설정. sayH로 시작하는 모든 메소드를 선택하게 한다.
pointcut.setMappedName("syaH*");

// 포인트컷과 어드바이스를 advisor로 묶어서 한 번에 추가
pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));

Hello proxiedHello = (Hello) pfBean.getObject();

assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
assertThat(proxiedHello.sayHi("Toby"), is("HI TOBY"));
assertThat(proxiedHello.sayThankYou("Toby"), is("Thank You Toby"));
}

ProxyFactoryBean에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있다. 포인트컷과 어드바이스를 따로 등록하면 어떤 어드바이스(부가 기능)에 대해 어떤 포인트컷(메소드 선정)을 적용할지 애매해지기 때문이다. 그래서 이 둘을 Advisor 타입의 오브젝트에 담아서 조합을 만들어 등록하는 것이다. 여러 개의 어드바이스가 등록되더라도 각각 다른 포인트컷과 조합될 수 있기 때문에 각기 다른 메소드 선정 방식을 적용할 수 있다. 이렇게 어드바이스와 포인트 컷을 묶은 오브젝트를 인터페이스 이름을 따서 어드바이저라고 부른다.

어드바이저 = 포인트컷(메소드 선정 알고리즘) + 어드바이스(부가기능)

어드바이스와 포인트컷의 재사용

ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용된 것이다. 그 덕분에 독립적이며, 여러 프록시가 공유할 수 있는 어드바이스와 포인트컷으로 확장 기능을 분리할 수 있었다.

proxyfactorybean_advice_pointcut

스프링 AOP

자동 프록시 생성

중복 문제의 접근 방법

JDK의 다이내믹 프록시는 특정 인터페이스를 구현한 오브젝트에 대해서 프록시 역할을 해주는 클래스를 런타임 시 내부적으로 만들어준다. 런타임 시에 만들어져 사용되기 때문에 클래스 소스가 따로 남지 않을 뿐이지 타깃 인터페이스의 모든 메소드를 구현하는 클래스가 분명히 만들어진다.

변하지 않는 타깃으로의 위임과 부가기능 적용 여부 판단이라는 부분은 코드 생성기법을 이용하는 다이내믹 프록시 기술에 맡기고, 변하는 부가기능 코드는 별도로 만들어서 다이내믹 프록시 생성 팩토리에 DI로 제공하는 방법을 사용한 것이다. 좀 독특하긴 하지만 변하는 로직과 변하지 않는 기계적인 코드를 잘 분리해낸 것이다.

반복적인 프록시의 메소드 구현은 코드 자동생성 기법을 이용해 해결했다면 반복적인 ProxyFactoryBean 설정 문제는 설정 자동등록 기법으로 해결할 수 없을까?

하지만 지금까지 살펴본 방법에서는 한 번에 여러 개의 빈에 프록시를 적용할 만한 방법은 없었다.

빈 후처리기를 이용한 자동 프록시 생성기

스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다.

그 중에서 관심을 가질 만한 확장 포인트는 BeanPostProcessor 인터페이스를 구현해서 만든 빈 후처리기다. 빈 후처리기는 이름 그대로 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.

스프링은 빈 후처리기 중의 하나로 DefaultAdvisorAutoProxyCreator를 제공한다. DefaultAdvisorAutoProxyCreator는 어드바이저를 이용한 자동 프록시 생성기다. 빈 후처리기를 스프링에 적용하는 방법은 간단하다. 빈 후처리기 자체를 빈으로 등록하는 것이다. 스프링은 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수도 있고 별도의 초기화 작업을 수행할 수도 있다. 심지어는 만들어진 빈오브젝트 자체를 바꿔치기할 수도 있다. 따라서 스프링이 설정을 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다.

이를 잘 활용하면 스프링이 생성하는 빈 오브젝트의 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다.

DefaultAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에게 빈을 보낸다. DefaultAdvisorAutoProxyCreator는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 프록시 적용 대상인지 확인한다. 프록시 적용 대상이면 그때는 내장된 프록시 생성기에게 현재 빈에 대한 프록시를 만들게 하고, 만들어진 프록시에 어드바이저를 연결해준다. 빈 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다. 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.

적용할 빈을 선정하는 로직이 추가된 포인트컷이 담긴 어드바이저를 등록하고 빈 후처리기를 사용하면 일일이 ProxyFactoryBean 빈을 등록하지 않아도 타깃 오브젝트에 자동으로 프록시가 적용되게 할 수 있다.

확장된 포인트컷

포인트컷은 클래스 필터와 메소드 매처 두 가지를 돌려주는 메소드를 갖고 있다. 실제 포인트컷의 선별 로직은 이 두가지 타입의 오브젝트에 담겨 있다.

1
2
3
4
public interface Pointcut {
ClassFilter getClassFilter(); // 프록시를 적용할 클래스인지 확인해준다.
MethodMatcher getMethodMatcher(); // 어드바이스를 적용할 메소드인지 확인해준다.
}

만약 Pointcut 선정 기능을 모두 적용한다면 먼저 프록시를 적용할 클래스인지 판단하고 나서, 적용 대상 클래스인 경우에는 어드바이스를 적용할 메소드인지 확인하는 식으로 동작한다. 결국 이 두 가지 조건이 모두 충족되는 타깃의 메소드에 어드바이스가 적용되는 것이다.

모든 빈에 대해 프록시 자동 적용 대상을 선별해야 하는 빈 후처리기인 DefaultAdvisorAutoProxyCreator는 클래스와 메소드 선정 알고리즘을 모두 갖고 있는 포인트컷이 필요하다. 정확히는 그런 포인트컷과 어드바이스가 결합되어 있는 어드바이저가 등록되어 있어야 한다.

DefaultAdvisorAutoProxyCreator의 적용

클래스 필터를 적용할 포인트컷 작성

메소드 이름만 비교하던 포인트컷인 NameMatchMethodPointcut을 상속해서 프로퍼티로 주어진 이름 패턴을 가지고 클래스 이름을 비교하는 ClassFilter를 추가하도록 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package springbook.learningtest.jdk.proxy;
//...
public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {
public void setMappedClassName(String mappedClassName) {
// 모든 클래스를 다 허용하던 디폴트 클래스 필터를 프로퍼티로 받은 클래스 이름을 이용해서 필터를 만들어 덮어씌운다.
this.setClassFilter(new SimpleClassFilter(mappedClassName));
}

static Class SimpleClassFilter implements ClassFilter {
String mappedName;

private SimpleClassFilter(String mappedName) {
this.mappedName = mappedName;
}

public boolean matches(Class<?> clazz) {
return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
}
}
}

어드바이저를 이용하는 자동 프록시 생성기 등록

자동 프록시 생성기인 DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 것을 모두 찾는다. 그리고 생성되는 모든 빈에 대해 어드바이저의 포인트컷을 적용해보면서 프록시 적용 대상을 선정한다. 빈 클래스가 프록시 선정 대상이라면 프록시를 만들어서 원래 빈 오브젝트와 바꿔치기한다. 원래 빈 오브젝트는 프록시 뒤에 연결돼서 프록시를 통해서만 접근 가능하게 바뀌며, 타깃 빈에 의존한다고 정의한 다른 빈들은 등록시 프록시 오브젝트를 대신 DI 받게 된다. DefaultAdvisorAutoProxyCreator 등록은 다음 한 줄과 같다.

1
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" />

다른 빈에서 참조되거나 코드에서 빈 이름으로 조회될 필요가 없는 빈이라면 아이디를 등록하지 않아도 무방하다.

포인트컷 표현식을 이용한 포인트컷

스프링은 아주 간단하고 효과적인 방법으로 포인트컷의 클래스와 메소드를 선정하는 알고리즘을 작성할 수 있는 방법을 제공한다. 정규식이나 JSP의 EL과 비슷한 일종의 표현식 언어를 사용해서 포인트컷을 작성할 수 있도록 하는 방법이다. 그래서 이것을 포인트컷 표현식이라고 부른다.

포인트컷 표현식

포인트컷 표현식을 지원하는 포인트컷을 적용하려면 AspectJExpressionPointcut 클래스를 사용하면 된다. Pointcut 인터페이스를 구현해야 하는 스프링의 포인트컷은 클래스 선정을 위한 클래스 필터와 메소드 선정을 위한 메소드 매처 두 가지를 각각 제공해야 한다.

하지만 AspectJExpressionPointcut은 클래스와 메소드의 선정 알고리즘을 포인트컷 표현식을 이용해 한 번에 지정할 수 있게 해준다. 포인트컷 표현식은 자바의 RegEx 클래스가 지원하는 정규식처럼 간단한 문자열로 복잡한 선정조건을 쉽게 만들어낼 수 있는 강력한 표현식을 지원한다. 사실 스프링이 사용하는 포인트컷 표현식은 AspectJ라는 유명한 프로엠워크에서 제공하는 것을 가져와 일부 문법을 확장해서 사용하는 것이다.

포인트컷 표현식을 이용하는 포인트컷 적용

포인트컷 표현식은 메소드의 시그니처를 비교하는 방식인 execution() 외에도 몇 가지 표현식 스타일을 갖고 있다. 대표적으로 스프링에서 사용될 때 빈의 이름으로 비교하는 bean()이 있다.

특정 애노테이션이 타입, 메소드, 파라미터에 적용되어 있는 것을 보고 메소드를 선정하게 하는 포인트컷도 만들 수 있다. 애노테이션만 부여해놓고, 포인트컷을 통해 자동으로 선정해서, 부가기능을 제공하게 해주는 방식은 스프링 내에서도 애용되는 편리한 방법이다.

클래스 이름은 ServiceImpl로 끝나고 메소드 일므은 upgrade로 시작하는 모든 클래스에 적용되도록 하는 표현식을 만들고 이를 적용한 빈 설정은 다음과 같다.

1
2
3
<bean id="transactionPointcut" class="org.springframework.aop.aspectj.AspectJExpressionPointcut">
<property name="expression" value"execution(* * .. *ServiceImpl.upgrade*(..))" />
</bean>

AOP란 무엇인가?

AOP: 애스펙트 지향 프로그래밍

부가기능 모듈화 작업은 기존의 객체지향 설계 패러다임과는 구분되는 새로운 특성이 있다고 생각했다. 그래서 이런 부가기능 모듈을 객체지향 기술에서는 주로 사용하는 오브젝트와는 다르게 특별한 이름으로 부르기 시작했다. 그것이 바로 애스펙트(aspect)다. 애스펙트란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.

애스펙트는 부가될 기능을 정의한 코드인 어드바이스와, 어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 갖고 있다.

독립된 측면에 존재하는 애스팩트로 분리한 덕에 핵심기능은 순수하게 그 기능을 담은 코드로만 존재하고 독립적으로 살펴볼 수 있도록 구분된 면에 존재하게 된 것이다.

이렇게 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스펙트 지향 프로그래밍(Aspect Oriented Programming) 또는 약자로 AOP라고 부른다.

AOP는 OOP를 돕는 보조적인 기술이지 OOP를 완전히 대체하는 새로운 개념은 아니다. AOP는 애스펙트를 분리함으로써 핵심기능을 설계하고 구현할 때 객체지향적인 가치를 지킬 수 있도록 도와주는 것이라고 보면 된다. AOP를 관점 지향 프로그래밍이라고도 한다.

AOP 적용기술

바이트코드 생성과 조작을 통한 AOP

프록시 방식이 아닌 AOP도 있다. AOP 기술의 원조이자, 가장 강력한 AOP 프레임워크로 꼽히는 AspectJ는 프록시를 사용하지 않는 대표적인 AOP 기술이다. AspectJ는 스프링처럼 다이내믹 프록시 방식을 사용하지 않는다.

AspectJ는 프록시처럼 간접적인 방법이 아니라, 타깃 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어주는 직접적인 방법을 사용한다. 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방법을 사용한다.

AspectJ가 프록시 같은 방법이 있지만 컴파일된 클래스 파일 수정이나 바이트코드 조작과 같은 복잡한 방법을 사용하는 것에는 두 가지 이유가 있다.

  1. 바이트코드를 조작해서 타깃 오브젝트를 직접 수정해버리면 스프링과 같은 DI 컨테이너의 도움을 받아서 자동 프록시 생성 방식을 사용하지 않아도 AOP를 적용할 수 있기 때문.
  2. 프록시 방식보다 훨씬 강력하고 유연한 AOP가 가능하기 때문. 바이트코드를 직접 조작해서 AOP를 적용하면 오브젝트의 생성, 필드 값이 조회와 조작, 스태틱 초기화 등의 다양한 작업에 부가기능을 부여해줄 수 있다.

트랜잭션 속성

트랜잭션이라고 모두 같은 방식으로 동작하는 것이 아니다. DefaultTransactionDefinition이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션 동작방식에 영향을 줄 수 있는 4가지 속성을 정의하고 있다.

  • 트랜잭션 전파
    트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식
  • 격리수준
    모든 DB 트랜잭션은 격리수준(isolation level)을 갖고 있다. 격리수준은 기본적으로 DB에 설정되어 있지만 JDBC 드라이버나 DataSource 등에서 재설정할 수 있고, 필요하다면 트랜잭션 단위로 격리수준을 조정할 수 있다.
  • 제한시간
    트랜잭션을 수행하는 제한시간(timeout)을 설정할 수 있다. DefaultTransactionDefinition의 기본 설정은 제한시간이 없다.
  • 읽기전용
    읽기전용(read only)으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다. 또한 데이터 액세스 기술에 따라서 성능이 향상될 수도 있다.

포인트컷과 트랜잭션 속성의 적용 전략

프록시 방식 AOP는 같은 타깃 오브젝트 내의 메소드를 호출할 때는 적용되지 않는다

이건 전략이라기보다는 주의사항이다. 프록시 방식의 AOP에서는 프록시를 통한 부가 기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 여기서 클라이언트는 인터페이스를 통해 타깃 오브젝트를 사용하는 다른 모든 오브젝트를 말한다. 반대로 타깃 오브젝트가 자기 자신의 메소드를 호출할 떄는 프록시를 통한 부가기능의 적용이 일어나지 않는다. 따라서 같은 오브젝트 안에서의 호출은 새로운 트랜잭션 속성을 부여하지 못한다는 사실을 의식하고 개발할 필요가 있다.

타깃 안에서의 호출에는 프록시가 적용되지 않는 문제를 해결할 수 있는 방법은 두 가지가 있다.

  1. 스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 이용하도록 강제하는 방법(별로 추천되지 않음)
  2. AspectJ와 같은 타깃의 바이트코드를 직접 조작하는 방식의 AOP 기술을 적용하는 방법

트랜잭션 속성 적용

트랜잭션 경계설정의 일원화

트랜잭션 경계설정의 부가기능을 여러 계층에서 중구난방으로 적용하는 건 좋지 않다. 일바적으로 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 바람직하다. 비즈니스 로직을 담고 있는 서비스 계층 오브젝트의 메소드가 트랜잭션 경계를 부여하기에 가장 적절한 대상이다.

트랜잭션 지원 테스트

선언적 트랜잭션과 트랜잭션 전파 속성

AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정 할 수 있게 하는 방법을 선언적 트랜잭션이라고 한다. 반대로 TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법은 프로그램에 의한 트랜잭션이라고 한다. 스프링은 이 두 가지 방법을 모두 지원하고 있다. 물론 특별한 경우가 아니라면 선언적 방식의 트랜잭션을 사용하는 것이 바람직하다.

트랜잭션 매니저와 트랜잭션 동기화

트랜잭션 추상화 기술의 핵심은 트랜잭션 매니저와 트랜잭션 동기화다. PlatformTransactionManager 인터페이스를 구현한 트랜잭션 매니저를 통해 구체적인 트랜잭션 기술의 종류에 상관없이 일관된 트랜잭션 제어가 가능했다. 또한 트랜잭션 동기화 기술이 있었기에 시작된 트랜잭션 정보를 저장소에 보관해뒀다가 DAO에서 공유할 수 있었다.

트랜잭션 동기화 기술은 트랜잭션 전파를 위해서도 중요한 역할을 한다. 진행 중인 트랜잭션이 있는지 확인하고, 트랜잭션 전파 속성에 따라서 이에 참여할 수 있도록 만들어주는 것도 트랜잭션 동기화 기술 덕분이다.

JdbcTemplate과 같이 스프링이 제공하는 데이터 액세스 추상화를 적용한 DAO에도 동일한 영향을 미친다. JdbcTemplate은 트랜잭션이 시작된 것이 있으면 그 트랜잭션에 자동으로 참여하고, 없으면 트랜잭션 없이 자동커밋 모드로 JDBC 작업을 수행한다. 개념은 조금 다르지만 JdbcTemplate의 메소드 단위로 마치 트랜잭션 전파 속성이 REQUIRED인 것처럼 동작한다고 볼 수 있다.

정리

  • 트랜잭션 경계설정 코드를 분리해서 별도의 클래스로 만들고 비즈니스 로직 클래스와 당일한 인터페이스를 구현하면 DI의 확장 기능을 이용해 클라이언트의 변경 없이도 깔끔하게 분리된 트랜잭션 부가기능을 만들 수 있다.
  • 트랜잭션처럼 환경과 외부 리소스에 영향을 받는 코드를 분리하면 비즈니스 로직에만 충실한 테스트를 만들 수 있다.
  • 목 오브젝트를 활용하면 의존관계 속에 있는 오브젝트도 손 쉽게 고립된 테스트로 만들 수 있다.
  • DI를 이용한 트랜잭션의 분리는 데코레이터 패턴과 프록시 패턴으로 이해될 수 있다.
  • 번거로운 프록시 클래스 작성은 JDK의 다이내믹 프록시를 사용하면 간단하게 만들 수 있다.
  • 다이내믹 프록시는 스태틱 팩토리 메소드를 사용하기 때문에 빈으로 등록하기 번거롭다. 따라서 팩토리 빈으로 만들어야 한다. 스프링은 자동 프록시 생성 기술에 대한 추상화 서비스를 제공하는 프록시 팩토리 빈을 제공한다.
  • 프록시 팩토리 빈의 설정이 반복되는 문제를 해결하기 위해 자동 프록시 생성기와 포인트 컷을 활용할 수 있다. 자동 프록시 생성기는 부가기능이 담긴 어드바이스를 제공하는 프록시를 스프링 컨테이너 초기화 시점에 자동으로 만들어준다.
  • 포인트컷은 AspectJ 포인트컷 표현식을 사용해서 작성하면 편리하다.
  • AOP는 OOP만으로는 모듈화하기 힘든 부가기능을 효과적으로 모듈화하도록 도와주는 기술이다.
  • 스프링은 자주 사용되는 AOP 설정과 트랜잭션 속성을 지정하는데 사용할 수 있는 전용 태그를 제공한다.
  • AOP를 이용해 트랜잭션 속성을 지정하는 방법에는 포인트컷 표현식과 메소드 이름 패턴을 이용하는 방법과 타깃에 직접 부여하는 @Transactional 애노테이션을 사용하는 방법이 있다.
  • @Transactgional을 이용한 트랜잭션 속성을 테스트에 적용하면 손쉽게 DB를 사용하는 코드의 테스트를 만들 수 있다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

토비의 스프링 5장 (서비스 추상화)

서비스 추상화

트랜잭션 서비스 추상화

트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다. 작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다.

트랜잭션 경계설정

DB는 그 자체로 완벽한 트랜잭션을 지원한다. SQL을 이용해 다중 로우의 수정이나 삭제를 위한 요청을 했을 때 일부 로우만 삭제되고 안 된다거나, 일부 필드는 수정했는데 나머지 필드는 수정이 안 되고 실패로 끝나는 경우는 없다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.

하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 이때 여러 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 이후의 SQL이 성공적으로 DB에서 수행되기 전에 문제가 발생할 경우 앞에서 처리한 SQL 작업도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백(transaction roolback)이라고 한다. 반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 트랜잭션 커밋(transaction commit)이라고 한다.

JDBC 트랜잭션의 트랜잭션 경계설정

모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 다 확장하는 커밋이다.

JDBC의 트랜잭션은 하나의 Connection을 가져와 사용하다가 닫는 사이에 일어난다. 트랜잭션의 시작과 종료는 Coonection 오브젝트를 통해 이루어지기 때문이다. JDBC의 기본 설정은 DB 작업을 수행한 직후에 자동으로 커밋이 되도록 되어 있다.

트랜잭션이 한 번 시작되면 commit() 또는 rollback() 메소드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다. commit() 또는 rollback()이 호출되면 그에 따라 작업 결과가 DB에 반영되거나 취소되고 트랜잭션이 종료된다.

setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정(transaction demarcation)이라고 한다. 트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다는 점도 기억하자. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(local transaction)이라고도 한다.

비즈니스 로직 내의 트랜잭션 경계설정

UserService와 UserDao를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다. UserDao가 가진 SQL이나 JDBC API를 이용한 데이터 엑세스 코드는 최대한 그대로 남겨둔 채로, UserService에는 트랜잭션 시작과 종료를 담당하는 최소한의 코드만 가져오게 만들면 어느 정도 책임이 다른 코드를 분리해 둔 채로 트랜잭션 문제를 해결할 수 있다.

UserService 트랜잭션 경계설정의 문제점

위의 방법을 사용하면 다음과 같은 문제점이 발생한다.

첫째는 DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다는 점이다. Try/catch/finally 블록은 이제 UserService 내에 존재하고, UserService의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수 밖에 없다.

두 번째 문제점은 DAO의 메소드와 비즈니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다.

세 번째 문제는 Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더 이상 데이터 액세스 기술에 독립적일 수가 없다는 점이다.

트랜잭션 동기화

스프링은 위의 문제를 해결할 수 있는 멋진 방법을 제공해준다.

Connection 파라미터 제거

Connection 오브젝트를 한번 생성 후 계속 메소드의 파라미터로 전달하다가 DAO를 호출할 때 사용하는 건 피하고 싶다. 이를 위해 스프링이 제안하는 방법은 트랜잭션 동기화(transaction synchronization) 방식이다. 트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO의 메소드에서는 저장된 Coonection을 가져다가 사용하게 하는 것이다.

트랜잭션 동기화 저장소는 작업 스레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티스레드 환경에서도 충돌이 날 염려는 없다.

이렇게 트랜잭션 동기화 기법을 사용하면 파라미터를 통해 일일이 Connection 오브젝트를 전달할 필요가 없어진다. 트래잭션의 경계설정이 필요한 Service에서만 Connection을 다루게 하고, 여기에 생성된 Connection과 트랜잭션을 DAO의 JdbcTemplate이 사용할 수 있도록 별도의 저장소에 동기화하는 방법을 적용하기만 하면 된다. 더 이상 로직을 담은 메소드에 Connection 타입의 파라미터가 전달될 필요도 없고, UserDao의 인터페이스에도 일일이 JDBC 인터페이스인 Connection을 사용한다고 노출할 필요가 없다.

트랜잭션 동기화 적용

스프링은 JdbcTemplate과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메소드를 제공하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private DeataSource dataSource;

// Connection을 생성할 때 사용할 DataSource를 DI 받도록 한다.
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {
// 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화 한다.
TransactionSynchronizationManager.initSynchronization();
// DB 커넥션을 생성하고 트랜잭션을 시작한다. 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행한다.
// DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드
Connection c = DataSourceUtils.getConnection(dataSource);
c.setAuthCommit(false);

try {
List<User> users = UserDao.getAll();
for (User user : users) {
if (canUpdatedLevel(user)) {
upgradeLevel(user);
}
}
c.commit();
} catch (Exception e) {
c.rollback();
throw e;
} finally {
DataSourceUtils.releaseConnection(c, dataSource);
// 동기화 작업 종료 및 정리
TransactionSynchronizationManager.unbindResource(this.dataSource);
TransactionSynchronizationManager.clearSynchronization();
}
}

스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager다. 이 클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다. 그리고 DataSourceUtils에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 생성한다. DataSource에서 Connection을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 생성해줄 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소에 바인딩해주기 때문이다.

트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다.

JdbcTemplate과 트랜잭션 동기화

JdbcTemplate은 영리하게 동작하도록 설계되어 있다. 만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다. 반면에 upgradeLevels() 메소드에서처럼 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다. 이를 통해 이미 시작된 트랜잭션에 참여하는 것이다.

트랜잭션 서비스 추상화

기술과 환경에 종속되는 트랜잭션 경계설정 코드

한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜재션으로는 불가능하다. 왜냐하면 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문이다. 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 고나리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(global transaction)방식을 사용해야 한다. 글로벌 트랜잭션을 적용해야 트랜잭션 매니저를 통해 여러 개의 DB가 참여하는 작업을 하나의 트랜잭션으로 만들 수 있다.

자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 메니저를 지원하기 위한 API은 JTA(Java Transaction API)를 제공하고 있다.

트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결된다. 이를 통해 트랜잭션 매니저가 실제 DB와 메시징 서버의 트랜잭션을 종합적으로 제어할 수 있게 되는 것이다. 이렇게 JTA를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.

트랜잭션 API의 의존관계 문제와 해결책

UserDao가 DAO 패턴을 사용해 구현 데이터 액세스 기술을 유연하게 바꿔서 사용할 수 있게 했지만 UserService에서 트랜잭션의 경계 설정을 해야 할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 되고 말았다.

UserService의 코드가 특정 트랜잭션 방법에 의존적이지 않고 독립적일 수 있게 만들려면 어떻게 해야 할까? UserService의 메소드 안에서 트랜잭션 경계설정 코드를 제거할 수는 없다. 하지만 특정 기술에 의존적인 Connection, UserTransaction, Session/Transaction API 등에 종속되지 않게 할 수 있는 방법은 있다.

추상화란 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다. 그렇게 하면 하위 시스템이 어떤 것인지 알지 못해도, 또는 하위 시스템이 바뀌더라도 일관된 방법으로 접할 수가 있다.

스프링의 트랜잭션 서비스 추상화

스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void upgradeLevels() {
PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);

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 (RuntimeException e) {
transactionManager.rollback(status);
throw e;
}
}

스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager다. JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 된다. 사용할 DB의 DataSource를 생성자 파라미터로 넣으면서 DataSourceTransactionManager의 오브젝트를 만든다.

JDBC를 이용하는 경우에는 먼저 Connection을 생성하고 나서 트랜잭션을 시작했다. 하지만 PlatformTransactionManager에서는 트랜잭션을 가져오는 요청인 getTransation() 메소드를 호출하기만 하면 된다. 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 같이 수행해주기 때문이다. 여기서 트랜잭션을 가져온다는 것은 일단 트랜잭션을 시작한다는 의미라고 생각하자. 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트는 트랜잭션에 대한 속성을 담고 있다.

이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다. TransactionStatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager 메소드의 파라미터로 전달해주면 된다.

트랜잭션이 시작됐으니 이제 JdbcTemplate을 사용하는 DAO를 이용하는 작업을 진행한다. 스프링의 트랜잭션 추상화 기술은 앞에서 적용해봤던 트랜잭션 동기화를 사용한다. PlatformTransactionManager로 시작한 트랜잭션 동기화 저장소에 저장된다. PlatformTransactionManager를 구현한 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다. 따라서 PlatformTransactionManager를 통해 시작한 트랜잭션은 UserDao의 JdbcTemplate 안에서 사용된다.

트랜잭션 기술 설정의 분리

JTATransactionManager는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 갖고 있다. 따라서 별다른 설정 없이 JTATransactionManager를 사용하기만 해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작한다.

어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI 원칙에 위배된다. 자신이 사용할 구체적인 클래스를 스스로 결정하고 생성하지 말고 컨테이너를 통해 외부에서 제공받게 하는 스프링 DI의 방식으로 바꾸자.

서비스 추상화와 단일 책임 원칙

수직, 수평 계층구조와 의존관계

기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다.

애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며, 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 스프링의 DI가 중요한 역할을 하고 있다. DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.

단일 책임 원칙

이런 적절한 분리가 가져오는 특징은 객체지향 설계의 원칙 중의 하나인 단일 책임 원칙(Single Responsibility Principle)으로 설명할 수 있다. 단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.

단일 책임 원칙의 장점

단일 책임 원칙을 잘 지키고 있다면, 어떤 변경이 필요할 때 수정대상이 명확해진다. 기술이 바뀌면 기술 계층과의 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 된다. 데이터를 가져오는 테이블의 이름이 바뀌었다면 데이터 액세스 로직을 담고 있는 UserDao를 변경하면 된다. 비즈니스 로직도 마찬가지다.

적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI다.

이렇게 스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다. 스프링을 DI 프레임워크라고 부르는 이유는 외부 설정정보를 통한 런타임 오브젝트 DI라는 단순한 기능을 제공하기 때문이 아니다. 오히려 스프링이 DI에 담긴 원칙과 이를 응용하는 프로그래밍 모델을 자바 엔터프라이즈 기술의 많은 문제를 해결하는 데 적극적으로 활용하고 있기 때문이다.

메일 서비스 추상화

테스트와 서비스 추상화

일반적으로 서비스 추상화라고 하면 트랜잭션과 같은 기능은 유사하거나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 잡근 방법을 제공해주는 것을 말한다.

이를 적용하면 어떤 경우에도 UserService와 같은 애플리케이션 계층의 코드는 아래 계층에서는 어떤 일이 일어나는지 상관없이 메일 발송을 요청한다는 기본 기능에 충실하게 작성하면 된다.

서비스 추상화란 이렇게 원활한 테스트만을 위해서도 충분한 가치가 있다. 기술이나 환경이 바뀔 가능성이 있음에도, JavaMail처럼 확장이 불가능하게 설계해놓은 API를 사용해야 하는 경우라면 추상화 계층의 도입을 적극 고려해볼 필요가 있다.

테스트 대역의 종류와 특징

테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 통틀어서 테스트 대역(test double)이라고 부른다.

대표적인 테스트 대역은 테스트 스텁(test stub)이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.

많은 경우 테스트 스텁이 결과를 돌려줘야 할 때도 있다. MailSender처럼 호출만 하면 그만인 것도 있지만, 리턴 값이 있는 메소드를 이용하는 경우에는 결과가 필요하다. 이럴 땐 스텁에 미리 테스트 중에 필요한 정보를 리턴해주도록 만들 수 있다.

테스트는 보통 어떤 시스템에 입력을 주었을 때 기대하는 출력이 나오는지를 검증한다.

목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.

테스트는 테스트의 대상이 되는 오브젝트에 직접 입력 값을 제공하고, 테스트 오브젝트가 돌려주는 출력 값, 즉 리턴 값을 가지고 결과를 확인한다. 테스트 대상이 받게 될 이볅 값을 제어하면서 그 결과가 어떻게 달라지는지 확인하기도 한다. 문제는 테스트 대상 오브젝트는 테스트로부터만 입력을 받는 것이 아니라는 점이다. 테스트가 수행되는 동안 실행되는 코드는 테스트 대상이 의존하고 있는 다른 의존 오브젝트와도 커뮤니케이션하기도 한다.

때론 테스트 대상 오브젝트가 의존 오브젝트에게 출력한 값에 관심이 있을 겨웅가 있다. 또는 의존 오브젝트를 얼마나 사용했는가 하는 커뮤니케이션 행위 자체에 관심이 있을 수가 있다. 문제는 이 정보는 테스트에서는 직접 알 수가 없다는 것이다. 이때는 테스트 대상과 의존 오브젝트 사이에 주고받는 정보를 보존해두는 기능을 가진 테스트용 의존 오브젝트인 목 오브젝트를 만들어서 사용해야 한다. 테스트 대상 오브젝트의 메소드 호출이 끝나고 나면 테스트는 목 오브젝트에게 테스트 대상과 목 오브젝트 사이에서 일어났던 일에 대해 확인을 요청해서, 그것을 테스트 검증 자료로 삼을 수 있다.

목 오브젝트를 이용한 테스트라는 게, 작성하기는 간단하면서도 기능은 상당히 막강하다는 사실을 알 수 있을 것이다. 보통의 테스트 방법으로는 검증하기가 매우 까다로운 테스트 대상 오브젝트의 내부에서 일어나는 일이나 다른 오브젝트 사이에서 부고받은 정보까지 검증하는 일이 손쉽기 때문이다.

정리

  • 비즈니스 로직을 담은 코드는 데이터 엑세스 로직을 담은 코드와 깔끔하게 분리되는 것이 바람직하다. 비즈니스 로직 코드 또한 내부적으로 책임과 역할에 따라서 깔끔하게 메소드로 정리돼야 한다.
  • 이를 위해서는 DAO의 기술 변화에 서비스 계층의 코드가 영향을 받지 않도록 인터페이스와 DI를 잘 활용해서 결합도를 낮춰줘야 한다.
  • DAO를 사용하는 비즈니스 로직에는 단위 작업을 보장해주는 트랜잭션이 필요하다.
  • 트랜잭션의 시작과 종료를 지정하는 일을 트랜잭션 경계설정이라고 한다. 트랜잭션 경계설정은 주로 비즈니스 로직 안에서 일어나는 경우가 많다.
  • 시작된 트랜잭션 정보를 담은 오브젝트를 파라미터로 DAO에 전달하는 방법은 매우 비효율적이기 때문에 스프링이 제공하는 트랜잭션 동기화 기법을 활용하는 것이 편리하다.
  • 자바에서 사용되는 트랜잭션 API의 종류와 방법은 다양하다. 환경과 서버에 따라서 트랜잭션 방법이 변경되면 경계설정 코드도 함께 변경돼야 한다.
  • 트랜잭션 방법에 따라 비즈니스 로직을 담은 코드가 함께 변경되면 단일 책임 원칙에 위배되며, DAO가 사용하는 특정 기술에 대해 강한 결합을 만들어낸다.
  • 트랜잭션 경계설정 코드가 비즈니스 로직 코드에 영향을 주지 않게 하려면 스프링이 제공하는 트랜잭션 서비스 추상화를 이용하면 된다.
  • 서비스 추상화는 로우레벨의 트랜잭션 기술과 API의 변화에 상관없이 일괄된 API를 가진 추상화 계층을 도입한다.
  • 서비스 추상화는 테스트하기 어려운 JavaMail 같은 기술에도 적용할 수 있다. 테스트를 편리하게 작성하도록 도와주는 것만으로도 서비스 추상화는 가치가 있다.
  • 테스트 대상이 사용하는 의존 오브젝트를 대체할 수 있도록 만든 오브젝트를 테스트 대역이라고 한다.
  • 테스트 대역은 테스트 대상 오브젝트가 원활하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.
  • 테스트 대역 중에서 테스트 대상으로부터 전달받은 정보를 검증할 수 있도록 설계된 것을 목 오브젝트라고 한다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

토비의 스프링 4장 (예외)

예외

JdbcTemplate을 대표로 하는 스프링의 데이터 액세스 기능에 담겨 있는 예외처리와 관련된 접근 방법에 대해 알아보자.

사라진 SQLException

JdbcTemplate을 적용한 코드에서는 SQLException이 사라졌다. 이 SQLException은 어디로 간 것일까?

초난감 예외처리

예외가 발생하면 그것을 catch 블록을 써서 잡아내는 것까지는 좋은데 그리고 아무것도 하지 않고 별문제 없는 것처럼 넘어가 버리는 건 정말 위험한 일이다. 원치 않는 예외가 발생하는 것보다도 훨씬 더 나쁜 일이다. 왜냐하면 프로그램 실행 중에 어디선가 오류가 있어서 예외가 발생했는데 그것을 무시하고 계속 진행해버리기 때문이다. 결국 발생한 예외로 인해 어떤 기능이 비정상적으로 동작하거나, 메모리나 리소스가 소진되거나, 예상치 못한 다른 문제를 일으킬 것이다.

예외를 처리할 때 반드시 지켜야할 핵심 원칙은 한 가지다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.

SQLException이 발생하는 이유는 SQL에 문법 에러가 있거나 DB에서 처리할 수 없을 정도로 데이터 액세스 로직에 심각한 버그가 있거나, 서버가 죽거나 네트워크가 끊기는 등의 심각한 상황이 벌어졌기 때문이다.

예외를 처리하는 2가지 나쁜 습관은 어떤 경우에도 용납하지 않아야 한다.

  • 예외 블랙홀
  • 무의미하고 무책임한 throws

예외의 종류와 특징

예외를 어떻게 다뤄야 할까? 가장 큰 이슈는 체크 예외(checked exception)라고 불리는 명시적인 처리가 필요한 예외를 사용하고 다루는 방법이다. 자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.

Error

첫째는 java.lang.Error 클래스의 서브클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 그래서 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안된다. 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않아도 된다.

Exception과 체크 예외

java.lang.Exception 클래스와 그 서브클래스로 정의되는 예외들은 에러와 달리 개발자들이 만든 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.

Exception 클래스는 다시 체크 예외(checked exception)언체크 예외(Unchecked exception)로 구분된다. 전자는 Exception 크래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들이고, 후자는 RuntimeException을 상속한 클래스들을 말한다. RuntimeException은 Exception의 서브클래스이므로 Exception의 일종이긴 하지만 자바는 이 RuntimeException과 그 서브클래스는 특별하게 다룬다.

일반적으로 예외라고 하면 Exception 클래스의 서브클래스 중에서 RuntimeException을 상속하지 않은 것만을 말하는 체크 예외라고 생각해도 된다. 체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다. 사용할 메소드가 체크 예외를 던진다면 이를 catch 문으로 잡든지, 아니면 다시 throws를 정의해서 메소드 밖으로 던져야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

RuntimeException과 언체크/런타임 예외

java.lang.RuntimeException 클래스를 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다. 또는 대표 클래스 이름을 따서 런타임 예외라고도 한다. 에러와 마찬가지로 이 런타임 예외는 catch 문으로 잡거나 throws로 선언하지 않아도 된다. 물론 명시적으로 잡거나 throw로 선언해줘도 상관없다.

대표적으로 오브젝트를 할당하지 않은 레퍼런스 변수를 사용하려고 시도했을 때 발생하는 NullPointerException이나, 허용되지 않는 값을 사용해서 메소드를 호출할 때 발생하는 IllegalArgumentException 등이 있다. 이런 예외는 코드에서 미리 조건을 체크하도록 주의 깊게 만든다면 피할 수 있다. 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외다. 따라서 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것이다.

예외처리 방법

예외 복구

첫 번째 예외처리 방법은 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예외처리 코드를 강제하는 체크 예외들은 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.

예외처리 회피

두 번째 방법은 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다. throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다. 예외를 자신이 처리하지 않고 회피하는 방법이다.

JdbcTemplate이 사용하는 콜백 오브젝트는 메소드 선언을 보면 알겠지만 ResultSet이나 PreparedStatement 등을 이용해서 작업하다 발생하는 SQLException을 자신이 처리하지 않고 템플릿으로 던져버린다. 콜백 오브젝트의 메소드는 모두 throws SQLException이 붙어있다. SQLException을 처리하는 일은 콜백 오브젝트의 일이 아니라고 보기 때문이다. 콜백 오브젝트의 메소드는 SQLException에 대한 예외를 회피하고 템플릿 레벨에서 처리하도록 던져준다.

예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다.

예외 전환

마지막으로 예외를 처리하는 방법은 예외 전환을 하는 것이다. 예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메소드 밖으로 던지는 것이다. 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.

예외 전환은 보통 두 가지 목적으로 사용된다.

첫째는 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다. API가 발생하는 기술적인 로우레벨을 상황에 적합한 의미를 가진 예외로 변경하는 것이다. 보통 전환하는 예외에 원래 발생한 예외를 담아서 **중첩 예외(nested exception)**로 만드는 것이 좋다. 중첩 예외는 getCause() 메소드를 이용해서 처음 발생한 예외가 무엇인지 확인할 수 있다.

두번째 전환 방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다. 중첩 예외를 이용해 새로운 예외를 만들고 원인이 되는 예외를 내부에 담아서 던지는 방식은 같다. 하지만 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아니다. 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.

일반적으로 체크 예외를 계속 throws를 사용해 넘기는 건 무의미하다. 메소드 선언은 지저분해지고 아무런 장점이 없다. DAO에서 발생한 SQLException이 웹 컨트롤러 메소드까지 명시적으로 전달된다고 해서 무슨 소용이 있을까? 어차피 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.

대부분 서버 환경에서는 애플리케이션 코드에서 처리하지 않고 전달된 예외들을 일괄적으로 다룰 수 있는 기능을 제공한다.

예외처리 전략

런타임 예외의 보편화

일반적으로는 체크 예외가 일반적인 예외를 다루고, 언체크 예외는 시스템 장애나 프로그램사으이 오류에 사용된다고 했다. 문제는 체크 예외는 복구할 가능성이 조금이라도 있는, 말 그대로 예외적인 상황이기 때문에 자바는 이를 처리하는 catch 블록이나 throws 선언을 강제하고 있다는 점이다.

독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 밸생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외상황을 복구할 수 있는 방법이 없다. 자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고 있다. 자칫하면 throws Exception으로 점철된 아무런 의미도 없는 메소드들을 낳을 뿐이다. 그래서 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는게 낫다.

자바 초기부터 있었던 JDK의 API와 달리 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는 API가 발생시키는 예외를 체크 예외 대신 언체크 예외로 정의하는 것이 일반화되고 있다. 언체크 예외라도 필요하다면 얼마든지 catch 블록으로 잡아서 복구하거나 처리할 수 있다. 하지만 대개는 복구 불가능한 상황이고 보나마나 RuntimeException 등으로 포장해서 던져야 할 테니 아예 API 차원에서 런타임 예외를 던지도록 한 것이다.

애플리케이션 예외

런타임 예외 중심의 전략은 굳이 이름을 붙이자면 낙관적인 예외처리 기법이라고 할 수 있다. 일단 복구할 수 있는 예외는 없다고 가정하고 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 알아서 처리해줄 것이고, 꼭 필요한 경우는 런타임 예외라도 잡아서 복구하거나 대응해줄 수 있으니 문제 될 것이 없다는 낙관적인 태도를 기반으로 하고 있다.

보통 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키는 애플리케이션 예외를 가지는 메소드를 설계하는 방법에는 두 가지가 있다.

첫 번째 방법은 정상적인 처리를 했을 경우와 애플리케이션 자체의 로직에 의해 의도적으로 예외를 발생시키고자 하는 경우에 각각 다른 종류의 리턴 값을 돌려주는 것이다. 하지만 이렇게 리턴 값으로 결과를 확인하고, 예외상황을 체크하면 불편한 점도 있다. 우선 예외 상황에 대한 리턴 값을 명확하게 코드화하고 잘 관리하지 않으면 혼란이 생길 수 있다. 또 한 가지 문제는 결과 값을 확인하는 조건문이 자주 등장한다는 점이다.

두 번째 방법은 정상적인 흐름을 따르는 코드는 그대로 두고, 애플리케이션 자체의 로직에 의해 의도적으로 예외를 발생시키고자 하는 경우에는 비즈니스적인 의미를 띤 예외를 던지도록 만드는 것이다. 이때 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 개발자가 잊지 않고 자주 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.

SQLException은 어떻게 됐나?

먼저 생각해볼 사항은 SQLException은 과연 복구가 가능한 예외인가이다. 대부분의 SQLException은 복구가 불가능하다. 더군다나 DAO 밖에서 SQLException을 다룰 수 있는 가능성은 거의 없다. 따라서 예외처리 전략을 적용해야 한다. 필요도 없는 기계적인 throws 선언이 등장하도록 방치하지 말고 가능한한 빨리 언체크/런타임 예외로 전환해줘야 한다.

스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다. 따라서 JdbcTemplate을 사용하는 UserDao 메소드에선 꼭 필요한 경우에만 런타임 예외인 DataAccessException을 잡아서 처리하면 되고 그 외의 경우에는 무시해도 된다.

그 밖에도 스프링의 API 메소드에 정의되어 있는 대부분의 예외는 런타임 예외다. 따라서 발생 가능한 예외가 있다고 하더라도 이를 처리하도록 강제하지 않는다.

예외 전환

예외를 다른 것으로 바꿔서 던지는 예외 전환의 목적은 두 가지이다. 하나는 런타임 예외로 포장해서 굳이 필요하지 않은 catch/throws를 줄여주는 것이고, 다른 하나는 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔서 던져주는 것이다.

스프링의 JdbcTemplate이 던지는 DataAccessException은 일단 런타임 예외로 SQLException을 포장해주는 역할을 한다. 그래서 대부분 복구가 불가능한 예외인 SQLException에 대해 애플리케이션 레벨에서는 신경 쓰지 않도록 해주는 것이다. 또한 DataAccessException은 SQLException에 담긴 다루기 힘든 상세한 예외정보를 의미 있고 일관성 있는 예외로 전환해서 추상화해주려는 용돌 쓰이기도 한다.

JDBC의 한계

JDBC는 자바 표준 JDK에서도 가장 많이 사용되는 기능 중의 하나다.

호환성 없는 SQLException의 DB 에러정보

DB마다 SQL 뿐만 아니라 에러의 종류와 원인도 제각각이다. 그래서 JDBC는 데이터 처리 중에 발생하는 다양한 예외를 그냥 SQLException 하나에 모두 담아버린다. SQLException은 예외가 발생했을 때의 DB 상태를 담은 SQL 상태정보를 부가적으로 제공한다. getSQLState() 메소드로 예외상황에 대한 상태정보를 가져올 수 있다. 이 상태정보는 DB별로 달라지는 에러 코드를 대신할 수 있도록, 스펙에 정의된 SQL 상태 코드를 따르도록 되어있다. 그러나 결국 호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQLException 만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능에 가깝다.

DB 에러 코드 매핑을 통한 전환

SQLException에 담긴 SQL 상태 코드는 신뢰할 만한게 아니므로 더 이상 고려하지 않는다. 차라리 DB 업체별로 만들어 유지해오고 있는 DB 전용 에러 코드가 더 정확한 정보라고 불 수 있다.

스프링은 DataAccessException이라는 SQLException을 대체할 수 있는 런타임 예외를 정의하고 있을 뿐 아니라 DataAccessException의 서브클래스로 세분화된 예외 클래스들을 정의하고 있다. 디에터 엑세스 작업 중에 발생 할 수 있는 예외 상황을 수십 가지 예외로 분류하고 이를 추상화해 정의한 다양한 예외 클래스를 제공한다.

JdbcTemplate은 SQLException을 단지 런타임 예외인 DataAccessException으로 포장하는 것이 아니라 DB의 에러 코드를 DataAccessException 계층구조의 클래스 중 하나로 매핑해준다. 전환되는 JdbcTemplate에서 던지는 예외는 모두 DataAccessException의 서브클래스 타입이다. DB별로 미리 준비된 매핑정보를 참고해서 적절한 예외 클래스를 선택하기 때문이 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있다. 데이터 엑세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.

JdbcTemplate을 이용한다면 JDBC에서 발생하는 DB 관련 예외는 거의 신경 쓰지 않아도 된다.

JDK 1.6에 포함된 JDBC 4.0부터는 기존에 JDBC의 단일 예외 클래스였던 SQLException을 스프링의 DataAccessException과 비슷한 방식으로 좀 더 세분화해서 정의하고 있다.

DAO 인터페이스와 DataAccessException 계층구조

DataAccessException은 JDBC의 SQLException을 전환하는 용도로만 만들어진 건 아니다. JDBC 외의 자바 데이터 엑세스 기술에서 발생하는 예외에도 적용된다.

DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다. 데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.

DAO 인터페이스와 구현의 분리

DAO를 굳이 따로 만들어서 사용하는 이유는 무엇일까? 가장 중요한 이유는 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위해서다.

대부분의 데이터 액세스 예외는 애플리케이션에서는 복구 불가능하거나 할 필요가 없는 것이다. 그렇다고 모든 예외를 다 무시해야 하는 건 아니다. 중복 키 에러처럼 비즈니스 로직에서 의미 있게 처리할 수 잇는 예외도 있다. 애플리케이션에서는 사용하지 않더라도 시스템 레벨에서 데이터 액세스 예외를 의미 있게 분류할 필요도 있다. 문제는 데이터 액세스 기술이 달라지면 같은 상황에서도 다른 종류의 예외가 던져진다는 점이다.

따라서 DAO를 사용하는 클라이언트 입장에서는 DAO의 사용 기술에 따라서 예외 처리 방법이 달라져야 한다. 결국 클라이언트가 DAO의 기술에 의존적이 될 수 밖에 없다.

데이터 액세스 예외 추상화

스프링은 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다. 스프링의 DataAccessException은 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있으며 이런 기술에서만 공통적으로 나타나는 예외를 포함해서 데이터 엑세스 기술에서 발상 가능한 대부분의 예외를 계층구조로 분류해놓았다.

JdbcTemplate과 같이 스프링의 데이터 액세스 지원 기술을 이용해 DAO를 만들면 사용 기술에 독립적인 일관성 있는 예외를 던질 수 있다. 결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수가 있다.

DataAccessException 활용시 주의사항

스프링을 활용하면 DB 종류나 데이터 액세스 기술에 상관없이 키 값이 중복이 되는 상황에서는 동일한 예외가 발생하리라고 기대할 것이다. 하지만 안타깝게도 DuplicateKeyException은 아직까지는 JDBC를 이용하는 경우에만 발생한다. 데이터 액세스 기술을 하이버네이트나 JPA를 사용했을 때도 동일한 예외가 발생할 것으로 기대하지만 실제로 다른 예외가 던져진다. 그 이유는 SQLException에 담긴 DB의 에러 코드를 바로 해석하는 JDBC의 경우와 달리 JPA나 하이버네이트, JDO 등에서는 각 기술이 재정의한 예외를 가져와 스프링이 최종적으로 DataAccessException으로 변환하는데, DB의 에러 코드와 달리 이런 예외들은 세분화되어 있지 않기 때문이다.

DataAccessException이 기술에 상관없이 어느 정도 추상화된 공통 예외로 변환해주긴 하지만 근본적인 한계 때문에 완벽하다고 기대할 수는 없다. 따라서 사용에 주의를 기울여야 한다.

스프링은 SQLException을 DataAccessException으로 전환하는 다양한 방법을 제공한다. 가장 보편적이고 효과적인 방법은 DB 에러 코드를 이용하는 것이다. SQLException을 코드에서 직접 전환하고 싶다면 SQLExceptionTranslator 인터페이스를 구현한 클래스 중에서 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

토비의 스프링 3장 (템플릿)

템플릿

개방 폐쇄 원칙 (OCP)

어떤 부분은 변경을 통해 그 기능이 다양해지고 확장하려는 성질이 있고, 어떤 부분은 고정되어 있고 변하지 않으려는 성질이 있다. 변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것이 개방 폐쇄 원칙이다.

템플릿이란 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.

일반적으로 서버에서는 제한된 개수의 DB 커넥션을 만들어서 재사용 가능한 풀로 관리한다. DB 풀은 매번 getConnection()으로 가져간 커넥션을 명시적으로 close()해서 돌려줘야지만 다시 풀에 넣었다가 다음 커넥션 요청이 있을 때 재사용할 수 있다. 그런데 이런 식으로 오류가 날 때마다 미처 반횐되지 못한 Connection이 계속 쌓이면 어느 순간에 커넥션 풀에 여유가 없어지고 리소스가 모자란다는 심각한 오류를 내며 서버가 중단될 수 있다. 그래서 JDBC 코드에서는 어떤 상황에서도 가져온 리소스를 반환하도록 try/catch/finally 구문 사용을 권장하고 있다. (finally는 try 블록을 수행한 후에 예외가 발생하든 정상적으로 처리되든 상관없이 반드시 실행되는 코드를 넣을 때 사용한다.)

어느 시점에서 예외가 발생했는지에 따라서 close()를 사용할 수 있는 변수가 달라질 수 있기 때문에 finally에서는 반드시 c(Connection)와 ps(PreparedStatment)가 null이 아닌지 먼저 확인한 후에 close() 메소드를 호출해야 한다.

변하는 것과 변하지 않는 것

이런 코드를 효과적으로 다룰 수 있는 방법은 없을까? 이 문제의 핵심은 변하지 않는, 그러나 많은 곳에서 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해내는 작업이다.

분리와 재사용을 위한 디자인 패턴 적용

메소드 추출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();

ps = makeStatement(c); // 변하는 부분을 메소드로 추출하고 변하지 않는 부분에서 호출하도록 만들었다.

ps.executeUpdate();
} catch(SQLException e)
...
}

private PreparedStatement makeStatement(Connection c) throws SQLException {
PreparedStatement ps;
ps = c.prepareStatement("delete from users");
return ps;
}

자주 바뀌는 부분을 메소드로 독립시켰는데 별 이득이 없어 보인다. 왜냐하면 보통 메소드 추출 리펙토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리시키고 남은 메소드가 재사용이 필요한 부분이고, 분리된 메소드는 DAO 로직마다 새롭게 만들어서 확장돼야 하는 부분이기 때문이다. 뭔가 반대로 됐다.

템플릿 메소드 패턴의 적용

템플릿 메소드 패턴은 상속을 통해 기능을 확장해서 사용하는 부분이다. 변하지 않는 부분은 슈퍼클래스에 두고 변하는 부분은 추상 메소드로 정의해둬서 서브클래스에서 오버라이드하여 새롭게 정의해 쓰도록 하는 것이다.

1
abstract protected PreparedStatement makeStatement(Connection c) throws SQLException;
1
2
3
4
5
6
7
public class UserDaoDeleteAll extends UserDao {

protected PreparedStatement makeStatement(Connection C) throws SQLException {
PreparedStatment ps = c.prepareStatement("delete from users");
return ps;
}
}

이제 UserDao 클래스의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있고, 확장 때문에 기존의 상위 DAO 클래스에 불필요한 변화는 생기지 않도록 할 수 있으니 객체지향 설계의 핵심 원리인 개방 폐쇄 원칙(OCP)을 그럭저럭 지키는 구조를 만들어낼 수는 있는것 같다. 그렇지만 아직 문제가 있다. 가장 큰 문제는 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 점이다. 이래서는 장점보다 단점이 더 많아 보인다.

변하지 않는 코드를 가진 UserDao의 JDBC try/catch/finally 블록과 변하는 PreparedStatement를 담고 있는 서브클래스들이 이미 클래스 레벨에서 컴파일 시점에 이미 그 관계가 결정되어 있다. 따라서 그 관계에 대한 유연성이 떨어진다.

전략 패턴의 적용

개방 폐쇠 원칙(OCP)을 잘 지키는 구조이면서도 템플릿 메소드 패턴보다 유연하고 확장성이 뛰어난 것이, 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만드는 전략 패턴이다. 전략 패턴은 OCP 관점에서 보면 확장에 해당하는 변하는 부분을 별도의 클래스로 만들어 추상화된 인터페이스를 통해 위임하는 방식이다.

deleteAll()은 JDBC를 이용해 DB를 업데이트하는 작업이라는 변하지 않는 맥락(context)을 갖는다. deleteAll()의 컨텍스트를 정리해보면 다음과 같다.

  • DB 커넥션 가져오기
  • PreparedStatement를 만들어줄 외부 기능 호출하기
  • 전달받은 PreparedStatement 실행하기
  • 예외가 발생하면 이를 다시 메소드 밖으로 던지기
  • 모든 경우에 만들어진 PreparedStatement와 Connection을 적절히 닫아주기

두번째 작업에서 사용하는 PreparedStatement를 만들어주는 외부 기능이 바로 전략 패턴에서 말하는 전략이라고 볼 수 있다. 전략 패턴의 구조를 따라 이 기능을 인터페이스로 만들어두고 인터페이스 이 메소드를 통해 PreparedStatement 생성 전략을 호출해주면 된다. 여기서 눈여겨볼 것은 이 PreparedStatement를 생성하는 전략을 호출할 때는 이 컨텍스트 내에서 만들어둔 DB 커넥션을 전달해야 한다는 점이다.

PreparedStatement를 만드는 전략의 인터페이스는 컨텍스트가 만들어준 Connection을 전달받아서, PreparedStatement를 만들고 만들어진 PreparedStatement 오브젝트를 돌려준다. 이 내용을 인터페이스로 정의하면 다음과 같다.

1
2
3
public interface StatementStrategy {
PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}

StatementStrategy 인터페이스를 상속해서 실제 전략 클래스를 만들고 이 전략 클래스를 이용한 전략 패턴을 적용한 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public void deleteAll() throws SQLException {
...
try {
c = dataSource.getConnection();

StatementStrategy strategy = new DeleteAllStatement();
ps = strategy.makePreparedStatement(c);

ps.executeUpdate();
} catch (SQLException e) {
...
}
}

전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서(OCP의 폐쇄 원칙) 전략을 바꿔 쓸 수 있다(OCP의 개방 원칙)는 것인데, 이렇게 컨텍스트 안에서 이미 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있다면 뭔가 이상하다. 컨텍스트가 StatementStrategy 인터페이스 뿐 아니라 특정 구현 클래스인 DeleteAllStatement를 직접 알고 있다는건, 전략 패턴에도 OCP에도 잘 들어맞는다고 볼 수 없기 때문이다.

DI 적용을 위한 클라이언트/컨텍스트 분리

전략 패턴에 따르면 Context가 어떤 전략을 사용하게 할 것인가는 Context를 사용하는 앞단의 Client가 결정하는게 일반적이다. Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하는 것이다.

결국 이 구조에서 전략 오브젝트 생성과 컨텍스트로의 전달을 담당하는 책임을 분리시킨 것이 바로 ObjectFactory이며, 이를 일반화한 것이 앞에서 살펴봤던 의존관계 주입(DI)이었다. 결국 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.

아무튼 여기서 이 패턴 구조를 코드에 적용해보자. 중요한 것은 컨텍스트에 해당하는 JDBC try/catch/finally 코드를 클라이언트 코드인 StatementStrategy를 만드는 부분에서 독립시켜야 한다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException {
connection c = null;
PreparedStatement ps = null;

try {
c = dataSource.getConnection();

ps = stmt.makePreparedStatement(c);

ps.executeUpdate();
} catch (SQLException e) {
throw e;
} finally {
if (ps != null) { try { ps.close(); } catch (SQLException e) {} }
if (c != null) { try { c.close(); } catch (SQLException e) {} }
}
}

이 메소드는 컨텍스트의 핵심적인 내용을 잘 담고 있다. 클라이언트로부터 StatementStrategy 타입의 전략 오브젝트를 제공받고 JDBC try/catch/finally 구조로 만들어진 컨텍스트 내에서 작업을 수행한다.

다음은 클라이언트에 해당하는 부분이다. 컨텍스트를 별도의 메소드로 분리했으니 deleteAll() 메소드가 클라이언트가 된다. deleteAll()은 전략 오브젝트를 만들고 컨텍스트를 호출하는 책임을 지고 있다.

1
2
3
4
public void deleteAll() throws SQLException {
StatementStrategy st = new DeleteAllStatement(); // 선정한 전략 클래스의 오브젝트 생성
jdbcContextWithStatementStrategy(st); // 컨텍스트 호출. 전략 오브젝트 전달
}

클라이언트가 컨텍스트가 사용할 전략을 정해서 전달하는 면에서 DI 구조라고 이해할 수도 있다.

마이크로 DI

의존관계 주입(DI)은 다양한 형태로 적용할 수 있다. DI의 가장 중요한 개념은 제3자의 도움을 통해 두 오브젝트 사이의 유연한 관계가 설정되도록 만든다는 것이다. 이 개념만 따른다면 DI를 이루는 오브젝트와 구성요소의 구조나 관계는 다양하게 만들 수 있다.

일반적으로 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 다이내믹하게 설정해주는 오브젝트 팩토리(DI 컨테이너), 그리고 이를 사용하는 클라이언트라는 4개의 오브직트 사이에서 일어난다. 하지만 때로는 원시적인 전략패턴 구조를 따라 클라이언트가 오브젝트 팩토리의 책임을 함께 지고 있을 수도 있다.

이런 경우에는 DI가 매우 작은 단위의 코드와 메소드 사이에서 일어나기도 한다. 이렇게 DI의 장점을 단순화해서 IoC 컨테이너의 도움 없이 코드 내에서 적용한 경우를 마이크로 DI라고도 한다. 또는 코드에 의한 의미로 수동 DI라고 부를 수도 있다.

JDBC 전략 패턴의 최적화

전략과 클라이언트의 동거

현재 구조에 두 가지 불만이 있다.

  • DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 것.
  • DAO 메소드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다는 것.

이 두가지 문제를 해결할 수 있는 방법을 생각해보자.

로컬 클래스

클래스 파일이 많아지는 문제는 간단한 해결 방법이 있다. StatementStrategy 전략 클래스를 매번 독립된 파일로 만들지 말고 UserDao 클래스 안에 내부 클래스로 정의해버리는 것이다. DeleteAllStatement나 AddStatement는 UserDao 밖에서는 사용되지 않는다. 둘 다 UserDao에서만 사용되고, UserDao의 메소드 로직에 강하게 결합되어 있다.

중첩 클래스의 종류

다른 클래스 내부에 정의되는 클래스를 중첩 클래스(nested class)라고 한다. 중첩 클래스는 독립적으로 오브젝트로 만들어질 수 있는 스태틱 클래스(static class)와 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스(inner class)로 구분된다.

내부 클래스는 다시 범위(scope)에 따라 세가지로 구분된다.

  • 멤버 내부 클래스 : 멤버 필드처럼 오브젝트 레벨에 정의된다.
  • 로컬 클래스 : 메소드 레벨에 정의된다.
  • 익명 내부 클래스 : 이름을 갖지 않는 익명 클래스이다. 익명 내부 클래스의 범위는 선언된 위치에 따라서 다르다.

로컬 클래스의 장점은 클래스가 내부 클래스이기 때문에 자신이 선언된 곳의 정보에 접근할 수 있다는 것이다. 내부 메소드는 자신이 정의된 메소드의 로컬 변수에 직접 접근할 수 있기 때문이다. 다만, 내부 클래스에서 외부의 변수를 사용할 때는 외부 변수는 반드시 final로 선언해줘야 한다.

로컬 클래스로 만들어두니, 메소드마다 추가해야 했던 클래스 파일을 하나 줄일 수 있고 내부 클래스의 특징을 이용해 로컬 변수를 바로 가져다 사용할 수 있다는 장점도 생겼다.

익명 내부 클래스

익명 내부 클래스

익명 내부 클래스(anonymous inner class)는 이름을 갖지 않는 클래스다. 클래스 선언과 오브젝트 생성이 결합된 상태로 만들어지며, 상속할 클래스나 구현할 인터페이스를 생성자 대신 사용해서 다음과 같은 형태로 만들어 사용한다. 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.

new 인터페이스이름() { 클래스 본문 };

익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다. 이름이 없기 때문에 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다.

1
2
3
4
5
6
7
8
9
10
StatementStrategy st = new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?,?,?)");
ps.setString(1, user.getId());
ps.setString(2, user.getName());
ps.setString(3, user.getPassword());

return ps;
}
}

만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아두지 말고 jdbcContextWithStatementStrategy() 메소드의 파라미터에서 바로 생성하는 편이 낫다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void add(final User user) throws SQLException {
jdbcContextWithStatementStrategy(
new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("insert into users(id, name, password) values(?,?,?)");
ps.setString(1, user.getId());
ps.setString(2, user.getName());
ps.setString(3, user.getPassword());

return ps;
}
}
);
}

컨텍스트와 DI

전략 패턴의 구조로 보자면 UserDao의 메소드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메소드는 컨텍스트다. 그런데 JDBC의 일반적인 작업 흐름을 담고 있는 jdbcContextWithStatementStrategy()는 다른 DAO에서도 사용 가능하다. 그러니 jdbcContextWithStatementStrategy()를 UserDao 클래스 밖으로 독립시켜서 모든 DAO가 사용할 수 있게 해야한다.

클래스 분리

분리해서 만들 클래스의 이름을 JdbcContext라고 하자. JdbcContext에 UserDao에 있던 컨텍스트 메소드를 workWithStatementStrategy()라는 이름으로 옮겨놓는다. 그런데, 이렇게 하면 DataSource가 필요한 것은 UserDao가 아니라 JdbcContext가 돼버린다. DB 커넥션을 필요로 하는 코드는 JdbcContext 안에 있기 때문이다. 따라서 JdbcContext가 DataSource에 의존하고 있으므로 DataSource 타입 빈을 DI 받을 수 있게 해줘야 한다.

빈 의존관계 변경

UserDao는 이제 JdbcContext에 의존한다. 그런데 JdbcContext는 인터페이스인 DataSource와는 달리 구체 클래스다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는게 목적이다. 하지만 이 경우 JdbcContext는 그 자체로 독립적인 JDBC 컨텍스트를 제공해주는 서비스 오브젝트로서 의미가 있을 뿐이고 구현 방법이 바뀔 가능성은 없다. 따라서 인터페이스를 구현하지 않고, UserDao와 JdbcContext는 인터페이스를 사이에 두지 않고 DI를 적용하는 특별한 구조가 된다.

스프링 빈으로 DI

인터페이스를 사용해서 클래스를 자유롭게 변경할 수 있게 하지는 않았지만, JdbcContext를 UserDao와 DI 구조로 만들어야 할 이유는 다음과 같다.

  1. JdbcContext가 스프링 컨테이너의 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 되기 때문이다.
  2. JdbcContext가 DI를 통해 다른 빈에 의존하고 있기 때문이다. JdbcContext는 dataSource 프로퍼티를 통해 JdbcContext 오브젝트를 주입받도록 되어 있다. DI를 위해서는 주입되는 오브젝트와 주입받는 오브젝트 양쪽 모두 스프링 빈으로 등록돼야 한다. 스프링이 생성하고 관리하는 IoC 대상이어야 DI에 참여할 수 있기 때문이다.

템플릿과 콜백

전략 패턴은 복잡하지만 바뀌지 않는 일정한 패턴을 갖는 작업 흐름이 존재하고 그중 일부분만 자주 바꿔서 사용해야 하는 경우에 적합한 구조다. 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다. 이런 방식을 스프링에서는 템플릿/콜백 패턴이라고 부른다. 전략 패턴의 컨텍스트를 템플릿이라 부르고, 익명 내부 클래스로 만들어지는 오브젝트를 콜백이라고 부른다.

템플릿

템플릿은 어떤 목적을 위해 미리 만들어둔 모양이 있는 틀을 가리킨다. 템플릿 메소드 패턴은 고정된 틀의 로직을 가진 템플릿 메소드를 슈퍼클래스에 두고, 바뀌는 부분을 서브클래스의 메소드에 두는 구조로 이뤄진다.

콜백

콜백은 실행되는 것을 목적으로 다른 오브젝트의 메소드에 전달되는 오브젝트를 말한다. 자바에서는 메소드 자체를 파라미터로 전달할 방법이 없기 때문에 메소드가 담긴 오브젝트를 전달해야 한다. 그래서 펑서녈 오브젝트(functional object)라고도 한다.

템플릿/콜백의 동작원리

템플릿은 고정된 작업 흐름을 가진 코드를 재사용한다는 의미에서 붙인 이름이다. 콜백은 템플릿 안에서 호출되는 것을 목적으로 만들어진 오브젝트를 말한다.

템플릿/콜백의 특징

여러 개의 메소드를 가진 일반적인 인터페이스를 사용할 수 있는 전략 패턴의 전략과 달리 템플릿/콜백 패턴의 콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기 때문이다. 콜백은 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다고 보면된다.

템플릿/콜백 패턴의 일반적인 작업 흐름은 다음과 같다.

  • 클라이언트의 역할은 템플릿 안에서 실행될 로직을 담은 콜백 오브젝트를 만들고, 콜백이 참조할 정보를 제공하는 것이다. 만들어진 콜백은 클라이언트가 템플릿의 메소드를 호출할 때 파라미터로 전달된다.
  • 템플릿은 정해진 작업 흐름을 따라 작업을 진행하다가 내부에서 생성한 참조정보를 가지고 콜백 오브젝트의 메소드를 호출한다. 콜백은 클라이언트 메소드에 있는 정보와 템플릿이 제공한 참조정보를 이용해서 작업을 수행하고 그 결과를 다시 템플릿에 돌려준다.
  • 템플릿은 콜백이 돌려준 정보를 사용해서 작업을 마저 수행한다. 경우에 따라 최종 결과를 클라이언트에 다시 돌려주기도 한다.

템플릿/콜백 방식은 전략 패턴과 DI의 장점을 익명 내부 클래스 사용 전략과 결합한 독특한 활용법이라고 이해할 수 있다. 단순히 전략 패턴으로만 보기엔 독특한 특징이 많으므로 템플릿/콜백을 하나의 고유한 패턴으로 기억해두면 좋다.

편리한 콜백의 재활용

템플릿/콜백 방식에서 한 가지 아쉬운 점이 있다. DAO 메소드에서 매번 익명 내부 클래스를 사용하기 때문에 상대적으로 코드를 작성하고 읽기가 조금 불편하다는 점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public void deleteAll() throws SQLException {
executeSql("delete from users"); // 변하는 SQL 문장
}
------------------------------------------------------------
private void executeSql(final String query) throws SQLException {
this.jdbcContext.workWithStatementStrategy(
new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement(query);
}
}
)
}

바뀌지 않는 모든 부분을 빼내서 executeSql() 메소드로 만들었다. 바뀌는 부분인 SQL 문장만 파라미터로 받아서 사용하게 만들었다. SQL을 담은 파라미터를 final로 선언해서 익명 내부 클래스인 콜백 안에서 직접 사용할 수 있게 하는 것만 주의하면 된다.

이렇게 재사용 가능한 콜백을 담고 있는 메소드라면 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다. 그 결과 결국 JdbcContext 안에 클라이언트와 템플릿, 콜백이 모두 함께 공존하면서 동작하는 구조가 됐다.

템플릿/콜백의 응용

고정된 작업 흐름을 갖고 있으면서 여기저기서 자주 반복되는 코드가 있다면, 중복되는 코드를 분리할 방법을 생각해보는 습관을 기르자. 중복된 코드는 먼저 메소드로 분리하는 간단한 시도를 해본다. 그중 일부 작업을 필요에 따라 바꾸어 사용해야 한다면 인터페이스를 사이에 두고 분리해서 전략패턴을 적용하고 DI로 의존관계를 관리하도록 만든다. 그런데 바뀌는 부분이 한 애플리케이션 안에서 동시에 여러 종류가 만들어질 수 있다면 이번엔 템플릿/콜백 패턴을 적용하는 것을 고려해볼 수 있다.

가장 전형적인 템플릿/콜백 패턴의 후보는 try/catch/finally 블록을 사용하는 코드다.

템플릿/콜백을 적용할 때는 템플릿과 콜백의 경계를 정하고 템플릿이 콜백에게, 콜백이 템플릿에게 각각 전달하는 내용이 무엇인지 파악하는게 가장 중요하다. 그에 따라 콜백의 인터페이스를 정의해야 하기 때문이다.

클래스 이름이 Template으로 끝나거나 인터페이스 이름이 Callback으로 끝난다면 템플릿/콜백이 적용된 것이라고 보면 된다.

정리

  • JDBC와 같은 예외가 발생할 가능성이 있으며 공유 리소스의 반환이 필요한 코드는 반드시 try/catch/finally 블록으로 관리해야 한다.
  • 일정한 작업 흐름이 반복되면서 그중 일부 기능만 바뀌는 코드가 존재한다면 전략 패턴을 적용한다. 바뀌지 않는 부분을 컨텍스트로, 바뀌는 부분은 전략으로 만들고 인터페이스를 통해 유연하게 전략을 변경할 수 있도록 구성한다.
  • 클라이언트 메소드 안에 익명 내부 클래스를 사용해서 전략 오브젝트를 구현하면 코드도 간결해지고 메소드의 정보를 직접 사용할 수 있어서 편리하다.
  • 컨텍스트가 하나 이상의 클라이언트 오브젝트에서 사용된다면 클래스를 분리해서 공유하도록 만든다.
  • 단일 전략 메소드를 갖는 전략 패턴이면서 익명 내부 클래스를 사용해서 매번 전략을 새로 만들어 사용하고, 컨텍스트 호출과 동시에 전략 DI를 수행하는 방식을 템플릿/콜백 패턴이라고 한다.
  • 콜백의 코드에도 일정한 패천이 반복된다면 콜백을 템플릿에 넣고 재활용하는 것이 편리하다.
  • 템플릿과 콜백의 타입이 다양하게 바뀔 수 있다면 제네릭스를 이용한다.
  • 템플릿은 한 번에 하나 이상의 콜백을 사용할 수도 있고, 하나의 콜백을 여러 번 호출할 수도 있다.
  • 템플릿/콜백을 설계할 때는 템플릿과 콜백 사이에 주고받는 정보에 관심을 둬야한다.

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리

토비의 스프링 2장 (테스트)

테스트

스프링이 개발자에게 제공하는 가장 중요한 가치는 객체지향과 테스트이다.

테스트란 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게 해주는 작업이다. 또한 테스트의 결과가 원하는 대로 나오지 않는 경우에는 코드나 설계에 결함이 있음을 알수 있다. 이를 통해 코드의 결함을 제거해가는 작업, 디버깅을 거치게 되고, 최종적으로 테스트가 성공하면 모든 결함이 제거됐다는 확신을 얻을 수 있다.

보통 웹 프로그램에서 사용하는 DAO를 테스트 하는 방법은 다음과 같다. DAO를 만든 뒤 바로 테스트하지 않고, 서비스 계층, MVC 프레젠테이션 계층까지 포함한 모든 입출력 기능을 대충이라도 코드로 다 만든다. 이렇게 만들어진 테스트 용 웹 애플리케이션을 서버에 배치한 뒤, 웹 화면을 띄워 폼을 열고, 값을 입력한 뒤 버튼을 눌러 등록해본다.

이렇게 웹 화면을 통해 값을 입력하고, 기능을 수행하고, 결과를 확인하는 방법은 가장 흔하게 쓰이는 방법이지만 단점이 너무 많다. 테스트를 하는 중에 에러가 나거나 테스트가 실패했다면, 과연 어디에서 문제가 발생했는지를 찾아내야 하는 수고도 필요하다. 하나의 테스트를 수행하는 데 참여하는 클래스와 코드가 너무 많기 때문이다.

테스트를 하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직하다. 테스트는 가능하면 작은 단위로 쪼개서 집중해서 할 수 있어야 한다. 관심사의 분리라는 원리가 여기에도 적용된다. 테스트의 관심이 다르다면 테스트할 대상을 분리하고 집중해서 접근해야한다.

작은 단위의 코드에 대해 테스트를 수행한 것을 단위 테스트(Unit test)라고 한다. 여기서 말하는 단위란 그 크기와 범위가 어느 정도인지 딱 정해진 건 아니다. 충분히 하나의 관심에 집중해서 효율적으로 텧스트할 만한 범위의 단위라고 보면 된다.

일반적으로 단위는 작을수록 좋다. 단위를 넘어서는 다른 코드들은 신경 쓰지 않고, 참여하지도 않고 테스트가 동작할 수 있으면 좋다. 그런 차원에서 통제할 수 없는 외부의 리소스에 의존하는 테스트는 단위 테스트가 아니라고 보기도 한다.

단위 테스트를 하는 이유는 개발자가 설계하고 만든 코드가 원래 의도한 대로 동작하는지를 개발자 스스로 빨리 확인받기 위해서다. 이때 확인의 대상과 조건이 간단하고 명확할수록 좋다.

테스트는 자동으로 수행되도록 코드로 만들어지는 것이 중요하다. 그렇게 되면 자주 반복할 수 있다는 장점을 얻을 수 있다. 테스트 자체가 사람의 수작업을 거치는 방법을 사용하기 보다는 코드로 만들어져서 자동으로 수행될 수 있어야 한다는 건 매우 중요하다. 그런데 애플리케이션을 구성하는 클래스 안에 테스트 코드를 포함시키는 것보다는 별도로 테스트용 클래스를 만들어서 테스트 코드를 넣는 편이 낫다.

테스트를 이용하면 새로운 기능도 기대한 대로 동작하는지 확인할 수 있을 뿐 아니라, 기존에 만들어뒀던 기능들이 새로운 기능을 추가하느라 수정한 코드에 영향을 받지 않고 여전히 잘 동작하는지를 확인할 수도 있다.

테스트 검증의 자동화

모든 테스트는 성공과 실패의 두 가지 결과를 가질 수 있다. 또 테스트의 실패는 테스트가 진행되는 동안에 에러가 발생해서 실패하는 경우와, 테스트 작업 중에 에러가 발생하진 않았지만 그 결과가 기대한 것과 다르게 나오는 경우로 구분해볼 수 있다. 여기서 전자를 테스트 에러, 후자를 테스트 실패로 구분할 수 있다.

테스트 중에 에러가 발생하는 것은 쉽게 확인이 가능하다. 콘솔에 에러 메시지와 긴호출 스택 정보가 출력되기 때문이다. 하지만 테스트가 실패하는 것은 별도의 확인 작업과 그 결과가 있어야만 알 수 있다.

자바에는 단순하면서도 실용적인 테스트를 위한 도구가 여러 가지 존재한다. JUnit은 이름 그대로 자바로 단위 테스트를 만들 때 유용하게 쓸 수 있다.

JUnit은 프레임워크다. 프레임워크는 개발자가 만든 클래스에 대한 제어 권한을 넘겨받아서 주도적으로 애플리케이션의 흐름을 제어한다. 개발자가 만든 클래스의 오브젝트를 생성하고 실행하는 일은 프레임워크에 의해 진행된다. 따라서 프레임워크에서 동작하는 코드는 main() 메소드도 필요 없고 오브젝트를 만들어서 실행시키는 코드를 만들 필요도 없다.

main() 메소드 테스트는 그런면에서 프레임워크에 적용하기엔 적합하지 않다. 테스트가 main() 메소드로 만들어졌다는 건 제어권을 직접 갖는다는 의미이기 때문이다. 그래서 가장 먼저 할 일은 main() 메소드에 있던 테스트 코드를 일반 메소드로 옮기는 것이다. 새로 만들 테스트 메소드는 JUnit 프레임워크가 요구하는 조건 두가지를 따라야 한다. 첫째는 메소드가 public으로 선언돼야 하는 것이고, 다른 하나는 메소드에 @Test라는 애노테이션을 붙여주는 것이다.

JUnit은 하나의 클래스 안에 여러 개의 테스트 메소드가 들어가는 것을 허용한다. @Test가 붙어있고 public 접근자가 있으며 리턴 값이 void 형이고 파라미터가 없다는 조건을 지키기만 하면된다.

1
2
3
4
5
6
7
8
public class Test {
@Test
public void add() {
ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");

...
}
}

검증 코드 변환

1
if (!user.getName().equals(user2.getName())) { ... }

이 if 문장의 기능을 JUnit이 제공해주는 assertThat이라는 스태틱 메소드를 이용해 다음과 같이 변경할 수 있다.

1
assertThat(user2.getName(), is(user.getName()));

assertThat() 메소드는 첫 번째 파라미터의 값을 뒤에 나오는 매처(matcher)라고 불리는 조건으로 비교해서 일치하면 다음으로 넘어가고, 아니면 테스트가 실패하도록 만들어 준다. is()는 매처의 일종으로 equals()로 비교해주는 기능을 가졌다.

JUni은 예외가 발생하거나 assertThat()에서 실패하지 않고 테스트 메소드의 실행이 완료되면 테스트가 성공했다고 인식한다.

JUnit 테스트 실행

스프링 컨테이너와 마찬가지로 JUnit 프레임워크도 자바 코드로 만들어진 프로그램이므로 어디선가 한 번은 JUnit 프레임워크를 시작시켜 줘야 한다.

어디에든 main() 메소드를 하나 추가하고, 그 안에 JUnitCore 클래스의 main 메소드를 호출해주는 간단한 코드를 넣어주면 된다. 메소드 파라미터에는 @Test 테스트 메소드를 가진 클래스의 이름을 넣어준다.

1
2
3
4
5
import org.junit.runner.jUnitCore;
...
public static void main(String[] args) {
JUnitCore.main("Springbook.user.dao.UserDaoTest");
}

JUnit은 assertThat()을 이용해 검증을 했을 때 기대한 결과가 아니면 이 **AssertionError**를 던진다. 또한 테스트 수행 중에 일반 예외가 발생한 경우에도 마찬가지로 테스트 수행은 중단되고 테스트는 실패한다.

JUnit

JUnit은 사실상 자바의 표준 테스팅 프레임워크라고 불릴만큼 폭넓게 사용되고 있다. 스프링의 핵심 기능 중 하나인 스프링 테스트 모듈도 JUnit을 이용한다. 또, 테스트 작성시 자주 필요한 편리한 여러 가지 부가기능도 제공한다. 대부분의 자바 IDE는 JUnit 테스트를 손쉽게 실행할 수 있는 JUnit 테스트 지원 기능을 내장하고 있어서 더욱 편리하게 JUnit 테스트를 만들고 활용할 수 있게 해준다.

JUnit 테스트 실행 방법

JUnitCore를 이용해 테스트를 실행하고 콘솔에 출력된 메시지를 보고 결과를 확인하는 방법은 가장 간단하긴 하지만 테스트의 수가 많아지면 관리하기가 힘들어진다는 단점이 있다. 가장좋은 JUnit 테스트 실행 방법은 자바 IDE에 내장된 JUnit 테스트 지원 도구를 사용하는 것이다. IDE를 사용하면 JUnitCore를 이용할 때처럼 main() 메소드를 만들지 않아도 된다.

JUnit은 한 번에 여러 테스트 클래스를 동시에 실행할 수도 있다. 패키지 아래에 있는 모든 JUnit 테스트를 한 번에 실행할 수도 있고, 소스 폴더나 프로젝트 전체를 선택해서 모든 테스트를 한 번에 실행할 수도 있다. 이런 면에서 JUnitCore를 사용해 테스트를 실행하는 것보다 훨씬 편리하다.

주의해야 할 점은 여러개의 테스트가 어떤 순서로 실행될지는 알 수 없다. JUnit은 특정한 테스트 메소드의 실행 순서를 보장해주지 않는다. 테스트의 결과가 테스트 실행 순서에 영향을 받는다면 테스트를 잘못 만든 것이다. 모든 테스트는 실행 순서에 상관없이 독립적으로 항상 동일한 결과를 낼 수 있도록 해야 한다.

빌드툴

프로젝트의 빌드를 위해 ANT메이븐(Maven)같은 빌드 툴과 스크립트를 사용하고 있다면, 빌드 툴에서 제공하는 JUnit 플러그인이나 태스크를 이용해 JUnit 테스트를 실행할 수 있다.

여러 개발자가 만든 코드를 모두 통합해서 테스트를 수행해야 할 때도 있다. 이런 경우에는 서버에서 모든 코드를 가져와 통합하고 빌드한 뒤에 테스트를 수행하는 것이 좋다. 이때는 빌드 스크립트를 이용해 JUnit 테스트를 실행하고 그 결과를 메일 등으로 통보받는 방법을 사용하면 된다.

포괄적인 테스트

예외조건에 대한 테스트

일반적으로는 테스트 중에 예외가 던져지면 테스트 메소드의 실행은 중단되고 테스트는 실패한다. assertThat()을 통한 검증 실패는 아니고 테스트 에러라고 볼 수 있다. 그런데 이번에는 반대로 테스트 진행 중에 특정 예외가 던져지면 테스트가 성공한 것이고, 예외가 던져지지 않고 정상적으로 작업을 마치면 테스트가 실패했다고 판단해야 한다. 문제는 예외 발생 여부는 메소드를 실행해서 리턴 값을 비교하는 방법으로 확인할 수 없다는 점이다.

그런데 바로 이런 경우를 위해 JUnit은 예외조건 테스트를 위한 특별한 방법을 제공해준다. @Test 애노테이션의 expected 엘리먼트다. expected는 메소드 실행 중에 발생하리라 기대하는 예외 클래스를 넣어주면 된다.

@Test에 expected를 추가해놓으면 보통의 테스트와는 반대로, 정상적으로 테스트 메소드를 마치면 테스트가 실패하고, expected에서 지정한 예외가 던져지면 테스트가 성공한다. 예외가 반드시 발생해야 하는 경우를 테스트하고 싶을 때 유용하게 쓸 수 있다.

테스트가 이끄는 개발

테스트 주도 개발

만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고, 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법이 있다. 이를 테스트 주도 개발(TDD)이라고 한다. 또는 테스트를 코드보다 먼저 작성한다고 해서 테스트 우선 개발(Test First Development)이라고도 한다.

“실패한 테스트를 성공시키기 위한 목적이 아닌 코드는 만들지 않는다”는 것이 TDD의 기본 원칙이다. TDD는 아예 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 식으로 진행하기 때문에 테스트를 빼먹지 않고 꼼꼼하게 만들어낼 수 있다. 또한 TDD를 하면 자연스럽게 단위 테스트를 만들 수 있다.

TDD의 장점 중 하나는 코드를 만들어 테스트를 실행하는 그 사이의 간격이 매우 짧다는 점이다. 개발한 코드의 오류는 빨리 발견할수록 좋다. 빨리 발견된 오류는 쉽게 대응이 가능하기 때문이다. 테스트 없이 오랜 시간 동안 코드를 만들고 나서 테스트를 하면, 오류가 발생했을 때 원인을 찾기가 쉽지 않다.

테스트 코드 개선

JUnit 프레임워크는 테스트 메소드를 실행할 때 부가적으로 해주는 작업이 몇 가지 있다. 그 중에서 테스트를 실행할 때마다 반복되는 준비 작업을 별도의 메소드에 넣게 해주고, 이를 매번 테스트 메소드를 실행하기 전에 먼저 실행시켜주는 기능이다. 이를 알기위해서는 JUnit 프레임워크가 테스트 메소드를 실행하는 과정을 알아야 한다.

JUnit이 하나의 테스트 클래스를 가져와 테스트를 수행하는 방식은 다음과 같다.

  1. 테스트 클래스에서 @Test가 붙은 public이고 void형이며 파라미터가 없는 테스트 메소드를 모두 찾는다.
  2. 테스트 클래스의 오브젝트를 하나 만든다.
  3. @Before가 붙은 메소드가 있으면 실행한다.
  4. @Test가 붙은 메소드를 하나 호출하고 테스트 결과를 저장해둔다.
  5. @After가 붙은 메소드가 있으면 실행한다.
  6. 나머지 테스트 메소드에 대해 2~5번을 반복한다.
  7. 모든 테스트의 결과를 종합해서 돌려준다.

JUnit은 @Test가 붙은 메소드를 실행하기 전과 후에 각각 @Before와 @After가 붙은 메소드를 자동으로 실행한다. 보통 하나의 테스트 클래스 안에 있는 테스트 메소드들은 공통적인 준비작업과 정리 작업이 필요한 경우가 많다. 이런 작업들을 @Before, @After가 붙은 메소드에 넣어두면 JUnit이 자동으로 메소드를 실행해주니 매우 편리하다.

대신 @Before나 @After 메소드를 테스트 메소드에서 직접 호출하지 않기 때문에 서로 주고받을 정보나 오브젝트가 있다면 인스턴스 변수를 이용해야 한다.

또 한가지 기억해야 할 사항은 각 테스트 메소드를 실행할 때마다 테스트 클래스의 오브젝트를 새로 만든다는 것이다. 한번 만들어진 테스트 클래스의 오브젝트는 하나의 테스트 메소드를 사용하고 나면 버려진다. 그렇기 때문에 각 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 확실히 보장할 수 있다. 덕분에 인스턴스 변수도 부담 없이 사용할 수 있다. 어차피 다음 테스트 메소드가 실행될 때는 새로운 오브젝트가 만들어져서 다 초기화될 것이다.

픽스처

테스트를 수행하는 데 필요한 정보나 오브젝트를 픽스처(fixture)라고 한다. 일반적으로 픽스처는 여러 테스트에서 반복적으로 사용되기 때문에 @Before 메소드를 이용해 생성해두면 편리하다.

스프링 테스트 적용

빈이 많아지고 복잡해지면 애플리케이션 컨텍스트 생성이 적지 않은 시간이 걸린다. 애플리케이션 컨텍스트가 만들어질 때는 모든 싱글톤 빈 오브젝트를 초기화한다. 또 한가지 문제는 애플리케이션 컨텍스트가 초기화될 때 어떤 빈은 독자적으로 많은 리스소를 할당하거나 독립적인 스레드를 띄우기도 한다는 것이다. 이런 경우에는 테스트를 마칠 때마다 애플리케이션 컨텍스트 내의 빈이 할당한 리소스 등을 깔끔하게 정리해주지 않으면 다음 테스트에서 새로운 애플리케이션 컨텍스트가 만들어지면서 문제를 일으킬 수도 있다.

다행히도 애플리케이션 컨텍스트는 초기화되고 나면 내부의 상태가 바뀌는 일은 거의 없다. 빈은 싱글톤으로 만들었기 때문에 상태를 갖지 않는다. 따라서 애플리케이션 컨텍스트는 한 번만 만들고 여러 테스트가 공유해서 사용해도 된다. 스프링이 직접 제공하는 애플리케이션 컨텍스트 지원 기능을 사용하면 애플리케이션을 한 번만 만들어 공유해 사용할 수 있다.

테스트를 위한 애플리케이션 컨텍스트 관리

스프링은 JUnit을 이용하는 테스트 컨택스트 프레임워크를 제공한다. 테스트 컨텍스트의 지원을 받으면 간단한 애노테이션 설정만으로 테스트에서 필요로 하는 애플리케이션 컨텍스트를 만들어서 모든 테스트가 공유하게 할 수 있다.

먼저 ApplicationContext 타입의 인스턴스 변수를 선언하고 스프링이 제공하는 @Autowired 애노테이션을 붙인다. 마지막으로 클래스 레벨에 @RunWith@ContextConfiguration 애노테이션을 추가해준다.

1
2
3
4
5
6
7
8
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(location="/applicationContext.xml")
public class Test {
@Autowired
private ApplicationContext context;

...
}

@RunWith는 JUnit 프레임워크의 테스트 실행 방법을 확장할 때 사용하는 어노테이션이다. SpringJUnit4ClassRunner라는 JUnit용 테스트 컨텍스트 프레임워크 확장 클래스를 지정해주면 JUnit이 테스트를 진행하는 중에 테스트가 사용할 애플리케이션 컨텍스트를 만들고 관리하는 작업을 진행해준다.

@ContextConfiguration은 자동으로 만들어줄 애플리케이션 컨텍스트의 설정파일 위치를 지정한 것이다.

context 변수에는 어떻게 애플리케이션 컨텍스트가 들어가 있을까? 스프링의 JUnit 확장기능은 테스트가 실행되기 전에 딱 한 번만 애플리케이션 컨텍스트를 만들어두고, 테스트 오브젝트가 만들어질 때마다 특별한 방법을 이용해 애플리케이션 컨텍스트 자신을 테스트 오브젝트의 특정 필드에 주입해주는 것이다. 일종의 DI라고 볼 수 있는데, 애플리케이션 오브젝트 사이의 관계를 관리하기 위한 DI와는 조금 성격이 다르다.

이렇게 해서 하나의 테스트 클래스 내의 테스트 메소드는 같은 애플리케이션 컨텍스트를 공유해서 사용할 수 있다.

테스트 클래스의 컨텍스트 공유

스프링 테스트 컨텍스트 프레임워크의 기능은 하나의 테스트 클래스 안에서 애플리케이션 컨텍스트를 공유해주는 것이 전부가 아니다. 여러 개의 테스트 클래스가 있는데 모두 같은 설정파일을 가진 애플리케이션 컨텍스트를 사용한다면, 스프링은 테스트 클래스 사이에서도 애플리케이션 컨텍스트를 공유하게 해준다.

테스트 클래스마다 다른 설정파일을 사용하도록 만들어도 되고, 몇 개의 테스트에서만 다른 설정파일을 사용할 수도 있다. 스프링은 설정파일의 종류만큼 애플리케이션 컨텍스트를 만들고, 같은 설정파일을 지정한 테스트에서는 이를 공유하게 해준다.

@Autowired

@Autowired는 스프링의 DI에 사용되는 특별한 애노테이션이다. @Autowired가 붙은 인스턴스 변수가 있으면, 테스트 컨텍스트 프레임워크는 변수 타입과 일치하는 컨텍스트 내의 빈을 찾는다. 타입이 일치하는 빈이 있으면 인스턴스 변수에 주입해준다. 일반적으로는 주입을 위해서는 생성자나 수정자 메소드 같은 메소드가 필요하지만, 이 경우에는 메소드가 없어도 주입이 가능하다. 또 별도의 DI 설정 없이 필드의 타입정보를 이용해 빈을 자동으로 가져올 수 있는데, 이런 방법을 타입에 의한 자동와이어링이라고 한다.

스프링 애플리케이션 컨텍스트는 초기화할 때 자기 자신도 빈으로 등록한다. 따라서 애플리케이션 컨텍스트에는 ApplicationContext 타입의 빈이 존재하는 것이고 DI도 가능하다.

@Autowired를 이용해 애플리케이션 컨텍스트가 갖고 있는 빈을 DI 받을 수 있다면 굳이 컨텍스트를 가져와 getBean()을 사용하는 것이 아니라, 아예 빈을 직접 DI 받을 수도 있다. (@Autowired를 지정하기만 하면 어떤 빈이든 다 가져올 수 있다.)

@Autowired는 변수에 할당 가능한 타입을 가진 빈을 자동으로 찾는다. 단, @Autowired는 같은 타입의 빈이 두 개 이상 있는 경우에는 타입만으로는 어떤 빈을 가져올지 결정할 수 없다. 타입으로 가져올 빈 하나를 선택할 수 없는 경우에는 변수의 이름과 같은 이름의 빈이 있는지 확인한다. 변수 이름으로도 빈을 찾을 수 없는 경우에는 예외가 발생한다.

테스트는 필요하다면 얼마든지 애플리케이션 클래스와 밀접한 관계를 맺고 있어도 상관없다. 개발자가 만드는 테스트는 코드 내부구조와 설정 등을 알고 있고 의도적으로 그 내용을 검증해야 할 필요가 있기 때문이다. 하지만 꼭 필요하지 않다면 테스트에서도 가능한 한 인터페이스를 사용해서 애플리케이션 코드와 느슨하게 연결해두는 편이 좋다.

DI와 테스트

인터페이스를 통해 DI를 적용해야 하는 이유는 다음과 같다.

  • 소프트웨어 개발에서 절대로 바뀌지 않는 것은 없기 때문이다.
  • 클래스의 구현 방식은 바뀌지 않는다고 하더라도 인터페이스를 두고 DI를 적용하게 해두면 다른 차원의 서비스 기능을 도입할 수 있기 때문이다.
  • 테스트 때문이다.

SingleConnectionDataSource

스프링이 제공하는 가장 빠른 DataSource이다. DB 커넥션을 하나만 만들어두고 계속 사용하기 때문에 매우 빠르다. 다중 사용자 환경에서는 사용할 수 없겠지만 순차적으로 진행되는 테스트에서라면 문제없다.

스프링 테스트 컨텍스트 프레임워크를 적용했다면 애플리케이션 컨텍스트는 테스트 중에 딱 한 개만 만들어지고 모든 테스트에서 공유해서 사용한다. 따라서 애플리케이션 컨텍스트의 구성이나 상태를 테스트 내에서 변경하지 않는 것이 원칙이다. 만약 한 번 변경하면 나머지 모든 테스트를 수행하는 동안 변경된 애플리케이션 컨텍스트가 계속 사용될 것이다. 이는 별로 바람직하지 못하다.

그럴때는 @DirtiesContext라는 애노테이션을 추가한다. 이 애노테이션은 스프링의 테스트 컨텍스트 프레임워크에게 해당 클래스의 테스트에서 애플리케이션 컨텍스트의 상태를 변경한다는 것을 알려준다. 테스트 컨텍스트는 이 애노테이션이 붙은 테스트 클래스에는 애플리케이션 컨텍스트 공유를 허용하지 않는다. 테스트 메소드를 수행하고 나면 매번 새로운 애플리케이션 컨텍스트를 만들어서 다음 테스트가 사용하게 해준다. 테스트 중에 변경한 컨텍스트가 뒤의 테스트에 영향을 주지 않게하기 위해서다.

@DirtiesContext는 클래스에만 적용할 수 있는 건 아니다. 하나의 메소드에서만 컨텍스트 상태를 변경한다면 메소드 레벨에 @DirtiesContext를 붙여주는 편이 낫다. 해당 메소드의 실행이 끝나고 나면 이후에 진행되는 테스트를 위해 변경된 애플리케이션 컨텍스트는 폐기되고 새로운 애플리케이션 컨텍스트가 만들어진다.

테스트를 위한 별도의 DI 설정

테스트 코드에서 빈 오브젝트에 수동으로 DI 하는 방법은 장점보다 단점이 많다. 코드가 많아져 번거롭기도 하고 애플리케이션 컨텍스트도 매번 새로 만들어야 하는 부담이 있다.

그래서 테스트 전용 설정파일을 따로 만들어 사용하는 방법을 이용한다. 테스트에서는 항상 테스트 전용 설정파일만 사용하게 해주면 된다.

@ContextConfiguration 애노테이션에 있는 locations 엘리먼트의 값을 새로 만든 테스트용 설정파일로 변경해준다.

1
2
3
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class Test {

참고

토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리