[토비의 스프링 3.1] 5장. 서비스 추상화

대연.

·

2021. 9. 27. 01:12

Source : yes24

  1. 해당 포스팅은 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
  2. 공부하면서 블로그를 참고하였는데, 책 내용을 그대로 정리하는데 그치는 글들이 절반이었습니다. 스스로 새롭게 알게 된 내용이거나 책의 설명이 너무 불친절한 경우 부가설명을 작성하거나 또는 새롭게 쓰고자 노력했습니다. 공부하다 생기는 의문들은 레포지토리 이슈에서 질의응답을 주고 받았으니 학습하다 궁금한 점이 생기면 검색 해보시기를 권장드립니다.
  3. 틀린 내용이 있다면 댓글로 알려주시면 감사하겠습니다.
  4. 코드나 책 내용 캡쳐 내용들은 다른 블로그의 캡쳐본이나 텍스트를 가져와 작성하였습니다. 대부분 출처를 표기하였으나 누락된 경우 원하시는 조치 내용을 댓글로 남겨주시면 시정하겠습니다.

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

지금까지 다루었던 DAO는 단순 DB에 저장하고 불러오는 기능만을 담당했다. 간단한 비즈니스 로직을 추가하는 것이 이 장의 목표다.

  1. 사용자의 레벨은 BASIC, SILVER, GOLD 세 가지 중 하나이다.
  2. 사용자의 초기 레벨은 BASIC, 활동에 따라 업그레이드된다.
  3. 가입 후 50회 이상 로그인시 BASIC 레벨에서 SILVER 레벨이 된다.
  4. SILVER 레벨이면서 30번 이상 추천을 받으면 GOLD 레벨이 된다.
  5. 사용자 레벨의 변경작업은 일정한 주기를 가지고 일괄적으로 진행된다. 실시간으로 조건을 충족하더라도 즉각적으로 등급의 변경이 일어나지 않는다.

ENUM

User 클래스에 사용자의 레벨을 어떻게 담을 수 있을까? 등급의 "BASIC"과 같이 문자열 그대로를 삽입하는 것은 좋은 아이디어가 아닐 것이다. (DB의 저장공간을 비효율적으로 사용하기도 하고, 나중에 등급명의 변경이 일어난다면? 직접 문자열 그대로를 저장하는 대신 관계형 DB를 고려하는 것이 좋지만 이 책에서 다루는 범위는 아니기에 설명을 하지 않는다.)

각 등급에 해당하는 상수 값을 매핑해서 사용하는 방법이 있다.

class User{
        private static final int BASIC = 1;
        private static final int SILVER = 2;
        private static final int GOLD = 3;

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

문제는, 1,2,3이 User 클래스에서는 유저의 레벨 정보를 의미하지만, 만약 숫자를 유저의 접근 권한으로 쓰는 클래스가 있다고 해보자. 그리고 프로그래머는 다음과 같은 엉뚱한 실수를 저지른다면, 타입 체킹에선 정수형이라 아무 문제가 일어나지 않지만 의미상으로는 굉장히 위험한 정보의 설정과 교환이 이루어 질 것이다.

혹은 3을 넘는 범위를 집어넣게 되면 예측할 수 없는 문제들이 생겨날 것이다.

user1.setLevel(otherClass.getAuthLevel());
user1.setLevel(20131201);

각 숫자에 의미를 부여하고 프로그래머가 조심하도록 사용하여 실수를 유발하는 정수타입 대신에, Enum을 사용하자.

public enum Level {
    BASIC(1), SILVER(2), GOLD(3); // ENUM 클래스의 인스턴스라 생각하기.

    private final int value;

    Level(int value) {
        this.value = value;
    }
        // Level.BASIC의 경우 value 필드가 1로 설정이 된다.

    public int intValue() {
        return value;
    }
        // 각 등급 Enum 인스턴스의 정수 값 반환

    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);
        }
    }
        // 각 등급의 매핑된 정수를 입력하면 Enum 인스턴스 반환
}

user1.setLevel(Level.SILVER); 

주의할 점은 JDBC에서 Enum 객체를 DB에 직접 집어넣을 수 없다. 대신 Level.intValue()를 정의했으니 각 Enum 인스턴스를 매핑된 정수로 변환하여 DB에 넣어야 한다.

MyBatis나 다른 JPA의 경우에는 Enum 객체를 매핑하면 각 Enum 인스턴스의 이름을 스트링으로 변환해 알아서 집어넣지만, 사용을 권장하지는 않는다.

사용자 수정기능 추가

먼저 테스트를 작성한다.

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

    dao.add(user1);

    user1.setName("고전파");
    user1.setPassword("1557");
    user1.setLevel(Level.GOLD);
    user1.setLogin(1557);
    user1.setRecommend(36);
    dao.update(user1);

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

테스트부터 작성했으니 당연히 dao.update() 메서드는 존재하지 않는다.

@Override
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());
}
// SQL에 문제가 있는 코드이다.

위 update() 메서드를 구현하면 테스트는 문제없이 통과가 되지만, 뭔가 찝찝한 기분이 들 것이다. WHERE는 update 구문에서 빠져도 문제가 없지만, 대신 모든 row가 update의 대상이 될 것이다. 즉 테이블의 전체 row가 변경될 것이라는 의미이다.

테스트를 좀 더 고도화하면 이러한 참사(?)를 막을 수 있다.

  1. SQL의 UPDATE, DELETE, INSERT등은 정수 값을 리턴하는데, 영향을 받은 row의 갯수를 반환한다. UPDATE의 경우, 변경된 row의 갯수를 반환할 것이다. 이를 확인하는 코드를 테스트에 삽입한다.
  2. 원하는 사용자 외의 정보는 변경되지 않음을 확인한다. 사용자를 다수 등록하고, 단 하나의 사용자만 수정한 다음, 영향받지 않아야할 다른 사용자의 정보가 그대로 있는지를 확인하는 방법이 있다.

사용자 관리 로직은 어디에 두어야 할까?

등급을 관리하기 위한 DB 기능은 모두 구현했다. 그러면 실질적으로 누구를 어떤 등급으로 설정할 지에 해당하는 사용자 관리 로직은 어디에 두어야 할까? DAO는 DB와 관련된 로직만을 담당하도록 두는 것이 좋다. 그 대신 UserService 클래스를 추가하자.

UserService 구현하기

UserDao를 인터페이스 타입으로 Bean을 DI받아서 사용한다. UserDao 구체 클래스의 구현이 바뀌어도 UserService는 영향을 받지 않아야한다. UserDao를 DI받기 위해선 당연히 UserService도 Spring Bean으로 등록되어야 한다.

source : https://leejaedoo.github.io/service_abstracting/

public class UserService {
    UserDao userDao; 
        // UserDao를 주입받아서 저장하는 공간

    public void setUserDao(UserDao userDao) { 
                // UserDao를 주입받는 Setter
        this.userDao = userDao;
    }
}

유저 레벨 조작 구현하기

DAO도 완성됐고, 비즈니스 로직을 다룰 UserService 클래스도 준비가 됐다/

public class UserService {
    //...    
    public void upgradeLevels() {
        List<User> users = userDao.getAll();
        for (User user : users) {
                        boolean isChanged = false;
            if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
                user.setLevel(Level.SILVER);
                isChanged = true; // 조건에 부합하여 유저 등급이 바뀌어야 함
            } else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
                user.setLevel(Level.GOLD);
                isChanged = true;
            }
                        if(isChanged){ userDao.update(user); } // 등급 바뀔 시 업데이트 적용
        }
    }
}

처음 가입한 회원은 BASIC 등급이어야 한다.

UserDao는 시키는 DB 트랜젝션 수행에만 관심이 있어야 한다. 그렇기에 UserDao는 적절치 않고, UserClass의 초기값을 BASIC으로 설정하기에도 좀 이상하다.

UserService에 회원가입을 담당하는 add() 메서드를 만들고, 그 안에서 초기 레벨을 BASIC으로 두도록 하자. 만약 어떤 유저는 회원가입할 때 부터 GOLD 등급으로 시작할 수도 있다. 이 경우에는 BASIC으로 초기화하지 않아야 할 것이다.

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

코드 리팩토링

코드를 돌아보며 다음 사항들을 체크하자.

  • 코드에 중복된 부분이 있는가?
  • 코드가 어떤 역할을 하는지 이해하기 불편한가?
  • 코드가 있어야 할 위치에 있는가?
  • 앞으로 코드에 어떤 변화가 있을 수 있고, 그 변화에 쉽게 대응이 가능한가?
for (User user : users) {
    if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
                userSetLevel(...);
                isChanged = true;
        }
}

분기문의 조건이 너무 복잡하다.

  1. 현재 레벨이 무엇인지 파악하는 로직
  2. 업그레이드 조건을 담은 로직
  3. 어떤 등급으로 업그레이드 할 것인지 담는 로직
  4. 미래의 업데이트 여부를 판독하기 위한 플래그
  5. 실제 등급변화를 반영하는 로직

문제는 이런 조건블록이 레벨이 많아지면 많아질수록 반복되어 나타난다. Level Enum도 수정해야 한다. upgradeLevels()는 점점 복잡해지고 비대해질것이며 버그가 생기기도 쉬울 것이고 디버그 하기는 점점 어려울 것이다. 이 요소들을 작은 단위로 분리해보자.

업그레이드 가능한지 여부를 확인하는 메서드

    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);
                            // 현재 로직에서 다룰 수 없는 레벨의 경우 예외를 발생한다.
                            // 추후 레벨을 추가하였는데 이 로직을 수정하지 않는다면 예외를 띄울 것이다.
    }
}

여기서부터는 책의 내용과 달라집니다. 저는 책에서 제안한 내용에 더해, Effective Java에서 다루었던 내용을 섞어 새롭게 리팩토링할 예정입니다.

 

책에서 제안한 코드는 위와 같지만, Enum 인스턴스에 직접 승급 조건을 판단하는 로직을 넣는 것이 관심사의 분리 측면에서 올바르다고 생각한다.

public enum Level {
    BASIC(1){ 
          public boolean isPossibleUpgrade(User user) { // 각 Enum 인스턴스에 업그레이드 조건 작성
              return (user.getLogin() >= 50);
          }
    }, 
    SILVER(2){
        public boolean isPossibleUpgrade(User user) {
                return (user.getRecommend() >= 30);
            }
    }, 
    GOLD(3){
        public boolean isPossibleUpgrade(User user) {
                return false; // 마지막 등급의 업그레이드는 불가능하기 때문에 항상 false
            }
    }; 

    private final int value;
        public abstract boolean isPossibleUpgrade(User user);
    Level(int value) {
        this.value = value;
    }
        // Level.BASIC의 경우 value 필드가 1로 설정이 된다.

    public int intValue() {
        return value;
    }
        // 각 등급 Enum 인스턴스의 정수 값 반환

    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);
        }
    }
        // 각 등급의 매핑된 정수를 입력하면 Enum 인스턴스 반환
}
// class UserService
private boolean canUpgradeLevel(User user) {
    Level currentLevel = user.getLevel();
    if(currentLevel == null){
                throw new IllegalStateException("No level for this User.");
        }
        return currentLevel.isPossibleUpgrade(user);

}

실질적 레벨 업그레이드를 수행하는 메서드

// class User
private void upgradeLevel() {
      Level currentLevel = this.getLevel();
            if(currentLevel == null) 
                    throw new IllegalStateException("유저의 레벨이 존재하지 않습니다.");
      try {
                    Level nextLevel = Level.valueOf(++currentLevel.intValue());
                    this.setLevel(nextLevel);
            }
            catch(AssertionError e){
                    // 승급할 레벨을 valueOf로 못 찾는 경우
                    throw new IllegalStateException(this.level + " 업그레이드 불가. 이미 최고레벨입니다.");
            }
  }
// class UserService
private void upgradeLevel(User user){
        user.upgradeLevel();
        userDao.update(user);
}

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

무식하게 if문을 때려박고 여러 책임이 혼재되어 있던 코드가 깔끔하게 분리가 됐다. 객체지향에선 다른 객체의 데이터를 직접 받아 처리하는 것 보다는, 각 객체의 상태 변경은 각 객체에게 요청을 통해 해당 객체의 책임 하에 이루어 져야 한다는 점이다. 각자 자기 책임에 충실하니 디버그도 쉽고 코드 이해가 쉬워진다.

승급 조건을 숫자로 명시하기보단 상수로 두어 코드 가독성을 높이고 반복적인 조건의 변경을 쉽게 하는 것도 소소한 리팩토링이다.

5.2 트랜잭션 서비스 추상화

레벨 관리 작업을 수행하던 중에 네트워크나 DB의 문제로 작업을 완료하기 어렵다면 사용자의 레벨은 그대로 두어야 하는가? 고객간 차별 논란이 있을 수 있기에 여지를 주지 않기 위해 롤백하는 편이 좋을 것이다. 이러한 기능을 테스트하려면 실제로 DB나 네트워크에 장애를 절묘한 타이밍에 만들어 내야할까? 그럴 필요까지는 없이 오류가 발생하면 발생하는 예외를 던지도록 작성하면 될 것이다.

원본 코드를 수정하는 것은 좋은 생각이 아니다. 대신 UserService를 상속받고, upgradeLevel을 override하여 구현하면 장애상황에서의 대응을 테스트하는 좋은 UserService가 된다.

트랜잭션

트랜잭션은 나눌수 없는 atomic한 작업의 단위다. 몇 개의 작업이 하나의 트랜잭션에 묶이든 상관없이 하나의 작업처럼 취급이 된다. 트랜잭션은 모두 성공하던지 모두 실패하여야 한다. 만약 트랜잭션을 완료할 수 없다면, 아예 작업이 시작조차 안한 것 처럼 감쪽같이 돌려놓아야 한다.

롤백

하나의 트랜잭션이 완전히 실행되지 못했다면 앞서 실행된 일부분의 SQL 작업을 취소시켜야 하는데, 이를 Transaction Rollback이라고 한다.

커밋

하나의 트랜잭션은 여러 작업으로 이루어질 수도 있다. 하나의 트랜잭션을 가르는 기준이 커밋이다. 커밋은 게임의 세이브 포인트와 같다고 생각하면 된다. 커밋 이후 실행되는 rollback은 가장 최근의 commit을 한 상태로 돌아오게 된다. (물론 commit에 이름을 붙이고 가장 최근이 아닌 commit으로도 rollback할 수는 있다.)

JDBC의 트랜잭션

JDBC는 하나의 Connection을 열고 닫는 사이에서 일어난다.

autocommit 옵션은 기본이 True인데, 매번 변화가 생길 때 마다 DB에 반영이 되니 트랜잭션을 원하는 대로 지정하고 싶다면 우선 false로 설정한다. setAutoCommit()이 트랜잭션의 시작이 된다.

commit(), rollback()으로 트랜잭션의 종료하는 방법을 Transaction Demarcation(트랜잭션 경계설정)이라고 부른다.

Connection c = dataSource.getConnection(); 

c.setAutoCommit(false); // 트랜잭션 시작 
try { 
        PreparedStatement st1 = c.prepareStatement("(update sql)"); 
        st1.executeUpdate(); 
        PreparedStatement st2 = c.prepareStatement("(delete sql)"); 
        st2.executeUpdate(); 
        c.commit(); // 트랜잭션 커밋 
} catch (Exception e) { 
        c.rollback(); // 트랜잭션 롤백 
} 

c.close();

UserDao를 작성하면서 JdbcTemplate를 구현하여 이용하면서 Connection을 직접 조작할 기회가 없었다. 그렇기에 트랜잭션 설정을 하지 못하는 상황이었다.

트랜잭션은 커넥션의 존재 범위가 짧은데, 템플릿 메서드가 호출될 때마다 트랜잭션이 생성되고 메서드를 빠져나오기 전에 종료가 된다.

userDao.update()를 수행할 때 마다 커넥션이 열고 닫히며 메서드 호출 한 번당 하나의 트랜잭션이 생겨나는 셈이지만 이걸 원하는 것이 아니기에 트랜잭션 설정을 위해 손을 좀 볼 필요가 있다.

첫 번째 아이디어 : 커넥션 관리를 UserService에 맡기기

UserService와 UserDao를 그대로 둔 채 트랜잭션을 적용하려면 경계설정 작업을 Service로 가져와야 한다. SQL이나 JDBC API를 이용한 데이트 코드를 그대로 두고 트랜잭션 부분만 가져오면 관심사 분리를 유지하면서 해결할 수 있다. upgradeLevels() 메서드 안에 커넥션의 종료와 시작을 수행해야 한다.

  1. DB 커넥션의 깔끔한 처리를 위해 JdbcTemplate를 작성했는데, 이 깔끔한 관심사의 분리는 더이상 유지가 되지 않는다.
  2. 비즈니스 로직만을 담고 있는 UserService의 메서드에 Connection 파라미터가 추가되고, 이 Connection은 모든 메서드에 파라미터를 통해서 전달되어야 한다. 코드가 지저분해진다.
  3. Connection 파라미터가 UserDao에 추가되면 데이터엑세스 기술에 독립적일 수가 없다. JDBC에 코드가 과하게 의존을 하게 된다.

두 번째 아이디어 : 독립적인 트랜잭션 동기화

UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를다른 곳에 저장해두고, DAO 메서드가 이 Connection을 사용하도록 한다.

source :  https://vvshinevv.tistory.com/71

update()를 3번 수행하는 동안 Connection은 죽지 않으며, autocommit=false, 그리고 각 dao.update()마다 commit()과 rollback()이 로직에 따라 수행될 것이다.

멀티쓰레드 환경에서도 안전하기 트랜잭션 구현하기

스프링에서는 멀티쓰레드 환경에서도 안전한 트랜젝션을 구성할 수 있도록 유틸리티메서드를 제공한다.

public void upgradeLevels() throws Exception {
    TransactionSynchronizationManager.initSynchronization();
        // 동기화 작업 시작.
    Connection c = DataSourceUtils.getConnection(dataSource);
        // DataSource에서 직접 가져오는 대신 DataSourceUtils에서 
        // Connection을 가져오는 이유는, ThreadSafe한 저장소에 바인딩하기 위함이다.
    c.setAutoCommit(false);
    try {
        List<User> users = userDao.findAll();
        for (User user : users) {
            if (canUpgradeLevel(user)) {
                upgradeLevel(user); // 하나의 커넥션으로 반복적인 쿼리 실행
            }
        }
        c.commit();
    } catch (Exception e) {
        c.rollback();
    } finally {
        DataSourceUtils.releaseConnection(c, dataSource);
        TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization();
                // 동기화 작업 종료 및 정리
    }
}

물론 UserService에 dataSource를 DI 해야 한다.

JdbcTemplate와 트랜잭션 동기화

이미 작성한 JdbcTemplate는 영리하게 동작한다. 만약 트랜잭션 동기화 저장소에 별도로 생성된 커넥션/트랜젝션이 없는 경우 원래 하던대로 커넥션을 생성한다. DataSourceUtils 및 TransactionSynchronizationManager로 커넥션을 생성했다면 새로 생성된 커넥션을 쓰도록 되어있다.

트랜젝션 서비스 추상화

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

시나리오 1

어떤 회사에서는 다수의 DB를 백업 목적으로 쓰고 있다. 여러 DB를 하나의 트랜젝션에 하길 원하지만, JDBC Connetion을 이용한 로컬 트랜젝션은 하나의 DB에 종속되기 때문에, 별도의 트랜젝션 관리자를 이용한 글로벌 트랜젝션을 사용해야 한다. 여러 개의 DB를 하나의 트랜잭션으로 관리할 수 있다. JDBC 이외의 글로벌 트랜젝션을 제공하는 Java Transaction API가 있다.

트랜젝션 매니저는 DB와 각 서버를 제어하고 관리하는 리소스 매니저와 XA 프로토콜을 이용해 연결된다. 어플리케이션은 JTA를 통해 다수 DB와 서버를 관리할 수 있다.

public void upgradeLevels() throws Exception {
    InitialContext ctx = new InitialContext();
    UserTransaction tx = (UserTransaction)ctx.lookup(USER_TX_JNDI_NAME);
    tx.begin();
    Connection c = dataSource.getConnection();
    try {
        // 데이터 액세스 코드
        tx.commit();
    } catch (Exception e) {
        tx.rollback();
        throw e;
    } finally {
        c.close();
    }
}

생소한 클래스와 메서드가 보이지만 전체적인 흐름은 유사하다. 문제는 JDBC 로컬 트랜젝션을 JTA로 바꾸는 변화가 필요하단 점이다. UserService는 비즈니스 로직을 관장하는데 이게 변하지 않았음에도 기술의 변화에 따라 코드가 바뀌는 것이 문제다.

JTA 뿐 아니라 하이버네이트 등 JPA 등의 기술을 사용하더라도 비즈니스 로직이 변하지 않는다면 코드는 변하지 않도록 하고 싶다. 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는다.

JDBC, JTA, 하이버네이트, JPA, JDO, JMS 등 다양한 기술들에 트랜잭션 개념이 있으니, 추상화로 개선할 수 있을 것이다.

source : https://xlffm3.github.io/spring & spring boot/toby-spring-chapter5/

 

@Autowired
private PlatformTransactionManager transactionManager // 변수 이름이 transactionManager인 것이 컨벤션이다.
            = new DataSourceTransactionManager(dataSource); 
            // 사용할 DB의 DataSource를 DataSourceTransactionManager 생성자 파라미터로 넘겨 객체를 만든다.
            // JTA로 바꾸고 싶다면 JTATransactionManager 객체를 생성하면 된다.

public void upgradeLevels() {
    TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
        //getTransaction() 메서드를 호출하기만 하면 트렌젝션 시작 뿐 아니라 커넥션까지 가져올 수 있다
        // 트랜젝션은 TransactionStatus 타입의 변수에 저장된다.
    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;
    }
}

코드가 어떤 구현 클래스를 쓸 지 아는 것은 것은 DI 원칙에 위반되므로, 주입식으로 이용하는 것이 좋다. 그렇다면 DataSourceTransactionManager는 스프링 빈으로 등록해야 하는데, 싱글턴으로 사용이 가능하므로 싱글턴으로 만들어 사용한다.

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

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

UserDao-UserService의 분리는 같은 계층에서 비즈니스 로직과 데이터 접근 로직을 분리한 수평적인 분리라면, 트랜잭션의 추상화는 애플리케이션 레벨의 로직과 로우레벨의 트랜잭션 기술에 독립적인 코드를 분리한 것이다.

단일 책임 원칙

모든 모듈은 단 하나의 책임을 가져야 한다는, 객체지향 설계 원칙 중 하나인 단일 책임 원칙을 지키기 위해 이러한 분리를 수행하는 것이다. 하나의 모듈이 바뀌어야 하는 이유는 단 하나여야만 한다.

지금같이 잘 분리된 UserService 대신 JDBC Connection을 직접 관리할 때를 생각해보면, 비즈니스 로직과 트랜잭션 관리의 변화, 2가지 이유로 UserService가 변해야 하기 때문에 분리가 필요했던 것이다.

단일 책임 원칙을 잘 지키면, 변경이 필요할 때 수정 대상이 명확해진다. 데이터를 접근하는 과정이 변해야 한다면 UserDao를, 비즈니스 로직을 바꾸려면 UserService를 바꾸는 등 명확하게 접근할 수 있다. 그러나 이를 잘 지키지 않는다면, 코드가 비대해졌을 때 하나의 변경에 수많은 클래스가 영향을 받아야 하며, 살인적으로 많은 반복적인 작업 속에 프로그래머가 실수하지 않으리란 보장도 없다.

이를 위해서

  • 책임과 관심이 다른 코드를 분리하고
  • 추상화를 도입하고
  • 애플리케이션 로직과 기술/환경을 분리하는

작업을 꾸준히 해왔던 것이다.

스프링 DI는 단일 책임 원칙을 위한 최고의 도구이며, 결합도를 극한으로 낮출 수 있게 되었다.

5.4 메일 서비스 추상화

레벨이 업그레이드 되는 고객에겐 안내 메일을 발송하는 요구사항이 생긴다고 가정하자. User에 Email 필드를 추가하고, UserService의 upgradeLevel()에 메일 발송 기능을 추가해야 한다.

자바에서 메일을 발송할 때는, Java 표준인 JavaMail을 사용하면 된다.

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() 
                                                        + "로 업그레이드 되었습니다.");
    } catch (AddressException e) {
        throw new RuntimeException(e);
    } catch (MessagingException e) {
        throw new RuntimeException(e);
    }
}
// 한글 encoding 설정이 생략되어 있는 코드입니다.

SMTP 프로토콜을 지원하는 메일 서버가 있다면, 안내 메일은 잘 작동될 것이다.

JavaMail의 테스트

메일 서버가 존재하지 않는다면, 위 코드를 수행하면서 예외가 발생할 것이다.

JavaMail을 테스트 하는 데는 몇 가지 고민이 필요하다.

  • 메일 발송은 부하가 많이 가는 작업인데, 테스트마다 실제 메일이 발송되는 것이 옳은가?
  • 실제로 메일을 고객들에게 발송한다면 혼란을 겪을텐데 테스트 메일 주소를 두어야 할까?
  • 메일이 실제로 잘 동작하는지 어떻게 테스트 해야할까?

JavaMail은 오랜 기간 널리 사용된 기술이다. 검증은 물론이고 안정성이 널리 인정받았다. JavaMail API가 요청을 올바르게 통신한다는 보장만 있으면 된다. 그렇다면 테스트용 메일 서버를 따로 두고, 메일 서버는 실제 메일을 전송하는 대신 SMTP 프로토콜 요청이 JavaMail로부터 잘 도착하는 지만 확인하면 된다. 메일 서버는 요청을 잘 수행한다는 가정을 깔고 간다.

메일 서버가 받은 요청을 잘 처리한다고 가정하듯이, JavaMail 또한 받은 요청을 올바르게 메일 서버로 전달한다는 가정이 있으면, 직접 JavaMail을 구동하는 대신 테스트용 껍데기 JavaMail을 사용하여 JavaMail이 메일 서버와 통신함으로써 생기는 부하와 리스크도 줄일 수 있다.

악명높은 JavaMail의 설계

테스트를 서버에 부하를 가하지 않고도 예외없이 실행할 수 있도록 JavaMail과 같은 인터페이스를 갖는 오브젝트를 만들면 될 것 같지만, Session 오브젝트를 만들어야 메일 메세지를 생성할 수 있고, 전송할 수 있다.

근데 이 Session이 진짜 환장할 구현인게,

  • Session은 인터페이스가 아닌 클래스다.
  • 생성자가 private라서 직접 생성도 불가능하다.
  • 상속도 불가능하게 final 한정자가 박혀있다.

메일 메세지를 작성하는 MailMessage, 전송을 담당하는 Transport도 마찬가지다. 테스트용으로 바꿔치기 할 만한 빈틈이 전혀 없어보이는데, 다행히도 Spring은 JavaMail을 추상화하는 기능을 제공하고 있다.

package org.springframework.mail;
//...
//https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/mail/MailSender.html
public interface MailSender{
        void send(SimpleMailMessage simpleMessage) throws MailException;
        void send(SimpleMailMessage[] simpleMessage) throws MailException;
        // MailException은 RuntimeException이기 때문에, try/catch 블록 처리를 하지 않아도 된다.
}
// 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);
}
protected void upgradeLevel(User user) {
    user.upgradeLevel();
    userDao.update(user);
    sendUpgradeEMail(user);
}
<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> 
-->
<bean id="mailSender" class="springbook.user.service.DummyMailSender" />
// JavaMail API를 대체하는 테스트용 오브젝트를 만들기 위해 JavaMailSenderImpl 대신 
// 아무 일도 하지 않는 MailSender를 구현하도록 한다.
public interface DummyMailSender implements MailSender{
        void send(SimpleMailMessage simpleMessage) throws MailException{
                //Do Nothing
        }
        void send(SimpleMailMessage[] simpleMessage) throws MailException{
        }
}

테스트와 서비스 추상화

일반적으로 추상화는 수행하는 기능은 같지만 로우레벨의 구현이 다른 트렌젝션이나 다양한 기술에 대해 일관적인 사용법을 제공하기 위한 접근 방법을 의미하지만, 이렇게 불건전하게 테스트에 도움이 안되도록 작성한 API를 테스트할 때도 유용하게 쓸 수 있다.

source : https://xlffm3.github.io/spring & spring boot/toby-spring-chapter5/

스프링이 제공하는 MailSender를 구현한 추상화 클래스를 이용하면, JavaMail이 아닌 다른 메세징 서버 API를 이용하고자 할 때도 MailSender를 구현한 클래스를 만들어 DI해주면 끝나는 일이다.

UserService는 그냥 MailSender 인터페이스를 통해 메일을 보낸다는 사실만 알면 되지 구체적인 구현은 알 방법이 없으며, MailSender 구체 클래스가 바뀌더라도 DI만 새로 해주면 끝이니 엄청 편리하다.

만약 DB의 트랜젝션 개념을 메일에 적용한다면?

  1. 메일을 업그레이드할 유저 목록을 별도의 목록에 저장하고, 업그레이드 작업이 성공적으로 끝마쳤다면 한 번에 전송하도록 한다
  2. 유사 트랜잭션을 구현한다. send() 메서드를 호출하더라도 실제로 메일을 발송하지 않고 있다가, 작업이 끝나면 메일을 모두 발송하고, 예외가 발생한다면 메일 발송을 취소하는 방법으로 구현한다.

이렇게 MailSender 인터페이스를 이용한다면 변경이 자유롭고 무궁한 활용을 기대할 수 있다. 외부 리소스와 연동하는 모든 작업은 추상화의 대상이 될 수 있다.

테스트 대역

DummyMailSender는 하는 것이 없다. 테스트를 편히 하기 위해 작성했다. 테스트 때 발송용 메서드를 제거했다가 테스트 끝나고 추가하는 것은 불가능한 일이다. 그 대신 DummyMailSender를 주입함으로써 간단하게 해결했다.

우리가 테스트하고자 하는 것은 UserService였는데, 이 UserService도 여러 객체에 의존하고 있다. MailSender 구현 클래스와 같은 것들을 말한다. 테스트 환경에서 테스트 대상이 되는 오브젝트의 기능을 충실하게 수행하면서 테스트를 자주 실행할 수 있도록 하는 오브젝트들을 테스트 대역(test double)이라고 한다.

테스트 스텁(Test Stub)

테스트 스텁은 테스트가 관심을 가지는 대상 오브젝트의 의존객체로 존재하면서, 테스트가 정상적으로 수행할 수 있도록 돕는다. DummyMailSender가 없었다면, 메일 서버가 구비되지 않은 상황에서 실제 메일을 발송할 주소가 존재하지 않아 생기는 예외들 때문에 테스트를 진행할 수 없었을 것이다.

만약 테스트 스텁의 메서드들이 리턴값을 가질 경우 특정 값을 리턴해주어야 한다. 테스트 스텁에 필요한 정보를 리턴하도록 구현한다.

테스트에 깊게 관여하는 테스트 대역

DummyMailSender는 호출이 되던 되지않던 상관없다. 테스트에 큰 영향을 끼치지 않는다.

테스트는 일반적으로 인풋에 따른 아웃풋을 검증한다. 그러나 테스트 대상 오브젝트가 의존 오브젝트에 넘기는 값과 행위를 검증하고 싶다면, 목 오브젝트(mock object)를 사용해야 한다.

이 목 오브젝트는 테스트 대상이 되는 오브젝트와 목 오브젝트에서 일어나는 커뮤니케이션을 추후 검증에 사용할 수 있도록 저장한다. 인풋과 아웃풋이 될 수도 있고, 얼마나 자주 커뮤니케이션이 일어나는지 통계적인 수치가 될 수도 있다.

예를 들어 upgradeLevels() 테스트에 실제 메일 발송을 제대로 요청했는지 알고 싶다면, MailSender를 구현한 MockMailSender를 만들고 Request 횟수를 저장하면 된다.

0개의 댓글