Group Study (2024-2025)/Spring 심화

[ Spring 심화 ] 5주차 - 서비스 추상화

woooni 2024. 11. 6. 13:43

5장 서비스 추상화

5.1 사용자 레벨 관리 기능 추가

5장에서는 dao 에 트랜잭션을 적용해보면서 스프링의 추상화 기술과 이를 지원하는 방법을 알아보자~

dao에 비즈니스 로직을 추가로 구현해보자

  • 사용자의 레벨은 basic, silver, gold 세가지 중 하나
  • 사용자가 처음 가입하면 basic, 이후 활동에 따라 한단계씩 업그레이드
  • 50회 이상 로그인 시 basic -> silver 레벨이 됨
  • silver 레벨이면서 30번 이상 추천받으면 gold 레벨이 됨
  • 사용자 레벨의 변경작업은 일정한 주기를 가지고 일괄적으로 진행
  • 변경 작업 전에는 조건을 충족하더라도 레벨의 변경 안일어남

🍃 필드 추가

Level enum

숫자 타입을 사용하게 되면 만약 level의 값이 1,2,3 이 아닌 다른 값이 들어가더라도 버그가 생길 수도 있고, 범위를 벗어나는 값을 넣을 위험도 있으므로 enum을 사용해서 관리하자!

User 필드 추가

위에서 만든 Level 타입의 변수를 User 클래스에 추가한다.

public class User {
    ...
    Level level; // level 필드 추가
    int login;
    int recommend;

    public Level getLevel(){
	    return level;
    }
    public void setLevel(Level level){
	    this.level = level;
	}
    ...
    // login, recommend 게터세터 생략

}

🍃 사용자 수정 기능 추가

User 오브젝트를 전달하면 id를 참고해서 사용자를 찾아 필드 정보를 update 문을 이용해 모두 변경해주는 메소드를 만들어보자~

사용자를 두명 등록해놓고, 그중 하나만 수정한 뒤에, 수정된 사용자와 수정하지 않은 사용자의 정보를 모두 확인해보자.

@Test
public void update(){
    dao.deleteAll();
    dao.add(user1); //수정할 사용자
    dao.add(user2); //수정하지 않을 사용자

    user1.setName("오민규");
    user1.setPassword("springno6");
    user1.setLevel(Level.GOLD);
    user1.setLogin(1000);
    user1.setRecommend(999);

    dao.update(user1);


    User user1update = dao.get(user1.getId());
    checkSameUser(user1, user1update);
    User user2same = dao.get(user2.getId());
    checkSameUser(user2, user2same);
}

🍃 UserService.upgradeLevels()

사용자 관리 비즈니스 로직을 담을 클래스를 하나 추가하자! UserService 로 하자.
UserService는 UserDao의 구현 클래스가 바뀌어도 영향받지 않도록해야하며, 데이터 액세스 로직이 바뀌었다고 비즈니스 로직코드를 수정해서는 안된다. 따라서 dao의 인터페이스를 사용하고, di를 적용해야한다.

upgradeLevels() 메소드

changed 플래그를 확인해서, 레벨 변경이 있는 경우에만 UserDao의 update()를 이용해 수정 내용을 db에 반영한다.

🍃 UserService.add()

처음 가입하는 사용자는 기본적으로 basic 레벨이어야 한다. -> 이 로직을 어디에 담는 것이 좋을까?

사용자 관리에 대한 비즈니스 로직을 담고 있는 UserService에 이 로직을 넣자!
add() 를 만들어 두고 사용자가 등록될 때 적용할 만한 비즈니스 로직을 담당하게 하자.

  • 사용자 신규 등록 로직을 담은 add() 메소드
public void add(User user){
    if(user.getLevel() == null) user.setLevel(Level.BASIC);
    userDao.add(user);
}

🍃 코드 개선

  1. 코드에 중복된 부분은 없는가?
  2. 코드를 이해하기 불편하진 않은가?
  3. 코드가 적절한 자리에 있는가?
  4. 변경이 일어난다면 어떤 것이고, 변화에 쉽게 대응가능한가?

upgradeLevels() 메소드 코드의 문제점

  1. if/else..블록들이 읽기 불편하다.
  2. 성격이 다른 로직이 한데 섞여 있다.
  • 현재 레벨이 무엇인지 파악하는 로직과, 업그레이드 조건을 담은 로직, 다음단계의 레벨이 무엇인지.. 등 이 섞여 있다.
  1. 이런 if 조건 블록이 레벨 개수만큼 반복된다. (새로운 레벨이 추가되면 조건식이 계속 추가된다.)
  2. 레벨과 업그레이드 조건을 동시에 비교하는 부분도 문제가 될 수 있다.

upgradeLevels() 리팩토링

  • 기본 작업 흐름만 남겨둔 코드
public void upgradeLevels(){
    List<User> users = userDao.getAll();
    for(User user : users){
	    if(canUpgradeLevel(user)){
		    upgradeLevel(user);
	    }
    }
}
  • 업그레이드 가능 확인 메소드
private boolean canUpgradeLevel(User user){
    Level currentLevel = user.getLevel();
    switch(currentLevel){
	    case BASIC: return (user.getLogin() >= 50);
	    case SILVER: return (user.getRecommend() >= 30);
        case GOLD: return false;
        default: throw new IllegalArgumentException("Unknown level: " + currentLevel);
    }
}
  • 레벨 업그레이드 작업 메소드
private void upgradeLevel(User user){
    if (user.getLevel() == Level.BASIC) user.setLevel(Level.SILVER);
    else if (user.getLevel() == Level.SILVER) user.setLevel(Level.GOLD);
    userDao.update(user);
}

하지만 위의 메소드 코드는 마음에 안든다.

  • 다음 단계가 무엇인가 하는 로직과 그때 사용자 오브젝트의 level 필드를 변경해준다는 로직이 함께 있다.
  • 예외상황에 대한 처리가 없다.(업그레이드 조건을 잘못 파악하는 경우,,?)

먼저 Level 에게 레벨의 순서과 다음 단계 레벨이 무엇인지를 결정하게 하자!

  • 업그레이드 순서를 담고 있도록 수정한 Level
public enum Level{
    GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);

    private final int value;
    private final Level next;
    
    ...

    public Level nextLevel(){
	    return this.next;
    }
    
    public static Level valueOf(int value){
	    switch(level){
		    case 1: return BASIC;
		    ...
		    default: throw new AssertionError("Unknown value:" + value);
	    }
    }
}

이번에는 사용자 정보가 바뀌는 부분을 UserService 메소드에서 User로 옮겨보자.
-> User의 내부 정보가 변경되는 것은 User가 스스로 다루는 게 적절하다.

  • User의 레벨 업그레이드 작업용 메소드
public void upgradeLevel(){
    
    Level nextLevel = this.level.nextLevel();
	if(nextLevel == null) {
        throw new IllegalStateException(this.level + "은 업그레이드 불가");
    } else{
        this.level = nextLevel; 
    } 
}
  • 더 간결해진 UserService의 upgradeLevel 메소드
private void upgradeLevel(User user){
    user.upgradeLevel();
    userDao.update(user);
}

개선된 코드는 각 오브젝트와 메소드가 각각 자기 몫의 책임을 맡아 일을 하는 구조로 만들어졌다.
객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 해달라고 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 한다.

UserService 테스트 개선

  • 코드에 나타난 숫자의 중복을 정수형 상수로 변경하자
  • 업그레이드 정책을 UserService에서 분리해서 평상시/이벤트 시의 업그레이드 정책을 각각 di 해준다
public interface UserLevelUpgradePolicy {
   boolean canUpgradeLevel(User user);
   void upgradeLevel(User user);
   // 업그레이드 정책을 담은 인터페이스를 만들어두고,
   //UserService는 di로 제공받은 정책 구현 클래스를 이 인터페이스를 통해 사용할 수 있다.
}

5.2 트랜잭션 서비스 추상화

이런 상황이 생길 수도 있다.

"정기 사용자 레벨 관리 작업을 수행하는 도중에 네트워크가 끊기거나 서버에 장애가 생겨서 작업을 완료할 수 없다면, 그때까지 변경된 사용자의 레벨은 그대로 둘까요? 아니면 모두 초기상태로 되돌려 놓아야할까요?"

🍃 모 아니면 도

테스트용 UserService 대역

static class TestUserService extends UserService{
    private String id;
    private TestUserService(String id){
	    this.id = id;
    }
    protected void upgradeLevel(User user){
	    if(user.getId().equals(this.id)) throw new TestUserServiceException();
	    super.upgradeLevel(user);
    }
}

=> 테스트는 실패한다.

테스트 실패의 원인

트랜잭션
트랜잭션이란 더 이상 나눌 수 없는 단위 작업을 말한다.
작업을 쪼개서 작은 단위로 만들 수 없다는 것은 트랜잭션의 핵심 속성인 원자성을 의미한다.

upgradeLevels() 메소드의 작업은 이 하나의 작업 단위인 트랜잭션이 적용되지 않았기 때문에 새로 추가된 기술 요건을 만족하지 못하고, 이를 검증하기 위해 만든 테스트가 실패하는 것이다. upgradeLevels() 메소드가 하나의 트랜잭션 안에서 동작하지 않았다.

🍃 트랜잭션 경계설정

DB는 그 자체로 완벽한 트랜잭션을 지원한다. 하나의 SQL 명령을 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.
BUT, 여러개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야하는 경우도 있다.

  • EX,, 계좌이체 (출금계좌의 잔고를 수정하는 것 & 입금계좌 레코드의 잔고를 수정하는 두개의 SQL)
  1. 트랜잭션 롤백 : 첫 번째 sql은 성공했지만 두 번째 sql이 실패하면 앞에서 처리한 SQL 작업도 취소해야한다.
  2. 트랜잭션 커밋 : 모든 sql이 성공적으로 완료되면 DB에 커밋되었다고 알려줘서 작업을 확정시켜야 한다.

JDBC 트랜잭션의 트랜잭션 경계설정

모든 트랜잭션은 시작하는 방법과 끝나는 지점이 있다.

  • 트랜잭션을 사용한 JDBC 코드
Connection c = dataSource.getConnection();

c.setAutoCommit(false); //트랜잭션 시작
try {
    //하나의 트랜잭션으로 묶인 단위 작업
    PreparedStatement st1 = c.prepareStatement("update users ...");
    st1.executeUpdate();

    PreparedStatement st2 = c.prepareStatement("delete users ...");
    st2.executeUpdate();

    //트랜잭션 커밋
    c.commit();
}
catch (Exception e) {
    //트랜잭션 롤백
    c.rollback();
}

c.close();
  • 트랜잭션의 시작과 종료는 Connection 오브젝트를 통해 이뤄짐.
  • 트랜잭션이 한 번 시작되면 commit, rollback 메소드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶임
  • 커밋 또는 롤백이 호출되면 그에 따라 작업 결과가 db에 반영되거나 취소되고 트랜잭션이 종료됨.
  • 예외가 발생하면 트랜잭션을 롤백함.

UserService와 UserDao의 트랜잭셔 문제

그렇다면 upgradeLevels()와 같이 여러번 db에 없데이트를 해야하는 작업을 하나의 트랜잭션으로 만들려면 어떻게 해야 할까?
어떤 일련의 작업이 하나의 트랜잭션으로 묶이려면 그 작업이 진행되는 동안 DB커넥션도 하나만 사용돼야 한다. 앞에서 설명한 것처럼 트랜잭션은 Connection 오브젝트 안에서 만들어지기 때문이다. 하지만 현재는 UserService에서 db 커넥션을 다룰 수 있는 방법이 없다.

비즈니스 로직 내의 트랜잭션 경계설정

UserService에서 만든 Connection 오브젝트를 UserDao에서 사용하려면 Dao 메소드를 호출 할 때마다 Connection 오브젝트를 파라미터로 전달해줘야 한다.

  • Connection 오브젝트를 파라미터로 전달받는 UserDao 메소드
public interface UserDao {
    public void add(Connection c, User user);
    public User get (Connection c, String id);
    ...
    public void update(Connection c, User user1);
}
  • Connection을 공유하도록 수정한 UserService 메소드 => UserService-upgradeLevels() 는 UserDao의 update() 직접 호출 x
    => UserDao 를 사용하는 것은 사용자별로 업그레이드 작업을 진행하는 upgradeLevel() 메소드
    => UserService의 메소드 사이에서도 Connection 오브젝트를 파라미터로 전달해줘야함

UserService 트랜잭션 경계 설정의 문제점

  1. JdbcTemplate 를 더이상 활용할 수 없다.
  2. UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다.
  3. Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao 는 데이터 액세스 기술에 독립적일 수 없다.

🍃 트랜잭션 동기화

Connection 파라미터 제거

트랜잭션 동기화
UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 호출되는 DAO 의 메소드에서는 저장된 Connection을 가져다가 사용하게 하는 것이다. 정확히는 DAO가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다.

  • 트랜잭션 동기화를 사용한 경우의 작업흐름
    1️⃣ UserService는 Connection 생성
    2️⃣ 이를 트랜잭션 동기화 저장소에 저장해두고 Connection의 setAutoCommit(false) 를 호출해 트랜잭션을 시작시킨 후에 본격적으로 DAO의 기능을 이용하기 시작한다.
    3️⃣ 첫번째 update() 메소드가 호출되고, update() 메소드 내부에서 이용하는 JdbcTemplate 메소드에서는 가장 먼저
    4️⃣ 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인한다. 2️⃣ upgradeLevels() 메소드 시작 부분에서 저장해준 Connection을 발견하고 이를 가져온다.
    5️⃣ Connection을 이용해 PreparedStatement 를 만들어 수정 sql을 실행한다.
    => 여전히 Connection은 열려 있고 트랜잭션은 진행중인 채로 트랜잭션 동기화 저장소에 저장되어 있다.
    6️⃣ 두번째 update()가 호출되면 이때도
    7️⃣ 트랜잭션 동기화 저장소에서 Connection 을 가져와서 사용한다.
    8️⃣ 마지막 update() 도 같은 트랜잭션 을 가진 Connection을 가져와 사용한다.

트랜잭션 동기화 적용

스프링은 TransactionSynchronizationManager 라는 트랜잭션 동기화 관리 클래스를 제공한다.

  1. 이 클래스를 이용해 트랜잭션 동기화 작업을 초기화하도록 요청
  2. DataSourceUtils 에서 제공하는 getConnection() 을 통해 DB 커넥션 생성
  3. 동기화 준비가 됐으면 트랜잭션을 시작하고 DAO의 메소드를 사용하는 트랜잭션 내의 작업을 진행
private DataSource dataSource;

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

public void upgradeLevels() throws Exception {
	TransactionSynchronizationManager.initSynchronization(); // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화함
	
    Connection c = DataSourceUtils.getConnection(dataSource);
	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);
		
		// 동기화 작업 종료 및 정리
		TransactionSynchronizationManager.unbindResource(this.dataSource);
		TransactionSynchronizationManager.clearSynchronization();
	}
}

 

트랜잭션 테스트 보완

@Autowired DataSource dataSource;
...
@Test
public void upgradeAllOrNothing() throws Exception {
	UserService testUserService = new TestUserService(users.get(3).getId());
	testUserService.setUserDao(this.userDao);
	testUserService.setDataSource(this.dataSource);
	...
}
  • dataSource 프로퍼티를 추가한 userService 빈 설정
<bean id="userService" class="springbook.user.service.UserService">
	<property name="userDao" ref="userDao" />
	<property name="dataSource" ref="dataSource" />
</bean>

JdbcTemplate 과 트랜잭션 동기화

이미 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다.

🍃 트랜잭션 서비스 추상화

기술과 환경에 종속되는 트랜잭션 경계설정 코드

만약, 이 사용자 관리 모듈을 구매해서 사용하기로 한 G사에서 새로운 요구를 했을 경우?

  • G사는 여러 개의 DB 사용 중
  • 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문에 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션(global transaction) 방식을 사용해야 함.

JTA(Java Transaction API)
JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API

✅ JTA 를 이용해 트랜잭션 매니저를 활용하면 여러 개의 DB나 메시징 서버에 대한 작업을 하나의 트랜잭션으로 통합하는 분산 트랜잭션 또는 글로벌 트랜잭션이 가능해진다.

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

트랜잭션 처리 코드에도 추상화를 도입해보자!

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

  • 스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager
  • JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 됨

스프링의 트랜잭션 추상화 기술은 트랜잭션 동기화를 사용한다!
(1) PlatformTransactionManager로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장됨.
(2) PlatformTransactionManager를 구현한 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션 관리해줌.

트랜잭션 기술 설정의 분리

트랜잭션 추상화 API 를 적용한 UserService 코드를 JTA를 이용하는 글로벌 트랜잭션으로 변경하려면?
=> PlatformTransactionManager를 구현 클래스를 JTATransactionManager로 바꿔주자!

  • 하이버네이트는 PlatformTransactionManager 구현 클래스를 HibernateTransactionManager를 사용하면 됨
  • JPA는 PlatformTransactionManager 구현 클래스를 JPATransactionManager를 사용하면 됨

어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI원칙에 위배되기 때문에 컨테이너를 통해 외부에서 제공받게 하는 스프링의 di 방식으로 바꾸자!

  • DataSourceTransactionManager 는 스프링 빈으로 등록하고 UserService 가 DI 방식으로 사용하게 하자.
  • 스프링이 제공하는 모든 PlatformTransactionManager 구현 클래스는 싱글톤으로 사용이 가능하다.
public class UserService{
	...
	private PlatformTransactionManager transactionManager;
	public void setTransactionManager(PlatformTransactionManager transactionManager){
		this.transactionManager = transactionManager;
	}
	
	public void upgradeLevels() {
		TransactionStatus status = 
        this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        //ㄴ di 받은 트랜잭션 매니저를 공유해서 사용한다. 멀티스레드 환경에서도 안전하다.
		try{
			List<User> users = userDao.getAll();
			for (User user : users) {
				if (canUpgradeLevel(user)){
					upgradeLevel(user);
				}
			}
			this.transactionManager.commit(status);
		} catch(RuntimeException e){
			this.transactionManager.rollback(status);
			throw e;
		}
	}
	...
}

=> UserSesrvice 는 트랜잭션 기술에서 완전히 독립적인 코드가 됐다.
트랜잭션을 JTA를 이용하는 것으로 싶다면 설정파일의 transactionManager 빈의 설정만 다음과 같이 고치면 된다.

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

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

기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 족속되지 않는 포터블한 코드를 만들 수 있다.
=> 같은 계층에서 수평적인 분리

애플리케이션 로직의 종류에 따른 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며, 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 '스프링의 DI'가 중요한 역할 하고 있다.

단일 책임 원칙(Single Responsibility Principle)

단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미다.

단일 책임 원칙의 장점

  • 어떤 변경이 필요할 때 수정 대상이 명확해짐
  • 기술이 바뀔 때

스프링 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다.

  • 비즈니스 로직을 데이터 로직과 별도로 만들어서 연결하는 데도 DI를 적용
  • 비즈니스 로직에 필요한 트랜잭션 기술을 추상화해서 분리하는 데도 역시 DI가 사용

5.4 메일 서비스 추상화

새로운 요청사항 -> 레벨이 업그레이드 되는 사용자에게 안내 메일을 발송해라.

그렇다면 해야할 일은?

  1. 사용자의 이메일 정보를 관리해야함.
  2. 업그레이드 작업을 담은 메소드에 메일 발송 기능을 추가해야함.

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

JavaMail 메일 발송

자바에서 메일을 발송할 때는 표준 기술인 JavaMail을 사용하면됨.
1️⃣ 업그레이드 작업을 담은 메소드인 upgradeLevel()에서 메일 발송 메소드 호출

protected void upgradeLevel(User user){
	user.upgradeLevel();
	userDao.update(user);
	sendUpgradeEMail(user);
}

2️⃣ JavaMail API를 사용하는 메소드를 추가

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(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);
	} catch(UnsupportedEncodingException e){
		throw new RuntimeException(e);
	}
}

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

그런데,, 메일 서버가 준비되어 있지 않다면 어떻게 될 것인가?!

  1. 테스트 메일 서버를 이용해 테스트해보자.
    => 외부로 메일을 발송하지 않고, JavaMail과 연동해서 메일 전송 요청을 받는 것까지만 담당
  2. 테스트용 JavaMail을 이용하자
    => 이걸 선택하자!

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

문제가 존재한다! JavaMail 핵심 API에는 DataSource처럼 인터페이스로 만들어져서 구현을 바꿀 수 있는 게 없음
=> 서비스 추상화를 적용하자

메일 발송 기능 추상화

  1. 스프링의 MailSender를 이용한 메일 발송 메소드 추가
  2. 메일 전송 기능을 가진 오브젝트를 DI 받도록 UserService 수정
  • UserService 에 MailSender 인터페이스 타입의 변수를 만들고 수정자 메소드를 추가해 di 가 가능하도록 만든다.
  1. 스프링 설정파일 안에 메일 발송 오브젝트의 빈 등록

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

  1. 테스트용 메일 전송 클래스를 만들어보자 -> 구현해야 할 인터페이스는 MailSender
  2. DummyMailSender는 MailSender 인터페이스를 구현했을 뿐, 하는 일이 없음
  3. 테스트 설정 파일을 JavaMailSenderImpl 대신 DummyMailSender로 변경
  4. 테스트용 UserService를 위해 메일 전송 오브젝트의 수동 DI 진행

코드에 한 가지 부족한 점 -> 메일 발송 작업에 트랜잭션 개념이 빠져있음!!!
메일 발송기능에도 트랜잭션 기능을 적용하자.

방법 1 : 메일을 업그레이드할 사용자를 발견할 때마다 발송하지 않고 발송 대상을 별도의 목록에 저장해두기
-> 메일 저장용 리스트 등을 파라미터로 계속 갖고 다녀야 함.

방법 2 : MailSender를 확장해 메일 전송에 트랜잭션 개념 적용하기
-> 서로 다른 종류의 작업을 분리해 처리가 가능함.

서비스 추상화의 가치!

기술이나 환경이 바뀔 가능성이 있음에도, JavaMail처럼 확장이 불가능하게 설계해놓은 API를 사용해야하는 경우라면 추상화 계층의 도입을 적극 고려해볼 필요가 있다.특별히 외부의 리소스와 연동하는 대부분의 작업은 추상화의 대상이 될 수 있다.

🍃 테스트 대역

테스트 대역의 종류와 특징

테스트 대역 : 테스트 환경을 만들어주기 위해, 테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게 자주 테스트를 실행할 수 있도록 사용하는 오브젝트를 통틀어서 말한다.

대표적인 테스트 대역

테스트 스텁 test stub!

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

목 오브젝트!

  • 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해줌

목오브젝트

  • 목 오브젝트로 만든 메일 전송 확인용 클래스
static class MockMailSender implements MailSender{
	private List<String> requests = new ArrayList<String>();

	public List<String> getRequests(){
		return requests;
	}
	
	public void send(SimpleMailMessage mailMessage) throws MailException {
		requests.add(mailMessage.getTo()[0]); // 전송 요청 받은 이메일 주소 저장 / 간단하게 첫 번째 수신자 메일 주소만 저장함
	}
		
	public void send(SimpleMailMessage[] mailMessage) throws MailException {
	}
}

=> 실제로 메일을 발송하는 기능이 없음
=> 대신 테스트 대상인 UserService가 send() 메소드를 통해 자신을 불러서 메일 전송 요청을 보냈을 때 관련 정보를 저장해두는 기능이 있음

  • 메일 발송 여부 검증하도록 수정한 테스트
@Test
@DirtiesContext // 컨텍스트의 DI 설정을 변경하는 테스트라는 것을 알려줌
public void upgradeLevels() throws Exception {
	userDao.deleteAll();
	for(User user : users) userDao.add(user);

	// 메일 발송 결과를 테스트할 수 있도록 목 오브젝트를 만들어 userService의 의존 오브젝트로 주입해줌
	MockMailSender mockMailSender = new MockMailSender();
	userService.setMailSender(mockMailSender);
	
	// 업그레이드 테스트. 
	//메일 발송이 일어나면 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));
	assertThat(request.get(0), is(users.get(1).getEmail()));
	assertThat(request.get(1), is(users.get(3).getEmail()));
}

목 오브젝트를 이용한 테스트는 작성하기 간단하면서도 기능이 막강하다.
보통의 테스트 방법으로는 검증하기가 매우 까다로운 테스트 대상 오브젝트의 내부에서 일어나는 일이나 다른 오브젝트 사이에서 주고받는 정보까지 검증하는 일이 손쉽기 때문이다.

정리

  1. 코드는 책임과 역할에 따라서 분리하자.
  2. 인터페이스와 DI를 활용해서 코드 계층간 결합도를 낮추자.
  3. DAO 를 사용하는 비즈니스 로직에는 단위작업을 보장해주는 트랜잭션이 필요하다.
  4. 서비스 추상화는 로우레벨의 트랜잭션 기술과 api의 변화에 상관없이 일관된 api를 가진 추상화 계층을 도입한다.
  5. 테스트 대역은 테스트 대상 오브젝트가 원활하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.