본문 바로가기
Spring&SpringBoot/<토비의 스프링 3.1 Vol1.1>, 이일민

트랜잭션 개선 과정 분석 (feat. 서비스 추상화, 책임 주도 접근법)

by 민휘 2023. 4. 4.

목차

  1. 트랜잭션 적용 확인을 위한 테스트
  2. 트랜잭션 적용을 위한 두번의 도약
    1. 트랜잭션 동기화 : JDBC를 사용하는 서비스 코드에서 Connection 객체의 전파 막기
    2. 트랜잭션 서비스 추상화 : 트랜잭션 처리 방법에 따른 차이를 추상화하는 PlatformTransactionManager
    3. 🌱 데이터 중심 접근법과 책임 중심 접근법에 따라 트랜잭션 개선 과정 분석하기 🌱
  3. 서비스 추상화와 단일 책임 원칙

 

트랜잭션 적용 확인을 위한 테스트

 

현재 UserService에는 트랜잭션이 적용되지 않은 상태이다. 따라서 upgradeLevels와 같이 여러 sql문을 실행하는 메소드를 실행하던 중에 예외나 오류가 발생해 실행이 중단되면, 어떤 user에는 업그레이드가 반영되고 어떤 user에는 업그레이드가 반영되지 않을 것이다. 이런 상황을 막고 메소드의 원자성(all or nothing)을 지키기 위해 트랜잭션을 적용해야 한다. 우선 트랜잭션 적용을 위한 테스트를 작성한다. 이 테스트는 트랜잭션을 적용하지 않은 현재는 실패하고, 트랜잭션을 적용하면 성공해야 한다.

 

어떻게 하면 updateLevels를 실행을 중간에 그만두게 할 수 있을까? DB 시스템 연결을 수동으로 끊어 확인하는 접근도 있지만, 너무 짧은 시간 안에 이루어지기 때문에 직접 하기는 어렵다. 대신 업그레이드를 하던 중에 런타임 예외를 던져서 실행이 중단되도록 만들자.

테스트에서 사용되는 updateLevels는 중간에 예외를 던져야 한다. 이럴 때는 UserService를 상속하는 TestUserService를 만들어 updateLevels를 오버라이딩해 예외를 던지도록 만든다. 이렇게 테스트를 위해 사용되는 클래스를 테스트 대역 클래스라고 한다.

 

테스트 대역 클래스로 만든 TestUserService가 오버라이딩하는 upgradeLevel는 특정 아이디를 가진 user를 업그레이드할 때 예외를 발생시키려고 한다. 상태 필드로 예외를 던질 user의 id를 받도록 한다. 테스트 메소드에서는 네번째 유저의 아이디를 TestUserService 생성에 넘긴다. upgradeLevels는 네번째 유저를 업그레이드하려고 할때 예외를 발생시키고, 두번째 유저가 업그레이드되지 않았음을 확인한다. (두번째 유저는 업그레이드 조건을 만족한다.) 테스트를 돌려보면 당연히 실패한다. 아직 트랜잭션이 적용되지 않았으므로 두번째 유저의 업그레이드는 성공인 상태로 남을 것이다.

public class UserServiceTest {

	@Test
  public void upgradeAllOrNothing(){

		UserService testUserService = new TestUserService(users.get(3).getId());
		testUserService.setUserDao(this.userDao);

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

		try {
			testUserService.upgradeLevels(); // 네번째 user를 실행하면 예외 던져짐
			fail("TestUserServiceException expected"); // 위에서 Test...Exception이 발생하지 않으면 실패
		} catch (TestUserServiceException e) {}
      checkLevelUpgraded(users.get(1), false); // 두번째 user가 업그레이드 되지 않았음을 확인
	  }
	}

	static class TestUserService extends UserService {
		private String id; // upgradeLevel 실행 중에 이 id를 만나면 예외 던짐

		private TestUserService(String id) {
			this.id = id;
		}

		@Override
		protected void upgradeLevel(User user) {
			if (user.getId().equals(this.id)) throw new TestUserServiceException();
			super.upgradeLevel(user);
		}
	}

	static class TestUserServiceException extends RuntimeException {}

}

 

 

 

JDBC 트랜잭션 적용하기

 

모든 트랜잭션은 시작하는 지점과 끝나는 지점이 있다. 시작하는 지점은 한 가지이지만, 끝나는 지점은 두가지이다. 정상적으로 실행을 완료했을 때 작업을 확정하는 커밋과 비정상적으로 실행을 중단했을 때 이전에 작업한 내용을 취소하는 롤백이 있다.

 

JDBC를 사용하는 트랜잭션은 Connection을 받아서 트랜잭션을 시작하고, JdbcTemplate을 사용하는 작업을 진행하고, 트랜잭션을 종료하고 Connection을 닫는 패턴을 가진다. 트랜잭션으로 묶인 작업은 원자성을 띈다. 개발자가 해야하는 작업은 트랜잭션의 범위를 지정하는 것인데, 이를 트랜잭션의 경계 설정이라고 한다. 트랜잭션 경계는 하나의 Connection이 만들어지고 닫히는 범위 안에 존재한다. 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션이라고 한다.

 

UserService의 updateLevels에 적용해보면 updateLevels 안에서 user 수만큼 호출하는 updateLevel이 전부 하나의 트랜잭션으로 묶여야 한다. 그러려면 updateLevels 안에서 Connection을 생성하고, 트랜잭션을 시작하고, 모든 user를 받아서 updateLevel을 진행하고, 만약 예외가 발생하면 롤백하고 정상적으로 끝나면 커밋, Connection을 반환하고 작업을 종료해야 한다. Connection은 updateLevels의 시작부터 끝까지 존재한다.

 

 

JDBC 트랜잭션 동기화

 

위에서 말한대로 updateLevels에 트랜잭션을 적용해보자. updateLevels에서 Connection을 생성하고 트랜잭션을 시작한다.

그런데 여기에 문제가 있다. updateLevels와 Connection을 공유해야하는 모든 메소드는 updateLevels에서 생성한 Connection을 받아야만 한다. 이 필드는 메소드 안에서만 공유되기 때문에 클래스 필드로 추출하기엔 부적절하므로 매개변수로 전달해야하는데, 그러면 updateLevels에서 호출하는 UserService updateLevel, updateLevel 안에서 호출하는 dao update에도 Connection을 필요로 하고, 결국 Dao 인터페이스까지 Connection이 노출된다. updateLevels와 의존성이 있는 모든 객체에 Connection이 전파되는 것이다.

public class UserService {
    DataSource dataSource;

    public void upgradeLevels() throws SQLException {
        Connection c = dataSource.getConnection();
        c.setAutoCommit(false);
        try{
            List<User> users = userDao.getAll();
            for(User user: users) {
                PreparedStatement st1;
                **upgradeLevel(c, user);**
            }
            c.commit();
        } catch (Exception e) {
            c.rollback();
        }
        c.close();
    }

    protected void upgradeLevel(**Connection c**, User user) {
        user.upgradeLevel();
        userDao.update(**c**, user);
    }

}

public interface UserDao {
    void add(**Connection c**, User user);
}

Connection은 JDBC에서만 사용되는 커넥션 객체이기 때문에 여러 곳에서 매개변수로 전파되는 것은 캡슐화를 망가뜨리고 변경에 취약하게 만든다. 따라서 커넥션 객체를 막기 위한 방법이 필요한데, 이것이 스프링이 제공하는 JDBC 트랜잭션 동기화이다.

 

컨셉은 간단하다. updateLevels와 의존성 있는 객체들이 접근하여 Connection을 공유할 수 있는 저장소를 두는 것이다. 커넥션을 캐시한다고 생각하면 된다. 이렇게 하면 매개변수 필드로 전파되는 것을 막을 수 있다. 클래스 필드로 두는 것보다 UserService의 응집도가 높다.

트랜잭션 동기화를 사용하는 코드를 살펴보자. DataSourceUtils.getConnection은 커넥션 오브젝트 생성과 함께 트랜잭션 동기화에 사용하도록 저장소에 바인딩해준다. 트랜잭션 동기화가 되어 있는 채로 JdbcTemplate을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB 커넥션을 사용하게 된다. 게다가 스프링이 제공하는 유틸리티 메소드는 멀티 스레드 환경에서도 안전한 트랜잭션 동기화를 제공하므로 안전하게 사용할 수 있다.

 

 

public class UserService {

  DataSource dataSource;

	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(canUpgradeLevel(user)) upgradeLevel(user);
            }
            c.commit(); // 정상 종료
        } catch (Exception e) {
            c.rollback(); // 비정상 종료
            throw e;
        } finally {
            DataSourceUtils.releaseConnection(c, dataSource); // DB 커넥션 안전하게 닫기
            TransactionSynchronizationManager.unbindResource(this.dataSource); // 동기화 작업 종료 및 정리
            TransactionSynchronizationManager.clearSynchronization(); // 동기화 작업 종료 및 정리
        }
    }

테스트를 진행해보면 성공한다. 트랜잭션이 적용되었다.

 

 

트랜잭션 서비스 동기화

 

하지만 위에서 사용한 TransactionSynchronizationManager는 Jdbc를 사용하는 경우에 한정된다. Connection 오브젝트를 캐싱하는 것이 목적이며, 하나의 Connection에서 안에서 만들어지는 트랜잭션만 처리할 수 있다. 만약 JDBC가 아닌 하이버네이트를 사용하면 Connection 대신 Session 오브젝트를 사용해야하고, 하나의 트랜잭션이 여러 DB에 참여하는 글로벌 트랜잭션을 처리하려면 JDBC API가 아닌 JTA API를 사용해야한다. 결국 트랜잭션을 어떻게 처리하는지 방법이 바뀌면 트랜잭션을 처리하는 UserService의 코드도 변경된다.

 

다행히 트랜잭션 처리 코드는 일정한 패턴을 갖는 유사한 구조를 가진다. 트랜잭션 처리 방법에 따라 달라지는 코드의 차이를 추상화하기 위해 스프링이 PlatformTransactionManager을 제공한다.

스프링이 제공하는 PlatformTransactionManager는 트랜잭션 계층의 기술들의 API를 제공하는 매니저들을 한번 더 추상화해서 트랜잭션 처리에 대한 통합 API를 제공한다. 유저 서비스는 트랜잭션 처리 방법에 따라 다른 매니저에 의존하지 않으므로 트랜잭션 처리 방법이 바뀌더라도 유저 서비스의 코드는 변경되지 않는다.

 

public class UserService {

	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 (Exception e) {
			transactionManager.rollback(status); // 비정상 종료
			throw e;
		}
	}

}

 

 

 

데이터 중심 접근법과 책임 중심 접근법에 따라 트랜잭션 개선 과정 분석하기

 

트랜잭션 개선은 트랜잭션 동기화와 서비스 추상화에 의해 단계적으로 이루어졌다. 도입 이전과 이후를 비교해보면 확실히 확장 가능한 설계 구조를 갖춘 것을 알 수 있다. 객체지향에서 좋은 설계를 판단하는 세 가지 척도인 캡슐화, 결합도, 응집도 측면에서 설계를 비교해보자. 또 어떤 설계 방법의 차이가 이러한 차이를 만들었는지 생각해보자.

 

객체지향 설계에서 사용하는 책임 주도 접근법과 반대되는 데이터 중심 접근법과 비교해보면 이해가 쉽다. 데이터 중심 접근법은 객체가 어떤 데이터를 필요로 하는지 먼저 결정한 후에 객체의 행동을 결정한다. 이렇게 되면 객체의 인터페이스를 정할 때 객체가 어떤 데이터를 사용하는지가 노출된다. 문제는 이 데이터는 요구사항의 변경에 따라 쉽게 변경되는 불안정한 구현에 속하기 때문에, 변경이나 확장을 유연하게 받아들일 수 없다.

 

데이터 중심 접근법은 캡슐화를 준수하지 않기 때문에 결합도가 높고 응집도가 낮은 설계를 만든다. 인터페이스에 구현이 노출되면, 이 인터페이스에 의존하는 다른 객체들이 구현에 강하게 결합된다. 만약 객체 내부 구현이 변경되면 해당 인터페이스에 의존하는 모든 객체의 구현도 흔들린다. 토비 5장에서 살펴본 트랜잭션 처리에서 JDBC에서만 사용하는 Connection을 공유하기 위해 매개변수로 전달했더니 Dao의 인터페이스에도 Connection이 노출되어, JDBC가 아닌 다른 트랜잭션 처리로는 확장 불가능한 케이스를 살펴보았다. 만약 Jdbc가 아니라 하이버네이트로 기술을 변경한다면 Connection을 사용하는 모든 객체는 Session으로 변경되어야 한다. 하나의 요구사항 반영을 위해 여러 객체를 수정해야하므로 응집도가 낮다. 이런 설계에서는 요구사항 변경을 반영하기 위해 수정해야하는 범위가 커지므로 작업량도 많고 버그가 숨어들 가능성도 크다. 그래서 확장이 어려운 설계가 만들어진다.

 

반면 책임 주도 접근법은 객체의 행동을 먼저 결정하고, 행동에 필요한 상태는 인터페이스 뒤로 숨겨서 외부에 노출되지 않도록 한다.(캡슐화 준수) 객체의 상태를 변경할 수 있는 유일한 방법은 객체의 행동이기 때문에 다른 객체는 이 객체의 상태 변화에 간섭할 수 없다. 다른 객체의 상태 변화를 요청하려면, 메시지를 전송해서 다른 객체의 행동이 스스로 동작하도록 만들어야 한다.(낮은 결합도) 그래서 책임을 수행하는 방법이 변경되었을 때 변경의 파급효과가 특정 객체로 제한된다.(높은 응집도) 따라서 변경에 유연한 설계가 가능하다.

 

Connection을 사용한 트랜잭션 처리가 확장하기 어려워진 이유는 다음과 같다.

  1. 처음 UserService가 필요로 하는 리소스인 Connection을 중심으로 updateLevels의 트랜잭션을 처리했다. 트랜잭션을 처리한다는 행동이 아닌 Connection을 사용해야한다는 것에 집중했다. 그래서 Connection이 UserService의 인터페이스에 노출되었다. 즉, 캡슐화가 깨졌다.
  2. UserService의 upgradeLevels이 호출하는 upgradeLevel과 UserDao의 add, update도 Connection을 받아야만 사용이 가능해졌고, 결국 UserDao의 인터페이스까지 Connection이 노출되었다. Jdbc 대신 하이버네이트를 사용하면 Connection을 사용하는 모든 객체는 변경되어야 한다. 결합도가 높으니 수정으로 인한 피로도가 높다.
  3. UserService는 의존성이 있는 객체에게 Connection을 공유하는 책임까지 맡게 되었다. UserService는 두 가지 변경 이유를 가지기 때문에 응집도가 낮다.

 

이러한 문제는 트랜잭션 동기화와 트랜잭션 서비스 추상화를 적용하면서 해결되었다. 트랜잭션 동기화는 캐싱 저장소에 Connection을 캡슐화하고 커넥션 공유의 책임을 할당해서 캡슐화를 준수하도록 했다. 그러나 트랜잭션 캐시로부터 받아오는 오브젝트는 구현체인 Connection이기 때문에 트랜잭션 방법이 변경되면 UserService도 변경되어야만 했다. 여전히 결합도가 높은 것이다.

 

Service와 트랜잭션 처리의 결합도를 낮추기 위해 적용한 것이 트랜잭션 추상화 API를 제공하는 PlatformTransactionManager이다. 이 API를 사용하면 트랜잭션 방법이 달라지더라도 동일한 API로 트랜잭션을 처리할 수 있으므로, 트랜잭션 방법마다 달라지는 커넥션 객체에 의존하지 않게 된다. 또한 PlatformTransactionManager가 트랜잭션 관리의 책임을 가져가기 때문에 UserService의 변경 이유가 적어져 응집도가 높아진다.

 

 

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

 

이번 장의 목표였던 스프링이 어떻게 목적이 비슷한 여러 종류의 기술을 추상화하는가에 대해 정리해보자.

 

업그레이드 로직을 UserDao가 아닌 UserService로 분리하여, 비즈니스 요구사항의 변경에 UserDao가 영향을 받지 않도록 만들었다. 또 스프링의 트랜잭션 서비스 추상화 기법을 도입해 다양한 트랜잭션 기술을 일관된 방식으로 제어할 수 있게 되었다. 전자는 같은 애플리케이션 계층에서 각 객체의 관심사와 내용에 따라 분리했다. 후자는 애플리케이션 계층과 로우레벨의 트랜잭션이라는 아예 다른 계층의 특성을 갖는 코드를 분리했다.

 

이런 수평적 구분이든 수직적 구분이든 서로 영향을 주지 않고 자유롭게 확장될 수 있는 구조를 만드는 것에는 스프링의 DI가 중요한 역할을 한다. DI를 통해 책임이 분리된 객체들은 단일 책임 원칙을 지킨다. 하나의 모듈이 바뀌는 이유는 한 가지인 것이다.

UserService와 UserDao를 비즈니스 로직과 데이터 액세스 로직이라는 관점으로 분리했다. 서로 다른 관심사에 따라 코드를 분리했으므로, 비즈니스 로직이 바뀌더라도 데이터 액세스 방법에는 영향을 미치지 않는다. 반대로 데이터 액세스 기술이나 DB가 달라지더라도 비즈니스 로직에는 영향을 미치지 않는다.

 

UserService가 PlatformTransactionManager를 사용하지 않았을 때는 트랜잭션 처리 방법에 따라 UserService가 바뀌었다. UserService의 원래 변경 이유인 비즈니스 로직에 추가로 트랜잭션 처리 방법이 변경 이유가 된 것이다. 이렇게 되면 변경에 따른 파급 효과를 파악하기 어렵기 때문에 확장에 제한적이다. DI를 사용하여 UserService가 트랜잭션 방법을 추상화한 PlatformTransactionManager에 의존하면, UserService는 더이상 트랜잭션 처리 방법에 따라 변경되지 않는다.

 

단일 책임 원칙을 지키면 얻을 수 있는 장점은 다음과 같다.

  • 변경이 필요할 때 수정 대상이 명확해진다
  • 클래스가 많은 경우에도 수정해야하는 작업량이 정해져있다
  • 클래스가 많은 경우와 비교해서 코드를 수정하다 버그가 일어날 가능성이 적다

스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리이다. 스프링은 DI에 담긴 원칙과 이를 응용하는 프로그래밍 모델을 자바 엔터프라이즈 기술의 많은 문제를 해결하는데 적극적으로 활용한다. 또 스프링을 사용하는 개발자가 만드는 애플리케이션 코드도 이런 DI를 활용해서 깔끔하고 유연한 코드와 설계를 만들어내도록 지원하고 지지한다.