Enjoy My Posts

토비의 스프링 6장

Posted on By Geunwon Lim

6장 AOP

AOP는 흩어진 부가기능을 모듈화하여, 핵심기능에 부가기능을 투명하게 제공하는 것이다. 부가기능은 투명하게 제공해야 한다. 투명하다는 건 부가기능을 적용한 후에도 기존 설계와 코드에 영향을 주지 않는다는 것이다. 투명하기 때문에 언제나 자유롭게 추가하거나 제거할 수 있고, 기존 코드는 원래 상태를 유지할 수 있다.

6.1 트랜잭션 코드의 분리

5장에서 서비스추상화를 통해 트랜잭션 기능을 기술에 종속적이지 않게 만들었다. 다만, 여전히 찜찜한 것이 있는데 비즈니스 로직에 트랜잭션 경계설정 코드를 넣었다는 것이다. 추상화를 했는데도 불구하고 비즈니스 로직이 메인이 아니라, 트랜잭션 코드가 많은 자리를 차지하는 게 못마땅하다. 하지만 논리적으로 트랜잭션 경계는 반드시 비즈니스 로직 전후에 설정돼야 하다보니, 비즈니스 로직에 트랜잭션 기능 코드 두는 걸 막을 수가 없다.

6.1.1 메소드 분리

예제 코드를 보면, 비즈니스 로직과 트랜잭션 설정 기능이 확 나뉜다. 즉, 비즈니스 로직 코드를 사이에 두고, 트랜잭션 시작과 종료를 담당하는 코드가 앞뒤에 위치한다. 여기서 특징은 트랜잭션 경계 설정 코드와 비즈니스 로직 코드 간 주고받는 정보가 없다는 것이다. 즉 비즈니스 로직과 트랜잭션 기능은 성격이 다를 뿐 아니라, 주고받는 것도 없는, 완전히 독립적인 코드다. 순서만 지켜지면 된다. 직관적으로 생각할 수 있는 분리는 메소드 추출이다. 코드 분리 후 보기 깔끔하다는 장점은 있지만, 트랜잭션 기능이 비즈니스 로직 부분에 위치한다는 건 변함이 없다(한계가 있다).

6.1.2 클래스 분리(di 이용)

원하는 건 비즈니스 로직 코드는 트랜잭션 코드가 있는지도 모르게 하는거다. 이렇게 하려면, 트랜잭션 코드를 클래스 밖으로 뽑아내면 된다. 그런데 비즈니스 코드(UserService)를 직접 호출할 경우, 어떻게든 해서 트랜잭션 코드를 클래스 밖으로 뽑아내면, 트랜잭션 기능이 빠지게 될 수밖에 없다. 구체적인 구현 클래스를 직접 사용했기 때문에 생긴 필연적인 단점(유연하게 확장할 수 없다는 것)이다.

직접 사용하는게 문제니까, di를 통해 간접적으로 사용하면 된다. 즉 UserService를 인터페이스로 만들고, 그걸 구현하게한다. 그러면 클라이언트와 구현 클래스의 결합이 약해지고, 직접 의존하지 않기 때문에 유연한 확장이 가능해진다. 여기에서 UserService를 추상화한 것은 이제까지 추상화했던 것과는 목적이 다르다. 이제까지 추상화했던 것은 구현 클래스를 바꿔가면서 사용하기 위함이었다. 하지만 여기에서는 그 목적 때문에 추상화한 것은 아니고, 책임을 분리하기 위해 추상화했다. UserService를 구현하는 클래스를 두 개 두고, 그것을 동시에 활용함으로써 말이다. 즉 UserService를 구현하는 첫 번째 클래스는 트랜잭션의 경계설정이라는 부가기능을 담고, UserService를 구현하는 두 번째 클래스는 비즈니스 로직을 담는다. 클라이언트가 UserService를 호출할 때, 첫 번째 UserService구현체(부가기능 담당)이 적절히 트랜잭션의 경계를 설정해주고, 그 사이에 비즈니스 로직이 필요한 부분은 두 번째 UserService구현체(비즈니스로직 담당)에 위임한다. 이렇게 하면 UserService 인터페이스를 활용하는 클라이언트 입장에선 트랜잭션이 적용된 비즈니스 로직을 활용할 수 있다. 이 때 필요한 것이 의존관계 설정을 잘 하는 것이다. 즉, 기존 UserService 인터페이스를 하나의 구현체와 연결할 때는 그냥 DI설정을 UserService에 UserServiceImpl을 매칭시켜주면 됐다. 그런데 이제 UserService를 구현하는 객체가 두 개이고, 그것을 동시에 활용해야 하기 때문에 UserService에 어떤 객체를 매칭시켜줘야 할 지 애매해진다. 호출의 흐름을 보면, 클라이언트가 첫 번째 UserService구현체(부가기능 담당)를 호출하고, 첫 번째 UserService구현체가 두 번째 UserService구현체(비즈니스 로직 담당)을 호출한다. 이렇게 되게 하기 위해, UserService에는 첫 번째 UserService구현체(부가기능담당)을 매칭시켜주고, 첫 번째 UserService구현체에 두 번째 UserService구현체(비즈니스 로직 담당)가 주입되게끔 DI 설정한다. 이렇게 의존관계를 설정하는 걸 수동으로 하려면 굉장히 힘들 것 같은데, 스프링을 활용하면 쉽게 할 수 있으니 이것도 IoC가 스프링의 핵심 가치 중 하나인 이유가 아닐까 싶다. 이렇게 클래스 분리를 하면 어떤 점이 좋을 지 정리해보자. 첫째, 비즈니스로직을 담당하는 코드가 비즈니스 로직에만 집중할 수 있게 됐다(이제까지 얘기했던 얘기임). 두 번째 장점은 비즈니스 로직에 대한 테스트를 쉽게 만들 수 있다는 것이다(이는 후술).

6.2 고립된 단위테스트

테스트 단위를 작게하면 좋은 점이 테스트 실패 시 원인을 찾기 쉽다는 것이다. 또한 테스트의 의도나 내용이 분명해지고 만들기도 쉬워진다. 하지만 작은 단위로 테스트하고 싶어도 그러지 못하는 경우가 있다. 테스트 대상이 다른 오브젝트나 환경에 의존하고 있다면 작은 단위의 테스트가 주는 장점을 얻기 어렵다.

UserService같은 경우 여러 개의 의존관계를 가지고 있다. 그리고 만약 그 의존대상들이 또 다른 대상에 의존하고 있다면…? 의존관계가 얽히고 설킬수록 UserService만 테스트하기 점점 어려워진다.

그래서 테스트의 대상이 환경이나 외부 서버, 다른 클래스의 코드에 종속되거나 영향받지 않도록 고립시킬 필요가 있다. 고립시키는 방법은 stub이나 mock을 활용해 테스트를 위한 대역을 사용하는 것이다. 이렇게 테스트를 고립시키면, 기존에 문제였던 테스트 단위가 너무 넓다는 것을 해결(복잡함 제거)할 뿐 아니라, 테스트 수행 성능도 좋아진다.

개인적으로 이 소단원에서 ‘UserService인터페이스 구현체를 두개로 분리해서 뒀을 때 이런 점에서 테스트하기 더 좋다’와 같은 내용을 기대했는데, 그런 내용은 없는 것 같다. 관심사를 잘 분리하고, 의존 대상에 구체적으로 의존하지 않고 추상화 및 DI를 통해 테스트 대역을 설정할 수 있고, 덕분에 단위테스트가 용이하다와 같은 내용이 주를 이룬다. 이런 내용은 그 이전에도 있었던 것 같아, 특별히 클래스 분리가 어떤 점에서 고립된 단위테스트를 돕는 지 이해하기 어려웠다.

그런데 다시 생각해보니, 로직을 담당하는 UserService는 아예 부가기능에 의존을 안하니, mocking도 안해도 돼서 고립된 단위테스트를 만들 수 있다는 것 같다. 확실하진 않다…

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

단순히 확장성을 고려해서 한 가지 기능을 분리한다면 전형적인 전략패턴을 사용하면 된다. 그런데 트랜잭션 기능에 추상화를 적용했을 때를 생각해보면, 추상화를 통해 구현을 분리했을 뿐이다. 즉, 구체적인 구현 코드는 제거했을지라도 위임을 통해 기능을 사용하는 코드는 핵심 코드와 함께 남아있다. 이를 분리하기 위해 아예 클래스를 분리해버렸다. 이 때의 특징은 부가기능을 담은 클래스가 핵심 기능을 사용하는 구조가 됐다는 것이다. 문제는 클라이언트가 부가기능을 담은 클래스를 무시하고 핵심기능을 담은 클래스를 직접 사용하면 부가기능이 적용되지 않는다는 것이다. 이를 막기 위해 부가기능은 마치 자기가 핵심기능을 담은 클래스인 것처럼 꾸며서 클라이언트가 반드시 자신을 거치도록 해야한다. 이를 위해서, 클라이언트는 인터페이스에 의존하게 만들고 부가기능이 그 인터페이스를 구현한 후, 자신이 클라이언트와 핵심 클래스 사이에 끼어들어야 한다. 결국 클라이언트는 자기가 핵심 기능 클래스를 사용하는 줄 알지만, 사실 부가기능을 통해 핵심기능을 사용하게 된다. 이런식으로 부가기능 클래스가 마치 핵심기능 클래스인것처럼 위장해서 클라이언트의 요청을 받아주는 것을 프록시라고 부른다. 최종적으로 요청을 위임받아 처리하는 객체를 타깃이라고 한다.

프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다. 프록시는 사용 목적에 따라 두 가지로 구분되는데, 첫째는 클라이언트가 타깃에 접근하는 방법을 제어하기 위해서이고 두번째는 타깃에 부가적인 기능을 부여해주기 위해서다. 각 목적에 따라 다른 패턴으로 구분된다.

데코레이터 패턴

데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴이다. 데코레이터 패턴이라고 불리는 이유는 타깃은 동일해도, 장식해주는 것처럼 부가기능을 부여해줄 수 있기 때문이다. 데코레이터 패턴에서는 인터페이스를 구현한 부가기능 클래스를 여러 개 구현하고, 차례로 구동되게 할 수 있다. 즉 인터페이스를 구현할 때 생성자나 수정자를 통해 위임대상을 런타임 시 주입받을 수 있도록 해야한다. 각 구현체들의 의존관계를 정하는 게 번거로울 수 있는데, 스프링을 활용하면 쉽게 할 수 있다. 데코레이터 패턴은 타깃의 코드를 손대지 않고, 클라이언트가 호출하는 방법도 변경하지 않은 채로 새로운 기능을 추가할 때 유용한 방법이다. => 개방폐쇄원칙을 잘 지킨 느낌.

프록시 패턴

일반적으로 대리자를 의미하는 프록시와 달리, 디자인패턴에서 프록시패턴은 프록시를 사용하는 방법 중 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 말한다. 프록시패턴에서는 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다. 타깃 오브젝트를 생성하기 복잡하거나 당장 필요하지 않은 경우에는 꼭 필요한 시점까지 오브젝트를 생성하지 않는 편이 좋다. 그런데 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 수 있다. 이럴 때 프록시 패턴을 적용하면 좋다. 클라이언트에게 타깃에 대한 레퍼런스 대신 프록시를 넘겨주는 것이다. 클라이언트가 프록시의 메소드를 통해 타깃을 사용하려고 시도하면, 그 때 프록시가 타깃 오브젝트를 생성하고 요청을 위임한다. 레퍼런스는 갖고 있지만 끝까지 사용하지 않거나 많은 작업이 진행된 후 사용된다면 프록시를 통해 생성을 늦춤으로써 얻는 장점이 많다. 원격 오브젝트를 쓸 때도 프록시패턴을 사용하면 좋다. 다른 서버에 존재하는 오브젝트를 사용해야 한다면, 원격 오브젝트에 대한 프록시를 만들어두고 클라이언트는 마치 로컬에 존재하는 오브젝트를 쓰는 것처럼 프록시를 쓸 수도 있다. 특별한 상황에서 타깃에 대한 접근권한을 제어하기 위해 프록시패턴을 쓸 수도 있다. 세 가지 경우 모두 타깃의 기능 자체에는 관여치 않으면서 접근하는 방법을 제어해준다.

프록시패턴과 데코레이터패턴은 구조적으로 유사하다. 다만 다른점은 프록시가 자신이 만들거나 접근할 타깃 클래스 정보를 알고 있는 경우가 많다는 것이다.

다이내믹 프록시

프록시는 유용하지만, 많은 개발자들이 차라리 타깃코드를 고치고 말지 번거롭게 프록시를 만들려고 하지 않는다. 마치 목 오브젝트를 만드는 것처럼 번거로운 것이다. 목 프레임워크를 활용해 번거로움을 해결한 것처럼, 프록시를 편하게 쓸 수 있는 방법도 있다. java.lang.reflect 패키지 안에 프록시를 손쉽게 만들 수 있도록 도와주는 클래스들이 있다. 일일이 프록시를 정의하지 않아도 몇몇 api를 활용해 프록시처럼 동작하는 오브젝트를 다이나믹하게 생성하는 것이다.

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

프록시는 두 가지 기능을 한다(여기서는 데코레이션 패턴의 프록시를 말함).

  1. 핵심 기능 수행을 타깃에 위임

  2. 부가기능 수행

프록시 만들기가 번거로운 이유를 위 두가지에서 찾을 수 있다.

  1. 타깃의 인터페이스를 구현하고 위임하는 코드를 작성하기 번거로움 : 부가기능이 필요없는 메소드도 구현해야함. 타깃 인터페이스의 메소드가 추가되거나 변경될 때마다 함께 수정해줘야한다는 부담도 있음.
  2. 부가기능 코드가 중복될 가능성이 많음. 예를 들어 트랜잭션 기능의 경우 많은 메소드에서 활용되는데, 메소드별로 부가기능을 추가해주면 트랜잭션 부가기능을 적용하는 코드가 중복될 것이다.

두번째 코드 중복문제는 메소드 분리를 통해 어떻게든 해결할 수 있을 것 같지만, 첫 번째 문제는 해결하기 간단하지 않다. 이 문제를 해결하기 위한 것이 jdk의 다이내믹 프록시다.

리플렉션

리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다. 다이내믹 프록시는 리플렉션을 이용하여 프록시를 만들어준다. 자바의 모든 클래스는 그 클래스의 자체의 정보를 담은 Class 타입의 오브젝트를 가진다. 이 클래스 오브젝트를 활용하면 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다.

다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트다. 다이내믹 프록시를 활용하면 프록시를 만들 때 인터페이스를 모두 구현하면서 클래스를 정의하지 않아도 된다. 프록시 팩토리가 인터페이스의 구현체를 만들어주는 역할을 담당해주기 때문이다. 프록시 팩토리에게 인터페이스 정보를 주면 프록시 팩토리가 그 인터페이스의 구현체를 만들어준다. 그런데, 다이내믹 프록시를 쓴다고 해도 팩토리가 인터페이스의 구현체는 만들어주지만 부가기능 코드는 직접 작성해야 한다. 이 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler를 구현한 오브젝트에 담는다. InvocationHandler의 함수인 invoke가 리플렉션의 Method 인터페이스를 파라미터로 받는다(이 때 리플렉션이 활용되는 것). 다이내믹 프록시 오브젝트가 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드에 넘긴다. 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다. 그렇다면 클라이언트의 메소드 요청을 어떻게 처리할까? 리플렉션을 통해 메소드와 파라미터 정보에 다 접근할 수 있기 때문에, 타깃 오브젝트의 메소드를 호출할 수 있다. InvocationHandler 구현 오브젝트가 타깃 오브젝트 레퍼런스를 갖고 있다면 리플렉션을 이용해 간단히 위임 코드를 만들 수 있는 것이다. 클라이언트가 프록시 팩토리에게 타깃 인터페이스를 제공하면서 다이내믹 프록시를 만들어달라고 요청하면, 팩토리가 해당 인터페이스를 구현한 오브젝트를 생성해준다. 이후 클라이언트가 InvocationHandler 인터페이스를 구현한 오브젝트를 프록시 팩토리에게 전달해주면, 다이내믹 프록시가 받는 모든 요청을 InvocatoinHandler의 invoke()로 보내준다. 타깃 인터페이스의 함수가 아무리 많아도 invoke() 하나로 처리할 수 있는 것이다.

프록시 생성을 위해 필요한 것: 클래스 로더(다이내믹 프록시가 정의되는 클래스 로더를 지정), 타깃 인터페이스, InvocatoinHandler 구현체(타켓오브젝트 정보를 가지고 있음).

다이나믹 프록시의 확장

다이나믹 프록시를 적용했을 때 명확히 보이는 장점이 있다. 직접 프록시를 구현한다면 타깃 인터페이스의 함수가 늘어날 때마다 프록시의 코드도 늘어난다. 반면, 다이내믹 프록시에서는 invoke() 메소드 하나로, 모든 요청을 처리한다. 두 번째 장점(InvocationHandler의)은 타깃의 종류에 상관없이 적용 가능하다는 것이다. 리플렉션을 활용하기 때문에, 타입이 상관없다. 그래서, 타깃의 타입을 오브젝트로 바꾸면 확장성이 생긴다. 그리고 타깃의 함수의 리턴값의 타입에 따라 부가기능을 적용할 지 적용하지 않을 지도 정할 수 있다. InvocationHandler는 단일 메소드에서 모든 요청을 처리하기 때문에 어떤 메소드에 어떤 기능을 적용할지를 선택하는 과정이 필요할 수도 있다(메소드에 따라 부가기능을 다르게 적용하고 싶을 수도 있다).

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

이제까지 어떤 타깃에도 적용 가능하고, 원하는 대상에만 선별적으로 부가기능을 적용할 수 있는 InvocationHandler를 만들었다. 이제 handler와 다이내믹프록시를 빈으로 등록해보자.

문제는 일반적인 스프링 빈으로는 di대상이 되는 다이내믹프록시를 등록할 방법이 없다는 것이다. 스프링이 빈을 만들려면, 클래스 이름이 필요하기 때문이다. 다이내믹 프록시 오브젝트는 이런식으로 생성되지 않았다(클래스 이름 자체가 없다…). 다이내믹프록시는 Proxy클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.

사실 스프링은 클래스 정보로 생성자를 통해 오브젝트를 만드는 것 말고도 빈을 만들 수 있는 다른 방법이 있다. 그 예로, 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어진 팩토리 빈을 활용할 수 있다. 이걸 적용하려면 FactoryBean을 구현하고 빈으로 등록하면 된다. 팩토리 빈의 활용을 예로 들면, 디폴트 생성자를 private으로 두고 public static 함수로만 그 객체를 생성할 수 있게 한 객체의 경우, 생성자로 만들지 말라는 의도가 들어있기 때문에 팩토리 빈을 활용해 생성하는 것이 바람직하다.

자 이제 팩토리 빈을 통해 다이내믹 프록시를 쉽게 di할 수 있게 됐다. 그런데 이제 테스트의 어려움이 생겼다. 예전 수동으로 di했을 땐 테스트 때도 수동 di하면서 의존관계를 원하는대로 설정할 수 있었는데, 이제 스프링을 통하다보니 테스트가 어려워진 것이다. 이미 스프링 빈으로 만들어진 다이내믹 프록시의 프로퍼티들을 원하는대로 제어할 수 없기 때문이다. 이 때 해결책으로 테스트 시 다이내믹 프록시를 주입받아 테스트하는 게 아니라, 팩토리 빈을 가져와서 다이내믹 프록시를 직접 만들어주는 것이다. 다이내믹프록시를 위한 팩토리 빈을 만듬으로써, 문제로 제기됐던 프록시 클래스를 매번 작성해야 하는 번거로움을 해결할 수 있게됐다.

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

장점 - 부가기능을 담는 핸들러와 더불어 핸들러를 활용하는 프록시를 생성해주는 프록시팩토리를 빈으로 등록해주면 코드 수정 없이도 다양한 클래스에 부가기능을 적용할 수 있었다. 해야할 건 단지 타깃 오브젝트에 맞는 프로퍼티 정보를 설정해서 빈으로 등록해주기만 하면 된다. 앞서 얘기한 프록시가 좋은데도 만들기 번거로워서 잘 활용되지 않은 두 가지 이유(타깃 인터페이스를 구현하는 프록시 클래스를 일일이 구현해야 하는 것, 부가 기능이 여러 코드에 중복된다는 것)를 해결한 것이다. 다이내믹 프록시를 활용하면 타깃 인터페이스를 구현하는 프록시 클래스를 일일이 만들어주지 않아도 된다. 또한 다이내믹 프록시에는 핸들러가 적용되어 있으니, 부가기능 코드가 중복된다는 문제도 해결한다. 문제를 해결하는 것에 더해, 팩토리 빈을 활용한 di까지 적용하면 다이내믹 프록시 생성 코드까지 제거할 수 있고, di설정만으로도 여러 타깃오브젝트에 부가기능을 적용할 수 있다. 즉 프록시 팩토리 빈을 활용하면, 프록시 기법을 빠르고 효과적으로 적용할 수 있다. 코드 한 줄 추가하지 않고 기존 코드에 부가적인 기능을 추가할 수 있는 것이다. 참고로 여기에서 스프링이 많은 부분을 도와줬다. 프록시를 활용하려면 스프링 di가 필요했고, 효율적 프록시 생성을 위한 다이내믹 프록시를 사용할 때도 팩토리 빈을 di하는 게 필요했다.

한계 - 중복 없는 최적화된 코드와 설정만을 이용해 이런 기능을 적용하려고 한다면 지금까지 살펴봤던 방법으로는 한계에 부딪힐것이다. 하나의 클래스 내 모든 메소드에 부가기능을 적용하는 건 쉽지만, 여러 클래스에 공통적인 부가기능을 제공하는 건 불가능하다. 여러 클래스에 부가기능을 적용하려면, 프록시 팩토리 빈 설정을 여러번 해줘야 했다. 하나의 타깃에 여러 개의 부가기능을 적용하려고 할 때도 문제다. 부가기능이 많아질수록, 프록시 팩토리 빈 설정이 많아질 수밖에 없다. 코드도 수정 없이 설정의 변경만으로 수천 개 이상의 메소드에 새로운 기능을 추가할 수 있다는 점은 분명 대단한 일이긴 하지만, 설정파일이 급격히 복잡해지는 것은 바람직하지 못하다. 심지어 타깃과 인터페이스만 다른, 거의 비슷한 설정이 자꾸 반복된다. 또 다른 문제점은 Handler가 프록시 팩토리 빈 수만큼 만들어져야 한다는 것이다. 같은 부가기능이라도, 타깃 오브젝트가 달라지면 그 타깃 오브젝트를 프로퍼티로 전달한 Handler를 새로 만들어야 한다. 타깃 오브젝트는 다이내믹 프록시와 달리 그 자체로 빈으로 등록될 수 있으니, 빈으로 등록하면 괜찮을까 싶지만 그러면 또 빈 설정이 많아진다는 문제가 생긴다. Handler의 중복을 없애고 모든 타깃에 적용 가능한 싱글톤 빈을 만들고 싶다. 스프링di를 활용하면 이 문제를 해결할 수 있을지도 모른다.

6.4 스프링의 프록시 팩토리 빈

스프링은 서비스 추상화를 프록시 기술에도 적용하고 있다. 자바가 다이내믹 프록시 말고도 편리하게 프록시를 만들 수 있는 방법을 다양하게 제공해주기 때문에, 프록시 만드는 기술을 추상화한 팩토리 빈을 제공한다. 위에서 사용한 FactoryBean과 달리, ProxyFactoryBean은 순수하게 프록시를 만드는 것만 담당하고, 부가기능은 별도의 빈에 둘 수 있다. 이 부가기능은 MethodInterceptor 인터페이스를 구현해서 만든다. Interceptor는 Handler와 비슷하지만 한 가지 다른 점이 있다. Handler의 invoke()는 타깃 오브젝트에 대한 정보를 제공하지 않는다. 따라서, InvocationHandler를 구현한 클래스가 타깃을 직접 알아야 한다. 반면 Interceptor의 invoke(MethodInvocatoin) 는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보도 함께 받는다. 이 차이로 인해 Interceptor는 타깃과 상관없이 독립적으로 만들어질 수 있다. 따라서 한 Interceptor를 타깃이 다른 여러 프록시에서 활용할 수 있게되고, 싱글톤 빈으로 만들 수 있다.

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

ProxyFactoryBean을 활용한 코드를 보면, 기존 JDK 다이내믹 프록시를 적용한 코드(아마 FactoryBean을 적용한 코드)와 차이가 있다. Handler구현체와 달리, Interceptor구현체(Advice)에는 타깃 오브젝트가 등장하지 않는다. Interceptor에 메소드 정보와 타깃정보가 담긴 MethodInvocation 오브젝트가 전달되기 때문이다. MethodInvation이 타깃 오브젝트의 메소드를 실행할 수 있기 때문에 Interceptor는 타깃을 신경쓰지 않고 부가기능에만 집중하면 된다. MethodInvation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. 즉 MethodInvocation이 일종의 공유 가능한 템플릿처럼 동작하는 것이다. 이것이 스프링의 ProxyFactoryBean을 쓰는 것이 JDK가 제공해주는 다이내믹프록시를 직접 사용하는 코드와 다른 점이자 장점이다. ProxyFactoryBean이 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다. ProxyFactoryBean에 MethodInterceptor를 설정해줄 때는 일반적인 di처럼 수정자를 사용하는 대신, addAdvice()를 사용하는 점도 눈여겨 봐야한다. add라는 이름에서 하나의 ProxyFactoryBean에 여러개의 Interceptor를 둘 수 있다는 것이고, 이것은 ProxyBeanFactory 하나로도 여러 개의 부가기능을 제공해주는 프록시를 만들 수 있다는 뜻이다. 따라서 앞선 문제였던 새로운 부가기능을 추가할때마다 프록시와 프록시팩토리 빈도 추가해줘야한다는 문제를 해결할 수 있다. Interceptor는 Advice의 일종이고, Advice는 타깃에 적용하는 부가기능을 담은 오브젝트를 말한다. ProxyFactoryBean이 JDK 다이내믹 프록시와 다른 점이 또 있는데, ProxyFactoryBean을 적용한 코드에는 타깃 인터페이스 정보를 넘겨주지 않는다. JDK 다이내믹 프록시를 만들 땐 꼭 타깃 인터페이스가 필요했는데, 그래야 다이내믹 프록시의 타입을 정할 수 있기 때문이었다. 스프링의 ProxyFactoryBean은 어떻게 인터페이스 타입을 제공받지도 않고 타깃 인터페이스를 구현할 수 있을까? ProxyFactoryBean에 있는 인터페이스 자동 검출 기능을 활용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낸다. 그리고 알아낸 인터페이스를 구현하는 프록시를 만든다. 즉 타깃 오브젝트로부터 타깃 인터페이스를 알아내고, 그걸 구현하는 것이다.

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

Handler가 해주는 것엔 부가기능 적용 외에도 메소드의 이름을 가지고 부가기능을 적용할 대상 메소드를 선정하는 것이 있었다. 한편, ProxyFactoryBean을 사용할 땐 Interceptor가 타깃 정보를 갖지 않도록 만들었기 때문에, 그리고 적용 대상을 선정하는 patten을 갖는 순간 여러 프록시가 Interceptor를 공유하기 어렵기 때문에 부가기능을 적용할 대상 메소드를 선정하기 어렵다. 이 부분은 이제까지 쭉 해왔던, 성격이 다른 코드 분리하기를 통해 해결할 수 있다.

Interceptor를 쓸 땐 Handler를 쓸 때와 다르게, 프록시가 핸들러로부터 받는 요청을 일일이 전달받을 필요는 없다. Interceptor에 메소드 선정 로직을 넣는 대신, 프록시에 메소드 선정 로직을 넣자.

기존에 Handler를 사용 시 문제를 다시 생각해보면, 부가기능을 가진 Handler가 타깃, 메소드 선정 로직도 가지고 있다는 것이다(의존하고 있음). 이로 인해 타깃이 다르고 메소드 선정 방식이 다르면 Handler를 여러 프록시가 공유할 수 없다. 설령 전략패턴으로 로직을 뺀다고 하더라도 빈으로 등록하고 생성이 되고나면, 다이내믹하게 전략을 바꿀 수 없다. 그래서 FactoryBean이 매번 Handler를 생성했던 것이다.

반면에 ProxyFactoryBean을 사용할 때는 Interceptor에 부가기능만 두고, 메소드 선정 로직은 따로 둔다(포인트컷). 스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 하고, 메소드 선정 로직을 담은 오브젝트를 포인트컷이라고 한다. 어드바이스와 포인트컷은 모두 프록시에 di된다. 각각 자기의 관심에만 집중하기 때문에 여러 프록시가 공유할 수 있고, 따라서 스프링에 싱글톤 빈으로 등록할 수 있다.

프록시가 클라이언트로부터 요청을 받으면, 먼저 포인트컷에게 부가기능을 부여할 메소드인지 확인해달라고 요청한다. 프록시는 포인트컷으로부터 부가기능을 적용할 메소드인지 확인받으면, Inteceptor 타입의 어드바이스를 호출한다. 어드바이스는 JDK 다이내믹프록시의 Handler와 달리 직접 타깃을 호출하지 않는다. 자신이 공유돼야해서, 타깃이라는 상태를 가질 수 없기 때문이다. 따라서 타깃에 직접 의존하지 않도록 템플릿 구조로 설계돼있다. 어드바이스가 부가기능을 부여하는 중에 타깃 메소드의 호출이 필요하면 프록시로부터 전달받은 MethodInvocation 타입의 콜백 오브젝트의 proceed()를 호출해주기만 하면 된다. Invocation 콜백은 프록시가 메소드 호출에 따라 새로 만들기 때문에, 타깃 오브젝트의 레퍼런스를 가질 수 있고, 이를 이용해 타깃 오브젝트의 메소드를 호출할 수 있다. 즉, 어드바이스가 일종의 템플릿이 되고, 타깃을 호출하는 기능을 갖는 MethodInvocation 오브젝트가 콜백이 된다. 템플릿이 한 번 만들면 재사용 가능하고 여러 빈이 공유해서 사용할 수 있듯이, 어드바이스도 독립적인 싱글톤 빈으로 등록하고 DI를 통해 여러 프록시가 공유할 수 있다.

프록시로부터 어드바이스와 포인트컷을 독립시키고 di를 적용한 것은 전형적인 전략패턴이다. 덕분에 여러 프록시가 어드바이스와 포인트컷을 공유할 수 있게됐고, 구체적인 로직(부가기능, 메소드 선정 방식)이 바뀌면 구현 클래스만 바꿔서 설정에 넣어주면 된다. 프록시와 ProxyFactoryBean은 수정하지 않아도 기능을 확장할 수 있기 때문에 OCP를 잘 지켰다고 할 수 있다.

포인트컷은 필요없으면 등록을 안할 수도 있지만, 등록을 할거면 어드바이스랑 묶어서 어드바이저(어드바이스 + 포인트컷)로 등록해야한다. 그 이유는 어드바이스와 매칭시켜야 어떤 어드바이스에 포인트컷을 적용할 지 알 수 있기 때문이다.

Interceptor 구현한 걸 보면, invoke에서 타깃을 호출하는 기능을 가진 콜백 오브젝트(invocation)을 프록시로부터 받는다. 덕분에 어드바이스(Interceptor)는 특정 타깃에 의존하지 않고 재사용 가능하다. Interceptor를 사용하니 JDK의 Handler때보다 간결한데, 리플렉션을 통한 타깃 메소드 호출작업의 번거로움이 없어졌고(아마 아예 없어진 게 아니라 Invocation이 해주는 듯), 타깃 메소드가 던지는 예외도 포장돼서 오지 않기 때문에 그대로 처리할 수 있다.

6.5 스프링 AOP

6.5.1 자동 프록시 생성

이제까지 잘 투명한 부가기능을 적용해봤다. 타깃 코드는 여전히 깔끔하고, 부가기능은 한 번만 만들어 모든 타깃과 메소드에 재사용이 가능하고, 타깃의 적용 메소드를 선정하는 방식도 독립적으로 분리했다.

하지만 아직도 해결할 과제가 남아있다. 프록시 팩토리 빈 방식의 접근방법의 한계라고 생각했던 두 가지 문제가 있었다. 그 중 부가기능이 타깃 오브젝트마다 새로 만들어지는 문제는 스프링 ProxyFactoryBean의 어드바이스를 통해 해결했다. 남은 것은 부가기능의 적용이 필요한 타깃 오브젝트마다 거의 비슷한 ProxyFactoryBean 설정 정보를 추가해줘야 한다는 것이었다. target 프로퍼티를 제외하고는 빈 클래스의 종류, 어드바이스, 포인트컷 등 설정이 동일하다. 이 중복을 없애고 싶다. 즉, 타깃이 달라짐에 따라 설정이 추가되는 문제는 아직 해결하지 못했다. 마치 다이내믹 프록시를 통해, 인터페이스만 제공해주면 그걸 구현하는 클래스를 생성해줬듯, 일정한 타깃 빈의 목록을 제공해주면 자동으로 각 타깃 빈에 대한 프록시를 만들어주는 방법이 있다면 해결할 수 있을 것 같은데, 아직까진 불가능해보인다. 반복적인 프록시의 구현을 코드 자동생성 기법을 이용해 해결했다면 반복적인 ProxyFactoryBean 설정 문제는 설정 자동등록 기법으로 해결할 수 없을까?

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

스프링을 통해 OCP를 잘 지킬 수 있었듯, 스프링 스스로도 OCP를 지키려고 노력했다. 스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분 외에는 대부분 확장할 수 있도록 해놨다. 그 대표적인 예시로 BeanPostProcessor를 구현해서 만드는 빈 후처리기가 있다. 빈 후처리기는 스프링 빈 오브젝트로 만들어진 후에 빈 오브젝트를 다시 가공할 수 있게 해준다. 이걸 구현하는 예시로, DefaultAdvisorAutoProxyCreator는 어드바이저를 활용한 자동 프록시 생성기이다. 이걸 적용하는 건 굉장히 간단한데, 빈 후처리기 자체를 빈으로 등록하면 된다. 스프링은 빈 후처리기가 빈으로 등록돼 있으면 빈 오브젝트가 생성될 때마다 빈 후처리기에 보내서 후처리 작업을 요청한다. 빈 후처리기는 빈 오브젝트의 프로퍼티를 강제로 수정할 수 있고, 별도의 초기화 작업을 수행할 수도 있다. 심지어 만들어진 빈 오브젝트 자체를 바꿔치기 할 수도 있다. 따라서 스프링이 설정을 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다. 이를 잘 이용하면 스프링이 생성하는 빈 오브젝트의 일부를 프록시로 포장하고, 프록시를 빈으로 대신 등록할 수도 있다. 이것이 자동 프록시 생성 빈 후처리기이다. 어드바이저를 등록하고, 빈 후처리기를 사용하면 일일이 ProxyFactoryBean을 등록하지 않아도 타깃 오브젝트에 자동으로 프록시가 적용되게 할 수 있다. 마지막 남은 ProxyFactoryBean 설정 문제를 말끔히 해결하였다.

확장된 포인트컷

이제까지 포인트컷은 메소드 선정 로직이라고 설명했는데, 갑자기 포인트컷이 등록된 빈 중에서 어떤 빈에 프록시를 적용할 지를 선택한다는 식으로 설명하고 있다. 포인트컷이 오브젝트 내의 메소드를 선택하는 게 아니고 빈 오브젝트 자체를 선택하는 기능을 가졌다는 뜻일까? 물론 아니다. 사실 포인트컷은 두 가지 기능을 모두 갖고있다. Pointcut 인터페이스를 보면, 클래스필터와 메소드매처 두 가지를 돌려주는 메소드를 갖고 있다. 실제 포인트컷의 선별로직은 이 두 가지 메소드에 의해 이뤄진다. getClassFilter()가 프록시를 적용할 클래스인지 확인해주고, getMethodMatcher()가 어드바이스를 적용할 메소드인지 확인해준다. 지금가지는 MethodMatcher만 사용한 것이다. 즉, 이제까지는 모든 클래스 다 ok이고(클래스의 종류는 상관이 없고) 메소드만 선별한것이다. 사실, ProxyFactoryBean을 사용한다는 것 부터가 이미 타깃이 정해져있다는 것이고, 포인트컷은 메소드 선별만 해주면 그만이었다. 만약 Pointcut의 선정기능을 모두 적용한다면, 먼저 프록시를 적용할 클래스인지 판단하고 나서, 적용대상클래스인 경우에는 어드바이스를 적용할 메소드인지 확인하는 식으로 동작한다. 즉 두 가지 조건을 모두 만족하는 타깃의 메소드에 어드바이스가 적용되는 것이다. ProxyFactoryBean을 사용할 때와 달리, 모든 빈에 대해 프록시 자동 적용 대상을 선별해야 하는 빈후처리기는 클래스와 메소드 선정 알고리즘을 모두 갖고있는 포인트컷이 필요하다. 정확히 말하면 어드바이스와 그런 포인트컷이 결합돼있는 어드바이저가 등록돼야 한다.

자동 프록시 생성기를 사용하는 테스트

@Autowired를 통해 주입되는 UserService타입 오브젝트는 UserServiceImpl이 아니라, 트랜잭션이 적용된 프록시여야 한다. 이를 검증하려면 트랜잭션이 적용된 upgradeAllOrNothing() 테스트가 필요한데, 기존의 방법으로는 한계가 있다. 지금까지는 ProxyFactoryBean이 빈으로 등록되어 있었으므로 이를 가져와 타깃을 테스트용 클래스로 바꿔치기하는 방법을 사용했다. 하지만 자동 프록시 생성기를 적용한 후에는 더 이상 가져올 ProxyFactoryBean 같은 팩토리 빈이 존재하지 않는다. 자동 프록시 생성기가 알아서 프록시를 만들어줬기 때문에 프록시 오브젝트만 남아있을 뿐이다. 지금까지는 부가기능이 적용되는 상황을 테스트할 때 테스트코드에서 빈을 가져와 수동DI로 구성을 바꿔준 것이다. 하지만 자동 프록시 생성기라는 스프링 컨테이너에 종속된 기법을 사용했기 때문에 예외상황을 위한 테스트 대상도 빈으로 등록해줄 필요가 있다. 이제는 타깃을 코드에서 바꿔치기할 방법도 없을 뿐더러, 자동 프록시 생성기의 적용이 되는지도 빈을 통해 확인할 필요가 있기 때문이다. 기존에 예외 상황을 테스트하기 위해 만들어둔 예외 발생용 TestUserService 클래스를 이제는 직접 빈으로 등록해보자. 그런데 이 때 두 가지 문제가 있다. 첫째는 TestUserService 클래스가 UserServiceTest 클래스의 내부에 정의된 스태틱 클래스라는 점이고, 둘째는 포인트컷이 트랜잭션 어드바이스를 적용해주는 대상 클래스의 이름 패턴이 *ServiceImpl이라고 돼있어서 TestUserService 클래스는 빈으로 등록해도 포인트컷이 프록시 적용 대상으로 선정해주지 않는다는 점이다. 이를 해결하기 위해 TestUserService 스태틱 멤버 클래스를 수정해보자. 포인트컷이 적용 대상으로 선정해주도록 이름을 TestUserServiceImpl이라고 수정한다. 그리고 이제 테스트 코드에서 이 클래스를 생성할 것이 아니기 때문에, 테스트 코드가 제공해주는 로직에 적용할 정보(예외를 발생시킬 기준이 되는 id)를 사용할 방법이 없다. 그러므로 이 정보를 아예 클래스 안에 넣어버리자. 그리고 빈으로 등록한다. 이렇게 수정하니, 테스트코드가 단순해졌다는 장점이 생겼고, 테스트 내용을 이해하려면 di정보도 참고해야하니 테스트의 내용을 이해하기 어려워졌다는 단점이 생겼다. 테스트는 모두 통과한다.

자동생성 프록시 확인

몇 가지 특별한(?) 빈 등록과 포인트컷 작성만으로 프록시가 자동으로 만들어진다는 게 잘 믿겨지지 않으니 확인해보자. 최소 두 가지는 확인해야한다. 첫째는 트랜잭션이 필요한 빈에 트랜잭션 부가기능이 적용됐는가이다. 이는 upgradeAllOrNothing() 테스트에서 확인했다. 둘째는 아무 빈에나 트랜잭션 부가기능이 적용된 건 아닌지 확인해야한다. 제대로 하려면 모든 빈을 다 가져와서 프록시로 변했는지 확인해야겠지만, 여기서는 간단히 클래스필터가 제대로 동작하는 지 확인해보는 것만으로도 충분할 듯 싶다. 확인하기 위해, 포인트컷 빈의 클래스 이름 패턴을 변경해서 이번에는 testUserServie 빈에 트랜잭션이 적용되지 않게 해보자. 이를 통해 클래스필터가 제대로 동작하고 있다는 최소한의 믿음을 가질 수 있다. 또한 다른 방법으로, 자동생성된 프록시를 확인할 수 있다. 프록시 자동 생성기에 의해 userService 빈이 프록시로 바꿔치기됐다면, getBean(“userService”)로 가져온 오브젝트는 TestUserService 타입이 아니라 JDK의 Proxy 타입일 것이다.

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

지금까지 사용했던 포인트컷은 메소드의 이름과 클래스의 이름 패턴을 각각 클래스 필터와 메소드 매처 오브젝트로 비교해서 선정하는 방식이었다. 이러면 일일이 클래스필터와 메소드 매처를 구현하거나 스프링이 제공하는 필터/매처 클래스를 가져와 프로퍼티를 설정하는 방식을 사용해야 했다. 지금까지는 단순히 이름을 비교하는 일이 전부였지만, 이보다 복잡하고 세밀한 기준을 이용해 클래스나 메소드를 선정하게 하려면 어떻게해야할까? 필터나 매처에서 클래스와 메소드의 메타정보를 받으니 어떤 식이든 불가능할 것은 없다. 리플렉션을 활용하면 클래스/메소드와 관련된 모든 정보를 알 수 있기 때문이다. 하지만 리플렉션 api는 코드를 작성하기가 번거롭다는 단점이 있다. 또한 리플렉션 api를 이용해 메타정보를 비교하는 방법은 조건이 달라질때마다 포인트컷 구현 코드를 수정해야 하는 번거로움도 있다. 스프링은 간단하고 효과적인 방법으로 포인트컷의 클래스와 메소드 선정 알고리즘을 작성할 수 있는 방법을 제공하고, 이것을 포인트컷 표현식이라고 부른다.

포인트컷 표현식

포인트컷 표현식을 활용하면, 기존에 포인트컷 구현체가 클래스와 메소드 선정 방식을 독립적으로 비교하도록 만들어져있던 것과 달리, 한 번에 지정할 수 있다. 이걸 활용하면 간단한 문자열로 복잡한 선정조건을 쉽게 만들 수 있다. execution()에 들어가는 표현식을 살펴볼 것인데, 복잡해보이지만 메소드의 풀 시그니처를 문자열로 비교하는 개념이라고 생각하면 간단하다. 표현식 중 생략이 가능한 것이 있는데, 생략이 가능하다는 건 이 항목에 대해서는 조건을 부여하지 않는다는 의미이다. 표현식을 여러 개 만들어보고, 그것에 대해 어떤 클래스/메소드에 적용될 지 생각해보는 연습이 필요하다.

포인트컷 표현식을 사용하면 로직이 짧은 문자열에 담기기 때문에 클래스나 코드를 추가할 필요가 없어서 코드와 설정이 모두 단순해진다는 장점이 있다. 반면에 문자열로 된 표현식이므로 런타임 시점까지 문법의 검증이나 기능 확인이 되지 않는다는 단점도 있다. 가뜩이나 백그라운드에서 작동하는 자동 프록시 생성기를 사용하고 있는데, 여기에 포인트컷 표현식까지 활용하면 실수할 가능성이 높아진다. 충분히 학습하고, 테스트 후 사용할 필요가 있다. 포인트컷이 정확히 원하는 빈에만 적용된 것인지(원하지 않는 빈에는 적용이 안됐는 지)를 테스트하는 건 번거롭다. vol.2에서 설명하는 스프링 지원 툴을 사용하면 간단히 포인트컷이 선정한 빈이 어떤 것인지 알 수 있다.

타입 패턴과 클래스 이름 패턴

포인트컷 표현식의 사용 이전까지 클래스의 이름을 기준으로 포인트컷 대상을 선정했다. 이 때와 포인트컷 표현식을 사용하는 것은 큰 차이가 있다. 포인트컷 표현식을 사용 후에는 기존의 TestUserSerivceImpl 클래스를 TestUserService라고 바꾼 후 테스트하면 성공한다. 포인트컷 표현식에 execution(* * . . Servicelmpl.upgrade( .. )) 이렇게 ServiceImpl이 들어간 애들에 적용한다고 했는데 왜 테스트가 통과할까? 이유는 포인트컷 표현식의 클래스 이름에 적용되는 패턴은 클래스 이름 패턴이 아니라 타입 패턴이기 때문이다. TestUserService의 클래스 이름은 TestUserService일 뿐이지만, 타입을 따져보면 TestUserService 클래스이자, 슈퍼클래스인 UserServiceImpl , 구현 인터페이스인 UserService 세 가지가 모두 적용된다. 즉 TestUserService 클래스로 정의된 빈은 UserServicelmpl 타입이기도 하고, 그 때문에 Servicelmpl로 끝나는 타입 패턴의 조건을 충족하는 것이다.

6.5.4 aop란 무엇인가?

비즈니스로직을 담은 userService에 트랜잭션을 적용해온 과정을 정리해보자.

  1. 트랜잭션 서비스 추상화

    트랜잭션 경계설정 코드를 비즈니스 로직을 담은 코드에 넣으면서 맞닥뜨린 첫 번째 문제는 특정 트랜잭션 기술에 종속되는 코드가 돼버린다는 것이다. 즉 기술을 바꾸려면(jdbc -> jta) 관련 코드를 다 수정해야했다. 그래서 트랜잭션 적용이라는 추상적인 작업 내용은 유치한 채로 구체적인 구현 방법을 자유롭게 비꿀수 있도록 서비스추상화를 적용했다. 덕분에 비즈니스 로직 코드는 트랜잭션을 처리하는 방식에 종속되지 않는다. 트랜잭션 추상화란 결국 인터페이스와 DI를 통해 무엇을 하는지(트랜잭션처리)는 남기고, 그것을 어떻게 하는지를 분리한 것이다.

  2. 프록시와 데코레이터 패턴

    트랜잭션 추상화를 해도, 여전히 비즈니스 로직 코드에는 트랜잭션을 적용하고 있다는 사실은 드러나 있다. 심지어 이러한 트랜잭션 부가기능은 많은 비즈니스 로직 코드에서 활용된다. 문제는 트랜잭션의 경계설정을 담당하는 코드의 특성(부가기능의 특성) 때문에 단순한 추상화와 메소드 추출 방법으로는 더이상 제거할 방법이 없다는 것이었다. 그래서 di를 활용한 데코레이터 패턴을 적용했다. 데코레이터 패턴을 통해 비즈니스 로직을 담은 클래스에는 영향을 주지 않으면서 부가기능을 자유롭게 부여할 수 있게 됐다. 클라이언트에 di를 통해 추상화된 비즈니스 로직 코드에 간접적으로 접근하고, 부가기능은 데코레이터에 담겨서 클라이언트와 비즈니스 로직을 담은 타깃 클래스 사이에 존재하도록 만들었다. 결국 비즈니스 로직 코드는 트랜잭션과 같은 성격이 다른 코드로부터 자유로워졌고, 트랜잭션 추상화에 의존하지 않게 됐으니 독립적으로 로직을 검증하는 고립된 단위 테스트를 만들 수도 있게 되었다.

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

    프록시를 통해 비즈니스 로직 코드에서 트랜잭션 코드는 모두 제거할 수 있었지만, 비즈니스 로직 인터페이스의 모든 메소드마다 트랜잭션 기능을 부여하는 코드를 넣어 프록시 클래스를 만들어야 하는 게 짐이 됐다. 심지어 부가기능이 필요 없는 메소드도 프록시로서 위임기능이 필요하기 때문에 일일이 다 구현해줘야 했다. 그래서 프록시 클래스 없이도 프록시 오브젝트를 런타임 시에 만들어주는 JDK 다이내믹 프록시 기술을 적용했다. 덕분에 프록시 클래스 코드 작성의 부담도 덜고, 부가 기능부여 코드가 여기저기 중복된다는 초기의(애초에 aop를 하게 된) 문제도 일부 해결했다. 일부 메소드에만 부가기능을 적용해야 하는 경우에는 메소드를 선정하는 패턴 등을 이용하여 해결할 수도 있었다. 하지만 동일한 기능의 프록시를 여러 오브젝트에 적용할 경우 오브젝트 단위로는 중복이 일어나는 문제는 해결하지 못했다. 그래서 JDK 다이내믹 프록시와 같은 프록시 기술을 추상화한 스프링의 프록시 팩토리 빈을 활용하여 다이나믹 프록시 생성방법에 di를 도입했다. 스프링 프록시 팩토리 빈이 템플릿/콜백 패턴을 적용한 덕분에 부가기능을 담당하는 어드바이스와 부가기능 적용 대상 선정을 담당하는 포인트컷을 프록시에서 분리할 수 있었다. 또한 여러 프록시가 어드바이스, 포인트컷을 공유할 수 있었다.

  4. 자동 프록시 생성 방법과 포인트컷

    3번까지 해도, 여전히 트랜잭션이 적용되는 빈마다 일일이 프록시 팩토리 빈을 설정해줘야 한다는 부담이 남아있었다. 이를 해결하기 위해 스프링 컨테이너의 빈 생성 후처리 기법을 활용해 컨테이너 초기화 시점에서 지동으로 프록시를 만들어주는 방법을 도입했다. 프록시를 적용할 대상을 일일이 지정하지 않고 패턴을 이용해 자동으로 선정할 수 있도록, 클래스를 선정하는 기능을 담은 확장된 포인트컷을 사용했다. 덕분에 트랜잭션 부가기능을 어디에 적용하는지에 대한 정보를 포인트컷이라는 독립적인 정보로 완전히 분리할 수 있었다. 처음엔 클래스와 메소드 선정 로직을 담은 코드를 직접 만들어서 포인트컷으로 사용했지만, 최종적으로는 포인트컷 표현식이라는 좀 더 편리하고 깔끔한 방법을 활용해서 간단한 설정만으로 적용 대상을 손쉽게 선택할 수 있었다.

  5. 부가기능의 모듈화

    aop적용 전까지 코드를 분리하고, 한데 모으고, 인터페이스를 도입하고, DI를 통해 런타임 시에 의존관계를 만들어줌으로써 대부분의 문제를 해결할 수 있었다. 하지만 이 트랜잭션 적용 코드(부가기능)는 기존에 써왔던 방법으로 간단하게 분리해서 독립된 모듈로 만들 수가 없었다. 트랜잭션 경계설정 기능은 다른 모듈의 코드에 부가적으로 부여되는 기능이라는 특징이 있기 때문이다. 그래서 한 데 모을 수 없고, 애플리케이션 전반에 여기저기 흩어져있다. 따라서 부가기능을 독립된 모듈로 만들려면 특별한 기법이 필요했다. 클래스를 만들지 않고도 새로운 구현 기능을 가진 오브젝트를 다이내믹하게 만들어내는 다이내믹 프록시, ioc/di 컨테이너의 빈 생성 작업을 가로채서 빈 오브젝트를 프록시로 대체하는 빈 후처리 기술 등이 필요했다. 부가기능이 핵심기능과 달리 모듈화하기 어려운 이유는 스스로 독립적으로는 적용되기 어렵기 때문이다. 부가기능은 부가기능을 추가해줄 다른 대상, 즉 타깃이 존재해야 의미가 있다. 따라서 각 기능을 부가할 대상인 각 타깃의 코드 안에 침투하거나 긴밀하게 연결되어야 한다. 타깃이 되는 핵심기능은 그 자체로 독립적으로 존재할 수 있다는 점에서 다르다. 여러 기법(di, 데코레이터 패턴, 다이내믹프록시, 오브젝트 생성 후처리, 자동 프록시 생성, 포인트컷 등)을 활용하여, 부가기능을 어드바이스로 모듈화할 수 있었다. 독립적으로 모듈화되어 있기 때문에 이 코드는 중복되지 않으며, 변경이 필요하면 한 곳만 수정하면 된다. 또한 포인트컷이라는 방법을 통해 부가기능을 부여할 대상을 선정할 수 있었다. 덕분에 핵심기능을 담은 코드와 설정에는 전혀 영향을 주지 않아도 됐다. 결국 긴 과정을 통해 핵심기능에 부여되는 부가기능을 효과적으로 모듈화할 수 있었다.

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

부가기능을 모듈화하는 것은 전통적인 객체지향 설계기술방법으로는 독립적으로 모듈화하기 어렵기 때문에, 객체지향패러다임과는 구분되는 특성이 있다고 볼 수 있다. 그래서 부가기능 모듈을 객체지향 기술에서 주로 사용하는 오브젝트와는 다르게, “에스펙트”라고 부르기로 했다. 즉, 부가기능 모듈 = 에스펙트이다. 에스펙트란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다. 애스펙트라고 이름 지은 이유는, 이게 어플리케이션을 구성하는 한가지 측면이라고 생각할수 있기 때문이다. 이렇게 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 애스펙트 지항 프로그래밍이라고 한다. 이름만 들으면 OOP가 아닌 다른 패러다임 같지만, AOP는 OOP를 보조하는 기술이지 OOP를 대체하는 것이 아니다. 부가기능이 핵심기능 안에 침투해버리면 핵심기능 설계에 객체지향 기술의 가치를 온전히 부여하기 어렵다. 부가된 코드로 인해 객체지향적인 설계가 주는 장점일 잃어버리기 십상이다. AOP는 부가기능을 모듈화하여 분리함으로써, 핵심기능을 설계할 때 객체지향적인 가치를 지킬 수 있도록 도와주는 것이라고 보면 된다. 같은 말로, 어플리케이션을 다양한 측면에서 독립적으로 모델링, 설계, 개발할 수 있도록 도와주는 것이다. 예를 들어, 어플리케이션을 사용자 관리라는 핵심 로직 측면에서 바라볼 땐 그 부분에 집중하여 개발할 수 있고, 부가기능 관점에서 바라볼 땐 부가기능에 집중해서 설계하고 개발할 수 있게 된다는 뜻이다.

6.5.5 AOP 적용기술

프록시를 이용한 AOP

스프링이 AOP를 지원하기 위해 활용한 다양한 기술 중 가장 핵심은 프록시를 이용했다는 것이다. 프록시를 통해 DI로 연결된 빈 사이의 타깃 호출 과정에 참여해 부가기능을 제공해주도록 만들었다. 이것은 스프링 AOP는 자바의 기본 JDK와 스프링 컨테이너 외에는 특별한 기술이나 환경을 요구하지 않는다는 것을 의미한다. 스프링 AOP의 부가기능을 담은 어드바이스가 적용되는 대상은 오브젝트의 메소드다. 독립적으로 개발한 부가기능 모듈을 다양한 타깃 오브젝트의 메소드에 다이내믹하게 적용해주기 위해 가장 중요한 역할을 맡고 있는게 프록시다.

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

프록시 방식이 아닌 대표적인 AOP 기술로 AspectJ가 있다. AspectJ는 프록시처럼 간접적인 방법이 아니라, 타깃 오브젝트를 뜯어고쳐서 부가기능을 직접 넣어주는 직접적인 방법을 사용한다. 이를 위해 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방식을 사용한다. 트랜잭션 코드가 UserService클래스에 비즈니스 로직과 함께 있었던 것처럼 만들어버리는 것이다. 왜 AspectJ는 이런 복잡한 방식을 사용할까? 첫째는 직접적인 방식을 사용하면 스프링같은 di컨테이너의 도움을 받지 않아도 aop를 적용할 수 있기 때문이다. 둘째는 프록시보다 훨씬 강력하고 유연한 aop가 가능하기 때문이다. 프록시를 사용하면 부가기능을 부여할 대상은 클라이언트가 호출할 때 사용하는 메소드로 제한된다. 반면 바이트코드를 직접 조작하면 오브젝트의 생성, 필드 값의 조회와 조작, 스태틱 초기화 등의 다양한 작업에 부가기능을 부여해줄 수 있다. 예를 들어 타깃오브젝트가 생성되는 순간 부가기능을 제공하고 싶을 수도 있는데 프록시 방식으론 불가능한 반면 바이트코드 조작 방식을 활용하면 가능하다. 대부분의 부가기능은 프록시 방식으로 충분하고 바이트코드를 조작하려면 복잡하기 때문에 일반적인 aop를 적용할 땐 스프링 aop로도 충분하다. 스프링 aop를 사용하면서 동시에 AspectJ를 쓸 수도 있기 때문에 상황에 맞춰 활용하면 된다.

6.5.6 AOP의 용어

  • 타깃: 부가기능을 부여할 대상이다.
  • 어드바이스: 타깃에게 제공할 부가기능을 담은 모듈이다. 어드바이스에는 여러 종류가 있는데, MethodInterceptor처럼 메소드 호출 과정 전반에 참여하는 것도 있지만, 예외가 발생했을 때만 동작하는 어드바이스처럼 메소드 호출과정의 일부에서만 동작하는 어드바이스도 있다.
  • 조인포인트: 어드바이스가 적용될 수 있는 위치를 말한다. 스프링에서 조인포인트는 메소드 실행단계 뿐이다.
  • 포인트컷: 어드바이스를 적용할 조인포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 스프링에서 조인포인트는 메소드이기 때문에, 포인트컷은 메소드를 선정하는 기능을 가졌다고 할 수 있다. 메소드는 클래스 안에 존재하기 때문에 결국 클래스를 선정하고 그 안의 메소드를 선정하는 과정을 거친다.
  • 프록시: 클라이언트와 타깃 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트이다. di를 통해 타깃 대신 클라이언트에 주입되고, 클라이언트의 메소드 호출을 대신 받아서 타깃에 위임해주는데, 그 과정에서 부가기능을 부여한다.
  • 어드바이저: 포인트컷과 어드바이스를 하나씩 갖고 있는 오브젝트다. 어드바이저는 어떤 부가기능을 어디에 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다. 스프링은 자동 프록시 생성기가 어드바이저를 AOP 작업의 정보로 활용한다. 어드바이저는 스프링 AOP에서만 쓰이고 일반적 AOP에서는 쓰이지 않는 용어다.
  • 에스펙트: OOP에 클래스가 있다면 AOP에는 에스펙트가 있다. 한 개 또는 그 이상의 포인트컷과 어드바이스의 조합으로 구성되고 보통 싱글톤 형태의 오브젝트로 존재한다.

스프링의 프록시 방식 AOP를 적용하려면 최소 네 가지의 빈을 등록해야 한다.

  • 자동 프록시 생성기: 다른 빈을 di하지도 않고, 자신도 di되지 않으며 독립적으로 존재한다. 애플리케이션 컨텍스트가 빈 오브젝트를 생성하는 과정에서 빈 후처리기로 참여한다. 빈으로 등록된 어드바이저를 활용해서 프록시를 자동으로 생성하는 기능을 담당한다.
  • 어드바이스: 부가기능을 구현한 클래스를 빈으로 등록한다. 직접 구현해야한다.
  • 포인트컷: 스프링의 AspectJExpressionPointcut을 빈으로 등록하고 expression 프로퍼티에 포인트컷 표현식을 넣어주면 된다. 코드를 작성할 필요는 없다.
  • 어드바이저: 스프링의 DefaultPointcutAdvisor 클래스를 빈으로 등록한다. 어드바이스와 포인트컷을 프로퍼티로 참조하는 것 외에 기능은 없다. 자동 프록시 생성기에 의해 자동 검색되어 사용된다.
  • 어드바이스를 제외한 나머지는 스프링이 제공하는 클래스를 빈으로 등록하고 프로퍼티 설정만 해준 것이다.

6.6 트랜잭션 속성

트랜잭션의 특성(원자성 등)은 기본적으로 지켜져야겠지만, 트랜잭션의 동작방식은 다양하다. TransactionDefinition은 트랜잭션의 동작방식에 영향을 끼칠 수 있는 네 가지 속성을 정의한다.

트랜잭션 전파

트랜잭션 전파란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다. 두 개의 트랜잭션이 연관돼있는 상황을 가정해보자. A트랜잭션이 진행 중, B트랜잭션이 시작되는 등 말이다. 이 때 특정 시점(A진행중이던, B 진행중이던)에 롤백이 필요할 수 있다. 그러면 B만 롤백할 지, A만 롤백할 지 등 기준이 필요하다. 이런 식으로 독자적인 트랜잭션 경계를 가진 코드에 대해 이미 진행중인 트랜잭션이 어떻게 영향을 미칠 수 있는가를 정의하는 것이 트랜잭션 전파 속성이다. 대표적으로 PROPAGATION REQUIRED, PROPAGATION_REQUIRES_NEW, PROPAGATION_NOT SUPPORTED 등의 트랜잭션 전파 속성을 설정할 수 있다. 트랜잭션 매니저를 통해 트랜잭션을 시작하려고 할 때 getTransaction() 이라는 메소드를 사용히는 이유는 바로 이 트랜잭션 전파 속성이 있기 때문이다. 트랜잭션 매니저의 getTransaction( ) 메소드는 항상 트랜잭션을 새로 시작하는 것이 아니다. 트랜잭션 전파 속성과 현재 진행 중인 트랜잭션이 존재하는지 여부에 따라서 새로운 트랜잭션을 시작할 수도 있고, 이미 진행 중인 트랜잭션에 참여하기만 할 수도 있다.

격리수준

서버환경에서는 여러 개의 트랜잭션이 동시에 진행될 수 있다. 가능하다면 모든 트랜잭션이 순차적으로 진행돼서 다른 트랜잭션의 작업에 독립적인 것이 좋겠지만 그러자면 성능이 떨어질 수밖에 없다. 따라서 적절하게 격리수준을 조정해서 가능한 한 많은 트랜잭션을 동시에 진행시키면서도 문제가 발생하지 않게 하는 제어가 필요하다.

제한시간

트랜잭션을 수행하는 제한시간 time out 을 설정할 수 있다. 기본 설정은 제한시간이 없는 것이다.

읽기전용

읽기전용으로 설정해두면 트랜잭션 내에서 데이터를 조작하는 시도를 막아줄 수 있다.

TransactionAdvice가 TransactionDefinition을 생성하고 사용하여 트랜잭션 경계설정을 한다. 트랜잭션 정의를 바꾸려면 디폴트 속성을 갖고 있는 DefaultTransactionDefinition을 사용하는 대신 외부에서 정의된 TransactionDefinition 오브젝트를 di 받아서 사용하도록 만들면 된다. 하지만 이 방법으로 트랜잭션 속성을 변경하면 TransactionAdvice를 사용하는 모든 트랜잭션의 속성이 한꺼번에 바뀐다는 문제가 있다.

6.6.2 트랜잭션 인터셉터와 트랜잭션 속성

메소드별로 다른 트랜잭션 정의를 적용하려면 어드바이스의 기능을 확장해야 한다. 메소드 이름 패턴에 따라 다른 트랜잭션 정의가 적용되도록 만드는 것이다. 이를 위해 기존에 만들었던 TransactionAdvice를 다시 설계할 필요는 없다. 스프링이 편리하게 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 만들어둔 TransactionInterceptor 덕분이다.

트랜잭션 경계설정 코드를 보면, 동작방식을 바꿀 수 있는 곳이 두 곳 있다. 첫째는 트랜잭션 정의를 통해 트랜잭션의 네 가지 조건을 구하는 것이고, 둘째는 롤백을 적용할 시점을 정하는 것, 즉 어떤 예외를 잡을 지 정하는 것이다. 현재 런타임 예외만 잡고 있는데, 런타임 예외가 아닌 체크예외일 경우 문제가 생길 수 있다. 그렇다고 모든 예외를 잡을 수도 없다. 비즈니스 로직상 예외를 보여주기 위해 타깃 오브젝트가 체크예외를 던질 땐 디비 트랜잭션은 커밋시켜야 하기 때문이다. 일반적으로 비즈니스 로직과 관련된 예외는 체크예외이고, 복구 불가능하면 언체크 예외를 던지게 설계하기 때문에 문제 없을 수도 있다. 하지만 Transactionlnterceptor에는 이러한 예외처리 기본 원칙을 따르지 않는 경우가 있을 수 있다. 그래서 TransactionAttribute는 rollbackOn( )이라는 속성을 둬서 기본 원칙과 다른 예외처리가 가능하게 해준다. 이를 활용하면 특정 체크 예외의 경우는 트랜잭션을 롤백시키고, 특정 런타임 예외에 대해서는 트랜잭션을 커밋시킬 수도 있다. Transactionlnterceptor는 이런 TransactionAttribute를 Properties 라는 일종의 맵 타입 오브젝트로 전달받는다.

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

프록시 방식 aop는 같은 타깃 오브젝트 내의 메소드를 호출할 때는 적용되지 않는다. 프록시 방식의 aop에서 프록시를 통한 부가기능의 적용은 클라이언트로부터 호출이 일어날 때만 가능하다. 여기서 클라이언트는 인터페이스를 통해 타깃 오브젝트를 사용하는 다른 모든 오브젝트를 말한다. 타깃 오브젝트 안에서 메소드 호출이 일어나는 경우에는 프록시 AOP를 통해 부여해준 부가기능이 적용되지 않는다는 점을 주의해야 한다.

타깃 안에서의 호출에는 프록시가 적용되지 않는 문제를 해결할 수 있는 방법은 두 가지가있다. 하나는 스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 이용하도록 강제하는 방법이다. 하지만 복잡한 과정을 거쳐서 순수한 비즈니스 로직만을 남겨두려고 노력했는데, 거기에 스프링 API와 프록시 호출 코드가 등장하는 건 바람직하지 않다. 다른 방법은 AspectJ와 같은 타깃의 바이트코드를 직접 조작하는 방식의 AOP 기술을 적용하는 것이다.

6.6.4 트랜잭션 속성 적용

트랜잭션 경계설정의 부가기능을 여러 계층에서 중구난방으로 적용히는 건 좋지 않다. 일반적으로 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 바람직하다. 가끔 클래스나 메소드에 따라 제각각 속성이 다른, 세밀하게 튜닝된 트랜잭션 속성을 적용해야 하는 경우도 있다. 이런 경우라면 메소드 이름 패턴을 이용해서 일괄적으로 트랜잭션 속성을 부여히는 방식은 적합하지 않다. 기본 속성과 다른 경우가 있을 때마다 일일이 포인트컷과 어드바이스를 새로 추가해줘야 하기 때문이다. 이럴 땐 설정파일에서 패턴으로 분류 가능한 그룹을 만들어서 일괄적으로 속성을 부여하는 대신에 직접 타깃에 트랜잭션 속성 정보를 가진 애노테이션을 지정하는 방법이 낫다. 트랜잭션 속성은 타입 레벨에 일괄적으로 부여할 수도 있지만 메소드 단위로 세분화해서 트랜잭션 속성을 다르게 지정할 수도 있기 때문에 매우 세밀한 트랜잭션 속성 제어가 가능해진다. 메소드마다 @Transactional을 부여하고 속성을 지정할 수 있다. 이렇게 하면 유연한 속성 제어는 가능하겠지만 코드는 지저분해지고, 동일한 속성 정보를 가진 애노테이션을 반복적으로 메소드마다 부여해주는 바람직하지 못한 결과를 가져올 수 있다.

그래서 스프링은 @Transactional을 적용할 때 4단계의 대체 fallback 정책을 이용하게 해준다. 메소드의 속성을 확인할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입(클래스, 인터페이스)의 순서에 따라서 @Transactional 이 적용됐는지 차례로 확인하고, 가장 먼저 발견되는 속성 정보를 사용하게 하는 방법이다. 단계적으로 확인해서 @Transactional 이 발견되면 적용하고, 끝까지 발견되지 않으면 해당 메소드는 트랜잭션 적용 대상이 아니라고 판단한다. @Transactional을 사용하면 대체 정책을 잘 활용해서 애노태이션 자체는 최소한으로 사용하면서도 세밀한 제어가 가능하다. @Transactional은 먼저 타입 레벨에 정의되고 공통 속성을 따르지 않는 메소드에 대해서만 메소드 레벨에 다시 @Transactional을 부여해주는 식으로 사용해야 한다. 인터페이스를 사용히는 프록시 방식의 AOP가 아닌 방식으로 트랜잭션을 적용하면 인터페이스에 정의한 @Transactional은 무시되기 때문에 안전하게 타깃 클래스에 @Transactional을 두는 방법을 권장한다. 프록시 방식 AOP의 종류와 특정 또는 비 프록시 방식 AOP의 동작원리를 잘 이해하고 있고 그에 따라 @Transactional 의 적용 대상을 적절하게 변경해줄 확신이 있거나, 반드시 인터페이스를 사용하는 타깃에만 트랜잭션을 적용하겠다는 확신이 있다면 인터페이스에 @Transactional을 적용하고 아니라면 마음 편하게 타깃 클래스와 타깃 메소드에 적용히는 편이 낫다. 인터페이스에 @Transactional을 두면 구현 클래스가 바뀌더라도 트랜잭션 속성을 유지할수있다는 장점이 있다.

트랜잭션이 적용되지 않았다는 사실은 파악하기가 쉽지 않다. 일반적으로는 트랜잭션이 적용되지 않았다고 기능이 동작하지 않는 것도 아니므로 예외적인 상황이 발생해서 롤백이 필요한 시점이 돼야 비로소 이상하다는 걸 느끼고 트랜잭션 적용 여부를 확인해보게 된다. 따라서 @Transactional을 사용할 때는 실수하지 않도록 주의하고 별도의 코드 리뷰를 거칠 필요가 있다.

6.8 트랜잭션 지원 테스트

트랜잭션 전파라는 기법을 사용했기 때문에 UserService 의 add( )는 독자적인 트랜잭션 단위가 될 수도 있고, 다른 트랜잭션의 일부로 참여할 수도 있다. AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여해주고 속성을 지정할 수 있게 하는 방법을 선언적 트랜잭션이라고 한다. 반대로 TransactionTemplate이나 개별 데이터 기술의 트랜잭션 API를 사용해 직접 코드 안에서 사용하는 방법은 프로그램에 의한 트랜잭션이라고 한다. 특별한 경우가 아니라면 선언적 방식의 트랜잭션을 사용하는 것이 바람직하다. 트랜잭션의 자유로운 전파와 그로 인한 유연한 개발이 가능할 수 있었던 기술적인 배경에는 AOP가 있다. AOP 덕분에 프록시를 이용한 트랜잭션 부가기능을 간단하게 애플리케이션 전반에 적용할수 있었다. 또 한가지 중요한 기술적인 기반은 바로 스프링의 트랜잭션 추상화다. 트랜잭션 추상화 기술의 핵심은 트랜잭션 매니저와 트랜잭션 동기화다. 구체적인 트랜잭션 기술의 종류에 상관없이 일관된 트랜잭션 제어가 가능했다. 또한 트랜잭션 동기화 기술이 있었기에 시작된 트랜잭션 정보를 저장소에 보관해뒀다가 DAO에서 공유할 수 있었다. 트랜잭션 동기화 기술은 트랜잭션 전파를 위해서도 중요한 역할을 한다. 진행 중인 트랜잭션이 있는 지 확인하고 트랜잭션 전파 속성에 따라서 이에 참여할 수 있도록 만들어주는 것도 트랜잭션 동기화 기술 덕분이다.

특별한 이유가 있다면 트랜잭션 매니저를 이용해 트랜잭션에 참여하거나 트랜잭션을 제어히는 방법을 사용할 수도 있다. 지금까지 진행했던 작업 중 특별하고 독특한 작업은 테스트다. 테스트를 위해 여러 개의 트랜잭션을 하나로 통합할 수는 없을까? 여러 개의 메소드 모두 트랜잭션 전파 속성이 REQUIRED이니 이 메소드들이 호출되기 전에 메소드를 추가하여, 트랜잭션이 시작되게만 한다면 가능하다. 그런데 메소드를 추가하지 않고도 테스트 코드만으로 세 메소드의 트랜잭션을 통합하는 방법이 있다. 테스트 메소드에서 UserService의 메소드를 호출하기 전에 트랜잭션을 미리 시작해주면 된다. 테스트에서 트랜잭션 매니저를 이용해 트랜잭션을 시작시키고 이를 동기화해주면 된다. 테스트도 트랜잭션 동기화에 참여하는것이다. 테스트를 통해 확인할 수 있듯이 스프링의 트랜잭션 추상화가 제공하는 트랜잭션 동기화 기술과 트랜잭션 전파 속성 덕분에 테스트도 트랙잭션으로 묶을 수 있다. 테스트 코드에서 미리 트랜잭션을 시작해놓으면 직접 호출하는 DAO 메소드도 하나의 트랜잭션으로 묶을 수 있다. 트랜잭션 결과나 상태를 조작하면서 테스트하는 것도 가능하다. 하이버네이트 같은 ORM에서 세션에서 분리된 엔티티의 동작을 확인할 때도 유용하다. 테스트 메소드 안에서 트랜잭션을 여러 번 만들 수도 있다. 트랜잭션 속성에 따라서 여러 메소드를 조합해 사용할 때 어떤 결과가 나오는지도 미리 검증 가능하다. 롤백 테스트에 테스트 코드로 트랜잭션을 제어하는 걸 적용할 수 있다. 롤백 테스트는 테스트 내의 모든 DB 작업을 하나의 트랜잭션 안에서 동작하게 하고 테스트가 끝나면 무조건 롤백해버리는 테스트를 말한다. 롤백 테스트는 DB 작업이 포함된 태스트가 수행돼도 DB에 영향을 주지 않기 때문에 장점이 많다. 복잡한 데이터를 바탕으로 동작하는 기능을 테스트하려면 테스트가 실행될 때의 DB 데이터와 상태가 매우 중요하다. 문제는 테스트에서 DB 에 쓰기 작업을 하는 기능을 실행하면서 테스트를 수행하고 나면 DB의 데이터가 바뀐다는 점이다. 결국 DB를 액세스하는 테스트를 위해서는 테스트를 할 때마다 테스트 데이터를 초기화하는 번거로운 작업이 필요해진다. 이런 이유 때문에 롤백 테스트는 유용하다. 물론 테스트에 따라서 고유한 테스트 데이터가 필요한 경우가 있다. 이때는 테스트 앞부분에서 그에 맞게 DB를 초기화하고 테스트를 진행하면 된다. 롤백 테스트는 심지어 여러 개발자가 하나의 공용 테스트용 DB를 사용할 수 있게도 해준다. 테스트에서 트랜잭션을 제어할 수 있기 때문에 얻을 수 있는 가장 큰 유익이 있다면 바로 이 롤백 테스트다. DB에 따라서 성공적인 작업이라도 트랜잭션을 롤백하면 커밋할 때보다 성능이 더 향상되기도 한다. 물론 다른 경우도 있기 때문에 단지 성능 때문에 롤백 테스트가 낫다고는 볼 수 없다. @Transactional 애노태이션을 테스트 클래스와 메소드에도 적용할 수 있다. 이를 이용하면 테스트 내에서 진행하는 모든 트랜잭션 관련 작업을 하나로 묶어줄 수 있다. 물론 테스트에서 사용하는 @Transactional은 AOP를 위한 것은 아니다. 단지 컨텍스트 테스트 프레임워크에 의해 트랜잭션을 부여해주는 용도로 쓰일 뿐이다. 테스트 메소드나 클래스에 사용히는 @Transactional은 애플리케이션의 클래스에 적용할 때와 디폴트 속성은 동일하다. 다만, 테스트용 트랜잭션은 테스트가 끝나면 자동으로 롤백된다. 테스트에 적용된 @Transactional은 기본적으로 트랜잭션을 강제 롤백시키도록 설정되어 있다. @Transactional을 지정해주면 롤백 테스트가 되는 것이다. 한편, 오히려 트랜잭션을 커밋시켜서 테스트에서 진행한 작업을 그대로 DB에 반영하고 싶다면 @Rollback 이라는 애노테이션을 이용하면 된다. @Rollback은 롤백 여부를 지정하는 값을 갖고 있다. @Rollback 애노테이션은 메소드 레벨에만 적용할 수 있다. 테스트 클래스의 모든 메소드에 트랜잭션을 적용하면서 모든 트랜잭션이 롤백되지 않고 커밋되게 하려면 @TransactionConfiguration 애노테이션을 이용하면 펀리하다. 필요하지도 않은 트랜잭션이 만들어지는 것이 꺼림칙하거나 트랜잭션이 적용되면 안 되는 경우에는 해당 메소드에만 테스트 메소드에 의한 트랜잭션이 시작되지 않도록 만들어줄 수 있다. @NotTransactional을 테스트 메소드에 부여하면 클래스 레벨의 @Transactional 설정을 무시하고 트랜잭션을 시작하지 않은 채로 테스트를 진행한다. @Transactional(propagation=Propagation.NEVER)을 해도 @NotTransactional과 마찬가지로 트랜잭션이 시작되지 않는다. DB가 사용되는 통합 태스트를 별도의 클래스로 만들어둔다면 기본적으로 클래스 레벨에 @Transactional을 부여해준다. DB가 사용되는 통합 테스트는 가능한 한 롤백 테스트로 만드는 게 좋다.