[토비의 스프링 3.1] 6장. AOP

대연.

·

2021. 12. 6. 20:27

728x90

Source : yes24

  1. 해당 포스팅은 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
  2. 공부하면서 블로그를 참고하였는데, 책 내용을 그대로 정리하는데 그치는 글들이 절반이었습니다. 스스로 새롭게 알게 된 내용이거나 책의 설명이 너무 불친절한 경우 부가설명을 작성하거나 또는 새롭게 쓰고자 노력했습니다. 공부하다 생기는 의문들은 레포지토리 이슈에서 질의응답을 주고 받았으니 학습하다 궁금한 점이 생기면 검색 해보시기를 권장드립니다.
  3. 틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다.
  4. 코드나 책 내용 캡쳐 내용들은 다른 블로그의 캡쳐본이나 텍스트를 가져와 작성하였습니다. 대부분 출처를 표기하였으나 누락된 경우 원하시는 조치 내용을 댓글로 남겨주시면 시정하겠습니다.

6.1 트랜잭션 코드의 분리

메서드 분리하기

트랜잭션의 경계는 비즈니스 로직의 전후로 설정이 되어야 하는 것이 분명하다. 실제 코드를 살펴보면 트랜잭션의 경계를 설정하는 코드와 비즈니스 로직을 구분할 수 있다.

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;
    }
    //------------------트랜잭션 경계설정 끝
}

트랜잭션을 설정하는 코드와 비즈니스 로직이 직접 공유하거나 주고 받는 데이터가 없다. 그렇기 때문에 실행 순서만 지켜지면 동작에 문제가 없다.

그래서 메서드 추출을 시도해 볼 수 있다.

public void upgradeLevels() throws Exception {
    //------------------트랜잭션 경계설정 시작
    TransactionStatus status = this.transactionManager
                        .getTransaction(new DefaultTransactionDefinition());
    //------------------트랜잭션 경계설정 시작 끝
    //------------------비즈니스 로직 시작
    try {
        upgradeLevelsInternal();
    //------------------비즈니스 로직 끝
    //------------------트랜잭션 경계설정 마무리 시작
        this.transactionManager.commit(status);
    } catch (Exception e) {
        this.transactionManager.rollback(status);
        throw e;
    }
    //------------------트랜잭션 경계설정 끝
}

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

비즈니스 로직을 분리함으로써 가독성도 높이고 비즈니스 로직을 수정하면서 트랜잭션 코드를 건드릴 필요도 없어졌다. 그러나 트랜잭션 코드가 여전히 UserService 내부에 존재하는 것이 부담이다.

DI를 이용해 트랜잭션 분리하기

현재 구현은 클라이언트가 UserService 클래스를 직접 참조하고 있다. 그래서 인터페이스에 의존하여 결합도를 낮추는 작업을 수행한다.

https://happyer16.tistory.com/entry/토비의-스프링-6장1-AOP-트랜잭션-코드의-분리

 

일반적으로 인터페이스를 통해 구체 클래스에 접근하고 DI로 주입해주는 이유는 구체 클래스를 필요에 따라 쉽게 교체하기 쉽도록 하기 위함이지만, 지금 해결하고자 하는 문제는 비즈니스 로직을 담고 있는 코드와 트랜잭션 담당하는 코드를 분리하기 위함이다.

그러기 위해 UserService를 구현한 UserServiceTx 클래스를 만든다.

  • UserServiceImpl을 대신하기 위한 클래스가 아니다.
  • 트랜잭션의 경계설정을 담당하고 있다.
  • 비즈니스 로직은 UserService를 구현한 구체 클래스에 위임한다.
// UserService 인터페이스
public interface UserService {
    void upgradeLevels();
    void add(User user);
}
// UserServiceImpl 구체 클래스. 실질적인 비즈니스 로직만을 담고 있다.
@Service
public class UserServiceImpl implements UserService {

    private UserDao userDao;
    private MailSender mailSender;
        //...

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

}
// UserServiceTx 구체 클래스. 트랜잭션만을 담당한다.
public class UserServiceTx implements UserService {

    private UserService userService;
        // UserService를 구현했지만 UserService를 사용하는 것이 포인트
        // 주입받는 UserService는 비즈니스 로직을 처리하는 구체 클래스고,
        // UserServiceTx는 트랜잭션만을 담당한다.
    private 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 transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            userService.upgradeLevels();
                        // 비즈니스 로직 위임
            transactionManager.commit(transaction);
                        // 트랜잭션 경계 설정
        } catch (RuntimeException e) {
            transactionManager.rollback(transaction);
            throw e;
        }
    }
}

기존의 코드는UserService 안에서 트랜잭션 경계 설정을 했지만, 이젠 아래와 같은 관계가 성립할 것이다.

Client → UserServiceTx(트랜잭션 경계 설정) → UserService(비즈니스 로직)

테스트 수정하기 : @Autowired

테스트에서는 @Autowired로 빈을 주입받아 사용했다. 이전엔 UserService로 단 하나의 빈을 특정할 수 있었기에 큰 문제가 되지 않지만, UserService 인터페이스를 구현한 클래스가 2개가 있기 때문에 단순 @Autowired로는 빈을 특정할 수 없는 문제가 생긴다. 그래서 아이디를 지정해주고, 필드명을 아이디와 일치시킴으로써 애매모호함을 해소할 수 있다.

@Autowired UserService userService;

userService가 아이디로 지정된 Bean을 가져올 수 있다.

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

  1. 비즈니스 로직을 담당하는 UserServiceImpl 코드 작성시 트랜잭션에 대한 고민은 하지 않아도 된다.
  2. 비즈니스 로직 테스트를 좀 더 쉽게 작성할 수 있다.

6.2 고립된 단위 테스트

테스트를 가장 쉽게 하는 방법은 가능한 작은 단위로 쪼개는 것이다. 테스트 실패 시 원인을 찾기 쉽기 때문이다. 하지만 작은 단위의 테스트를 작성하는게 항상 가능한 것은 아니다.

UserService를 테스트 하기 위해서는 세 가지 타입의 의존 오브젝트가 필요하다.

  • UserDaoJdbc
  • DSTransactionManager
  • JavaMailSenderImpl

https://happyer16.tistory.com/entry/토비의-스프링-6장2-고립된-단위-테스트



UserService를 테스트하기 위해 많은 오브젝트, 환경, 서비스, 서버, 네트워크까지 테스트에 포함된다는 점이다. 단순히 비즈니스 로직을 테스트하고 싶어하는 경우에도 말이다.

그래서 테스트 대상이 외부에 환경이나 다른 클래스에 종속되고 영향을 받지 않도록 '고립'시킬 필요가 있다.

https://gunju-ko.github.io/toby-spring/2018/11/20/AOP.html

 

앞서 목 오브젝트를 만들어 테스트 대역으로 사용했던 방법을 적용한다. upgradeLevels()가 제대로 수행되는지 알기 위해 UserDao에게 어떤 요청을 했는지 주고받은 정보를 저장해뒀다가 테스트의 막바지 검증에 사용할 수 있도록 하는 것이 좋다.

테스트 수정하기

// UserDao의 목 오브젝트
public class MockUserDao implements UserDao {

    private List<User> users;
    private List<User> updated = new ArrayList<>();

    public MockUserDao(List<User> users) {
        this.users = users;
    }

    public List<User> getAll() {
        return this.users;
    }

    public void update(User user) {
        this.updated.add(user);
    }

    public List<User> getUpdated() {
        return updated;
    }

    public void deleteAll() {
        throw new UnsupportedOperationException();
                // 테스트의 관심사가 아니므로 실수로 호출하는 상황을 방지하기 위해 예외를 throw
    }

    public int getCount() {
        throw new UnsupportedOperationException();
    }

    public void add(User user) {
        throw new UnsupportedOperationException();
    }

    public User get(String id) {
        throw new UnsupportedOperationException();
    }
}

UserDao 인터페이스를 모두 구현한 MockUserDao를 구현할 때 신경써야 할 부분은 다음과 같다.

  • getAll() 메서드는 마치 DB에서 읽어와 유저 목록을 반환하는 것 처럼 행동해야 한다.
  • update() 메서드는 DB 수정을 요청한 유저 목록을 담고 있다가 검증을 위해 돌려주기 위한 것이다.

이렇게 만들어진 목 오브젝트는 스프링 컨테이너에서 빈을 가져올 필요가 없다. 완전히 고립되어 테스트만을 위해 동작하기 때문이다.

실제 외부환경과의 상호작용 등이 배제되었기 때문에 굉장히 짧은 시간 내에 테스트를 마칠 수 있음은 물론이고 실제 운용환경에도 영향을 미치지 않는다는 장점이 있다.

단위 테스트와 통합 테스트

단위 테스트의 단위는 정하기 나름이다. 하나의 기능, 하나의 클래스, 하나의 메소드 그 무엇이든 상관없다. 책에서는 외부에 의존하지 않고 고립되어 수행하는 테스트를 단위 테스트라고 부르고, 그 반대인 외부의 환경이나 리소스의 참여가 있는 테스트를 통합 테스트라고 부른다.

  • 단위 테스트를 우선 고려한다.
  • 단위 테스트는 효과적인 테스트를 작성하기 쉽다.
  • 외부 리소스가 꼭 필요한 테스트는 통합 테스트로 만든다.
  • DAO는 단위 테스트의 장점을 가져가기 어렵기 때문에 통합 테스트로 만든다.
  • 그렇다고 꼭 DAO를 이용한 코드를 통합 테스트로 만들 필요는 없는게, 만약 DAO를 통합 테스트로 확실하게 동작함을 보장할 수 있다면 DAO를 이용하는 코드는 DAO를 목 오브젝트나 스텁으로 대체할 수 있다.
  • 통합 테스트도 전체 시스템의 테스트를 위해 필요하지만, 단위 테스트를 충분히 거쳤다면 통합 테스트의 부담이 줄어든다.
  • 통합 테스트가 꼭 필요한 코드도 가능한 많은 부분을 단위 테스트로 검증하도록 한다.
  • 스프링 테스트 컨텍스트 프레임워크를 이용하는 테스트는 통합 테스트이다.

목 프레임워크

단위 테스트를 위해선 목 오브젝트와 테스트 스텁의 사용이 필수적이다. 메서드/클래스 단위로 테스트를 하기 위해선 외부로의 의존성을 제거해야 하기 때문이다.

그러나 목 오브젝트는 작성이 너무 귀찮다. 테스트에 사용하지 않을 인터페이스의 메서드도 구현해야 하고, 메서드의 호출 내용을 담아뒀다가 불러오는 것도 귀찮다. 경우에 따라선 같은 의존 인터페이스를 구현한 다수의 목 클래스를 작성해야 할 수도 있다.

Mockito 프레임워크

이 귀찮은 것들을 편리하게 해주는 Mockito 프레임워크를 사용해보자.

  • 사용하기 편리하고 코드도 직관적이다.
  • 목 클래스를 일일이 준비할 필요가 없다.
  • 메서드 호출만으로도 특정 인터페이스를 구현한 테스트용 목 오브젝트를 만들 수 있다.
UserDao mockUserDao = mock(UserDao.class);
//  org.mockito.Matchers에 정의된 mock();
// 목 오브젝트를 Mockito로 생성한다.

when(mockUserDao.getAll()).thenReturn(this.users);
// getAll이 호출될 때 (when) this.users를 리턴한다.
// 목 오브젝트가 리턴할 값을 지정한다.

// 테스트에 사용한다.

verify(mockUserDao, time(2)).update(any*User.class));
// 목 오브젝트의 특정 메서드가 몇 번 호출되었는지, 어떤 값을 가지고 몇 번 호출되었는지 검증

특별한 기능을 가진 목 오브젝트를 만들 필요가 없다면 대부분의 목 오브젝트는 Mockito로 생성이 가능하다.

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

일반적인 전략 패턴으로 특정 구현을 분리할 때, 원래 코드에는 '해당 기능을 위임하는' 코드가 있어야 한다. 구체적인 구현은 드러나지 않을 지라도, 위임 코드를 포함하여야 한다.

그러나 UserServiceTx를 통해 트랜잭션 코드를 위임하는 흔적도 남기지 않고 UserServiceImpl에서 분리할 수 있었다. 그러나 여전히 문제가 남아있다.

  1. 부가 기능을 구현한 클래스(여기선 UserServiceTx)가 주요 기능을 위임하는 코드를 가지고 있다.
  2. 만약 핵심기능을 가진 클래스를 클라이언트에서 직접 호출한다면, 부가 기능이 작동하지 않는다.

이렇게 클라이언트가 사용하려고 하는 실제 대상(주요 비즈니스 로직)인 것 처럼 위장해 클라이언트의 요청을 받는 패턴을 프록시 패턴이라고 한다. 이 프록시를 거쳐 최종적으로 요청을 처리하는 실제 오브젝트를 타겟 혹은 실체(real subject)라고 부른다.

  • 프록시는 타깃과 같은 인터페이스를 구현한다.
  • 프록시가 타깃을 제어할 수 있는 위치에 있어야 한다.

프록시 패턴을 사용하는 이유는 다음과 같다.

  1. 클라이언트가 타겟에 접근하는 방법을 제어하기 위함이다.
  2. 타겟에 부가적인 기능을 부여하기 위해 쓴다.

프록시를 사용한다는 공통점이 있지만, 목적에 따라 디자인 패턴에선 다르게 분류하기도 한다.

데코레이터 패턴

타겟에 부가적인 기능을 런타임에 다이나믹하게 부여하기 위한 프록시를 사용하는 패턴이다.

코드 상으로는 어떤 방법과 순서로 프록시와 타겟이 연결되는지 정해져 있지 않고 런타임에 다이나믹하게 정해진다. 프록시가 한 개로 제한이 되어 있지 않고, 프록시→타겟으로 고정되어 있지 않고 프록시→프록시가 될 수도 있다. 프록시 자기자신 조차도 다음 위임하는 대상이 타겟인지 프록시인지 알지 못한다. 타겟의 코드를 손대지 않고 부가적인 기능을 구현할 때 유용하다.

인터페이스를 통한 데코레이터의 정의와 런타임의 다이나믹한 구성은 스프링의 DI로 구현이 가능하다. UserServiceTx는 UserService 타입의 빈을 DI 받는다. 트랜잭션 경계설정 기능을 부여해 UserService 타입 빈에 위임한다.

프록시 패턴

타겟의 접근 방법을 제어하려는 목적을 가지고 프록시를 사용하는 패턴이다. 기능의 확장이나 추가의 역할을 하지 않지만, 클라이언트가 접근하는 방식을 변경한다.

가장 대표적인 예시는 타겟 오브젝트의 생성 비용이 크지만 항상 만들어져 있어야 할 이유가 없을 때이다. 프록시를 생성하고, 클라이언트가 프록시를 통해 접근을 원할 때 오브젝트가 생성되어 있지 않으면 그 떄 타겟 오브젝트를 생성하고 위임하는 방시긍로 구현할 수 있다.

또 다른 예시는 원격 오브젝트(현재 머신에 존재하지 않는 오브젝트)를 사용할 때도 프록시가 유용할 수 있다. RMI, EJB 등의 리모팅 기술을 통해 다른 서버에 존재하는 오브젝트를 접근하고자 한다면, 프록시를 마치 로컬의 오브젝트 인것처럼 사용하고, 접근할 때 원격의 오브젝트와 통신하여 결과를 반환한다.

타겟에 대한 접근권한을 제어하기 위해서도 사용한다. 특정 단계에서 특정 오브젝트가 읽기 전용으로만 동작하도록 만들고 싶다면, 오브젝트의 프록시를 만들어 특정 메서드(Setter가 대표적)를 접근하지 못하도록 예외를 던지는 방식으로 바꿀 수 있다.

다이나믹 프록시

프록시는 유용하지만, 번거롭게 느껴질 수도 있다. 새로운 클래스를 정의하고 위임하는 코드를 작성하는게 귀찮을 수 있다. 목 오브젝트와 유사하게 프록시도 모든 인터페이스의 메서드를 구현하지 않고도 편리하게 사용할 수 있을까?

java.lang.reflect 패키지에 이런 기능이존재한다. 목 프레임워크와 유사하다. 프록시 클래스를 직접 정의하지 않고도 API 호출로 간단하게 프록시 오브젝트를 생성할 수 있다.

프록시는

  • 타겟과 같은 인터페이스를 구현하고, 메서드가 호출되면 타겟 오브젝트로 위임한다.
  • 지정된 요청에 부가기능을 수행한다.

그러나 직접 프록시 오브젝트를 만드는 일은

  • 사용하지 않는 인터페이스 메서드 조차도 구현해주어야 하는 귀찮은 일이다. 타겟 인터페이스가 변한다면 프록시도 함께 수정해주어야 하는 부담이 존재한다.
  • 부가기능 코드가 중복될 가능성이 많다.

리플렉션

자바의 모든 클래스는 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 가지고 있다. 이 클래스 오브젝트를 이용하면

  • 클래스의 이름
  • 클래스 상속 정보
  • 인터페이스 구현
  • 필드의 목록과 타입
  • 메서드의 목록과 시그니쳐
  • 오브젝트 필드 값 읽기/쓰기
  • 메서드 호출

등을 할 수 있다.

String name = "Spring";

이라는 스트링 오브젝트가 있다고 가정하자.

이 스트링의 길이를 알고 싶다면 length() 메서드로 쉽게 알 수 있다.

리플렉션을 이용해 호출하고자 한다면, 메서드의 정보를 담은 Method 타입의 변수를 정의한다.

String name = "Spring";

Method lengthMethod = String.class.getMethod("length");
int length = lengthMethod.invoke(name);
// name 오브젝트에서 lengthMethod를 수행한다. 오브젝트 파라미터 뒤에 추가되는 파라미터는
// 메서드에 전달할 파라미터를 의미한다.

프록시 클래스

다이나믹 프록시를 구현해보자.

// 인터페이스
public interface Hello {
    String sayHello(String name); 
    String sayHi(String name); 
    String sayThankYou(String name);
}
// 타겟 클래스
public class HelloTarget implements Hello {

    @Override
    public String sayHello(String name) {
        return "Hello " + name;
    }

    @Override
    public String sayHi(String name) {
        return "Hi " + name;
    }

    @Override
    public String sayThankYou(String name) {
        return "Thank you " + name;
    }
}
// 프록시 클래스
public class HelloUppercase implements Hello {

    private final Hello delegate;
        // 위임하는 오브젝트가 타겟일 수도, 프록시일 수도 있으니 인터페이스로 접근한다.
    public HelloUppercase(Hello delegate) {
        this.delegate = delegate;
    }

    @Override
    public String sayHello(String name) {
        return delegate.sayHello(name).toUpperCase();
                // 위임과 동시에 스트링의 모든 문자를 대문자로 바꾸는 부가기능이다.
    }

    @Override
    public String sayHi(String name) {
        return delegate.sayHi(name).toUpperCase();
    }

    @Override
    public String sayThankYou(String name) {
        return delegate.sayThankYou(name).toUpperCase();
    }
}

간단하게 hello 클래스에 부가기능을 추가하는 프록시를 볼 수 있지만,

  • 코드의 중복
  • 구현하기 귀찮다

는 두 가지 문제가 여전히 존재한다.

다이나믹 프록시 적용

다이나믹 프록시는 아래와 같이 동작한다.

https://gunju-ko.github.io/toby-spring/2018/11/20/AOP.html

  • 다이나믹 프록시는 타겟의 인터페이스와 같은 타입으로 만들어진다.
  • 클라이언트는 다이나믹 프록시를 통해 접근한다.
  • 다이나믹 프록시는 클라이언트의 요청을 리플렉션 정보로 변환해서 invoke()로 넘긴다.
  • 프록시 팩토리에 타입만 알려주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어준다.
  • 부가 기능은 InvocationHandler를 구현한 오브젝트에 담는다. 해당 인터페이스는 public Object invoke(Object proxy, Method method, Object[] args)만을 메서드로 가지고 있다.

public class UppercaseHandler implements InvocationHandler {

    private final Hello target;

    public UppercaseHandler(Hello target) {
        this.target = target;
                // 전달받은 요청을 위임할 타깃/프록시 오브젝트
    }

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

다이나믹 프록시를 생성할 때, Proxy 클래스의 newProxyInstance() 스태틱 팩토리 메서드를 이용할 수 있다.

Hello proxiedHello = (Hello) Proxy.newProxyInstance(
                            getClass().getClassLoader(), // 다이나믹 프록시가 정의되는 클래스로더.                        
                            new Class[]{Hello.class}, // 다이나믹 프록시가 구현할 인터페이스. 다수일 수도 있다.
                            new UppercaseHandler(new HelloTarget())); // 부가기능과 위임 구현한 InvocationHandler 구현 오브젝트
public class Uppercasehandler implements InvocationHandler {

    private Object target;

    public Uppercasehandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object ret = method.invoke(target, args);
                //
        if (ret instanceof String && method.getName().startsWith("say")) {
                // 호출한 메서드의 타입에 따라 결과가 달라지도록 구현
            return ((String) ret).toUpperCase();
        }
        return ret;
    }
}

다이나믹 프록시는 직접 만든 프록시보다 좋은 점은 인터페이스의 메서드가 늘어나도 추가적인 구현이 필요없다.

다이나믹 프록시로 트랜잭션 설정 구현하기

직접 만든 UserServiceTx는 필요한 모든 메서드마다 트랜잭션 처리 코드가 중복된다. 하나의 InvocationHandler로 편리하게 처리해보자.

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(PlatormTransactionManager 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)) { // 메서드의 이름이 pattern으로 시작하면 트랜잭션 적용
            return invokeInTransaction(method, args);
        } else {
            return method.invoke(target, args);
        }
    }

    private Object invokeInTransaction(Method method, Object[] args) throws Throwable {
        TransactionStatus status = 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);
            return e.getTargetException();
        }
    }
}

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

문제는 다이나믹 프록시 오브젝트는 프록시 팩토리를 통해 생성하기 때문에 일반적인 방법으로 Bean 등록을 할 수 없다.

  • 스프링 Bean은 리플렉션 API를 이용해서 Bean Definition에 나오는 클래스 이름으로 Bean 오브젝트를 생성한다.
  • 다이나믹 프록시 클래스 또한 동적으로 정의하기 때문에 일반적인 방법으로는 스프링 Bean으로 등록할 수 없다.

설정 파일이나 Annotation으로 스프링이 만드는 Bean 대신, 팩토리 Bean을 통해 오브젝트 생성 로직을 담당하는 빈을 만들 수 있다. 방법은 여러가지가 있는데, FactoryBean 인터페이스를 구현한 클래스를 스프링 Bean으로 등록하면 팩토리 Bean으로 동작한다.

// FactoryBean 인터페이스
public interface FactoryBean<T> {
    T getObject() throws Exception; 
    Class<? extends T> getObjectType(); 
    boolean isSingleton(); 
}
public class Message {
    String text;

    private Message(String text) { 
                // 프라이빗 생성자기 때문에 외부에서 생성이 불가능하다.
                // 즉, 일반적인 방법으로 Bean 등록이 불가능하다.
                // 리플렉트는 생성자가 private이라도 객체 생성이 가능하지만, 
                // 개발자가 private으로 객체 생성이 불가하도록 만든 이유가 있기 때문에 
                // 기술적으로 가능하다 한들 강제로 객체를 만들지 않도록 한다.
        this.text = text;
    }

    public String getText() {
        return text;
    }

    public static Message newMessage(String text) { // 대신 스태틱 팩토리 메서드로 객체를 얻을 수 있다.
        return new Message(text);
    }
}
public class MessageFactoryBean implements FactoryBean<Message> {
    String text;

        // 메세지 오브젝트를 생성할 때 필요한 정보는 DI로 주입할 수 있도록 한다.
    public void setText(String text) {
        this.text = text;
    }

        // 실제 빈으로 사용될 오브젝트를 생성한다. 
    public Message getObject() throws Exception {
                // 복잡한 로직도 가능
        return Message.newMessage(this.next);
    }

    public Class<? extends Message> getObjectType() {
        return Message.class;
    }

    public boolean isSingleton() {
                // 싱글톤 여부를 알려준다. getObject()를 실행할 때 마다 새 객체가 만들어 지겠지만,
                // t를 반환하면 싱글톤으로 Bean이 관리될 수 있도록 스프링이 관리한다.
        return false;
    }
}

이렇게 등록된 팩토리 빈은 빈 오브젝트를 생성하는 과정에서만 사용되며, 그 이후에는 생성된 Bean은 일반적인 스프링 Bean과 똑같이 동작하고 사용할 수 있다.

<bean id="message"
            class="{classpath}.MessageFactoryBean">
        <property name="text" value="Factory Bean"/>
</bean>

주의할 점은, Bean 오브젝트의 타입은 MessageFactoryBean이 아니라, 팩토리 Bean에 정의된 getObjectType() 메서드가 반환하는 타입, 즉 Message와 같다는 것이다.

그럼 이 팩토리 Bean을 다이나믹 프록시를 만드는데 사용해보자.

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

팩토리 Bean에 객체를 생성하는 부분에 다이나믹 프록시 객체를 생성하는 프록시 팩터리 메서드를 호출하도록 하면 된다.

https://jongmin92.github.io/2018/04/15/Spring/toby-6/

public class TxProxyFactoryBean implements FactoryBean<Object> {
    Object target;
    PlatformTransactionManager transactionManager;
    String pattern;
    Class<?> serviceInterface;
        // 와일드카드 한정자를 통해 모든 타입의 인터페이스를 다 받아들인다.

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

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

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

    public void setServiceInterface(Class<?> serviceInterface) {
        this.serviceInterface = serviceInterface;
    }

    //FactoryBean 인터페이스 구현 메소드
    public Object getObject() throws Exception {
        TransactionHandler txHandler = new TransactionHandler();
        txHandler.setTarget(targer);
        txHandler.setTransactionManager(transactionManager);
        txHandler.setPattern(pattern);
        return Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class[] { serviceInterface },
                txHandler);
                // 프록시 팩토리 메서드로 프록시 오브젝트를 생성한다.
    }


    public Class<?> getObjectType() {
        return serviceInterface;
                // DI받은 인터페이스 타입에 따라 다른 값을 반환한다.
    }

    public boolean isSingleton() {
        return false; 
                // getObject()가 매번 identical한 객체를 반환하지 않는다는 의미다.
    }
}
<bean id="userService" class="springbook.user.service.TxProxyFactoryBean">
    <property neme="target" ref="{classpath}.userSerivceImpl" />
    <property neme="transactionManager" ref="{classpath}.transactionManager" />
    <property neme="pattern" ref="{classpath}.pattern" />
    <property neme="serviceInterface" ref="{classpath}.serviceInterface" />
</bean>

프록시 팩토리 빈의 장단점

다이나믹 프록시를 생성하는 팩토리 빈을 만들어 놓으면

타겟의 타입에 상관없이 재사용이 가능하다.

모든 타입의 인터페이스를 받아들이도록 설계되었기 때문에, 타겟 오브젝트에 맞는 타입의 프로퍼티를 설정하기만 하면 된다.

코드를 작성하는 번거로움을 줄일 수 있다

프록시를 적용할 대상이 가지고 있는 메서드 전체를 구현해야 하는 번거로움이 줄어든다. 또한 인터페이스가 변화하더라도 프록시는 그대로 사용할 수 있다.

코드의 중복이 사라진다

핸들러 메서드를 구현함으로써 타겟 인터페이스를 구현하는 프록시 클래스를 만드는 번거로움도, 부가적인 기능을 반복작성하는 어려움도 없다.

프록시를 통해 다수의 클래스에 공통적인 부가기능을 추가하는 것은 불가능하다

한 클래스 내의 다수의 메서드에 부가기능 제공을 하기는 쉽지만, 다수의 클래스에 공통적으로 적용하는 것은 불가능하다.

하나의 타겟에 여러 부가기능을 적용하는 것도 어렵다

클래스당 추가되는 서비스 빈 설정은 적을 수 있지만, 클래스가 많아지면 설정 파일이 한없이 커질 수 있다.

TransactionHandler 오브젝트가 한없이 많아진다.

타겟 오브젝트 당 하나의 TransactionHandler가 있어야 한다. 타겟 오브젝트가 한없이 많아지면?

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

기존 코드 수정없이 트랜잭션 부가기능을 추가할 수 있는 방법을 다뤘지만 어딘가 모르게 복잡해보인다. 스프링의 세련된 해결법을 살펴보자.

ProxyFactoryBean

트랜젝션 기술과 메일 발송에 적요왰던 서비스 추상화를 프록시에도 동일하게 적용하고 있으며, 일관적인 방법으로 프록시를 만들 수 있도록 하는 추상 레이어를 제공한다.

스프링의 ProxyFactoryBean은 프록시를 생성하는 팩토리 빈이다. 부가기능은 별도의 빈에 두도록 한다. 이 부가기능은 MethodInterceptor를 구현해서 만든다. 이를 구현한 부가기능은 프록시 구현으로부터 분리되며, 어드바이스라 부른다. ProxyFactoryBean으로 부터 타깃 오브젝트 정보를 제공받는다. 그래서 독립적으로 생성이 가능하며 타깃이 다른 여러 프록시에서도 재활용이 가능하고 싱글톤 빈으로 등록할 수 있다.

인터페이스 타입을 제공받지 않고도 Hello 인터페이스를 구현한 프록시를 만들 수 있는 이유는, 내부에서 타겟 오브젝트의 인터페이스를 검출한다.

  • Setter 대신 addAdvice()를 통해 어드바이스를 DI한다.
  • 아무리 많은 부가기능이 있더라도 프록시와 프록시 팩토리 빈을 추가해줄 필요가 없다.

MethodInvocation

일종의 콜백 오브젝트이다. proceed() 메서드는 타겟 오브젝트의 메서드를 내부적으로 실행한다. MethodInvocation은 일종의 템플릿 역할을 하며, 타겟과 파라미터에 종속되지 않아 싱글톤으로 두고 쓸 수 있다. (여기서 콜백은 타겟과 각종 어드바이스 되시겠다.)

Advice 인터페이스를 상속하고 있는 인터페이스이다.

public class DynamicProxyTest {
    @Test
    public void proxyFactoryBean() {
        ProxyFactoryBean pfBean = new ProxyFactoryBean();
        pfBean.setTarget(new HelloTarget()); // 타겟을 명시함
        pfBean.addAdvice(new UppercaseAdvice()); // 어드바이스 추가. 다수 추가 가능

        Hello proxiedHello = (Hello) pfBean.getObject();
        assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
        assertThat(proxiedHello.sayHello("Toby"), is("HI TOBY"));
        assertThat(proxiedHello.sayThankYou("Toby"), is("THANK YOU TOBY"));
    }

    static class UppercaseAdvice implements MethodInterceptor {
        @Override 
        public Object invoke(MethodInvocation invocation) throws Throwable {
                        // 타겟 오브젝트 정보가 등장하지 않는다.
            String ret = (String)invocation.proceed();
                        // 타겟 오브젝트의 메서드를 실행하는 기능이 존재한다.
            return ret.toUpperCase();
                        // 오로지 추가기능 구현에만 집중할 수 있다.
        }
    }

    static interface Hello {
        String sayHello(String name);
        String sayHi(String name);
        String sayThankYou(String name);
    }

    static class HelloTarget implements Hello {
        @Override
        public String sayHello(String name) {
            return "Hello " + name;
        }

        @Override
        public String sayHi(String name) {
            return "Hi " + name;
        }

        @Override
        public String sayThankYou(String name) {
            return "Thank You " + name;
        }
    }
}

포인트컷 : 부가기능을 적용할 메서드를 지정하는 방법

TxProxyFactoryBean에선 pattern이라는 스트링을 담고 있고, pattern으로 시작하는 메서드만 부가기능을 적용하는 구현이 있었다. 이를 ProxyFactoryBean에서 해결하고자 한다면, 메서드를 선정하는 기능을 추가하면 될 것 같지만, 2가지 문제가 있다.

  • 메서드 선정 알고리즘 코드에 의존하고 있다면, 특정 타겟에만 적용가능한, 즉 종속적이므로 여러 프록시가 공유할 수가 없다.
  • 프록시의 핵심 가치는 클라이언트의 요청을 대신 받아 처리하는 역할을 해야 하는데, 메서드를 선정하는 것은 성격이 다르기 때문에 궁극적으로는 분리하여야 하는 코드라는 점이다.

타겟 변경이나 메서드 선정하는 알고리즘이 변한다면 코드를 직접 변경해야하기 때문에, 확장엔 유연하지 못하고 변경엔 닫혀있지 않은 OCP를 지키지 않은 코드가 된다.

대신 스프링에선 포인트컷이라는 유연한 구조를 제공한다.

https://gunju-ko.github.io/

  • 프록시는 클라이언트로 요청을 받으면 포인트컷에게 '부가기능을 부여할 메서드인지' 확인을 요청한다.
  • 포인트컷이 이를 확인하면, 프록시는 MethodInterceptor의 어드바이스를 호출한다.
  • 어드바이스를 수행하는 중 타겟 메서드의 호출이 필요하면 proceed()를 호출한다.

MethodInvocation이 콜백이 되고 어드바이스가 템플릿이 되는 템플릿/콜백 패턴이 된다.

@Test
void pointcutAdvisor() {
    ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
    proxyFactoryBean.setTarget(new HelloTarget());

    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    pointcut.setMappedName("sayH*"); // sayH로 시작하는 모든 메서드를 선택한다.
    proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));
        // 어드바이스와 포인트컷을 하나로 묶는다. 하나로 묶은 것을 어드바이저라고 부른다.
        // 어드바이스 + 포인트컷 = 어드바이저

    Hello proxy = (Hello) proxyFactoryBean.getObject();
    assertThat(proxy.sayHello("DAEYEON")).is("HELLO DAEYEON");
    assertThat(proxy.sayHi("DAEYEON")).is("HI DAEYEON");
    assertThat(proxy.sayThankYou("DAEYEON")).is("Thank You DAEYEON");
        // sayH로 시작하지 않는 메서드는 어드바이스가 적용되지 않을 것이다.
}
public class TransactionAdvice implements MethodInterceptor { // 스프링의 어드바이스 인터페이스 구현
    PlatformTransactionManager transactionManager;

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

    public Object invoke(MethodInvocation invocation) throws Throwable {
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            Object ret = invocation.proceed(); 
            this.transactionManager.commit(status);
            return ret;
        } catch(RuntimeException e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}

기타 필요한 Bean 설정은 알아서 하면 된다.

  • transactionAdvice에 주입할 transactionManager
  • mappedName에 주입할 패턴 스트링
  • advice/pointcut 빈

6.5 스프링 AOP

비즈니스 로직에 반복적으로 등장해야 했던 트랜잭션 코드를 분리해내는 예제를 다루었다. 이 트랜잭션 코드를 분리한 뒤, 기존 코드에 영향을 주지 않도록 많은 과정을 거쳐왔다. 나중에 트랜잭션을 제거하더라도 당연히 영향을 받지 않는다.

자동 프록시 생성

타깃 오브젝트마다 내용이 거의 동일한 ProxyFactoryBean 설정정보를 추가해주어야 하는 문제가 있다. 이 중복을 어떻게 제거할 수 있을까?

다이나믹 프록시를 생각해보면, 특정 인터페이스를 구현한 클래스의 프록시 역할을 하는 클래스를 리플렉션을 이용해 동적으로 런타임에 만들어주었다.

타깃 인터페이스의 모든 메서드를 구현하는 프록시 클래스는 다이나믹 프록시 기술에 맡기고, 부가기능 코드는 다이나믹 프록시 생성 팩토리에 DI로 제공하는 방법을 생각할 수 있다. 반복적인 부분은 컨텍스트로, 변하는 부분은 전략으로 - 전략 패턴을 적용한 예로 생각할 수 있겠다.

반복적인 프록시 구현을 해결했다면, 반복적인 ProxyFactoryBean 설정 문제는 해결할 수 없을까?

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

포인트컷 심화

포인트컷은 어드바이스를 적용할 메서드를 확인하는 기능 뿐 아니라, 클래스 필터, 즉 대상 클래스인지 확인하는 기능도 가지고 있다. 앞서서 타겟 오브젝트가 명확했기 때문에 메서드 선별만 해주는 역할에 그쳤지만, 어드바이스가 적용될 타겟 메서드인지 확인해야 할 때도 사용할 수 있다.

public interface Pointcut{
        ClassFilter getClassFilter(); // 프록시 적용할 클래스인지 확인
        MethodMatcher getMethodMatcher(); // 어드바이스 적용할 메서드인지 확인
}
  1. 어드바이스가 적용될 수 있는 타겟 오브젝트인지 확인한 다음
  2. 타겟 오브젝트의 메서드를 선별하여 어드바이스를 적용한다.
@Test
void classNamePointcutAdvisor() {
    NameMatchMethodPointcut nameMatchMethodPointcut = new NameMatchMethodPointcut() {
                // nameMatchMethodPointcut은, Pointcut 인터페이스를 상속했지만
                // 실질적으로 메서드 선별 기능만을 담당하기 때문에 
                // getClassFilter()는 항상 true를 반환하는 있으나 마나한 구현이 된다.
                // 실질적인 클래스 필터를 구현하기 위해 Override한다.
        @Override
        public ClassFilter getClassFilter() {
            return new ClassFilter() {
                @Override
                public boolean matches(Class<?> clazz) {
                    return clazz.getSimpleName().startsWith("HelloT");
                }
            };
        }
    };
    nameMatchMethodPointcut.setMappedName("sayH*");

    checkAdviced(new HelloTarget(), nameMatchMethodPointcut, true);
    checkAdviced(new HelloWorld(), nameMatchMethodPointcut, false);
    checkAdviced(new HelloToby(), nameMatchMethodPointcut, true);

}

private void checkAdviced(Hello hello, NameMatchMethodPointcut nameMatchMethodPointcut, boolean adviced) {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(hello);
    pfBean.addAdvisor(new DefaultPointcutAdvisor(nameMatchMethodPointcut, new UppercaseAdvice()));
    Hello proxiedHello = (Hello) proxyFactoryBean.getObject();

    if (adviced) {
        assertThat(proxiedHello.sayHello("Daeyeon")).is("HELLO DAEYEON");
        assertThat(proxiedHello.sayHi("Daeyeon")).is("HI DAEYEON");
        assertThat(proxiedHello.sayThankYou("Daeyeon")).is("Thank You Daeyeon"); // 미적용
    } else {
        assertThat(proxiedHello.sayHello("Daeyeon")).is("Hello Daeyeon");
        assertThat(proxiedHello.sayHi("Daeyeon")).is("Hi Daeyeon");
        assertThat(proxiedHello.sayThankYou("Daeyeon")).is("Thank You Daeyeon");
                // 전체 미적용! ex) HelloWorld 클래스는 HelloT...로 시작하지 않기 때문
    }
}

HelloWorld는 클래스필터에서 이미 걸러지므로, 어드바이스의 부가 기능이 적용되지 않는다.

BeanPostProcessor는 인터페이스를 구현해서 만드는데, 빈 오브젝트로 만들어지고 난 이후에도 빈 오브젝트를 다시 가공할 수 있도록 해준다. 그 중 DefaultAdvisorAutoProxyCreator는 어드바이저를 이용한 프록시 생성기다. 스프링 컨테이너가 빈 오브젝트를 생성할 때 마다, BeanPostProcessor에 보내 후처리를 처리할 수 있도록 한다.

  • 프로퍼티를 강제로 수정하거나
  • 초기화 작업을 수행하거나
  • 빈 오브젝트 자체를 바꿔치기 할 수도 있다.
  • 스프링이 생성하는 빈 오브젝트의 일부를 프록시로 포장하고, 이 프록시를 빈으로 대신 등록할 수도 있다.

클래스 필터가 적용된 포인트 컷을 만들자.

public class NameMatchClassMethodPointcut extends NameMatchMethodPointcut {

    public void setMappedClassName(String mappedClassName) {
        this.setClassFilter(new SimpleClassFilter(mappedClassName));
    }
        // 모든 것을 허용해주는 클래스 필터를 대체할 수 있도록 setter 제공

    static class SimpleClassFilter implements ClassFilter {

        String mappedName;

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

        @Override
        public boolean matches(Class<?> clazz) {
            return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName());
                        // PatternMatchUtils.simpleMatch는 와일드카드를 포함한 패턴의 매칭을 돕는 메서드다.
                        // ex) "king*" -> "kingDonkatsu", "kingDaeyeon", ... 등등 허용
        }
    }
}

DefaultAdvisorAutoProxyCreator는 등록된 Bean 중 Advisor 인터페이스를 구현한 것을 모두 찾고, 어드바이저의 포인트컷을 적용하면서 프록시 적용 타겟 오브젝트와 메서드를 선정한다. 만약 어떤 빈 클래스가 프록시 선정 대상이라면, 원래 빈 오브젝트와 바꿔치기한다. 그래서 Bean으로 등록된 타겟 오브젝트 대신 프록시를 통해서 접근하도록 바뀐다.

<bean class="org.sapringframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" />
// 별도의 ID가 필요없다.
// 이를 등록함으로써  transactionAdvisor를 명시적으로 DI하는 대신, 
// DefaultAdvisorAutoProxyCreator가 Advisor 인터페이스를 구현한 것을 찾아서 
// 프록시 대상 선정하고 Bean을 바꿔치기하게 될 것이다.

<bean id="transactionPointcut" class="{classpath}.proxy.NameMatchClassMethodPointcut">
    <property name="mappedClassName" value="*ServiceImpl"/>
    <property name="mappedName" value="upgrade*"/>
</bean>
// 앞에서 직접 만든 포인트 컷에 선정할 클래스와 메서드 이름의 패턴을 주입한다.

<bean id="userService" class="{classpath}.service.UserServiceImpl">
    <property name="userDao" ref="userDao"/>
    <property name="mailSender" ref="mailSender"/>
</bean>
// UserService는 이제 어드바이저를 주입하거나 할 필요가 없이 필요한 Bean만 DI 받도록 한다.
// 즉 비즈니스 로직만 남길 수 있다.

<bean id="transactionAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor">
    <property name="advice" ref="transactionAdvice"/>
    <property name="pointcut" ref="transactionPointcut"/>
</bean>

<bean id="transactionAdvice" class="{classpath}.proxy.TransactionAdvice">
    <property name="transactionManager" ref="transactionManager"/>
</bean>

테스트 코드 작성하기

트랜잭션을 테스트하고 싶다면, 예외적인 상황을 스트에 구현해야만 했다. 롤백을 일으키는 상황에 대해서는 수동 DI로 구성을 바꾸었지만, 프록시 생성을 스프링 컨테이너에 종속적인 방법을 택했기 때문에 수동 DI는 더 이상 작동하지 않을 것이다.

그래서 TestUserService 클래스를 직접 빈으로 등록해야 하는데 2가지 문제가 있다.

  1. TestUserService는 UserService 내부에 정의된 static 클래스이다.
  2. 이 클래스는 포인트 컷의 패턴(*ServiceImpl)에 맞지 않아 포인트 컷의 클래스 필터에 걸리지 않는다.

static 클래스 자체는 Bean으로 등록하는데 문제가 없지만, 조금 특별하게 등록하여야 한다.

<bean id="testUserService" class="springbook.service.UserServiceTest$TestUserServiceImpl"
     parent="userService"/> 
// parent attribute => 다른 빈 설정 내용을 상속받을 수 있다. DI를 위한 프로퍼티 등을 상속받고,
// Override 또한 가능하다.
// static 클래스와 부모 클래스 사이의 $를 확인하자.

그 다음 이름 또한 TestUserServiceImpl이라고 변경한다.

static class TestUserServiceImpl extends UserServiceImpl {

    private String id = "madnite1";
        // 테스트 중 예외를 발생시킬 테스트 픽스쳐를 클래스에 직접 작성한다.
    @Override
    protected void upgradeLevel(User user) {
        if (user.getId().equals(this.id)) {
            throw new TestUserServiceException();
        }
        super.upgradeLevel(user);
    }
}
public class UserServiceTest{
      @Autowired UserService userService;
      @Autowired UserService testUserService; // 타입이 같은 Bean이 2개이면, 필드 이름을 기준으로 id를 찾아 주입받는다.
      // …

        @Test
      public void upgradeAllOrNothing(){
            userDao.deleteAll();
            for(User user: users) userDao.add(user);

            try{
                  this.testUserService.upgradeLevels();
                  fail(“TestUserServiceException expected”);
            }
            catch(TestUserServiceException e){}
            checkLevelUpgraded(user.get(1), false);
      }
}

테스트가 잘 돌아가더라도 의심이 있을 수 있으니 눈으로 확인하자.

  1. 트랜잭션이 필요한 빈에 진짜 트랜잭션 부가기능이 적용되었는가?
  2. 대상 빈 이외에도 아무 빈에나 부가기능이 적용되지는 않았는가

빈의 이름을 바꾸거나 포인트컷의 메서드 선정 패턴을 바꾸어 테스트를 진행해보면 위 2가지를 모두 검증할 수 있다.

또 다른 방법으로는 Bean을 가져왔을 때 해당 Bean의 타입을 확인하는 것이다. 만약 프록시로 바꿔치기 되었다면 java.lang.reflect.Proxy.class 타입일 것이다.

포인트컷 표현식

클래스 필터와 메서드 매처 오브젝트로 비교해서 대상 클래스와 메서드를 선정하는 방법 대신, 좀 더 세밀한 기준으로 선정, 즉 메서드 시그니쳐에 포함되는 클래스 경로, 클래스 메서드의 이름, 파라미터, 린턴 값, 애너테이션, 구현한 인터페이스, 상속한 클래스 등의 정보를 가지고 비교할 ㅅ 있을 것이다.

리플렉션 API로 세밀한 구현이 가능하지만, 어렵고 번거롭게 작성해야 한다. 그 대신 포인트컷 표현식을 이용해 간편하게 작성할 수 있다.

AspectJExpressionPointCut 클래스를 이용하면 포인트컷표현식을 이용해 클래스와 메서드의 선정 알고리즘을 쉽게 작성할 수 있다. 정규표현식 처럼 복잡한 조건을 짧은 문자열로 지정할 수 있다. AspectJ라는 프레임워크에서 제공하는 표현식을 확장한 것으로 AspectJ 포인트컷 표현식이라고도 부른다.

execution()

AspectJ 포인트컷 표현식은 포인트컷 지시자를 이용해 작성한다. 가장 대표적인 것은 execution() 지시자이다.

execution([public|private|protected|default/*접근제한자 패턴*/] 
                    return type 패턴 
                    [패키지+클래스 패턴]
          method name 패턴
                    (파라미터의 타입 패턴 | "..", ...) 
                    [throws 예외 패턴]
)
// []로 감싸져 있는 것은 생략이 가능하다.

접근제한자

public, protected, private등의 접근제한자를 의미한다. 생략 시 접근제한자에 대한 조건을 검토하지 않는다.

return type

해당 메서드의 리턴 타입을 의미한다. *로 모든 타입을 지정할 수도 있다. 필수

패키지와 클래스 패턴

패키지와 클래스 이름의 경로를 지정하는 패턴이며, 생략 가능하다. 지정 시 '.'으로 계층을 구분한다. 패키지 이름이나 클래스 이름에 역시 와일드카드인 '*'로 지정 가능. '..' 사용 시 여러 패키지 선택 가능

method name

메서드 이름 지정하는 부분이며 필수항목이다.

파라미터 타입 패턴

(int, int)와 같이 파라미터 이름을 제외한 타입 패턴을 기입한다. 파라미터가 없는 메서드 지정시 ()와 같이 작성한다. '..'를 넣으면 타입과 개수에 상관없이 모두 허용한다는 의미이며, 일부를 작성하고 그 뒤에 '..'을 붙이면 뒷부분의 파라미터 조건만 생략할 수도 있다. 필수항목.

예외 패턴

throws java.lang.RuntimeException과 같이 작성하며 생략 가능하다.

포인트컷 테스트용 클래스다.

class Target implements TargetInterface {
    public void hello() {}
    public void hello(String a) {}
    public int minus(int a, int b) throws RuntimeException { return 0; }
    public int plus(int a, int b) { return 0; }
    public void method() {}
}

class Bean {
    public void method() throws RuntimeException {}
}
@Test
void methodSignaturePointcut() throws SecurityException, NoSuchMethodException {

    AspectJExpressionPointcut aspectJExpressionPointcut = new AspectJExpressionPointcut();
    aspectJExpressionPointcut.setExpression(
                "execution(public int springbook.learningtest.PointcutTest$Target.minus(int, int) throws java.lang.RuntimeException)"
        );
        // AspectJ 포인트컷 표현식을 작성하면 getMethodMatcher() getClassFilter() 가 알아서 생성된다.

    assertThat(aspectJExpressionPointcut.getClassFilter().matches(Target.class) &&
            aspectJExpressionPointcut.getMethodMatcher().matches(
                                Target.class.getMethod("minus", int.class, int.class), null), is(true));

    assertThat(aspectJExpressionPointcut.getClassFilter().matches(Target.class) &&
            aspectJExpressionPointcut.getMethodMatcher().matches(
                                Target.class.getMethod("plus", int.class, int.class), null), is(false));

    assertThat(aspectJExpressionPointcut.getClassFilter().matches(Bean.class) &&
            aspectJExpressionPointcut.getMethodMatcher().matches(
                                Target.class.getMethod("method", int.class, int.class), null), is(false));
}

포인트컷 표현식은 execution() 외에도 스프링 bean의 이름으로 지정하는 bean(), 특정 애너테이션이 적용된 @annotation() 등이 있다.

한 가지 주의해야 할 점은, 포인트컷 표현식의 클래스 이름은 클래스 이름 그 자체보다는 타입패턴이다. 무슨 의미냐면, TestUserService의 경우, 클래스 이름인 TestUserService, 슈퍼클래스인 UserSErviceImpl, 구현한 인터페이스인 UserService 세 가지의 타입을 가지고 있기 떄문이다.

AOP 살펴보기

부가기능의 모듈화는 전통적인 객체지향에서 성취하기 어려웠기에, 자연어 대신 기계어로 사고하는 컴퓨터과학의 아버지들이 연구하여 성취하였다. 객체지향 패러다임과 부가기능은 구분되는 특성이 있다고 보았기에 부가기능 모듈을 Aspect라고 부르게 되었다.

영어 단어 의미 그 자체로, 어플리케이션을 구성하는 측면과 같다. 기존의 객체지향 테두리 안에서 부가기능을 적용하려면 반복적으로, 지저분한 방식으로 작성해야하며 SRP와 OCP를 해치고 테스트하기 어려운 코드를 만들기 쉽상이었다. 그 대신 핵심코드와 관련이 없는 부가기능을 독립적인 Aspect로 분리해 내었다.

일반적으로 클래스 다이어그램과 같은 그림을 그릴 때 2차원적인 평면으로 표현하는데, 부가기능을 OOP 원칙을 준수하며 깔끔하게 작성하기 어려웠었지만, 3차원 다면체 구조로 가져가며 부가기능을 핵심기능과 다른 차원에 위치하도록 작성할 수 있었다.

https://xlffm3.github.io/

어떤 측면에서 보느냐에 따라 어떤 부가기능이 어떤 핵심기능과 어우러져 동작하는지 달라지게 된다. 어떻게보면 다이나믹한(런타임) 관점에서 Aspect가 결정될 것이며, 핵심기능과 부가기능은 서로 독립적으로 존재할 수 있게 되었다.

이렇게 부가 기능을 핵심 기능에서 분리하여 Aspect라는 모듈로 설계하고 개발하는 방법을 AOP라고 부른다. 핵심 기능은 OOP로 구현하고, 부가 기능은 AOP 원칙 아래서 구현함으로써 핵심 기능이 OOP 원칙을 훼손하지 않도록 보조한다.

스프링 AOP vs AspectJ AOP

스프링은 IoC/DI 컨테이너 + 다이나믹 프록시 + 데코레이터/프록시 + 자동 프록시 생성기법 + 빈 오브젝트 후처리 등 코드레벨의 기술과 패턴 등을 이용하여 구현한 프록시 방식의 AOP이다. 자바 JDK와 스프링 컨테이너만을 이용하여 컴팩트하게 이용할 수 있다는 장점이 있다.

반면에 AspectJ AOP는 부가 기능이 적용될 타겟 오브젝트의 컴파일된 클래스 파일을 수정하거나, 클래스가 JVM에 로딩되는 시점의 바이트코드를 조작하여 AOP를 구현한다.

  • 스프링 컨테이너와 같은 기술에 의존하지 않아도 된다.
  • 프록시 방식보다 훨씬 유연한 AOP가 가능하다. 실제 메서드 호출을 가로채 부가기능을 부여하고 위임하는 것이 다인 스프링 AOP에 비해, 오브젝트 생성, 필드 값 읽기/쓰기, 스태틱 초기화 등 다양한 기능 부여가 가능하다.
  • 반면에 JVM의 옵션을 변경하거나 컴파일러 설정이 필요하거나 클래스 로더의 변경이 필요한 점 등 복잡한 설정을 요구하는 문제가 있다.

AOP의 용어 살펴보기

타겟

부가기능을 부여할 오브젝트, 혹은 다른 부가 기능을 제공하는 프록시 오브젝트를 의미한다.

어드바이스

타겟에게 제공할 부가기능을 담은 모듈이다.

조인 포인트

어드바이스가 적용될 수 있는 위치를 말한다. 스프링 프록시 AOP에서는 메서드 실행단계에 제한된다.

스프링 프록시 AOP에서는 오로지 메서드의 실행시점에 국한되지만, 다른 AOP에서는 메서드의 원하는 지점 그 어디든 조인 포인트가 될 수 있다.

 

포인트컷

어드바이스를 적용할 조인 포인트를 선별하는 작업 그 자체 혹은 그 기능을 정의한 모듈을 의미한다.

프록시

클라이언트와 타겟 사이에 존재하면서 부가 기능을 부여하는 오브젝트이다. 프록시는 클라이언트의 메서드 호출을 가로채어 타겟에 부가기능을 부여한다.

어드바이저

포인트컷 + 어드바이스 오브젝트. 어디에 무엇을 적용할지 정보를 가지고 있기 때문에 AOP의 가장 기본이 되는 단위이다. 스프링 AOP에서만 사용되는 용어다.

애스팩트

OOP의 클래스가 기본 모듈이라면, AOP의 애스팩트가 기본 모듈이 된다. 1개 이상의 포인트컷 + 어드바이스 조합으로 만들어지며, 일반적으론 싱글톤 형태의 오브젝트로 존재한다.

AOP 네임스페이스

AOP를 사용하기 위해선 최소 네 가지 빈을 등록해야 한다.

  • 자동 프록시 생성기
  • 어드바이스
  • 포인트컷
  • 어드바이저

부가 기능을 구현하는 어드바이스를 제외하면 나머지 세 가지는 스프링에서 제공하는 빈을 등록하면 된다. 특별하게 신경 쓸 것이 없으니 AOP를 사용하게 되면 기계적으로 반복하여 등록하는 빈을 간편하게 등록할 수 있는 aop 스키마를 제공한다. aop 스키마를 사용하기 위해선 설정 파일에 aop 네임스페이스 선언이 필요하다.

<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    **xmlns:aop="http://www.springframework.org/schema/aop"**
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
        http://www.springframework.org/schema/beans/spring-beans.xsd
        **http://www.springframework.org/schema/aop
        http://www.springframework.org/schema/aop/spring-aop-3.0.xsd**">

        // aop 스키마는 aop 네임스페이스를 가지므로 aop 접두어를 사용한다.
    <aop:config> // AOP 설정을 담는 부모 태그며 필요에 따라 AspectJAdvisorAutoProxyCreator를 빈으로 등록한다.
        <aop:pointcut id="transactionPointCut" 
                                            expression="execution(* *..*ServiceImpl.upgrade*(..))" />
                                            // 정의한 표현식을 프로퍼티로 가지는 AspectJExpressionPointcut을 빈으로 등록한다.
        <aop:advisor advice-ref="transactionAdvice" pointcut-ref="transactionPointCut"/>
                // advice와 pointcut의 ref를 프로퍼티로 갖는 DefaultBeanFactoryPointcutAdvisor를 빈으로 등록한다.
    </aop:config>
</beans>

직접 작성한 어드바이저를 제외한 나머지 AOP 관련 Bean들은 aop 전용 태그를 이용하여 작성하면 코드의 양도 줄고 readability를 증가시킬 수 있다.

AspectJ 포인트컷 표현식을 활용할 경우, aop 스키마 태그를 사용하여 구현할 때 포인트컷을 빈으로 등록하고 어드바이저에 참조하도록 만드는 대신, 어드바이저 태그에 결합하여 직접 선언할 수도 있다. 물론 해당 포인트컷이 여러 어드바이저에서 참조될 필요가 있다면 이렇게 해선 안된다.

<aop:config>
    <aop:advisor advice-ref="transactionAdvice" pointcut="execution(* *..*ServiceImpl.upgrade*(..))"/>
</aop:config>

6.6 트랜잭션 속성

앞에서 살펴봤었던 DefaultTransactionDefinition 오브젝트는 TransactionDefinition 인터페이스를 구현하고 있고, 트랜잭션 동작 방식에 영향을 줄 수 있는 네 가지 속성이 정의되어 있다.

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;
    }
    //------------------트랜잭션 경계설정 끝
}
  • 트랜잭션 전파(Transaction Propagation)
  • 격리수준(Isolation level)
  • 제한시간(Timeout)
  • 읽기 전용(Read only)

트랜잭션 전파(Transaction Propagation)

트랜잭션 전파는 트랜잭션의 경계설정 코드를 수행하는 시점에, 이미 진행중인 다른 트랜잭션의 존재에 따라서 새 트랜잭션이 어떻게 동작할지 결정하는 방법을 말한다.

A라는 루틴이 트랜잭션 경계설정을 하고 트랜잭션이 진행중일 때, A 내부의 B 메서드가 호출되고 B 메서드 역시 트랜잭션을 시작한다면?

  • B는 A의 트랜잭션에 참여한다. 즉 하나의 트랜잭션 내에서 A와 B 로직을 모두 수행한다.
  • A와 B의 트랜잭션을 개별적으로 생성한다.
트랜잭션 시작
// logics...
B.method();
// logics...
트랜잭션 종료

method(){
        트랜잭션 시작...
        트랜잭션 종료..
}

PROPAGATION_REQUIRED

진행중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있다면 이에 참여한다.

PROPAGATION_REQUIRES_NEW

항상 새로은 트랜잭션을 시작한다.

PROPAGATION_NOT_SUPPORTED

트랜잭션 없이 동작하도록 한다. 진행중인 트랜잭션이 있더라도 무시한다. 트랜잭션을 안 쓸거면 뭐하러 경계설정을 하겠나 싶겠지만 AOP를 통해 메서드들에 일괄적으로 적용하는 상황에서 특정 메서드만을 트랜잭션에서 제외시키고 싶을 때 사용한다.

작은 의문이지만 beginTransaction이 아닌 getTransaction인 이유도 이런 트랜잭션 전파 옵션 입장에서는 항상 begin하지 않고 때론 기존의 트랜잭션에 참여할 수도 있기 때문이다.

격리 수준

모든 DB 트랜잭션은 격리수준을 가지고 있어야 한다. 이상적으론 모든 트랜잭션이 순차적으로 진행될 수 있으면 좋겠지만, 성능의 문제가 있을 수 있다. 가능한 많은 트랜잭션을 수행할 수 있으면서 동시에 문제가 생기지 않도록 조정이 필요하다.

참고 : UNDO와 REDO

트랜잭션은 상황에 따라 복구해야 할 일이 생긴다.

REDO는 사용자의 명령을 따라가며 마지막 확인된 체크포인트로부터 순차적으로 실행하여 복구한다면, UNDO는 사용자의 명령을 역순으로 실행하며 복구합니다.

즉 미완의 완성 vs 없던 것처럼 감쪽같이 되돌리기로 정리할 수 있습니다.

모든 SQL의 수행은 UNDO와 REDO의 로그로 저장이 됩니다.

참고 : 격리 수준 레벨 정리

READ UNCOMMITTED

어떤 트랜잭션에서 변경된 내용이, 다른 트랜잭션에서 commit이나 rollback과 관계없이 보여진다.

예를 들어서 A 트랜잭션에서 특정 row의 특정 column의 값을 변경하였지만 커밋하지 않은 상태에서, B 트랜잭션이 A 트랜잭션에서 변경한 내용을 읽어들일 수 있는 레벨이다. 이를 Dirty Read라고 하며 데이터 정합성의 문제가 생긴다.

READ COMMITTED

어떤 트랜잭션에서 변경된 내용은 commit되어야만 다른 트랜잭션에서 변경된 내용을 읽을 수 있다. 즉 UNDO 영역의 값을 불러들이며, 가장 널리 쓰이는 격리 수준이다. 그러나 NON-REPEATABLE READ 부정합 문제가 존재할 수 있다.

경기 정보를 담고 있는 DB가 있다고 가정하자.

  • A 트랜잭션에서 4번 경기의 현재 스코어를 조회했을 때 0:0 이었다.
  • B 트랜잭션에서 4번 경기의 현재 스코어를 1:0으로 업데이트 하고 커밋했다.
  • A 트랜잭션이 다시 4번 경기의 스코어를 조회했을 때 1:0으로 조회된다.

하나의 트랜잭션 내에서 SELECT를 수행하였는데 다른 결과가 나타나는 것을 NON-REPEATABLE READ 부정합이라고 한다.

금융정보와 같은 민감한 정보를 다룰 때 문제가 있을 수 있다.

REPEATABLE READ

어떤 트랜잭션에서 읽어들이는 값은 해당 트랜잭션이 시작되기 전에 commit된 값에 한정한다. InnoDB의 트랜잭션은 순차적으로 증가하는 번호를 부여받으며, 자신보다 낮은 번호를 가진 트랜잭션에서 commit된 값만을 보게 된다.

트랜잭션 별로 다양한 DB의 상태 버젼을 관리해야 하는 오버헤드가 있지만, 일반적으로는 트랜잭션이 금방금방 끝나기 때문에 큰 문제는 되지 않는다고 한다.

UPDATE 부정합이 발생할 수 있다.

START TRANSACTION; -- transaction id : 1
SELECT * FROM Member WHERE name='daebalprime';

    START TRANSACTION; -- transaction id : 2
    SELECT * FROM Member WHERE name = 'daebalprime';
    UPDATE Member SET name = 'daeyeon' WHERE name = 'daebalprime';
    COMMIT;

UPDATE Member SET name = 'supersexyguy' WHERE name = 'daebalrpime'; -- 0 row(s) affected
COMMIT;
  • 2번 트랜잭션에서 daebalprime 이름을 가진 사람의 이름을 daeyeon으로 변경한다.
  • 1번 트랜잭션에서 정보를 일관되게 읽을 수 있도록 2번 트랜잭션에서 UPDATE를 수행할 때 name = 'daebalprime'의 내용을 언두로그에 남긴다.
  • 맨 아래의 UPDATE 문을 수행할 때, DB에 존재하는 실제 데이터, 즉 레코드 데이터의 해당 row에 잠금이 필요하지만, name = 'daebalprime'의 경우 UNDO 로그에 있고, 이 영역에 대해선 쓰기 잠금이 불가능하다.
  • 결국 1번 트랜잭션이 바라보는 name = 'daebalprime'에 대해 쓰기 잠금을 얻지 못하여 아무런 변화도 일어나지 않는다.

SERIALIZABLE

가장 강한 격리 수준이며 읽기까지 잠금을 관리한다. 가장 동시성이 떨어지며 성능 이슈가 동반된다. 데이터 정합성만큼은 확실하게 관리할 수 있다.

일반적으로 DB에 설정되어 있으며, JDBC 드라이버나 DataSource 등에서 재설정이 가능하며 트랜잭션 단위로도 수준 설정이 가능하다. DefaultTransactionDefinition의 기본 설정 격리 수준은 ISOLATION_DEFAULT, 즉 DataSource에 설정되어 있는 격리 수준을 그대로 따른다는 의미이다.

제한시간(Timeout)

트랜잭션을 수행하는 타임아웃을 설정한다. DefaultTransactionDefinition의 기본 설정은 무제한이다.

읽기 전용(Read only)

트랜잭션 내에서 데이터를 쓰지 못하도록 막는다. 상황과 기술에 따라서 성능 향상을 볼 수도 있다.

만약 트랜잭션 정의를 적용하고 싶다면, TransactionDefinition 오브젝트를 DI 받아서 TransactionAdvice가 사용하도록 하면 되지만, 모든 트랜잭션의 속성이 일괄적으로 바뀌는 문제가 있다. 원하는 메서드만 개별적으로 트랜잭션을 적용할 수 있도록 해보자.

TransactionInterceptor

스프링에서 제공하는 트랜잭션 경계설정 어드바이스로 사용할 수 있도록 TransactionInterceptor를 제공한다.

프로퍼티

  • PlatformTransactionManager
  • Properties 타입의 transactionAttributes
    • 네 가지 기본 항목을 정의한 TransactionDefinition 인터페이스 + rollbackOn() 메서드를 가지고 있는 TransactionAttribute 인터페이스
    • rollbackOn() 메서드는 롤백을 수행할 예외상황을 결정하는 메서드다.
        // ...
    TransactionStatus status = this.transactionManager
                        .getTransaction(new DefaultTransactionDefinition());
                        // 트랜잭션 정의
        //...
    } catch (Exception e) { // 어떤 예외가 발생할 때 롤백을 처리할 지
        //...
    }
        //...

위의 코드에서 나타나듯이 트랜잭션 부가기능의 동작방식은 트랜잭션 정의 4가지 + 롤백이 수행될 예외를 통해 변경된다.

기존 코드의 문제는, 모든 종류의 예외에 대해서 트랜잭션을 롤백하도록 해야할까? 물어보면 아니다. 몇몇 비즈니스 로직상의 문제를 정의한 체크 예외의 경우 DB 트랜잭션은 커밋시켜야 하기 때문이다.

TransactionInterceptor는 2가지의 예외 처리 방식을 제공한다.

  • 런타임 예외의 경우 트랜잭션은 롤백된다.
  • 체크 예외를 던지는 경우 비즈니스 로직에 따라서 개발자가 의도한 예외라고 해석하고 트랜잭션을 커밋해버린다.

그리고 위 2가지 상황에 부합하지 않는 예외에 대해서 rollbackOn() 속성을 통해 특정 체크 예외의 경우에도 롤백시키거나, 그 반대도 가능하다.

TransactionAttribute를 일종의 Map 타입인 Properties 타입으로 전달 받는데, 그 이유는 메서드 패턴에 따라 다른 트랜잭션 속성을 부여할 수 있도록 하기 위함이다.

(Properties) transactionAttributes

메서드 패턴을 키로, 트랜잭션 속성을 값으로 갖는다.

메서드 패턴은 다음과 같이 정의한다.

PROPAGATION_NAME, ISOLATION_NAME, readOnly, timeout_NNNN, -Exception1, +Exception2
  • PROPAGATION_NAME : 필수항목, PROPAGATION_ 으로 시작
  • ISOLATION_NAME : 생략가능, ISOLATION_으로 시작
  • readOnly : 생략가능, 읽기전용
  • timeout_NNNN : 생략가능, 제한시간 지정. NNNN은 초단위로 지정
  • -Exception1 : 한 개 이상 등록가능, 체크 예외중 롤백 대상으로 추가할 것
  • +Exception2 : 한 개 이상 등록가능, 런타임이지만 롤백되지 않을 예외 지정

생략 시에는 DefaultTransactionDefiniton에 정의된 속성이 부여된다. 순서는 상관없으며 하나의 문자열로 모든 속성을 지정한다.

        <bean id="transactionAdvice" 
                    class="org.springframework.transaction.interceptor.TransactionInterceptor">
        <property name="transactionManager" ref="transactionManager"/>
        <property name="transactionAttributes">
            <props>
                <prop key="get*">PROPAGATION_REQUIRED,readOnly,timeout_30</prop>
                <prop key="upgrade*">PROPAGATION_REQUIRES_NEW,ISOLATION_SERIALIZABLE</prop>
                <prop key="*">PROPAGATION_REQUIRED</prop>
            </props>
        </property>
    </bean>

주의할 점은, 이미 readonly가 아닌 트랜잭션이 시작된 상태에서 새로운 readonly 트랜잭션이 참여할 경우 무시된다.

readonly나 timeout의 경우 트랜잭션이 처음 시작될 때에만 적용이 된다. 그 외의 경우에는 진행중인 트랜잭션 속성을 따른다.

tx 네임스페이스 이용하기

tx 스키마의 전용 태그로 정의할 수 있다. 컨테이너에 의해 자주사용되는 기반 기술 설정의 한 가지 종류이기 때문이다.

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
                            https://www.springframework.org/schema/beans/spring-beans.xsd
                            http://www.springframework.org/schema/aop
                            http://www.springframework.org/schema/aop/spring-aop.xsd
                            http://www.springframework.org/schema/tx
                            http://www.springframework.org/schema/tx/spring-tx.xsd">

        // ...
        // advice 태그에 의해 TransactionInterceptor 빈이 등록됨
    <tx:advice id="transactionAdvice", transaction-manager="transactionManager">
                // 만약 transaction-manager 빈 아이디가 transactionManager이면 생략 가능
        <tx:attributes>
            <tx:method name="get*" /*... 각종 속성들 ...*/ />
                        <tx:method name="upgrade*" propagation="..." isolation="SERIALIZABLE"/>
                        // 오타 발생 시 xml 유효성 검사 선에서 알려줌.
            <tx:method name="*" />
        </tx:attributes>
    </tx:advice>

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

  1. 트랜잭션 포인트컷 표현식은 타입 패턴이나 빈 이름을 이용한다
    • 트랜잭션을 적용할 타깃 클래스의 메서드는 모두 트랜잭션 적용 후보가 되어야 한다. 세밀하게 메서드 단위의 선정을 하는 대신.
    • 쓰기 작업이 없고 단순한 읽기 작업만 하는 메서드도 트랜잭션을 적용한다.
    • execution() 방식의 포인트컷 대신 bean() 표현식을 사용하는 것도 고려해보자.
  2. 공통된 메서드 이름규칙을 통해 최소한의 트랜잭션 어드바이스와 속성을 정의한다.
    • get, find와 같이 조회전용 메서드의 접두어를 정해두기
  3. 프록시 방식 AOP는 같은 타깃 오브젝트 내의 메서드를 호출할 때는 적용되지 않는다.
    • 다르게 말해 타겟 오브젝트가 자기 자신의 메서드를 호출할 때는 부가기능의 적용이 일어나지 않는다.
    • 이를 해결하기 위해선 스프링 API를 이용하는 방법과 AspectJ를 이용하는 방법이 있다.

트랜잭션 속성 적용의 원칙과 전략 정리하기

트랜잭션 경계설정 일원화

트랜잭션 경계설정 부가기능을 다양한 계층에서 중구난방으로 적용하는 것은 좋지 않다. 일반적으로 서비스 계층 오브젝트 메서드가 가장 적절하다.

서비스 계층을 트랜잭션 경계로 정했다면, DAO에 직접 접근하는 일은 지양하도록 한다.

서비스 빈에 적용되는포인트컷 표현식 등록하기

포인트컷 표현식을 비즈니스 로직의 서비스 빈에 적용되도록 작성한다.

트랜잭션 속성을 가진 트랜잭션 어드바이스 등록

바로 위에서 다루었던 tx 스키마를 이용한 트랜잭션 어드바이스 정의 방법을 이용한다. TransactionInterceptor를 tx:advice 태그를 이용해 등록하고, attributes를 잘 정의하도록 한다.

그리고 테스트를 수행한다.

6.7 애너테이션 기반의 트랜잭션 속성과 포인트컷

클래스나 메서드에 따라 세밀하게 정의된 트랜잭션 속성을 적용하고 싶을 경우 애너테이션을 지정하는 방법이 있다.

@Transactional

DOCS 참조하기

이 애너테이션의 타겟은 메서드와 타입이다. 즉

  • 메서드
  • 클래스
  • 클래스
  • 인터페이스

에 적용할 수 있다.

이 애너테이션이 부착되어 있으면 TransactionAttributeSourcePointcut 포인트컷이 부착된 모든 오브젝트를 타겟 오브젝트로 알아서 인식한다.

애너테이션을 부착하는 것은 트랜잭션 속성을 정의함과 동시에 포인트컷의 자동등록에도 사용된다.

동작 방식

  • TransactionInterceptor는 패턴을 통해 부여되는 트랜잭션 속성정보를 무시하고,
  • TransactionAttributeSourcePointcut을 통해 타겟 오브젝트를 선정한다.
  • 애너테이션에서 트랜잭션 속성을 가져오는 AnnotationTransactionAttributeSource를 사용한다.

대체 정책

@Transactional을 이용하면 메서드마다 유연하기 속성 제어는 쉬워지겠지만 코드가 지저분해지는 단점이 있다. 동일한 속성 정보를 가진 애너테이션을 반복적으로 부여해주어야 할 필요도 있는데, @Transactional을 적용할 때 fallback 정책을 통해 이를 줄일 수 있다.

  • 타겟 메서드
  • 타겟 클래스
  • 선언 메서드 (인터페이스 메서드)
  • 선언 타입 (인터페이스 그 자체)

순서를 따라 @Transactional이 적용되었는지 확인하면서 가장 먼저 발견되는 속성정보를 이용하도록 한다. 결국 이 순서를 통해 찾지 못한다면 트랜잭션 적용 대상이 아니라고 판단한다.

주의할 점

  • 프록시 방식의 AOP가 아닌 방식으로 트랜잭션을 적용하면 인터페이스에 적용한 @Transactional은 무시된다. 확신을 위해 타겟 클래스와 타겟 메서드에 적용하도록 하자.
  • 타입 레벨에 먼저 정의하고 특별한 케이스에 대해서만 메서드 레벨에 다시 애너테이션을 부여하도록 작성하는게 좋다. 대체 정책을 이용하여 중복을 줄이자.
  • <tx:annotation-driven /> 정보를 등록하여야 사용가능하다.
  • 트랜잭션 적용 여부가 확인하기 쉽지 않기 때문에 사용 정책을 확실하게 두어야 지저분해지지 않는다.

6.8 트랜잭션 지원 테스트

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

UserService.add()는 트랜잭션 속성이 디폴트인 REQUIRED로 지정되어 있으며, 이미 진행중인 트랜잭션에 합류하거나 만약 트랜잭션이 없으면 독자적인 트랜잭션을 시작한다.

만약 하루 단위의 이벤트 신청내역을 모두 가져와 DB에 등록하고 조작하는 메서드가 다른 서비스의 메서드로 구현되어있다고 가정했을 때, 하루 치 작업을 모두 가져오고 조작하고 DB에 반영하는 것이 하나의 작업단위가 된다. 그 중에는 UserService.add()를 호출하기도 하지만 트랜잭션 전파를 이용한 덕에 하루치 작업을 담당하는 메서드 내부에 add() 메서드를 복사 붙여넣기 할 필요가 없어졌다.

AOP를 이용해 코드 외부에서 트랜잭션의 기능을 부여하고 속성을 지정하는 것을 선언적 트랜잭션(declarative transaction)이라고 한다.

TransactionTemplate나 개별 기술의 트랜잭션 API를 이용해 코드안에 직접 속성과 기능을 지정하는 방법을 프로그램에 의한 트랜잭션(programmatic transaction) 이라 부른다.

특별한 경우가 아니면 선언적 트랜잭션을 사용하는 것이 좋다.

트랜잭션의 전파와 유연한 개발이 가능한 배경에는 AOP스프링의 트랜잭션 추상화가 있다.

AOP 덕분에 부가기능을 쉽게 적용할 수 있었으며, 추상화 덕에 데이터 엑세스 기술에 무관하게 동일 코드로 기능을 부여할 수 있게 되었다.

트랜잭션 추상화 - 매니저와 동기화

트랜잭션 매니저

PlatformTransactionManager 인터페이스를 구현한 트랜잭션 매니저를 통해 트랜잭션의 구현 기술과 관계없이 일관적으로 동일 코드로 트랜잭션 제어가 가능하다.

트랜잭션 동기화

트랜잭션 정보를 저장소에 보관했다가 DAO에서 공유할 수 있다. 이 저장소에 진행중인 트랜잭션 정보가 있기 때문에 트랜잭션 전파를 위해 이 저장소를 확인하여 진행중인 트랜잭션 정보가 있는지 확인하고 참여를 할 수 있도록 한다.

프로그램에 의한 트랜잭션의 예외

일반적으로는 선언적 트랜잭션을 통해 선언하는 것이 편하지만, 테스트는 예외다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "/test-applicationContext.xml")
public class UserServiceTest {
        @Autowired
        PlatformTransactionManager transactionManager;
        // 트랜잭션 매니저도 @Autowired로 가져올 수 있다.

        // List<User> users = new ArrayList<>();
        // users에 element 등록하는 코드 ...

        @Test
        public void transactionSync(){
                userService.deleteAll();
                userService.add(users.get(0));
                userService.add(users.get(1));
                // 각 메서드 호출마다 트랜잭션이 발생한다.
        }
}

위 테스트는 문제없이 진행되지만, 각 메서드에 트랜잭션이 적용되어 있어 3개의 트랜잭션이 생성된다. 이를 하나의 트랜잭션에서 돌아가도록 할 수 없을까?

메서드의 트랜잭션 설정이 모두 REQUIRED이기 때문에, 이 메서드가 호출되기 전 트랜잭션이 시작된다면 가능하다. userService의 메서드가 시작되기 전에 아래 코드를 추가하자.

        @Test
        public void transactionSync(){
                DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();
                txDefinition.setReadOnly(true); // 트랜잭션을 읽기전용으로 설정한다.
                TransactionStatus txStatus = transactionManager.getTransaction(txDefinition);
                // 트랜잭션 매니저에게 트랜잭션을 요청한다. 기존에 진행되는 트랜잭션이 없으니 새 트랜잭션을 시작한다.
                // 트랜잭션을 반환함과 동시에 동기화한다.

                // 기존 코드...
        }

트랜잭션이 읽기 전용으로 시작되었으니 deleteAll()은 실패하여야 하고, 실제로도 실패한다. 기존에 시작한 트랜잭션에 deleteAll()이 참여하고 있음을 알 수 있다.

선언적 트랜잭션이 적용된 서비스 메서드 뿐 아니라 JdbcTemplate 같이 스프링이 제공하는 데이터 엑세스 추상화를 적용한 DAO에도 영향을 미친다. 또한 롤백도 가능하다.

        @Test
        public void transactionSync(){
                userDao.deleteAll();
                assertThat(userDao.getCount(), is(0)); // 롤백테스트를 위해 초기 상태를 지정한다.

                DefaultTransactionDefinition txDefinition = new DefaultTransactionDefinition();
                TransactionStatus txStatus = transactionManager.getTransaction(txDefinition);
                // 트랜잭션 매니저에게 트랜잭션을 요청한다. 기존에 진행되는 트랜잭션이 없으니 새 트랜잭션을 시작한다.
                // 트랜잭션을 반환함과 동시에 동기화한다.

                // ... service 메서드 호출

                transactionManager.rollback(txStatus);
                assertThat(userDao.getCount(), is(0)); // 롤백 이전의 상태로 돌아가는지 확인.
        }

이렇게 테스트를 진행한 후 롤백을 진행하는 것을 롤백 테스트라고 한다.

  • DB에 영향을 주지 않는다.
  • 하나의 테스트용 DB를 여러 개발자가 공유할 수 있다.
  • 동시에 여러 개의 테스트가 진행되어도 상관없다.

@Transactional 애너테이션

스프링의 컨텍스트 테스트 프레임워크엔 @ContextConfiguration을 클래스에 부여하면 테스트를 실행하기 전에 컨테이너를 초기화하고, @Autowired 애너테이션이 붙은 필드를 통해 빈에 접근이 가능하다. 그 외의 애너테이션 중 가장 대표적인 것이 @Transactional이다.

  • @Transactional 애너테이션을 사용하면 복잡한 코드를 작성하지 않고도 트랜잭션을 적용할 수 있다.
  • 속성과 기능을 적용할 수 있다.
  • 메서드 레벨에 적용할 수도 있고, 클래스 레벨에 적용하면 클래스 내 모든 메서드에 트랜잭션이 적용된다. 메서드의 트랜잭션 속성이 클래스의 속성보다 우선한다.
  • 중요 : 테스트에 적용된 @Transactional은 자동으로 롤백되는 것이 기본 설정이다.

@Rollback

@Transactional을 테스트에 적용할 때 강제 롤백을 원하지 않으면 @Rollback 애너테이션을 사용할 수 있다. 별도로 기능을 제어하기 위해서다.

  • @Rollback(false)라고 선언하면 롤백되지 않는다.
  • 메서드 레벨에만 적용이 가능하다.
  • 기본 값은 true다.

@TransactionConfiguration

특정 클래스의 모든 메서드에 @Rollback(false)를 적용하고 싶다면 무식하게 모든 메서드에 선언해주어야 할까? 대신 @TransactionConfiguration을 사용하자.

  • 클래스 레벨에 부여한다.
  • 클래스 아래 메서드에 모두 적용하지만, 만약 특정 메서드에 속성이 지정되어 있는 경우 무시당한다.
  • @TransactionConfiguration(defaultRollback=false)

@NotTransactional

클래스 레벨에 @Transactional을 적용하면 클래스 메서드 모두에 트랜잭션이 적용되지만, 일부는 트랜잭션 설정을 하고싶지 않다면 @NotTransactional을 원하는 메서드에 부착한다.

  • 스프링3.0에서 deprecated될 예정이다.
  • 이 애너테이션을 사용하는 대신 트랜잭션이 필요한 테스트와 그렇지 않은 테스트를 분리해서 작성하는 것이 더 바람직하다.
  • 혹은 @Transactional(propagation = Propagation.NEVER)를 적용하면 트랜잭션이 시작되지 않는다.

효과적인 DB 테스트

  • 고립된 환경에서 진행하는 단위 테스트와 통합 테스트는 클래스를 구분해서 작성한다.
  • DB가 참여하는 통합 테스트는 @Transactional을 부여한다.
  • 테스트는 어떠한 상황에서도 서로 영향을 주고 의존하면 안된다.

참고하기

Spring Transaction Management: @Transactional In-Depth

0개의 댓글