본문 바로가기
프로그래밍/Spring

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

by Loper Lee 2022. 6. 13.

5장 서비스 추상화

자바엔 표준 스펙/사용 제품/오픈소스를 통틀어서 사용 방법과 형식은 다르지만 기능과 목적이 유사한 기술이 존재한다. 환경과 상황에 따라서 기술이 바뀌고, 그에 따라 다른 API를 사용하고 다른 스타일의 접근 방법을 따라야 한다는 건 매우 피곤한 일이다.

트랜잭션 서비스 추상화

임의로 예외를 어떻게 발생시킬까?

정기 사용자 레벨 관리 작업을 수행하는 도중에 네트워크가 끊기거나 서버에 장애가 생겨서 작업을 완료할 수 없다면, 그때까지 변경된 사용자 레벨은 모두 되돌려 놓고, 유저들에게 알려야한다.

이런 상황의 테스트를 진행할 때는 어떤 방법이 있을까?

  • 코드 자체를 수정한다.
    • 테스트를 위해서 코드를 수정하는것은 매우 좋지 못한 방법이다. 때문에 해당 방법은 사용하지 않겠다.
  • 상속을 통해서 테스트를 위한 전용 클래스를 만든다.
    • 가장 이상적인 방법이다. 하지만 UserService클래스에 upgradeLevel이 private로 제한되어있기 때문에 어쩔 수 없이 protected로 소스를 일부 변경한다.

상속을 통해서 테스트 전용의 클래스를 만든다.

예외를 발생시키는 가장 쉬운 방법은 소스를 수정하는 것이지만, 테스트를 위해 코드를 수정하는 것은 좋은 생각이 아니다. 때문에 UserService 클래스를 상속 받아 기능을 추가하도록 일부 메소드를 추가하는 방법이 나을 것 같다.

또한 해당 테스트의 목적은 그 전에 업그레이드 했던 사용자도 원래 상태로 돌아갔는지를 확인하는 것이다.

static class TestUserService extends UserService {
        private String id;
        private TestUserService(String id) {
                this.id = id;
        }
        protected void upgradeLevel(User user) { // 메소드 오버라이드
            // 지정된 id의 User 오브젝트가 발견되면 예외를 던져서 작업을 강제로 중단시킨다.
            if (user.getId().equals(this.id)) throw new TestUserServiceException();
            super.upgradeLevel(user);
        }
}
public class TestUserServiceException extends RuntimeException {
}
@Test
@DisplayName("예외 발생 시 작업 취소 여부 테스트")
public void test_cancel_when_exception() {
    UserService testUserServiceProxy = new TestUserService(users.get(3).getId(), userDao);

    assertThrows(TestUserServiceException.class, () -> {
        testUserServiceProxy.upgradeLevels();
    });

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

해당 코드를 실행시켜보면 테스트는 실패하고, 결과는

테스트 실패의 원인

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

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

트랜잭션 경계 설정

DB는 그 자체로도 완벽한 트랜잭션을 지원한다. SQL을 이용해 다중 로우의 수정이나 삭제를 위한 요청을 했을 때 일부 로우만 삭제되고 나머지는 안 된다거나, 일부 필드는 수정했는데 나머지 필드는 수정이 안되고 실패로 끝나는 경우는 없다.

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

  • Rollack - 이때 여러 가지 작업이 하나의 트랜잭션이 되려면, 두 번째 이후의 SQL이 성공적으로 DB에서 수행되기 전에 문제가 발생할 경우 앞에서 처리한 SQL 작업을 취소
  • Commit - 여러 개의 SQL을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업이 다 성공적으로 마무리됐다고 DB에 알려줘서 작업을 확정

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

  • 트랜잭션을 시작하는 방법은 하나지만 끝내는 방법은 Rollback과 Commit 두 가지 방법이 있다.
  • 애플리케이션 내에서 트랜잭션이 시작되고 끝나는 위치를 트랜잭션의 경계라고 부른다.
  • 복잡한 로직의 흐름 사이에서 정확하게 트랜잭션 경계를 설정하는 일은 매우 중요한 작업이다.
  • setAutoCommit(false)로 트랜잭션의 시작을 선언하고 commit() 또는 rollback()으로 트랜잭션을 종료하는 작업을 트랜잭션의 경계설정(transaction demarcation)이라고 한다.
  • 이렇게 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션이라고도 한다.

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

  • JdbcTemplate을 더 이상 사용할 수 없다.
  • 비즈니스 로직을 담당하는 UserService 클래스에 Connection 파라미터가 추가된다.
  • Connection 파라미터가 추가되어 UserDao가 기술에 대해 독립적일 수 없다.
  • Connection 파라미터가 추가되어 Test 코드에도 영향을 미친다.

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

public void upgradeLevels() throws Exception { 
    (1) DB Connection 생성
    (2) 트랜잭션 시작
    try (
        (3) DAO 메소드 호출
        (4) 트랜잭션 커밋
    catch(Exception e) {
        (5) 트랜잭션 롤백
        throw e;
    } finally {
        (6) DB Connection 종료
    }
}
  • UserService와 UserDao를 그대로 둔 채로 트랜잭션을 적용하려면 결국 트랜잭션의 경계설정 작업을 UserService 쪽으로 가져와야 한다.
  • UserDao가 가진 SQL이나 JDBC API를 이용한 데이터 엑세스 코드는 최대한 그대로 남겨둔 채로, UserService에는 트랜잭션 시작과 종료를 담당하는 최소한의 코드만 가져오게 만들면 어느 정도 책임이 다른 코드를 분리해 둔 채로 트랜잭션 문제를 해결할 수 있다.

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

  1. DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate을 더 이상 활용할 수 없다는 점이다. Try/catch/finally 블록은 이제 UserService 내에 존재하고, UserService의 코드는 JDBC 작업 코드의 전형적인 문제점을 그대로 가질 수 밖에 없다.
  2. DAO의 메소드와 비즈니스 로직을 담고 있는 UserService의 메소드에 Connection 파라미터가 추가돼야 한다는 점이다.
  3. Connection 파라미터가 UserDao 인터페이스 메소드에 추가되면 UserDao는 더 이상 데이터 액세스 기술에 독립적일 수가 없다는 점이다.

트랜잭션 동기화

  1. UserService는 커넥션을 생성
  2. Connection을 트랜잭션 동기화 저장소에 저장 후 Connection의 setAutoCommit(false)를 호출해 트랜잭션을 시작
  3. 첫번째 update를 호출
  4. 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인 ⇒ 존재하면 가져옴
  5. 가져온 Connection을 이용해 SQL을 실행, 이때 JdbcTemplate은 Connnection을 닫지 않은 채로 작업 마침 ⇒ 즉 여전히 Connenction은 열려있고, 트랜잭션은 진행 중
  6. 3~5 과정을 update가 끝날때까지 반복
  7. 모든 작업이 정상적으로 끝나면 UserService는 Connection의 commit()을 호출 ⇒ 트랜잭션을 완료시킴
  8. 트랜잭션 저장소가 더 이상 Connection 오브젝트를 저장해두지 않도록 제거

이렇게 트랜잭션 동기화 기법을 사용하면 파라미터를 통해 일일이 Connection 오브젝트를 전달할 필요가 없어진다. 트래잭션의 경계설정이 필요한 Service에서만 Connection을 다루게 하고, 여기에 생성된 Connection과 트랜잭션을 DAO의 JdbcTemplate이 사용할 수 있도록 별도의 저장소에 동기화하는 방법을 적용하기만 하면 된다.

트랜잭션 동기화 적용

스프링은 JdbcTemplate과 더불어 이런 트랜잭션 동기화 기능을 지원하는 간단한 유틸리티 메소드를 제공하고 있다.

private DeataSource dataSource;

// Connection을 생성할 때 사용할 DataSource를 DI 받도록 한다.
public void setDataSource(DataSource dataSource) {
    this.dataSource = dataSource;
}

public void upgradeLevels() throws Exception {
    // 트랜잭션 동기화 관리자를 이용해 동기화 작업을 초기화 한다.
    TransactionSynchronizationManager.initSynchronization();

    // DB 커넥션을 생성하고 트랜잭션을 시작한다. 이후의 DAO 작업은 모두 여기서 시작한 트랜잭션 안에서 진행한다.
    // DB 커넥션 생성과 동기화를 함께 해주는 유틸리티 메소드
    Connection c = DataSourceUtils.getConnection(dataSource);
    c.setAuthCommit(false);

    try {
        List<User> users = UserDao.getAll();
        for (User user : users) {
            if (canUpdatedLevel(user)) {
                upgradeLevel(user);
            }
        }
        c.commit();
    } catch (Exception e) {
        c.rollback();
        throw e;
    } finally {
        DataSourceUtils.releaseConnection(c, dataSource);
              // 동기화 작업 종료 및 정리
          TransactionSynchronizationManager.unbindResource(this.dataSource);
        TransactionSynchronizationManager.clearSynchronization();
    }
}
  • 스프링이 제공하는 트랜잭션 동기화 관리 클래스는 TransactionSynchronizationManager이다.
    이 클래스를 이용해 먼저 트랜잭션 동기화 작업을 초기화하도록 요청한다.
  • DataSourceUtils에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 생성한다.
  • 트랜잭션 동기화가 되어있는 채로 JdbcTemplate를 사용하면 JdbcTemplate의 작업에서 동기화 시킨 DB 커넥션을 사용하게 된다.
    • 결국 UserDao를 통해 진행되는 모든 JDBC작업은 upgradeLevels() 메소드에서 만든 Connection 오브젝트를 사용하고 같은 트랜잭션에 참여하게 된다.

JdbcTemplate과 트랜잭션 동기화

JdbcTemplate은 영리하게 동작하도록 설계되어 있다.

  • 만약 미리 생성돼서 트랜잭션 동기화 저장소에 등록된 DB 커넥션이나 트랜잭션이 없는 경우에는 JdbcTemplate이 직접 DB 커넥션을 만들고 트랜잭션을 시작해서 JDBC 작업을 진행한다.
  • upgradeLevels() 메소드에서처럼 트랜잭션 동기화를 시작해놓았다면 그때부터 실행되는 JdbcTemplate의 메소드에서는 직접 DB 커넥션을 만드는 대신 트랜잭션 동기화 저장소에 들어 있는 DB 커넥션을 가져와서 사용한다. 이를 통해 이미 시작된 트랜잭션에 참여하는 것이다.
  • 비즈니스 로직레벨의 트랜잭션을 적용했지만 JdbcTemplate을 포기할 필요도 없고, 지저분한 Connection 파라미터를 계속 물고 다니지 않아도 된다.

트랜잭션 서비스 추상화

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

  • 한 개 이상의 DB로의 작업을 하나의 트랜잭션으로 만드는 건 JDBC의 Connection을 이용한 트랜잭션 방식인 로컬 트랜재션으로는 불가능하다. 왜냐하면 로컬 트랜잭션은 하나의 DB Connection에 종속되기 때문이다.
  • 따라서 각 DB와 독립적으로 만들어지는 Connection을 통해서가 아니라, 별도의 트랜잭션 관리자를 통해 트랜잭션을 관리하는 글로벌 트랜잭션 방식을 사용해야 한다.
  • 자바는 JDBC 외에 이런 글로벌 트랜잭션을 지원하는 트랜잭션 메니저를 지원하기 위한 API은 JTA(Java Transaction API)를 제공하고 있다.
  • UserService는 자신의 로직이 바뀌지 않았음에도 기술환경에 따라서 코드가 바뀌는 문제가 발생했다.

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

UserDao가 DAO 패턴을 사용해 구현 데이터 액세스 기술을 유연하게 바꿔서 사용할 수 있게 했지만 UserService에서 트랜잭션의 경계 설정을 해야 할 필요가 생기면서 다시 특정 데이터 액세스 기술에 종속되는 구조가 되고 말았다.

다행이도 트랜잭션의 경계설정을 담당하는 코드는 일정한 패턴을 갖는 유사한 구조다. 이렇게 여러 기술의 사용방법에 공통점이 있다면 추상화를 생각해볼 수 있다.

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

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

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

public void upgradeLevels() {
        //JDBC 트랜잭션 추상 오브젝트 생성
    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        //트랜잭션 안에서 진행되는 작업
    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;
    }
}
  • 스프링이 제공하는 트랜잭션 경계설정을 위한 추상 인터페이스는 PlatformTransactionManager다.
  • JDBC의 로컬 트랜잭션을 이용한다면 PlatformTransactionManager를 구현한 DataSourceTransactionManager를 사용하면 된다. 사용할 DB의 DataSource를 생성자 파라미터로 넣으면서 DataSourceTransactionManager의 오브젝트를 만든다.
  • JDBC를 이용하는 경우에는 먼저 Connection을 생성하고 나서 트랜잭션을 시작했다.
    • 하지만 PlatformTransactionManager에서는 트랜잭션을 가져오는 요청인 getTransation() 메소드를 호출하기만 하면 된다.
    • 필요에 따라 트랜잭션 매니저가 DB 커넥션을 가져오는 작업도 같이 수행해주기 때문이다..
    • 파라미터로 넘기는 DefaultTransactionDefinition 오브젝트는 트랜잭션에 대한 속성을 담고 있다.
      • 여기서 트랜잭션을 가져온다는 것은 일단 트랜잭션을 시작한다는 의미라고 생각하자
  • 이렇게 시작된 트랜잭션은 TransactionStatus 타입의 변수에 저장된다. TransactionStatus는 트랜잭션에 대한 조작이 필요할 때 PlatformTransactionManager 메소드의 파라미터로 전달해주면 된다.
  • PlatformTransactionManager를 구현한 DataSourceTransactionManager 오브젝트는 JdbcTemplate에서 사용될 수 있는 방식으로 트랜잭션을 관리해준다.

트랜잭션 기술 설정의 분리

JTATransactionManager는 주요 자바 서버에서 제공하는 JTA 정보를 JNDI를 통해 자동으로 인식하는 기능을 갖고 있다. 따라서 별다른 설정 없이 JTATransactionManager를 사용하기만 해도 서버의 트랜잭션 매니저/서비스와 연동해서 동작한다.

PlatformTransactionManager txManager = new JTATransactionManager();

어떤 트랜잭션 매니저 구현 클래스를 사용할지 UserService 코드가 알고 있는 것은 DI 원칙에 위배된다. 자신이 사용할 구체적인 클래스를 스스로 결정하고 생성하지 말고 컨테이너를 통해 외부에서 제공받게 하는 스프링 DI의 방식으로 바꾸자.

  • 상태를 갖고 있고, 멀티스레드 환경에서 안전하지 않은 클래스를 빈으로 무작정 등록하면 심각한 문제가 발생할 수 있다.
    • 스프링이 제공하는 PlatformTransactionManager의 구현클래스는 싱글톤으로 사용 가능하다

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

  • 기술과 서비스에 대해 추상화 기법 적용
    • UserDao와 UserService가 각각 담당하는 코드의 기능적인 관심에 따라 분리, 독자적으로 확장이 가능하도록 작업
  • 트랜잭션 추상화
    • 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리

단일 책임의 원칙

  • 하나의 모듈은 한가지 책임을 가져야함
    • 인터페이스를 도입하고 DI로 연결해야 하며, 그 결과로 단일 책임 원칙과 개방 폐쇄 원칙도 잘 지키고, 모듈 간 결합도가 낮아서 서로의 변경이 영향 X, 같은 이유로 변경이 단일 책임에 집중되는 응집도 높은 코드를 작성하게 됨