아마도 이번 글이 스프링 이메일 확장하기 시리즈의 마지막이 될 것 같습니다. 이전까지 작업한 내용들을 간략히 간추리자면 다음과 같습니다.

스프링 이메일 확장하기 1 - SimpleMailMessage를 확장하는 방법.
스프링 이메일 확장하기 2 - MimeMessage, JavaMailSender, 별도의 메일 클래스 계층 구조, 이 셋을 가지고 메일을 전송하는 SendMailService를 사용하는 방법.

이번에는 SendMailService를 StudyService와 MemberService 같은 곳에 혼재되어 나타나는 것을 Aspect로 분리한 것에 대해 이야기하렵니다. (보여드리는 편이 서로 편하겠지만..)

우선 메일 서비스 때문에 불편한 점들이 속속 생기기 시작했습니다. 테스트 하기가 불편했습니다. 단위테스트야 목킹한 다음에 대충 무시해주면 그만이지만, 통합테스트일 경우 스터디나 모임 추가/변경 테스트를 할 때 마다 테스트성 메일이 전송되는데, 이 때 생기는 오버헤드와 불필요한 메일 메시지가 상당히 거슬렸습니다. 그래서 대부분 단위테스트로 바꿨습니다.

그러나 단위테스트를 한다고 해서 편해지는 것도 아니었습니다. StudyService에서 SendMailService를 사용하고 있는 이상 목킹을 하고 해당 목이 잘 동작하는지 테스트를 해야했죠. 결국 테스트 할 분량이 늘어나서 테스트를 작성할 때 귀찮았습니다. 비스무리하게 메일 전송하는 부분을 스터디, 모임 추가/변경 때마다 테스트를 목킹해줘야 했으니 말이죠.

다음은 코딩하기가 번거로웠습니다. 지금까지 메일 서비스의 큼직한 설계 변경 두 번으로 StudyService와 MemberService는 계속 고쳐져야했습니다. 아니.. 메일 전송 관련 부분을 고치는데 얘네들이 이렇게 고생을 많이 해도 되는걸까? 뭔가 잘못된거 아닌가.. 하는 생각이 들었습니다. (OCP 원칙에 위배된 코드였던게죠.)

결론은.. 그래.. 빼자.. 빼..

어떻게?

AOP!!

그래서 SendMailAspect라는 것을 하나 만들었습니다. 그리고 StudyService와 MemberService에서 참조하고 있던 SendMailService를 없애고, SendMailAspect 안으로 넣어줬습니다.

SendMailAspect에 포인트컷을 정의하고, 어드바이스를 정의해서 간단하게 메일 전송 서비스를 AOP로 구현할 수 있었습니다. 그러나 곧 또다른 요구사항이 생기기 시작했습니다.

스터디, 모임 추가/변경 할 때 트위터에 메시지를 올리자는 것이었습니다. 그리고 재버 애플리케이션을 이용해서 구글 토크로 메시지도 전송하자고 했습니다.

캬.. 멋지구나!!

아.. 이름을 바꿔야겠다. NotificationAspect로 바꾸고 그 안에 TwitterService와 GoogleTalkService를 추가하면 되겠구나!! 그래서 이름을 NotificationAspect로 수정했습니다.

@Aspect
public class NotificationAspect {
   
    @Autowired SendMailService sendMailService;
   
    @Pointcut("execution(* *..*.StudyService.addStudy(..))") void addStudyPointcut(){}
    @Pointcut("execution(* *..*.StudyService.updateStudy(..))") void updateStudyPointcut(){}
    @Pointcut("execution(* *..*.StudyService.addMeeting(..))") void addMeetingPointcut(){}
    @Pointcut("execution(* *..*.StudyService.updateMeeting(..))") void updateMeetingPointcut(){}
    @Pointcut("execution(* *..*.MemberService.add(..))") void addMemberPointcut(){}

    @AfterReturning(pointcut = "addStudyPointcut() && args(study)", argNames="study")
    public void sendMailAfterAddStudy(Study study){
        sendMailService.sendMail(new StudyMail(study, StudyStatus.OPEN));
    }

    @AfterReturning(pointcut = "updateStudyPointcut() && args(study)", argNames="study")
    public void sendMailAfterUpdateStudy(Study study){
        if(study.getStatus() != StudyStatus.ENDED)
            sendMailService.sendMail(new StudyMail(study, StudyStatus.UPDATED));
    }
   
    @AfterReturning(pointcut = "addMeetingPointcut() && args(study, meeting)", argNames="study, meeting")
    public void sendMailAfterAddMeeting(Study study, Meeting meeting){
        sendMailService.sendMail(new MeetingMail(study, meeting, MeetingStatus.OPEN));
    }

    @AfterReturning(pointcut = "updateMeetingPointcut() && args(meeting)", argNames="meeting")
    public void sendMailAfterUpdateMeeting(Meeting meeting){
        if(meeting.getStatus() == MeetingStatus.OPEN)
            sendMailService.sendMail(new MeetingMail(meeting, MeetingStatus.UPDATED));
    }

    @AfterReturning(pointcut = "addMemberPointcut() && args(member)", argNames="member")
    public void sendMailAfterAddMember(Member member){
        sendMailService.sendMail(new ConfirmMail(member));
    }
   
}

이제는 TwitterService와 GoogleTalkService만 위 애스팩트에 추가해서 넣으면 됩니다.

차후에 세미나 정보가 추가됐을 때 메시지를 보내달라는 요구사항이 생길 수도 있는데, 그 때도 위에 있는 애스팩트만 고치면 되지 세미나 서비스는 건드릴 필요가 없어졌습니다.

이렇게 좋은 점만 있었던 것은 아닙니다. 개인적으로 애스팩트를 개발할 때 가장 필요하다고 생각하는 테스트가 바로 포인트컷 테스트였는데, 그걸 못했었습니다. 어떻게 해야할지 떠오르지가 않더군요. 매번 애플리케이션을 돌려가며 확인하는것은 정말 너무 노가다이고, STS의 AspectJ 툴이 보여주는 위빙 포인트로는 만족하지 못하겠고 말이죠.

그런데.. 오늘.. 그동안 비밀리에 베타리딩 중이던 책을 읽다가 딱.. 제가 원하던 코드를 볼 수 있었습니다. 그 부분을 참조하여 포인트컷이 내가 원하는 메서드에 걸리는지 확인하는 테스트를 작성했습니다.

    @Test
    public void pointcuts() throws Exception {
        checkPointcutMatches(NotificationAspect.class, "addStudyPointcut",
                StudyService.class, "addStudy", Study.class);
        checkPointcutMatches(NotificationAspect.class, "updateStudyPointcut",
                StudyService.class, "updateStudy", Study.class);
        checkPointcutMatches(NotificationAspect.class, "addMeetingPointcut",
                StudyService.class, "addMeeting", Study.class, Meeting.class);
        checkPointcutMatches(NotificationAspect.class, "updateMeetingPointcut",
                StudyService.class, "updateMeeting", Meeting.class);
        checkPointcutMatches(NotificationAspect.class, "addMemberPointcut",
                MemberService.class, "add", Member.class);
    }

    private void checkPointcutMatches(Class<?> aspectClass,
            String pointcutMethodName, Class<?> targetClass,
            String targetMethodName, Class<?>... args)
            throws SecurityException, NoSuchMethodException {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression((String) AnnotationUtils
                .getValue(AnnotationUtils.findAnnotation(ReflectionUtils
                        .findMethod(aspectClass, pointcutMethodName),
                        Pointcut.class)));

        assertThat(pointcut.matches(targetClass), is(true));
        assertThat(pointcut.matches(targetClass.getMethod(targetMethodName,
                args), targetClass), is(true));
    }

인라인 리팩토링을 너무 많이 해서 그런지... 다소 복잡해 보이는 코드지만, 방법은 무척 간단했습니다. 단.. 테스트를 조금 편하게 하기 위해 불필요하게(여러 어드바이스에서 공용으로 사용할 포인트컷이 아님에도 불구하고) pointcut을 별도로 정의한 것이 조금 맘에 걸리기는 하지만, 그 정도는 뭐.. 애교로..ㅎㅎ 사실 더 정확하게 포인트컷을 테스트 하려면 애스팩트의 코드를 다음과 같이 바꿔야 합니다.

@Aspect
public class NotificationAspect {
   
    @Autowired SendMailService sendMailService;
   
    @Pointcut("execution(* *..*.StudyService.addStudy(springsprout.domain.study.Study))") void addStudyPointcut(){}
    @Pointcut("execution(* *..*.StudyService.updateStudy(springsprout.domain.study.Study))") void updateStudyPointcut(){}
    @Pointcut("execution(* *..*.StudyService.addMeeting(springsprout.domain.study.Study, springsprout.domain.study.Meeting))") void addMeetingPointcut(){}
    @Pointcut("execution(* *..*.StudyService.updateMeeting(springsprout.domain.study.Meeting))") void updateMeetingPointcut(){}
    @Pointcut("execution(* *..*.MemberService.add(springsprout.domain.Member))") void addMemberPointcut(){}

    @AfterReturning(pointcut = "addStudyPointcut()", argNames="springsprout.domain.study.Study")
    public void sendMailAfterAddStudy(Study study){
        sendMailService.sendMail(new StudyMail(study, StudyStatus.OPEN));
    }

    @AfterReturning(pointcut = "updateStudyPointcut()", argNames="springsprout.domain.study.Study")
    public void sendMailAfterUpdateStudy(Study study){
        if(study.getStatus() != StudyStatus.ENDED)
            sendMailService.sendMail(new StudyMail(study, StudyStatus.UPDATED));
    }
   
    @AfterReturning(pointcut = "addMeetingPointcut()", argNames="springsprout.domain.study.Study, springsprout.domain.study.Meeting")
    public void sendMailAfterAddMeeting(Study study, Meeting meeting){
        sendMailService.sendMail(new MeetingMail(study, meeting, MeetingStatus.OPEN));
    }

    @AfterReturning(pointcut = "updateMeetingPointcut())", argNames="springsprout.domain.study.Meeting")
    public void sendMailAfterUpdateMeeting(Meeting meeting){
        if(meeting.getStatus() == MeetingStatus.OPEN)
            sendMailService.sendMail(new MeetingMail(meeting, MeetingStatus.UPDATED));
    }

    @AfterReturning(pointcut = "addMemberPointcut()", argNames="springsprout.domain.Member")
    public void sendMailAfterAddMember(Member member){
        sendMailService.sendMail(new ConfirmMail(member));
    }

이전 애스팩트에서는 Advice에서 포인트컷을 다시 한 번 조합하기 때문에 실제로 어드아비스가 엉뚱한 곳에 적용된다던지.. 원하는 곳에 적용이 되지 않는 등의 문제가 발생할 수도 있습니다.

따라서, 애스팩트를 만들 때 테스트 가이드로

1. 포인트컷은 항상 @Pointcut을 이용하여 별도의 메서드로 정의하고 어드바이스에서는 포인트컷 메서드 이름만 참조하고, 조합은 하지 않는다. 조합이 필요할 때는 또 다른 @Pointcut을 정의하여 사용한다.

또는

2. 포인트컷 테스트는 어드바이스에 정의된 것을 가지고 테스트한다.

라고 정하던지 해야할 것 같습니다. 후자로 한다면 조금 복잡해지는 부분(여러 애노테이션 중에서 포인트컷 표현식을 가져오는 부분)이있어서 저라면 1번을 택할 겁니다.

Anyway!.. 이상으로 스프링 이메일 확장하기는 종료합니다.
다음에는 Twitter 서비스 만들기나 구글 토크 메시징 서비스 만들기 등의 글을 연재할지도... 바쁘면 안 할지도...

ps: 비밀리에 베타리딩 중인 책의 저자님 감사합니다. (__)/