본문 바로가기
챕터정리방

[5장] 서비스 추상화

by 100end 2021. 12. 29.

5장에서는 DAO에 트랜잭션을 적용해보면서 스프링이 어떻게 성격이 비슷한 여러 종류의 기술을 추상화하고 이를 일관된 방법으로 사용할 수 있도록 지원하는지 살펴보자.


1. 사용자 레벨 관리 기능 추가

지금까지의 UserDao는 가장 기초적인 CRUD 작업만 가능하다. 사용자 정보를 DB에 넣고 빼는 것을 제외하면 어떤 비즈니스 로직도 갖고 있지 않다. 서비스 추상화에 관한 내용을 보기 전에 사용자 레벨 관리 기능을 추가하자.

 

여기에 다음과 같은 비즈니스 로직을 구현해보자.

  • 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나다.
  • 사용자가 처음 가입하면 BASIC 레벨이 되며, 이후 활동에 따라서 한 단계씩 업그레이드될 수 있다.
  • 가입 후 50회 이상 로그인을 하면 BASIC에서 SILVER 레벨이 된다.
  • SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD 레벨이 된다.
  • 사용자 레벨의 변경 작업은 일정한 주기를 가지고 일관적으로 진행된다. 변경 작업 전에는 조건을 충족하더라도 레벨의 변경이 일어나지 않는다.

필드 추가

먼저 User 클래스에 사용자 레벨을 저장할 필드를 어떤 타입으로 넣을지, DB의 User테이블에는 어떤 타입으로 넣을지 생각해보자.

DB에 일정한 종류의 정보를 문자열로 넣는 것보다 각 레벨을 범위가 작은 숫자로 관리하면 DB 용량도 많이 차지하지 않고 가벼워서 좋다. 하지만 의미 없는 숫자나 범위를 벗어나는 값을 넣을 위험이 있다.

그래서 숫자 타입을 직접 사용하는 것보다 자바에서 제공하는 enum을 이용하는 게 안전하고 편리하다. 레벨로 사용할 enum을 아래와 같이 정의한다.

package springbook.user.domain;

public enum Level {
    BASIC(1), SILVER(2), GOLD(3);

    private final int value;

    Level(int value) {
        this.value = value;
    }

    public int intValue() {
        return value;
    }

    public static Level valueOf(int value) {
        switch(value) {
            case 1: return BASIC;
            case 2: return SILVER;
            case 3: return GOLD;
            default: throw new AssertionError("Unknown value : " + value);
        }
    }
}

 

위에서 만든 Level 타입의 변수를 User 클래스에 추가하자. 사용자 레벨 관리 로직에서 언급된 로그인 횟수와 추천수도 추가하자. 그리고 각각 접근자와 수정자 메소드도 추가해둔다.

public class User {
    ...
    Level level;
    int login;
    int recommend;

    public Level getLevel() {
        return level;
    }

    public void setLevel(Level level) {
        this.level = level;
    }
    ...
}

이제 DB의 USER 테이블에도 표에 나와 있는 필드를 추가한다.

필드명 타입 설정
Level tinyint Not Null
Login int Not Null
Recommend int Not Null

테스트 픽스처에 새로 추가된 세 필드의 값을 넣는다.

public class UserDaoTest {
    ...
    @Before
    public void setUp() {
        user1 = new User("gyumee", "박성철", "springno1", Level.BASIC, 1, 0);
        user2 = new User("leegw700", "이길원", "springno2", Level.SILVER, 55, 10);
        user3 = new User("bumjin", "박범진", "springno3", Level.GOLD, 100, 40);
    }
    ...
}

이에 맞게 User 클래스 생성자의 파라미터도 추가해준다.

class User {
    ...
    public User(String id, String name, String password, Level level, int login, int recommend) {
        this.id = id;
        this.name = name;
        this.password = password;
        this.level = level;
        this.login = login;
        this.recommend = recommend;
    }
    ...
}

다음은 UserDaoTest에서 checkSameUser() 메소드를 수정한다.

private void checkSameUser(User user1, User user2) {
    assertThat(user1.getId(), is(user2.getId()));
    assertThat(user1.getName(), is(user2.getName()));
    assertThat(user1.getPassword(), is(user2.getPassword()));
    assertThat(user1.getLevel(), is(user2.getLevel()));
    assertThat(user1.getLogin(), is(user2.getLogin()));
    assertThat(user1.getRecommend(), is(user2.getRecommend()));
}

수정한 checkSameUser()를 사용하도록 addAndGet() 메소드를 수정한다.

@Test
public void addAndGet() {
    ...
    User userget1 = dao.get(user1.getId());
    checkSameUser(userget1, user1);

    User userget2 = dao.get(user2.getId());
    checkSameUser(userget2, user2);
}

등록을 위한 add() 메소드의 SQL과 각종 조회 작업에 사용되는 User 오브젝트 매핑용 콜백인 userMapper에 추가된 필드를 넣는다.

public class UserDaoJdbc implements UserDao {
    ...
    private RowMapper<User> userMapper =
            new RowMapper<User>() {
                public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                    User user = new User();
                    user.setId(rs.getString("id"));
                    user.setName(rs.getString("name"));
                    user.setPassword(rs.getString("password"));
                    user.setLevel(Level.valueOf(rs.getInt("level")));
                    user.setLogin(rs.getInt("login"));
                    user.setRecommend(rs.getInt("recommend"));

                    return user;
                }
            };
    ...
    public void add(final User user) throws DuplicateUserIdException {
        jdbcTemplate.update("insert into users(id, name, password, level, login, recommend) values(?, ?, ?, ?, ?, ?)",
                user.getId(), user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend());
    }
}

여기서 눈여겨볼 것은 Level 타입의 level 필드를 사용하는 부분이다. Level enum은 오브젝트이므로 DB에 저장될 수 있는 SQL 타입이 아니다. 따라서 DB에 저장 가능한 정수형 값으로 변환해줘야 한다. 각 Level enum의 DB 저장용 값을 얻기 위해서는 Level에 미리 만들어둔 intValue() 메소드를 사용한다. add() 메소드에서 이 메소드를 사용했다.

이제 테스트를 실행해보면 성공할 것이다.

사용자 수정 기능 추가

사용자 관리 비즈니스 로직에 따르면 사용자 정보는 여러 번 수정될 수 있다. 기본키인 id를 제외한 나머지 필드는 수정될 가능성이 있다. 아직 사용자 정보가 단순하고 필드도 몇 개 되지 않으니 간단히 접근하자. 수정할 정보가 담긴 User 오브젝트를 전달하면 id를 참고해서 사용자를 찾아 필드 정보를 UPDATE 문을 이용해 모두 변경해주는 메소드를 하나 만들겠다.

 

만들어야 할 코드의 기능을 생각해볼 겸 아래와 같은 테스트를 먼저 작성한다.

@Test
public void update() {
    dao.deleteAll();

    dao.add(user1);

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

여기서 user1이라는 픽스처는 인스턴스 변수로 만들어놓은 것인데, 이를 직접 변경하는 것에 의아함을 가질 수 있다. 하지만 상관없다. 어차피 테스트 메소드가 실행될 때마다 UserDaoTest 오브젝트는 새로 만들어지고, setUp() 메소드도 다시 불려서 초기화될 테니 변경해도 상관없다.

 

여기까지 만들고 나면 dao 변수의 타입인 UserDao 인터페이스에 update() 메소드가 없다는 컴파일 에러가 날 것이다. UserDao 인터페이스에 update() 메소드를 추가한다.

public interface UserDao {
    ...
    public void update(User user1);
}

UserDaoJdbc의 update() 메소드는 JdbcTemplate의 update() 기능을 사용해서 UPDATE 문과 바인딩할 파라미터를 전달해주면 된다.

public void update(User user) {
    this.jdbcTemplate.update(
            "update users set name = ?, password = ?, level = ?, login = ?, recommend = ? where id = ?",
            user.getName(), user.getPassword(), user.getLevel().intValue(), user.getLogin(), user.getRecommend(), user.getId());
}

이제 테스트를 돌려서 결과를 확인해보자.

 

위에서 작성한 update() 테스트로는 검증하지 못하는 오류가 있을 수 있다. 바로 UPDATE 문장에서 WHERE 절을 빼먹는 경우다. UPDATE는 WHERE가 없어도 아무런 경고 없이 정상적으로 동작하는 것처럼 보인다. update() 테스트에서 바뀐 로우의 내용만 확인해보면 정상적으로 동작하는 것처럼 보인다. 하지만 현재 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의 getAll() 메소드로 사용자를 다 가져와서 사용자별로 레벨 업그레이드 작업을 진행하면서 UserDao의 update()를 호출해 DB에 결과를 넣어준다. UserService는 UserDao의 구현 클래스가 바뀌어도 영향받지 않도록 해야 한다.

UserService의 클래스 레벨 의존관계를 정리해보면 아래와 같다.

UserService의 의존관계

UserService 클래스를 만들고 사용할 UserDao 오브젝트를 저장해둘 인스턴스 변수를 선언한다.

DI가 가능하도록 수정자 메소드도 추가한다.

package springbook.user.service;

public class UserService {
    UserDao userDao;
    
    public void setUserDao(UserDao userDao) {
    	this.userDao = userDao;
    }
}

그리고 스프링 설정 파일에 UserService 아이디로 빈을 추가한다.

<bean id="userService" class="springbook.user.service.UserService">
	<property name="userDao" ref="userDao" />
</bean>
...

다음은 UserServiceTest 클래스를 추가하고 테스트 대상인 UserService 빈을 제공받을 수 있도록 @Autowired가 붙은 인스턴스 변수로 선언해준다. 그리고 간단히 userService 빈이 생성돼서 userService 변수에 주입되는지 확인하는 테스트도 추가하자.

package springbook.user.service;

...
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/test-applicationContext.xml")
public class UserServiceTest {
	
    @Autowired
    UserService userService;
    
    @Test
    public void bean() {
    	assertThat(this.userService, is(notNullValue()));
    }
}

이제 사용자 레벨 관리 기능을 만들어보자. UserService에 다음 메소드를 추가한다.

public void upgradeLevels() {
    List<User> users = userDao.getAll();
    for(User user : users) {
        Boolean changed = null;
        if(user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
            user.setLevel(Level.SILVER);
            changed = true;
        } else if(user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
            user.setLevel(Level.GOLD);
            changed = true;
        } else if(user.getLevel() == Level.GOLD) {
            changed = false;
        } else {
            changed = false;
        }

        if(changed) {
            userDao.update(user);
        }
    }
}

사용자 레벨은 BASIC, SILVER, GOLD 세 가지가 있고, 변경이 일어나지 않는 GOLD를 제외한 나머지는 두 가지 업그레이드가 되는 경우와 아닌 경우가 있을 수 있으므로 최소한 다섯 가지 경우를 살펴봐야 한다. 다섯 종류의 사용자 정보를 등록해두고 업그레이드를 진행한 후에 예상한 대로 결과가 나오는지 테스트를 만들어보자.

class UserServiceTest {
	...
    List<User> users;		//	테스트 픽스처
    
    @Before
    public void setUp() {
    	users = Arrays.asList(
            new User("bumjin", "박범진", "p1", Level.BASIC, 49, 0),
            new User("joytouch", "강명성", "p2", Level.BASIC, 50, 0),
            new User("erwins", "신승한", "p3", Level.SILVER, 60, 29),
            new User("madnite1", "이상호", "p4", Level.SILVER, 60, 30),
            new User("green", "오민규", "p5", Level.GOLD, 100, 100)
        );
    }
    
    @Test
    public void upgradeLevels() {
    	userDao.deleteAll();
        for(User user : users) {
        	userDao.add(user);
        }
        
        userService.upgradeLevels();
        
        checkLevel(users.get(0), Level.BASIC);
        checkLevel(users.get(1), Level.SILVER);
        checkLevel(users.get(2), Level.SILVER);
        checkLevel(users.get(3), Level.GOLD);
        checkLevel(users.get(4), Level.GOLD);
    }
    
    private void checkLevel(User user, Level expectedLevel) {
    	User userUpdate = userDao.get(user.getId());
        assertThat(userUpdate.getLevel(), is(expectedLevel));
    }
}

 

UserService.add()

사용자 관리 비즈니스 로직에서 대부분 구현했지만 아직 하나가 남았다. 처음 가입하는 사용자는 기본적으로 BASIC 레벨이어야 한다는 부분이다. 이 로직은 사용자 관리에 대한 비즈니스 로직을 담고 있는 UserService에 넣는 것이 적합할 것이다. 우선 테스트부터 만들어보자.

@Test
public void add() {
    userDao.deleteAll();

    User userWithLevel = users.get(4);      //  GOLD 레벨
    User userWithoutLevel = users.get(0);
    userWithoutLevel.setLevel(null);
	
    userService.add(userWithLevel);
    userService.add(userWithoutLevel);
    
    User userWithLevelRead = userDao.get(userWithLevel.getId());
    User userWithoutLevelRead = userDao.get(userWithoutLevel.getId());
    
    assertThat(userWithLevelRead.getLevel(), is(userWithLevel.getLevel()));
    assertThat(userWithoutLevelRead.getLevel(), is(Level.BASIC));
}

이제 테스트가 성공하도록 코드를 만들어보자.

public void add(User user) {
    if(user.getLevel == null) {
    	user.setLevel(Level.BASIC);
    }
    userDao.add(user);
}

테스트를 돌려보면 성공할 것이다.

 

코드 개선

이제 비즈니스 로직의 구현을 모두 마쳤다. 코드를 다시 한번 검토해보자.

작성된 코드를 살펴볼 때는 다음과 같은 질문을 해볼 필요가 있다.

  • 코드에 중복된 부분은 없는가?
  • 코드가 무엇을 하는 것인지 이해하기 불편하지 않은가?
  • 코드가 자신이 있어야 할 자리에 있는가?
  • 앞으로 변경이 일어난다면 어떤 것이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

이런 질문을 하며 UserService의 upgradeLevels() 메소드를 살펴보면 몇 가지 문제점이 보인다.

 

if 조건 블록이 레벨 개수만큼 반복된다. 업그레이드 조건이 복잡해지거나 업그레이드 작업에서 하는 일이 level 필드를 변경하는 수준 이상이라면 upgradeLevels() 메소드는 점점 복잡해지며, 갈수록 이해하고 관리하기 힘들어진다.

지저분해지는 코드 탓에 찾기 힘든 버그가 숨어들 가능성이 높아질 것이다.

또 현재 레벨과 업그레이드 조건을 동시에 비교하는 부분도 문제가 될 수 있다. 

 

이제 이 코드를 리팩토링 해보자.

레벨 업그레이드 작업의 기본 흐름만 추상적으로 다음과 같이 만들어보자.

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

구체적인 내용은 모르겠지만 upgradeLevels() 메소드가 어떤 작업을 하는지는 쉽게 이해할 수 있다. 이제 하나씩 구체적인 내용을 담은 메소드를 만들면 된다.

 

업그레이드가 가능한지 알려주는 메소드인 canUpgradeLevel() 메소드를 만들어보자.

private boolean canUpgradeLevel(User user) {
    Level currnetLevel = user.getLevel();
    switch(currentLevel) {
    	case BASIC: return (user.getLogin() >= 50);
        case SILVER: return (user.getRecommend() >= 30);
        case GOLD: return false;
        default: throw new IllegalArgumentException("Unkown Level: " + currentLevel);
    }
}

다음은 upgradeLevel() 메소드를 만들어보자.

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

User의 내부 정보가 변경되는 것은 UserService보다는 User가 스스로 다루는게 적절하다.  그래서 사용자 정보가 바뀌는 부분을 User에 옮겼다.

 

다음은 User의 upgradeLevel() 메소드이다.

public void upgradeLevel() {
    Level nextLevel = this.level.nextLevel();
    if(nextLevel == null) {
    	throw new IllegalStateException(this.level + "은 업그레이드가 불가능합니다.");
    } else {
    	this.level = nextLevel;
    }
}

레벨의 순서와 다음 단계 레벨이 무엇인지를 결정하는 일은 Level에게 맡기자. Level을 다음과 같이 수정한다.

public enum Level {
    GOLD(3, null), SILVER(2, GOLD), BASIC(1, SILVER);
    
    private final int value;
    private final Level next;
    
    Level(int value, Level next) {
        this.value = value;
        this.next = next;
    }
    
    public int intValue() {
        return value;
    }

    public Level nextLevel() {
        return next;
    }
    ...
}

이제 테스트를 돌려보면 테스트가 성공할 것이다. 사용자 레벨 관리 기능 구현을 완성하였다.

 


2. 트랜잭션 서비스 추상화

여러 개의 SQL의 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우가 있다.

이 때, 두 개의 SQL 작업이 하나의 트랜잭션이 되려면, 첫 번째 SQL을 성공적으로 실행했지만 두 번째 SQL이 성공하기 전에 장애가 생겨서 작업이 중단되는 경우, 앞에서 처리한 SQL도 취소시켜야 한다. 이런 취소 작업을 트랜잭션 롤백(transaction rollback)이라고 한다. 반대로 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정시켜야 한다. 이것을 트랜잭션 커밋(transaction commit)이라고 한다.

 

JDBC 트랜잭션 경계설정

모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 방법은 한 가지이지만 끝나는 방법은 두 가지다. 모든 작업을 무효화하는 롤백과 모든 작업을 확정하는 커밋이다. 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다.

 

JDBC 트랜잭션의 시작과 종료는 Connection 오브젝트를 통해 이뤄진다. JDBC에서 트랜잭션을 시작하려면setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하면 된다.

이러한 작업을 트랜잭션 경계 설정이라고 한다.

 

트랜잭션의 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다. 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션(local transaction)이라고도 한다.

 

 

트랜잭션 동기화

비즈니스 로직에 트랜잭션 경계를 설정할 때 깔끔하게 정리된 코드를 포기하거나 트랜잭션 기능을 포기해야 하는 딜레마가 생긴다. 여기서 스프링은 트랜잭션 동기화(Transaction Synchronization)라는 독립적인 방법을 제안한다.

트랜잭션 동기화란 Connection 오브젝트를 특별한 저장소에 보관해두고, 이후에 저장된 Connection을 가져다가 사용하게 하는 것이다. 정확히는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다. 그리고 트랜잭션이 모두 종료되면, 그 때는 동기화를 마치면 된다.

 

아래 그림은 트랜잭션 동기화 방식을 사용한 경우의 작업 흐름을 보여준다.

트랜잭션 동기화를 사용한 경우의 작업 흐름

 

트랜잭션 서비스 추상화

UserService 같은 곳에 트랜잭션 경계설정 코드가 들어가면 특정 트랜잭션 방법에 의존적인 코드가 되어버릴 수 있다.

UserService의 코드가 특정 트랜잭션 방법에 의존적이지 않고 독립적일 수 있게 만들려면 어떻게 해야 할까?

트랜잭션 경계설정 코드를 제거할 수는 없지만 특정 기술에 종속되지 않게 할 수 있는 방법은 있다. 다행히도 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다. 덕분에 추상화를 생각해볼 수 있다.

추상화란 하위 시스템의 공통점을 뽑아내서 분리시키는 것을 말한다. 그렇게 하면 하위 시스템이 바뀌거나 알지 못해도 일관된 방법으로 접근할 수가 있다. 

 

트랜잭션 경계설정 방법에서 공통적인 특징을 모아서 추상화된 트랜잭션 관리 계층을 만들 수 있다. 그리고 애플리케이션 코드에서는 트랜잭션 추상 계층이 제공하는 API를 이용해 트랜잭션을 이용하게 만들어준다면 특정 기술에 종속되지 않는 트랜잭션 경계설정 코드를 만들 수 있을 것이다.

 

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

스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다. 이를 이용하면 애플리케이션에서 직접 각 기술의 트랜잭션 API를 이용하지 않고도, 일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다. 아래 그림은 스프링이 제공하는 트랜잭션 추상화 계층구조를 보여준다.

스프링의 트랜잭션 추상화 계층

다음은 스프링의 트랜잭션 서비스 추상화 기술을 사용하여 수정한 코드들이다.

UserService.java

public class UserService {
    ...
    private PlatformTransactionManager transactionManager;
    
    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }
    
    ...
    public void upgradeLevels() {
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            List<User> users = userDao.getAll();
            for(User user : users) {
                if(canUpgradeLevel(user)) {
                    upgradeLevel(user);
                }
            }
            transactionManager.commit(status);
        } catch(RuntimeException e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
    ...
}

UserServiceTest.java

public class UserServiceTest {
    ...
    @Autowired
    PlatformTransactionManager transactionManager;
    
    ...
    
    //	예외 발생 시 rollback 여부 테스트
    @Test
    public void upgradeAllOrNothing() {
        UserService testUserService = new UserServiceTest.TestUserService(users.get(3).getId());
        testUserService.setUserDao(this.userDao);
        testUserService.setTransactionManager(transactionManager);

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

        try {
            testUserService.upgradeLevels();
            fail("TestUserServiceException expected");
        } catch(UserServiceTest2.TestUserServiceException e) {

        }

        checkLevelUpgraded(users.get(1), false);
    }

    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 UserServiceTest2.TestUserServiceException();
            }
            super.upgradeLevel(user);
        }
    }

    static class TestUserServiceException extends RuntimeException {
    }
    
}

3. 서비스 추상화와 단일 책임 원리

 

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

UserDao와 UserService는 각자 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다. 같은 애플리케이션 로직을 담은 코드지만 내용에 따라 분리했고, 같은 계층에서 수평적인 분리라고 볼 수 있다.

아래 그림은 지금까지 만들어진 사용자 관리 모듈의 의존관계를 나타낸다. 

계층과 책임의 분리

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

 

단일 책임 원칙

이런 적절한 분리가 가져오는 특징은 객체지향 설계의 원칙 중 하나인 단일 책임 원칙(Single Responsibillty Priciple)로 설명할 수 있다. 단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미다. 하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.

 

단일 책임 원칙의 장점

단일 책임 원칙을 잘 지키고 있다면, 어떤 변경이 필요할 때 수정 대상이 명확해진다. 기술이 바뀌면 기술 계층과의 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 된다. 기술적인 수정사항이나 비즈니스 로직도 마찬가지다.

그래서 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다.

이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI다. DI를 통해 객체 생성과 의존관계 설정을 스프링에 맡기면 완벽하게 특정 기술에서 자유로운 애플리케이션 계층의 코드를 가질 수 있게 된다.


4. 메일 서비스 추상화

 

JavaMail 메일 발송과 테스트

자바에서 메일을 발송할 때는 표준 기술인 JavaMail을 사용한다.

JavaMail을 이용해 메일을 발송 기능을 운영할 때는 메일 전송 서버가 준비되어 있어야 한다. 하지만 테스트를 할 때는 메일 전송 서버를 준비해두고 테스트를 하면서 매번 메일을 발송시키는 것은 바람직하지 못하다. 메일 발송은 부하가 매우 큰 작업이기 때문이다. 실제 운영 중인 메일 서버를 통해 테스트를 실행할 때마다 메일을 보내면 메일 서버에 상당한 부담을 줄 수 있다.

 

우리가 만들 메일 발송 기능은 사용자 레벨 업그레이드 작업의 보조적인 기능에 불과하다. 정상적으로 동작하는지 확인하는 일이 중요하긴 하지만, 업그레이드 정책에 따라 업그레이드가 실제로 일어나는지, 그것이 DB에 잘 반영되는지를 확인하는 일만큼 중요하지는 않다.

 

메일 서버는 충분히 테스트된 시스템이다. SMTP로 메일 전송 요청을 받으면 별문제 없이 메일이 잘 전송됐다고 믿어도 충분하다. 따라서 메일 테스트를 한다고 매번 메일 수신 여부까지 일일이 확인할 필요는 없고, 테스트 가능한 메일 서버까지만 잘 전송되는지 확인하면 된다. 그리고 테스트용 메일 서버는 메일 전송 요청은 받지만 실제로 메일이 발송되지 않도록 설정해주면 된다.

 

JavaMail은 자바의 표준 기술이고 이미 수많은 시스템에 사용돼서 검증된 안정적인 모듈이다. 따라서 JavaMail API를 통해 요청이 들어간다는 보장만 있으면 굳이 테스트 할 때마다 JavaMail을 직접 구동시킬 필요가 없다. 운영 시에는 JavaMail을 이용해서 동작하도록 해야겠지만, 개발 중이거나 테스트를 수행할 때는 JavaMail을 대신할 수 있는, 그러나 JavaMail을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 만들어도 될 것이다. 이렇게 한다면 불필요한 메일 전송 요청을 보내지 않아도 되고, 테스트도 매우 빠르고 안전하게 수행될 수 있다.

 

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

그런데 한 가지 심각한 문제가 있다. JavaMail의 핵심 API에는 DataSource처럼 인터페이스로 만들어져서 구현을 바꿀 수 있는게 없다. JavaMial은 확장이나 지원이 불가능하도록 만들어진 악명 높은 표준 API 중의 하나로 알려져 있다. 이렇게 테스트하기 힘든 구조인 API를 테스트하기 좋게 만드는 방법은 서비스 추상화를 적용하면 된다.

 

스프링은 JavaMail을 사용한 코드는 테스트하기 힘들다는 문제를 해결하기 위해 JavaMail에 대한 추상화 기능을 제공하고 있다. 아래는 스프링이 제공하는 메일 서비스 추상화의 핵심 인터페이스다.

package org.springframework.mail;

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

이 인터페이스는 SimpleMailMessage라는 인터페이스를 구현한 클래스에 담긴 메일 메시지를 전송하는 메소드로만 구성되어 있다. 기본적으로는 JavaMail을 사용해 메일 발송 기능을 제공하는 JavaMailSenderImpl 클래스를 이용하면 된다.

 

아래 그림은 스프링이 제공하는 MailSender 인터페이스를 핵심으로 하는 메일 전송 서비스 추상화의 구조다.

스프링의 메일 전송 서비스 추상화 구조

스프링이 직접 제공하는 MailSender를 구현한 추상화 클래스는 JavaMailSenderImpl 하나뿐이다. 이 추상화된 메일 전송 기능을 사용해 애플리케이션을 작성함으로써 얻을 수 있는 장점은 크다. 추상화 계층을 이용할 수 있는 수많은 응용 방법이 있고 테스트를 원활하게 해주기 때문이다.

 

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

 

다음은 스프링의 MailSender를 이용해 수정한 UserService이다.

public class UserService {
    ...
    private MailSender mailSender;
    
    public void setMailSender(MailSender mailSender) {
        this.mailSender = mailSender;
    }
    
    ...
    protected void upgradeLevel(User user) {
        user.upgradeLevel();
        userDao.update(user);
        sendUpgradeEMail(user);
    }
    
    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() + "로 업그레이드 되었습니다.");
        
        this.mailSender.send(mailMessage);
    }
}

 

 

테스트가 수행될 때는 JavaMail을 사용해서 메일을 전송할 필요가 없다. 따라서 아무것도 하지 않는 MailSender 구현 클래스(DummyMailSender)를 만들어보자.

package springbook.user.service;

...
public class DummyMailSender implements MailSender {
    public void send(SimpleMailMessage mailMessage) throws MailException {
    }
    
    public void send(SimpleMailMessage[] mailMessage) throws MailException {
    }
}

 

DummyMailSender를 빈으로 등록하고 UserService에 DI 해준다.

<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.DummyMailSender" />

 

이제 테스트 코드를 수정하고 테스트를 수행해보자.

public class UserServiceTest {
    ...
    @Autowired
    MailSender mailSender;
    
    ...
    
    @Test
    public void upgradeAllOrNothing() throws Exception {
        ...
        testUserService.setMailSender(mailSender);
        ...
    }
}

 

테스트 대역

테스트 대상이 되는 오브젝트의 기능에만 충실하게 수행하면서 빠르게, 자주 테스트를 실행할 수 있도록 사용하는 이런 오브젝트를 통틀어서 테스트 대역(test double)이라고 부른다.

대표적인 테스트 대역은 테스트 스텁(test stub)이다. 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행할 수 있도록 돕는 것을 말한다. DummyMailSender는 가장 단순하고 심플한 테스트 스텁의 예다.

 

테스트 대상 오브젝트의 메소드가 돌려주는 결과뿐 아니라 테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위 자체에 대해서도 검증하고 싶은 경우에는 테스트 대상의 간접적인 출력 결과를 검증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 목 오브젝트(mock object)를 사용해야 한다. 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.

 

아래 그림은 목 오브젝트를 이용한 테스트의 구조다.

목 오브젝트를 이용한 테스트 동작방식

여기서 (5)번을 제외하면 스텁이라고 봐도 된다.

 

테스트 대상은 의존 오브젝트에게 값을 출력하기도 하고 값을 입력받기도 한다. 출력은 무시한다고 칠 수 있지만, 간접적으로 테스트 대상이 받아야할 입력 값은 필요하다. 이를 위해 별도로 준비해둔 스텁 오브젝트가 메소드 호출 시 특정 값을 리턴하도록 만들어두면 된다.

 

때론 테스트 대상 오브젝트가 의존 오브젝트에게 출력한 값에 관심이 있을 경우가 있다. 또는 의존 오브젝트를 얼마나 사용했는가 하는 커뮤니케이션 행위 자체에 관심이 있을 수 있다. 문제는 이 정보는 테스트에서는 직접 알 수가 없다는 점이다. 이 때 테스트 대상과 의존 오브젝트 사이에 주고 받는 정보를 보존해두는 기능을 가진 테스트용 의존 오브젝트인 목 오브젝트를 만들어서 사용해야 한다. 테스트 대상 오브젝트의 메소드 호출이 끝나고 나면 테스트는 목 오브젝트에게 테스트 대상과 목 오브젝트 사이에서 일어났던 일에 대해 확인을 요청해서, 그것을 테스트 검증 자료로 삼을 수 있다.

 

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

 

테스트가 수행될 수 있도록 의존 오브젝트에 간접적으로 입력 값을 제공해주는 스텁 오브젝트와 간접적인 출력 값까지 확인이 가능한 목 오브젝트, 이 두가지는 테스트 대역의 가장 대표적인 방법이며 효과적인 테스트 코드를 작성하는 데 빠질 수 없는 중요한 도구다.

'챕터정리방' 카테고리의 다른 글

[7장] 스프링 핵심 기술의 응용  (0) 2022.01.16
[6장] AOP  (0) 2022.01.03
[5장] 서비스 추상화  (0) 2021.12.26
[4장] 예외  (0) 2021.12.21
[3장] 템플릿  (0) 2021.12.17

댓글