서비스 추상화 2

자바, 스프링 개발자들의 종착역이자, 주기적으로 다시 돌아오는 그곳, <토비의 스프링 3.1>을 드디어 읽는다.

한창 개발 걸음마를 막 떼고 스프링으로 아장아장 기어다니며 CRUD를 할 때즈음, 토비의 스프링을 찬양하고 있는 많은 개발자들을 보았고, 역시 지식이 깊어지려면, 책을 통해서 지식을 정리하고 깊이를 다지는 시간이 필요하겠구나, 생각했다. 하지만, 개발을 이제 막 배운 그때의 나에게 토비 책은 너무나도 무시무시했고, 토비책을 읽다가 개발을 포기한 사람들의 증언도 여럿 읽고 나니, 이 책은 개발자로서 레베루업이 필요할 때즈음, 다시 꺼내봐야겠다고 생각했다.

그리고 지금이다. 퇴사 후, 이직을 준비하는 동안, 지식의 깊이를 좀 더 다져놓고 싶다는 생각이 들었고, 집앞 도서관에서 (아무도 빌려가지 않는) 토비님의 책을 빌려서 읽기 시작했다. 처음에는 가볍게 읽기 시작했지만, 읽을 수록 인사이트가 쌓여, 기록하면서 제대로 읽고 싶어졌다. 그래서 나의 세컨 브레인인 이곳 블로그에 짧은 글들로 그 내용을 적어보며, 토비님으로부터 얻은 인사이트를 내것으로 만들어보고자 한다.

트랜잭션 서비스 추상화

  • 데이터베이스는 하나의 SQL 명령을 처리하는 경우, 완벽하게 트랜잭션을 보장해준다. 그 명령이 실패할 경우, 데이터는 명령이 실행되기 이전의 상태로 원상복구 된다.

  • 문제는 여러개의 SQL 명령을 처리하는 경우이다. 이 여러개의 명령이 하나의 사이클로 묶여서 실행되어야할 경우, 트랜잭션을 어떻게 보장할 것인가? 그것이 오늘 고민해야할 부분이다.

  • 트랜잭션 롤백 : SQL 명령에 실패했다면 앞어서 실행된 SQL 작업이 일괄적으로 취소되는 것

  • 트랜잭션 커밋 : 여러개의 SQL 을 하나의 트랜잭션으로 처리하는 경우에 모든 SQL 수행 작업에 성공했다면, 이를 DB 에 알려주어서 작업을 확정시켜야 한다.

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

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

  • setAutoCommit(false) 로 트랜잭션의 시작을 선언하고

  • commit() or rollback() 으로 트랜잭션을 종료하는 작업

  • 이 트랜잭션 경계는 하나의 connection 이 만들어지고 닫히는 범위 안에서 존재한다. -> 로컬 트랜잭션

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

  • JDBC 트랜잭션은 결국 하나의 SQL 메소드 실행에만 영향을 끼친다. 여러 개의 메소드가 같은 트랜잭션 범위 내에 존재하려면 결국, service 단에서 트랜잭션 처리에 관한 코드가 추가되어야 한다.

    • upgradeLevels() 메소드의 시작지점에서 데이터베이스 커넥션을 생성하고, 성공시에 커밋을, 실패시에는 롤백을 해주도록, 모든 결과가 끝내면 db connection 을 닫아주도록 try-catch-finally 문을 통해서 코드를 감싸주어야 한다.

  • 이런 변경은 문제점이 많다.

    • 첫째로, db connection 을 비롯해서 리소스를 깔끔하게 처리할 수 있었던 jdbcTemplate 을 더이상 이용할 수 없다. try-catch 문은 다시 service 단으로 이동해야한다.

    • 둘쨰로, 모든 DAO 와 Service 메소드마다 Connection 파라미터가 추가되어야 한다.

    • 셋쨰로, Connection 파라미터가 추가되면, UserDao 는 이제 JDBC 데이터 엑세스 기술에 종속되게 된다. 만약 JPA나 하이버네이트로 구현 방식을 변경하려면 Connection 파라미터 대신에 EntityManager 나 Session 오브젝트를 전달받도록 변경해주어야 한다. 결국 계속 코드 변화가 발생하는 꼴이다.

    • 넷째로, 테스트 코드에서도 매 테스트마다 커넥션을 만드는 것에 대해 신경써야 한다.

트랜잭션 동기화

  • 스프링은 이러한 문제를 트랜잭션 동기화의 개념을 통해 해결한다.

  • 대략적으로 보자면 아래와 같은 방식으로 이루어진다.

    • 우선 UserService 에서 Connection 을 생성하고

    • setAutoCommit(false) 를 통해 트랜잭션 경계점을 시작해준다.

    • 첫번째 update() 매소드가 호출되고 그 내부에서 사용하는 jdbcTemplate 메소드는 가장 먼저 TransactionSynchronizations 트랜잭션 동기화 저장소에 현재 시작된 트랜잭션을 가진 Connection 오브젝트가 존재하는지 확인한다.

    • 이미 시작된 Connection 을 발견하고, 이를 가져온다.

    • 가져온 Connection 을 이용해서 PreparedStatement 를 만들어 수정SQL 을 실행한다. 아직 connection 을 닫지 않은 채로 작업 마친다.

    • 두번째 update 에서도 같은 방식으로 실행이 되고, 세번째도 마찬가지이다.

    • 이제 모든 작업이 정상적으로 끝났다면, UserService 는 이제 Connection 의 commit() 를 호출해서 트랜잭션을 완료시킨다.

    • 그 이후에는 더이상 Connection 오브젝트를 저장해두지 않도록 이를 제거한다.

트랜잭션 동기화 적용

  • 스프링에서는 TransactionSynchronizationManager 클래스를 제공하여 트랜잭션 동기화 관리를 돕는다.

  • Connection c = DataSourceUtils.getConnection(dataSource);

    • Connection 오브젝트를 생성해주고

    • 트랜잭션 동기화에 사용하는 저장소에 바인딩 해준다.

public class UserService {

	private DataSource dataSource;  			
	
	//dataSource DI 받도록 설정 
	public void setDataSource(DataSource dataSource) {
		this.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);	//커넥션 릴리즈 
			TransactionSynchronizationManager.unbindResource(this.dataSource);  
			TransactionSynchronizationManager.clearSynchronization();  
		}
	}
}

트랜잭션 테스트 보완

  • 테스트 코드에서는 TestUserService 클래스를 테스트 용으로 별도로 생성하였으니, setters 메소드를 통해서 직접 DI 를 해주어야 한다.

  • 그리고 운영 단에서는 스프링 컨테이너가 초기화한 userService 를 사용할 수 있도록 프로퍼티에 dataSource 를 추가해준다.

JdbcTemplate 과 트랜잭션 동기화

  • JDBC 는 똑똑하게 동작한다. 기본적으로는 하나의 메소드를 하나의 커넥션 흐름동안 트랜잭션 유지하도록 지원하지만,

  • 만약 트랜잭션 경계설정이 일부러 된 경우라면, 그때부터는 트랜잭션 동기화 저장소를 통해서 기존의 커넥션을 가져다가 사용한다.

트랜잭션 서비스 추상화

  • 지금까지 진행한 것을 정리해보자.

    • UserService, UserDao, UserDaoJdbc 는 책임과 성격에 따라서 데이터 엑세스 부분과 비즈니스 로직을 잘 분리했을 뿐만 아니라, JDBC API 를 이용하여 편리하게 데이터베이스를 이용했고, 트랜잭션까지 적용했다.

  • 여기까지 꽤 좋은 개선 과정이었지만 여전히 문제점이 존재한다.

    • 트랜잭션 경계설정 코드가 기술과 환경에 종속된다.

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

문제 1) 트랜잭션 경계설정 코드가 기술과 환경에 종속된다.

  • 현재상황

    • 원래 UserDao 에만 종속적이었던 UserService가 이제 Connection 을 생성하기 시작하면서 JDBC DAO 에 종속되게 되었다.

    • JDBC 트랜잭션은 하나의 DB를 대상으로한 트랜잭션에서만 유효하다. 로컬 트랜잭션은 하나의 데이터베이스에 종속되기 떄문이다.

  • 여기서 만약

    • 여러 개 DB 에 걸쳐 트랜잭션을 적용하고 싶다면? - 글로벌 트랜잭션이 필요하다.

    • 하이버네이트 환경의 회사에서 트랜잭션 적용이 필요하다면? - 하이버네이트에서는 Connection 을 쓰지 않고, Session 을 사용하며, 독자적인 트랜잭션 관리 API 를 가지고 있다.

  • 여러 개의 DB에 걸쳐 트랜잭션 적용하기

    • JTA - Java Transaction API 를 이용해 글로벌 트랜잭션을 적용한다.

    • 어플리케이션이 JTA 를 통해 트랜잭션 메니저가 트랜잭션을 관리하도록 한다.

    • 그리고 트랜잭션 메니저는 DB, 메시지 서버와 각각 XA 프로토콜을 이용하여 통신한다.

    • 즉, JDBC API 가 아니라 JTA 를 이용해서 트랜잭션을 관리하도록 코드를 변경해야한다.

    • 하지만 이렇게 되면, 결국 UserService 코드를 수정해야하는 문제가 생기게 된다.

  • 기술과 환경에 종속되지 않도록 하려면 결국 트랜잭션 서비스를 추상화해야한다.

    • 데이터 엑세스 기술이 JDBC 이던, JPA, Hibernate 건 간에 상관없이 추상화된 레벨에서 공통된 메소드를 사용하도록 하여 적용할 수 있다.

    • 어차피 데이터 엑세스 기술의 목적이 같기 때문에 제공하는 메소드들도 유사하거나 겹칠 것이기 때문에 충분히 가능하다.

스프링은 이런 트랜잭션 서비스 추상화 기술을 제공한다.

  • PlatformTransactionManager 라는 추상화된 인터페이스를 통해 하위에 다양한 기술에서 각각 구현체를 만들 수 있도록 한다.

  • 트랜잭션 기술 설정의 분리

    • setTransactionManager() 를 통해 UserService는 외부 설정을 통해 트랜잭션 메니저를 DI 받도록 하였고 각 클라이언트들은 본인들의 데이터 엑세스 기술에 맞게 XML 설정을 수정하면 된다.

<bean id="userService" class="springbook.user.service.UserService">
   <property name="userDao" ref="userDao" />
   <property name="transactionManager" ref="transactionManager" />
</bean>

<bean id="transactionManager" 
   class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
   <property name="dataSource" ref="dataSource" />  
</bean>
public class UserService {
   ...
   private PlatformTransactionManager transactionManager;

   ...
   
   //DI 
   public void setTransactionManager(PlatformTransactionManager transactionManager) {
      this.transactionManager = transactionManager;
   }

   public void upgradeLevels() {
      TransactionStatus status = 
         this.transactionManager.getTransaction(new DefaultTransactionDefinition());
      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;
      }
   }
   
   ...
}

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

단일책임원칙, Single Responsibility Principle

  • 객체지향 설계 원칙 중 하나

  • 하나의 모듈은 한 가지 책임을 가져야 한다.

  • 하나의 모듈이 바뀌는 이유는 한 가지여야한다.

  • 장점

    • 어떤 변경이 필요할 때, 수정할 대상이 명확해진다.

    • 기술이 바뀌면 기술 계층과의 연동을 담당하는 기술 추상화 계층의 설정만 바꿔주면 된다.

  • UserService 에 만약 JDBC 기술에 종속되는 코드가 있었다고 가정한다면, 트랜잭션 기술을 JTA 로 바꾸는 순간 서비스 클래스 수 * 트랜잭션을 사용하는 메소드의 수 만큼 엄청난 코드 변경이 있을 것이다. 이런 불편을 XML 설정을 바꾸는 것만으로 해결할 수 있다니, 놀랍지 않은가!

  • 결국 스프링을 지탱하는 핵심은 DI 이다.

    • DAO와 DataSource 를 분리할 때에도,

    • 효과적인 단위테스트를 만드는 과정에도,

    • 템플릿, 콜백 패턴에서도

    • 비즈니스 로직과 데이터 로직을 별도로 만들어 연결하는 것에서도

    • 비즈니스 로직에 필요한 트랜잭션 기술을 추상화해서 분리하는데도 역시 DI 가 사용되었다.

  • 좋은 코드를 설계하고 만들기 위해서는 꾸준한 노력이 필요하다. 기능이 동작한다고 만족하는 것이 아니라, 끊임없이 코드를 개선하는 과정이 필요하다.

메일 서비스 추상화

  • 기능 추가에 대한 요청이 왔다. 내용은 대충 아래와 같다.

    • 레벨이 업그레이드 된 사용자에게는 이메일이 전송되도록 해달라는 것!

  • 이를 위해 필요한 것은 아래와 같다.

    • 먼저, 사용자의 이메일 주소를 가지고 있어야 한다.

    • upgradeLevel() 메소드에 메일 발송 기능을 추가해야한다.

  • 우선 메일 발송 기능은 이미 자바의 표준 기술인 JavaMail API 를 이용하여 시스템을 만들면 된다. SMTP 프로토콜을 지원하는 메일 서버만 준비되어있다면 코드는 정상동작하고 안내 메일이 발송될 것이다.

  • 문제는 테스트 환경이다. 테스트 시에는 메일 서버가 준비될리 없다. 이 경우에는 어떻게 해결할 수 있을까?

JavaMail 메일 발송 기능의 추상화

  • 테스트 환경에서는 메일 서버가 구축되어있지 않으므로 메일 전송을 할 수 없다. 이 경우, 실제로 메일을 전송하지 말고, 전송된 척 결과만 반환해주도록 테스트 환경을 구축하면 된다.

  • 다행히도 스프링 에서는 메일 발송 기능을 추상화한 기술을 지원한다.

    • MailSender 인터페이스

    • 그리고 UserService 에서는 DI 를 통해 외부에서 mailSender 를 주입받을 수 있도록 코드를 설정해주면 된다.

public class DummyMailSender implements MailSender {
   public void send(SimpleMailMessage mailMessage) throws MailException {
   }

   public void send(SimpleMailMessage[] mailMessage) throws MailException {
   }
}


public class 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 level test");
		mailMessage.setText(user.getLevel().name());
		
		this.mailSender.send(mailMessage);
	}
	...
}
<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" />

테스트 대역

  • DI 의 장점은 운영환경과 다른 테스트 환경을 위해서 의존 오브젝트를 바꿔치기할 수 있다는 점이다.

    • UserDao -> SimpleDataSource -> TestDb

    • UserService -> DummyMailSender

  • 테스트 대역 : 테스트 용으로 사용되는 특별한 오브젝트들

  • 대표적으로는 테스트 스텁이 있다. 이는 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행될 수 있도록 돕는 것을 의미한다.

  • 테스트 스텁은 실제 코드와 그대로 실행되지 않기 때문에 리턴 값이 없는 경우도 많다. 이런 경우, 간접적인 출력값을 받을 수 있도록 세팅할 수 있는데, 목 오브젝트를 이용하면 된다.

  • 목 오브젝트

    • 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와준다.

    • 테스트 오브젝트와 자신 사이에서 일어나는 커뮤니케이션 내용을 저장해두었다가 테스트 결과를 검증하는데 활용할 수 있게 해준다.

목 오브젝트를 이용한 테스트

  • 제대로 send() 가 호출되었다면, requests 리스트에 두 개의 요소가 있을 것이고, 그것은 각각 2번, 4번 사용자의 이메일 주소와 일치할 것이다.

public class UserServiceTest {

	@Test 
	@DirtiesContext
	public void upgradeLevels() {
		userDao.deleteAll();
		for(User user : users) userDao.add(user);
		
		MockMailSender mockMailSender = new MockMailSender();  
		userService.setMailSender(mockMailSender);  
		...
		
		List<String> request = mockMailSender.getRequests();  
		assertThat(request.size(), is(2));  
		assertThat(request.get(0), is(users.get(1).getEmail()));  
		assertThat(request.get(1), is(users.get(3).getEmail()));  
	}	
}

   static class MockMailSender implements MailSender {
      private List<String> requests = new ArrayList<String>();   
      
      public List<String> getRequests() {
         return requests;
      }
   
      public void send(SimpleMailMessage mailMessage) throws MailException {
         requests.add(mailMessage.getTo()[0]);  
      }
   
      public void send(SimpleMailMessage[] mailMessage) throws MailException {
      }
   }
}

정리

  • 비즈니스 로직을 담은 코드와 데이터 엑세스 로직을 담은 코드는 서로 깔끔하게 분리하는 것이 바람직하다. 비즈니스 로직 코드 역시, 내부적으로 책임과 역할에 따라서 깔끔하게 메소드로 정리되어야 한다.

  • 이를 위해서는 DAO 기술 변화에 서비스 계층의 코드가 영향을 받지 않도록 인터페이스와 DI 를 잘 활용해서 결합도를 낮춰줘야 한다.

  • DAO 를 사용하는 비즈니스 로직에는 단위 작업을 보장해주는 트랜잭션이 필요하다.

  • 트랜잭션의 시작과 종료를 지정하는 일을 트랜젝션의 경계설정이라고 한다. 이는 주로 비즈니스 로직 안에서 일어나는 경우가 많다.

  • 시작된 트랜잭션 정보를 담은 오브젝트를 파라미터로 DAO 에 전달하는 방법은 매우 비효율적이다. 따라서 스프링이 제공하는 트랜잭션 동기화 기법을 활용하는 것이 좋다.

  • 자바에서 사용되는 트랜잭션 API 의 종류와 방법은 다양하다. 환경과 서버에 따라서 트랜잭션 방법이 변경되면 경계설정 코드도 함께 변경되어야 한다.

  • 트랜잭션 방법에 따라 비즈니스 로직을 담은 코드가 변경된다면 단일 책임 원칙에 위배된다. DAO 가 사용하는 특정 기술에 강한 결합을 만들어내는 셈이다.

  • 이를 해결하려면 트랜잭션 서비스 추상화를 이용하면 된다.

  • 서비스 추상화는 로우레벨의 트랜잭션 기술과 API 의 변화에 상관없이 일관된 API 를 가진 추상화 계층을 도입한다.

  • 서비스 추상화는 테스트하기 어려운 JavaMail 과 같은 기술에도 적용할 수 있다. 테스트를 편리하게 작성하도록 도와주는 것만으로도 서비스 추상화는 가치가 있다.

  • 테스트 대상이 사용하는 의존 오브젝트를 대체할 수 있도록 만든 오브젝트를 테스트 대역이라고 한다.

  • 테스트 대역은 테스트 대상 오브젝트가 원활하게 동작할 수 있도록 도우면서 테스트를 위해 간접적인 정보를 제공해주기도 한다.

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

Last updated