-
토비의 스프링[6] AOPSpring 2021. 3. 8. 04:39
서론
AOP는 IoC/DI, 서비스 추상화와 더불어 스프링의 3대 기반기술의 하나이며, 스프링 기술중 가장 난해한 용어 및 개념을 가지고 있어서 이해하기 가장 어렵다.
스프링에서 가장 인기있는 AOP의 적용 대상은 트랜잭션 기능이기에 앞서서 다룬 서비스 추상화를 통해 많은 근본적인 문제를 해결했던 트랜잭션 경계설정 기능을 AOP를 이용해 더욱 세련되고 깔금한 방식으로 바꿔보자.
트랜잭션 코드의 분리
스프링이 제공하는 깔끔한 트랜잭션 인터페이스를 썼음에도 비즈니스 로직이 주인이어야할 메소드 안에는 길고 무시무시하게 생긴 트랜잭션 코드가 더 많은 자리를 차지하고 있다.
public void upgradeLevels() throws Exception { // 트랜잭션 로직 PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); // JDBC 트랜잭션 추상 오브젝트 생성 TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // 비지니스 로직 시작 List<User> users = userDao.getAll(); users.stream().forEach(user -> { if(canUpgradeLevel(user)) { upgradeLevel(user); } }); // 비지니스 로직 끝 transactionManager.commit(status); // 트랜잭션 커밋 } catch (RuntimeException e) { // 트랜잭션 로직 transactionManager.rollback(status); // 트랜잭션 커밋 throw e; } }
비지니스 로직을 담당하는 코드가 트랜잭션의 시작과 종료 작업 사이에서 수행돼야 한다는 사항만 지켜지면 성격이 다른 코드를 2개의 메소드로 분리할 수 있다.
public void upgradeLevels() throws Exception { PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); // JDBC 트랜잭션 추상 오브젝트 생성 TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { upgradeLevelsInternal(); this.transactionManager.commit(status); // 트랜잭션 커밋 } catch (RuntimeException e) { this.transactionManager.rollback(status); // 트랜잭션 커밋 throw e; } } private void upgradeLevelsInternal() { List<User> users = userDao.getAll(); users.stream().forEach(user -> { if(canUpgradeLevel(user)) { upgradeLevel(user); } }); }
DI를 이용한 클래스의 분리
여전히 메소드로 구분시켜 가독성이 좋아졌더라도 트랜잭션코드와 비즈니스 코드가 전부다 UserService에 모두 존재한다. 어짜피 서로 직직접적으로 정보를 주고받을 것이 없다면, 트랜잭션 코드를 클래스 밖으로 뽑아내면 된다.
DI 적용을 위한 트랜잭션 분리
UserService는 현재 클래스로 되어 있어 다른 코드에서 사용한다면 UserService 클래스를 직접 참조하게 된다. 그렇다면 트랜잭션 코드를 어떻게든 해서 UserService 밖으로 빼버리면 UserService 클래스를 직접 사용하는 클라이언트 코드에서는 트랜잭션 기능이 빠진 UserService를 사용하게 될 것이다. 구체적인 구현 클래스를 직접 참조하는 경우의 전형적인 단점이다.
그렇기에 인터페이스를 이용하여 간접적으로 사용하면 된다.
그런데 보통 인터페이스를 이용해 구현 클래스를 클라이언트에 노출하지 않고 런타임 시에 DI를 통해 적용하는 방법을 사용하는 이유로는, 일반적으로 구현 클래스를 바꿔 가면서 사용하기 위해서다. 테스트 때는 필요에 따라 테스트 구현 클래스를, 정식 운영중에는 정규 구현 클래스를 DI 해주는 방법처럼 한 번에 한 가지 클래스를 선택해서 적용하도록 되어 있다.
하지만 꼭 그래야 한다는 제약은 없기에 한 번에 두 개의 UserService 인터페이스 구현 클래스를 동시에 이용한다면 어떨까? 지금 해결하려고 하는 문제는 UserService에는 순수하게 비즈니스 로직을 담고 있는 코드만 놔두고 트랜잭션 경계설정을 담당하는 코드를 외부로 빼내려는 것이다. 하지만 클라이언트가 UserService의 기능을 제대로 이용하려면 트랜잭션이 적용돼야 한다.
그래서 UserService를 구현한 또 다른 구현 클래스를 만든다. 이 클래스는 사용자 관리 로직을 담고 있는 구현 클래스인 UserServiceImpl을 대신하기 위해 만든 게 아니다. 단지 트랜잭션의 경계설정이라는 책임을 맡고 있을 뿐이다. 그리고 스스로는 비즈니스 로직을 담고 있지 않기 때문에 또 다른 비즈니스 로직을 담고 있는 UserService의 구현 클래스에 실제적인 로직 처리 작업을 위임하는 것이다. 그 위임을 위한 호출 작업 이전과 이후에 적절한 트랜잭션 경계를 설정해주면 클라이언트 입장에서 볼 때는 결국 트랜잭션이 적용된 비즈니스 로직의 구현이라는 기대하는 동작이 일어날 것이다.UserService 인터페이스 도입
public interface UserService { void add(User user); void upgradeLevels(); } /** * UserService 트랜잭션 코드를 제거한 비즈니스 로직 */ public class UserServiceImpl implements UserService { UserDao userDao; MailSender mailSender; public UserServiceImpl(UserDao userDao, MailSender mailSender) { this.userDao = userDao; this.mailSender = mailSender; } public void add(User user) { if(user.getLevel() == null) user.setLevel(User.Level.BASIC); userDao.add(user); } public void upgradeLevels() { List<User> users = userDao.getAll(); users.stream().forEach(user -> { if(canUpgradeLevel(user)) { upgradeLevel(user); } }); } } /** * UserServie 트랜잭션 로직을 분리한 클래스 */ public class UserServiceTx implements UserService { PlatformTransactionManager transactionManager; UserService userService; public UserServiceTx(PlatformTransactionManager transactionManager, UserService userService) { this.transactionManager = transactionManager; 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 (Exception e) { this.transactionManager.rollback(status); throw e; } } }
UserServiceImpl의 경우 트랜잭션을 고려하지 않고 단순하게 로직만 구현했던 처음 모습으로 돌아왔다. 코드 어디에도 기술, 서버환경, 스프링 관련코드가 없으며 자체로 UserDao라는 인터페이스를 이용하여 User라는 도메인 정보를 가진 비즈니스 로직에만 충실한 깔끔한 코드이다.
UserServiceTx의 경우에는 비즈니스 로직에는 아무 관여하지 않고 트랜잭션 경계설정만 작업해주는 코드이다. upgradeLevels()는 UserService에서 트랜잭션 처리 메소드와 비즈니스 로직 메소드를 분리했을 때 트랜잭션을 담당한 메소드와 거의 한 메소드가 됐다. 추상화된 트랜잭션 구현 오브젝트를 DI 받을 수 있도록 PlatformTranscationManager 타입의 프로퍼티도 추가됐다.
트랜잭션 적용을 위한 DI설정
클라이언트가 UserService라는 인터페이스를 통해 사용자 관리 로직을 이용하려고 할 때 먼저 트랜잭션을 담당하는 오브젝트가 사용돼서 트랜잭션에 관련된 작업을 진행해주고, 실제 사용자 관리 로직을 담은 오브젝트가 이후에 호출돼서 비즈니스 로직에 관련된 작업을 수행하도록 만든다. 스프링의 DI 설정에 의해 결국 만들어질 빈 오브젝트와 그 의존관계는 아래와 같이 구성돼야 한다.
Client -> UserServiceTx -> UserServiceImpl
// 빈 추가추가 @Bean public UserServiceTx userService() { return new UserServiceTx(transactionManager(), userSErviceImpl()); } @Bean public UserServiceImpl userSErviceImpl() { return new UserServiceImpl(userDao(), mailSender()); }
스프링 컨텍스트에서 가져가는 빈들은 기본적으로 타입이 일치하는 빈을 찾아주며 동일한 타입으로 여러개의 빈이 존재한다면 필드 이름을 이용하여 가져오게 된다.
변경 후 UserServiceTest 코드도 변경해주어야 한다.
첫 번째로 MailSender 목 오브젝트를 이용한 테스트에서는 테스트에서 직접 MailSender를 DI 해줘야 할 필요가 있었다. MailSender를 DI 해줄 대상을 구체적으로 알고 있어야 하기 때문에 UserService가 아닌 UserServiceImpl 클래스의 오브젝트를 가져올 이유가 있다.
두 번째로는 upgradeAllOrNothing() 테스트 이다. 이 테스트는 사실 사용자 관리 로젝을 테스트 하기 위함이 아니라 트랜잭션 기술이 올바르게 적용되었는지 확인하는 학습 테스트이다. 그래서 직접 테스트용 확장 클래스도 만들고 수동 DI도 적용한 만큼 바뀐 구조를 모두 반영해주는 작업을 해주어야 한다.
@Test @DisplayName("정확한 user 레벨 업그레이드 테스트") // user 레벨 업그레이드 테스트를 개선한 업그레이드 테스트 public void upgradeLevels() throws Exception { MockUserDao mockUserDao = new MockUserDao(this.users); MockMailSender mockMailSender = new MockMailSender(); UserServiceImpl userServiceImpl = new UserServiceImpl(mockUserDao, mockMailSender); userServiceImpl.upgradeLevels(); List<User> updated = mockUserDao.getUpdated(); Assertions.assertEquals(updated.size(), 2); checkUserAndLevel(updated.get(0), "InWooJeon2", User.Level.SILVER); checkUserAndLevel(updated.get(1), "InWooJeon4", User.Level.GOLD); userService.upgradeLevels(); List<String> request = mockMailSender.getRequest(); Assertions.assertEquals(request.size(), 2); Assertions.assertEquals(request.get(0), users.get(1).getEmail()); Assertions.assertEquals(request.get(1), users.get(3).getEmail()); } @Test @DisplayName("예외 발생 시 작업 취소 여부 테스트") public void upgradeAllOrNothing() throws Exception { TestUserService testUserService = new TestUserService(userDao, mailSender, users.get(3).getId()); UserServiceTx txUserService = new UserServiceTx(transactionManager, testUserService); userDao.deleteAll(); for(User user : users) userDao.add(user); try { txUserService.upgradeLevels(); Assertions.fail("TestUserServiceException expected"); } catch (TestUserServiceException e) { } checkLevelUpgraded(users.get(1), false); } static class TestUserService extends UserServiceImpl { private String id; public TestUserService(UserDao userDao, MailSender mailSender, String id) { super(userDao, mailSender); this.id = id; } @Override protected void upgradeLevel(User user) { if(user.getId().equals(this.id)) throw new TestUserServiceException(); super.upgradeLevel(user); } }
트랜잭션 경계설정 코드 분리의 장점
트랜잭션 경계설정 코드의 분리와 DI를 통한 연결은 지금까지 해왔던 작업 중에서 가장 복잡하고, 큰 개선 작업이었다.
이 작업의 큰 장점으로는
- 비즈니스 로직을 담당하고 있는 UserServiceImpl의 코드를 작성할때 트랜잭션과 같은 기술적인 내용을 신경쓰지 않아도 된다.
- 스프링의 JDBC나 JTA 같은 로우레벨 트랜잭션 API는 물론이고 스프링의 트랜잭션 추상화 API조차 필요 없다.
- 브즈니스 로직코드에 괜히 손을대 불편해지는 일이 일어나지 않을것이며, 필요하다면 트랜잭션은 DI를 이용해 UserServiceTx와 같은 트랜잭션 기능을 가진 오브젝트가 먼저 실행되도록 만들기만 하면 된다. 따라서 언제든지 트랜잭션을 도입할 수 있다.
- 트랜잭션 같은 로우레벨 기술이 부족하더라도 비즈니스 로직이해도 및 자바 기초에 충실하여 복잡한 비즈니스 로직을 개발할 수 있다.
- 비즈시스 로직에 대한 테스트도 쉽게 만들 수 있다. (다음 절에서 살펴보자)
고립된 단위 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것이다. 그 이유로는 실패 시 원인을 찾기 쉽기 때문이다. 또한 테스트의 규모가 작을 수 록 의도나 내용이 분명해지고 만들기 쉬워진다. 클래스 하나가 동작하도록 테스트를 만드는 것과 수십개의 클래스가 연계되어 동작하는 테스트 중에서 어떤 것이 논리적인 오류를 찾기 쉬울지는 명확하다. 또한 오류를 찾더라도 수정을 할 때에도 마찬가지 이다.
복잡한 의존관계 속의 테스트
UserService의 경우 엔터프라이즈 시스템의 복잡한 모듈과는 비교할 수 없을 만큼 간단한 기능만을 갖고 있다. 그럼에도 UserService의 구현 클래스들이 동작하려면 세 가지 타입의 의존 오브젝트가 필요하다.
1. UserDao 오브젝트를 통해 DB와 데이터를 주고받으며
2. MailSender를 구현해 메일을 발송해주어야 하며
3. 트랜잭션 처리를 위해 PlatformTransactionManager와 커뮤니케이션이 필요하다.
UserServiceTest는 사용자 관리 로직을 구현한 코드를 대상으로한 테스트 코드이다. 그렇기에 UserService코드가 올바르면 성공, 아니라면 실패하면 된다. 하지만 UserService는 세 가지 의존관계를 가지고 있으며, 그 세 가지 역시 자신의 코드만 실행하는게 아닌 DB, 네트워크 통신 등등을 이용하고 있다.
따라서 UserService를 테스트 하는것 처럼 보이지만 실제로는 그 뒤에 존재하는 더 많은 오브젝트, 네트워크, 서비스, 서버 등을 테스트한는 것이다. 만약 네트워크에 문제가 생겨 UserServiceTest가 실패하게 된다면 사실은 그 뒤의 의존관계를 따라 등장하는 서비스, 네트워크, 서버, 등 모두가 합쳐서 테스트 대상이 되는 것이다.
막상 간단한 조회 로직을 수행하는데 연관 되어있는 DB에서 수천줄 수만줄의 SQL이 실행된다면... 벌써부터 끔찍하다...
테스트 대상 오브젝트 고립시키기
위에서 언급한 배보다 배꼽이 더 큰 테스트가 되기 위해서는 테스트의 대상이 다른 외부(서버, 네트워크 등등)에 종속되고 영향을 받지않게 고립시켜야 한다.
방법으로는 MockMailSender와 같이 테스트를 위한 대역을 사용하는 것이다.
테스트를 위한 UserServiceImpl 고립
트랜잭션 코드의 경우 ServiceTx 클래스를 이용해 독립시켰기에 UserServiceImpl 클래스는 더이상 PlatformTransactionManager에 의존하지 않는다. 이렇게 고립된 테스트가 가능하도록 UserService를 재구성해보면 Mock을 이용한 DAO, MailSender에만 의존하는 완벽하게 고립된 테스트 대상으로 만들 수 있다.
UserDao는 단지 테스트 대상의 코드가 정상적으로 수행되도록 도와주기만 하는 스텁이 아니라, 부가적인 검증 기능까지 가진 목 오브젝트로 만들었다. 그 이유는 고립된 환경에서 동작하는 upgradeLevelse()의 테스트 결과를 검증할 방법이 필요하기 때문이다.
- UserServiceImpl의 upgradeLevelse() 메소드의 반환은 void이기에 메소드를 실행하고 그 결과를 받아 검증하는 것이 불가능하다. upgradeLevels()는 DAO를 통해 필요한 정보를 가져와 작업 후 결과를 DAO를 통해 DB에 반영한다. 따라서 검증을 위해서는 DB를 직접 확인할 수 밖에 없기에 기존에는 DB에 들어간 결과값을 이용해 테스트 여부를 판단했다.
- 그래서 UserServiceImpl 협렵 오브젝트인 UserDao에게 어떤 요청을 했는지를 확인할 작업이 필요하다 테스트 중에는 DB에 반영되지 않지만 UserDao의 update(0 메소드를 호출하는 것을 확인한다면 결국 DB에 그 결과가 반영될 것이라고 결론을 내릴 수 있기 때문이다.
고립된 단위테스트 활용
@Test @DisplayName("정확한 user 레벨 업그레이드 테스트") // user 레벨 업그레이드 테스트를 개선한 업그레이트 테스트 public void upgradeLevels() throws Exception { userDao.deleteAll(); for(User user : users) userDao.add(user); // DB 테스트 데이터 준비 MockMailSender mockMailSender = new MockMailSender(); userService.setMailSender(mockMailSender); // 메일 발송여부 확인을 위한 목 오브젝트 DI userService.upgradeLevels(); checkLevelUpgraded(users.get(0), false); checkLevelUpgraded(users.get(1), true); checkLevelUpgraded(users.get(2), false); // DB에 저장된 결과 확인 checkLevelUpgraded(users.get(3), true); checkLevelUpgraded(users.get(4), false); List<String> request = mockMailSender.getRequest(); Assertions.assertEquals(request.size(), 2); // 목 오브젝트를 이용한 결과 확인 Assertions.assertEquals(request.get(0), users.get(1).getEmail()); Assertions.assertEquals(request.get(3), users.get(4).getEmail()); }
테스트 작업을 분류 해보자면 처음 DB 테스트 데이터 준비 및 Mock DI는 메소드가 실행되는 동안에 사용하는 의존 오브젝트가 테스트의 목적에 맞게 동작하도록 준비하는 과정이다.
- 첫 번째 작업은 의존관계를 따라 마지막에 등장하는 DB를 준비하는 것인 반면
- 두 번째는 테스트를 으존 오브젝트와 서버 등에서 독립시키기 위해 목 오브젝트를 준비하는 점이 다르다.
- 결과 확인역시 DB에서 확인하거나 목 오브젝트를 통해 요청이 있는지 확인을 하는 점이 다르다.
UserDao 목 오브젝트
실제 의존하고 있는 첫 번째 작업의 테스트 방식도 목 오브젝트를 이용해 적용해보자!
테스트를 실행하며 사용되는 UserDao의 메소드는 아래와 같다.
- getAll
- List<User>가 반환이 이루어져야 한다.
- update
- void
/** * 테스트용 MockUserDao */ static class MockUserDao implements UserDao { private List<User> users; // 레벨 업그레이드 후보 User 오브젝트 목록 private List<User> updated = new ArrayList<>(); // 업그레이드 대상 오브젝트를 저장해둘 목록 private MockUserDao(List<User> users) { // 생성자를 통해 전달받은 사용자 목록을 저장 this.users = users; } public List<User> getUpdated() { return this.updated; } public List<User> getAll() { return this.users; } public void update(User user) { updated.add(user); } // 실수라도 사용되면 UnsupportedOperationException 발생되게 설계 @Override public void add(User user) { throw new UnsupportedOperationException(); } @Override public User get(String id) { throw new UnsupportedOperationException(); } @Override public void deleteAll() { throw new UnsupportedOperationException(); } @Override public int getCount() { throw new UnsupportedOperationException(); } } @Test @DisplayName("정확한 user 레벨 업그레이드 테스트") // user 레벨 업그레이드 테스트를 개선한 업그레이드 테스트 public void upgradeLevels() throws Exception { MockUserDao mockUserDao = new MockUserDao(this.users); MockMailSender mockMailSender = new MockMailSender(); UserServiceImpl userServiceImpl = new UserServiceImpl(mockUserDao, mockMailSender); // mock 주입(Dao, MailSender) userServiceImpl.upgradeLevels(); List<User> updated = mockUserDao.getUpdated(); Assertions.assertEquals(updated.size(), 2); checkUserAndLevel(updated.get(0), "InWooJeon2", User.Level.SILVER); checkUserAndLevel(updated.get(1), "InWooJeon4", User.Level.GOLD); userService.upgradeLevels(); List<String> request = mockMailSender.getRequest(); Assertions.assertEquals(request.size(), 2); Assertions.assertEquals(request.get(0), users.get(1).getEmail()); Assertions.assertEquals(request.get(1), users.get(3).getEmail()); }
기존 UserService에 MockMailSender수동 DI 주입 시 실패한 테스트 코드가 UserServiceImpl에서 수동 DI 주입하니 테스트 성공...
테스트 수행 성능의 향상
테스트가 정상적으로 성공했으면 수행시간을 비교해보면 더 빨라진걸 알 수 있다.
물론 지금은 가벼운 애플리케이션이기에 미미한 차이만 존재하지만 거대한 애플리케이션 일 수 록 엄청난 차이가 존재하게 된다고 한다.
- add : 13ms (DB 사용)
- upgradeAllOrNothing : 14ms (DB 사용)
- upgradeLevels : 6ms (Mock 사용)
단위 테스트와 통합 테스트
참고 : hodolee246.tistory.com/59
여기서의 통합테스트는 두 개 이사으이 단위가 결합해서 동작하면서 테스트가 수행되는 것이라고 보면 된다. 스프링의 테스트 컨텍스트 프레임워크를 이용해서 컨텍스트에서 생성되고 DI된 오브젝트를 테스트하는 것도 통합 테스트이다.
- 테스트는 항상 단위 테스트를 고려
- 하나의 클래스나 성격과 목적이 같은 긴밀한 클래스 몇 개를 모아 외부를 차단하고 필요에 따라 스텁, 목 오브젝트 등의 테스트 대역을 이용하도록 테스트를 만든다. 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며, 테스트 대상 외의 코드나 환경으로부터 테스트 결과에 영향받지도 않기 때문에 가장 빠른 시간에 효과적인 테스트를 작성하기에 유리하다.
- 외부 리소스를 이용할때만 통합테스트 이용
- DAO 같이 DB를 연동하는 DataAccess로직은 DB와 함께 통합테스트를 준비
- 이 과정이 성공 시 이미 성공한 코드이기에 DAO를 스텁이나 목으로 대체가능
- 여러 개의 단위가 의존관계를 가지고 동작할 때를 위한 통합 테스트는 필요하다. 다만 단위테스트를 충분히 거쳐야 통합 테스트의 부담은 상대적으로 줄어든다.
- 단위테스트 하기 복잡하다면 통합테스트를 고려하며, 통합테스트 코드 중 가능한 많은 부분을 미리 단위테스트로 검증해야 유리하다.
- 스프링을 이용 시 통합테스트로 분류
Mockito 프레임워크
org.mockito.Matchers
- 인터페이스를 이용해 목오브젝트를 생성
- 목 오브젝트가 반환값이 있으면 이를 지정(예외도 가능)
- 테스트 대상 오브젝트에 목 오브젝트 DI해서 테스트에 사용되도록 사용
- 테스트 대상 오브젝트 사용 후 목 오브젝트가 얼마나 , 특정 메소드가, 어떤값을 가지고 등등 호출됐는지 검증
@Test @DisplayName("upgradeLevels() / 정확한 user 레벨 업그레이드 테스트") // user 레벨 업그레이드 테스트를 개선한 업그레이드 테스트 public void upgradeLevels() throws Exception { UserDao mockUserDao = Mockito.mock(UserDao.class); // getAll() 호출 시 users 반환 Mockito.when(mockUserDao.getAll()).thenReturn(this.users); MailSender mockMailSender = Mockito.mock(MailSender.class); UserServiceImpl userServiceImpl = new UserServiceImpl(mockUserDao, mockMailSender); userServiceImpl.upgradeLevels(); // mockUserDao.update()아무 user객체로 2번 호출 시 Mockito.verify(mockUserDao, Mockito.times(2)).update(Mockito.any(User.class)); Mockito.verify(mockUserDao, Mockito.times(2)).update(Mockito.any(User.class)); // mockUserDao.update() users.get(1) 객체로 호출 시 Mockito.verify(mockUserDao).update(users.get(1)); Assertions.assertEquals(users.get(1).getLevel(), User.Level.SILVER); Mockito.verify(mockUserDao).update(users.get(3)); Assertions.assertEquals(users.get(3).getLevel(), User.Level.GOLD); ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class); // 파라미터를 정밀하게 검사하기 위해 캡처할 수 도 있다. Mockito.verify(mockMailSender, Mockito.times(2)).send(mailMessageArg.capture()); List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues(); Assertions.assertEquals(mailMessages.get(0).getTo()[0], users.get(1).getEmail()); Assertions.assertEquals(mailMessages.get(1).getTo()[0], users.get(3).getEmail()); }
다이내믹 프록시와 팩토리 빈
프록시와 프록시 패턴, 데코레이터 패턴
마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는걸 프록시라 부르며 프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타겟 또는 실체라 부른다.
데코레이터 패턴 은 타깃의 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴으로 Input/OutPutStream도 데코레이터 패턴의 적용된 예 이다.
프록시로부터 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 자신도 최종 타깃으로 위임하는지, 다음 단계 데코레이션으로 위임하는지 알지 못한다. 그렇기에 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 외부에서 런타임 시 주입받을 수 있도록 만들어야 한다.
프록시 패턴
프록시라는 용어는 디자인 패턴 용어와 구분할 필요가 있다. 일반 프록시는 클라이언트와 사용 대상 사이의 대리 역활을 맡은 오브젝트이며, 패턴의 프록시는 사용하는 방법 중에서 타깃에 대한 접근 방법을 제어하려는 목적을 가진 경우를 말한다.
- 패턴의 경우 부가 기능이 추가되지 않으며, 접근방식만 변경해준다.
- 타깃 오브젝트를 생성하기 위해서는 레퍼런스가 필요한데 이 정보 대신 프록시를 넘겨 실제 요청 시 프록시를 통해 타깃 오브젝트를 생성하여 위임하는 방식으로 오브젝트 생성을 늦춰 이점을 얻을 수 있다.
** 앞으로는 타깃과 동일한 인터페이스를 구현하고 클라이언트와 타깃 사이에 존재하면서 기능을 부가 또는 접근 제어를 담당하는 오브젝트를 프록시라고 부르겠다. **
다이내믹 프록시
프록시는 기존 코드에 영향을 주지 않고 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법이지만 아래처럼 작업이 번거로워 사용은 잘 하지 않는다.
- 매번 새로운 클래스 정의
- 하지만 java.lang.reflect 패키지에서 쉽게 프록시 생성을 지원한다.
- 인터페이스 메소드 일일이 위임하는 코드 작성
프록시 기능은 2가지 기능으로 구성되지만...
- 타깃과 같은 메소드를 구현하고 있다가 메소드가 호출되면 타깃 오브젝트로 위임한다.
- 지정된 요청에 대해서는 부가기능을 수행한다.
위에서 말한 인터페이스 메소드 일일이 위임하는 코드 작성 및 부가기능 코드 중복으로 인하여 잘 사용하지 않는다. 코드 중복이야 중복되는 코드를 분리해서 사용하면 되지만 인터페이스 메소드의 구현과 위임 기능은 간단하지 않다. 그래서 이를 해결하기 위해 사용하는게 JDK의 다이내믹 프록시이다.
리플랙션
다이내믹 프록시는 리플렉션 기능을 이용해 프록시를 만들어준다. 리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만들어진 것으로 클래스 오브젝트를 이용해 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있다. (이름, 뭘 상속하고, 어떤 인터페이스 구현, 메소드(파람, 리턴) 등등) 자세한건 doc 참조
/** 리플렉션을 이용한 다이나믹 프록시 학습 테스트 * */ public class ReflectionTest { @Test public void invokeMethod() throws Exception { String name = "JeonInWoo"; // length Assertions.assertEquals(name.length(), 9); Method lengthMethod = String.class.getMethod("length"); Assertions.assertEquals((Integer)lengthMethod.invoke(name), 9); // chartAt Assertions.assertEquals(name.charAt(0), 'J'); Method chartAtMethod = String.class.getMethod("charAt", int.class); Assertions.assertEquals(chartAtMethod.invoke(name, 0), 'J'); } }
프록시 클래스
프록시 제작
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 "ThankYou " + name; } } /** Hello 인터페이스를 통해 HelloTarget 오브젝트를 사용하는 클라이언트 역활 테스트 * */ public class ProxyTest { @Test public void simpleProxy() { Hello hello = new HelloTarget(); // 타깃은 인터페이스로 생성 Assertions.assertEquals(hello.sayHello("Toby"), "Hello Toby"); Assertions.assertEquals(hello.sayHi("Toby"), "Hi Toby"); Assertions.assertEquals(hello.sayThankYou("Toby"), "ThankYou Toby"); } }
해당 프록시에 데코레이터 패턴을 적용해 UpperCase로 변경
public class HelloUppercase implements Hello { Hello hello; // 위임할 타깃 오브젝트. 여기서는 타깃 클래스의 오브젝트인 것을 알지만 다른 프록시를 추가할 수도 있으므로 인터페이스로 접근한다. public HelloUppercase(Hello hello) { this.hello = hello; } @Override public String sayHello(String name) { return hello.sayHello(name).toUpperCase(); } @Override public String sayHi(String name) { return hello.sayHi(name).toUpperCase(); } @Override public String sayThankYou(String name) { return hello.sayThankYou(name).toUpperCase(); } } @Test public void upperProxy() { Hello proxiedHello = new HelloUppercase(new HelloTarget()); // 프록시를 통해 타깃 오브젝트에 접근하도록 구성 Assertions.assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY"); Assertions.assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY"); Assertions.assertEquals(proxiedHello.sayThankYou("Toby"), "THANKYOU TOBY"); }
이 프록시는 프록시 적용의 일반적인 2가지 문제점을 모두 가지고 있다.
- 인터페이스 메소드 구현해 위임하는 코드
- 부가기능인 리턴 값을 대문자로 바꾸는 코드 중복
다이내믹 프록시 적용
다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트로 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어진다. 클라이언트는 다이내믹 프록시를 타깃 인터페이스를 통해 사용할 수 있다. 이 덕분에 프록시를 만들 때 인터페이스를 모두 구현해가면서 클래스를 정의하는 수고를 덜 수 있다. 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 으브젝트를 자동으로 만들어주기 때문이다.
다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메소드로 넘기는 것이다. 이로 인해 타깃 인터페이스의 모든 메소드 요청이 하나의 메소드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있다.
public class UppercaseHandler implements InvocationHandler { Hello target; // 타깃의 종류와 상관없이 적용이 가능 public UppercaseHandler(Hello target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String ret = method.invoke(target, args); return ret.toUpperCase(); } } @Test public void dynamicProxy() { // params 1. 클래스 로더 2. 다이내믹 프록시가 구현할 인터페이스 3. 부가기능과 위임 관련 코드를 담고있는 InvocationHandler 구현 오브젝트 Hello proxiedHello = (Hello) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {Hello.class}, new UppercaseHandler(new HelloTarget())); }
다이내믹 프록시의 확장
Hello 인터페이스 메소드가 3개가 아니라 30개로 늘어나면, 인터페이스가 변경되면 HelloUpperCase처럼 클래스로 직접 구현한 프록시는 매번 코드를 추가해야 한다.하지만 UppercaseHandler와 다이내믹 프록시를 생성해서 사용하는 코드는 전혀 손댈 게 없다. 다이내믹 프록시를 생성해서 사용하는 코드는 전혀 손댈 게 없다. 다이내믹 프록시가 만들어질 때 추가된 메소드가 자동으로 포함될 것이고, 부가기능은 invoke() 메소드에서 처리되기 때문이다.
// 확장된 UpperCaseHandler public class UppercaseHandler implements InvocationHandler { Object target; // 타깃의 종류와 상관없이 적용이 가능 public UppercaseHandler(Hello 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(); } else { // 미 일치시 타겟 메소드 그대로 호출 return ret; } } }
다이내믹 프록시를 이용한 트랜잭션 부가기능
매번 메소드마다 트랜잭션 코드가 필요할 경우 추가하는 UserServiceTx는 점점 메소드와 클래스가 증가할 수록 부담이 된다 그렇기에 트랜잭션 기능을 이용하는 InvocationHandler를 사용하여 다이내믹 프록시와 연동하면 훨씬 효율적으로 변경할 수 있다. 추가로 InvocationHandler 방식의 장점으로 타깃의 종류에 상관없이도 적용이 가능하다.
Method.getName() 이용 시 메소드명도 알 수 있다.
public class UppercaseHandler implements InvocationHandler { private final Object target; // 타깃(어떤 종류의 인터페이스를 구현한 타깃에도 적용하게끔 Object로 수정) private UppercaseHandler(Object target) { this.target = target; } public Object invoke(Object proxy, Method method, Object[] args) throws Trowable { Object ret = method.invoke(target, args); if(ret instanceof String) { // 호출한 메소드 리턴타입 검사) return ((String)ret).toUpperCase(); } else { return ret; } } }
다이내믹 프록시를 이용한 트랜잭션 부가기능
UserServiceTx를 다이내믹 프록시 방식으로 변경해보자.
UserServiceTx는 서비스 인터페이스의 메소드를 모두 구현해야 하고 트랜잭션이 필요한 메소드마다 트랜잭션 처리코드가 중복돼서 나타나는 비효율적인 방법으로 만들어져 있다. 트랜잭션이 필요한 클래스와 메소드가 증가하면 UserServiceTx처럼 프록스 클래스를 일일이 구현하는 것은 부담됨으로 트랜잭션 부가기능을 제공해주는 다이내믹 프록시를 만들어 적욕하는 것이 효율적이다.
public class TransactionHandler implements InvocationHandler { private final Object target; // 타깃 private final PlatformTransactionManager transactionManager; // 트랜잭션 기능용 트랜잭션 매니저 private final String patter; // 트랜잭션을 적용할 메소드 이름 패턴 public TransactionHandler(Object target, PlatformTransactionManager transactionManager, String patter) { this.target = target; this.transactionManager = transactionManager; this.patter = patter; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if(method.getName().startsWith(patter)) { 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); throw e.getTargetException(); } } }
- 요청을 위임 받을 타겟을 DI로 주입
- 타깃을 저장할 변수는 Object
- 트랜잭션 추상화 인터페이스인 PlatformTransactionManager DI로 주입
- 트랜잭션을 적용할 메소드 이름 패턴 DI로 주입
3개의 DI를 주입받은 이후 트랜잭션을 적용할 메소드인지 판단 후 그대로 반환하거나 트랜잭션을 적용하여 반환하게 된다. 만약 트랜잭션을 적용할 경우 메소드를 호출하는 것은 UserServiceTx와 동일한데 Method.invoke() 메소드를 이용하여 타겟 오브젝트를 호출할때 발생하는 예외가 InvocationTargetException으로 포장되기에 롤백을 적용하기 위한 예외 부분이 기존 RuntimeException에서 InvocationTargetException으로 달라진다.
TranscationHandler와 다이내믹 프록시를 이용하는 테스트
앞에서 제작한 다이내믹 프록시를 이용하여 UserServiceTx를 대신하여 사용하도록 테스트를 변경하여 테스트를 진행해보자.
@Test public void upgradeAllOrNothing() throws Exception { ... TransactionHandler txHandler = new TransactionHandler(testUserService, transactionManager, "upgradeLevels"); UserService txUserService = (UserService)Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { UserService.class }, txHandler); ... }
다이내믹 프록시를 위한 팩토리 빈
앞 절에서 어떤 타깃이든 적용이 가능한 트랜잭션 부가기능을 담은 TransactionHandler를 제작하였고, 이제 이를 TransactionHandler와 다이내믹 프록시를 스프링의 DI를 통해 사용할 수 있도록 만들어야 한다. 하지만 DI 대상이 되는 다이내믹 프록시 오브젝트는 일반적인 스프링의 빈으로는 등록할 방법이 없다.
스프링의 빈은 기본적으로 클래스 이름과 프로퍼티로 정의된다. 스프링은 지정된 클래스 이름을 가지고 리플렉션을 이용해서 해당 클래스의 오브젝트를 만든다.
문제는 다이내믹 프록시 오브젝트는 이런 식으로 프록시 오브젝트가 생성되지 않으며, 사실 다이내믹 프록시 오브젝트의 클래스가 어떤 것인지도 알 수 없다. 그렇기에 사전에 오브젝트의 클래스 정보를 알아내 빈으로 등록할 수 없다. 다이내믹 프록시는 Proxy 클래스의 newProxyInstance()라는 스태틱 팩토리 메소드를 통해서만 만들 수 있다.
팩토리 빈
스프링은 클래스 정보를 가지고 빈을 만드는 방법 외 다른 방법을 제공하는데 대표적으로 스프링을 대신해서 오브젝트의 생성로직을 담당하도록 만들어지는 팩토리 빈을 이용한다.
팩토리 빈을 만드는 방법에는 여러가지가 있으며 가장 간단한 인터페이스를 구현하는 것이다. FactoryBean 인터페이스는 3가지의 메소드로 구성되어 있다.
- getObject() : 빈 오브젝트를 생성해 돌려준다.
- getObectType() : 생성되는 오브젝트 타입을 알려준다.
- inSingleton : getObject() 가 돌려주는 객체가 싱글톤 오브젝트인지 알려준다.
public class Message { String text; private Message(String text) { // 접근제어자가 private 이기에 외부에서 생성 불가능 this.text = text; } public String getText() { return text; } public static Message newMessage(String text) { // 생성을 하려면 static 메소드를 통해 새로운 객체를 생성해야함 return new Message(text); } } public class MessageFactoryBean implements FactoryBean<Message> { String text; public MessageFactoryBean(String text) { this.text = text; } @Override public Message getObject() throws Exception { // 실제 빈으로 사용될 오브젝트를 직접 생성한다. return Message.newMessage(this.text); } @Override public Class<?> getObjectType() { return Message.class; } @Override public boolean isSingleton() { // 요청시 새로운 객체를 생성하기에 싱글톤이 아니다. return false; } } ... @Bean public MessageFactoryBean message() { return new MessageFactoryBean("Factory Bean"); }
스프링은 private 생성자를 가진 클래스도 빈으로 등록해주면 리플렉션을 이용해 오브젝트를 만들어준다. 하지만 하지말란 이유가 있어 private 으로 생성했다면 하지말란 소리이다.
팩토리 빈은 MessageFactoryBean이지만 스프링 빈으로 만들어두면 getObject()라는 메소드가 생성해주는 오브젝트가 실제 빈의 오브젝트로 대체되어 Message가 된다. 추가로 팩토리 빈 자체를 가지고 오고 싶으면 ApplicationContext.getBean("&message"); 처럼 "&" 기호를 사용하면 팩토리 빈 자체를 돌려준다.
프록시 팩토리 빈 방식의 장점과 한계
다이내믹 프록시를 생성해주는 팩토리 빈을 사용하는 방법은 여러 가지 장점이 있다. 한번 부가기능을 가진 프록시를 생성하는 팩토리 빈을 만들어두면 타깃의 타입에 상관없이 재사용할 수 있기 때문이다.
프록시 팩토리 빈의 재사용
- 하나 이상의 팩토리 빈을 동시에 여러개 등록해도 상관없으며, 팩토리 빈이기에 각 빈의 타입은 인터페이스와 일치한다.
- 프록시 팩토리 빈을 이용하면 프록시 기법을 빠르게 적용할 수 있다.
프록시 팩토리 빈 방식의 장점
- 다이내믹 프록시를 이용하면 타깃 인터페이스를 구현하는 클래스를 매번 만드는 작업을 제거할 수 있다.
- 하나의 핸들러 메소드를 구현하는 것 만으로도 수많은 메소드에 부가기능을 부여해줄수 있어서 부가 기능 코드의 중복을 제거할 수 있다.
프록시 팩토리 빈의 한계
- 프록시를 통해 타깃에 부가기능을 제공하는 것은 메소드 단위로 일어나는 일이다. 하나의 클래스 안에 존재하는 여러개의 메소드에 부가기능을 한 번에 제공하는 건 어렵지 않게 가능했다. 그렇지만 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으로는 불가능하다. 하나의 타깃 오브젝트에만 부여되는 부가기능이라면 상관없지만, 트랜잭션 같이 비즈니스 로직을 담은 수 많은 클래스의 메소드에 적용할 필요가 있다면 거의 비슷한 프록시 팩토리 빈의 설정이 중복되는 것을 막을 수 없다.
스프링의 프록시 팩토리 빈
ProxyFactoryBean
- 스프링은 트랜잭션 기술과 메일 발송 기술에 적용했던 서비스 추상화를 프록시 기술에도 동일하게 적용하고 있다. 자바에는 JDK에서 제공하는 다이내믹 프록시 외에도 편리하게 프록시를 만들 수 있도록 지원해주는 다양한 기술이 존재한다. 따라서 스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다.
- 생성된 프록시는 스프링의 빈으로 등록돼야 한다. 스프링은 프록시 오브젝트를 생성해주는 기술을 추상화한 팩토리 빈을 제공해준다. 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다. 기존에 만들었던 TxProxyFactoryBean과 달리, ProxyFactoryBean과 달리, ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.
- ProxyFactoryBean이 생성하는 프록시에서 사용할 부가기능은 Methodlnterceptor 인터페이스를 구현해서 만든다. Methodlnterceptor는 InvocationHandler와 비슷하지만 한 가지 다른 점이 있다. InvocationHandler의 invoke() 메소드는 타깃 오브젝트에 대한 정보를 제공하지 않는다. 따라서 타깃은 InvocationHandler를 구현한 클래스가 직접 알고 있어야 한다. Methodlnterceptor의 invoke() 메소드는 ProxyFactoryBean으로부터 타깃 오브젝트에 대한 정보까지도 함께 제공받는다. Methodlnterceptor는 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있다.
@Test public void dynamicProxy() { // params 1. 클래스 로더 2. 다이내믹 프록시가 구현할 인터페이스 3. 부가기능과 위임 관련 코드를 담고있는 InvocationHandler 구현 오브젝트 Hello proxiedHello = (Hello) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] {Hello.class}, new UppercaseHandler(new HelloTarget())); } @Test public void proxyFactoryBean() { ProxyFactoryBean pfBean = new ProxyFactoryBean(); pfBean.setTarget(new HelloTarget()); pfBean.addAdvice(new UppercaseAdvice()); Hello proxiedHello = (Hello) pfBean.getObject(); Assertions.assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY"); Assertions.assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY"); Assertions.assertEquals(proxiedHello.sayThankYou("Toby"), "THANKYOU TOBY"); } class UppercaseAdvice implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { String ret = (String) invocation.proceed(); // 메소드 실행 시 타겟 오브젝트 전달필요 없음 (이미 타겟 오브젝트 정보를 알고있음) return ret.toUpperCase(); // 부가기능 } }
어드바이스: 타깃이 필요 없는 순수한 부가기능
- Methodlnterceptor를 구현한 UppercaseAdvice에는 타깃 오브젝트가 등장하지 않는다. Methodlnterceptor로는 메 소드 정보와 함께 타깃 오브젝트가 담긴 Methodlnvocation 오브젝트가 전달된다. Methodlnterceptor은 타깃 오브젝트의 메소드를 실행할 수 있는 기능이 있기에 Methodlnterceptor는 부가기능을 제공하는 데만 집중할 수 있다.
- Methodlnvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. 그렇다면 Methodlnvocation구현 클래스는 일종의 공유 가능한 템플릿처럼 동작하는 것이다. 바로 이 점이 JDK의 다이내믹 프록시를 직접 사용하는 코드와 스프링이 제공해주는 프록시 추상화 기능인 ProxyFactoryBean을 사용하는 코드의 가장 큰 차이점이자 ProxyFactoryBean의 장점이다.
- ProxyFactoryBean은 작은 단위의 템플릿/콜백 구조를 응용해서 적용했기 때문에 템플릿 역할을 하는 Methodlnvocation을 싱글톤으로 두고 공유할 수 있다. 마치 SQL 파라미터 정보에 종속되지 않는 JdbcTemplate이기 때문에 수많은 DAO 메소드가 하나의 JdbcTemplate 오브젝트를 공유할 수 있는 것과 마찬가지이다.
- addAdvice() 라는 메소드를 통해 ProxyFactoryBean에는 여러 개의 Methodlnterceptor를 추가할 수 있다. ProxyFactoryBean 하나만으로 여러 개의 부가 기능을 제공해주는 프록시를 만들 수 있다는 뜻이다. 따라서 앞서 살펴봤던 새로운 부가기능을 추가할 때마다 프록시와 프록시 팩토리 빈도 추가해줘야 하는 단점을 해결할 수 있다.
- 참고로 Methodlnterceptor는 Advice 인터페이스를 상속하고 있는 서브인터페이스이다. MethodInterceptort처럼 타깃 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스라고 부른다.
- ProxyFactoryBean을 적용한 코드에는 프록시가 구현해야 하는 Hello라는 인터페이스를 제공해주는 부분이 없다. 프록시를 직접 만들 때나 JDK 다이내믹 프록시를 만들때 반드시 제공해주어야 하는 정보가 Hello 인터페이스 였다. 그래야만 다이내믹 프록시의 오브젝트 타입을 결정할 수 있기 때문이다. 하지만 굳이 인터페이스를 알려주지 않아도 ProxyFactoryBean에 있는 인터페이스 자동검출 기능을 사용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낸다. 그리고 알아낸 인터페이스를 모두 구현하는 프록시를 만들어준다.
- 타깃 오브젝트가 구현하는 인터페이스중 일부만 프록시를 적용하기 원하면 인터페이스 정보를 직접 제공해줘도 된다.
- ProxyFactoryBean은 기본적으로 JDK가 제공하는 다이내믹 프록시를 만들어준다. 경우에 따라서 CGLib이라는 오픈소스 바이트코드 생성 프레임워크를 이용해 프록시를 만들기도 한다.
- 어드바이스는 타깃 오브젝트에 종속되지 않는 순수한 부가기능을 담은 오브젝트라는 사실을 잘 기억해두고 다음으로 넘어가자.
포인트컷: 부가기능 적용 대상 메소드 선정 방법
기존
TxProxyFactoryBean은 Patten이라는 메소드 이름 비교용 스트링 값을 DI받아 TransactionHandler를 생성할때 이를 넘겨주고 TransactionHandler요청을 받을 경우 메소드 이름과 패턴을 비교하여 부가기능인 트랜잭션 적용 대상을 판별했다.
문제점
- 부가기능을 가진 InvocationHadnler가 타깃과 메소드 선정 알고리즘 코드에 의존하고 있다는 점이다. 만약 타깃이 다르고 메소드 선정 방식이 다르다면 InvocationHandler 오브젝트를 여러 프록시에 공유할 수 없다.
- 따라서 타깃 변경과 선정 알고리즘 변경 같은 확장이 필요하면 팩토리 빈 내의 프록시 생성코드를 직접 변경해야 한다. 결국 확장에는 유연하지 못하고 관련 없는 코드의 변경이 필요할 수 있는 OCP 원칙을 지키지 못하는 어줌짢은 코드이다.
스프링의 ProxyfactoryBean 방식은 두 가지 확장 기능인 부가기능 과 메소드 선정 알고리즘을 활용하는 유연한 구조를 제공한다.
- 부가기능을 제공해주는 오브젝트를 어드바이스라고 부른다.
- 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다.
- 어드바이스 및 포인트컷은 모두 프록시에 DI로 주입되어 사용되며, 2가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 스프링의 싱글톤 빈으로 등록이 가능하다.
@Test public void pointcutAdvisor() { ProxyFactoryBean pfBean = new ProxyFactoryBean(); pfBean.setTarget(new HelloTarget()); NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); pointcut.setMappedName("sayH*"); pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice())); Hello proxiedHello = (Hello) pfBean.getObject(); Assertions.assertEquals(proxiedHello.sayHello("Toby"), "HELLO TOBY"); Assertions.assertEquals(proxiedHello.sayHi("Toby"), "HI TOBY"); Assertions.assertEquals(proxiedHello.sayThankYou("Toby"), "ThankYou Toby"); }
- 포인트컷이 필요 없을 때는 ProxyFactoryBean의 addAdvice() 메소드를 호출해서 어드바이스만 등록하면 된다. 하지만 포인트컷의 경우 어떤 메소드를 선정해 적용할지 애매하기 때문에 단품으로는 등록하지 못한다.
- 아드바이저는 부가기능인 어드바이스와 메소드 선정 알고리즘은 포인트컷을 함께 부르는 용어이다.
어드바이스와 포인트컷의 재사용
ProxyFactoryBean은 스프링의 DI와 템플릿/콜백 패턴, 서비스 추상화 등의 기법이 모두 적용된 것이다. 그 덕분에 독립적이며 여러 프록시가 공유할 수 있는 어드바이스와 포인트컷으로 확장 기능을 분리할 수 있었다.
스프링 AOP
자동 프록시 생성
프록시 팩토리 빈 방식의 접근 방법의 한계라고 생각했던 두 가지 문제중 하나는 부가기능이 타깃이 타깃 오브젝트마다 새로 만들어지는 문제는 ProxyFactoryBean의 어드바이스를 통해 해결했다. 남은 것은 부가기능의 적용이 필요한 타깃 오브젝트마다 거의 비슷한 내용의 ProxyFactoryBean 빈 설정 정보를 추가해 주는 부분이다.
중복 문제의 접근 방법
- JDBC API를 사용하는 DAO 코드에서 사용하는 try / catch / finally 중복 코드는 템플릿과 콜백, 클라이언트로 나누는 방법으로 해결했다. 전략 패턴과 DI를 적용한 덕분이다.
- 반복적인 위임 코드가 필요한 프록시 클래스 코드의 경우 다이내믹 프록시라는 런타임 코드 자동생성 기법을 이용했다. JDK의 다이내믹 프록시는 특정 인터페이스를 구현한 오브젝트에 대해서 프록시 역할을 해주는 클래스를 런타임 시 내부적으로 만들어준다. 그 덕분에 개발자가 일일이 인터페이스 메소드를 구현하는 프록시 클래스를 만들어서 위임과 부가기능의 코드를 중복해서 넣어주지 않아도 되게 해줬다.
- 변하지 않은 타깃으로의 위임과 부가기능 적용 여부 판단이라는 부분은 코드 생성 기법을 이용하는 다이내믹 프록시 기술에게 맡기고 변하는 부기능 코드는 별도로 만들어서 다이내믹 프록시 생성 팩토리에 DI로 제공하는 방법을 사용한 것이다.
- 이제 다이내믹 프록시가 인터페이스만 제공하면 모든 메소드에 대한 구현 클래스를 자동으로 만들듯이, 일정한 타깃 빈의 목록을 제공하면 자동으로 갓 타깃 빈에 대한 프록시를 만들어주는 방법이 있다면 ProxyFactoryBean타입 빈 설정을 매번 추가해서 프록시를 만들어내는 수고를 덜 수 있을거 같다.
빈 후처리기를 이용한 자동 프록시 생성기
스프링은 컨테이너로서 제공하는 기능 중에서 변하지 않는 핵심적인 부분 외에는 대부분 확장할 수 있도록 확장 포인트를 제공해준다. 그중에서 관심을 가질 만한 확장 포인트는 바로 BeanPostProcessor 인터페이스를 구현해서 만드는 빈 후처리기다. 빈 후처리기는 이름 그대로 스프링 빈 오브젝트로 만들어지고 난 후에, 빈 오브젝트를 다시 가공할 수 있게 해준다.
DefualtAdvisorAutoProxyCreator
- 어드바이저를 이용한 자동 프록시 생성기다. 빈 후처리기를 스프링에 적용하는 방법은 간단하다. 빈 후처리기 자체를 빈으로 등록하는 것이다.
- 스프링은 빈 후처리기가 빈으로 등록되어 있으면 빈 오브젝트으 프로퍼티를 강제로 수정할 수도 있고 별도의 초기화 작업을 수행할 수 있다.
- 빈 오브젝트를 자체를 바꿔치기 할 수 있다. 따라서 스프링이 설정을 참고해서 만든 오브젝트가 아닌 다른 오브젝트를 빈으로 등록시키는 것이 가능하다.
- DefualtAdvisorAutoProxyCreator 빈 후처리기가 등록되어 있으면 스프링은 빈 오브젝트를 만들 때마다 후처리기에게 빈을 보낸다.
- DefualtAdvisorAutoProxyCreator는 빈으로 등록된 모든 어드바이저 내의 포인트컷을 이용해 전달받은 빈이 프록시 적용 대상인지 확인한다.
- 프록시 적용 대상이면 그때는 내장된 프록시 생성기에게 현재 빈에 대한 프록시를 만들게 하고, 만들어진 프록시에 어드바이저를 연결해준다.
- 빈 후처리기는 프록시가 생성되면 원래 컨테이너가 전달해준 빈 오브젝트 대신 프록시 오브젝트를 컨테이너에게 돌려준다.
- 컨테이너는 최종적으로 빈 후처리기가 돌려준 오브젝트를 빈으로 등록하고 사용한다.
확장된 포인트컷
포인트컷
- getClassFilter() : 프록시를 적용할 클래스인지 확인해준다.
- getMethodMatcher() : 어드바이스를 적용할 메소드인지 확인해준다.
기존에 사용한 NameMatchMethodPointcut은 메소드 선별 기능만 가진 특별한 포인트컷이다. 메소드만 선별한다는 건 클래스 필터는 모든 클래스를 다 받아주도록 만들어져 있다는 뜻이다. 따라서 클래스의 종류는 상관없이 메소드만 판단한다. ProxyFactoryBean에서 포인트컷을 사용할 때는 이미 타깃이 정해져 있기 때문에 포인트컷은 메소드 선별만 해주면 그만이었다. Pointcut 선정 기능을 모두 적용한다면 먼저 프록시를 적용할 클래스인지 판단하고 나서, 적용 대상 클래스인 경우에는 어드바이스를 적용할 메소드인지 확인하는식으로 동작한다. 모든 빈에 대해 프록시 자동 적용 대상을 선별해야 하는 빈 후처리기인 DefaultAdvisorAutoProxyCreator는 클래스와 메소드 선정 알고리즘을 모두 갖고 있는 포인트컷이 필요하다. 정확히는 그런 포인트컷과 어드바이스가 결합되어 있는 어드바이저가 등록되어 있어야한다.
DefaultAdvisorAutoProxyCreator의 적용
클래스 필터를 적용한 포인트컷 작성
메소드 이름만 비교하던 포인트컷인 NameMatchMethodPointcut을 상속해서 프로퍼티로 주어진 이름 패턴을 가지고 클래스 이름을 비교하는 ClassFilter를 추가하도록 만든 것이다.
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) { // 와일드카드(*)가 들어간 문자열 비교를 지원하는 스프링 유틸리티 메소드 ex)*name, name*, *name* return PatternMatchUtils.simpleMatch(mappedName, clazz.getSimpleName()); } } }
어드바이저를 이용하는 자동 프록시 생성기 등록
- DefaultAdvisorAutoProxyCreator는 등록된 빈 중에서 Advisor 인터페이스를 구현한 것을 모두 찾는다. 그리고 생성되는 모든 빈에 대해 어드바이저의 포인트컷을 적용해보면서 프록시 적용 대상을 선정한다.
- 빈 클래스가 프록시 선정 대상이라면 프록시를 만들어 원래 오브젝트와 바꿔치기한다. 원래 빈 오브젝트는 프록시 뒤에 연결돼서 프록시를 통해서만 접근 가능하게 바뀌는 것이다. 따라서 타깃 빈에 의존한다고 정의한 다른 빈들은 프록시 오브젝트를 대신 DI 받게 될 것이다.
AOP: 에스펙트 지향 프로그래밍
애스펙트란 그 자체로 애플리케이션의 핵심기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다. 애스펙트는 부가될 기능을 정의한 코드인 어드바이스와, 어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 가지고 있다. 지금 사용하고 있는 어드바이저는 아주 단순한 형태의 애스펙트라고 볼 수 있다.
그림과 같이 핵심기능 코드 사이에 침투한 부가기능을 독립적인 모듈인 애스펙트로 구분해낸 것이다. 2차원적인 평면 구조에서는 어떤 설계 기법을 동원해도 해결할 수 없었던 것을, 3차원의 다면체 구조로 가져가면서 각각 성격이 다른 부가기능은 다른 면에 존재하도록 만들었다. 이렇게 독립된 측면에 존재하는 애스펙트로 분리한 덕에 핵심기능을 순수하게 그 기능을 담은 코드로만 존재하고 독립적으로 살펴볼 수 있도록 구분된 면에 존재하게 된 것이다. 물론 애플리케이션의 여러 다른 측면에 존재하는 부가기능은 결국 핵심기능과 함계 어우러져서 동작하게 되어 있다. 하나 이상의 부가기능이 핵심기능과 함께 동시에 동작 할 수도 있다. 결국 런타임 시에는 왼쪽의 그림처럼 각 부가기능 애스펙트는 자기가 필요한 위치에 다이내믹하게 참여하게 될 것이다. 하지만 설계와 개발은 오른쪽 그림처럼 다른 특성을 띤 애스펙트들을 독립적인 관점으로 작성하게 할 수 있다.
애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 애스펙트라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 AOP(Aspect Oriented Programming)이라고 한다.
AOP는 OOP를 돕는 보조적인 기술이지 OOP를 대체하는 새로운 개념은 아니다.
트랜잭션 정의
기본 개념인 더 이상 쪼갤 수 없는 최소 단위의 작업이라는 개념은 유효하지만 경계 안에서 진행된 작업은 커밋을 통해 모두 성공 or 롤백을 통해 모두 실패해야 한다. DefaultTransactionDefinition이 구현하고 있는 TransactionDefinition 인터페이스는 트랜잭션의 동작방식에 영향을 줄 수 있는 네 가지 속성을 정의하고 있다.
트랜잭션 전파란 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있거나 없을 때 어떻게 동작할 것인가를 결정하는 방식을 말한다.- PROPAGATION_REQUIRED
- 진행중인 트랜잭션이 없으면 새로 시작하고, 이미 시작된 트랜잭션이 있으면 이에 참여한다.
- PROPAGATION_REQURES_NEW
- 항상 새로운 트랜잭션을 시작한다.
- PROPAGATION_NOT_SUPPORTED
- 트랜잭션 없이 동작하도록 생성이 가능하며, 진행중인 트랜잭션이 존재해도 무시한다.
포인트컷 표현식과 트랜잭션 속성을 이용해 트랜잭션을 일괄적으로 적용하는 방식은 복잡한 트랜잭션 속성이 요구되지 않는 한 대부분의 상황에 잘 들어맞는다. 그런데 가끔은 클래스나 메소드에 따라 제각각 속성이 다른, 세밀하게 튜닝된 트랜잭션 속성을 적용해야 하는 경우도 있다.
대체 정책
스프링은 @Transactional을 적용할 때 4단계의 대체 정책을 이용하게 해준다. 메소드의 속성을 확인할 때 타깃 메소드, 타깃 클래스, 언언 메소드, 언언 타입의 순서에 따라서 @Transactional이 있는지 확인한다.
부여 시 속성으로 사용 없을 시 다음 대체 후보의 @Transactional 애노테이션을 찾는다.H2, @TransactionConfiguration
H2 데이터베이스를 사용한 경우 @Transactional을 이용한 테스트가 실패할 수도있다.
@TransactionConfiguration : deprecated
정리
- 트랜잭션 경계설정 코드를 분리해서 별도의 클래스로 만들고 비즈니스 로직 클래스와 동일한 인터페이스를 구현하면 DI의 확장 기능을 이용해 클라이언트의 변경 없이도 깔끔하게 분리된 트랜잭션 부가기능을 만들 수 있다.
- 트랜잭션처럼 환경과 외부 리소스에 영향을 받는 코드를 분리하면 비즈니스 로직에만 충실한 태스트를 만들 수 있다.
- 목 오브젝트를 활용하면 의존관계 속에 있는 오브젝트도 손쉽게 고립된 테스트로 만들 수 있다.
- DI를 이용한 트랜잭션의 분리는 데코레이터 패턴과 프록시 패턴으로 이해될 수 있다.
- 번거로운 프록시 클래스 작성은 JDK의 다이내믹 프록시를 사용하면 간단하게 만들 수 있다.
- 다이내믹 프록시는 스태틱 팩토리 메소드를 사용하기 때문에 빈으로 등록하기 번거롭다. 따라서 팩토리 빈으로 만들어야 한다. 스프링은 자동 프록시 생성 기술에 대한 추상화 서비스를 제공하는 프록시 팩토리 빈을 제공한다.
- 프록시 팩토리 빈의 설정이 반복되는 문제를 해결하기 위해 자동 프록시 생성기와 포인트컷을 활용할 수 있다. 자동 프록시 생성기는 부가기능이 담긴 어드바이스를 제공히는 프록시를 스프링 컨테이너 초기화 시점에 자동으로 만들어준다.
- 포인트컷은 AspectJ 포인트컷 표현식을 사용해서 작성하면 편리하다.
- AOP는 OOP만으로는 모듈화하기 힘든 부가기능을 효과적으로 모듈화하도록 도와주는 기술이다.
- 스프링은 자주 사용되는 AOP 설정과 트랜잭션 속성을 지정하는 데 사용할 수 있는 전용 태그를 제공한다.
- AOP를 이용해 트랜잭션 속성을 지정하는 방법에는 포인트컷 표현식과 메소드 이름 패턴을 이용하는 방법과 타깃에 직접 부여하는 @Transactional 사용하는 방법이 있다.
- @Transactional을 이용한 트랜잭션 속성을 테스트에 적용하면 손쉽게 DB(H2제외)를 사용하는 코드의 테스트를 만들 수 있다.
'Spring' 카테고리의 다른 글
토비의 스프링[8] 스프링이란 무엇일까? (0) 2021.07.19 토비의 스프링[7] 스프링 핵심기술과 응용 (0) 2021.04.14 토비의 스프링[5] 서비스 추상화 (0) 2021.02.25 Spring Boot Test (0) 2021.02.09 토비의 스프링[4] 예외처리 (0) 2021.01.27