토비의 스프링 3.1 - 5장_서비스 추상화

Roeniss Moon·2020년 6월 30일
0

토비의 스프링 3.1

목록 보기
6/6
post-thumbnail

각 레이어가 다른 레이어의 내부 구현을 몰라도 되도록 설계하는 방식을 배운다.

🤔 사견 : 처음엔 기본 레벨이 BASIC인데, 책을 읽다보면 어느 순간 BRONZE라고 부르고 있다. 그럴 수도 있지...

레벨 업그레이드 기능을 추가해보자

업그레이드는 (1) 주기적으로 이루어지며, 모든 유저를 대상으로 (2) 각각 레벨업이 필요한지 체크하고 (3) 필요할 경우 레벨업(DB 업데이트)을 수행한다.

이 때 중요한 것은, '일부 유저의 업그레이드가 실패할 경우, 이번 주기에 진행되던 업그레이드를 모두 롤백한다'는 점이다.

Level enum 추가

각 enum 객체(?)가 (1) 내부적으로 int로 인코딩된 value와 (2) 다음 레벨이 무엇인지 알려주는 next로 구성되어있다.

package springbook.user.domain;

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 Level nextLevel() {
        return next;
    }

    public int intValue() {
        return this.value;
    }

    public static Level valueOf(int value) {
        for (Level eachLevel : Level.values()) {
            if (eachLevel.intValue() == value) {
                return eachLevel;
            }
        }
        throw new AssertionError("Unknown value : " + value);
    }
}

Service 레이어 수정


public class UserService {
    UserDao userDao;

    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }
    
    public void upgrade() {
        List<User> users = userDao.getAll();

        for (User user : users) {
            if (user.getLevel() == Level.BASIC && user.getLogin() >= 50) {
                // BASIC --> SILBER 조건 충족
                user.setLevel(Level.SILVER);
                shouldUpgrade = true;
                userDao.update(user);
            } else if (user.getLevel() == Level.SILVER && user.getRecommend() >= 30) {
                // SILBER --> GOLD 조건 충족
                user.setLevel(Level.GOLD);
                shouldUpgrade = true;
                userDao.update(user);
            }
        }
    }
}

이 코드는 곧바로 다음과 같이 리팩토링 된다.

// ...

    public void upgrade() {
        // 핵심 플로우만 드러나도록 내부 구현은 별도 메소드로 분리
        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) {
            // 현재 레벨을 기준으로 분리 (for readability)
            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) {
        Level currentLevel = user.getLevel();
        if (currentLevel == Level.BASIC) user.setLevel(Level.SILVER);
        else if (currentLevel == Level.SILVER) user.setLevel(Level.GOLD);
        userDao.update(user);
    }
// ...

upgradeLevel()의 문제점 :

  • 다음 단계가 무엇인지가 노골적으로 드러나있다.

  • 예외 처리가 없다. 즉, 전 단계인 canUpgradeLevel()에 의지하고 있다.

따라서 Level enum에서 설정한 nextLevel()을 사용하자 (원래는 이 대목에서 Level enum class를 리팩토링한다)

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

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

왜 User 객체가 레벨 업그레이드를 책임지는가? --> "User 오브젝트를 UserService만 사용하는 건 아니므로 스스로 예외상황에 대한 검증 기능을 갖고 있는 편이 안전하다"

"오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리"

상수 도입

public static final int MIN_LOGIN_COUNT_FOR_SILVER = 50;
public static final int MIN_RECOMMEND_COUNT_FOR_GOLD = 30;

🤔 사견 : 5.1 장의 마지막에서 "UserLevelUpgradePolicy 인터페이스를 UserService에 DI 적용해봐라"고 말하는데 안하는 걸 권장한다. 이 뒤에 나오는 내용은 모조리 이 '실습'을 안했다는 전제하에 진행하기 때문에, 책을 그대로 따라할 수가 없다. 뭐 나름 공부는 됐지만...

트랜잭션을 도입하자

여기에서 말하는 트랜잭션은 DB가 자체적으로 제공하는 트랜잭션과 살짝 차이가 있다. 왜냐하면 논리적인 단위면서 정책적인 차원에서 얼마든지 수정될 수 있는 트랜잭션을 논하기 때문이다. 잔금 부족 시 이체를 롤백하는 은행 전산 시스템의 예시는 어디까지나 'DB가 기본적으로 제공하는, 단일 SQL에 대한 atomicity'와는 다르다. 다시 말하자면, "애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층을 분리하는 것이 트랜잭션 수준의 추상화"

🤔 사견 : 그렇기 때문에 이 단원의 트랜잭션은 서비스 계층에서 컨트롤한다. 그 부분이 매우 중요하다.

UserService에서 Connection을 직접 관리해볼까

현재는 UserDao.add()마다 Connection이 새로 발생하기 때문에 트랜잭션을 한 번에 컨트롤 할 수가 없다.

UserService에서 Connection 하나를 만들어서, UserDao에서 이를 사용하도록 하면 가능은 하겠지만, 많은 문제점이 생긴다.

  • JdbcTemplate을 더 이상 사용할 수 없다. (이 클래스가 Connection을 은닉해주기 때문)

  • Connection이 대롱대롱 메소드를 타고 따라가야 되므로 몹시 보기 좋지 않다.

  • Connection 파라미터는 Jdbc 기술에 의존적이다. 따라서 UserDao가 Data Access 기술에 독립적이지 못한 코드가 된다.

트랜잭션 동기화 방식을 사용하자

Transaction Synchronization은 스프링에서 제공하는 기능이다. 독립된 트랜잭션 동기화 저장소에 Connection을 만들어 저장해두고, JdbcTemplate이 이를 사용하도록 한다.

// ...
    protected void upgrade() throws Exception {
        List<User> users = userDao.getAll();

        TransactionSynchronizationManager.initSynchronization();
        Connection c = DataSourceUtils.getConnection(dataSource);
        c.setAutoCommit(false);

        try {
            for (User user : users) {
                // 책에서 권장한대로 레벨 관련 정책을 별도 인터페이스로 분리했다.
                if (this.userLevelUpgradePolicy.canUpgradeLevel(user)) {
                    this.userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            c.commit();
        } catch (Exception e) {
            c.rollback();
            throw e;
        } finally {
            DataSourceUtils.releaseConnection(c, dataSource);
            TransactionSynchronizationManager.unbindResource(dataSource);
            TransactionSynchronizationManager.clearSynchronization();
        }
    }
// ...

*여러개의 DB를 대상으로 트랜잭션을 걸기 위해선 Global Transaction 방식을 사용해야 하는데, 자바에서는 JTA를 사용한다.

그런데 위 코드는 매우 Jdbc에 의존하고 있다. Connection c = DataSourceUtils.getConnection(dataSource); 이 라인에서 가장 극명하게 드러난다. (ex. 하이버네이트는 Connection이 아닌 Session을 사용한다)

트랜잭션 추상화 계층

// ...
   protected void upgrade() throws Exception {
        List<User> users = userDao.getAll();

        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            for (User user : users) {
                if (this.userLevelUpgradePolicy.canUpgradeLevel(user)) {
                    this.userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            this.transactionManager.commit(status);
        } catch (Exception e) {
            this.transactionManager.rollback(status);
            throw e;
        }
    }
// ...

PlatformTransactionManager은 데이터 액세스 기술에 독립적이다. 하지만 DataSourceTransactionManager가 기술에 의존적이니, 이 부분은 Bean으로 빼내서 처리하면 된다.

<!--/test-applicationContext.xml-->
<!--...-->
    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource"/>
    </bean>
<!--...-->

메일 서비스를 도입해보자

여기서는 앞에서 적용한 서비스 추상화를 복습하면서, 테스트 대역(mock)의 활용을 새로 적용해본다.

레벨이 업그레이드 되었을 때 메일로 안내하는 기능을 추가해보자.

이 과정에서는 트랜잭션 적용을 생략한다.

메일을 테스트한다는 것

온갖 이유로, Java 9 이상에서는 JavaMail 적용이 잘 안되는 것 같다. 그동안 Java 14를 쓰고 있었는데 이거 때문에 8로 내려서 빌드를 다시 했다.

메일이 실제로 발송되는지, 그리고 그 내용을 확인하는 것은 매우 번거롭고 자동화하기 힘들다. 대신 "JavaMail 객체에 메일 전송 요청을 받는 것까지만" 체크하는 걸로 대체한다. JavaMail이 들어온 요청(메일)을 잘 전송한다는 보장을 믿는 것이다.

그런데 JavaMail은 (datasource과 같은) 적절한 인터페이스가 없어서 추상화가 불가능하다. 그래서 스프링에서 제공하는 추상화 계층을 사용하기로 한다.

// ...
    public void sendUpgradeEmail(User user) {
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setTo(user.getEmail());
        mailMessage.setFrom("sodongyocs@gmail.com");
        mailMessage.setSubject("NOTIFY LEVEL-UP");
        mailMessage.setText("Your level became " + user.getLevel().name());

        this.mailSender.send(mailMessage);
    }
// ...

mailSender 객체는 MailSender를 상속한 JavaMailSenderImpl 클래스를 DI 주입한다.

<!--/test-applicationContext.xml-->
<!-- ... -->
    <bean id="userLevelUpgradePolicy" class="springbook.user.dao.UserLevelUpgradePolicyImpl">
        <property name="userDao" ref="userDao"/>
        <property name="mailSender" ref="mailSender"/>
    </bean>

    <bean id="userDao" class="springbook.user.dao.UserDaoJdbc">
        <property name="dataSource" ref="dataSource"/>
    </bean>

<!--    <bean id="mailSender" class="org.springframework.mail.javamail.JavaMailSenderImpl">
        <property name="host" value="smtp.gmail.com"/>
        <property name="port" value="587"/>
        <property name="username" value="평소에내가쓰던@gmail.com"/>
        <property name="password" value="테스트메일발송용메일이있다"/>
        <property name="defaultEncoding" value="utf-8"/>
        <property name="javaMailProperties">
            <props>
                <prop key="mail.smtp.auth">true</prop>
                <prop key="mail.smtp.starttls.enable">true</prop>
            </props>
        </property>
    </bean>-->
    <bean id="mailSender" class="springbook.user.dao.DummyMailSender">
    </bean>
<!-- ... -->

'JavaMailSenderImpl gmail' 정도로 검색하면 자신의 메일로 발송기능을 구현하는 방법은 금방 나온다. 보안 수준을 낮추는 세팅을 까먹지 말 것.

위 xml에서 보이듯이, 테스트를 할 때는 구현이 텅 비어있는 DummyMailSender를 사용하면 된다. 이를 통해 "테스트를 할 때 코드하나 바꾸지 않고" xml에서 DI를 갈아끼우는 방식으로 메일 기능 추상화가 완성되었다.

의존 오브젝트 교체를 통한 테스트 방식

테스트 환경을 만들어주기 위해 사용하는 오브젝트들을 테스트 대역(test double)이라 부른다.

여러 하위 카테고리로 나뉘는데, https://beomseok95.tistory.com/295 이 글과 https://roybatty.tistory.com/2 이 글을 보니 Dummy - Fake - Stub - Mock 순으로 고도화되는 듯 하다.

아무튼 책에서 설명하는 방식을 따르자면 "테스트가 수행될 수 있도록 의존 오브젝트에 간접적으로 입력 값을 제공해주는 스텁 오프젝트와 간접적인 출력 값까지 확인이 가능한 목 오브젝트. 이 두 가지가 가장 대표적인 테스트 대역"이라고 한다.

🤔 사견 : 한 번에 납득하기 애매한 정의가 아닌가 싶다.

텅 빈 DummyMailSender은 Stub이고, 아래처럼 내용을 추가하면 Mock이라고 한다.

public class MockMailSender implements MailSender {
    private List<String> requests = new ArrayList<>();

    // 모든 send가 끝난 후 이 메소드를 호출해서 잘 저장되었는지 검증할 수 있다
    public List<String> getRequests() {
        return requests; 
    }

    @Override
    public void send(SimpleMailMessage mailMessage) throws MailException {
        requests.add(mailMessage.getTo()[0]);
    }

    @Override
    public void send(SimpleMailMessage[] mailMessages) throws MailException {

    }
}

정리

  • 비즈니스 로직을 담은 코드는 데이터 액세스 로직을 담은 코드와 깔끔하게 분리되어야 한다

  • 비즈니스 로직의 코드 안에서도 책임과 역할에 따라 깔끔하게 분리되어야 한다

  • DAO를 사용하는 비즈니스 로직에는 단위 작업을 보장해주는 트랜잭션(의 경계설정)이 필요하다

  • 스프링이 제공하는 트랜잭션 동기화 기법을 활용하자

  • 트랜잭션 서비스 추상화로 DI를 느슨하게 조절하자

  • 테스트 '대상'이 사용하는 '의존 오브젝트'를 대체하는 오브젝트를 테스트 대역이라 부른다

  • 테스트 대역 중에서도 목 오브젝트는 테스트 '대상으로부터' 전달받은 정보를 검증할 수 있도록 설계된 것을 말한다

profile
기능이 아니라 버그예요

0개의 댓글