본문 바로가기

Spring/토비의 스프링 3.1

5.4 메일 서비스 추상화


요구사항 : 레벨이 업그레이드 되는 사용자에게 안내 메일 발송 기능 추가

5.4.1 JavaMail을 이용한 메일 발송 기능

사용자의 이메일 정보를 관리해야함

  • DB에 email 필드 추가
  • User 클래스에 email 프로퍼티 추가
  • UserDao의 UserMapper와 insert(), update()에 email 필드 처리
  • UserDaoTest 수정

JavaMail 메일 발송

upgradeLvl()에서 메일 발송 메소드를 호출

public class UserService {
    protected void upgradeLvl(User user) {
        user.upgradeLvl();
        userDao.update(user);
        sendUpgradeEmial(user);
    }

    private void sendUpgradeEmial(User user) {
        Properties props = new Properties();
        props.put("mail.smtp.host", "mail.ksug.org");
        Session s = Session.getInstance(props, null);

        MimeMessage message = new MimeMessage(s);
        try {
            message.setFrom(new InternetAddress("useradmin@ksug.org"));
            message.setRecipient(Message.RecipientType.TO, new InternetAddress(user.getEmail()));
            message.setSubject("Upgrade 안내");
            message.setText("사용자님의 등급이 " + user.getLvl().name() + "로 업그레이드되었습니다.");

            Transport.send(message);
        } catch (AddressException e) {
            throw new RuntimeException(e);
        } catch (MessagingException e) {
            throw new RuntimeException(e);
        }
    }
}

SMTP 프로토콜을 지원하는 메일 서버가 준비되어 있다면 정상적으로 동작할 것이고, 메일이 발송될 것이다.

5.4.2 JavaMail이 포함된 코드의 테스트

메일 서버가 준비되어 있지 않다면?

  • 테스트는 실패
java.lang.RuntimeException: javax.mail.MessagingException: Could not connect to SMTP host: mail.ksug.org, port: 25;

테스트를 하면서 매번 메일을 발송되게 하는 것이 바람직한가?

  • 메일 발송 작업은 부하가 큰 작업
  • 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과

메일 서버를 사용하지 않고 테스트 메일 서버를 이용해야 한다.

  • JavaMail과 연동해서 메일 전송 요청을 받는 것까지만 담당

JavaMail을 사용하지 않고, 테스트용 JavaMail을 이용한다면 JavaMail을 직접 구동시킬 필요도 없다.

  • 운영시에는 JavaMail을 이용
  • 테스트 시에는 JavaMail을 이용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록

5.4.3 테스트를 위한 서비스 추상화

JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들어서 사용하면 됨

JavaMail을 이용한 테스트의 문제점

문제는 JavaMail의 API는 이 방법을 적용할 수 없다.

  • 싱글톤으로 구현되어 있는 Session은 인터페이스가 아닌 클래스이며, 생성자 역시 private으로 선언되어 직접 생성도 불가
  • JavaMail : 확장이나 지원이 불가능

포기할까? No, 서비스 추상화를 이용하면 됨

  • 서비스 추상화 : 테스트하기 힘든 구조의 API를 테스트하기 좋게 만드는 방법을 제공

메일 발송 기능 추상화

JavaMail의 서비스 추상화 인터페이스

public interface MailSender {
    void send(SimpleMailMessage simpleMessage) throws MailException;
    void send(SimpleMailMessage[] simpleMessages) throws MailException;
}

SimpleMailMessage라는 인터페이스를 구현한 클래스에 담긴 메일을 전송하는 메소드로 구성

  • JavaMailSenderImpl 클래스를 이용하면 됨
public class UserService {
    private void sendUpgradeEmial(User user) {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost("mail.server.com");

        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(user.getEmail());
        mailMessage.setFrom("useradmin@ksug.org");
        mailMessage.setSubject("Upgrade 안내");
        mailMessage.setText("사용자님의 등급이 " + user.getLvl().name() + "로 업그레이드되었습니다.");

        mailSender.send(mailMessage);
    }
}

JavaMail에서 발생하는 Exception을 런타임 예외로 포장해서 던져주기 때문에 예외처리 코들가 사라졌다.

추가할 라이브러리
com.sum.javax.activation-1.2.0
com.sun.mail-1.5.6
org.springframework.context.support-{org.springframwork-version}

SimpleMailMessage 오브젝트를 만들어 JavaMailSender 타입 오브젝트의 send() 메소에 전달

  • 아직은 테스트용 오브젝트로 대체할 수 없음
  • JavaMailSenderImpl 클래스의 오브젝트를 코드에서 직접 사용하기 때문

스프링 DI 적용

  • sendUpgradeEmail() 메소드에 JavaMailSenderImpl 클래스가 구현한 MailSender 인터페이스만 남김
  • UserService에 MailSender 인터페이스 타입의 변수를 만들고, 수정자 메소드를 추가
public class UserService {
    // ...

    private MailSender mailSender;

    public void setMailSender(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    private void sendUpgradeEMail(User user) {
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(user.getEmail());
        mailMessage.setFrom("useradmin@ksug.org");
        mailMessage.setSubject("Upgrade 안내");
        mailMessage.setText("사용자님의 등급이 " + user.getLvl().name() + "로 업그레이드되었습니다.");

        this.mailSender.send(mailMessage);
    }
}

설정파일 변경

<bean id="userService" class="com.david.tobysspring.user.service.UserService">
    <property name="userDao" ref="userDao" />
    <property name="transactionManager" ref="transactionManager" />
    <property name="mailSender" ref="mailSender" />
</bean>

<bean id="mailSender" class="org.springframework.mail.javamail.JavaMailSenderImpl">
    <property name="host" value="mail.server.com" />
</bean>

테스트용 메일 발송 오브젝트

mailSender 빈의 host 프로퍼티에 메일 서버를 지정

  • 테스트 실행 시 지정된 메일 서버로 메일이 발송됨

JavaMail을 사용하고 싶지 않아!

  • 테스트 메일 전용 클래스 생성
  • MailSender 인터페이스를 구현
  • 테스트 시에는 메일이 발송될 필요가 없음 (빈 클래스를 구현)
public class UserServiceTest {
    static class DummyMailSender implements MailSender {
        @Override
        public void send(SimpleMailMessage simpleMessage) throws MailException {
        }

        @Override
        public void send(SimpleMailMessage... simpleMessages) throws MailException {
        }
    }
}

DummyMailSender 역시 테스트에서만 사용할 것이므로 내부 클래스로 정의했다.

설정 파일도 DummyMailSender로 변경해준다.

  • 내부 클래스를 참조할 때는 '$' 를 사용한다.
  • host는 더 이상 필요 없으므로 프로퍼티를 삭제해준다.
<bean id="mailSender" class="com.david.tobysspring.user.service.UserServiceTest$DummyMailSender" />

UserServiceTest에서는 수동 DI를 해주어야 한다.

public class UserServiceTest {
    @Autowired MailSender mailSender;

    @Test
    public void upgradeAllOrNothing() throws Exception {
        // ...
        testUserService.setMailSender(mailSender);
        // ...
    }
}

UserServiceTest 테스트는 성공적으로 끝난다.

  • 메일이 실제로 발송될 일은 없다.
  • 메일 전송 메소드가 호출됐는지 확인해보려면 발송정보를 콘솔에 찍어보는 방법이 있다.

테스트와 서비스 추상화

서비스 추상화

  • 일반적으로 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 일관성 있는 접근 방법을 제공하는 것을 의미
  • JavaMail과 같이 테스트를 어렵게 만드는 API를 사용할 때도 유용하게 사용됨
  • JavaMail이 아닌 다른 메세징 서버의 API를 이용하는 경우에도 MailSender 구현 클래스를 만들어서 DI 해주면 됨

비즈니스 로직이 바뀌지 않는 한 UserService는 수정할 필요가 없음

문제점 : 트랜잭션 개념이 빠져있음

  • 레벨 업그레이드에 트랜잭션이 적용되어 있으므로 메일 발송 기능에도 트랜잭션을 적용해야 함
    1. 발송 대상을 별도의 목록에 저장 : 사용자 관리 비즈니스 로직과 메일 발송 트랜잭션 기술 부분이 섞임
    2. MailSender를 확장해서 메일 전송에 트랜잭션 개념을 적용 : 서로 다른 종류의 작업을 분리해 처리가 가능

5.4.4 테스트 대역

테스트 할 대상이 의존하고 있는 오브젝트를 DI를 통해 바꿔치기 함

  • 테스트용 DB / DummyMail 등
  • 테스트 환경에서 유용하게 사용 가능

의존 오브젝트의 변경을 통한 테스트 방법

원래 UserDao는 운영 시스템에서 사용하는 DB와 연결돼서 동작함

  • 테스트에서는 운영 DB의 연결이나 WAS의 DB 풀링 서비스는 번거로울 뿐
  • 그럼에도 불구하고 DB는 반드시 있어야 하므로 가벼운 버전을 이용

UserService는 메일 전송 기능을 이용

  • 메일 전송 기능을 아예 뗄 수는 없음
  • 테스트에 지장을 주지 않기 위해 DummyMailSender를 도입

테스트 대상이 되는 오브젝트가 또 다른 오브젝트에 의존하는 일은 매우 흔함

  • 의존이란 종속되거나 기능을 사용한다는 의미
  • 간단한 오브젝트의 테스트를 위해 너무 많은 작업이 뒤 따름

해결책

  1. UserDao : 환경 자체를 간단한게 만듬
  2. UserService : 아무런 일도 하지 않는 빈 오브젝트로 대체

테스트 대역의 종류와 특징

테스트 대역 : 테스트용으로 사용되는 특별한 오브젝트

  • 대부분 테스트 대상인 오브젝트의 의존 오브젝트
  • UserDao의 DataSource, UserService의 DummyMailSender

테스트 스텁 : 대표적인 테스트 대역

  • 테스트 대상 오브젝트의 의존객체로 존재
  • 테스트 동안 코드가 정상적으로 수행할 수 있도록 돕는 것

DummyMailSender는 upgradeLvls()가 동작하는 동안 사용이 되도 그만, 안 되는 그만이다. 그러나 일반적으로 테스트는 시스템에 어떤 입력을 주었을 때 기대하는 출력이 나오는 지를 검증한다.

  • 스텁을 이용하면 간접적인 입력 값을 지정할 수도 있고, 간접적인 출력 값을 받게 할 수도 있다.

테스트 대상의 오브젝트의 메소드가 돌려주는 리턴 값 뿐만 아니라 의존 오브젝트에 넘기는 값과 그 행위 자체를 검증하고 싶다면?

  • assertThat()으로는 불가능
  • 목 오브젝트(mock object)를 이용

목 오브젝트를 이용한 테스트

UserServiceTest에 목 오브젝트 개념을 적용

  • upgradeAllOrNothing()은 메일의 전송 여부에 관심이 없으므로 DummyMailSender를 사용하면 된다.
  • 하지만 upgradeLvls() 테스트는 메일 전송 자체에 대해서도 검증할 필요가 있다.
  • 목 오브젝트를 이용하면 메일 발송 여부에 대해 확인이 가능

목 오브젝트를 이용한 upgradeLvls()

  1. MailSender를 대체할 새로운 클래스 생성
    • UserService의 코드가 정상적으로 수행되도록 돕는 역할이 우선
    • 테스트 대상이 넘겨주는 출력 값을 보관해두는 기능 추가
    • static 멤버 클래스로 정의
public class UserServiceTest {
    static class MockMailSender implements MailSender {
        // UserService로부터 전송 요청을 받은 메일 주소를 저장해두고 이를 읽을 수 있게 한다.
        private List<String> requests = new ArrayList<String>();

        public List<String> getRequests() {
            return requests;
        }

        @Override
        public void send(SimpleMailMessage mailMessage) throws MailException {
            // 전송 요청을 받은 이메일 주소를 저장해둔다.
            // 간단하게 첫 번째 수신자 메일 주소만 저장했다.
            requests.add(mailMessage.getTo()[0]);
        }

        @Override
        public void send(SimpleMailMessage... mailMessage) throws MailException {
        }
    }
}

MockMailSender 역시 단순하다.

  • UserService가 send() 메소드를 통해 자신을 불러 메일 전송 요청을 보냈을 때 관련 정보를 저장해두는 기능
  • 어차피 한 명씩 보내기 때문에 첫 번째 수신자 메일 주소를 꺼내온다.

upgradeLvls() 테스트 수정

public class UserServiceTest {
    @Test
    // 컨텍스트의 DI 설정을 변경하는 테스트라는 것을 알려줌
    @DirtiesContext
    public void upgradeLvls() throws Exception {
        userDao.deleteAll();

        for (User user : users) {
            userDao.add(user);
        }

        // 메일 발송 결과를 테스트할 수 있도록 목 오브젝트를 만들어 userService 의존 오브젝트로 DI
        MockMailSender mockMailSender = new MockMailSender();
        userService.setMailSender(mockMailSender);

        // 업그레이드 테스트 메일 발송이 일어나면 MockMailSender 오브젝트의 리스트에 그 결과가 저장됨
        userService.upgradeLvls();

        checkLvlUpgraded(users.get(0), false);
        checkLvlUpgraded(users.get(1), true);
        checkLvlUpgraded(users.get(2), false);
        checkLvlUpgraded(users.get(3), true);
        checkLvlUpgraded(users.get(4), false);

        // 목 오브젝트에서 저장한 메일 수신자 목록을 가져와 업그레이드 대상과 일치하는 지 확인
        List<String> request = mockMailSender.getRequests();
        assertThat(request.size(), is(2));
        assertThat(request.get(0), is(users.get(1).getEmail()));
        assertThat(request.get(1), is(users.get(3).getEmail()));
    }
}

테스트 프로세스

  1. DummyMailSender를 대신해서 사용할 메일 전송 검증용 목 오브젝트를 준비
  2. MockMailSender 오브젝트 생성 후 수동 DI
  3. 업그레이드 검증 과정
  4. 목 오브젝트로부터 getRequests()를 호출해서 메일 주소가 저장된 리스트를 가져옴
  5. 두 번째와 네 번째 유저가 업그레이드가 되었고, 메일이 발송되었음을 확인


' Spring > 토비의 스프링 3.1' 카테고리의 다른 글

6장. AOP  (0) 2019.02.07
5.5 정리  (0) 2019.02.07
5.3 서비스 추상화와 단일 책임 원칙  (0) 2019.02.06
5.2 트랜잭션 서비스 추상화  (0) 2019.02.06
5.1 사용자 레벨 관리 기능 추가  (0) 2019.02.06