독서/토비의 스프링

토비의 스프링 Vol.1 5장 서비스 추상화

Debin 2023. 2. 17.
반응형

실습 코드는 아래에서 확인 가능합니다.

https://github.com/happysubin/book-study/commits/main

 

GitHub - happysubin/book-study

Contribute to happysubin/book-study development by creating an account on GitHub.

github.com

5.1 사용자 레벨 기능 추가

이전 시간까지는 UserDao에서 User 객체를 단순히 CRUD 하는 작업만 가능했는데, 이번 파트 부터는 특별한 비즈니스 로직을 추가했다.

UserDao에서 User 객체를 모두 가져와서 일정 방문 횟수와 추천 수를 넘기면 User 객체의 레벨을 올리는 로직이다.

레벨을 나타내기 위해 Enum을 사용했다.

 

이번 파트에서 제일 인상 깊었던 부분은 Level Enum에서 다음 레벨의 값을 프로퍼티로 가지는 부분이다.

이를 통해 코드도 정말 깔끔해졌고 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 this.next;
    }

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

5.2 트랜잭션 서비스 추상화

이제 사용자 레벨 관리 기능에 대한 구현을 마쳤다.

현재 레벨 관리 기능은 모든 유저를 대상으로 레벨 기준을 체크하고 기준을 넘는다면 레벨을 올린다.

만약 작업을 하는 도중 네트워크가 끊기거나 서버에 장애가 발생하면 작업이 중단될텐데 이미 유저의 레벨이 기준을 넘어서 올라갔다면 올라간 레벨이 유지된다. 즉 기존에 작업한 내용이 취소되지 않는다.

 

레벨 조정은 중간에 문제가 발생해서 작업이 중단된다면 그때까지 진행된 변경 작업도 모두 취소해야 한다.

일부 사용자는 레벨이 조정됐는데 일부는 안 됐다면 사용자의 반발이 심할 것으로 예상되기 때문이다.

 

그럼 이제 우리는 네트워크가 끊기거나 서버에 장애가 발생해 작업이 중단된다면 이전까지 작업한 내용도 모두 취소해야 한다.

 

이를 위해서는 비즈니스 로직을 수행하는 서비스 계층의 UserService 클래스 메서드에 트랜잭션을 걸어야 한다.

현재는 모든 작업이 UserDao 메서드마다 트랜잭션이 걸리고, 이 메서드가 성공하면 DB에 바로 커밋이 되어버린다.

그러므로 레벨 조정을 진행하다가 장애가 발생해도 이전의 작업들이 취소되지 않는다.

 

이제 비즈니스 로직을 수행하는 메서드(upgradeLevels라고 부르겠다.)에서 하나의 작업 단위인 트랜잭션을 적용하자.

upgradLevels에 트랜잭션을 적용한다면 모든 작업이 하나의 트랜잭션에서 이루어져

만약 장애가 발생한다면 롤백이 되어 이전의 작업들이 DB에 반영되지 않는다.

트랜잭션 도입 과정

1. JDBC Connection을 사용

JDBC를 이용해 트랜잭션을 도입했다. 

JDBC의 트랜잭션은 DataSource에서 하나의 Connection을 가져와 사용하다가 닫는 사이에서 일어난다.

이걸 upgradeLevels에서 시작해 UserDao에서도 트랜잭션이 걸리길 원한다면 파라미터로 이를 넘겨야 한다.

문제점

  • 인프라스트럭처로 부터 독립적인 UserDao의 파라미터에 Connection이라는 파라미터가 추가되며
    UserDao 인터페이스가 특정 기술에 종속되어 버린다.
  • 더불어 파라미터가 지저분해진다. Connection을 파라미터로 받으며 테스트 코드 작성도 어려워진다.
  • 또한 우리가 사용하던 JdbcTemplate를 사용할 수 없게 된다. 다시 JDBC API를 사용해야 하며 try, catch, finally 지옥에 빠진다.

2. 트랜잭션 동기화

제일 큰 문제는 Connection을 UserDao의 파라미터로 넘긴다는 것이다.

 

이 문제를 해결하기 위해 스프링은 트랜잭션 동기화라는 방식을 제공한다.

 

트랜잭션 동기화란 UserService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소에 보관해두고,

이후에 호출되는 Dao의 메서드에서는 저장된 Connection을 가져다가 사용하게 하는 방식이다.

정확히는 Dao가 사용하는 JdbcTemplate이 트랜잭션 동기화 방식을 이용하도록 하는 것이다.

그리고 트랜잭션이 모두 종료되면, 그때는 동기화를 마친다.

 

트랜잭션 동기화 저장소는 작업 스레드마다 독립적을 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티 스레드 환경에서도 충돌이 날 염려는 없다.  쓰레드 로컬을 사용하기 때문이다.

 

이렇게 트랜잭션 동기화 기법을 사용하면 Connection을 파라미터로 계속 넘겨줄 필요가 없다.

 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(userLevelUpgradePolicy.canUpgradeLevel(user)){
                    userLevelUpgradePolicy.upgradeLevel(user);
                }
            }
            c.commit();
        }
        catch(Exception e){
            c.rollback();
            throw e;
        }
        finally {
            DataSourceUtils.releaseConnection(c, dataSource); //디비 커넥션을 닫음

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

참고

  • JdbcTemplate은 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다.
  • 반면에 upgradeLevels()에서처럼 트랜잭션을 동기화 해놓고 시작했다면 그때부터 실행되는 JdbcTemplate의 메서드에는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 있는 DB 커넥션을 가져와서 사용한다.

지금까지 만든 코드는 JDBC API를 사용하고 트랜잭션을 적용했으면서, 책임과 성격에 따라 데이터 액세스 부분과 비즈니스 로직을 잘 분리, 유지할 수 있게 만든 뛰어난 코드다.

3. 새로운 문제 

그러나 새로운 문제가 발생했다. 여러 개의 DB를 사용해야 하는 상황이 발생한 것이다.

그래서 하나의 트랜잭션 안에서 여러 개의 DB에 데이터를 넣는 작업을 해야 할 필요가 발생했다.

한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜잭션으로는 불가능하다. 로컬 트랜잭션 방식은 하나의 DB Connection에 종속적이기 때문이다.

 

따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다.

자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 매니저를 지원하기 위한 API인 JTA를 제공하고 있다.

 

제일 큰 문제는 JDBC 로컬 트랜잭션을 JTA를 이용하는 글로벌 트랜잭션으로 바꾸려면 UserService의 코드를 수정해야 한다는 점이다.

UserService는 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀌는 코드가 돼버렸다.

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

이런 문제를 해결하려면 트랜잭션 처리 코드에도 추상화를 도입해야할 것이다.

역시 스프링은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하고 있다.

이를 사용하면 애플리케잇현에서 직접 각 기술의 트랜잭션 API를 사용하지 않고도,

일관된 방식으로 트랜잭션을 제어하는 트랜잭션 경계설정 작업이 가능해진다.

public void upgradeLevels(){
   //인스턴스 변수 transactionManager
    TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
    try{
        List<User> users = userDao.getAll();
        for (User user : users) {
            if(userLevelUpgradePolicy.canUpgradeLevel(user)){
                userLevelUpgradePolicy.upgradeLevel(user);
            }
        }
        transactionManager.commit(status);
    }
    catch(Exception e){
        transactionManager.rollback(status);
        throw e;
    }
}

스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager다.

이를 사용하면 특정 기술에 종속되지 않고 트랜잭션 경계설정을 할 수 있다.

스프링의 트랜잭션 추상화 기술은 앞에서 적용해봤던 트랜잭션 동기화를 사용한다.

PlatformTransactionManager로 시작한 트랜잭션은 트랜잭션 동기화 저장소에 저장된다.

 

PlatformTransactionManager를 사용하면서 우리는 특정 기술에 종속되지 않는 깔끔한 코드와 우리가 원하는 요구사항까지 코드에 반영했다.

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

이번 실습을 통해 기술과 서비스에 대한 추상화 기법을 이용하면 특정 기술환경에 종속되지 않는 포터블한 코드를 만들 수 있다는 것을 깊게 느낄 수 있었다.

 

UserDao와 UserService는 각각 담당하는 코드의 기능적인 관심에 따라 분리되고, 서로 불필요한 영향을 주지 않으면서 독자적으로 확장이 가능하도록 만든 것이다.

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

 

애플리케이션 로직의 종류에 따라 수평적인 구분이든, 로직과 기술이라는 수직적인 구분이든 모두 결합도가 낮으며,

서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만들 수 있는 데는 스프링의 DI가 중요한 역할을 하고 있다.

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

 

이런 적절한 분리가 가져오는 특징은 객체지향 설계의 원칙 중의 하나인 단일 책임 원칙이다.

단일 책임 원칙을 잘 지키고 있따면, 어떤 변경이 필요할 때 수정 대상이 명확해진다.

 

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

이를 위한 핵심적인 도구가 스프링에서 제공하는 DI다.

5.4 메일 서비스 추상화

레벨이 올라간다면 사용자에게 안내 메일을 보내야한다는 새로운 요구 사항이 들어왔다.

메일 서버가 준비되어 있다면 잘 동작할 수 있도록 JavaMail 자바의 표준 기술을 사용해 실습을 진행했다. 

만약 메일 서버가 준비되어 있지 않다면 동작하지 않는다. 

동일하게 테스트를 실행해도 메일 서버가 준비되어 있지 않다면 실패한다.

 

테스트 실패의 원인은 메일을 발송하려는데 메일 서버가 현 재 연결 가능하도록 준비되어 있지 않기 때문이다.

한 가지 질문을 던벼좌. 과연 테스트를 하면서 매번 메일이 발송되는 것이 바람직한가?

 

실제로 메일이 발송되는 문제가 있으며, 메일 발송은 부하가 매우 큰 작업이다.

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

정상적으로 동작하는지 확인하는 일이 중요하지만, 업그레이드 정책에 따라 업그레이드가 실제로 일어나는지,

그것이 DB에 잘 반영되는지를 확인하는 일만큼 중요하지는 않다.

게다가 메일 발송 테스트란 엄밀히 말해서 불가능하다.

메일이 정말 잘 도착했는지를 확인하지 못하기 때문이다.

 

JavaMail은 자바의 표준 기술이고 검증되고 안정적인 기술이다. 따라서 모든 테스트에서 직접 구동시킬 이유가 없다.

운영시에는 JavaMail이 동작하면 좋지만 개발 중이거나 테스트를 수행할 때는 JavaMail을 대신할 수 있는,
그러나 JavaMail을 사용할 때와 동일한 인터페이스를 갖는 코드가 동작하도록 만들어도 될 것이다.

이렇게 할 수 있다면 굳이 매번 검증이 필요 없는 불필요한 메일 전송 요청을 보내지 않아도 되고,

테스트도 매우 빠르고 안전하게 수행할 수 있다.

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

  • JavaMail에서는 Session 오브젝트를 만들어야만 메일 메시지를 생성할 수 있고, 메일을 전송할 수 있다.
  • 그런데 이 Session은 인터페이스가 아니고 클래스며, 생성자는 모두 prviate으로 막혀있다.
  • 심지어 final 클래스다.
  • 결론적으로 말하면 JavaMail의 구현을 테스트용으로 바꿔치기 하는 건 불가능하다.
  • 그럼 어떻게 해야할까? JavaMail처럼 테스트하기 힘든 구조인 API를 테스트하기 좋게 만드는 방법이 있다.
  • 트랜잭션을 적용한 것처럼 살펴봤던 서비스 추상화를 적용하면 된다.

스프링은 JavaMail을 사용해 만든 코드는 손쉽게 테스트하기 힘들다는 문제를 해결하기 위해서 JavaMail에 대한 추상화 기능을 제공하고 있다.

MailSender 인터페이스가 메일 서비스 추상화의 핵심 인터페이스다.

스프링이 제공하는 메일 전송 기능에 대한 인터페이스가 있으니 이를 구현해서 테스트용 메일 전송 클래스를 만들자.

아래 클래스는 MailSender 인터페이스를 구현했을 뿐 하는 일이 없다.

public class DummyMailSender implements MailSender {
    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {

    }

    @Override
    public void send(SimpleMailMessage... simpleMessages) throws MailException {

    }
}

MailSender 인터페이스를 사용해 DI를 진행하므로 실제 동작하던 클래스 대신 위 클래스를 스프링 빈으로 등록해도 아무런 문제도 발생하지 않는다. 

이제 우리가 실습을 진행한 코드에서 테스트를 돌려보면 아무런 문제도 발생하지 않는다.

테스트 서비스와 추상화

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

서비스 추상화라고 하면 트랜잭션과 같이 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 접근 방법을 제공해주는 것을 말한다.

JavaMail의 경우처럼 테스트를 어렵게 만드는 건전하지 않은 방식으로 설계된 API를 사용할 때도 유용하게 쓰일 수 있다.

MailSender를 구현한 구현 클래스를 만들어서 DI를 사용하면 다양한 구현 클래스를 손쉽게 교체할 수 있다.

 

UserService와 같은 애플리케이션 계층의 코드는 아래 계층에서는 어떤 일이 일어나는지 상관 없이 메일 발송을 요청한다는 기본 기능에 충실하게 작성된다. 비즈니스 로직이 바뀌지 않는 한 UserService 코드를 변경할 필요가 없다.

 

서비스 추상화란 이렇게 원할한 테스트만을 위해서도 충분히 가치가 있다.

테스트 대역의 종류와 특징

테스트용으로 사용되는 특별한 오브젝트들이 있다.

대부분 테스트 대상인 오브젝트의 의존 오브젝트가 되는 것들이다.

UserDao의 DataSource, UserService의 MailSender 인터페이스를 구현한 것들이다.

 

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

테스트 스텁(stub)

  • 대표적인 테스트 대역은 테스트 스텁이다.
  • 테스트 스텁은 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행될 수 있도록 돕는 것을 말한다.
  • 일반적으로 테스트 스텁은 메서드를 통해 전달하는 파라미터와 달리, 테스트 코드 내부에서 간접적으로 사용된다.
  • 따라서 DI등을 통해 미리 의존 오브젝트를 테스트 스텁으로 변경해야 한다. DummyMailSender가 심플한 스텁의 예다.
  • 테스트 스텁이 결과를 돌려줘야할 때도 있다. 리턴 값이 있는 메서드를 이용하는 경우에는 당연히 결과가 필요하다.
  • 이럴 땐 스텁에 미리 테스트 중에 필요한 정보를 리턴해주도록 만들 수 있다.
  • 또는 어떤 스텁은 메서드를 호출하면 강제로 예외를 발생시키게 해서 테스트 대상 오브젝트가 예외상황에서 어떻게 반응하는지를 테스트할 때 적용할 수도 있다.

목 오브젝트 (Mock)

  • 테스트 대상 오브젝트의 메서드가 돌려주는 결과뿐 아니라 테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위에 대해서도 검증하고 싶다면 어떻게 해야 할까? 
  • 이런 경우에는 테스트 대상의 간접적인 출력 결과를 거증하고, 테스트 대상 오브젝트와 의존 오브젝트 사이에서 일어나는 일을 검증할 수 있도록 특별히 설계된 목 오브젝트를 사용해야 한다.
  • 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
  • 아래는 실습에서 작성한 목 오브젝트, MockMailSender 클래스다.
public class MockMailSender implements MailSender {

    private List<String> requests = new ArrayList<>();

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

    @Override
    public void send(SimpleMailMessage... simpleMessages) throws MailException {

    }

    public List<String> getRequests() {
        return requests;
    }
}

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

 

이상으로 포스팅을 마칩니다.

 

참고 자료

토비의 스프링 3.1 Vol. 1 (5장 서비스 추상화)

 

반응형

댓글