토비의 스프링 Chapter 5.3 ~ 5.4 정리

종명·2021년 5월 1일
0

서비스 추상화와 단일 책임 원칙

수직, 수평 계층구조와 의존관계

  1. 수평적 분리
    • UserDao와 UserService의 분리
    • 각각 담당하는 코드의 기능적인 관심에 따라 분리
    • 서로 불필요한 영향을 주지 않으며 독자적으로 확장이 가능
    • 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리
  2. 수직적 분리
    • 트랜잭션 추상화
    • 애플리케이션 비지니스 로직과 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리

사용자 관리 모듈의 의존관계

  • 애플리케이션 계층 : UserService, UserDao
    • 애플리케이션의 로직
    • 인터페이스와 DI를 통해 연결됨으로써 결합도가 낮아졌다.
  • UserDao는 DB 연결을 생성하는 방법에 대해 독립적이다.
    • DataSource 인터페이스와 DI를 통해 추상화된 방식으로 로우레벨의 DB 연결 기술을 사용하기 때문이다.
  • UserService는 구체적인 트랜잭션 기술에 독립적이다.
    • PlatformTransactionManager 인터페이스를 통한 추상화 계층을 사이에 두고 사용하기 때문이다.

단일 책임 원칙

단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미이다.
하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.

  • UserService에서 JDBC Connection의 메소드를 직접 사용하는 트렉젠션 코드를 사용하고 있을 때를 생각해보자.
  • UserService는 어떻게 사용자 레벨을 관리할 것인가, 어떻게 트랜잭션을 관리 할 것인가 두가지 책임을 가지고 있었다.
  • 두가지 책임을 가지고 있다는 건 코드가 수정되야할 이유가 두가지라는 뜻이다.
    1. 사용자의 레벨 업그레이드 정책과 같은 사용자 관리 로직이 바뀔 때
    2. 트랜잭션 기술을 JDBC에서 JTA로 변경해야 할 때
  • 하지만 트랙잭션 서비스의 추상화 방식을 도입하고, 이를 DI를 통해 외부로 제어하도록 만들고 난 뒤에는 JDBC에서 JTA로 바뀌더라도 UserService 코드는 단 한줄도 수정할 이유가 없었다. 즉, UserService는 오직 사용자 관리에 대한 책임만 가지며 단일 책임 원칙을 충실히 지키게 되었다.

단일 책임 원칙의 장점

  • 어떤 변경이 필요할 때 수정 대상이 명확해진다.
    • 만약 DAO가 각각 수백 개가 되고, 서비스 클래스도 그만큼 많다고 가정해보자. 서비스 하나가 여러개의 DAO를 사용하는 경우가 많아 질 것이다. 이떄, DAO를 수정했는데 그에 의존하는 서비스 클래스를 수정해야 한다면.. DAO를 사용하는 모든 서비스 클래스를 수정해야한다. 또, 이 때문에 수많은 테스트도 수정해야 할 수도 있다.
  • 단일 책임 원칙을 잘 지키는 코드를 만들려면 인터페이스를 도입하고 이를 DI로 연결해야 한다. 그 결과 단일 책임 원칙 뿐만 아니라 OCP도 잘 지키고, 모듈 간의 결합도가 낮아져 서로의 변경에 영향을 주지 않고 같은 이유로 변경이 단일 책임에 집중되는 응집도가 높은 코드가 나온다.

메일 서비스 추상화

고객으로부터 레벨 업그레이드 안내 메일 발송해달라는 요청사항이 들어왔다. 메일 발송을 위해 해야하는 할 일 두 가지가 있다.
1. 사용자 이메일 관리
2. 메일 발송 기능 추가

JavaMail 발송

    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.getLevel().name());

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

JavaMail이 포함된 코드의 테스트

만약 메일 서버가 준비되어 있지 않다면 어떻게 될 것인가? 운영중에야 메일 서버가 있을테지만, 개발중이라면 어떨까?


다음과 같은 예외가 발생할 것이다.

java.lang.RuntimeException: javax.mail.MessagingException: Could not connect to SMTP host: mail.ksug.org, port: 25;

테스트가 수행될 때 실제로 사용할 메일 서버를 제대로 준비해두고 실행하면 아무런 문제가 없을 것이다.

그런데 과연 테스트를 하면서 매번 메일을 발송하는 것이 바람직한가? 대개는 바람직하지 못하다.

  • 메일 발송이란 매우 부하가 큰 작업이기에 메일 서버에 부담을 줄 수 있다.
  • 메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과하다
    • 꼭 필요하고 정상적으로 동작하는지 확인하는 일도 중요하긴 하지만, 실제로 업그레이드가 일어나 DB에 잘 반영되는지 확인하는 일만큼 중요하지는 않다.
  • 게다가 메일 발송 테스트란 엄밀히 말해 불가능하다. (메일이 정말 잘 도착했는지 확인하지 못한다, 다만 메일 발송용 서버에 별문제 없이 전달됐음을 확인할 뿐이다.)

메일 서버는 충분히 테스트된 시스템이다. SMTP로 메일 전송 요청을 받으면 메일이 잘 전송됐다고 믿어도 충분하다. 따라서 JavaMail을 통해 메일 서버까지만 잘 전달되면 사용자에게도 잘 보내졌을거라 생각할 수 있다.

이와 마찬가지로 UserService와 JavaMail사이에도 똑같은 원리를 적용할 수 있다. JavaMail은 자바의 표준 기술이고 안정적인 모듈이다. JavaMail API를 통해 요청이 들어간다는 보장만 있으면 굳이 테스트를 할 때마다 JavaMail을 구동하지 않아도 된다.

운영시에는 JavaMail을 직접 이용해서 동작하도록 해야겠지만, 개발 중이거나 테스트를 수행할 때는 JavaMail을 대신할 수 있는, 그러나 JavaMail을 사용할 떄와 동일한 인터페이스를 갖는 코드가 동작하도록 만들어도 될것이다. 이렇게 하면 굳이 매번 검증이 필요 없는 불필요한 메일 전송 요청을 보내지 않아도 되고, 테스트도 빠르고 안전하게 수행될 수 있다.

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

JavaMail 대신에 테스트에서 사용할, JavaMail 같은 인터페이스를 갖는 오브젝트를 만들어서 사용하면 문제는 모두 해결된다.

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

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

  • 메일 발송을 위해 생성해야하는 Session은 인터페이스가 아닌 클래스이이며, 생성자 역시 private으로 선언되어 직접 생성도 불가능하다.
  • 스태틱 팩토리 메소드를 이용한 오브젝트 생성만 가능하고 final클래스이기에 상속도 되지 않는다.
  • JavaMail의 구현을 테스트용으로 바꿔치기하는 건 불가능 하다고 볼 수 밖에 없다.

트랜잭션을 적용하면서 살펴봤던 서비스 추상화를 이용하면 적용하면 된다.

메일 발송 기능 추상화

메일을 전송하는 메소드로만 구성된 인터페이스

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

기본적으로 메일 발송 기능을 제공하는 JavaMailSenderImpl 클래스를 이용하면 된다.

    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.getLevel().name());

        mailSender.send(mailMessage);
    }
  • 스프링의 예외처리 원칙에 따라 JavaMail을 처리하는 중 발생하는 각종 예외를 MailException이라는 런타임 에러로 포장해서 던져주기에 귀찮은 try/catch 블록을 만들지 않아도 된다.

아직은 JavaMailSimpleImple 클래스의 오브젝트를 직접사용하기에 JavaMail API를 사용하지 않는 테스트용 오브젝트로 대체할 수는 없다. 이제 스프링의 DI를 적용해보자.

  • sendUpgradeEmail() 메소드에 JavaMailSenderImpl 클래스가 구현한 MailSender 인터페이스만 남기고 구체적인 전송 구현을 담은 클래스의 정보는 코드에서 모두 제거
  • UserService에 MailSender 인터페이스 타입의 변수를 만들고, 수정자 메소드를 추가해 DI 가능하도록 만듬
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.getLevel().name());

        this.mailSender.send(mailMessage);
    }
}
<bean id="userService" class="springbook.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을 사용해 메일을 전송할 필요가 없다. 그냥 아무것도 하지 않는, 빈 클래스르 만들어 보자.

public class UserServiceTest {
    static class DummyMailSender implements MailSender {
        @Override
        public void send(SimpleMailMessage simpleMessage) throws MailException {
        }

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

테스트 설정파일의 JavaMailSenderImpl -> DummyMailSender로 변경한다.

<bean id="mailSender" class="springbook.user.service.UserServiceTest$DummyMailSender" />

테스트가 성공적인걸 볼 수 있다.
1. 실제로 메일이 발송될 일이 없다.
2. 메일 발송 기능 자체는 MailServer에 대한 학습 테스트 또는 메일 서버 설정 점검용 테스트를 만들어서 확인해보면 된다.
3. 메일 전송 메소드가 호출됐는지 간단히 확인해보려면 DummyMailSender의 메소드에 간단한 콘솔을 출력하도록 만들고 한 번쯤 찍힌 내용을 확인해보는 방법도 사용 할 수 있다.

public class UserServiceTest {
    @Autowired MailSender mailSender;

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

테스트와 서비스 추상화

서비스 추상화

  • 일반적으로 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 일관성 있는 접근 방법을 제공하는 것을 말한다.
  • 반면에, JavaMail과 같이 테스트를 어렵게 만드는 API를 사용할 때도 유용하게 쓰일 수 있다.
  • JavaMail이 아닌 다른 메세징 서버의 API를 이용하는 경우가 생겨도 해당 기술의 API를 이용하는 MailSender 구현 클래스를 만들어서 DI 해주면 된다.
  • 메일 서버가 바뀌고 메일 발송 방식이 바뀌는 변화가 있어도 메일을 발송한다는 비즈니스 로직이 바뀌지 않는 한 UserService는 수정할 필요가 없다.

레벨 업그레이드 작업 중간에 예외가 발생해서 롤백된다면 보낸 메일들도 취소해야한다. 즉, 메일 발송 기능에도 트랜잭션을 적용해야 한다.
  1. 발송 대상을 별도의 목록에 저장해두고 업그레이드가 성공적으로 끝났을 때 한 번에 메일을 발송한다. 이 방식의 단점은 메일 저장용 리스트 등을 파라미터로 계속 갖고 다녀야 한다는 점이다.
  2. MailSender를 확장해서 메일 전송에 트랜잭션 개념을 적용한다. MailSender를 구현한 트랜잭션 기능이 있는 메일 전송용 클래스를 만든다. 이 오브젝트에 업그레이드 이전에 새로운 메일 전송 작업 시작을 알려주고, 그때부터는 mailSender.send()를 메소드를 호출해도 실제로 전송하지 않고 저장해준다. 그리고 업그레이드 작업이 끝나면 트랙잭션 기능을 가진 MailSender에 지금까지 저장된 메일을 모두 발생하고, 예외가 발생하면 모두 최소하게 할 수 있다.

두 가지 방법에 쓴 전략이 비슷하긴 하다. 하지만 전자가 사용자 관리 비즈니스 로직과 메일 발송 트랜잭션 개념을 적용하는 기술적인 부분이 한데 섞이게 한다면, MailSender의 구현 클래스를 이용하는 방법은 서로 다른 종류의 작업을 분리해 처리한다는 면에서 장점이 있다.

서비스 추상화란 이렇게 원할한 테스트만을 위해서도 충분히 가치가 있다. 기술이나 환경이 변할 수 있음에도 JavaMail처럼 확장이 불가능하게 설계해놓은 API를 사용해야 한다면 추상화 계층을 도입을 적극 고려해볼 필요가 있다.

테스트 대역

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

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

  • 테스트에서는 운영 DB의 연결도 WAS의 DB 풀링 서비스의 사용도 번거로운 짐이 될 뿐이다.
  • 하지만 UserDao가 제 기능을 수행하려면 반드시 DB를 사용해야 하기에 무시할 수 없다. 그래서 이를 대신할 수 있도록 가벼운 버전을 사용한다.

UserService는 메일 전송 기능

  • 메일 전송 기능을 아예 뗄 수는 없다.
  • 테스트에 지장을 주지 않기 위해 도입 한것이 결국 DummyMailSender이다.

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

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

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

테스트 대역의 종류와 특징

테스트 대역

  • 테스트용으로 사용되는 특별한 오브젝트
  • 대부분 테스트 대상인 오브젝트의 의존 오브젝트
  • UserDao의 DataSource, UserService의 DummyMailSender
  • 이렇게 테스트 환경을 만들어주기 위해, 자주 테스트를 실행할 수 있도록 사용하는 오브젝트를 통틀러 테스트 대역이라고 한다.

테스트 스텁

  • 대표적인 테스트 대역
  • 테스트 대상 오브젝트의 의존객체로 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다.
  • 일반적으로 테스트 스텁은 메소드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용된다. 따라서 DI등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야한다.
  • DummyMailSender는 가장 단순하고 심플한 테스트 스텁의 예다.
  • 스텁을 이용하면 간접적인 입력 값을 지정할 수도 있고, 간접적인 출력 값을 받게 할 수도 있다.

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

  • assertThat()으로 검증하는 것이 불가능하다.
  • 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 목 오브젝트(mock object)를 사용해야 한다.
  • 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.

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

UserServiceTest에 목 오브젝트 개념을 적용해보자.

  • upgradeAllOrNothing()은 메일의 전송 여부는 관심의 대상이 아니다. 따라서 DummyMailSender를 사용하면 된다.
  • 반면에 upgradeLevels() 테스트에서는 메일 전송 자체에 대해서도 검증할 필요가 있다. 조건을 만족하는 사용자의 레벨을 수정했다면, 메일도 발송했어야 하기 때문이다.
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 {
        }
    }
}
  • UserService가 send() 메소드를 통해 자신을 불러 메일 전송 요청을 보냈을 때 관련 정보를 저장해두는 기능
  • 어차피 한 명씩 보내기 때문에 첫 번째 수신자 메일 주소를 꺼내온다.
  • 수신자 메일 주소를 미리 준비해둔 리스트에 저장해둔다.
  • 이를 테스트에서 접근할 수 있도록 간단한 접근자 메소드를 만들어 둔다.
public class UserServiceTest {
    @Test
    // 컨텍스트의 DI 설정을 변경하는 테스트라는 것을 알려준다.
    @DirtiesContext
    public void upgradeLevels() throws Exception {
        userDao.deleteAll();

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

        // 메일 발송 결과를 테스트할 수 있도록 목 오브젝트를 만들어 userService 의존 오브젝트를 주입해준다.
        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()));
    }
}

0개의 댓글