카테고리 없음

서비스 추상화

sanook 2022. 10. 2. 19:39

트랜잭션 경계 설정 

트랜잭션 경계설정

트랜잭션이 시작하고 끝나는 곳을 설정하는 것

시작과 종료는 Connection 오브젝트를 통해 일어남

 

로컬 트랜잭션

하나의 DB 커넥션 안에서 만들어지는 트랜잭션

시작과 종료는 Connection 오브젝트를 통해 일어남

 

Service에 Connection 오브젝트를 변수로 두면 안되는 이유?

service는 스프링 빈으로 선언해서 싱글톤

멀티쓰레드 환경에서는 공유하는 인스턴스 변수에 쓰레드 별로 생성하는 정보를 저장한다면 서로 덮어 쓰는 일 발생

 

public interface UserDao {
  public void add(Connection c, User user);
  public User get(Connection c, String id);
  ...
  public void update(Connection c, User user);
}

connection 오브젝트 공유를 위해 dao 레이어에 파라미터로 추가하면 안되는 이유?

dao는 더 이상 데이터 엑세스 기술에 독립적일 수 없음

JPA나 하이버네이트로 변경시 connection을 entityManager나 session으로 변경해야 함

→ dao의 interface와 service 코드가 모두 변경됨으로 인터페이스를 사용해 dao 구성 및 DI 하는 이유가 없어짐

 

트랜잭션 동기화

service 에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 작업 스레드마다 독립적으로 특별한 저장소에 보관하고, dao 메소드에서 저장된 Connection을 가져다가 쓴다.

dao가 사용하는 JdbcTemplate가 트랜잭션 동기화 방식을 이용하는 것이다.

 

(1) UserService는 Connection을 생성

(2) 트랜잭션 동기화 저장소에 저장해두고 Connection의 setAutoCommit(false)를 호출해 트랜잭션을 시작시킨 후에 DAO 기능 이용 시작

(3) update() 호출,

(4) JdbcTemplate 메소드에서 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인 / (2)에서 생성한 Connection을 가져옴

(5) Connection을 이용하여 수정 SQL을 실행. Connection은 닫지 않은채 DB 작업을 마침

(6~11) 반복

(12) Connection의 commit()을 호출해서 트랜잭션 완료시킴

(13) 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 이를 제거

 

private DataSource dataSource;

public void setDataSource(DataSource dataSource) {
  this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {
  TransactionSynchronizationManager.initSynchronization();
  // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화 요청

  Connection c = DataSourceUtils.getConnection(dataSource); 
  // Connection 오브젝트 생성과 트랜잭션 동기화에 사용하도록 저장소에 바인딩 
  c.setAutoCommit(false);

  try {
    List<User> users = userDao.getAll();
    for (User user : users) {
      if (canUpgradeLevel(user))
        upgradeLevel(user);
    }
    c.commit();
  } catch (Exception e) {
    c.rollback();
    throw e;
  } finally {
    DataSourceUtils.releaseConnection(c, dataSource); // DB 커넥션을 안전하게 닫는다.

    // 동기화 작업 종료 및 정리
    TransactionSynchronizationManager.unbindResource(this.dataSource);
    TransactionSynchronizationManager.clearSynchronization();
  }
}

+) JdbcTemplate은 update()나 query()같은 JDBC 작업의 템플릿 메소드를 호출하면 직접 Connection을 생성하고 종료한다.

다만, 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행하고 트랜잭션 동기화를 시작해 놓았다면 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와 사용해 이미 시작된 트랜잭션에 참여한다.

⇒ DAO 사용시 트랜잭션이 필요 없다면 바로 호출해서 사용해도 되고, DAO 외부에서 트랜잭션을 만들고 이를 관리할 필요가 있다면 미리 DB 커넥션을 생성한 다음 트랜잭션 동기화를 해주고 사용하면 된다.

 

트랜잭션 서비스 추상화

글로벌 트랜잭션

트랜잭션 매니저를 통해 여러 개의 DB가 참여하는 작업을 하나의 트랜잭션으로 만드는 것

(+ JMS(메세징 서버를 지원하는 API)와 같은 트랜잭션 기능을 지원하는 서비스도 트랜잭션에 참여)

JTA(Java Transaction API)는 자바가 트랜잭션을 지원하기 위한 API

 

애플리케이션에서는 기본 방법대로, DB는 JDBC, 메시징 서버라면 JMS API를 사용해서 필요한 작업을 수행

단, 트랜잭션은 JDBC나 JMS API를 사용해서 직접 제어하지 않고 JTA를 통해 트랜잭션 매니저가 관리하도록 위임

트랜잭션 매니저를 활용해 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합

트랜잭션 매니저는 DB와 메시징 서버를 제어하고 관리하는 각각의 리소스 매니저와 XA 프로토콜을 통해 연결

 

// JNDI를 이용해 서버의 Transaction 오브젝트를 가져온다.
InitialContext ctx = new InitialContext();
UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);

tx.begin();
// JNDI(Java Naming and Directory Interface)로 가져온 dataSource를 사용해야 한다.
Connection c = dataSource.getConnection();
try {
  // 데이터 액세스 코드
  tx.commit();
} catch (Exception e) {
  tx.rollback();
  throw e;
} finally {
  c.close();
}

→ JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 Service 코드를 수정해야 한다.

 

트랜잭션 API의 의존관계 문제와 해결책

(출저 : https://velog.io/@jakeseo_me/%ED%86%A0%EB%B9%84%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%A0%95%EB%A6%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-5.2-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%B6%94%EC%83%81%ED%99%94

service는 UserDao 인터페이스에만 의존하는 구조로 dao 클래스의 구현 기술이 JDBC에서 하이버네이트나 여타 기술로 바뀌더라도 UserService 코드는 영향을 받지 않음

(전형적인 OCP 원칙을 지키는 코드)

Service에서 트랜잭션 경계설정을 해야 할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 됨

(UserDaoJdbc에 간접적으로 의존하는 코드가 되어버림)

트랜잭션 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조임으로 기술 들의 사용 방법에 공통점이 있다면 추상화를 생각해 볼 수 있다.

추상화

하위 시스템의 공통점을 뽑아내서 분리시키는 것

하위 시스템이 어떤 것인지 알지 못해도, 바뀌어도 일관된 방법으로 접근 가능

 

스프링의 트랜잭션 서비스 추상화

(출저 : https://velog.io/@jakeseo_me/%ED%86%A0%EB%B9%84%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%A0%95%EB%A6%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-5.2-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%B6%94%EC%83%81%ED%99%94

애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다.

스프링에서 제공하는 모든 PlatformTransactionManager의 구현 클래스는 싱글톤으로 사용 가능함으로 스프링 빈으로 등록해도 좋다

(멀티스레드 환경에서 안전)

 

public class UserService {
    UserDao userDao;
    DataSource dataSource;
    PlatformTransactionManager transactionManager;
    ...

    public void upgradeLevels() {
				//JDBC 트랜잭션 추상 오브젝트 생성
				//PlatformTransactionManager 만 있으면 
				//Connection 생성과 트랜잭션 경계설정 기능을 모두 이용 가능함으로
				//DataSource 변수와 수정자 메소드는 제거해도 됨
				//DataSouce는 PlatformTransactionManager 타입의 빈에서 사용 
				//this.transactionManager = new DataSourceTransactionManager(this.dataSource);

	
        // 트랜잭션 시작
				// 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 수행
        TransactionStatus status =
                this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            List<User> users = userDao.getAll();
            for (User user : users) {
                if (canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }

            this.transactionManager.commit(status);
        }catch(Exception e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
..
<bean id="userService" class="toby_spring.user.service.UserService">
        <property name="transactionManager" ref="transactionManager"/>
        <property name="userDao" ref="userDao" />
        <property name="dataSource" ref="dataSource" />
        <property name="userLevelUpgradePolicy" ref="userLevelUpgradePolicy" />
</bean>

// dataSource 빈으로부터 Connection을 가져와 트랜잭션 처리를 해야 함으로 
// dataSource 프로퍼티를 갖음
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource" />
</bean>

PlatformTransactionManager로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장되고 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다. 따라서 PlatformTransactionManager를 통해 시작한 트랜잭션은 UserDao의 JdbcTemplate 안에서 사용된다.

 

JTA로 바꾸면 아래와 같이 수정하면 된다.

PlatformTransacionManager txManager = new JTATransactionManager();
  • 주요 자바 서버에서 제공하는 JTA정보를 JNDI를 통해 자동으로 인식하는 기능
  • 별다른 설정 없이 서버의 트랜잭션 매니저/서비스와 연동하여 동작

 

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

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

수평적인 분리

애플리케이션의 비즈니스 로직을 dao와 service는 각각 담당하는 코드의 기능적인 관심사에 따라 분리되고 독자적으로 확장이 가능하게 됨

수직적인 분리

트랜잭션 추상화와 같이 애플리케이션의 비즈니스 로직과 그 하위에서 존재하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특정을 갖는 코드를 분리

 

(출저 : https://velog.io/@jakeseo_me/%ED%86%A0%EB%B9%84%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%A0%95%EB%A6%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-5.3-%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%B6%94%EC%83%81%ED%99%94%EC%99%80-%EB%8B%A8%EC%9D%BC-%EC%B1%85%EC%9E%84-%EC%9B%90%EC%B9%99

UserDao와 UserService는 인터페이스와 DI를 통해 연결됨으로써 결합도가 낮아졌다.

(결합도가 낮다는 건 데이터 엑세스 로직이나 이 기술이 바뀐다고 해도 service 코드에 영향을 주지 않고 서로 독립적으로 확장 될 수 있다는 뜻)

UserDao는 DB 연결을 생성하는 방법에 독립적이다.

DataSource 인터페이스와 DI를 통해 추상화된 방식으로 로우레벨의 DB 연결 기술을 사용하기 때문이다.

애플리케이션 안에서 동작하는 DB 풀링 라이브러리를 사용하든, JDBC의 원시적인 DriverManager를 사용하든, WAS가 JNDI를 통해 제공하는 데이터 소스 서비스를 이용하든 상관없이 UserDao의 코드는 조금도 변하지 않는다.

즉, UserDao와 DB 연결 기술도 결합도가 낮다.

UserService와 트랜잭션 기술과도 스프링이 제공하는 PlatformTransactionManager 인터페이스를 통한 추상화 계층을 사이에 두고 사용하게 했기 떄문에, 구체적인 트랜잭션 기술에 독립적인 코드가 되었다.

서버가 바뀌고 로우레벨의 트랜잭션 기술이 변경된다고 할지라도 service는 영향을 받지 않는다.

DI의 가치는 이렇게 관심, 책임, 성격이 다른 코드를 깔끔하게 분리하는 데 있다.

단일 책임 원칙

하나의 모듈은 한 가지 책임을 가져야 하며 하나의 모듈이 바뀌는 이유는 한가지여야 한다.

예시) UserService에 JDBC Connection 메소드를 직접 사용하는 트랜잭션 코드가 들어가 있다면

  1. 어떻게 사용자 레벨을 관리할 것인가
  2. 어떻게 트랜잭션을 관리할 것인가

라는 두가지 책임을 가지고 수정되어야 하는 이유가 두가지임

장점

어떤 변경이 필요할 때 수정대상이 명확해 진다.

(기술 추상화 계층의 설정, 데이터 액세스 로직을 담은 dao 등등)

단일 책임 원칙을 잘 지키는 코드를 만들려면 인터페이스를 도입하고 이를 DI로 연결해야 하며, 그 결과로 단일 책임 원칙뿐 아니라 개방 폐쇄 원칙도 잘 지키고, 모듈 간에 결합도가 낮아서 서로의 변경이 영향을 주지 않고, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드가 나온다.

이 과정에서 전략 패턴, 어댑터 패턴, 브리지 패턴, 미디에어터 패턴등이 자연스럽게 적용되며 스프링이 지원하는 DI와 싱글톤 레지스트리 덕분에 더욱 편리하게 자동화된 테스트를 만들 수 있다.

 

메일 서비스 추상화

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

물론 정상 동작하는지 중요하지만, 업그레이드가 정상 동작 하는지 확인하는 일 만큼 중요하지 않고 실제 메일이 도착하는 것을 테스트하는 것은 불가능하다.

SMTP라는 표준 메일 발송 프로토콜에 메일 서버에 요청이 전달되기만 하면 메일이 발송될 것이라고 믿고 테스트용 메일 서버를 사용해 테스트를 수행한다면, JavaMail API이라는 검증된 자바 표준 기술에 요청이 들어간다는 것만 테스트 하면됨

(실제 동작시 외부 메일 서버와 네트워크 연동 및 전송하는 부하가 크기 떄문에 생략하면 좋음)

⇒ JavaMail과 동일한 인터페이스를 갖는 코드가 테스트시 동작하게 하면 됨

 

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

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

JavaMail의 핵심 API에는 DataSource 처럼 인터페이스로 만들어져 구현을 바꿀 수 있는 게 없다.

Session s = Session.getInstance(props, null);

JavaMail에서는 Session 오브젝트를 만들어야만 메일 메세지를 생성 및 전송가능한데 Session은 인터페이스가 아닌 클래스다.

생성자가 private로 되어 있어 직접 생성이 불가능하고 더이상 상속이 불가능한 final 클래스다.

메일 메세지를 작성하는 MailMessage도 전동 기능을 맡은 Transport도 마찬가지다

⇒ Java Mail의 구현을 테스트용으로 바꿔치기하는건 불가능

 

메일 발송 기능 추상화

JavaMail에 대한 추상화 기능을 사용하자

public interface MailSender {
	void send(SimpleMailMessage simpleMessage) throws MailException;
  void send(SimpleMailMessage[] simpleMessage) throws MailException; 
}
public class UserService {
    private void sendUpgradeEmial(User user) {
        //MailSender 구현 클래스의 오브젝트를 생성한다.
				JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost("mail.server.com");

			  //MailMessage 인터페이스의 구현 클래스 오브젝트를 만들어 메일 내용 작성 
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(user.getEmail());
        mailMessage.setFrom("useradmin@ksug.org");
        mailMessage.setSubject("Upgrade 안내");
        mailMessage.setText("사용자님의 등급이 " + user.getLvl().name() + "로 업그레이드되었습니다.");

        mailSender.send(mailMessage);
    }
}

아래와 같이 변경

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);
    }
}
public class UserServiceTest {
    static class DummyMailSender implements MailSender {
        @Override
        public void send(SimpleMailMessage simpleMessage) throws MailException {
        }

        @Override
        public void send(SimpleMailMessage... simpleMessages) throws MailException {
        }
    }
}
<bean id="mailSender" class="com.david.tobysspring.user.service.UserServiceTest$DummyMailSender" />
public class UserServiceTest {
    @Autowired MailSender mailSender;

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

(출저 : https://gunju-ko.github.io/toby-spring/2018/10/30/%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%B6%94%EC%83%81%ED%99%94.html

JavaMail이 아닌 다른 메시징 서버의 API를 이용해 메일을 전송해야 하는 경우가 생겨도, 해당 기술의 API를 이용하는 MailSender 구현 클래스를 만들어서 DI 해주면 된다.

또한, MailSender 인터페이스를 구현한, 메일 발송 큐의 구현을 하나 만들어 DI를 통해 JavaMailServiceImpl 같은 실제 메일 발송용 오브젝트를 연결하는 등 추상화 계층을 이용할 수 있는 수많은 응용 방법이 있다.

 

메일 발송 기능에 트랜잭션 개념 적용

  1. 메일 업그레이드할 사용자를 발견시 발송하지 않고 발송 대상을 별도의 목록에 저장해두고 업그레이드 작업이 모두 성공적으로 끝났을 때 한 번에 메일을 전송하는 방식

→ 메일 저장용 리스트 등을 파라미터로 계속 갖고 다녀야 함

  1. MailSender를 확장해서 메일 전송에 트랜잭션 개념을 적용

MailSender를 구현한 트랜잭션 기능이 있는 메일 전송용 클래스를 만들어 이 오브젝트에 업그레이드 작업 이전에 새로운 메일 전송 작업 시작을 알려주고, 그때부터는 mailSender.sender() 메소드를 호출해도 실제로 메일을 발송하지 않고 저장해둔다.

그리고 업그레이드 작업이 끝나면 트랜잭션 기능을 가진 MailSender에 지금까지 저장된 메일을 모두 발송하고, 예외가 발생하면 모두 취소한다.

→ 사용자 관리 비즈니스 로직과 메일 발송 트랜잭션 개념을 적용하는 기술적인 부분을 분리하여 처리함

 

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

(출저 : https://velog.io/@devsigner9920/%ED%86%A0%EB%B9%84%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A7%81-5.3%EC%9E%A5-5%EC%9E%A5-%EB%A7%88%EB%AC%B4%EB%A6%AC-%EC%8A%A4%ED%84%B0%EB%94%94

UserDaoTest의 관심은 UserDao가 어떻게 동작하는지에 있지, 그 뒤에 존재하는 DB 커넥션 풀이나 DB자체에 있지 않기 때문에 간단한 DataSource와 테스트 DB를 사용하여 가볍게 사용한다.

 

의존 오브젝트(= 협력 오브젝트)

하나의 오브젝트가 사용하는 오브젝트

의존 : 종속되거나 기능을 사용한다

작은 기능이라도 다른 오브젝트의 기능을 사용하면, 사용하는 오브젝트의 기능이 바뀌었을 때 자신이 영향을 받을 수 있음

 

테스트 대역의 종류와 특징

테스트 대역

테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 오브젝트

테스트 스텁

대표적인 테스트 대역

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

메소드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용해야 함으로 DI 등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야 한다.

간접적인 입/출력 값을 지정해 줄 수 있음

목 오브젝트

테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위 자체에 대해서도 검증하고 싶을 때 사용

테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 설계됨

테스트 오브젝트와 자신 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는데 사용

 

 

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

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 {
        }
    }
}
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()));
    }
}

정상적인 사용자 레벨 업그레이드 결과를 확인하는 upgradeLevels() 테스트에서는 조건을 만족하는 사용자의 레벨을 수정했다면, 메일도 발송되었는지 테스트 해야함

 

출저 :  이일민 저, 토비의 스프링 3.1 Vol. 1 스프링의 이해와 원리, 에이콘 출판사