* 사내 강의용으로 사용한 자료를 Blog에 공유합니다. Spring을 이용한 Web 개발에 대한 전반적인 내용에 대해서 다루고 있습니다.
DataSource의 변경
DriverManagerDataSource는 매우 단순한 DataSource입니다. 1개의 Connection에 대한 open/close만을 담당하고 있는 매우 단순한 형태의 DataSource입니다. 실무에서는 절대로 사용되면 안되는 형태의 DataSource이기도 합니다. 실무에서는 Connection Pool을 이용해서 DB에 대한 연결을 담당합니다. Connection Pool은 미리 Connection을 준비해두고, dao에서 사용된 Connection을 Pool에 넣어서 관리하는 형태입니다. 이 방식은 다음과 같은 장점을 갖습니다.
# DB에 대한 가장 큰 부하인 Connection open / close 횟수를 줄여, System의 부하를 줄일수 있습니다.
# Web과 같은 동시접근성이 보장되어야지 되는 시스템에서 Connection의 여유분을 만들어서, 시스템의 성능을 높일 수 있습니다.
# DB System에 대한 max connection 숫자를 파악할 수 있기 때문에, DB System에 대한 부하 및 성능에 대한 예측이 가능합니다.
실무에서는 무조건! Connection Pool을 사용해야지 됩니다. java 진영에서 주로 사용되는 connection pool에는 다음 두가지가 있습니다.
# c3p0
# BoneCP
둘에 대한 간단한 설명을 하자면, c3p0의 경우, Hibernate에서 기본으로 사용되는 Connnection Pool입니다. 오래된 Connection Pool이기도 합니다. 다만, 근간에는 DB Connection의 Deadlock 문제가 간간히 발표가 되고 있어, BoonCP에 비하여 밀리고 있는 것이 사실입니다. BoneCP는 아직까지 DB Connection에 대한 Deadlock이 보고된 적은 없습니다. BoonCP를 이용한 DataSource는 다음과 같이 구성이 됩니다.
<dependency> <groupId>com.jolbox</groupId> <artifactId>bonecp</artifactId> <version>0.7.1.RELEASE</version> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>14.0</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.2</version> </dependency>
DataSource를 BoneCP로 변경해서 사용해보도록 하겠습니다.
<bean id="dataSource" class="com.jolbox.bonecp.BoneCPDataSource" destroy-method="close"> <property name="driverClass" value="${connect.driver}" /> <property name="jdbcUrl" value="${connect.url}" /> <property name="username" value="${connect.username}" /> <property name="password" value="${connect.password}" /> <property name="idleConnectionTestPeriodInMinutes" value="60" /> <property name="idleMaxAgeInMinutes" value="240" /> <property name="maxConnectionsPerPartition" value="30" /> <property name="minConnectionsPerPartition" value="10" /> <property name="partitionCount" value="3" /> <property name="acquireIncrement" value="5" /> <property name="statementsCacheSize" value="100" /> <property name="releaseHelperThreads" value="3" /> </bean>
기본적으로 가지고 있던 driverClass, jdbcUrl, username, password의 경우에는 완전히 동일합니다. 다른 여러가지 property들이 추가로 들어가게 되는데 각 property들은 다음과 같은 의미를 가지고 있습니다.
# idleConnectionTestPeriodInMinutes : connection이 쉬고 있는 상태인지 확인하는 주기입니다.
# idleMaxAgeInMinutes : connection이 유휴상태로 놓어지는 최대시간입니다. 이 시간 이후, connection은 소멸됩니다.
# maxConnectionsPerPartition : Partition 당 최대 connection의 갯수입니다.
# minConnectionsPerPartition : Partition 당 최소 connection의 갯수입니다. (DB에 연결되는 최소 connection의 갯수는 partitionCount * minConnectionsPerPartition이 됩니다.)
# partitionCount : partition 갯수입니다. BoneCP는 partition 이라는 개념으로 Connection Pool의 영역을 나눠서 관리를 합니다.
# acquireIncrement : 한번에 connection을 얻어낼 때, 얻어내는 숫자입니다.
# releaseHelperThread : connection의 반환시에 사용되는 thread의 갯수입니다.
spring에 BooneCPDataSource를 추가 후에, 기존 DataSource의 spring bean 설정을 제거하면 기존 코드의 아무런 변경이 없이, BoneCP가 적용되고 있습니다. 이는 interface가 잘 되어 있는 객체의 활용이 되며, DI에 의한 IoC의 대표적 한 예가 될 수 있을 것 같습니다.
Service의 구성
지금까지 Dao Layer를 구성하는 방법과 Spring을 통해 bean application Context를 구성하는 방법에 대해서 알아봤습니다. Dao Layer는 일반적으로 Table에 대한 CRUD를 지원하게 되는 것이 일반적이며, Day Layer는 Book, User, History와 같은 entity객체들을 return 시켜주는 것이 일반적인 개발 방법입니다. 마지막으로, Service Layer는 일반적인 Business Logic을 구성하는 layer입니다. 일반적으로 Service Layer는 여러 Dao를 이용해서 많은 Table에 CRUD를 동시에 하게 되고, 그 결과를 Controller Layer에 전달하는 구조로 구성되게 됩니다.
지금까지 구성된 bookstore는 다음과 같은 Business Logic을 가질 예정입니다.
# user는 book을 빌리거나 반납할 수 있다.
# user가 book을 빌리면, point가 10점씩 쌓인다.
# point가 100점이 넘어가면 READER가 된다.
# point가 300점이 넘어가면 MVP가 된다.
# point가 100점 이하인 경우, 일반유저(NORMAL)이다.
# 전체 book을 list up 할 수 있으며, 대출이 가능한 책 우선으로 Sort된다.
# book은 대출 가능, 대출중, 분실의 3가지의 상태를 갖는다.
# user는 자신이 지금까지 빌린 book들의 기록(대출,반납)을 최신 순으로 볼 수 있다.
# user의 RENT/RETURN은 모두 History가 남는다.
# user가 book을 빌리면, point가 10점씩 쌓인다.
# point가 100점이 넘어가면 READER가 된다.
# point가 300점이 넘어가면 MVP가 된다.
# point가 100점 이하인 경우, 일반유저(NORMAL)이다.
# 전체 book을 list up 할 수 있으며, 대출이 가능한 책 우선으로 Sort된다.
# book은 대출 가능, 대출중, 분실의 3가지의 상태를 갖는다.
# user는 자신이 지금까지 빌린 book들의 기록(대출,반납)을 최신 순으로 볼 수 있다.
# user의 RENT/RETURN은 모두 History가 남는다.
매우 간단한 BL입니다. 그리고, 이 BL에 대한 Service의 주체를 기준으로 다음과 같이 명명한 서비스들을 구상할 수 있습니다.
* UserService : 사용자가 action의 주체가 되는 서비스입니다.
* BookService : Book이 주체가 되는 서비스입니다.
서비스의 명명법은 영문법을 따르게 되며, 다음과 같은 영문장으로 구성을 하면 좋습니다.
* UserService : 사용자가 action의 주체가 되는 서비스입니다.
* BookService : Book이 주체가 되는 서비스입니다.
서비스의 명명법은 영문법을 따르게 되며, 다음과 같은 영문장으로 구성을 하면 좋습니다.
User.rentBook(Book book) User.returnBook(Book book) User.listUpHistory() Book.listUp()
서비스의 설계가 될 수 있는 interface는 다음과 같이 구성이 가능합니다.
public interface UserService { public boolean rent(int userId, int bookId); public boolean returnBook(int userId, int bookId); public List<User> listup(); public List<History> getHistories(int userId); } public interface BookService { public List<Book> listup(); }
이와 같이 interface는 단순히 객체에 대한 프로그래밍적 요소로만 사용되는 것이 아닌, 프로그램의 in/out에 대한 설계로서 사용이 가능합니다. 우리가 어떠한 application을 작성을 할때, input/output에 대한 정의를 명확히 할 수 있는 경우, interface를 이용해서 코드를 명확히 구성하는 것이 가능합니다. 만들어진 UserService와 BookStoreService를 구현해보도록 하겠습니다.
@Service
public class UserServiceImpl implements UserService { @Autowired private BookDao bookDao; @Autowired private UserDao userDao; @Autowired private HistoryDao historyDao; public BookDao getBookDao() { return bookDao; } public void setBookDao(BookDao bookDao) { this.bookDao = bookDao; } public UserDao getUserDao() { return userDao; } public void setUserDao(UserDao userDao) { this.userDao = userDao; } public HistoryDao getHistoryDao() { return historyDao; } public void setHistoryDao(HistoryDao userHistoryDao) { this.historyDao = userHistoryDao; } @Override public boolean rent(int userId, int bookId) { User user = userDao.get(userId); Book book = bookDao.get(bookId); user.setPoint(user.getPoint() + 10); user.setLevel(getUserLevel(user.getPoint())); book.setRentUserId(user.getId()); book.setStatus(BookStatus.RentNow); History history = new History(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RENT); userDao.update(user); bookDao.update(book); historyDao.add(history); return true; } private UserLevel getUserLevel(int point) { if(point < 100) { return UserLevel.NORMAL; } else if(point >= 100 && point < 300) { return UserLevel.READER; } else { return UserLevel.MASTER; } } @Override public boolean returnBook(int userId, int bookId) { Book book = bookDao.get(bookId); book.setStatus(BookStatus.CanRent); book.setRentUserId(null); History history = new History(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RETURN); bookDao.update(book); historyDao.add(history); return true; } @Override public List<User> listup() { return userDao.getAll(); } @Override public List<History> getHistories(int userId) { return historyDao.getByUser(userId); } }
그리고, 이에 대한 테스트 코드를 작성해서 Business Logic이 무사히 통과되고 있는지를 확인하도록 합니다. 이는 매우 중요한 작업입니다. 우리는 지금까지 DB에 대한 CRUD만을 통과를 시켰습니다. 우리의 BL이 정확하게 구성이 가능한것인지를 파악하는 수단으로 Test code는 최적의 방법입니다. 테스트 없이는 개발이 되지 않는다. 라는 원칙을 유념해주세요. 지금 구성되는 UserService의 테스트의 포인트는 무엇일까요? 테스트를 구현을 할때, 이제는 BL을 같이 생각을 하고 구현을 들어가야지 됩니다. 제가 생각하는 지금 UserService의 테스트 포인트는 다음과 같습니다.
# point가 1, 99, 299, 301 인 사용자가 책을 빌릴때 사용자의 Level이 정상적으로 변경이 되는지 확인
# book의 status가 RentNow로 변경이 되었는지 확인
# User History의 Action이 정상적으로 설정되었는지 확인
# User History의 Action이 정상적으로 설정되었는지 확인
이러한 점에 주안점을 두고, 테스트 코드를 하나하나 작성해보도록 하겠습니다.
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration("classpath:applicationContextWithAutowired.xml") public class UserServiceImplTest { @Autowired private UserService userService; @Autowired private UserDao userDaoImplWithJdbcTemplate; @Autowired private BookDao bookDaoImplWithJdbcTemplate; @Autowired private HistoryDao userHistoryDaoImplWithJdbcTemplate; @Before public void setUp() { assertNotNull(userService); userHistoryDaoImplWithJdbcTemplate.deleteAll(); bookDaoImplWithJdbcTemplate.deleteAll(); userDaoImplWithJdbcTemplate.deleteAll(); List<User> users = getUsers(); for(User user : users) { userDaoImplWithJdbcTemplate.add(user); } List<Book> books = getBooks(); for(Book book : books) { bookDaoImplWithJdbcTemplate.add(book); } } private List<User> getUsers() { User user1 = new User(); user1.setName("name01"); user1.setPassword("password01"); user1.setPoint(99); user1.setLevel(UserLevel.NORMAL); User user2 = new User(); user2.setName("name02"); user2.setPassword("password02"); user2.setPoint(101); user2.setLevel(UserLevel.READER); User user3 = new User(); user3.setName("name03"); user3.setPassword("password03"); user3.setPoint(301); user3.setLevel(UserLevel.MVP); User user4 = new User(); user4.setName("name04"); user4.setPassword("password04"); user4.setPoint(290); user4.setLevel(UserLevel.READER); return Arrays.asList(user1, user2, user3, user4); } private List<Book> getBooks() { Book book1 = new Book(); book1.setName("book name01"); book1.setAuthor("autor name 01"); book1.setComment("comment01"); book1.setPublishDate(new Date()); book1.setStatus(BookStatus.CanRent); Book book2 = new Book(); book2.setName("book name02"); book2.setAuthor("autor name 02"); book2.setComment("comment02"); book2.setPublishDate(new Date()); book2.setStatus(BookStatus.RentNow); Book book3 = new Book(); book3.setName("book name03"); book3.setAuthor("autor name 03"); book3.setComment("comment03"); book3.setPublishDate(new Date()); book3.setStatus(BookStatus.Missing); List<Book> books = Arrays.asList(book1, book2, book3); return books; } @Test public void rentWithNoLevelUp() { Book oldBook = bookDaoImplWithJdbcTemplate.getAll().get(0); int bookId = oldBook.getId(); User oldUser = null; for(User user : userDaoImplWithJdbcTemplate.getAll()) { if(user.getName().equals("name02")) { oldUser = user; break; } } int userId = oldUser.getId(); userService.rent(userId, bookId); User user = userDaoImplWithJdbcTemplate.get(userId); assertThat(user.getPoint(), is(oldUser.getPoint() + 10)); assertThat(user.getLevel(), is(UserLevel.READER)); Book book = bookDaoImplWithJdbcTemplate.get(bookId); assertThat(book.getRentUserId(), is(user.getId())); assertThat(book.getStatus(), is(BookStatus.RentNow)); List<History> histories = userHistoryDaoImplWithJdbcTemplate.getByUser(userId); assertThat(histories.size(), is(1)); History history = histories.get(0); assertThat(history.getUserId(), is(userId)); assertThat(history.getBookId(), is(bookId)); assertThat(history.getAction(), is(HistoryActionType.RENT)); } @Test public void rentWithLevelUpForREADER() { Book oldBook = bookDaoImplWithJdbcTemplate.getAll().get(0); int bookId = oldBook.getId(); User oldUser = null; for(User user : userDaoImplWithJdbcTemplate.getAll()) { if(user.getName().equals("name01")) { oldUser = user; break; } } int userId = oldUser.getId(); userService.rent(userId, bookId); User user = userDaoImplWithJdbcTemplate.get(userId); assertThat(user.getPoint(), is(oldUser.getPoint() + 10)); assertThat(user.getLevel(), is(UserLevel.READER)); Book book = bookDaoImplWithJdbcTemplate.get(bookId); assertThat(book.getRentUserId(), is(user.getId())); assertThat(book.getStatus(), is(BookStatus.RentNow)); List<History> histories = userHistoryDaoImplWithJdbcTemplate.getByUser(userId); assertThat(histories.size(), is(1)); History history = histories.get(0); assertThat(history.getUserId(), is(userId)); assertThat(history.getBookId(), is(bookId)); assertThat(history.getAction(), is(HistoryActionType.RENT)); } @Test public void rentWithLevelUpForMVP() { Book oldBook = bookDaoImplWithJdbcTemplate.getAll().get(0); int bookId = oldBook.getId(); User oldUser = null; for(User user : userDaoImplWithJdbcTemplate.getAll()) { if(user.getName().equals("name04")) { oldUser = user; break; } } int userId = oldUser.getId(); userService.rent(userId, bookId); User user = userDaoImplWithJdbcTemplate.get(userId); assertThat(user.getPoint(), is(oldUser.getPoint() + 10)); assertThat(user.getLevel(), is(UserLevel.MVP)); Book book = bookDaoImplWithJdbcTemplate.get(bookId); assertThat(book.getRentUserId(), is(user.getId())); assertThat(book.getStatus(), is(BookStatus.RentNow)); List<History> histories = userHistoryDaoImplWithJdbcTemplate.getByUser(userId); assertThat(histories.size(), is(1)); History history = histories.get(0); assertThat(history.getUserId(), is(userId)); assertThat(history.getBookId(), is(bookId)); assertThat(history.getAction(), is(HistoryActionType.RENT)); } @Test public void returnBook() { int bookId = bookDaoImplWithJdbcTemplate.getAll().get(0).getId(); int userId = userDaoImplWithJdbcTemplate.getAll().get(0).getId(); userService.rent(userId, bookId); userService.returnBook(userId, bookId); Book book = bookDaoImplWithJdbcTemplate.get(bookId); assertThat(book.getStatus(), is(BookStatus.CanRent)); List<History> histories = userHistoryDaoImplWithJdbcTemplate.getByUser(userId); assertThat(histories.size(), is(2)); History history = histories.get(1); assertThat(history.getUserId(), is(userId)); assertThat(history.getBookId(), is(bookId)); assertThat(history.getAction(), is(HistoryActionType.RETURN)); } }
코드가 매우 깁니다. 이 긴 코드를 한번 살펴보도록 하겠습니다. 지금 구성된 BL은 point값이 정상적으로 증가하는지, 그리고 그 증가된 point에 따라서 User Level이 정상적으로 승급되는지를 알아보는것이 포인트입니다. 따라서, 이 경우에는 UserLevel을 각각 NORMAL, READER, MVP로 나눠서 각 사용자들의 LEVEL이 올라가는 것을 하나하나 확인하는 것이 좋습니다. 그리고, 각각의 업무가 발생했을 때, DB에 정상적인 값들이 insert되었는지를 명확히 확인하는 것이 필요합니다. 이러한 테스트 코드는 후에, 에러가 발생했을때 그 에러에 대한 tracing역시 이 테스트 코드를 통해서 에러를 검증하게 됩니다. in/out이 정상적인지, 그리고 그 in/out에서 어떤 에러가 발생하는지를 확인하는 것 역시 테스트 코드에서 하게 되는 일입니다.
이제 BookServiceImpl에 대한 테스트 코드를 작성해주세요. BookServiceImpl의 테스트 코드는 매우 단순합니다. Sort가 정상적으로 되어서 나오고 있는지를 확인해주면 됩니다. 이 방법은 Dao에 새로운 method를 넣어서 Sort Order를 넣어 구현도 가능하고, 아니면 Dao에서 얻어온 List를 Service Layer에서 재 Sort 하는 것으로도 구현 가능합니다. 어느 방법이던지 한번 구현해보시길 바랍니다.
Transaction의 적용
지금까지 구현된 Service, Dao Layer는 결정적인 문제를 가지고 있습니다. 예를 들어, rentBook action에서 book의 상태를 업데이트 한 후에, DB의 문제나 application의 exception이 발생했다면 어떤 문제가 발생할까요?
지금의 JdbcTemplate은 각 Dao Layer단으로 Connection이 분리 되어 있습니다. 따라서 한쪽에서 Exception이 발생하더라도, 기존 update 사항에 대해서는 DB에 그대로 반영이 되어버립니다. 이건 엄청난 문제를 발생시킵니다. DAO는 우리가 서비스를 만드는 도구이고, 결국은 사용자나 BL의 한개의 action으로 DB의 Transaction이 적용이 되어야지 되는데, 이러한 규칙을 모두 날려버리게 되는 것입니다.
다시 한번 정리하도록 하겠습니다. BL상으로, Service의 method는 BL의 기본 단위가 됩니다. 기술적으로는 Transaction의 단위가 Service의 method 단위가 되어야지 됩니다. 간단히 sudo 코드를 작성한다면 rent method는 다음과 같이 작성되어야지 됩니다.
@Override public boolean rent(int userId, int bookId) { Transaction.begin(); User user = userDao.get(userId); Book book = bookDao.get(bookId); user.setPoint(user.getPoint() + 10); user.setLevel(getUserLevel(user.getPoint())); book.setRentUserId(user.getId()); book.setStatus(BookStatus.RentNow); UserHistory history = new UserHistory(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RENT); userDao.update(user); bookDao.update(book); userHistoryDao.add(history); Transaction.commit(); return true; }
Transaction은 매우 골치아픈 개념입니다. 먼저 지금 사용중인 DataSource는 직접적으로 JDBC에 연결되는 Connection입니다. 그런데, 이를 Hibernate의 session 또는 MyBatis의 ObjectMapper들을 사용한다면 완전히 다른 Transaction 기술을 사용해야지 됩니다. 지금까지 기술에 종속적이지 않은 서비스 코드를 작성하고 있는데, 이제 다시 Transaction에 의한 기술 종속적 코드로 변경이 되어야지 되는 상황이 되어버린것입니다. 그래서, 이 경우를 해결하기 위해서 Transaction의 기술들에 대한 interface를 spring은 제안하고 있습니다. 바로 org.springframework.transaction.PlatformTransactionManager가 바로 그 interface입니다.
일단 spring에서 제공되는 JdbcTemplate은 spring DataSource를 이용합니다. 이 DataSource에 대한 TransactionManager 역시 제공이 되고 있으며, Hibernate와 같은 orm에 대한 기본 TransactionManager들도 역시 모두 제공되고 있습니다. PlatformTransactionManager의 구조를 살펴보도록 하겠습니다.
일단 spring에서 제공되는 JdbcTemplate은 spring DataSource를 이용합니다. 이 DataSource에 대한 TransactionManager 역시 제공이 되고 있으며, Hibernate와 같은 orm에 대한 기본 TransactionManager들도 역시 모두 제공되고 있습니다. PlatformTransactionManager의 구조를 살펴보도록 하겠습니다.
public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }
Transaction을 얻어내고, Transaction을 commit, rollback하는 간단한 구조로 되어 있습니다. 이러한 기본 구조는 우리가 Dao Layer를 이용해서 Transaction을 사용하는데 충분합니다.
PlatformTransactionManager를 이용한 Transaction 구현을 간단한 sudo code로 구현하면 다음과 같습니다.
public void doSomething() { TransactionStatus status = transactionManager.getTransaction(definition); try { // ..do something transactionManager.commit(status); } catch(Exception ex) { transactionManager.rollback(status) } }
전에 보던 Template-callback pattern과 동일한 패턴의 코드가 완성됩니다. TransactionManager의 생성자에는 DataSource interface를 구현하고 있기 때문에 Dao Layer에서 사용하는 Connection을 한번에 묶어서 처리가 가능합니다. Spring Transaction을 한번 구현해보도록 하겠습니다.
먼저, spring transaction을 추가해야지 됩니다. pom.xml에 spring transaction을 추가합니다.
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>3.2.0.RELEASE</version>
</dependency>
JdbcTemplate에서 사용될 TransactionManager를 bean에 선언합니다. 일반적으로 PlatformTransactionManager는 Spring에서 transactionManager라는 이름으로 사용됩니다. 관례적으로 사용되는 이름이니 이를 따르도록 하겠습니다.
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean>
그리고, TransactionManager를 이용한 코드로 rentBook method를 구현해보도록 하겠습니다.
@Override public boolean rent(int userId, int bookId) { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { User user = userDao.get(userId); Book book = bookDao.get(bookId); user.setPoint(user.getPoint() + 10); user.setLevel(getUserLevel(user.getPoint())); book.setRentUserId(user.getId()); book.setStatus(BookStatus.RentNow); UserHistory history = new UserHistory(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RENT); userDao.update(user); bookDao.update(book); userHistoryDao.add(history); transactionManager.commit(status); return true; } catch(RuntimeException e) { this.transactionManager.rollback(status); throw e; } }
이제 다른 method의 Transation을 한번 구현해보도록 하겠습니다. 코딩을 해주세요.
Transaction을 어떻게 테스트를 할 수 있을까?
트랜젝션에 대한 코드를 완성했습니다. 그런데, 이 코드는 어떻게 테스트를 해야지 될까요? 코드에 Exception을 발생시키는 로직을 추가하는 것이 좋을까요? 그렇게 된다면 코드 안에 버그를 심게 되기 때문에 문제가 발생할 수 있습니다. 아니면 다른 방법이 있을까요?
일단, 이렇게 코드를 구성하면 안되지만, Transaction 중간에 에러가 발생하는 상황을 만들어보도록 합시다. 이 에러가 발생할 수 있는 상황은 DB에 접근하는 중에 오류가 발생하거나 db를 업데이트 하는 도중, Business Logic 상의 에러가 발생하는 것을 의미합니다. 이 두가지 모두 테스트를 작성하기가 매우 힘든 상황입니다. 이런 상황을 어떻게 하면 직접 만들수 있을까요?
일단, 이렇게 코드를 구성하면 안되지만, Transaction 중간에 에러가 발생하는 상황을 만들어보도록 합시다. 이 에러가 발생할 수 있는 상황은 DB에 접근하는 중에 오류가 발생하거나 db를 업데이트 하는 도중, Business Logic 상의 에러가 발생하는 것을 의미합니다. 이 두가지 모두 테스트를 작성하기가 매우 힘든 상황입니다. 이런 상황을 어떻게 하면 직접 만들수 있을까요?
한번 생각의 전환을 해보도록 하겠습니다. Service는 Business Logic의 모음이라고 했습니다. `모음' 이라는 용어에 주의할 필요가 있습니다. 이는 Business Logic 역시 한개의 객체로서 표현이 가능하다는 뜻이 될 수 있습니다. 지금 구성된 코드에서 조금 맘에 걸리는 부분이 있습니다. 다음 코드를 봐주세요.
user.setPoint(user.getPoint() + 10); user.setLevel(getUserLevel(user.getPoint()));
위 코드는 user에 point를 더하고, user의 level을 결정해주는 method입니다. getUserLevel이라는 private method 자체가 하나의 BL이 되는 것입니다. 다시 한번 생각해보도록 하겠습니다. 우리는 다음과 같이 BL을 정했습니다.
# user가 book을 빌리면, point가 10점씩 쌓인다.
# point가 100점이 넘어가면 READER가 된다.
# point가 300점이 넘어가면 MVP가 된다.
# point가 100점 이하인 경우, 일반유저(NORMAL)이다.
# point가 100점이 넘어가면 READER가 된다.
# point가 300점이 넘어가면 MVP가 된다.
# point가 100점 이하인 경우, 일반유저(NORMAL)이다.
저 BL은 언제든지 바뀔 수 있는 BL입니다. 어느 순간에 정책의 변경으로 인하여 point의 증감폭이 5점으로 바뀐다던지, 아니면 point의 level 단계의 100, 300에서 1000, 3000으로도 언제든지 변경할 수 있는 것입니다. 따라서, 이런 변화 가능한 부분은 객체에서 외부에서 변경이 가능하도록 따로 객체나 Property로 뽑는 것이 맞습니다. UserLevelRole 이라는 interface를 만들어 이런 BL을 따로 객체화 해보도록 합시다. 다음은 UserLevelRole의 interface와 객체입니다.
public interface UserLevelRole { void updatePointAndLevel(User user); int getAddRentPoint(); void setAddRentPoint(int addRentPoint); int getReaderThreshold(); void setReaderThreshold(int readerThreshold); int getMvpThreashold(); void setMvpThreashold(int mvpThreashold); } public class UserLevelRoleImpl implements UserLevelRole { private int addRentPoint; private int readerThreshold; private int mvpThreashold; @Override public int getAddRentPoint() { return addRentPoint; } @Override public void setAddRentPoint(int addRentPoint) { this.addRentPoint = addRentPoint; } @Override public int getReaderThreshold() { return readerThreshold; } @Override public void setReaderThreshold(int readerThreshold) { this.readerThreshold = readerThreshold; } @Override public int getMvpThreashold() { return mvpThreashold; } @Override public void setMvpThreashold(int mvpThreashold) { this.mvpThreashold = mvpThreashold; } @Override public void updatePointAndLevel(User user) { user.setPoint(user.getPoint() + addRentPoint); if(user.getPoint() >= mvpThreashold) { user.setLevel(UserLevel.MVP); } else if(user.getPoint() >= readerThreshold) { user.setLevel(UserLevel.READER); } else { user.setLevel(UserLevel.NORMAL); } } }
자. 이렇게 구성된 UserLevelRole을 이제 서비스에 반영해주도록 하겠습니다.
UserServiceImpl에 UserLevelRole에 대한 property를 다음과 같이 추가합니다.
@Autowired private UserLevelRole userLevelRole; public UserLevelRole getUserLevelRole() { return this.userLevelRole; } public void setUserLevelRole(UserLevelRole userLevelRole) { this.userLevelRole = userLevelRole; }
추가된 property를 applicationContext.xml에서 구성해주도록 합니다. 다음과 같이 처리되면 됩니다.
<bean id="userLevelRole" class="com.xyzlast.bookstore02.services.UserLevelRoleImpl"> <property name="addRentPoint" value="10"/> <property name="readerThreshold" value="100"/> <property name="mvpThreashold" value="300"/> </bean>
이제 지금까지 구성된 테스트 코드를 수정해주도록 하겠습니다. 지금까지 상수로 10씩 더한것을 확인하던 테스트 코드를 이제 설정된 값으로 변경되고 있는 것을 확인할 수 있어야지 됩니다.
User user = userDaoImplWithJdbcTemplate.get(userId); assertThat(user.getPoint(), is(oldUser.getPoint() + + userLevelRole.getAddRentPoint())); assertThat(user.getLevel(), is(UserLevel.READER));
그리고 Service에 대한 코드를 조금 수정해주도록 하겠습니다.
@Override public boolean rent(int userId, int bookId) { User user = userDaoImplWithJdbcTemplate.get(userId); Book book = bookDaoImplWithJdbcTemplate.get(bookId); book.setRentUserId(user.getId()); book.setStatus(BookStatus.RentNow); History history = new History(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RENT); bookDaoImplWithJdbcTemplate.update(book); userLevelRole.updatePointAndLevel(user); userDaoImplWithJdbcTemplate.update(user); historyDaoWithJdbcTemplate.add(history); return true; }
userLevelRole에 user에 대한 Point와 Level을 업데이트 하는 로직을 위임하고 있는 것을 알 수 있습니다. 자, 이제 에러를 발생시켜보도록 하겠습니다. 단순하게 userLevelRole을 null로 만들어주면 저 code에서 NullPointException이 발생하게 됩니다. Null이 발생되도록 만들어주는 테스트 코드입니다.
@Test(expected=NullPointerException.class) @DirtiesContext public void rentBookWithException() { ((UserServiceImpl) userService).setUserLevelRole(null); Book oldBook = bookDaoImplWithJdbcTemplate.getAll().get(0); int bookId = oldBook.getId(); User oldUser = null; for(User user : userDaoImplWithJdbcTemplate.getAll()) { if(user.getName().equals("name02")) { oldUser = user; break; } } int userId = oldUser.getId(); try { userService.rent(userId, bookId); } finally { //Exception이 발생한 이후에, 값이 업데이트 되지 않고, 기존값과 동일해야지 됨 Book updatedBook = bookDaoImplWithJdbcTemplate.get(bookId); assertThat(updatedBook.getStatus(), is(oldBook.getStatus())); assertThat(updatedBook.getRentUserId(), is(nullValue())); } }
이 테스트에는 다음 3개의 특징을 가지고 있습니다.
먼저, @DirtiesContext입니다. 이는 이 test를 통과하게 되면 applicationContext에서 설정한 객체의 특성이 변경되기 때문에, 여기서 사용한 객체를 제거하고 다시 applicationContext에 있는 객체로 사용하기를 설정하는 것입니다. 다음은 @Test에 expected가 추가 된 것입니다. 내부 코드에서 예상된 exception이 발생되는지 확인하는 코드로, exception이 발생하지 않으면 test가 실패하게 됩니다. 마지막으로, finally 코드를 봐주시길 바랍니다. exception이 발생하더라도, Book의 값이 update되지 않았는지를 확인하는 코드입니다. 만약에 Transaction이 정상적으로 처리가 되었다면 테스트가 통과가 될 것입니다. 테스트 결과를 한번 확인해보도록 하겠습니다.
테스트 결과는 보시다시피 실패했습니다. 에러 내용을 확인해보도록 하겠습니다. NullPointException이 발생할 줄 알았지만, AssertionError가 발생된 것을 알 수 있습니다. AssertionError의 경우, Book의 Status가 다르게 나와서 에러가 발생되었음을 알 수 있습니다. DB의 값이 Transaction 처리가 되지 않아 업데이트가 되었다는 뜻입니다. 자, 이제 PlatformTransactionManager를 이용한 Transaction을 반영해보도록 하겠습니다.
@Override public boolean rent(int userId, int bookId) { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { User user = userDaoImplWithJdbcTemplate.get(userId); Book book = bookDaoImplWithJdbcTemplate.get(bookId); book.setRentUserId(user.getId()); book.setStatus(BookStatus.RentNow); History history = new History(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RENT); bookDaoImplWithJdbcTemplate.update(book); userLevelRole.updatePointAndLevel(user); userDaoImplWithJdbcTemplate.update(user); historyDaoWithJdbcTemplate.add(history); transactionManager.commit(status); } catch(Exception ex) { transactionManager.rollback(status); throw ex; } return true; }
transactionManager가 반영된 applicationContext.xml은 다음과 같습니다.
<?xml version="1.0" encoding="UTF-8"?><beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd"> <bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate"> <property name="dataSource" ref="dataSource" /> </bean> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="${connect.driver}" /> <property name="url" value="${connect.url}" /> <property name="username" value="${connect.username}" /> <property name="password" value="${connect.password}" /> </bean> <context:property-placeholder location="classpath:spring.property" /> <context:component-scan base-package="com.xyzlast.bookstore02.dao" /> <context:component-scan base-package="com.xyzlast.bookstore02.services" /> <bean id="userLevelRole" class="com.xyzlast.bookstore02.services.UserLevelRoleImpl"> <property name="addRentPoint" value="10" /> <property name="readerThreshold" value="100" /> <property name="mvpThreashold" value="300" /> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource" /> </bean></beans>
이제 테스트를 돌리면 정상적으로 도는 것을 확인할 수 있습니다.
annotation을 이용한 Transaction 구현
지금까지 구현된 코드에는 한가지 문제가 있습니다. Transaction은 기술적인 영역으로 Service 객체에는 어울리지 않는 내용입니다. Service는 BL의 집합이라는 것을 다시 한번 상기해주시길 바랍니다. BL에 기술적인 요소가 들어가게 되면, 기술적인 요소에 따른 BL의 수정이 가해질 수 있습니다. 따라서, Spring에서는 이를 분리하는 것을 제안하고 있으며, 특히 Transaction에서는 @Transactional annotaion을 이용한 분리를 제안하고 있습니다.
@Transactional은 method, class에 모두 적용 가능한 annotation입니다. @Transactional을 사용하기 위해서는 applicationContext.xml에 다음 설정을 추가하면 됩니다.
<tx:annotation-driven transaction-manager="transactionManager"/>
그리고, 지금까지 작성된 class의 선언부에 @Transactional을 선언해주면 class의 모든 public method에 Transaction이 설정되게 됩니다. @Transaction이 구성된 전체 UserServiceImpl의 코드입니다.
@Service @Transactional
public class UserServiceImpl implements UserService { @Autowired private BookDao bookDao; @Autowired private UserDao userDao; @Autowired private HistoryDao userHistoryDao; @Autowired private UserLevelRole userLevelRole; @Autowired private PlatformTransactionManager transactionManager; public BookDao getBookDao() { return bookDao; } public void setBookDao(BookDao bookDao) { this.bookDao = bookDao; } public UserDao getUserDao() { return userDao; } public void setUserDao(UserDao userDao) { this.userDao = userDao; } public HistoryDao getUserHistoryDao() { return userHistoryDao; } public void setUserHistoryDao(HistoryDao userHistoryDao) { this.userHistoryDao = userHistoryDao; } @Override public boolean rent(final int userId, final int bookId) { User user = userDao.get(userId); Book book = bookDao.get(bookId); book.setRentUserId(user.getId()); book.setStatus(BookStatus.RentNow); bookDao.update(book); userLevelRole.updatePointAndLevel(user); UserHistory history = new UserHistory(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RENT); userDao.update(user); userHistoryDao.add(history); return true; } @Override public boolean returnBook(int userId, int bookId) { Book book = bookDao.get(bookId); book.setStatus(BookStatus.CanRent); book.setRentUserId(null); UserHistory history = new UserHistory(); history.setUserId(userId); history.setBookId(book.getId()); history.setAction(HistoryActionType.RETURN); bookDao.update(book); userHistoryDao.add(history); return true; } @Override public List<User> listup() { return userDao.getAll(); } @Override public List<UserHistory> getHistories(int userId) { return userHistoryDao.getByUser(userId); } @Override public void setUserLevelRole(UserLevelRole userLevelRole) { this.userLevelRole = userLevelRole; } }
이제 Service에서는 모든 Business Logic을 구현할 수 있게 되었고, 기술적으로 독립적인 코드로 구성되었습니다. BookService에 대해서도, 또는 자신이 서비스를 직접 만들어서 코드를 확장시켜보시길 바랍니다.
그럼 이와 같은 코드는 어떻게 구성이 된 것일까요? Spring은 어떤 일을 해서 이와 같은 Transaction을 구성할 수 있을지 한번 알아보도록 하겠습니다.
Spring @Transactional의 구현 방법
Spring은 이러한 문제를 어떻게 해결하고 있을까요? 전에 Spring에서 자주 사용되는 pattern으로 Template-callback pattern을 봤습니다. Transaction 역시 Template-callback pattern으로 처리가 가능합니다. 그렇지만, 지금 사용한 @Transactional와 같은 annotation을 이용해서는 처리가 불가능합니다.
구현 원리를 알아보기 전에 한번 다음 코드를 실행해보도록 하겠습니다. 먼저 방금 붙였던 @Transactional을 제거하고, 다음 테스트 코드를 돌려보도록 하겠습니다.
@Test public void displayUserServiceObjectName() { System.out.println("UserService의 구현 객체는 " + userService.getClass().getName() + "입니다."); assertThat("userService는 UserServiceImpl이 할당되어 있지 않습니다.", userService instanceof UserServiceImpl, is(true)); }
결과는 다음과 같이 나타납니다.
UserService의 구현 객체는 com.xyzlast.bookstore03.services.UserServiceImpl입니다.
이 결과는 지금까지 보셨던것과 같이, 객체 이름 + Instance Key의 형태로 객체를 표현하게 됩니다. 그리고 UserService interface를 상속받은 UserServiceImpl임을 알 수 있습니다.
그럼 @Transactional을 붙였을 때, 어떻게 나오는지 확인해보도록 하겠습니다.
UserService의 class 이름은 $Proxy15입니다.
테스트가 실패하고, UserService의 class 이름은 듣도보지도 못한 $Proxy라는 이상한 객체로 변경되어 있습니다.
이게 어떻게 된걸까요?
이 부분을 이해하기 위해서는 Spring의 이제 2번째 개념인 AOP에 대한 이해가 필요합니다. 다음 장에서는 AOP에 대해서 깊게 들어가보도록 하겠습니다. @Transactional의 경우, UserServiceImpl을 다른 객체로 한번 더 감싼 Proxy객체로 사용하게 된다. 라는 개념으로 일단 이 장을 마무리 하도록 하겠습니다.