토비의 스프링 정리
토비의 스프링 - 5.4 메일 서비스 추상화
ksb-dev
2022. 10. 5. 21:28
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라는 표준 메일 발송 프로토콜로 메일 서버에 요청이 전달만 되면 정상적으로 작동하는 것임
- 더 나아가, UserService와 JavaMail 사이에도 적용할 수 있음
- 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가 테스트 대역의 예시임
- 테스트 대역의 대표적인 두 가지 방법
- 테스트 스텁(Test Stub)
- 의존 오브젝트에 간접적인 입력 제공
- 목 테스트(Mock Test)
- 스텁 오브젝트와 간접적인 출력 확인
- 테스트 스텁(Test Stub)
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()));
}
}