본문 바로가기

토비의 스프링 정리

토비의 스프링 - 5.4 메일 서비스 추상화

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

  • 새로운 요구사항 등장
  • 레벨 업그레이드된 유저에게 안내 메일을 보내는 기능 추가
  • DB에 email 필드 추가
  • UserDao의 userMapper, insert(), update()에 emial 필드 처리 추가
  • 테스트 데이터를 맞게 준비 및 등록, 수정, 조회에서 테스트 코드 수정

💡 앞으로 할 부분에 email 부분을 굳이 필요로 하지 않기 때문에 추가 안할것임

 

5.4.2 JavaMail 발송

  • JavaMail을 이용해 메일을 발송하는 전형적인 코드
  • 단순한 예제이므로 한글 인코딩 부분 생략
  • SMTP 프로토콜을 지원하는 메일 전송 서버가 준비 되었다면 정상적으로 실행될 것임
public class UserService {
    ...
    protected void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
        //sendUpdateEmail(user); 
    }
    private void sendUpgradeEmail(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.addRecipient(Messasge.RecipientType.TO,
                    new InternetAddress(user.getEmail()));
            message.setSubject("Upgrade 안내");
            message.setText("사용자님의 등급이 " + user.getLevel().name() + 
																"로 업그레이드 되었습니다.");
            Transport.send(message);
        } catch (AddressException e){
            throw new RuntimeException(e);
        } catch (MessagingException e){
            throw new RuntimeException(e);
        } catch (UnsupportedEncodingException e){
            throw new RuntimeException(e);
        }
    }
}

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

  • 메일 전송 서버는 매우 부하가 큰 작업임
  • 메일 서버가 준비되지 않으면 코드가 정상적으로 동작하는지 알 수 없음
  • 또한, 서버가 준비되었다 해도 테스트 할 때 마다 메일 서버를 동작시키는 것은 옳지 못함
  • 메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능일 뿐임
  • 따라서, 업그레이드 발생되고 DB에 잘 반영 되는지 만큼 중요하지 않음
  • 또한, 메일을 발송했다 해도 목적지에 실제로 도착을 했는지 확인하지 못하므로 메일 발송 테스트는 불가능하다라고 할 수 있음
  • 하지만, 메일 서버는 충분히 테스트된 시스템이기 때문에 JavaMail을 통해 메일 서버까지만 잘 전달이 되면 별문제 없이 메일이 잘 전송됐다고 믿어도 충분함
  • 즉, 테스트용 메일 서버를 만들어 해당 서버까지 잘 전달이 되는지만 확인

  • SMTP라는 표준 메일 발송 프로토콜로 메일 서버에 요청이 전달만 되면 정상적으로 작동하는 것임
  • 더 나아가, UserServiceJavaMail 사이에도 적용할 수 있음
  • JavaMail API은 자바의 표준 기술이고, 수많은 시스템에서 사용 및 검증된 모듈임
  • 즉, JavaMail를 통해 요청이 들어가는 보장만 있으면 굳이 테스트를 할 때마다 실제 JavaMail을 구동할 필요가 없음

5.4.4 JavaMail을 이용한 테스트의 문제점 해결방안

  • JavaMail의 핵심 API에는 인터페이스로 만들어져서 구현을 바꿀 수 없음
  • JavaMail은 Session 클래스를 이용하여 메일 메시지를 생성할 수 있음
  • Session은 인터페이스가 아닌 클래스로, 생성자가 모두 private여서 직접 생성 불가함
  • Session은 스태틱 팩토리 메소드를 이용해야만 오브젝트를 만들 수 있음
  • Session 클래스는 상속이 불가능한 final 클래스
  • 메시지를 작성하는 MailMessage와 전송을 담당하는 Transport도 Session과 마찬가지임
  • JavaMail의 구현을 테스트용으로 바꿔치기 하는것은 매우 어려움
  • 물론, JavaMail처럼 테스트 하기 힘든 구조의 API를 테스트하기 좋게 만드는 방법이 있음
  • 서비스 추상화를 이용하면 됨
  • 스프링에서 JavaMail의 문제점을 해결한 JavaMail에 대한 추상화 기능 제공하고 있음
Session s = Session.getInstance(props, null);
implementation group: 'javax.mail', name: 'com.springsource.javax.mail', version: '1.4.0'
implementation group: 'org.springframework', name: 'spring-context-support', version: '3.0.7.RELEASE'
compileOnly group: 'javax.activation', name: 'com.springsource.javax.activation', version: '1.1.0'
//mail 라이브러리에 존재
package org. springframework.mail;

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

5.4.5 스프링의 MailSender를 이용한 메일 발송 메소드

  • JavaMailSender 구현 클래스를 사용한 메일 발송용 코드임
  • JavaMail을 처리하는 중에 발생하는 예외를 런타임 예외인 MailException으로 포장함
  • try/catch를 사용하지 않아도 됨
  • 하지만, 아직 JavaMail API를 사용하지 않는 테스트용 오브젝트로 대체할 수 없음
public void sendUpdateEmail(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.getLevel().name());

    mailSender.send(mailMessage);
}

5.4.6 메일 발송 기능 추상화

  • 스프링의 DI 적용
  • JavaMailSenderImpl 클래스가 구현한 MailSender 인터페이스만 남김
public class UserService {
    ...
    private MailSender mailSender;

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

    private void sendUpdateEmail(User user){
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo("user.getEmail()");
        mailMessage.setFrom("useradmin@ksug.org");
        mailMessage.setSubject("Upgrade 안내");
        mailMessage.setText("사용자님의 등급이"+user.getLevel().name());

        this.mailSender.send(mailMessage);
    }
    ...
}
<beans
    ...>
    <bean id="userService" class="com.ksb.spring.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>
    ...
</beans>

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

  • 스프링이 제공한 메일 전송 기능에 대한 인페이스(MailSender)가 있으니 이를 구현한 메일 테스트용 메일 전송 클래스(DummyMailSender) 생성
  • XML에서 구현 클래스를 DummyMailSender로 변경
  • DummyMailSender를 이용한 메일은 메일 서버로 발송되지는 않음
  • DummyMailSender는 아무것도 하지 않음
  • 데이터가 정상적으로 JavaMail API로 전송되면 메일이 실제 전송 될 것으므로, DummyMailSender로 대체하여 메일이 전송되지 않게 함
  • DummyMailSender는 아무것도 하지 않지만, 가치는 매우 큼
  • DummyMailSender로 메일을 직접 발송하는 JavaMail을 대체하지 않으면 테스트는 매우 불편해 질것임
public class DummyMailSender implements MailSender {
    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {   
    }
    @Override
    public void send(SimpleMailMessage[] simpleMessages) throws MailException {
    }
}

public class UserServiceTest {
    @Autowired
    MailSender mailSender;
    ...
    
    @Test
    public void upgradeAllOrNothing() {
        testUserService.setMailSender(this.mailSender); //수동 DI
        ...
    }
}
<beans
    ...>
    ...
    <bean id="mailSender"
        class="com.ksb.spring.DummyMailSender">
    ...
</beans>

5.4.8 테스트와 서비스 추상화

  • 서비스 추상화란, 추상 인터페이스와 일관성 있는 접근 방법을 제공해 주는 것임
  • 스프링이 직접 제공하는 MailSender를 구현한 추상화 클래스는 JavaMailServiceImpl 하나 뿐
  • 다양한 트랜잭션 기술에 대해 추상 클래스를 제공하는 것과 대비
  • 💡 일반적인 추상화는 여러 클래스 중 공통적인 부분을 추출해 만드는 것임
  • 대비된다 하더라도, 추상화된 메일 전송 기능을 사용해 애플리케이션을 작성함으로써 얻을 수 있는 장점은 매우 큼
  • 다른 메시징 서버의 API를 사용하더라도 해당 API를 이용하는 MailSender 구현 클래스를 만들어 DI 해주면 됨
  • 또한, 메일을 바로 전송하지 않고 에 담아서 일괄적으로 처리할 수 있음
  • 어떠한 경우에도 UserService는 메일을 발송한다는 비즈니스 로직이 변하지 않는이상 수정할 필요가 없음

5.4.9 현재 메일 발송 로직의 문제점

  • 메일 발송 로직에 트랜잭션 개념이 빠져있음
  • 한 유저가 업그레이드 완료되면 바로 메일을 보내기 때문에 업그레이드 작업 중간에 DB에 문제가 생겨 롤백을 해도 이미 발송된 메일을 취소할 수 없음
  • 해결에 두 가지 방법이 있음
  • 첫 번째, 업그레이드 할 때마다 메일을 보내지 않고 별도의 목록에 저장 후 일괄적 전송
  • 단점은, 메일 저장용 리스트 등을 파라미터로 계속 갖고 다녀야 함
  • 두 번째, MailSender를 확장해서 메일 전송에 트랜잭션 개념 적용
  • 첫 번째 방법은 사용자 관리 비즈니스 로직과 메일 발송에 트랜잭션 개념을 적용하는 기술적인 부분이 한데 섞일 수 밖에 없음
  • 두 번째 방법은 MailSender의 구현 클래스를 이용해 서로 다른 종류의 작업을 분리 가능

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

  • UserDaoTest는 운영 환경에서 DB와 연결되어서 동작함
  • 대용량 DB에 최적화된 복잡한 DataSource의 구현 클래스를 이용 및 대용량 DB 연결 기능에 최적화된 WAS에서 동작하는 DB 풀링 서비스를 사용함
  • 하지만, 테스트에서는 이런 기능이 필요없음
  • 단순한 DataSource의 구현 클래스를 사용하고 가벼운 DB만을 사용해 테스트를 진행해도 충분함
  • 마찬가지로 운영 환경에서 UserService는 JavaMailSenderImpl와 JavaMail을 이용해 메일 서버와 연결함
  • 하지만, UserService는 사용자 정보를 가공하는 비즈니스 로직이지, 메일이 어떻게 전송될 것인가에 대한 관심은 없음
  • 때문에 JavaMail을 DummyMailSender를 이용하여 테스트 환경에서도 원할히 테스트가 이뤄지도록 함

💡 의존 오브젝트의 변경으로 테스트가 원활해짐

 

5.4.11 의존 오브젝트 또는 협력 오브젝트

  • 테스트 대상이 되는 오브젝트가 또 다른 오브젝트에 의존하는 일은 매우 흔함
  • UserService는 userDao, transactionManager, mailSender 세 가지에 의존하고 있음
  • 의존한다는 것은 종속되거나 기능 사용을 한다는 의미임
  • 작은 기능이라도 다른 오브젝트의 기능을 사용하면 자신이 영향을 받을 수 있기 때문에 의존하고 있다는 말하는 것임
  • 의존을 통해 기능을 사용함으로써 의존 오브젝트와 협력해 일을 처리함
  • 의존 오브젝트를 협력 오브젝트(Collaborator Object)라고도 함
  • 테스트 대상인 오브젝트가 의존 오브젝트를 가지고 있으면 너무 거창한 작업이 뒤따르는 경우가 발생함
  • 때문에 거창한 작업을 테스트에서는 간단한 작업으로 대치하여 처리함
  • 테스트용 XML을 따로 만들어 테스트할 때 사용을 하면, DI를 통해 간단히 테스트 용으로 작업을 대치할 수 있음

5.4.12 테스트 대역의 종류와 특징

  • 테스트 대역(Test Double)이란, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 시행할 수 잇도록 하는 오브젝트
  • UseDao의 DataSource 구현체인 SimpleDriverDataSource나, UserService의 MailSender 구현체인 DummyMailSender가 테스트 대역의 예시임
  • 테스트 대역의 대표적인 두 가지 방법
    1. 테스트 스텁(Test Stub)
      • 의존 오브젝트에 간접적인 입력 제공
    2. 목 테스트(Mock Test)
      • 스텁 오브젝트와 간접적인 출력 확인

5.4.13 테스트 스텁

  • 테스트 대상 오브젝트의 의존객체
  • 테스트 동안 코드가 정상적을 수행할 수 있도록 도움
  • DummyMailSender는 가장 심플한 테스트 스텁의 예시임
  • 테스트 스텁은 MailSender 처럼 호출하면 그만인 것도 있지만, 결과를 리턴해야 할 때가 있음
  • 이럴 때는 테스트 중에 필요한 정보를 리턴하면 됨
  • 또는 강제적으로 예외를 발생시켜 테스트 대상의 오브젝트가 예외상황에 어떤 반응을 보이는지 테스트할 때 적용할 수 있음
  • 스텁을 이용하면 간접적인 입력 값 지정간접적 출력 값을 받게 할 수 있음
  • DummyMailSender는 테스트 오브젝트에게 러턴은 하지 않지만, UserService로 부터 전달받는 것은 있음

5.4.14 목 테스트

  • 의존 오브젝트에 넘기는 값과 행위 자체를 검증할 때 사용
  • 테스트 대상 오브젝트와 의존 오브젝트 사이의 발생하는 일을 검증할 수 있음

  • 위 그림에서 (5)을 제외하고는 스텁이라 해도 됨
  • 테스트 대상 오브젝트는 테스트의 입력의존 오브젝트와 커뮤니케이션이 발생됨
  • 테스트 대상은 의존 오브젝트에게 값을 입력 및 출력을 받기도 함

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

  • 테스트 대상 오브젝트의 메소드 호출이 끝나면 테스트는 목 오브젝트에게 테스트 대상과 목 오브젝트 사이에 일어났던 일에 대해 확인 요청을 하여 테스트 검증 자료로 사용
  • UserServiceTest의 upgradeAllOrNothing()는 메일이 전송 됐는지에 대한 관심이 없음
  • 반면 UserService에서는 레벨 업그레이드 시 메일이 전송되야 하므로 메일 전송에 대해 관심이 있음
  • 만약 JavaMail을 DummyMailSender로 대체하지 않았으면 일일히 메일 발송에 대해 확인을 해야함
  • 하지만, JavaMail 서비스를 추상화 했기 때문에 목 오브젝트를 만들어서 메일 발송 여부를 확인 할 수 있음
  • MockMailSender의 오브젝트로 부터 UserService 사이에 일어난 일을 검증(목 테스트)할 수 있음
  • userService에 DI 해줬던 목 오브젝트로 부트 getRequests를 호출하고 이를 비교하여 행위 자체검증
public class MockMailSender implements MailSender {
    //UserService로 부터 전송 요청을 받은 메일 주소를 저장해 읽을 수 있게 함
    private List<String> requests = new ArrayList<>();
    public List<String> getRequests(){
        return requests;
    }
    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {
        requests.add(simpleMessage.getTo()[0]);
    }
    @Override
    public void send(SimpleMailMessage[] simpleMessages) throws MailException {

    }
}

//주석 부분이 추가한 것임
public class UserServiceTest {
    ...
    @Test
    //@DirtiesContext //DI 설정을 변경한다고 알림
    public void upgradeLevels() {
        userDao.deleteAll();
        for (User user : users) userDao.add(user);

//        MockMailSender mockMailSender = new MockMailSender();
//        userService.setMailSender(mockMailSender);

        userService.upgradeLevels();

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

        //List<String> request = mockMailSender.getRequests();
        //assertThat(request.size(), is(2)); //2,4이 업그레이드 이므로 총 2번
        //assertThat(request.get(0), is(users.get(1).getEmail()));
        //assertThat(request.get(0), is(users.get(1).getEmail()));
    }
}