public class HelloImpl implements Hello {
public String sayHello(String name) {
return "hello " + name;
}
public String sayHi(String name) {
return "hi " + name;
}
public String sayBye(String name) {
return "bye " + name;
}
}
이런 추상 인터페이스와 구현체가 존재한다.
HelloImpl을 건드리지 않고 반환되는 값을 모두 대문자로 변경하고 싶다면 이렇게 HelloImpl을 멤버로 가져와서 사용하는 다음과 같은 클래스를 만들어야 한다.
@RequiredArgsConstructor
public class HelloUpperCase implements Hello {
private final Hello hello;
@Override
public String sayHello(String name) {
return this.hello.sayHello(name).toUpperCase();
}
@Override
public String sayHi(String name) {
return this.hello.sayHi(name).toUpperCase();
}
@Override
public String sayBye(String name) {
return this.hello.sayBye(name).toUpperCase();
}
}
이거를 InvocationHandler를 사용해 바꿔보자.
@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {
private final Hello target;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getReturnType().equals(String.class)) {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
else return method.invoke(target, args);
}
}
이렇게 호출받은 메서드를 실행하면서 전후로 무언가 추가적인 작업을 할 수 있다.
나는 여기서 리턴의 타입이 String이면 대문자로 넘겨줄 수 있도록 만들었다.
@Test
public void simpleProxy2(){
Hello proxyHello = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(),
//구현할 인터페이스
new Class[]{Hello.class},
//부가기능을 담은 InvocationHandler 구현체
new HelloUpperCase(new HelloImpl())
);
Assertions.assertEquals("HELLO SEUNGKYU", proxyHello.sayHello("seungkyu"));
Assertions.assertEquals("HI SEUNGKYU", proxyHello.sayHi("seungkyu"));
Assertions.assertEquals("BYE SEUNGKYU", proxyHello.sayBye("seungkyu"));
}
Proxy.newProxyInstance를 통해 프록시가 설정된 클래스를 생성한다.
파라미터를 하나씩 살펴보자.
첫번째 파라미터
그냥 클래스로더이다. 다이나믹 프록시가 적용되는 클래스 로더를 지정하는 것이라고 한다.
두번째 파라미터
다이나믹 프록시가 구현할 인터페이스이다. 여기서 배열을 사용하는 이유는 하나 이상의 인터페이스를 구현하기 때문이다.
세번째 파라미터
부가기능을 가지고 있는 InvocationHandler 구현 오브젝트이다.
여기서 invoke 메서드는 요청받은 클래스의 메서드만 사용 가능하다.
만약 이렇게 FakeHello가 존재하고, FakeHello에도 동일한 메서드들이 존재한다면
@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {
private final Hello target;
private final FakeHello fakeTarget;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getReturnType().equals(String.class)) {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
else return method.invoke(target, args);
}
}
FakeHello를 통해서 invoke 하려고 해도, 에러가 발생한다는 것이다.
이렇게 해서 simpleProxy2를 테스트해보니 다음과 같이 성공하는 것을 볼 수 있었다.
그리고 또 궁금한 점이 생겼다.
만약 타입에 맞지 않는 값을 반환한다면?
UserService에 정수를 반환하는 메서드를 추가하고, invocationHandler를 아래와 같이 바꾸고
@RequiredArgsConstructor
public class HelloUpperCase implements InvocationHandler {
private final Hello target;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String returnValue = (String)method.invoke(target, args);
return returnValue.toUpperCase();
}
}
다음의 테스트를 실행해보았다.
@Test
public void return1Test(){
Hello proxyHello = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(),
//구현할 인터페이스
new Class[]{Hello.class},
//부가기능을 담은 InvocationHandler 구현체
new HelloUpperCase(new HelloImpl())
);
proxyHello.return1();
}
스프링을 배울수록 아... 이게 참 어려운 내용이었구나 싶었던 부분이고, 로그 관련해서 앞으로 사용할 계획이기에 이번 기회에 깊게 공부하도록 하자.
아마 가장 많이 사용할 AOP는 트랜잭션이다.
@Transactional은 기계적으로 굉장히 많이 사용했던 것 같다.
트랜잭션 자체가 어떻게 적용되는지는 저번 글에서 알 수 있었고, 이번에는 AOP 자체가 어떻게 적용되는지 알아보자.
메소드 분리
기존에 만들었던 upgradeLevels()를 보면 비즈니스 로직을 트랜잭션 설정들이 감싸고 있는 것을 볼 수 있다.
또한 비즈니스 로직은 트랜잭션에게 어떠한 값도 넘겨주지 않는다.
서로 꼭 필요하지만 굉장히 독립적이다.
그렇기에 분리가 가능해보인다.
일단 바로 분리해보자.
public void upgradeLevels() {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
this.upgradeLevelsLogic();
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
private void upgradeLevelsLogic(){
List<User> users = userDao.getAll();
for (User user : users) {
if (user.isUpgradeLevel()) upgradeLevel(user);
}
}
이렇게 트랜잭션 코드와 비즈니스 코드를 분리했다.
DI를 이용한 클래스의 분리
이제 UserService에서 트랜잭션 코드를 분리해보자.
이건 참 클래스의 의존들이 어려우니, 이해가 힘들면 계속 다시 보도록 하자.
현재
이렇게 UserService를 인터페이스로 만들고, UserServiceImpl로 구현해 사용하고 있다.
그리고 UserServiceTest에서 이거를 받아 사용하고 있던 중이다.
이 문제를 해결하기 위해서, 처음보는 방법이지만 한 번에 두 개의 UserService 인터페이스를 사용한다고 한다.
UserServiceImpl에 비즈니스 로직을 담고, UserServiceTx는 트랜잭션을 담당하도록 만들고 싶다.
그리고 이거를 다시 UserService가 사용하는..? 어려운 구조이다.
솔직히 이해는 힘들지만 직접 코드를 만들며 이해해보자.
일단 UserService 인터페이스이다.
public interface UserService {
void upgradeLevels();
void add(User user);
}
이렇게 add(), upgradeLevels() 2개의 메서드를 가지고 있었다.
그리고 UserServiceImpl이다.
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{
private final UserDao userDao;
@Getter
private final MailSender mailSender;
public void upgradeLevels() {
List<User> users = userDao.getAll();
for (User user : users) {
if (user.isUpgradeLevel()) upgradeLevel(user);
}
}
public void add(User user) {
if(user.getLevel() == null)
user.setLevel(Level.BRONZE);
userDao.add(user);
}
private void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user);
mailSender.sendUpgradeEmail(user);
}
}
기존에 존재하던 트랜잭션 코드를 모두 제거했다.
트랜잭션 설정은 UserServiceTx에서 하기 때문에, 이제 관련 코드를 제거해도 괜찮다.
이렇게 만드니 막상 과거의 코드로 돌아간 것 같다.
이제 트랜잭션 설정을 해보자.
@RequiredArgsConstructor
public class UserServiceTx implements UserService {
private final UserService userService;
private final PlatformTransactionManager platformTransactionManager;
@Override
public void upgradeLevels() {
TransactionStatus transactionStatus = this.platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
try
{
userService.upgradeLevels();
platformTransactionManager.commit(transactionStatus);
this.platformTransactionManager.commit(transactionStatus);
}
catch(Exception e)
{
this.platformTransactionManager.rollback(transactionStatus);
throw e;
}
}
@Override
public void add(User user) {
this.userService.add(user);
}
}
이렇게 UserService를 가져와서 트랜잭션 설정을 하며 UserService의 메서드를 호출하는 방식이다.
이제부터 클라이언트는 UserServiceTx를 사용해야 하기에, 기존의 UserService를 주입하는 부분에는 UserServiceTx를 주입해주고 UserServiceTx의 UserService에는 내부의 메서드를 사용할 수 있도록 UserServiceImpl을 주입해준다.
일단 테스트를 위해서는 서버가 준비되어 있어야 한다.(지금 SMTP 서버가 없기는 하다...)
그리고 테스트를 할 때마다 메일이 발송되는 것도 웃기지 않은가?
사실 메일 서비스는 테스트를 할 필요가 없다.
javaMail은 스프링에서 검증된 API이며
사실 서버로 보내진 경우에 메일이 보내지지 않는 것은, 여기서 테스트한다고 바뀌지 않는다.
해당 서버의 문제까지 테스트를 할 필요는 없지 않은가.
그렇기에 테스트 단계에서는 실제로는 메일이 전송되지 않는 다른 클래스를 사용해도 괜찮다.
테스트를 위한 서비스 추상화
지금은 메일 전송을 다음과 같이 사용하고 있다.
public class UserServiceImpl implements UserService{
private final UserDao userDao;
private final PlatformTransactionManager transactionManager;
private final MailSender mailSender;
}
늘 그렇듯 이러한 부분은 추상화 인터페이스로 만들고 거기에 의존해야 후에 교체가 가능하다.
그렇기에 MailSender를 인터페이스로 만들고, MailSenderImpl로 구현해서 빈에 등록했다.
테스트는 굳이 메일이 전송되지 않기를 바란다
그렇기에 그냥 로그만 작성하도록 클래스를 구현해서 상속받았다.
public class MailSenderTest implements MailSender {
public void sendUpgradeEmail(@NonNull User user) {
System.out.println("sendUpgradeEmail 호출");
System.out.println(user);
}
public void sendUpgradeEmail(@NonNull User[] users) {
System.out.println("sendUpgradeEmail 호출");
for (User user : users) System.out.println(user);
}
}
이렇게 하고 테스트를 실행해보니, 이렇게 성공적으로 로그가 남는 것을 볼 수 있었다.
현재의 MailSender는 다음과 같이 이용하고 있다.
MailSenderImpl은 JavaMail을 이용하고 있으며, 만약 다른 API를 사용해야 하는 경우 MailSender를 또 구현해서 등록하면 된다.
이제 여기서 안타까운 부분이 있다.
만약 1~5번 유저를 업그레이드 하던 중, 3번 유저에게 메일만 전송하고 업그레이드가 되지 않았다면?
이런 부분에서 무결성에 대한 문제가 나올 수 있다.
이런 문제에 관해서는 2가지 방법이 있다고 한다.
업그레이드 사용자를 별도의 목록에 저장해두고, 업그레이드가 끝나면 메일을 일괄 전송
MailSender 자체를 확장해서 트랜잭션을 적용
일단 이 방법 중 가능한 방법을 사용하면 될 거 같고, 이런 JavaMail조차 추상화하면 큰 장점이 있다.
그렇기에 적극적으로 추상화의 방법을 사용해보도록 하자.
테스트 대역
테스트에서는 메일이 전송되지 않고, 단순하게 로그만 남도록 했다.
스프링의 DI를 통해 테스트에서는 다른 빈을 주입받도록 했기에 가능한 일이었다.
이렇게 테스트용으로 사용되는 클래스들이 존재한다.
이렇게 UserService와 같이 테스트의 대상이 의존하는 클래스들이다.
이렇게 빠르게 간편하게 테스트를 도와주도록 주입해주는 클래스들을 테스트 대역이라고 부른다.
현재 우리의 MailSenderTest는 모든 메서드가 void지만, 가끔은 테스트 대역들이 값을 리턴해야 하는 경우가 있다.
그럴 때는 대역들에게 테스트에 필요한 값들을 리턴하도록 만들 수 있다.
필요하다면 대역들이 입력받는 값도 검증해 볼 수 있다.
목 오브젝트라고 하며 테스트가 정상적으로 실행되도록 도와주면서, 테스트의 결과를 검증하는데 사용할 수도 있다.
사실 mockito를 사용하면 바로 끝나지만, 그래도 MailSenderTest를 수정해서 목 오브젝트 느낌으로 만들어보자.
@Getter
public class MailSenderTest implements MailSender {
private final List<String> history = new ArrayList<>();
public void sendUpgradeEmail(@NonNull User user) {
history.add(user.getEmail());
}
public void sendUpgradeEmail(@NonNull User[] users) {
System.out.println("sendUpgradeEmail 호출");
for (User user : users) System.out.println(user);
}
}
이렇게 메일을 전송할 때마다, 일단 내부 리스트에서 저장을 해두었다.
그리고 메일을 모두 전송한 후, history와 맞는지 체크해보았다.
@Getter
public class MailSenderTest implements MailSender {
private final List<String> history = new ArrayList<>();
public void sendUpgradeEmail(@NonNull User user) {
history.add(user.getEmail());
}
public void sendUpgradeEmail(@NonNull User[] users) {
System.out.println("sendUpgradeEmail 호출");
for (User user : users) System.out.println(user);
}
}